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

19 KiB
Raw Blame History

子 Agent 设计

本文说明 PicoBot 具名子 Agentnamed Agent的运行时设计重点是结果如何从子 Agent 传回主 Agent,以及实现过程中踩过的坑。文中描述以当前代码为准(src/agent/src/storage/agent_run.rssrc/storage/agent_inbox.rssrc/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

---
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_fieldsfrontmatter 出现未知字段直接拒绝。
  • enabled(默认 true):单个定义的开关;enabled: false 的定义保留在磁盘供管理 UI 查看,但不进入活动 catalog。
  • id 必须匹配文件名、小写字母开头、长度 ≤64且保留 root/main/default/general
  • llm_profile 或内联 provider+model 二选一必填;内联的 provider 和 model 必须成对出现。
  • 文件必须是非符号链接的普通文件≤256KBrole body 非空且 ≤64K 字符。
  • 计算 definition_hashcanonical frontmatter + role body 的 SHA256随 run 持久化,用于识别运行代内定义是否变更。

内置 general-purpose 定义在首次启动释放到 ~/.picobot/agents/,作为 delegates 缺省时的默认委托目标。

2.2 Catalog 与委托图

AgentCatalog 每个运行代不可变。加载时把定义解析成 AgentDefinition(含解析后的 provider config并校验

  • Provider profile 存在、工具名/Skill 名在注册表里、delegates 列表里显式列出的目标存在(* 与缺省不校验)。
  • 任一无效 → 整代拒绝启动/热重载。

委托规则:

  • 主 AgentROOT:可委托给任意具名子 Agentroot_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。执行上下文 AgentExecutionContextsrc/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. 一次性持久化所有 runaccept_agent_runsstatus=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 预留一个完成槽:

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_terminalsrc/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_reservedbackground把预留槽转换成一条 completion 事件写入 agent_inbox_eventsstatus=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归档/删除/超限)
  • claimpending → leased,带 lease_token + lease_until + attempt_count+1。claim 条件 status='pending',天然防双租。
  • admit(仅 steerleased → admitted,绑定 admitted_turn_id
  • releaseleased/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_eventleased→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_triggercompletion 事件渲染为「后台任务完成Agent、状态、Run ID、任务、结果/错误signal 渲染为「后台信号(级别、摘要)」。

续接 Turn 的特殊性:

  • 触发消息 client_visibility=hiddenturn_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_signalpending + reserved + 1 ≤ 上限 下条件插入,并做冷却窗 deduperun_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_idcompletion_slot_reserved、时间线、revision。execution_id 唯一索引。
  • agent_inbox_events:收件箱。run_idNOT 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.resultinbox payload 只放有界摘要/元数据。

6. 取消与恢复

  • 取消 runcancel_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_atOption<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_orchestrationsrc/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 最老事件等待上限(秒)