第四章:Tool 为什么是运行时协议
解释 Claude Code 为什么把 Tool 做成运行时协议,而不是简单函数调用。
1. 为什么第二个重点要拆它
如果说 query.ts 解决的是“这一轮 agent 怎么跑”,那工具系统解决的就是“agent 真正靠什么干活”。
相关深拆:
- 工具总索引
- 核心拆解 03:
BashTool子系统 - 核心拆解 04:
FileReadTool子系统 - 核心拆解 05:
FileEditTool子系统 - 核心拆解 06:
FileWriteTool子系统 - 核心拆解 07:
NotebookEditTool子系统 - 核心拆解 08:
GlobTool+GrepTool搜索子系统 - 核心拆解 09:
WebFetchTool+WebSearchTool外部信息子系统 - 核心拆解 10:
AgentTool+SkillTool委派与技能子系统 - 核心拆解 11:
AskUserQuestion+ExitPlanMode+SendMessage+TodoWrite协作控制子系统 - 核心拆解 12:
TaskCreate/TaskGet/TaskUpdate/TaskList/TaskStop/TaskOutput任务运行时 - 核心拆解 13:
ConfigTool+EnterWorktree/ExitWorktree+ListMcpResources/ReadMcpResource运行时辅助层 - 核心拆解 14:
ToolSearchTool+SyntheticOutputTool+TestingPermissionTool元工具层 - 核心拆解 15:
MCPTool+McpAuthTool+services/mcp/client.ts动态扩展层 - 核心拆解 16:
LSPTool语义代码理解层 - 核心拆解 17:
REPLTool包装层 - 核心拆解 18:上下文压缩与会话续航
- 核心拆解 19:
Tool Result Budget与大输出持久化 - 核心拆解 20:溢出恢复回路与
reactive compact边界 - 核心拆解 21:
context collapse的接口边界与持久化协议 - 核心拆解 22:C# 运行时映射与接口蓝图
- 核心拆解 23:权限运行时
Claude Code 的工具层不是一个简单的:
tool.execute(input) -> output
而是一整套运行时机制,里面至少混着五类责任:
- 工具协议定义
- 权限决策
- hook 插桩
- 执行编排
- UI / transcript / telemetry 映射
所以这块如果不拆清楚,后面做 C# 版时很容易犯一个典型错误:
把 Tool 误抄成一个只有 ExecuteAsync() 的接口。
那样你最后得到的不会是 Claude Code 的设计,只会是一个“能调用工具”的简化版壳子。
2. 先说结论
我对这套设计的判断是:
Claude Code 的 Tool 不是“函数插件”,而是“带策略、带权限、带展示协议的运行时参与者”。
更准确一点,它的工具系统可以拆成这五层:
src/Tool.ts定义工具协议本身。src/utils/permissions/permissions.ts+src/hooks/useCanUseTool.tsx定义权限规则和最终审批流程。src/services/tools/toolHooks.ts把 hook 插到工具生命周期里。src/services/tools/toolExecution.ts跑单次工具调用。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:362src/Tool.ts:481src/Tool.ts:500src/Tool.ts:514src/Tool.ts:557src/Tool.ts:757src/Tool.ts:783
4.1 它包含哪些职责
Tool 类型里最核心的字段,大概可以分成六组。
| 组 | 代表方法 | 含义 |
|---|---|---|
| 协议描述 | description()、prompt()、inputSchema、outputSchema | 告诉模型“这工具是什么、怎么用、参数长什么样” |
| 执行能力 | 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:757 的 TOOL_DEFAULTS 很值得注意:
isConcurrencySafe默认falseisReadOnly默认falseisDestructive默认falsecheckPermissions默认直接allowtoAutoClassifierInput默认空字符串
这里最关键的是前两个默认值:
并发安全默认不信,读写安全默认不信。
这是一种很典型的运行时设计风格:
- 能保守就先保守
- 需要优化时,再由具体工具显式声明
对 C# 迁移来说,这意味着你的默认实现也应该偏保守,而不是一开始就把所有工具都当成可并发。
5. 单次工具调用不是直接 call(),而是一条完整生命周期
源码锚点:
src/services/tools/toolExecution.ts:785src/services/tools/toolExecution.ts:800src/services/tools/toolExecution.ts:921src/services/tools/toolExecution.ts:995src/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.ts 在 src/services/tools/toolExecution.ts:785 左右,会先对输入做一个浅拷贝,再跑 backfillObservableInput()。
目的不是改参数本身,而是:
- 让 hook 能看到补全后的字段
- 让
canUseTool能看到补全后的字段 - 让 transcript / SDK stream 的观察侧输入更完整
但同时又保留原始 API 输入不变,避免影响:
- prompt cache
- transcript 一致性
- 录制 / 回放夹具
这点我很建议在 C# 版里保留。可以拆成两个对象:
OriginalToolInputObservedToolInput
不要只有一个会被到处修改的 Dictionary<string, object>.
6. 权限系统不是一个 bool CanRun
源码锚点:
src/utils/permissions/permissions.ts:473src/utils/permissions/permissions.ts:1071src/utils/permissions/permissions.ts:1158src/hooks/useCanUseTool.tsx:28src/hooks/useCanUseTool.tsx:37src/hooks/useCanUseTool.tsx:96src/hooks/useCanUseTool.tsx:113src/hooks/useCanUseTool.tsx:160src/types/permissions.ts:133
6.1 它的返回值不是 true/false,而是三态决策
权限结果核心是三种:
allowdenyask
再往下一层,还有一个内部态:
passthrough
也就是说,工具自己的 checkPermissions() 并不是直接给最终裁决,它很多时候只是把球继续往权限编排器那里踢。
6.2 权限顺序是明确分层的
hasPermissionsToUseToolInner() 的顺序大致是:
- 整个工具是否被 deny rule 禁掉
- 整个工具是否被 ask rule 拦住
- 调工具自己的
checkPermissions() - 如果工具返回 deny,立即结束
- 如果工具要求交互,或者命中了 content-specific ask / safetyCheck,也不能绕过
- 如果当前是
bypassPermissions,才统一 allow - 如果整个工具被 always allow rule 命中,则 allow
- 剩下的
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变成denyauto:先走 classifier,再决定 allow / deny / fallback to promptshouldAvoidPermissionPrompts:headless 场景先跑PermissionRequesthooks,再自动拒绝
这点对 C# 很重要:
权限模式应该是 runtime policy,不应该塞进具体工具里。
7. 工具调度模型其实很清楚
源码锚点:
src/services/tools/toolOrchestration.ts:19src/services/tools/toolOrchestration.ts:91src/services/tools/toolOrchestration.ts:118src/services/tools/toolOrchestration.ts:152src/services/tools/StreamingToolExecutor.ts:40src/services/tools/StreamingToolExecutor.ts:265src/services/tools/StreamingToolExecutor.ts:362src/services/tools/StreamingToolExecutor.ts:412src/services/tools/StreamingToolExecutor.ts:453
7.1 非流式调度:按“并发安全批次”切
toolOrchestration.ts 的 partitionToolCalls() 规则很简单:
- 连续的并发安全工具,合成一个 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():
cancelblock
也就是说,不是所有在跑的工具都能在用户发新消息时随便取消。
这个设计在 C# 里也很值得保留,不然你会把“可以安全取消的工具”和“必须等它收尾的工具”混在一起。
8. 两个代表性工具,正好能看出这套协议怎么落地
8.1 FileReadTool:轻工具的典型
源码锚点:
src/tools/FileReadTool/FileReadTool.ts:337src/tools/FileReadTool/FileReadTool.ts:373src/tools/FileReadTool/FileReadTool.ts:388src/tools/FileReadTool/FileReadTool.ts:395src/tools/FileReadTool/FileReadTool.ts:398src/tools/FileReadTool/FileReadTool.ts:496src/tools/FileReadTool/FileReadTool.ts:652
它有几个很鲜明的特征:
isConcurrencySafe() === trueisReadOnly() === truebackfillObservableInput()里把路径标准化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:420src/tools/BashTool/BashTool.tsx:434src/tools/BashTool/BashTool.tsx:437src/tools/BashTool/BashTool.tsx:445src/tools/BashTool/BashTool.tsx:539src/tools/BashTool/BashTool.tsx:555src/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. 下一步最适合继续拆什么
我建议下一轮直接拆下面两个方向里的一个:
-
BashTool的权限与执行子系统
把只读判定、compound command 分解、sandbox、后台任务、大输出持久化单独拆透。 -
权限系统本身
把PermissionMode、规则来源、更新持久化、interactive / auto / headless 三条分支画成状态图。
如果你的目标是尽快做出 C# 原型,我会更建议先拆第 1 个,因为它最能暴露运行时边界该怎么切。