ARTICLE · 1350783
Claude Code 源码-Tool 契约与注册表
Claude Code 源码解析系列
第 3 讲:Tool 契约与注册表
基于 Claude Code 源码快照 · 2026-11-13
💡 本讲一句话:Claude Code 里的"工具"不是"模型能调的一个函数",而是一个三面体——对模型是 schema+描述,对安全系统是权限元数据,对 UI 是一组渲染方法。这一讲逐行拆解 Tool.ts(792 行)的契约定义和 tools.ts(389 行)的注册表装配,读完你能明白任何新工具如何靠一个 buildTool({...}) 插进这台机器。
一、一张契约表,三个面孔
第 2 讲停在命令分发。从这里开始进入 agent 循环的核心:模型做的每一个动作,最终都是一次工具调用。Claude Code 在写第一个具体工具之前,先回答了一个更根本的问题——"工具"到底是什么?答案在 src/Tool.ts 第 362-695 行:一个类型,约 40 个成员。先看最核心的几个(每个工具都必须实现或可覆盖):
📄 src/Tool.ts (第 379-416 行,核心成员摘录)
call(args, context, canUseTool, parentMessage, onProgress?) // 唯一的"执行"方法:args 已经过 inputSchema 校验,context 是整个世界(下节细讲) : Promise<ToolResult<Output>> // Output 泛型 = 该工具自己的输出类型;canUseTool 回调让工具在执行中也能二次询问权限description(input, options): Promise<string> // 动态描述:可以按会话状态/权限上下文改变给模型看的措辞,不是写死的字符串readonly inputSchema: Input // Zod schema——对模型是 JSON Schema(API 参数),对运行时是 args 的校验器,一份定义两用readonly inputJSONSchema?: ToolInputJSONSchema // MCP 工具可直接提供原生 JSON Schema,跳过 Zod→JSON 的转换环节outputSchema?: z.ZodType<unknown> // 输出类型声明;源码注释写着 "TODO: Make it required"——目前还允许缺省inputsEquivalent?(a, b): boolean // 判断两次调用是否等价——供去重/缓存类优化做决策用isConcurrencySafe(input): boolean // 这次调用能否与其他工具并行?默认 false = 假设不安全,宁可串行isEnabled(): boolean // 运行时开关:这个会话里要不要把我注册进工具列表(区别于编译期的 feature gate)isReadOnly(input): boolean // 只读还是写操作——权限系统对这两类的默认态度完全不同isDestructive?(input): boolean // 不可逆操作(删除/覆盖/发送);注释明说"Defaults to false,只在真做破坏性动作时设 true"interruptBehavior?(): 'cancel' | 'block' // 工具运行中用户发来新消息:cancel=停下丢弃结果,block=继续跑、让新消息排队为什么这样设计:注意这份契约里混着三类完全不同的关注点——inputSchema/description() 是给模型看的,isReadOnly/isDestructive 是给安全系统看的,renderToolUseMessage(后面会看到)是给终端 UI看的。把三者塞进同一个类型,意味着"新增一个工具"和"保证它安全、能显示"是同一份代码里的两件事——不可能出现"模型能调但权限系统不认识"的缝隙。这是用 TypeScript 类型当架构契约的典型手法:成员分必填(call、inputSchema、checkPermissions)和可选(带 ?),必填的是"不做就不能进系统",可选的是"有默认行为、按需覆盖"。
把约 40 个成员按职责归组,这张契约表的全貌就清楚了:
inputSchema / prompt() / description() | ||
isReadOnly / isDestructive / toAutoClassifierInput | ls -la) | |
validateInput / checkPermissions / preparePermissionMatcher | ||
renderToolUseMessage / renderToolResultMessage / renderGroupedToolUse | ||
isEnabled / isConcurrencySafe / interruptBehavior |
还有一个容易被忽略的成员:maxResultSizeChars(第 457-466 行)。注释写得很直白——结果超过这个字符数就落盘存文件,模型只拿到预览+路径;而 Read 工具把它设成 Infinity,因为"persisting creates a circular Read→file→Read loop"——读文件的结果再落盘成文件、模型再去读那个文件,会形成死循环。一个数字背后是对上下文预算的精细控制。
二、ToolUseContext:每次调用都递进去的"世界"
call() 的第二个参数 ToolUseContext(第 158-300 行)是整份契约里最"胖"的类型——约 40 个字段。它的设计哲学是:工具可能需要的任何东西,都从这一个对象拿,而不是散落的参数:
📄 src/Tool.ts (第 158-300 行,字段摘录)
export type ToolUseContext = { // 每次工具调用都收到的"世界对象":配置、状态、UI 钩子、身份,全在这里 options: { // 会话级静态配置块 commands: Command[] // slash command 列表——工具可以查询当前有哪些 /命令可用 mainLoopModel: string // 主循环模型名——需要"用哪个模型做二次判断"的工具从这里拿 tools: Tools // 本会话完整工具表——子代理/组合型工具可以查其他工具的 schema mcpClients: MCPServerConnection[] // 已连接的 MCP server 列表 isNonInteractiveSession: boolean // 是否 headless(-p / SDK):决定能不能弹 UI、问用户 refreshTools?: () => Tools // 拉取最新工具表的回调——MCP server 可能在查询中途才连上 } abortController: AbortController // 全局中断句柄:用户按 Esc,所有长操作都该检查这个 signal readFileState: FileStateCache // 文件读取状态 LRU 缓存——防重复读、给 CLAUDE.md 注入做去重 getAppState(): AppState // 读全局应用状态(权限上下文、MCP 状态、后台任务……) setAppState(f): void // 写回全局状态;注释警告:对异步子代理这是 no-op,见 createSubagentContext handleElicitation?: (serverName, params, signal) => Promise<ElicitResult> // MCP -32042 URL elicitation 回调:print/SDK 模式委托给 structuredIO setToolJSX?: SetToolJSXFn // 注入自定义 React UI——local JSX command 用它渲染自己的界面 addNotification?: (notif) => void // 往通知中心推一条消息(OS 级或应用内) appendSystemMessage?: (msg: Exclude<SystemMessage, SystemLocalCommandMessage>) => void // 追加"仅 UI"系统消息:在 normalizeMessagesForAPI 边界被剥掉,Exclude<> 让类型系统强制这一点 // ……(中间约 25 个字段:文件历史、commit 归属状态、skill 触发器、denial 计数、内容替换预算……) messages: Message[] // 当前会话消息流——AgentTool 这类工具靠它把任务上下文传给子代理 agentId?: AgentId // 仅子代理设置:hooks 用它区分"主线程调用"还是"子代理调用"为什么这样设计:胖,是因为工具的种类太杂——BashTool 需要 abortController(长命令要能杀),FileReadTool 需要 readFileState(去重+缓存),AgentTool 需要 messages+agentId(传上下文+标身份),local JSX command 需要 setToolJSX(画界面)。与其给每个工具设计不同的参数签名,不如一个"上帝对象"全量下发、各取所需——代价是类型膨胀,收益是新增工具零适配成本:任何工具拿到 context 就能做上面所有事。两个细节值得放大看:setAppState 对异步子代理是 no-op(第 186-190 行注释),因为子代理跑在独立上下文里,写主线程状态会串台;而 appendSystemMessage 用 Exclude<> 在类型层面保证"UI-only 消息永远不会漏进 API 请求"——安全边界不靠自觉,靠编译器。
三、buildTool:安全默认值 + 显式覆盖
如果每个工具都要实现全部约 40 个成员,写新工具会是噩梦。Claude Code 的解法是 buildTool()(第 783-792 行):给最常打桩的 8 个方法提供默认值,工具定义里只写自己关心的部分。源码注释把原则说得很清楚——"fail-closed where it matters":
📄 src/Tool.ts (第 707-713 行 + 第 757-792 行)
type DefaultableToolKeys = // buildTool 提供默认值的 8 个方法:ToolDef 里可以省略它们 | 'isEnabled' | 'isConcurrencySafe' | 'isReadOnly' | 'isDestructive' | 'checkPermissions' | 'toAutoClassifierInput' | 'userFacingName'const TOOL_DEFAULTS = { // 默认值集合——注释原话:"Defaults (fail-closed where it matters)" isEnabled: () => true, // 默认可用(opt-out):想禁用要显式声明,而不是默认藏着 isConcurrencySafe: (_input?) => false, // 假设不可并行 → 调度器串行执行,安全侧兜底 isReadOnly: (_input?) => false, // 假设是写操作 → 权限系统按更高风险对待,宁可多问一次 isDestructive: (_input?) => false, // 默认非破坏性(只有真做删除/覆盖才设 true) checkPermissions: (input, _ctx?) => Promise.resolve({ behavior: 'allow', updatedInput: input }), // 无工具特化逻辑 → 放行给通用权限系统裁决,不自己拍板 toAutoClassifierInput: (_input?) => '', // 空串 = 跳过 auto-mode 安全分类器;注释警告:安全相关工具必须覆盖它 userFacingName: (_input?) => '', // 占位——下面 buildTool 会立刻用 name 覆盖掉}export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> { // 注释要求:所有工具导出都走这里,默认值只活在一个地方 return { // 运行时就是一个 spread:先铺默认值…… ...TOOL_DEFAULTS, userFacingName: () => def.name, // ……显示名默认 = 工具自己的 name(覆盖上面的空串占位) ...def, // ……最后铺工具自身定义——"安全默认 + 显式覆盖",调用方永远拿到完整 Tool } as BuiltTool<D> // 类型层面用 BuiltTool<D> 镜像这个 spread:0-error typecheck across all 60+ tools(源码注释)为什么这样设计:看默认值的取向就懂了——isConcurrencySafe 默认 false(假设不能并行,调度器就串行跑),isReadOnly 默认 false(假设会写,权限系统就按高风险走)。两个"假设最坏情况"的默认值,意味着新工具作者忘了声明时,系统自动走向保守一侧——这正是 fail-closed:出错的代价是慢一点/多问一次,而不是数据被改。而 checkPermissions 默认返回 allow 看似宽松,实则是"我不懂这个工具的语义,交给通用权限系统(permissions.ts)按规则裁决"——职责分离。类型层的 BuiltTool<D>(第 735-741 行)用条件类型精确镜像运行时的 spread:工具自己写了的方法保留原签名,没写的填上默认值——60+ 个工具全部通过 typecheck,说明这套"部分定义→完整对象"的推导没有一处失真。
四、实例解剖:GrepTool 如何填这张表
契约是抽象的,看一个真实工具就具体了。选 Grep(src/tools/GrepTool/GrepTool.ts,577 行)——它是 ripgrep 的包装器,麻雀虽小五脏俱全:
📄 src/tools/GrepTool/GrepTool.ts (第 160-200 行,声明部分)
export const GrepTool = buildTool({ // 一个对象字面量就是全部"注册表":契约的每个槽位在这里各就各位 name: GREP_TOOL_NAME, // 主名 'Grep'——模型按这个名字发起调用 searchHint: 'search file contents with regex (ripgrep)', // ToolSearch 关键词匹配用的一句话能力描述(规范要求 3-10 词) maxResultSizeChars: 20_000, // 结果超 2 万字符 → 落盘,模型只拿预览+路径(源码注释:persistence threshold) strict: true, // 严格模式:API 端更严格遵守工具说明和参数 schema async description() { return getDescription() }, // 动态描述函数——可以按环境变化措辞 userFacingName() { return 'Search' }, // UI 显示名:模型调的是 Grep,用户看到的是 Search(两个名字各服务一个读者) get inputSchema(): InputSchema { return inputSchema() }, // Zod schema:pattern/path/glob/type/output_mode/-A/-B/-C/head_limit…… isConcurrencySafe() { return true }, // 纯读搜索 → 显式声明可并行(覆盖默认的 false),调度器可以和其他调用并发跑 isReadOnly() { return true }, // 只读 → 权限系统可按"低风险"处理 toAutoClassifierInput(input) { // auto-mode 安全分类器的紧凑输入:把参数压成一行可读文本 return input.path ? `${input.pattern} in ${input.path}` : input.pattern // "pattern in path"——分类器一眼看懂这次搜索的范围 }, isSearchOrReadCommand() { return { isSearch: true, isRead: false } }, // UI 折叠标记:搜索类调用在紧凑视图里合并显示 getPath({ path }) { return path || getCwd() }, // 提取文件路径——权限规则(如 Grep(src/**))就是拿它做匹配的 async preparePermissionMatcher({ pattern }) { // hook if 条件匹配器:每个输入只调用一次,昂贵解析放这里 return rulePattern => matchWildcardPattern(rulePattern, pattern) // 返回闭包逐条规则模式匹配——"准备一次、匹配多次"的两段式设计 },为什么这样设计:三个细节最能体现契约的价值。其一,name(Grep)和 userFacingName(Search)分开——模型需要稳定的 API 名,用户需要直觉的显示名,一个字段服务不了两个读者。其二,isConcurrencySafe() 显式返回 true:搜索是纯读操作,声明之后调度器就能把它和其他工具并发执行——而默认值是 false,所以"想并行必须证明安全"。其三,preparePermissionMatcher 的两段式:解析输入只做一次(返回闭包),之后每条 hook 规则模式都复用这个闭包——把 O(规则数 × 解析成本) 降成 O(解析 + 规则数)。再看执行器 call(),以及一个藏在校验里的安全细节:
📄 src/tools/GrepTool/GrepTool.ts (第 207-210 行 + 第 310-348 行摘录)
// —— validateInput 摘录:一个藏在输入校验里的安全细节 ——if (absolutePath.startsWith('\\\\') || absolutePath.startsWith('//')) { // UNC 网络路径(\\server\share)——源码注释:防止 NTLM 凭据泄漏 return { result: true } // 直接跳过 fs.stat:不去触碰网络共享,把风险挡在文件系统调用之外// —— call() 摘录:工具如何把模型参数翻译成真实命令 ——async call({ pattern, path, glob, type, output_mode = 'files_with_matches', '-B': context_before, ... }, { abortController, getAppState }) { // 解构 args;'-B'/'-A'/'-C' 是 ripgrep 风格 flag(带连字符的 key 必须加引号) const absolutePath = path ? expandPath(path) : getCwd() // 没给路径 → 从当前工作目录搜起 const args = ['--hidden'] // 也搜隐藏文件:.env、.gitignore 这类模型经常需要的东西默认是隐藏的 for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) { // 排除版本控制元数据目录,减少 .git/objects 之类的噪音 args.push('--glob', `!${dir}`) // ripgrep 的 --glob !pattern = 排除匹配项 } args.push('--max-columns', '500') // 单行超 500 字符就截断——防 base64/压缩代码把上下文灌爆(源码注释原话) if (multiline) { args.push('-U', '--multiline-dotall') } // 多行模式只在显式要求时开:默认关闭避免误伤性能 if (case_insensitive) { args.push('-i') } // 大小写不敏感 flag 透传 if (output_mode === 'files_with_matches') { args.push('-l') } // -l = 只列文件名(默认模式) else if (output_mode === 'count') { args.push('-c') } // -c = 按文件统计匹配数 if (show_line_numbers && output_mode === 'content') { args.push('-n') } // 行号只在 content 模式下有意义,其他模式加了是噪音 if (pattern.startsWith('-')) { args.push('-e', pattern) } // 以连字符开头的 pattern → 用 -e 显式声明"这是模式不是 flag" else { args.push(pattern) } // 常规情况直接透传为什么这样设计:--max-columns 500 是上下文预算的守门员——模型搜到一个 minified JS 文件,不截断的话一行几万字符直接吃掉整个窗口。而 UNC 路径那段(第 207-210 行)值得单独说:在 Windows 上对 \\server\share 做 fs.stat 会触发 SMB/NTLM 认证握手,可能把凭据信息暴露给恶意共享——所以校验阶段直接短路返回"通过",让后续环节按普通路径处理而不发起网络调用。这是"安全边界前移到输入校验层"的实例:不等到执行时才发现问题。-e flag 同理——模型完全可能生成 grep -foo bar 这种以连字符开头的 pattern,不加防护会被 ripgrep 当成命令行选项解析。
五、注册表 tools.ts:工具清单是怎么拼出来的
单个工具定义好了,谁来决定"这个会话里存在哪些工具"?答案是 src/tools.ts(389 行)的 getAllBaseTools()——源码注释自称 "the source of truth for ALL tools"。它的写法本身就是一篇供应链安全教材:
📄 src/tools.ts (第 193-251 行,清单摘录)
export function getAllBaseTools(): Tools { // "当前环境里所有可能可用的工具"——注释自称 source of truth return [ AgentTool, // 子代理派生——多智能体编排的核心入口 TaskOutputTool, // 读后台任务输出(配合 TaskCreate/Stop 构成任务系统) BashTool, // shell 执行——能力最强也最危险的工具 ...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]), // Ant 内部构建把 bfs/ugrep 嵌进 bun 二进制 → 专用搜索工具冗余,直接不注册 ExitPlanModeV2Tool, // 退出计划模式(v2) FileReadTool, FileEditTool, FileWriteTool, // 文件读/编辑/写三件套 NotebookEditTool, // Jupyter ipynb 单元格编辑 WebFetchTool, TodoWriteTool, WebSearchTool, TaskStopTool, // 抓取 / todo 列表 / 联网搜索 / 停任务 AskUserQuestionTool, SkillTool, EnterPlanModeTool, // 向用户澄清 / 加载技能 / 进入计划模式 ...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []), // Ant 内部专用:配置工具,外部构建里根本不存在 ...(isTodoV2Enabled() ? [TaskCreateTool, TaskGetTool, TaskUpdateTool, TaskListTool] : []), // todo v2 = 任务管理四件套(运行时开关) ...(isEnvTruthy(process.env.ENABLE_LSP_TOOL) ? [LSPTool] : []), // LSP 语言服务——环境变量门控,默认关 ...(isWorktreeModeEnabled() ? [EnterWorktreeTool, ExitWorktreeTool] : []), // git worktree 隔离模式进出 getSendMessageTool(), // agent 间消息——注意是函数调用:lazy require 打破循环依赖(见下) ...(isAgentSwarmsEnabled() ? [getTeamCreateTool(), getTeamDeleteTool()] : []), // 多智能体团队(swarm)创建/删除 ...(process.env.NODE_ENV === 'test' ? [TestingPermissionTool] : []), // 仅测试环境存在的权限工具——生产构建里不存在 ListMcpResourcesTool, ReadMcpResourceTool, // MCP 资源读取——常开(MCP server 连上才有实际内容) ...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []), // 工具搜索:乐观检查,真正"延迟加载哪些工具"在请求时由 claude.ts 决定 ]}为什么这样设计:这份清单里藏着三层门控,对应三种"工具不存在":编译期——feature('AGENT_TRIGGERS') 这类 bun:bundle 特性开关配合条件 require()(第 29-52 行),让 cron/Sleep/Monitor 等工具在外部构建里被物理删除(dead code elimination),不是运行时 if,而是代码根本不在产物里;环境期——USER_TYPE === 'ant'、ENABLE_LSP_TOOL 等环境变量,同一份构建按部署环境裁剪;运行期——每个工具的 isEnabled(),会话级动态开关。三层叠加,"模型能看到什么工具"就变成一条可审计的决策链。另一个工程细节:TeamCreate/TeamDelete/SendMessage 三个工具用 lazy require(第 61-72 行),注释明说原因——tools.ts → TeamCreateTool → …… → tools.ts 的循环依赖,顶层 import 会死锁模块图,改成函数内 require 就断了。还有第 190-192 行那条注释值得所有做 prompt cache 的人抄走:"This MUST stay in sync with [Statsig dynamic config]…in order to cache the system prompt across users"——工具清单的顺序和成员必须与服务端缓存策略严格同步,否则跨用户共享的 system prompt 缓存全部失效。
CronCreate/Delete/List、Sleep、Monitor、WebBrowser | feature('…') + 条件 require,代码不进产物 | |
ConfigTool、TungstenTool(ant-only);LSPTool、PowerShellTool | USER_TYPE / ENABLE_LSP_TOOL 等环境变量裁剪 | |
TaskCreate/Get/List(todo v2) | ||
NODE_ENV === 'test' | TestingPermissionTool |
注册表旁边还有一个容易被忽略的目录:src/tools/shared/——跨工具复用的公共件。两个文件很有代表性:gitOperationTracking.ts(277 行)用正则识别 git commit / git push / gh pr create,文件头注释点明设计意图——"regexes operate on raw command text so they work identically for Bash and PowerShell":BashTool 和 PowerShellTool 各写一套检测是重复劳动,抽出来按原始命令文本匹配就两边通用;spawnMultiAgent.ts(1093 行)则是"从 TeammateTool 里抽出来的 teammate 创建逻辑,供 AgentTool 复用"——多智能体 spawn 的公共底座。shared/ 的存在说明这套工具系统已经过了"每个工具自给自足"的阶段,开始收敛公共能力。
六、getTools 过滤管线与 prompt cache 稳定性
基础清单 ≠ 最终清单。模型实际看到的工具表,还要过 getTools(permissionContext)(第 271-327 行)这道权限过滤:
📄 src/tools.ts (第 271-327 行,主干摘录)
export const getTools = (permissionContext: ToolPermissionContext): Tools => { // 本会话最终工具表 = 基础清单 × 权限上下文 if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { // SIMPLE 模式(--bare):最小工具集,面向嵌入场景的"最小权限"需求 const simpleTools: Tool[] = [BashTool, FileReadTool, FileEditTool] // 只留 Bash/Read/Edit 三件套——其余一律不存在 if (feature('COORDINATOR_MODE') && coordinatorModeModule?.isCoordinatorMode()) { // 若同时开了协调器模式…… simpleTools.push(AgentTool, TaskStopTool, getSendMessageTool()) // ……补上编排三件套:coordinator 要派任务、停任务、发消息(源码注释) } return filterToolsByDenyRules(simpleTools, permissionContext) // 最小模式也要过 deny 规则——权限过滤没有豁免通道 } const specialTools = new Set([ListMcpResourcesTool.name, ReadMcpResourceTool.name, SYNTHETIC_OUTPUT_TOOL_NAME]) // "特殊工具":别处按条件单独添加,这里先剔除防重复注册 const tools = getAllBaseTools().filter(tool => !specialTools.has(tool.name)) // 基础清单减去特殊工具 let allowedTools = filterToolsByDenyRules(tools, permissionContext) // blanket deny(如 mcp__server 整服禁用)——在模型看到之前就删掉,不是调用时才拒绝 if (isReplModeEnabled()) { // REPL 模式:原始工具被包进 VM 沙箱里…… allowedTools = allowedTools.filter(tool => !REPL_ONLY_TOOLS.has(tool.name)) // ……所以从直用列表隐藏,防止绕过沙箱直接调(源码注释) } const isEnabled = allowedTools.map(_ => _.isEnabled()) // 运行期开关:每个工具自己决定这个会话要不要出现 return allowedTools.filter((_, i) => isEnabled[i]) // 最终表 = 基础清单 − 特殊工具 − deny − REPL 隐藏,再 × isEnabled为什么这样设计:注意 deny 规则的生效位置——第 258-260 行注释说得很清楚:用与运行时权限检查(step 1a)同一个 matcher,让 mcp__server 这类前缀规则在模型看到工具表之前就把整个 server 的工具删光。区别在于:调用时才拒绝,模型会反复尝试、浪费轮次;注册时就删除,模型压根不知道有这个东西——对"用户明确禁用的能力",后者才是正确的安全姿态(不提示攻击面)。REPL 隐藏同理:工具没被禁用,只是换了个入口(VM 内),直用通道必须关掉。
内置工具和 MCP 工具的合流在 assembleToolPool()(第 345-367 行),这里藏着一个跨端协作的细节:
📄 src/tools.ts (第 345-367 行)
export function assembleToolPool(permissionContext: ToolPermissionContext, mcpTools: Tools): Tools { // 内置 + MCP 统一工具池——REPL.tsx 和 runAgent.ts(coordinator worker)共用这一个入口 const builtInTools = getTools(permissionContext) // 第一步:内置工具(已经过模式/权限/isEnabled 全套过滤) const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext) // 第二步:MCP 工具同样过 deny 规则——外部 server 的工具没有豁免权 const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name) // 排序比较器:按名字字母序 return uniqBy( // 第三步:按 name 去重——内置工具优先(uniqBy 保留插入顺序,冲突时先出现的赢) [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)), // 关键:两个分区各自排序后拼接,内置保持连续前缀 'name', )}为什么这样设计:"各自排序再拼接"看起来多此一举,第 354-361 行注释给出了原因——服务端的 claude_code_system_cache_policy 在"最后一个前缀匹配的内置工具之后"放全局缓存断点。如果做扁平排序(内置和 MCP 混在一起按名字排),某个 MCP 工具的名字恰好排在两个内置工具之间,就会把内置前缀打断,下游所有缓存键全部失效;分区排序保证内置工具永远是连续前缀,MCP 工具的增删只影响断点之后的部分。这是客户端代码为服务端缓存策略"让路"的实例——prompt cache 是跨用户共享的省钱机制,工具表顺序就是它的命门。注释还特意避开 Array.toSorted(Node 20+),因为要兼容 Node 18——readonly 数组先 copy 再 sort,细节都照顾到了。
七、一次工具调用的完整旅程
把这一讲的零件串起来:模型发出一个 tool_use 块之后,系统按固定管线处理它——契约表上的每个成员都在某个环节被调用:
一次工具调用的完整旅程(契约成员 → 管线环节)
① 查找:findToolByName(tools, name)
按主名或 alias 匹配(toolMatchesName);找不到 → 错误直接回给模型。
▼
② 校验:validateInput(input, context)
工具特化输入检查;失败返回 message+errorCode,让模型自己纠正重试。
▼
③ 权限:checkPermissions + 通用权限系统
allow/deny/ask 三态裁决;后台 agent 经 shouldAvoidPermissionPrompts 自动拒绝。
▼
④ 执行:call(args, context, canUseTool, …)
返回 Promise<ToolResult>;长操作经 onProgress 上报进度、监听 abortController。
▼
⑤ 序列化:mapToolResultToToolResultBlockParam
转成 API tool_result 块;超 maxResultSizeChars 落盘,模型拿预览+路径。
▼
⑥ 渲染与副作用:renderToolResultMessage + newMessages
UI 展示结果;newMessages 注入附加消息,contextModifier 仅对非并发安全工具生效。
小结:Claude Code 的工具系统 = 一张契约表(Tool.ts)× 一个注册表(tools.ts)。契约表用 TypeScript 类型把"模型面/安全面/UI 面"焊死在同一个对象上,buildTool 的 fail-closed 默认值保证新工具作者犯错时系统自动走向保守;注册表用编译期 DCE + 环境期 env gate + 运行期 isEnabled() 三层门控决定"谁能存在",再用 deny 规则、REPL 隐藏和分区排序把最终清单打磨成既安全又对 prompt cache 友好的形态。下一讲进入上下文收集——CLAUDE.md 是怎么被注入进模型视野的。
📚 系列导航
← 第 2 讲:CLI 入口与命令分发
→ 第 4 讲:上下文收集(CLAUDE.md 注入)
关注公众号「AI技术推荐官」获取更多源码解析内容
📌 每天 30 条,挑出真能用的 AI 东西
UnitAI 每天从 GitHub / Hacker News / Hugging Face 抓一遍 AI 热榜,还收录了 50+ 个上过榜的项目,能按分类、星级、上榜天数筛。免费看,不用注册。

长按识别二维码打开 · 或点左下角「阅读原文」















































































