Claude Code 源码分析 by Codex (2) – 多 Agent 协作

Arc 发布于 2026-04-01 466 次阅读


前言

基于泄露的Claude Code最新版本:v2.1.88 ,使用GPT 5.4进行分析。不绕弯子,直接复制原始回答。

Claude Code CLI 多 Agent 协作机制深度研究

研究对象:claude-code-cli 仓库中与多 agent 协作直接相关的实现。

研究方法:以源码静态阅读为主,重点梳理 AgentToolrunAgentLocalAgentTaskSendMessageToolTeamCreateToolspawnMultiAgentinProcessRunnerteammateMailboxcoordinatorMode 等链路。

说明:本文关注“多 agent 如何协作”,不再展开记忆系统、普通单 agent REPL、MCP 生态的全量分析。本文结论主要基于源码结构与控制流,不包含完整运行时压测结论。

一句话结论

这个仓库里的“多 agent”并不是“给模型加一个 spawn_worker() 工具”这么简单。它实际上做了两套彼此相关、但定位不同的协作系统:

  1. 后台子代理系统(background subagent)
    适合“把一个具体任务交出去,让它异步做完,再把结果回传给主线程”。
  2. 团队队友系统(swarm teammate)
    适合“让多个常驻代理长期存在、反复收消息、彼此通信、等待继续工作”。

这两套系统共享的底层思想是:agent 不是一次性的函数调用,而是一个被 runtime 管理的执行体。这个执行体有自己的身份、上下文、转录、生命周期、权限、消息通道、可恢复能力,以及回流主线程的协议。

如果用最通俗的话讲:

  • 后台子代理更像“你临时外包出去的一次任务工单”。
  • 团队队友更像“你手下长期在线的几个工程师”。

而这个仓库最有技术深度的地方在于:它并没有把两者混成一锅粥,而是给了它们不同的状态机、不同的通信模型、不同的恢复方式,却又让它们最终都能回到同一个主循环里,被主 agent 继续理解和调度。


1. 先建立正确的心智模型

很多人第一次看这类项目,会自然地把多 agent 理解成:

  1. 主 agent 调一个工具。
  2. 子 agent 跑一下。
  3. 子 agent 结束后把字符串结果返回。

这个理解对于本仓库来说太浅了。更接近真实情况的模型是下面这样:

角色 在仓库中的真实含义
主线程 / 主代理 当前用户直接交互的会话循环,负责最终对用户说话
后台子代理 AgentTool 派生出的异步任务,有 transcript、有任务状态、可恢复
队友 teammate 团队中的长期存在执行体,可以闲置、被再次唤醒、能收发消息
Task runtime 管理 agent 生命周期的统一壳层,不只是 UI 项
Mailbox 队友之间的文件消息通道,不依赖特定进程形态
Task Notification 后台任务完成后重新注入主消息循环的 XML 消息
TeamFile 团队控制平面的持久化配置文件
Backend 队友实际运行的宿主:tmuxiTerm2、或同进程 in-process
Permission Broker 谁能用什么工具、谁来批准、批准信息怎么回写

这套系统真正要解决的问题,不是“怎么多开几个模型”,而是:

  • 怎么让它们有身份
  • 怎么让它们有生命周期
  • 怎么让它们能继续之前的工作
  • 怎么让它们能在不同运行基座上表现一致
  • 怎么让它们的结果重新成为主模型可理解的输入
  • 怎么在多人协作式 agent 场景里维持权限、安全、UI 和持久化的一致性

这也是为什么这个仓库的多 agent 代码会横跨工具层、任务层、消息队列、磁盘存储、权限系统和系统提示词,而不是只藏在一个 agents/ 目录里。


2. 它其实不是一套多 Agent,而是两套

2.1 第一套:后台子代理

这一套以 src/tools/AgentTool/AgentTool.tsx 为入口,以 src/tools/AgentTool/runAgent.ts 为执行核心,以 src/tasks/LocalAgentTask/LocalAgentTask.tsx 为生命周期容器。

它的特点是:

  • 可以同步跑,也可以转为后台异步跑;
  • 每个 agent 有独立 transcript 和 metadata;
  • 结果不是直接回调给父函数,而是变成 <task-notification> 进入全局消息队列;
  • 后续可以用 SendMessage 按 agent 名称或 agentId 继续对话;
  • 如果 agent 已停止,还可以基于 transcript 继续恢复执行。

这套更像“任务型委派系统”。

2.2 第二套:团队队友

这一套以 src/tools/TeamCreateTool/TeamCreateTool.ts 建立团队,以 src/tools/shared/spawnMultiAgent.ts 负责生成队友,以 src/utils/swarm/inProcessRunner.ts 或 pane backend 负责长期运行,以 src/utils/teammateMailbox.ts 负责通信。

它的特点是:

  • 队友是长期存在的,不是一次性执行完就销毁;
  • 队友可以处于 idle,等待下一条消息,而不是必须结束;
  • 队友之间、队友与 leader 之间用 mailbox 协议通信;
  • 队友可以跑在 tmux/iTerm2 的外部 pane,也可以跑在同一 Node 进程里;
  • 即便是同进程队友,也维持了独立身份、独立控制器、独立任务状态;
  • 队友能处理结构化控制消息,如关机请求、计划审批响应等。

这套更像“多成员常驻团队系统”。

2.3 为什么要分成两套

这是理解整个设计最关键的一点。

如果只做“后台子代理”,你能委派任务,但很难自然地表达:

  • 某个代理长期扮演某个专家角色;
  • 我后面还要继续找同一个代理;
  • 多个代理之间需要互相发消息;
  • 它们在空闲时依然是“活着的成员”。

如果只做“常驻队友”,你又会让很多一次性任务变得过于重:

  • 为一个很小的调查创建完整团队成员,成本太高;
  • 一次性子任务也未必需要 mailbox、队伍、pane、idle 状态。

所以作者把它拆成:

  • 短生命周期、结果导向:后台子代理;
  • 长生命周期、协作导向:团队队友。

这不是重复造轮子,而是把两类完全不同的协作语义拆开了。

为了避免后文阅读时不断在脑内来回切换,这里先给出一张最实用的对照表:

维度 后台子代理 团队队友
主要入口 AgentTool 普通调用 TeamCreateTool + spawnMultiAgent
默认目标 完成一个具体任务 成为长期协作者
生命周期 任务导向,可结束后再恢复 常驻导向,可 idle、可继续
默认回传方式 自动发 <task-notification> 不自动回传,需要显式协作
继续交互方式 SendMessage 排队或 resume mailbox 持续通信
外壳状态 LocalAgentTask InProcessTeammateTask 或 pane teammate task
执行宿主 当前进程中的子运行时 tmux/iTerm2 pane 或 in-process
典型使用感受 “帮我去做这件事” “你以后就是这个团队的一员”

3. 后台子代理是怎么工作的

这一部分是理解“委派任务”能力的主线。

3.1 AgentTool 不是单纯的 spawn,它先做路由判断

src/tools/AgentTool/AgentTool.tsx 里的 call() 并不是“收到 prompt 就开跑”。它首先要决定这次调用到底属于哪一种 agent 行为。

它会先处理几个关键分支:

  1. 是否要走 team spawn
    当存在 team_name 且提供了 name 时,这不是普通子代理,而是要生成一个 teammate。
  2. 是否是 队友试图再生队友
    代码里明确阻止这种情况,原因是 TeamFile.members 是扁平数组,嵌套队友会让团队来源关系变得混乱。
  3. 是否是 in-process teammate 试图开后台 agent
    这也被显式禁止。实现里写得很清楚:in-process teammate 的生命周期依附于 leader 所在进程,因此不能再随意生成自己的后台代理;而 pane/tmux 队友因为是独立进程,约束没这么强。
  4. 普通子代理是否要 同步 还是 异步/后台
    这由 run_in_background、agent 定义里的 background: true、coordinator 模式、assistant/KAIROS 模式、主动模式等因素共同决定。

换句话说,AgentTool 本质上是一个调度路由器。它先决定“这是什么类型的多 agent 行为”,再选择下面哪条执行链。

3.2 前台代理和后台代理,其实共享一套壳

很多系统会把“同步子代理”和“后台子代理”做成两套完全不同的逻辑。本仓库没有这么做。

它采用的方式是:

  • 前台启动registerAgentForeground(...)
  • 后台启动registerAsyncAgent(...)

二者都在 src/tasks/LocalAgentTask/LocalAgentTask.tsx 中。

registerAgentForeground(...) 的设计非常值得注意。它并不是“纯前台,不留痕迹”,而是:

  • 也会注册一个 task;
  • 也会初始化 output symlink;
  • 也会创建 AbortController
  • 也会给这个 agent 安装清理逻辑;
  • 额外构造一个 backgroundSignal Promise。

这个 backgroundSignal 的意义是:前台运行中的 agent 可以在运行过程中被切换成后台。也就是说,前台和后台不是两种完全不同的 agent,而更像是同一种 agent 的两种可切换显示/管理形态。

backgroundAgentTask(...) 做的事情也很关键:

  • 把任务状态改成 isBackgrounded: true
  • resolve 对应的 backgroundSignal
  • 让调用方知道它应该从“阻塞式等待”切换为“后台继续跑”。

这说明作者把“后台化”做成了一种运行时状态,而不是一次 spawn 时不可变的硬编码选择。

3.3 LocalAgentTask:后台子代理真正的外壳

LocalAgentTask 这一层很重要,因为它决定了后台子代理不会退化成“一个 Promise”。

它的状态里不只有 status,还包括:

  • agentId
  • prompt
  • selectedAgent
  • agentType
  • abortController
  • progress
  • result
  • messages
  • pendingMessages
  • isBackgrounded
  • retain
  • diskLoaded
  • notified

这个状态结构透露出一个很强的设计意图:agent 结果不是唯一重要的东西,过程状态同样重要

比如:

  • pendingMessages 允许你在 agent 运行途中继续给它发消息;
  • messages 让 UI 能查看它的转录;
  • progress 支持任务进度展示;
  • notified 用来避免重复完成通知;
  • diskLoaded 和 transcript 机制一起支撑“任务状态不在内存也能恢复”。

这一层还维护了几个很关键的行为:

3.3.1 结果不是回调,而是通知

enqueueAgentNotification(...) 会构造一段 XML:

<task-notification>
  <task-id>...</task-id>
  <output-file>...</output-file>
  <status>completed|failed|killed</status>
  <summary>...</summary>
  <result>...</result>
  <usage>...</usage>
</task-notification>

然后通过 enqueuePendingNotification(...) 放进全局命令队列,而且默认优先级是 later,避免系统消息饿死用户输入。

这件事的意义非常大:

  • 它让后台代理完成事件进入与主线程同构的消息通道;
  • 它让“代理结果”成为主模型下一轮可见的上下文,而不是宿主程序偷偷消费的内部回调;
  • 它也给 SDK、TUI、日志等多个上层消费方一个统一事件形态。

3.3.2 继续对话不是黑魔法,而是显式消息队列

queuePendingMessage(...)drainPendingMessages(...) 让运行中的后台子代理可以在安全点收到后续消息。

这和很多人想的不一样:不是“外部强行塞一条消息给一个正在推理的模型”,而是把消息先排进 task 自己的 pendingMessages,等 agent 到达合适轮次再交给它。

所以它更像是给后台 agent 维护了一个轻量级 inbox,只是这个 inbox 不走团队 mailbox 文件,而是挂在 task 状态里。

3.4 runAgent(...):子代理不是 helper,而是完整会话运行时

理解 src/tools/AgentTool/runAgent.ts,几乎就理解了这个仓库里“agent”真正是什么。

它不是一个简单的 model.generate(prompt) 包装,而是完整地为子 agent 重建一套运行环境:

  1. 解析 agent 定义;
  2. 组装系统提示词;
  3. 初始化 agent 专属 MCP server;
  4. 合并工具池;
  5. 构造 agent 专属 ToolUseContext
  6. 记录 transcript 与 metadata;
  7. 跑完整的 query(...) 循环;
  8. 在 finally 中执行大规模清理。

这里最关键的几个技术点如下。

3.4.1 它真的给子代理做了独立上下文

createSubagentContext(...) 会创建新的子上下文。

从注释和参数能看出作者是有意区分两类 agent 的:

  • 同步子代理
    可以共享一部分父级回调,比如 setAppState
  • 异步子代理
    尽量使用隔离状态,比如独立 AbortController、独立读文件状态等。

这意味着“子 agent 继承父 agent 的一切”并不成立。它继承的是某些必要能力,但在文件缓存、取消控制、状态归属上有刻意隔离。

3.4.2 它会控制 thinking、工具和 prompt cache 前缀

普通 subagent 默认关闭 thinking,以控制输出 token 成本;而 fork 子代理在 useExactTools 路径下会继承父级 thinking 配置,目的是让 API 请求前缀尽量字节级一致,复用 prompt cache。

这说明这里的 agent 设计并不仅是“功能正确”,还深度考虑了缓存命中、成本控制、prefix 稳定性

3.4.3 它会为 agent 记录 sidechain transcript 和 metadata

在 query 循环开始前,它会 fire-and-forget 地写:

  • recordSidechainTranscript(initialMessages, agentId)
  • writeAgentMetadata(agentId, { agentType, worktreePath, description })

运行中每个可记录消息也会继续增量写入 transcript。

这带来的能力是:

  • agent 可以被 resume;
  • transcript 可以被查看;
  • worktree、description、agentType 等附加信息可以跨进程、跨内存恢复。

3.4.4 清理逻辑非常“工程化”

finally 里的清理并不敷衍,包含:

  • 清理 agent 自己创建的 MCP server;
  • 清 session hook;
  • 清 prompt cache 跟踪状态;
  • 清 read file cache;
  • 清 transcript 子目录映射;
  • 清 todos 条目;
  • 杀掉该 agent 启动的后台 shell 任务;
  • 在某些特性开启时,连 monitor MCP 任务也一并杀掉。

这说明作者非常清楚一个事实:多 agent 系统最大的敌人不是“不会跑”,而是“跑完以后留下半死不活的资源”

3.5 后台子代理的真正完成流程

可以把它概括成下面这条链:

主线程调用 AgentTool
  -> 决定走普通子代理
  -> registerForeground / registerAsyncAgent
  -> runAgent 建立完整子运行时
  -> query 循环持续执行
  -> LocalAgentTask 更新进度/消息/状态
  -> completeAgentTask / failAgentTask
  -> enqueueAgentNotification 生成 XML 通知
  -> 全局消息队列
  -> 主线程下一轮把通知重新喂给模型

注意最后两步:不是“宿主收到结果然后替你决定怎么办”,而是“结果被转成一条新的、主模型能读懂的系统性消息”。这个设计非常 agent-native。

3.6 为什么 SendMessage 能继续一个已完成的后台代理

src/tools/SendMessageTool/SendMessageTool.ts 里有一段很关键的路由逻辑:

  • 如果 to 能在 agentNameRegistry 或格式化 agentId 里命中;
  • 且该目标是 LocalAgentTask
  • 如果它还在运行,就 queuePendingMessage(...)
  • 如果它已经停止,就走 resumeAgentBackground(...)
  • 如果它已经从 AppState 里被逐出,也会尝试从磁盘 transcript 恢复。

这个行为说明了一件很重要的事:

后台子代理在语义上是“可继续的会话”,而不是“一次性函数调用”。

3.7 resumeAgentBackground(...) 的本质:重建而不是复活

src/tools/AgentTool/resumeAgent.ts 不是简单地“把旧 Promise 接回来”。它做的是一次基于 transcript 的重建

它会:

  1. 读取磁盘上的 transcript 和 metadata;
  2. 过滤掉空白 assistant 消息、孤立 thinking-only 消息、未完成 tool_use;
  3. 重建 content replacement 状态;
  4. 恢复原 worktree(如果还存在);
  5. 根据 metadata 重新选择 agent 类型;
  6. 再次 registerAsyncAgent(...)
  7. 用恢复后的消息历史重新调用 runAsyncAgentLifecycle(...)runAgent(...)

这意味着“resume”不是魔法暂停恢复,而是“拿旧状态重新构造一个新运行实例”。这是一种更稳妥、更可持久化的设计。


4. 团队队友系统是怎么工作的

如果说后台子代理解决的是“委派一次任务”,那团队队友系统解决的就是“形成一个长期协作组织”。

4.1 团队首先是一个持久化控制平面

src/tools/TeamCreateTool/TeamCreateTool.ts 不只是把 teamName 存到内存里。

它做了很多事情:

  1. 检查当前 leader 是否已经在带一个 team;
  2. 生成唯一 team 名称;
  3. 为 team lead 生成确定性的 leadAgentId,格式是 team-lead@teamName
  4. 读取当前主会话模型,写入 team member 信息;
  5. 在磁盘上写出 TeamFile
  6. 重置并创建 task list 目录;
  7. 把 team context 注册进 AppState
  8. 记录 analytics 事件。

这里的 TeamFile 定义在 src/utils/swarm/teamHelpers.ts,里面的信息很多:

  • name
  • description
  • createdAt
  • leadAgentId
  • leadSessionId
  • teamAllowedPaths
  • members[]

members[] 里又包含:

  • agentId
  • name
  • agentType
  • model
  • prompt
  • color
  • planModeRequired
  • joinedAt
  • tmuxPaneId
  • cwd
  • worktreePath
  • sessionId
  • subscriptions
  • backendType
  • isActive
  • mode

从这个结构就能看出来:作者把“team”设计成了一个完整的控制平面对象,而不是 UI 上的一组标签。

4.2 队友实际跑在哪儿,是由 backend registry 决定的

队友并不总是同一种运行形态。

src/utils/swarm/backends/registry.ts 负责解决“这个环境下队友该怎么运行”。

它的策略大致是:

  • 如果会话明确配置为 in-process,那就用同进程执行;
  • 如果明确配置为 tmux,那就走 pane backend;
  • 如果是默认 auto
    • tmux 里时,优先 pane backend;
    • 在 iTerm2 里时,也优先 pane backend;
    • 否则使用 in-process
  • 非交互会话直接强制 in-process
  • 如果原本想走 pane backend,但环境不具备条件,也能记录 fallback,改成 in-process

这一步非常关键,因为它让“teammate”这个抽象从宿主环境里解耦了。

换句话说:

  • 对上层编排来说,teammate 只是 teammate;
  • 至于它到底是跑在 tmux paneiTerm2 pane,还是同一进程的 ALS 上下文里,是 backend 决定的实现细节。

这就是典型的执行基座抽象

4.3 spawnMultiAgent:真正的团队生成总控

src/tools/shared/spawnMultiAgent.ts 是团队生成的总控模块。

它负责的事情非常多:

  • 解析队友名字、颜色、model、agent_type;
  • 处理继承的 CLI flags 和 env;
  • 决定使用哪个 backend;
  • 在需要时创建 tmux session / pane;
  • AppStateTeamFile 里登记队友;
  • 针对不同 backend 采用不同的启动方式;
  • 对 pane 模式下的初始 prompt 进行 mailbox 投递。

这一层最值得强调的,是它天然分成了两部分:

4.3.1 控制平面

控制平面关心的是:

  • 谁是这个 agent;
  • 它属于哪个 team;
  • 它的 agentId 是什么;
  • 它在 UI 里如何显示;
  • 它在哪个 backend 上;
  • 团队配置文件如何更新;
  • 出错时如何清理。

4.3.2 数据平面

数据平面关心的是:

  • 初始 prompt 怎么送到对方手里;
  • 后续消息如何送达;
  • 响应如何回来。

在 pane 模式下,这两件事是分离的:先把队友“建出来”,再通过 mailbox 发送初始消息。这种分层让实现更稳定,也减少了“spawn 和 prompt 注入耦死”的问题。

还有一个经常被忽略、但非常关键的点:pane 模式下生成出来的 teammate 并不会因为“它不在当前进程里”就失去系统管理能力。spawnMultiAgent.ts 会额外调用 registerOutOfProcessTeammateTask(...),把它注册成一条可观察、可终止、可在任务 UI 中展示的任务状态。

也就是说,作者追求的是:

  • 执行宿主可以不同;
  • 但系统管理界面和控制抽象要尽量统一。

4.4 pane teammate:另一种并列执行基座

如果 backend registry 决定当前环境适合 pane 模式,spawnMultiAgent 会进入 split-pane 或 separate-window 路径。

根据源码注释和控制流,它的大致行为是:

  • 在 tmux 内部:leader 和 teammates 共享一个窗口布局,leader 在左,队友在右;
  • 在 tmux 外部但可用 tmux 时:创建外部 swarm session,让多个队友在独立 pane/window 中运行;
  • 在 iTerm2 且 it2 CLI 可用时:走原生 iTerm2 backend;
  • 在 auto 模式下如果 pane backend 不可用:回退到 in-process,并记录 fallback 状态,让 UI 也反映真实模式。

这条路径并不是“开一个 pane 然后把 prompt 打进去”那么粗糙。它更像这样:

  1. 先创建 pane 或 session;
  2. 再把这个 teammate 登记到 AppStateTeamFile
  3. 补全 agentId / paneId / backendType / cwd / model 等元数据;
  4. 通过 mailbox 投递初始消息;
  5. 让远端 teammate 自己的 inbox poller 读取第一条消息并开始执行。

这个顺序很能说明作者的架构取向:先把控制平面建好,再让数据平面流动

此外,out-of-process teammate 的 task 上依然挂了 AbortController。当它被触发时,系统会根据 backendType 调回对应 backend 去执行 killPane(...)。这再次说明 task 不是 UI 装饰,而是跨 backend 的统一控制入口。

4.5 spawnInProcessTeammate(...):同进程队友不是假的

很多系统说自己支持 in-process worker,本质只是“同步调用另一个函数”。这里不是。

src/utils/swarm/spawnInProcess.ts 做的事情足够严肃:

  • 生成确定性的 agentId
  • 生成独立 taskId
  • 创建独立 AbortController
  • 构建 TeammateIdentity
  • 创建 TeammateContext,用于 AsyncLocalStorage 语义隔离;
  • 注册 Perfetto trace;
  • 创建 InProcessTeammateTaskState
  • 把它注册进 AppState;
  • 安装清理逻辑。

这说明 in-process teammate 不是“普通子代理换了个名字”,而是同一进程中的常驻同行执行体

4.6 InProcessTeammateTask:队友任务和后台子代理任务不是一种东西

src/tasks/InProcessTeammateTask/types.ts 很直白地显示了两者的差异。

队友任务状态有一些后台子代理没有的字段:

  • identity
  • awaitingPlanApproval
  • permissionMode
  • currentWorkAbortController
  • pendingUserMessages
  • isIdle
  • shutdownRequested
  • onIdleCallbacks

而且注释里写得很清楚:

  • in-process teammate 支持 plan mode 审批流;
  • 可以 idle,不必终止;
  • conversation history 是用于 zoomed view 的 UI mirror,而完整对话保存在本地 allMessages 和磁盘 transcript 中。

这说明队友的抽象核心不是“完成结果”,而是“持续存在、可切换工作状态”。

4.7 runInProcessTeammate(...):真正的常驻队友主循环

src/utils/swarm/inProcessRunner.ts 是整个多 agent 系统里最值得细读的文件之一。

它揭示了 in-process teammate 的真实模型:常驻 worker loop

这个函数的关键特征如下。

4.7.1 它有自己的系统提示词体系

它会先构造 teammateSystemPrompt

  • 默认系统提示词;
  • 再加上 TEAMMATE_SYSTEM_PROMPT_ADDENDUM
  • 如果给定自定义 agent 定义,再附加 agent 自己的 prompt;
  • 如果用户给了 append/replace 型 systemPrompt,也按模式合并。

这意味着队友在“人格和职责”上不是主代理的简单复制,而是有自己的运行准则。

4.7.2 它会强行注入团队协作必需工具

即便队友指定了显式工具列表,它也会补齐这些团队级工具:

  • SendMessage
  • TeamCreate
  • TeamDelete
  • TaskCreate
  • TaskGet
  • TaskList
  • TaskUpdate

这件事很说明问题:作者认为“队友”首先是协作者,其次才是具体工作者。没有这些工具,它就不能真正参与团队协议。

4.7.3 它默认把 permissionMode 设为 default

这一点很值得注意。实现里有明确注释:队友应该始终保有完整工具访问能力,不应简单继承 leader 的限制模式。

这不是在“放大权限”,而是在配合后续的审批转发机制。因为 in-process teammate 并不是偷偷绕过审批,而是把需要 ask 的工具使用请求转给 leader 来批准。

4.7.4 它持有跨多轮的 allMessages

这是它和普通后台子代理最大的区别之一。

后台子代理通常是一项任务跑到底,完成就算。而 in-process teammate 会:

  • 保存累计消息历史 allMessages
  • 在每次收到新 prompt 后,把旧消息当上下文继续推理;
  • 因此它是“持续存在的会话”,不是重复生成的新 agent。

4.7.5 它会自动 compact 自己的历史

allMessages token 数超阈值时,它会调用 compactConversation(...) 做压缩。

压缩时又非常谨慎:

  • 会克隆 readFileState
  • 不影响主会话的 UI 回调;
  • 压缩后重建 allMessages
  • 重置 microcompact 状态和 content replacement state;
  • 同步更新 task.messages 的镜像,防止 AppState UI mirror 无边界膨胀。

这表明作者非常清楚“常驻 teammate 最大的资源风险就是历史无限增长”。

4.7.6 它有双层中断控制

这也是一个非常成熟的设计细节。

它同时维护:

  • 生命周期级别的 abortController
    用于终止整个 teammate;
  • 当前工作轮次的 currentWorkAbortController
    用于只停止当前一轮工作,而不杀死这个 teammate 本身。

这意味着 UI 上“停掉当前工作”和“关闭整个 teammate”在底层是两种不同操作。

4.7.7 它重用 runAgent(...)

尽管 teammate 是常驻模型,但每一轮真正执行时,它仍然调用的是 runAgent(...)

这说明作者没有维护两套完全独立的 agent 执行内核,而是:

  • runAgent(...) 作为统一的单轮执行引擎;
  • 在外层增加一个 teammate-specific 的常驻循环。

这是一个非常聪明的复用方式:共享核心推理循环,差异放在生命周期壳层

4.7.8 它执行完成后不会自动把结果“塞回 leader”

这点非常重要。

一轮工作做完后,队友会:

  • 更新自己的 task 状态;
  • 标记自己 idle;
  • 通过 mailbox 通知 leader 自己空闲了;
  • 然后继续等待下一条 prompt 或 shutdown。

但是它不会默认把完整工作产物自动回传给 leader。如果想把结果发给 leader,需要队友显式使用 SendMessage 等协作工具。

这和后台子代理完全不同。后台子代理的默认语义是“做完就发 task-notification”;队友的默认语义是“我是常驻成员,做完当前轮次后进入空闲状态,是否汇报由协作协议决定”。

这也是两套系统的本质差异之一。

4.8 waitForNextPromptOrShutdown(...):队友为什么能像常驻成员一样“等活”

这段逻辑几乎可以被看作一个小型 agent scheduler。

它每 500ms 轮询一次,并且按优先级处理不同来源的工作:

  1. 先看内存里的 pendingUserMessages
  2. 再查 mailbox;
  3. mailbox 里优先处理 shutdown_request
  4. 再优先处理 team lead 的消息;
  5. 其他 peer message 再按 FIFO。

这套优先级设计非常合理:

  • 关机请求是控制面最高优先级;
  • leader 消息代表用户意图,应避免被队友之间闲聊饿死;
  • peer 消息是协作补充,不应抢 leader 的调度权。

换句话说,这里的 teammate 不是“消息来一条就执行一条”的被动消费者,而是一个有内部优先级策略的常驻 agent。


5. 多 Agent 之间到底怎么通信

5.1 背景子代理和队友,通信方式并不相同

这个仓库没有强行统一所有通信路径,而是按语义分工:

  • 后台子代理
    • 继续对话:LocalAgentTask.pendingMessages
    • 完成回传:<task-notification> + 全局命令队列
  • 团队队友
    • 日常通信:mailbox 文件
    • 控制消息:结构化 mailbox 消息
    • 工作流回报:由队友主动使用协作工具发送

这是一种很实用的设计:对于短命 task,没必要上完整 mailbox;对于常驻 teammate,没有 mailbox 就会很别扭。

5.2 SendMessageTool 是两套系统的统一路由器

src/tools/SendMessageTool/SendMessageTool.ts 是整个多 agent 系统的交通枢纽。

它能处理三类目标:

  1. 后台子代理
    • 正在运行:queuePendingMessage(...)
    • 已停止:resumeAgentBackground(...)
  2. 团队队友
    • 通过 writeToMailbox(...) 发送到 inbox
  3. 跨会话目标
    • uds:bridge: 之类目标

这意味着 SendMessage 不是简单的“发一条文本”,而是一个多协议路由器

5.3 队友 mailbox 是文件协议,不是内存事件总线

src/utils/teammateMailbox.ts 里能看到完整实现:

  • inbox 路径:~/.claude/teams/{team}/inboxes/{agent}.json
  • 每条消息都有 from / text / timestamp / read / color / summary
  • 写入前先确保目录和 inbox 文件存在;
  • 通过 lockfile 和重试保证并发写入安全;
  • 读消息、标记已读也都有明确函数。

这套设计的优点是:

  • 与进程模型解耦;
  • tmux pane、iTerm2 pane、in-process teammate 都能共用;
  • 出了问题可以直接检查磁盘文件;
  • 不依赖一个中央常驻 broker 进程。

它的代价也很明显:

  • 有轮询延迟;
  • 文件 IO 与锁开销比纯内存队列大;
  • 语义更偏“可靠投递”而非“低延迟流式通信”。

但对 CLI agent 团队来说,这其实是很合适的折中。

5.4 结构化消息揭示了它不是“聊天”,而是“协作协议”

SendMessageTool 不只发字符串,还支持结构化消息:

  • shutdown_request
  • shutdown_response
  • plan_approval_response

这说明队友间通信不是自然语言聊天那么简单,而是有一层显式控制协议。

例如 shutdown 流程:

  1. leader 发送 shutdown_request
  2. teammate 决定批准或拒绝;
  3. 如果批准,给 leader 发确认;
  4. in-process teammate 还会直接触发自己的 abort controller。

这是一套完整的控制面协议,而不仅仅是“Hey,停一下”。


6. 这套系统最妙的一点:结果如何重新回到主模型

这是本仓库多 agent 设计里最值得反复琢磨的一点。

6.1 后台任务完成后,并不是宿主代码偷偷消费结果

如前所述,后台子代理结束后会调用 enqueueAgentNotification(...),把 XML 推进全局消息队列。

而在 src/query.ts 里,主循环会在适当时机从命令队列中取出这些待消费项。

实现里明确写了:

  • 主线程只消费 agentId === undefined 的命令;
  • 子代理只消费发给自己的 task-notification
  • prompt 类型命令仍然只进主线程,不会乱灌给子代理。

这个按 agent 作用域划分的队列消费策略,非常像一个轻量级 actor mailroom。

6.2 task-notification 会被包装成模型可读消息

src/utils/messages.ts 里,wrapCommandText(...)task-notification 的包装是:

A background agent completed a task:
{raw_xml}

也就是说,这个通知最终会以附件/消息的形式进入模型上下文。

6.3 print.ts 会发 SDK 事件,但仍然继续喂给模型

src/cli/print.tstask-notification 的处理也非常有意思:

  • 先解析 XML;
  • 给 SDK 消费者发系统事件;
  • 然后仍然继续调用 ask(),让模型看到这条结果并自己决定下一步行动。

这是一个特别“agentic”的选择。

它意味着:

  • 系统不替主模型做最后决策;
  • 主模型能基于 worker 回来的结果继续规划;
  • worker 结果天然进入主线程因果链,而不是游离在外。

6.4 为什么这比传统 callback 更强

传统 callback 模型的思路是:

  • worker 完成;
  • host 拿到结果;
  • host 决定是否提醒主 agent。

而本仓库的做法是:

  • worker 完成;
  • 结果转成结构化消息;
  • 结果进入消息总线;
  • 主 agent 在自己的语境里重新“看到”它。

这带来几个好处:

  1. 主模型拥有上下文连续性
    它不是被动收到一个宿主总结,而是直接看到原始完成信号。
  2. 编排逻辑可以继续由模型承担
    比如继续追问同一个 worker、综合多个 worker 结果、停止另一个 worker。
  3. 上层 UI/SDK 和下层 agent loop 共享同一事件来源
    不需要分别维护一套 callback 协议和一套模型消息协议。

从架构视角看,这是一种把“agent runtime”真正做成“消息驱动系统”的方法。


7. 权限、隔离与控制:这套协作为什么没有失控

多 agent 系统一旦做复杂,最容易失控的就是权限和边界。本仓库在这方面下了很多功夫。

7.1 子代理默认不是无脑继承父代理权限

runAgent(...) 在构建子代理上下文时,会根据 agent 定义、调用形态、是否异步、是否 fork 等条件,重建工具池和上下文。

这意味着:

  • 可以限制某个 agent 能看到的工具;
  • 可以根据 permission mode 调整行为;
  • fork 子代理可以为了 cache 一致性保留 exact tools;
  • 普通 agent 则可以用更受控的工具集运行。

7.2 in-process teammate 的权限请求会冒泡给 leader

createInProcessCanUseTool(...) 是整个权限协作设计的关键。

当工具权限检查结果是 ask 时,它不会让 teammate 自己偷偷决定,而是:

  1. 先看 bash classifier 能不能自动批准;
  2. 如果不行,优先使用 leader 的 ToolUseConfirm UI 队列;
  3. 如果没有 bridge/UI,再退回 mailbox 请求。

也就是说,同进程队友虽然和 leader 在一个进程里,但它依然走了一条经由 leader 审批的受控路径

这非常重要,因为它避免了一个常见问题:

“既然是在同进程里,那不就什么都能干了吗?”

源码给出的答案是:不是。同进程只是执行宿主同一个 Node 进程,不代表权限治理就被绕过。

7.3 队友不是完全隔离,也不是完全共享

这是这套设计里最成熟的现实主义部分。

它没有追求虚假的“绝对沙箱”。相反,它做的是有边界的共享:

  • 共享:
    • 同一进程资源;
    • 一些主会话上下文;
    • 统一权限 UI;
    • 全局消息队列;
  • 隔离:
    • agentId / TeammateIdentity
    • AbortController
    • allMessages
    • task state
    • transcript
    • AsyncLocalStorage teammate context

这比“全共享”安全,也比“全隔离”实用。

7.4 planModeRequired 和独立 permissionMode

队友状态里会记录:

  • planModeRequired
  • permissionMode

而 leader 还能对不同队友独立切换 permission mode。

这说明权限不是会话级唯一值,而是成员级运行属性。这对多 agent 团队非常关键,因为不同成员可能正在做不同风险级别的事。


8. 这套多 Agent 系统为什么能持久、可恢复、可观察

这是它和很多“研究原型式 agent 框架”的最大区别。

8.1 每个 agent 都有自己的 transcript

后台子代理和队友都会在不同程度上维护 transcript。

这带来的好处是:

  • agent 不是黑盒;
  • UI 能查看历史;
  • agent 可以 resume;
  • 调试时可以追踪真实对话过程;
  • 运行时中断不至于把一切状态清空。

8.2 任务输出有独立 output file / symlink

LocalAgentTask 注册时会初始化 output symlink,这让外部系统和 UI 能直接找到任务输出路径。

这其实是一种“把 agent 结果实体化到文件系统”的思路。对于 CLI 产品来说非常实用,因为文件路径天然适合:

  • tail / 查看;
  • 外部工具集成;
  • 会话恢复。

8.3 团队有 TeamFile

队伍不是只活在内存里,团队成员与元数据会落到 config.json 一类的持久文件中。

这让系统能够:

  • 发现已有团队;
  • 管理成员;
  • 更新 mode;
  • 做 session cleanup;
  • 在 UI 和 backend 之间共享控制平面状态。

8.4 队友有 idle 状态,不必非得 terminate

这是一个非常产品化的细节。

很多系统的 worker 完成一次推理后就算结束,而这里的 teammate 可以处于:

  • running
  • idle
  • shutdownRequested
  • terminal status

这让“长期协作”真正成立。否则你每次都只是重新 spawn 一个新 agent,那就不叫 teammate,只是更重的 subagent。

8.5 Cleanup 是架构的一部分,不是补丁

无论是 runAgent(...) 的 finally,还是 task 注册时的 cleanup handler,都说明作者从一开始就把“如何收尾”视为架构设计的一部分。

这是多 agent 系统能长期稳定运行的关键。


9. coordinatorMode:这套系统不只是 runtime,还有一套“工作哲学”

src/coordinator/coordinatorMode.ts 里最有意思的地方,不是一个布尔开关,而是它提供的系统提示词。

这个提示词明确告诉 coordinator:

  • 你的职责是编排,不是自己做所有事;
  • worker 结果会以 <task-notification> 形式回来;
  • 不要用一个 worker 去盯另一个 worker;
  • 研究任务可以并行;
  • 写代码和验证要分阶段;
  • worker 完成后应考虑继续同一个 worker,而不是总重开新的;
  • 验证必须是真验证,不是形式化过场。

这说明作者对多 agent 的理解不是“加几个 API 就行”,而是:

  1. runtime 要提供协作基础设施;
  2. system prompt 要教会模型如何正确使用这些基础设施。

这两者缺一不可。

如果没有 runtime,prompt 再聪明也没用;
如果只有 runtime,没有 prompt 约束,模型也很容易把多 agent 用得一团乱。

所以 coordinatorMode 本质上是这套产品的操作手册内化版本


10. 这套设计最值得称道的地方

10.1 它把“agent”提升成了系统级实体

在很多框架里,agent 只是函数包装、链式调用对象或 prompt 模板。

而在这里,agent 拥有:

  • 身份;
  • 生命周期;
  • 磁盘转录;
  • 持久化元数据;
  • 权限路径;
  • 通信协议;
  • UI 表示;
  • 可恢复能力。

这已经很接近一个小型“agent OS”了。

10.2 它没有迷信单一协作模型

后台子代理和团队队友分开,是非常正确的工程判断。

现实里确实存在两类需求:

  • 一次性委派;
  • 长期团队协作。

把两者硬做成同一抽象,最后通常会两边都不好用。

10.3 它对执行基座做了抽象

pane teammate 和 in-process teammate 共享的是概念模型,不共享的是宿主实现。

这使得产品可以根据环境灵活选择:

  • 有终端 UI 时,用 pane 获得更直观的物理分离;
  • 没有条件时,用 in-process 保持功能可用;
  • 对上层用户来说,协作体验尽量一致。

10.4 它把结果回流做成消息,而不是宿主内部黑箱

这是非常 agent-native 的设计,也是本文最想强调的一点。

因为一旦你把 worker 结果变成宿主内部 callback,主模型就失去了“自然接续”的能力;而把它做成结构化消息重新注入上下文,才真正把多 agent 纳入统一推理空间。

10.5 它对资源泄漏和状态膨胀非常警觉

从 transcript 清理、shell task kill、队友 messages cap、auto compact、fallback 标记等细节可以看出,这套系统不是只追求 demo 成功,而是明显考虑了长会话、重并发、鲸鱼级别 session 的稳定性问题。


11. 这套设计的代价和复杂度

说优点的同时,也必须承认它的成本。

11.1 概念面很多

要真正理解这套多 agent 系统,你至少得同时理解:

  • AgentTool
  • runAgent
  • LocalAgentTask
  • InProcessTeammateTask
  • SendMessageTool
  • TeamFile
  • mailbox
  • command queue
  • coordinatorMode

这显然比“单文件 worker pool”复杂得多。

11.2 同进程隔离始终不是硬隔离

即便用了 AsyncLocalStorage、独立 abort controller、独立 task state,同进程 teammate 终究还是共享一个 Node 进程。

这带来的好处是低成本、集成强;坏处是边界永远比不上真正独立进程。

作者的策略不是假装它是硬隔离,而是通过权限代理、状态分层和协议限制来尽量控制风险。这是现实主义做法,但也意味着系统复杂度会上升。

11.3 mailbox 轮询不是零延迟

文件 mailbox 的优点是通用和稳定,缺点是:

  • 需要轮询;
  • 有锁和 IO;
  • 不适合特别高频的细粒度通信。

不过从 CLI 工程协作的使用模式看,这个代价多数时候是可以接受的。

11.4 TeamFile 的扁平 roster 带来结构约束

代码里明确禁止队友再生队友,就是因为目前 team roster 是扁平的。

这意味着:

  • 团队结构简单;
  • 但嵌套组织、代理来源追踪、树状管理能力会受限。

这是一个明显的工程取舍:先把简单 team model 做扎实,而不是一开始追求无限嵌套。

11.5 Runtime 与 Prompt 是强耦合的

这套系统之所以好用,一个重要原因是 coordinatorMode 等 prompt 明确教模型如何工作。

这同时也意味着:如果 prompt 层失配、退化或被错误覆盖,runtime 再强,也可能被模型用歪。

不过在 agent 产品里,这种“prompt-runtime 双轮驱动”其实很常见,也很难完全避免。


12. 如果把整个多 Agent 系统压缩成一张图

12.1 后台子代理路径

用户/主线程
  -> AgentTool
     -> 判断:普通子代理 / teammate / remote / worktree
     -> registerAgentForeground 或 registerAsyncAgent
     -> runAgent
        -> 组 prompt / 工具 / MCP / 子上下文
        -> 跑 query 循环
        -> 记录 transcript / metadata
     -> LocalAgentTask 持续更新状态
     -> 完成/失败/停止
     -> enqueueAgentNotification(XML)
     -> 全局命令队列
     -> 主线程下一轮读取 task-notification
     -> 主模型决定下一步:汇总 / 继续 / 再派活 / 停止其他 agent

12.2 团队队友路径

主线程
  -> TeamCreateTool
     -> 写 TeamFile / AppState.teamContext / task list
  -> spawnMultiAgent
     -> backend registry 决定 in-process 或 pane
     -> spawnInProcessTeammate 或 tmux/iTerm2 spawn
     -> 队友注册为 task
  -> 队友进入 runInProcessTeammate 常驻循环
     -> 等消息 / 等任务 / 等审批 / 等 shutdown
     -> 每次收到 prompt 时调用 runAgent 执行一轮
     -> 完成后进入 idle
     -> 通过 SendMessage / mailbox 参与协作

12.3 两条线最终如何汇合

汇合点不是“同一个 spawn API”,而是:

  • 都有 task 状态;
  • 都能被 UI 观察;
  • 都有 transcript;
  • 都受权限系统约束;
  • 都最终通过消息机制参与主线程决策。

因此,更准确的说法是:

这个仓库并不是“实现了多 agent 工具调用”,而是“实现了一个围绕任务、消息、权限、转录和团队控制平面的 agent 协作运行时”。


13. 作为源码阅读者,最值得继续深挖的点

如果要继续往下研究,我认为最值得进一步精读的是这几组文件:

13.1 任务型多 agent 主线

  • src/tools/AgentTool/AgentTool.tsx
  • src/tools/AgentTool/runAgent.ts
  • src/tasks/LocalAgentTask/LocalAgentTask.tsx
  • src/tools/AgentTool/resumeAgent.ts

这四个文件合起来,基本就是“后台子代理”完整机制。

13.2 团队型多 agent 主线

  • src/tools/TeamCreateTool/TeamCreateTool.ts
  • src/tools/shared/spawnMultiAgent.ts
  • src/utils/swarm/backends/registry.ts
  • src/utils/swarm/spawnInProcess.ts
  • src/utils/swarm/inProcessRunner.ts
  • src/utils/teammateMailbox.ts

这几组文件决定了 teammate 是怎么被生成、如何存活、如何通信、如何在不同 backend 上保持抽象一致。

13.3 协作结果回流主线

  • src/tools/SendMessageTool/SendMessageTool.ts
  • src/query.ts
  • src/utils/messageQueueManager.ts
  • src/utils/messages.ts
  • src/cli/print.ts
  • src/coordinator/coordinatorMode.ts

这里能看清楚“为什么 worker 结果不是回调,而是重新进入主消息循环”。


14. 最终结论

如果只用一句更技术化的话来概括这个仓库的多 agent 设计,我会这样说:

它把 agent 从“模型调用的别名”提升成了“带身份、带状态、带存储、带协议、带权限治理的运行时实体”,并且把短命任务代理与长命团队成员这两种协作语义明确拆开,再通过统一的消息回流机制让它们共同服务于主线程编排。

而如果换成更通俗的话,那就是:

这里的多 agent 不是“多开几个模型窗口”,而是真的在做一个小型协作操作系统。

这也是为什么它看起来复杂,但复杂得很有道理。因为它解决的已经不是“能不能开 worker”,而是“worker 作为系统成员,如何活着、如何沟通、如何被管理、如何被继续使用、以及如何把结果自然地带回主线程”。

It is my final heart.
最后更新于 2026-04-01