Teammate + Inbox
让持续队友保留身份和历史,通过持久 Mailbox 异步通信。
先看它怎样跑起来
这一章不是几个孤立知识点,而是一条会产生结果的因果链。
Teammate + Inbox
亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。
- 一次性 Subagent 与持续 Teammate 的生命周期不同。
- 消息投递不等于协议完成。
- 公共 Loop 负责消费事件,队友拥有自己的历史。
顺着原文把边界看清
01导读:问题背景与本章目标这句话说起来轻松,但真正踩到这个边界时,体感完全不同。一个后端重构任务同时涉及认证、数据库、API 路由和测试,单个 Agent 处理到后半程,前半程的细节已经离开上下文。它不是突然变笨,而是物理上看不见了。⌄
上下文窗口就那么大。
这句话说起来轻松,但真正踩到这个边界时,体感完全不同。一个后端重构任务同时涉及认证、数据库、API 路由和测试,单个 Agent 处理到后半程,前半程的细节已经离开上下文。它不是突然变笨,而是物理上看不见了。
第 6 章的一次性子 Agent 可以把独立任务放进干净上下文,完成后只把结果带回 Lead。但有些工作不是“一问一答”:队友需要保留自己的历史,接收后续补充,异步汇报进展,并在完成一轮工作后继续存在。
第 15 章实现的就是这组最小闭环:持续 Teammate、持久 Mailbox,以及把 Mailbox 消息重新送入公共 Agent Loop 的 typed event 通道。
02问题的本质:协作不是多开几个模型调用一次性子 Agent 和持续队友的差别,不在于有没有并发,而在于身份、历史、通信和生命周期由谁持有。⌄
一次性子 Agent 和持续队友的差别,不在于有没有并发,而在于身份、历史、通信和生命周期由谁持有。
| 边界 | 一次性子 Agent | 第 15 章 Teammate |
|---|---|---|
| 生命周期 | 完成一次委派后返回 | 完成后进入 idle,可接收后续消息 |
| 对话历史 | 每次委派独立 | 每个名字绑定一个独立 AgentRunner,后续继续复用 |
| 通信 | 返回最终结果 | 通过持久 Mailbox 双向发送文本消息 |
| 公开工具 | Lead 的 task | Lead 的 spawn_teammate、send_message |
| 状态 | 运行或结束 | running、idle、failed、shutdown |
持续存在不能只靠一个进程内 dict。如果消息读取后直接删除,Agent 在“读到消息”和“写入历史”之间崩溃,工作就消失了。如果多个 writer 追加同一个 .jsonl,还要额外解决并发写、部分行和读取游标。
因此本章没有实现一个 MessageBus 加 read_text() / unlink(),而是把协作拆成四个明确边界:
Lead 工具:spawn_teammate / send_message
|
v
FileMailboxStore
每消息一个文件,ready -> processing -> done/quarantine
|
v
TeammateRuntime
持有队友 Runner、状态和 worker,并组合 Mailbox/Cron event
|
v
AgentRunner.runEvents()
把 typed message 注入 canonical history,再执行 event-only turn相关实现集中在以下文件:
- [Mailbox 领域模型](code/chapters/ch15/src/features/mailbox.ts)
- [每消息一文件的 store](code/chapters/ch15/src/adapters/mailbox-json.ts)
- [持续队友运行时](code/chapters/ch15/src/features/teammates.ts)
- [typed EventInbox](code/chapters/ch15/src/core/events.ts)
- [公共 Agent Loop](code/chapters/ch15/src/core/loop.ts)
- [章节组合根](code/chapters/ch15/src/bootstrap.ts)
- [CLI 组合入口](code/chapters/ch15/src/cli.ts)
第 15 章严格等于第 14 章已有能力,再增加 TEAMMATE 和 MAILBOX。Cron、后台任务、权限、Hook、恢复和前面章节的工具都继续存在,不另起一套 Loop。
03公开工具:Lead 只增加两个const spawnTeammateInputSchema = z.object({⌄
Lead 相对第 14 章只追加两个工具:
const spawnTeammateInputSchema = z.object({
name: z.string(),
role: z.string(),
prompt: z.string(),
}).strict();
const sendMessageInputSchema = z.object({
to: z.string(),
content: z.string(),
}).strict();| 工具 | 必填字段 | 可观察结果 |
|---|---|---|
spawn_teammate | name、role、prompt | 创建独立 Runner,把首个 task 消息持久写入队友 Mailbox,并异步启动 worker |
send_message | to、content | 把 message 持久写入 Lead 或已存在队友的 Mailbox |
工具 schema 拒绝额外字段,也不为关键字段填默认值。Agent 名必须是安全的小写 slug,例如 alice、api-writer;路径片段、大小写混合、Windows 保留名和重复名字都会在创建工作前失败。lead 是保留名,不能被队友占用。
P15 的 Lead 工具序列完整保留 P14 前缀,只在末尾追加:
shell, read_file, write_file, edit_file, glob,
todo_write, task, load_skill,
create_task, get_task, list_tasks, claim_task, complete_task,
schedule_cron,
spawn_teammate, send_message这里没有 check_inbox。Lead 不靠模型猜测何时主动拉取消息;Mailbox 是运行时事件源,消息由 TeammateRuntime 发布到 EventInbox。
04MailboxMessage:先把消息建模成事件interface MailboxMessage extends RuntimeEvent {⌄
一条消息不是随手拼出的字典,而是严格、不可变的领域对象:
interface MailboxMessage extends RuntimeEvent {
readonly id: string;
readonly sender: string;
readonly recipient: string;
readonly kind: MailboxMessageKind;
readonly content: string;
readonly createdAtUtc: Date;
readonly idempotencyKey: string;
}kind 只有三种:
| kind | 用途 |
|---|---|
task | spawn_teammate 产生的首次任务 |
message | Lead 或队友主动发送的后续文本 |
result | 队友一轮 Runner 执行完成后的最终结果,或可观察失败结果 |
消息 ID 必须是规范 UUID,时间必须是 aware UTC datetime,sender/recipient 必须通过同一套 Agent 名校验,content 不能为空。原始 content 会原样保留,不通过隐式 strip() 改写消息正文。
MailboxMessage 同时满足 RuntimeEvent:
eventId = message.id;
idempotencyKey = message.id;这样 Mailbox 与 Cron 可以共享 typed EventInbox,同时保留各自的领域状态和确认逻辑。消息只在进入 Loop 的边界被序列化为普通 user message;内部不使用含混的文本前缀判断事件类型。
05每消息一个文件,状态由目录表达每条消息对应一个 {uuid}.json。目录名就是消息状态:⌄
Mailbox 数据位于当前 workspace:
.agent_tutorial/
.mailboxes.lock
mailboxes/
lead/
ready/
processing/
done/
quarantine/
alice/
ready/
processing/
done/
quarantine/每条消息对应一个 {uuid}.json。目录名就是消息状态:
| 状态 | 含义 |
|---|---|
ready | 已持久写入,尚未被消费者认领 |
processing | 已被一个消费者原子 claim,尚未确认 |
done | 已进入消费者的完成边界 |
quarantine | 文件或消息无效,或队友处理该消息失败 |
发送时先在目标目录创建临时文件,写入 UTF-8 JSON,flush 并 fsync,最后用 os.replace 提交到 ready。如果替换失败,目标消息和临时文件都不会残留。
claim 不是“读完再删”,而是同一把跨进程 FileLock 下的原子 rename:
ready/{id}.json
-- os.replace -->
processing/{id}.jsonack 同样通过 rename 完成:
processing/{id}.json
-- os.replace -->
done/{id}.json还要处理一个跨 runtime 时序:第二个 runtime 可能先把旧 processing 恢复为 ready,重新 claim 并完成 ack,而第一个消费者仍持有同一条消息。此时第一个消费者再次 ack,会在同一把锁内读取 done/{id}.json。完整消息相同就幂等返回成功,内容不同则抛出 MailboxStorageError。因此旧消费者不会把已完成消息重新排队,同 ID 冲突也不会被静默吞掉。
release 把 processing 放回 ready,quarantine 把它移到 quarantine。这些迁移失败时保留原文件,不能出现源和目标都不存在的半状态。
同一个 UUID 在所有 Agent、所有状态目录中必须唯一。store 发现冲突会明确失败,不允许第二条消息覆盖第一条。并发 writer 和并发 claimer 都经过锁保护;同一条 ready 消息最多只会被一个 claimer 移入 processing。
同一进程内,FileMailboxStore 还会先通过 withProcessMutex 串行化操作。它解决的是多个 store 实例在同一 Agent 进程内同时读写同一份状态目录的问题;跨进程竞争才由 .mailboxes.lock 接管。两个锁解决的问题不同,因此不能互相替代。
06FIFO、恢复与坏消息隔离文件名字按 UUID 排序并不等于消息顺序。claim 的权威 FIFO 键是:⌄
文件名字按 UUID 排序并不等于消息顺序。claim 的权威 FIFO 键是:
(created_at_utc, id)先比较 UTC 创建时间;时间相同时,用规范 UUID 文本稳定打破平局。这让重启后的顺序与目录枚举顺序无关。
processing 不是“已经完成”,因此运行时重建时需要恢复:
processing -> ready -> 再次 claimLead runtime 启动时恢复 Lead 的 processing 消息;创建队友时恢复该队友 Mailbox 中遗留的 processing 消息。这是至少一次投递:崩溃可能让同一消息重放,但不能因为进程中断把它静默丢掉。
坏消息也不能堵住整个收件箱。claim 和 recover 会逐个严格校验:
- 文件必须是普通
.json文件,文件名必须是规范 UUID。 - JSON 必须是 UTF-8,字段、消息 ID 和 recipient 必须与路径一致。
- 未知字段、坏 JSON、坏字节和路径错配会原子移入
quarantine。 - 隔离一个坏文件后继续寻找下一条合法消息。
如果 quarantine 已有同名文件,store 会选择不冲突的新名字,保留两份证据,不覆盖旧故障现场。
07TeammateRuntime:一个名字,一个持续 Runnerspawnteammate 返回时不等待队友完成。运行时为每个合法且未占用的名字创建一个内部 Worker 记录,其中持有:⌄
spawn_teammate 返回时不等待队友完成。运行时为每个合法且未占用的名字创建一个内部 Worker 记录,其中持有:
Teammate 快照
AgentRunner
当前受管 Promise task
当前 processing MailboxMessage首次 prompt 不直接塞进一个临时 messages[],而是先作为 task 消息落入队友 Mailbox。worker claim 后调用自己的 AgentRunner.run(content, { idempotencyKey: current.id })。该稳定 UUID 会进入这一轮所有工具的 ToolContext.idempotencyKey。消息重放时键不变,外部副作用实现可以据此去重。AgentRunner.run() 会在历史、Hook 和模型调用前拒绝空键,不能让损坏的幂等上下文退化成普通执行。
模型返回最终文本时,运行时把它作为 result 发给 Lead,然后才 ack 当前输入消息。稳定键提供去重依据,但 Mailbox 的权威语义仍是至少一次投递,不把任意外部动作承诺成跨进程恰好一次。
队友完成一轮后不会销毁 Runner:
spawn: running
一轮完成: idle
收到新消息: running
再次完成: idle
执行异常: failed
runtime close 完成: shutdownLead 后续调用 send_message(to="alice", ...) 时,如果 alice 处于 idle,运行时重新启动该 worker,但继续使用原来的 Runner。因此第二轮模型请求仍能看到第一轮的 user/assistant history。Alice 和 Bob 则各自持有不同 Runner,历史不会串线。
failed 和 shutdown 的队友不能继续接收消息。重复 spawn 同名队友时,即使两个调用并发到达,注册锁也只允许一个成功。
TeammateRuntime.has_pending_work 不把“持续队友仍存在”算作普通 turn 必须等待的工作。否则只要创建过一个队友,用户 turn 就可能永远等不到系统认为空闲。
08队友工具严格裁剪为四个Lead 继承前 14 章的完整工具面,队友没有。队友 Runner 只注册:⌄
Lead 继承前 14 章的完整工具面,队友没有。队友 Runner 只注册:
shell
read_file
write_file
send_message它没有 edit_file、glob、TODO、Skill、一次性子 Agent、Task DAG、Cron,也没有 spawn_teammate。团队结构固定为一个 Lead 加多个平级队友,队友不能继续生成队友。
工具少不等于绕过治理。每个队友 Runner 使用自己的名字作为 ToolContext.identity,并复用当前章节的 Hook、PermissionPolicy、Recovery 和 Compaction 边界。队友调用 read_file、write_file 或 shell 时,仍按公共顺序执行参数校验、Hook、当前权限和 handler。
队友的 send_message 也不是一个可以伪造 sender 的通用函数。工具输入只有 to 和 content,sender 来自当前 Runner 的可信 identity。
09Inbox 注入:先进入 canonical history,再 ack队友写给 Lead 的消息会从 ready claim 到 processing,再以 MailboxMessage 原对象发布到共享 EventInbox。Loop 空闲时执行显式 event-only turn:⌄
队友写给 Lead 的消息会从 ready claim 到 processing,再以 MailboxMessage 原对象发布到共享 EventInbox。Loop 空闲时执行显式 event-only turn:
EventInbox drain 一条 typed RuntimeEvent
-> 校验稳定 event_id
-> 序列化并追加到 Lead canonical history
-> 调用 TeammateRuntime.acknowledge_events()
-> Mailbox processing -> done
-> 模型执行这一条 event turn顺序不能倒过来。若先 ack 再追加 history,进程在两步之间崩溃会永久丢消息。当前契约要求 ack 时,canonical history 中已经存在且只存在一份对应事件。
ack 失败时,TeammateRuntime 把同一个事件重新发布到 EventInbox。Runner 已经记住该 event_id,重试时不会重复追加 history。确认成功后才会进行模型调用。于是一次可观察的 ack 持久化失败不会造成重复历史或重复模型 turn。
这仍然是“至少一次接收 + Runner 内稳定 ID 去重”,不是对任意外部副作用的全局恰好一次承诺。Mailbox message ID 会作为 idempotency key 传播,真正有外部副作用的工具仍需在自己的边界实现幂等。
10Mailbox、Cron 和 Background 共享运行时P15 不创建第二个事件队列或第二个 task owner。CLI 先创建一个 EventInbox,再让这些组件共享它:⌄
P15 不创建第二个事件队列或第二个 task owner。CLI 先创建一个 EventInbox,再让这些组件共享它:
EventInbox
<- JobSupervisor 的 background event
<- CronRuntime 的 CronEvent
<- TeammateRuntime 的 MailboxMessage
JobSupervisor
<- background job
<- cron scheduler
<- teammate workerTeammateRuntime 组合已有的 CronRuntime:drain/wait 继续走同一条 typed FIFO,ack 时由 Cron 和 Mailbox 各自确认属于自己的事件。Bootstrap 会验证传入的 TeammateRuntime 与 CronRuntime 持有的正是同一个 supervisor 和同一个 EventInbox;看起来相似但彼此断开的资源会在构建阶段失败。
这样 Lead 不需要轮询 .json 文件,也不需要 check_inbox。P15 把消息持久发布到 inbox 后,由宿主显式调用 AgentRunner.runEvents()。普通 user turn 的 drain 点只会暂存这类带 idempotencyKey 的事件,不会把它混入当前 turn。P15 也不会因为空闲 Lead 收到消息就自动启动新 turn。如果显式 runEvents() 遇到 Lead 正在处理用户 turn,Runner 的异步锁阻止并发进入同一 history,消息仍保留在 inbox/processing,等待后续 event turn。Cron 继续保留 P14 已有的自动 wakeup,这不是 Mailbox 在 P15 新增的行为。
11一次完整协作的状态流假设 Lead 要让 Alice 先读现有说明,再根据反馈补充内容:⌄
假设 Lead 要让 Alice 先读现有说明,再根据反馈补充内容:
Lead
-> spawn_teammate(name="alice", role="writer", prompt="阅读说明并列出缺口")
FileMailboxStore
-> alice/ready/{task-id}.json
Teammate worker
-> claim: alice/ready -> alice/processing
-> Alice Runner.run("阅读说明并列出缺口", { idempotencyKey: taskId })
-> read_file(...)
-> result: lead/ready/{result-id}.json
-> ack: alice/processing -> alice/done
-> Alice status = idle
Lead event turn
-> claim: lead/ready -> lead/processing
-> typed message 进入 Lead canonical history
-> ack: lead/processing -> lead/done
-> Lead 根据结果继续决策
Lead
-> send_message(to="alice", content="只补充恢复与失败边界")
Teammate worker
-> 复用同一个 Alice Runner 和既有 history
-> Alice status: idle -> running -> idleAlice 的 result 是持久消息,不是后台线程向终端打印的一行日志。打印只能让人看到;进入 Lead history 才能让模型继续协作。
12失败与关闭:P15 做到哪里队友执行模型或工具链时抛出异常,当前 processing 输入消息会进入 quarantine,队友状态变为 failed。运行时还会尽力向 Lead 发送一条包含失败原因的 result,让失败成为可观察事件,而不是只留在后台日志。⌄
队友执行模型或工具链时抛出异常,当前 processing 输入消息会进入 quarantine,队友状态变为 failed。运行时还会尽力向 Lead 发送一条包含失败原因的 result,让失败成为可观察事件,而不是只留在后台日志。
如果 TeammateRuntime.close() 发生在队友执行中,它会:
- 标记 runtime 关闭,拒绝新 spawn/send。
- cancel 并 await 所有在途队友 task。
- 当前 processing 消息通过 release 回到 ready,保留重放机会。
- 对每个队友依次尝试 release processing 消息并关闭 Runner;某一项失败不跳过其余队友。
- 把队友状态迁移为
shutdown;未完成的清理会保留,以便重试 close。
TeammateRuntime.close() 本身不关闭共享 CronRuntime 或 JobSupervisor。组合根把资源注册为:
resources = [
supervisor,
cronRuntime,
teammateRuntime,
];AgentRunner.close() 逆序关闭,所以顺序是 Teammate -> Cron -> supervisor。先停止会产生任务的运行时,再关闭唯一 task owner;CLI 构建失败时也保持同样的逆序清理。
某个资源关闭失败时,运行时仍会继续尝试剩余资源。全部尝试结束后,单个异常按原对象抛出,多个异常组成 AggregateError。未完成的队友清理会在下一次 close 重试。只有一轮无异常地完成全部关闭,组合根的 Runner 才进入最终 closed 状态。
这里的 shutdown 是本进程资源关闭状态,不是队友之间的 shutdown 消息协议。请求、批准和关闭握手属于下一章。
13组合根与两个入口P15 的 BuildDependencies 显式要求 teammateRuntime。P14 传入它会失败,P15 缺少它也会失败,避免 profile 声明能力但运行时没有接入。⌄
P15 的 BuildDependencies 显式要求 teammateRuntime。P14 传入它会失败,P15 缺少它也会失败,避免 profile 声明能力但运行时没有接入。
固定章节入口:
Set-Location 'F:\笔记\Agent实操\code'
npm run ch15 -- --prompt "创建一名 writer 队友,让她阅读 README 并汇报缺口"通用入口:
Set-Location 'F:\笔记\Agent实操\code'
npm run agent-tutorial -- run --chapter 15 --prompt "创建一名 writer 队友,让她阅读 README 并汇报缺口"消息状态会写入当前 workspace 的 .agent_tutorial/mailboxes/。
14与 Claude Code 的差异参照 learn-claude-code/s15agentteams/README.md 中深入 CC 源码的分析,P15 的 Teammate/Mailbox 在教学简化上与 Claude Code 的团队协作机制存在以下差异:⌄
参照 learn-claude-code/s15_agent_teams/README.md 中深入 CC 源码的分析,P15 的 Teammate/Mailbox 在教学简化上与 Claude Code 的团队协作机制存在以下差异:
存储模型。 CC 教学版为每个 Agent 使用一个 .jsonl 文件作为收件箱,read_inbox() 执行读文件 + unlink() 消费式读取,存在 read/unlink 竞态。P15 使用每消息一个 .json 文件,通过目录名(ready/processing/done/quarantine)表达消息状态,用 FileLock 保护原子 os.replace 迁移,并发 writer 和 claimer 都被锁保护。
公开工具。 CC 教学版为 Lead 暴露 spawn_teammate、send_message、check_inbox 三个新工具(共 14 个 Lead 工具)。P15 的 Lead 工具序列不包含 check_inbox,mailbox 是运行时事件源;消息由 TeammateRuntime 发布到 EventInbox,Lead 在显式 event turn 中接收,不需要模型猜测何时拉取。
消息读取方式。 CC 教学版由 Lead 显式调用 check_inbox 拉取消息。CC 真实源码的 useInboxPoller 每 1 秒检查一次收件箱,检测到消息后自动提交为新 turn,不需要用户输入。P15 把 mailbox 事件放入共享 EventInbox,由宿主显式调用 AgentRunner.runEvents() 处理。普通 user turn 的 drain 点只暂存带 idempotencyKey 的事件,不会因为 Lead 收到消息就自动启动新 turn。
队友实现。 CC 教学队友运行在 daemon 线程中,持有简化 system prompt、独立 messages 列表和 4 个子工具,教学版限制最多 10 轮对话后自动结束。CC 真实源码的队友由 spawnTeammate() 创建,可运行在 tmux 窗格或进程内,并用 idle loop 等待 inbox 消息。P15 的每名队友拥有独立 AgentRunner,Runner 持有完整历史、Hook 和 Recovery/Compaction 边界。完成一轮后进入 idle 状态而非销毁,后续消息复用同一 Runner 和既有 history。
消息类型。 CC 教学版消息只有 "message" 和 "result" 两种 type。CC 真实源码有 15 种结构化消息类型(包括 idle_notification、permission_request、permission_response、plan_approval_request、shutdown_request/approved/rejected、task_assignment 等),文本消息包装在 <teammate-message> XML 标签中交付模型。P15 的 MailboxMessageKind 只有 task、message、result 三种,消息同时实现 RuntimeEvent 接口,通过 typed event inbox 而非 XML 标签注入 history。
投递语义。 CC 教学版的 read_inbox 是 read + unlink,进程在读取后、处理前崩溃会永久丢失消息(至多一次投递)。CC 真实源码用 proper-lockfile 文件锁保护 JSON 数组收件箱。P15 采用每消息独立文件 + 目录状态机,processing 在重启时恢复为 ready,坏消息隔离到 quarantine;通过稳定一致 idempotency key 实现至少一次投递,同一 done 消息允许幂等 ack。
权限冒泡。 CC 教学版省略了权限冒泡。CC 真实源码使用双向轮询:队友发 permission_request 到 Lead 收件箱,Lead 的 useInboxPoller 检测并路由到审批队列;用户审批后 Lead 发 permission_response 回队友,队友的 useSwarmPermissionPoller(每 500ms)接收回复后继续或拒绝。P15 的队友和 Lead 复用同一套 Hook、PermissionPolicy、Recovery 和 Compaction,未单独实现跨 Agent 权限冒泡。
生命周期与关闭。 CC 教学版队友是 daemon 线程,10 轮后自动结束,无体面关闭协议。CC 真实源码的队友经历 spawn → work → idle(Stop hook 触发 idle_notification)→ shutdown(Lead 发 shutdown_request,队友回复 shutdown_approved 后清理)。P15 的队友状态机为 running → idle → failed/shutdown,关闭是进程内资源释放(cancel 在途 task、release processing 消息),不是跨 Agent 的 shutdown 握手协议。
队友工具面。 CC 教学版队友工具为 bash、read_file、write_file、send_message,真实 CC 队友还持有 TaskCreate、TaskUpdate 等工具,任务系统是团队共享的。P15 的队友工具注册 shell、read_file、write_file、send_message,显式排除 edit_file、glob、TODO、Skill、子 Agent、Task DAG、Cron 和 spawn_teammate;send_message 的 sender 来自可信 identity 而非工具输入。
团队注册表。 CC 真实源码通过 ~/.claude/teams/{teamName}/config.json 存储团队注册表,包含成员 ID、颜色和激活状态;P15 无持久团队注册表,队友信息和状态完全在 TeammateRuntime 进程内管理。
上述 CC 实现细节来自本仓库的参考快照 learn-claude-code/s15_agent_teams/README.md 及该快照引用的 CC 源码行号。
15从 ai-agent-book 学到什么:消息信封与可观察协作本地仓库的 ai-agent-book 第 10 章正文 最贴近本章:它把不共享上下文的多 Agent 协作拆成数据平面与控制平面,并用结构化信封和 Message Bus 解决异步通信。配套实验 10-4 的说明见 README,总线实现见 messagebus.py。⌄
本地仓库的 [ai-agent-book 第 10 章正文](ai-agent-book/book/chapter10.md) 最贴近本章:它把不共享上下文的多 Agent 协作拆成数据平面与控制平面,并用结构化信封和 Message Bus 解决异步通信。配套实验 10-4 的说明见 [README](ai-agent-book/chapter10/parallel-web-research/README.md),总线实现见 [message_bus.py](ai-agent-book/chapter10/parallel-web-research/message_bus.py)。
16ai-agent-book 的实验设计ai-agent-book 第 10 章强调,消息不能只是一段裸字符串,而应携带结构化信封。messagebus.py 的 Envelope 包含 senderid、target、type、payload、seq、ts,消息类型包括 taskassigned、statusupdate、result、terminate、ack。MessageBus 按订阅关系转发,支持点对点投递和 BROADCAST="" 广播。history 保留完整消息流,short() 输出带时间戳和全局序号的紧凑日志,让实验“看得见”每条协作事件。⌄
ai-agent-book 第 10 章强调,消息不能只是一段裸字符串,而应携带结构化信封。message_bus.py 的 Envelope 包含 sender_id、target、type、payload、seq、ts,消息类型包括 task_assigned、status_update、result、terminate、ack。MessageBus 按订阅关系转发,支持点对点投递和 BROADCAST="*" 广播。history 保留完整消息流,short() 输出带时间戳和全局序号的紧凑日志,让实验“看得见”每条协作事件。
实验 10-4 把这套总线用到了真实并行搜索上:Manager 动态启动多个同构 worker,每个 worker 使用独立 Playwright browser context;单站点超时或解析失败不会阻塞 peers。第一个 target_found 在 asyncio.Lock 下结算,只允许一次 terminate 广播,后续迟到命中只记录不重复结算。输家收到 terminate 后在安全点取消、发送 ack 并关闭 browser context。README 还要求串行路径访问同一批真实网站、使用同一抽取函数,用真实 wall-clock 和资源创建/关闭计数作为验收证据。
17P15 的对照与取舍P15 没有照搬中心 Message Bus,而是把 ai-agent-book 的结构化消息、可观察历史和资源审计原则落到持久 Mailbox:⌄
P15 没有照搬中心 Message Bus,而是把 ai-agent-book 的结构化消息、可观察历史和资源审计原则落到持久 Mailbox:
- 结构化信封。 ai-agent-book 用
Envelope携带 sender/target/type/payload;P15 的MailboxMessage携带id、sender、recipient、kind、content、createdAtUtc、idempotencyKey,并直接实现RuntimeEvent。消息类型由MailboxMessageKind限定为task/message/result,不需要用字符串前缀猜类型。 - 点对点 vs 发布订阅。 ai-agent-book 的中心
MessageBus支持订阅和广播,适合动态 peer 集;P15 当前拓扑固定为 Lead 与多个平级队友,因此用持久收件箱做点对点投递,不实现BROADCAST、不做新 Agent 自主订阅,也不让队友动态生成队友。 - 可观测性。 ai-agent-book 的
history和带时间戳日志让消息流可回放;P15 不把日志当作持久消息,而是用磁盘ready/processing/done/quarantine状态目录和EventInbox的 typed history 表达同一件事。测试可以断言 send/claim/ack/release/quarantine 后的 store 状态,而不是只看打印文本。 - 优雅终止与资源审计。 实验 10-4 用 terminate + ack + browser context 创建/关闭计数证明资源不漏;P15 的
TeammateRuntime.close()cancel/await 在途 worker、release processing 消息并尝试关闭每个 Runner,测试断言shutdown状态和可重试清理。P15 没有跨 Agent terminate/shutdown 握手,留给 P16。 - 失败隔离。 ai-agent-book 的单站点失败不阻塞 peers;P15 的队友执行失败会把输入移入 quarantine、状态改为
failed,并向 Lead 发布可观察失败result。Lead 仍可继续协作,不会因为一个队友的模型或工具错误整条链崩溃。 - 离线验证 vs 真实实验。 ai-agent-book 用真实浏览器和真实串行基线测 speedup,并用 provenance 保存 114 条总线事件、资源计数和 acceptance gates;P15 聚焦可重复离线测试:注入确定 UUID、UTC clock 和模型替身,断言状态迁移、事件流、幂等 ack、失败事件和资源关闭,不声称有真实 wall-clock 加速证据。
P15 明确不做的事: 没有中心 MessageBus、订阅过滤、BROADCAST、status_update、terminate/ack 总线协议,也没有跨 Agent 的 shutdown 握手。ai-agent-book 的广播和动态拓扑更适合 Agent 数量不固定、需要自主发现与订阅的场景;第 15 章先守住“固定团队、持久收件箱、typed event 注入唯一 Loop”这条最小闭环。
18如何验证本章测试注入确定 UUID、UTC clock 和模型替身,不依赖真实 OpenAI、真实 sleep 或网络。五组聚焦测试覆盖 store、持续队友、Loop 事件确认、组合根和双入口:⌄
本章测试注入确定 UUID、UTC clock 和模型替身,不依赖真实 OpenAI、真实 sleep 或网络。五组聚焦测试覆盖 store、持续队友、Loop 事件确认、组合根和双入口:
- [Mailbox store 测试](code/chapters/ch15/tests/ch15-mailbox.test.ts)
- [持续队友测试](code/chapters/ch15/tests/ch15-teammates.test.ts)
- [Mailbox event turn 测试](code/chapters/ch15/tests/ch15-loop.test.ts)
- [P14/P15 增量与组合根测试](code/chapters/ch15/tests/ch15-bootstrap.test.ts)
- [双入口测试](code/chapters/ch15/tests/ch15-entry.test.ts)
运行聚焦验证:
Set-Location 'F:\笔记\Agent实操\code'
npm run test:ch15
npm run typecheck
npm run lint
npm run format:check
npm run build关键断言都是可观察结果:
- P15 capability 严格等于 P14 加
TEAMMATE、MAILBOX。 - Lead 工具保持 P14 完整前缀,只追加
spawn_teammate、send_message。 - 队友工具严格为
shell、read_file、write_file、send_message。 - 两名队友并行执行,各自 history 隔离;idle 后的新消息复用原 Runner。
- 并发同名 spawn 只成功一次,非法名字和未知收件人在创建工作前失败。
- FIFO 使用
(created_at_utc, id),并发 writer 不丢消息,并发 claimer 不重复认领。 - send/claim/ack/release/quarantine/recover 的替换失败保留提交前状态。
- 重启把 processing 恢复为 ready;坏消息进入 quarantine 且不阻塞合法消息。
- 队友工具获得当前 Mailbox UUID 作为稳定 idempotency key;空键在任何 turn 副作用前失败。
- Mailbox event 由显式 event turn 接收,进入 canonical history 后才 ack;ack 失败重试不重复 history 或模型调用;跨 runtime 的相同 done 消息可幂等 ack,内容冲突明确失败。
- Cron 与 Mailbox 事件由各自 owner 确认,但共享一个 typed EventInbox。
- 队友失败会隔离输入并向 Lead 发布可观察结果;close 会 cancel、await 并留下
shutdown状态;单个资源关闭失败不跳过其余资源,多异常可观察且关闭可重试。 - CLI 两个入口构建共享 runtime,并按 Teammate -> Cron -> supervisor -> model 关闭。
19本章明确不做什么第 15 章只完成持续队友和持久文本 Mailbox 的最小闭环,没有提前实现:⌄
第 15 章只完成持续队友和持久文本 Mailbox 的最小闭环,没有提前实现:
check_inbox、list、cancel 或额外 Lead 工具- 结构化 request/response 协议
- 计划审批和权限请求冒泡
shutdown_request/shutdown_approved握手- 队友从共享 Task DAG 自主找活、抢占工作或空闲自驱
request/response、计划审批和 shutdown 握手属于第 16 章。空闲队友自主发现并领取工作属于第 17 章。P15 的 idle 只表示 Runner 保留且正在等待显式消息,不表示它会自己找任务。
20小结给 Agent 加队友,不是启动几个无人管理的后台线程。真正的工程边界是:⌄
给 Agent 加队友,不是启动几个无人管理的后台线程。真正的工程边界是:
- Lead 只通过两个严格工具创建队友和发送消息。
- 每个队友持有独立、可复用的 Runner history,并拥有明确状态机。
- 每条消息以独立文件经历 ready/processing/done/quarantine 原子迁移。
- processing 在重启时恢复,坏消息隔离,投递采用稳定 ID 的至少一次语义;同一 done 消息允许幂等 ack。
- MailboxMessage 通过共享 typed EventInbox 进入唯一 Agent Loop,先写 canonical history 再 ack。
- Teammate、Cron 和 Background 共用 JobSupervisor,并按资源依赖逆序、全资源尝试关闭。
做到这些以后,队友的进展不再是一行易丢的日志,Lead 也不需要靠 check_inbox 猜测消息何时到达;宿主可以在明确的 event turn 边界处理它。下一篇再在这条持久消息通道上增加 request/response、计划审批和 shutdown 握手。
换个场景,你还会判断吗?
每题只测一个边界。先做决定,再看解释。
准备开始