这一章讲「一个工具」在程序里被建模成什么样子,以及 40 个内建工具是怎么被组织和投放的。
Tool.ts 全文 793 行,其中类型定义 Tool<Input, Output, Progress> 占了 330 行。它把「一个工具需要回答的所有问题」切成了七组互不重叠的能力:
因为这一组不是给人看的,是给调度器看的。调度器完全不认识任何具体工具 —— 它不知道什么是 Bash、什么是 Read,它只会问这几个布尔问题,然后据此安排执行:
| 谓词 | 调度器拿它做什么决定 |
|---|---|
isConcurrencySafe(参数) | 这个调用能不能和相邻的调用并行执行(第 5 章) |
isReadOnly(参数) | 能不能走权限判定的快速通道 —— 只读操作通常可以自动放行 |
isDestructive(参数) | 要不要额外弹一次确认 |
isOpenWorld(参数) | 要不要按「访问外网」的策略处理 |
requiresUserInteraction() | 后台任务里能不能用 —— 后台没人在场,弹不出确认框 |
isSearchOrReadCommand(参数) | 界面上要不要把这次调用折叠成一行(避免刷屏) |
这样一来,调度策略就从工具实现里被完全剥离出来了。
新增一个工具时,不需要修改调度器的任何一行代码 —— 只需要在新工具里如实回答这几个问题。反过来,改进调度算法时也不需要碰任何工具的实现。
isConcurrencySafe(input) 是接收参数的方法,不是一个静态标记。
同一个 Bash 工具:执行 ls(列文件)是并发安全的,执行 rm -rf(删除)就不安全。安全性取决于这次具体要做什么,而不取决于工具类型。如果建模成静态标记,Bash 工具就只能永远声明「我不安全」,从而失去所有并行机会。
所有工具都通过一个工厂函数创建:
const TOOL_DEFAULTS = {
isEnabled: () => true,
isConcurrencySafe: () => false, // ← 默认"不能并行"
isReadOnly: () => false, // ← 默认"会写入"
isDestructive: () => false,
checkPermissions: (input) => ({ behavior:'allow', updatedInput: input }),
toAutoClassifierInput: () => '', // ← 默认"跳过安全分类器"
userFacingName: () => '',
}
export function buildTool<D>(def: D): BuiltTool<D> {
return { ...TOOL_DEFAULTS, // 先铺默认值
userFacingName: () => def.name,
...def } // 再用工具自己的定义覆盖
}
claude-code/src/Tool.ts
源码注释总结了设计原则:「Defaults (fail-closed where it matters)」(默认值在重要的地方倒向保守)。
| 工具作者忘了声明 | 后果 |
|---|---|
| 并发安全性 | 当成不安全 → 串行执行 → 慢一点,但绝不会出竞态 |
| 只读性 | 当成会写入 → 多问一次权限 → 啰嗦一点,但绝不会误放行 |
| 破坏性 | 当成不破坏 → 少一次确认 |
toAutoClassifierInput 默认返回空字符串,意思是「这个工具不进安全分类器的视野」。注释解释了原因:
「skip classifier — security-relevant tools must override」
译:跳过分类器 —— 有安全含义的工具必须自己重写这个方法。
逻辑是:安全分类器是给「有安全含义」的工具用的。一个工具如果没有显式声明自己有安全含义,它就不该占用分类器的 token 预算。
安全性由前面那条 10 步权限判定链保证(第 7 章),不靠分类器兜底。这个区分把「省钱」和「保安全」两件事的责任分清了 —— 分类器是成本敏感的优化手段,不是安全防线。
| 类别 | 工具 |
|---|---|
| 文件操作 | FileReadTool 读 · FileWriteTool 写 · FileEditTool 精确替换 · NotebookEditTool 改 Jupyter 笔记本 |
| 搜索 | GlobTool 按文件名模式找 · GrepTool 按内容找注意:在内部版本里这两个会被去掉 —— 因为可执行文件里内嵌了更快的搜索程序,直接在 shell 里用 |
| 命令执行 | BashTool(157 KB,最复杂的工具)· PowerShellTool(Windows,141 KB)· REPLTool(内部版,让模型写 JS 编排内部工具) |
| 网络 | WebFetchTool 抓网页 · WebSearchTool 搜索 · WebBrowserTool 浏览器(特性开关控制) |
| 子智能体 | AgentTool(228 KB)· TaskStopTool · TaskOutputTool · TeamCreateTool / TeamDeleteTool(多智能体群)· SendMessageTool |
| 任务管理 | TodoWriteTool 待办清单 · TaskCreateTool / TaskGetTool / TaskUpdateTool / TaskListTool(新版任务系统) |
| 交互 | AskUserQuestionTool 向用户提问 · EnterPlanModeTool / ExitPlanModeTool 计划模式进出 |
| 扩展接入 | SkillTool 调用技能 · MCPTool · ListMcpResourcesTool / ReadMcpResourceTool · McpAuthTool · ToolSearchTool 工具搜索 |
| 工作树 | EnterWorktreeTool / ExitWorktreeTool —— 让智能体在一份隔离的代码副本里工作 |
| 定时与远程 | ScheduleCronTool(创建/删除/列出定时任务)· RemoteTriggerTool · SleepTool |
| 其他 | LSPTool 代码导航 · ConfigTool · BriefTool · SyntheticOutputTool 结构化输出 · SnipTool 历史裁剪 |
export function getAllBaseTools(): Tools {
return [
AgentTool,
TaskOutputTool,
BashTool,
// 内部原生构建版把快速搜索程序内嵌进了可执行文件,
// shell 里的 find/grep 被别名指向它们,所以不需要独立的 Glob/Grep 工具
...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
ExitPlanModeV2Tool,
FileReadTool, FileEditTool, FileWriteTool, NotebookEditTool,
WebFetchTool, TodoWriteTool, WebSearchTool, TaskStopTool,
AskUserQuestionTool, SkillTool, EnterPlanModeTool,
...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []), // 只给内部用户
...(isTodoV2Enabled() ? [TaskCreateTool, TaskGetTool, ...] : []),
...(isEnvTruthy(process.env.ENABLE_LSP_TOOL) ? [LSPTool] : []),
...(isWorktreeModeEnabled() ? [EnterWorktreeTool, ExitWorktreeTool] : []),
...(isAgentSwarmsEnabled() ? [getTeamCreateTool(), getTeamDeleteTool()] : []),
...cronTools,
...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
]
}
三种条件维度:编译期特性开关(feature('XXX'))、运行时环境变量、用户类型(内部 / 外部)。第 13 章会讲编译期开关怎么做到「外部版本里这些代码根本不存在」。
每个工具的完整说明文字都要放进系统提示词,而系统提示词每一轮都要重发。用户接了十几个 MCP 外部服务时,工具总数可能上百个,说明文字加起来几万 token —— 每轮都付一遍。
相关的两个字段:
shouldDefer: true —— 这个工具延迟加载alwaysLoad: true —— 永不延迟。用于模型在第一轮就必须看到的工具。MCP 外部工具可以在服务端通过 _meta['anthropic/alwaysLoad'] 声明「3–10 words, no trailing period. Prefer terms not already in the tool name (e.g. 'jupyter' for NotebookEdit).」
译:3 到 10 个词,末尾不加句号。优先用工具名里还没有的词(比如 NotebookEdit 这个工具的关键词应该写 'jupyter')。
为什么?因为模型如果搜 "notebook",工具名本身就能匹配上。关键词的价值在于覆盖工具名里没体现的同义说法 —— Jupyter 是那类笔记本文件的实际产品名,模型很可能用这个词描述需求。
延迟加载有一个副作用:模型可能凭记忆调用一个它还没加载完整说明的工具,参数写错了。所以参数校验失败时有一个特殊提示:
const schemaHint = buildSchemaNotSentHint(tool, toolUseContext.messages,
toolUseContext.options.tools)
if (schemaHint) {
logEvent('tengu_deferred_tool_schema_not_sent', {
toolName: sanitizeToolNameForAnalytics(tool.name), isMcp: tool.isMcp ?? false })
errorContent += schemaHint // 追加提示:"你还没加载这个工具的说明,先搜一下"
}
而且这个情况专门有埋点(tengu_deferred_tool_schema_not_sent)—— 说明他们在监控「延迟加载导致的调用失败率」,用来判断这个优化的净收益。
这段代码只有 8 行,但它揭示的东西非常值钱:
export function assembleToolPool(permissionContext, mcpTools): Tools {
const builtInTools = getTools(permissionContext) // 内建工具
const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)
const byName = (a, b) => a.name.localeCompare(b.name)
return uniqBy(
[...builtInTools].sort(byName) // ★ 内建工具单独排序
.concat(allowedMcpTools.sort(byName)), // ★ 外部工具单独排序后拼在后面
'name', // 按名字去重,内建优先
)
}
claude-code/src/tools.ts
注意:两组是分别排序后拼接的,不是合并成一个大数组统一排序。对不了解缓存机制的人来说,这看起来是多余的复杂化。源码注释给出了答案:
「The server's cache policy places a global cache breakpoint after the last prefix-matched built-in tool; a flat sort would interleave MCP tools into built-ins and invalidate all downstream cache keys whenever an MCP tool sorts between existing built-ins.」
译:服务端的缓存策略在「最后一个前缀匹配成功的内建工具」之后放置一个全局缓存分界点。统一排序会让外部工具插进内建工具中间 —— 那么每当有一个外部工具的名字恰好排在两个内建工具之间时,分界点之后的全部缓存键都会失效。
同一个文件里还有一行注释:
/**
* NOTE: This MUST stay in sync with
* https://console.statsig.com/.../claude_code_global_system_caching,
* in order to cache the system prompt across users.
*/
export function getAllBaseTools(): Tools { ... }
译:注意:这个函数必须和某个线上配置保持同步,才能让系统提示词在所有用户之间共享缓存。
系统提示词的缓存是跨用户共享的。工具清单的顺序是那份全局配置的一部分。
如果排序逻辑出错,受影响的不是一个用户,而是所有用户的缓存一起崩。这也解释了为什么这么一段看起来不优雅的代码值得存在。
有时工具需要给日志、钩子、开发工具包补充一些派生字段(比如把相对路径展开成绝对路径)。但那个要发回接口的原始参数对象绝对不能改 —— 改一个字节,缓存就没了。
/**
* Called on copies of tool_use input before observers see it (SDK stream,
* transcript, canUseTool, PreToolUse/PostToolUse hooks). Mutate in place
* to add legacy/derived fields. Must be idempotent. The original API-bound
* input is never mutated (preserves prompt cache).
*/
backfillObservableInput?(input: Record<string, unknown>): void
调用处的实现更讲究:
const originalInput = block.input as Record<string, unknown>
const inputCopy = { ...originalInput } // 克隆
tool.backfillObservableInput(inputCopy) // 只改克隆体
// ★ 只有当补充操作"新增了字段"时才产生克隆版消息;
// 如果只是覆写了已有字段,连克隆都不做
const addedFields = Object.keys(inputCopy).some(k => !(k in originalInput))
if (addedFields) {
clonedContent ??= [...message.message.content]
clonedContent[i] = { ...block, input: inputCopy }
}
为什么「只覆写已有字段」就不克隆?注释解释了:
「Overwrites change the serialized transcript and break VCR fixture hashes on resume, while adding nothing the SDK stream needs — hooks get the expanded path via toolExecution.ts separately.」
译:覆写会改变序列化后的对话记录,并且在恢复时破坏录制回放测试固件的哈希值,而它又没给开发工具包的流提供任何新东西 —— 钩子已经通过另一条路径拿到展开后的路径了。
(录制回放测试:把真实的接口请求响应录下来,测试时回放,避免每次跑测试都真的调接口。它靠请求内容的哈希来匹配录制,所以序列化结果变了就匹配不上。)
这个级别的克制程度,能说明「保护缓存」在这个系统里是一等公民约束 —— 甚至连一个可能影响测试固件的字段覆写都要避免。