diff --git a/app-instance/backend-old/.dockerignore b/app-instance/backend-old/.dockerignore
deleted file mode 100644
index 020b9ec..0000000
--- a/app-instance/backend-old/.dockerignore
+++ /dev/null
@@ -1,13 +0,0 @@
-__pycache__
-*.pyc
-*.pyo
-*.pyd
-*.egg-info
-dist/
-build/
-.git
-.env
-.assets
-node_modules/
-bridge/dist/
-workspace/
diff --git a/app-instance/backend-old/.gitignore b/app-instance/backend-old/.gitignore
deleted file mode 100644
index f2280ee..0000000
--- a/app-instance/backend-old/.gitignore
+++ /dev/null
@@ -1,201 +0,0 @@
-<<<<<<< HEAD
-.assets
-.env
-*.pyc
-dist/
-build/
-docs/
-*.egg-info/
-*.egg
-*.pyc
-*.pyo
-*.pyd
-*.pyw
-*.pyz
-*.pywz
-*.pyzz
-.venv/
-venv/
-__pycache__/
-poetry.lock
-.pytest_cache/
-botpy.log
-tests/
-=======
-# ---> Python
-# Byte-compiled / optimized / DLL files
-__pycache__/
-*.py[cod]
-*$py.class
-
-# C extensions
-*.so
-
-# Distribution / packaging
-.Python
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-share/python-wheels/
-*.egg-info/
-.installed.cfg
-*.egg
-MANIFEST
-
-# PyInstaller
-# Usually these files are written by a python script from a template
-# before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
-
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.nox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-*.py,cover
-.hypothesis/
-.pytest_cache/
-cover/
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-local_settings.py
-db.sqlite3
-db.sqlite3-journal
-
-# Flask stuff:
-instance/
-.webassets-cache
-
-# Scrapy stuff:
-.scrapy
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-.pybuilder/
-target/
-
-# Jupyter Notebook
-.ipynb_checkpoints
-
-# IPython
-profile_default/
-ipython_config.py
-
-# pyenv
-# For a library or package, you might want to ignore these files since the code is
-# intended to run in multiple environments; otherwise, check them in:
-# .python-version
-
-# pipenv
-# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
-# However, in case of collaboration, if having platform-specific dependencies or dependencies
-# having no cross-platform support, pipenv may install dependencies that don't work, or not
-# install all needed dependencies.
-#Pipfile.lock
-
-# UV
-# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
-# This is especially recommended for binary packages to ensure reproducibility, and is more
-# commonly ignored for libraries.
-#uv.lock
-
-# poetry
-# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
-# This is especially recommended for binary packages to ensure reproducibility, and is more
-# commonly ignored for libraries.
-# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
-#poetry.lock
-
-# pdm
-# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
-#pdm.lock
-# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
-# in version control.
-# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
-.pdm.toml
-.pdm-python
-.pdm-build/
-
-# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
-__pypackages__/
-
-# Celery stuff
-celerybeat-schedule
-celerybeat.pid
-
-# SageMath parsed files
-*.sage.py
-
-# Environments
-.env
-.venv
-env/
-venv/
-ENV/
-env.bak/
-venv.bak/
-
-# Spyder project settings
-.spyderproject
-.spyproject
-
-# Rope project settings
-.ropeproject
-
-# mkdocs documentation
-/site
-
-# mypy
-.mypy_cache/
-.dmypy.json
-dmypy.json
-
-# Pyre type checker
-.pyre/
-
-# pytype static type analyzer
-.pytype/
-
-# Cython debug symbols
-cython_debug/
-
-# PyCharm
-# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
-# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
-# and can be added to the global gitignore or merged into this file. For a more nuclear
-# option (not recommended) you can uncomment the following to ignore the entire idea folder.
-#.idea/
-
-# Ruff stuff:
-.ruff_cache/
-
-# PyPI configuration file
-.pypirc
-
->>>>>>> origin/main
diff --git a/app-instance/backend-old/A2A_Multiagent_change.md b/app-instance/backend-old/A2A_Multiagent_change.md
deleted file mode 100644
index 57a0c5e..0000000
--- a/app-instance/backend-old/A2A_Multiagent_change.md
+++ /dev/null
@@ -1,753 +0,0 @@
-# A2A Multi-Agent 改造方案
-
-## 1. 需求目标
-
-当前 `spawn`/`sub-agent` 只有一种执行方式: 创建一个本地后台 subagent 去完成任务。
-
-这次需求要改成:
-
-1. 调用 `sub-agent` 时,不一定新建本地 subagent。
-2. 先从“已添加的 Agent”里找可用目标。
-3. 再从 skills 中声明的 `agent cards` 里找可用目标。
-4. 通过 A2A 协议把任务发给对应 agent。
-5. 支持一个任务发给多个 agent,形成 `agent group`,最后回到主 agent 汇总。
-6. 保持现有 `spawn(task, label)` 兼容,不破坏已有行为。
-
-结论先说:
-
-- 最合适的做法不是继续把能力堆进 `SubagentManager`。
-- 应该把“本地 subagent 执行”升级为“统一委派层”。
-- `spawn` 工具继续保留,但语义从“创建 subagent”扩展为“委派给合适的 agent / agent group”。
-
-## 2. 当前代码现状
-
-### 2.1 当前触发链路
-
-现有链路很单一:
-
-1. `AgentLoop` 初始化 `SubagentManager`
- - 位置: `nanobot/agent/loop.py:88-114`
-2. `AgentLoop._register_default_tools()` 注册 `SpawnTool`
- - 位置: `nanobot/agent/loop.py:116-138`
-3. LLM 调用 `spawn(task, label)`
-4. `SpawnTool.execute()` 直接转发给 `SubagentManager.spawn()`
- - 位置: `nanobot/agent/tools/spawn.py:67-76`
-5. `SubagentManager.spawn()` 创建本地 asyncio 后台任务
- - 位置: `nanobot/agent/subagent.py:64-93`
-6. `_run_subagent()` 用一个受限工具集运行本地子代理
- - 位置: `nanobot/agent/subagent.py:95-195`
-7. `_announce_result()` 把结果包装成 `channel="system"` 的消息回投主消息总线
- - 位置: `nanobot/agent/subagent.py:197-230`
-8. `AgentLoop._process_message()` 接到 `system` 消息,再整理成用户可见回复
- - 位置: `nanobot/agent/loop.py:331-347`
-
-### 2.2 当前已经有但没接入调度链路的能力
-
-仓库里已经有两类“候选 agent 信息”,但没有进入实际调度:
-
-1. Plugin agents
- - `PluginLoader.find_agent()` 已能找 agent
- - 位置: `nanobot/agent/plugins.py:83-91`
- - `build_agents_summary()` 也已能汇总 agent 信息
- - 位置: `nanobot/agent/plugins.py:100-121`
- - 但当前 `AgentLoop` / `ContextBuilder` 并没有用它做调度
-
-2. Skills
- - `SkillsLoader` 已能枚举 / 读取 skill
- - 位置: `nanobot/agent/skills.py:32-249`
- - 但 skill 目前只被当作 prompt 资源,不会暴露成“可路由 agent”
-
-### 2.3 当前缺口
-
-当前缺少这几层:
-
-1. 统一的 `Agent Registry`
-2. A2A `agent card` 发现与缓存
-3. A2A client 调用层
-4. 统一的委派器,负责在“本地 subagent / plugin agent / skill agent card / agent group”之间做路由
-5. group 级别的状态管理和结果聚合
-
-## 3. 推荐总方案
-
-推荐采用“保留 `spawn` 工具名,重构内部执行层”的方案。
-
-### 3.1 核心思路
-
-把当前:
-
-- `SpawnTool -> SubagentManager -> 本地 subagent`
-
-改成:
-
-- `SpawnTool -> DelegationManager -> AgentResolver -> Executor(local/plugin/a2a/group)`
-
-也就是:
-
-1. `spawn` 不再等价于“必须创建 subagent”。
-2. `spawn` 变成“委派任务”。
-3. 真正执行方式由委派层动态决定。
-
-### 3.2 为什么这样最合适
-
-如果直接继续扩 `SubagentManager`,很快会出现这些问题:
-
-1. 一个类同时负责本地 LLM 运行、A2A 网络调用、agent card 发现、group 并发、结果聚合。
-2. 后续要支持 plugin agent、本地 named agent、A2A streaming 时会越来越乱。
-3. 当前 `SubagentManager` 的职责本来就已经比较明确: “本地后台 subagent 执行器”。
-
-所以更合理的拆法是:
-
-1. `SubagentManager` 保留或下沉为 `LocalSubagentExecutor`
-2. 新增 `DelegationManager` 作为统一入口
-3. 新增 `AgentRegistry` / `AgentResolver`
-4. 新增 `A2AClient`
-
-## 4. 推荐模块拆分
-
-### 4.1 新增 `DelegationManager`
-
-建议新文件:
-
-- `nanobot/agent/delegation.py`
-
-职责:
-
-1. 接收 `spawn` 请求
-2. 根据参数和任务内容选择目标 agent
-3. 决定执行方式
-4. 对 group 做并发调度
-5. 统一把结果回投主消息总线
-
-建议接口:
-
-```python
-class DelegationManager:
- async def dispatch(
- self,
- task: str,
- label: str | None = None,
- target: str | None = None,
- targets: list[str] | None = None,
- strategy: str = "auto",
- origin_channel: str = "cli",
- origin_chat_id: str = "direct",
- ) -> str: ...
-```
-
-### 4.2 保留本地执行器
-
-当前 `nanobot/agent/subagent.py` 的 `_run_subagent()` 逻辑可以保留,但角色改为:
-
-- `LocalSubagentExecutor`
-
-也可以第一版不重命名文件,只把里面逻辑拆成:
-
-1. `spawn_local()`
-2. `_run_local_subagent()`
-3. `_announce_local_result()`
-
-这样可以最小改动落地。
-
-### 4.3 新增 `AgentRegistry`
-
-建议新文件:
-
-- `nanobot/agent/agent_registry.py`
-
-职责:
-
-1. 汇总所有可调度 agent
-2. 统一输出规范化 descriptor
-3. 维护优先级和去重逻辑
-
-统一后的 agent 来源:
-
-1. workspace 中“已添加的 agent”
-2. plugin agents
-3. skill frontmatter 里声明的 `agent_cards`
-4. 必要时 fallback 到本地 `local-subagent`
-
-建议统一 descriptor:
-
-```python
-@dataclass
-class AgentDescriptor:
- id: str
- name: str
- description: str
- source: str # workspace | plugin | skill | builtin
- kind: str # local_prompt | a2a_remote | local_fallback
- protocol: str | None # a2a | None
- plugin_name: str | None = None
- skill_name: str | None = None
- model: str | None = None
- endpoint: str | None = None
- card_url: str | None = None
- tags: list[str] = field(default_factory=list)
- capabilities: dict[str, Any] = field(default_factory=dict)
- metadata: dict[str, Any] = field(default_factory=dict)
-```
-
-### 4.4 新增 A2A client 层
-
-建议新目录:
-
-- `nanobot/a2a/client.py`
-- `nanobot/a2a/cards.py`
-- `nanobot/a2a/models.py`
-
-职责:
-
-1. 获取 agent card
-2. 解析 card 能力
-3. 对远端 agent 发 JSON-RPC 请求
-4. 处理同步返回 / task 轮询 / streaming 兼容
-
-## 5. 代码插入点
-
-## 5.1 `nanobot/agent/loop.py`
-
-### 插入点 A: `__init__`
-
-当前:
-
-- `self.subagents = SubagentManager(...)`
-- 位置: `nanobot/agent/loop.py:88-102`
-
-建议改成:
-
-1. 初始化 `PluginLoader`
-2. 初始化 `AgentRegistry`
-3. 初始化 `DelegationManager`
-4. `DelegationManager` 内部持有 `LocalSubagentExecutor` / `A2AExecutor`
-
-推荐形态:
-
-```python
-self.plugins = PluginLoader(workspace)
-self.agent_registry = AgentRegistry(workspace, plugins=self.plugins, ...)
-self.delegation = DelegationManager(
- provider=provider,
- workspace=workspace,
- bus=bus,
- registry=self.agent_registry,
- ...
-)
-```
-
-### 插入点 B: `_register_default_tools`
-
-当前:
-
-- 注册 `SpawnTool(manager=self.subagents)`
-- 位置: `nanobot/agent/loop.py:130-134`
-
-建议改成:
-
-```python
-self.tools.register(SpawnTool(manager=self.delegation))
-```
-
-### 插入点 C: `_set_tool_context`
-
-当前会给 `spawn` 工具写 origin context:
-
-- 位置: `nanobot/agent/loop.py:165-192`
-
-这里逻辑可以继续保留,不需要大改,因为 A2A / group 结果最终也要回到原会话。
-
-## 5.2 `nanobot/agent/tools/spawn.py`
-
-当前 `SpawnTool` 参数只有:
-
-- `task`
-- `label`
-
-位置:
-
-- schema: `nanobot/agent/tools/spawn.py:49-65`
-- execute: `nanobot/agent/tools/spawn.py:67-76`
-
-建议扩成:
-
-```python
-{
- "task": "string",
- "label": "string?",
- "target": "string?",
- "targets": "string[]?",
- "strategy": "auto|local|plugin|a2a|group"
-}
-```
-
-兼容规则:
-
-1. 老调用只传 `task/label` 时,等价于 `strategy="auto"`
-2. `target` 表示单目标
-3. `targets` 表示 group
-4. `strategy="local"` 强制走本地 subagent
-5. `strategy="a2a"` 强制只找 A2A 目标
-
-## 5.3 `nanobot/agent/context.py`
-
-当前 prompt 中只注入:
-
-1. bootstrap
-2. memory
-3. skills summary
-
-位置:
-
-- `build_system_prompt()`: `nanobot/agent/context.py:38-76`
-
-建议新增一段:
-
-- `# Available Agents`
-
-由 `AgentRegistry.build_agents_summary()` 生成,内容只放:
-
-1. agent id / name
-2. 简短 description
-3. source
-4. protocol
-5. 是否支持 group / streaming
-
-目标是让主 agent 知道:
-
-1. 当前有哪些现成 agent 可用
-2. 什么时候应该 `spawn(target=...)`
-3. 哪些是 skill 暴露出来的 A2A agent
-
-## 5.4 `nanobot/agent/skills.py`
-
-这是 skill agent cards 的关键入口。
-
-当前 skill frontmatter 已支持 `metadata` 字段,并会解析其中的 JSON:
-
-- `_parse_nanobot_metadata()`: `nanobot/agent/skills.py:190-196`
-- `_get_skill_meta()`: `nanobot/agent/skills.py:209-212`
-
-最推荐的做法不是去扫 `SKILL.md` 正文里的自由文本,而是约定 skill frontmatter 的 `metadata.nanobot.agent_cards`。
-
-建议新增:
-
-```python
-def list_skill_agent_cards(self) -> list[dict[str, Any]]: ...
-```
-
-推荐 skill 写法:
-
-```md
----
-name: github-research
-description: GitHub research helper
-metadata: '{"nanobot":{"agent_cards":[{"id":"repo-analyst","url":"https://example.com/.well-known/agent-card","tags":["github","research"],"auth_env":"REPO_AGENT_TOKEN"}]}}'
----
-```
-
-为什么推荐这样做:
-
-1. 当前 frontmatter 解析已经存在
-2. 不需要引入新的 skill 文件格式
-3. 不需要解析自由文本
-4. skill 打包/上传链路也不需要大改
-
-## 5.5 `nanobot/agent/plugins.py`
-
-当前 plugin agents 已能加载:
-
-- `find_agent()`: `nanobot/agent/plugins.py:83-91`
-- `_load_agents()`: `nanobot/agent/plugins.py:210-229`
-
-建议:
-
-1. `AgentRegistry` 直接复用 `PluginLoader`
-2. plugin agent 作为“本地可执行 agent”来源之一
-
-这里不建议把 plugin agent 强行转成 A2A。
-
-更合理的处理是:
-
-1. plugin agent 本地执行
-2. skill agent cards 远程 A2A 调用
-3. workspace 手动添加的 agent 也可走 A2A
-
-## 5.6 `nanobot/config/schema.py`
-
-当前 `ToolsConfig` 只有:
-
-- `web`
-- `exec`
-- `restrict_to_workspace`
-- `mcp_servers`
-
-位置:
-
-- `nanobot/config/schema.py:337-347`
-
-建议新增:
-
-```python
-class A2AConfig(Base):
- enabled: bool = True
- timeout_seconds: int = 30
- poll_interval_seconds: int = 2
- card_cache_ttl_seconds: int = 300
- max_parallel_agents: int = 4
- allow_skill_cards: bool = True
- allow_workspace_agents: bool = True
- allowed_hosts: list[str] = Field(default_factory=list)
-```
-
-然后挂到:
-
-```python
-class ToolsConfig(Base):
- ...
- a2a: A2AConfig = Field(default_factory=A2AConfig)
-```
-
-## 5.7 `nanobot/web/server.py`
-
-当前 web API 有:
-
-- `/api/skills`
-- `/api/plugins`
-
-位置:
-
-- skills: `nanobot/web/server.py:702-843`
-- plugins: `nanobot/web/server.py:1000-1037`
-
-建议新增:
-
-1. `GET /api/agents`
- - 返回统一后的 agent registry
-2. `POST /api/agents`
- - 添加 workspace agent card
-3. `DELETE /api/agents/{id}`
- - 删除 workspace agent
-4. `POST /api/agents/refresh`
- - 刷新 card cache
-
-这样“已添加的 Agent”才有明确的持久化来源。
-
-## 6. 推荐的数据来源优先级
-
-为了行为稳定,推荐 resolver 按以下优先级匹配:
-
-1. workspace 手动添加的 agent
-2. plugin agents
-3. skill metadata 里的 agent cards
-4. fallback 到本地 subagent
-
-原因:
-
-1. workspace 手动添加通常是用户明确希望接入的 agent
-2. plugin agent 是本地稳定能力
-3. skill card 往往是外部资源,可信度和可用性最弱
-4. 本地 subagent 最后兜底,保证老行为不失效
-
-## 7. A2A 协议接入建议
-
-## 7.1 Agent Card 发现
-
-建议支持 3 种入口:
-
-1. 显式 `card_url`
-2. `base_url + /.well-known/agent-card`
-3. fallback `base_url + /.well-known/agent.json`
-
-这样做的原因是:
-
-1. 当前 A2A 文档和样例在 card 路径上存在新旧写法并存
-2. 兼容性会更好
-
-## 7.2 RPC 调用兼容层
-
-建议客户端优先尝试:
-
-1. `tasks/send`
-2. 不支持时 fallback `message/send`
-
-后续可选支持:
-
-1. `tasks/sendSubscribe`
-2. `message/sendStream`
-3. `tasks/get`
-4. `tasks/cancel`
-
-推荐第一期先做:
-
-1. 非流式发任务
-2. 如果返回 `Task` 状态不是最终态,就轮询 `tasks/get`
-
-这样能最小代价先打通。
-
-## 7.3 发送给远端 agent 的上下文范围
-
-不要把主会话完整 history 直接发给远端 agent。
-
-建议第一版只发送:
-
-1. 任务目标
-2. 必要的结构化说明
-3. 主 agent 整理好的最小上下文
-
-原因:
-
-1. 当前本地 subagent 也不共享主会话历史
-2. 外部 A2A agent 不可信时,最小化数据泄漏面
-3. 避免 token 膨胀
-
-## 8. agent group 设计
-
-## 8.1 什么时候触发 group
-
-建议第一版只支持两种触发:
-
-1. 用户明确指定多个 agent
-2. LLM 在工具调用里显式传 `targets=[...]`
-
-不建议第一版做“自动拆成多个 agent 并行”的强自动化。
-
-原因:
-
-1. 容易失控
-2. 很难解释为什么调了这些 agent
-3. 对成本和网络调用不可控
-
-## 8.2 group 执行链路
-
-推荐链路:
-
-1. `SpawnTool.execute()` 收到 `targets`
-2. `DelegationManager.dispatch()` 创建 `group_run_id`
-3. `AgentRegistry` 解析出每个 target 的 descriptor
-4. 按 executor 类型并发执行
-5. `asyncio.gather(..., return_exceptions=True)` 收集结果
-6. 统一做 group aggregation
-7. `_announce_group_result()` 回投主消息总线
-8. 主 agent 再生成最终用户回复
-
-## 8.3 group 结果聚合
-
-建议 group 执行器输出结构化结果:
-
-```python
-@dataclass
-class AgentRunResult:
- agent_id: str
- status: str # ok | error | timeout | cancelled
- summary: str
- raw: dict[str, Any] | None = None
-```
-
-group 最终回投内容建议类似:
-
-```text
-[Agent group 'repo-check' completed]
-
-Members:
-- researcher: ok
-- reviewer: ok
-- planner: error
-
-Results:
-...
-
-Summarize this naturally for the user. Mention disagreements if any.
-```
-
-这样能继续复用当前 `system -> main agent -> user` 的输出模式。
-
-## 9. 推荐触发方式
-
-## 9.1 用户显式触发
-
-用户说法示例:
-
-1. “把这个任务交给 `github-reviewer`”
-2. “让 `researcher` 和 `reviewer` 一起处理”
-3. “如果有现成 agent 就不要新建 subagent”
-
-这时主 agent 应调用:
-
-```json
-{
- "task": "...",
- "target": "github-reviewer"
-}
-```
-
-或者:
-
-```json
-{
- "task": "...",
- "targets": ["researcher", "reviewer"],
- "strategy": "group"
-}
-```
-
-## 9.2 模型自主触发
-
-当主 agent 判断:
-
-1. 任务独立可并行
-2. 已有 agent 专长明显更匹配
-3. 任务耗时长,适合后台执行
-
-则调用 `spawn`,但不再默认认为一定是“新建本地 subagent”。
-
-## 9.3 自动回退
-
-如果没有找到匹配 agent:
-
-1. `strategy=auto` -> fallback 本地 subagent
-2. `strategy=a2a` -> 直接返回未找到
-3. `strategy=group` 且部分目标不存在 -> 明确报错或只跑已解析目标,建议第一版严格报错
-
-## 10. workspace 中“已添加 agent”的建议存储
-
-建议新增:
-
-- `workspace/agents/registry.json`
-
-示例:
-
-```json
-[
- {
- "id": "github-reviewer",
- "name": "GitHub Reviewer",
- "description": "Review GitHub repository changes",
- "protocol": "a2a",
- "base_url": "https://reviewer.example.com/a2a",
- "card_url": "https://reviewer.example.com/.well-known/agent-card",
- "auth_env": "GITHUB_REVIEWER_TOKEN",
- "enabled": true,
- "tags": ["github", "review"]
- }
-]
-```
-
-为什么不用直接塞进 `config.json`:
-
-1. 这是 workspace 维度资源,不是全局运行参数
-2. web API 做增删改查更方便
-3. 不要求用户每次改 agent 都改配置再重启
-
-## 11. 推荐实施顺序
-
-### Phase 1: 打通单 agent 路由
-
-目标:
-
-1. 引入 `AgentRegistry`
-2. `spawn` 支持 `target`
-3. 支持 workspace agent 和 skill agent card
-4. 支持 A2A 单点调用
-5. 找不到时 fallback 本地 subagent
-
-### Phase 2: 接入 plugin agent 本地执行
-
-目标:
-
-1. plugin agent 进入统一 registry
-2. plugin agent 可作为 `target`
-3. 本地 prompt-based agent 与 A2A remote agent 共存
-
-### Phase 3: group 并发和聚合
-
-目标:
-
-1. `targets=[...]`
-2. 并发执行
-3. group 级状态跟踪
-4. 聚合后回投主 agent
-
-### Phase 4: web 管理接口
-
-目标:
-
-1. `/api/agents`
-2. 添加 / 删除 / 刷新 agent
-3. 前端展示 unified registry
-
-## 12. 兼容性要求
-
-这次改造一定要保留以下兼容性:
-
-1. 旧的 `spawn(task, label)` 调用仍然可用
-2. 没有 A2A agent 时,行为和现在一致
-3. skill 没写 `agent_cards` 时,skill 仍只是普通 skill
-4. plugin agent 不参与调度时,现有 plugin 机制不受影响
-
-## 13. 风险点
-
-### 13.1 A2A 规范新旧写法并存
-
-从当前公开文档和样例看,存在这些并行写法:
-
-1. card 路径: `/.well-known/agent-card` 和 `/.well-known/agent.json`
-2. RPC 方法: `tasks/send` 和 `message/send`
-
-所以客户端必须做兼容适配,不能写死一种。
-
-### 13.2 外部 agent 的安全边界
-
-需要限制:
-
-1. 白名单 host
-2. 超时
-3. card cache TTL
-4. skill card 是否允许自动启用
-
-### 13.3 远端 agent 无法直接访问本地 workspace
-
-这意味着:
-
-1. 不能把“去读本地文件然后处理”原样发给远端 A2A agent
-2. 主 agent 需要先整理出必要上下文
-3. 第一版最好只做文本级委派
-
-## 14. 我建议的落地结论
-
-如果要控制改动面,又要保证后续可扩展,推荐最终采用下面这个结构:
-
-```text
-AgentLoop
- -> SpawnTool
- -> DelegationManager
- -> AgentRegistry / AgentResolver
- -> LocalSubagentExecutor
- -> PluginAgentExecutor
- -> A2AExecutor
- -> AgentGroupExecutor
- -> announce_result() -> MessageBus(system) -> AgentLoop -> user
-```
-
-也就是说:
-
-1. `spawn` 工具保留
-2. `SubagentManager` 不再是唯一执行器
-3. `DelegationManager` 成为真正总入口
-4. skills 里的 `agent_cards` 用 frontmatter metadata 承载
-5. workspace agent 单独持久化
-6. group 通过并发 executor + 汇总消息实现
-
-这是当前仓库里最稳妥、最符合现有架构的改法。
-
-## 15. 外部参考
-
-以下是我写这个方案时核对的 A2A 资料:
-
-1. A2A Protocol Development Guide: https://a2aprotocol.ai/docs/guide/a2a-typescript-guide.html
-2. Python A2A Tutorial: https://a2aprotocol.ai/docs/guide/python-a2a-tutorial-20250513
-
-注意:
-
-1. 当前公开文档里既能看到 `tasks/send`,也能看到 `message/send`
-2. agent card 路径也能看到 `agent-card` 与 `agent.json` 两种写法
-3. 所以实现时建议做兼容层,不要只押一种命名
diff --git a/app-instance/backend-old/COMMUNICATION.md b/app-instance/backend-old/COMMUNICATION.md
deleted file mode 100644
index 84c25f5..0000000
--- a/app-instance/backend-old/COMMUNICATION.md
+++ /dev/null
@@ -1,5 +0,0 @@
-We provide QR codes for joining the HKUDS discussion groups on **WeChat** and **Feishu**.
-
-You can join by scanning the QR codes below:
-
-
\ No newline at end of file
diff --git a/app-instance/backend-old/Dockerfile b/app-instance/backend-old/Dockerfile
deleted file mode 100644
index 5102b4f..0000000
--- a/app-instance/backend-old/Dockerfile
+++ /dev/null
@@ -1,43 +0,0 @@
-FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
-
-# Install Node.js 20 for the WhatsApp bridge
-RUN apt-get update && \
- apt-get install -y --no-install-recommends curl ca-certificates gnupg git && \
- mkdir -p /etc/apt/keyrings && \
- curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
- echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list && \
- apt-get update && \
- apt-get install -y --no-install-recommends nodejs && \
- apt-get purge -y gnupg && \
- apt-get autoremove -y && \
- rm -rf /var/lib/apt/lists/*
-
-WORKDIR /app
-
-# Install Python dependencies first (cached layer)
-COPY pyproject.toml README.md LICENSE ./
-RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
- uv pip install --system --no-cache . && \
- rm -rf nanobot bridge
-
-# Copy the full source and install
-COPY nanobot/ nanobot/
-COPY bridge/ bridge/
-COPY third_party/swarms/ third_party/swarms/
-RUN uv pip install --system --no-cache .
-
-# Build the WhatsApp bridge
-WORKDIR /app/bridge
-RUN git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" && \
- git config --global url."https://github.com/".insteadOf "git@github.com:" && \
- npm install && npm run build
-WORKDIR /app
-
-# Create config directory
-RUN mkdir -p /root/.nanobot
-
-# Gateway default port
-EXPOSE 18790
-
-ENTRYPOINT ["nanobot"]
-CMD ["status"]
diff --git a/app-instance/backend-old/LICENSE b/app-instance/backend-old/LICENSE
deleted file mode 100644
index 24bdacc..0000000
--- a/app-instance/backend-old/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2025 nanobot contributors
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
\ No newline at end of file
diff --git a/app-instance/backend-old/README.md b/app-instance/backend-old/README.md
deleted file mode 100644
index c161703..0000000
--- a/app-instance/backend-old/README.md
+++ /dev/null
@@ -1,470 +0,0 @@
-# Boardware Genius Backend
-
-这是 `Boardware Genius` 的后端服务仓库;当前技术命令和包名仍沿用 `nanobot`,但产品品牌按 `Boardware Genius` 表述:
-
-- `nanobot web`:单用户 FastAPI 后端,供独立前端或 `/docs` 调试使用
-- `nanobot gateway`:常驻 worker,负责渠道接入、cron、heartbeat
-- MCP 动态工具接入
-- Outlook 集成:通过外部 `BW_Outlook_Mcp` 服务接入 Microsoft Graph / Exchange EWS
-- 工作区文件、技能、插件、代理、MCP 管理等 Web API
-
-如果你后续要把它打包成 Docker 丢到服务器,这份 README 就是给开发和部署同事看的执行文档。
-
-## 这套仓库现在是什么
-
-这不是一个自带前端静态页面的全栈仓库,而是后端仓库:
-
-- Web 模式启动的是 FastAPI API 服务
-- Gateway 模式启动的是常驻 agent / channel / cron 进程
-- WhatsApp 相关逻辑依赖 `bridge/` 里的 Node 20 bridge
-- Outlook 不是仓库内置模块,而是通过外部 `BW_Outlook_Mcp` 仓库接进来
-
-更细的执行链路可以看 [workflow.md](./workflow.md)。
-
-## 目录结构
-
-```text
-.
-├── nanobot/ # Python 主体:CLI、agent、web、channels、config、MCP
-├── bridge/ # WhatsApp bridge(Node 20)
-├── tests/ # 测试
-├── Dockerfile # 当前镜像构建文件
-├── docker-compose.yml # 当前自带 compose 示例(偏 gateway / CLI)
-└── workflow.md # 运行链路说明
-```
-
-## 运行模式
-
-| 命令 | 用途 | 默认端口 | 适合谁 |
-| --- | --- | --- | --- |
-| `nanobot agent` | 本地单轮 / 交互调试 | 无 | 开发排查 |
-| `nanobot web` | 启动 FastAPI 后端 | `18080` | 独立前端、接口调试、单用户使用 |
-| `nanobot gateway` | 启动常驻 worker | 无固定 HTTP 入口 | Telegram/Slack/Email/cron/heartbeat |
-| `nanobot status` | 查看配置和 provider 状态 | 无 | 开发、运维 |
-
-注意:
-
-- 如果你是给 Web 前端提供后端,请启动 `nanobot web`,不要误用 `gateway`
-- `gateway` 当前不是对外 Web API 服务
-- `web` 和 `gateway` 都会碰到同一份 workspace / cron / MCP 状态,通常不要在同一份数据目录上无脑同时跑两套
-
-## 环境要求
-
-- Python `>=3.11`
-- 推荐使用 `uv`
-- 如果要构建 WhatsApp bridge 或使用仓库自带 Dockerfile,需要 Node.js `20`
-
-本地开发最省事的方式:
-
-```bash
-uv sync --extra dev
-```
-
-如果你不用 `uv`,也可以:
-
-```bash
-python3 -m venv .venv
-. .venv/bin/activate
-pip install -e ".[dev]"
-```
-
-## 本地快速启动
-
-### 1. 初始化配置
-
-```bash
-nanobot onboard
-```
-
-初始化后默认会生成:
-
-- 配置文件:`~/.nanobot/config.json`
-- 工作区:`~/.nanobot/workspace`
-
-### 2. 填最小配置
-
-下面是一份适合服务器环境的最小示例,重点是:
-
-- 用绝对路径的 workspace
-- 建议打开 `restrictToWorkspace`
-- 先用 API Key provider,少踩 OAuth 交互坑
-
-```json
-{
- "agents": {
- "defaults": {
- "workspace": "/root/.nanobot/workspace",
- "model": "openai/gpt-5"
- }
- },
- "providers": {
- "openai": {
- "apiKey": "sk-xxxx"
- }
- },
- "tools": {
- "restrictToWorkspace": true
- }
-}
-```
-
-如果你不是跑在容器里,把 `/root/.nanobot/workspace` 换成你自己的绝对路径。
-
-### 3. 检查配置
-
-```bash
-nanobot status
-```
-
-### 4. 本地调试 agent
-
-```bash
-nanobot agent -m "你好"
-```
-
-### 5. 启动 Web 后端
-
-```bash
-nanobot web --host 0.0.0.0 --port 18080
-```
-
-启动后可直接访问:
-
-- `http://127.0.0.1:18080/docs`
-- `http://127.0.0.1:18080/api/ping`
-
-## Web API 能力概览
-
-当前 `nanobot web` 提供的 API 大致包括:
-
-- 聊天与流式输出
-- 会话管理
-- cron 任务管理
-- skills / plugins / agents 管理
-- 工作区文件浏览、上传、下载、删除
-- MCP server 管理与测试
-- Outlook 集成状态、连接测试、连接/断开、Overview、Message Detail
-
-如果你有独立前端,这个后端就是给前端接的;如果没有前端,也可以直接走 `/docs` 调试。
-
-## Outlook MCP 集成
-
-这是当前仓库里最容易部署时踩坑的一块。
-
-### 关系先说清楚
-
-当前后端不会自己实现 Outlook 协议,它依赖外部仓库 `BW_Outlook_Mcp`:
-
-- 后端代码位置:`nanobot/web/outlook.py`
-- 默认查找逻辑:
- 1. 先看环境变量 `NANOBOT_OUTLOOK_MCP_ROOT`
- 2. 再看与本仓库同级目录的 `../BW_Outlook_Mcp`
- 3. 如果以上都没有,就尝试直接执行 PATH 里的 `bw-outlook-mcp`
-
-也就是说,部署同事必须额外把 `BW_Outlook_Mcp` 这个仓库准备好,或者把它直接安装进镜像。
-
-### 推荐的两种接法
-
-#### 方案 A:把 `BW_Outlook_Mcp` 安装进同一个 Python 环境
-
-这是生产环境更稳的方案。
-
-部署同事需要:
-
-```bash
-git clone <你们的 BW_Outlook_Mcp 仓库地址> /srv/BW_Outlook_Mcp
-cd /srv/BW_Outlook_Mcp
-pip install -e .
-```
-
-安装完成后,容器或宿主机里能直接执行:
-
-```bash
-bw-outlook-mcp --help
-```
-
-这样 Boardware Genius 就会直接用 PATH 里的 `bw-outlook-mcp`,不依赖额外挂载路径。
-
-#### 方案 B:把 `BW_Outlook_Mcp` 作为外部目录挂进来
-
-这是开发或临时部署更方便的方案。
-
-部署同事需要至少做到两件事:
-
-1. 把 `BW_Outlook_Mcp` 仓库拉到服务器
-2. 让这个目录里存在一个可执行的 `bw-outlook-mcp`
-
-最简单的约定是:
-
-```bash
-git clone <你们的 BW_Outlook_Mcp 仓库地址> /srv/BW_Outlook_Mcp
-cd /srv/BW_Outlook_Mcp
-python3 -m venv .venv
-. .venv/bin/activate
-pip install -e .
-```
-
-然后给 Boardware Genius 设置:
-
-```bash
-export NANOBOT_OUTLOOK_MCP_ROOT=/srv/BW_Outlook_Mcp
-```
-
-因为当前后端会优先寻找:
-
-```text
-$NANOBOT_OUTLOOK_MCP_ROOT/.venv/bin/bw-outlook-mcp
-```
-
-如果你挂了仓库目录但里面没有 `.venv/bin/bw-outlook-mcp`,那就必须确保 `bw-outlook-mcp` 已经在容器 PATH 里。
-
-### Outlook 的认证和配置
-
-`BW_Outlook_Mcp` 本身支持两套后端:
-
-- `graph`:Microsoft 365 / Exchange Online
-- `ews`:本地或回迁后的 Exchange Server
-
-#### Graph 登录
-
-```bash
-bw-outlook-mcp auth login-graph \
- --workspace /root/.nanobot/workspace \
- --client-id YOUR_CLIENT_ID \
- --tenant-id YOUR_TENANT_ID
-```
-
-#### EWS 配置
-
-```bash
-bw-outlook-mcp auth setup-ews \
- --workspace /root/.nanobot/workspace \
- --email you@example.com \
- --username your_username \
- --domain example.com \
- --server mail.example.com
-```
-
-如果你已经有固定 EWS URL,也可以改用:
-
-```bash
-bw-outlook-mcp auth setup-ews \
- --workspace /root/.nanobot/workspace \
- --email you@example.com \
- --username your_username \
- --service-endpoint https://mail.example.com/EWS/Exchange.asmx
-```
-
-#### 查看状态
-
-```bash
-bw-outlook-mcp auth status --workspace /root/.nanobot/workspace
-```
-
-### Outlook 状态文件会落在哪里
-
-所有 Outlook 相关状态默认都落在 workspace 下:
-
-```text
-/state/bw_outlook_mcp/
-├── config.json
-├── secrets.json
-├── graph_token_cache.bin
-├── delta_store.json
-└── idempotency.sqlite3
-```
-
-所以 Docker 部署时,不要只挂配置文件;要把整份 `~/.nanobot` 或至少 workspace 做持久化。
-
-### Nanobot 里如何注册 Outlook MCP
-
-如果你通过 Web 接口完成 Outlook 连接,后端会自动把 MCP server 注册到配置里。
-
-手工写配置时,结构类似这样:
-
-```json
-{
- "tools": {
- "mcpServers": {
- "outlook": {
- "command": "bw-outlook-mcp",
- "args": ["serve", "--workspace", "/root/.nanobot/workspace"],
- "sensitive": true,
- "toolTimeout": 60
- }
- }
- }
-}
-```
-
-这里一定要用绝对路径,不要写 `~/.nanobot/workspace`。
-
-### 可选的 Outlook 环境变量
-
-| 变量 | 作用 |
-| --- | --- |
-| `NANOBOT_OUTLOOK_MCP_ROOT` | 指向外部 `BW_Outlook_Mcp` 仓库目录 |
-| `NANOBOT_OUTLOOK_MCP_COMMAND` | 强制指定 `bw-outlook-mcp` 可执行文件 |
-| `NANOBOT_OUTLOOK_MCP_EXTRA_ARGS` | 给 `bw-outlook-mcp serve` 追加参数 |
-| `NANOBOT_OUTLOOK_DEFAULT_DOMAIN` | Web 连接表单的默认域名 |
-| `NANOBOT_OUTLOOK_DEFAULT_EWS_URL` | Web 连接表单默认 EWS 地址 |
-| `NANOBOT_OUTLOOK_DEFAULT_EWS_SERVER` | Web 连接表单默认 Exchange 主机 |
-| `NANOBOT_OUTLOOK_DEFAULT_TIMEZONE` | Web 连接表单默认时区 |
-| `NANOBOT_OUTLOOK_DEFAULT_AUTODISCOVER` | Web 连接表单默认是否启用 autodiscover |
-
-## Docker 部署
-
-### 先说结论
-
-服务器部署时,最重要的是持久化这份目录:
-
-```text
-/root/.nanobot
-```
-
-因为它里面不只是 `config.json`,还包括:
-
-- workspace
-- sessions
-- cron 状态
-- Web 登录信息
-- Outlook 状态与 token 缓存
-
-### 构建镜像
-
-```bash
-docker build -t nanobot-backend:latest .
-```
-
-### 首次初始化
-
-第一次跑容器时,先执行一次:
-
-```bash
-docker run --rm \
- -v /srv/nanobot/data:/root/.nanobot \
- nanobot-backend:latest \
- onboard
-```
-
-然后去编辑宿主机上的:
-
-```text
-/srv/nanobot/data/config.json
-```
-
-或者先进去执行:
-
-```bash
-docker run --rm -it \
- -v /srv/nanobot/data:/root/.nanobot \
- nanobot-backend:latest \
- status
-```
-
-### 作为 Web 后端启动
-
-如果你是给前端项目配后端,推荐这样跑:
-
-```bash
-docker run -d \
- --name nanobot-web \
- -p 18080:18080 \
- -v /srv/nanobot/data:/root/.nanobot \
- -e NANOBOT_OUTLOOK_MCP_ROOT=/opt/BW_Outlook_Mcp \
- -v /srv/BW_Outlook_Mcp:/opt/BW_Outlook_Mcp \
- nanobot-backend:latest \
- web --host 0.0.0.0 --port 18080
-```
-
-如果你已经把 `bw-outlook-mcp` 安装进镜像了,就不需要挂 `/srv/BW_Outlook_Mcp`,也不需要 `NANOBOT_OUTLOOK_MCP_ROOT`。
-
-### 作为 Gateway/Worker 启动
-
-如果你要接 Telegram / Slack / Email / cron 之类的常驻能力,再跑 gateway:
-
-```bash
-docker run -d \
- --name nanobot-gateway \
- -v /srv/nanobot/data:/root/.nanobot \
- nanobot-backend:latest \
- gateway
-```
-
-### 推荐的服务器 compose 片段
-
-仓库自带的 [docker-compose.yml](./docker-compose.yml) 更偏本地 gateway/CLI 示例。
-如果你是部署 Web 后端到服务器,更建议单独写成这样:
-
-```yaml
-services:
- nanobot-web:
- image: nanobot-backend:latest
- container_name: nanobot-web
- command: ["web", "--host", "0.0.0.0", "--port", "18080"]
- restart: unless-stopped
- ports:
- - "18080:18080"
- volumes:
- - /srv/nanobot/data:/root/.nanobot
- - /srv/BW_Outlook_Mcp:/opt/BW_Outlook_Mcp
- environment:
- NANOBOT_OUTLOOK_MCP_ROOT: /opt/BW_Outlook_Mcp
-```
-
-如果你想把 Outlook 依赖做得更稳,推荐直接把 `BW_Outlook_Mcp` 安装进镜像,而不是运行时挂载仓库。
-
-## 部署给同事时,至少要交代这几件事
-
-1. 这是后端仓库,不带前端静态页面,前端请单独部署
-2. Web API 用 `nanobot web` 启动,不是 `gateway`
-3. 数据目录必须持久化到 `/root/.nanobot`
-4. 如果要 Outlook,必须额外拉取 `BW_Outlook_Mcp`
-5. Outlook 有两种接法:装进镜像,或者挂外部仓库并设置 `NANOBOT_OUTLOOK_MCP_ROOT`
-6. Outlook 的状态文件也在 workspace 里,删容器不挂卷就会丢
-
-## 常用命令
-
-```bash
-nanobot onboard
-nanobot status
-nanobot agent -m "你好"
-nanobot web --host 0.0.0.0 --port 18080
-nanobot gateway
-nanobot provider login openai-codex
-```
-
-## 开发备注
-
-- `workflow.md` 记录了当前代码实际运行链路,和旧版 README 更接近“真实代码”
-- `nanobot/web/outlook.py` 是当前 Outlook 集成入口
-- `tests/` 里有 Web API、Email、Docker 相关测试
-- 如果要上服务器,建议在配置里显式打开 `tools.restrictToWorkspace=true`
-
-## 排错
-
-### Web 启动了,但 Outlook 相关接口报错
-
-优先检查:
-
-- `bw-outlook-mcp` 是否能在当前容器里执行
-- `NANOBOT_OUTLOOK_MCP_ROOT` 是否指向正确目录
-- 如果走目录挂载模式,目录里是否真的有 `.venv/bin/bw-outlook-mcp`
-
-### MCP 注册了,但工具没有出现
-
-检查:
-
-- `config.json` 里的 `tools.mcpServers`
-- `nanobot web` 或 `nanobot agent` 启动时是否用了同一份 `~/.nanobot`
-- Outlook MCP 是否能单独执行 `bw-outlook-mcp auth status --workspace ...`
-
-### Docker 里配置改了没生效
-
-优先检查你挂载的是不是整份:
-
-```text
-/srv/nanobot/data:/root/.nanobot
-```
-
-不是只挂了某一个文件。
diff --git a/app-instance/backend-old/SECURITY.md b/app-instance/backend-old/SECURITY.md
deleted file mode 100644
index 6e0831d..0000000
--- a/app-instance/backend-old/SECURITY.md
+++ /dev/null
@@ -1,264 +0,0 @@
-# Security Policy
-
-## Reporting a Vulnerability
-
-If you discover a security vulnerability in Boardware Genius, please report it by:
-
-1. **DO NOT** open a public GitHub issue
-2. Create a private security advisory on GitHub or contact the repository maintainers (xubinrencs@gmail.com)
-3. Include:
- - Description of the vulnerability
- - Steps to reproduce
- - Potential impact
- - Suggested fix (if any)
-
-We aim to respond to security reports within 48 hours.
-
-## Security Best Practices
-
-### 1. API Key Management
-
-**CRITICAL**: Never commit API keys to version control.
-
-```bash
-# ✅ Good: Store in config file with restricted permissions
-chmod 600 ~/.nanobot/config.json
-
-# ❌ Bad: Hardcoding keys in code or committing them
-```
-
-**Recommendations:**
-- Store API keys in `~/.nanobot/config.json` with file permissions set to `0600`
-- Consider using environment variables for sensitive keys
-- Use OS keyring/credential manager for production deployments
-- Rotate API keys regularly
-- Use separate API keys for development and production
-
-### 2. Channel Access Control
-
-**IMPORTANT**: Always configure `allowFrom` lists for production use.
-
-```json
-{
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allowFrom": ["123456789", "987654321"]
- },
- "whatsapp": {
- "enabled": true,
- "allowFrom": ["+1234567890"]
- }
- }
-}
-```
-
-**Security Notes:**
-- Empty `allowFrom` list will **ALLOW ALL** users (open by default for personal use)
-- Get your Telegram user ID from `@userinfobot`
-- Use full phone numbers with country code for WhatsApp
-- Review access logs regularly for unauthorized access attempts
-
-### 3. Shell Command Execution
-
-The `exec` tool can execute shell commands. While dangerous command patterns are blocked, you should:
-
-- ✅ Review all tool usage in agent logs
-- ✅ Understand what commands the agent is running
-- ✅ Use a dedicated user account with limited privileges
-- ✅ Never run Boardware Genius as root
-- ❌ Don't disable security checks
-- ❌ Don't run on systems with sensitive data without careful review
-
-**Blocked patterns:**
-- `rm -rf /` - Root filesystem deletion
-- Fork bombs
-- Filesystem formatting (`mkfs.*`)
-- Raw disk writes
-- Other destructive operations
-
-### 4. File System Access
-
-File operations have path traversal protection, but:
-
-- ✅ Run Boardware Genius with a dedicated user account
-- ✅ Use filesystem permissions to protect sensitive directories
-- ✅ Regularly audit file operations in logs
-- ❌ Don't give unrestricted access to sensitive files
-
-### 5. Network Security
-
-**API Calls:**
-- All external API calls use HTTPS by default
-- Timeouts are configured to prevent hanging requests
-- Consider using a firewall to restrict outbound connections if needed
-
-**WhatsApp Bridge:**
-- The bridge binds to `127.0.0.1:3001` (localhost only, not accessible from external network)
-- Set `bridgeToken` in config to enable shared-secret authentication between Python and Node.js
-- Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700)
-
-### 6. Dependency Security
-
-**Critical**: Keep dependencies updated!
-
-```bash
-# Check for vulnerable dependencies
-pip install pip-audit
-pip-audit
-
-# Update to latest secure versions
-pip install --upgrade nanobot-ai
-```
-
-For Node.js dependencies (WhatsApp bridge):
-```bash
-cd bridge
-npm audit
-npm audit fix
-```
-
-**Important Notes:**
-- Keep `litellm` updated to the latest version for security fixes
-- We've updated `ws` to `>=8.17.1` to fix DoS vulnerability
-- Run `pip-audit` or `npm audit` regularly
-- Subscribe to security advisories for Boardware Genius and its dependencies
-
-### 7. Production Deployment
-
-For production use:
-
-1. **Isolate the Environment**
- ```bash
- # Run in a container or VM
- docker run --rm -it python:3.11
- pip install nanobot-ai
- ```
-
-2. **Use a Dedicated User**
- ```bash
- sudo useradd -m -s /bin/bash nanobot
- sudo -u nanobot nanobot gateway
- ```
-
-3. **Set Proper Permissions**
- ```bash
- chmod 700 ~/.nanobot
- chmod 600 ~/.nanobot/config.json
- chmod 700 ~/.nanobot/whatsapp-auth
- ```
-
-4. **Enable Logging**
- ```bash
- # Configure log monitoring
- tail -f ~/.nanobot/logs/nanobot.log
- ```
-
-5. **Use Rate Limiting**
- - Configure rate limits on your API providers
- - Monitor usage for anomalies
- - Set spending limits on LLM APIs
-
-6. **Regular Updates**
- ```bash
- # Check for updates weekly
- pip install --upgrade nanobot-ai
- ```
-
-### 8. Development vs Production
-
-**Development:**
-- Use separate API keys
-- Test with non-sensitive data
-- Enable verbose logging
-- Use a test Telegram bot
-
-**Production:**
-- Use dedicated API keys with spending limits
-- Restrict file system access
-- Enable audit logging
-- Regular security reviews
-- Monitor for unusual activity
-
-### 9. Data Privacy
-
-- **Logs may contain sensitive information** - secure log files appropriately
-- **LLM providers see your prompts** - review their privacy policies
-- **Chat history is stored locally** - protect the `~/.nanobot` directory
-- **API keys are in plain text** - use OS keyring for production
-
-### 10. Incident Response
-
-If you suspect a security breach:
-
-1. **Immediately revoke compromised API keys**
-2. **Review logs for unauthorized access**
- ```bash
- grep "Access denied" ~/.nanobot/logs/nanobot.log
- ```
-3. **Check for unexpected file modifications**
-4. **Rotate all credentials**
-5. **Update to latest version**
-6. **Report the incident** to maintainers
-
-## Security Features
-
-### Built-in Security Controls
-
-✅ **Input Validation**
-- Path traversal protection on file operations
-- Dangerous command pattern detection
-- Input length limits on HTTP requests
-
-✅ **Authentication**
-- Allow-list based access control
-- Failed authentication attempt logging
-- Open by default (configure allowFrom for production use)
-
-✅ **Resource Protection**
-- Command execution timeouts (60s default)
-- Output truncation (10KB limit)
-- HTTP request timeouts (10-30s)
-
-✅ **Secure Communication**
-- HTTPS for all external API calls
-- TLS for Telegram API
-- WhatsApp bridge: localhost-only binding + optional token auth
-
-## Known Limitations
-
-⚠️ **Current Security Limitations:**
-
-1. **No Rate Limiting** - Users can send unlimited messages (add your own if needed)
-2. **Plain Text Config** - API keys stored in plain text (use keyring for production)
-3. **No Session Management** - No automatic session expiry
-4. **Limited Command Filtering** - Only blocks obvious dangerous patterns
-5. **No Audit Trail** - Limited security event logging (enhance as needed)
-
-## Security Checklist
-
-Before deploying Boardware Genius:
-
-- [ ] API keys stored securely (not in code)
-- [ ] Config file permissions set to 0600
-- [ ] `allowFrom` lists configured for all channels
-- [ ] Running as non-root user
-- [ ] File system permissions properly restricted
-- [ ] Dependencies updated to latest secure versions
-- [ ] Logs monitored for security events
-- [ ] Rate limits configured on API providers
-- [ ] Backup and disaster recovery plan in place
-- [ ] Security review of custom skills/tools
-
-## Updates
-
-**Last Updated**: 2026-02-03
-
-For the latest security updates and announcements, check:
-- GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories
-- Release Notes: https://github.com/HKUDS/nanobot/releases
-
-## License
-
-See LICENSE file for details.
diff --git a/app-instance/backend-old/agent_workspace/error.txt b/app-instance/backend-old/agent_workspace/error.txt
deleted file mode 100644
index e69de29..0000000
diff --git a/app-instance/backend-old/bridge/package.json b/app-instance/backend-old/bridge/package.json
deleted file mode 100644
index 8b7f53a..0000000
--- a/app-instance/backend-old/bridge/package.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "name": "nanobot-whatsapp-bridge",
- "version": "0.1.0",
- "description": "WhatsApp bridge for Boardware Genius using Baileys",
- "type": "module",
- "main": "dist/index.js",
- "scripts": {
- "build": "tsc",
- "start": "node dist/index.js",
- "dev": "tsc && node dist/index.js"
- },
- "dependencies": {
- "@whiskeysockets/baileys": "7.0.0-rc.9",
- "ws": "^8.17.1",
- "qrcode-terminal": "^0.12.0",
- "pino": "^9.0.0"
- },
- "devDependencies": {
- "@types/node": "^20.14.0",
- "@types/ws": "^8.5.10",
- "typescript": "^5.4.0"
- },
- "engines": {
- "node": ">=20.0.0"
- }
-}
diff --git a/app-instance/backend-old/bridge/src/index.ts b/app-instance/backend-old/bridge/src/index.ts
deleted file mode 100644
index 56eb24e..0000000
--- a/app-instance/backend-old/bridge/src/index.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/usr/bin/env node
-/**
- * Boardware Genius WhatsApp Bridge
- *
- * This bridge connects WhatsApp Web to the Boardware Genius Python backend
- * via WebSocket. It handles authentication, message forwarding,
- * and reconnection logic.
- *
- * Usage:
- * npm run build && npm start
- *
- * Or with custom settings:
- * BRIDGE_PORT=3001 AUTH_DIR=~/.nanobot/whatsapp npm start
- */
-
-// Polyfill crypto for Baileys in ESM
-import { webcrypto } from 'crypto';
-if (!globalThis.crypto) {
- (globalThis as any).crypto = webcrypto;
-}
-
-import { BridgeServer } from './server.js';
-import { homedir } from 'os';
-import { join } from 'path';
-
-const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
-const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth');
-const TOKEN = process.env.BRIDGE_TOKEN || undefined;
-
-console.log('Boardware Genius WhatsApp Bridge');
-console.log('========================\n');
-
-const server = new BridgeServer(PORT, AUTH_DIR, TOKEN);
-
-// Handle graceful shutdown
-process.on('SIGINT', async () => {
- console.log('\n\nShutting down...');
- await server.stop();
- process.exit(0);
-});
-
-process.on('SIGTERM', async () => {
- await server.stop();
- process.exit(0);
-});
-
-// Start the server
-server.start().catch((error) => {
- console.error('Failed to start bridge:', error);
- process.exit(1);
-});
diff --git a/app-instance/backend-old/bridge/src/server.ts b/app-instance/backend-old/bridge/src/server.ts
deleted file mode 100644
index 7d48f5e..0000000
--- a/app-instance/backend-old/bridge/src/server.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-/**
- * WebSocket server for Python-Node.js bridge communication.
- * Security: binds to 127.0.0.1 only; optional BRIDGE_TOKEN auth.
- */
-
-import { WebSocketServer, WebSocket } from 'ws';
-import { WhatsAppClient, InboundMessage } from './whatsapp.js';
-
-interface SendCommand {
- type: 'send';
- to: string;
- text: string;
-}
-
-interface BridgeMessage {
- type: 'message' | 'status' | 'qr' | 'error';
- [key: string]: unknown;
-}
-
-export class BridgeServer {
- private wss: WebSocketServer | null = null;
- private wa: WhatsAppClient | null = null;
- private clients: Set = new Set();
-
- constructor(private port: number, private authDir: string, private token?: string) {}
-
- async start(): Promise {
- // Bind to localhost only — never expose to external network
- this.wss = new WebSocketServer({ host: '127.0.0.1', port: this.port });
- console.log(`🌉 Bridge server listening on ws://127.0.0.1:${this.port}`);
- if (this.token) console.log('🔒 Token authentication enabled');
-
- // Initialize WhatsApp client
- this.wa = new WhatsAppClient({
- authDir: this.authDir,
- onMessage: (msg) => this.broadcast({ type: 'message', ...msg }),
- onQR: (qr) => this.broadcast({ type: 'qr', qr }),
- onStatus: (status) => this.broadcast({ type: 'status', status }),
- });
-
- // Handle WebSocket connections
- this.wss.on('connection', (ws) => {
- if (this.token) {
- // Require auth handshake as first message
- const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000);
- ws.once('message', (data) => {
- clearTimeout(timeout);
- try {
- const msg = JSON.parse(data.toString());
- if (msg.type === 'auth' && msg.token === this.token) {
- console.log('🔗 Python client authenticated');
- this.setupClient(ws);
- } else {
- ws.close(4003, 'Invalid token');
- }
- } catch {
- ws.close(4003, 'Invalid auth message');
- }
- });
- } else {
- console.log('🔗 Python client connected');
- this.setupClient(ws);
- }
- });
-
- // Connect to WhatsApp
- await this.wa.connect();
- }
-
- private setupClient(ws: WebSocket): void {
- this.clients.add(ws);
-
- ws.on('message', async (data) => {
- try {
- const cmd = JSON.parse(data.toString()) as SendCommand;
- await this.handleCommand(cmd);
- ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
- } catch (error) {
- console.error('Error handling command:', error);
- ws.send(JSON.stringify({ type: 'error', error: String(error) }));
- }
- });
-
- ws.on('close', () => {
- console.log('🔌 Python client disconnected');
- this.clients.delete(ws);
- });
-
- ws.on('error', (error) => {
- console.error('WebSocket error:', error);
- this.clients.delete(ws);
- });
- }
-
- private async handleCommand(cmd: SendCommand): Promise {
- if (cmd.type === 'send' && this.wa) {
- await this.wa.sendMessage(cmd.to, cmd.text);
- }
- }
-
- private broadcast(msg: BridgeMessage): void {
- const data = JSON.stringify(msg);
- for (const client of this.clients) {
- if (client.readyState === WebSocket.OPEN) {
- client.send(data);
- }
- }
- }
-
- async stop(): Promise {
- // Close all client connections
- for (const client of this.clients) {
- client.close();
- }
- this.clients.clear();
-
- // Close WebSocket server
- if (this.wss) {
- this.wss.close();
- this.wss = null;
- }
-
- // Disconnect WhatsApp
- if (this.wa) {
- await this.wa.disconnect();
- this.wa = null;
- }
- }
-}
diff --git a/app-instance/backend-old/bridge/src/types.d.ts b/app-instance/backend-old/bridge/src/types.d.ts
deleted file mode 100644
index 3aeb18b..0000000
--- a/app-instance/backend-old/bridge/src/types.d.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-declare module 'qrcode-terminal' {
- export function generate(text: string, options?: { small?: boolean }): void;
-}
diff --git a/app-instance/backend-old/bridge/src/whatsapp.ts b/app-instance/backend-old/bridge/src/whatsapp.ts
deleted file mode 100644
index 069d72b..0000000
--- a/app-instance/backend-old/bridge/src/whatsapp.ts
+++ /dev/null
@@ -1,187 +0,0 @@
-/**
- * WhatsApp client wrapper using Baileys.
- * Based on OpenClaw's working implementation.
- */
-
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import makeWASocket, {
- DisconnectReason,
- useMultiFileAuthState,
- fetchLatestBaileysVersion,
- makeCacheableSignalKeyStore,
-} from '@whiskeysockets/baileys';
-
-import { Boom } from '@hapi/boom';
-import qrcode from 'qrcode-terminal';
-import pino from 'pino';
-
-const VERSION = '0.1.0';
-
-export interface InboundMessage {
- id: string;
- sender: string;
- pn: string;
- content: string;
- timestamp: number;
- isGroup: boolean;
-}
-
-export interface WhatsAppClientOptions {
- authDir: string;
- onMessage: (msg: InboundMessage) => void;
- onQR: (qr: string) => void;
- onStatus: (status: string) => void;
-}
-
-export class WhatsAppClient {
- private sock: any = null;
- private options: WhatsAppClientOptions;
- private reconnecting = false;
-
- constructor(options: WhatsAppClientOptions) {
- this.options = options;
- }
-
- async connect(): Promise {
- const logger = pino({ level: 'silent' });
- const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir);
- const { version } = await fetchLatestBaileysVersion();
-
- console.log(`Using Baileys version: ${version.join('.')}`);
-
- // Create socket following OpenClaw's pattern
- this.sock = makeWASocket({
- auth: {
- creds: state.creds,
- keys: makeCacheableSignalKeyStore(state.keys, logger),
- },
- version,
- logger,
- printQRInTerminal: false,
- browser: ['nanobot', 'cli', VERSION],
- syncFullHistory: false,
- markOnlineOnConnect: false,
- });
-
- // Handle WebSocket errors
- if (this.sock.ws && typeof this.sock.ws.on === 'function') {
- this.sock.ws.on('error', (err: Error) => {
- console.error('WebSocket error:', err.message);
- });
- }
-
- // Handle connection updates
- this.sock.ev.on('connection.update', async (update: any) => {
- const { connection, lastDisconnect, qr } = update;
-
- if (qr) {
- // Display QR code in terminal
- console.log('\n📱 Scan this QR code with WhatsApp (Linked Devices):\n');
- qrcode.generate(qr, { small: true });
- this.options.onQR(qr);
- }
-
- if (connection === 'close') {
- const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
- const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
-
- console.log(`Connection closed. Status: ${statusCode}, Will reconnect: ${shouldReconnect}`);
- this.options.onStatus('disconnected');
-
- if (shouldReconnect && !this.reconnecting) {
- this.reconnecting = true;
- console.log('Reconnecting in 5 seconds...');
- setTimeout(() => {
- this.reconnecting = false;
- this.connect();
- }, 5000);
- }
- } else if (connection === 'open') {
- console.log('✅ Connected to WhatsApp');
- this.options.onStatus('connected');
- }
- });
-
- // Save credentials on update
- this.sock.ev.on('creds.update', saveCreds);
-
- // Handle incoming messages
- this.sock.ev.on('messages.upsert', async ({ messages, type }: { messages: any[]; type: string }) => {
- if (type !== 'notify') return;
-
- for (const msg of messages) {
- // Skip own messages
- if (msg.key.fromMe) continue;
-
- // Skip status updates
- if (msg.key.remoteJid === 'status@broadcast') continue;
-
- const content = this.extractMessageContent(msg);
- if (!content) continue;
-
- const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
-
- this.options.onMessage({
- id: msg.key.id || '',
- sender: msg.key.remoteJid || '',
- pn: msg.key.remoteJidAlt || '',
- content,
- timestamp: msg.messageTimestamp as number,
- isGroup,
- });
- }
- });
- }
-
- private extractMessageContent(msg: any): string | null {
- const message = msg.message;
- if (!message) return null;
-
- // Text message
- if (message.conversation) {
- return message.conversation;
- }
-
- // Extended text (reply, link preview)
- if (message.extendedTextMessage?.text) {
- return message.extendedTextMessage.text;
- }
-
- // Image with caption
- if (message.imageMessage?.caption) {
- return `[Image] ${message.imageMessage.caption}`;
- }
-
- // Video with caption
- if (message.videoMessage?.caption) {
- return `[Video] ${message.videoMessage.caption}`;
- }
-
- // Document with caption
- if (message.documentMessage?.caption) {
- return `[Document] ${message.documentMessage.caption}`;
- }
-
- // Voice/Audio message
- if (message.audioMessage) {
- return `[Voice Message]`;
- }
-
- return null;
- }
-
- async sendMessage(to: string, text: string): Promise {
- if (!this.sock) {
- throw new Error('Not connected');
- }
-
- await this.sock.sendMessage(to, { text });
- }
-
- async disconnect(): Promise {
- if (this.sock) {
- this.sock.end(undefined);
- this.sock = null;
- }
- }
-}
diff --git a/app-instance/backend-old/bridge/tsconfig.json b/app-instance/backend-old/bridge/tsconfig.json
deleted file mode 100644
index 7f472b2..0000000
--- a/app-instance/backend-old/bridge/tsconfig.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2022",
- "module": "ESNext",
- "moduleResolution": "node",
- "esModuleInterop": true,
- "strict": true,
- "skipLibCheck": true,
- "outDir": "./dist",
- "rootDir": "./src",
- "declaration": true,
- "resolveJsonModule": true
- },
- "include": ["src/**/*"],
- "exclude": ["node_modules", "dist"]
-}
diff --git a/app-instance/backend-old/case/code.gif b/app-instance/backend-old/case/code.gif
deleted file mode 100644
index 159dad8..0000000
Binary files a/app-instance/backend-old/case/code.gif and /dev/null differ
diff --git a/app-instance/backend-old/case/memory.gif b/app-instance/backend-old/case/memory.gif
deleted file mode 100644
index fc91f55..0000000
Binary files a/app-instance/backend-old/case/memory.gif and /dev/null differ
diff --git a/app-instance/backend-old/case/scedule.gif b/app-instance/backend-old/case/scedule.gif
deleted file mode 100644
index a2e3073..0000000
Binary files a/app-instance/backend-old/case/scedule.gif and /dev/null differ
diff --git a/app-instance/backend-old/case/search.gif b/app-instance/backend-old/case/search.gif
deleted file mode 100644
index fd3d067..0000000
Binary files a/app-instance/backend-old/case/search.gif and /dev/null differ
diff --git a/app-instance/backend-old/change.md b/app-instance/backend-old/change.md
deleted file mode 100644
index b8ae543..0000000
--- a/app-instance/backend-old/change.md
+++ /dev/null
@@ -1,1038 +0,0 @@
-# Beaver Backend 重构蓝图
-
-## 命名说明
-
-当前项目正式名称已经不是 `nanobot`,而是 `beaver`。
-
-这份文档里如果出现 `nanobot/...`,一律表示“当前仓库里还没迁走的历史代码路径 / 现状实现位置”,不代表目标命名。
-
-后续重构目标应统一收敛到:
-
-1. 产品名、项目名、运行时内核名统一按 `beaver` 表达。
-2. `nanobot` 只作为迁移期遗留路径存在,最终应逐步退出目录、模块和文档命名。
-3. 新增目录、新增模块、新增文档都应优先使用 `beaver` 命名,而不是继续扩散 `nanobot`。
-
-## 1. 这次重构到底要解决什么
-
-当前后端已经不是“功能不够”,而是“能力已经长出来了,但结构还停留在早期阶段”。
-
-现在项目里同时存在这些事实:
-
-1. `AgentLoop` 已经承担了太多职责,既管主 agent 对话,又管工具、委派、MCP、会话、事件、memory。
-2. `web/server.py` 已经变成超大文件,FastAPI app factory、chat API、session、文件、skills、cron、A2A、Outlook 都放在一起。
-3. `agent_team` 已经接上了 `swarms`,但目前更像“业务层直接借用第三方 runtime”,不是“我们自己的多智能体平台”。
-4. `skills` 已经有加载、安装、审核,但本质还是 Markdown 说明书,不是可学习、可演化、可评估的能力对象。
-5. 项目里已经隐约出现了三个方向,但还没有被统一成一个完整架构:
- - `swarms` 提供多智能体架构能力
- - `hermes-agent` 提供 skill 生命周期与长期演进思路
- - `OpenHarness` 提供模块化的 harness 设计方法
-
-所以这次重构不是简单“整理目录”,而是把项目从“围绕一个 CLI 主 agent 生长出来的系统”升级成“所有 agent 共享同一内核的自有 agent harness 平台”。
-
-### 1.1 当前落地状态(2026-05-07)
-
-截至当前实现,新 `app-instance/backend/beaver` 已经把主链推进到:
-
-1. `AgentService` 前面增加了 Main Agent 路由层。
- - 简单问题直接走原有 `AgentLoop` 单轮回答。
- - 复杂任务自动进入内部 Task 模式。
- - 前端和外部调用仍只使用聊天入口,不暴露显式创建 Task 的产品 API。
-2. 新增内部 Task 子系统:
- - `beaver/tasks/models.py`
- - `beaver/tasks/store.py`
- - `beaver/tasks/service.py`
- - `beaver/tasks/router.py`
- - `beaver/tasks/validation.py`
-3. Task 模式已经能把一次或多次 `RunRecord` 归属到内部 `task_id`。
- - `RunRecord` 增加 `task_id`
- - `RunRecord` 增加 `attempt_index`
- - `RunRecord` 增加 `validation_result`
-4. Task 模式每轮完成后会自动验证。
- - 验证输入包含 task goal、用户请求、可见 transcript excerpt、工具摘要、最终输出。
- - 验证通过标准为 `passed=true` 且 `score >= 0.75`。
- - 验证失败自动重试一次;第一次失败尝试不会继续留在可见上下文。
-5. 用户反馈闭环已经接入最小产品面。
- - `POST /api/chat/feedback`
- - 前端最新 assistant 消息下显示“满意 / 需要修改 / 放弃”
- - 反馈通过 `run_id -> task_id` 找到内部 Task
- - 反馈状态会投影回 session 可见消息,刷新后仍保留
-6. 学习触发已经从“run 完成即候选”收紧为 Task 门控。
- - 普通 run 仍记录运行收据和 skill effect
- - Task 模式先只记录 receipts
- - 只有“自动验证通过 + 用户满意”才生成成功学习候选
- - “放弃”写 Failure Memory,不生成成功 Skill draft
-7. Agent Team v1 已经落成 Beaver 自有轻量 coordinator。
- - `TeamService.run_team(...)` 是内部服务入口
- - `LocalAgentRunner` 让 sub-agent 复用主 `AgentLoop.process_direct()` / `submit_direct()`
- - 已支持 `sequence / parallel / dag`
- - `parallel` 和 DAG 同层节点保持真并发
- - 每个 run 使用独立 memory snapshot
- - 支持 pinned skill 继承和 per-node provider factory
- - sub-agent run 归入父 Task
- - 节点级异常归一成 `NodeRunResult`
-8. Agent Team 已接入 Task mode 内部执行策略。
- - `TaskExecutionPlanner` 使用 LLM JSON 规划 `single / team`
- - team node 只声明 `skill_query / required_capabilities`,不声明固定 specialist 人设
- - `TaskSkillResolver` 为 generic sub-agent 选择 published skill;未命中时生成 draft-only skill,并作为本次 run 的 ephemeral pinned instruction 使用
- - team 模式调用 `TeamService.run_team(...)` 产生 sub-agent runs
- - team 输出注入主 Agent synthesis run
- - 用户可见最终回答仍由主 Agent 生成,并继续走验证、反馈和学习门控
- - planner 失败或 graph 非法时降级 `single`
-
-当前仍未落地的部分:
-
-1. Agent Team 不暴露产品级聊天路由或显式 Task API;当前作为 Task 内部 sub-agent 执行策略。
-2. `moa / hierarchy / heavy / group_chat / forest / maker / router` 仍是策略预留,不是 v1 完整行为。
-3. 自动验证目前是 LLM validator,不是 replay sandbox。
-4. Skill draft synthesis / review / publish 安全链已有基础服务,但还没有做成完整后台学习 pipeline。
-5. `/api/agents` 和 agent registry 可作为未来外部 agent/A2A 管理面保留,但不参与 Task sub-agent 选择。
-6. 不允许在线直接改 published skill,这条约束保持不变。
-
-### 1.2 参考项目核对说明
-
-这版蓝图不是只根据印象在写。`2026-05-06` 我们已经重新核对过下面三个参考项目的公开入口文档:
-
-1. `OpenHarness`
- -
-2. `hermes-agent`
- -
-3. `swarms`
- -
-
-这一步的目的不是“照着抄目录”,而是把“到底借什么、不借什么”明确写死,避免后续施工时又把第三方项目的实现细节直接揉回 Beaver。
-
-## 2. 我是怎么想的
-
-我的核心判断是:我们不能继续把第三方库、业务流程、执行控制、UI/API 接口揉在一起,而是应该先定义我们自己的稳定边界,再让第三方能力挂进来。
-
-换句话说,目标不是“把仓库改得更像 swarms / hermes / OpenHarness”,而是:
-
-1. 用 `swarms` 的强项来解决“团队编排”。
-2. 用 `hermes-agent` 的强项来解决“skills 怎么创建、维护、学习、沉淀”。
-3. 用 `OpenHarness` 的强项来解决“工程边界、模块职责、可维护性”。
-4. 最终收口成我们自己的抽象和目录,而不是长期让第三方结构反向塑造我们。
-
-这里把三者的借鉴边界再说得更具体一点:
-
-1. `OpenHarness`
- - 借它的 harness 分层方式:`engine / tools / skills / permissions / memory / coordinator / prompts / config`
- - 借它“一条统一 loop + 明确 tool registry / permission / hook 边界”的工程组织方式
- - 不直接照搬它的 CLI/TUI、commands、plugin 生态,也不要求 Beaver 长成它的目录镜像
-2. `hermes-agent`
- - 借它的 memory / session / session_search / skills 运行时关系
- - 借它对 FTS5 transcript 搜索、长期记忆、显式 skill 注入、session lineage 的处理方向
- - 不把“自动学习闭环、完整渠道网关、全部终端后端、Honcho 用户建模”当成当前阶段必须同步迁入的范围
-3. `swarms`
- - 借它已经验证过的多智能体执行形态,例如 sequential / hierarchy / rearrange / router 这类 orchestration 结构
- - 借它作为 team execution backend 的角色,而不是借它来定义 Beaver 的主 runtime、session、tool、provider 契约
- - 不再允许 Beaver 上层直接感知 `third_party/swarms`、`SwarmRouter` 参数细节或 import 副作用
-
-这意味着后续所有设计都应遵守四条原则:
-
-### 2.1 我们要有自己的抽象
-
-不能让业务代码直接依赖:
-
-- `third_party/swarms` 的导入路径
-- `SwarmRouter` 的参数细节
-- 某个第三方 skill 文件格式
-- 某个第三方 runtime 的副作用
-
-我们应该先定义自己的核心对象,例如:
-
-- `AgentDescriptor`
-- `SkillSpec`
-- `SkillVersion`
-- `TeamSpec`
-- `ExecutionPlan`
-- `ProcedureRecord`
-- `RunRecord`
-- `BridgeResult`
-
-第三方库只能作为 adapter / backend 存在。
-
-### 2.2 所有 agent 共享同一套运行内核
-
-后面不应该再保留“CLI 单 agent”和“其他 agent 另一套执行方式”这种概念分叉。
-
-正确做法应该是:
-
-1. 所有 agent 都复用同一个 `AgentLoop` / engine。
-2. 主 agent、subagent、team member、A2A local specialist 都只是不同的运行配置和上下文。
-3. tools、skills、memory、permissions、MCP、delegation 都在同一套内核里装载。
-4. CLI 只是一个 interface,作用是把用户输入送进内核,而不是代表一种单独的 agent 类型。
-
-这样做的意义是:
-
-1. 所有 agent 的能力边界一致。
-2. 不会再出现“这个能力只在 CLI 主 agent 可用,子 agent 不一致”的问题。
-3. agent 的差异只存在于 profile / policy / prompt / runtime context,而不是存在于不同执行栈里。
-
-### 2.3 Harness 和业务要分开
-
-当前很多逻辑混在一起:既有“平台级能力”,也有“具体产品接入”。
-
-后面应该分成两层:
-
-1. Harness 层
- - tool use
- - skills
- - memory
- - delegation
- - orchestration
- - governance
-2. Product / Interface 层
- - web API
- - gateway
- - channel adapters
- - Outlook / WhatsApp / 外部服务接入
-
-这样平台能力才能稳定,接入层才能随产品变化而变化。
-
-### 2.4 多智能体是平台能力,不是工具技巧
-
-现在 `spawn_agent_team` 已经存在,但在结构上还像“一个高级工具”。
-
-后面应该把 multi-agent 当成正式 runtime 能力:
-
-- 有 plan 层 (计划)
-- 有 strategy 层 (策略)
-- 有 execution backend 层 (执行后端)
-- 有 result normalization 层 (结果归一化)
-- 有 memory / procedure reuse 层 (内存/过程重用)
-- 有 governance / safety / skill constraints (治理/安全/技能限制)
-
-这里要特别说明 `2.4` 和 `2.5` 的关系:
-
-1. multi-agent 不是独立于 skills 的第二套指导系统。
-2. 我们仍然保留之前的群组讨论机制,也就是“探索式协作 + 流程化执行”两种能力都保留。
-3. 但无论是探索式 group discussion,还是流程化 sequential / rearrange / hierarchy,都必须受 skills 指引和约束。
-4. 也就是说,skills 决定“应该如何思考、遵守什么边界、优先采用什么方法”,而 multi-agent 负责“由几个人、以什么结构去执行”。
-
-所以后续正确关系应是:
-
-`skills -> 约束与方法指导`
-`multi-agent -> 在 skills 约束下进行探索、讨论、流程化执行`
-
-### 2.5 skills 必须变成生命周期系统
-
-现在的 skills 更像可读文档包,适合“手工维护”,不适合“自动学习”。
-
-如果以后要做到自动创建、自动修订、自动推荐、自动淘汰,skills 必须具备:
-
-- 结构化元数据
-- 版本号
-- 来源与 lineage
-- 审核状态
-- 效果统计
-- 与 procedure 的映射关系
-- 可回滚、可禁用、可发布
-
-并且这里的 `skills` 不应只服务于“工具使用技巧”,而应成为整个 agent 系统的统一指引层,包括:
-
-1. 主 agent 如何规划和执行
-2. subagent / team member 如何行动
-3. memory 如何参与判断
-4. procedure reuse 如何被触发和约束
-5. multi-agent 讨论时允许采用哪些方法、角色分工和输出习惯
-
-换句话说:
-
-1. memory / procedure reuse 不是独立于 skills 的平行系统。
-2. 但 memory 的实现标准要以 `hermes-agent` 为准,而不是继续沿用当前偏自由发挥的记忆模型。
-3. skills 提供全局行为指引;memory 只保存跨会话仍然有价值的稳定事实;session_search 负责找回历史细节;procedure 只作为可选优化层。
-
-这里要明确四者分工:
-
-1. `skills`
- - 指导“怎么做”
- - 约束工具使用、讨论方式、流程化执行方式
-2. `memory`
- - 保存 durable facts
- - 例如用户偏好、环境事实、项目约定、工具 quirks
-3. `session_search`
- - 检索历史会话细节
- - 不把大量过程细节直接塞进 memory
-4. `procedure`
- - 作为 coordinator 内部的复用优化
- - 不是主 memory 契约,也不是主要 prompt 注入来源
-
-## 3. 现有项目现在是咋样的
-
-### 3.1 当前的主结构
-
-从代码上看,`app-instance/backend` 当前大致是这几块。
-
-注意:下面这些路径仍写作 `nanobot/...`,是因为这里描述的是“现状代码位置”,不是目标命名。
-
-1. 启动与装配
- - `nanobot/cli/commands.py`
- - `nanobot/__main__.py`
-2. agent 运行时
- - `nanobot/agent/loop.py`
- - `nanobot/agent/context.py`
- - `nanobot/agent/tools/*`
- - `nanobot/session/*`
- - `nanobot/providers/*`
-3. 多 agent / 委派
- - `nanobot/agent/delegation.py`
- - `nanobot/agent_team/*`
- - `nanobot/a2a/*`
-4. Web / Gateway / Channels
- - `nanobot/web/server.py`
- - `nanobot/channels/*`
- - `bridge/`
-5. 技能与插件
- - `nanobot/skills/*`
- - `nanobot/agent/skills.py`
- - `nanobot/agent/plugins.py`
-6. 外部运行时耦合点
- - 当前主要是 vendored `swarms`
-
-### 3.2 当前已经有的优点
-
-这套代码不是没基础,相反已经有几个很有价值的雏形:
-
-1. 已经有 `AgentRegistry`、`DelegationManager`、`agent_team`,说明“统一委派层”思路已经出现。
-2. 已经有 `ProcedureMemory` 和 `RunMemory`,说明“从执行中学习”的基础数据层已经出现。
-3. 已经有 `skills` 的加载、安装、审核,说明“受控扩展机制”已经存在。
-4. 已经有 `SwarmsBridge`、`SwarmsPolicy`、`SwarmsRunPlanner`,说明多智能体桥接已经不是空白。
-
-所以这次重构不是推倒重来,而是把这些散落的雏形收敛成一个完整架构。
-
-### 3.3 当前最主要的问题
-
-#### 问题一:装配逻辑散落
-
-同一个后端能力,在 CLI、Web、Gateway 中经常重复装配,甚至行为已经开始漂移。
-
-这会导致:
-
-1. 同样的配置在不同入口行为不同。
-2. 改一个入口容易漏另一个入口。
-3. 测试覆盖变难。
-
-#### 问题二:`AgentLoop` 太重,但又没有成为唯一内核
-
-`AgentLoop` 已经不是纯 loop,而是“半个 runtime 内核”。
-
-这会导致:
-
-1. 主 agent 与其他 agent 的边界不清。
-2. tool、memory、delegation、session、events 相互缠绕。
-3. 很多能力只能靠继续往 `AgentLoop` 里塞。
-4. 同时又没有真正做到“所有 agent 都统一复用它”。
-
-#### 问题三:`swarms` 接入边界不干净,而且 `third_party` 目录本身会持续恶化维护成本
-
-当前 `agent_team` 虽然有 bridge,但仍然直接依赖:
-
-1. `sys.path` 注入 vendored `swarms`
-2. 顶层 `swarms` 包导入副作用
-3. `SwarmRouter` 的参数细节
-4. `AutoSwarmBuilder` 自己的 LLM 栈
-
-这意味着现在不是“我们调度 swarms”,而是“我们的平台有一部分被 swarms runtime 反向定义了”。
-
-另外,`third_party/` 这种目录在这个项目里不应该长期存在。它会带来两个问题:
-
-1. 仓库边界不清,到底哪些代码是我们的,哪些不是,很难维护。
-2. 一旦改动第三方源码,升级、回滚、排障都会变得更脆弱。
-
-#### 问题四:skills 还是静态文档包
-
-现在的 skill 系统适合:
-
-- 展示
-- 人工安装
-- prompt 注入
-
-但不适合:
-
-- 自动学习
-- 自动合并
-- 自动评估
-- 版本回滚
-- 基于效果做选择
-
-#### 问题五:接口层和核心层耦合过深
-
-`web/server.py` 过大说明一个事实:
-
-平台内核与外部 API、外部接入、外部服务没有完成分层。
-
-## 4. 后面应该怎么改
-
-## 4.1 先把系统改成 OpenHarness 风格的能力分组
-
-这里我建议明确参考 OpenHarness 那种“按能力分组、核心目录更扁平”的结构,而不是继续按历史演化路径堆目录。
-
-核心思路是:
-
-1. 用 `engine` 作为唯一运行内核。
-2. 用 `coordinator` 负责委派和多 agent 编排。
-3. 用 `tools`、`skills`、`memory`、`permissions` 作为独立能力层。
-4. 用 `interfaces` 只放 CLI / Web / Gateway / Channels 这类入口。
-5. 用 `integrations` 放外部协议和外部系统适配。
-
-这样拆完之后,模块关系应变成:
-
-`interfaces -> engine/coordinator/tools/skills/memory -> foundation`
-
-而不是像现在这样互相横穿。
-
-## 4.2 彻底去掉 `third_party/`,把 `swarms` 改造成可替换 backend
-
-### 旧实现状态
-
-旧 `agent_team` 曾经接通:
-
-- `GroupChat`
-- `SequentialWorkflow`
-- `ConcurrentWorkflow`
-- `AgentRearrange`
-- `MixtureOfAgents`
-- `HierarchicalSwarm`
-
-但这些能力还不是 Beaver 的正式能力集合,而是“旧 bridge 恰好能跑通的一部分 swarms 类型”。
-
-更重要的是,当前它们依赖 `third_party/swarms` 这个 vendored 目录,这是后续必须去掉的。
-
-### 当前 Beaver 状态
-
-新后端已经先落地了不依赖 `third_party/swarms` 的 Agent Team v1:
-
-1. 自有核心模型:
- - `AgentDescriptor`
- - `DelegationEnvelope`
- - `ExecutionNode`
- - `ExecutionGraph`
- - `NodeRunResult`
- - `TeamRunResult`
-2. 内部服务入口:
- - `TeamService.run_team(...)`
-3. 本地 delegated runner:
- - `LocalAgentRunner`
- - sub-agent 复用主 `AgentLoop.process_direct()` / `submit_direct()`
-4. 已实现策略:
- - `sequence`
- - `parallel`
- - `dag`
-5. 已固定的安全语义:
- - parent Task 必须存在且 session 匹配
- - sub-agent run_ids 回填父 Task
- - team/sub-agent 默认只写 receipts/effects,不生成 learning candidates
- - learning candidates 仍只由 Task feedback gate 触发
- - 节点级异常归一成 `NodeRunResult`
- - summary 只聚合成功输出并列出失败节点
-
-### 目标状态
-
-后续应该继续沿用我们自己的团队执行抽象:
-
-```text
-TeamSpec
- -> TeamPlanner
- -> ExecutionPlan
- -> StrategyBackend
- -> NormalizedResult
-```
-
-然后:
-
-1. `SwarmsBackend` 如果以后存在,也只能是 `StrategyBackend` 的一个实现。
-2. 平台对外暴露的是自己的策略名和能力矩阵。
-3. `swarms` 只提供可选执行或策略参考,不再负责定义平台边界。
-4. 仓库内不再保留 `third_party/`。
-5. 高级策略可以先编译成 Beaver `ExecutionGraph` 或 step loop,而不是直接暴露 swarms runtime。
-
-### 具体改法
-
-1. 保留当前 `coordinator/models.py / local.py / execution/scheduler.py` 作为 v1 core。
-2. 在平台层继续扩展正式支持的 strategy。
- - 已实现:`sequence / parallel / dag`
- - 预留:`moa / hierarchy / heavy / group_chat / forest / maker / router`
-3. 高级 strategy preset 先转成 `ExecutionGraph` 或 step loop。
-4. 如果后续接外部 swarms,单独放进 `coordinator/backends/swarms/`,并统一输入输出为 Beaver models。
-
-### 结果
-
-改完之后:
-
-1. `third_party/` 目录消失。
-2. 上层不再知道 `third_party/swarms` 这个路径。
-3. 对上层透明的是 Beaver 自有 team model 和 `TeamService`,不是 vendored 源码目录。
-
-## 4.3 把 `skills` 从静态文档升级成能力生命周期系统
-
-### 当前状态
-
-现在 skill 基本等于:
-
-- 一个目录
-- 一个 `SKILL.md`
-- 一点 frontmatter
-- 一点审核流程
-
-### 目标状态
-
-后续 skill 至少要分成三类对象:
-
-1. `SkillDraft`
- - 自动生成或人工创建
- - 还没发布
-2. `SkillVersion`
- - 某个稳定版本
- - 可启用/禁用/回滚
-3. `SkillRuntimeView`
- - 当前对模型暴露的生效版本
-
-同时 skill 应该带这些元信息:
-
-- `id`
-- `name`
-- `version`
-- `summary`
-- `usage_rules`
-- `inputs`
-- `outputs`
-- `dependencies`
-- `source`
-- `derived_from_procedure`
-- `review_status`
-- `metrics`
-
-### 自动学习建议
-
-不要直接让 agent 在线改 live skills。
-
-正确链路应该是:
-
-`Task -> validated run result -> user feedback -> learning candidate -> skill draft -> review -> publish -> runtime use`
-
-这比“自动改 `SKILL.md`”安全得多,也更适合生产环境。
-
-把它再展开成运行时视角,应该是下面这种树形过程:
-
-```text
-一次 Task 模式 run 完成
-│
-├─ 记录本轮结果并归属内部 Task
-│ ├─ RunRecord
-│ ├─ task_id / attempt_index
-│ ├─ SkillActivationReceipt[]
-│ └─ SkillEffectRecord[]
-│
-├─ 自动验证
-│ ├─ ValidationResult
-│ ├─ task_validation_snapshotted hidden event
-│ └─ RunRecord.validation_result
-│
-├─ 如果验证失败
-│ ├─ 自动修订一次
-│ ├─ 失败草稿尝试从可见上下文隐藏
-│ └─ 第二次仍失败则等待用户反馈,不进入成功学习
-│
-├─ 用户反馈
-│ ├─ satisfied(验证通过后关闭 Task,并生成成功学习候选)
-│ ├─ revise(Task 进入 needs_revision,下一条消息复用该 Task)
-│ └─ abandon(Task 进入 abandoned,写 Failure Memory)
-│
-├─ 聚合 skill 历史表现
-│ └─ SkillPerformanceSnapshot
-│
-├─ 生成学习候选
-│ ├─ revise_skill
-│ ├─ new_skill
-│ ├─ merge_skills
-│ └─ retire_skill
-│
-├─ 如需真正演化:
-│ ├─ evidence selection
-│ ├─ skill draft synthesis
-│ ├─ review
-│ ├─ publish / disable / rollback
-│ └─ runtime catalog 切换到新的 published version
-│
-└─ 明确禁止:
- └─ agent 直接在线改 live `SKILL.md`
-```
-
-### 结果
-
-改完之后,skills 不再只是 prompt 资源,而是平台知识层的一等对象。
-
-## 4.4 以 `hermes-agent` 的 memory 模型为基线重做 memory 层
-
-这里要明确:新的 memory 设计不再以当前 `ProcedureMemory` 为中心,而是以 `hermes-agent` 的 memory 模型为准。
-
-### 主 memory 契约
-
-新的主 memory 契约应是:
-
-1. 一个统一的 `memory` tool
-2. 三个核心动作:
- - `add`
- - `replace`
- - `remove`
-3. 两个目标存储:
- - `memory`:agent 的环境事实、项目约定、工具经验
- - `user`:用户画像、偏好、习惯、纠正记录
-
-它的行为应对齐 Hermes:
-
-1. `add`
- - 追加新条目
- - 精确重复时跳过
- - 超限时返回当前条目和占用情况
-2. `replace`
- - 用 `old_text` 的短语义片段匹配条目并整体替换
- - 多条匹配时要求更精确的 `old_text`
-3. `remove`
- - 也是通过 `old_text` 的语义片段删除
- - 多条匹配时同样要求更精确匹配
-
-这里要采用“子串匹配”而不是 UUID,因为这更符合 LLM 的操作习惯。
-
-### 写入安全与并发安全
-
-新的 memory 层应保留 Hermes 这几个关键约束:
-
-1. 写入前扫描注入/渗透模式
-2. 在锁内重新从磁盘加载目标文件
-3. 做重复检测和字符上限检测
-4. 通过临时文件 + `os.replace()` 做原子写入
-
-也就是说,并发安全的关键不是“先读后写”,而是:
-
-`scan -> lock -> reload -> validate -> atomic write`
-
-### 冻结快照模式
-
-新的 memory 层必须采用 frozen snapshot,而不是“每次 memory 写入都改 system prompt”。
-
-规则是:
-
-1. 会话开始时,从磁盘加载 `memory` 和 `user`
-2. 立刻冻结成 system prompt snapshot
-3. 会话中写入 memory 时,只更新磁盘上的 live state
-4. 当前会话里的 system prompt 保持不变
-5. 下一个会话开始时,再重新加载最新 memory
-
-### session_search 取代“把所有过程细节塞进 memory”
-
-大量过程细节不应继续塞进 `memory`。
-
-因此新后端应该明确区分:
-
-1. `memory`
- - 保存小而精的、跨会话稳定有效的事实
-2. `session_search`
- - 检索历史会话
- - 支持“无 query 浏览最近会话”和“有 query 的全文搜索 + 摘要”
-
-这个能力后续应在 Beaver 中落成:
-
-- `beaver/memory/curated/*`
-- `beaver/memory/search/*`
-- `beaver/tools/builtins/memory.py`
-- `beaver/tools/builtins/session_search.py`
-
-### `ProcedureMemory` 的新定位
-
-这不表示 `ProcedureMemory` 没价值,而是它的地位要下降:
-
-1. `ProcedureMemory` 不再是主 memory 契约
-2. 它不应该直接承担“跨会话记忆”职责
-3. 它更适合作为 coordinator 内部的流程复用与路由优化层
-
-新的优先级应是:
-
-1. 用户偏好、纠正、环境事实 -> `memory`
-2. 历史会话细节 -> `session_search`
-3. 稳定方法论和工作法 -> `skills`
-4. 团队/流程复用优化 -> `ProcedureMemory`
-
-## 4.5 CLI 不再代表单 agent 模式,只保留为薄入口
-
-当前入口层太厚,后续应该改成:
-
-1. CLI 只做参数解析与 runtime 启动
-2. Web 只做 API 与 request/response 映射
-3. Gateway 只做渠道接入与消息转发
-
-所有核心能力都由统一的 application services 提供,例如:
-
-- `ChatApplicationService`
-- `DelegationApplicationService`
-- `TeamRunApplicationService`
-- `SkillApplicationService`
-- `MemoryApplicationService`
-
-同时要明确一条原则:
-
-CLI 不是“单 agent 专用模式”。
-
-它只是这些 interface 之一:
-
-- CLI
-- Web
-- Gateway
-- Channel
-
-无论从哪个入口进来,最终都进入同一套 `AgentLoop` / engine。
-
-这样就不会再出现“CLI 一套 agent,其他入口另一套 agent”的问题。
-
-## 5. 具体改动后会是什么样
-
-## 5.1 所有 agent 共用同一套 engine
-
-### 现在
-
-`CLI/Web/Gateway -> 各自装配一套 AgentLoop 或相关依赖`
-
-### 之后
-
-`CLI/Web/Gateway/Channel -> AgentEntryService -> AgentLoop(engine) -> tools/skills/memory/permissions/delegation`
-
-结果是:
-
-1. 主 agent、subagent、team member 复用同一套 engine。
-2. 装载逻辑只在 engine 内统一处理一次。
-3. 不再保留“CLI 单 agent 概念”。
-4. 测试可以直接测 engine 和 service,而不是分别测入口分支。
-
-## 5.2 多 agent 场景
-
-### 现在
-
-`TeamService.run_team -> TeamGraphScheduler -> LocalAgentRunner -> AgentLoop.process_direct / submit_direct`
-
-Task mode 内部已经变成:
-
-`AgentService._run_task_mode -> TaskExecutionPlanner -> optional TeamService.run_team -> 主 Agent synthesis run -> ValidationService`
-
-### 之后
-
-`TeamService`
-`-> strategy preset`
-`-> ExecutionGraph`
-`-> TeamGraphScheduler`
-`-> LocalAgentRunner / optional StrategyBackend`
-`-> NormalizedTeamResult`
-
-结果是:
-
-1. 团队能力不再绑定某个第三方 runtime 结构。
-2. v1 已经支持 `sequence / parallel / dag`。
-3. 可以逐步增加高级 preset 或第二种 backend,而不推翻平台层。
-3. `swarms` 只是其中一个可插拔执行器。
-
-## 5.3 skill 场景
-
-### 现在
-
-`SkillsLoader -> 读 SKILL.md -> 摘要注入 / 手动审核安装`
-
-### 之后
-
-`SkillCatalog`
-`-> SkillDraftStore`
-`-> SkillReviewService`
-`-> SkillPublisher`
-`-> SkillRuntimeResolver`
-
-结果是:
-
-1. skill 可以有版本。
-2. skill 可以从 procedure 生成。
-3. skill 可以审核和回滚。
-4. skill 可以做效果分析和推荐。
-
-## 5.4 运行学习场景
-
-### 现在
-
-新后端已经不再把复杂任务学习完全混在 session / memory / procedure 中。
-
-当前实际状态是:
-
-`Chat input`
-`-> MainAgentRouter`
-`-> simple answer 或 internal Task`
-`-> RunRecord + TaskEvent + ValidationResult`
-`-> /api/chat/feedback`
-`-> satisfied / revise / abandon`
-
-也就是说:
-
-1. Task 是复杂任务的内部执行容器。
-2. Run 仍是一次模型/tool loop 的执行收据。
-3. ValidationResult 是进入学习前的自动质量门。
-4. 用户反馈是成功学习和失败记忆的最终门控。
-
-### 之后
-
-`Run transcript`
-`-> session_search index`
-
-`Durable fact`
-`-> memory(add/replace/remove)`
-
-`Stable method / workaround / reusable workflow`
-`-> SkillCandidateGenerator`
-`-> SkillDraft`
-`-> Review`
-`-> Publish`
-
-`Repeated execution pattern`
-`-> optional ProcedureMemory`
-
-结果是:
-
-1. durable facts、历史细节、稳定方法三类信息终于分层。
-2. 自动学习不会把临时过程污染到主 memory。
-3. skills 仍是最高层指导系统,而 memory 变成受控 CRUD 系统。
-4. 成功 Skill 学习只能来自验证通过且用户满意的 Task。
-5. 放弃或验证失败只进入 Failure Memory / 风险记忆,不污染 published skill。
-
-## 6. 分阶段落地建议
-
-这次重构不应该一次性推翻,建议分四期做。
-
-### 第一期:边界清理
-
-目标:
-
-1. 把入口装配统一掉
-2. 把 `web/server.py` 开始拆分
-3. 先落地 Beaver 自有 Agent Team v1 core,避免继续依赖 vendored swarms
-
-交付物:
-
-- 统一 app factory / service wiring
-- 初步拆分 web routes
-- `coordinator/models.py / local.py / execution/scheduler.py`
-
-### 第二期:平台抽象固化
-
-目标:
-
-1. 定义 team / skill / memory / session_search 的正式模型
-2. 让上层只依赖平台模型
-
-交付物:
-
-- `AgentDescriptor / ExecutionGraph / TeamRunResult`
-- `SkillSpec`
-- `ExecutionPlan`
-- `MemoryEntry`
-- `MemorySnapshot`
-- `SessionSearchResult`
-- `SkillDraft`
-- `SkillVersion`
-
-### 第三期:skills 生命周期
-
-目标:
-
-1. 从“文档技能”升级到“版本化能力”
-2. 打通“稳定方法 -> SkillDraft”
-3. 按 Hermes 基线完成 memory CRUD、frozen snapshot、session_search
-
-这一期里的“学习/自进化”过程,建议始终按下面这条线施工:
-
-```text
-run
-│
-├─ receipt collection
-│ ├─ RunRecord
-│ ├─ SkillActivationReceipt
-│ └─ SkillEffectRecord
-│
-├─ evidence aggregation
-│ ├─ session transcript
-│ ├─ curated memory
-│ ├─ current published skill version
-│ └─ repeated user corrections / outcomes
-│
-├─ learning candidate generation
-│ ├─ new_skill
-│ ├─ revise_skill
-│ ├─ merge_skills
-│ └─ retire_skill
-│
-├─ draft lifecycle
-│ ├─ create draft
-│ ├─ review
-│ ├─ publish
-│ ├─ disable
-│ └─ rollback
-│
-└─ runtime use
- └─ 只暴露 published version 给运行时
-```
-
-交付物:
-
-- skill catalog
-- review/publish flow
-- runtime resolver
-- memory tool
-- session search tool
-
-### 第四期:高级多智能体能力
-
-目标:
-
-1. 放开更多正式支持的 strategy
-2. 评估 `GraphWorkflow`、`HeavySwarm`
-3. 增加 fallback / retry / policy routing
-
-交付物:
-
-- 完整 strategy registry
-- 多 backend 能力矩阵
-- team execution fallback
-
-## 7. 重构后的推荐目录
-
-下面这个目录我已经按你说的方向收紧了:
-
-1. 不保留 `third_party/`
-2. 不保留“CLI 单 agent”这类结构暗示
-3. 尽量参考 OpenHarness 那种按能力分组、观感更规整的布局
-4. 每个目录后面都加中文说明
-
-```text
-app-instance/backend/
-├── change.md # 这份重构蓝图
-├── README.md # 后端总说明
-├── workflow.md # 运行链路说明
-├── docs/ # 架构文档和迁移文档
-│ ├── architecture/ # 核心架构说明
-│ └── migration/ # 分阶段迁移计划
-├── beaver/
-│ ├── foundation/ # 最底层公共设施:配置、模型、事件、错误、工具函数
-│ │ ├── config/ # 配置定义与加载
-│ │ ├── models/ # 全局共享数据模型
-│ │ ├── events/ # 统一事件模型与事件派发
-│ │ ├── errors/ # 统一错误类型
-│ │ └── utils/ # 通用工具函数
-│ ├── engine/ # 统一 agent 内核,所有 agent 都复用这里
-│ │ ├── loop.py # AgentLoop 主循环与执行入口
-│ │ ├── loader.py # tools、skills、memory、permissions 的统一装载
-│ │ ├── context/ # 上下文拼装
-│ │ ├── session/ # 会话状态与持久化
-│ │ ├── providers/ # LLM provider 适配
-│ │ └── runtime/ # 运行时辅助对象与执行上下文
-│ ├── tools/ # 工具系统
-│ │ ├── registry/ # 工具注册与发现
-│ │ ├── builtins/ # 内置工具
-│ │ ├── mcp/ # MCP 工具适配
-│ │ └── policies/ # 工具权限与调用约束
-│ ├── skills/ # 技能系统
-│ │ ├── builtin/ # 内置技能内容
-│ │ ├── catalog/ # 技能目录、索引与查询
-│ │ ├── drafts/ # 自动生成或待审核的 skill draft
-│ │ ├── reviews/ # 技能审核流
-│ │ ├── publisher/ # 技能发布与版本切换
-│ │ └── resolver/ # 运行时技能解析与注入
-│ ├── memory/ # 记忆与经验沉淀系统
-│ │ ├── curated/ # Hermes 风格的 MEMORY / USER 持久记忆
-│ │ ├── search/ # session_search 与历史会话检索
-│ │ ├── runs/ # 单次执行记录
-│ │ ├── procedures/ # 可选的流程复用优化层
-│ │ └── stores/ # 底层存储与原子写实现
-│ ├── tasks/ # 内部 Task 系统:自动 Task 化、验证、反馈、失败记忆入口
-│ │ ├── models.py # TaskRecord / TaskEvent / ValidationResult
-│ │ ├── store.py # Task 文件存储
-│ │ ├── service.py # Task 状态机与反馈处理
-│ │ ├── router.py # MainAgentRouter simple/task 分类
-│ │ └── validation.py # LLM validator 与验证结果归一化
-│ ├── permissions/ # 权限、沙箱、治理规则
-│ │ ├── policies/ # 权限策略
-│ │ ├── guards/ # 执行前检查
-│ │ └── profiles/ # 不同 agent 运行权限画像
-│ ├── coordinator/ # 多 agent 协调层,参考 OpenHarness 的 coordinator 风格
-│ │ ├── models.py # AgentDescriptor / ExecutionGraph / TeamRunResult
-│ │ ├── local.py # LocalAgentRunner:复用主 AgentLoop
-│ │ ├── execution/ # sequence / parallel / dag 调度与聚合
-│ │ ├── backends/ # 后续可替换多 agent backend
-│ │ └── team/ # team 级模型 re-export / 后续高级编排对象
-│ ├── services/ # application services,对外提供统一能力入口
-│ │ ├── agent_service.py # 统一 agent 运行入口
-│ │ ├── team_service.py # 多 agent 执行入口
-│ │ ├── skill_service.py # 技能管理入口
-│ │ ├── memory_service.py # memory 查询与写入入口
-│ │ └── admin_service.py # 平台管理入口
-│ ├── interfaces/ # 薄入口层,不承载核心业务
-│ │ ├── cli/ # CLI 入口,只负责把请求送进 services/engine
-│ │ ├── web/ # FastAPI 接口层
-│ │ │ ├── app.py # Web app factory
-│ │ │ ├── routes/ # 路由拆分
-│ │ │ ├── schemas/ # Web 请求/响应模型
-│ │ │ └── deps.py # Web 依赖装配
-│ │ ├── gateway/ # 常驻 worker / gateway 入口
-│ │ └── channels/ # Telegram/Slack/Email 等渠道入口
-│ ├── integrations/ # 外部系统与协议集成
-│ │ ├── a2a/ # A2A 协议与 client
-│ │ ├── mcp/ # MCP 连接与管理
-│ │ ├── outlook/ # Outlook 集成
-│ │ ├── whatsapp/ # WhatsApp bridge 适配
-│ │ └── providers/ # 外部 provider 特定集成
-│ ├── plugins/ # 插件系统
-│ │ ├── loader.py # 插件发现与装载
-│ │ ├── registry.py # 插件注册表
-│ │ └── hooks.py # 插件 hooks
-│ └── templates/ # 默认模板、system prompt 模板、内置文本资源
-├── tests/ # 测试
-│ ├── unit/ # 单元测试
-│ ├── integration/ # 集成测试
-│ ├── e2e/ # 端到端测试
-│ └── fixtures/ # 测试数据与夹具
-└── bridge/ # 独立 Node/bridge 代码,作为外部桥接层保留
-```
-
-## 8. 最终结论
-
-这次重构的本质不是“把代码拆小一点”,而是完成三件事:
-
-1. 把当前项目从“围绕 `AgentLoop` 生长的单体系统”升级成“所有 agent 共用一个 engine 的可维护 harness 平台”。
-2. 把 `swarms` 从“放在 `third_party/` 里的深耦合运行时”降级成“可替换的多智能体 backend”。
-3. 把 `skills` 从“静态 Markdown 包”升级成“可学习、可审核、可发布、可回滚的能力系统”。
-
-如果这三件事做成了,后面再扩多智能体架构、自动学习、插件生态、外部接入,代码就不会继续失控。
-
----
-
-## 9. 最新落地状态:Task Team 后三件套
-
-本轮已经把 Task Team 融合后的三个缺口推进到 v1 可用状态:
-
-1. **Task Sub-agent Skill Resolver**
- - 新增 `beaver/tasks/skill_resolver.py`。
- - sub-agent 是临时 generic worker,不承载固定角色人设。
- - `TaskExecutionPlanner` 的 team node 输出 `skill_query / required_capabilities / expected_output`。
- - `TaskSkillResolver` 从 published skill catalog 中选择合适 skill,并写入 node pinned skills。
- - 如果没有命中 published skill,会创建 draft-only skill,并把 draft 内容作为本次 sub-agent 的 ephemeral pinned skill context 使用。
- - draft 不自动 approve/publish,不进入 runtime catalog;后续仍走 review/publish。
- - agent registry / target resolver 不参与 Task sub-agent strategy,可作为未来外部 agent/A2A 管理面保留。
-
-2. **Task Team Process Projection**
- - Task attempt 隐藏事件增加 `skill_queries / selected_skill_names / generated_skill_draft_ids / skill_resolution_report / node_results / task_synthesis_completed`。
- - 新增 `GET /api/sessions/{session_id}/process`。
- - 前端 `ChatWorkbench` 已接入 `ProcessLane` 和移动端 `Process` tab。
- - 展示规划、skill selection、draft-only ephemeral guidance、team node、main synthesis、validation/retry,不把 team summary 直接当最终回答。
-
-3. **Learning Pipeline 闭环**
- - 新增 `SkillLearningPipelineService`。
- - Web API 覆盖 candidates、drafts、submit、approve、reject、publish、disable、rollback。
- - `/skills` 页面增加 Published / Candidates / Drafts tabs。
- - publish 仍要求 approved draft;rejected draft 不可 publish;draft 不进入 runtime catalog。
-
-验证状态:
-
-- 后端:`76 passed`。
-- 前端:`npm run typecheck` 通过,`npm test` 通过,`npm run lint` 通过但仍有既有 warnings。
diff --git a/app-instance/backend-old/core_agent_lines.sh b/app-instance/backend-old/core_agent_lines.sh
deleted file mode 100755
index 3f5301a..0000000
--- a/app-instance/backend-old/core_agent_lines.sh
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/bin/bash
-# Count core agent lines (excluding channels/, cli/, providers/ adapters)
-cd "$(dirname "$0")" || exit 1
-
-echo "nanobot core agent line count"
-echo "================================"
-echo ""
-
-for dir in agent agent/tools bus config cron heartbeat session utils; do
- count=$(find "nanobot/$dir" -maxdepth 1 -name "*.py" -exec cat {} + | wc -l)
- printf " %-16s %5s lines\n" "$dir/" "$count"
-done
-
-root=$(cat nanobot/__init__.py nanobot/__main__.py | wc -l)
-printf " %-16s %5s lines\n" "(root)" "$root"
-
-echo ""
-total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/providers/*" | xargs cat | wc -l)
-echo " Core total: $total lines"
-echo ""
-echo " (excludes: channels/, cli/, providers/)"
diff --git a/app-instance/backend-old/docker-compose.yml b/app-instance/backend-old/docker-compose.yml
deleted file mode 100644
index 5c27f81..0000000
--- a/app-instance/backend-old/docker-compose.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-x-common-config: &common-config
- build:
- context: .
- dockerfile: Dockerfile
- volumes:
- - ~/.nanobot:/root/.nanobot
-
-services:
- nanobot-gateway:
- container_name: nanobot-gateway
- <<: *common-config
- command: ["gateway"]
- restart: unless-stopped
- ports:
- - 18790:18790
- deploy:
- resources:
- limits:
- cpus: '1'
- memory: 1G
- reservations:
- cpus: '0.25'
- memory: 256M
-
- nanobot-cli:
- <<: *common-config
- profiles:
- - cli
- command: ["status"]
- stdin_open: true
- tty: true
diff --git a/app-instance/backend-old/guide.md b/app-instance/backend-old/guide.md
deleted file mode 100644
index ad5c79f..0000000
--- a/app-instance/backend-old/guide.md
+++ /dev/null
@@ -1,143 +0,0 @@
-# Boardware Genius 前后端分离启动指南(单用户直连)
-
-本指南对应当前仓库:
-`/home/ivan/xuan/steven_project/nanobot`
-
-## 1. 环境准备
-
-- Python: `>=3.11`
-- Node.js: `>=18`
-- 包管理工具: `uv`、`npm`
-
-在项目根目录执行:
-
-```bash
-cd /home/ivan/xuan/steven_project/nanobot
-uv sync
-```
-
-如果你第一次使用 Boardware Genius,需要先初始化:
-
-```bash
-./.venv/bin/python -m nanobot onboard
-```
-
-然后编辑配置文件(至少配置一个可用模型):
-
-- `~/.nanobot/config.json`
-
-## 2. 启动后端(Web API)
-
-在项目根目录执行:
-
-```bash
-cd /home/ivan/xuan/steven_project/nanobot
-./.venv/bin/python -m nanobot web --host 127.0.0.1 --port 10000
-```
-
-启动成功后会看到类似日志:
-
-- `Uvicorn running on http://127.0.0.1:10000`
-
-可用接口示例:
-
-- `GET http://127.0.0.1:10000/api/status`
-
-### 2.1 准备登录账号 JSON(必需)
-
-Web 登录会读取本地账号文件,默认路径:
-
-- `/home/ivan/xuan/steven_project/nanobot/web_auth_users.json`
-
-示例内容(任选一种格式):
-
-```json
-{
- "users": [
- { "username": "admin", "password": "123456" }
- ]
-}
-```
-
-```json
-{
- "admin": "123456",
- "alice": "alice_pwd"
-}
-```
-
-也可通过环境变量指定自定义路径:
-
-```bash
-export NANOBOT_AUTH_FILE=/your/path/users.json
-```
-
-## 3. 启动前端(Next.js)
-
-新开一个终端,执行:
-
-```bash
-cd /home/ivan/xuan/steven_project/nanobot/frontend
-cp env_template .env.local
-npm install
-npm run dev
-```
-
-前端默认地址:
-
-- `http://127.0.0.1:3080`
-
-前端默认会请求:
-
-- `NEXT_PUBLIC_API_URL=http://127.0.0.1:10000`
-
-注意:如果你之前已经有 `frontend/.env.local`,请确认里面不是旧地址(例如 `localhost:8080`)。
-
-如果你要改后端地址,修改:
-
-- `frontend/.env.local`
-
-## 4. 访问与验证
-
-1. 打开 `http://127.0.0.1:3080`
-2. 首屏应进入登录页
-3. 使用 `web_auth_users.json` 中正确的账号密码登录
-4. 登录成功后进入对话页并可正常收发消息
-
-## 5. 常见问题
-
-### 5.1 前端显示“未连接/服务离线”
-
-按顺序检查:
-
-1. 后端是否在运行(终端是否有 `Uvicorn running ...`)
-2. 前端 `NEXT_PUBLIC_API_URL` 是否指向正确地址
-3. 端口是否被占用(`10000` / `3080`)
-
-### 5.2 后端启动报 `No module named fastapi`
-
-在项目根目录重新执行:
-
-### 5.3 反向代理下登录后跳错前端域名
-
-如果 API 域名和主前端域名不同,启动 backend 前显式设置主前端公开地址:
-
-```bash
-export NANOBOT_FRONTEND_PUBLIC_BASE_URL=https://nanobot.bwgdi.com
-```
-
-这样登录/注册成功后,backend 返回的 `frontend_base_url` 会固定为这个公开域名,而不是按 API 域名去拼 `:3080`。
-
-```bash
-uv sync
-```
-
-### 5.3 需要开发测试工具(pytest/ruff)
-
-```bash
-uv sync --extra dev
-```
-
-## 6. 停止服务
-
-- 在各自终端按 `Ctrl + C` 即可停止。
diff --git a/app-instance/backend-old/nanobot/__init__.py b/app-instance/backend-old/nanobot/__init__.py
deleted file mode 100644
index 4663bf5..0000000
--- a/app-instance/backend-old/nanobot/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-"""
-Boardware Genius - A lightweight AI agent framework
-"""
-
-__version__ = "0.1.4"
-__brand__ = "Boardware Genius"
-__logo__ = ""
diff --git a/app-instance/backend-old/nanobot/__main__.py b/app-instance/backend-old/nanobot/__main__.py
deleted file mode 100644
index c7f5620..0000000
--- a/app-instance/backend-old/nanobot/__main__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""
-Entry point for running nanobot as a module: python -m nanobot
-"""
-
-from nanobot.cli.commands import app
-
-if __name__ == "__main__":
- app()
diff --git a/app-instance/backend-old/nanobot/a2a/__init__.py b/app-instance/backend-old/nanobot/a2a/__init__.py
deleted file mode 100644
index 9f19bf4..0000000
--- a/app-instance/backend-old/nanobot/a2a/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""A2A helpers."""
-
-from nanobot.a2a.client import A2AClient
-
-__all__ = ["A2AClient"]
diff --git a/app-instance/backend-old/nanobot/a2a/client.py b/app-instance/backend-old/nanobot/a2a/client.py
deleted file mode 100644
index d3516cd..0000000
--- a/app-instance/backend-old/nanobot/a2a/client.py
+++ /dev/null
@@ -1,1216 +0,0 @@
-"""A2A 客户端实现。
-
-目标不是完整覆盖所有厂商变体,而是提供一条足够稳的兼容链路:
-1. 先拉 agent card,解析可用端点和偏好传输;
-2. 优先尝试流式订阅,拿到实时进度;
-3. 流式不可用或中断时,回退到轮询;
-4. 同时兼容 JSON-RPC 和 HTTP+JSON 风格接口。
-"""
-
-from __future__ import annotations
-
-import asyncio
-import json
-import os
-import time
-import uuid
-from collections.abc import Awaitable, Callable
-from dataclasses import dataclass, field
-from typing import Any
-from urllib.parse import urlparse, urlunparse
-
-import httpx
-
-from nanobot.agent.agent_registry import AgentDescriptor
-from nanobot.agent.run_result import AgentRunResult, has_meaningful_summary
-
-
-class A2AError(RuntimeError):
- """A2A 请求失败时抛出的统一异常。"""
-
-
-class A2AUnsupportedMethodError(A2AError):
- """远端端点不支持某个方法时抛出的异常。"""
-
-
-@dataclass
-class A2AStreamEvent:
- """A2A 订阅流事件的归一化表示。"""
-
- # 事件类型,例如 task / message / status-update / artifact-update。
- kind: str
- # 远端任务 ID;一旦出现,上层就可以登记用于取消或恢复订阅。
- task_id: str | None = None
- # 归一化后的状态文本。
- status: str | None = None
- # 适合展示给用户的增量文本。
- text: str | None = None
- # 是否已到达终态。
- final: bool = False
- # 原始事件体,便于调试和后续扩展。
- raw: dict[str, Any] | None = None
-
-
-@dataclass
-class _StreamState:
- """流式任务状态累加器。"""
-
- task_id: str | None = None
- status: str = "working"
- artifacts: dict[str, str] = field(default_factory=dict)
- artifact_order: list[str] = field(default_factory=list)
- messages: list[str] = field(default_factory=list)
- status_messages: list[str] = field(default_factory=list)
- latest_result: dict[str, Any] | None = None
- final_seen: bool = False
-
- def apply(self, result: dict[str, Any], client: A2AClient) -> A2AStreamEvent:
- """吸收一条原始结果并产出归一化流事件。"""
- self.latest_result = result
- kind = str(result.get("kind") or result.get("type") or "result").lower()
- task_id = str(result.get("id") or result.get("taskId") or "").strip() or None
- if task_id:
- self.task_id = task_id
-
- raw_status = result.get("status")
- normalized_status = client._normalize_status(raw_status)
- # 非 ok 状态会覆盖当前状态;否则在 task/status-update 终态时再更新。
- if normalized_status not in {"", "ok"}:
- self.status = normalized_status
- elif kind in {"task", "status-update"} and client._is_terminal_status(raw_status):
- self.status = client._normalize_status(raw_status)
-
- text = ""
- if kind == "artifact-update":
- # artifact-update 需要增量拼接同一个 artifact 的文本内容。
- text = self._apply_artifact_update(result, client)
- elif kind == "status-update":
- # 某些实现把状态消息放在 status 里,有些放在 message 里,这里都兜一遍。
- text = client._extract_text(result.get("status")) or client._extract_text(
- result.get("message")
- )
- self._append_unique(self.status_messages, text)
- elif kind in {"message", "task"}:
- self._apply_task_or_message(result, client)
- text = client._extract_text(result)
- else:
- text = client._extract_text(result)
-
- final = bool(result.get("final")) or client._is_terminal_status(raw_status)
- if final:
- self.final_seen = True
- if self.status == "working":
- # 即使没拿到更明确状态,也尽量用终态把 working 覆盖掉。
- self.status = client._normalize_status(raw_status)
- if text and kind not in {"artifact-update", "message", "task"}:
- self._append_unique(self.messages, text)
-
- return A2AStreamEvent(
- kind=kind,
- task_id=self.task_id,
- status=self.status,
- text=text or None,
- final=final,
- raw=result,
- )
-
- def build_summary(self, client: A2AClient) -> str:
- """按 artifact -> message -> status 的优先级生成最终摘要。"""
- artifact_text = "\n".join(
- self.artifacts[artifact_id]
- for artifact_id in self.artifact_order
- if self.artifacts.get(artifact_id)
- ).strip()
- if artifact_text:
- return artifact_text
-
- message_text = "\n".join(text for text in self.messages if text).strip()
- if message_text:
- return message_text
-
- status_text = "\n".join(text for text in self.status_messages if text).strip()
- if status_text:
- return status_text
-
- if self.latest_result:
- return client._extract_text(self.latest_result)
- return ""
-
- def _apply_artifact_update(self, result: dict[str, Any], client: A2AClient) -> str:
- """把一条 artifact-update 事件并入累积状态。"""
- artifact = result.get("artifact")
- if not isinstance(artifact, dict):
- artifact = result
- artifact_id = str(
- artifact.get("artifactId")
- or artifact.get("id")
- or result.get("artifactId")
- or f"artifact-{len(self.artifact_order) + 1}"
- )
- text = client._extract_text(artifact)
- if not text:
- return ""
-
- if artifact_id not in self.artifacts:
- self.artifacts[artifact_id] = ""
- self.artifact_order.append(artifact_id)
-
- # append=true 时做增量拼接,否则视为完整覆盖。
- if result.get("append") or artifact.get("append"):
- self.artifacts[artifact_id] += text
- else:
- self.artifacts[artifact_id] = text
- return text
-
- def _apply_task_or_message(self, result: dict[str, Any], client: A2AClient) -> None:
- """把 task/message 类型结果中的 artifact 和文本提取出来。"""
- artifacts = result.get("artifacts")
- if isinstance(artifacts, list):
- for index, artifact in enumerate(artifacts):
- if not isinstance(artifact, dict):
- continue
- artifact_id = str(
- artifact.get("artifactId")
- or artifact.get("id")
- or f"artifact-{len(self.artifact_order) + index + 1}"
- )
- text = client._extract_text(artifact)
- if not text:
- continue
- if artifact_id not in self.artifacts:
- self.artifact_order.append(artifact_id)
- self.artifacts[artifact_id] = text
-
- text = client._extract_text(result)
- self._append_unique(self.messages, text)
-
- @staticmethod
- def _append_unique(collection: list[str], text: str) -> None:
- """仅当文本与上一个不同才追加,避免流式重复刷屏。"""
- if text and (not collection or collection[-1] != text):
- collection.append(text)
-
-
-@dataclass(frozen=True)
-class _A2ATransportTarget:
- """解析后的远端传输目标。"""
-
- mode: str
- endpoint: str
-
-
-class A2AClient:
- """支持 JSON-RPC 与 HTTP+JSON 回退链路的 A2A 客户端。"""
-
- def __init__(
- self,
- timeout_seconds: int = 600,
- poll_interval_seconds: int = 2,
- card_cache_ttl_seconds: int = 300,
- allowed_hosts: list[str] | None = None,
- transport: httpx.AsyncBaseTransport | None = None,
- authz_config: Any | None = None,
- backend_identity: Any | None = None,
- ):
- # 这些参数决定超时、轮询频率和安全边界。
- self.timeout_seconds = timeout_seconds
- self.poll_interval_seconds = poll_interval_seconds
- self.card_cache_ttl_seconds = card_cache_ttl_seconds
- self.allowed_hosts = {host.lower() for host in (allowed_hosts or []) if host}
- self.transport = transport
- self.authz_config = authz_config
- self.backend_identity = backend_identity
- self._card_cache: dict[str, tuple[float, dict[str, Any]]] = {}
-
- async def run_task(
- self,
- agent: AgentDescriptor,
- task: str,
- label: str | None = None,
- event_callback: Callable[[A2AStreamEvent], Awaitable[None]] | None = None,
- task_callback: Callable[[str], Awaitable[None]] | None = None,
- prefer_streaming: bool = True,
- ) -> AgentRunResult:
- """执行一次远端 A2A 任务。"""
- card = await self.fetch_agent_card(agent)
- params = self._build_message_params(task, label)
- targets = self._resolve_transport_targets(card, agent)
- if not targets:
- raise A2AError(f"Agent '{agent.id}' does not expose a supported A2A endpoint")
-
- last_unsupported: Exception | None = None
- for target in targets:
- try:
- # 若 card 支持流式,则优先尝试流式以获取中间态。
- if prefer_streaming and self._supports_streaming(card):
- stream_result = await self._run_task_streaming(
- target=target,
- params=params,
- agent=agent,
- event_callback=event_callback,
- task_callback=task_callback,
- )
- if stream_result is not None:
- return stream_result
-
- # 流式不可用时回退到普通 send,再视状态决定是否轮询。
- result = await self._send_task(target, params, agent)
- if self._is_task_result(result) and not self._is_terminal_status(result.get("status")):
- task_id = str(result.get("id") or result.get("taskId") or "").strip()
- if task_id:
- if task_callback:
- await task_callback(task_id)
- result = await self._poll_task(target, task_id, agent)
-
- return self._build_run_result(agent, result)
- except A2AUnsupportedMethodError as exc:
- last_unsupported = exc
- continue
-
- if last_unsupported:
- raise last_unsupported
- raise A2AError(f"Agent '{agent.id}' does not expose a usable A2A endpoint")
-
- async def fetch_agent_card(self, agent: AgentDescriptor) -> dict[str, Any]:
- """拉取远端 agent card,并带本地 TTL 缓存。"""
- _, card = await self.fetch_agent_card_with_url(agent)
- return card
-
- async def fetch_agent_card_with_url(self, agent: AgentDescriptor) -> tuple[str, dict[str, Any]]:
- """拉取远端 agent card,并返回命中的 card URL。"""
- urls = self._candidate_card_urls(agent)
- last_error: Exception | None = None
- for url in urls:
- cache_key = url.lower()
- cached = self._card_cache.get(cache_key)
- if cached and cached[0] > time.monotonic():
- return url, cached[1]
-
- try:
- card = await self._fetch_json(url, agent)
- except Exception as exc:
- last_error = exc
- continue
-
- if isinstance(card, dict):
- self._card_cache[cache_key] = (
- time.monotonic() + self.card_cache_ttl_seconds,
- card,
- )
- return url, card
-
- if last_error:
- raise A2AError(f"Failed to fetch agent card for '{agent.id}': {last_error}")
- raise A2AError(f"Failed to fetch agent card for '{agent.id}'")
-
- def invalidate_card(self, agent: AgentDescriptor) -> None:
- """清空某个 agent 相关的 card 缓存。"""
- for url in self._candidate_card_urls(agent):
- self._card_cache.pop(url.lower(), None)
-
- def _candidate_card_urls(self, agent: AgentDescriptor) -> list[str]:
- """根据 agent 配置推导一组候选 card URL。"""
- urls: list[str] = []
- if agent.card_url:
- urls.append(agent.card_url)
- base_url = str(agent.base_url or agent.endpoint or "").rstrip("/")
- if base_url:
- urls.extend(
- [
- f"{base_url}/.well-known/agent-card",
- f"{base_url}/.well-known/agent-card.json",
- f"{base_url}/.well-known/agent.json",
- ]
- )
- deduped: list[str] = []
- seen: set[str] = set()
- for url in urls:
- normalized = url.strip()
- if not normalized or normalized.lower() in seen:
- continue
- seen.add(normalized.lower())
- deduped.append(normalized)
- return deduped
-
- async def _run_task_streaming(
- self,
- target: _A2ATransportTarget,
- params: dict[str, Any],
- agent: AgentDescriptor,
- event_callback: Callable[[A2AStreamEvent], Awaitable[None]] | None,
- task_callback: Callable[[str], Awaitable[None]] | None,
- ) -> AgentRunResult | None:
- """优先尝试流式方法执行任务,失败时可回退为 None。"""
- if target.mode == "rest":
- stream_variants = [("message/stream", params)]
- else:
- stream_variants = [
- ("tasks/sendSubscribe", {"id": str(uuid.uuid4()), **params}),
- ("message/stream", params),
- ]
-
- saw_supported_stream = False
- last_error: Exception | None = None
- for method, payload in stream_variants:
- state = _StreamState()
- try:
- # 每个流式方法都独立尝试;一旦成功拿到终态结果就直接返回。
- return await self._consume_stream_method(
- target=target,
- method=method,
- params=payload,
- agent=agent,
- event_callback=event_callback,
- task_callback=task_callback,
- state=state,
- allow_resume=True,
- )
- except A2AUnsupportedMethodError as exc:
- last_error = exc
- continue
- except A2AError as exc:
- # 已经跑到一半但中断时,如果拿到了 task_id,就尝试恢复订阅或轮询。
- saw_supported_stream = True
- last_error = exc
- if state.task_id:
- try:
- return await self._resume_or_poll(
- target=target,
- agent=agent,
- task_id=state.task_id,
- state=state,
- event_callback=event_callback,
- task_callback=task_callback,
- )
- except A2AError as resume_exc:
- last_error = resume_exc
- continue
- else:
- saw_supported_stream = True
-
- if saw_supported_stream and last_error:
- raise last_error
- return None
-
- async def _consume_stream_method(
- self,
- target: _A2ATransportTarget,
- method: str,
- params: dict[str, Any],
- agent: AgentDescriptor,
- event_callback: Callable[[A2AStreamEvent], Awaitable[None]] | None,
- task_callback: Callable[[str], Awaitable[None]] | None,
- state: _StreamState,
- allow_resume: bool,
- ) -> AgentRunResult:
- """消费一个具体流式方法,直到拿到终态结果。"""
- saw_event = False
- seen_task_id: str | None = state.task_id
- try:
- async for body in self._stream_request(target, method, params, agent):
- saw_event = True
- result = self._unwrap_result_object(body)
- event = state.apply(result, self)
- # 首次看到 task_id 时通知上层登记,以便取消或恢复。
- if task_callback and event.task_id and event.task_id != seen_task_id:
- seen_task_id = event.task_id
- await task_callback(event.task_id)
- if event_callback:
- await event_callback(event)
- if event.final:
- return self._build_run_result(agent, result, state)
- except (httpx.ReadError, httpx.RemoteProtocolError, httpx.TimeoutException) as exc:
- if not state.task_id:
- raise A2AError(str(exc)) from exc
-
- if state.final_seen:
- return self._build_run_result(agent, state.latest_result or {}, state)
- if allow_resume and state.task_id:
- # 流结束但还没终态时,尝试恢复订阅或退化成轮询。
- return await self._resume_or_poll(
- target=target,
- agent=agent,
- task_id=state.task_id,
- state=state,
- event_callback=event_callback,
- task_callback=task_callback,
- )
- if saw_event:
- raise A2AError("A2A stream ended before a final result was received")
- raise A2AUnsupportedMethodError(method)
-
- async def _resume_or_poll(
- self,
- target: _A2ATransportTarget,
- agent: AgentDescriptor,
- task_id: str,
- state: _StreamState,
- event_callback: Callable[[A2AStreamEvent], Awaitable[None]] | None,
- task_callback: Callable[[str], Awaitable[None]] | None,
- ) -> AgentRunResult:
- """在流式中断后尝试恢复订阅,失败则退化为轮询。"""
- try:
- return await self._consume_stream_method(
- target=target,
- method="tasks/subscribe" if target.mode == "rest" else "tasks/resubscribe",
- params={"id": task_id},
- agent=agent,
- event_callback=event_callback,
- task_callback=task_callback,
- state=state,
- allow_resume=False,
- )
- except A2AUnsupportedMethodError:
- result = await self._poll_task(target, task_id, agent)
- return self._build_run_result(agent, result, state)
-
- async def cancel_task(self, agent: AgentDescriptor, task_id: str) -> bool:
- """尽力取消一个远端 A2A 任务。"""
- task_id = task_id.strip()
- if not task_id:
- return False
- card = await self.fetch_agent_card(agent)
- targets = self._resolve_transport_targets(card, agent)
- if not targets:
- raise A2AError(f"Agent '{agent.id}' does not expose a supported A2A endpoint")
- for target in targets:
- try:
- if target.mode == "rest":
- # REST 风格通常使用 `/tasks/{id}:cancel`。
- await self._request_json(
- "POST",
- self._rest_endpoint(target.endpoint, f"/tasks/{task_id}:cancel"),
- agent,
- json_body={"name": f"tasks/{task_id}"},
- )
- else:
- # JSON-RPC 风格使用 `tasks/cancel`。
- await self._rpc_jsonrpc(target.endpoint, "tasks/cancel", {"id": task_id}, agent)
- return True
- except A2AUnsupportedMethodError:
- continue
- return False
-
- async def _send_task(
- self,
- target: _A2ATransportTarget,
- params: dict[str, Any],
- agent: AgentDescriptor,
- ) -> dict[str, Any]:
- """发送一次非流式任务请求。"""
- if target.mode == "rest":
- body = await self._request_json(
- "POST",
- self._rest_endpoint(target.endpoint, "/message:send"),
- agent,
- json_body=self._build_rest_payload(params),
- )
- return self._unwrap_result_object(body)
-
- send_variants = [
- ("tasks/send", {"id": str(uuid.uuid4()), **params}),
- ("message/send", params),
- ]
- last_error: Exception | None = None
- for method, payload in send_variants:
- try:
- response = await self._rpc_jsonrpc(target.endpoint, method, payload, agent)
- return self._unwrap_result_object(response)
- except A2AUnsupportedMethodError as exc:
- last_error = exc
- continue
-
- raise last_error or A2AError("No supported A2A send method found")
-
- async def _poll_task(
- self,
- target: _A2ATransportTarget,
- task_id: str,
- agent: AgentDescriptor,
- ) -> dict[str, Any]:
- """轮询远端 task,直到进入终态或超时。"""
- deadline = time.monotonic() + self.timeout_seconds
- while time.monotonic() < deadline:
- if target.mode == "rest":
- body = await self._request_json(
- "GET",
- self._rest_endpoint(target.endpoint, f"/tasks/{task_id}"),
- agent,
- )
- result = self._unwrap_result_object(body)
- else:
- response = await self._rpc_jsonrpc(target.endpoint, "tasks/get", {"id": task_id}, agent)
- result = self._unwrap_result_object(response)
-
- if self._is_terminal_status(result.get("status")):
- return result
- await asyncio.sleep(self.poll_interval_seconds)
-
- raise A2AError(
- f"A2A task '{task_id}' timed out after {self.timeout_seconds} seconds"
- )
-
- async def _fetch_json(self, url: str, agent: AgentDescriptor) -> dict[str, Any]:
- """以 GET 方式拉取 JSON 对象。"""
- self._check_allowed_host(url)
- async with httpx.AsyncClient(
- timeout=self.timeout_seconds,
- transport=self.transport,
- ) as client:
- response = await client.get(url, headers=await self._build_headers(agent))
- response.raise_for_status()
- payload = response.json()
- if not isinstance(payload, dict):
- raise A2AError("Agent card response must be a JSON object")
- return payload
-
- async def _rpc_jsonrpc(
- self,
- endpoint: str,
- method: str,
- params: dict[str, Any],
- agent: AgentDescriptor,
- ) -> dict[str, Any]:
- """发送一条 JSON-RPC 请求。"""
- self._check_allowed_host(endpoint)
- payload = {
- "jsonrpc": "2.0",
- "id": str(uuid.uuid4()),
- "method": method,
- "params": params,
- }
-
- async with httpx.AsyncClient(
- timeout=self.timeout_seconds,
- transport=self.transport,
- ) as client:
- try:
- response = await client.post(
- endpoint,
- json=payload,
- headers=await self._build_headers(agent),
- )
- response.raise_for_status()
- except httpx.HTTPStatusError as exc:
- if exc.response.status_code in {404, 405, 501}:
- raise A2AUnsupportedMethodError(method) from exc
- raise A2AError(str(exc)) from exc
- body = response.json()
-
- if not isinstance(body, dict):
- raise A2AError("A2A RPC response must be a JSON object")
-
- error = body.get("error")
- if isinstance(error, dict):
- code = error.get("code")
- message = str(error.get("message") or "unknown error")
- if code == -32601 or "not found" in message.lower():
- raise A2AUnsupportedMethodError(message)
- raise A2AError(message)
-
- return body
-
- async def _stream_request(
- self,
- target: _A2ATransportTarget,
- method: str,
- params: dict[str, Any],
- agent: AgentDescriptor,
- ):
- """根据 transport mode 选择具体流式实现。"""
- if target.mode == "rest":
- async for body in self._stream_rest(target.endpoint, method, params, agent):
- yield body
- return
-
- async for body in self._stream_jsonrpc(target.endpoint, method, params, agent):
- yield body
-
- async def _stream_jsonrpc(
- self,
- endpoint: str,
- method: str,
- params: dict[str, Any],
- agent: AgentDescriptor,
- ):
- """通过 JSON-RPC 流式接口接收事件。"""
- self._check_allowed_host(endpoint)
- payload = {
- "jsonrpc": "2.0",
- "id": str(uuid.uuid4()),
- "method": method,
- "params": params,
- }
-
- async with httpx.AsyncClient(
- timeout=self.timeout_seconds,
- transport=self.transport,
- ) as client:
- try:
- async with client.stream(
- "POST",
- endpoint,
- json=payload,
- headers=await self._build_headers(agent),
- ) as response:
- try:
- response.raise_for_status()
- except httpx.HTTPStatusError as exc:
- if exc.response.status_code in {404, 405, 501}:
- raise A2AUnsupportedMethodError(method) from exc
- raise A2AError(str(exc)) from exc
-
- content_type = response.headers.get("content-type", "").lower()
- if "text/event-stream" in content_type:
- # 标准 SSE 按 event/data 行拼装;这里只消费 data 载荷。
- async for raw_event in self._iter_sse_events(response):
- if raw_event.strip() == "[DONE]":
- break
- yield self._parse_stream_body(raw_event)
- else:
- body = await response.aread()
- if not body:
- return
- yield self._parse_stream_body(body.decode("utf-8"))
- except httpx.HTTPStatusError as exc:
- if exc.response.status_code in {404, 405, 501}:
- raise A2AUnsupportedMethodError(method) from exc
- raise A2AError(str(exc)) from exc
-
- async def _stream_rest(
- self,
- endpoint: str,
- method: str,
- params: dict[str, Any],
- agent: AgentDescriptor,
- ):
- """通过 REST 风格流式接口接收事件。"""
- if method == "message/stream":
- requests = [
- (
- "POST",
- self._rest_endpoint(endpoint, "/message:stream"),
- self._build_rest_payload(params),
- )
- ]
- elif method == "tasks/subscribe":
- task_id = str(params.get("id") or "").strip()
- if not task_id:
- raise A2AError("Missing task id for REST task subscribe")
- subscribe_url = self._rest_endpoint(endpoint, f"/tasks/{task_id}:subscribe")
- requests = [("GET", subscribe_url, None), ("POST", subscribe_url, None)]
- else:
- raise A2AUnsupportedMethodError(method)
-
- last_error: Exception | None = None
- for http_method, url, payload in requests:
- self._check_allowed_host(url)
- async with httpx.AsyncClient(
- timeout=self.timeout_seconds,
- transport=self.transport,
- ) as client:
- try:
- async with client.stream(
- http_method,
- url,
- json=payload,
- headers=await self._build_headers(agent),
- ) as response:
- try:
- response.raise_for_status()
- except httpx.HTTPStatusError as exc:
- if self._is_unsupported_http_error(exc, allow_validation_errors=True):
- raise A2AUnsupportedMethodError(method) from exc
- raise A2AError(self._http_error_message(exc)) from exc
-
- content_type = response.headers.get("content-type", "").lower()
- if "text/event-stream" in content_type:
- async for raw_event in self._iter_sse_events(response):
- if raw_event.strip() == "[DONE]":
- break
- yield self._parse_stream_body(raw_event)
- return
-
- body = await response.aread()
- if not body:
- return
- yield self._parse_stream_body(body.decode("utf-8"))
- return
- except A2AUnsupportedMethodError as exc:
- last_error = exc
- continue
-
- raise last_error or A2AUnsupportedMethodError(method)
-
- async def _request_json(
- self,
- http_method: str,
- url: str,
- agent: AgentDescriptor,
- *,
- json_body: dict[str, Any] | None = None,
- params: dict[str, str] | None = None,
- ) -> dict[str, Any]:
- """发送普通 HTTP JSON 请求。"""
- self._check_allowed_host(url)
- async with httpx.AsyncClient(
- timeout=self.timeout_seconds,
- transport=self.transport,
- ) as client:
- try:
- response = await client.request(
- http_method,
- url,
- json=json_body,
- params=params,
- headers=await self._build_headers(agent),
- )
- response.raise_for_status()
- except httpx.HTTPStatusError as exc:
- if self._is_unsupported_http_error(exc):
- raise A2AUnsupportedMethodError(url) from exc
- raise A2AError(self._http_error_message(exc)) from exc
-
- try:
- body = response.json()
- except json.JSONDecodeError as exc:
- raise A2AError(f"Invalid JSON response from {url}") from exc
-
- if not isinstance(body, dict):
- raise A2AError("A2A response must be a JSON object")
- return body
-
- async def _iter_sse_events(self, response: httpx.Response):
- """把 SSE 响应流按事件边界还原为 data 文本块。"""
- data_lines: list[str] = []
- async for line in response.aiter_lines():
- if line == "":
- if data_lines:
- yield "\n".join(data_lines)
- data_lines = []
- continue
- if line.startswith(":"):
- continue
- field, _, value = line.partition(":")
- value = value.lstrip()
- if field == "data":
- data_lines.append(value)
- if data_lines:
- yield "\n".join(data_lines)
-
- @staticmethod
- def _parse_stream_body(raw_event: str) -> dict[str, Any]:
- """解析单条流事件 JSON,并统一处理远端 error 对象。"""
- try:
- body = json.loads(raw_event)
- except json.JSONDecodeError as exc:
- raise A2AError(f"Invalid A2A stream payload: {raw_event}") from exc
- if not isinstance(body, dict):
- raise A2AError("A2A stream payload must be a JSON object")
-
- error = body.get("error")
- if isinstance(error, dict):
- code = error.get("code")
- message = str(error.get("message") or "unknown error")
- if code == -32601 or "not found" in message.lower():
- raise A2AUnsupportedMethodError(message)
- raise A2AError(message)
- return body
-
- def _resolve_transport_targets(
- self,
- card: dict[str, Any],
- agent: AgentDescriptor,
- ) -> list[_A2ATransportTarget]:
- """根据 card 和本地配置解析一组可尝试的传输目标。"""
- default_url = self._resolve_primary_url(card, agent)
- declared = self._collect_declared_interfaces(card)
- preferred = self._normalize_transport(
- card.get("preferred_transport") or card.get("preferredTransport")
- )
-
- candidates: list[_A2ATransportTarget] = []
- if preferred in {"jsonrpc", "rest"}:
- preferred_url = declared.get(preferred) or default_url
- if preferred_url:
- candidates.append(self._transport_target(preferred, preferred_url))
-
- for mode in ("jsonrpc", "rest"):
- url = declared.get(mode)
- if url:
- candidates.append(self._transport_target(mode, url))
-
- if default_url:
- if preferred not in {"jsonrpc", "rest"}:
- candidates.append(self._transport_target("jsonrpc", default_url))
- candidates.append(self._transport_target("rest", default_url))
- elif preferred == "jsonrpc":
- candidates.append(self._transport_target("rest", default_url))
- else:
- candidates.append(self._transport_target("jsonrpc", default_url))
-
- deduped: list[_A2ATransportTarget] = []
- seen: set[tuple[str, str]] = set()
- for target in candidates:
- # 同一个 mode + endpoint 只保留一次,避免重复尝试。
- key = (target.mode, target.endpoint.rstrip("/").lower())
- if key in seen:
- continue
- seen.add(key)
- deduped.append(target)
- return deduped
-
- def _collect_declared_interfaces(self, card: dict[str, Any]) -> dict[str, str]:
- """从 card 的 interfaces 字段里提取声明过的 transport/url。"""
- interfaces = None
- for key in (
- "additional_interfaces",
- "additionalInterfaces",
- "interfaces",
- "supported_interfaces",
- "supportedInterfaces",
- ):
- candidate = card.get(key)
- if isinstance(candidate, list):
- interfaces = candidate
- break
-
- result: dict[str, str] = {}
- if not isinstance(interfaces, list):
- return result
-
- for item in interfaces:
- if not isinstance(item, dict):
- continue
- mode = self._normalize_transport(item.get("transport"))
- url = str(item.get("url") or "").strip()
- if mode in {"jsonrpc", "rest"} and url:
- result.setdefault(mode, url)
- return result
-
- def _resolve_primary_url(self, card: dict[str, Any], agent: AgentDescriptor) -> str:
- """解析 card 的主 URL;当 card 返回 0.0.0.0 时退回本地配置。"""
- card_url = str(card.get("url") or "").strip()
- fallback = str(agent.endpoint or agent.base_url or "").strip()
- if card_url and (urlparse(card_url).hostname or "").strip() not in {"0.0.0.0", "::"}:
- return card_url
- return fallback or card_url
-
- def _transport_target(self, mode: str, url: str) -> _A2ATransportTarget:
- """构造标准化的 transport target。"""
- normalized_url = url.strip()
- if mode == "rest":
- normalized_url = self._normalize_rest_base_url(normalized_url)
- return _A2ATransportTarget(mode=mode, endpoint=normalized_url)
-
- @staticmethod
- def _normalize_transport(value: Any) -> str | None:
- """把不同命名风格的 transport 文本归一化。"""
- text = str(value or "").strip().lower()
- if not text:
- return None
- if text in {"jsonrpc", "json-rpc"}:
- return "jsonrpc"
- if text in {"http+json", "http-json", "http_json", "rest"}:
- return "rest"
- if text == "grpc":
- return "grpc"
- return None
-
- @staticmethod
- def _normalize_rest_base_url(url: str) -> str:
- """把各种 REST 端点变体规整到 `/v1` 根路径。"""
- parsed = urlparse(url)
- path = parsed.path.rstrip("/")
- for suffix in ("/message:send", "/message:stream"):
- if path.endswith(suffix):
- path = path[: -len(suffix)]
- break
- if "/tasks/" in path and (path.endswith(":cancel") or path.endswith(":subscribe")):
- path = path.split("/tasks/", 1)[0]
- if not path.endswith("/v1"):
- path = f"{path}/v1" if path else "/v1"
- return urlunparse(parsed._replace(path=path, params="", query="", fragment="")).rstrip("/")
-
- @staticmethod
- def _rest_endpoint(base_url: str, route: str) -> str:
- """基于 REST 根路径拼接具体路由。"""
- return f"{base_url.rstrip('/')}{route}"
-
- @staticmethod
- def _supports_streaming(card: dict[str, Any]) -> bool:
- """根据 card capability 判断是否支持流式。"""
- capabilities = card.get("capabilities")
- if not isinstance(capabilities, dict) or "streaming" not in capabilities:
- return True
- streaming = capabilities.get("streaming")
- if isinstance(streaming, dict):
- for key in ("enabled", "supported"):
- if key in streaming:
- return bool(streaming.get(key))
- return True
- return bool(streaming)
-
- @classmethod
- def _unwrap_result_object(cls, payload: dict[str, Any]) -> dict[str, Any]:
- """从不同协议变体里提取真正的结果对象。"""
- candidate: Any = payload
- if isinstance(candidate, dict) and isinstance(candidate.get("result"), dict):
- candidate = candidate["result"]
- if not isinstance(candidate, dict):
- raise A2AError("Malformed A2A response")
-
- for key, kind in (
- ("task", "task"),
- ("message", "message"),
- ("statusUpdate", "status-update"),
- ("status_update", "status-update"),
- ("artifactUpdate", "artifact-update"),
- ("artifact_update", "artifact-update"),
- ):
- value = candidate.get(key)
- if isinstance(value, dict):
- result = dict(value)
- result.setdefault("kind", kind)
- return result
- return candidate
-
- @staticmethod
- def _is_unsupported_http_error(
- exc: httpx.HTTPStatusError,
- *,
- allow_validation_errors: bool = False,
- ) -> bool:
- """判断 HTTP 错误是否应被解释为“方法不支持”。"""
- status_code = exc.response.status_code
- if status_code in {404, 405, 501}:
- return True
- if allow_validation_errors and status_code in {400, 422}:
- message = exc.response.text.lower()
- return "not supported" in message or "unsupported" in message
- return False
-
- @staticmethod
- def _http_error_message(exc: httpx.HTTPStatusError) -> str:
- """从 HTTP 错误响应中抽取更可读的错误文本。"""
- try:
- payload = exc.response.json()
- except json.JSONDecodeError:
- payload = None
- if isinstance(payload, dict):
- for key in ("detail", "title", "message", "error"):
- value = payload.get(key)
- if isinstance(value, str) and value.strip():
- return value.strip()
- return str(exc)
-
- async def _build_headers(self, agent: AgentDescriptor) -> dict[str, str]:
- """构造请求头,包括可选的 Bearer Token 和自定义 headers。"""
- headers = {"Accept": "application/json, text/event-stream"}
- auth_mode = (agent.auth_mode or "none").strip().lower()
- if auth_mode == "oauth_backend_token":
- headers["Authorization"] = f"Bearer {await self._issue_backend_token(agent)}"
- else:
- token = os.environ.get(agent.auth_env or "")
- if token:
- headers["Authorization"] = f"Bearer {token}"
- extra = agent.metadata.get("headers")
- if isinstance(extra, dict):
- for key, value in extra.items():
- if key and isinstance(value, str):
- headers[key] = value
- return headers
-
- async def _issue_backend_token(self, agent: AgentDescriptor) -> str:
- from nanobot.authz.client import AuthzClient
-
- authz_base_url = str(getattr(self.authz_config, "base_url", "") or "").strip()
- client_id = str(getattr(self.backend_identity, "client_id", "") or "").strip()
- client_secret = str(getattr(self.backend_identity, "client_secret", "") or "").strip()
- if not authz_base_url or not client_id or not client_secret:
- raise A2AError(
- f"A2A agent '{agent.id}' requires AuthZ backend tokens, but authz/backend identity is incomplete"
- )
-
- audience = str(agent.auth_audience or agent.id).strip()
- if not audience:
- raise A2AError(f"A2A agent '{agent.id}' is missing auth audience")
- if not audience.startswith("a2a:"):
- audience = f"a2a:{audience}"
-
- scopes = [scope for scope in agent.auth_scopes if scope]
- if not scopes:
- scopes = ["run_task"]
-
- authz_client = AuthzClient(
- authz_base_url,
- timeout_seconds=int(getattr(self.authz_config, "request_timeout_seconds", 10)),
- )
- token_response = await authz_client.issue_token(
- client_id=client_id,
- client_secret=client_secret,
- audience=audience,
- scopes=scopes,
- )
- access_token = str(token_response.get("access_token") or "").strip()
- if not access_token:
- raise A2AError(f"A2A agent '{agent.id}' did not receive an access token from AuthZ")
- return access_token
-
- def _check_allowed_host(self, url: str) -> None:
- """在配置了白名单时校验远端 host 是否允许访问。"""
- if not self.allowed_hosts:
- return
- host = (urlparse(url).hostname or "").lower()
- if host not in self.allowed_hosts:
- raise A2AError(f"Host '{host}' is not allowed for A2A access")
-
- @staticmethod
- def _build_message_params(task: str, label: str | None) -> dict[str, Any]:
- """把委派任务包装成 A2A 标准 message 参数。"""
- message = {
- "messageId": str(uuid.uuid4()),
- "role": "user",
- "parts": [{"type": "text", "kind": "text", "text": task}],
- }
- if label:
- message["metadata"] = {"label": label}
- return {"message": message}
-
- @classmethod
- def _build_rest_payload(cls, params: dict[str, Any]) -> dict[str, Any]:
- """把通用 message 参数转换成 REST 风格 payload。"""
- payload = json.loads(json.dumps(params))
- message = payload.get("message")
- if not isinstance(message, dict):
- return payload
-
- # 某些 REST 实现要求 role 使用枚举字面量,而不是自由字符串。
- role = str(message.get("role") or "").strip().lower()
- if role == "user":
- message["role"] = "ROLE_USER"
- elif role == "agent":
- message["role"] = "ROLE_AGENT"
-
- parts = message.pop("parts", None)
- if isinstance(parts, list) and "content" not in message:
- # REST 风格通常把 `parts` 拍平成 `content` 数组。
- content: list[dict[str, Any]] = []
- for part in parts:
- if not isinstance(part, dict):
- continue
- if "text" in part:
- content.append({"text": part["text"]})
- continue
- if "file" in part:
- content.append({"file": part["file"]})
- continue
- if "data" in part:
- content.append({"data": part["data"]})
- message["content"] = content
-
- return payload
-
- def _build_run_result(
- self,
- agent: AgentDescriptor,
- result: dict[str, Any],
- state: _StreamState | None = None,
- ) -> AgentRunResult:
- """把远端结果对象转换成统一的 AgentRunResult。"""
- summary = ""
- if state:
- summary = state.build_summary(self)
- if not summary:
- summary = self._extract_text(result) or json.dumps(result, ensure_ascii=False)
- status = self._normalize_status(result.get("status"))
- if not has_meaningful_summary(summary):
- status = "error"
- return AgentRunResult(
- agent_id=agent.id,
- agent_name=agent.name,
- status=status,
- summary=summary,
- raw=result,
- )
-
- @staticmethod
- def _is_task_result(result: dict[str, Any]) -> bool:
- """判断返回值是否表示一个 task 对象。"""
- if "status" in result:
- return True
- kind = str(result.get("kind") or "").lower()
- return kind == "task"
-
- @staticmethod
- def _is_terminal_status(status: Any) -> bool:
- """判断状态是否已进入终态。"""
- state = A2AClient._normalized_state_token(status)
- return state in {"completed", "complete", "failed", "error", "cancelled", "canceled"}
-
- @staticmethod
- def _normalize_status(status: Any) -> str:
- """把五花八门的远端状态名归一化。"""
- state = A2AClient._normalized_state_token(status or "ok")
- if state in {"", "completed", "complete", "success", "ok"}:
- return "ok"
- if state in {"working", "running", "in_progress", "submitted", "queued"}:
- return "working"
- if state in {"failed", "error"}:
- return "error"
- if state in {"cancelled", "canceled"}:
- return "cancelled"
- return state
-
- @staticmethod
- def _normalized_state_token(status: Any) -> str:
- """抽取状态里的核心 token,例如去掉 `TASK_STATE_` 前缀。"""
- if isinstance(status, dict):
- state = str(status.get("state") or status.get("status") or "")
- else:
- state = str(status or "")
- state = state.strip().lower()
- if state.startswith("task_state_"):
- state = state[len("task_state_") :]
- return state
-
- @classmethod
- def _extract_text(cls, payload: Any) -> str:
- """从嵌套对象里尽可能提取最有价值的文本内容。"""
- if isinstance(payload, str):
- return payload
- if isinstance(payload, dict):
- for key in ("text", "content", "summary", "finalText", "final_text"):
- value = payload.get(key)
- text = cls._extract_text(value)
- if text:
- return text
- for key in (
- "message",
- "artifact",
- "artifacts",
- "messages",
- "parts",
- "output",
- "toolResults",
- "tool_results",
- "task",
- "statusUpdate",
- "status_update",
- "artifactUpdate",
- "artifact_update",
- "history",
- ):
- value = payload.get(key)
- text = cls._extract_text(value)
- if text:
- return text
- if "status" in payload and isinstance(payload["status"], dict):
- text = cls._extract_text(payload["status"])
- if text:
- return text
- return ""
- if isinstance(payload, list):
- parts = [cls._extract_text(item) for item in payload]
- return "\n".join(part for part in parts if part)
- return ""
diff --git a/app-instance/backend-old/nanobot/agent/__init__.py b/app-instance/backend-old/nanobot/agent/__init__.py
deleted file mode 100644
index 0344efd..0000000
--- a/app-instance/backend-old/nanobot/agent/__init__.py
+++ /dev/null
@@ -1,35 +0,0 @@
-"""agent 核心模块导出入口。
-
-这里刻意改成懒加载导出:
-1. 避免 `nanobot.agent` 被导入时立即拉起一整串重量级依赖;
-2. 降低循环导入概率,特别是 `loop/context/skills` 之间的交叉引用;
-3. 保持对外 API 不变,调用方仍然可以 `from nanobot.agent import AgentLoop`。
-"""
-
-from __future__ import annotations
-
-from typing import Any
-
-__all__ = ["AgentLoop", "ContextBuilder", "MemoryStore", "SkillsLoader"]
-
-
-def __getattr__(name: str) -> Any:
- # 只有访问某个导出符号时才真正 import 对应模块,避免 import-time 副作用。
- if name == "AgentLoop":
- from nanobot.agent.loop import AgentLoop
-
- return AgentLoop
- if name == "ContextBuilder":
- from nanobot.agent.context import ContextBuilder
-
- return ContextBuilder
- if name == "MemoryStore":
- from nanobot.agent.memory import MemoryStore
-
- return MemoryStore
- if name == "SkillsLoader":
- from nanobot.agent.skills import SkillsLoader
-
- return SkillsLoader
- # 交给 Python 默认语义处理不存在的导出名。
- raise AttributeError(name)
diff --git a/app-instance/backend-old/nanobot/agent/agent_registry.py b/app-instance/backend-old/nanobot/agent/agent_registry.py
deleted file mode 100644
index 2ffe769..0000000
--- a/app-instance/backend-old/nanobot/agent/agent_registry.py
+++ /dev/null
@@ -1,419 +0,0 @@
-"""统一 agent 注册表。
-
-这个模块把当前工作区里“可被委派”的执行体统一抽象成 `AgentDescriptor`:
-1. workspace 手工登记的远端 A2A agent;
-2. plugin 提供的本地 prompt agent;
-3. skill 元数据里声明的 agent cards;
-4. 内置 local fallback agent。
-
-上层委派逻辑只和 `AgentDescriptor` 打交道,不需要关心来源细节。
-"""
-
-from __future__ import annotations
-
-import json
-import re
-from dataclasses import asdict, dataclass, field
-from pathlib import Path
-from typing import Any
-
-from nanobot.agent.plugins import PluginLoader
-from nanobot.agent.skills import SkillsLoader
-
-_TOKEN_RE = re.compile(r"[a-z0-9_-]+")
-_CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
-
-
-@dataclass
-class AgentDescriptor:
- """委派层使用的统一 agent 描述对象。"""
-
- # 稳定 ID,供路由、持久化和精确匹配使用。
- id: str
- # 面向 UI/日志的展示名。
- name: str
- # 简短说明,主要供模型和前端展示。
- description: str
- # 来源类型:builtin / plugin / skill / workspace。
- source: str
- # 运行方式:local_prompt / local_fallback / a2a_remote 等。
- kind: str
- # 底层协议,目前主要是 a2a 或 None。
- protocol: str | None = None
- plugin_name: str | None = None
- skill_name: str | None = None
- model: str | None = None
- system_prompt: str | None = None
- endpoint: str | None = None
- base_url: str | None = None
- card_url: str | None = None
- auth_env: str | None = None
- auth_mode: str = "none"
- auth_audience: str | None = None
- auth_scopes: list[str] = field(default_factory=list)
- enabled: bool = True
- tags: list[str] = field(default_factory=list)
- aliases: list[str] = field(default_factory=list)
- capabilities: dict[str, Any] = field(default_factory=dict)
- metadata: dict[str, Any] = field(default_factory=dict)
- support_streaming: bool = False
-
- def matches(self, target: str) -> bool:
- """判断给定目标字符串是否命中当前 agent。"""
- probe = (target or "").strip().lower()
- if not probe:
- return False
- # 同时支持按 id / name / alias 命中,方便模型用自然语言近似引用。
- candidates = {self.id.lower(), self.name.lower()}
- candidates.update(alias.lower() for alias in self.aliases if alias)
- return probe in candidates
-
- def searchable_text(self) -> str:
- """构造一段用于简单相关性匹配的可搜索文本。"""
- fields = [
- self.id,
- self.name,
- self.description,
- " ".join(self.tags),
- " ".join(self.aliases),
- self.plugin_name or "",
- self.skill_name or "",
- ]
- return " ".join(part for part in fields if part).lower()
-
- def public_dict(self) -> dict[str, Any]:
- """导出给前端使用的安全字典。"""
- data = asdict(self)
- # system_prompt 属于内部实现细节,不应默认暴露给前端。
- data.pop("system_prompt", None)
- return data
-
-
-class WorkspaceAgentStore:
- """workspace 级 agent 存储。
-
- 这里保存的是用户在 Web UI 或本地配置里手工登记的 agent,
- 文件位置固定为 `/agents/registry.json`。
- """
-
- def __init__(self, workspace: Path):
- self.workspace = workspace
- # 单独放到 `agents/` 目录,便于和 skills / memory / files 等目录职责分离。
- self.directory = workspace / "agents"
- self.path = self.directory / "registry.json"
-
- def list_agents(self) -> list[dict[str, Any]]:
- """读取并返回所有手工登记 agent。"""
- if not self.path.exists():
- return []
- try:
- raw = json.loads(self.path.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError, ValueError):
- # 存储损坏时不抛异常拖垮主流程,直接视为空。
- return []
- if not isinstance(raw, list):
- return []
- result: list[dict[str, Any]] = []
- for item in raw:
- # 仅接受带 id 的对象,保证后续 registry 至少有稳定主键。
- if isinstance(item, dict) and item.get("id"):
- result.append(item)
- return result
-
- def save_agents(self, agents: list[dict[str, Any]]) -> None:
- """将 agent 列表完整覆写到 registry 文件。"""
- self.directory.mkdir(parents=True, exist_ok=True)
- self.path.write_text(
- json.dumps(agents, indent=2, ensure_ascii=False),
- encoding="utf-8",
- )
-
- def upsert_agent(self, agent: dict[str, Any]) -> dict[str, Any]:
- """按 id 新增或更新一个 agent 记录。"""
- record = dict(agent)
- agent_id = str(record.get("id", "")).strip()
- if not agent_id:
- raise ValueError("Agent id is required")
- record["id"] = agent_id
- # 对基础展示字段做最小兜底,避免后续 UI 或提示词出现空值。
- record.setdefault("name", agent_id)
- record.setdefault("description", record["name"])
- record.setdefault("protocol", "a2a")
- record.setdefault("enabled", True)
- record.setdefault("tags", [])
- # 先剔除旧记录再 append,最后统一排序,保持存储文件稳定可读。
- agents = [a for a in self.list_agents() if a.get("id") != agent_id]
- agents.append(record)
- agents.sort(key=lambda item: item.get("id", "").lower())
- self.save_agents(agents)
- return record
-
- def delete_agent(self, agent_id: str) -> bool:
- """按 id 删除一个 agent,删除成功返回 True。"""
- target = agent_id.strip()
- if not target:
- return False
- agents = self.list_agents()
- filtered = [a for a in agents if a.get("id") != target]
- if len(filtered) == len(agents):
- return False
- self.save_agents(filtered)
- return True
-
-
-class AgentRegistry:
- """构建并查询当前可委派 agent 集合。"""
-
- def __init__(
- self,
- workspace: Path,
- plugins: PluginLoader | None = None,
- 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
- # 插件和技能加载器允许外部复用同一个实例,避免重复扫描磁盘。
- self.plugins = plugins or PluginLoader(workspace)
- 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 | 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:
- for record in self.workspace_store.list_agents():
- if not record.get("enabled", True):
- continue
- agent = self._workspace_record_to_descriptor(record)
- if agent:
- agents.append(agent)
-
- # plugin agents 本质上是“带独立系统提示词的本地执行器”。
- 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 的静态入口。
- for card in self.skills.list_skill_agent_cards():
- agent = self._skill_card_to_descriptor(card)
- if agent:
- agents.append(agent)
-
- if include_local_fallback:
- # 永远保留一个本地兜底执行器,确保自动路由时至少有可执行目标。
- agents.append(
- AgentDescriptor(
- id="local-subagent",
- name="Local Subagent",
- description="Local fallback agent that can use files, shell, and web tools.",
- source="builtin",
- kind="local_fallback",
- protocol=None,
- aliases=["subagent", "local"],
- )
- )
-
- seen: set[str] = set()
- result: list[AgentDescriptor] = []
- for agent in agents:
- # 去重规则按 id 小写匹配,优先保留先出现的来源。
- key = agent.id.lower()
- if key in seen:
- continue
- seen.add(key)
- result.append(agent)
- return result
-
- def get_agent(self, target: str) -> AgentDescriptor | None:
- """按 id / name / alias 获取单个 agent。"""
- probe = (target or "").strip()
- if not probe:
- return None
- for agent in self.list_agents():
- if agent.matches(probe):
- return agent
- return None
-
- def suggest_agents(self, query: str, limit: int = 5) -> list[AgentDescriptor]:
- """基于简单词项打分为一段任务文本推荐 agent。"""
- query_text = query or ""
- query_lower = query_text.lower()
- tokens = {token for token in _TOKEN_RE.findall(query_lower) if len(token) > 2}
- query_cjk_bigrams = self._cjk_bigrams(query_text)
-
- scored: list[tuple[int, AgentDescriptor]] = []
- for agent in self.list_agents(include_local_fallback=False):
- haystack = agent.searchable_text()
- haystack_cjk_bigrams = self._cjk_bigrams(haystack)
- score = 0
- for token in tokens:
- # token 命中一次给基础分。
- if token in haystack:
- score += 2
- # 如果查询里直接出现了 agent 名或 id,再给更高权重。
- if agent.name.lower() in query_lower or agent.id.lower() in query_lower:
- score += 5
- for phrase in [agent.name, agent.id, *agent.tags, *agent.aliases]:
- phrase_text = str(phrase or "").strip()
- if not phrase_text:
- continue
- if phrase_text.lower() in query_lower or phrase_text in query_text:
- score += 3
- if query_cjk_bigrams and haystack_cjk_bigrams:
- # 中文任务没有空格分词,先用 bigram overlap 做粗粒度召回。
- score += min(6, len(query_cjk_bigrams & haystack_cjk_bigrams))
- if score > 0:
- scored.append((score, agent))
-
- scored.sort(key=lambda item: (-item[0], item[1].name.lower()))
- return [agent for _, agent in scored[:limit]]
-
- @staticmethod
- def _cjk_bigrams(text: str) -> set[str]:
- """提取中文 bigram,用于中文任务的轻量召回。"""
- chunks = _CJK_RE.findall(str(text or ""))
- result: set[str] = set()
- for chunk in chunks:
- if len(chunk) == 1:
- result.add(chunk)
- continue
- for index in range(len(chunk) - 1):
- result.add(chunk[index:index + 2])
- return result
-
- def build_agents_summary(self) -> str:
- """把 agent 列表格式化成 prompt 可直接嵌入的 XML 片段。"""
- agents = self.list_agents()
- if not agents:
- return ""
-
- def esc(value: str) -> str:
- # 这里手工转义最基础的 XML 特殊字符,避免描述文本破坏结构。
- return (
- value.replace("&", "&")
- .replace("<", "<")
- .replace(">", ">")
- )
-
- lines = [""]
- for agent in agents:
- lines.append(" ")
- lines.append(f" {esc(agent.id)}")
- lines.append(f" {esc(agent.name)}")
- lines.append(f" {esc(agent.source)}")
- lines.append(f" {esc(agent.kind)}")
- lines.append(f" {esc(agent.description)}")
- if agent.protocol:
- lines.append(f" {esc(agent.protocol)}")
- if agent.tags:
- lines.append(f" {esc(', '.join(agent.tags))}")
- lines.append(" ")
- lines.append("")
- return "\n".join(lines)
-
- def list_public_agents(self) -> list[dict[str, Any]]:
- """列出脱敏后的 agent 结构,供 Web API 使用。"""
- return [agent.public_dict() for agent in self.list_agents()]
-
- def _workspace_record_to_descriptor(self, record: dict[str, Any]) -> AgentDescriptor | None:
- """把 workspace registry 里的原始记录转成统一描述对象。"""
- protocol = str(record.get("protocol") or "a2a").lower()
- if protocol != "a2a":
- # 当前仅支持把 workspace 记录解释成 A2A agent。
- return None
- agent_id = str(record.get("id", "")).strip()
- if not agent_id:
- return None
- name = str(record.get("name") or agent_id)
- return AgentDescriptor(
- id=agent_id,
- name=name,
- description=str(record.get("description") or name),
- source="workspace",
- kind="a2a_remote",
- protocol="a2a",
- endpoint=record.get("endpoint") or record.get("base_url"),
- base_url=record.get("base_url") or record.get("endpoint"),
- card_url=record.get("card_url"),
- auth_env=record.get("auth_env"),
- auth_mode=str(record.get("auth_mode") or "none").strip().lower() or "none",
- auth_audience=(str(record.get("auth_audience") or "").strip() or None),
- auth_scopes=[
- str(scope).strip()
- for scope in record.get("auth_scopes", [])
- if str(scope).strip()
- ],
- enabled=bool(record.get("enabled", True)),
- tags=[str(tag) for tag in record.get("tags", []) if str(tag).strip()],
- aliases=[
- alias
- for alias in [record.get("name"), *record.get("aliases", [])]
- if isinstance(alias, str) and alias.strip()
- ],
- capabilities=record.get("capabilities", {}) if isinstance(record.get("capabilities"), dict) else {},
- metadata=record.get("metadata", {}) if isinstance(record.get("metadata"), dict) else {},
- support_streaming=bool(record.get("support_streaming", False)),
- )
-
- def _skill_card_to_descriptor(self, card: dict[str, Any]) -> AgentDescriptor | None:
- """把 skill frontmatter 中的 agent card 转成统一描述对象。"""
- card_id = str(card.get("id") or "").strip()
- skill_name = str(card.get("skill_name") or "").strip()
- if not card_id:
- return None
- name = str(card.get("name") or card_id)
- return AgentDescriptor(
- id=card_id,
- name=name,
- description=str(card.get("description") or name),
- source="skill",
- kind="a2a_remote",
- protocol="a2a",
- skill_name=skill_name or None,
- endpoint=card.get("endpoint") or card.get("base_url"),
- base_url=card.get("base_url") or card.get("endpoint"),
- card_url=card.get("url") or card.get("card_url"),
- auth_env=card.get("auth_env"),
- auth_mode=str(card.get("auth_mode") or "none").strip().lower() or "none",
- auth_audience=(str(card.get("auth_audience") or "").strip() or None),
- auth_scopes=[
- str(scope).strip()
- for scope in card.get("auth_scopes", [])
- if str(scope).strip()
- ],
- tags=[str(tag) for tag in card.get("tags", []) if str(tag).strip()],
- aliases=[
- alias
- for alias in [card.get("name"), *card.get("aliases", [])]
- if isinstance(alias, str) and alias.strip()
- ],
- capabilities=card.get("capabilities", {}) if isinstance(card.get("capabilities"), dict) else {},
- metadata=card.get("metadata", {}) if isinstance(card.get("metadata"), dict) else {},
- support_streaming=bool(card.get("support_streaming", False)),
- )
diff --git a/app-instance/backend-old/nanobot/agent/context.py b/app-instance/backend-old/nanobot/agent/context.py
deleted file mode 100644
index 43f8a29..0000000
--- a/app-instance/backend-old/nanobot/agent/context.py
+++ /dev/null
@@ -1,257 +0,0 @@
-"""上下文构建器:负责为每次 LLM 调用组装完整消息上下文。
-
-本模块主要做三件事:
-1. 生成 system prompt(身份、运行时信息、bootstrap 文件、记忆、技能摘要);
-2. 将历史消息与当前用户输入拼接成模型可消费的 messages;
-3. 在工具调用循环中追加 assistant/tool 消息,维持对话状态连续性。
-"""
-
-import base64
-import mimetypes
-import platform
-from pathlib import Path
-from typing import Any
-
-from nanobot.agent.agent_registry import AgentRegistry
-from nanobot.agent.memory import MemoryStore
-from nanobot.agent.skills import SkillsLoader
-
-
-class ContextBuilder:
- """
- Agent 上下文装配器。
-
- 设计目标:
- - 把“静态配置”(AGENTS/USER/TOOLS 等)与“动态上下文”(时间、会话、历史)统一拼装;
- - 保持 prompt 结构稳定,降低模型行为波动;
- - 让工具调用前后的消息追加逻辑集中在一个位置,便于维护。
- """
-
- # bootstrap 文件按此顺序加载并拼接,顺序会影响最终提示词语义优先级。
- BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
-
- def __init__(
- self,
- workspace: Path,
- skills_loader: SkillsLoader | None = None,
- agent_registry: AgentRegistry | None = None,
- ):
- self.workspace = workspace
- # 记忆与技能都按 workspace 维度隔离,避免跨项目污染。
- self.memory = MemoryStore(workspace)
- # 若上层已构造好 SkillsLoader / AgentRegistry,则复用,避免重复扫描磁盘。
- self.skills = skills_loader or SkillsLoader(workspace)
- # agent_registry 可选:只有支持多 agent 委派时才会把可用 agent 摘要塞进 prompt。
- self.agent_registry = agent_registry
-
- def build_system_prompt(
- self,
- skill_names: list[str] | None = None,
- execution_context: str | None = None,
- ) -> str:
- """构建 system prompt(身份 + 配置 + 记忆 + 技能信息)。"""
- # skill_names 目前作为接口预留,便于未来按需只加载指定技能。
- parts = []
-
- # 1) 核心身份段:包含当前时间、系统环境、工作区路径等动态信息。
- parts.append(self._get_identity())
-
- # 2) workspace 里的 bootstrap 文件(若存在)按顺序拼接。
- bootstrap = self._load_bootstrap_files()
- if bootstrap:
- parts.append(bootstrap)
-
- # 3) 长期记忆上下文(来自 memory/MEMORY.md 等)。
- memory = self.memory.get_memory_context()
- if memory:
- parts.append(f"# Memory\n\n{memory}")
-
- # 4) 技能采用“渐进加载”策略。
- # 4.1 always 技能:直接把完整内容塞进当前 prompt。
- always_skills = self.skills.get_always_skills()
- if always_skills:
- always_content = self.skills.load_skills_for_context(always_skills)
- if always_content:
- parts.append(f"# Active Skills\n\n{always_content}")
-
- # 4.2 可用技能:只放摘要,具体内容让 agent 运行时按需 read_file。
- # 这样可以控制 token 体积,避免把所有技能全文塞入上下文。
- skills_summary = self.skills.build_skills_summary()
- if skills_summary:
- parts.append(f"""# Skills
-
-The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
-Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
-
-{skills_summary}""")
-
- if self.agent_registry:
- parts.append("""# Delegation Tools
-
-Use `spawn_subagent` when the task should go to one delegated worker.
-Use `spawn_agent_team` when the task should be explored in parallel by multiple workers.
-At the top level, you do not need to choose concrete downstream agents.
-Use the `skills` argument when the delegated worker or team must follow specific skills.""")
-
- if execution_context:
- # `execution_context` 用于 cron / system task 这类“不是普通用户消息”的额外运行说明。
- parts.append(f"# Execution Context\n\n{execution_context.strip()}")
-
- # 各块之间用分隔线拼接,提升提示词可读性与结构稳定性。
- return "\n\n---\n\n".join(parts)
-
- def _get_identity(self) -> str:
- """生成核心身份段。"""
- import time as _time
- from datetime import datetime
- # 时间与时区在 system prompt 中显式给出,减少模型对“当前时间”的猜测。
- now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
- tz = _time.strftime("%Z") or "UTC"
- # 固化绝对工作区路径,帮助模型生成更准确的文件操作指令。
- workspace_path = str(self.workspace.expanduser().resolve())
- # 运行时信息可帮助模型在跨平台命令选择时更稳健(如 macOS/Linux 差异)。
- system = platform.system()
- runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
-
- return f"""# Boardware Genius
-
-You are Boardware Genius, a helpful AI assistant.
-
-## Current Time
-{now} ({tz})
-
-## Runtime
-{runtime}
-
-## Workspace
-Your workspace is at: {workspace_path}
-- Long-term memory: {workspace_path}/memory/MEMORY.md
-- History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
-- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
-
-Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel.
-
-## Tool Call Guidelines
-- Before calling tools, you may briefly state your intent (e.g. "Let me check that"), but NEVER predict or describe the expected result before receiving it.
-- Before modifying a file, read it first to confirm its current content.
-- Do not assume a file or directory exists — use list_dir or read_file to verify.
-- After writing or editing a file, re-read it if accuracy matters.
-- If a tool call fails, analyze the error before retrying with a different approach.
-- Do not write directly into `{workspace_path}/skills`; new or updated skills must go through the review flow before activation.
-
-## Delegation Policy
-- Solve simple tasks yourself when the work is short, direct, and does not benefit from delegation.
-- Delegate only when the task is complex, multi-step, time-consuming, or benefits from specialized/parallel work.
-- Use `spawn_subagent` for one focused delegated worker when only the final result matters.
-- Use `spawn_agent_team` when multiple agents should explore the task in parallel, compare findings, or work across separate areas.
-- Do not delegate by default if you can complete the task reliably in the current turn.
-- Do not create or modify persistent local sub-agents unless the user explicitly asks for a reusable long-lived worker.
-
-## Memory
-- Remember important facts: write to {workspace_path}/memory/MEMORY.md
-- Recall past events: grep {workspace_path}/memory/HISTORY.md"""
-
- def _load_bootstrap_files(self) -> str:
- """从 workspace 读取 bootstrap 文件并拼接。"""
- parts = []
-
- for filename in self.BOOTSTRAP_FILES:
- file_path = self.workspace / filename
- if file_path.exists():
- # 缺失文件时静默跳过,保持默认可用。
- content = file_path.read_text(encoding="utf-8")
- parts.append(f"## {filename}\n\n{content}")
-
- return "\n\n".join(parts) if parts else ""
-
- def build_messages(
- self,
- history: list[dict[str, Any]],
- current_message: str,
- skill_names: list[str] | None = None,
- execution_context: str | None = None,
- media: list[str] | None = None,
- channel: str | None = None,
- chat_id: str | None = None,
- ) -> list[dict[str, Any]]:
- """构建一次 LLM 调用的完整 messages 数组。"""
- messages = []
-
- # 第 1 条固定是 system prompt。
- system_prompt = self.build_system_prompt(skill_names, execution_context=execution_context)
- if channel and chat_id:
- # 把当前会话路由信息也写入系统提示,便于模型做跨渠道决策。
- system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {chat_id}"
- messages.append({"role": "system", "content": system_prompt})
-
- # 追加历史消息(通常已由 SessionManager 做窗口与清洗)。
- messages.extend(history)
-
- # 追加当前用户输入;若带图片则转换为多模态 content 结构。
- user_content = self._build_user_content(current_message, media)
- messages.append({"role": "user", "content": user_content})
-
- return messages
-
- def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
- """构建 user content,支持文本或“文本+图片”多模态格式。"""
- # 无媒体时直接走纯文本,保持最简单路径。
- if not media:
- return text
-
- images = []
- for path in media:
- p = Path(path)
- mime, _ = mimetypes.guess_type(path)
- # 仅接收本地图片文件,其他媒体类型暂不注入到模型内容。
- if not p.is_file() or not mime or not mime.startswith("image/"):
- continue
- # 按 data URL 形式内联图片,兼容支持 image_url 的 provider 接口。
- b64 = base64.b64encode(p.read_bytes()).decode()
- images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
-
- # 没有合法图片时回退纯文本,避免传空数组导致模型侧解析异常。
- if not images:
- return text
- # 多模态结构中把图片放前、文本放后,便于模型先“看图”再读文字指令。
- return images + [{"type": "text", "text": text}]
-
- def add_tool_result(
- self,
- messages: list[dict[str, Any]],
- tool_call_id: str,
- tool_name: str,
- result: str
- ) -> list[dict[str, Any]]:
- """把工具执行结果追加到 messages。"""
- messages.append({
- "role": "tool",
- "tool_call_id": tool_call_id,
- "name": tool_name,
- "content": result
- })
- return messages
-
- def add_assistant_message(
- self,
- messages: list[dict[str, Any]],
- content: str | None,
- tool_calls: list[dict[str, Any]] | None = None,
- reasoning_content: str | None = None,
- ) -> list[dict[str, Any]]:
- """把 assistant 消息追加到 messages(可携带 tool_calls/reasoning)。"""
- msg: dict[str, Any] = {"role": "assistant"}
-
- # 始终写入 content 键:
- # 部分 provider 在 key 缺失时会拒绝请求(即使值是 None 也要有该键)。
- msg["content"] = content
-
- if tool_calls:
- msg["tool_calls"] = tool_calls
-
- # reasoning_content 是“思考模型”专用字段,仅在有值时附加。
- if reasoning_content is not None:
- msg["reasoning_content"] = reasoning_content
-
- messages.append(msg)
- return messages
diff --git a/app-instance/backend-old/nanobot/agent/delegation.py b/app-instance/backend-old/nanobot/agent/delegation.py
deleted file mode 100644
index 189dd00..0000000
--- a/app-instance/backend-old/nanobot/agent/delegation.py
+++ /dev/null
@@ -1,1435 +0,0 @@
-"""统一委派管理器。
-
-这是本次多 agent 改造的核心编排层,负责:
-1. 根据接口语义选择单 subagent 或 agent team 路径;
-2. 跟踪每次后台委派的运行状态,支持取消;
-3. 统一发出 bus 公告和结构化 process events;
-4. 在本地执行器和 A2A 客户端之间做协议桥接。
-"""
-
-from __future__ import annotations
-
-import asyncio
-import re
-import uuid
-from collections.abc import Awaitable, Callable
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import TYPE_CHECKING, Any
-
-from loguru import logger
-
-from nanobot.a2a.client import A2AClient, A2AStreamEvent
-from nanobot.agent.agent_registry import AgentDescriptor, AgentRegistry
-from nanobot.agent.process_events import (
- emit_process_event,
- has_process_event_sink,
- new_run_id,
- process_run_context,
-)
-from nanobot.agent.run_result import AgentRunResult
-from nanobot.agent_team.orchestrator import AgentTeamOrchestrator
-from nanobot.agent_team.types import BridgeResult
-from nanobot.bus.events import InboundMessage, OutboundMessage
-from nanobot.bus.queue import MessageBus
-from nanobot.providers.base import LLMProvider
-
-if TYPE_CHECKING:
- from nanobot.agent.skills import SkillsLoader
-
-DirectAnnouncementCallback = Callable[[str, dict[str, str], str, bool], Awaitable[None]]
-
-
-@dataclass
-class DelegationRun:
- """记录一次正在运行的委派任务及其远端子任务状态。"""
-
- # 后台 asyncio 任务句柄,用于取消和生命周期管理。
- task: asyncio.Task[None]
- # 面向日志/UI 的短标签。
- label: str
- # 原会话路由,委派完成后需要把结果送回这里。
- origin: dict[str, str]
- # 是否通过 bus 回注 system 消息;直连模式下通常为 False。
- announce_via_bus: bool = True
- # 远端 agent 描述和 task_id 映射,用于取消 A2A 子任务。
- remote_agents: dict[str, AgentDescriptor] = field(default_factory=dict)
- remote_task_ids: dict[str, str] = field(default_factory=dict)
-
-
-class DelegationManager:
- """把任务分发到单个 subagent 或 agent team。"""
-
- def __init__(
- self,
- provider: LLMProvider,
- model: str | None,
- workspace: Path,
- bus: MessageBus,
- registry: AgentRegistry,
- skills_loader: "SkillsLoader | None",
- local_executor: Any,
- timeout_seconds: int = 600,
- poll_interval_seconds: int = 2,
- card_cache_ttl_seconds: int = 300,
- max_parallel_agents: int = 4,
- 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,
- gateway_port: int = 18790,
- ):
- self.provider = provider
- self.workspace = workspace
- self.bus = bus
- self.registry = registry
- self.skills_loader = skills_loader
- # 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,
- poll_interval_seconds=poll_interval_seconds,
- card_cache_ttl_seconds=card_cache_ttl_seconds,
- allowed_hosts=allowed_hosts,
- authz_config=authz_config,
- backend_identity=backend_identity,
- )
- # 新 orchestrator 只负责 agent team 路径;单 agent 委派仍走原有逻辑。
- self.agent_team_orchestrator = AgentTeamOrchestrator(
- workspace=workspace,
- provider=provider,
- model=model,
- registry=registry,
- bus=bus,
- local_executor=local_executor,
- member_runner=self._run_team_member_for_swarms,
- max_parallel_agents=self.max_parallel_agents,
- gateway_port=gateway_port,
- )
- 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,
- ) -> None:
- """注册直连模式下的本地公告处理器。"""
- self._direct_announcement_callback = callback
-
- async def delegate_for_subagent(
- self,
- task: str,
- label: str | None = None,
- target: str | None = None,
- strategy: str = "auto",
- skills: list[str] | None = None,
- ) -> str:
- """供 delegated worker 使用的同步下游委派入口。"""
- display_label = label or task[:30] + ("..." if len(task) > 30 else "")
- try:
- descriptor = self._resolve_nested_delegate(task, target, strategy)
- result = await self._execute_descriptor(
- descriptor,
- task,
- display_label,
- skill_names=skills,
- allow_nested_delegation=False,
- )
- except Exception as exc:
- return f"Error: Nested delegation failed: {exc}"
-
- status_text = "completed successfully" if result.status == "ok" else result.status
- return (
- f"Nested delegation via {result.agent_name} ({result.agent_id}) {status_text}.\n\n"
- f"Result:\n{result.summary}"
- )
-
- def build_nested_agents_summary(self) -> str:
- """构造 delegated worker 可见的下游 agent 摘要。"""
- agents = [
- agent
- for agent in self.registry.list_agents()
- if self._nested_descriptor_allowed(agent)
- ]
- if not agents:
- return ""
-
- def esc(value: str) -> str:
- return (
- value.replace("&", "&")
- .replace("<", "<")
- .replace(">", ">")
- )
-
- lines = [""]
- for agent in agents:
- lines.append(" ")
- lines.append(f" {esc(agent.id)}")
- lines.append(f" {esc(agent.name)}")
- lines.append(f" {esc(agent.kind)}")
- lines.append(f" {esc(agent.source)}")
- lines.append(f" {esc(agent.description)}")
- if agent.protocol:
- lines.append(f" {esc(agent.protocol)}")
- lines.append(" ")
- lines.append("")
- return "\n".join(lines)
-
- async def dispatch_subagent(
- self,
- task: str,
- label: str | None = None,
- skills: list[str] | None = None,
- origin_channel: str = "cli",
- origin_chat_id: str = "direct",
- announce_via_bus: bool = True,
- ) -> str:
- """启动一个后台 subagent 委派任务,并立即返回已启动提示。"""
- return await self._dispatch(
- task=task,
- label=label,
- target=None,
- targets=[],
- strategy="auto",
- skills=skills or [],
- origin_channel=origin_channel,
- origin_chat_id=origin_chat_id,
- announce_via_bus=announce_via_bus,
- mode="subagent",
- )
-
- async def dispatch_agent_team(
- self,
- task: str,
- label: str | None = None,
- skills: list[str] | None = None,
- origin_channel: str = "cli",
- origin_chat_id: str = "direct",
- announce_via_bus: bool = True,
- ) -> str:
- """启动一个后台 agent team 任务,并立即返回已启动提示。"""
- return await self._dispatch(
- task=task,
- label=label,
- target=None,
- targets=[],
- strategy="group",
- skills=skills or [],
- origin_channel=origin_channel,
- origin_chat_id=origin_chat_id,
- announce_via_bus=announce_via_bus,
- mode="agent_team",
- )
-
- async def _dispatch(
- self,
- task: str,
- label: str | None,
- target: str | None,
- targets: list[str],
- strategy: str,
- skills: list[str],
- origin_channel: str,
- origin_chat_id: str,
- announce_via_bus: bool,
- mode: str,
- ) -> str:
- """启动一个后台委派任务,并立即返回已启动提示。"""
- run_id = str(uuid.uuid4())[:8]
- display_label = label or task[:30] + ("..." if len(task) > 30 else "")
- origin = {"channel": origin_channel, "chat_id": origin_chat_id}
- kind_label = "Agent team" if mode == "agent_team" else "Subagent"
- bg_task = asyncio.create_task(
- self._run_dispatch(
- run_id=run_id,
- task=task,
- label=display_label,
- target=target,
- targets=targets,
- strategy=strategy,
- skills=skills,
- origin=origin,
- mode=mode,
- )
- )
- self._running_tasks[run_id] = DelegationRun(
- task=bg_task,
- label=display_label,
- origin=origin,
- announce_via_bus=announce_via_bus,
- )
- bg_task.add_done_callback(lambda _: self._running_tasks.pop(run_id, None))
- logger.info("{} [{}] started: {}", kind_label, run_id, display_label)
- return (
- f"{kind_label} [{display_label}] started (id: {run_id}). "
- "I'll notify you when it completes."
- )
-
- def get_running_count(self) -> int:
- """返回当前正在执行的委派数量。"""
- return len(self._running_tasks)
-
- @staticmethod
- def _clean_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
- """删除空值,避免过程事件 metadata 出现大量噪声字段。"""
- cleaned: dict[str, Any] = {}
- for key, value in metadata.items():
- if value is None:
- continue
- if isinstance(value, str) and not value.strip():
- continue
- if isinstance(value, (list, tuple, set, dict)) and not value:
- continue
- cleaned[key] = value
- return cleaned
-
- @staticmethod
- def _ui_status(status: str | None) -> str:
- """把底层状态归一化成前端更稳定的显示状态。"""
- probe = (status or "").strip().lower()
- if probe in {"", "ok", "done", "completed", "complete", "success"}:
- return "done"
- if probe in {"working", "running", "queued", "submitted", "waiting", "in_progress"}:
- return "running" if probe != "waiting" else "waiting"
- if probe in {"cancelled", "canceled"}:
- return "cancelled"
- if probe in {"failed", "error"}:
- return "error"
- return probe or "running"
-
- async def _emit_team_progress(
- self,
- run_id: str,
- text: str,
- *,
- stage_label: str,
- metadata: dict[str, Any] | None = None,
- ) -> None:
- """为 agent team 根 run 发一条过程可观察事件。"""
- await emit_process_event(
- "process_run_progress",
- run_id=run_id,
- actor_type="system",
- actor_id="agent-group",
- actor_name="Agent Team",
- text=text,
- metadata=self._clean_metadata({
- "source": "agent_team_dispatch",
- "stage_label": stage_label,
- **(metadata or {}),
- }),
- )
-
- async def _emit_agent_started(
- self,
- run_id: str,
- descriptor: AgentDescriptor,
- label: str,
- *,
- parent_run_id: str | None = None,
- task: str | None = None,
- ) -> None:
- # 单 agent 执行开始事件,供前端画执行树。
- await emit_process_event(
- "process_run_started",
- run_id=run_id,
- parent_run_id=parent_run_id,
- actor_type="agent",
- actor_id=descriptor.id,
- actor_name=descriptor.name,
- source=descriptor.source,
- title=label,
- status="running",
- metadata={
- "kind": descriptor.kind,
- "protocol": descriptor.protocol,
- "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,
- run_id: str,
- descriptor: AgentDescriptor,
- result: AgentRunResult,
- ) -> None:
- # 单 agent 结束事件只保留归一化状态和摘要,原始状态放 metadata 里。
- await emit_process_event(
- "process_run_finished",
- run_id=run_id,
- actor_type="agent",
- actor_id=descriptor.id,
- actor_name=descriptor.name,
- status=self._ui_status(result.status),
- summary=result.summary,
- metadata={"raw_status": result.status},
- )
-
- async def _emit_agent_cancelled(
- self,
- run_id: str,
- descriptor: AgentDescriptor | None,
- label: str,
- ) -> None:
- # 取消事件允许 descriptor 为空,用于还没解析出具体目标就被取消的情况。
- await emit_process_event(
- "process_run_cancelled",
- run_id=run_id,
- actor_type="agent" if descriptor is not None else "system",
- actor_id=descriptor.id if descriptor is not None else "delegation",
- actor_name=descriptor.name if descriptor is not None else label,
- status="cancelled",
- )
-
- async def _emit_group_started(self, run_id: str, label: str, targets: list[str]) -> None:
- """发送 agent team 开始事件。"""
- await emit_process_event(
- "process_run_started",
- run_id=run_id,
- parent_run_id=None,
- actor_type="system",
- actor_id="agent-group",
- actor_name="Agent Team",
- source="agent_team",
- title=label,
- status="running",
- metadata=self._clean_metadata({
- "source": "agent_team_dispatch",
- "phase": "dispatch",
- "stage_label": "团队任务已创建",
- "planned_targets": targets,
- "selected_targets": targets,
- "selected_count": len(targets),
- }),
- )
-
- async def _emit_group_finished(
- self,
- run_id: str,
- label: str,
- results: list[AgentRunResult],
- *,
- status: str = "done",
- summary: str | None = None,
- metadata_extra: dict[str, Any] | None = None,
- ) -> None:
- """发送 agent team 结束事件。
-
- Demo 输出:
- `process_run_finished(status="done", summary="weekly report: 2 member(s) finished")`
- """
- # 老路径和新 orchestrator 路径都复用这个事件,所以允许上层补充额外 metadata。
- metadata = {
- "members": [
- {
- "agent_id": item.agent_id,
- "agent_name": item.agent_name,
- "status": item.status,
- }
- for item in results
- ]
- }
- if metadata_extra:
- metadata.update(metadata_extra)
- await emit_process_event(
- "process_run_finished",
- run_id=run_id,
- actor_type="system",
- actor_id="agent-group",
- actor_name="Agent Team",
- status=status,
- summary=summary or f"{label}: {len(results)} member(s) finished",
- metadata=metadata,
- )
-
- async def _publish_prefixed_progress(
- self,
- origin: dict[str, str],
- descriptor: AgentDescriptor,
- text: str,
- *,
- publish_via_bus: bool,
- tool_hint: bool = False,
- ) -> None:
- """把子 agent 进度转发到原会话的 outbound 进度消息。"""
- text = text.strip()
- if not text or not publish_via_bus:
- return
- await self.bus.publish_outbound(OutboundMessage(
- channel=origin["channel"],
- chat_id=origin["chat_id"],
- content=f"[{descriptor.name}] {text}",
- metadata={"_progress": True, "_tool_hint": tool_hint},
- ))
-
- async def _emit_direct_user_message(self, prompt: str, fallback: str) -> None:
- """存在 process sink 时,直接发一条给用户看的 assistant 消息。"""
- # 这个分支主要服务于 WebSocket/SSE 直连模式:
- # 没有 bus consumer 时,不能依赖 system 消息回流再二次总结。
- if not has_process_event_sink():
- return
- # 这条用户可见消息只是“即时回执”,真正详细总结仍由主 agent 回流处理。
- # 这里不再额外依赖一次 LLM,避免 provider 短暂故障把 team 收尾也拖失败。
- content = " ".join((fallback or prompt or "").strip().split())
- if not content:
- return
-
- await emit_process_event(
- "message",
- role="assistant",
- content=content,
- )
-
- async def cancel(self, run_id: str) -> bool:
- """Cancel a running delegation and attempt remote A2A cancellation."""
- state = self._running_tasks.get(run_id)
- if state is None:
- return False
-
- # 先尽力取消远端任务,再取消本地 asyncio task,避免远端继续跑飞。
- await self._cancel_remote_tasks(run_id, state)
- state.task.cancel()
- return True
-
- async def cancel_all(self) -> None:
- """Cancel all running delegations."""
- for run_id in list(self._running_tasks):
- await self.cancel(run_id)
-
- async def _notify_direct_announcement(
- self,
- content: str,
- origin: dict[str, str],
- sender_id: str,
- *,
- run_id: str | None = None,
- category: str | None = None,
- ) -> None:
- """在非 bus 模式下,把公告直接回写到本地会话。"""
- callback = self._direct_announcement_callback
- if callback is None:
- if run_id:
- await self._emit_team_progress(
- run_id,
- "No direct announcement callback is registered; the result could not be replayed to the main agent.",
- stage_label="缺少公告回流处理器",
- metadata={
- "phase": "announcement",
- "step": "direct_callback_missing",
- "announcement_path": "direct",
- "announcement_sender_id": sender_id,
- "announcement_category": category,
- },
- )
- return
- if run_id:
- await self._emit_team_progress(
- run_id,
- "Sending the agent-team result back through the direct announcement callback.",
- stage_label="请求主 Agent 总结",
- metadata={
- "phase": "announcement",
- "step": "direct_callback_start",
- "announcement_path": "direct",
- "announcement_sender_id": sender_id,
- "announcement_category": category,
- "origin_channel": origin.get("channel"),
- "origin_chat_id": origin.get("chat_id"),
- },
- )
- try:
- await callback(
- content,
- origin,
- sender_id,
- not has_process_event_sink(),
- )
- if run_id:
- await self._emit_team_progress(
- run_id,
- "The direct announcement callback completed successfully.",
- stage_label="主 Agent 总结完成",
- metadata={
- "phase": "announcement",
- "step": "direct_callback_complete",
- "announcement_path": "direct",
- "announcement_sender_id": sender_id,
- "announcement_category": category,
- },
- )
- except Exception as exc:
- if run_id:
- await self._emit_team_progress(
- run_id,
- f"Direct announcement callback failed: {exc}",
- stage_label="主 Agent 总结失败",
- metadata={
- "phase": "announcement",
- "step": "direct_callback_failed",
- "announcement_path": "direct",
- "announcement_sender_id": sender_id,
- "announcement_category": category,
- "error": str(exc),
- },
- )
- logger.warning("Failed to handle direct delegation announcement: {}", exc)
-
- async def _run_dispatch(
- self,
- run_id: str,
- task: str,
- label: str,
- target: str | None,
- targets: list[str],
- strategy: str,
- skills: list[str],
- origin: dict[str, str],
- mode: str,
- ) -> None:
- """后台委派主入口。"""
- descriptor: AgentDescriptor | None = None
- state = self._running_tasks.get(run_id)
- # 某些极短生命周期场景下 state 可能已被移除,此时回落到默认 True。
- announce_via_bus = True if state is None else state.announce_via_bus
- is_group = mode == "agent_team"
- try:
- if is_group:
- planned_targets = list(targets)
- await self._emit_group_started(run_id, label, planned_targets)
- await self._emit_team_progress(
- run_id,
- "Agent team dispatch accepted and moved into swarms orchestration.",
- stage_label="开始团队编排",
- metadata={
- "phase": "dispatch",
- "strategy": strategy,
- "execution_path": "swarms",
- "announce_via_bus": announce_via_bus,
- "requested_targets": planned_targets,
- },
- )
- logger.info(
- "Agent team [{}] dispatch started: mode=swarms announce_via_bus={} requested_targets={}",
- run_id,
- announce_via_bus,
- planned_targets,
- )
- await self._emit_team_progress(
- run_id,
- "DelegationManager handed the task to AgentTeamOrchestrator.",
- stage_label="编排器接管任务",
- metadata={
- "phase": "orchestrator",
- "step": "handoff_to_orchestrator",
- "requested_targets": planned_targets,
- },
- )
- orchestrated = await self.agent_team_orchestrator.run_task(
- task=task,
- label=label,
- skills=skills,
- origin=origin,
- announce_via_bus=announce_via_bus,
- run_id=run_id,
- )
- await self._emit_team_progress(
- run_id,
- "AgentTeamOrchestrator returned a final bridge result.",
- stage_label="编排器已返回结果",
- metadata={
- "phase": "orchestrator",
- "step": "orchestrator_result_ready",
- "execution_mode": orchestrated.mode.value,
- "candidate_procedure_id": (
- orchestrated.candidate_procedure.id
- if orchestrated.candidate_procedure is not None
- else None
- ),
- "attempt_count": len(orchestrated.attempts),
- "success": orchestrated.success,
- },
- )
- await self._emit_group_finished(
- run_id,
- label,
- orchestrated.last_member_results(),
- status="done" if orchestrated.success else "error",
- summary=orchestrated.summary,
- metadata_extra={
- "execution_mode": orchestrated.mode.value,
- "candidate_procedure_id": (
- orchestrated.candidate_procedure.id
- if orchestrated.candidate_procedure is not None
- else None
- ),
- "attempts": [attempt.to_dict() for attempt in orchestrated.attempts],
- },
- )
- await self._announce_orchestrator_result(
- run_id,
- label,
- task,
- orchestrated,
- origin,
- announce_via_bus=announce_via_bus,
- )
- return
-
- # 单 agent 场景先解析目标,再执行。
- descriptor = self._resolve_single(task, target, strategy)
- await self._emit_agent_started(run_id, descriptor, label, task=task)
- progress_callback = self._build_progress_callback(
- origin,
- descriptor,
- event_run_id=run_id,
- tracking_run_id=run_id,
- publish_via_bus=announce_via_bus,
- )
- result = await self._execute_descriptor(
- descriptor,
- task,
- label,
- skill_names=skills,
- event_callback=progress_callback,
- task_callback=self._build_task_callback(run_id, descriptor),
- process_run_id=run_id,
- )
- await self._emit_agent_finished(run_id, descriptor, result)
- await self._announce_single_result(
- run_id,
- label,
- task,
- result,
- origin,
- announce_via_bus=announce_via_bus,
- )
- except asyncio.CancelledError:
- logger.info("Delegation [{}] cancelled", run_id)
- if is_group:
- await emit_process_event(
- "process_run_cancelled",
- run_id=run_id,
- actor_type="system",
- actor_id="agent-group",
- actor_name="Agent Team",
- status="cancelled",
- )
- else:
- await self._emit_agent_cancelled(run_id, descriptor, label)
- await self._announce_cancelled(
- run_id,
- label,
- task,
- origin,
- announce_via_bus=announce_via_bus,
- )
- raise
- except Exception as exc:
- # 所有异常统一转换成 AgentRunResult 风格的错误结果,避免上层出现未处理异常。
- logger.error("Delegation [{}] failed: {}", run_id, exc)
- error_result = AgentRunResult(
- agent_id=target or "delegation",
- agent_name=target or "delegation",
- status="error",
- summary=f"Error: {exc}",
- )
- if is_group:
- await self._emit_team_progress(
- run_id,
- f"Agent team execution failed before announcement: {exc}",
- stage_label="团队执行失败",
- metadata={
- "phase": "error",
- "step": "dispatch_failed",
- "error": str(exc),
- },
- )
- await emit_process_event(
- "process_run_finished",
- run_id=run_id,
- actor_type="system",
- actor_id="agent-group",
- actor_name="Agent Team",
- status="error",
- summary=f"Error: {exc}",
- )
- elif descriptor is not None:
- await self._emit_agent_finished(run_id, descriptor, error_result)
- await self._announce_single_result(
- run_id,
- label,
- task,
- error_result,
- origin,
- announce_via_bus=announce_via_bus,
- )
-
- def _resolve_single(self, task: str, target: str | None, strategy: str) -> AgentDescriptor:
- """按显式目标或路由策略解析单个 agent。"""
- if target:
- 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"
- ]
- if suggestions:
- return suggestions[0]
- raise ValueError("No matching plugin agent found")
-
- if strategy == "a2a":
- suggestions = [
- agent for agent in self.registry.suggest_agents(task)
- if agent.protocol == "a2a"
- ]
- if suggestions:
- return suggestions[0]
- raise ValueError("No matching A2A agent found")
-
- # 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
-
- def _resolve_nested_delegate(self, task: str, target: str | None, strategy: str) -> AgentDescriptor:
- """为 delegated worker 解析允许的下游目标。"""
- probe = (strategy or "auto").strip().lower()
- if target:
- descriptor = self.registry.get_agent(target)
- if descriptor is None:
- raise ValueError(f"Agent '{target}' not found")
- self._ensure_nested_descriptor_allowed(descriptor)
- if probe == "a2a" and not self._is_nested_a2a_descriptor(descriptor):
- raise ValueError(f"Agent '{target}' is not an allowed A2A downstream target")
- if probe == "ephemeral_subagent" and not self._is_ephemeral_local_descriptor(descriptor):
- raise ValueError(f"Agent '{target}' is not an allowed ephemeral downstream target")
- return descriptor
-
- if probe == "a2a":
- suggestions = [
- agent for agent in self.registry.suggest_agents(task, limit=5)
- if self._is_nested_a2a_descriptor(agent)
- ]
- if suggestions:
- return suggestions[0]
- raise ValueError("No matching downstream A2A agent found")
-
- if probe == "ephemeral_subagent":
- suggestions = [
- agent for agent in self.registry.suggest_agents(task, limit=5)
- if self._is_ephemeral_local_descriptor(agent)
- ]
- if suggestions:
- return suggestions[0]
- descriptor = self.registry.get_agent("local-subagent")
- if descriptor and self._is_ephemeral_local_descriptor(descriptor):
- return descriptor
- raise ValueError("No ephemeral local subagent is available")
-
- a2a_suggestions = [
- agent for agent in self.registry.suggest_agents(task, limit=5)
- if self._is_nested_a2a_descriptor(agent)
- ]
- if a2a_suggestions:
- return a2a_suggestions[0]
- local_suggestions = [
- agent for agent in self.registry.suggest_agents(task, limit=5)
- if self._is_ephemeral_local_descriptor(agent)
- ]
- if local_suggestions:
- return local_suggestions[0]
- descriptor = self.registry.get_agent("local-subagent")
- if descriptor and self._nested_descriptor_allowed(descriptor):
- return descriptor
- raise ValueError("No allowed downstream agent found")
-
- @staticmethod
- def _normalize_skill_names(skill_names: list[str] | None) -> list[str]:
- result: list[str] = []
- seen: set[str] = set()
- for item in skill_names or []:
- name = str(item or "").strip()
- if not name:
- continue
- key = name.lower()
- if key in seen:
- continue
- seen.add(key)
- result.append(name)
- return result
-
- def _build_skill_context(self, skill_names: list[str] | None) -> str:
- names = self._normalize_skill_names(skill_names)
- if not names:
- return ""
- header = "Required skills: " + ", ".join(names)
- if self.skills_loader is None:
- return header
- content = self.skills_loader.load_skills_for_context(names).strip()
- if not content:
- return header
- return f"{header}\n\n{content}"
-
- def _augment_task_with_skills(self, task: str, skill_names: list[str] | None) -> str:
- skill_context = self._build_skill_context(skill_names)
- if not skill_context:
- return task
- return (
- f"{task}\n\n"
- "You must follow the required skills below while completing this delegated work.\n\n"
- f"{skill_context}"
- )
-
- @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_team_member_for_swarms(
- self,
- descriptor: AgentDescriptor,
- task: str,
- parent_run_id: str,
- skills: list[str],
- ) -> AgentRunResult:
- """Execute one swarms-selected nanobot agent as a process child run."""
- state = self._running_tasks.get(parent_run_id)
- label = "Agent Team" if state is None else state.label
- origin = {"channel": "system", "chat_id": "direct"} if state is None else state.origin
- announce_via_bus = True if state is None else state.announce_via_bus
- child_run_id = new_run_id("agent")
- try:
- self._ensure_descriptor_allowed(descriptor)
- await self._emit_agent_started(
- child_run_id,
- descriptor,
- label,
- parent_run_id=parent_run_id,
- task=task,
- )
- result = await self._execute_descriptor(
- descriptor,
- task,
- label,
- skill_names=skills,
- event_callback=self._build_progress_callback(
- origin,
- descriptor,
- event_run_id=child_run_id,
- tracking_run_id=parent_run_id,
- publish_via_bus=announce_via_bus,
- ),
- task_callback=self._build_task_callback(parent_run_id, descriptor),
- process_run_id=child_run_id,
- )
- await self._emit_agent_finished(child_run_id, descriptor, result)
- return result
- except asyncio.CancelledError:
- await self._emit_agent_cancelled(child_run_id, descriptor, label)
- raise
- except Exception as exc:
- result = AgentRunResult(
- agent_id=descriptor.id,
- agent_name=descriptor.name,
- status="error",
- summary=f"Error: {exc}",
- )
- await self._emit_agent_finished(child_run_id, descriptor, result)
- return result
-
- async def _execute_descriptor(
- self,
- descriptor: AgentDescriptor,
- task: str,
- label: str,
- skill_names: list[str] | None = None,
- event_callback=None,
- task_callback=None,
- process_run_id: str | None = None,
- allow_nested_delegation: bool = True,
- ) -> AgentRunResult:
- """根据 descriptor 类型执行具体 agent。"""
- logger.info("Delegating '{}' to {}", label, descriptor.id)
- skill_context = self._build_skill_context(skill_names)
- 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(
- task=task,
- label=label,
- agent_id=descriptor.id,
- agent_name=descriptor.name,
- system_prompt=descriptor.system_prompt,
- model=descriptor.model,
- progress_callback=event_callback,
- allow_nested_delegation=allow_nested_delegation,
- skill_context=skill_context,
- skill_names=self._normalize_skill_names(skill_names),
- )
- if descriptor.kind == "a2a_remote" or descriptor.protocol == "a2a":
- # 远端执行交给 A2AClient,委派层只负责传递事件回调和 task_callback。
- with process_run_context(process_run_id):
- return await self.a2a_client.run_task(
- descriptor,
- task=self._augment_task_with_skills(task, skill_names),
- label=label,
- event_callback=event_callback,
- task_callback=task_callback,
- )
- 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
-
- @staticmethod
- def _is_persistent_local_subagent_descriptor(descriptor: AgentDescriptor) -> bool:
- return bool(descriptor.metadata.get("local_subagent"))
-
- def _is_ephemeral_local_descriptor(self, descriptor: AgentDescriptor) -> bool:
- return descriptor.kind in {"local_fallback", "local_prompt"} and self._descriptor_allowed(descriptor)
-
- def _is_nested_a2a_descriptor(self, descriptor: AgentDescriptor) -> bool:
- return (
- (descriptor.protocol == "a2a" or descriptor.kind == "a2a_remote")
- and not self._is_persistent_local_subagent_descriptor(descriptor)
- )
-
- def _nested_descriptor_allowed(self, descriptor: AgentDescriptor) -> bool:
- return self._is_ephemeral_local_descriptor(descriptor) or self._is_nested_a2a_descriptor(descriptor)
-
- def _ensure_descriptor_allowed(self, descriptor: AgentDescriptor) -> None:
- if not self._descriptor_allowed(descriptor):
- raise ValueError(f"Delegation to '{descriptor.id}' is disabled")
-
- def _ensure_nested_descriptor_allowed(self, descriptor: AgentDescriptor) -> None:
- if not self._nested_descriptor_allowed(descriptor):
- raise ValueError(f"Delegation to '{descriptor.id}' is not allowed for delegated workers")
-
- def _build_progress_callback(
- self,
- origin: dict[str, str],
- descriptor: AgentDescriptor,
- event_run_id: str,
- tracking_run_id: str | None = None,
- publish_via_bus: bool = True,
- ):
- """构造统一的进度回调,适配本地 agent 和 A2A 流事件。"""
- last_text: str | None = None
- last_status: str | None = None
-
- if descriptor.protocol == "a2a":
- async def _callback(event: A2AStreamEvent) -> None:
- nonlocal last_text, last_status
- # 远端一旦暴露 task_id,立刻登记,便于后续取消。
- if tracking_run_id and event.task_id:
- self._register_remote_task(tracking_run_id, descriptor, event.task_id)
- text = (event.text or "").strip()
- status = (event.status or "").strip()
- if text and text != last_text:
- last_text = text
- # 文本进度既发给 bus,也发结构化 process event。
- await self._publish_prefixed_progress(
- origin,
- descriptor,
- text,
- publish_via_bus=publish_via_bus,
- )
- await emit_process_event(
- "process_run_progress",
- run_id=event_run_id,
- actor_type="agent",
- actor_id=descriptor.id,
- actor_name=descriptor.name,
- text=text,
- metadata={"kind": event.kind, "protocol": "a2a"},
- )
- if event.kind == "artifact-update":
- # artifact-update 单独再抛一份 artifact 事件,前端可按附件样式渲染。
- await emit_process_event(
- "process_run_artifact",
- run_id=event_run_id,
- actor_type="agent",
- actor_id=descriptor.id,
- actor_name=descriptor.name,
- title=f"{descriptor.name} artifact",
- artifact_type="text",
- content=text,
- metadata={"kind": event.kind, "protocol": "a2a"},
- )
- if status and status != last_status:
- last_status = status
- # A2A 的原始状态名不稳定,这里统一归一化后再发给前端。
- await emit_process_event(
- "process_run_status",
- run_id=event_run_id,
- actor_type="agent",
- actor_id=descriptor.id,
- actor_name=descriptor.name,
- status=self._ui_status(status),
- text=f"{descriptor.name}: {status}",
- metadata={"raw_status": status, "protocol": "a2a"},
- )
-
- return _callback
-
- async def _local_callback(text: str, *, tool_hint: bool = False) -> None:
- nonlocal last_text, last_status
- clean = text.strip()
- if clean and clean != last_text:
- last_text = clean
- await self._publish_prefixed_progress(
- origin,
- descriptor,
- clean,
- publish_via_bus=publish_via_bus,
- tool_hint=tool_hint,
- )
- await emit_process_event(
- "process_run_progress",
- run_id=event_run_id,
- actor_type="agent",
- actor_id=descriptor.id,
- actor_name=descriptor.name,
- text=clean,
- metadata={"tool_hint": tool_hint, "protocol": "local"},
- )
- status = "running"
- if status != last_status:
- last_status = status
- # 本地执行没有像 A2A 那样细粒度状态流,至少发一次 running 状态。
- await emit_process_event(
- "process_run_status",
- run_id=event_run_id,
- actor_type="agent",
- actor_id=descriptor.id,
- actor_name=descriptor.name,
- status=status,
- text=f"{descriptor.name} is working",
- metadata={"protocol": "local"},
- )
-
- return _local_callback
-
- def _build_task_callback(self, run_id: str, descriptor: AgentDescriptor):
- """为远端 A2A agent 构造 task_id 登记回调。"""
- if descriptor.protocol != "a2a":
- return None
-
- async def _callback(task_id: str) -> None:
- self._register_remote_task(run_id, descriptor, task_id)
-
- return _callback
-
- def _register_remote_task(
- self,
- run_id: str,
- descriptor: AgentDescriptor,
- task_id: str,
- ) -> None:
- """把远端 agent 产生的 task_id 记到运行状态里。"""
- state = self._running_tasks.get(run_id)
- if state is None:
- return
- state.remote_agents[descriptor.id] = descriptor
- state.remote_task_ids[descriptor.id] = task_id
-
- async def _cancel_remote_tasks(self, run_id: str, state: DelegationRun) -> None:
- """尽力取消当前委派对应的所有远端 A2A 任务。"""
- if not state.remote_task_ids:
- return
-
- async def _cancel_one(agent_id: str, task_id: str) -> tuple[str, bool]:
- descriptor = state.remote_agents.get(agent_id)
- if descriptor is None:
- return agent_id, False
- try:
- cancelled = await self.a2a_client.cancel_task(descriptor, task_id)
- return agent_id, cancelled
- except Exception as exc:
- # 取消失败只记日志,不阻断其他任务的取消尝试。
- logger.warning("Failed to cancel remote task {} for {}: {}", task_id, agent_id, exc)
- return agent_id, False
-
- results = await asyncio.gather(*[
- _cancel_one(agent_id, task_id)
- for agent_id, task_id in list(state.remote_task_ids.items())
- ])
- for agent_id, cancelled in results:
- if cancelled:
- logger.info("Cancelled remote A2A task for {} in delegation {}", agent_id, run_id)
-
- async def _announce_cancelled(
- self,
- run_id: str,
- label: str,
- task: str,
- origin: dict[str, str],
- *,
- announce_via_bus: bool,
- ) -> None:
- """公告委派被取消。"""
- if announce_via_bus:
- await self._publish_announcement(
- (
- f"[Delegation '{label}' cancelled]\n\n"
- f"Task: {task}\n\n"
- "Tell the user briefly that the delegated work was cancelled."
- ),
- origin,
- sender_id="delegation-cancel",
- )
- else:
- await self._notify_direct_announcement(
- (
- f"[Delegation '{label}' cancelled]\n\n"
- f"Task: {task}\n\n"
- "Tell the user briefly that the delegated work was cancelled."
- ),
- origin,
- "delegation-cancel",
- )
- await self._emit_direct_user_message(
- f"The delegated work '{label}' for task '{task}' was cancelled. Tell the user briefly.",
- f"已取消委派任务:{label}",
- )
-
- async def _announce_single_result(
- self,
- run_id: str,
- label: str,
- task: str,
- result: AgentRunResult,
- origin: dict[str, str],
- *,
- announce_via_bus: bool,
- ) -> None:
- """公告单 agent 委派结果。"""
- status_text = "completed successfully" if result.status == "ok" else result.status
- content = (
- f"[Delegation '{label}' {status_text}]\n\n"
- f"Agent: {result.agent_name} ({result.agent_id})\n"
- f"Task: {task}\n\n"
- f"Result:\n{result.summary}\n\n"
- "Summarize this naturally for the user. Keep it brief (1-2 sentences). "
- "Do not mention technical details like task IDs unless they matter."
- )
- if announce_via_bus:
- await self._publish_announcement(content, origin, sender_id="delegation")
- else:
- await self._notify_direct_announcement(content, origin, "delegation")
- await self._emit_direct_user_message(
- content,
- f"{result.agent_name} 已完成:{result.summary}",
- )
- logger.debug("Delegation [{}] announced result", run_id)
-
- async def _announce_orchestrator_result(
- self,
- run_id: str,
- label: str,
- task: str,
- result: BridgeResult,
- origin: dict[str, str],
- *,
- announce_via_bus: bool,
- ) -> None:
- """公告 orchestrator 驱动的 agent team 结果。
-
- Demo 输出:
- `[Agent team 'weekly report' completed]\nExecution mode: swarms\nMatched procedure: procedure-a1b2c3d4`
- """
- # 这里显式保留 mode / procedure 信息,方便主 agent 做更准确的用户总结。
- await self._emit_team_progress(
- run_id,
- "Preparing orchestrated agent-team summary for the main agent.",
- stage_label="整理团队结果",
- metadata={
- "phase": "announcement",
- "step": "build_orchestrator_summary",
- "execution_mode": result.mode.value,
- "attempt_count": len(result.attempts),
- },
- )
- status_text = "completed" if result.success else "failed"
- lines = [
- f"[Agent team '{label}' {status_text}]",
- "",
- f"Task: {task}",
- f"Execution mode: {result.mode.value}",
- ]
- if result.matched_procedure is not None:
- lines.append(
- "Matched procedure: "
- f"{result.matched_procedure.id} "
- f"(confidence={result.matched_procedure.confidence:.2f})"
- )
- if result.attempts:
- lines.extend(["", "Attempts:"])
- for attempt in result.attempts:
- attempt_status = "ok" if attempt.success else "error"
- lines.append(f"- {attempt.mode.value}: {attempt_status}")
- if attempt.error:
- lines.append(f" error: {attempt.error}")
-
- member_results = result.last_member_results()
- if member_results:
- lines.extend(["", "Members:"])
- for item in member_results:
- lines.append(f"- {item.agent_name} ({item.agent_id}): {item.status}")
- lines.extend(["", "Results:"])
- for item in member_results:
- lines.append(f"### {item.agent_name} ({item.status})")
- lines.append(item.summary)
- lines.append("")
-
- lines.extend([
- "Final summary:",
- result.summary,
- "",
- "Summarize this naturally for the user. Mention disagreements or failures if any.",
- ])
- summary = "\n".join(lines).strip()
- if announce_via_bus:
- await self._publish_announcement(
- summary,
- origin,
- sender_id="delegation-team",
- run_id=run_id,
- category="agent_team_orchestrated",
- )
- else:
- await self._notify_direct_announcement(
- summary,
- origin,
- "delegation-team",
- run_id=run_id,
- category="agent_team_orchestrated",
- )
- await self._emit_direct_user_message(
- summary,
- "Agent team 已完成,请查看最终结论与各次尝试摘要。",
- )
- logger.debug("Agent team [{}] announced orchestrated result", run_id)
-
- async def _publish_announcement(
- self,
- content: str,
- origin: dict[str, str],
- sender_id: str,
- *,
- run_id: str | None = None,
- category: str | None = None,
- ) -> None:
- """通过 system inbound 消息把公告重新送回主 agent 链路。"""
- msg = InboundMessage(
- channel="system",
- sender_id=sender_id,
- chat_id=f"{origin['channel']}:{origin['chat_id']}",
- content=content,
- )
- await self.bus.publish_inbound(msg)
- if run_id:
- await self._emit_team_progress(
- run_id,
- "Team summary has been published back to the main agent via the system bus.",
- stage_label="团队结果已回流",
- metadata={
- "phase": "announcement",
- "step": "bus_publish_complete",
- "announcement_path": "bus",
- "announcement_sender_id": sender_id,
- "announcement_category": category,
- "origin_channel": origin.get("channel"),
- "origin_chat_id": origin.get("chat_id"),
- },
- )
diff --git a/app-instance/backend-old/nanobot/agent/loop.py b/app-instance/backend-old/nanobot/agent/loop.py
deleted file mode 100644
index 1bff1d0..0000000
--- a/app-instance/backend-old/nanobot/agent/loop.py
+++ /dev/null
@@ -1,813 +0,0 @@
-"""Agent 主循环:Boardware Genius 的核心处理引擎。
-
-职责概览:
-1. 从消息总线读取入站消息;
-2. 结合会话历史、记忆与工作区上下文构建提示词;
-3. 调用 LLM 并迭代执行工具调用;
-4. 将结果写回会话并发布出站消息;
-5. 在后台处理记忆归档与 MCP 工具连接生命周期。
-"""
-
-from __future__ import annotations
-
-import asyncio
-import json
-import re
-from contextlib import AsyncExitStack
-from pathlib import Path
-from typing import TYPE_CHECKING, Any, Awaitable, Callable
-
-from loguru import logger
-
-from nanobot.agent.agent_registry import AgentRegistry
-from nanobot.agent.context import ContextBuilder
-from nanobot.agent.delegation import DelegationManager
-from nanobot.agent.memory import MemoryStore
-from nanobot.agent.plugins import PluginLoader
-from nanobot.agent.process_events import process_event_sink
-from nanobot.agent.subagent import SubagentManager
-from nanobot.agent.tools.base import Tool
-from nanobot.agent.tools.cron import CronTool
-from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
-from nanobot.agent.tools.message import MessageTool
-from nanobot.agent.tools.registry import ToolRegistry
-from nanobot.agent.tools.shell import ExecTool
-from nanobot.agent.tools.spawn import DelegationTool, SpawnAgentTeamTool, SpawnSubagentTool
-from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
-from nanobot.bus.events import InboundMessage, OutboundMessage
-from nanobot.bus.queue import MessageBus
-from nanobot.providers.base import LLMProvider
-from nanobot.session.manager import Session, SessionManager
-
-if TYPE_CHECKING:
- from nanobot.config.schema import A2AConfig, ChannelsConfig, ExecToolConfig
- from nanobot.cron.service import CronService
-
-
-class AgentLoop:
- """
- AgentLoop 是 Boardware Genius 运行时的“对话编排器”。
-
- 一次标准处理链路:
- 1. 接收入站消息(来自 CLI 或外部渠道);
- 2. 恢复对应会话并构建当前轮上下文;
- 3. 调用模型,解析工具调用并执行;
- 4. 将本轮新增消息写入会话;
- 5. 输出最终回复(或由消息工具自行发送)。
- """
-
- def __init__(
- self,
- bus: MessageBus,
- provider: LLMProvider,
- workspace: Path,
- model: str | None = None,
- max_iterations: int = 40,
- temperature: float = 0.1,
- max_tokens: int = 4096,
- memory_window: int = 100,
- brave_api_key: str | None = None,
- exec_config: ExecToolConfig | None = None,
- a2a_config: "A2AConfig | None" = None,
- cron_service: CronService | None = None,
- restrict_to_workspace: bool = False,
- session_manager: SessionManager | None = None,
- mcp_servers: dict | None = None,
- 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,
- gateway_port: int = 18790,
- ):
- from nanobot.config.schema import A2AConfig, ExecToolConfig
- # 基础依赖与运行参数。
- self.bus = bus
- self.channels_config = channels_config
- self.provider = provider
- self.workspace = workspace
- self.model = model or provider.get_default_model()
- self.max_iterations = max_iterations
- self.temperature = temperature
- self.max_tokens = max_tokens
- self.memory_window = memory_window
- self.brave_api_key = brave_api_key
- self.exec_config = exec_config or ExecToolConfig()
- self.a2a_config = a2a_config or A2AConfig()
- self.cron_service = cron_service
- 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)
- # SkillsLoader 需要感知 plugin 附带的 skill 目录,因此单独抽到 helper 构建。
- self.skills = self._build_skills_loader()
- self.agent_registry = AgentRegistry(
- workspace,
- plugins=self.plugins,
- 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,
- skills_loader=self.skills,
- agent_registry=self.agent_registry,
- )
- self.sessions = session_manager or SessionManager(workspace)
- self.tools = ToolRegistry()
- self.subagents = SubagentManager(
- provider=provider,
- workspace=workspace,
- model=self.model,
- temperature=self.temperature,
- max_tokens=self.max_tokens,
- brave_api_key=brave_api_key,
- exec_config=self.exec_config,
- restrict_to_workspace=restrict_to_workspace,
- )
- self.delegation = DelegationManager(
- provider=provider,
- model=self.model,
- workspace=workspace,
- bus=bus,
- registry=self.agent_registry,
- skills_loader=self.skills,
- local_executor=self.subagents,
- timeout_seconds=self.a2a_config.timeout_seconds,
- poll_interval_seconds=self.a2a_config.poll_interval_seconds,
- card_cache_ttl_seconds=self.a2a_config.card_cache_ttl_seconds,
- max_parallel_agents=self.a2a_config.max_parallel_agents,
- 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,
- gateway_port=gateway_port,
- )
- self.subagents.set_nested_delegate(self.delegation)
-
- # 运行时状态位。
- self._running = False
- self._mcp_servers = mcp_servers or {}
- self._mcp_stack: AsyncExitStack | None = None
- self._mcp_connected = False
- self._mcp_connecting = False
- # `_mcp_report` 保存最近一次连接结果,供 Web API 展示状态和错误信息。
- self._mcp_report: dict[str, dict[str, Any]] = {}
- # 会话级记忆归档控制:避免同一会话并发归档。
- self._consolidating: set[str] = set() # Session keys with consolidation in progress
- self._consolidation_tasks: set[asyncio.Task] = set() # Strong refs to in-flight tasks
- self._consolidation_locks: dict[str, asyncio.Lock] = {}
- self._register_default_tools()
-
- def apply_runtime_config(self, *, authz_config: Any | None, backend_identity: Any | None) -> None:
- """同步运行中 loop 的鉴权上下文,避免变更后必须重启。"""
- self.authz_config = authz_config
- self.backend_identity = backend_identity
- self.delegation.a2a_client.authz_config = authz_config
- self.delegation.a2a_client.backend_identity = backend_identity
-
- def _register_default_tools(self) -> None:
- """注册默认工具集合。"""
- # 启用工作区限制时,文件读写工具仅允许访问 workspace 目录树。
- allowed_dir = self.workspace if self.restrict_to_workspace else None
- protected_skill_paths = [self.workspace / "skills"]
- self.tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
- self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
- self.tools.register(
- WriteFileTool(
- workspace=self.workspace,
- allowed_dir=allowed_dir,
- protected_paths=protected_skill_paths,
- )
- )
- self.tools.register(
- EditFileTool(
- workspace=self.workspace,
- allowed_dir=allowed_dir,
- protected_paths=protected_skill_paths,
- )
- )
-
- # Shell 工具独立配置超时与目录约束。
- self.tools.register(ExecTool(
- working_dir=str(self.workspace),
- timeout=self.exec_config.timeout,
- restrict_to_workspace=self.restrict_to_workspace,
- protected_paths=protected_skill_paths,
- ))
-
- # 网络、消息、委派工具按职责注册。
- self.tools.register(WebSearchTool(api_key=self.brave_api_key))
- self.tools.register(WebFetchTool())
- if self.allow_message:
- self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
- if self.allow_spawn:
- self.tools.register(SpawnSubagentTool(manager=self.delegation))
- self.tools.register(SpawnAgentTeamTool(manager=self.delegation))
-
- # 只有注入 cron_service 时才暴露 cron 工具,避免空引用。
- if self.cron_service and self.allow_cron:
- self.tools.register(CronTool(self.cron_service))
-
- async def _connect_mcp(self) -> None:
- """懒加载连接 MCP 服务器(单次连接,失败可重试)。"""
- # 已连接 / 正在连接 / 未配置时直接返回。
- if self._mcp_connected or self._mcp_connecting or not self._mcp_servers:
- return
- self._mcp_connecting = True
- from nanobot.agent.tools.mcp import connect_mcp_servers
- try:
- # 用 AsyncExitStack 统一托管各 MCP 连接的退出清理。
- self._mcp_stack = AsyncExitStack()
- await self._mcp_stack.__aenter__()
- self._mcp_report = await connect_mcp_servers(
- self._mcp_servers,
- self.tools,
- self._mcp_stack,
- authz_config=self.authz_config,
- backend_identity=self.backend_identity,
- )
- self._mcp_connected = any(item.get("status") == "connected" for item in self._mcp_report.values())
- except Exception as e:
- # 失败后保留可重试能力:释放已建立资源,下一条消息再尝试连接。
- logger.error("Failed to connect MCP servers (will retry next message): {}", e)
- if self._mcp_stack:
- try:
- await self._mcp_stack.aclose()
- except Exception:
- pass
- self._mcp_stack = None
- self._mcp_report = {
- name: {
- "status": "error",
- "last_error": str(e),
- "tool_names": [],
- "tool_count": 0,
- "transport": "stdio" if getattr(cfg, "command", "") else "http",
- }
- for name, cfg in self._mcp_servers.items()
- }
- finally:
- self._mcp_connecting = False
-
- def _clear_mcp_tools(self) -> None:
- """移除当前 registry 里所有 MCP 工具包装器。"""
- for tool_name in list(self.tools.tool_names):
- if tool_name.startswith("mcp_"):
- self.tools.unregister(tool_name)
-
- async def reload_mcp_servers(self, mcp_servers: dict | None) -> None:
- """替换 MCP 配置并按新配置重新连接。"""
- # 先彻底关闭旧连接并移除旧工具,避免新旧配置混杂。
- await self.close_mcp()
- self._clear_mcp_tools()
- self._mcp_servers = mcp_servers or {}
- self._mcp_connected = False
- self._mcp_connecting = False
- self._mcp_report = {}
- if self._mcp_servers:
- await self._connect_mcp()
-
- def get_mcp_servers_view(self) -> list[dict[str, Any]]:
- """返回 MCP 静态配置与运行态状态合并后的视图。"""
- result: list[dict[str, Any]] = []
- for name in sorted(self._mcp_servers):
- cfg = self._mcp_servers[name]
- report = self._mcp_report.get(name, {})
- sensitive = bool(getattr(cfg, "sensitive", False))
- tool_names = report.get("tool_names")
- if not isinstance(tool_names, list):
- # 若当前 report 不完整,则退化为扫描已注册工具名进行推断。
- tool_names = [
- item
- for item in self.tools.tool_names
- if item.startswith(f"mcp_{name}_")
- ]
- result.append({
- "id": name,
- "name": name,
- "transport": "stdio" if getattr(cfg, "command", "") else "http",
- "url": getattr(cfg, "url", "") or None,
- "command": getattr(cfg, "command", "") or None,
- "args": list(getattr(cfg, "args", []) or []),
- "auth_mode": getattr(cfg, "auth_mode", "none") or "none",
- "auth_audience": getattr(cfg, "auth_audience", "") or None,
- "auth_scopes": [str(item) for item in list(getattr(cfg, "auth_scopes", []) or [])],
- "headers": (
- {key: "***" for key in dict(getattr(cfg, "headers", {}) or {})}
- if sensitive
- else dict(getattr(cfg, "headers", {}) or {})
- ),
- "env": (
- {key: "***" for key in dict(getattr(cfg, "env", {}) or {})}
- if sensitive
- else dict(getattr(cfg, "env", {}) or {})
- ),
- "tool_timeout": int(getattr(cfg, "tool_timeout", 30)),
- "sensitive": sensitive,
- "enabled": True,
- "status": report.get("status", "disconnected"),
- "tool_count": int(report.get("tool_count", len(tool_names))),
- "tool_names": tool_names,
- "last_error": report.get("last_error"),
- })
- return result
-
- def _set_tool_context(
- self,
- channel: str,
- chat_id: str,
- message_id: str | None = None,
- session_key: str | None = None,
- ) -> None:
- """把当前请求的路由上下文写入各工具的默认目标。
-
- 设计目的:
- 1. 工具调用参数里不一定每次都显式传 `channel/chat_id`;
- 2. 通过这里预注入默认值,工具可自动回落到“当前会话”;
- 3. 每条消息处理前都调用一次,避免沿用上一轮残留上下文。
- """
- # message 工具:需要 channel/chat_id 才能发消息;
- # message_id 在支持线程回复/引用回复的渠道里可用于“回这条消息”。
- if message_tool := self.tools.get("message"):
- # ToolRegistry.get() 返回通用 Tool | None,
- # 用 isinstance 确认具体类型后再调用专有 set_context()。
- if isinstance(message_tool, MessageTool):
- message_tool.set_context(channel, chat_id, message_id)
-
- # 委派工具:后台任务完成后需要把结果回投到原会话,
- # 因此只需记住来源 channel/chat_id。
- for tool_name in ("spawn_subagent", "spawn_agent_team"):
- if delegation_tool := self.tools.get(tool_name):
- if isinstance(delegation_tool, DelegationTool):
- delegation_tool.set_context(channel, chat_id, announce_via_bus=self._running)
-
- # cron 工具:创建任务时会把 deliver 目标写入任务 payload,
- # 后续定时触发时才能把结果送回同一会话。
- if cron_tool := self.tools.get("cron"):
- if isinstance(cron_tool, CronTool):
- cron_tool.set_context(channel, chat_id, session_key=session_key)
-
- def _build_skills_loader(self):
- """构造可感知 plugin skill 目录的 SkillsLoader。"""
- from nanobot.agent.skills import SkillsLoader
-
- return SkillsLoader(self.workspace, extra_dirs=self.plugins.get_skill_dirs())
-
- @staticmethod
- def _strip_think(text: str | None) -> str | None:
- """去除模型输出中的 `...` 推理块。"""
- # 某些模型会把思考内容混入最终文本,这里统一做显示层清洗。
- if not text:
- return None
- return re.sub(r"[\s\S]*?", "", text).strip() or None
-
- @staticmethod
- def _tool_hint(tool_calls: list) -> str:
- """把工具调用格式化为简短提示,如 `web_search("query")`。"""
- def _fmt(tc):
- val = next(iter(tc.arguments.values()), None) if tc.arguments else None
- if not isinstance(val, str):
- return tc.name
- return f'{tc.name}("{val[:40]}…")' if len(val) > 40 else f'{tc.name}("{val}")'
- return ", ".join(_fmt(tc) for tc in tool_calls)
-
- async def _run_agent_loop(
- self,
- initial_messages: list[dict],
- on_progress: Callable[..., Awaitable[None]] | None = None,
- tool_registry: ToolRegistry | None = None,
- ) -> tuple[str | None, list[str], list[dict]]:
- """执行 agent 迭代循环。
-
- 返回:
- - final_content: 最终可回复文本(无则为 None)
- - tools_used: 本轮调用过的工具名列表
- - messages: 迭代结束后的完整消息数组(含 tool 结果)
- """
- messages = initial_messages
- tools = tool_registry or self.tools
- iteration = 0
- final_content = None
- tools_used: list[str] = []
-
- # 循环直到拿到最终回复,或达到最大迭代次数。
- while iteration < self.max_iterations:
- iteration += 1
-
- # 每一轮都带上当前消息状态与工具定义,让模型决定是否继续调工具。
- response = await self.provider.chat(
- messages=messages,
- tools=tools.get_definitions(),
- model=self.model,
- temperature=self.temperature,
- max_tokens=self.max_tokens,
- )
-
- if response.has_tool_calls:
- # 进度回调用于 CLI/渠道侧实时展示:先输出正文片段,再输出工具提示。
- if on_progress:
- clean = self._strip_think(response.content)
- if clean:
- await on_progress(clean)
- await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
-
- tool_call_dicts = [
- {
- "id": tc.id,
- "type": "function",
- "function": {
- "name": tc.name,
- "arguments": json.dumps(tc.arguments, ensure_ascii=False)
- }
- }
- for tc in response.tool_calls
- ]
- # 把 assistant 的“工具调用意图”写入对话,再逐个执行工具。
- messages = self.context.add_assistant_message(
- messages, response.content, tool_call_dicts,
- reasoning_content=response.reasoning_content,
- )
-
- for tool_call in response.tool_calls:
- tools_used.append(tool_call.name)
- args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
- logger.info("Tool call: {}({})", tool_call.name, args_str[:200])
- result = await tools.execute(tool_call.name, tool_call.arguments)
- messages = self.context.add_tool_result(
- messages, tool_call.id, tool_call.name, result
- )
- else:
- # 无工具调用即视为本轮收敛,输出最终内容。
- final_content = self._strip_think(response.content)
- # 将最终 assistant 回复写入消息链,确保会话可持久化回放。
- # 对于空/None 内容,回退到原始 content(或空串)避免丢失一轮回复。
- persist_content = final_content if final_content is not None else (response.content or "")
- messages = self.context.add_assistant_message(
- messages,
- persist_content,
- reasoning_content=response.reasoning_content,
- )
- break
-
- if final_content is None and iteration >= self.max_iterations:
- # 兜底提示:防止模型反复调工具导致“无终止回复”。
- logger.warning("Max iterations ({}) reached", self.max_iterations)
- final_content = (
- f"I reached the maximum number of tool call iterations ({self.max_iterations}) "
- "without completing the task. You can try breaking the task into smaller steps."
- )
- # 将兜底回复也写入会话,避免刷新后看不到最终结论。
- messages = self.context.add_assistant_message(messages, final_content)
-
- return final_content, tools_used, messages
-
- async def run(self) -> None:
- """启动常驻循环:持续消费入站消息并发布出站消息。"""
- self._running = True
- await self._connect_mcp()
- logger.info("Agent loop started")
-
- while self._running:
- try:
- # 用短超时轮询,便于 stop() 后快速退出循环。
- msg = await asyncio.wait_for(
- self.bus.consume_inbound(),
- timeout=1.0
- )
- try:
- response = await self._process_message(msg)
- if response is not None:
- await self.bus.publish_outbound(response)
- elif msg.channel == "cli":
- # CLI 下若消息工具已代发,仍回一个空结束包通知“本轮结束”。
- await self.bus.publish_outbound(OutboundMessage(
- channel=msg.channel, chat_id=msg.chat_id, content="", metadata=msg.metadata or {},
- ))
- except Exception as e:
- # 单条消息失败不影响主循环存活。
- logger.error("Error processing message: {}", e)
- await self.bus.publish_outbound(OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content=f"Sorry, I encountered an error: {str(e)}"
- ))
- except asyncio.TimeoutError:
- continue
-
- async def close_mcp(self) -> None:
- """关闭 MCP 连接并释放退出栈。"""
- if self._mcp_stack:
- try:
- await self._mcp_stack.aclose()
- except (RuntimeError, BaseExceptionGroup):
- # MCP SDK 在取消清理阶段可能抛出噪声异常,这里忽略即可。
- pass
- self._mcp_stack = None
- self._mcp_connected = False
- self._mcp_connecting = False
-
- def stop(self) -> None:
- """请求停止主循环。"""
- self._running = False
- logger.info("Agent loop stopping")
-
- def _get_consolidation_lock(self, session_key: str) -> asyncio.Lock:
- """获取会话级归档锁;不存在则创建。"""
- lock = self._consolidation_locks.get(session_key)
- if lock is None:
- lock = asyncio.Lock()
- self._consolidation_locks[session_key] = lock
- return lock
-
- def _prune_consolidation_lock(self, session_key: str, lock: asyncio.Lock) -> None:
- """在锁空闲时清理缓存,避免锁字典无限增长。"""
- if not lock.locked():
- self._consolidation_locks.pop(session_key, None)
-
- async def _process_message(
- self,
- msg: InboundMessage,
- session_key: str | None = None,
- on_progress: Callable[[str], Awaitable[None]] | None = None,
- execution_context: str | None = None,
- extra_tools: list[Tool] | None = None,
- ) -> OutboundMessage | None:
- """处理单条入站消息并返回出站消息(或 None)。"""
- # system 通道用于内部任务(如 cron/heartbeat),来源路由编码在 chat_id。
- if msg.channel == "system":
- channel, chat_id = (msg.chat_id.split(":", 1) if ":" in msg.chat_id
- else ("cli", msg.chat_id))
- logger.info("Processing system message from {}", msg.sender_id)
- key = f"{channel}:{chat_id}"
- session = self.sessions.get_or_create(key)
- self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"), session_key=key)
- history = session.get_history(max_messages=self.memory_window)
- messages = self.context.build_messages(
- history=history,
- current_message=msg.content,
- execution_context=execution_context,
- channel=channel,
- chat_id=chat_id,
- )
- final_content, _, all_msgs = await self._run_agent_loop(messages)
- self._save_turn(session, all_msgs, 1 + len(history))
- self.sessions.save(session)
- return OutboundMessage(channel=channel, chat_id=chat_id,
- content=final_content or "Background task completed.")
-
- preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
- logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
-
- key = session_key or msg.session_key
- session = self.sessions.get_or_create(key)
-
- # 内建斜杠命令:在进入模型前优先处理。
- cmd = msg.content.strip().lower()
- if cmd == "/new":
- # `/new` 的语义是“开启新会话”,但在真正清空前要先做一次强制归档:
- # - 把尚未沉淀的消息写入 MEMORY/HISTORY;
- # - 若归档失败则直接返回,不执行清空,避免用户上下文丢失。
-
- # 取会话级锁并标记 consolidating,防止与后台自动归档并发执行。
- # (同一会话同时归档可能导致重复写入或状态错乱)
- lock = self._get_consolidation_lock(session.key)
- self._consolidating.add(session.key)
- try:
- async with lock:
- # 只处理“未归档尾部”消息:
- # [0:last_consolidated] 视为已经落入长期记忆,
- # [last_consolidated:] 才是本次需要补归档的增量。
- snapshot = session.messages[session.last_consolidated:]
- if snapshot:
- # 用临时 Session 包装快照,再传给 consolidate:
- # 1) 不污染当前 live session 对象;
- # 2) 即便归档失败,也不会提前改动原会话结构。
- temp = Session(key=session.key)
- temp.messages = list(snapshot)
- # archive_all=True:对这个临时快照做“全量归档”,
- # 确保 /new 前的上下文尽可能完整地写入记忆文件。
- if not await self._consolidate_memory(temp, archive_all=True):
- return OutboundMessage(
- channel=msg.channel, chat_id=msg.chat_id,
- content="Memory archival failed, session not cleared. Please try again.",
- )
- except Exception:
- # 归档过程任何异常都视为失败,保持原会话不动并给出明确提示。
- logger.exception("/new archival failed for {}", session.key)
- return OutboundMessage(
- channel=msg.channel, chat_id=msg.chat_id,
- content="Memory archival failed, session not cleared. Please try again.",
- )
- finally:
- # 无论成功/失败都要撤销 in-progress 标记并清理空闲锁缓存,
- # 避免会话长期卡在 consolidating 状态。
- self._consolidating.discard(session.key)
- self._prune_consolidation_lock(session.key, lock)
-
- # 走到这里说明归档已成功(或本就无增量可归档),才执行真正清空。
- session.clear()
- # clear 后立即落盘,保证重启后状态一致。
- self.sessions.save(session)
- # 使内存缓存失效,后续读取将基于磁盘中的“新空会话”重新构建。
- self.sessions.invalidate(session.key)
- return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
- content="New session started.")
- if cmd == "/help":
- return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
- content="Boardware Genius commands:\n/new — Start a new conversation\n/help — Show available commands")
-
- # 异步触发记忆归档:达到窗口阈值时在后台执行,不阻塞当前回复。
- unconsolidated = len(session.messages) - session.last_consolidated
- if (unconsolidated >= self.memory_window and session.key not in self._consolidating):
- self._consolidating.add(session.key)
- lock = self._get_consolidation_lock(session.key)
-
- async def _consolidate_and_unlock():
- try:
- async with lock:
- await self._consolidate_memory(session)
- finally:
- # 无论成功失败都要解注册状态,避免会话长期卡在 consolidating。
- self._consolidating.discard(session.key)
- self._prune_consolidation_lock(session.key, lock)
- _task = asyncio.current_task()
- if _task is not None:
- self._consolidation_tasks.discard(_task)
-
- _task = asyncio.create_task(_consolidate_and_unlock())
- self._consolidation_tasks.add(_task)
-
- # 每轮处理前刷新工具上下文,并重置 message 工具的“本轮已发送”状态。
- self._set_tool_context(
- msg.channel,
- msg.chat_id,
- msg.metadata.get("message_id"),
- session_key=key,
- )
- if message_tool := self.tools.get("message"):
- if isinstance(message_tool, MessageTool):
- message_tool.start_turn()
-
- active_tools = self.tools
- if extra_tools:
- active_tools = self.tools.clone()
- for tool in extra_tools:
- active_tools.register(tool)
-
- # 从会话中截取有限历史,避免上下文无限膨胀。
- history = session.get_history(max_messages=self.memory_window)
- # 组装本轮发给模型的初始消息:
- # - history: 会话历史(已按窗口裁剪)
- # - current_message: 用户本轮输入
- # - media: 可选多模态附件(如图片)
- # - channel/chat_id: 当前会话路由信息(写入 system prompt 供工具决策)
- initial_messages = self.context.build_messages(
- history=history,
- current_message=msg.content,
- execution_context=execution_context,
- media=msg.media if msg.media else None,
- channel=msg.channel, chat_id=msg.chat_id,
- )
-
- async def _bus_progress(content: str, *, tool_hint: bool = False) -> None:
- # `_bus_progress` 是“默认进度回调”:
- # - 当 _run_agent_loop 里出现中间文本/工具提示时被调用;
- # - 不走最终回复通道,而是作为“中间态事件”发到 outbound。
- #
- # 这样做的好处:
- # 1) CLI/渠道可以实时显示“正在做什么”,而不是一直静默等待;
- # 2) 进度消息与最终答复共用同一队列,但可通过 metadata 区分。
- meta = dict(msg.metadata or {})
- # `_progress=True`:标记这是进度事件,消费端可选择轻量渲染。
- meta["_progress"] = True
- # `_tool_hint=True`:标记这是工具调用提示(例如 web_search(...))。
- # 消费端可按配置独立开关(send_tool_hints)来显示/隐藏。
- meta["_tool_hint"] = tool_hint
- # 进度消息仍沿用原始 channel/chat_id,保证路由到当前会话。
- await self.bus.publish_outbound(OutboundMessage(
- channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta,
- ))
-
- # 执行核心 agent 迭代:
- # - 可能多轮“模型 -> 工具 -> 模型”
- # - on_progress 若外部未传,则默认走 `_bus_progress` 输出中间态
- final_content, _, all_msgs = await self._run_agent_loop(
- initial_messages,
- on_progress=on_progress or _bus_progress,
- tool_registry=active_tools,
- )
-
- if final_content is None:
- # 极少数情况下模型未给出最终文本(例如异常边界),这里兜底避免空回复。
- final_content = "I've completed processing but have no response to give."
-
- # 日志只打印预览,避免超长内容污染日志输出。
- preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
- logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
-
- # 把本轮新增消息(assistant/tool/final)写回会话并持久化到磁盘。
- # `1 + len(history)` 用于跳过本轮前已存在的 system+history 部分。
- self._save_turn(session, all_msgs, 1 + len(history))
- self.sessions.save(session)
-
- if message_tool := self.tools.get("message"):
- if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
- # 去重保护:
- # 若本轮 agent 已通过 message 工具主动发过消息,
- # 再返回 OutboundMessage 会导致渠道侧“同内容重复发送”。
- # 因此返回 None,交给上层按“已发过”路径结束本轮。
- return None
-
- return OutboundMessage(
- channel=msg.channel, chat_id=msg.chat_id, content=final_content,
- metadata=msg.metadata or {},
- )
-
- _TOOL_RESULT_MAX_CHARS = 500
-
- def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None:
- """保存本轮新增消息到会话,并截断过长工具输出。"""
- from datetime import datetime
- for m in messages[skip:]:
- # 不持久化 reasoning_content,避免会话文件冗长且混入思考文本。
- entry = {k: v for k, v in m.items() if k != "reasoning_content"}
- if entry.get("role") == "tool" and isinstance(entry.get("content"), str):
- content = entry["content"]
- if len(content) > self._TOOL_RESULT_MAX_CHARS:
- # 大工具结果只保留前缀,兼顾可读性与存储体积。
- entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
- entry.setdefault("timestamp", datetime.now().isoformat())
- session.messages.append(entry)
- session.updated_at = datetime.now()
-
- async def _consolidate_memory(self, session, archive_all: bool = False) -> bool:
- """调用 MemoryStore 做记忆归档;成功返回 True。"""
- return await MemoryStore(self.workspace).consolidate(
- session, self.provider, self.model,
- archive_all=archive_all, memory_window=self.memory_window,
- )
-
- async def process_system_announcement(
- self,
- content: str,
- *,
- origin_channel: str,
- origin_chat_id: str,
- sender_id: str = "delegation",
- ) -> str:
- """在无常驻 run() 的场景下,本地处理一条 system 公告。"""
- await self._connect_mcp()
- msg = InboundMessage(
- channel="system",
- sender_id=sender_id,
- chat_id=f"{origin_channel}:{origin_chat_id}",
- content=content,
- )
- response = await self._process_message(msg)
- return response.content if response else ""
-
- async def process_direct(
- self,
- content: str,
- session_key: str = "cli:direct",
- channel: str = "cli",
- chat_id: str = "direct",
- on_progress: Callable[[str], Awaitable[None]] | None = None,
- process_event_callback: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
- execution_context: str | None = None,
- extra_tools: list[Tool] | None = None,
- ) -> str:
- """直接处理一条消息(用于 CLI 单轮或 cron 触发)。"""
- # 直连模式不依赖 run() 主循环,但仍需确保 MCP 可用。
- await self._connect_mcp()
- msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content)
- # process_event_sink 只在当前调用链内生效,因此不会污染其他并发请求。
- with process_event_sink(process_event_callback):
- response = await self._process_message(
- msg,
- session_key=session_key,
- on_progress=on_progress,
- # execution_context / extra_tools 主要服务于 cron 和其他系统触发场景。
- execution_context=execution_context,
- extra_tools=extra_tools,
- )
- return response.content if response else ""
diff --git a/app-instance/backend-old/nanobot/agent/marketplace.py b/app-instance/backend-old/nanobot/agent/marketplace.py
deleted file mode 100644
index 254e2b8..0000000
--- a/app-instance/backend-old/nanobot/agent/marketplace.py
+++ /dev/null
@@ -1,582 +0,0 @@
-"""Marketplace manager for Boardware Genius — discover, install, and manage plugin marketplaces."""
-
-from __future__ import annotations
-
-import json
-import shutil
-import subprocess
-import tempfile
-from dataclasses import asdict, dataclass
-from pathlib import Path
-
-from loguru import logger
-
-
-@dataclass
-class MarketplaceEntry:
- """A registered marketplace source."""
-
- name: str
- source: str
- type: str # "local" or "git"
-
-
-@dataclass
-class MarketplacePluginInfo:
- """A plugin available in a marketplace."""
-
- name: str
- description: str
- source_path: str # Relative path inside the marketplace (e.g. "./claude-plugins/data-toolkit")
- marketplace_name: str
- installed: bool
-
-
-class MarketplaceManager:
- """
- Manages plugin marketplaces: register/remove marketplace sources, discover
- available plugins, and install/uninstall them into ``~/.nanobot/plugins/``.
-
- Marketplace sources can be local directories or git repositories. Each
- marketplace root must contain ``.claude-plugin/marketplace.json`` with the
- manifest listing available plugins.
-
- Config is persisted in ``~/.nanobot/marketplaces.json``.
- Git repos are cached in ``~/.nanobot/marketplace-cache//``.
- Installed plugins land in ``~/.nanobot/plugins//``.
- """
-
- CONFIG_PATH = Path.home() / ".nanobot" / "marketplaces.json"
- CACHE_DIR = Path.home() / ".nanobot" / "marketplace-cache"
- PLUGINS_DIR = Path.home() / ".nanobot" / "plugins"
-
- GIT_TIMEOUT = 60 # seconds
-
- def __init__(
- self,
- config_path: Path | None = None,
- cache_dir: Path | None = None,
- plugins_dir: Path | None = None,
- ):
- self.config_path = config_path or self.CONFIG_PATH
- self.cache_dir = cache_dir or self.CACHE_DIR
- self.plugins_dir = plugins_dir or self.PLUGINS_DIR
-
- # ------------------------------------------------------------------ public
-
- def list_marketplaces(self) -> list[MarketplaceEntry]:
- """Return all registered marketplaces."""
- return self._load_config()
-
- def add_marketplace(self, source: str) -> MarketplaceEntry:
- """
- Register a new marketplace from a local path or git URL.
-
- For git sources the repo is cloned (``--depth=1``) into the cache
- directory and the manifest is read to determine the marketplace name.
- For local sources the path must exist and contain a valid manifest.
-
- Returns the created ``MarketplaceEntry``.
-
- Raises ``ValueError`` on invalid source or duplicate name.
- """
- source_type = self._detect_type(source)
-
- if source_type == "git":
- entry = self._add_git_marketplace(source)
- else:
- entry = self._add_local_marketplace(source)
-
- # Persist — update existing entry if one with the same name exists
- entries = self._load_config()
- replaced = False
- for i, existing in enumerate(entries):
- if existing.name == entry.name:
- logger.info(
- "Updating existing marketplace '{}' (old source: {} → new source: {})",
- entry.name,
- existing.source,
- entry.source,
- )
- entries[i] = entry
- replaced = True
- break
- if not replaced:
- entries.append(entry)
- self._save_config(entries)
- logger.info("Registered marketplace '{}' from {}", entry.name, entry.source)
- return entry
-
- def remove_marketplace(self, name: str) -> None:
- """
- Unregister a marketplace by name.
-
- If the marketplace was cloned from git, the cached clone is also deleted.
-
- Raises ``ValueError`` if the marketplace is not found.
- """
- entries = self._load_config()
- entry = self._find_entry(entries, name)
-
- # Clean up git cache if applicable
- cache_path = self.cache_dir / name
- if cache_path.exists():
- shutil.rmtree(cache_path)
- logger.debug("Removed cached clone at {}", cache_path)
-
- entries = [e for e in entries if e.name != name]
- self._save_config(entries)
- logger.info("Removed marketplace '{}'", name)
-
- def list_available_plugins(
- self, marketplace_name: str
- ) -> list[MarketplacePluginInfo]:
- """
- List all plugins offered by a registered marketplace.
-
- For git marketplaces the cached clone is updated (``git pull --ff-only``)
- before reading the manifest.
-
- Raises ``ValueError`` if the marketplace is not found or the manifest
- is missing/invalid.
- """
- entries = self._load_config()
- entry = self._find_entry(entries, marketplace_name)
- root = self._resolve_root(entry)
- manifest = self._read_manifest(root, entry.name)
-
- installed_names = self._installed_plugin_names()
-
- plugins: list[MarketplacePluginInfo] = []
- for p in manifest.get("plugins", []):
- pname = p.get("name", "")
- if not pname:
- continue
- # Skip plugins whose names would be unsafe as directory names
- try:
- self._validate_name(pname, "plugin name")
- except ValueError:
- logger.warning(
- "Skipping plugin with unsafe name '{}' in marketplace '{}'",
- pname,
- marketplace_name,
- )
- continue
- plugins.append(
- MarketplacePluginInfo(
- name=pname,
- description=p.get("description", ""),
- source_path=p.get("source", ""),
- marketplace_name=entry.name,
- installed=pname in installed_names,
- )
- )
- return plugins
-
- def install_plugin(self, marketplace_name: str, plugin_name: str) -> Path:
- """
- Install a plugin from a marketplace into ``~/.nanobot/plugins/``.
-
- The plugin directory is copied (not symlinked) so it works even if the
- marketplace source is later removed.
-
- Returns the ``Path`` to the installed plugin directory.
-
- Raises ``ValueError`` if the marketplace or plugin is not found, or if
- the plugin source directory does not exist.
- """
- self._validate_name(plugin_name, "plugin name")
-
- entries = self._load_config()
- entry = self._find_entry(entries, marketplace_name)
- root = self._resolve_root(entry)
- manifest = self._read_manifest(root, entry.name)
-
- plugin_meta = self._find_plugin_in_manifest(manifest, plugin_name, entry.name)
- source_rel = plugin_meta.get("source", "")
- source_dir = (root / source_rel).resolve()
- root_resolved = root.resolve()
-
- # Guard against path traversal — source_dir must be inside the marketplace root
- if not str(source_dir).startswith(str(root_resolved)):
- raise ValueError(
- f"Plugin source '{source_rel}' resolves outside the marketplace "
- f"root ({root_resolved}). This looks like a path traversal attempt."
- )
-
- if not source_dir.is_dir():
- raise ValueError(
- f"Plugin source directory does not exist: {source_dir}"
- )
-
- dest = self.plugins_dir / plugin_name
- if dest.exists():
- logger.debug("Removing existing plugin dir at {}", dest)
- shutil.rmtree(dest)
-
- self.plugins_dir.mkdir(parents=True, exist_ok=True)
- shutil.copytree(source_dir, dest)
- logger.info(
- "Installed plugin '{}' from marketplace '{}' → {}",
- plugin_name,
- entry.name,
- dest,
- )
- return dest
-
- def update_marketplace(self, name: str) -> MarketplaceEntry:
- """
- Update a marketplace's cached data.
-
- For git marketplaces: clones if cache is missing, pulls if it exists.
- For local marketplaces: validates the path still exists.
-
- Returns the ``MarketplaceEntry``.
-
- Raises ``ValueError`` if the marketplace is not registered or the
- update fails.
- """
- entries = self._load_config()
- entry = self._find_entry(entries, name)
-
- if entry.type == "git":
- cache_path = self.cache_dir / name
- if not cache_path.exists():
- # Cache missing (e.g. fresh Docker container) — clone
- self.cache_dir.mkdir(parents=True, exist_ok=True)
- try:
- subprocess.run(
- ["git", "clone", "--depth=1", entry.source, str(cache_path)],
- capture_output=True,
- timeout=self.GIT_TIMEOUT,
- check=True,
- )
- logger.info(
- "Cloned marketplace '{}' from {}", name, entry.source
- )
- except subprocess.CalledProcessError as e:
- stderr = (
- e.stderr.decode(errors="replace").strip()
- if e.stderr
- else ""
- )
- raise ValueError(
- f"Failed to clone marketplace '{name}': {stderr}"
- ) from e
- except subprocess.TimeoutExpired as e:
- raise ValueError(
- f"Git clone timed out after {self.GIT_TIMEOUT}s "
- f"for marketplace '{name}'"
- ) from e
- else:
- # Cache exists — pull latest
- try:
- subprocess.run(
- ["git", "pull", "--ff-only"],
- cwd=cache_path,
- capture_output=True,
- timeout=self.GIT_TIMEOUT,
- check=True,
- )
- logger.info(
- "Updated marketplace '{}' from {}", name, entry.source
- )
- except subprocess.CalledProcessError as e:
- stderr = (
- e.stderr.decode(errors="replace").strip()
- if e.stderr
- else ""
- )
- raise ValueError(
- f"Failed to update marketplace '{name}': {stderr}"
- ) from e
- except subprocess.TimeoutExpired as e:
- raise ValueError(
- f"Git pull timed out after {self.GIT_TIMEOUT}s "
- f"for marketplace '{name}'"
- ) from e
- else:
- # Local marketplace — just verify path still exists
- path = Path(entry.source).expanduser().resolve()
- if not path.is_dir():
- raise ValueError(
- f"Local marketplace directory no longer exists: {path}"
- )
- logger.debug("Local marketplace '{}' verified at {}", name, path)
-
- return entry
-
- def uninstall_plugin(self, plugin_name: str) -> None:
- """
- Remove an installed plugin from ``~/.nanobot/plugins/``.
-
- Raises ``ValueError`` if the plugin directory does not exist.
- """
- dest = self.plugins_dir / plugin_name
- if not dest.exists():
- raise ValueError(
- f"Plugin '{plugin_name}' is not installed (expected at {dest})"
- )
- shutil.rmtree(dest)
- logger.info("Uninstalled plugin '{}'", plugin_name)
-
- # ------------------------------------------------------------------ config
-
- def _load_config(self) -> list[MarketplaceEntry]:
- """Load the marketplaces config file. Returns empty list on missing/corrupt file."""
- if not self.config_path.exists():
- return []
- try:
- raw = json.loads(self.config_path.read_text(encoding="utf-8"))
- if not isinstance(raw, list):
- logger.warning(
- "marketplaces.json is not a list, resetting to empty"
- )
- return []
- return [
- MarketplaceEntry(
- name=item["name"],
- source=item["source"],
- type=item["type"],
- )
- for item in raw
- if isinstance(item, dict) and "name" in item and "source" in item and "type" in item
- ]
- except (json.JSONDecodeError, OSError) as e:
- logger.warning("Failed to read marketplaces.json: {}", e)
- return []
-
- def _save_config(self, entries: list[MarketplaceEntry]) -> None:
- """Persist the marketplaces list to disk."""
- self.config_path.parent.mkdir(parents=True, exist_ok=True)
- data = [asdict(e) for e in entries]
- self.config_path.write_text(
- json.dumps(data, indent=2, ensure_ascii=False) + "\n",
- encoding="utf-8",
- )
-
- # ------------------------------------------------------------------ helpers
-
- @staticmethod
- def _validate_name(name: str, label: str = "name") -> None:
- """Reject names that could cause path traversal when used in filesystem paths.
-
- Raises ``ValueError`` if *name* contains ``/``, ``\\``, or is ``.`` / `..``.
- """
- if "/" in name or "\\" in name or name in (".", ".."):
- raise ValueError(
- f"Invalid {label} '{name}': must not contain path separators "
- f"or be '.' / '..'"
- )
-
- @staticmethod
- def _detect_type(source: str) -> str:
- """Determine whether a source string is a git URL or a local path."""
- if (
- source.startswith("http://")
- or source.startswith("https://")
- or source.startswith("ssh://")
- or source.startswith("git://")
- or source.startswith("git@")
- or source.endswith(".git")
- ):
- return "git"
- return "local"
-
- def _find_entry(
- self, entries: list[MarketplaceEntry], name: str
- ) -> MarketplaceEntry:
- """Lookup a marketplace entry by name or raise ValueError."""
- for entry in entries:
- if entry.name == name:
- return entry
- raise ValueError(
- f"Marketplace '{name}' is not registered. "
- f"Use add_marketplace() first."
- )
-
- def _resolve_root(self, entry: MarketplaceEntry) -> Path:
- """
- Return the filesystem root of a marketplace.
-
- For local marketplaces this is the source path directly.
- For git marketplaces this is the cached clone, updated with
- ``git pull --ff-only`` before returning.
- """
- if entry.type == "git":
- cache_path = self.cache_dir / entry.name
- if not cache_path.exists():
- raise ValueError(
- f"Git cache for marketplace '{entry.name}' not found at "
- f"{cache_path}. Try removing and re-adding the marketplace."
- )
- # Update the cached clone
- try:
- subprocess.run(
- ["git", "pull", "--ff-only"],
- cwd=cache_path,
- capture_output=True,
- timeout=self.GIT_TIMEOUT,
- check=True,
- )
- logger.debug("Updated git cache for '{}'", entry.name)
- except subprocess.CalledProcessError as e:
- logger.warning(
- "git pull failed for '{}': {}",
- entry.name,
- e.stderr.decode(errors="replace").strip() if e.stderr else str(e),
- )
- except subprocess.TimeoutExpired:
- logger.warning("git pull timed out for '{}'", entry.name)
- return cache_path
- else:
- path = Path(entry.source).expanduser().resolve()
- if not path.is_dir():
- raise ValueError(
- f"Local marketplace directory does not exist: {path}"
- )
- return path
-
- def _read_manifest(self, root: Path, marketplace_name: str) -> dict:
- """Read marketplace manifest, or auto-discover plugins if no manifest exists.
-
- Looks for ``.claude-plugin/marketplace.json`` first. If that file is
- missing, falls back to scanning ``claude-plugins/`` for subdirectories
- that contain a ``plugin.json`` or ``.claude-plugin/plugin.json``.
- """
- manifest_path = root / ".claude-plugin" / "marketplace.json"
- if manifest_path.exists():
- try:
- data = json.loads(manifest_path.read_text(encoding="utf-8"))
- except (json.JSONDecodeError, OSError) as e:
- raise ValueError(
- f"Failed to parse marketplace manifest at {manifest_path}: {e}"
- ) from e
-
- if not isinstance(data, dict):
- raise ValueError(
- f"Marketplace manifest at {manifest_path} must be a JSON object"
- )
- if "plugins" not in data or not isinstance(data["plugins"], list):
- raise ValueError(
- f"Marketplace manifest at {manifest_path} missing 'plugins' array"
- )
- return data
-
- # Fallback: auto-discover plugins under claude-plugins/
- return self._auto_discover_plugins(root, marketplace_name)
-
- def _auto_discover_plugins(self, root: Path, marketplace_name: str) -> dict:
- """Scan ``claude-plugins/`` for plugin directories and build a manifest."""
- plugins_dir = root / "claude-plugins"
- if not plugins_dir.is_dir():
- raise ValueError(
- f"Marketplace at {root} has no .claude-plugin/marketplace.json "
- f"and no claude-plugins/ directory to scan."
- )
-
- plugins: list[dict] = []
- for plugin_dir in sorted(plugins_dir.iterdir()):
- if not plugin_dir.is_dir():
- continue
- # Read plugin metadata
- name = plugin_dir.name
- description = ""
- for candidate in (plugin_dir / "plugin.json", plugin_dir / ".claude-plugin" / "plugin.json"):
- if candidate.exists():
- try:
- meta = json.loads(candidate.read_text(encoding="utf-8"))
- name = meta.get("name", name)
- description = meta.get("description", "")
- except (json.JSONDecodeError, OSError):
- pass
- break
- plugins.append({
- "name": name,
- "source": f"./claude-plugins/{plugin_dir.name}",
- "description": description,
- })
-
- logger.info(
- "Auto-discovered {} plugins in marketplace '{}' (no manifest file)",
- len(plugins), marketplace_name,
- )
- return {"name": marketplace_name, "plugins": plugins}
-
- @staticmethod
- def _find_plugin_in_manifest(
- manifest: dict, plugin_name: str, marketplace_name: str
- ) -> dict:
- """Find a plugin entry by name in a marketplace manifest."""
- for p in manifest.get("plugins", []):
- if p.get("name") == plugin_name:
- return p
- raise ValueError(
- f"Plugin '{plugin_name}' not found in marketplace '{marketplace_name}'. "
- f"Available: {[p.get('name') for p in manifest.get('plugins', [])]}"
- )
-
- def _installed_plugin_names(self) -> set[str]:
- """Return the set of currently installed plugin directory names."""
- if not self.plugins_dir.exists():
- return set()
- return {d.name for d in self.plugins_dir.iterdir() if d.is_dir()}
-
- # ------------------------------------------------------------------ git
-
- def _add_git_marketplace(self, source: str) -> MarketplaceEntry:
- """Clone a git URL, read the manifest to get the name, move to cache."""
- with tempfile.TemporaryDirectory() as tmp:
- tmp_path = Path(tmp) / "repo"
- logger.debug("Cloning {} into temp dir", source)
- try:
- subprocess.run(
- ["git", "clone", "--depth=1", source, str(tmp_path)],
- capture_output=True,
- timeout=self.GIT_TIMEOUT,
- check=True,
- )
- except subprocess.CalledProcessError as e:
- stderr = e.stderr.decode(errors="replace").strip() if e.stderr else ""
- raise ValueError(
- f"Failed to clone git repository '{source}': {stderr}"
- ) from e
- except subprocess.TimeoutExpired as e:
- raise ValueError(
- f"Git clone timed out after {self.GIT_TIMEOUT}s for '{source}'"
- ) from e
-
- # Derive a fallback name from the git URL (e.g. "my-marketplace" from ".../my-marketplace.git")
- fallback_name = source.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git") or "unknown"
- manifest = self._read_manifest(tmp_path, fallback_name)
- name = manifest.get("name")
- if not name or not isinstance(name, str):
- name = fallback_name
- self._validate_name(name, "marketplace name")
-
- # Move to permanent cache location
- cache_path = self.cache_dir / name
- if cache_path.exists():
- shutil.rmtree(cache_path)
- self.cache_dir.mkdir(parents=True, exist_ok=True)
- shutil.move(str(tmp_path), str(cache_path))
- logger.debug("Cached git marketplace '{}' at {}", name, cache_path)
-
- return MarketplaceEntry(name=name, source=source, type="git")
-
- def _add_local_marketplace(self, source: str) -> MarketplaceEntry:
- """Register a local directory as a marketplace source."""
- path = Path(source).expanduser().resolve()
- if not path.is_dir():
- raise ValueError(
- f"Local marketplace path does not exist or is not a directory: {path}"
- )
-
- fallback_name = path.name
- manifest = self._read_manifest(path, fallback_name)
- name = manifest.get("name")
- if not name or not isinstance(name, str):
- name = fallback_name
- self._validate_name(name, "marketplace name")
-
- return MarketplaceEntry(name=name, source=str(path), type="local")
diff --git a/app-instance/backend-old/nanobot/agent/memory.py b/app-instance/backend-old/nanobot/agent/memory.py
deleted file mode 100644
index cdbc49f..0000000
--- a/app-instance/backend-old/nanobot/agent/memory.py
+++ /dev/null
@@ -1,143 +0,0 @@
-"""Memory system for persistent agent memory."""
-
-from __future__ import annotations
-
-import json
-from pathlib import Path
-from typing import TYPE_CHECKING
-
-from loguru import logger
-
-from nanobot.utils.helpers import ensure_dir
-
-if TYPE_CHECKING:
- from nanobot.providers.base import LLMProvider
- from nanobot.session.manager import Session
-
-
-_SAVE_MEMORY_TOOL = [
- {
- "type": "function",
- "function": {
- "name": "save_memory",
- "description": "Save the memory consolidation result to persistent storage.",
- "parameters": {
- "type": "object",
- "properties": {
- "history_entry": {
- "type": "string",
- "description": "A paragraph (2-5 sentences) summarizing key events/decisions/topics. "
- "Start with [YYYY-MM-DD HH:MM]. Include detail useful for grep search.",
- },
- "memory_update": {
- "type": "string",
- "description": "Full updated long-term memory as markdown. Include all existing "
- "facts plus new ones. Return unchanged if nothing new.",
- },
- },
- "required": ["history_entry", "memory_update"],
- },
- },
- }
-]
-
-
-class MemoryStore:
- """Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log)."""
-
- def __init__(self, workspace: Path):
- self.memory_dir = ensure_dir(workspace / "memory")
- self.memory_file = self.memory_dir / "MEMORY.md"
- self.history_file = self.memory_dir / "HISTORY.md"
-
- def read_long_term(self) -> str:
- if self.memory_file.exists():
- return self.memory_file.read_text(encoding="utf-8")
- return ""
-
- def write_long_term(self, content: str) -> None:
- self.memory_file.write_text(content, encoding="utf-8")
-
- def append_history(self, entry: str) -> None:
- with open(self.history_file, "a", encoding="utf-8") as f:
- f.write(entry.rstrip() + "\n\n")
-
- def get_memory_context(self) -> str:
- long_term = self.read_long_term()
- return f"## Long-term Memory\n{long_term}" if long_term else ""
-
- async def consolidate(
- self,
- session: Session,
- provider: LLMProvider,
- model: str,
- *,
- archive_all: bool = False,
- memory_window: int = 50,
- ) -> bool:
- """Consolidate old messages into MEMORY.md + HISTORY.md via LLM tool call.
-
- Returns True on success (including no-op), False on failure.
- """
- if archive_all:
- old_messages = session.messages
- keep_count = 0
- logger.info("Memory consolidation (archive_all): {} messages", len(session.messages))
- else:
- keep_count = memory_window // 2
- if len(session.messages) <= keep_count:
- return True
- if len(session.messages) - session.last_consolidated <= 0:
- return True
- old_messages = session.messages[session.last_consolidated:-keep_count]
- if not old_messages:
- return True
- logger.info("Memory consolidation: {} to consolidate, {} keep", len(old_messages), keep_count)
-
- lines = []
- for m in old_messages:
- if not m.get("content"):
- continue
- tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else ""
- lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}")
-
- current_memory = self.read_long_term()
- prompt = f"""Process this conversation and call the save_memory tool with your consolidation.
-
-## Current Long-term Memory
-{current_memory or "(empty)"}
-
-## Conversation to Process
-{chr(10).join(lines)}"""
-
- try:
- response = await provider.chat(
- messages=[
- {"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."},
- {"role": "user", "content": prompt},
- ],
- tools=_SAVE_MEMORY_TOOL,
- model=model,
- )
-
- if not response.has_tool_calls:
- logger.warning("Memory consolidation: LLM did not call save_memory, skipping")
- return False
-
- args = response.tool_calls[0].arguments
- if entry := args.get("history_entry"):
- if not isinstance(entry, str):
- entry = json.dumps(entry, ensure_ascii=False)
- self.append_history(entry)
- if update := args.get("memory_update"):
- if not isinstance(update, str):
- update = json.dumps(update, ensure_ascii=False)
- if update != current_memory:
- self.write_long_term(update)
-
- session.last_consolidated = 0 if archive_all else len(session.messages) - keep_count
- logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated)
- return True
- except Exception:
- logger.exception("Memory consolidation failed")
- return False
diff --git a/app-instance/backend-old/nanobot/agent/plugins.py b/app-instance/backend-old/nanobot/agent/plugins.py
deleted file mode 100644
index d5c6e79..0000000
--- a/app-instance/backend-old/nanobot/agent/plugins.py
+++ /dev/null
@@ -1,291 +0,0 @@
-"""Plugin system for Boardware Genius - load agents, commands, and skills from plugin directories."""
-
-from __future__ import annotations
-
-import json
-import re
-from dataclasses import dataclass, field
-from pathlib import Path
-
-from loguru import logger
-
-
-@dataclass
-class PluginAgent:
- name: str
- description: str
- model: str | None
- system_prompt: str
- plugin_name: str
-
-
-@dataclass
-class PluginCommand:
- name: str
- description: str
- argument_hint: str | None
- content: str # Raw body with $ARGUMENTS placeholder
- plugin_name: str
-
- def expand(self, arguments: str) -> str:
- return self.content.replace("$ARGUMENTS", arguments.strip())
-
-
-@dataclass
-class Plugin:
- name: str
- description: str
- source: str # "global" or "workspace"
- agents: dict[str, PluginAgent] = field(default_factory=dict)
- commands: dict[str, PluginCommand] = field(default_factory=dict)
- skill_dirs: list[Path] = field(default_factory=list)
-
-
-class PluginLoader:
- """
- Loads plugins from global and workspace plugin directories.
-
- Search paths (workspace takes priority over global):
- - Global: ~/.nanobot/plugins//
- - Workspace: /plugins//
-
- Each plugin directory may contain:
- - plugin.json — manifest with name/description
- - agents/.md — agent definitions (frontmatter + system prompt)
- - commands/.md — slash command definitions (frontmatter + content)
- - skills//SKILL.md — skill files exposed to SkillsLoader
- """
-
- GLOBAL_DIR = Path.home() / ".nanobot" / "plugins"
-
- def __init__(self, workspace: Path, global_dir: Path | None = None):
- self.workspace = workspace
- self.global_dir = global_dir or self.GLOBAL_DIR
- self.workspace_dir = workspace / "plugins"
- self._plugins: dict[str, Plugin] | None = None
-
- @property
- def plugins(self) -> dict[str, Plugin]:
- if self._plugins is None:
- self._plugins = self._load_all()
- return self._plugins
-
- def find_command(self, cmd_name: str) -> PluginCommand | None:
- """Find a command by name. Workspace plugins take priority over global."""
- for plugin in self.plugins.values():
- if plugin.source == "workspace" and cmd_name in plugin.commands:
- return plugin.commands[cmd_name]
- for plugin in self.plugins.values():
- if plugin.source == "global" and cmd_name in plugin.commands:
- return plugin.commands[cmd_name]
- return None
-
- def find_agent(self, agent_name: str) -> PluginAgent | None:
- """Find an agent by name. Workspace plugins take priority over global."""
- for plugin in self.plugins.values():
- if plugin.source == "workspace" and agent_name in plugin.agents:
- return plugin.agents[agent_name]
- for plugin in self.plugins.values():
- if plugin.source == "global" and agent_name in plugin.agents:
- return plugin.agents[agent_name]
- return None
-
- def get_skill_dirs(self) -> list[Path]:
- """Return all skill root directories contributed by plugins."""
- dirs = []
- for plugin in self.plugins.values():
- dirs.extend(plugin.skill_dirs)
- return dirs
-
- def build_agents_summary(self) -> str:
- """Build an XML summary of all plugin agents for the system prompt."""
- agents = []
- for plugin in self.plugins.values():
- agents.extend(plugin.agents.values())
- if not agents:
- return ""
-
- def esc(s: str) -> str:
- return s.replace("&", "&").replace("<", "<").replace(">", ">")
-
- lines = [""]
- for agent in agents:
- lines.append(" ")
- lines.append(f" {esc(agent.name)}")
- lines.append(f" {esc(agent.plugin_name)}")
- lines.append(f" {esc(agent.description)}")
- if agent.model:
- lines.append(f" {esc(agent.model)}")
- lines.append(" ")
- lines.append("")
- return "\n".join(lines)
-
- def build_commands_summary(self) -> str:
- """Build an XML summary of all plugin commands for the system prompt."""
- commands = []
- for plugin in self.plugins.values():
- commands.extend(plugin.commands.values())
- if not commands:
- return ""
-
- def esc(s: str) -> str:
- return s.replace("&", "&").replace("<", "<").replace(">", ">")
-
- lines = [""]
- for cmd in commands:
- lines.append(" ")
- lines.append(f" /{esc(cmd.name)}")
- lines.append(f" {esc(cmd.plugin_name)}")
- lines.append(f" {esc(cmd.description)}")
- if cmd.argument_hint:
- lines.append(f" {esc(cmd.argument_hint)}")
- lines.append(" ")
- lines.append("")
- return "\n".join(lines)
-
- # ------------------------------------------------------------------ private
-
- def _load_all(self) -> dict[str, Plugin]:
- """Load all plugins from global then workspace (workspace wins)."""
- plugins: dict[str, Plugin] = {}
-
- if self.global_dir.exists():
- for plugin_dir in sorted(self.global_dir.iterdir()):
- if plugin_dir.is_dir():
- plugin = self._load_plugin(plugin_dir, "global")
- if plugin:
- plugins[plugin.name] = plugin
- logger.debug("Loaded global plugin: {}", plugin.name)
-
- if self.workspace_dir.exists():
- for plugin_dir in sorted(self.workspace_dir.iterdir()):
- if plugin_dir.is_dir():
- plugin = self._load_plugin(plugin_dir, "workspace")
- if plugin:
- plugins[plugin.name] = plugin # override global
- logger.debug("Loaded workspace plugin: {}", plugin.name)
-
- return plugins
-
- def _load_plugin(self, plugin_dir: Path, source: str) -> Plugin | None:
- """Load a single plugin from a directory."""
- try:
- name = plugin_dir.name
- description = ""
-
- # Look for plugin.json at root, then fall back to .claude-plugin/plugin.json
- # so that Claude Code plugin repos work without copying files.
- manifest_file = plugin_dir / "plugin.json"
- if not manifest_file.exists():
- manifest_file = plugin_dir / ".claude-plugin" / "plugin.json"
- if manifest_file.exists():
- try:
- manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
- name = manifest.get("name", name)
- description = manifest.get("description", "")
- except (json.JSONDecodeError, OSError) as e:
- logger.warning("Failed to parse plugin.json in {}: {}", plugin_dir, e)
-
- agents_dir = plugin_dir / "agents"
- agents = self._load_agents(agents_dir, name) if agents_dir.exists() else {}
-
- commands_dir = plugin_dir / "commands"
- commands = self._load_commands(commands_dir, name) if commands_dir.exists() else {}
-
- skills_dir = plugin_dir / "skills"
- skill_dirs = [skills_dir] if skills_dir.exists() else []
-
- return Plugin(
- name=name,
- description=description,
- source=source,
- agents=agents,
- commands=commands,
- skill_dirs=skill_dirs,
- )
- except Exception as e:
- logger.warning("Failed to load plugin from {}: {}", plugin_dir, e)
- return None
-
- def _load_agents(self, agents_dir: Path, plugin_name: str) -> dict[str, PluginAgent]:
- """Load agent .md files from a directory."""
- agents: dict[str, PluginAgent] = {}
- for md_file in sorted(agents_dir.glob("*.md")):
- try:
- content = md_file.read_text(encoding="utf-8")
- meta, body = self._parse_frontmatter(content)
- name = meta.get("name", md_file.stem)
- description = meta.get("description", "")
- model = meta.get("model") or None
- agents[name] = PluginAgent(
- name=name,
- description=description,
- model=model,
- system_prompt=body,
- plugin_name=plugin_name,
- )
- except Exception as e:
- logger.warning("Failed to load agent {}: {}", md_file, e)
- return agents
-
- def _load_commands(self, commands_dir: Path, plugin_name: str) -> dict[str, PluginCommand]:
- """Load command .md files from a directory."""
- commands: dict[str, PluginCommand] = {}
- for md_file in sorted(commands_dir.glob("*.md")):
- try:
- content = md_file.read_text(encoding="utf-8")
- meta, body = self._parse_frontmatter(content)
- name = md_file.stem
- description = meta.get("description", "")
- argument_hint = meta.get("argument-hint") or None
- commands[name] = PluginCommand(
- name=name,
- description=description,
- argument_hint=argument_hint,
- content=body,
- plugin_name=plugin_name,
- )
- except Exception as e:
- logger.warning("Failed to load command {}: {}", md_file, e)
- return commands
-
- def _parse_frontmatter(self, content: str) -> tuple[dict[str, str], str]:
- """
- Parse YAML frontmatter delimited by ``---`` lines.
-
- Returns (meta_dict, body). Supports simple ``key: value`` pairs and
- block scalars (``key: |``). Does not require PyYAML.
- """
- if not content.startswith("---"):
- return {}, content
-
- match = re.match(r"^---\n(.*?)\n---\n?", content, re.DOTALL)
- if not match:
- return {}, content
-
- raw = match.group(1)
- body = content[match.end():].strip()
-
- meta: dict[str, str] = {}
- lines = raw.split("\n")
- i = 0
- while i < len(lines):
- line = lines[i]
- if ":" in line and not line.startswith((" ", "\t")):
- key, _, value = line.partition(":")
- key = key.strip()
- value = value.strip()
- if value == "|":
- # Block scalar: collect following indented lines
- block_lines: list[str] = []
- i += 1
- while i < len(lines) and (lines[i].startswith(" ") or lines[i] == ""):
- block_lines.append(lines[i][2:] if lines[i].startswith(" ") else "")
- i += 1
- meta[key] = "\n".join(block_lines).strip()
- continue
- else:
- meta[key] = value.strip("\"'")
- i += 1
-
- return meta, body
diff --git a/app-instance/backend-old/nanobot/agent/process_events.py b/app-instance/backend-old/nanobot/agent/process_events.py
deleted file mode 100644
index 9feed44..0000000
--- a/app-instance/backend-old/nanobot/agent/process_events.py
+++ /dev/null
@@ -1,84 +0,0 @@
-"""结构化过程事件辅助工具。
-
-这个模块的作用是把“运行中的中间状态”从底层执行逻辑安全地带到上层 UI:
-1. 用 `ContextVar` 记录当前异步上下文是否挂了事件 sink;
-2. 用单独的 run_id 上下文把父子流程串起来;
-3. 让委派、MCP、A2A 等模块只管发事件,不需要知道 WebSocket/SSE 细节。
-"""
-
-from __future__ import annotations
-
-import uuid
-from contextlib import contextmanager
-from contextvars import ContextVar
-from datetime import datetime, timezone
-from typing import Any, Awaitable, Callable
-
-ProcessEvent = dict[str, Any]
-ProcessEventSink = Callable[[ProcessEvent], Awaitable[None]]
-
-# `_sink_var` 保存“当前异步上下文的事件接收器”。
-# 这样可以避免把回调一层层显式往下传,同时又不会污染并发请求之间的上下文。
-_sink_var: ContextVar[ProcessEventSink | None] = ContextVar("process_event_sink", default=None)
-# `_run_id_var` 保存“当前流程的父 run_id”。
-# 子流程发事件时可以把它挂到 `parent_run_id`,供前端拼接树状执行视图。
-_run_id_var: ContextVar[str | None] = ContextVar("process_current_run_id", default=None)
-
-
-def new_run_id(prefix: str = "run") -> str:
- """生成一个短且可读的运行 ID。"""
- # 只截取 8 位十六进制是为了兼顾:
- # 1. 日志 / WebSocket 里更短、更容易肉眼追踪;
- # 2. 同一进程内短期冲突概率仍足够低。
- return f"{prefix}-{uuid.uuid4().hex[:8]}"
-
-
-def utc_now_iso() -> str:
- """返回带 `Z` 后缀的 UTC ISO8601 时间戳。"""
- return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
-
-
-@contextmanager
-def process_event_sink(sink: ProcessEventSink | None):
- """为当前异步上下文临时绑定一个事件 sink。"""
- # `ContextVar.set()` 会返回 token,退出时要 reset,避免泄漏到后续请求。
- token = _sink_var.set(sink)
- try:
- yield
- finally:
- _sink_var.reset(token)
-
-
-@contextmanager
-def process_run_context(run_id: str | None):
- """为当前异步上下文绑定一个逻辑父 run_id。"""
- token = _run_id_var.set(run_id)
- try:
- yield
- finally:
- _run_id_var.reset(token)
-
-
-def current_process_run_id() -> str | None:
- """读取当前上下文里绑定的 run_id。"""
- return _run_id_var.get()
-
-
-def has_process_event_sink() -> bool:
- """判断当前上下文是否具备过程事件接收能力。"""
- return _sink_var.get() is not None
-
-
-async def emit_process_event(event_type: str, **payload: Any) -> None:
- """在存在 sink 时发出一个结构化过程事件。"""
- sink = _sink_var.get()
- # 没有 sink 说明当前调用链不关心中间态,例如纯 CLI 单轮场景,直接静默跳过。
- if sink is None:
- return
- # `created_at` 允许调用方覆盖;未传时统一补 UTC 时间,方便前端排序。
- event: ProcessEvent = {
- "type": event_type,
- "created_at": payload.pop("created_at", utc_now_iso()),
- **payload,
- }
- await sink(event)
diff --git a/app-instance/backend-old/nanobot/agent/run_result.py b/app-instance/backend-old/nanobot/agent/run_result.py
deleted file mode 100644
index e1378b1..0000000
--- a/app-instance/backend-old/nanobot/agent/run_result.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""委派执行结果的共享类型定义。"""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-from typing import Any
-
-
-_PLACEHOLDER_SUMMARY_MARKERS = (
- "task completed but no final response was generated",
- "no final response was generated",
- "已启动代理团队",
- "代理团队正在后台工作",
- "agent team [",
- "spawn_agent_team",
- "error calling llm",
- "litellm.timeout",
- "dashscopeexception",
- "service temporarily unavailable",
- "planner调用失败",
- "本任务当前不可执行",
- "无法由单一非sop工具完成",
-)
-
-
-def normalize_summary_text(text: str | None) -> str:
- """把摘要文本压成便于判定的稳定形式。"""
- return " ".join(str(text or "").strip().split())
-
-
-def contains_placeholder_summary(text: str | None) -> bool:
- """判断摘要是否只是占位兜底文本。"""
- normalized = normalize_summary_text(text).lower()
- if not normalized:
- return True
- return any(marker in normalized for marker in _PLACEHOLDER_SUMMARY_MARKERS)
-
-
-def has_meaningful_summary(text: str | None) -> bool:
- """判断摘要是否包含可复用的真实结果。"""
- normalized = normalize_summary_text(text)
- return bool(normalized) and not contains_placeholder_summary(normalized)
-
-
-@dataclass
-class AgentRunResult:
- """统一描述一次 agent 执行结果。"""
-
- # 执行方的稳定 ID,适合程序判断和日志检索。
- agent_id: str
- # 展示给用户或前端时使用的人类可读名称。
- agent_name: str
- # 归一化状态:通常是 `ok` / `error` / `cancelled` 等。
- status: str
- # 面向上层的简要总结,是最终展示和二次总结的主要输入。
- summary: str
- # 可选原始载荷,保留底层协议返回值,便于调试或后续扩展。
- raw: dict[str, Any] | None = None
diff --git a/app-instance/backend-old/nanobot/agent/skill_reviews.py b/app-instance/backend-old/nanobot/agent/skill_reviews.py
deleted file mode 100644
index c4457e0..0000000
--- a/app-instance/backend-old/nanobot/agent/skill_reviews.py
+++ /dev/null
@@ -1,238 +0,0 @@
-"""Review-first skill installation helpers."""
-
-from __future__ import annotations
-
-import json
-import secrets
-import shutil
-import zipfile
-from pathlib import Path, PurePosixPath
-from typing import Any
-
-from nanobot.utils.helpers import ensure_dir, get_workspace_state_path, safe_filename, timestamp
-
-
-def _is_relative_to(path: Path, root: Path) -> bool:
- try:
- path.relative_to(root)
- return True
- except ValueError:
- return False
-
-
-def _parse_frontmatter(content: str) -> dict[str, str]:
- if not content.startswith("---"):
- return {}
-
- end = content.find("\n---", 3)
- if end == -1:
- return {}
-
- metadata: dict[str, str] = {}
- for line in content[3:end].splitlines():
- if ":" not in line:
- continue
- key, value = line.split(":", 1)
- metadata[key.strip()] = value.strip().strip("\"'")
- return metadata
-
-
-def _parse_skill_metadata(raw: str) -> dict[str, Any]:
- if not raw:
- return {}
- try:
- data = json.loads(raw)
- except json.JSONDecodeError:
- return {}
- if not isinstance(data, dict):
- return {}
- nested = data.get("nanobot", data.get("openclaw", {}))
- return nested if isinstance(nested, dict) else {}
-
-
-class SkillReviewManager:
- """Stage workspace skill installs until the user explicitly approves them."""
-
- REVIEW_META_FILE = "review.json"
- ARCHIVE_FILE = "upload.zip"
- STAGED_DIR = "staged"
-
- def __init__(self, workspace: Path):
- self.workspace = workspace.expanduser().resolve()
- self.workspace_skills = ensure_dir(self.workspace / "skills")
- self.reviews_dir = ensure_dir(get_workspace_state_path(self.workspace) / "skill-reviews")
-
- def list_reviews(self) -> list[dict[str, Any]]:
- reviews: list[dict[str, Any]] = []
- for review_dir in sorted(self.reviews_dir.iterdir(), reverse=True):
- if not review_dir.is_dir():
- continue
- try:
- reviews.append(self._read_review(review_dir))
- except FileNotFoundError:
- continue
- return reviews
-
- def get_review(self, review_id: str) -> dict[str, Any]:
- return self._read_review(self._review_dir(review_id))
-
- def create_review_from_zip(self, filename: str, content: bytes) -> dict[str, Any]:
- review_id = secrets.token_hex(8)
- review_dir = ensure_dir(self._review_dir(review_id))
- archive_path = review_dir / self.ARCHIVE_FILE
- archive_path.write_bytes(content)
-
- staged_root = ensure_dir(review_dir / self.STAGED_DIR)
- preview = self._extract_archive(archive_path, staged_root, filename)
- review = {
- "id": review_id,
- "status": "pending_review",
- "created_at": timestamp(),
- "archive_name": filename,
- **preview,
- }
- self._write_review(review_dir, review)
- return review
-
- def approve_review(self, review_id: str, overwrite: bool = False) -> dict[str, Any]:
- review_dir = self._review_dir(review_id)
- review = self._read_review(review_dir)
-
- if review.get("status") == "approved":
- return review
-
- skill_name = str(review.get("skill_name") or "").strip()
- if not skill_name:
- raise ValueError("Review is missing a skill_name")
-
- source_dir = review_dir / self.STAGED_DIR / skill_name
- if not source_dir.is_dir():
- raise FileNotFoundError(f"Staged skill not found for review {review_id}")
-
- target_dir = self.workspace_skills / skill_name
- if target_dir.exists():
- if not overwrite:
- raise FileExistsError(
- f"Skill '{skill_name}' already exists. Re-submit approval with overwrite=true."
- )
- shutil.rmtree(target_dir)
-
- shutil.copytree(source_dir, target_dir)
- review["status"] = "approved"
- review["approved_at"] = timestamp()
- review["overwrite"] = overwrite
- review["installed_path"] = str(target_dir / "SKILL.md")
- self._write_review(review_dir, review)
- return review
-
- def discard_review(self, review_id: str) -> None:
- review_dir = self._review_dir(review_id)
- if not review_dir.exists():
- raise FileNotFoundError(f"Skill review '{review_id}' not found")
- shutil.rmtree(review_dir)
-
- def _review_dir(self, review_id: str) -> Path:
- return self.reviews_dir / review_id
-
- def _read_review(self, review_dir: Path) -> dict[str, Any]:
- review_file = review_dir / self.REVIEW_META_FILE
- if not review_file.exists():
- raise FileNotFoundError(f"Skill review metadata not found: {review_dir.name}")
- return json.loads(review_file.read_text(encoding="utf-8"))
-
- def _write_review(self, review_dir: Path, review: dict[str, Any]) -> None:
- review_file = review_dir / self.REVIEW_META_FILE
- review_file.write_text(
- json.dumps(review, ensure_ascii=False, indent=2),
- encoding="utf-8",
- )
-
- def _extract_archive(
- self,
- archive_path: Path,
- staged_root: Path,
- upload_name: str,
- ) -> dict[str, Any]:
- with zipfile.ZipFile(archive_path, "r") as zf:
- file_infos = [info for info in zf.infolist() if not info.is_dir()]
- if not file_infos:
- raise ValueError("Zip archive is empty")
-
- skill_md_entries: list[str] = []
- for info in file_infos:
- rel = PurePosixPath(info.filename)
- if rel.name != "SKILL.md":
- continue
- if len(rel.parts) not in (1, 2):
- raise ValueError(
- "SKILL.md must be at the archive root or inside a single top-level directory"
- )
- skill_md_entries.append(info.filename)
-
- if not skill_md_entries:
- raise ValueError("Zip must contain a top-level SKILL.md file")
-
- skill_md_entry = skill_md_entries[0]
- skill_md_parts = PurePosixPath(skill_md_entry).parts
- top_level_dir = skill_md_parts[0] if len(skill_md_parts) == 2 else ""
- frontmatter = _parse_frontmatter(
- zf.read(skill_md_entry).decode("utf-8", errors="replace")
- )
-
- if top_level_dir:
- skill_name = top_level_dir
- else:
- skill_name = frontmatter.get("name") or Path(upload_name).stem
-
- skill_name = safe_filename(skill_name).replace(" ", "-")
- if not skill_name:
- raise ValueError("Could not determine a safe skill name")
-
- staged_skill_dir = staged_root / skill_name
- staged_skill_dir.mkdir(parents=True, exist_ok=False)
-
- extracted_files: list[str] = []
- for info in file_infos:
- raw_rel = PurePosixPath(info.filename)
- if "__MACOSX" in raw_rel.parts or raw_rel.name == ".DS_Store":
- continue
-
- if top_level_dir:
- if not raw_rel.parts or raw_rel.parts[0] != top_level_dir:
- continue
- rel_parts = raw_rel.parts[1:]
- else:
- rel_parts = raw_rel.parts
-
- if not rel_parts:
- continue
- if any(part in {"", ".", ".."} for part in rel_parts):
- raise ValueError(f"Unsafe archive entry: {info.filename}")
-
- dest = staged_skill_dir.joinpath(*rel_parts)
- dest.parent.mkdir(parents=True, exist_ok=True)
- resolved_dest = dest.resolve()
- if not _is_relative_to(resolved_dest, staged_skill_dir.resolve()):
- raise ValueError(f"Unsafe archive entry: {info.filename}")
-
- with zf.open(info) as src, open(dest, "wb") as dst:
- shutil.copyfileobj(src, dst)
- extracted_files.append(PurePosixPath(*rel_parts).as_posix())
-
- if not (staged_skill_dir / "SKILL.md").exists():
- raise ValueError("Staged skill is missing SKILL.md after extraction")
-
- skill_meta = _parse_skill_metadata(frontmatter.get("metadata", ""))
- target_dir = self.workspace_skills / skill_name
- return {
- "skill_name": skill_name,
- "declared_name": frontmatter.get("name", skill_name),
- "description": frontmatter.get("description", ""),
- "metadata": frontmatter,
- "requires": skill_meta.get("requires", {}),
- "file_count": len(extracted_files),
- "files": sorted(extracted_files),
- "target_exists": target_dir.exists(),
- "target_path": str(target_dir / "SKILL.md"),
- "staged_path": str(staged_skill_dir / "SKILL.md"),
- }
diff --git a/app-instance/backend-old/nanobot/agent/skills.py b/app-instance/backend-old/nanobot/agent/skills.py
deleted file mode 100644
index 139f7e6..0000000
--- a/app-instance/backend-old/nanobot/agent/skills.py
+++ /dev/null
@@ -1,284 +0,0 @@
-"""Skills loader for agent capabilities."""
-
-import json
-import os
-import re
-import shutil
-from pathlib import Path
-
-# Default builtin skills directory (relative to this file)
-BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills"
-
-
-class SkillsLoader:
- """
- Loader for agent skills.
-
- Skills are markdown files (SKILL.md) that teach the agent how to use
- specific tools or perform certain tasks.
- """
-
- def __init__(
- self,
- workspace: Path,
- builtin_skills_dir: Path | None = None,
- extra_dirs: list[Path] | None = None,
- ):
- self.workspace = workspace
- self.workspace_skills = workspace / "skills"
- self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
- if extra_dirs is None:
- from nanobot.agent.plugins import PluginLoader
-
- extra_dirs = PluginLoader(workspace).get_skill_dirs()
- self.extra_dirs: list[Path] = extra_dirs
-
- def list_skills(self, filter_unavailable: bool = True) -> list[dict[str, str]]:
- """
- List all available skills.
-
- Args:
- filter_unavailable: If True, filter out skills with unmet requirements.
-
- Returns:
- List of skill info dicts with 'name', 'path', 'source'.
- """
- skills = []
-
- # Workspace skills (highest priority)
- if self.workspace_skills.exists():
- for skill_dir in self.workspace_skills.iterdir():
- if skill_dir.is_dir():
- skill_file = skill_dir / "SKILL.md"
- if skill_file.exists():
- skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "workspace"})
-
- # Extra skill roots (e.g. plugin-provided skills)
- for extra_dir in self.extra_dirs:
- if extra_dir.exists():
- for skill_dir in extra_dir.iterdir():
- if skill_dir.is_dir():
- skill_file = skill_dir / "SKILL.md"
- if skill_file.exists() and not any(s["name"] == skill_dir.name for s in skills):
- skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "plugin"})
-
- # Built-in skills
- if self.builtin_skills and self.builtin_skills.exists():
- for skill_dir in self.builtin_skills.iterdir():
- if skill_dir.is_dir():
- skill_file = skill_dir / "SKILL.md"
- if skill_file.exists() and not any(s["name"] == skill_dir.name for s in skills):
- skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "builtin"})
-
- # Filter by requirements
- if filter_unavailable:
- return [s for s in skills if self._check_requirements(self._get_skill_meta(s["name"]))]
- return skills
-
- def load_skill(self, name: str) -> str | None:
- """
- Load a skill by name.
-
- Args:
- name: Skill name (directory name).
-
- Returns:
- Skill content or None if not found.
- """
- # Check workspace first
- workspace_skill = self.workspace_skills / name / "SKILL.md"
- if workspace_skill.exists():
- return workspace_skill.read_text(encoding="utf-8")
-
- # Check plugin-provided roots
- for extra_dir in self.extra_dirs:
- extra_skill = extra_dir / name / "SKILL.md"
- if extra_skill.exists():
- return extra_skill.read_text(encoding="utf-8")
-
- # Check built-in
- if self.builtin_skills:
- builtin_skill = self.builtin_skills / name / "SKILL.md"
- if builtin_skill.exists():
- return builtin_skill.read_text(encoding="utf-8")
-
- return None
-
- def load_skills_for_context(self, skill_names: list[str]) -> str:
- """
- Load specific skills for inclusion in agent context.
-
- Args:
- skill_names: List of skill names to load.
-
- Returns:
- Formatted skills content.
- """
- parts = []
- for name in skill_names:
- content = self.load_skill(name)
- if content:
- content = self._strip_frontmatter(content)
- parts.append(f"### Skill: {name}\n\n{content}")
-
- return "\n\n---\n\n".join(parts) if parts else ""
-
- def build_skills_summary(self) -> str:
- """
- Build a summary of all skills (name, description, path, availability).
-
- This is used for progressive loading - the agent can read the full
- skill content using read_file when needed.
-
- Returns:
- XML-formatted skills summary.
- """
- all_skills = self.list_skills(filter_unavailable=False)
- if not all_skills:
- return ""
-
- def escape_xml(s: str) -> str:
- return s.replace("&", "&").replace("<", "<").replace(">", ">")
-
- lines = [""]
- for s in all_skills:
- name = escape_xml(s["name"])
- path = s["path"]
- desc = escape_xml(self._get_skill_description(s["name"]))
- skill_meta = self._get_skill_meta(s["name"])
- available = self._check_requirements(skill_meta)
-
- lines.append(f" ")
- lines.append(f" {name}")
- lines.append(f" {desc}")
- lines.append(f" {path}")
-
- # Show missing requirements for unavailable skills
- if not available:
- missing = self._get_missing_requirements(skill_meta)
- if missing:
- lines.append(f" {escape_xml(missing)}")
-
- lines.append(" ")
- lines.append("")
-
- return "\n".join(lines)
-
- def _get_missing_requirements(self, skill_meta: dict) -> str:
- """Get a description of missing requirements."""
- missing = []
- requires = skill_meta.get("requires", {})
- for b in requires.get("bins", []):
- if not shutil.which(b):
- missing.append(f"CLI: {b}")
- for env in requires.get("env", []):
- if not os.environ.get(env):
- missing.append(f"ENV: {env}")
- return ", ".join(missing)
-
- def _get_skill_description(self, name: str) -> str:
- """Get the description of a skill from its frontmatter."""
- meta = self.get_skill_metadata(name)
- if meta and meta.get("description"):
- return meta["description"]
- return name # Fallback to skill name
-
- def _strip_frontmatter(self, content: str) -> str:
- """Remove YAML frontmatter from markdown content."""
- if content.startswith("---"):
- match = re.match(r"^---\n.*?\n---\n", content, re.DOTALL)
- if match:
- return content[match.end():].strip()
- return content
-
- def _parse_nanobot_metadata(self, raw: str) -> dict:
- """Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys)."""
- try:
- data = json.loads(raw)
- return data.get("nanobot", data.get("openclaw", {})) if isinstance(data, dict) else {}
- except (json.JSONDecodeError, TypeError):
- return {}
-
- def _check_requirements(self, skill_meta: dict) -> bool:
- """Check if skill requirements are met (bins, env vars)."""
- requires = skill_meta.get("requires", {})
- for b in requires.get("bins", []):
- if not shutil.which(b):
- return False
- for env in requires.get("env", []):
- if not os.environ.get(env):
- return False
- return True
-
- def _get_skill_meta(self, name: str) -> dict:
- """Get nanobot metadata for a skill (cached in frontmatter)."""
- meta = self.get_skill_metadata(name) or {}
- return self._parse_nanobot_metadata(meta.get("metadata", ""))
-
- def get_always_skills(self) -> list[str]:
- """Get skills marked as always=true that meet requirements."""
- result = []
- for s in self.list_skills(filter_unavailable=True):
- meta = self.get_skill_metadata(s["name"]) or {}
- skill_meta = self._parse_nanobot_metadata(meta.get("metadata", ""))
- if skill_meta.get("always") or meta.get("always"):
- result.append(s["name"])
- return result
-
- def get_skill_metadata(self, name: str) -> dict | None:
- """
- Get metadata from a skill's frontmatter.
-
- Args:
- name: Skill name.
-
- Returns:
- Metadata dict or None.
- """
- content = self.load_skill(name)
- if not content:
- return None
-
- if content.startswith("---"):
- match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
- if match:
- # Simple YAML parsing
- metadata = {}
- for line in match.group(1).split("\n"):
- if ":" in line:
- key, value = line.split(":", 1)
- metadata[key.strip()] = value.strip().strip('"\'')
- return metadata
-
- return None
-
- def get_skill_agent_cards(self, name: str) -> list[dict]:
- """从 skill 元数据里提取 A2A agent card 声明。"""
- # 技能 frontmatter 里的 metadata 是字符串形式,先复用现有解析逻辑拿到 nanobot 扩展字段。
- meta = self.get_skill_metadata(name) or {}
- skill_meta = self._parse_nanobot_metadata(meta.get("metadata", ""))
- cards = skill_meta.get("agent_cards", [])
- if not isinstance(cards, list):
- return []
-
- result = []
- for idx, card in enumerate(cards):
- if not isinstance(card, dict):
- continue
- # 复制一份,避免直接修改原 metadata 结构。
- item = dict(card)
- # 对缺失字段做兜底补全,保证后续 AgentRegistry 可以稳定消费。
- item.setdefault("id", item.get("name") or f"{name}-agent-{idx + 1}")
- item.setdefault("name", item["id"])
- item.setdefault("description", meta.get("description", item["name"]))
- # 额外挂回 skill_name,方便前端展示来源,也便于后续定位声明位置。
- item["skill_name"] = name
- result.append(item)
- return result
-
- def list_skill_agent_cards(self) -> list[dict]:
- """聚合所有可见 skill 中声明的 agent card。"""
- cards = []
- for skill in self.list_skills(filter_unavailable=False):
- cards.extend(self.get_skill_agent_cards(skill["name"]))
- return cards
diff --git a/app-instance/backend-old/nanobot/agent/subagent.py b/app-instance/backend-old/nanobot/agent/subagent.py
deleted file mode 100644
index 093accb..0000000
--- a/app-instance/backend-old/nanobot/agent/subagent.py
+++ /dev/null
@@ -1,311 +0,0 @@
-"""本地委派执行器。
-
-这个类不再负责“后台任务管理”和“结果回流”,只保留一件事:
-在统一委派层要求执行本地任务时,提供一个受限工具集的本地 agent 执行环境。
-"""
-
-from __future__ import annotations
-
-import json
-import re
-import time as _time
-from datetime import datetime
-from pathlib import Path
-from typing import TYPE_CHECKING, Any, Awaitable, Callable
-
-from loguru import logger
-
-from nanobot.agent.run_result import AgentRunResult, has_meaningful_summary
-from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
-from nanobot.agent.tools.registry import ToolRegistry
-from nanobot.agent.tools.shell import ExecTool
-from nanobot.agent.tools.spawn import NestedDelegateTool
-from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
-from nanobot.providers.base import LLMProvider
-
-if TYPE_CHECKING:
- from nanobot.agent.delegation import DelegationManager
- from nanobot.config.schema import ExecToolConfig
-
-
-class SubagentManager:
- """用受限工具集在本地执行委派任务。"""
-
- def __init__(
- self,
- provider: LLMProvider,
- workspace: Path,
- model: str | None = None,
- temperature: float = 0.7,
- max_tokens: int = 4096,
- brave_api_key: str | None = None,
- exec_config: ExecToolConfig | None = None,
- restrict_to_workspace: bool = False,
- ):
- from nanobot.config.schema import ExecToolConfig
-
- # 这里保存的都是本地执行所需的静态配置,不再维护后台任务表。
- self.provider = provider
- self.workspace = workspace
- self.model = model or provider.get_default_model()
- self.temperature = temperature
- self.max_tokens = max_tokens
- self.brave_api_key = brave_api_key
- self.exec_config = exec_config or ExecToolConfig()
- self.restrict_to_workspace = restrict_to_workspace
- self._nested_delegate: DelegationManager | None = None
-
- def set_nested_delegate(self, manager: "DelegationManager | None") -> None:
- """注入 delegated worker 可用的受控下游委派器。"""
- self._nested_delegate = manager
-
- async def run_local_task(
- self,
- task: str,
- label: str | None = None,
- agent_id: str = "local-subagent",
- agent_name: str = "Local Subagent",
- system_prompt: str | None = None,
- model: str | None = None,
- progress_callback: Callable[..., Awaitable[None]] | None = None,
- allow_nested_delegation: bool = True,
- skill_context: str = "",
- skill_names: list[str] | None = None,
- ) -> AgentRunResult:
- """执行一次本地委派任务,并返回结构化结果。"""
- # 每次任务都新建一套局部工具注册表,避免不同任务之间共享临时状态。
- tools = self._build_local_tools(
- allow_nested_delegation=allow_nested_delegation,
- skill_names=skill_names,
- )
- prompt = self._build_subagent_prompt(
- task,
- agent_name=agent_name,
- custom_system_prompt=system_prompt,
- allow_nested_delegation=allow_nested_delegation,
- skill_context=skill_context,
- )
- # 本地委派不共享主会话历史,只带“专用 system prompt + 当前任务”。
- messages: list[dict[str, Any]] = [
- {"role": "system", "content": prompt},
- {"role": "user", "content": task},
- ]
-
- # 本地子 agent 也走“模型 -> 工具 -> 模型”的短循环,但轮数更保守。
- max_iterations = 15
- iteration = 0
- final_result: str | None = None
-
- while iteration < max_iterations:
- iteration += 1
- response = await self.provider.chat(
- messages=messages,
- tools=tools.get_definitions(),
- model=model or self.model,
- temperature=self.temperature,
- max_tokens=self.max_tokens,
- )
-
- if response.has_tool_calls:
- if progress_callback:
- # 进度回调只发对用户有价值的文本,不把 `` 之类内部推理暴露出去。
- clean = self._strip_think(response.content)
- if clean:
- await progress_callback(clean, tool_hint=False)
- # 额外补一条短工具提示,让上层 UI 知道当前在做什么。
- await progress_callback(self._tool_hint(response.tool_calls), tool_hint=True)
-
- tool_call_dicts = [
- {
- "id": tc.id,
- "type": "function",
- "function": {
- "name": tc.name,
- "arguments": json.dumps(tc.arguments, ensure_ascii=False),
- },
- }
- for tc in response.tool_calls
- ]
- messages.append({
- "role": "assistant",
- "content": response.content or "",
- "tool_calls": tool_call_dicts,
- })
- for tool_call in response.tool_calls:
- args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
- logger.debug("Agent [{}] executing: {} with arguments: {}", agent_id, tool_call.name, args_str)
- # 真正执行工具后,把结果回填到 messages,让下一轮模型能看到执行结果。
- result = await tools.execute(tool_call.name, tool_call.arguments)
- messages.append({
- "role": "tool",
- "tool_call_id": tool_call.id,
- "name": tool_call.name,
- "content": result,
- })
- else:
- # 没有继续调用工具时,视为任务已收敛,直接采纳当前回复。
- final_result = response.content
- break
-
- status = "ok"
- raw: dict[str, Any] | None = None
- if not has_meaningful_summary(final_result):
- # 兜底避免出现“任务做完了但完全没文本”的空结果,并显式标记为失败,
- # 防止上层把这类占位结果学习成 procedure。
- final_result = "Task completed but no final response was generated."
- status = "error"
- raw = {
- "reason": "no_final_response_generated",
- "iterations": iteration,
- }
-
- return AgentRunResult(
- agent_id=agent_id,
- agent_name=agent_name,
- status=status,
- summary=final_result,
- raw=raw,
- )
-
- def _build_local_tools(
- self,
- *,
- allow_nested_delegation: bool,
- skill_names: list[str] | None = None,
- ) -> ToolRegistry:
- """构建本地委派可用的受限工具集。"""
- tools = ToolRegistry()
- allowed_dir = self.workspace if self.restrict_to_workspace else None
- protected_skill_paths = [self.workspace / "skills"]
- # 文件工具统一按相同的 workspace / allowed_dir 约束注册。
- tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
- tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
- tools.register(
- WriteFileTool(
- workspace=self.workspace,
- allowed_dir=allowed_dir,
- protected_paths=protected_skill_paths,
- )
- )
- tools.register(
- EditFileTool(
- workspace=self.workspace,
- allowed_dir=allowed_dir,
- protected_paths=protected_skill_paths,
- )
- )
- # 本地命令执行沿用主配置里的超时和 workspace 限制。
- tools.register(ExecTool(
- working_dir=str(self.workspace),
- timeout=self.exec_config.timeout,
- restrict_to_workspace=self.restrict_to_workspace,
- protected_paths=protected_skill_paths,
- ))
- # 网络能力保持只读:搜索和抓取,不提供消息发送/再次委派等工具。
- tools.register(WebSearchTool(api_key=self.brave_api_key))
- tools.register(WebFetchTool())
- if allow_nested_delegation and self._nested_delegate is not None:
- tools.register(NestedDelegateTool(manager=self._nested_delegate, default_skills=skill_names))
- return tools
-
- @staticmethod
- def _strip_think(text: str | None) -> str | None:
- """Remove provider-specific think blocks from visible progress text."""
- if not text:
- return None
- return re.sub(r"[\s\S]*?", "", text).strip() or None
-
- @staticmethod
- def _tool_hint(tool_calls: list) -> str:
- """把工具调用列表格式化成简短进度提示。"""
-
- def _fmt(tc):
- val = next(iter(tc.arguments.values()), None) if tc.arguments else None
- if not isinstance(val, str):
- return tc.name
- return f'{tc.name}("{val[:40]}...")' if len(val) > 40 else f'{tc.name}("{val}")'
-
- return ", ".join(_fmt(tc) for tc in tool_calls)
-
- def _build_subagent_prompt(
- self,
- task: str,
- agent_name: str = "Local Subagent",
- custom_system_prompt: str | None = None,
- allow_nested_delegation: bool = True,
- skill_context: str = "",
- ) -> str:
- """构建子代理专用 system prompt。"""
- now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
- tz = _time.strftime("%Z") or "UTC"
- # plugin agent 的自定义系统提示拼到末尾,保留通用约束,再叠加个性化指令。
- extra = f"\n\n## Agent Instructions\n{custom_system_prompt.strip()}" if custom_system_prompt else ""
- can_do_lines = [
- "- Read and write files in the workspace",
- "- Execute shell commands",
- "- Search the web and fetch web pages",
- "- Complete the task thoroughly",
- ]
- cannot_do_lines = [
- "- Send messages directly to users (no message tool available)",
- "- Access the main agent's conversation history",
- ]
- delegation_section = (
- "\n## Downstream Delegation\n"
- "- Do not delegate further. Complete the task yourself with the tools you have."
- )
- if allow_nested_delegation and self._nested_delegate is not None:
- can_do_lines.append(
- "- Use `delegate_task` for controlled downstream delegation when specialized help is required"
- )
- cannot_do_lines.append("- Do not start agent teams or use background delegation tools")
- nested_summary = self._nested_delegate.build_nested_agents_summary()
- summary_block = f"\n\n{nested_summary}" if nested_summary else ""
- delegation_section = (
- "\n## Downstream Delegation\n"
- "- Use `delegate_task` only when a specialized downstream worker is actually needed.\n"
- "- `strategy=\"a2a\"` delegates directly to an available A2A agent.\n"
- "- `strategy=\"ephemeral_subagent\"` runs a temporary local worker for this task only.\n"
- "- Never create, register, or persist a new local sub-agent through `subagentctl.py`, `/api/subagents`, or registry edits."
- f"{summary_block}"
- )
- else:
- cannot_do_lines.append("- Spawn other subagents or downstream workers")
- skill_section = f"\n## Required Skills\n{skill_context.strip()}" if skill_context.strip() else ""
-
- return f"""# {agent_name}
-
-## Current Time
-{now} ({tz})
-
-You are a delegated agent spawned by the main agent to complete a specific task.
-
-## Rules
-1. Stay focused - complete only the assigned task, nothing else
-2. Your final response will be reported back to the main agent
-3. Do not initiate conversations or take on side tasks
-4. Be concise but informative in your findings
-5. Do not create or modify persistent local sub-agents unless the task explicitly requires that workflow
-
-## What You Can Do
-{chr(10).join(can_do_lines)}
-
-## What You Cannot Do
-{chr(10).join(cannot_do_lines)}
-
-{delegation_section}
-
-{skill_section}
-
-## Workspace
-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-old/nanobot/agent/subagents.py b/app-instance/backend-old/nanobot/agent/subagents.py
deleted file mode 100644
index 4c9d2ee..0000000
--- a/app-instance/backend-old/nanobot/agent/subagents.py
+++ /dev/null
@@ -1,258 +0,0 @@
-"""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_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-old/nanobot/agent/tools/__init__.py b/app-instance/backend-old/nanobot/agent/tools/__init__.py
deleted file mode 100644
index aac5d7d..0000000
--- a/app-instance/backend-old/nanobot/agent/tools/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""Agent tools module."""
-
-from nanobot.agent.tools.base import Tool
-from nanobot.agent.tools.registry import ToolRegistry
-
-__all__ = ["Tool", "ToolRegistry"]
diff --git a/app-instance/backend-old/nanobot/agent/tools/base.py b/app-instance/backend-old/nanobot/agent/tools/base.py
deleted file mode 100644
index ca9bcc2..0000000
--- a/app-instance/backend-old/nanobot/agent/tools/base.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""Base class for agent tools."""
-
-from abc import ABC, abstractmethod
-from typing import Any
-
-
-class Tool(ABC):
- """
- Abstract base class for agent tools.
-
- Tools are capabilities that the agent can use to interact with
- the environment, such as reading files, executing commands, etc.
- """
-
- _TYPE_MAP = {
- "string": str,
- "integer": int,
- "number": (int, float),
- "boolean": bool,
- "array": list,
- "object": dict,
- }
-
- @property
- @abstractmethod
- def name(self) -> str:
- """Tool name used in function calls."""
- pass
-
- @property
- @abstractmethod
- def description(self) -> str:
- """Description of what the tool does."""
- pass
-
- @property
- @abstractmethod
- def parameters(self) -> dict[str, Any]:
- """JSON Schema for tool parameters."""
- pass
-
- @abstractmethod
- async def execute(self, **kwargs: Any) -> str:
- """
- Execute the tool with given parameters.
-
- Args:
- **kwargs: Tool-specific parameters.
-
- Returns:
- String result of the tool execution.
- """
- pass
-
- def validate_params(self, params: dict[str, Any]) -> list[str]:
- """Validate tool parameters against JSON schema. Returns error list (empty if valid)."""
- schema = self.parameters or {}
- if schema.get("type", "object") != "object":
- raise ValueError(f"Schema must be object type, got {schema.get('type')!r}")
- return self._validate(params, {**schema, "type": "object"}, "")
-
- def _validate(self, val: Any, schema: dict[str, Any], path: str) -> list[str]:
- t, label = schema.get("type"), path or "parameter"
- if t in self._TYPE_MAP and not isinstance(val, self._TYPE_MAP[t]):
- return [f"{label} should be {t}"]
-
- errors = []
- if "enum" in schema and val not in schema["enum"]:
- errors.append(f"{label} must be one of {schema['enum']}")
- if t in ("integer", "number"):
- if "minimum" in schema and val < schema["minimum"]:
- errors.append(f"{label} must be >= {schema['minimum']}")
- if "maximum" in schema and val > schema["maximum"]:
- errors.append(f"{label} must be <= {schema['maximum']}")
- if t == "string":
- if "minLength" in schema and len(val) < schema["minLength"]:
- errors.append(f"{label} must be at least {schema['minLength']} chars")
- if "maxLength" in schema and len(val) > schema["maxLength"]:
- errors.append(f"{label} must be at most {schema['maxLength']} chars")
- if t == "object":
- props = schema.get("properties", {})
- for k in schema.get("required", []):
- if k not in val:
- errors.append(f"missing required {path + '.' + k if path else k}")
- for k, v in val.items():
- if k in props:
- errors.extend(self._validate(v, props[k], path + '.' + k if path else k))
- if t == "array" and "items" in schema:
- for i, item in enumerate(val):
- errors.extend(self._validate(item, schema["items"], f"{path}[{i}]" if path else f"[{i}]"))
- return errors
-
- def to_schema(self) -> dict[str, Any]:
- """Convert tool to OpenAI function schema format."""
- return {
- "type": "function",
- "function": {
- "name": self.name,
- "description": self.description,
- "parameters": self.parameters,
- }
- }
diff --git a/app-instance/backend-old/nanobot/agent/tools/cron.py b/app-instance/backend-old/nanobot/agent/tools/cron.py
deleted file mode 100644
index ced5319..0000000
--- a/app-instance/backend-old/nanobot/agent/tools/cron.py
+++ /dev/null
@@ -1,246 +0,0 @@
-"""cron 工具:给 Agent 提供“定时任务管理”能力。
-
-这个工具是 LLM 在对话中可调用的 function tool,主要负责三件事:
-1. `add`:创建一个定时任务(周期/cron/一次性);
-2. `list`:列出现有任务;
-3. `remove`:删除指定任务。
-
-设计定位说明:
-- 本工具只做“任务管理面”,不直接负责“定时器循环”;
-- 真正的调度与执行由 `CronService` 统一负责(start/stop/on_job);
-- 工具层通过 `set_context(channel, chat_id)` 注入当前会话路由,
- 从而让定时任务在触发后把结果回投到正确会话。
-"""
-
-from typing import Any
-
-from nanobot.agent.tools.base import Tool
-from nanobot.cron.service import CronService
-from nanobot.cron.types import CronSchedule
-
-
-class CronTool(Tool):
- """对话可调用的 cron 管理工具。
-
- 调用来源:
- - 主 agent 在工具调用回合中发起 `cron(...)`。
-
- 关键约束:
- - action 仅支持 `add/list/remove` 三种;
- - `add` 必须带 message,并且必须先注入 session 上下文(channel/chat_id);
- - 时间相关参数三选一:`every_seconds` / `cron_expr` / `at`。
- """
-
- def __init__(self, cron_service: CronService):
- # 持有同一个 CronService 实例,保证:
- # 1) CLI 命令与 agent 工具看到同一份 jobs.json;
- # 2) 任务状态(next_run、enabled)在进程内一致。
- self._cron = cron_service
- # 路由上下文由 AgentLoop 每轮注入。
- # 任务触发时将按该路由把结果投递回原会话。
- self._channel = ""
- self._chat_id = ""
- self._session_key = ""
-
- def set_context(self, channel: str, chat_id: str, session_key: str | None = None) -> None:
- """设置当前会话路由上下文。
-
- 为什么需要它:
- - 用户在 A 会话里让 agent“每天提醒我”,
- 任务未来触发时应回到 A,而不是误发到其他会话。
- - 因此 channel/chat_id 不依赖模型每次显式传参,
- 而是由运行时在调用前预注入默认目标。
- """
- self._channel = channel
- self._chat_id = chat_id
- self._session_key = session_key or f"{channel}:{chat_id}"
-
- @property
- def name(self) -> str:
- # 暴露给模型的工具名。模型会以 `cron(...)` 发起 function call。
- return "cron"
-
- @property
- def description(self) -> str:
- # 给模型看的简要能力描述,尽量短而明确。
- return "Schedule reminders and recurring tasks. Actions: add, list, remove. Use mode=reminder or task."
-
- @property
- def parameters(self) -> dict[str, Any]:
- # OpenAI function schema:
- # - 定义参数结构与类型;
- # - 由 ToolRegistry 在调用前做基础参数校验。
- return {
- "type": "object",
- "properties": {
- "action": {
- "type": "string",
- "enum": ["add", "list", "remove"],
- "description": "Action to perform"
- },
- "message": {
- "type": "string",
- # add 时的任务文本:
- # - 既可做“纯提醒文案”,也可做“交给 agent 执行的提示”。
- "description": "Reminder message (for add)"
- },
- "mode": {
- "type": "string",
- "enum": ["reminder", "task"],
- "description": "Execution mode: reminder sends message directly; task re-enters agent"
- },
- "every_seconds": {
- "type": "integer",
- # 固定间隔调度(单位秒),内部会转换为毫秒。
- "description": "Interval in seconds (for recurring tasks)"
- },
- "cron_expr": {
- "type": "string",
- # 标准 cron 表达式(5 段),例如每天 9 点:0 9 * * *
- "description": "Cron expression like '0 9 * * *' (for scheduled tasks)"
- },
- "tz": {
- "type": "string",
- # 仅与 cron_expr 搭配使用的 IANA 时区。
- "description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')"
- },
- "at": {
- "type": "string",
- # 一次性触发时间,ISO 格式(本地/带偏移都可由 fromisoformat 解析)。
- "description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')"
- },
- "job_id": {
- "type": "string",
- "description": "Job ID (for remove)"
- }
- },
- "required": ["action"]
- }
-
- async def execute(
- self,
- action: str,
- message: str = "",
- mode: str | None = None,
- every_seconds: int | None = None,
- cron_expr: str | None = None,
- tz: str | None = None,
- at: str | None = None,
- job_id: str | None = None,
- **kwargs: Any
- ) -> str:
- """工具主入口:按 action 分发到具体处理函数。
-
- 注意:
- - 这里不直接抛异常给上层;尽量返回可读错误字符串。
- - 真正未捕获异常(如非法日期解析)会被 ToolRegistry 包装成 Error 文本。
- """
- # add:创建任务(并立即持久化),返回任务 ID。
- if action == "add":
- return self._add_job(message, mode, every_seconds, cron_expr, tz, at)
- # list:只读取并格式化输出,不改状态。
- elif action == "list":
- return self._list_jobs()
- # remove:按 ID 删除任务并重置调度器。
- elif action == "remove":
- return self._remove_job(job_id)
- # schema 已限制枚举,这里是兜底防御。
- return f"Unknown action: {action}"
-
- def _add_job(
- self,
- message: str,
- mode: str | None,
- every_seconds: int | None,
- cron_expr: str | None,
- tz: str | None,
- at: str | None,
- ) -> str:
- """创建任务并写入 CronService。
-
- 参数优先级(互斥选择):
- 1. `every_seconds` -> 固定间隔任务
- 2. `cron_expr` -> cron 表达式任务
- 3. `at` -> 一次性任务(执行后自动删除)
- """
- # message 是 add 的必填语义字段:没有内容就无法定义“要做什么”。
- if not message:
- return "Error: message is required for add"
- # channel/chat_id 由 AgentLoop 注入;
- # 若缺失,说明当前调用上下文不完整,无法保证结果回投目标正确。
- if not self._channel or not self._chat_id:
- return "Error: no session context (channel/chat_id)"
- # 时区仅对 cron 表达式有意义;避免用户误把 tz 用在 every/at 上。
- if tz and not cron_expr:
- return "Error: tz can only be used with cron_expr"
- # 尽早校验时区,提前给出明确错误,避免把非法数据写入存储。
- if tz:
- from zoneinfo import ZoneInfo
- try:
- ZoneInfo(tz)
- except (KeyError, Exception):
- return f"Error: unknown timezone '{tz}'"
-
- # mode 缺省时默认按“提醒”处理:
- # - 与 cron skill 的说明一致;
- # - 避免把原始建任务指令再次送回 agent,造成任务自复制。
- normalized_mode = (mode or "reminder").strip().lower()
- if normalized_mode not in {"reminder", "task"}:
- return "Error: mode must be 'reminder' or 'task'"
- payload_kind = "system_event" if normalized_mode == "reminder" else "agent_turn"
-
- # 构建调度对象:
- # - CronService 内部统一使用毫秒时间戳;
- # - `at` 任务默认 delete_after_run=True,执行一次后自动移除。
- delete_after = False
- if every_seconds:
- schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000)
- elif cron_expr:
- schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
- elif at:
- from datetime import datetime
- # fromisoformat 解析失败会抛 ValueError,
- # 该异常会由 ToolRegistry 统一转换为错误字符串返回给模型。
- dt = datetime.fromisoformat(at)
- at_ms = int(dt.timestamp() * 1000)
- schedule = CronSchedule(kind="at", at_ms=at_ms)
- delete_after = True
- else:
- return "Error: either every_seconds, cron_expr, or at is required"
-
- # 创建任务并持久化:
- # - name 使用 message 前 30 字符做简短标题,便于列表展示;
- # - deliver=True:任务触发后默认向当前会话投递结果;
- # - channel/to 使用注入上下文,确保消息路由一致。
- job = self._cron.add_job(
- name=message[:30],
- schedule=schedule,
- message=message,
- payload_kind=payload_kind,
- session_key=self._session_key or None,
- deliver=True,
- channel=self._channel,
- to=self._chat_id,
- delete_after_run=delete_after,
- )
- # 返回简明确认文本,便于模型后续引用 job_id 做删除或说明。
- return f"Created {normalized_mode} job '{job.name}' (id: {job.id})"
-
- def _list_jobs(self) -> str:
- """列出当前可见任务(默认仅启用任务)。"""
- jobs = self._cron.list_jobs()
- if not jobs:
- return "No scheduled jobs."
- # 输出格式保持轻量,避免把过多状态塞给模型。
- # 详细状态(next_run/last_error)可在 CLI 的 `nanobot cron list` 查看。
- lines = [f"- {j.name} (id: {j.id}, {j.schedule.kind})" for j in jobs]
- return "Scheduled jobs:\n" + "\n".join(lines)
-
- def _remove_job(self, job_id: str | None) -> str:
- """按 ID 删除任务。"""
- if not job_id:
- return "Error: job_id is required for remove"
- # remove_job 返回 bool,工具层负责转换成对话友好的文案。
- if self._cron.remove_job(job_id):
- return f"Removed job {job_id}"
- return f"Job {job_id} not found"
diff --git a/app-instance/backend-old/nanobot/agent/tools/cron_action.py b/app-instance/backend-old/nanobot/agent/tools/cron_action.py
deleted file mode 100644
index 924168b..0000000
--- a/app-instance/backend-old/nanobot/agent/tools/cron_action.py
+++ /dev/null
@@ -1,116 +0,0 @@
-"""结构化 cron 生命周期控制工具。
-
-cron 任务不是普通用户对话,它经常需要在运行完成后主动告诉调度器:
-- 这个任务已经可以删掉;
-- 今天这一轮先结束,下一天再继续;
-- 下次应该改成新的时间表。
-
-这个工具就是让模型把这些决策显式写成结构化数据,而不是只留在自然语言里。
-"""
-
-from __future__ import annotations
-
-from typing import Any
-
-from nanobot.agent.tools.base import Tool
-from nanobot.cron.types import CronAction
-
-
-class CronActionTool(Tool):
- """捕获模型输出的机器可读 cron 控制决策。"""
-
- def __init__(self, job_id: str):
- # `job_id` 仅用于回显和审计,不参与决策本身。
- self.job_id = job_id
- # `_decision` 在本轮 agent 执行期间最多被写一次,外部在结束后读取。
- self._decision: CronAction | None = None
-
- @property
- def name(self) -> str:
- return "cron_action"
-
- @property
- def description(self) -> str:
- return "Record a structured lifecycle action for the currently running cron job."
-
- @property
- def parameters(self) -> dict[str, Any]:
- return {
- "type": "object",
- "properties": {
- "action": {
- "type": "string",
- "enum": ["none", "remove", "disable", "complete_today", "reschedule"],
- "description": "Lifecycle action for the current cron job",
- },
- "reason": {
- "type": "string",
- "description": "Short reason for audit logs",
- },
- "every_seconds": {
- "type": "integer",
- "description": "Required when action=reschedule and using fixed interval",
- },
- "cron_expr": {
- "type": "string",
- "description": "Required when action=reschedule and using cron expression",
- },
- "tz": {
- "type": "string",
- "description": "Optional timezone for cron_expr reschedules",
- },
- "at": {
- "type": "string",
- "description": "Required when action=reschedule and using one-time ISO datetime",
- },
- },
- "required": ["action"],
- }
-
- @property
- def decision(self) -> CronAction | None:
- # 暴露最终结构化决策给 cron runtime,便于后处理调度状态。
- return self._decision
-
- async def execute(
- self,
- action: str,
- reason: str | None = None,
- every_seconds: int | None = None,
- cron_expr: str | None = None,
- tz: str | None = None,
- at: str | None = None,
- **_kwargs: Any,
- ) -> str:
- # 统一做小写规范化,避免模型传入 `Remove` / `REMOVE` 之类大小写变体。
- normalized = (action or "").strip().lower()
- allowed_actions = {"none", "remove", "disable", "complete_today", "reschedule"}
- if normalized not in allowed_actions:
- return f"Error: unsupported cron action '{action}'"
- # 非重排任务不允许额外携带调度字段,避免出现“说 remove 但又传 cron_expr”的脏数据。
- if normalized != "reschedule" and any(value is not None for value in (every_seconds, cron_expr, tz, at)):
- return "Error: schedule fields can only be used when action='reschedule'"
-
- if normalized == "reschedule":
- # 重新排期必须在三种时间表达方式里三选一,不能都不传,也不能混传。
- options = int(every_seconds is not None) + int(bool(cron_expr)) + int(bool(at))
- if options != 1:
- return "Error: reschedule requires exactly one of every_seconds, cron_expr, or at"
- # 时区只有 cron 表达式才有意义。
- if tz and not cron_expr:
- return "Error: tz can only be used with cron_expr"
-
- # 校验通过后,把本轮决策固化为 dataclass,交给 runtime 在执行后统一消费。
- self._decision = CronAction(
- action=normalized or "none",
- reason=(reason or "").strip() or None,
- every_seconds=every_seconds,
- cron_expr=cron_expr,
- tz=tz,
- at=at,
- )
- # 返回给模型/日志的是一条可读确认文本,方便工具调用结果出现在上下文里。
- detail = f" for job {self.job_id}"
- if self._decision.reason:
- detail += f" ({self._decision.reason})"
- return f"Recorded cron_action={self._decision.action}{detail}"
diff --git a/app-instance/backend-old/nanobot/agent/tools/filesystem.py b/app-instance/backend-old/nanobot/agent/tools/filesystem.py
deleted file mode 100644
index d7e838c..0000000
--- a/app-instance/backend-old/nanobot/agent/tools/filesystem.py
+++ /dev/null
@@ -1,275 +0,0 @@
-"""File system tools: read, write, edit."""
-
-import difflib
-from pathlib import Path
-from typing import Any
-
-from nanobot.agent.tools.base import Tool
-
-
-def _resolve_path(path: str, workspace: Path | None = None, allowed_dir: Path | None = None) -> Path:
- """Resolve path against workspace (if relative) and enforce directory restriction."""
- p = Path(path).expanduser()
- if not p.is_absolute() and workspace:
- p = workspace / p
- resolved = p.resolve()
- if allowed_dir:
- try:
- resolved.relative_to(allowed_dir.resolve())
- except ValueError:
- raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
- return resolved
-
-
-def _is_relative_to(path: Path, root: Path) -> bool:
- try:
- path.relative_to(root.resolve())
- return True
- except ValueError:
- return False
-
-
-def _protected_write_error() -> str:
- return (
- "Error: Direct writes to workspace skills are blocked. "
- "Stage the skill for review and require explicit user approval before installation."
- )
-
-
-class ReadFileTool(Tool):
- """Tool to read file contents."""
-
- def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
- self._workspace = workspace
- self._allowed_dir = allowed_dir
-
- @property
- def name(self) -> str:
- return "read_file"
-
- @property
- def description(self) -> str:
- return "Read the contents of a file at the given path."
-
- @property
- def parameters(self) -> dict[str, Any]:
- return {
- "type": "object",
- "properties": {
- "path": {
- "type": "string",
- "description": "The file path to read"
- }
- },
- "required": ["path"]
- }
-
- async def execute(self, path: str, **kwargs: Any) -> str:
- try:
- file_path = _resolve_path(path, self._workspace, self._allowed_dir)
- if not file_path.exists():
- return f"Error: File not found: {path}"
- if not file_path.is_file():
- return f"Error: Not a file: {path}"
-
- content = file_path.read_text(encoding="utf-8")
- return content
- except PermissionError as e:
- return f"Error: {e}"
- except Exception as e:
- return f"Error reading file: {str(e)}"
-
-
-class WriteFileTool(Tool):
- """Tool to write content to a file."""
-
- def __init__(
- self,
- workspace: Path | None = None,
- allowed_dir: Path | None = None,
- protected_paths: list[Path] | None = None,
- ):
- self._workspace = workspace
- self._allowed_dir = allowed_dir
- self._protected_paths = [p.expanduser().resolve() for p in protected_paths or []]
-
- @property
- def name(self) -> str:
- return "write_file"
-
- @property
- def description(self) -> str:
- return "Write content to a file at the given path. Creates parent directories if needed."
-
- @property
- def parameters(self) -> dict[str, Any]:
- return {
- "type": "object",
- "properties": {
- "path": {
- "type": "string",
- "description": "The file path to write to"
- },
- "content": {
- "type": "string",
- "description": "The content to write"
- }
- },
- "required": ["path", "content"]
- }
-
- async def execute(self, path: str, content: str, **kwargs: Any) -> str:
- try:
- file_path = _resolve_path(path, self._workspace, self._allowed_dir)
- if any(_is_relative_to(file_path, protected) for protected in self._protected_paths):
- return _protected_write_error()
- file_path.parent.mkdir(parents=True, exist_ok=True)
- file_path.write_text(content, encoding="utf-8")
- return f"Successfully wrote {len(content)} bytes to {file_path}"
- except PermissionError as e:
- return f"Error: {e}"
- except Exception as e:
- return f"Error writing file: {str(e)}"
-
-
-class EditFileTool(Tool):
- """Tool to edit a file by replacing text."""
-
- def __init__(
- self,
- workspace: Path | None = None,
- allowed_dir: Path | None = None,
- protected_paths: list[Path] | None = None,
- ):
- self._workspace = workspace
- self._allowed_dir = allowed_dir
- self._protected_paths = [p.expanduser().resolve() for p in protected_paths or []]
-
- @property
- def name(self) -> str:
- return "edit_file"
-
- @property
- def description(self) -> str:
- return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file."
-
- @property
- def parameters(self) -> dict[str, Any]:
- return {
- "type": "object",
- "properties": {
- "path": {
- "type": "string",
- "description": "The file path to edit"
- },
- "old_text": {
- "type": "string",
- "description": "The exact text to find and replace"
- },
- "new_text": {
- "type": "string",
- "description": "The text to replace with"
- }
- },
- "required": ["path", "old_text", "new_text"]
- }
-
- async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str:
- try:
- file_path = _resolve_path(path, self._workspace, self._allowed_dir)
- if any(_is_relative_to(file_path, protected) for protected in self._protected_paths):
- return _protected_write_error()
- if not file_path.exists():
- return f"Error: File not found: {path}"
-
- content = file_path.read_text(encoding="utf-8")
-
- if old_text not in content:
- return self._not_found_message(old_text, content, path)
-
- # Count occurrences
- count = content.count(old_text)
- if count > 1:
- return f"Warning: old_text appears {count} times. Please provide more context to make it unique."
-
- new_content = content.replace(old_text, new_text, 1)
- file_path.write_text(new_content, encoding="utf-8")
-
- return f"Successfully edited {file_path}"
- except PermissionError as e:
- return f"Error: {e}"
- except Exception as e:
- return f"Error editing file: {str(e)}"
-
- @staticmethod
- def _not_found_message(old_text: str, content: str, path: str) -> str:
- """Build a helpful error when old_text is not found."""
- lines = content.splitlines(keepends=True)
- old_lines = old_text.splitlines(keepends=True)
- window = len(old_lines)
-
- best_ratio, best_start = 0.0, 0
- for i in range(max(1, len(lines) - window + 1)):
- ratio = difflib.SequenceMatcher(None, old_lines, lines[i : i + window]).ratio()
- if ratio > best_ratio:
- best_ratio, best_start = ratio, i
-
- if best_ratio > 0.5:
- diff = "\n".join(difflib.unified_diff(
- old_lines, lines[best_start : best_start + window],
- fromfile="old_text (provided)", tofile=f"{path} (actual, line {best_start + 1})",
- lineterm="",
- ))
- return f"Error: old_text not found in {path}.\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
- return f"Error: old_text not found in {path}. No similar text found. Verify the file content."
-
-
-class ListDirTool(Tool):
- """Tool to list directory contents."""
-
- def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
- self._workspace = workspace
- self._allowed_dir = allowed_dir
-
- @property
- def name(self) -> str:
- return "list_dir"
-
- @property
- def description(self) -> str:
- return "List the contents of a directory."
-
- @property
- def parameters(self) -> dict[str, Any]:
- return {
- "type": "object",
- "properties": {
- "path": {
- "type": "string",
- "description": "The directory path to list"
- }
- },
- "required": ["path"]
- }
-
- async def execute(self, path: str, **kwargs: Any) -> str:
- try:
- dir_path = _resolve_path(path, self._workspace, self._allowed_dir)
- if not dir_path.exists():
- return f"Error: Directory not found: {path}"
- if not dir_path.is_dir():
- return f"Error: Not a directory: {path}"
-
- items = []
- for item in sorted(dir_path.iterdir()):
- prefix = "📁 " if item.is_dir() else "📄 "
- items.append(f"{prefix}{item.name}")
-
- if not items:
- return f"Directory {path} is empty"
-
- return "\n".join(items)
- except PermissionError as e:
- return f"Error: {e}"
- except Exception as e:
- return f"Error listing directory: {str(e)}"
diff --git a/app-instance/backend-old/nanobot/agent/tools/mcp.py b/app-instance/backend-old/nanobot/agent/tools/mcp.py
deleted file mode 100644
index 422f316..0000000
--- a/app-instance/backend-old/nanobot/agent/tools/mcp.py
+++ /dev/null
@@ -1,382 +0,0 @@
-"""MCP 客户端封装。
-
-职责分两层:
-1. `connect_mcp_servers()` 负责建立与 MCP server 的连接,并把远端工具注册成 nanobot 本地工具;
-2. `MCPToolWrapper` 负责把单个远端 MCP tool 包装成可供 LLM 调用的 `Tool`,同时发出结构化过程事件。
-"""
-
-import asyncio
-import json
-from collections.abc import Awaitable, Callable
-from contextlib import AsyncExitStack
-from typing import Any
-
-import httpx
-from loguru import logger
-
-from nanobot.agent.process_events import current_process_run_id, emit_process_event, new_run_id
-from nanobot.agent.tools.base import Tool
-from nanobot.agent.tools.registry import ToolRegistry
-
-
-def _iter_leaf_exceptions(exc: BaseException) -> list[BaseException]:
- if isinstance(exc, BaseExceptionGroup):
- leaves: list[BaseException] = []
- for sub_exc in exc.exceptions:
- leaves.extend(_iter_leaf_exceptions(sub_exc))
- return leaves
- return [exc]
-
-
-def _describe_mcp_exception(exc: BaseException, *, server_name: str, url: str | None = None) -> str:
- leaves = _iter_leaf_exceptions(exc)
- target = f" ({url})" if url else ""
-
- for leaf in leaves:
- if isinstance(leaf, httpx.TimeoutException):
- return f"MCP server '{server_name}' timed out while waiting for a response{target}"
- if isinstance(leaf, httpx.ConnectError):
- return f"MCP server '{server_name}' is unreachable{target}"
- if isinstance(leaf, httpx.HTTPStatusError):
- return f"MCP server '{server_name}' returned HTTP {leaf.response.status_code}{target}"
- if isinstance(leaf, httpx.HTTPError):
- detail = str(leaf).strip() or leaf.__class__.__name__
- return f"MCP server '{server_name}' HTTP error{target}: {detail}"
-
- detail_source = leaves[0] if leaves else exc
- detail = str(detail_source).strip() or detail_source.__class__.__name__
- if isinstance(exc, BaseExceptionGroup):
- return f"MCP server '{server_name}' failed: {detail_source.__class__.__name__}: {detail}"
- return detail
-
-
-class MCPToolWrapper(Tool):
- """把单个 MCP server tool 包装成 nanobot Tool。"""
-
- def __init__(
- self,
- session,
- server_name: str,
- tool_def,
- *,
- call_tool: Callable[[str, dict[str, Any]], Awaitable[Any]] | None = None,
- tool_timeout: int = 30,
- sensitive: bool = False,
- ):
- self._session = session
- self._call_tool = call_tool or self._default_call_tool
- # 记录来源服务名,便于日志、事件流和最终导出的工具名保持可追踪。
- self._server_name = server_name
- self._original_name = tool_def.name
- # 在 nanobot 内部为 MCP 工具统一加 `mcp__` 前缀,避免同名冲突。
- self._name = f"mcp_{server_name}_{tool_def.name}"
- self._description = tool_def.description or tool_def.name
- self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}}
- self._tool_timeout = tool_timeout
- self._sensitive = sensitive
-
- @property
- def name(self) -> str:
- return self._name
-
- @property
- def description(self) -> str:
- return self._description
-
- @property
- def parameters(self) -> dict[str, Any]:
- return self._parameters
-
- async def execute(self, **kwargs: Any) -> str:
- from mcp import types
- # 每次 MCP 调用都分配独立 run_id,前端可以把它显示成树状子步骤。
- run_id = new_run_id("mcp")
- args_json = json.dumps(kwargs, ensure_ascii=False) if kwargs else "{}"
- await emit_process_event(
- "process_run_started",
- run_id=run_id,
- parent_run_id=current_process_run_id(),
- actor_type="mcp",
- actor_id=self._server_name,
- actor_name=self._server_name,
- title=f"{self._server_name}.{self._original_name}",
- status="running",
- metadata={
- "tool_name": self._original_name,
- "tool_args": None if self._sensitive else kwargs,
- "tool_timeout": self._tool_timeout,
- "sensitive": self._sensitive,
- },
- )
- # 在真正请求远端前先发一条 progress,方便 UI 及时显示“正在调用哪个工具”。
- await emit_process_event(
- "process_run_progress",
- run_id=run_id,
- parent_run_id=current_process_run_id(),
- actor_type="mcp",
- actor_id=self._server_name,
- actor_name=self._server_name,
- text=(
- f"Calling {self._original_name}"
- if self._sensitive
- else f"Calling {self._original_name} with {args_json}"
- ),
- metadata={"tool_name": self._original_name, "sensitive": self._sensitive},
- )
- try:
- result = await asyncio.wait_for(
- self._call_tool(self._original_name, kwargs),
- timeout=self._tool_timeout,
- )
- except asyncio.TimeoutError:
- # 超时被视为业务失败,但不抛异常给上层 agent 循环,而是返回可读错误文本。
- logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout)
- summary = f"(MCP tool call timed out after {self._tool_timeout}s)"
- await emit_process_event(
- "process_run_status",
- run_id=run_id,
- actor_type="mcp",
- actor_id=self._server_name,
- actor_name=self._server_name,
- status="error",
- text=summary,
- metadata={"tool_name": self._original_name, "sensitive": self._sensitive},
- )
- await emit_process_event(
- "process_run_finished",
- run_id=run_id,
- actor_type="mcp",
- actor_id=self._server_name,
- actor_name=self._server_name,
- status="error",
- summary=summary,
- metadata={"tool_name": self._original_name, "sensitive": self._sensitive},
- )
- return summary
-
- # MCP SDK 返回的是结构化 content block 列表,这里统一摊平成文本。
- parts = []
- for block in result.content:
- if isinstance(block, types.TextContent):
- parts.append(block.text)
- else:
- parts.append(str(block))
- output = "\n".join(parts) or "(no output)"
- artifact_type = "text"
- artifact_data: Any | None = None
- stripped = output.strip()
- # 如果看起来像 JSON,则额外解析成结构化 artifact,方便前端做更丰富展示。
- if stripped.startswith("{") or stripped.startswith("["):
- try:
- artifact_data = json.loads(stripped)
- artifact_type = "json"
- except json.JSONDecodeError:
- artifact_data = None
- await emit_process_event(
- "process_run_artifact",
- run_id=run_id,
- actor_type="mcp",
- actor_id=self._server_name,
- actor_name=self._server_name,
- title=f"{self._server_name}.{self._original_name} result",
- artifact_type="redacted" if self._sensitive else artifact_type,
- content=None if self._sensitive or artifact_data is not None else output,
- data=None if self._sensitive else artifact_data,
- metadata={"tool_name": self._original_name, "sensitive": self._sensitive},
- )
- await emit_process_event(
- "process_run_finished",
- run_id=run_id,
- actor_type="mcp",
- actor_id=self._server_name,
- actor_name=self._server_name,
- status="done",
- summary=(
- f"{self._original_name} completed"
- if self._sensitive
- else output[:1000]
- ),
- metadata={"tool_name": self._original_name, "sensitive": self._sensitive},
- )
- return output
-
- async def _default_call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
- return await self._session.call_tool(tool_name, arguments=arguments)
-
-
-async def connect_mcp_servers(
- mcp_servers: dict,
- registry: ToolRegistry,
- stack: AsyncExitStack,
- *,
- authz_config: Any | None = None,
- backend_identity: Any | None = None,
-) -> dict[str, dict[str, Any]]:
- """连接所有配置中的 MCP server,并把工具注册到 registry。"""
- from mcp import ClientSession, StdioServerParameters
- from mcp.client.stdio import stdio_client
- from mcp.client.streamable_http import streamable_http_client
- from nanobot.authz.client import AuthzClient
-
- async def _build_http_headers(server_name: str, cfg: Any) -> dict[str, str]:
- headers = dict(getattr(cfg, "headers", {}) or {})
- if getattr(cfg, "auth_mode", "none") != "oauth_backend_token":
- return headers
-
- if not (
- authz_config
- and getattr(authz_config, "base_url", "").strip()
- and backend_identity
- and getattr(backend_identity, "client_id", "").strip()
- and getattr(backend_identity, "client_secret", "").strip()
- ):
- raise RuntimeError(
- f"MCP server '{server_name}' requires AuthZ backend token, but authz/backend identity is incomplete"
- )
-
- authz_client = AuthzClient(
- getattr(authz_config, "base_url"),
- timeout_seconds=int(getattr(authz_config, "request_timeout_seconds", 10)),
- )
- raw_audience = str(getattr(cfg, "auth_audience", "") or "").strip()
- # Older managed Outlook configs stored `auth_audience="mcp"`, but AuthZ
- # permissions are issued against `mcp:`.
- if not raw_audience or raw_audience == "mcp":
- audience = f"mcp:{server_name}"
- elif raw_audience.startswith("mcp:"):
- audience = raw_audience
- else:
- audience = f"mcp:{raw_audience}"
- token_response = await authz_client.issue_token(
- client_id=getattr(backend_identity, "client_id"),
- client_secret=getattr(backend_identity, "client_secret"),
- audience=audience,
- scopes=[str(item) for item in list(getattr(cfg, "auth_scopes", []) or [])],
- )
- access_token = str(token_response.get("access_token") or "").strip()
- if not access_token:
- raise RuntimeError(f"MCP server '{server_name}' did not receive an access token from AuthZ")
- headers["Authorization"] = f"Bearer {access_token}"
- return headers
-
- async def _open_http_session(
- session_stack: AsyncExitStack,
- cfg: Any,
- *,
- headers: dict[str, str],
- ):
- http_client = await session_stack.enter_async_context(
- httpx.AsyncClient(
- headers=headers or None,
- follow_redirects=True,
- trust_env=False,
- )
- )
- read, write, _ = await session_stack.enter_async_context(
- streamable_http_client(cfg.url, http_client=http_client)
- )
- session = await session_stack.enter_async_context(ClientSession(read, write))
- await session.initialize()
- return session
-
- async def _list_http_tools(server_name: str, cfg: Any):
- async with AsyncExitStack() as session_stack:
- headers = await _build_http_headers(server_name, cfg)
- session = await _open_http_session(session_stack, cfg, headers=headers)
- tools = await session.list_tools()
- return tools.tools
-
- def _make_http_call_tool(server_name: str, cfg: Any) -> Callable[[str, dict[str, Any]], Awaitable[Any]]:
- async def _call_tool(tool_name: str, arguments: dict[str, Any]) -> Any:
- async with AsyncExitStack() as session_stack:
- headers = await _build_http_headers(server_name, cfg)
- session = await _open_http_session(session_stack, cfg, headers=headers)
- return await session.call_tool(tool_name, arguments=arguments)
-
- return _call_tool
-
- # `report` 会返回给调用方,用于 Web UI 展示连接状态和已发现工具。
- report: dict[str, dict[str, Any]] = {}
- for name, cfg in mcp_servers.items():
- report[name] = {
- "status": "disconnected",
- "last_error": None,
- "tool_names": [],
- "tool_count": 0,
- "transport": "stdio" if getattr(cfg, "command", "") else "http",
- }
- try:
- if cfg.command:
- # stdio 模式:本地拉起一个子进程,通过 stdin/stdout 与 MCP server 通信。
- params = StdioServerParameters(
- command=cfg.command, args=cfg.args, env=cfg.env or None
- )
- read, write = await stack.enter_async_context(stdio_client(params))
- session = await stack.enter_async_context(ClientSession(read, write))
- await session.initialize()
- tools = await session.list_tools()
- for tool_def in tools.tools:
- wrapper = MCPToolWrapper(
- session,
- name,
- tool_def,
- tool_timeout=cfg.tool_timeout,
- sensitive=bool(getattr(cfg, "sensitive", False)),
- )
- registry.register(wrapper)
- logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)
- report[name]["tool_names"].append(wrapper.name)
- elif cfg.url:
- if getattr(cfg, "auth_mode", "none") == "oauth_backend_token":
- tools_defs = await _list_http_tools(name, cfg)
- call_tool = _make_http_call_tool(name, cfg)
- for tool_def in tools_defs:
- wrapper = MCPToolWrapper(
- None,
- name,
- tool_def,
- call_tool=call_tool,
- tool_timeout=cfg.tool_timeout,
- sensitive=bool(getattr(cfg, "sensitive", False)),
- )
- registry.register(wrapper)
- logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)
- report[name]["tool_names"].append(wrapper.name)
- else:
- headers = await _build_http_headers(name, cfg)
- session = await _open_http_session(stack, cfg, headers=headers)
- tools = await session.list_tools()
- for tool_def in tools.tools:
- wrapper = MCPToolWrapper(
- session,
- name,
- tool_def,
- tool_timeout=cfg.tool_timeout,
- sensitive=bool(getattr(cfg, "sensitive", False)),
- )
- registry.register(wrapper)
- logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)
- report[name]["tool_names"].append(wrapper.name)
- else:
- # 没有 command 也没有 url 的条目视为无效配置,跳过但不抛异常。
- logger.warning("MCP server '{}': no command or url configured, skipping", name)
- continue
-
- report[name]["tool_count"] = len(report[name]["tool_names"])
- report[name]["status"] = "connected"
- logger.info(
- "MCP server '{}': connected, {} tools registered",
- name,
- len(report[name]["tool_names"]),
- )
- except Exception as e:
- # 单个 server 失败不影响其他 server 继续连;错误写进 report 供 UI 展示。
- error_detail = _describe_mcp_exception(
- e,
- server_name=name,
- url=str(getattr(cfg, "url", "") or "").strip() or None,
- )
- report[name]["status"] = "error"
- report[name]["last_error"] = error_detail
- logger.error("MCP server '{}': failed to connect: {}", name, error_detail)
- return report
diff --git a/app-instance/backend-old/nanobot/agent/tools/message.py b/app-instance/backend-old/nanobot/agent/tools/message.py
deleted file mode 100644
index 40e76e3..0000000
--- a/app-instance/backend-old/nanobot/agent/tools/message.py
+++ /dev/null
@@ -1,108 +0,0 @@
-"""Message tool for sending messages to users."""
-
-from typing import Any, Awaitable, Callable
-
-from nanobot.agent.tools.base import Tool
-from nanobot.bus.events import OutboundMessage
-
-
-class MessageTool(Tool):
- """Tool to send messages to users on chat channels."""
-
- def __init__(
- self,
- send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
- default_channel: str = "",
- default_chat_id: str = "",
- default_message_id: str | None = None,
- ):
- self._send_callback = send_callback
- self._default_channel = default_channel
- self._default_chat_id = default_chat_id
- self._default_message_id = default_message_id
- self._sent_in_turn: bool = False
-
- def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
- """Set the current message context."""
- self._default_channel = channel
- self._default_chat_id = chat_id
- self._default_message_id = message_id
-
- def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
- """Set the callback for sending messages."""
- self._send_callback = callback
-
- def start_turn(self) -> None:
- """Reset per-turn send tracking."""
- self._sent_in_turn = False
-
- @property
- def name(self) -> str:
- return "message"
-
- @property
- def description(self) -> str:
- return "Send a message to the user. Use this when you want to communicate something."
-
- @property
- def parameters(self) -> dict[str, Any]:
- return {
- "type": "object",
- "properties": {
- "content": {
- "type": "string",
- "description": "The message content to send"
- },
- "channel": {
- "type": "string",
- "description": "Optional: target channel (telegram, discord, etc.)"
- },
- "chat_id": {
- "type": "string",
- "description": "Optional: target chat/user ID"
- },
- "media": {
- "type": "array",
- "items": {"type": "string"},
- "description": "Optional: list of file paths to attach (images, audio, documents)"
- }
- },
- "required": ["content"]
- }
-
- async def execute(
- self,
- content: str,
- channel: str | None = None,
- chat_id: str | None = None,
- message_id: str | None = None,
- media: list[str] | None = None,
- **kwargs: Any
- ) -> str:
- channel = channel or self._default_channel
- chat_id = chat_id or self._default_chat_id
- message_id = message_id or self._default_message_id
-
- if not channel or not chat_id:
- return "Error: No target channel/chat specified"
-
- if not self._send_callback:
- return "Error: Message sending not configured"
-
- msg = OutboundMessage(
- channel=channel,
- chat_id=chat_id,
- content=content,
- media=media or [],
- metadata={
- "message_id": message_id,
- }
- )
-
- try:
- await self._send_callback(msg)
- self._sent_in_turn = True
- media_info = f" with {len(media)} attachments" if media else ""
- return f"Message sent to {channel}:{chat_id}{media_info}"
- except Exception as e:
- return f"Error sending message: {str(e)}"
diff --git a/app-instance/backend-old/nanobot/agent/tools/registry.py b/app-instance/backend-old/nanobot/agent/tools/registry.py
deleted file mode 100644
index ea2c75e..0000000
--- a/app-instance/backend-old/nanobot/agent/tools/registry.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""工具注册中心。
-
-职责很单一:
-1. 保存当前可用工具实例;
-2. 向 LLM 暴露 function schema;
-3. 在执行前做基础参数校验,并把异常统一转成文本结果。
-"""
-
-from typing import Any
-
-from nanobot.agent.tools.base import Tool
-
-
-class ToolRegistry:
- """
- Registry for agent tools.
-
- Allows dynamic registration and execution of tools.
- """
-
- def __init__(self):
- # 工具名到实例的映射表;工具名在整个 registry 内必须唯一。
- self._tools: dict[str, Tool] = {}
-
- def register(self, tool: Tool) -> None:
- """注册一个工具实例。"""
- self._tools[tool.name] = tool
-
- def clone(self) -> "ToolRegistry":
- """创建一个浅拷贝,复用同一批工具实例。"""
- # 这里不深拷贝工具对象,因为很多工具本身持有运行时状态或外部连接。
- # 当前需求只是“在一个请求里临时附加额外工具”,复用实例即可。
- other = ToolRegistry()
- other._tools = dict(self._tools)
- return other
-
- def unregister(self, name: str) -> None:
- """Unregister a tool by name."""
- self._tools.pop(name, None)
-
- def get(self, name: str) -> Tool | None:
- """Get a tool by name."""
- return self._tools.get(name)
-
- def has(self, name: str) -> bool:
- """Check if a tool is registered."""
- return name in self._tools
-
- def get_definitions(self) -> list[dict[str, Any]]:
- """Get all tool definitions in OpenAI format."""
- return [tool.to_schema() for tool in self._tools.values()]
-
- async def execute(self, name: str, params: dict[str, Any]) -> str:
- """
- Execute a tool by name with given parameters.
-
- Args:
- name: Tool name.
- params: Tool parameters.
-
- Returns:
- Tool execution result as string.
-
- Raises:
- KeyError: If tool not found.
- """
- _hint = "\n\n[Analyze the error above and try a different approach.]"
-
- tool = self._tools.get(name)
- if not tool:
- return f"Error: Tool '{name}' not found. Available: {', '.join(self.tool_names)}"
-
- try:
- # schema 级参数校验放在真正调用前做,尽量把错误反馈成模型能自修复的文本。
- errors = tool.validate_params(params)
- if errors:
- return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) + _hint
- result = await tool.execute(**params)
- # 约定:工具若返回以 Error 开头的文本,说明是业务失败而非程序崩溃。
- if isinstance(result, str) and result.startswith("Error"):
- return result + _hint
- return result
- except Exception as e:
- # 保持“不抛异常到模型层”的接口语义,统一回成可读文本。
- return f"Error executing {name}: {str(e)}" + _hint
-
- @property
- def tool_names(self) -> list[str]:
- """Get list of registered tool names."""
- return list(self._tools.keys())
-
- def __len__(self) -> int:
- return len(self._tools)
-
- def __contains__(self, name: str) -> bool:
- return name in self._tools
diff --git a/app-instance/backend-old/nanobot/agent/tools/shell.py b/app-instance/backend-old/nanobot/agent/tools/shell.py
deleted file mode 100644
index aa118f0..0000000
--- a/app-instance/backend-old/nanobot/agent/tools/shell.py
+++ /dev/null
@@ -1,284 +0,0 @@
-"""Shell execution tool."""
-
-import asyncio
-import os
-import re
-import shlex
-from pathlib import Path
-from typing import Any
-
-from nanobot.agent.tools.base import Tool
-
-
-class ExecTool(Tool):
- """Tool to execute shell commands."""
-
- def __init__(
- self,
- timeout: int = 60,
- working_dir: str | None = None,
- deny_patterns: list[str] | None = None,
- allow_patterns: list[str] | None = None,
- restrict_to_workspace: bool = False,
- protected_paths: list[Path] | None = None,
- ):
- self.timeout = timeout
- self.working_dir = working_dir
- self.deny_patterns = deny_patterns or [
- r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
- r"\bdel\s+/[fq]\b", # del /f, del /q
- r"\brmdir\s+/s\b", # rmdir /s
- r"(?:^|[;&|]\s*)format\b", # format (as standalone command only)
- r"\b(mkfs|diskpart)\b", # disk operations
- r"\bdd\s+if=", # dd
- r">\s*/dev/sd", # write to disk
- r"\b(shutdown|reboot|poweroff)\b", # system power
- r":\(\)\s*\{.*\};\s*:", # fork bomb
- ]
- self.allow_patterns = allow_patterns or []
- self.restrict_to_workspace = restrict_to_workspace
- self.protected_paths = [Path(p).expanduser().resolve() for p in protected_paths or []]
-
- @property
- def name(self) -> str:
- return "exec"
-
- @property
- def description(self) -> str:
- return "Execute a shell command and return its output. Use with caution."
-
- @property
- def parameters(self) -> dict[str, Any]:
- return {
- "type": "object",
- "properties": {
- "command": {
- "type": "string",
- "description": "The shell command to execute"
- },
- "working_dir": {
- "type": "string",
- "description": "Optional working directory for the command"
- }
- },
- "required": ["command"]
- }
-
- async def execute(self, command: str, working_dir: str | None = None, **kwargs: Any) -> str:
- cwd = working_dir or self.working_dir or os.getcwd()
- guard_error = self._guard_command(command, cwd)
- if guard_error:
- return guard_error
-
- try:
- process = await asyncio.create_subprocess_shell(
- command,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- cwd=cwd,
- )
-
- try:
- stdout, stderr = await asyncio.wait_for(
- process.communicate(),
- timeout=self.timeout
- )
- except asyncio.TimeoutError:
- process.kill()
- # Wait for the process to fully terminate so pipes are
- # drained and file descriptors are released.
- try:
- await asyncio.wait_for(process.wait(), timeout=5.0)
- except asyncio.TimeoutError:
- pass
- return f"Error: Command timed out after {self.timeout} seconds"
-
- output_parts = []
-
- if stdout:
- output_parts.append(stdout.decode("utf-8", errors="replace"))
-
- if stderr:
- stderr_text = stderr.decode("utf-8", errors="replace")
- if stderr_text.strip():
- output_parts.append(f"STDERR:\n{stderr_text}")
-
- if process.returncode != 0:
- output_parts.append(f"\nExit code: {process.returncode}")
-
- result = "\n".join(output_parts) if output_parts else "(no output)"
-
- # Truncate very long output
- max_len = 10000
- if len(result) > max_len:
- result = result[:max_len] + f"\n... (truncated, {len(result) - max_len} more chars)"
-
- return result
-
- except Exception as e:
- return f"Error executing command: {str(e)}"
-
- def _guard_command(self, command: str, cwd: str) -> str | None:
- """Best-effort safety guard for potentially destructive commands."""
- cmd = command.strip()
- lower = cmd.lower()
-
- for pattern in self.deny_patterns:
- if re.search(pattern, lower):
- return "Error: Command blocked by safety guard (dangerous pattern detected)"
-
- if self.allow_patterns:
- if not any(re.search(p, lower) for p in self.allow_patterns):
- return "Error: Command blocked by safety guard (not in allowlist)"
-
- if self.restrict_to_workspace:
- if "..\\" in cmd or "../" in cmd:
- return "Error: Command blocked by safety guard (path traversal detected)"
-
- cwd_path = Path(cwd).resolve()
-
- win_paths = re.findall(r"[A-Za-z]:\\[^\\\"']+", cmd)
- # Only match absolute paths — avoid false positives on relative
- # paths like ".venv/bin/python" where "/bin/python" would be
- # incorrectly extracted by the old pattern.
- posix_paths = re.findall(r"(?:^|[\s|>])(/[^\s\"'>]+)", cmd)
-
- for raw in win_paths + posix_paths:
- try:
- p = Path(raw.strip()).resolve()
- except Exception:
- continue
- if p.is_absolute() and cwd_path not in p.parents and p != cwd_path:
- return "Error: Command blocked by safety guard (path outside working dir)"
-
- protected_error = self._guard_protected_paths(command, cwd)
- if protected_error:
- return protected_error
-
- return None
-
- def _guard_protected_paths(self, command: str, cwd: str) -> str | None:
- if not self.protected_paths:
- return None
-
- cwd_path = Path(cwd).expanduser().resolve()
- if self._is_blocked_clawhub_install(command, cwd_path):
- return self._protected_write_error()
-
- if not self._looks_like_write(command):
- return None
-
- for raw in self._extract_path_tokens(command):
- resolved = self._resolve_command_path(raw, cwd_path)
- if resolved and any(self._is_relative_to(resolved, root) for root in self.protected_paths):
- return self._protected_write_error()
-
- return None
-
- def _is_blocked_clawhub_install(self, command: str, cwd_path: Path) -> bool:
- lower = command.lower()
- if "clawhub" not in lower or not re.search(r"\b(install|update)\b", lower):
- return False
-
- workdir = self._extract_flag_value(command, "--workdir")
- if workdir:
- resolved = self._resolve_command_path(workdir, cwd_path)
- return any(
- resolved == root.parent or self._is_relative_to(root, resolved)
- for root in self.protected_paths
- )
-
- return any(cwd_path == root.parent for root in self.protected_paths)
-
- @staticmethod
- def _protected_write_error() -> str:
- return (
- "Error: Direct writes to workspace skills are blocked. "
- "Stage the skill for review and require explicit user approval before installation."
- )
-
- @staticmethod
- def _is_relative_to(path: Path, root: Path) -> bool:
- try:
- path.relative_to(root)
- return True
- except ValueError:
- return False
-
- @staticmethod
- def _extract_flag_value(command: str, flag: str) -> str | None:
- tokens = ExecTool._tokenize(command)
- for i, token in enumerate(tokens):
- if token == flag and i + 1 < len(tokens):
- return tokens[i + 1]
- if token.startswith(flag + "="):
- return token.split("=", 1)[1]
- return None
-
- @staticmethod
- def _looks_like_write(command: str) -> bool:
- lower = command.lower()
- if re.search(r"(^|[^<])>>?\s*\S+", command):
- return True
- if re.search(r"\bsed\s+-i(?:\s|$)", lower):
- return True
- return bool(re.search(
- r"\b(cp|mv|rm|mkdir|touch|install|tee|tar|unzip|zip|chmod|chown|git|python|python3|node|npx|bash|sh|zsh|pwsh|powershell)\b",
- lower,
- ))
-
- @staticmethod
- def _extract_path_tokens(command: str) -> list[str]:
- tokens = ExecTool._tokenize(command)
- path_tokens: list[str] = []
- skip_next = False
- for i, token in enumerate(tokens):
- if skip_next:
- skip_next = False
- continue
- if token in {"--workdir", "-C"}:
- if i + 1 < len(tokens):
- path_tokens.append(tokens[i + 1])
- skip_next = True
- continue
- if "=" in token:
- key, value = token.split("=", 1)
- if key in {"--workdir"}:
- path_tokens.append(value)
- continue
- cleaned = token.strip("\"'")
- if ExecTool._looks_like_path_token(cleaned):
- path_tokens.append(cleaned)
- return path_tokens
-
- @staticmethod
- def _looks_like_path_token(token: str) -> bool:
- if not token or token in {".", ".."}:
- return True
- if token.startswith(("~", "/", "./", "../")):
- return True
- if re.match(r"^[A-Za-z]:\\", token):
- return True
- return "/" in token or "\\" in token
-
- @staticmethod
- def _resolve_command_path(raw: str, cwd_path: Path) -> Path | None:
- token = raw.strip().strip("\"'")
- if not token:
- return None
- try:
- path = Path(token).expanduser()
- if not path.is_absolute():
- path = (cwd_path / path).resolve()
- else:
- path = path.resolve()
- return path
- except Exception:
- return None
-
- @staticmethod
- def _tokenize(command: str) -> list[str]:
- try:
- return shlex.split(command, posix=os.name != "nt")
- except ValueError:
- return command.split()
diff --git a/app-instance/backend-old/nanobot/agent/tools/spawn.py b/app-instance/backend-old/nanobot/agent/tools/spawn.py
deleted file mode 100644
index 316a71a..0000000
--- a/app-instance/backend-old/nanobot/agent/tools/spawn.py
+++ /dev/null
@@ -1,204 +0,0 @@
-"""委派工具:分别暴露 subagent 与 agent team 两种调用接口。"""
-
-from typing import TYPE_CHECKING, Any
-
-from nanobot.agent.tools.base import Tool
-
-if TYPE_CHECKING:
- from nanobot.agent.delegation import DelegationManager
-
-
-class DelegationTool(Tool):
- """委派类工具的公共上下文注入逻辑。"""
-
- def __init__(self, manager: "DelegationManager"):
- self._manager = manager
- self._origin_channel = "cli"
- self._origin_chat_id = "direct"
- self._announce_via_bus = True
-
- def set_context(self, channel: str, chat_id: str, announce_via_bus: bool = True) -> None:
- """设置后台委派结果回传的目标会话。"""
- self._origin_channel = channel
- self._origin_chat_id = chat_id
- self._announce_via_bus = announce_via_bus
-
-
-class SpawnSubagentTool(DelegationTool):
- """把任务委派给单个 subagent。"""
-
- @property
- def name(self) -> str:
- return "spawn_subagent"
-
- @property
- def description(self) -> str:
- return (
- "Delegate a focused task to one background subagent. "
- "Use this for complex or time-consuming work that can run independently. "
- "You only provide the task and optional required skills; downstream routing decides the concrete agent. "
- "The subagent will report back when done."
- )
-
- @property
- def parameters(self) -> dict[str, Any]:
- return {
- "type": "object",
- "properties": {
- "task": {
- "type": "string",
- "description": "The task for the delegated subagent to complete",
- },
- "label": {
- "type": "string",
- "description": "Optional short label for the task (for display)",
- },
- "skills": {
- "type": "array",
- "items": {"type": "string"},
- "description": "Optional list of skill names the delegated worker must follow",
- },
- },
- "required": ["task"],
- }
-
- async def execute(
- self,
- task: str,
- label: str | None = None,
- skills: list[str] | None = None,
- **kwargs: Any,
- ) -> str:
- """创建并启动一个 subagent 后台任务。"""
- return await self._manager.dispatch_subagent(
- task=task,
- label=label,
- skills=skills,
- origin_channel=self._origin_channel,
- origin_chat_id=self._origin_chat_id,
- announce_via_bus=self._announce_via_bus,
- )
-
-
-class SpawnAgentTeamTool(DelegationTool):
- """启动一个 agent team 任务。"""
-
- @property
- def name(self) -> str:
- return "spawn_agent_team"
-
- @property
- def description(self) -> str:
- return (
- "Start an agent team for parallel exploration. "
- "Use this when multiple agents should investigate the task in parallel and return a combined result. "
- "You only provide the task and optional required skills; downstream routing selects the concrete members."
- )
-
- @property
- def parameters(self) -> dict[str, Any]:
- return {
- "type": "object",
- "properties": {
- "task": {
- "type": "string",
- "description": "The shared task for the agent team",
- },
- "label": {
- "type": "string",
- "description": "Optional short label for the team task (for display)",
- },
- "skills": {
- "type": "array",
- "items": {"type": "string"},
- "description": "Optional list of skill names the team must follow",
- },
- },
- "required": ["task"],
- }
-
- async def execute(
- self,
- task: str,
- label: str | None = None,
- skills: list[str] | None = None,
- **kwargs: Any,
- ) -> str:
- """创建并启动一个 agent team 后台任务。"""
- return await self._manager.dispatch_agent_team(
- task=task,
- label=label,
- skills=skills,
- origin_channel=self._origin_channel,
- origin_chat_id=self._origin_chat_id,
- announce_via_bus=self._announce_via_bus,
- )
-
-
-class NestedDelegateTool(Tool):
- """供 delegated worker 使用的受控下游委派工具。"""
-
- def __init__(self, manager: "DelegationManager", default_skills: list[str] | None = None):
- self._manager = manager
- self._default_skills = [str(item).strip() for item in (default_skills or []) if str(item).strip()]
-
- @property
- def name(self) -> str:
- return "delegate_task"
-
- @property
- def description(self) -> str:
- return (
- "Synchronously delegate a downstream task from a delegated worker. "
- "Use this only when specialized help is needed. "
- "It can route to an A2A agent or an ephemeral local subagent, but never creates a persistent subagent."
- )
-
- @property
- def parameters(self) -> dict[str, Any]:
- return {
- "type": "object",
- "properties": {
- "task": {
- "type": "string",
- "description": "The downstream task to delegate",
- },
- "label": {
- "type": "string",
- "description": "Optional short label for the downstream task",
- },
- "target": {
- "type": "string",
- "description": "Optional agent ID or name for the downstream worker",
- },
- "strategy": {
- "type": "string",
- "enum": ["auto", "a2a", "ephemeral_subagent"],
- "description": "Routing strategy for downstream delegation. Default is auto.",
- },
- "skills": {
- "type": "array",
- "items": {"type": "string"},
- "description": "Optional required skills for the downstream delegate. Defaults to the current worker's required skills.",
- },
- },
- "required": ["task"],
- }
-
- async def execute(
- self,
- task: str,
- label: str | None = None,
- target: str | None = None,
- strategy: str = "auto",
- skills: list[str] | None = None,
- **kwargs: Any,
- ) -> str:
- """同步执行一次受控下游委派,并把结果返回给当前 worker。"""
- return await self._manager.delegate_for_subagent(
- task=task,
- label=label,
- target=target,
- strategy=strategy,
- skills=skills if skills is not None else list(self._default_skills),
- )
diff --git a/app-instance/backend-old/nanobot/agent/tools/web.py b/app-instance/backend-old/nanobot/agent/tools/web.py
deleted file mode 100644
index 90cdda8..0000000
--- a/app-instance/backend-old/nanobot/agent/tools/web.py
+++ /dev/null
@@ -1,163 +0,0 @@
-"""Web tools: web_search and web_fetch."""
-
-import html
-import json
-import os
-import re
-from typing import Any
-from urllib.parse import urlparse
-
-import httpx
-
-from nanobot.agent.tools.base import Tool
-
-# Shared constants
-USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36"
-MAX_REDIRECTS = 5 # Limit redirects to prevent DoS attacks
-
-
-def _strip_tags(text: str) -> str:
- """Remove HTML tags and decode entities."""
- text = re.sub(r'