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.
19 KiB
子 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):
---
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)阻塞等待结果:
- 先解析所有 target(任何非法请求在写库之前失败,不留孤儿行)。
- 一次性持久化所有 run(
accept_agent_runs,status=queued)。 - 若调用方是具名 Agent,把父 run 置为
waiting_children(等待期间不占 step permit)。 - 并发执行(
join_all),结果保持请求顺序返回。 - 每个 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 预留一个完成槽:
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)在一个事务里完成:
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)。- 若 run 绑定了 plan item,用同一个
execution_id条件完成该子项。 - 若
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 两阶段准入(任何一步失败都必须无损回退):
- claim(pending→leased,拿到 lease token)。
- 在 TurnMailbox 的 agent lane 预留一个槽(容量独立于 user lane)。
admit_inbox_event(leased→admitted,绑 turn_id)。- 同一 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. 易出错点总结
实现过程中反复踩坑的地方,按重要程度排序:
- execution_id 条件更新。终态、running、信号写入都必须带
execution_id(和 generation)条件,命中 0 行 = 迟到旧结果,静默丢弃。否则一个超时后被重试的旧 runner 可能覆盖新终态。 - 收件箱容量 = pending + reserved,且必须条件 UPDATE。signal 不能挤掉 background 的完成预留;用无锁
COUNT(*)推断会并发漂移,必须在同一写事务里UPDATE ... WHERE pending+reserved+n ≤ limit RETURNING。 - 完成槽预留 → 完成事件转换必须在终态提交的同一事务里。一旦分开,崩溃就会留下「已预留但永远不产出 completion」的槽。
- wake 是尽力而为,不是正确性来源。任何依赖「wake 一定到达」的逻辑都会在丢 wake 时漏投。事实源是 durable inbox,wake 只加速,周期性重新 claim 兜底。
- steer 两阶段准入的无损性。claim → mailbox 预留 → DB admit 任何一步失败都要释放 lease 并 wake queue lane;Turn 关闭时已 admit 的 steer 要按 lease token 放回 pending(退化为 queue)。绝不静默丢弃。
- 输入归属互斥(steer / 下一 Turn FIFO /
/stop)。一条输入要么属于当前活动 Turn,要么进下一 Turn FIFO,/stop两者都丢弃;三者必须无损且互斥。 - 委托循环、预算、深度、树 run 上限要在解析阶段就拦下。
ancestry判环、budget判耗尽、reserve_tree_run用共享原子计数强制max_runs_per_tree。 - fail-closed 顺序:先解析所有 target 再写库。否则批量里一个非法 target 会留下前几个 run 的孤儿行。
- spawn 失败的补偿。TaskSupervisor 拒绝 spawn(如关机)时,要取消该 run 并释放其完成槽,否则槽永远占着。
- 子 Agent 管理器对 Coordinator 用
Weak。Coordinator 拥有 manager,manager 若强引用 coordinator 会成环。 - 结果两段式:模型看到截断、库存全量。
max_result_chars截断返回给模型的内容并提示「用 agent_task get_result 查全量」;full_content原样持久化。截断要按floor_char_boundary,否则 UTF-8 边界 panic。 - MIN 聚合无行时返回 NULL 被解成 0。曾导致 worker 空转;
oldest_pending_due/next_pending_due_at用Option<Option<i64>>显式区分「无行」与「值为 0」。 - claim 的确定性与防双租。claim 按
created_at, id排序保证确定性;lease token 让 release 只作用于本 worker 租下的事件,防止把别人已消费的又放回 pending。 - idempotency_key 的部分唯一索引。
UNIQUE(root_session_id, caller_scope_id, idempotency_key) WHERE idempotency_key IS NOT NULL——NULL不参与去重,否则 SQLite 里所有 NULL 会互相冲突。 - signal 冷却窗去重键要含时间窗(
event_key = signal:{key}:{now/cooldown})。否则「窗口内去重、窗口外再发」无法表达。 - 父 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 |
最老事件等待上限(秒) |