s15:Agent Teams(团队协作)
s15:Agent Teams(团队协作)
问题背景
假设要让 Agent 重构整个后端:配置加载、认证、测试三块。一个 Agent 当然可以串行处理,但总耗时更长,且早期细节会随上下文窗口被推出去。
这类工作适合并行,但用户通常只给一句目标,不会替运行时设计团队:
重构这个示例后端。清理配置加载、认证和测试,
保持现有接口,并确保测试通过。
Harness 必须回答一组互相牵连的问题:
- 谁来决定要不要并行、新增几个队友? —— 不能让模型擅自开线程烧 token
- 每个队友如何跨任务保留身份和上下文? —— subagent 是一次性的,干完就忘
- 结果怎么自动回到 Lead,而不是让 Lead 反复轮询收件箱?
- 空闲队友能不能直接接手 ready task,不再等 Lead 一项项派发?
- 并行修改可能互相冲突时,任务该用哪个工作目录?
- 关机和计划审批,怎么变成可追踪、可执行的协议,而不是靠猜消息意图?
解决方案全景(模块架构图)
s13 复用 s10 的任务系统、Hooks、权限,再叠加一套由 Lead 管理的**“团队运行时”**。
┌─────────────────────────────────────────┐
│ Lead (主线程) │
│ 提方案 → 等确认 → 建任务/worktree → 派活 │
└───────────────┬───────────────┬──────────┘
spawn │ │ consume_lead_inbox()
▼ ▼
┌──────────────────────┐ .mailboxes/lead.jsonl
│ TeammateRuntime │ (队友事件 → 注入 history)
│ (每队友一个线程) │
└───┬───────┬───────┬───┘
WORK │ │ │ IDLE
┌───────────────┘ │ └──────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌────────────────┐ ┌──────────────────┐
│ 基础工具 │ │ MessageBus │ │ 空闲任务发现 │
│ bash/read/... │ │ (.mailboxes/) │ │ claim_next_task │
│ cwd=任务目录 │ │ send/read/等待 │ │ (扫 ready task) │
└──────┬───────┘ └───────┬────────┘ └──────────────────┘
│ │
▼ ▼
┌──────────────┐ ┌────────────────┐
│ 任务系统 │ │ 团队协议 │
│ .tasks/*.json│ │ 关机/计划审批 │
│ owner/status │ │ request_id 关联 │
└──────┬───────┘ └────────────────┘
│
▼
┌──────────────────────┐
│ 可选 Worktree │
│ .worktrees/ │
│ 分支 wt/ │
└──────────────────────┘
七类核心机制总览
| 机制 | 职责 | 所在层级 |
|---|---|---|
| Lead | 和用户对话,提出分工方案并等待确认 | 控制层 |
| 队友(Teammate) | 跑独立 Agent Loop,在 WORK 和 IDLE 之间切换直到关机 | 执行层 |
| MessageBus | 通过文件收件箱传递普通消息、结果和控制事件 | 通信层 |
| 运行时投递 | 消费 Lead 收件箱,把团队事件注入下一轮对话 | 通信层 |
| 共享任务板 | 空闲队友发现 ready task,并在锁内原子认领 | 协调层 |
| 可选 worktree | 需要时把任务绑定到另一个工作目录;未绑定任务仍用仓库目录 | 隔离层 |
| 类型化协议 + 计划闸门 | 显式记录关机/审批状态,并在计划获批前锁住修改型工具 | 安全层 |
注意:s11 的后台任务、s12 的定时任务没有被带入本章——它们不参与队友通信、任务认领或计划审批。
模块逐一拆解
模块 1:任务系统(共享任务板)
做什么
把"要做什么"持久化成一条条任务记录,支持三个核心维度:
- 依赖(
blockedBy):任务之间的先后约束 - 状态机(
pending → in_progress → completed):任务的生命周期 - 归属(
owner):谁在负责这个任务
这是所有协作的真相源(Single Source of Truth)。
怎么做
- Task 数据结构:是一个
dataclass,序列化到.tasks/task_<8位hex>.json文件 - ID 生成:
create_task用secrets.token_hex(4)生成唯一 ID(避免和其他进程撞 ID) - 依赖检查:
can_start(task_id)检查blockedBy里每个依赖都已completed,缺失依赖视为 blocked——这是后续"自动解锁下游任务"的基础 - 原子认领:
claim_task(task_id, owner)在task_store_lock()内把owner/status一次性写下去,并把{task_id, cwd}记进teammate_assignments[owner](每个队友同时只有一个 assignment) - 完成任务:
complete_task(task_id, owner)校验"调用者就是 owner"且"计划闸门没拦着"才置completed,并列出因此被解锁的下游任务
关键安全点
task_store_lock() 同时拿了两把锁:
| 锁类型 | 保护对象 | 作用范围 |
|---|---|---|
进程内 RLock(threading.Lock) |
内存中的数据结构 | 同一进程内的多线程并发 |
fcntl 文件锁(.tasks/.lock) |
磁盘上的任务文件 | 跨进程 / 重启后的安全 |
save_task采用先写临时文件再os.replace原子替换的策略,防止写到一半崩溃留下半截 JSON。
为什么两把锁都要? 见下方模块 6 的详细解释。
模块 2:任务绑定的 Worktree(可选隔离目录)
做什么
当并行修改会互相踩脚(比如 A、B 都要改 config.py)时,给任务一个独立的 git worktree 目录 + 分支 wt/<name>,让两个队友各写各的,互不干扰。
怎么做
create_worktree(name, task_id)只允许 Lead 调用。它先校验一长串前置条件:- 名字合法
- 任务必须
pending且无人认领且未绑定 - 当前目录是 git 仓库根
- 分支名合法且分支不存在
- 路径未注册
- 校验全部通过后,才执行
git worktree add -b wt/<name> - 最后才把
task.worktree = name写回任务文件
Partial Operation 处理
如果 git worktree add 报错了但已经留下了分支或已注册的 checkout,运行时不会偷偷删掉,而是报 “partial operation”(部分操作失败),任务保持未绑定状态,并把产物(路径/分支/注册项)列出来供人工恢复。
工作目录解析
| 场景 | 行为 |
|---|---|
| 任务没绑 worktree | 使用仓库 WORKDIR |
| 任务绑了 worktree 但绑定损坏 | 失败关闭(fail closed)——绝不像旧版那样悄悄回退到仓库目录 |
| 进程重启后 | 能根据 .tasks/ 里持久化的 owner/worktree 恢复进行中的 assignment;一旦 work version 对不上就失效(崩溃可重建) |
清理规则
remove_worktree 只给宿主/用户调用,永远保留 wt/<name> 分支(方便事后审查);拒绝 pending/in_progress 绑定,以及当前轮次仍在占用的 lease。
模块 3:基础工具与工作目录锁
做什么
bash / read_file / write_file / edit_file / glob 五个工作区工具,全部通过 assignment_cwd(owner) 解析 cwd——队友只能改自己认领到的任务目录。
怎么做
run_agent_bash/read/write/edit/glob等包装器先调_agent_cwd():没有认领任务直接返回错误,不回退仓库- 这把"能改哪里"从运行时层面硬锁死,而不是靠提示词劝导
safe_path校验路径不逃逸 workspacerun_bash超时 120s、输出截断 50k 字符
设计哲学:安全靠机制保障,不靠 LLM 自觉。
模块 4:MessageBus 邮件总线(通信放在模型上下文之外)
为什么不能共享 messages 数组?
Lead 和队友不能共享同一个 messages 数组——否则一个队友的工具结果会混进另一个队友的推理上下文,导致混乱。所以通信必须搬出模型上下文,走文件。
怎么做
每个 Agent 在磁盘上有一个文件 .mailboxes/<名字>.jsonl,这就是它的"信箱":
| 操作 | 实现 | 特点 |
|---|---|---|
发送 send |
在信箱文件末尾追加一行 JSON(一行 = 一条消息) | 追加后调用 notify_all() 唤醒可能在等的线程 |
接收 read_inbox |
读文件全部内容,读完就删空(destructive read / 破坏性读) | 保证每条消息只被消费一次 |
等待 wait_for_messages |
用 threading.Condition 锁阻塞等待 |
没消息就短时睡眠(IDLE 轮询间隔约 2 秒),消息一到就被唤醒——避免忙等占 CPU |
结果上报机制
队友的汇报不是靠 Lead 主动去问(轮询),而是往 Lead 的信箱文件里塞一行 JSON。Lead 主循环读信箱时顺手把内容塞进下一轮对话——所以 Lead 永远是**“被动唤醒”**,不轮询。
模块 5:进程锁 + 文件锁(双重锁定机制)
为什么需要两把锁?
| 锁类型 | 锁的是什么 | 防的是什么 |
|---|---|---|
进程锁(threading.Lock) |
同一进程内的内存 | Lead 主线程 + alice/bob 线程都在同一个 Python 程序里,共享内存。谁拿到锁谁进临界区改任务板,别人阻塞等待 |
文件锁(fcntl.flock) |
磁盘上的文件本身 | 由操作系统管理,跨进程有效。程序崩了、重启成新进程,OS 仍按文件锁裁决,新进程拿不到锁就被挡 |
少一把都有洞
- 只有进程锁 → 程序一重启,锁随进程消失,两个新进程能同时改任务板 → 数据竞争
- 只有文件锁 → 同进程内频繁加文件锁开销大、逻辑绕、性能差
- 两把一起 = 双保险:队内用进程锁(轻量快速),跨进程/重启用文件锁(安全可靠),谁都抢不过去
模块 6:团队协议(关机 / 计划审批的类型化消息)
做什么
普通协作可以用自由文本,但关机和审批不能靠猜消息意图。它们用结构化协议来确保可靠传递和正确匹配。
怎么做
-
ProtocolState记录一条控制消息,包含以下字段:字段 作用 request_id把回复关联到原始请求(防错配) type消息类型(如 shutdown_request、plan_approval_response)——阻止不匹配的回复改状态sender发送者身份 target目标接收者 status当前状态——阻止同一回复重复生效 payload消息载荷(具体内容) work_version工作版本号(防越权) task_id关联的任务 ID -
三字段联合防错配:
request_id:把回复关联到请求type:阻止不匹配的回复改状态(关机回复不能改计划状态)status:阻止同一回复重复生效
-
consume_lead_inbox:CLI 主循环消费 Lead 收件箱时,对*_response类型调用match_response更新协议状态,再把事件注入 history。
模块 7:空闲任务发现(队友自己找活,不靠 Lead 派发)
做什么
空闲队友自动发现 ready task 并接手,Lead 不必逐项派发。这大大减少了 Lead 的协调负担。
怎么做
scan_unclaimed_tasks()只做"快照":返回满足以下所有条件的任务列表:status = pendingowner = None(无人认领)can_start = True(依赖已满足)- worktree 可用(如果需要的话)
claim_next_task(name)遍历快照逐个调claim_task,只认领第一个真正可用的;且当前已有 assignment 的队友直接返回 None(永不拿第二份活)- 关键设计:扫描只是快照,真正的原子认领在
claim_task持锁内进行——多个队友同时发现同一任务,也只有一个能把它推进到in_progress
模块 8:队友运行时 TeammateRuntime(持久队友的 WORK/IDLE 循环)
做什么
一个持久执行单元——拥有独立 system prompt、messages、工具,在线程里跑 WORK → IDLE → WORK 直到关机。
和 s06 一次性 subagent 的根本区别:subagent 干完就销毁;TeammateRuntime 是持久的,可以在多个任务之间切换。
怎么做
初始化 __init__
- 构造专属 system prompt(如"你是 alice,一个 coder…")
- 把已分配任务注入首条 user 消息
- 若
require_plan则附上"需先交计划"的指示 - handlers 表挂载:
bash / read / write / edit / glob / send_message / submit_plan / list_tasks / claim / complete
工作模式 work()
每轮的流程:
- 先
handle_inbox消化消息(可能触发停机) - 跑一次 LLM
- 如果返回
tool_use→ 执行工具 → 返回continue(继续工作循环) - 如果返回文本总结 → 发
result消息给 Lead - 如果计划闸门是
pending→ 转waiting_approval(等待审批) - 否则 → 释放 assignment → 转
idle→ 发idle_notification
空闲等待 wait_for_work()
IDLE 循环里的优先级顺序:
- 先查收件箱(直接消息优先——可能是 Lead 的指令或关机请求)
- 再
claim_next_task找 ready task 注入 messages(自动接活)
消息处理 handle_inbox()
分流处理不同类型的消息:
| 消息类型 | 处理方式 |
|---|---|
shutdown_request |
回 shutdown_response 并 return stop(退出循环) |
plan_approval_response |
更新计划闸门状态 |
plan_request |
处理计划提交 |
普通 message |
注入 messages 让 LLM 处理 |
主循环 run()
continue → idle → stop 主循环;退出时 release_teammate_assignment 把没干完的活退回任务板,并清理 active_teammates / plan_gates。
模块 9:启动队友 spawn_teammate(先认领初始任务,再起线程)
做什么
在一个子线程里启动一个持久队友。
怎么做
- 校验名字:1–64 字母数字/下划线/短横,不能是
lead/agent保留名,不与现有队友重名 - 注册
active_teammates[name]=working、plan_gates、assignment_versions - 若带了
task_id,先claim_task(认领失败就整段回滚,绝不留下半启动的队友) - 构造
TeammateRuntime、起 daemon 线程
重要边界
Lead 的系统提示词里写死**“先提方案、等用户确认,确认前不许调 spawn_teammate”。所以"开不开团队"这个成本/并发决策始终握在用户手里**,模型不能擅自开队友烧 token。
模块 10:计划闸门 Plan Gate(改文件前先看计划)
做什么
在队友动文件/跑 shell 之前,强制它先交计划并获批。防止队友"拿到任务就直接开干"导致方向偏差。
怎么做
- 闸门状态机:
plan_gates[teammate] ∈ { not_required, required, pending, rejected, approved } - 拦截时机:
_run_teammate_tool在bash / write_file / edit_file前检查 gate——不是approved就直接Blocked - 提交流程:
submit_plan建 pending 请求 + 把 gate 设pending - 审批流程:
review_plan(Lead 侧)审批通过才设approved,且校验work_version / task_id仍一致才生效 - 版本联动:认领或释放任务会
advance_assignment_version,使旧审批自动失效——防止"换了个任务还沿用旧计划"的越权行为
模块 11:钩子与权限(工具调用前后拦截)
做什么
在所有工具调用前后插一道拦截层——覆盖危险命令、工作区外路径、日志记录、大输出截断、会话总结等场景。
怎么做
- 四类 Hook:
UserPromptSubmit / PreToolUse / PostToolUse / Stop - 权限检查
check_permission:- 比对
DENY_LIST(如rm -rf /、sudo) - 比对
DESTRUCTIVE(如rm、> /etc/) - 队友线程里
prompt_user=False:直接返回 permission 错误交 Lead 处理(队友不能从后台线程读用户输入) - Lead 线程里才交互式问用户
- 比对
- 执行链路:
execute_tool串起PreToolUse → handler → PostToolUse
模块 12:Lead 主循环 + CLI 事件驱动(双路唤醒)
做什么
Lead 主循环同时监听终端输入和收件箱消息两条通路,实现双路唤醒。
怎么做
agent_loop:跑 LLM,遇到tool_use时execute_tool,否则结束本轮wait_for_cli_event:用select同时等sys.stdin(用户输入)和BUS.peek("lead")(收件箱消息):- 收件箱有消息 → 返回
"wake" - 否则显示
s13 >>提示符等用户输入
- 收件箱有消息 → 返回
- 主循环逻辑:
- 收到用户输入 →
agent_loop - 收到
"wake"→consume_lead_inbox把事件格式化成[Team events]注入 history →agent_loop
- 收到用户输入 →
核心优势:队友事件到达会自动唤醒 Lead 新一轮,Lead 不必轮询收件箱或 list_teammates。
端到端完整流程
以一个具体的重构需求为例,串联所有模块:
用户:把后端重构拆到共享任务板,尽量并行完成配置、认证和测试。
认证任务用 worktree。
① Lead 提方案(模块 8 边界:用户确认后才启动)
"建议按 config / auth / tests 三个方向分工,是否启动?"
用户:"开始吧"
② 建任务 + 绑 worktree(模块 1、2:Lead 侧操作)
create_task(config) → create_task(auth) → create_task(tests)
create_worktree("auth-refactor", auth_task_id) # auth 走独立目录
③ 派活:spawn_teammate 在线程启动前原子认领初始任务(模块 8)
spawn alice → claim config (cwd=仓库根目录)
spawn bob → claim auth (cwd=.worktrees/auth-refactor)
④ 队友 WORK 循环(模块 7、3:队友独立执行)
alice/bob 各自跑 LLM + 工具干活(cwd 锁定到各自任务目录)
→ complete_task → 发 result + idle_notification 到 lead.jsonl
⑤ 运行时投递(模块 4、11:消息回到 Lead)
主循环 consume_lead_inbox
[Team events] bob: result "认证已重构,测试通过"
→ 注入 history → 唤醒 Lead 新一轮 → Lead 继续协调
⑥ 空闲自动接活(模块 6:不需 Lead 逐个派发)
alice 干完 config → IDLE → wait_for_work
→ 扫到 tests 还没人认领 → claim_next_task → 自动开干
⑦ 平滑关机(模块 5、6:协议驱动)
Lead request_shutdown(bob)
→ shutdown_request → bob 完成当前步 → shutdown_response
→ bob 循环退出,release_teammate_assignment 把活退回任务板
落盘文件一览
| 路径 | 内容 | 用途 |
|---|---|---|
.tasks/*.json |
任务状态/归属/依赖 | 协作的真相源,崩溃可重建 |
.mailboxes/*.jsonl |
消息/结果/协议响应 | 通信记录,支持投递和恢复 |
.worktrees/<name> |
隔离工作目录 | 并行修改的文件隔离(分支 wt/<name> 始终保留) |
崩溃后可由
.tasks/+.mailboxes/重建现场。
一句话串全流程
用户给目标 → Lead 提方案等确认(模块 8 边界)→ 建任务/绑 worktree(模块 1、2)→ spawn 时原子认领初始任务(模块 8)→ 队友 WORK 循环用 cwd 锁干活(模块 3、7)→ 结果经 MessageBus 回到 Lead(模块 4、11)→ 空闲队友自动 claim 下游任务(模块 6)→ 协议驱动的关机/计划审批收尾(模块 5、9)
相对 s10 的变化对比
| 维度 | s10(单 Agent 任务系统) | s13(Agent Teams) |
|---|---|---|
| Agent 数量 | 单个 Agent | 一个 Lead + N 个持久队友 |
| 用户流程 | 直接执行请求 | 先提团队方案,用户确认后再启动 |
| 通信机制 | 无 | 文件收件箱 + 运行时自动投递 |
| 生命周期 | 一个循环 | 队友 WORK / IDLE / shutdown 多状态 |
| 共享工作方式 | 单 Agent 用任务工具 | IDLE 扫描 + 队友原子认领 |
| 工作目录 | 仓库 WORKDIR |
必须认领任务;任务可选绑定 worktree |
| 结果上报 | 当前 Agent 输出 | 分开的 result 与 idle_notification |
| 控制机制 | 无 | 类型化关机与计划审批协议 |
| 执行约束 | 无团队约束 | 计划闸门会锁住修改型工具 |






