第十六章:协作控制流是怎样被工具化的
拆 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.tsxsrc/tools/AskUserQuestionTool/prompt.tssrc/tools/ExitPlanModeTool/ExitPlanModeV2Tool.tssrc/tools/ExitPlanModeTool/prompt.tssrc/tools/ExitPlanModeTool/UI.tsxsrc/tools/SendMessageTool/SendMessageTool.tssrc/tools/SendMessageTool/prompt.tssrc/tools/SendMessageTool/UI.tsxsrc/tools/TodoWriteTool/TodoWriteTool.tssrc/tools/TodoWriteTool/prompt.ts
补充看的还有:
src/tools.tssrc/utils/tasks.tssrc/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()返回truecheckPermissions()固定返回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_requestshutdown_responseplan_approval_response
这说明 Claude Code 不只是把 SendMessage 当聊天工具,而是把它当控制协议载体。
7.3 它会先把输入“补解释”成可观察语义
backfillObservableInput() 很有意思。
它会把输入补成更高层的可观察形态,比如:
broadcastmessageshutdown_responseplan_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里没有TodoReadToolsrc/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 还是 htmlAskUserQuestion会在 channel 模式下直接禁用,避免没有人在 TUI 前时挂住AskUserQuestion用requiresUserInteraction()明确声明这是交互点ExitPlanMode会从磁盘 plan file 读 plan,而不是信任调用参数ExitPlanMode会在 CCR 编辑场景下把用户编辑后的 plan 回写磁盘并重新 snapshotExitPlanMode会处理 auto mode gate 关闭时的恢复逻辑ExitPlanMode会把 teammate 的审批转交给 team lead,而不是弹本地确认SendMessage会对 bridge 通信加 bypass-immune safety checkSendMessage会根据目标类型走 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);
}
我会特别建议你保留这些概念:
InteractionBarrierPlanApprovalRequestPermissionModeTransitionAgentAddressStructuredControlMessageSessionChecklistState
其中最重要的是两个:
InteractionBarrier表示这里必须停下来等真正的批准或回答PermissionModeTransition表示 plan mode / auto mode / default mode 切换不是文案,而是状态迁移
如果没有这两个概念,C# 版很容易退化成“模型输出一段话,宿主自己猜该怎么办”。
那就不是 Claude Code 这套设计了。
12. 我的总体评价
拆完这一组以后,Claude Code 的设计思路又更清楚了一层:
它不只是把“干活的动作”工具化,也把“控制流转折点”工具化。
也就是:
- 什么时候该问人
- 什么时候算批准
- 什么时候算真正发出消息
- 什么时候算任务状态更新
这些事情它都不愿意交给自然语言自由发挥。
这是一个很成熟的工程判断。
因为真正难做的从来不是“让模型看起来像在协作”,而是“让协作在复杂 runtime 里仍然稳定可控”。
这一组工具,正好就是 Claude Code 在这方面最直接的证据。