Book 第十六章:协作控制流是怎样被工具化的
第四部分:扩展与协作

第十六章:协作控制流是怎样被工具化的

拆 AskUserQuestion、ExitPlanMode、SendMessage、TodoWrite 这一组控制流工具。

1. 为什么这几个要一起拆

前面拆到文件、搜索、web、agent、skill 时,我们看到的主要还是“Claude 怎么干活”。

这一组工具开始,重心变成了另一件事:

Claude 怎么和人协作、怎么和别的 agent 协作、怎么把执行状态正式推进。

它们分别对应四种控制面:

  • AskUserQuestion:把“问用户”做成正式协议
  • ExitPlanMode:把“从计划进入执行”做成正式网关
  • SendMessage:把“agent 间通信”做成正式协议
  • TodoWrite:把“当前任务状态”做成正式状态对象

所以它们放在一起看,能看出 Claude Code 一个很重要的设计习惯:

只要某个动作会改变后续执行路径,Claude Code 就倾向于把它从自然语言里抽出来,做成可验证、可审批、可渲染的 tool 协议。

2. 先说结论

我对这一组工具的总体判断是:

Claude Code 不相信“模型自己说一句就算完成控制流切换”。

它更喜欢把关键控制动作显式建模出来:

  • 向用户提问,不靠自由文本,而是结构化问题模型
  • 请求计划批准,不靠“我准备好了”,而是正式退出 plan mode
  • 给别的 agent 传话,不靠 transcript 猜测,而是独立消息协议
  • 维护任务进度,不靠自然语言总结,而是专门的 todo / task 状态

换句话说,这一组工具不是在做“能力”,而是在做 governance layer

3. 源码锚点

这次主要看的源码是:

  • src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx
  • src/tools/AskUserQuestionTool/prompt.ts
  • src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts
  • src/tools/ExitPlanModeTool/prompt.ts
  • src/tools/ExitPlanModeTool/UI.tsx
  • src/tools/SendMessageTool/SendMessageTool.ts
  • src/tools/SendMessageTool/prompt.ts
  • src/tools/SendMessageTool/UI.tsx
  • src/tools/TodoWriteTool/TodoWriteTool.ts
  • src/tools/TodoWriteTool/prompt.ts

补充看的还有:

  • src/tools.ts
  • src/utils/tasks.ts
  • src/utils/todo/types.ts

4. 总体结构图

flowchart TD
    A["当前 agent"] --> B{"需要推进哪种控制流"}

    B -->|"向用户确认"| C["AskUserQuestion"]
    B -->|"计划转执行"| D["ExitPlanMode"]
    B -->|"给其他 agent 发消息"| E["SendMessage"]
    B -->|"更新当前任务状态"| F["TodoWrite"]

    C --> C1["结构化问题/选项/预览 schema"]
    C1 --> C2["requiresUserInteraction + ask 权限"]
    C2 --> C3["返回用户答案并回灌 transcript"]

    D --> D1["从磁盘读取 plan"]
    D1 --> D2["用户审批 or team lead 审批"]
    D2 --> D3["恢复权限模式并切出 plan mode"]
    D3 --> D4["把批准后的 plan 回灌给执行阶段"]

    E --> E1["解析目标: teammate / broadcast / uds / bridge"]
    E1 --> E2["普通文本或结构化协议消息"]
    E2 --> E3["邮箱投递 / bridge 发送 / 恢复后台 agent"]
    E3 --> E4["shutdown / plan approval / 普通协作消息"]

    F --> F1["写入当前 session 的 todo 状态"]
    F1 --> F2["同步 UI 面板"]
    F2 --> F3["必要时给出 verification nudge"]

这里最关键的一点是:

这几个工具都不是在产出业务结果,而是在推进下一步运行时状态。

5. AskUserQuestion:把“问用户”从自然语言升级成结构化交互

AskUserQuestion 很能代表 Claude Code 的风格。

很多 agent 系统的做法是:

  • 模型直接问一句话
  • 用户再回一句话
  • 全靠自然语言理解

Claude Code 没走这条路。

它把“问用户”定义成了一个正式 schema:

  • questions
  • 每个问题的 header
  • options
  • 是否 multiSelect
  • 可选 preview
  • 用户回答后的 answers
  • 可选 annotations

这意味着它不是简单地“向用户说话”,而是要求模型先把提问结构化。

5.1 为什么要这样设计

因为“向用户确认”本质上是一个控制流分叉点。

如果继续靠自由文本,会出现几个问题:

  • UI 很难做成稳定组件
  • 权限系统没法识别这是不是一次真正的用户交互
  • 回答结果不方便结构化回灌
  • 模型很容易在 plan mode 下问错问题

所以 Claude Code 直接把它做成工具,并且显式声明:

  • requiresUserInteraction() 返回 true
  • checkPermissions() 固定返回 ask

这说明在 Claude Code 看来,“请用户作答”本身就是一种需要治理的动作。

5.2 它不是只支持文字选项,还支持 preview

这个工具有个很值得注意的设计:preview

不同运行环境下,preview 可以是:

  • markdown 预览
  • html fragment 预览

而且 HTML 预览还专门做了约束:

  • 不能给整页文档
  • 不能有 <html> / <body> / <!DOCTYPE>
  • 不能有 <script> / <style>

这说明 Claude Code 不只是想“问一下用户”,而是想把它做成真正可比较方案的选择 UI。

也就是说,它支持的不是只有“你想要 A 还是 B”,而是:

  • 布局草图二选一
  • 代码方案二选一
  • 配置变体二选一

5.3 它还专门处理了 plan mode 的误用

prompt.ts 里专门强调:

  • 在 plan mode 下,可以用它澄清需求
  • 但不能拿它来问“计划行不行”
  • 计划批准必须用 ExitPlanMode

这一点非常关键。

Claude Code 明确不允许模型把两个控制动作混掉:

  • AskUserQuestion 是“收集信息”
  • ExitPlanMode 是“请求批准并切换状态”

这就是在防止模型把控制流做成一锅粥。

6. ExitPlanMode:它不是提示词约定,而是正式的状态机网关

如果说 AskUserQuestion 负责“问”,那 ExitPlanMode 负责的就是:

把计划阶段正式关掉,并把执行阶段正式打开。

这也是为什么我一直觉得 Claude Code 的 plan mode 不是 prompt 技巧,而是 runtime mode。

6.1 它读的是 plan 文件,不是调用参数

ExitPlanModeV2Tool 一个特别重要的设计是:

  • 工具输入里不要求直接传 plan 内容
  • 真正的 plan 从磁盘 plan file 里读

只有在某些远端 / CCR 编辑场景里,才允许 input.plan 作为被注入的编辑结果,再同步回磁盘。

这个设计很像“plan 是一份正式文档”,而不是一次函数调用参数。

这对后续执行阶段有两个好处:

  • plan 是可持久化的
  • plan 是可再次读取和引用的

6.2 它负责真正切换运行时模式

这个工具最关键的副作用不是显示“用户批准了”,而是更新 app state:

  • plan 模式恢复到 prePlanMode
  • 必要时恢复或重新剥离危险权限
  • 处理 auto mode gate 关闭时的 fallback
  • 设置 plan mode exit attachment / auto mode exit attachment

也就是说,ExitPlanMode 的本质是权限与模式恢复器。

如果没有这一层,plan mode 就只是一句提示词,而 Claude Code 显然不接受这种松散做法。

6.3 teammate 场景下,它连审批对象都变了

这个工具还有一条非常 Claude Code 的分支:

如果当前是 teammate,且这个 teammate 的 plan 必须由 leader 批准,那么它不会弹本地用户确认。

而是会:

  • 生成 plan_approval_request
  • 把 plan 内容和路径写进 mailbox
  • 发给 team-lead
  • 把当前任务标成 awaiting approval

也就是说,requiresUserInteraction() 在 teammate 场景下直接变成 false,因为这里的“用户”其实变成了 team lead。

这个细节很说明问题:

Claude Code 的交互对象不是固定的人类用户,而是当前控制流里真正拥有批准权的角色。

6.4 它返回的 tool result 不是一句“ok”,而是下一阶段的执行提示

mapToolResultToToolResultBlockParam() 里有好几个分支:

  • teammate 等待 leader 批准
  • agent 自己被批准
  • 空计划
  • 正常用户批准计划

正常分支里它会把批准后的 plan 明文再塞回 transcript,并且提醒:

  • 你现在可以开始 coding
  • 如有需要先更新 todo list
  • 如果能拆并行任务,可以考虑 TeamCreate

这说明 Claude Code 不是把“批准”当成结束,而是当成:

从 plan phase 切到 execution phase 的上下文桥。

7. SendMessage:agent 间通信不是 transcript 技巧,而是正式消息协议

SendMessageTool 是这一组里工程味最重的一个。

它暴露出来的设计思路很清楚:

只要消息不是发给用户,而是发给其他 agent / 会话,就不要靠自然语言假装通信,要走正式信道。

7.1 它区分了几种完全不同的收件目标

to 字段支持的目标并不只有 teammate 名字,还包括:

  • 普通 teammate 名
  • * 广播
  • uds:/path.sock 本机其他 Claude 会话
  • bridge:session_xxx 远端控制会话

这说明 Claude Code 已经在把“Claude 和 Claude 通信”当成平台能力,而不是 swarm 的局部小技巧。

7.2 它不是只有字符串消息,还有结构化协议消息

message 支持两大类:

  • 普通字符串
  • 结构化消息对象

结构化消息里目前主要有:

  • shutdown_request
  • shutdown_response
  • plan_approval_response

这说明 Claude Code 不只是把 SendMessage 当聊天工具,而是把它当控制协议载体。

7.3 它会先把输入“补解释”成可观察语义

backfillObservableInput() 很有意思。

它会把输入补成更高层的可观察形态,比如:

  • broadcast
  • message
  • shutdown_response
  • plan_approval_response

也就是说,Claude Code 不满足于只拿原始 JSON,它希望运行时和 UI 看到的是“这次通信在做什么”。

这和前面其他工具一样,本质上还是在做 semantic runtime

7.4 它对 cross-session 通信非常谨慎

如果目标是 bridge:,权限决策不会直接 allow,而是返回:

  • behavior: ask
  • 且附带 safetyCheck

源码里的注释讲得很直白:

这是跨机器 prompt 注入,不能被 bypass,也不能被 auto mode 的 allowlist 绕过去。

这说明 Claude Code 对这件事的判断是:

跨 session、跨机器的 agent 通信,本质上是安全边界穿透。

所以它必须比普通 teammate 消息更严格。

7.5 它甚至负责“恢复停止中的 agent”

这个工具最出乎意料的一点,是它不只是投递消息。

如果你给一个已停止的 background agent 发消息,它会尝试:

  • 直接向运行中的 agent 队列消息
  • 如果 agent 已停,但还可恢复,就 resumeAgentBackground(...)
  • 如果 task 状态里没了,也会试图从 transcript 恢复

这说明在 Claude Code 里,“发消息”不是单纯写 inbox,而是:

尽量把目标执行主体重新拉起来,让消息真正被消费。

所以它实际更接近一个 AgentMessenger + AgentResumeGateway

7.6 shutdown / plan approval 都被塞进同一条通信骨干

从实现看,下面几种事情都通过 SendMessage 这根总线跑:

  • 普通 teammate 协作消息
  • 广播
  • shutdown 请求与回复
  • leader 对 teammate plan 的批准或拒绝

这意味着 Claude Code 其实把 swarm 的控制面统一到了 mailbox / message protocol 上。

这很像分布式系统里的 control bus。

8. TodoWrite:V1 todo 不是文案,而是 session 状态

TodoWrite 这部分虽然表面上简单,但非常说明 Claude Code 的执行风格。

它的核心判断是:

复杂任务就应该显式维护一份运行中的任务清单。

8.1 它写的是 app state,不是 transcript

call() 里真正做的事是更新:

  • 当前 session 或当前 agent 对应的 todo key
  • 对应 app state 里的 todos

也就是说,todo 不是“我给用户看的一段列表”,而是 runtime 的状态。

所以它的结果不仅会影响 transcript,也会影响 UI 面板。

8.2 它有自己的一套工作规约

prompt.ts 里写得很重:

  • 复杂任务主动建 todo
  • 多步任务主动建 todo
  • 收到新指令立刻更新 todo
  • 开始做之前就把一个任务标成 in_progress
  • 同一时刻 ideally 只有一个 in_progress
  • 完成就立刻更新,不要攒着一起改

你能明显感觉到,Claude Code 在用这个工具约束模型形成“显式任务推进”的习惯。

8.3 “全部完成”时它会直接把 V1 todo 清空

实现里有个细节:

  • 如果所有 todo 都是 completed
  • 那它实际写入 app state 的是空列表

也就是说,V1 todo 的语义更像“当前活动任务板”,不是永久历史记录。

8.4 它还会在收尾时给 verification nudge

这个细节很有 Claude Code 味道。

如果满足这些条件:

  • 主线程 agent
  • 一次性关闭了 3 个以上任务
  • todo 里没有 verification 类任务
  • feature gate 打开

它会在 tool result 里额外加一句提醒:

  • 该去拉 verification agent 了

这说明 Claude Code 不是只把 todo 当组织工具,它还拿 todo 当“流程钩子”。

9. 为什么这份快照里没有 TodoReadTool

我这轮专门搜过这份快照:

  • src/tools 里没有 TodoReadTool
  • src/tools.ts 里只有 TodoWriteTool
  • 同时又有 TaskCreate/Get/Update/List

所以从这份源码快照看,更合理的理解是:

  • V1 todo 是一个主要靠 TodoWrite 写入的轻量会话状态
  • 读取更多由 UI / app state 直接承接
  • 更正式的双向任务系统已经往 V2 Task* 工具迁移

我不想把这件事说死成“完全替代”,但至少从这份快照能看出来:

Claude Code 正在把简单 todo 往更正式的 task runtime 演进。

10. Claude Code 在这组工具上做了哪些额外处理

把零散细节收一下,大概有这些:

  • AskUserQuestion 会按运行环境决定是否支持 preview,以及 preview 用 markdown 还是 html
  • AskUserQuestion 会在 channel 模式下直接禁用,避免没有人在 TUI 前时挂住
  • AskUserQuestionrequiresUserInteraction() 明确声明这是交互点
  • ExitPlanMode 会从磁盘 plan file 读 plan,而不是信任调用参数
  • ExitPlanMode 会在 CCR 编辑场景下把用户编辑后的 plan 回写磁盘并重新 snapshot
  • ExitPlanMode 会处理 auto mode gate 关闭时的恢复逻辑
  • ExitPlanMode 会把 teammate 的审批转交给 team lead,而不是弹本地确认
  • SendMessage 会对 bridge 通信加 bypass-immune safety check
  • SendMessage 会根据目标类型走 mailbox、UDS、bridge 或 background agent 恢复
  • SendMessage 的 UI 会刻意隐藏很多内部路由细节,避免 transcript 太吵
  • TodoWrite 只在 V1 todo 模式启用,V2 task 模式下会被隐藏
  • TodoWrite 不只是更新列表,还会在特定收尾时机提醒做 verification

这些处理都说明了一件事:

Claude Code 真正在意的,不是“工具能不能调起来”,而是“控制流能不能稳定、安全、可解释地推进”。

11. 转成 C# 时我建议怎么建模

如果你后面要做 C# 版,我建议把这一组拆成四类服务,而不是都塞进工具实现里:

public interface IUserQuestionGateway
{
    Task<UserQuestionResult> AskAsync(UserQuestionRequest request, CancellationToken ct);
}

public interface IPlanModeGateway
{
    Task<PlanExitResult> RequestExitAsync(PlanExitRequest request, CancellationToken ct);
}

public interface IAgentMessageBus
{
    Task<MessageSendResult> SendAsync(AgentMessageRequest request, CancellationToken ct);
}

public interface ISessionTodoStore
{
    Task<TodoUpdateResult> UpdateAsync(TodoUpdateRequest request, CancellationToken ct);
}

我会特别建议你保留这些概念:

  • InteractionBarrier
  • PlanApprovalRequest
  • PermissionModeTransition
  • AgentAddress
  • StructuredControlMessage
  • SessionChecklistState

其中最重要的是两个:

  • InteractionBarrier 表示这里必须停下来等真正的批准或回答
  • PermissionModeTransition 表示 plan mode / auto mode / default mode 切换不是文案,而是状态迁移

如果没有这两个概念,C# 版很容易退化成“模型输出一段话,宿主自己猜该怎么办”。

那就不是 Claude Code 这套设计了。

12. 我的总体评价

拆完这一组以后,Claude Code 的设计思路又更清楚了一层:

它不只是把“干活的动作”工具化,也把“控制流转折点”工具化。

也就是:

  • 什么时候该问人
  • 什么时候算批准
  • 什么时候算真正发出消息
  • 什么时候算任务状态更新

这些事情它都不愿意交给自然语言自由发挥。

这是一个很成熟的工程判断。

因为真正难做的从来不是“让模型看起来像在协作”,而是“让协作在复杂 runtime 里仍然稳定可控”。

这一组工具,正好就是 Claude Code 在这方面最直接的证据。