From 29dfd14aa685c3d4df1aaae3de7736afc6db7f89 Mon Sep 17 00:00:00 2001 From: steven_li Date: Fri, 27 Mar 2026 10:15:35 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(agent):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=AF=B9=E6=8C=81=E4=B9=85=E5=8C=96=E5=AD=90=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E7=9A=84=E6=94=AF=E6=8C=81=E5=B9=B6=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E5=A7=94=E6=B4=BE=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加了持久化子智能体的完整生命周期管理功能,包括创建、更新、删除和查询API接口。 新增了子智能体的JSON-RPC通信协议支持,实现了远程调用和任务管理功能。 同时增强了委派管理器的功能: - 添加了对本地委派、插件委派和本地回退的开关控制 - 实现了持久化子智能体任务的自动检测和本地执行保护 - 增加了对不同委派类型的权限验证机制 修改了智能体注册表以支持插件智能体的条件性包含,并更新了工具注册逻辑以支持可选工具。 BREAKING CHANGE: 委派管理器的构造函数签名已更改,添加了新的控制参数。 ``` --- .../backend/nanobot/agent/agent_registry.py | 41 +- .../backend/nanobot/agent/delegation.py | 107 +- app-instance/backend/nanobot/agent/loop.py | 27 +- .../backend/nanobot/agent/subagent.py | 7 + .../backend/nanobot/agent/subagents.py | 259 ++ .../nanobot/skills/subagent-manager/SKILL.md | 82 + .../subagent-manager/scripts/subagentctl.py | 212 ++ app-instance/backend/nanobot/web/server.py | 309 ++ .../frontend/app/(app)/agents/page.tsx | 679 ++++- app-instance/frontend/app/(app)/cron/page.tsx | 3 + .../app/(app)/office/[taskId]/page.tsx | 532 ++++ .../frontend/app/(app)/office/page.tsx | 268 ++ app-instance/frontend/app/(app)/page.tsx | 51 +- app-instance/frontend/components/Header.tsx | 13 +- .../chat-workbench/AgentTeamBlock.tsx | 533 ++++ .../chat-workbench/ChatWorkbench.tsx | 67 +- .../components/chat-workbench/MessageList.tsx | 125 +- .../components/office/OfficePhaserCanvas.tsx | 488 ++++ .../components/office/OfficeShared.tsx | 76 + .../components/office/test-add-file.tmp | 0 .../task-management/TaskManagementTabs.tsx | 53 + app-instance/frontend/lib/api.ts | 54 + app-instance/frontend/lib/office.test.ts | 227 ++ app-instance/frontend/lib/office.ts | 704 +++++ app-instance/frontend/lib/store.ts | 19 +- app-instance/frontend/next.config.js | 8 + app-instance/frontend/office-ui.md | 1818 ++++++++++++ app-instance/frontend/package-lock.json | 1705 ++++++++++- app-instance/frontend/package.json | 8 +- app-instance/frontend/public/office/README.md | 18 + .../frontend/public/office/atlas/README.md | 9 + .../frontend/public/office/maps/README.md | 18 + .../office/maps/office-winter-v1-grid.txt | 14 + .../office/maps/office-winter-v1-preview.png | Bin 0 -> 10621 bytes .../maps/office-winter-v1-scene-preview.png | Bin 0 -> 19025 bytes .../office/maps/office-winter-v1-sketch.md | 54 + .../public/office/maps/office-winter-v1.tmj | 2498 +++++++++++++++++ .../public/office/sprites/agents/README.md | 23 + .../public/office/sprites/furniture/README.md | 25 + .../public/office/sprites/status/README.md | 13 + .../frontend/public/office/tiles/README.md | 21 + .../tiles/office-winter-tileset-preview.png | Bin 0 -> 1680 bytes .../office/tiles/office-winter-tileset.png | Bin 0 -> 825 bytes .../public/office/vendor/pixel-agents/LICENSE | 21 + .../office/vendor/pixel-agents/README.md | 17 + .../pixel-agents/assets/characters/char_0.png | Bin 0 -> 3956 bytes .../pixel-agents/assets/characters/char_1.png | Bin 0 -> 5022 bytes .../pixel-agents/assets/characters/char_2.png | Bin 0 -> 5221 bytes .../pixel-agents/assets/characters/char_3.png | Bin 0 -> 4930 bytes .../pixel-agents/assets/characters/char_4.png | Bin 0 -> 5488 bytes .../pixel-agents/assets/characters/char_5.png | Bin 0 -> 5071 bytes .../pixel-agents/assets/default-layout-1.json | 92 + .../pixel-agents/assets/floors/floor_0.png | Bin 0 -> 1719 bytes .../pixel-agents/assets/floors/floor_1.png | Bin 0 -> 3590 bytes .../pixel-agents/assets/floors/floor_2.png | Bin 0 -> 3590 bytes .../pixel-agents/assets/floors/floor_3.png | Bin 0 -> 3594 bytes .../pixel-agents/assets/floors/floor_4.png | Bin 0 -> 3594 bytes .../pixel-agents/assets/floors/floor_5.png | Bin 0 -> 3597 bytes .../pixel-agents/assets/floors/floor_6.png | Bin 0 -> 3597 bytes .../pixel-agents/assets/floors/floor_7.png | Bin 0 -> 3596 bytes .../pixel-agents/assets/floors/floor_8.png | Bin 0 -> 3592 bytes .../pixel-agents/assets/furniture/BIN/BIN.png | Bin 0 -> 252 bytes .../assets/furniture/BIN/manifest.json | 13 + .../assets/furniture/BOOKSHELF/BOOKSHELF.png | Bin 0 -> 388 bytes .../assets/furniture/BOOKSHELF/manifest.json | 13 + .../assets/furniture/CACTUS/CACTUS.png | Bin 0 -> 558 bytes .../assets/furniture/CACTUS/manifest.json | 13 + .../assets/furniture/CLOCK/CLOCK.png | Bin 0 -> 304 bytes .../assets/furniture/CLOCK/manifest.json | 13 + .../assets/furniture/COFFEE/COFFEE.png | Bin 0 -> 223 bytes .../assets/furniture/COFFEE/manifest.json | 13 + .../furniture/COFFEE_TABLE/COFFEE_TABLE.png | Bin 0 -> 274 bytes .../furniture/COFFEE_TABLE/manifest.json | 13 + .../CUSHIONED_BENCH/CUSHIONED_BENCH.png | Bin 0 -> 250 bytes .../furniture/CUSHIONED_BENCH/manifest.json | 13 + .../CUSHIONED_CHAIR/CUSHIONED_CHAIR_BACK.png | Bin 0 -> 205 bytes .../CUSHIONED_CHAIR/CUSHIONED_CHAIR_FRONT.png | Bin 0 -> 247 bytes .../CUSHIONED_CHAIR/CUSHIONED_CHAIR_SIDE.png | Bin 0 -> 255 bytes .../furniture/CUSHIONED_CHAIR/manifest.json | 44 + .../assets/furniture/DESK/DESK_FRONT.png | Bin 0 -> 310 bytes .../assets/furniture/DESK/DESK_SIDE.png | Bin 0 -> 278 bytes .../assets/furniture/DESK/manifest.json | 33 + .../DOUBLE_BOOKSHELF/DOUBLE_BOOKSHELF.png | Bin 0 -> 627 bytes .../furniture/DOUBLE_BOOKSHELF/manifest.json | 13 + .../furniture/HANGING_PLANT/HANGING_PLANT.png | Bin 0 -> 693 bytes .../furniture/HANGING_PLANT/manifest.json | 13 + .../LARGE_PAINTING/LARGE_PAINTING.png | Bin 0 -> 1056 bytes .../furniture/LARGE_PAINTING/manifest.json | 13 + .../furniture/LARGE_PLANT/LARGE_PLANT.png | Bin 0 -> 1285 bytes .../furniture/LARGE_PLANT/manifest.json | 13 + .../assets/furniture/PC/PC_BACK.png | Bin 0 -> 349 bytes .../assets/furniture/PC/PC_FRONT_OFF.png | Bin 0 -> 427 bytes .../assets/furniture/PC/PC_FRONT_ON_1.png | Bin 0 -> 479 bytes .../assets/furniture/PC/PC_FRONT_ON_2.png | Bin 0 -> 476 bytes .../assets/furniture/PC/PC_FRONT_ON_3.png | Bin 0 -> 485 bytes .../assets/furniture/PC/PC_SIDE.png | Bin 0 -> 451 bytes .../assets/furniture/PC/manifest.json | 88 + .../assets/furniture/PLANT/PLANT.png | Bin 0 -> 703 bytes .../assets/furniture/PLANT/manifest.json | 13 + .../assets/furniture/PLANT_2/PLANT_2.png | Bin 0 -> 543 bytes .../assets/furniture/PLANT_2/manifest.json | 13 + .../pixel-agents/assets/furniture/POT/POT.png | Bin 0 -> 288 bytes .../assets/furniture/POT/manifest.json | 13 + .../SMALL_PAINTING/SMALL_PAINTING.png | Bin 0 -> 473 bytes .../furniture/SMALL_PAINTING/manifest.json | 13 + .../SMALL_PAINTING_2/SMALL_PAINTING_2.png | Bin 0 -> 473 bytes .../furniture/SMALL_PAINTING_2/manifest.json | 13 + .../SMALL_TABLE/SMALL_TABLE_FRONT.png | Bin 0 -> 240 bytes .../SMALL_TABLE/SMALL_TABLE_SIDE.png | Bin 0 -> 225 bytes .../furniture/SMALL_TABLE/manifest.json | 33 + .../assets/furniture/SOFA/SOFA_BACK.png | Bin 0 -> 192 bytes .../assets/furniture/SOFA/SOFA_FRONT.png | Bin 0 -> 210 bytes .../assets/furniture/SOFA/SOFA_SIDE.png | Bin 0 -> 255 bytes .../assets/furniture/SOFA/manifest.json | 44 + .../furniture/TABLE_FRONT/TABLE_FRONT.png | Bin 0 -> 400 bytes .../furniture/TABLE_FRONT/manifest.json | 13 + .../furniture/WHITEBOARD/WHITEBOARD.png | Bin 0 -> 336 bytes .../assets/furniture/WHITEBOARD/manifest.json | 13 + .../furniture/WOODEN_BENCH/WOODEN_BENCH.png | Bin 0 -> 208 bytes .../furniture/WOODEN_BENCH/manifest.json | 13 + .../WOODEN_CHAIR/WOODEN_CHAIR_BACK.png | Bin 0 -> 290 bytes .../WOODEN_CHAIR/WOODEN_CHAIR_FRONT.png | Bin 0 -> 295 bytes .../WOODEN_CHAIR/WOODEN_CHAIR_SIDE.png | Bin 0 -> 261 bytes .../furniture/WOODEN_CHAIR/manifest.json | 44 + .../pixel-agents/assets/walls/wall_0.png | Bin 0 -> 1738 bytes .../pixel-agents/previews/char_0-grid.png | Bin 0 -> 9271 bytes .../pixel-agents/previews/char_1-grid.png | Bin 0 -> 8860 bytes .../pixel-agents/previews/char_2-grid.png | Bin 0 -> 9598 bytes .../pixel-agents/previews/char_3-grid.png | Bin 0 -> 8929 bytes .../pixel-agents/previews/char_4-grid.png | Bin 0 -> 10213 bytes .../pixel-agents/previews/char_5-grid.png | Bin 0 -> 9079 bytes app-instance/frontend/types/index.ts | 35 + app-instance/frontend/vitest.config.ts | 10 + 133 files changed, 11656 insertions(+), 220 deletions(-) create mode 100644 app-instance/backend/nanobot/agent/subagents.py create mode 100644 app-instance/backend/nanobot/skills/subagent-manager/SKILL.md create mode 100644 app-instance/backend/nanobot/skills/subagent-manager/scripts/subagentctl.py create mode 100644 app-instance/frontend/app/(app)/office/[taskId]/page.tsx create mode 100644 app-instance/frontend/app/(app)/office/page.tsx create mode 100644 app-instance/frontend/components/chat-workbench/AgentTeamBlock.tsx create mode 100644 app-instance/frontend/components/office/OfficePhaserCanvas.tsx create mode 100644 app-instance/frontend/components/office/OfficeShared.tsx create mode 100644 app-instance/frontend/components/office/test-add-file.tmp create mode 100644 app-instance/frontend/components/task-management/TaskManagementTabs.tsx create mode 100644 app-instance/frontend/lib/office.test.ts create mode 100644 app-instance/frontend/lib/office.ts create mode 100644 app-instance/frontend/office-ui.md create mode 100644 app-instance/frontend/public/office/README.md create mode 100644 app-instance/frontend/public/office/atlas/README.md create mode 100644 app-instance/frontend/public/office/maps/README.md create mode 100644 app-instance/frontend/public/office/maps/office-winter-v1-grid.txt create mode 100644 app-instance/frontend/public/office/maps/office-winter-v1-preview.png create mode 100644 app-instance/frontend/public/office/maps/office-winter-v1-scene-preview.png create mode 100644 app-instance/frontend/public/office/maps/office-winter-v1-sketch.md create mode 100644 app-instance/frontend/public/office/maps/office-winter-v1.tmj create mode 100644 app-instance/frontend/public/office/sprites/agents/README.md create mode 100644 app-instance/frontend/public/office/sprites/furniture/README.md create mode 100644 app-instance/frontend/public/office/sprites/status/README.md create mode 100644 app-instance/frontend/public/office/tiles/README.md create mode 100644 app-instance/frontend/public/office/tiles/office-winter-tileset-preview.png create mode 100644 app-instance/frontend/public/office/tiles/office-winter-tileset.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/LICENSE create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/README.md create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/characters/char_0.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/characters/char_1.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/characters/char_2.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/characters/char_3.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/characters/char_4.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/characters/char_5.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/default-layout-1.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_0.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_1.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_2.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_3.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_4.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_5.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_6.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_7.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/floors/floor_8.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/BIN/BIN.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/BIN/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/BOOKSHELF/BOOKSHELF.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/BOOKSHELF/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/CACTUS/CACTUS.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/CACTUS/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/CLOCK/CLOCK.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/CLOCK/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/COFFEE/COFFEE.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/COFFEE/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/COFFEE_TABLE/COFFEE_TABLE.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/COFFEE_TABLE/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/CUSHIONED_BENCH/CUSHIONED_BENCH.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/CUSHIONED_BENCH/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_BACK.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_FRONT.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_SIDE.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/CUSHIONED_CHAIR/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/DESK/DESK_FRONT.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/DESK/DESK_SIDE.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/DESK/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/DOUBLE_BOOKSHELF/DOUBLE_BOOKSHELF.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/DOUBLE_BOOKSHELF/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/HANGING_PLANT/HANGING_PLANT.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/HANGING_PLANT/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/LARGE_PAINTING/LARGE_PAINTING.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/LARGE_PAINTING/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/LARGE_PLANT/LARGE_PLANT.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/LARGE_PLANT/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PC/PC_BACK.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PC/PC_FRONT_OFF.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PC/PC_FRONT_ON_1.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PC/PC_FRONT_ON_2.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PC/PC_FRONT_ON_3.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PC/PC_SIDE.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PC/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PLANT/PLANT.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PLANT/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PLANT_2/PLANT_2.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/PLANT_2/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/POT/POT.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/POT/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/SMALL_PAINTING/SMALL_PAINTING.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/SMALL_PAINTING/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/SMALL_PAINTING_2/SMALL_PAINTING_2.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/SMALL_PAINTING_2/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/SMALL_TABLE/SMALL_TABLE_FRONT.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/SMALL_TABLE/SMALL_TABLE_SIDE.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/SMALL_TABLE/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/SOFA/SOFA_BACK.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/SOFA/SOFA_FRONT.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/SOFA/SOFA_SIDE.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/SOFA/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/TABLE_FRONT/TABLE_FRONT.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/TABLE_FRONT/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/WHITEBOARD/WHITEBOARD.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/WHITEBOARD/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/WOODEN_BENCH/WOODEN_BENCH.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/WOODEN_BENCH/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_BACK.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_FRONT.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_SIDE.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/furniture/WOODEN_CHAIR/manifest.json create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/assets/walls/wall_0.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/previews/char_0-grid.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/previews/char_1-grid.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/previews/char_2-grid.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/previews/char_3-grid.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/previews/char_4-grid.png create mode 100644 app-instance/frontend/public/office/vendor/pixel-agents/previews/char_5-grid.png create mode 100644 app-instance/frontend/vitest.config.ts diff --git a/app-instance/backend/nanobot/agent/agent_registry.py b/app-instance/backend/nanobot/agent/agent_registry.py index 818bc2e..f6d1708 100644 --- a/app-instance/backend/nanobot/agent/agent_registry.py +++ b/app-instance/backend/nanobot/agent/agent_registry.py @@ -171,6 +171,8 @@ class AgentRegistry: skills: SkillsLoader | None = None, allow_skill_cards: bool = True, allow_workspace_agents: bool = True, + include_local_fallback: bool = True, + include_plugin_agents: bool = True, ): self.workspace = workspace # 插件和技能加载器允许外部复用同一个实例,避免重复扫描磁盘。 @@ -178,10 +180,14 @@ class AgentRegistry: self.skills = skills or SkillsLoader(workspace, extra_dirs=self.plugins.get_skill_dirs()) self.allow_skill_cards = allow_skill_cards self.allow_workspace_agents = allow_workspace_agents + self.include_local_fallback = include_local_fallback + self.include_plugin_agents = include_plugin_agents self.workspace_store = WorkspaceAgentStore(workspace) - def list_agents(self, include_local_fallback: bool = True) -> list[AgentDescriptor]: + def list_agents(self, include_local_fallback: bool | None = None) -> list[AgentDescriptor]: """按统一格式列出当前可见 agent。""" + if include_local_fallback is None: + include_local_fallback = self.include_local_fallback agents: list[AgentDescriptor] = [] if self.allow_workspace_agents: @@ -193,23 +199,24 @@ class AgentRegistry: agents.append(agent) # plugin agents 本质上是“带独立系统提示词的本地执行器”。 - for plugin in self.plugins.plugins.values(): - for agent in plugin.agents.values(): - agents.append( - AgentDescriptor( - id=f"plugin:{agent.name}", - name=agent.name, - description=agent.description or agent.name, - source="plugin", - kind="local_prompt", - protocol=None, - plugin_name=agent.plugin_name, - model=agent.model, - system_prompt=agent.system_prompt, - aliases=[agent.name], - metadata={"plugin_name": agent.plugin_name}, + if self.include_plugin_agents: + for plugin in self.plugins.plugins.values(): + for agent in plugin.agents.values(): + agents.append( + AgentDescriptor( + id=f"plugin:{agent.name}", + name=agent.name, + description=agent.description or agent.name, + source="plugin", + kind="local_prompt", + protocol=None, + plugin_name=agent.plugin_name, + model=agent.model, + system_prompt=agent.system_prompt, + aliases=[agent.name], + metadata={"plugin_name": agent.plugin_name}, + ) ) - ) if self.allow_skill_cards: # skill 里声明的 card 视为远端 A2A agent 的静态入口。 diff --git a/app-instance/backend/nanobot/agent/delegation.py b/app-instance/backend/nanobot/agent/delegation.py index 68c3965..fd39a12 100644 --- a/app-instance/backend/nanobot/agent/delegation.py +++ b/app-instance/backend/nanobot/agent/delegation.py @@ -10,6 +10,7 @@ from __future__ import annotations import asyncio +import re import uuid from collections.abc import Awaitable, Callable from dataclasses import dataclass, field @@ -68,6 +69,9 @@ class DelegationManager: allowed_hosts: list[str] | None = None, authz_config: Any | None = None, backend_identity: Any | None = None, + allow_local_delegation: bool = True, + allow_plugin_delegation: bool = True, + allow_local_fallback: bool = True, ): self.provider = provider self.workspace = workspace @@ -76,6 +80,9 @@ class DelegationManager: # local_executor 只负责“本地执行”,不再承担队列编排职责。 self.local_executor = local_executor self.max_parallel_agents = max(1, max_parallel_agents) + self.allow_local_delegation = allow_local_delegation + self.allow_plugin_delegation = allow_plugin_delegation + self.allow_local_fallback = allow_local_fallback # A2AClient 只处理远端协议细节,委派策略和公告统一放在本类。 self.a2a_client = A2AClient( timeout_seconds=timeout_seconds, @@ -88,6 +95,19 @@ class DelegationManager: self._running_tasks: dict[str, DelegationRun] = {} self._direct_announcement_callback: DirectAnnouncementCallback | None = None + _PERSISTENT_SUBAGENT_PATTERNS = ( + re.compile(r"\bsub[\s-]?agent\b", re.IGNORECASE), + re.compile(r"\bpersistent\b", re.IGNORECASE), + re.compile(r"\bagents\.json\b", re.IGNORECASE), + re.compile(r"\bregistry\.json\b", re.IGNORECASE), + re.compile(r"\bsubagentctl\b", re.IGNORECASE), + re.compile(r"/api/subagents", re.IGNORECASE), + re.compile(r"workspace/agents", re.IGNORECASE), + re.compile(r"子智能体"), + re.compile(r"子 agent", re.IGNORECASE), + re.compile(r"持久化"), + ) + def set_direct_announcement_callback( self, callback: DirectAnnouncementCallback | None, @@ -160,6 +180,7 @@ class DelegationManager: label: str, *, parent_run_id: str | None = None, + task: str | None = None, ) -> None: # 单 agent 执行开始事件,供前端画执行树。 await emit_process_event( @@ -177,8 +198,21 @@ class DelegationManager: "protocol": descriptor.protocol, "support_group": descriptor.support_group, "support_streaming": descriptor.support_streaming, + "delegated_task": task, }, ) + if task: + await emit_process_event( + "process_run_message", + run_id=run_id, + parent_run_id=parent_run_id, + actor_type="agent", + actor_id=descriptor.id, + actor_name=descriptor.name, + message_role="user", + text=task, + metadata={"source": "delegation_input"}, + ) async def _emit_agent_finished( self, @@ -386,7 +420,7 @@ class DelegationManager: # 单 agent 场景先解析目标,再执行。 descriptor = self._resolve_single(task, target, strategy) - await self._emit_agent_started(run_id, descriptor, label) + await self._emit_agent_started(run_id, descriptor, label, task=task) progress_callback = self._build_progress_callback( origin, descriptor, @@ -468,15 +502,20 @@ class DelegationManager: descriptor = self.registry.get_agent(target) if descriptor is None: raise ValueError(f"Agent '{target}' not found") + self._ensure_descriptor_allowed(descriptor) return descriptor if strategy == "local": + if not self.allow_local_fallback: + raise ValueError("Local fallback delegation is disabled") descriptor = self.registry.get_agent("local-subagent") if descriptor is None: raise ValueError("Local subagent is not available") return descriptor if strategy == "plugin": + if not self.allow_plugin_delegation: + raise ValueError("Plugin delegation is disabled") suggestions = [ agent for agent in self.registry.suggest_agents(task) if agent.kind == "local_prompt" and agent.source == "plugin" @@ -494,15 +533,46 @@ class DelegationManager: return suggestions[0] raise ValueError("No matching A2A agent found") - suggestions = self.registry.suggest_agents(task, limit=1) + # Persistent sub-agent 管理是本地工作区变更任务,必须留在本地执行, + # 不能自动委派给远端 A2A agent,否则远端看不到本地规范和状态。 + if self._is_persistent_subagent_task(task): + if not self.allow_local_fallback: + raise ValueError("Persistent sub-agent management requires local fallback delegation") + descriptor = self.registry.get_agent("local-subagent") + if descriptor is None: + raise ValueError("Local fallback agent is not available") + return descriptor + + suggestions = [ + agent for agent in self.registry.suggest_agents(task, limit=5) + if self._descriptor_allowed(agent) + ] if suggestions: return suggestions[0] # 自动路由一个都猜不到时,最后回到本地兜底 agent。 + if not self.allow_local_fallback: + raise ValueError("No allowed agent found for delegation") descriptor = self.registry.get_agent("local-subagent") if descriptor is None: raise ValueError("Local fallback agent is not available") return descriptor + @classmethod + def _is_persistent_subagent_task(cls, task: str) -> bool: + text = (task or "").strip() + if not text: + return False + + matched = sum(1 for pattern in cls._PERSISTENT_SUBAGENT_PATTERNS if pattern.search(text)) + if matched >= 2: + return True + + lowered = text.lower() + return ( + ("create" in lowered or "update" in lowered or "repair" in lowered or "fix" in lowered) + and ("subagent" in lowered or "sub-agent" in lowered) + ) + async def _run_group( self, task: str, @@ -520,7 +590,10 @@ class DelegationManager: resolved_targets.append(target) if not resolved_targets: # 未显式给出目标时,根据任务文本自动挑若干个候选 agent。 - suggestions = self.registry.suggest_agents(task, limit=self.max_parallel_agents) + suggestions = [ + agent for agent in self.registry.suggest_agents(task, limit=self.max_parallel_agents * 2) + if self._descriptor_allowed(agent) + ] resolved_targets = [agent.id for agent in suggestions] if not resolved_targets: raise ValueError("No agents available for group delegation") @@ -533,6 +606,7 @@ class DelegationManager: if descriptor is None: missing.append(item) else: + self._ensure_descriptor_allowed(descriptor) descriptors.append(descriptor) if missing: raise ValueError(f"Agent(s) not found: {', '.join(missing)}") @@ -544,7 +618,13 @@ class DelegationManager: child_run_id = new_run_id("agent") async with semaphore: try: - await self._emit_agent_started(child_run_id, descriptor, label, parent_run_id=run_id) + await self._emit_agent_started( + child_run_id, + descriptor, + label, + parent_run_id=run_id, + task=task, + ) result = await self._execute_descriptor( descriptor, task, @@ -588,6 +668,12 @@ class DelegationManager: """根据 descriptor 类型执行具体 agent。""" logger.info("Delegating '{}' to {}", label, descriptor.id) if descriptor.kind in {"local_fallback", "local_prompt"}: + if not self.allow_local_delegation or ( + descriptor.kind == "local_prompt" and not self.allow_plugin_delegation + ) or ( + descriptor.kind == "local_fallback" and not self.allow_local_fallback + ): + raise ValueError(f"Delegation to '{descriptor.id}' is disabled") # 本地执行时,把当前 run_id 写入上下文,便于更深层的 MCP/tool 事件挂父节点。 with process_run_context(process_run_id): return await self.local_executor.run_local_task( @@ -611,6 +697,19 @@ class DelegationManager: ) raise ValueError(f"Unsupported agent kind '{descriptor.kind}'") + def _descriptor_allowed(self, descriptor: AgentDescriptor) -> bool: + if descriptor.kind == "local_fallback": + return self.allow_local_fallback and self.allow_local_delegation + if descriptor.kind == "local_prompt": + return self.allow_local_delegation and self.allow_plugin_delegation + if descriptor.protocol == "a2a" or descriptor.kind == "a2a_remote": + return True + return False + + def _ensure_descriptor_allowed(self, descriptor: AgentDescriptor) -> None: + if not self._descriptor_allowed(descriptor): + raise ValueError(f"Delegation to '{descriptor.id}' is disabled") + def _build_progress_callback( self, origin: dict[str, str], diff --git a/app-instance/backend/nanobot/agent/loop.py b/app-instance/backend/nanobot/agent/loop.py index 312f7d0..aeff7fd 100644 --- a/app-instance/backend/nanobot/agent/loop.py +++ b/app-instance/backend/nanobot/agent/loop.py @@ -76,6 +76,13 @@ class AgentLoop: channels_config: ChannelsConfig | None = None, authz_config: Any | None = None, backend_identity: Any | None = None, + allow_spawn: bool = True, + allow_message: bool = True, + allow_cron: bool = True, + include_local_fallback: bool = True, + allow_local_delegation: bool = True, + allow_plugin_delegation: bool = True, + include_plugin_agents: bool = True, ): from nanobot.config.schema import A2AConfig, ExecToolConfig # 基础依赖与运行参数。 @@ -95,6 +102,13 @@ class AgentLoop: self.restrict_to_workspace = restrict_to_workspace self.authz_config = authz_config self.backend_identity = backend_identity + self.allow_spawn = allow_spawn + self.allow_message = allow_message + self.allow_cron = allow_cron + self.include_local_fallback = include_local_fallback + self.allow_local_delegation = allow_local_delegation + self.allow_plugin_delegation = allow_plugin_delegation + self.include_plugin_agents = include_plugin_agents # 核心组件:上下文构建、会话管理、工具注册、子代理管理。 self.plugins = PluginLoader(workspace) @@ -106,6 +120,8 @@ class AgentLoop: skills=self.skills, allow_skill_cards=self.a2a_config.allow_skill_cards, allow_workspace_agents=self.a2a_config.allow_workspace_agents, + include_local_fallback=self.include_local_fallback, + include_plugin_agents=self.include_plugin_agents, ) self.context = ContextBuilder( workspace, @@ -137,6 +153,9 @@ class AgentLoop: allowed_hosts=self.a2a_config.allowed_hosts, authz_config=self.authz_config, backend_identity=self.backend_identity, + allow_local_delegation=self.allow_local_delegation, + allow_plugin_delegation=self.allow_plugin_delegation, + allow_local_fallback=self.include_local_fallback, ) # 运行时状态位。 @@ -193,11 +212,13 @@ class AgentLoop: # 网络、消息、子代理工具按职责注册。 self.tools.register(WebSearchTool(api_key=self.brave_api_key)) self.tools.register(WebFetchTool()) - self.tools.register(MessageTool(send_callback=self.bus.publish_outbound)) - self.tools.register(SpawnTool(manager=self.delegation)) + if self.allow_message: + self.tools.register(MessageTool(send_callback=self.bus.publish_outbound)) + if self.allow_spawn: + self.tools.register(SpawnTool(manager=self.delegation)) # 只有注入 cron_service 时才暴露 cron 工具,避免空引用。 - if self.cron_service: + if self.cron_service and self.allow_cron: self.tools.register(CronTool(self.cron_service)) async def _connect_mcp(self) -> None: diff --git a/app-instance/backend/nanobot/agent/subagent.py b/app-instance/backend/nanobot/agent/subagent.py index 10f916f..06c6e88 100644 --- a/app-instance/backend/nanobot/agent/subagent.py +++ b/app-instance/backend/nanobot/agent/subagent.py @@ -236,4 +236,11 @@ You are a delegated agent spawned by the main agent to complete a specific task. Your workspace is at: {self.workspace} Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed) +## Special Workflow +- If the task is about creating, updating, repairing, or deleting a persistent local sub-agent, read `skills/subagent-manager/SKILL.md` before making changes. +- For persistent local sub-agents, follow only the canonical workflow from that skill. +- Do not manually create `workspace/agents//agent.json` as a substitute for a persistent sub-agent. +- Do not manually edit `workspace/agents/registry.json` to register a persistent sub-agent. +- A valid persistent sub-agent must be created through `subagentctl.py` or `/api/subagents` and must end up at `workspace/agents/_agent/AGENTS.json`. + When you have completed the task, provide a clear summary of your findings or actions.{extra}""" diff --git a/app-instance/backend/nanobot/agent/subagents.py b/app-instance/backend/nanobot/agent/subagents.py new file mode 100644 index 0000000..686b122 --- /dev/null +++ b/app-instance/backend/nanobot/agent/subagents.py @@ -0,0 +1,259 @@ +"""Persistent local sub-agent storage helpers.""" + +from __future__ import annotations + +import json +import re +import shutil +from dataclasses import asdict, dataclass, field +from importlib.resources import files as pkg_files +from pathlib import Path +from typing import Any + +from nanobot.config.schema import Config, MCPServerConfig + +_INVALID_ID_RE = re.compile(r"[^a-z0-9-]+") + + +def normalize_subagent_id(value: str) -> str: + normalized = _INVALID_ID_RE.sub("-", str(value or "").strip().lower()).strip("-") + normalized = re.sub(r"-{2,}", "-", normalized) + if not normalized: + raise ValueError("Sub-agent id is required") + return normalized + + +@dataclass +class SubagentSpec: + id: str + name: str + description: str + enabled: bool = True + workspace: str = "" + system_prompt: str = "" + model: str | None = None + delegation_mode: str = "remote_a2a_only" + allow_mcp: bool = True + tags: list[str] = field(default_factory=list) + aliases: list[str] = field(default_factory=list) + mcp_servers: dict[str, dict[str, Any]] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_dict(cls, payload: dict[str, Any], *, workspace_path: Path | None = None) -> "SubagentSpec": + agent_id = normalize_subagent_id(payload.get("id", "")) + name = str(payload.get("name") or agent_id).strip() or agent_id + description = str(payload.get("description") or name).strip() or name + workspace = str(payload.get("workspace") or "").strip() + if not workspace and workspace_path is not None: + workspace = str(workspace_path) + tags = [str(item).strip() for item in payload.get("tags", []) if str(item).strip()] + aliases = [str(item).strip() for item in payload.get("aliases", []) if str(item).strip()] + mcp_servers = payload.get("mcp_servers", {}) + if not isinstance(mcp_servers, dict): + mcp_servers = {} + metadata = payload.get("metadata", {}) + if not isinstance(metadata, dict): + metadata = {} + return cls( + id=agent_id, + name=name, + description=description, + enabled=bool(payload.get("enabled", True)), + workspace=workspace, + system_prompt=str(payload.get("system_prompt") or "").strip(), + model=(str(payload.get("model") or "").strip() or None), + delegation_mode=(str(payload.get("delegation_mode") or "remote_a2a_only").strip() or "remote_a2a_only"), + allow_mcp=bool(payload.get("allow_mcp", True)), + tags=tags, + aliases=aliases, + mcp_servers=mcp_servers, + metadata=metadata, + ) + + def to_dict(self) -> dict[str, Any]: + payload = asdict(self) + if not self.model: + payload["model"] = None + return payload + + +class LocalSubagentStore: + """Persist sub-agent definitions under `/agents/_agent/`.""" + + def __init__(self, workspace: Path): + self.workspace = workspace.expanduser().resolve() + self.directory = self.workspace / "agents" + + def list_subagents(self) -> list[SubagentSpec]: + if not self.directory.exists(): + return [] + result: list[SubagentSpec] = [] + for child in sorted(self.directory.iterdir()): + agents_json = child / "AGENTS.json" + if not child.is_dir() or not agents_json.exists(): + continue + try: + payload = json.loads(agents_json.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError): + continue + if not isinstance(payload, dict): + continue + result.append(SubagentSpec.from_dict(payload, workspace_path=child)) + return result + + def get_subagent(self, agent_id: str) -> SubagentSpec | None: + path = self.agents_json_path(agent_id) + if not path.exists(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError): + return None + if not isinstance(payload, dict): + return None + return SubagentSpec.from_dict(payload, workspace_path=self.subagent_dir(agent_id)) + + def upsert_subagent(self, payload: dict[str, Any], config: Config) -> SubagentSpec: + agent_id = normalize_subagent_id(payload.get("id", "")) + workspace_path = self.subagent_dir(agent_id) + spec = SubagentSpec.from_dict(payload, workspace_path=workspace_path) + + self._ensure_workspace(workspace_path) + spec.workspace = str(workspace_path) + self._sync_agents_md(workspace_path, spec) + self.agents_json_path(agent_id).write_text( + json.dumps(spec.to_dict(), indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + from nanobot.agent.agent_registry import WorkspaceAgentStore + + WorkspaceAgentStore(self.workspace).upsert_agent(self.build_registry_record(spec, config)) + return spec + + def delete_subagent(self, agent_id: str) -> bool: + agent_id = normalize_subagent_id(agent_id) + target = self.subagent_dir(agent_id) + if not target.exists(): + return False + + from nanobot.agent.agent_registry import WorkspaceAgentStore + + WorkspaceAgentStore(self.workspace).delete_agent(agent_id) + shutil.rmtree(target) + return True + + def subagent_dir(self, agent_id: str) -> Path: + return self.directory / f"{normalize_subagent_id(agent_id)}_agent" + + def agents_json_path(self, agent_id: str) -> Path: + return self.subagent_dir(agent_id) / "AGENTS.json" + + def local_base_url(self, config: Config, agent_id: str) -> str: + return f"http://127.0.0.1:{int(config.gateway.port)}/subagents/{normalize_subagent_id(agent_id)}" + + def build_registry_record(self, spec: SubagentSpec, config: Config) -> dict[str, Any]: + base_url = self.local_base_url(config, spec.id) + card_url = f"{base_url}/.well-known/agent-card" + return { + "id": spec.id, + "name": spec.name, + "description": spec.description, + "protocol": "a2a", + "base_url": base_url, + "endpoint": f"{base_url}/rpc", + "card_url": card_url, + "enabled": spec.enabled, + "tags": sorted(set(["local-subagent", *spec.tags])), + "aliases": sorted(set([spec.name, *spec.aliases])), + "metadata": { + **spec.metadata, + "workspace": spec.workspace, + "managed_by": "subagent-manager", + "local_subagent": True, + }, + "capabilities": {"streaming": False}, + "support_group": False, + "support_streaming": False, + } + + @staticmethod + def build_agent_card(spec: SubagentSpec, config: Config) -> dict[str, Any]: + base_url = f"http://127.0.0.1:{int(config.gateway.port)}/subagents/{spec.id}" + rpc_url = f"{base_url}/rpc" + return { + "id": spec.id, + "name": spec.name, + "description": spec.description, + "url": rpc_url, + "preferred_transport": "jsonrpc", + "interfaces": [{"transport": "jsonrpc", "url": rpc_url}], + "capabilities": {"streaming": False}, + "tags": sorted(set(["local-subagent", *spec.tags])), + "metadata": { + "workspace": spec.workspace, + "managed_by": "subagent-manager", + }, + } + + @staticmethod + def coerce_mcp_servers(spec: SubagentSpec) -> dict[str, MCPServerConfig]: + if not spec.allow_mcp: + return {} + result: dict[str, MCPServerConfig] = {} + for name, payload in spec.mcp_servers.items(): + if not isinstance(payload, dict): + continue + try: + result[name] = MCPServerConfig.model_validate(payload) + except Exception: + continue + return result + + def _ensure_workspace(self, workspace_path: Path) -> None: + workspace_path.mkdir(parents=True, exist_ok=True) + + templates_dir = pkg_files("nanobot") / "templates" + for item in templates_dir.iterdir(): + if not item.name.endswith(".md") or item.name == "AGENTS.md": + continue + dest = workspace_path / item.name + if not dest.exists(): + dest.write_text(item.read_text(encoding="utf-8"), encoding="utf-8") + + memory_dir = workspace_path / "memory" + memory_dir.mkdir(exist_ok=True) + memory_template = templates_dir / "memory" / "MEMORY.md" + memory_file = memory_dir / "MEMORY.md" + if not memory_file.exists(): + memory_file.write_text(memory_template.read_text(encoding="utf-8"), encoding="utf-8") + history_file = memory_dir / "HISTORY.md" + if not history_file.exists(): + history_file.write_text("", encoding="utf-8") + (workspace_path / "skills").mkdir(exist_ok=True) + + def _sync_agents_md(self, workspace_path: Path, spec: SubagentSpec) -> None: + content = self._render_agents_md(spec) + (workspace_path / "AGENTS.md").write_text(content, encoding="utf-8") + + @staticmethod + def _render_agents_md(spec: SubagentSpec) -> str: + prompt = spec.system_prompt.strip() or "Complete delegated tasks accurately and concisely." + return f"""# {spec.name} + +You are {spec.name}, a persistent local sub-agent managed by Boardware Genius. + +## Role +{spec.description} + +## System Prompt +{prompt} + +## Constraints +- Work only inside this workspace. +- Respond only to delegated tasks. +- Delegate only to remote A2A agents when delegation is enabled. +- Do not create or manage local sub-agents. +- Do not message end users directly. +""" diff --git a/app-instance/backend/nanobot/skills/subagent-manager/SKILL.md b/app-instance/backend/nanobot/skills/subagent-manager/SKILL.md new file mode 100644 index 0000000..291acab --- /dev/null +++ b/app-instance/backend/nanobot/skills/subagent-manager/SKILL.md @@ -0,0 +1,82 @@ +--- +name: subagent-manager +description: Create, inspect, update, and remove persistent local A2A sub-agents. Use when the user wants Boardware Genius to manage sub-agents with their own workspace under ~/.nanobot/workspace/agents/_agent, their own AGENTS.json and AGENTS.md, and local A2A visibility in the agent list. +--- + +# Subagent Manager + +Use this skill when the user wants to create or manage a persistent local sub-agent. + +## Required Rules + +- Persistent sub-agents must be created and updated only through `subagentctl.py` or `/api/subagents`. +- Treat `~/.nanobot/workspace/agents/_agent/AGENTS.json` as the source of truth. +- Do not create a sub-agent by manually editing `workspace/agents/registry.json`. +- Do not create ad-hoc layouts such as `workspace/agents//agent.json`, `main.py`, or `README.md` as a substitute for a persistent sub-agent. +- Do not write `protocol: "local"` registry records for persistent sub-agents. A valid persistent sub-agent is registered automatically as local A2A with `protocol: "a2a"`. +- Prefer the bundled script over hand-editing JSON files, because the script keeps `AGENTS.json`, `AGENTS.md`, and the registry entry consistent. + +## Workflow + +1. Inspect the current sub-agents first: + `uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py list` +2. Create or update the sub-agent with: + `uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py create ...` +3. Verify the generated workspace: + `~/.nanobot/workspace/agents/_agent/` +4. Verify the agent registry entry exists by checking `/api/agents` or `workspace/agents/registry.json`. +5. If the user wants custom skills, edit files under: + `~/.nanobot/workspace/agents/_agent/skills/` + +## Creation Standard + +When the user asks for a new specialized sub-agent, always: + +1. Choose a stable kebab-case id. +2. Create it with `subagentctl.py create` or `POST /api/subagents`. +3. Confirm the generated workspace is `~/.nanobot/workspace/agents/_agent/`. +4. Confirm `AGENTS.json` exists in that directory. +5. Confirm the unified agent list shows the same id as a managed sub-agent entry. + +If the user asks for "an agent for X", interpret that as a persistent sub-agent when they want a reusable local worker with its own prompt, memory, skills, or MCP setup. + +## Repair Standard + +If you find a malformed "sub-agent" created through the wrong path, repair it instead of reusing the broken layout: + +1. Read any existing metadata that is useful, such as id, name, description, prompt, tags, aliases, or MCP config. +2. Recreate the agent through `subagentctl.py create` or `/api/subagents`. +3. Verify the new canonical directory `~/.nanobot/workspace/agents/_agent/AGENTS.json`. +4. Remove the malformed directory or stale registry entry only after the canonical sub-agent exists. + +Malformed examples include: + +- `workspace/agents//agent.json` +- registry entries with `protocol: "local"` +- agent folders that do not contain `AGENTS.json` + +## Commands + +- Create: + `uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py create --id research-agent --name "Research Agent" --description "Research-focused local A2A sub-agent" --system-prompt "Focus on research tasks and be concise."` +- Show: + `uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py show research-agent` +- Delete: + `uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py delete research-agent` +- Set system prompt: + `uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py set-system-prompt research-agent --text "New prompt"` +- Add HTTP MCP: + `uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py add-mcp-http research-agent --server-id docs --url http://127.0.0.1:9000/mcp` +- Add stdio MCP: + `uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py add-mcp-stdio research-agent --server-id localtools --command npx --arg -y --arg @modelcontextprotocol/server-filesystem` +- Remove MCP: + `uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py remove-mcp research-agent --server-id docs` + +## Notes + +- `AGENTS.json` is the machine-readable source of truth for the sub-agent. +- `AGENTS.md` is regenerated from `AGENTS.json` when the script updates the sub-agent. +- Builtin skills remain available automatically. Workspace-specific skills live under the sub-agent workspace `skills/` directory. +- This MVP exposes the sub-agent through local A2A `message/send` only. +- New sub-agents default to `delegation_mode="remote_a2a_only"`: they can delegate outward only to remote A2A agents, not to local fallback or plugin agents. +- A valid persistent sub-agent should appear in both `/api/subagents` and `/api/agents`. diff --git a/app-instance/backend/nanobot/skills/subagent-manager/scripts/subagentctl.py b/app-instance/backend/nanobot/skills/subagent-manager/scripts/subagentctl.py new file mode 100644 index 0000000..f4141b7 --- /dev/null +++ b/app-instance/backend/nanobot/skills/subagent-manager/scripts/subagentctl.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Manage persistent local sub-agents.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys +from typing import Any + +ROOT = Path(__file__).resolve().parents[4] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from nanobot.agent.subagents import LocalSubagentStore, SubagentSpec +from nanobot.config.loader import load_config + + +def _store(): + config = load_config() + return config, LocalSubagentStore(config.workspace_path) + + +def _print_json(payload: Any) -> None: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + + +def _load_spec_or_die(store: LocalSubagentStore, agent_id: str) -> SubagentSpec: + spec = store.get_subagent(agent_id) + if spec is None: + raise SystemExit(f"Sub-agent not found: {agent_id}") + return spec + + +def _parse_key_values(items: list[str]) -> dict[str, str]: + result: dict[str, str] = {} + for item in items: + if "=" not in item: + raise SystemExit(f"Expected KEY=VALUE, got: {item}") + key, value = item.split("=", 1) + key = key.strip() + if not key: + raise SystemExit(f"Invalid empty key in: {item}") + result[key] = value + return result + + +def cmd_list(_: argparse.Namespace) -> None: + _, store = _store() + _print_json([spec.to_dict() for spec in store.list_subagents()]) + + +def cmd_show(args: argparse.Namespace) -> None: + _, store = _store() + spec = _load_spec_or_die(store, args.agent_id) + _print_json(spec.to_dict()) + + +def cmd_create(args: argparse.Namespace) -> None: + config, store = _store() + current = store.get_subagent(args.agent_id) + payload = current.to_dict() if current is not None else {"id": args.agent_id} + payload.update({ + "id": args.agent_id, + "name": args.name or payload.get("name") or args.agent_id, + "description": args.description or payload.get("description") or args.name or args.agent_id, + "enabled": not args.disabled, + "delegation_mode": payload.get("delegation_mode") or "remote_a2a_only", + }) + if args.system_prompt: + payload["system_prompt"] = args.system_prompt + if args.model: + payload["model"] = args.model + spec = store.upsert_subagent(payload, config) + _print_json(spec.to_dict()) + + +def cmd_delete(args: argparse.Namespace) -> None: + _, store = _store() + deleted = store.delete_subagent(args.agent_id) + if not deleted: + raise SystemExit(f"Sub-agent not found: {args.agent_id}") + _print_json({"ok": True, "id": args.agent_id}) + + +def cmd_set_system_prompt(args: argparse.Namespace) -> None: + config, store = _store() + spec = _load_spec_or_die(store, args.agent_id) + payload = spec.to_dict() + payload["system_prompt"] = args.text + updated = store.upsert_subagent(payload, config) + _print_json(updated.to_dict()) + + +def cmd_add_mcp_http(args: argparse.Namespace) -> None: + config, store = _store() + spec = _load_spec_or_die(store, args.agent_id) + payload = spec.to_dict() + payload.setdefault("mcp_servers", {}) + payload["mcp_servers"][args.server_id] = { + "url": args.url, + "headers": _parse_key_values(args.header), + "auth_mode": args.auth_mode, + "auth_audience": args.auth_audience, + "auth_scopes": list(args.auth_scope), + "tool_timeout": args.tool_timeout, + "sensitive": args.sensitive, + } + updated = store.upsert_subagent(payload, config) + _print_json(updated.to_dict()) + + +def cmd_add_mcp_stdio(args: argparse.Namespace) -> None: + config, store = _store() + spec = _load_spec_or_die(store, args.agent_id) + payload = spec.to_dict() + payload.setdefault("mcp_servers", {}) + payload["mcp_servers"][args.server_id] = { + "command": args.command, + "args": list(args.arg), + "env": _parse_key_values(args.env), + "auth_mode": args.auth_mode, + "auth_audience": args.auth_audience, + "auth_scopes": list(args.auth_scope), + "tool_timeout": args.tool_timeout, + "sensitive": args.sensitive, + } + updated = store.upsert_subagent(payload, config) + _print_json(updated.to_dict()) + + +def cmd_remove_mcp(args: argparse.Namespace) -> None: + config, store = _store() + spec = _load_spec_or_die(store, args.agent_id) + payload = spec.to_dict() + mcp_servers = payload.setdefault("mcp_servers", {}) + mcp_servers.pop(args.server_id, None) + updated = store.upsert_subagent(payload, config) + _print_json(updated.to_dict()) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Manage persistent local sub-agents") + sub = parser.add_subparsers(dest="command", required=True) + + list_parser = sub.add_parser("list", help="List sub-agents") + list_parser.set_defaults(func=cmd_list) + + show_parser = sub.add_parser("show", help="Show one sub-agent") + show_parser.add_argument("agent_id") + show_parser.set_defaults(func=cmd_show) + + create_parser = sub.add_parser("create", help="Create or update a sub-agent") + create_parser.add_argument("--id", dest="agent_id", required=True) + create_parser.add_argument("--name", default="") + create_parser.add_argument("--description", default="") + create_parser.add_argument("--system-prompt", default="") + create_parser.add_argument("--model", default="") + create_parser.add_argument("--disabled", action="store_true") + create_parser.set_defaults(func=cmd_create) + + delete_parser = sub.add_parser("delete", help="Delete a sub-agent") + delete_parser.add_argument("agent_id") + delete_parser.set_defaults(func=cmd_delete) + + prompt_parser = sub.add_parser("set-system-prompt", help="Update the system prompt") + prompt_parser.add_argument("agent_id") + prompt_parser.add_argument("--text", required=True) + prompt_parser.set_defaults(func=cmd_set_system_prompt) + + http_parser = sub.add_parser("add-mcp-http", help="Add an HTTP MCP server") + http_parser.add_argument("agent_id") + http_parser.add_argument("--server-id", required=True) + http_parser.add_argument("--url", required=True) + http_parser.add_argument("--header", action="append", default=[]) + http_parser.add_argument("--auth-mode", default="none") + http_parser.add_argument("--auth-audience", default="") + http_parser.add_argument("--auth-scope", action="append", default=[]) + http_parser.add_argument("--tool-timeout", type=int, default=30) + http_parser.add_argument("--sensitive", action="store_true") + http_parser.set_defaults(func=cmd_add_mcp_http) + + stdio_parser = sub.add_parser("add-mcp-stdio", help="Add a stdio MCP server") + stdio_parser.add_argument("agent_id") + stdio_parser.add_argument("--server-id", required=True) + stdio_parser.add_argument("--command", required=True) + stdio_parser.add_argument("--arg", action="append", default=[]) + stdio_parser.add_argument("--env", action="append", default=[]) + stdio_parser.add_argument("--auth-mode", default="none") + stdio_parser.add_argument("--auth-audience", default="") + stdio_parser.add_argument("--auth-scope", action="append", default=[]) + stdio_parser.add_argument("--tool-timeout", type=int, default=30) + stdio_parser.add_argument("--sensitive", action="store_true") + stdio_parser.set_defaults(func=cmd_add_mcp_stdio) + + remove_mcp = sub.add_parser("remove-mcp", help="Remove an MCP server") + remove_mcp.add_argument("agent_id") + remove_mcp.add_argument("--server-id", required=True) + remove_mcp.set_defaults(func=cmd_remove_mcp) + + return parser + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/app-instance/backend/nanobot/web/server.py b/app-instance/backend/nanobot/web/server.py index 62dd3a4..832980b 100644 --- a/app-instance/backend/nanobot/web/server.py +++ b/app-instance/backend/nanobot/web/server.py @@ -11,6 +11,7 @@ import secrets import shlex import shutil import time +import uuid import zipfile from pathlib import Path from typing import TYPE_CHECKING, Any @@ -436,6 +437,21 @@ class MCPServerRequest(BaseModel): sensitive: bool = False +class SubagentRequest(BaseModel): + id: str + name: str | None = None + description: str | None = None + system_prompt: str = "" + model: str | None = None + enabled: bool = True + delegation_mode: str = "remote_a2a_only" + allow_mcp: bool = True + tags: list[str] = Field(default_factory=list) + aliases: list[str] = Field(default_factory=list) + mcp_servers: dict[str, dict[str, Any]] = Field(default_factory=dict) + metadata: dict[str, Any] = Field(default_factory=dict) + + class OutlookConnectionRequest(BaseModel): email: str password: str @@ -733,6 +749,7 @@ def create_app( app.state.auth_tokens: dict[str, str] = {} app.state.handoff_codes: dict[str, dict[str, Any]] = {} app.state.auth_file = _get_auth_file_path() + app.state.subagent_tasks: dict[str, dict[str, Any]] = {} _register_routes(app) return app @@ -1083,6 +1100,157 @@ def _register_routes(app: FastAPI) -> None: backend_identity=config.backend_identity, ) + def _jsonrpc_error(payload_id: Any, code: int, message: str) -> JSONResponse: + return JSONResponse( + status_code=200, + content={ + "jsonrpc": "2.0", + "id": payload_id, + "error": {"code": code, "message": message}, + }, + ) + + def _extract_subagent_task(params: dict[str, Any]) -> str: + message = params.get("message") + if not isinstance(message, dict): + raise ValueError("Missing 'message' object") + + parts = message.get("parts") + if isinstance(parts, list): + for part in parts: + if not isinstance(part, dict): + continue + text = str(part.get("text") or "").strip() + if text: + return text + + content = message.get("content") + if isinstance(content, list): + for item in content: + if not isinstance(item, dict): + continue + text = str(item.get("text") or "").strip() + if text: + return text + + raise ValueError("A2A message does not contain text content") + + async def _run_subagent_task(agent_id: str, task: str) -> str: + from nanobot.agent.loop import AgentLoop + from nanobot.agent.subagents import LocalSubagentStore + + config: Config = app.state.config + store = LocalSubagentStore(config.workspace_path) + spec = store.get_subagent(agent_id) + if spec is None or not spec.enabled: + raise HTTPException(status_code=404, detail="Sub-agent not found") + delegation_mode = (spec.delegation_mode or "remote_a2a_only").strip().lower() + allow_spawn = delegation_mode in {"remote_a2a_only", "full"} + allow_local = delegation_mode == "full" + + provider = _make_provider(config) + loop = AgentLoop( + bus=app.state.bus, + provider=provider, + workspace=Path(spec.workspace), + model=spec.model or config.agents.defaults.model, + max_iterations=config.agents.defaults.max_tool_iterations, + temperature=config.agents.defaults.temperature, + max_tokens=config.agents.defaults.max_tokens, + memory_window=config.agents.defaults.memory_window, + brave_api_key=config.tools.web.search.api_key or None, + exec_config=config.tools.exec, + a2a_config=config.tools.a2a, + cron_service=None, + restrict_to_workspace=True, + session_manager=SessionManager(Path(spec.workspace)), + mcp_servers=LocalSubagentStore.coerce_mcp_servers(spec), + authz_config=config.authz, + backend_identity=config.backend_identity, + allow_spawn=allow_spawn, + allow_message=False, + allow_cron=False, + include_local_fallback=allow_local, + allow_local_delegation=allow_local, + allow_plugin_delegation=allow_local, + include_plugin_agents=allow_local, + ) + try: + return await loop.process_direct( + task, + session_key=f"a2a:{spec.id}", + channel="system", + chat_id=spec.id, + ) + finally: + await loop.close_mcp() + + def _subagent_task_result(task_id: str) -> dict[str, Any] | None: + payload = app.state.subagent_tasks.get(task_id) + if not isinstance(payload, dict): + return None + result = { + "id": task_id, + "status": payload.get("status", "submitted"), + } + error = str(payload.get("error") or "").strip() + summary = str(payload.get("summary") or "").strip() + if summary: + result["summary"] = summary + if error: + result["summary"] = error + metadata = payload.get("metadata") + if isinstance(metadata, dict) and metadata: + result["metadata"] = metadata + return result + + def _cancel_subagent_task(task_id: str) -> dict[str, Any] | None: + payload = app.state.subagent_tasks.get(task_id) + if not isinstance(payload, dict): + return None + task = payload.get("asyncio_task") + if isinstance(task, asyncio.Task) and not task.done(): + task.cancel() + payload["status"] = "cancelled" + payload["error"] = "" + payload.setdefault("summary", "Task cancelled") + return _subagent_task_result(task_id) + + def _start_subagent_task(agent_id: str, task: str) -> dict[str, Any]: + task_id = str(uuid.uuid4()) + app.state.subagent_tasks[task_id] = { + "agent_id": agent_id, + "task": task, + "status": "submitted", + } + + async def _runner() -> None: + app.state.subagent_tasks[task_id]["status"] = "working" + try: + summary = await _run_subagent_task(agent_id, task) + app.state.subagent_tasks[task_id]["status"] = "completed" + app.state.subagent_tasks[task_id]["summary"] = summary + except asyncio.CancelledError: + app.state.subagent_tasks[task_id]["status"] = "cancelled" + app.state.subagent_tasks[task_id].setdefault("summary", "Task cancelled") + raise + except Exception as exc: # noqa: BLE001 + app.state.subagent_tasks[task_id]["status"] = "error" + app.state.subagent_tasks[task_id]["error"] = str(exc) + + app.state.subagent_tasks[task_id]["asyncio_task"] = asyncio.create_task(_runner()) + return _subagent_task_result(task_id) or {"id": task_id, "status": "submitted"} + + def _serialize_subagent(spec: Any, config: Config) -> dict[str, Any]: + from nanobot.agent.subagents import LocalSubagentStore + + payload = spec.to_dict() + base_url = LocalSubagentStore(config.workspace_path).local_base_url(config, spec.id) + payload["base_url"] = base_url + payload["endpoint"] = f"{base_url}/rpc" + payload["card_url"] = f"{base_url}/.well-known/agent-card" + return payload + def _require_authenticated_user(authorization: str | None = Header(default=None)) -> str: return _require_web_user(app, authorization) @@ -1125,6 +1293,98 @@ def _register_routes(app: FastAPI) -> None: frontend_netloc = f"{frontend_host}:{frontend_port}" if frontend_port else frontend_host return urlunsplit((api_parts.scheme or "http", frontend_netloc, "", "", "")).rstrip("/") + @app.get("/subagents/{agent_id}/.well-known/agent-card") + @app.get("/subagents/{agent_id}/.well-known/agent-card.json") + @app.get("/subagents/{agent_id}/.well-known/agent.json") + async def get_subagent_card(agent_id: str): + from nanobot.agent.subagents import LocalSubagentStore + + config: Config = app.state.config + store = LocalSubagentStore(config.workspace_path) + spec = store.get_subagent(agent_id) + if spec is None or not spec.enabled: + raise HTTPException(status_code=404, detail="Sub-agent not found") + return LocalSubagentStore.build_agent_card(spec, config) + + @app.post("/subagents/{agent_id}/rpc") + async def subagent_rpc(agent_id: str, payload: dict[str, Any]): + payload_id = payload.get("id") + method = str(payload.get("method") or "").strip() + params = payload.get("params") + if not isinstance(params, dict): + return _jsonrpc_error(payload_id, -32602, "Invalid params") + + if method == "tasks/get": + task_id = str(params.get("id") or "").strip() + if not task_id: + return _jsonrpc_error(payload_id, -32602, "Missing task id") + result = _subagent_task_result(task_id) + if result is None: + return _jsonrpc_error(payload_id, -32602, "Unknown task id") + return { + "jsonrpc": "2.0", + "id": payload_id, + "result": {"task": result}, + } + + if method == "tasks/cancel": + task_id = str(params.get("id") or "").strip() + if not task_id: + return _jsonrpc_error(payload_id, -32602, "Missing task id") + result = _cancel_subagent_task(task_id) + if result is None: + return _jsonrpc_error(payload_id, -32602, "Unknown task id") + return { + "jsonrpc": "2.0", + "id": payload_id, + "result": {"task": result}, + } + + if method == "tasks/send": + try: + task = _extract_subagent_task(params) + except ValueError as exc: + return _jsonrpc_error(payload_id, -32602, str(exc)) + result = _start_subagent_task(agent_id, task) + return { + "jsonrpc": "2.0", + "id": payload_id, + "result": {"task": result}, + } + + if method != "message/send": + return _jsonrpc_error(payload_id, -32601, f"Method '{method}' not found") + + try: + task = _extract_subagent_task(params) + except ValueError as exc: + return _jsonrpc_error(payload_id, -32602, str(exc)) + + try: + response = await _run_subagent_task(agent_id, task) + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + logger.exception("Sub-agent RPC failed for {}", agent_id) + return _jsonrpc_error(payload_id, -32000, str(exc)) + + return { + "jsonrpc": "2.0", + "id": payload_id, + "result": { + "message": { + "role": "agent", + "parts": [ + { + "type": "text", + "kind": "text", + "text": response, + } + ], + } + }, + } + def _local_backend_view(config: Config) -> dict[str, Any]: return { "backend_id": config.backend_identity.backend_id, @@ -2473,6 +2733,55 @@ def _register_routes(app: FastAPI) -> None: }) return result + @app.get("/api/subagents") + async def list_subagents(): + """List persistent local sub-agents.""" + from nanobot.agent.subagents import LocalSubagentStore + + config: Config = app.state.config + store = LocalSubagentStore(config.workspace_path) + return [_serialize_subagent(spec, config) for spec in store.list_subagents()] + + @app.get("/api/subagents/{agent_id}") + async def get_subagent(agent_id: str): + """Get one persistent local sub-agent.""" + from nanobot.agent.subagents import LocalSubagentStore + + config: Config = app.state.config + store = LocalSubagentStore(config.workspace_path) + spec = store.get_subagent(agent_id) + if spec is None: + raise HTTPException(status_code=404, detail="Sub-agent not found") + return _serialize_subagent(spec, config) + + @app.post("/api/subagents") + async def create_subagent(req: SubagentRequest): + """Create or replace a persistent local sub-agent.""" + from nanobot.agent.subagents import LocalSubagentStore + + config: Config = app.state.config + store = LocalSubagentStore(config.workspace_path) + spec = store.upsert_subagent(req.model_dump(), config) + return _serialize_subagent(spec, config) + + @app.put("/api/subagents/{agent_id}") + async def update_subagent(agent_id: str, req: SubagentRequest): + """Update a persistent local sub-agent.""" + if agent_id != req.id: + raise HTTPException(status_code=400, detail="Path id must match body id") + return await create_subagent(req) + + @app.delete("/api/subagents/{agent_id}") + async def delete_subagent(agent_id: str): + """Delete a persistent local sub-agent.""" + from nanobot.agent.subagents import LocalSubagentStore + + config: Config = app.state.config + store = LocalSubagentStore(config.workspace_path) + if store.delete_subagent(agent_id): + return {"ok": True, "id": agent_id} + raise HTTPException(status_code=404, detail="Sub-agent not found") + @app.get("/api/agents") async def list_agents(): """List unified agents from workspace, plugins, skills, and local fallback.""" diff --git a/app-instance/frontend/app/(app)/agents/page.tsx b/app-instance/frontend/app/(app)/agents/page.tsx index e75d83d..15e9a70 100644 --- a/app-instance/frontend/app/(app)/agents/page.tsx +++ b/app-instance/frontend/app/(app)/agents/page.tsx @@ -1,11 +1,30 @@ 'use client'; import React, { useCallback, useEffect, useState } from 'react'; -import { Bot, Plus, RefreshCw, Trash2, Loader2, AlertCircle, Tags, ChevronDown } from 'lucide-react'; +import { + AlertCircle, + Bot, + ChevronDown, + Loader2, + Pencil, + Plus, + RefreshCw, + Tags, + Trash2, +} from 'lucide-react'; -import { addAgent, deleteAgent, listAgents, refreshAgents } from '@/lib/api'; +import { + addAgent, + createSubagent, + deleteAgent, + deleteSubagent, + listAgents, + listSubagents, + refreshAgents, + updateSubagent, +} from '@/lib/api'; import { useChatStore } from '@/lib/store'; -import type { UiAgentDescriptor } from '@/types'; +import type { UiAgentDescriptor, UiSubagentDescriptor } from '@/types'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -13,9 +32,11 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Textarea } from '@/components/ui/textarea'; -const EMPTY_FORM = { +const EMPTY_AGENT_FORM = { id: '', name: '', description: '', @@ -30,17 +51,69 @@ const EMPTY_FORM = { aliases: '', }; +const EMPTY_SUBAGENT_FORM = { + id: '', + name: '', + description: '', + system_prompt: '', + model: '', + delegation_mode: 'remote_a2a_only', + enabled: true, + allow_mcp: true, + tags: '', + aliases: '', + metadata_json: '{}', + mcp_servers_json: '{}', +}; + +function formatJson(value: Record): string { + return JSON.stringify(value, null, 2); +} + +function parseJsonObject(raw: string, label: string): Record { + const probe = raw.trim(); + if (!probe) { + return {}; + } + let parsed: unknown; + try { + parsed = JSON.parse(probe); + } catch { + throw new Error(`${label} 需要是合法 JSON`); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${label} 需要是 JSON 对象`); + } + return parsed as Record; +} + +function parseNestedJsonObject(raw: string, label: string): Record> { + const parsed = parseJsonObject(raw, label); + for (const [key, value] of Object.entries(parsed)) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} 中的 ${key} 必须是 JSON 对象`); + } + } + return parsed as Record>; +} + export default function AgentsPage() { const cachedAgents = useChatStore((s) => s.agentRegistry); const setCachedAgents = useChatStore((s) => s.setAgentRegistry); const [agents, setAgents] = useState(cachedAgents); + const [subagents, setSubagents] = useState([]); const [loading, setLoading] = useState(cachedAgents.length === 0); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); - const [dialogOpen, setDialogOpen] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [advancedOpen, setAdvancedOpen] = useState(false); - const [form, setForm] = useState(EMPTY_FORM); + const [agentDialogOpen, setAgentDialogOpen] = useState(false); + const [subagentDialogOpen, setSubagentDialogOpen] = useState(false); + const [agentSubmitting, setAgentSubmitting] = useState(false); + const [subagentSubmitting, setSubagentSubmitting] = useState(false); + const [agentAdvancedOpen, setAgentAdvancedOpen] = useState(false); + const [subagentAdvancedOpen, setSubagentAdvancedOpen] = useState(false); + const [agentForm, setAgentForm] = useState(EMPTY_AGENT_FORM); + const [subagentForm, setSubagentForm] = useState(EMPTY_SUBAGENT_FORM); + const [editingSubagentId, setEditingSubagentId] = useState(null); const load = useCallback(async (background = false) => { if (background) { @@ -50,9 +123,14 @@ export default function AgentsPage() { } setError(null); try { - const data = await listAgents(); - const nextAgents = Array.isArray(data) ? data : []; + const [agentData, subagentData] = await Promise.all([ + listAgents(), + listSubagents(), + ]); + const nextAgents = Array.isArray(agentData) ? agentData : []; + const nextSubagents = Array.isArray(subagentData) ? subagentData : []; setAgents(nextAgents); + setSubagents(nextSubagents); setCachedAgents(nextAgents); } catch (err: any) { setError(err.message || '加载智能体失败'); @@ -73,9 +151,14 @@ export default function AgentsPage() { setError(null); setRefreshing(true); try { - const data = await refreshAgents(); - const nextAgents = data.agents || []; + const [agentData, subagentData] = await Promise.all([ + refreshAgents(), + listSubagents(), + ]); + const nextAgents = agentData.agents || []; + const nextSubagents = Array.isArray(subagentData) ? subagentData : []; setAgents(nextAgents); + setSubagents(nextSubagents); setCachedAgents(nextAgents); } catch (err: any) { setError(err.message || '刷新智能体失败'); @@ -84,59 +167,133 @@ export default function AgentsPage() { } }; - const handleDialogOpenChange = (open: boolean) => { - setDialogOpen(open); + const handleAgentDialogOpenChange = (open: boolean) => { + setAgentDialogOpen(open); if (!open) { - setAdvancedOpen(false); - setForm(EMPTY_FORM); + setAgentAdvancedOpen(false); + setAgentForm(EMPTY_AGENT_FORM); } }; - const handleCreate = async (e: React.FormEvent) => { + const handleSubagentDialogOpenChange = (open: boolean) => { + setSubagentDialogOpen(open); + if (!open) { + setSubagentAdvancedOpen(false); + setEditingSubagentId(null); + setSubagentForm(EMPTY_SUBAGENT_FORM); + } + }; + + const handleCreateAgent = async (e: React.FormEvent) => { e.preventDefault(); - const hasAddress = [form.base_url, form.endpoint, form.card_url].some((value) => value.trim()); + const hasAddress = [agentForm.base_url, agentForm.endpoint, agentForm.card_url].some((value) => value.trim()); if (!hasAddress) { setError('请至少填写 A2A 部署地址、接口地址或卡片地址'); return; } - setSubmitting(true); + setAgentSubmitting(true); setError(null); try { await addAgent({ - id: form.id || undefined, - name: form.name || undefined, - description: form.description || undefined, + id: agentForm.id || undefined, + name: agentForm.name || undefined, + description: agentForm.description || undefined, protocol: 'a2a', - base_url: form.base_url || undefined, - endpoint: form.endpoint || undefined, - card_url: form.card_url || undefined, - auth_env: form.auth_env || undefined, - auth_mode: form.auth_mode || 'none', - auth_audience: form.auth_mode === 'none' ? undefined : form.auth_audience || undefined, - auth_scopes: form.auth_mode === 'none' + base_url: agentForm.base_url || undefined, + endpoint: agentForm.endpoint || undefined, + card_url: agentForm.card_url || undefined, + auth_env: agentForm.auth_env || undefined, + auth_mode: agentForm.auth_mode || 'none', + auth_audience: agentForm.auth_mode === 'none' ? undefined : agentForm.auth_audience || undefined, + auth_scopes: agentForm.auth_mode === 'none' ? [] - : form.auth_scopes.split(',').map((item) => item.trim()).filter(Boolean), - tags: form.tags.split(',').map((item) => item.trim()).filter(Boolean), - aliases: form.aliases.split(',').map((item) => item.trim()).filter(Boolean), + : agentForm.auth_scopes.split(',').map((item) => item.trim()).filter(Boolean), + tags: agentForm.tags.split(',').map((item) => item.trim()).filter(Boolean), + aliases: agentForm.aliases.split(',').map((item) => item.trim()).filter(Boolean), }); - handleDialogOpenChange(false); - await load(); + handleAgentDialogOpenChange(false); + await load(true); } catch (err: any) { setError(err.message || '新增智能体失败'); } finally { - setSubmitting(false); + setAgentSubmitting(false); } }; - const handleDelete = async (agentId: string) => { + const handleDeleteAgent = async (agentId: string) => { try { await deleteAgent(agentId); - await load(); + await load(true); } catch (err: any) { setError(err.message || '删除智能体失败'); } }; + const handleEditSubagent = (subagent: UiSubagentDescriptor) => { + setEditingSubagentId(subagent.id); + setSubagentForm({ + id: subagent.id, + name: subagent.name, + description: subagent.description, + system_prompt: subagent.system_prompt || '', + model: subagent.model || '', + delegation_mode: subagent.delegation_mode || 'remote_a2a_only', + enabled: subagent.enabled, + allow_mcp: subagent.allow_mcp, + tags: (subagent.tags || []).join(', '), + aliases: (subagent.aliases || []).join(', '), + metadata_json: formatJson(subagent.metadata || {}), + mcp_servers_json: formatJson(subagent.mcp_servers || {}), + }); + setSubagentDialogOpen(true); + }; + + const handleSaveSubagent = async (e: React.FormEvent) => { + e.preventDefault(); + if (!subagentForm.id.trim()) { + setError('Sub-agent ID 不能为空'); + return; + } + setSubagentSubmitting(true); + setError(null); + try { + const payload = { + id: subagentForm.id.trim(), + name: subagentForm.name.trim() || subagentForm.id.trim(), + description: subagentForm.description.trim() || subagentForm.name.trim() || subagentForm.id.trim(), + system_prompt: subagentForm.system_prompt, + model: subagentForm.model.trim() || undefined, + enabled: subagentForm.enabled, + delegation_mode: subagentForm.delegation_mode, + allow_mcp: subagentForm.allow_mcp, + tags: subagentForm.tags.split(',').map((item) => item.trim()).filter(Boolean), + aliases: subagentForm.aliases.split(',').map((item) => item.trim()).filter(Boolean), + metadata: parseJsonObject(subagentForm.metadata_json, 'Metadata'), + mcp_servers: parseNestedJsonObject(subagentForm.mcp_servers_json, 'MCP Servers'), + }; + if (editingSubagentId) { + await updateSubagent(editingSubagentId, payload); + } else { + await createSubagent(payload); + } + handleSubagentDialogOpenChange(false); + await load(true); + } catch (err: any) { + setError(err.message || '保存 Sub-Agent 失败'); + } finally { + setSubagentSubmitting(false); + } + }; + + const handleDeleteManagedSubagent = async (subagentId: string) => { + try { + await deleteSubagent(subagentId); + await load(true); + } catch (err: any) { + setError(err.message || '删除 Sub-Agent 失败'); + } + }; + if (loading) { return (
@@ -154,17 +311,17 @@ export default function AgentsPage() { 智能体

- 管理工作区智能体,并查看来自插件、技能和内置能力的可委派目标。 + 管理外部 A2A 智能体,以及持久化的本地 Sub-Agent。

-
+
- + - @@ -173,53 +330,51 @@ export default function AgentsPage() { 新增工作区智能体 -
+
setForm((s) => ({ ...s, base_url: e.target.value }))} + value={agentForm.base_url} + onChange={(e) => setAgentForm((s) => ({ ...s, base_url: e.target.value }))} placeholder="https://agent.example.com 或 agent.example.com:19090" />

默认只需要填写部署地址。保存时会自动读取 - /.well-known/agent-card - 、/.well-known/agent-card.json - 和/.well-known/agent.json - ,并补齐 ID、名称、描述、接口地址等信息。 + /.well-known + 路径并补齐 card 信息。

- +
- setForm((s) => ({ ...s, id: e.target.value }))} placeholder="留空则从 A2A card 自动生成" /> + setAgentForm((s) => ({ ...s, id: e.target.value }))} />
- setForm((s) => ({ ...s, name: e.target.value }))} placeholder="留空则从 A2A card 自动填充" /> + setAgentForm((s) => ({ ...s, name: e.target.value }))} />
-