PicoBot/docs/SUB_AGENT_DESIGN.md
xiaoxixi b558a0a99b feat: make sub-agent orchestration always-on, consolidate design docs
Remove the agent_orchestration enabled feature switch and root_delegates config; delegation edges now derive from the catalog's delegate_targets. Replace the four orchestration design/review docs with a single SUB_AGENT_DESIGN.md. Bump version to 1.13.0.
2026-08-13 18:07:32 +08:00

280 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 子 Agent 设计
本文说明 PicoBot 具名子 Agentnamed Agent的运行时设计重点是**结果如何从子 Agent 传回主 Agent**,以及实现过程中踩过的坑。文中描述以当前代码为准(`src/agent/``src/storage/agent_run.rs``src/storage/agent_inbox.rs``src/tools/delegate.rs` 等)。
## 1. 概述与设计目标
子 Agent 让主 Agent 把一个独立、可验收的子任务交给一个**具名角色**去执行,角色有独立的 Provider/模型、工具集、系统提示词和执行预算。核心目标:
- **一切可审计**每次委托run先落库再执行终态、结果、工具调用数、迭代数都持久化WebUI 可查。
- **结果不丢**:后台任务的完成结果即使进程崩溃、收件箱打满、唤醒丢失也能最终送达主 Agent。
- **fail-closed**:定义文件、工具集、委托边、信号契约任一处非法都拒绝加载或拒绝执行,绝不悄悄放宽权限。
- **有界**:树深度、树内 run 数、并发、信号频率、结果长度全部有上限,模型只能收窄不能扩张。
## 2. 概念模型
### 2.1 Agent 定义Markdown
每个子 Agent 是 `definitions_dir`(默认 `agents/`)下的一个 `*.md` 文件,文件名必须等于 `id`。前面是 YAML frontmatter后面是角色正文role body
```md
---
id: researcher
description: Research primary sources
llm_profile: research # 引用 config.json 顶层 agents 的 key
# 或者内联指定WebUI 首选):
# provider: openai
# model: gpt-4.1
tools: [file_read, file_search, web_fetch]
delegates: [coder] # 下一级委托目标;缺省=general-purpose[]=不可,["*"]=任意
skills: [summarize] # get_skill 的作用域
limits:
timeout_secs: 900
max_iterations: 24
max_result_chars: 16000
signal: # 可选:启用 emit_signal
delivery: queue # queue | steer
---
# Role
只返回有证据支撑的结论。
```
关键校验(`src/agent/definition.rs`
- `deny_unknown_fields`frontmatter 出现未知字段直接拒绝。
- `enabled`(默认 `true`):单个定义的开关;`enabled: false` 的定义保留在磁盘供管理 UI 查看,但不进入活动 catalog。
- `id` 必须匹配文件名、小写字母开头、长度 ≤64且保留 `root/main/default/general`
- `llm_profile` 或内联 `provider`+`model` 二选一必填;内联的 provider 和 model 必须成对出现。
- 文件必须是非符号链接的普通文件≤256KBrole body 非空且 ≤64K 字符。
- 计算 `definition_hash`canonical frontmatter + role body 的 SHA256随 run 持久化,用于识别运行代内定义是否变更。
内置 `general-purpose` 定义在首次启动释放到 `~/.picobot/agents/`,作为 `delegates` 缺省时的默认委托目标。
### 2.2 Catalog 与委托图
`AgentCatalog` 每个运行代不可变。加载时把定义解析成 `AgentDefinition`(含解析后的 provider config并校验
- Provider profile 存在、工具名/Skill 名在注册表里、`delegates` 列表里显式列出的目标存在(`*` 与缺省不校验)。
- 任一无效 → 整代拒绝启动/热重载。
委托规则:
- **主 AgentROOT**:可委托给任意具名子 Agent`root_can_delegate` 只判断目标是否在 catalog 里)。
- **子 Agent 的下一级**:由定义里的 `delegates` 决定,语义如下:
| `delegates` | 含义 |
|-------------|------|
| 缺省(不写该字段) | 仅可委托内置 `general-purpose` |
| `[]` | 不可继续委托 |
| `["*"]` | 可委托任意子代理(除自己) |
| `["a", "b"]` | 按列表指定 |
self 与祖先链上的 Agent 在 `resolve_agent` 时永远被拒绝(循环检测)。`can_delegate(caller, target)` / `root_can_delegate(target)` / `delegate_targets(caller)` 是这套语义的唯一实现点。
### 2.3 Run 与执行上下文
一次委托 = 一个 run持久化在 `agent_runs`。执行上下文 `AgentExecutionContext``src/agent/run.rs`)携带:
| 字段 | 含义 |
|------|------|
| `root_session_id` | 整棵委托树所属的会话 |
| `run_id` / `execution_id` | run ID 与「执行尝试」ID首次两者相同`execution_id` 用于条件状态转换,迟到的旧执行写不进状态 |
| `parent_run_id` / `ancestry` | 父 run 与祖先链(用于循环检测、授权) |
| `depth` | 委托深度≥1 |
| `budget` | 剩余 run 数与剩余深度 |
| `tree_runs` | 整棵树的共享原子计数,强制 `max_runs_per_tree` |
| `signal_contract` | 信号契约(`None` 表示该 run 不能发信号) |
| `cancellation` | CancellationToken父取消会向子级联 |
`child()` 构造子上下文:深度 +1、预算 -1、`parent_run_id` 设为父 run、`ancestry` 追加目标,并**共享** `tree_runs`
## 3. 执行模型
### 3.1 foreground同步等待
`delegate` 工具 `mode=foreground` 时,调用方(主 Agent 或某个子 Agent阻塞等待结果
1. 先解析所有 target任何非法请求在写库之前失败不留孤儿行
2. 一次性持久化所有 run`accept_agent_runs`status=queued
3. 若调用方是具名 Agent把父 run 置为 `waiting_children`(等待期间不占 step permit
4. 并发执行(`join_all`),结果**保持请求顺序**返回。
5. 每个 run 各自 commit terminal父 run 恢复 `running`
结果直接作为工具返回值回到模型,同时完整结果持久化到 `agent_runs.result`
### 3.2 background异步 + 收件箱)
`mode=background` 时,`delegate` 只做「接纳」就立即返回 run ID真正执行在后台 runner 里,结果通过 durable inbox 送达。这是结果传递机制最复杂的部分,见第 4 节。
只有 ROOT 能发起 background子 Agent 发起的 background、以及 background 里再 background 都不开放。
## 4. 结果传递机制(重点)
foreground 的结果是「调用即返回」,没有跨 Turn 的传递问题。**真正需要设计的是 background 的结果如何可靠地回到主 Agent**——因为 background runner 跑在后台,主 Agent 可能正在忙别的 Turn甚至已经结束上一个 Turn。
核心思路:**结果不是直接通知 Channel而是落进一个持久化收件箱由主 Agent 的「续接 Turn」continuation Turn读取并汇入会话**。
```
background runner
└─ terminal commit原子事务
├─ agent_runs → 终态execution_id + generation 条件)
├─ plan item 完成(若有)
└─ 预留槽 → agent_inbox_events 完成事件completion
session 收件箱 workerqueue lane
│ claim(pending→leased)
continuation Turnhidden 触发 + 只读工具集)
│ commit_continuation_turn原子
可见的 assistant 结果 + 事件 consumed
```
### 4.1 完成槽预留(保证不丢)
接纳 background 批次时,先对每个 run 预留一个完成槽:
```sql
UPDATE agent_session_state
SET reserved_completion_slots = reserved_completion_slots + ?,
revision = revision + 1, updated_at = ?
WHERE root_session_id = ?
AND pending_event_count + reserved_completion_slots + ? <= ?
RETURNING revision;
```
- 这是**条件更新**:只有 `pending + reserved + 新增 ≤ 上限` 时才成功,避免并发 `COUNT(*)` 漂移。
- 预留成功后才持久化 run预留失败则整批拒绝。
- 意义background 的完成事件**永远占得住位置**,不会因为收件箱被 signal 打满而丢失。signal 只能在「未预留」的容量里插入(见 4.4)。
### 4.2 终态提交(单写者)
`commit_agent_terminal``src/storage/agent_run.rs`)在一个事务里完成:
1. `UPDATE agent_runs SET status=终态, result=?, error=?, usage...`,条件是 `WHERE id=? AND execution_id=? AND runtime_generation=? AND status IN ('queued','running','waiting_children')`。**命中 0 行 = 迟到的旧结果,直接丢弃(返回 `None`**。
2. 若 run 绑定了 plan item用同一个 `execution_id` 条件完成该子项。
3.`completion_slot_reserved`background把预留槽**转换成**一条 completion 事件写入 `agent_inbox_events`status=completed/failed/timed_out/cancelled/interrupted携带 result/error/signal_ids
三步同一事务提交:要么全部生效,要么全部回滚,**内存与数据库永不分叉**。
### 4.3 收件箱事件状态机
`agent_inbox_events` 里每条事件signal 或 completion
```
pending ──claim──▶ leased ──admit(steer)──▶ admitted ──▶ consumed
▲ │ │
└──release(backoff)◀──────────────────────────┘
pending/leased/admitted ──supersede──▶ superseded显式取消
pending/leased/admitted ──dead_letter──▶ dead_letter归档/删除/超限)
```
- **claim**`pending → leased`,带 `lease_token` + `lease_until` + `attempt_count+1`。claim 条件 `status='pending'`,天然防双租。
- **admit**(仅 steer`leased → admitted`,绑定 `admitted_turn_id`
- **release**`leased/admitted → pending`,带重试 `next_attempt_at`。lease token 防止别的 worker 已消费后又被释放。
- **consume**:在续接 Turn 提交事务里原子完成。
- **supersede**:显式取消 run 时,把其未消费 signal 置为 supersededcompletion 永不 supersede
- **dead_letter**:会话归档/删除、或投递超过 `max_inbox_delivery_attempts` 时;最多发一次有界 system fallback 提示。
### 4.4 两条投递 lanequeue 与 steer
事件按 `delivery` 分两种语义(`SignalDelivery`,定义在 `signal.delivery`
| lane | 语义 | 到达方式 |
|------|------|----------|
| `queue` | 排队到**下一个** Turn | 收件箱 worker 在调度边界把事件变成续接 Turn |
| `steer` | 注入**当前活动** Turn 的安全边界 | 两阶段准入claim → 预留 mailbox 槽 → DB admit(turn_id) |
**steer 两阶段准入**(任何一步失败都必须无损回退):
1. claimpending→leased拿到 lease token
2. 在 TurnMailbox 的 agent lane 预留一个槽(容量独立于 user lane
3. `admit_inbox_event`leased→admitted绑 turn_id
4. 同一 Turn/代激活。
失败路径claim 失败 → 释放 lease 并 wake queue lanemailbox 满 → 释放 lease 回 pending等 queue lane 以 continuation 送达。**steer 可靠退化为 queue**:当活动 Turn 关闭时,已 admit 的 steer 事件按 lease token 释放回 pending绝不静默丢弃。
### 4.5 续接 Turncontinuation Turn
queue lane 的 worker claim 一批事件后,把它们合成为一条**隐藏的触发消息**`build_continuation_trigger`completion 事件渲染为「后台任务完成Agent、状态、Run ID、任务、结果/错误signal 渲染为「后台信号(级别、摘要)」。
续接 Turn 的特殊性:
- 触发消息 `client_visibility=hidden``turn_origin=agent_continuation`——**不进客户端历史、不进 Channel 投递、只供模型回放**。
- 工具集受限为只读:`file_read/file_search/content_search/web_fetch/calculator/agent_task`。续接 Turn 不能写文件、发消息、再委托、调度。
- `commit_continuation_turn` 在**一个事务**里写 hidden trigger + 可见 assistant/tool 消息 + usage + 事件 consume + session 计数,客户端永远不会看到「半成品续接」。
`requires_continuation=false` 的完成事件(如 `/stop` 产生的 cancel 完成)直接写成 consumed不触发续接。
### 4.6 唤醒与公平调度
事件 commit 成功后Coordinator 通过 `AgentInboxNotifier` 做一次**尽力而为**的 wake弱引用、late-bound避免与 SessionManager 形成强引用环)。**wake 丢失不是错误**durable inbox 是唯一事实源worker 有周期性重新 claim 的兜底。
公平调度:空闲(无用户积压)时 due 事件立即 claim完成即返回忙碌时连续处理 `max_user_turn_burst_before_inbox` 个用户 Turn 后,或最老 pending 事件等待超过 `max_inbox_wait_secs`,下一个调度项必须是一批 inbox 事件。当前活动 Turn 从不被 queue 事件抢占。
### 4.7 emit_signal信号
只在定义声明 `signal` 块时run 才会被注入 `emit_signal` 工具。契约字段总量、单条字节、最小间隔、burst、severity allowlist、dedupe 冷却窗、JSON 深度)全部由工具与 Coordinator 强制,模型只提供 key/severity/summary/details/dedupe_key。
- 结构校验severity 是否在 allowlist、summary/key 长度、payload 大小与深度)在工具内做,不依赖模型自觉。
- 频率限制是每 run 内存态(工具实例为单个 run 的 registry 创建)。
- Coordinator `emit_signal` 再校验run 存在、`execution_id` 匹配、非终态;`insert_agent_signal``pending + reserved + 1 ≤ 上限` 下条件插入,并做冷却窗 dedupe`run_id + event_type + event_key` 唯一)。
- 信号 ID 记入 `emitted_signals`,最终写进该 run 的 completion 事件 payload供主 Agent 交叉核对。
## 5. 持久化模型
三张 agent 表schema v8
- **`agent_runs`**:每次委托一行。含 run id、root session、父子、caller 身份(`caller_agent_id`/`caller_scope_id`、agent/definition 快照(`definition_hash`、provider/model、mode、depth、task/context、budget、signal 契约快照、statusqueued/running/waiting_children/终态、result/error、usage、`execution_id``completion_slot_reserved`、时间线、revision。`execution_id` 唯一索引。
- **`agent_inbox_events`**:收件箱。`run_id`NOT NULLFK、event_typesignal/completion、event_key去重键、deliveryqueue/steer、requires_continuation、severity、payload、status、attempt/lease、`UNIQUE(run_id, event_type, event_key)`
- **`agent_session_state`**:每根会话一行,权威容量计数(`pending_event_count` + `reserved_completion_slots` + 单调 `revision`)。所有容量增减都是条件 UPDATE。
结果不复制大文本:完整结果在 `agent_runs.result`inbox payload 只放有界摘要/元数据。
## 6. 取消与恢复
- **取消 run**`cancel_run`):先按树位置授权、确认非终态,然后 `cancel_agent_run_with_completion`(写终态 + 若预留槽则转换 completion 事件),取消 CancellationToken并 supersede 未消费 signal。`suppress_continuation=true` 时 completion 写成 consumed`/stop`/归档后不再续接)。
- **取消会话**`cancel_session`):取消该会话所有非终态 run完成事件写 consumed。
- **启动恢复**`recover_agent_state`):旧运行代的 queued/running/waiting_children → interruptedbackground 转换 failure completion过期 lease → pending 带 backoff、超限 → dead_letter按行重算容量计数差异修复并告警。
## 7. 授权
run ID 不是凭证。ROOT 可访问本会话所有 run具名 Agent 只能访问自己的 run 及其**后代**(沿 `parent_run_id` 向上走到自己)。其他会话一律拒绝读取/取消。
## 8. 易出错点总结
实现过程中反复踩坑的地方,按重要程度排序:
1. **execution_id 条件更新**。终态、running、信号写入都必须带 `execution_id`(和 generation条件命中 0 行 = 迟到旧结果,静默丢弃。否则一个超时后被重试的旧 runner 可能覆盖新终态。
2. **收件箱容量 = pending + reserved且必须条件 UPDATE**。signal 不能挤掉 background 的完成预留;用无锁 `COUNT(*)` 推断会并发漂移,必须在同一写事务里 `UPDATE ... WHERE pending+reserved+n ≤ limit RETURNING`
3. **完成槽预留 → 完成事件转换必须在终态提交的同一事务里**。一旦分开,崩溃就会留下「已预留但永远不产出 completion」的槽。
4. **wake 是尽力而为,不是正确性来源**。任何依赖「wake 一定到达」的逻辑都会在丢 wake 时漏投。事实源是 durable inboxwake 只加速,周期性重新 claim 兜底。
5. **steer 两阶段准入的无损性**。claim → mailbox 预留 → DB admit 任何一步失败都要释放 lease 并 wake queue laneTurn 关闭时已 admit 的 steer 要按 lease token 放回 pending退化为 queue。绝不静默丢弃。
6. **输入归属互斥**steer / 下一 Turn FIFO / `/stop`)。一条输入要么属于当前活动 Turn要么进下一 Turn FIFO`/stop` 两者都丢弃;三者必须无损且互斥。
7. **委托循环、预算、深度、树 run 上限要在解析阶段就拦下**`ancestry` 判环、`budget` 判耗尽、`reserve_tree_run` 用共享原子计数强制 `max_runs_per_tree`
8. **fail-closed 顺序:先解析所有 target 再写库**。否则批量里一个非法 target 会留下前几个 run 的孤儿行。
9. **spawn 失败的补偿**。TaskSupervisor 拒绝 spawn如关机要取消该 run 并释放其完成槽,否则槽永远占着。
10. **子 Agent 管理器对 Coordinator 用 `Weak`**。Coordinator 拥有 managermanager 若强引用 coordinator 会成环。
11. **结果两段式:模型看到截断、库存全量**`max_result_chars` 截断返回给模型的内容并提示「用 agent_task get_result 查全量」;`full_content` 原样持久化。截断要按 `floor_char_boundary`,否则 UTF-8 边界 panic。
12. **MIN 聚合无行时返回 NULL 被解成 0**。曾导致 worker 空转;`oldest_pending_due`/`next_pending_due_at``Option<Option<i64>>` 显式区分「无行」与「值为 0」。
13. **claim 的确定性与防双租**。claim 按 `created_at, id` 排序保证确定性lease token 让 release 只作用于本 worker 租下的事件,防止把别人已消费的又放回 pending。
14. **idempotency_key 的部分唯一索引**`UNIQUE(root_session_id, caller_scope_id, idempotency_key) WHERE idempotency_key IS NOT NULL`——`NULL` 不参与去重,否则 SQLite 里所有 NULL 会互相冲突。
15. **signal 冷却窗去重键要含时间窗**`event_key = signal:{key}:{now/cooldown}`)。否则「窗口内去重、窗口外再发」无法表达。
16. **父 run 的 `waiting_children` 状态必须对称恢复**。父等待时释放 step permit子结束后要 `restore_agent_run_running`,否则父 run 卡在 waiting 状态。
## 9. 配置项
`agent_orchestration``src/config/mod.rs`
| 键 | 默认 | 含义 |
|----|------|------|
| `definitions_dir` | `agents` | 定义目录(相对 config.json 所在目录) |
| `max_tree_depth` | `4` | 委托树最大深度 |
| `max_runs_per_tree` | `16` | 一棵树内最大 run 数 |
| `max_concurrent_runs` / `max_concurrent_runs_per_session` | `6` / `4` | 全局/每会话并发 run 上限 |
| `max_pending_inbox_events_per_session` | `128` | 每会话 pending + reserved 上限 |
| `max_inbox_delivery_attempts` | `8` | 投递尝试上限(超过进 dead-letter |
| `max_user_turn_burst_before_inbox` | `4` | 公平调度:连续处理多少个用户 Turn 后必须清 inbox |
| `max_inbox_wait_secs` | `30` | 最老事件等待上限(秒) |