协作协议与计划门控
用稳定 requestId、严格响应匹配和硬门控处理审批、关机与重试。
先看它怎样跑起来
这一章不是几个孤立知识点,而是一条会产生结果的因果链。
协作协议与计划门控
亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。
- 自由文本不能可靠表达“正在解决哪个请求”。
- shutdown 要等待当前消息完成再关闭。
- plan_gate 必须在 handler 前拒绝未批准的写入。
顺着原文把边界看清
01导读:问题背景与本章目标队友能干活之后,下一个暴露出来的问题往往不是能力,而是协调。⌄
队友能干活之后,下一个暴露出来的问题往往不是能力,而是协调。
前一个版本里,Lead 可以创建持续队友、给他们发消息、接收结果。但普通 task/message/result 只表达内容,不表达“这条回复正在解决哪个请求”。顺利路径下这没有问题,一旦涉及审批、并发和重试,靠自由文本猜上下文就不够了。
两个场景最能说明问题。
场景一是关机。Lead 想让 Alice 停下来,但不能在她写文件时直接取消。正确顺序是:先让当前消息完成,再处理结构化 shutdown 请求,发回响应,然后结束 worker;Runner 资源仍由组合根统一关闭。
场景二是计划审批。Bob 要重构认证模块,他先提交计划,Lead 决定批准或驳回。只要计划仍是 pending 或 rejected,写入和执行类工具就必须在 handler 之前被拒绝,不能只靠 Prompt 提醒模型“先等批准”。
这两个场景的共同结构是:请求先登记,消息携带稳定 ID,响应严格匹配请求,状态只迁移一次。
第 16 章在第 15 章的持续 Teammate 和持久 Mailbox 上,只增加 protocol 与 plan_gate 两项能力。
02问题的本质:消息送达不等于协议完成普通 Mailbox 已经解决了持久投递问题:每条消息拥有稳定 UUID,并经过 ready -> processing -> done/quarantine。但它不知道下面这些事实:⌄
普通 Mailbox 已经解决了持久投递问题:每条消息拥有稳定 UUID,并经过 ready -> processing -> done/quarantine。但它不知道下面这些事实:
shutdown_response是否对应某个真实存在的 shutdown 请求;- 响应双方是否正好是原请求的 target 和 sender;
- 请求是否仍为 pending,是否已经过期;
- 同一个响应因 Mailbox ack 故障重试时,是否已经消费过;
- 一个不同 transport message 携带相同 request ID 时,是否属于重复响应。
因此 P16 没有把协议塞进普通 content 字符串,也没有让模型解析一段自由文本 JSON。它增加了一层确定性运行时:
Lead / Teammate 协议工具
|
v
ProtocolRuntime
先登记 ProtocolRequest,再发送 typed protocol message
|
+----> JsonProtocolStore
| .agent_tutorial/protocol/state.json
|
+----> FileMailboxStore
ready -> processing -> done/quarantine
|
v
TeammateRuntime
协议消息先走确定性 router,普通消息才进入模型相关实现集中在以下文件:
- [协议领域模型、工具与路由](code/chapters/ch16/src/features/protocol.ts)
- [协议单快照 store](code/chapters/ch16/src/adapters/protocol-json.ts)
- [普通与协议 Mailbox 模型](code/chapters/ch16/src/features/mailbox.ts)
- [联合消息文件 adapter](code/chapters/ch16/src/adapters/mailbox-json.ts)
- [持续队友协议 host](code/chapters/ch16/src/features/teammates.ts)
- [权限组合](code/chapters/ch16/src/core/permissions.ts)
- [章节组合根](code/chapters/ch16/src/bootstrap.ts)
- [CLI 组合入口](code/chapters/ch16/src/cli.ts)
协议没有创建第二套消息目录、第二个 EventInbox 或第二个 Agent Loop。
03公开工具:Lead 两个,队友一个| Lead | requestshutdown | teammate | 先登记 pending shutdown request,再发送 typed request |⌄
P16 的公开差量只有三项:
| 角色 | 工具 | 输入 | 可观察结果 |
|---|---|---|---|
| Lead | request_shutdown | teammate | 先登记 pending shutdown request,再发送 typed request |
| Lead | review_plan | request_id、approve、可选 feedback | 向原计划提交者发送 typed response |
| Teammate | submit_plan | plan | 先登记 pending plan request,再发送给 Lead |
Lead 的 P15 工具序列保持完整前缀,只在末尾追加:
request_shutdown, review_plan队友工具严格为:
shell, read_file, write_file, send_message, submit_plan队友不会获得 spawn_teammate、request_shutdown 或 review_plan。Lead 也不会获得 submit_plan。
原文差量表曾单独列出 request_plan,但正文没有定义它的输入、状态迁移或响应方。当前实现不增加这个孤立工具,也不保留兼容别名。计划请求由队友主动调用 submit_plan 发起。
04ProtocolRequest:持久请求才是状态真相请求不是一个进程内全局字典。领域对象使用规范 UUID、明确参与方、aware UTC 和封闭状态:⌄
请求不是一个进程内全局字典。领域对象使用规范 UUID、明确参与方、aware UTC 和封闭状态:
interface ProtocolRequest {
readonly id: string;
readonly kind: "shutdown" | "plan_approval";
readonly sender: string;
readonly target: string;
readonly status: "pending" | "approved" | "rejected";
readonly content: string;
readonly createdAtUtc: Date;
readonly expiresAtUtc: Date;
readonly resolution: ProtocolResolution | null;
}关键不变量如下:
- sender 和 target 不能相同,名字必须是安全的小写 Agent slug;
- ID 必须是规范 UUID,不接受六位随机短 ID、大小写变体或别名;
- pending 请求不能带 resolution;approved/rejected 请求必须带匹配的 resolution;
- resolution 时间不能早于请求创建,也不能落在或超过过期时刻;
- 默认响应窗口是 5 分钟,区间为
[created_at_utc, expires_at_utc)。
JsonProtocolStore 把全部请求保存到:
.agent_tutorial/protocol/state.json这是一个带 version 的单一快照。创建、查询、响应校验和终态替换都由同一个跨进程 proper-lockfile 锁串行化;写入使用临时文件、fsync 和原子 rename。持久化失败时,旧字节和旧状态保持不变。
同一进程内,JsonProtocolStore 还会先通过进程内 mutex 串行化操作。它解决的是多个 store 实例在同一 Agent 进程内同时访问 state.json 的问题;跨进程竞争才由 proper-lockfile 的 .protocol.lock 接管。两个锁解决的问题不同,因此不能互相替代。
快照数组保留锁内创建顺序。两个请求可以拥有相同 UTC 时间,但后追加的计划仍是“最新计划”;随机 UUID 不参与授权先后判断。若注入时钟真的倒退,新请求会明确失败,不会偷偷重排状态。
05第一条硬规则:先登记,再发送无论 shutdown 还是 plan approval,请求都先写入 ProtocolStore,再通过 Mailbox 发送:⌄
无论 shutdown 还是 plan approval,请求都先写入 ProtocolStore,再通过 Mailbox 发送:
const request = await protocol.requestShutdown("alice");
// request 已先写入 JsonProtocolStore,再由 TeammateRuntime 投递 typed Mailbox顺序不能反。如果先发送,接收方可能在请求登记前就回复,状态机将找不到 request ID。
发送失败也不能假装请求没有发生。当前实现抛出 ProtocolDeliveryError,同时保留已经持久化的 pending 请求,供诊断和恢复使用。P16 不自动创建第二个请求来掩盖失败,也不在后台无限重试。
06typed Mailbox:不把协议降级成 content JSONP15 的 MailboxMessageKind 仍然只有:⌄
P15 的 MailboxMessageKind 仍然只有:
task, message, resultP16 新增独立的 ProtocolMessageKind:
shutdown_request
shutdown_response
plan_approval_request
plan_approval_responseProtocolMailboxMessage 除共享的 transport 字段外,还显式包含 request_id 和 approved:
| 字段 | 约束 |
|---|---|
id | transport 自己的规范 UUID,也是 event ID 和 idempotency key |
sender / recipient | 安全 Agent 名,双方不能相同 |
kind | 必须来自独立 ProtocolMessageKind |
request_id | 对应持久 ProtocolRequest 的规范 UUID |
content | 非空请求正文或反馈 |
approved | request 必须为 null,response 必须为明确布尔值 |
created_at_utc | aware UTC |
普通消息 schema 没有增加可选协议字段。文件 adapter 使用严格联合类型解析两种 envelope,协议消息继续复用原来的 ready/processing/done/quarantine 原子迁移。
这使 router 可以在模型调用之前用类型分流,而不是依赖 content.startswith(...)、endswith("_response") 或自由文本约定。
07第二条硬规则:响应必须完整匹配2. response kind 与 request kind 配对;⌄
一条响应只有同时满足以下条件,才能改变请求状态:
request_id指向真实请求;- response kind 与 request kind 配对;
- response sender 等于原 request target;
- response recipient 等于原 request sender;
- request 仍为 pending;
- 当前时间严格早于
expires_at_utc。
错 ID、错类型、错参与方、过期响应和不同 transport 的重复响应都会明确失败,原状态不变。
这里还要区分两种“重复”:
- 同一个 protocol message ID 因 Mailbox ack 失败而重试:幂等返回已经完成的结果;
- 另一个 message ID 携带相同 request ID:拒绝为重复响应。
不同线程或不同 store 实例并发消费两个响应时,proper-lockfile 锁内只有一个能完成 pending -> approved/rejected 迁移。另一个重新读取快照后会看到 resolved 状态并失败。
08为什么 Lead 必须在 history 之后消费响应Lead 收到协议消息时,TeammateRuntime 分成两个阶段处理。⌄
Lead 收到协议消息时,TeammateRuntime 分成两个阶段处理。
drain 阶段只做只读验证,然后把 typed event 交给公共 Loop。Loop 先把 event 写入 canonical history。只有进入 acknowledge_events() 后,shutdown response 才会原子消费 ProtocolRequest,并把 Mailbox 文件从 processing 移到 done。
claim protocol response
-> validate only
-> append typed event to Lead canonical history
-> consume ProtocolRequest once
-> ack Mailbox processing -> done如果 ProtocolRequest 已消费,但 Mailbox ack 暂时失败,同一个 event 会重新进入 EventInbox。Runner 用稳定 event ID 避免重复 history;ProtocolStore 用相同 response message ID 幂等返回;下一次 ack 成功后才结束。
这仍是“至少一次 transport + 稳定 ID 幂等”,不是把任意外部副作用神奇地变成全局恰好一次。
09shutdown:完成当前消息,再走确定性路由shutdown request 进入队友的同一个持久 Mailbox。worker 每次只处理一个 processing 消息,因此正在执行的普通任务先完成,shutdown request 随后才会被 claim。⌄
shutdown request 进入队友的同一个持久 Mailbox。worker 每次只处理一个 processing 消息,因此正在执行的普通任务先完成,shutdown request 随后才会被 claim。
协议分流发生在模型调用之前:
当前 task/message 完成并 ack
-> claim shutdown_request
-> 校验 request ID、类型和双方
-> Teammate status = shutdown
-> 发送 shutdown_response(approved=true)
-> ack shutdown_request
-> 不调用模型shutdown 状态表示该 worker 不再接收新消息;队友 Runner 并没有被协议 handler 直接销毁。最终资源仍由 AgentRunner.close() 按 Teammate -> Cron -> JobSupervisor 的依赖逆序关闭。
如果 close 正好取消了“已 claim 请求、正在发送响应”的 worker,当前 request 会 release 回 ready。如果协议状态存储或响应投递失败,有效 transport 也会 release,并让 worker 进入可观察的 failed 状态;只有错 ID、错类型、错参与方、重复或过期这类消息本身无效的情况才进入 quarantine。
10计划审批:状态先变,Runner 后恢复-> create pending PLANAPPROVAL request⌄
计划流程从队友发起:
Teammate submit_plan(plan)
-> create pending PLAN_APPROVAL request
-> send plan_approval_request to lead
Lead event turn
-> typed request enters history, then Mailbox ack
-> review_plan(request_id, approve, feedback)
-> send plan_approval_response to original sender
Teammate worker
-> claim typed response
-> consume response in ProtocolStore
-> approved/rejected state becomes visible to permission gate
-> reuse the same Runner with a deterministic approval/rejection prompt顺序很关键。队友收到 response 后先完成状态迁移,再调用同一个 Runner。模型即使立刻请求写工具,权限层看到的也已经是 approved 或 rejected 状态。
review_plan 只接受仍为 pending 的 plan request。它不能拿 shutdown request ID 做计划审批,也不能把响应发给与原请求无关的队友。
11plangate:Prompt 不能授予执行权限P16 没有使用“请等待批准”作为唯一防线。计划 gate 是一条结构化 PermissionRule,通过公开组合入口追加到队友已有策略:⌄
P16 没有使用“请等待批准”作为唯一防线。计划 gate 是一条结构化 PermissionRule,通过公开组合入口追加到队友已有策略:
const teammatePolicy = basePolicy.withRules([protocol.planGateRule]);withRules() 保留原策略的 ApprovalProvider 和 AuditSink。最终顺序仍是:参数验证 -> PreToolUse -> PermissionPolicy -> dispatcher/handler -> PostToolUse。plan gate 的 deny 与工作区 deny 一样发生在 handler 和后台提交之前。
门控规则如下:
- 没有提交过计划时,不凭空强制所有任务走审批;
- 最新计划为 pending 或 rejected 时,写入、执行和其他 effectful 工具被拒绝;
read工具保持可用,队友仍能收集制定计划所需的信息;send_message和submit_plan保持可用,否则队友无法沟通或重新提交;- 最新计划 approved 后,允许按原权限策略执行多步计划;
- 已批准计划之后提交的新计划会成为最新状态,并重新关闭 effectful 工具。
批准不跳过原有权限。比如 write_file 原本还需要终端 ASK,那么计划 approved 后仍需人工明确批准,并照常写入 audit。plan gate 只增加限制,不会放宽系统边界。
12idle 不等于轮询原文示例使用 while 加 time.sleep(1) 轮询 inbox。这会长期占用线程,关闭时难以管理,还把 P17 的自驱 polling 提前塞进 P16。⌄
原文示例使用 while 加 time.sleep(1) 轮询 inbox。这会长期占用线程,关闭时难以管理,还把 P17 的自驱 polling 提前塞进 P16。
当前 Teammate 在完成一批消息后进入 idle,worker task 随即结束,但独立 Runner 和 history 保留。新的普通或协议消息持久写入后,TeammateRuntime 才通过共享 JobSupervisor 启动受管 worker。没有消息时没有 daemon、没有空转 task,也没有真实 sleep。
Lead 的普通 Mailbox 事件仍沿用 P15 的显式 run_events() 边界;P16 不新增空闲 Lead 自动找活。Cron 保留 P14 已有的 wakeup,不应与 Mailbox 协议混为一谈。
13组合根:一个 Mailbox,两种持久状态CLI 只构造一个 FileMailboxStore,TeammateRuntime 和 ProtocolRuntime 必须共享同一个对象:⌄
CLI 只构造一个 FileMailboxStore,TeammateRuntime 和 ProtocolRuntime 必须共享同一个对象:
const mailbox = new FileMailboxStore(workspace);
const teammates = new TeammateRuntime({
store: mailbox,
inbox: eventInbox,
supervisor,
cronRuntime,
});
const protocol = new ProtocolRuntime({
store: new JsonProtocolStore(workspace),
team: teammates,
});BuildDependencies 在 P16 显式要求 protocolRuntime。P15 传入未使用的 protocol dependency 会失败,P16 缺少它也会失败。Bootstrap 还验证:
- ProtocolRuntime 指向传入的同一个 TeammateRuntime;
- 两者共享同一个 MailboxStore;
- Teammate、Cron 和 Background 继续共享同一个 EventInbox 与 JobSupervisor。
构建通过后,Bootstrap 才为 Lead 注册两个协议工具,为队友 factory 追加 submit_plan,并把 plan gate 组合进队友权限策略。
14两个运行入口Set-Location 'F:\笔记\Agent实操\code'⌄
固定章节入口:
Set-Location 'F:\笔记\Agent实操\code'
npm run ch16 -- --prompt "创建 writer 队友,让她先提交修改 README 的计划,批准后执行并优雅关闭"通用入口:
Set-Location 'F:\笔记\Agent实操\code'
npm run agent-tutorial -- run --chapter 16 --prompt "创建 writer 队友,让她先提交修改 README 的计划,批准后执行并优雅关闭"普通与协议 transport 写入 .agent_tutorial/mailboxes/;请求状态写入 .agent_tutorial/protocol/state.json。
15与 Claude Code 的差异参照 learn-claude-code/s16teamprotocols/README.md 中深入 CC 源码的分析,P16 的 Protocol/Plan Gate 在教学简化上与 Claude Code 的真实团队协议存在以下差异:⌄
参照 learn-claude-code/s16_team_protocols/README.md 中深入 CC 源码的分析,P16 的 Protocol/Plan Gate 在教学简化上与 Claude Code 的真实团队协议存在以下差异:
关机响应。 P16 用统一的 shutdown_response 携带 approved: true 完成握手,由 ProtocolStore 原子消费一次。CC 真实源码把关机拆成 shutdown_approved 与 shutdown_rejected 两种独立消息;确认后还会发送 teammate_terminated 通知相关方,并清理 tmux/iTerm2 窗格、解除任务认领、从团队配置中移除成员。P16 的 shutdown 只结束队友消息循环,Runner 资源仍由组合根统一关闭。
计划审批来源。 P16 由队友显式调用 submit_plan 发起审批,Lead 用 review_plan 返回批准/拒绝。CC 真实源码中,计划审批主要由 ExitPlanModeV2Tool 在 plan-mode-required 队友退出计划模式时产生;useInboxPoller 会自动回写 approval 并把请求交给 Lead 作为普通上下文,同时保留显式 approve/reject 能力。CC 审批还可以附带 permissionMode,例如“批准但以 plan mode 运行”;P16 只实现 approve/reject/feedback 三要素。
工具命名。 P16 明确拒绝原文曾单独列出的 request_plan 孤立别名,Lead 只暴露 request_shutdown 与 review_plan,队友只追加 submit_plan;CC 教学版 s16 的 Lead 工具集则同时出现 request_shutdown、request_plan、review_plan。这不是功能缺失,而是刻意避免让教学实现维护一个没有状态迁移、没有响应方的伪协议。
消息 schema。 P16 的 ProtocolMailboxMessage 是独立 typed envelope,字段固定为 kind、request_id、approved,并复用 Mailbox 的稳定 message ID。CC 真实源码的协议消息同样是结构化 JSON,并通过 Zod schema 校验;但字段命名并不统一,permission 消息使用 request_id,shutdown 与 plan approval 使用 requestId。P16 用统一命名换可读性,CC 则保留了不同模块的真实演进痕迹。
执行门控。 P16 把 planGateRule 实现为 PermissionRule,pending/rejected 时 effectful 工具在 handler 和后台提交之前被 deny,属于真实硬门控。CC 教学版 s16 只演示 plan approval 消息流程,未在未批准时拦截 bash/write_file;CC 真实源码的队友有完整 permission gating。这也是 P16 向生产语义靠拢的部分。
持久化模型。 P16 用 JsonProtocolStore 把请求写入 .agent_tutorial/protocol/state.json,请求 ID 是规范 UUID,时间使用 aware UTC,并通过 .protocol.lock 保护跨进程并发。CC 教学版 s16 的 ProtocolState 存在进程内 pending_requests 字典,使用 req_xxx 短 ID 和 float 时间戳,重启即丢失。真实 CC 源码的消息是结构化 JSON,但教学版没有持久化状态真相。
投递与幂等。 P16 遵循先登记后发送,发送失败保留 pending。Mailbox 至少一次投递;ProtocolStore 用响应 message ID 保证同 transport 重试幂等,但不承诺全局 exactly-once。CC 教学版沿用 read + unlink 消费,进程在读取后处理前崩溃会丢消息,是至多一次语义。P16 的“响应严格消费一次”针对单个持久请求,不解决外部副作用去重。
idle 生命周期。 P16 的队友完成本轮后进入 idle 且 worker task 结束,新消息通过共享 JobSupervisor 启动受管 worker,没有 daemon 或 sleep。CC 教学版 s16 的队友在 LLM 返回非 tool_use 后每秒轮询 inbox,等待 shutdown_request 或新消息;CC 真实源码在 idle 时发送 idle_notification,让 Lead 能感知队友空闲。P16 的事件驱动模型不是对 CC 轮询的补丁,而是换掉了等待机制。
16从 ai-agent-book 学到什么:控制平面与优雅终止ai-agent-book 第 10 章正文与本章主题最贴近:它把不共享上下文的多 Agent 协作拆成数据平面与控制平面,用结构化信封、状态获取、优雅终止和任务生命周期解决“消息已经送达,但协作尚未完成”的问题。配套实验 10-4 的说明见 README,总线实现见 messagebus.py。⌄
ai-agent-book 第 10 章正文与本章主题最贴近:它把不共享上下文的多 Agent 协作拆成数据平面与控制平面,用结构化信封、状态获取、优雅终止和任务生命周期解决“消息已经送达,但协作尚未完成”的问题。配套实验 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)。
17ai-agent-book 的实验设计ai-agent-book 第 10 章指出,不共享上下文的多 Agent 系统需要两套基础设施:共享文件系统承载产物交换,形成数据平面;消息传递、状态查询、执行终止与资源调度组成控制平面。它特别强调,状态获取更自然的做法是主动发 statusupdate 或写约定进度文件,而不是让 Manager 无界轮询。执行终止应以优雅终止为首选,即 terminate -> 子 Agent 在安全点清理资源 -> ack -> 退出;强制终止只作兜底。⌄
ai-agent-book 第 10 章指出,不共享上下文的多 Agent 系统需要两套基础设施:共享文件系统承载产物交换,形成数据平面;消息传递、状态查询、执行终止与资源调度组成控制平面。它特别强调,状态获取更自然的做法是主动发 status_update 或写约定进度文件,而不是让 Manager 无界轮询。执行终止应以优雅终止为首选,即 terminate -> 子 Agent 在安全点清理资源 -> ack -> 退出;强制终止只作兜底。
配套的 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。第一个 target_found 在 asyncio.Lock 下结算,只允许一次 terminate 广播,后续迟到命中只记录不重复结算。输家收到 terminate 后在安全点取消、发送 ack 并关闭 browser context。README 用 context 创建/关闭计数、串行基线 wall-clock 和 provenance 事件流作为验收证据。ai-agent-book 还强调,模型可以提出“完成”,但不能批准自己的“完成”;完成结论必须经过独立验证或 harness 门控。
管理者模式部分则提醒:Manager 是单点瓶颈,计划质量是整个系统的上限。A2A 协议进一步把任务生命周期标准化为 submitted、working、needs input、completed、failed,让协作双方不依赖自由文本猜测任务状态。
18P16 的对照与取舍P16 没有照搬中心 Message Bus,而是把 ai-agent-book 的控制平面原则落到固定拓扑的持久 Mailbox 和 request/response 状态机上:⌄
P16 没有照搬中心 Message Bus,而是把 ai-agent-book 的控制平面原则落到固定拓扑的持久 Mailbox 和 request/response 状态机上:
- 消息总线 vs 持久 Mailbox。 ai-agent-book 的中心
MessageBus支持订阅和广播,适合动态 peer 集;P16 当前拓扑固定为 Lead 与多个平级队友,因此用持久 Mailbox 做点对点投递,不实现订阅、BROADCAST或动态生成队友。 - Envelope vs ProtocolMailboxMessage。 ai-agent-book 的
Envelope用sender_id/target/type/payload表达内容信封;P16 的ProtocolMailboxMessage增加request_id、approved,并进入持久 request/response 状态机。模型不再从自由文本里猜“这条回复对应哪个请求、是否已批准”。 - terminate/ack vs request_shutdown。 ai-agent-book 用
terminate做通用并行取消;P16 的request_shutdown是结构化优雅关闭:先完成当前消息,再在模型调用前路由 shutdown,向 Lead 发回结构化响应,然后结束 worker;Runner 资源仍由组合根统一关闭。 - 状态查询 vs 协议状态真相。 ai-agent-book 建议用
status_update或进度文件代替轮询;P16 不引入轮询,而是把请求持久化为.agent_tutorial/protocol/state.json,用pending/approved/rejected和 canonical history 表达协作状态,重启后仍可恢复。 - Manager 计划质量 vs plan gate。 ai-agent-book 指出规划质量决定 Manager 模式上限;P16 不把计划审批只交给模型记忆,而是用
submit_plan/review_plan加PermissionRule,在 effectful handler 和后台提交之前硬性拦截未批准计划。 - A2A 生命周期 vs P16 封闭状态机。 A2A 面向跨组织互操作,任务状态是 submitted/working/needs input/completed/failed;P16 是同一团队内的封闭协议,只实现 shutdown/plan_approval 两类请求和 pending/approved/rejected 三种状态,不引入 Agent Card、流式进度或跨组织发现。
- 真实实验 vs 离线验证。 ai-agent-book 用真实浏览器和真实 wall-clock 证明级联终止与资源审计;P16 聚焦可重复离线测试:注入确定 UUID、UTC clock 和模型替身,断言请求先登记、响应只消费一次、过期/错误参与方被拒绝、shutdown 不进入模型、资源全部关闭。
P16 明确不做的事: 没有中心 MessageBus、订阅过滤、BROADCAST、status_update、跨组织 A2A 或动态 peer 发现。ai-agent-book 的广播和开放协议适合 Agent 数量不固定、需要自主发现与互操作的场景;第 16 章先守住“固定团队、持久请求、严格匹配、硬门控”这条最小闭环,下一章再让队友基于共享任务板主动发现和认领工作。
19如何验证本章测试注入确定 UUID、UTC clock、模型和故障边界,不依赖真实 OpenAI、网络或 sleep:⌄
本章测试注入确定 UUID、UTC clock、模型和故障边界,不依赖真实 OpenAI、网络或 sleep:
- [协议 store 与 typed Mailbox 测试](code/chapters/ch16/tests/ch16-protocol.test.ts)
- [计划硬门控测试](code/chapters/ch16/tests/ch16-plan-gate.test.ts)
- [Teammate 协议路由测试](code/chapters/ch16/tests/ch16-runtime.test.ts)
- [P15/P16 增量与组合根测试](code/chapters/ch16/tests/ch16-bootstrap.test.ts)
- [双入口测试](code/chapters/ch16/tests/ch16-entry.test.ts)
运行聚焦验证:
Set-Location 'F:\笔记\Agent实操\code'
npm run test:ch16
npm run typecheck
npm run lint
npm run format:check
npm run build关键断言都是具体业务结果:
- P16 capability 严格等于 P15 加
protocol、plan_gate; - P15 普通消息枚举和序列化 schema 完全不变;
- 请求先持久登记,发送失败仍保留 pending 状态;
- ID 碰撞、坏状态和原子替换失败不覆盖旧字节;
- request ID、类型、双方、pending 和有效期必须全部匹配;
- 截止时刻已经过期,同 clock 请求仍按创建顺序判定最新,时钟倒退明确失败;
- 不同响应并发时只有一个完成迁移,同 transport 重试幂等,另一个 message ID 被拒绝;
- pending/rejected 计划让 effectful handler 零调用,approved 后仍保留原 approval/audit;
- busy 队友完成当前消息后才 shutdown,协议请求不进入模型;
- ack、取消、投递和协议存储故障保留可恢复 transport;无效协议消息才 quarantine;
- P16 Lead 与 Teammate 工具集合精确,两个 CLI 入口共享同一 transport 并关闭全部资源。
20本章明确不做什么- daemon thread、每秒 idle polling 或无人持有的 task;⌄
P16 只完成结构化协议和计划执行门控,没有提前实现:
- 未定义的
request_plan别名; - daemon thread、每秒 idle polling 或无人持有的 task;
- 自动扫描任务板、任务窃取、SQLite claim token 或 lease;
- Worktree 隔离、MCP 动态工具池;
- 把 pending 发送失败静默改成另一个请求;
- 对任意外部副作用的全局 exactly-once 承诺。
任务板扫描、并发认领和 shutdown 优先级属于第 17 章。P16 只保证协议请求可追踪、响应严格消费一次,并让计划状态在真正的工具执行边界生效。
21小结1. 请求使用规范 UUID、aware UTC 和持久单快照,先登记再发送。⌄
协议层的价值,是把隐式约定变成可验证状态:
- 请求使用规范 UUID、aware UTC 和持久单快照,先登记再发送。
- 协议拥有独立 typed Mailbox envelope,不污染 P15 普通消息 schema。
- 响应同时验证 ID、类型、双方、pending 和过期时间,并只迁移一次。
- Lead 先把 typed event 写入 canonical history,再消费状态并 ack transport。
- shutdown 在当前消息之后确定性处理,不进入模型;故障保留恢复机会。
- plan gate 位于权限硬边界,pending/rejected 时 handler 和后台提交均为零调用。
- idle 生命周期仍由事件驱动,没有轮询线程,也没有提前实现下一章的自驱找活。
做到这些以后,“请关机”“计划已批准”不再是模型之间靠语气猜测的聊天,而是可以持久恢复、严格匹配、并发验证和失败重试的运行时契约。下一篇再让队友基于共享任务板主动发现并认领工作。
换个场景,你还会判断吗?
每题只测一个边界。先做决定,再看解释。
准备开始