全文目录Contents
- 0 · The Project at a Glance, and a Map of the Code
- 1 · The Entry Layer and Startup
- 2 · The Session Layer: QueryEngine
- 2.1 The problem it solves
- 2.2 What state it holds
- 2.3 The full flow of one submitMessage
- 2.4 Why the user message must hit disk first
- 2.5 Consuming the main loop’s output: one big switch
- 2.6 The compact boundary: proactively releasing memory
- 2.7 Three kinds of exit result
- 2.8 ask(): a convenience wrapper for one-shot calls
- 3 · The Agent Main Loop ★
- 3.1 The skeleton of the loop
- 3.2 State: centralizing cross-iteration state
- 3.3 transition: a field that exists purely for testability
- 3.4 The seven transition edges, one by one
- 3.5 The error-withholding mechanism
- 3.6 Interrupt handling
- 3.7 Model fallback: three actions
- 3.8 The three laws of thinking blocks
- 3.9 Other mechanisms in the loop
- 3.10 Every exit point of the loop
- 4 · The Tool Model
- 4.1 The Tool interface: seven orthogonal capability groups
- 4.2 Why the “safety predicates” deserve their own group
- 4.3 Fail-safe defaults
- 4.4 The 40 built-in tools, by category
- 4.5 Progressive tool loading
- 4.6 Tool list assembly: a hidden constraint about caching
- 4.7 backfillObservableInput: an extreme example of cache protection
- 5 · Tool Execution
- 5.1 The execution pipeline at a glance
- 5.2 Concurrency partitioning: a greedy algorithm
- 5.3 Context modifications are queued until the batch ends
- 5.4 A single execution: the full flow of runToolUse
- 5.5 The streaming tool executor
- 5.6 The sibling abort controller: the most elegant design in the file
- 5.7 The discard mechanism
- 5.8 “Tombstone” messages
- 5.9 Final processing of results
- 6 · Context Management ★
- 7 · The Permission System
- 8 · Subagents
- 9 · The Extension System
- 10 · The Terminal UI Layer
- 10.1 Writing a Terminal UI in React
- 10.2 The Four Biggest Components
- 10.3 Why the Input Box Is 347 KB
- 10.4 The Virtualized Message List
- 10.5 Six Rendering States for Tool Results
- 10.6 Collapsing: Avoiding Screen Flood
- 10.7 87 State-Management Units
- 10.8 The Interface Between UI and Kernel: Callbacks in ToolUseContext
- 10.9 A Fun Detail: ANSI to PNG
- 11 · Persistence and Resume
- 12 · The Observability System
- 13 · Build and Distribution
4 · 工具模型
这一章讲「一个工具」在程序里被建模成什么样子,以及 40 个内建工具是怎么被组织和投放的。
4.1 Tool 接口:七组正交能力
Tool.ts 全文 793 行,其中类型定义 Tool<Input, Output, Progress> 占了 330 行。它把「一个工具需要回答的所有问题」切成了七组互不重叠的能力:
4.2 为什么「安全谓词」值得单独成组
因为这一组不是给人看的,是给调度器看的。调度器完全不认识任何具体工具 —— 它不知道什么是 Bash、什么是 Read,它只会问这几个布尔问题,然后据此安排执行:
| 谓词 | 调度器拿它做什么决定 |
|---|---|
isConcurrencySafe(参数) | 这个调用能不能和相邻的调用并行执行(第 5 章) |
isReadOnly(参数) | 能不能走权限判定的快速通道 —— 只读操作通常可以自动放行 |
isDestructive(参数) | 要不要额外弹一次确认 |
isOpenWorld(参数) | 要不要按「访问外网」的策略处理 |
requiresUserInteraction() | 后台任务里能不能用 —— 后台没人在场,弹不出确认框 |
isSearchOrReadCommand(参数) | 界面上要不要把这次调用折叠成一行(避免刷屏) |
这样一来,调度策略就从工具实现里被完全剥离出来了。
新增一个工具时,不需要修改调度器的任何一行代码 —— 只需要在新工具里如实回答这几个问题。反过来,改进调度算法时也不需要碰任何工具的实现。
一个容易被忽略的细节:谓词接收参数
isConcurrencySafe(input) 是接收参数的方法,不是一个静态标记。
同一个 Bash 工具:执行 ls(列文件)是并发安全的,执行 rm -rf(删除)就不安全。安全性取决于这次具体要做什么,而不取决于工具类型。如果建模成静态标记,Bash 工具就只能永远声明「我不安全」,从而失去所有并行机会。
4.3 失败保守默认值
所有工具都通过一个工厂函数创建:
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 章),不靠分类器兜底。这个区分把「省钱」和「保安全」两件事的责任分清了 —— 分类器是成本敏感的优化手段,不是安全防线。
4.4 40 个内建工具分类
| 类别 | 工具 |
|---|---|
| 文件操作 | 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 章会讲编译期开关怎么做到「外部版本里这些代码根本不存在」。
4.5 渐进式工具加载
问题
每个工具的完整说明文字都要放进系统提示词,而系统提示词每一轮都要重发。用户接了十几个 MCP 外部服务时,工具总数可能上百个,说明文字加起来几万 token —— 每轮都付一遍。
解法:defer_loading(延迟加载)
相关的两个字段:
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)—— 说明他们在监控「延迟加载导致的调用失败率」,用来判断这个优化的净收益。
4.6 工具清单装配:一个关于缓存的隐藏约束
这段代码只有 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 { ... }
译:注意:这个函数必须和某个线上配置保持同步,才能让系统提示词在所有用户之间共享缓存。
系统提示词的缓存是跨用户共享的。工具清单的顺序是那份全局配置的一部分。
如果排序逻辑出错,受影响的不是一个用户,而是所有用户的缓存一起崩。这也解释了为什么这么一段看起来不优雅的代码值得存在。
4.7 backfillObservableInput:一个极致的缓存保护例子
有时工具需要给日志、钩子、开发工具包补充一些派生字段(比如把相对路径展开成绝对路径)。但那个要发回接口的原始参数对象绝对不能改 —— 改一个字节,缓存就没了。
/**
* 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.」
译:覆写会改变序列化后的对话记录,并且在恢复时破坏录制回放测试固件的哈希值,而它又没给开发工具包的流提供任何新东西 —— 钩子已经通过另一条路径拿到展开后的路径了。
(录制回放测试:把真实的接口请求响应录下来,测试时回放,避免每次跑测试都真的调接口。它靠请求内容的哈希来匹配录制,所以序列化结果变了就匹配不上。)
这个级别的克制程度,能说明「保护缓存」在这个系统里是一等公民约束 —— 甚至连一个可能影响测试固件的字段覆写都要避免。
4 · The Tool Model
This chapter covers how “a tool” is modeled inside the program, and how the 40 built-in tools are organized and served to the model.
4.1 The Tool interface: seven orthogonal capability groups
Tool.ts is 793 lines in total, of which the type definition Tool<Input, Output, Progress> takes up 330. It slices “every question a tool needs to answer” into seven non-overlapping capability groups:
4.2 Why the “safety predicates” deserve their own group
Because this group is not for humans; it is for the scheduler. The scheduler knows nothing about any concrete tool — it has no idea what Bash is or what Read is. It only asks these few yes/no questions and arranges execution accordingly:
| Predicate | What the scheduler decides with it |
|---|---|
isConcurrencySafe(input) | Whether this call can run in parallel with its neighbors (Chapter 5) |
isReadOnly(input) | Whether it can take the fast lane through permission checks — read-only operations can usually be auto-approved |
isDestructive(input) | Whether to pop up an extra confirmation |
isOpenWorld(input) | Whether to apply the “external network access” policy |
requiresUserInteraction() | Whether it can be used in a background task — nobody is present in the background, so no confirmation dialog can appear |
isSearchOrReadCommand(input) | Whether the UI should collapse this call to one line (to avoid flooding the screen) |
With this, scheduling policy is completely separated from tool implementation.
Adding a new tool requires changing not a single line of the scheduler — you just answer these questions truthfully in the new tool. Conversely, improving the scheduling algorithm never requires touching any tool’s implementation.
An easily overlooked detail: the predicates take arguments
isConcurrencySafe(input) is a method that takes the input, not a static flag.
Same Bash tool: running ls (list files) is concurrency-safe; running rm -rf (delete) is not. Safety depends on what this particular call is going to do, not on the tool type. Modeled as a static flag, the Bash tool could only ever declare “I am unsafe,” giving up every opportunity for parallelism.
4.3 Fail-safe defaults
Every tool is created through a factory function:
const TOOL_DEFAULTS = {
isEnabled: () => true,
isConcurrencySafe: () => false, // ← default: "cannot run in parallel"
isReadOnly: () => false, // ← default: "writes"
isDestructive: () => false,
checkPermissions: (input) => ({ behavior:'allow', updatedInput: input }),
toAutoClassifierInput: () => '', // ← default: "skip the safety classifier"
userFacingName: () => '',
}
export function buildTool<D>(def: D): BuiltTool<D> {
return { ...TOOL_DEFAULTS, // lay down the defaults first
userFacingName: () => def.name,
...def } // then override with the tool's own definition
}
claude-code/src/Tool.ts
A source comment sums up the design principle: “Defaults (fail-closed where it matters)”.
| The tool author forgot to declare | Consequence |
|---|---|
| Concurrency safety | Treated as unsafe → runs serially → a bit slower, but never a race condition |
| Read-only-ness | Treated as writing → one more permission prompt → a bit chattier, but never wrongly approved |
| Destructiveness | Treated as non-destructive → one fewer confirmation |
The one default that looks like it breaks the rule
toAutoClassifierInput defaults to returning an empty string, meaning “this tool stays out of the safety classifier’s view.” The comment explains why:
“skip classifier — security-relevant tools must override”
In other words: skip the classifier — tools with security implications must override this method themselves.
The logic: the safety classifier is for tools that “have security implications.” A tool that has not explicitly declared itself security-relevant should not consume the classifier’s token budget.
Safety is guaranteed by the 10-step permission decision chain mentioned earlier (Chapter 7), not by the classifier as a backstop. This distinction cleanly separates the responsibilities of “saving money” and “staying safe” — the classifier is a cost-sensitive optimization, not a security boundary.
4.4 The 40 built-in tools, by category
| Category | Tools |
|---|---|
| File operations | FileReadTool read · FileWriteTool write · FileEditTool exact replacement · NotebookEditTool edit Jupyter notebooks |
| Search | GlobTool find by filename pattern · GrepTool find by contentNote: in the internal build these two are removed — the executable embeds faster search programs that are used directly from the shell |
| Command execution | BashTool (157 KB, the most complex tool) · PowerShellTool (Windows, 141 KB) · REPLTool (internal build; lets the model write JS to orchestrate internal tools) |
| Network | WebFetchTool fetch web pages · WebSearchTool search · WebBrowserTool browser (behind a feature flag) |
| Subagents | AgentTool (228 KB) · TaskStopTool · TaskOutputTool · TeamCreateTool / TeamDeleteTool (multi-agent swarms) · SendMessageTool |
| Task management | TodoWriteTool to-do list · TaskCreateTool / TaskGetTool / TaskUpdateTool / TaskListTool (the new task system) |
| Interaction | AskUserQuestionTool ask the user a question · EnterPlanModeTool / ExitPlanModeTool enter and leave plan mode |
| Extension access | SkillTool invoke a skill · MCPTool · ListMcpResourcesTool / ReadMcpResourceTool · McpAuthTool · ToolSearchTool tool search |
| Worktrees | EnterWorktreeTool / ExitWorktreeTool — let the agent work in an isolated copy of the code |
| Scheduling and remote | ScheduleCronTool (create/delete/list scheduled jobs) · RemoteTriggerTool · SleepTool |
| Other | LSPTool code navigation · ConfigTool · BriefTool · SyntheticOutputTool structured output · SnipTool history trimming |
The tool list is assembled conditionally
export function getAllBaseTools(): Tools {
return [
AgentTool,
TaskOutputTool,
BashTool,
// The internal native build embeds fast search programs in the executable, and
// find/grep in the shell are aliased to them, so standalone Glob/Grep tools aren't needed
...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
ExitPlanModeV2Tool,
FileReadTool, FileEditTool, FileWriteTool, NotebookEditTool,
WebFetchTool, TodoWriteTool, WebSearchTool, TaskStopTool,
AskUserQuestionTool, SkillTool, EnterPlanModeTool,
...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []), // internal users only
...(isTodoV2Enabled() ? [TaskCreateTool, TaskGetTool, ...] : []),
...(isEnvTruthy(process.env.ENABLE_LSP_TOOL) ? [LSPTool] : []),
...(isWorktreeModeEnabled() ? [EnterWorktreeTool, ExitWorktreeTool] : []),
...(isAgentSwarmsEnabled() ? [getTeamCreateTool(), getTeamDeleteTool()] : []),
...cronTools,
...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
]
}
Three dimensions of condition: compile-time feature flags (feature('XXX')), runtime environment variables, and user type (internal / external). Chapter 13 explains how compile-time flags achieve “this code does not even exist in the external build.”
4.5 Progressive tool loading
The problem
Every tool’s full description goes into the system prompt, and the system prompt is resent every turn. When a user has connected a dozen or so MCP external servers, the total tool count can exceed a hundred, and the descriptions add up to tens of thousands of tokens — paid for again every turn.
The fix: defer_loading
The two related fields:
shouldDefer: true— this tool is lazily loadedalwaysLoad: true— never deferred. For tools the model must see on the very first turn. MCP external tools can declare this on the server side via_meta['anthropic/alwaysLoad']
How to write the keywords
“3–10 words, no trailing period. Prefer terms not already in the tool name (e.g. 'jupyter' for NotebookEdit).”
In other words: 3 to 10 words, no trailing period. Prefer words that are not already in the tool name (for instance, the keyword for the NotebookEdit tool should be 'jupyter').
Why? Because if the model searches for "notebook," the tool name alone already matches. The value of the keywords lies in covering synonyms the tool name does not express — Jupyter is the actual product name for that kind of notebook file, and the model is quite likely to use that word when describing what it needs.
Remediation when deferred loading goes wrong
Deferred loading has a side effect: the model may call a tool from memory before its full description has been loaded, and get the parameters wrong. So when parameter validation fails, there is a special hint:
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 // append the hint: "you haven't loaded this tool's description yet; search for it first"
}
And this situation has its own telemetry event (tengu_deferred_tool_schema_not_sent) — which tells you they are monitoring “the rate of call failures caused by deferred loading” to judge the net benefit of this optimization.
4.6 Tool list assembly: a hidden constraint about caching
This code is only 8 lines long, but what it reveals is extremely valuable:
export function assembleToolPool(permissionContext, mcpTools): Tools {
const builtInTools = getTools(permissionContext) // built-in tools
const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)
const byName = (a, b) => a.name.localeCompare(b.name)
return uniqBy(
[...builtInTools].sort(byName) // ★ built-in tools sorted on their own
.concat(allowedMcpTools.sort(byName)), // ★ external tools sorted on their own, then appended
'name', // dedup by name; built-ins win
)
}
claude-code/src/tools.ts
Note: the two groups are sorted separately and then concatenated, not merged into one big array and sorted together. To someone unfamiliar with the caching mechanism, this looks like needless complication. The source comment gives the answer:
“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.”
In other words: the server’s cache policy places a global cache breakpoint after “the last built-in tool that prefix-matched.” A flat sort would interleave external tools among the built-ins — so whenever an external tool’s name happens to sort between two built-ins, every cache key after the breakpoint is invalidated.
How serious this is
The same file has another comment:
/**
* 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 { ... }
In other words: note: this function must stay in sync with a particular live configuration so that the system prompt’s cache can be shared across all users.
The system prompt cache is shared across users. The order of the tool list is part of that global configuration.
If the sorting logic goes wrong, what gets hit is not one user, but the cache for every user at once. That also explains why a piece of code this inelegant-looking deserves to exist.
4.7 backfillObservableInput: an extreme example of cache protection
Sometimes a tool needs to add derived fields for logs, hooks, or the SDK (for example, expanding a relative path into an absolute one). But the original parameter object that gets sent back to the API must never be changed — change one byte and the cache is gone.
/**
* 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
The implementation at the call site is even more careful:
const originalInput = block.input as Record<string, unknown>
const inputCopy = { ...originalInput } // clone
tool.backfillObservableInput(inputCopy) // mutate only the clone
// ★ Produce a cloned message only when the backfill "added fields";
// if it merely overwrote existing fields, don't even clone
const addedFields = Object.keys(inputCopy).some(k => !(k in originalInput))
if (addedFields) {
clonedContent ??= [...message.message.content]
clonedContent[i] = { ...block, input: inputCopy }
}
Why no clone when it “only overwrites existing fields”? The comment explains:
“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.”
In other words: overwrites change the serialized transcript and break the record-and-replay test fixture hashes on resume, while contributing nothing the SDK stream needs — hooks already get the expanded path through a separate route.
(Record-and-replay testing: record real API requests and responses, then replay them during tests so you do not actually hit the API on every test run. It matches recordings by a hash of the request content, so if the serialized result changes, nothing matches.)
This level of restraint shows that “protect the cache” is a first-class constraint in this system — even a field overwrite that might affect test fixtures is avoided.