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.
280 lines
19 KiB
Markdown
280 lines
19 KiB
Markdown
# 子 Agent 设计
|
||
|
||
本文说明 PicoBot 具名子 Agent(named 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 必须成对出现。
|
||
- 文件必须是非符号链接的普通文件,≤256KB;role 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` 列表里显式列出的目标存在(`*` 与缺省不校验)。
|
||
- 任一无效 → 整代拒绝启动/热重载。
|
||
|
||
委托规则:
|
||
|
||
- **主 Agent(ROOT)**:可委托给任意具名子 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 收件箱 worker(queue lane)
|
||
│ claim(pending→leased)
|
||
▼
|
||
continuation Turn(hidden 触发 + 只读工具集)
|
||
│ 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 置为 superseded(completion 永不 supersede)。
|
||
- **dead_letter**:会话归档/删除、或投递超过 `max_inbox_delivery_attempts` 时;最多发一次有界 system fallback 提示。
|
||
|
||
### 4.4 两条投递 lane:queue 与 steer
|
||
|
||
事件按 `delivery` 分两种语义(`SignalDelivery`,定义在 `signal.delivery`):
|
||
|
||
| lane | 语义 | 到达方式 |
|
||
|------|------|----------|
|
||
| `queue` | 排队到**下一个** Turn | 收件箱 worker 在调度边界把事件变成续接 Turn |
|
||
| `steer` | 注入**当前活动** Turn 的安全边界 | 两阶段准入:claim → 预留 mailbox 槽 → DB admit(turn_id) |
|
||
|
||
**steer 两阶段准入**(任何一步失败都必须无损回退):
|
||
|
||
1. claim(pending→leased,拿到 lease token)。
|
||
2. 在 TurnMailbox 的 agent lane 预留一个槽(容量独立于 user lane)。
|
||
3. `admit_inbox_event`(leased→admitted,绑 turn_id)。
|
||
4. 同一 Turn/代激活。
|
||
|
||
失败路径:claim 失败 → 释放 lease 并 wake queue lane;mailbox 满 → 释放 lease 回 pending,等 queue lane 以 continuation 送达。**steer 可靠退化为 queue**:当活动 Turn 关闭时,已 admit 的 steer 事件按 lease token 释放回 pending,绝不静默丢弃。
|
||
|
||
### 4.5 续接 Turn(continuation 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 契约快照、status(queued/running/waiting_children/终态)、result/error、usage、`execution_id`、`completion_slot_reserved`、时间线、revision。`execution_id` 唯一索引。
|
||
- **`agent_inbox_events`**:收件箱。`run_id`(NOT NULL,FK)、event_type(signal/completion)、event_key(去重键)、delivery(queue/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 → interrupted(background 转换 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 inbox,wake 只加速,周期性重新 claim 兜底。
|
||
5. **steer 两阶段准入的无损性**。claim → mailbox 预留 → DB admit 任何一步失败都要释放 lease 并 wake queue lane;Turn 关闭时已 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 拥有 manager,manager 若强引用 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` | 最老事件等待上限(秒) |
|