Claude Code 源码解剖

Claude Code 的工具系统

从 Tool 接口、buildTool 默认值、tools.ts 注册过滤、MCP 合并、结果预算和渲染契约,拆解 Claude Code 如何把模型意图变成受约束的行动能力。

第 3 章 15 分钟

Claude Code 的工具系统

这一章解决的问题是:Claude Code 怎么把“模型想做事”变成“运行时可控制的动作入口”。

模型本身不会读文件、跑命令、搜索代码、编辑文件。它能做的是在 API 响应里生成 tool_use。Claude Code 的工具系统负责把这些可调用能力先定义成统一契约,再按构建配置、运行环境、权限规则和 MCP 扩展装配成最终工具池,最后只把 工具名、描述、输入 schema 暴露给模型。

核心直觉

工具系统不是“给模型一堆函数”。它是把外部世界切成一组受控入口:模型只能选择入口和参数,真正的副作用、权限、并发、结果预算都留在运行时处理。

先看源码入口

读工具系统,先抓这些文件和符号:

源码定位点关键符号负责什么
restored-src/src/Tool.ts:362-695Tool 接口工具的统一契约:描述、schema、权限、执行、渲染、预算
restored-src/src/Tool.ts:757-769TOOL_DEFAULTS工具默认行为,尤其是 fail-closed 的只读和并发判断
restored-src/src/Tool.ts:783-792buildTool()把默认值和工具定义合并成可运行工具
restored-src/src/tools.ts:16-135feature() 条件加载构建期或启动期决定工具模块是否存在
restored-src/src/tools.ts:193-251getAllBaseTools()收拢内置工具候选池
restored-src/src/tools.ts:271-327getTools()按 deny 规则、REPL 模式、isEnabled() 过滤当前会话工具
restored-src/src/tools.ts:345-367assembleToolPool()合并内置工具和 MCP 工具,稳定排序并去重
restored-src/src/constants/toolLimits.tsDEFAULT_MAX_RESULT_SIZE_CHARSMAX_TOOL_RESULTS_PER_MESSAGE_CHARS控制工具结果大小
restored-src/src/tools/BashTool/BashTool.tsxisReadOnly()isConcurrencySafe()PROGRESS_THRESHOLD_MS输入感知的风险判断和进度渲染
restored-src/src/tools/GrepTool/GrepTool.tsisReadOnly()isConcurrencySafe()maxResultSizeChars只读并发工具的典型实现

这一章只讲“工具怎么定义和暴露”。下一章再进 runTools()runToolUse()、hook、权限链和流式执行器。

总流程图

flowchart TB A["具体工具定义<br/>BashTool / GrepTool / FileReadTool"] --> B["buildTool(def)<br/>合并 TOOL_DEFAULTS"] B --> C["getAllBaseTools()<br/>内置工具候选池"] D["feature() / USER_TYPE / 环境判断"] --> C C --> E["getTools(permissionContext)<br/>deny 规则 / REPL 模式 / isEnabled()"] F["MCP tools"] --> G["filterToolsByDenyRules(mcpTools)"] E --> H["assembleToolPool()<br/>内置优先 / 按名称排序 / uniqBy(name)"] G --> H H --> I["转换为 API tool schema<br/>name / description / inputSchema"] I --> J["模型看到可调用能力"] J --> K["模型返回 tool_use"] K --> L["下一章: 执行编排和权限链"]

这张图里有两个关键边界:

  • Tool 对象是运行时内部契约,包含 call()、权限、渲染、预算等执行细节。
  • API tool schema 是模型可见契约,主要暴露工具名、描述和输入结构。

模型看到的是“怎么调用”,不是“怎么实现”。

Tool 接口:把能力拆成可治理字段

原文把 Tool 接口定位在 restored-src/src/Tool.ts:362-695。它不是一个简单的函数类型,而是一份完整行动契约。

字段类型或形态模型/运行时影响
namereadonly string工具唯一标识,权限匹配、日志、API 传输都靠它
description(input, options) =&gt; Promise@@INLINE_0@@发送给模型的工具描述,可按权限上下文动态变化
prompt(options) =&gt; Promise@@INLINE_0@@工具级提示词,用来补充局部使用规则
inputSchemaz.ZodType运行时校验参数,并生成 API JSON Schema
call(args, context, canUseTool, parentMessage, onProgress?) =&gt; Promise@@INLINE_0@@真正执行副作用
checkPermissions(input, context) =&gt; Promise@@INLINE_0@@工具自己的权限检查
validateInput(input, context) =&gt; Promise@@INLINE_0@@schema 之后的语义校验
maxResultSizeCharsnumber单工具结果预算
isConcurrencySafe(input) =&gt; boolean调度器能否和其他工具并发执行
isReadOnly(input) =&gt; boolean权限系统和并发系统判断副作用风险
isEnabled() =&gt; boolean当前环境下是否可用

这张表要读出三个层次:

层次字段说明
模型选择层namedescriptionpromptinputSchema模型根据这些信息决定要不要调用、怎么传参
执行控制层callcheckPermissionsvalidateInputcanUseTool运行时决定是否允许执行,以及如何处理子权限
调度和呈现层isConcurrencySafeisReadOnlymaxResultSizeChars、渲染方法决定并发、预算、UI 可见性

容易误解

description 不是给人看的注释,inputSchema 也不是普通文档。它们会进入模型上下文,直接影响模型是否选择这个工具、能生成什么参数。

为什么 description 是函数

原文特别强调 description 是函数而不是字符串。这个设计说明工具描述不是静态文案,它可以根据权限、模式、上下文变化。

例如当前会话中某些 Bash 子命令被 deny,工具描述就可以提前提醒模型不要尝试。这样做的价值是:在模型规划之前降低无效工具调用概率

行为链路是:

读取当前 permissionContext
-> 生成工具 description
-> description 进入 API tool schema
-> 模型基于描述选择工具
-> 被禁止的路径尽量不进入计划

这比“模型先调用,运行时再拒绝”更省 token,也让任务路径更稳定。

inputSchema 同时服务模型和运行时

inputSchema 使用 Zod v4。原文提到它可以通过 z.toJSONSchema() 转换为发送给 API 的 JSON Schema,而 z.strictObject() 能阻止模型传入未定义字段。

这说明 schema 有双重用途:

用途发生位置结果
模型约束API tool schema模型知道应该生成哪些字段
运行时校验工具执行前 safeParse无效参数不会进入真实副作用

核心直觉

工具 schema 是模型世界和运行世界之间的接口。模型靠它组织参数,运行时靠它拒绝错误参数。

call 接收 canUseTool,说明权限不是一次性门禁

call 的签名里有 canUseTool 回调。这个细节很重要:工具执行过程中也可能需要再次做权限判断。

典型例子是 AgentTool:父工具启动子 Agent 时,子 Agent 还会使用其他工具。运行时不能因为父工具被允许,就默认子过程里的所有动作都允许。

这证明权限链不是:

执行前检查一次 -> 放行后随便跑

而更接近:

执行前检查
-> 工具内部遇到子动作
-> 再调用 canUseTool
-> 子动作继续受当前权限上下文约束

buildTool():把 fail-closed 默认值写进底座

具体工具不是裸对象直接导出,而是通过 buildTool() 构建。原文给出的源码:

export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
  return {
    ...TOOL_DEFAULTS,
    userFacingName: () => def.name,
    ...def,
  } as BuiltTool<D>;
}

这段代码运行时只是对象展开,但它证明了一个工程约束:每个工具都会先拿到统一默认值,然后由具体定义覆盖。

TOOL_DEFAULTS 的关键默认值:

默认方法默认值证明了什么
isEnabled() =&gt; true工具默认存在,除非显式禁用
isConcurrencySafe() =&gt; false没声明并发安全,就按不安全处理
isReadOnly() =&gt; false没声明只读,就按可能写入处理
isDestructive() =&gt; false破坏性需要工具额外说明
checkPermissions返回 { behavior: &#39;allow&#39; }默认交给通用权限系统,工具可追加判断
toAutoClassifierInput() =&gt; &#39;&#39;默认不参与自动安全分类
userFacingName() =&gt; def.nameUI 默认显示工具名

这里最重要的是 isConcurrencySafe: falseisReadOnly: false。它们体现了 fail-closed:新工具忘记声明安全属性时,系统不会乐观地并发执行,也不会把它当只读工具。

GrepTool:主动声明只读和并发安全

原文给出的 GrepTool 片段:

export const GrepTool = buildTool({
  name: GREP_TOOL_NAME,
  searchHint: 'search file contents with regex (ripgrep)',
  maxResultSizeChars: 20_000,
  strict: true,
  // ...
  isConcurrencySafe() { return true },
  isReadOnly() { return true },
  // ...
});

这段代码证明 GrepTool 明确把自己从默认保守值里“解锁”出来。因为搜索不会修改文件系统,所以它可以被并发调度,也可以在权限判断里被视为只读。

BashTool:并发安全取决于输入

BashTool 的判断更细:

isConcurrencySafe(input) {
  return this.isReadOnly?.(input) ?? false;
},
isReadOnly(input) {
  const compoundCommandHasCd = commandHasAnyCd(input.command);
  const result = checkReadOnlyConstraints(input, compoundCommandHasCd);
  return result.behavior === 'allow';
},

这段代码证明 isConcurrencySafe 不是工具级静态标签,而是输入感知的运行时判断。git statuslscat 这类命令可能只读;git checkoutrmnpm install 这类命令会改变环境,就不能并发。

读源码抓手

看一个工具是否安全,不要只看工具名。先看它有没有覆盖 isReadOnly(input)isConcurrencySafe(input),再看判断是否真的解析了输入。

tools.ts:工具注册不是罗列,而是多层过滤

工具定义完成后,还要经过 tools.ts 的装配管线。这里回答的是:当前这次会话里,模型到底能看见哪些工具?

第一层:构建期和启动期条件加载

原文给出的例子:

const SleepTool =
  feature('PROACTIVE') || feature('KAIROS')
    ? require('./tools/SleepTool/SleepTool.js').SleepTool
    : null;

const cronTools = feature('AGENT_TRIGGERS')
  ? [
      require('./tools/ScheduleCronTool/CronCreateTool.js').CronCreateTool,
      require('./tools/ScheduleCronTool/CronDeleteTool.js').CronDeleteTool,
      require('./tools/ScheduleCronTool/CronListTool.js').CronListTool,
    ]
  : [];

这说明实验工具不是“总在产物里,只是 UI 隐藏”。如果 feature() 在构建期为 false,对应模块树可能直接不进入 bundle。

还有运行身份过滤:

const REPLTool =
  process.env.USER_TYPE === 'ant'
    ? require('./tools/REPLTool/REPLTool.js').REPLTool
    : null;

这证明内部工具即使代码存在,也会按运行身份决定是否进入当前工具池。

第二层:getAllBaseTools() 收集候选池

getAllBaseTools() 是内置工具注册表。原文给出的片段:

export function getAllBaseTools(): Tools {
  return [
    AgentTool,
    TaskOutputTool,
    BashTool,
    ...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
    FileReadTool,
    FileEditTool,
    FileWriteTool,
    // ... 省略 30+ 个工具
    ...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
  ];
}

这里要注意 hasEmbeddedSearchTools()。如果构建里已经嵌入了搜索工具,独立的 GlobToolGrepTool 就不进入基础工具池。这个判断说明工具注册不只是“功能越多越好”,还要避免重复能力。

第三层:getTools() 做会话过滤

getTools() 会继续处理三类过滤:

过滤行为影响
deny 规则filterToolsByDenyRules() 移除被 alwaysDeny 覆盖的工具被明确禁止的工具不发送给模型
REPL 模式隐藏 BashReadEdit 等基础工具通过 REPLTool 的 VM 上下文间接暴露
isEnabled()每个工具最后自检当前环境不可用的工具退出工具池

这里的关键是“前置过滤”。如果用户配置了 &quot;Bash&quot;: &quot;deny&quot;,更好的行为不是让模型看到 Bash 后调用失败,而是工具 schema 里根本没有 Bash。这样模型不会把它纳入计划。

容易误解

权限系统当然还要在执行时检查。但工具列表前置过滤同样重要,因为它控制模型的可见世界。模型看不到的工具,通常就不会消耗推理路径去尝试。

第四层:assembleToolPool() 合并 MCP 工具

最终合并入口:

export function assembleToolPool(
  permissionContext: ToolPermissionContext,
  mcpTools: Tools,
): Tools {
  const builtInTools = getTools(permissionContext);
  const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext);
  const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name);
  return uniqBy(
    [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
    'name',
  );
}

这段代码证明三个行为:

行为源码证据设计意义
MCP 工具也过 deny 规则filterToolsByDenyRules(mcpTools, permissionContext)外部工具不能绕过本地权限边界
内置工具先排序再拼接[...builtInTools].sort(byName).concat(...)内置工具形成稳定前缀
名称冲突时内置工具胜出uniqBy(..., &#39;name&#39;) 保留第一次出现项MCP 不能覆盖内置工具

排序还有缓存意义。工具 schema 是 prompt 的一部分,顺序抖动会影响 prompt cache。内置工具作为稳定前缀,MCP 工具作为后缀按名称排序,可以降低外部工具增减带来的缓存扰动。

工具结果预算:不能让工具输出淹没上下文

工具结果最终要回写成 tool_result,给下一轮模型看。但工具输出可能非常大:构建日志、搜索结果、网页内容、子 Agent 总结都可能把上下文挤爆。

Claude Code 用两级预算控制。

单工具预算:maxResultSizeChars

每个工具声明自己的结果上限:

工具maxResultSizeChars设计含义
McpAuthTool10,000认证结果通常较短
GrepTool20,000搜索结果应精简,太多说明范围过宽
BashTool30,000构建/测试输出需要保留足够错误上下文
GlobTool100,000文件列表可能较大
AgentTool100,000子 Agent 结果可能包含多步总结
WebSearchTool100,000搜索结果需要较宽上下文
FileReadToolInfinity避免 Read 结果被替换为磁盘引用后再次 Read 的循环

FileReadToolInfinity 很有代表性。它不是“不控制文件读取大小”,而是选择由文件读取工具自己的 maxTokensmaxSizeBytes 等机制控制输出,避免结果存储层把文件内容换成“请再读这个磁盘文件”的引用。

单消息聚合预算

并行工具会把多个 tool_result 放进同一条 user message。原文提到:

常量定义位置
DEFAULT_MAX_RESULT_SIZE_CHARS50,000 字符constants/toolLimits.ts:13
MAX_TOOL_RESULT_TOKENS100,000 tokenconstants/toolLimits.ts:22
MAX_TOOL_RESULT_BYTES400,000 字节constants/toolLimits.ts:33
MAX_TOOL_RESULTS_PER_MESSAGE_CHARS200,000 字符constants/toolLimits.ts:49
TOOL_SUMMARY_MAX_LENGTH50 字符constants/toolLimits.ts:57

这说明预算不是只防单个工具失控,还要防并发工具的结果总量一起爆炸。

核心直觉

工具结果不是越完整越好。Agent 需要的是“足够继续判断的证据”,不是把整个外部世界塞进上下文。

渲染契约:工具调用在 UI 里分三段出现

Tool 接口还定义了终端 UI 的渲染方法:

renderToolUseMessage
renderToolUseProgressMessage
renderToolResultMessage

它们对应工具生命周期里的三个时刻:

flowchart LR A["模型流式输出 tool_use<br/>参数可能还不完整"] --> B["renderToolUseMessage<br/>展示调用意图"] B --> C["工具开始执行"] C --> D["renderToolUseProgressMessage<br/>展示进度"] D --> E["工具结束"] E --> F["renderToolResultMessage<br/>展示结果"]

意图展示:Partial@@INLINE_0@@ 是流式 UI 的证据

原文给出签名:

renderToolUseMessage(
  input: Partial<z.infer<Input>>,
  options: { theme: ThemeName; verbose: boolean; commands?: Command[] },
): React.ReactNode;

inputPartial,说明工具参数在 API 流里可能还没完整到达,UI 也要能先渲染。这不是类型细节,而是流式交互要求:用户应该尽早看到 Agent 准备调用什么工具,而不是等 JSON 参数全部解析完。

进度展示:长任务不能黑箱运行

renderToolUseProgressMessage 是可选的,但对 BashToolAgentTool 这类长任务很关键。原文提到 BashTool 在命令执行超过 PROGRESS_THRESHOLD_MS = 2000 后开始显示进度。

这说明工具 UI 不是只显示最终结果,而是让用户在危险或耗时动作中间保持可见性。对终端 Agent 来说,这直接影响信任感。

结果展示:verbose 和 condensed 是两种阅读模式

renderToolResultMessage 可以根据 style?: &#39;condensed&#39; 决定展示细节。搜索类工具在非 verbose 模式下可以显示摘要,在 verbose 模式下展示完整结果。工具还可以用 isResultTruncated(output) 告诉 UI 结果是否被截断。

这条契约把“给模型的结果”和“给用户看的结果”分开了:模型需要可继续推理的 tool_result,用户需要能判断 Agent 在做什么的终端视图。

延迟加载:工具太多时,不要一次把 schema 全塞给模型

当 MCP 工具很多时,完整 schema 会占用大量 token。原文提到 Claude Code 支持 deferred loading:

字段行为
shouldDefer: true初始提示只发送工具名和 defer_loading: true,不发送完整参数 schema
searchHint用 3-10 个词描述能力,帮助 ToolSearchTool 搜索
alwaysLoad: true核心工具永远发送完整 schema
ToolSearchTool模型先搜索工具定义,再调用延迟工具

这个机制说明工具系统还承担 token 预算职责。工具越多,不代表越应该一次性全部暴露。核心工具应该常驻,低频或外部工具可以搜索后加载。

状态和数据结构

工具系统里最重要的数据结构可以压成这几组:

结构关键字段影响
ToolnamedescriptioninputSchemacall定义模型可见能力和运行时执行入口
ToolcheckPermissionsvalidateInputcanUseTool控制执行前和执行中的安全边界
ToolisReadOnlyisConcurrencySafeisEnabled控制会话过滤和执行调度
ToolmaxResultSizeChars控制单工具结果进入上下文的大小
TOOL_DEFAULTSisReadOnly: falseisConcurrencySafe: falsefail-closed 默认值
Tools内置工具数组、MCP 工具数组assembleToolPool() 合并成最终工具池
API tool schemanamedescription、JSON Schema模型实际看见的能力表面

设计取舍

取舍点源码体现工程判断
描述函数化description(input, options)工具说明要根据权限和上下文动态变化,提前影响模型计划
schema 双用inputSchema 同时 Zod 校验和 JSON Schema 输出一份结构既约束模型,也约束运行时
fail-closed 默认值isReadOnly: falseisConcurrencySafe: false新工具忘记声明时按危险处理
输入感知属性BashTool.isConcurrencySafe(input)同一工具不同输入风险不同,不能只按工具名判断
前置过滤工具getTools()filterToolsByDenyRules()被拒工具不该进入模型可见世界
内置工具优先sort().concat(...).uniqBy(&#39;name&#39;)MCP 不能覆盖内置工具,工具顺序也更利于 prompt cache
分层结果预算maxResultSizeChars + message aggregate limit既防单个工具失控,也防并发工具合力挤爆上下文
渐进渲染Partial@@INLINE_0@@、progress、result renderer用户能看到意图、进度和结果,而不是黑箱等待

读源码抓手

建议按这个顺序读第 3 章相关源码:

  1. 先看 restored-src/src/Tool.ts 的接口字段,不追具体工具实现。
  2. 再看 TOOL_DEFAULTSbuildTool(),确认默认值如何合并。
  3. GrepToolBashTool 对比:一个静态只读,一个输入感知。
  4. 进入 restored-src/src/tools.ts,只追 feature() 条件加载、getAllBaseTools()getTools()assembleToolPool()
  5. filterToolsByDenyRules(),确认 deny 规则如何影响模型可见工具。
  6. constants/toolLimits.ts,把单工具预算和单消息预算分开。
  7. 最后看渲染方法签名,理解工具在终端 UI 里如何从意图流到结果。

小结

- Tool 接口把一个能力拆成模型描述、输入 schema、执行入口、权限、预算和渲染契约。 - buildTool() 的默认值体现 fail-closed:没声明只读和并发安全,就按危险处理。 - tools.ts 不只是注册表,它通过构建期、环境、权限、模式和 MCP 合并决定模型能看见什么。 - assembleToolPool() 的排序和内置优先策略,同时服务安全边界和 prompt cache 稳定性。 - 工具结果预算和三阶段渲染说明:工具系统不仅负责执行,还负责控制上下文体积和用户可见性。