Claude Code 架构全解Claude Code Architecture, in Full第 4 章 · 14 章Chapter 4 of 14
全文目录Contents
  1. 0 · 项目全景与代码地图
    1. 0.1 这个软件是什么
    2. 0.2 源码目录逐个解释
    3. 0.3 从这张地图能读出的三件事
    4. 0.4 一次完整请求的旅程(全文导航)
    5. 0.5 全文章节索引
  2. 1 · 入口层与启动流程
    1. 1.1 四种启动形态
    2. 1.2 启动的第一个设计:快路径分派
    3. 1.3 命令行参数:60 多个选项
    4. 1.4 --bare:一个值得单独讲的极简模式
    5. 1.5 启动时序
    6. 1.6 系统提示词的三段结构
  3. 2 · 会话层:QueryEngine
    1. 2.1 它解决什么问题
    2. 2.2 它持有哪些状态
    3. 2.3 一次 submitMessage 的完整流程
    4. 2.4 用户消息为什么必须先落盘
    5. 2.5 消费主循环输出:一个大 switch
    6. 2.6 压缩分界点:主动释放内存
    7. 2.7 三种退出结果
    8. 2.8 ask():一次性调用的便捷封装
  4. 3 · 智能体主循环 ★
    1. 3.1 循环的骨架
    2. 3.2 State:把跨轮次状态集中管理
    3. 3.3 transition:只为可测试而存在的字段
    4. 3.4 七条转移边逐条详解
    5. 3.5 错误扣留机制
    6. 3.6 中断处理
    7. 3.7 模型降级:三个动作
    8. 3.8 思考块三定律
    9. 3.9 循环里的其他机制
    10. 3.10 循环的所有退出点
  5. 4 · 工具模型
    1. 4.1 Tool 接口:七组正交能力
    2. 4.2 为什么「安全谓词」值得单独成组
    3. 4.3 失败保守默认值
    4. 4.4 40 个内建工具分类
    5. 4.5 渐进式工具加载
    6. 4.6 工具清单装配:一个关于缓存的隐藏约束
    7. 4.7 backfillObservableInput:一个极致的缓存保护例子
  6. 5 · 工具执行
    1. 5.1 执行链路总览
    2. 5.2 并发分区:贪心算法
    3. 5.3 上下文修改要排队到批次结束
    4. 5.4 单次执行:runToolUse 的完整流程
    5. 5.5 流式工具执行器
    6. 5.6 兄弟中止控制器:最漂亮的一处设计
    7. 5.7 丢弃机制
    8. 5.8 「墓碑」消息
    9. 5.9 结果的最终处理
  7. 6 · 上下文治理 ★
    1. 6.1 五级流水线
    2. 6.2 第 ① 级:工具结果预算
    3. 6.3 第 ③ 级:微压缩与缓存编辑
    4. 6.4 第 ⑤ 级:自动摘要压缩
    5. 6.5 上下文真的超了:三级恢复瀑布
  8. 7 · 权限系统
    1. 7.1 六种权限模式
    2. 7.2 十步决策级联
    3. 7.3 自动模式:模型分类器 + 三级快速通道
    4. 7.4 权限规则的语法
    5. 7.5 沙箱与只读命令判定
    6. 7.6 权限判定的完整数据结构
    7. 7.7 权限决策的可解释性
  9. 8 · 子智能体
    1. 8.1 首要动机是上下文隔离,不是并行
    2. 8.2 三种形态
    3. 8.3 分叉:把提示词缓存用到极致
    4. 8.4 子智能体的工具限制
    5. 8.5 子智能体的上下文构造
    6. 8.6 内建智能体类型
    7. 8.7 后台任务的几种形态
    8. 8.8 中断的级联
  10. 9 · 扩展体系
    1. 9.1 四类扩展点对照
    2. 9.2 技能系统
    3. 9.3 插件系统
    4. 9.4 MCP 客户端
    5. 9.5 钩子:15 类生命周期事件
    6. 9.6 输出样式
  11. 10 · 终端界面层
    1. 10.1 用 React 写终端界面
    2. 10.2 最大的四个组件
    3. 10.3 输入框为什么有 347 KB
    4. 10.4 虚拟消息列表
    5. 10.5 工具结果的六种渲染状态
    6. 10.6 折叠:避免刷屏
    7. 10.7 87 个状态管理单元
    8. 10.8 界面和内核的接口:ToolUseContext 里的回调
    9. 10.9 一个有趣的细节:ANSI 转 PNG
  12. 11 · 持久化与恢复
    1. 11.1 对话记录的格式:JSONL
    2. 11.2 对话记录是一棵树,不是一个列表
    3. 11.3 写入队列
    4. 11.4 子智能体的记录:支链文件
    5. 11.5 恢复:三种方式
    6. 11.6 文件历史:智能体改过的文件可以回滚
    7. 11.7 配置的多来源与迁移
    8. 11.8 记忆目录
  13. 12 · 可观测体系
    1. 12.1 埋点密度
    2. 12.2 事件命名
    3. 12.3 查询链路追踪
    4. 12.4 类型层面的隐私保护
    5. 12.5 缓存断裂检测
    6. 12.6 性能剖析检查点
    7. 12.7 慢操作日志
    8. 12.8 内存错误缓冲区
    9. 12.9 内部错误的响亮日志
    10. 12.10 埋点的成本意识
  14. 13 · 构建与分发
    1. 13.1 Bun 单文件可执行程序
    2. 13.2 编译期特性开关:89 个
    3. 13.3 死代码消除:为什么这不只是「if 判断」
    4. 13.4 编译期宏
    5. 13.5 运行时特性开关:另一套系统
    6. 13.6 版本与更新
    7. 13.7 从构建方式反推的架构约束
  1. 0 · The Project at a Glance, and a Map of the Code
    1. 0.1 What this software is
    2. 0.2 The source tree, directory by directory
    3. 0.3 Three things you can read off this map
    4. 0.4 The journey of one complete request (a map of the whole book)
    5. 0.5 Chapter index
  2. 1 · The Entry Layer and Startup
    1. 1.1 Four launch modes
    2. 1.2 The first design decision at startup: fast-path dispatch
    3. 1.3 Command-line arguments: 60-plus options
    4. 1.4 --bare: a minimal mode worth its own section
    5. 1.5 Startup sequence
    6. 1.6 The three-part structure of the system prompt
  3. 2 · The Session Layer: QueryEngine
    1. 2.1 The problem it solves
    2. 2.2 What state it holds
    3. 2.3 The full flow of one submitMessage
    4. 2.4 Why the user message must hit disk first
    5. 2.5 Consuming the main loop’s output: one big switch
    6. 2.6 The compact boundary: proactively releasing memory
    7. 2.7 Three kinds of exit result
    8. 2.8 ask(): a convenience wrapper for one-shot calls
  4. 3 · The Agent Main Loop ★
    1. 3.1 The skeleton of the loop
    2. 3.2 State: centralizing cross-iteration state
    3. 3.3 transition: a field that exists purely for testability
    4. 3.4 The seven transition edges, one by one
    5. 3.5 The error-withholding mechanism
    6. 3.6 Interrupt handling
    7. 3.7 Model fallback: three actions
    8. 3.8 The three laws of thinking blocks
    9. 3.9 Other mechanisms in the loop
    10. 3.10 Every exit point of the loop
  5. 4 · The Tool Model
    1. 4.1 The Tool interface: seven orthogonal capability groups
    2. 4.2 Why the “safety predicates” deserve their own group
    3. 4.3 Fail-safe defaults
    4. 4.4 The 40 built-in tools, by category
    5. 4.5 Progressive tool loading
    6. 4.6 Tool list assembly: a hidden constraint about caching
    7. 4.7 backfillObservableInput: an extreme example of cache protection
  6. 5 · Tool Execution
    1. 5.1 The execution pipeline at a glance
    2. 5.2 Concurrency partitioning: a greedy algorithm
    3. 5.3 Context modifications are queued until the batch ends
    4. 5.4 A single execution: the full flow of runToolUse
    5. 5.5 The streaming tool executor
    6. 5.6 The sibling abort controller: the most elegant design in the file
    7. 5.7 The discard mechanism
    8. 5.8 “Tombstone” messages
    9. 5.9 Final processing of results
  7. 6 · Context Management ★
    1. 6.1 The five-tier pipeline
    2. 6.2 Tier ①: the tool result budget
    3. 6.3 Tier ③: micro-compaction and cache edits
    4. 6.4 Tier ⑤: auto summary compaction
    5. 6.5 When the context really does overflow: the three-tier recovery cascade
  8. 7 · The Permission System
    1. 7.1 Six Permission Modes
    2. 7.2 The Ten-Step Decision Cascade
    3. 7.3 Auto Mode: Model Classifier + Three-Tier Fast Path
    4. 7.4 Permission Rule Syntax
    5. 7.5 Sandboxing and Read-Only Command Detection
    6. 7.6 The Full Data Structure Behind a Permission Decision
    7. 7.7 Explainability of Permission Decisions
  9. 8 · Subagents
    1. 8.1 The Primary Motivation Is Context Isolation, Not Parallelism
    2. 8.2 Three Forms
    3. 8.3 Forking: Pushing Prompt Caching to the Limit
    4. 8.4 Tool Restrictions on Subagents
    5. 8.5 Constructing a Subagent's Context
    6. 8.6 Built-in Agent Types
    7. 8.7 The Forms Background Tasks Take
    8. 8.8 Cascading Interruption
  10. 9 · The Extension System
    1. 9.1 The Four Extension Points Compared
    2. 9.2 The Skill System
    3. 9.3 The Plugin System
    4. 9.4 The MCP Client
    5. 9.5 Hooks: 15 Kinds of Lifecycle Events
    6. 9.6 Output Styles
  11. 10 · The Terminal UI Layer
    1. 10.1 Writing a Terminal UI in React
    2. 10.2 The Four Biggest Components
    3. 10.3 Why the Input Box Is 347 KB
    4. 10.4 The Virtualized Message List
    5. 10.5 Six Rendering States for Tool Results
    6. 10.6 Collapsing: Avoiding Screen Flood
    7. 10.7 87 State-Management Units
    8. 10.8 The Interface Between UI and Kernel: Callbacks in ToolUseContext
    9. 10.9 A Fun Detail: ANSI to PNG
  12. 11 · Persistence and Resume
    1. 11.1 The Transcript Format: JSONL
    2. 11.2 The Transcript Is a Tree, Not a List
    3. 11.3 The Write Queue
    4. 11.4 Subagent Records: Sidechain Files
    5. 11.5 Resume: Three Ways
    6. 11.6 File History: Rolling Back Files the Agent Changed
    7. 11.7 Multi-Source Settings and Migrations
    8. 11.8 The Memory Directory
  13. 12 · The Observability System
    1. 12.1 Instrumentation Density
    2. 12.2 Event Naming
    3. 12.3 Query Chain Tracing
    4. 12.4 Privacy Protection at the Type Level
    5. 12.5 Cache-Break Detection
    6. 12.6 Profiling Checkpoints
    7. 12.7 Slow-Operation Logging
    8. 12.8 The In-Memory Error Buffer
    9. 12.9 Loud Logging for Internal Errors
    10. 12.10 Cost-Awareness in Instrumentation
  14. 13 · Build and Distribution
    1. 13.1 The Bun Single-File Executable
    2. 13.2 Compile-Time Feature Flags: 89 of Them
    3. 13.3 Dead-Code Elimination: Why It's Not Just an “if”
    4. 13.4 Compile-Time Macros
    5. 13.5 Runtime Feature Flags: A Separate System
    6. 13.6 Versions and Updates
    7. 13.7 Architectural Constraints Inferred from the Build

4 · 工具模型

这一章讲「一个工具」在程序里被建模成什么样子,以及 40 个内建工具是怎么被组织和投放的。

4.1 Tool 接口:七组正交能力

Tool.ts 全文 793 行,其中类型定义 Tool<Input, Output, Progress> 占了 330 行。它把「一个工具需要回答的所有问题」切成了七组互不重叠的能力:

Tool<Input, Output, Progress> └─ 三个尖括号里是"泛型参数",意思是"输入类型、输出类型、 进度类型由每个具体工具自己决定" │ ├─ ① 执行 │ call(参数, 上下文, 权限检查函数, 父消息, 进度回调) → Promise<结果> │ 这是唯一真正干活的方法 │ ├─ ② 契约(描述参数长什么样) │ inputSchema 用 Zod 库写的运行时校验模式 │ inputJSONSchema 给 MCP 外部工具用的原始 JSON Schema │ outputSchema 输出的模式 │ strict 是否让接口更严格地遵守参数模式 │ ├─ ③ 提示词(给模型看的文字) │ prompt() 完整说明,进系统提示词,每轮都要重发 │ description(输入) 这一次调用的简短描述 │ searchHint 3~10 个关键词,供"工具搜索"匹配 │ ├─ ④ 安全谓词(给调度器看的布尔问题) │ isReadOnly(参数) 这次是只读的吗 │ isDestructive(参数) 会不可逆地破坏东西吗 │ isConcurrencySafe(参数) 能和别的工具同时跑吗 │ isEnabled() 当前环境下可用吗 │ isOpenWorld(参数) 会访问外部网络吗 │ requiresUserInteraction() 必须有人在场吗 │ isSearchOrReadCommand(参数) 界面上要不要折叠显示 │ ├─ ⑤ 权限 │ validateInput(参数, 上下文) 参数合法吗(不合法要告诉模型为什么) │ checkPermissions(参数, 上下文) 该放行吗(工具特有的判断) │ preparePermissionMatcher(参数) 给钩子条件用的匹配器 │ toAutoClassifierInput(参数) 给安全分类器的压缩表示 │ ├─ ⑥ 预算与生命周期 │ maxResultSizeChars 结果超多少字符就落盘 │ interruptBehavior() 被中断时是 'cancel' 还是 'block' │ shouldDefer / alwaysLoad 说明文字是否延迟加载 │ backfillObservableInput(输入) 只改可观测副本,不动原件 │ inputsEquivalent(a, b) 两次调用参数等价吗(去重用) │ └─ ⑦ 渲染(10 个以上方法,全部和终端界面耦合) renderToolUseMessage 调用进行中 renderToolUseProgressMessage 进度 renderToolUseQueuedMessage 排队中 renderToolResultMessage 结果 renderToolUseRejectedMessage 被拒绝 renderToolUseErrorMessage 出错 renderGroupedToolUse 多个并行调用合并显示 renderToolUseTag 调用后面的小标签(超时/模型等) getToolUseSummary(输入) 紧凑视图下的一行摘要 getActivityDescription(输入) 加载动画旁边的活动描述 isResultTruncated(输出) 非详细模式下是否被截断了 extractSearchText(输出) 供对话记录搜索的纯文本 mapToolResultToToolResultBlockParam(输出, id) → 回传给模型的序列化

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(延迟加载)

程序启动时 ├─ 常用工具(Read / Bash / Edit / Grep …) │ → 完整说明放进系统提示词 └─ 冷门工具 + MCP 外部工具 → 只放"名字 + 一句话关键词",每个几十 token ↓ 模型觉得需要某个能力时 └─ 调用 ToolSearchTool,用关键词检索 ↓ 命中之后 └─ 那个工具的完整说明才被加载进上下文

相关的两个字段:

  • 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.」

译:服务端的缓存策略在「最后一个前缀匹配成功的内建工具」之后放置一个全局缓存分界点。统一排序会让外部工具插进内建工具中间 —— 那么每当有一个外部工具的名字恰好排在两个内建工具之间时,分界点之后的全部缓存键都会失效。

统一排序的结果(错误做法): BashTool → brave_search → GlobTool → GrepTool → ReadTool ↑ 外部工具插进了内建工具区间中间 ↑ 内建工具不再是连续的一整块 ↑ 服务端的缓存分界点被劈开 → 之后的全部失效 分区排序(Claude Code 的做法): BashTool → GlobTool → GrepTool → ReadTool │ brave_search └────────── 内建工具连续整块 ──────────┘ └─ 外部工具 ─┘ ↑ 分界点稳稳落在这里

这件事有多严重

同一个文件里还有一行注释:

/**
 * 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:

Tool<Input, Output, Progress> └─ The three things in angle brackets are "generic parameters," meaning "the input type, output type, and progress type are decided by each concrete tool" │ ├─ ① Execution │ call(input, context, permissionCheck, parentMessage, onProgress) → Promise<result> │ The only method that actually does work │ ├─ ② Contract (what the parameters look like) │ inputSchema a runtime validation schema written with the Zod library │ inputJSONSchema raw JSON Schema for MCP external tools │ outputSchema the output schema │ strict whether the API should enforce the parameter schema strictly │ ├─ ③ Prompt (text the model sees) │ prompt() the full description; goes into the system prompt, resent every turn │ description(input) a short description of this particular call │ searchHint 3-10 keywords for "tool search" matching │ ├─ ④ Safety predicates (yes/no questions for the scheduler) │ isReadOnly(input) is this call read-only │ isDestructive(input) will it irreversibly destroy something │ isConcurrencySafe(input) can it run at the same time as other tools │ isEnabled() is it available in the current environment │ isOpenWorld(input) will it reach the external network │ requiresUserInteraction() does a human need to be present │ isSearchOrReadCommand(input) should the UI show it collapsed │ ├─ ⑤ Permissions │ validateInput(input, context) are the parameters valid (if not, tell the model why) │ checkPermissions(input, context) should it be allowed (tool-specific judgment) │ preparePermissionMatcher(input) a matcher for hook conditions │ toAutoClassifierInput(input) a compact representation for the safety classifier │ ├─ ⑥ Budget and lifecycle │ maxResultSizeChars spill the result to disk above this many characters │ interruptBehavior() 'cancel' or 'block' when interrupted │ shouldDefer / alwaysLoad whether the description is lazily loaded │ backfillObservableInput(input) mutate only the observable copy, never the original │ inputsEquivalent(a, b) are two calls' parameters equivalent (for dedup) │ └─ ⑦ Rendering (10-plus methods, all coupled to the terminal UI) renderToolUseMessage call in progress renderToolUseProgressMessage progress renderToolUseQueuedMessage queued renderToolResultMessage result renderToolUseRejectedMessage rejected renderToolUseErrorMessage error renderGroupedToolUse several parallel calls shown merged renderToolUseTag the small tag after a call (timeout / model / etc.) getToolUseSummary(input) one-line summary in compact view getActivityDescription(input) activity description next to the spinner isResultTruncated(output) was it truncated in non-verbose mode extractSearchText(output) plain text for transcript search mapToolResultToToolResultBlockParam(output, id) → serialization sent back to the model

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:

PredicateWhat 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 declareConsequence
Concurrency safetyTreated as unsafe → runs serially → a bit slower, but never a race condition
Read-only-nessTreated as writing → one more permission prompt → a bit chattier, but never wrongly approved
DestructivenessTreated 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

CategoryTools
File operations FileReadTool read · FileWriteTool write · FileEditTool exact replacement · NotebookEditTool edit Jupyter notebooks
Search GlobTool find by filename pattern · GrepTool find by content
Note: 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

At program startup ├─ Frequently used tools (Read / Bash / Edit / Grep …) │ → full description goes into the system prompt └─ Rarely used tools + MCP external tools → only "name + a one-line set of keywords," a few dozen tokens each ↓ When the model decides it needs some capability └─ it calls ToolSearchTool and searches by keyword ↓ On a hit └─ only then is that tool's full description loaded into the context

The two related fields:

  • shouldDefer: true — this tool is lazily loaded
  • alwaysLoad: truenever 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.

Result of a flat sort (the wrong approach): BashTool → brave_search → GlobTool → GrepTool → ReadTool ↑ an external tool landed in the middle of the built-in range ↑ the built-ins are no longer one contiguous block ↑ the server's cache breakpoint is split → everything after it is invalidated Partitioned sort (what Claude Code does): BashTool → GlobTool → GrepTool → ReadTool │ brave_search └────── built-ins, one contiguous block ──┘ └─ external ─┘ ↑ the breakpoint lands squarely here

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.