# 子 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>` 显式区分「无行」与「值为 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` | 最老事件等待上限(秒) |