Book 第四章:Tool 为什么是运行时协议
第二部分:运行时主链路

第四章:Tool 为什么是运行时协议

解释 Claude Code 为什么把 Tool 做成运行时协议,而不是简单函数调用。

1. 为什么第二个重点要拆它

如果说 query.ts 解决的是“这一轮 agent 怎么跑”,那工具系统解决的就是“agent 真正靠什么干活”。

相关深拆:

Claude Code 的工具层不是一个简单的:

tool.execute(input) -> output

而是一整套运行时机制,里面至少混着五类责任:

  • 工具协议定义
  • 权限决策
  • hook 插桩
  • 执行编排
  • UI / transcript / telemetry 映射

所以这块如果不拆清楚,后面做 C# 版时很容易犯一个典型错误:

把 Tool 误抄成一个只有 ExecuteAsync() 的接口。

那样你最后得到的不会是 Claude Code 的设计,只会是一个“能调用工具”的简化版壳子。

2. 先说结论

我对这套设计的判断是:

Claude Code 的 Tool 不是“函数插件”,而是“带策略、带权限、带展示协议的运行时参与者”。

更准确一点,它的工具系统可以拆成这五层:

  1. src/Tool.ts 定义工具协议本身。
  2. src/utils/permissions/permissions.ts + src/hooks/useCanUseTool.tsx 定义权限规则和最终审批流程。
  3. src/services/tools/toolHooks.ts 把 hook 插到工具生命周期里。
  4. src/services/tools/toolExecution.ts 跑单次工具调用。
  5. src/services/tools/toolOrchestration.ts + src/services/tools/StreamingToolExecutor.ts 决定多个工具怎么串行、并行、边流边跑。

3. 总体结构图

flowchart TD
    A["模型输出 tool_use"] --> B["toolExecution.ts<br/>解析输入、准备调用"]
    B --> C["backfillObservableInput<br/>只补观察侧输入,不改原始 transcript 输入"]
    C --> D["PreToolUse Hooks<br/>toolHooks.ts"]
    D --> E["resolveHookPermissionDecision"]
    E --> F["hasPermissionsToUseTool<br/>permissions.ts"]
    F --> G["useCanUseTool<br/>交互审批 / classifier / headless fallback"]
    G --> H{"最终决策"}
    H -- "deny / ask" --> I["生成 tool_result 错误或拒绝消息"]
    H -- "allow" --> J["tool.call(...)"]
    J --> K["PostToolUse / PostToolUseFailure Hooks"]
    K --> L["mapToolResultToToolResultBlockParam"]
    L --> M["回灌 transcript,驱动 query.ts 下一轮"]

    N["toolOrchestration.ts"] --> B
    O["StreamingToolExecutor.ts"] --> B

这个图里最重要的一点是:

真正执行工具之前,Claude Code 已经经过了“补输入、跑 hook、解权限、决定交互方式”这一长串前置流程。

4. Tool.ts 里定义的根本不是一个“执行器接口”

源码锚点:

  • src/Tool.ts:362
  • src/Tool.ts:481
  • src/Tool.ts:500
  • src/Tool.ts:514
  • src/Tool.ts:557
  • src/Tool.ts:757
  • src/Tool.ts:783

4.1 它包含哪些职责

Tool 类型里最核心的字段,大概可以分成六组。

代表方法含义
协议描述description()prompt()inputSchemaoutputSchema告诉模型“这工具是什么、怎么用、参数长什么样”
执行能力call()真正干活
安全与并发validateInput()checkPermissions()isConcurrencySafe()isReadOnly()preparePermissionMatcher()决定能不能跑、何时能并发、规则怎么匹配
输入修补backfillObservableInput()给 hook / transcript / classifier / canUseTool 看到的输入补派生字段
模型侧结果映射mapToolResultToToolResultBlockParam()toAutoClassifierInput()把工具结果翻译回模型能吃的 block
宿主展示renderToolUseMessage()renderToolResultMessage()renderToolUseErrorMessage()extractSearchText()终端 UI、搜索索引、紧凑展示

也就是说,这个 Tool 协议天然混合了三层东西:

  • domain 层:工具是什么
  • runtime 层:工具怎么执行、怎么过权限
  • host 层:工具怎么显示在 CLI/Ink 里

4.2 buildTool() 的默认值很能说明作者的心态

src/Tool.ts:757TOOL_DEFAULTS 很值得注意:

  • isConcurrencySafe 默认 false
  • isReadOnly 默认 false
  • isDestructive 默认 false
  • checkPermissions 默认直接 allow
  • toAutoClassifierInput 默认空字符串

这里最关键的是前两个默认值:

并发安全默认不信,读写安全默认不信。

这是一种很典型的运行时设计风格:

  • 能保守就先保守
  • 需要优化时,再由具体工具显式声明

对 C# 迁移来说,这意味着你的默认实现也应该偏保守,而不是一开始就把所有工具都当成可并发。

5. 单次工具调用不是直接 call(),而是一条完整生命周期

源码锚点:

  • src/services/tools/toolExecution.ts:785
  • src/services/tools/toolExecution.ts:800
  • src/services/tools/toolExecution.ts:921
  • src/services/tools/toolExecution.ts:995
  • src/services/tools/toolExecution.ts:1176

5.1 主流程图

flowchart TD
    A["tool_use block"] --> B["解析 schema"]
    B --> C["复制 processedInput"]
    C --> D["backfillObservableInput"]
    D --> E["runPreToolUseHooks"]
    E --> F["resolveHookPermissionDecision"]
    F --> G{"allow ?"}
    G -- "否" --> H["生成拒绝 / 错误 tool_result"]
    G -- "是" --> I["调用 tool.call()"]
    I --> J["收集 progress / output / contextModifier"]
    J --> K["Post hooks"]
    K --> L["mapToolResultToToolResultBlockParam"]

5.2 这里有个非常细的设计点

toolExecution.tssrc/services/tools/toolExecution.ts:785 左右,会先对输入做一个浅拷贝,再跑 backfillObservableInput()

目的不是改参数本身,而是:

  • 让 hook 能看到补全后的字段
  • canUseTool 能看到补全后的字段
  • 让 transcript / SDK stream 的观察侧输入更完整

但同时又保留原始 API 输入不变,避免影响:

  • prompt cache
  • transcript 一致性
  • 录制 / 回放夹具

这点我很建议在 C# 版里保留。可以拆成两个对象:

  • OriginalToolInput
  • ObservedToolInput

不要只有一个会被到处修改的 Dictionary<string, object>.

6. 权限系统不是一个 bool CanRun

源码锚点:

  • src/utils/permissions/permissions.ts:473
  • src/utils/permissions/permissions.ts:1071
  • src/utils/permissions/permissions.ts:1158
  • src/hooks/useCanUseTool.tsx:28
  • src/hooks/useCanUseTool.tsx:37
  • src/hooks/useCanUseTool.tsx:96
  • src/hooks/useCanUseTool.tsx:113
  • src/hooks/useCanUseTool.tsx:160
  • src/types/permissions.ts:133

6.1 它的返回值不是 true/false,而是三态决策

权限结果核心是三种:

  • allow
  • deny
  • ask

再往下一层,还有一个内部态:

  • passthrough

也就是说,工具自己的 checkPermissions() 并不是直接给最终裁决,它很多时候只是把球继续往权限编排器那里踢。

6.2 权限顺序是明确分层的

hasPermissionsToUseToolInner() 的顺序大致是:

  1. 整个工具是否被 deny rule 禁掉
  2. 整个工具是否被 ask rule 拦住
  3. 调工具自己的 checkPermissions()
  4. 如果工具返回 deny,立即结束
  5. 如果工具要求交互,或者命中了 content-specific ask / safetyCheck,也不能绕过
  6. 如果当前是 bypassPermissions,才统一 allow
  7. 如果整个工具被 always allow rule 命中,则 allow
  8. 剩下的 passthrough 才转成 ask

这里最重要的不是顺序本身,而是它透露出的设计意图:

Claude Code 把“规则层”和“工具自定义层”叠起来了,而且某些 ask 是绕不过去的。

6.3 hook allow 也不是万能通行证

源码锚点:

  • src/services/tools/toolHooks.ts:332

resolveHookPermissionDecision() 里有一个很关键的约束:

PreToolUse hook 就算返回 allow,也不能跳过 deny/ask 规则检查。

这说明作者不想让 hook 成为一个“万能后门”。
如果你以后做 C# 版,也建议保留这个原则,不然插件或 hook 很容易把权限模型冲穿。

6.4 useCanUseTool() 做的不是权限判断,而是“最后一公里”

permissions.ts 更像“规则与策略引擎”,而 useCanUseTool.tsx 更像“审批编排器”。

它会根据 ask 决策继续往下分流:

  • coordinator handler
  • swarm worker handler
  • speculative classifier fast-path
  • interactive permission dialog

也就是说:

权限结果里的 ask,并不等于立刻弹框。

中间还有一层宿主相关的审批工作流。

6.5 auto / dontAsk / headless 都不是工具内部逻辑

hasPermissionsToUseTool() 外层还会做模式转换:

  • dontAsk:把 ask 变成 deny
  • auto:先走 classifier,再决定 allow / deny / fallback to prompt
  • shouldAvoidPermissionPrompts:headless 场景先跑 PermissionRequest hooks,再自动拒绝

这点对 C# 很重要:

权限模式应该是 runtime policy,不应该塞进具体工具里。

7. 工具调度模型其实很清楚

源码锚点:

  • src/services/tools/toolOrchestration.ts:19
  • src/services/tools/toolOrchestration.ts:91
  • src/services/tools/toolOrchestration.ts:118
  • src/services/tools/toolOrchestration.ts:152
  • src/services/tools/StreamingToolExecutor.ts:40
  • src/services/tools/StreamingToolExecutor.ts:265
  • src/services/tools/StreamingToolExecutor.ts:362
  • src/services/tools/StreamingToolExecutor.ts:412
  • src/services/tools/StreamingToolExecutor.ts:453

7.1 非流式调度:按“并发安全批次”切

toolOrchestration.tspartitionToolCalls() 规则很简单:

  • 连续的并发安全工具,合成一个 batch
  • 不并发安全的工具,单独串行执行

作者不是做了一个复杂 DAG scheduler,而是用了一个更稳妥的策略:

只在“连续的、明确定义为安全”的局部窗口里并发。

这很符合 CLI agent 的现实场景,因为很多工具之间其实天然有上下文依赖。

7.2 并发工具的 context 修改是延后提交的

runTools() 里,并发 batch 跑完后才统一应用 contextModifier

意思是:

  • 并发执行时,大家共享同一份起始上下文
  • 各自产生的上下文修改先记账
  • 等整批跑完,再按原顺序提交

这是一种很实用的折中:

  • 保留读类工具的并发收益
  • 避免边跑边改共享上下文造成竞态

7.3 流式调度:工具可以一边流出一边开跑

StreamingToolExecutor 是这块最有“runtime 味道”的地方。

它做了几件很关键的事:

  • tool_use block 还在流式到达时,就可以入队
  • 并发安全工具能提前执行
  • progress 消息会立刻往外吐
  • 正式结果仍然尽量按工具出现顺序输出

也就是说,Claude Code 追求的是:

执行尽量提前,展示尽量稳定。

7.4 只有 Bash 出错会连坐兄弟任务

StreamingToolExecutor.ts:362 左右有个很关键的策略:

  • 如果当前出错的是 Bash
  • siblingAbortController.abort('sibling_error')

作者还专门写了注释说明:

  • Bash 经常有隐含依赖链
  • 一个命令失败,后面的并发命令往往也没意义
  • 但 Read / WebFetch 这类工具互相独立,不应该被一起干掉

这是一个很值得保留的策略点。
它不是通用并发框架会自带的逻辑,而是 Claude Code 自己的 agent policy。

7.5 “用户中断”也不是统一粗暴处理

StreamingToolExecutor 还看 interruptBehavior()

  • cancel
  • block

也就是说,不是所有在跑的工具都能在用户发新消息时随便取消。
这个设计在 C# 里也很值得保留,不然你会把“可以安全取消的工具”和“必须等它收尾的工具”混在一起。

8. 两个代表性工具,正好能看出这套协议怎么落地

8.1 FileReadTool:轻工具的典型

源码锚点:

  • src/tools/FileReadTool/FileReadTool.ts:337
  • src/tools/FileReadTool/FileReadTool.ts:373
  • src/tools/FileReadTool/FileReadTool.ts:388
  • src/tools/FileReadTool/FileReadTool.ts:395
  • src/tools/FileReadTool/FileReadTool.ts:398
  • src/tools/FileReadTool/FileReadTool.ts:496
  • src/tools/FileReadTool/FileReadTool.ts:652

它有几个很鲜明的特征:

  • isConcurrencySafe() === true
  • isReadOnly() === true
  • backfillObservableInput() 里把路径标准化
  • preparePermissionMatcher() 用文件路径做模式匹配
  • checkPermissions() 直接走文件读权限规则
  • mapToolResultToToolResultBlockParam() 会区分 text / image / notebook / pdf / file_unchanged

这说明在 Claude Code 里,“读文件”根本不是一个单一返回字符串的小工具,而是一个:

按文件类型分支、带权限匹配、带 transcript 优化、带多媒体映射的工具协议实现。

还有一个细节很像 Claude Code 的风格:

  • 它不会把 Read 的结果再持久化成一个文件让模型读回来

源码里直接写了理由:那会形成 Read -> 文件 -> 再 Read 的循环。

这说明作者会把“这条能力链会不会自我打转”也放进工具设计里,而不只是关心功能能不能做出来。

8.2 BashTool:重工具的典型

源码锚点:

  • src/tools/BashTool/BashTool.tsx:420
  • src/tools/BashTool/BashTool.tsx:434
  • src/tools/BashTool/BashTool.tsx:437
  • src/tools/BashTool/BashTool.tsx:445
  • src/tools/BashTool/BashTool.tsx:539
  • src/tools/BashTool/BashTool.tsx:555
  • src/tools/BashTool/BashTool.tsx:624

BashTool 的设计能看出 Claude Code 把多少运行时复杂度压进了一个工具里:

  • isConcurrencySafe(input) 不是固定值,而是依赖 isReadOnly(input) 动态判断
  • isReadOnly(input) 又要先做命令语义分析
  • preparePermissionMatcher() 会把 compound command 先切成多个子命令,再做规则匹配
  • checkPermissions() 走的是专门的 bashToolHasPermission(...)
  • call() 里同时处理执行、progress、sandbox、background task、大输出持久化、图片输出压缩、cwd 影响
  • mapToolResultToToolResultBlockParam() 还要把 UI 展示和模型可见结果分开

这基本说明:

Bash 在 Claude Code 里不是一个“shell wrapper”,而是一个小型执行子系统。

如果以后你要做 C# 版,我会建议你一开始就把 Bash/PowerShell 这类工具单独看待,不要和普通 JSON 工具一个抽象层级去实现。

9. 我不建议你 1:1 抄 TypeScript 的 Tool

如果你做的是 C# 版,而且希望以后能支持:

  • CLI
  • IDE 宿主
  • headless SDK
  • 可能的 Web UI

那我不建议把 TypeScript 里的 Tool 直接翻成一个巨大的 ITool 接口。

原因很简单:

  • 现在的 Tool 混了 runtime
  • 也混了 host UI
  • 还混了 transcript/search/telemetry 的展示责任

TypeScript 这边这么做是能跑的,因为整个系统从一开始就是同一个 CLI runtime 长出来的。
但 C# 版如果想做得更稳,最好趁迁移时把边界理顺。

10. 我建议的 C# 拆法

10.1 接口层

public interface IToolSpec
{
    string Name { get; }
    JsonSchema InputSchema { get; }
    JsonSchema OutputSchema { get; }
    Task<string> BuildPromptAsync(ToolPromptContext context);
    Task<string> DescribeAsync(object? input, ToolDescribeContext context);
}

public interface IToolExecutor
{
    Task<ToolCallResult> ExecuteAsync(
        object input,
        ToolCallContext context,
        IProgress<ToolProgressEvent>? progress = null,
        CancellationToken cancellationToken = default);
}

public interface IToolPermissionHandler
{
    Task<PermissionResult> CheckAsync(
        object input,
        ToolCallContext context,
        CancellationToken cancellationToken = default);
}

public interface IToolPolicy
{
    bool IsReadOnly(object input);
    bool IsConcurrencySafe(object input);
    void BackfillObservableInput(IDictionary<string, object?> input);
    Task<Func<string, bool>?> PreparePermissionMatcherAsync(object input);
    object ToAutoClassifierInput(object input);
}

public interface IToolResultSerializer
{
    ToolResultBlock SerializeForModel(object output, string toolUseId);
}

public interface IToolPresentationAdapter
{
    string GetUserFacingName(object? input);
    string? GetToolUseSummary(object? input);
    string? GetActivityDescription(object? input);
}

10.2 运行时层

public interface IToolRuntime
{
    Task<ToolRunOutcome> RunSingleAsync(ToolInvocation invocation, RuntimeContext context);
    IAsyncEnumerable<ToolRunUpdate> RunBatchAsync(IReadOnlyList<ToolInvocation> invocations, RuntimeContext context);
}

public interface IPermissionOrchestrator
{
    Task<PermissionDecision> ResolveAsync(
        IToolDescriptor tool,
        object input,
        RuntimeContext context,
        CancellationToken cancellationToken = default);
}

public interface IToolScheduler
{
    IAsyncEnumerable<ToolRunUpdate> ScheduleAsync(
        IReadOnlyList<ToolInvocation> invocations,
        RuntimeContext context,
        CancellationToken cancellationToken = default);
}

10.3 聚合对象

public sealed class ToolDescriptor
{
    public required IToolSpec Spec { get; init; }
    public required IToolExecutor Executor { get; init; }
    public required IToolPermissionHandler PermissionHandler { get; init; }
    public required IToolPolicy Policy { get; init; }
    public required IToolResultSerializer ResultSerializer { get; init; }
    public IToolPresentationAdapter? Presentation { get; init; }
}

这个拆法的核心不是“更面向对象”,而是把原来一个 TypeScript Tool 里的责任拆成几块可替换模块。

这样做的好处是:

  • CLI 宿主可以接 IToolPresentationAdapter
  • headless SDK 可以完全不要展示适配器
  • 权限编排器可以复用,不依赖具体工具实现
  • 调度器只关心 IsConcurrencySafe
  • 结果序列化可以单独替换成 OpenAI / Anthropic / 自定义协议

11. 我建议你在 C# 版保留的几个设计点

11.1 一定要保留

  • 权限决策三态:allow / ask / deny
  • hook allow 不能绕过规则层
  • 并发安全默认保守
  • 观察侧输入和原始输入分离
  • 工具结果“展示给用户”和“回给模型”分离
  • 流式执行和稳定输出顺序分离
  • 部分工具的错误要能触发兄弟任务取消

11.2 不建议照抄

  • 把所有 UI 渲染方法都塞进核心 ITool
  • 让工具自己感知太多宿主状态
  • 用一个大而松散的 ToolUseContext 贯穿所有层

TypeScript 里这么做有历史包袱,也有 CLI 一体化的便利;
但你既然要重写成 C#,其实正好可以在这里做一次更干净的分层。

12. 这一轮拆出来的核心结论

如果只用一句话总结第二个重点,那就是:

Claude Code 的工具系统,本质上不是“工具集合”,而是“围绕工具调用构建出来的一层运行时内核”。

它真正稳定的设计中心不是某个具体工具,而是这几件事:

  • 工具调用前要经过哪些治理节点
  • 权限结果怎么流到不同宿主
  • 并发和顺序怎么同时成立
  • 模型看到的结果和用户看到的结果怎么分开

这几个点,才是后面迁到 C# 时最值得保留的“设计思路”。

13. 下一步最适合继续拆什么

我建议下一轮直接拆下面两个方向里的一个:

  1. BashTool 的权限与执行子系统
    把只读判定、compound command 分解、sandbox、后台任务、大输出持久化单独拆透。

  2. 权限系统本身
    PermissionMode、规则来源、更新持久化、interactive / auto / headless 三条分支画成状态图。

如果你的目标是尽快做出 C# 原型,我会更建议先拆第 1 个,因为它最能暴露运行时边界该怎么切。