多个 Agent 怎样通信、等待与中断
从 MultiAgentV2 的 send_message、followup_task、InputQueue、wait_agent、terminal-turn 通知与 interrupt 出发,解释消息到达、启动 turn、收到完成通知和关闭旧版 Agent 为什么是不同状态变化。
第 32 章已经把一个子 Agent 放进独立 Thread:它有自己的 ThreadId、AgentPath、history 和运行状态。独立以后,新的问题才出现。父 Thread 怎样把消息交给它?消息到了以后是否马上开新 turn?父 Thread 等待时究竟在等谁?一个 child turn 完成,和整个 child Thread 被关闭,又是不是同一件事?
MultiAgentV2 没有用一个“等待所有协作者”的抽象掩盖这些区别。固定版本把协调拆成一组很窄的动作:
send_message -> queue a mailbox item
followup_task -> queue a mailbox item with trigger_turn=true
wait_agent -> wait for Mailbox / Steer / Timeout activity
child complete -> try to queue a Result item to the parent, trigger_turn=false
interrupt -> interrupt the target's current turn
internal shutdown_agent_tree -> close the target and its live descendants
最后一项是内部控制动作,不是 MultiAgentV2 暴露给模型的 tool;V2 的公开动作列表里没有 close_agent 或 shutdown_agent_tree。本章保留它,只为解释 live subtree 的关闭边界。
读这一章最重要的纪律是:wait_agent 不是 join。它可以因为一条普通消息、一条成功投递的完成通知、新的用户输入或超时而返回。返回只说明“等待点附近发生了活动”,并不说明某个指定 Agent 已经结束,更不说明所有子 Agent 已经一起结束。
同一个通信结构,两种 delivery mode
send_message 和 followup_task 共用 handle_message_string_tool。它们先把 target 解析成 Thread,再确认这条 Thread 在 Agent registry 中可识别,必要时恢复 V2 residency,最后构造 InterAgentCommunication。真正不同的只有 MessageDeliveryMode:QueueOnly 写入 trigger_turn:false,TriggerTurn 写入 trigger_turn:true。
本节源码依据(3 处)
共享 handler 仍然保留若干治理边界。空消息被拒绝;followup_task 不能把 root 当 target;缺少 agent_path 的 target 不能被通信层猜测;发送成功后才发出 SubAgentActivityKind::Interacted。这意味着 activity event 是一次已提交交互的投影,不是消息内容的第二份 owner。
本节源码依据(2 处)
协议对象本身也把路由和唤醒分开。InterAgentCommunication 保存 author、recipient、其他收件人、content、可选 encrypted content 和 trigger_turn。同一条内容可以只进入 mailbox,也可以请求新 turn;“消息是什么”与“收到后是否立即运行”不是同一个字段。
本节源码依据(1 处)
AgentControl 在投递前只对会启动 turn 的通信检查 execution capacity。queue-only 消息不会因此占用一个新的执行槽;trigger_turn:true 且目标当前没有 active turn 时,才进入 V2 subagent 的容量限制。目标已有 active turn 时,通信可以被交给现有执行,而不是再记一个并发 turn。
本节源码依据(2 处)
mailbox 负责排队,activity 只负责叫醒等待者
InputQueue 是 session-scoped mailbox owner。它把 InterAgentCommunication 放进 VecDeque,再通过 Tokio watch channel 把 activity 更新成 Mailbox。用户 steer 则写进 turn-local pending input,并把 activity 更新成 Steer。队列保存内容,watch channel 只保存“最近发生了哪类活动”的信号,两者不能互换。
本节源码依据(2 处)
订阅时还有一个明确优先级:先检查当前 turn 是否已经有 user input,有则把 pending activity 设为 Steer;否则再检查 mailbox。这个顺序防止一个已经等待处理的用户输入被旧 mailbox item 遮住。它仍然不代表 mailbox 内容消失,真正的内容要等 get_pending_input 在 delivery phase 允许时 drain。
本节源码依据(2 处)
这套结构解释了为什么 wait_agent 不返回消息正文。等待工具先订阅 activity,然后等待三个 outcome:MailboxActivity、Steered、TimedOut。结果 JSON 只有 message 和 timed_out。它没有 target 参数,没有 AgentStatus map,也没有 drain mailbox;真正内容会在后续 input projection 进入模型上下文。
wait_agent outcome | 触发来源 | 返回文案 | 能否推出 child 已结束 |
|---|---|---|---|
| MailboxActivity | 已排队或新到达的任意 mailbox item | Wait completed. | 不能 |
| Steered | 当前 turn 收到新的用户输入 | Wait interrupted by new input. | 不能 |
| TimedOut | deadline 或 activity channel 关闭 | Wait timed out. | 不能 |
本节源码依据(3 处)
V1 的同名工具确实曾按 Agent final status 等待。V2 的 tool description 已经把语义改成“wait for a mailbox update from any live agent”。把 V1 的 status map 解释搬到 V2,是最容易制造错误心智模型的兼容性混读。
本节源码依据(1 处)

这张 macOS 终端图记录的是本章早期的两项 layer
test:subagent_notification_is_included_without_wait 与
steer_interrupts_wait_agent_and_is_sent_in_follow_up_request,实际结果为
2 tests run: 2 passed, 2967 skipped。当前正文的三项 MultiAgentV2
合同测试另行通过 just test 校准,截图没有展示第三项
shutdown_agent_tree_closes_live_descendants。图不能把 wait_agent 解释成等待所有子 Agent
终止的 join,也不能证明跨进程调度、消息一定被模型正确理解或整个 Agent subtree 已关闭。
child 完成只是父 mailbox 的一种消息
MultiAgentV2 不安装 detached completion watcher。child session 发送 TurnComplete 或 TurnAborted terminal event 后,会调用 maybe_notify_parent_of_terminal_turn,但调用函数不等于一定通知 parent。它先确认当前 turn 是 V2 的 ThreadSpawn child,再把 event 映射成 AgentStatus 并经过 is_final。TurnComplete 会得到 Completed;普通错误型 abort 会得到 Errored,二者才进入 completion envelope。Interrupted 与 BudgetLimited abort 通常映射成非 final 的 Interrupted,会在这里返回,不投递 Result。
进入投递路径的 completion 使用 AgentCommunicationKind::Result,trigger_turn 为 false。投递成功时,它可以唤醒正在运行的 wait_agent,却不会凭自己启动新的 parent turn。若 send_inter_agent_communication 失败,当前实现只记一条 debug log 后返回,没有 durable retry、ack 或补偿队列;child 已完成不等于 parent 一定收到 Result。
detached completion watcher 是 legacy / 非 V2 路径。spawn 和 resume 只有在 multi_agent_version != V2 时才安装它;把 watcher 的 status subscription 搬到 V2,会把两个版本不同的完成时机写成同一套实现。
本节源码依据(4 处)
“final” 在这里是一次 child turn 的状态,不等于 child 从此不可再用。followup_task 可以给已完成的 child 再触发一轮;测试在 parent delivery 成功的 fixture 中,让同一个 worker 完成第一轮、收到 followup、再完成第二轮,并断言父 Thread 各收到一次 completion notification。这也是测试名里的 on_every_turn,不是“每个 Agent 只通知一次”,更不是可靠投递证明。
本节源码依据(1 处)
把这些状态放进一张时间图,wait_agent 的停止点会更清楚:
sequenceDiagram
accTitle: MultiAgentV2 的消息、等待与完成通知时间线
accDescr: 父 Agent 可先用 send_message 只排入消息,也可用 followup_task 排入并触发 child turn;wait_agent 监听父 InputQueue 的 Mailbox 或 Steer 活动,child 的 final completion 会尝试投递不触发父 turn 的 Result 消息,只有投递成功才产生 MailboxActivity,因此 wait 返回并不等于 join
participant P as Parent Thread
participant PM as Parent InputQueue
participant C as Child Thread
P->>C: send_message (trigger_turn=false)
P->>C: followup_task (trigger_turn=true)
C->>C: run child turn
P->>PM: wait_agent subscribes
C-->>PM: Result notification (if delivery succeeds)
PM-->>P: MailboxActivity
Note over P: wait returns, mailbox content and child lifecycle remain separate
C->>C: later followup may start another turn
interrupt、close 与 capacity 不要压成一个 cancel
interrupt_agent 先读取 previous status,再向目标发送 Op::Interrupt。它拒绝 root 与 self target,成功后只发 Interrupted activity。tool spec 甚至直接写明:Agent remains available for messages and follow-up tasks。中止的是当前 turn,不是 Agent identity,也不是 Thread history。
本节源码依据(2 处)
关闭走另一条、带版本边界的路径。close_agent 是 legacy / V1 model-facing surface;MultiAgentV2 暴露的是 spawn_agent、send_message、followup_task、wait_agent、interrupt_agent 与 list_agents,没有 close_agent。legacy close_agent 会尝试把非 ephemeral spawn edge 标成 Closed,再调用 shutdown_agent_tree。后者只沿 in-memory live spawn tree 找 descendants,逐个发送 shutdown。持久 edge 状态与 live Thread removal 是两个动作;ephemeral Agent 甚至没有要更新的持久 edge。
本节源码依据(1 处)
| 动作 | 改变什么 | 不改变什么 |
|---|---|---|
send_message | mailbox 多一条 queue-only 消息 | 不保证开 turn,不等待读取 |
followup_task | mailbox 多一条 trigger-turn 消息 | 不保证这一轮成功完成 |
wait_agent | 当前等待点结束 | 不 drain 内容,不 join,不关 Agent |
| completion | 投递成功时 parent mailbox 多一条 Result | 不保证送达,不自动启动 parent turn,不关闭 child |
interrupt_agent | 目标当前 turn 收到 Interrupt | 不删除 Thread、history 或持久 graph edge |
legacy close_agent | 持久 edge 尽力标 Closed,并关 live tree | 不是 V2 model tool;不抹掉历史谱系 |
指定测试:三条契约分别成立
本章在第六部导言规定的 disposable archive / detached worktree 中,用项目标准 just test 运行三个 exact test。这个 wrapper 会设置 RUST_MIN_STACK=8388608,然后用 nextest 选择目标;命令为:
: "${ARCHIVE_CODEX_RS:?先执行第六部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-core -E 'test(multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn) | test(multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity) | test(shutdown_agent_tree_closes_live_descendants)'
本次由固定 commit 导出的 disposable 副本的真实结果是:
Starting 3 tests across 4 binaries
PASS multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity
PASS multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn
PASS shutdown_agent_tree_closes_live_descendants
Summary: 3 tests run: 3 passed
本节源码依据(3 处)
这里保留一个环境事实:直接运行计划中的裸 cargo test 时,followup 与 subtree 两项在本机默认 test-thread stack 下真实发生了 stack overflow;mailbox 一项通过。加上项目 recipe 使用的 8 MiB stack 后,三项全部执行并通过。这个校准不能被省略成“裸 cargo 无条件通过”,更不能把 stack overflow 或 running 0 tests 写成成功。
三项测试仍然只证明各自的组件边界:第一项证明在 fixture 的 parent delivery 成功时,每轮都产生 completion notification;第二项证明 mailbox activity 会结束 wait;第三项证明 live descendants 收到 shutdown。它们没有覆盖 completion send failure,也没有证明 durable retry、ack、跨进程调度或消息一定被某个模型正确理解。
协作边界到这里结束
mailbox delivery、turn trigger、steer、timeout、interrupt / subtree shutdown 到这里已经闭合。Realtime 不消费这组协调状态,也不是 child Thread 完成后的下一阶段。它紧接在本章之后,只因为 WebSocket、流式事件和长生命周期 manager 很容易让两套会话被误读成同一个东西。
下一章《普通 Responses 与 Realtime 为什么是两套会话》直接比较两套 transport/session owner。普通 Responses WebSocket 负责 inference request 的连接复用与 fallback;Realtime 拥有自己的 audio/text queue、start/close 生命周期和 handoff。两者只在源码明确的 context 与 text routing 边上相遇。