Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- 删除被专家 `named` 引用的存储后端时返回 `STORAGE_BACKEND_REFERENCED` 并列出引用专家

### 修复
- 从专家模板新建的 agent,第一次对话不再回答"我还是一片空白":此前 bootstrap 仪式会把首条消息的 system prompt 整体替换为 BOOTSTRAP.md("记忆是空的,一切从零开始"),吞掉模板预置的身份(SOUL.md/IDENTITY.md);现在模板文件 seed 成功后即写入完成标记 `.bootstrapped`,agent 从第一条消息起就以所赋身份作答。自定义(无模板)agent 仍保留原 onboarding 流程
- 超大图片不再降级为附件路径提示:超过视觉嵌入上限(2 MB)的图片由 Pillow 压缩缩放至最长边 1568px 后仍以内联图片嵌入请求(保留 EXIF 方向与透明通道,仅当压缩失败时才回退为路径提示),视觉模型自动升级随之生效 (#219)

## [0.9.20] - 2026-08-09
Expand Down
Binary file added images/image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/屏幕截图 2026-08-11 162333.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 34 additions & 7 deletions src/octop/infra/agents/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,9 @@ async def create(self, spec: AgentCreateSpec, *, defer_bootstrap: bool = False)
row = self._repos.agent_repo.get(agent_id)
assert row is not None
if spec.template_name:
await self._seed_expert_template(row, spec.template_name)
seeded = await self._seed_expert_template(row, spec.template_name)
if seeded > 0:
self._mark_template_bootstrapped(row)
if defer_bootstrap:
self._repos.agent_repo.set_state(agent_id, "starting")
asyncio.create_task(
Expand Down Expand Up @@ -1671,15 +1673,39 @@ def _backend_workspace_for_row(self, row: AgentRow) -> Any:
resolve_backend(backend, workspace_dir=workspace_dir), workspace_dir
)

async def _seed_expert_template(self, row: AgentRow, template_name: str) -> None:
"""Copy bundled expert files into the agent workspace before harness start."""
def _mark_template_bootstrapped(self, row: AgentRow) -> None:
"""Write the bootstrap-completion marker for a template-seeded agent.

Expert templates carry a predefined persona (``SOUL.md`` / ``IDENTITY.md``
under ``prompt_files``). Without the marker, ``BootstrapMiddleware`` would
replace the very first system prompt with ``BOOTSTRAP.md`` and the agent
would answer "I'm a blank slate" instead of acting as its assigned
identity. The marker skips that ritual so identity is present from turn 1.

A marker write failure is non-fatal: the agent still starts and identity
simply loads from the second turn (normal bootstrap path).
"""
try:
workspace = self._backend_workspace_for_row(row)
workspace.write_text(".bootstrapped", "", force=True)
except Exception:
logger.exception(
"Failed to write bootstrap marker for template agent %s",
row.agent_id,
)

async def _seed_expert_template(self, row: AgentRow, template_name: str) -> int:
"""Copy bundled expert files into the agent workspace before harness start.

Returns the number of files seeded (``0`` when skipped/failed).
"""
if self._expert_catalog is None:
logger.warning(
"Agent %s: template_name=%r set but no expert_catalog configured; skipping",
row.agent_id,
template_name,
)
return
return 0

expert = self._expert_catalog.get(template_name)
if expert is None:
Expand All @@ -1688,7 +1714,7 @@ async def _seed_expert_template(self, row: AgentRow, template_name: str) -> None
row.agent_id,
template_name,
)
return
return 0

from octop.infra.agents.experts.catalog import ( # noqa: PLC0415
MANIFEST_FILENAME,
Expand All @@ -1697,7 +1723,7 @@ async def _seed_expert_template(self, row: AgentRow, template_name: str) -> None

expert_dir = self._expert_catalog.expert_dir(template_name)
if not expert.files and not (expert_dir / MANIFEST_FILENAME).is_file():
return
return 0

workspace = self._backend_workspace_for_row(row)
try:
Expand All @@ -1713,13 +1739,14 @@ async def _seed_expert_template(self, row: AgentRow, template_name: str) -> None
template_name,
exc,
)
return
return 0
logger.info(
"Agent %s: seeded expert template %r (%d files)",
row.agent_id,
template_name,
count,
)
return count

# ------------------------------------------------------------------
# Internal — background reload worker
Expand Down
17 changes: 16 additions & 1 deletion tests/unit/agents/test_agent_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def _row(
agent_id: str = "01AGENT",
config_json: str | None = None,
default_model: str | None = None,
system_prompt: str | None = None,
) -> AgentRow:
return AgentRow(
id=1,
Expand All @@ -65,7 +66,7 @@ def _row(
description=None,
persona_mbti=None,
default_model=default_model,
system_prompt=None,
system_prompt=system_prompt,
enabled=1,
config_json=config_json,
last_state=None,
Expand Down Expand Up @@ -236,6 +237,20 @@ def test_build_harness_config_enables_bootstrap_for_expert_template(manager: Age
assert cfg.bootstrap_enabled is True


def test_build_harness_config_loads_identity_when_bootstrapped(manager: AgentManager) -> None:
"""With the bootstrap marker present, persona/system_prompt survive and memory
is left to the harness (auto-loads workspace SOUL.md etc.) — so the first
message speaks as the assigned identity rather than a blank slate."""
row = _row(agent_id="AGT001", system_prompt="<persona>")
ws = manager._paths.ensure_agent_workspace("AGT001")
(ws / ".bootstrapped").write_text("", encoding="utf-8")

cfg = manager._build_harness_config(row)

assert cfg.system_prompt == "<persona>"
assert cfg.memory is None


def _fs_backend(ws: Path) -> dict[str, str]:
return {"type": "filesystem", "root_dir": str(ws), "virtual_mode": False}

Expand Down
67 changes: 67 additions & 0 deletions tests/unit/agents/test_agent_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,73 @@ async def test_on_provider_changed_removes_stale_provider(tmp_path: Path, monkey
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_create_with_template_marks_bootstrapped(tmp_path: Path) -> None:
"""create() from an expert template writes the bootstrap marker so the first
message speaks with its seeded identity instead of a blank-slate onboarding."""
from octop.infra.agents.experts.catalog import ( # noqa: PLC0415
Expert,
ExpertCatalog,
ExpertSummary,
)

services = _make_services(tmp_path)
expert_dir = tmp_path / "experts-lib" / "my-expert"
expert_dir.mkdir(parents=True)
(expert_dir / "SOUL.md").write_text("# Soul", encoding="utf-8")

fake_entry = MagicMock()
fake_entry.agent.backend = MagicMock()
fake_hm = _make_fake_hm()
fake_hm.create_agent = MagicMock(return_value=fake_entry)

fake_catalog = MagicMock(spec=ExpertCatalog)
fake_catalog.get = MagicMock(
return_value=Expert(
summary=ExpertSummary(
id="my-expert",
label_zh="测试",
label_en="Test",
description_zh="",
description_en="",
),
files=["SOUL.md"],
prompt_files=["SOUL.md"],
)
)
fake_catalog.expert_dir = MagicMock(return_value=expert_dir)

registry = _attach_registry(
services,
fake_hm=fake_hm,
expert_catalog=fake_catalog,
)
_bootstrap_factory_from_db(registry, fake_hm)

row = await registry.create(AgentCreateSpec(name="expert-bot", template_name="my-expert"))

ws = services.paths.agent_workspace(row.agent_id)
assert (ws / ".bootstrapped").is_file()


@pytest.mark.asyncio
async def test_create_without_template_no_bootstrapped_marker(tmp_path: Path) -> None:
"""create() without a template must not skip onboarding for blank agents."""
services = _make_services(tmp_path)
fake_entry = MagicMock()
fake_entry.agent.backend = MagicMock()
fake_hm = _make_fake_hm()
fake_hm.shared_factory = MagicMock()
fake_hm.create_agent = MagicMock(return_value=fake_entry)

registry = AgentManager(repos=services.repos, paths=services.paths)
registry._harness_manager = fake_hm

row = await registry.create(AgentCreateSpec(name="plain-bot"))

assert not (services.paths.agent_workspace(row.agent_id) / ".bootstrapped").exists()


@pytest.mark.asyncio
async def test_create_with_template_writes_files(tmp_path: Path) -> None:
"""create() with template_name uploads expert files to the agent backend."""
Expand Down
Loading