OpenAI Codex · 多 Agent 图

多 Agent 是一张要持久化的图

派出去的是节点,边一出生就是 Open。信先入队,followup 才叫醒。关掉的是边,历史还在。

课程目标读完能说清三件事:子 agent 是图上的节点,边只有 Open 和 Closed;send_message 只入队,followup_task 才叫醒;关机卸运行时,关边才从图里除名。第二天还能不能接着说话,先查边。
先玩一遍 · 派生一个探索者
从 /root 派出 Hypatia,看信怎么走、结果怎么回流
收尾
关机只卸运行时,边仍是 Open。关边才写成 Closed。切一下再播,重启后的名单不一样。
根会话 /root 空闲,可以派孩子
还没派出 等待 spawn 图上还没有这个节点
SQLite 边卡
还没有 thread_spawn_edges 记录。
信箱与注册表
MESSAGE FOLLOWUP RESULT
注册表空着。恢复只沿着 Open 边把身份贴回来。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 计算下一层深度,写入 ThreadSpawnregistry.rs L87
  2. 用 task_name 拼出绝对路径 /root/explore_authmulti_agents_common.rs L117
  3. 从科学家名单抽出外号 Hypatiacontrol/spawn.rs L32
  4. 非临时会话立刻 upsert 一条 Open 边control.rs L776
  5. send_message 按 QueueOnly 组包,只入队message_tool.rs L103
  6. 信箱是会话级队列,入队后发 Mailbox 活动input_queue.rs L127
  7. followup_task 把 trigger_turn 打开,这才叫醒handlers.rs L98
  8. 子 turn 结束,给父发 Result,不叫醒session/mod.rs L1977
  9. 关机不改边;关边只标目标自己的入边 Closedlegacy.rs L6
  10. 重启只把 Open 后代的身份装回注册表control/spawn.rs L158
点播放,看一个探索者怎么长到图上,信怎么走,结果怎么回流。
图先于运行时节点一出生就有路径、外号和一条 Open 边。运行时可以卸掉,边还在,历史还在 rollout 里。
信和叫醒是两件事MESSAGE 只入队。FOLLOWUP 才开工。RESULT 飞回父信箱,默认不抢当前轮。
收尾决定明天还在不在切换上面的收尾再播一遍,看重启后注册表还认不认这个孩子。
教学示意:外号固定为 Hypatia,路径固定为 /root/explore_auth,用于展示图、信箱和边状态。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 父子关系做成有状态的边
它解决什么问题

你让主会话派一个探索者去查 auth 模块。模型调用 spawn_agent,工具回了一句任务名 /root/explore_auth,外号 Hypatia。过几分钟点 wait_agent,信箱里躺着一份 FINAL_ANSWER

第二天打开同一条 thread。子会话的运行时已经卸掉了。系统如果只记得昨天发生过一次调用,这条路径就找不到。你再发 send_message,控制面会报 live agent path not found

思路是什么

Codex 把父子做成有向边。边只有两个值。Open 表示还能当打开的 spawned agent 恢复。Closed 表示从图的视角已经关掉。序列化是 openclosed

出处:codex-rs/agent-graph-store/src/types.rs 第 4 至 12 行

图存在 SQLite 的 thread_spawn_edges 表。child_thread_id 是主键,一个孩子不能挂两个父。同一孩子再 spawn 一次,父和状态都会被新值盖住。会话正文不进这张表。子 agent 的模型上下文仍走自己的 rollout。图只回答谁生了谁,这条边现在开还是关。

出处:codex-rs/state/migrations/0021_thread_spawn_edges.sql 第 1 至 8 行

非临时会话在线程建出来之后立刻 upsert 一条 Open 边。写入失败只打 warn。子 thread 已经在跑,图可以稍后补。补写用 ON CONFLICT DO NOTHING,不会把已经 Closed 的边改回去。

出处:codex-rs/core/src/agent/control.rs 第 767 至 780 行

列后代时,过滤条件作用在走过的每一条边上。Some(Open) 只沿着 Open 走。父边已经 Closed 的子树,就算孙边仍是 Open,也不会被列出来。

出处:codex-rs/agent-graph-store/src/store.rs 第 49 至 54 行

路径才是寻址键,外号是给人认的。根是 /root。相对名接到当前路径后面。... 被拒绝,所以不能靠相对路径爬到兄弟。角色可以收能力,不能替换父会话的权威。内置活角色是 defaultexplorerworkerexplorer.toml 是空文件。

出处:codex-rs/core/src/agent/role.rs 第 1 至 4 行

spawn_agent 新 child thread 路径 + 外号 upsert Open 边 SQLite child rollout JSONL 谁生了谁,开还是关 会话正文不进边表
一次 spawn 之后:身份进注册表,边进 SQLite,正文进 rollout。
为什么长期成立

重启之后必须能问:这个孩子还算活着吗。只记一次 spawn 事件回答不了。Open 才能进恢复列表。Closed 从子树遍历里消失。child_id 做主键,图保持树,遍历可以按深度 BFS。换个语言重写,最小形态仍是一张三列表:parent、child、status。

思路二 · 通信和叫醒分开
它解决什么问题

子 agent 转完了,要回一封 FINAL_ANSWER。如果这封信自带叫醒,父正在写用户看得见的最终答案时,会被子结果强行开一轮。用户看到半截话,再加一份突然插进来的完成通知。

思路是什么

通信种类有四个标签:Spawn、Message、Followup、Result。它们是 OTEL 用的标签。协议侧只有一份 InterAgentCommunication,靠 trigger_turn 区分要不要叫醒。

send_message 是 QueueOnly,只入队。followup_task 是 TriggerTurn,才叫醒。空消息直接拒。followup_task 不能打根节点。

出处:codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs 第 11 至 24 行

处理函数先入队,再决定要不要开工。trigger_turn 为假时,信继续躺着。只有它为真,或会话还有未完成的 durable sleep,才去开工。V2 的完成通知是 Result,trigger_turn 为假。父如果正在说话,这封信按信箱相位排队。

出处:codex-rs/core/src/session/handlers.rs 第 89 至 99 行

信箱是会话级队列。用户插话进 pending_input,子邮件进 mailbox_pending_mails,两条槽。兄弟之间只要用绝对路径,例如 /root/worker_b,就可以互发。相对名 worker_b 会接到自己后面,变成自己的孩子。

出处:codex-rs/core/src/session/input_queue.rs 第 76 至 80 行

send_message QueueOnly followup_task TriggerTurn 子 turn 结束 Result 父或子的信箱 先入队,再看 trigger_turn 躺着,等下一轮 MESSAGE / RESULT 开工,maybe_start_turn 只有 FOLLOWUP 走这里
三种信都进信箱。只有 followup 打开 trigger_turn,完成通知不抢当前轮。
为什么长期成立

叫醒权是稀缺的。谁能开一轮,谁就不能随便开。把投递和开工拆开,完成通知默认不叫醒,叫醒权留给 followup_task 和用户。换一套消息总线也用得上这根布尔:wake 还是只入队。

思路三 · 关边和关机是两件事
它解决什么问题

V2 驻留名额满了,会按 LRU 卸掉一个孩子。如果卸运行时顺便把边标成 Closed,这个孩子从 Open 子树消失。下次恢复找不到它。用户没关过它,系统自己把它除名了。

思路是什么

shutdown_live_agent 关掉活着的 agent,刷 rollout,发 Shutdown,从管理器摘掉 thread。边还是 Open。下次恢复仍会把它当活子树成员。

出处:codex-rs/core/src/agent/control/legacy.rs 第 6 至 8 行

close_agent 先把目标自己的入边标 Closed,再关机。后代的边不会在这里被标 Closed。父 turn 正常结束走 TurnComplete,不调用 close_agent。孩子继续跑,边保持 Open。

出处:codex-rs/core/src/agent/control/legacy.rs 第 48 至 58 行

V2 恢复分两步。先把 Open 后代的身份装回注册表,不重开运行时。真正有人 send_messagefollowup_task 时,才按 rollout 把 thread 挂回来。

出处:codex-rs/core/src/agent/control/spawn.rs 第 144 至 162 行

边是 Open 关机 关边 边仍 Open,运行时卸掉 边变成 Closed 恢复时身份装回注册表 恢复时这条边被滤掉
卸运行时不等于从图里除名。只有 close 才改 status。
关掉的是边,历史还在。
为什么长期成立

运行时活着和从图上除名是两件独立的事。驻留 LRU、进程重启、用户关窗口,都可能卸运行时。只有编排者明确 close,才写成 Closed。恢复时沿着 Open 边走。这条分账不依赖 Rust。

横向对比 · 子 agent 该抽象成什么

DSH:接缝优先,图是列举结果

DSH 把子 agent 做成可替换的 provider 接缝。SubagentProvidernamecapabilitiesinheritsParentContextstart。进程内 fork、Claude Code、Codex、ACP,都是同一张接口上的不同实现。

它也能列出孩子和后代,从活着的 session store 和可选的 persistence 只读枚举。没有一张 thread_spawn_edges 那样的 Open / Closed 边表。拓扑是 session header 的 origin: subagent 加上事后折出来的。换实现便宜,按边恢复要另做。

已核对源码 · 2026-08-22 · packages/subagent/subagent/src/types.ts 第 285 至 295 行 · DSH · Subagent 是一个 seam

Claude Code:工具调用加 transcript 侧链

模型面对的工具现名是 Agent。旧线名仍叫 Task,给权限规则、hook、恢复中的会话做兼容。Explore / Plan 是一次性的,父不会再续跑。

运行时给每个孩子发一个 agentId。没有单独的 spawn-edge 表。恢复靠读这个 id 对应的 transcript。父要列活孩子得扫侧链,没有按边过滤。Codex 多一张表、两套状态,换来重启后仍能按图说话。

已核对源码 · 2026-08-22 · restored-src/src/tools/AgentTool/constants.ts 第 1 至 4 行
课堂练习
01

Closed 父边下面的 Open 孙边还在吗

画一棵三层树:根到 A 为 Closed,A 到 B 为 Open。用 Some(Open)None 各列一次后代。B 会不会出现?

过滤条件作用在走过的每一条边上。然后对照 codex-rs/agent-graph-store/src/store.rs 第 49 至 54 行的注释,把两种结果写下来。

Takeaway:子 agent 是图上的节点。边只有 Open 和 Closed,会话正文走 rollout。send 只入队,followup 才叫醒,完成通知不抢当前轮。关机卸运行时,关边才从图里除名。第二天先查边,再决定要不要挂回运行时。