diff --git a/CHANGELOG.md b/CHANGELOG.md index c520f7da..a6e4ad47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/images/image.png b/images/image.png new file mode 100644 index 00000000..e93e5dc5 Binary files /dev/null and b/images/image.png differ diff --git "a/images/\345\261\217\345\271\225\346\210\252\345\233\276 2026-08-11 162333.png" "b/images/\345\261\217\345\271\225\346\210\252\345\233\276 2026-08-11 162333.png" new file mode 100644 index 00000000..4c8bbb2a Binary files /dev/null and "b/images/\345\261\217\345\271\225\346\210\252\345\233\276 2026-08-11 162333.png" differ diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index e046dbbd..161ceb8f 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -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( @@ -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: @@ -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, @@ -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: @@ -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 diff --git a/tests/unit/agents/test_agent_manager.py b/tests/unit/agents/test_agent_manager.py index 64a1bb03..2e6bea33 100644 --- a/tests/unit/agents/test_agent_manager.py +++ b/tests/unit/agents/test_agent_manager.py @@ -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, @@ -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, @@ -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="") + ws = manager._paths.ensure_agent_workspace("AGT001") + (ws / ".bootstrapped").write_text("", encoding="utf-8") + + cfg = manager._build_harness_config(row) + + assert cfg.system_prompt == "" + assert cfg.memory is None + + def _fs_backend(ws: Path) -> dict[str, str]: return {"type": "filesystem", "root_dir": str(ws), "virtual_mode": False} diff --git a/tests/unit/agents/test_agent_registry.py b/tests/unit/agents/test_agent_registry.py index d162a0b9..575b7da5 100644 --- a/tests/unit/agents/test_agent_registry.py +++ b/tests/unit/agents/test_agent_registry.py @@ -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."""