Claude Code 架构全解Claude Code Architecture, in Full第 5 章 · 14 章Chapter 5 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

5 · 工具执行

这一章讲:模型给出一批工具调用之后,程序是怎么把它们跑完的。

5.1 执行链路总览

模型返回若干个 tool_use(工具调用) │ ▼ 【编排层】toolOrchestration.ts · 189 行 partitionToolCalls() 贪心分区:相邻的安全工具合并成并行批 │ ├─ 并行批 → runToolsConcurrently() 最多 10 个同时跑 └─ 串行批 → runToolsSerially() 一个一个来 │ ▼ 【单次执行】toolExecution.ts · runToolUse() ① 按名字找工具(支持别名,用于已改名的老工具) ② 工具不存在 → 造一条错误结果返回 ③ Zod 参数格式校验 → 失败则返回 InputValidationError ④ tool.validateInput() → 工具自己的参数合法性检查 ⑤ 投机性地提前启动 bash 分类器(和下面的步骤并行跑) ⑥ PreToolUse 钩子 ⑦ canUseTool() 权限判定 → 第 7 章 ⑧ tool.call() 真正执行 ⑨ PostToolUse / PostToolUseFailure 钩子 ⑩ 结果超限则落盘 → 第 6 章 ⑪ mapToolResultToToolResultBlockParam() 序列化成回传格式

5.2 并发分区:贪心算法

function partitionToolCalls(toolUseMessages, toolUseContext): Batch[] {
  return toolUseMessages.reduce((acc: Batch[], toolUse) => {
    const tool = findToolByName(toolUseContext.options.tools, toolUse.name)
    const parsedInput = tool?.inputSchema.safeParse(toolUse.input)

    const isConcurrencySafe = parsedInput?.success
      ? (() => {
          try { return Boolean(tool?.isConcurrencySafe(parsedInput.data)) }
          catch {
            // 如果判定函数抛异常(比如 shell 引号解析失败),
            // 保守地当成"不安全"
            return false
          }
        })()
      : false                        // 参数格式都不合法 → 也当成不安全

    if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) {
      acc[acc.length - 1]!.blocks.push(toolUse)      // 并入上一个并行批
    } else {
      acc.push({ isConcurrencySafe, blocks: [toolUse] })   // 开一个新批
    }
    return acc
  }, [])
}

claude-code/src/services/tools/toolOrchestration.ts

执行效果:

模型返回的 6 个调用(按模型给出的顺序,不能打乱): Read(a.ts) Read(b.ts) Grep("foo") Edit(a.ts) Read(c.ts) Bash("npm test") └──── 只读,并发安全 ────┘ └ 写,不安全 ┘ └ 安全 ┘ └── 不安全 ──┘ 分区结果: 批次 1【并行】Read(a.ts) + Read(b.ts) + Grep("foo") ← 三个同时跑 批次 2【串行】Edit(a.ts) ← 单独跑 批次 3【并行】Read(c.ts) ← 只有一个,也算一批 批次 4【串行】Bash("npm test") ← 单独跑
为什么是「贪心分区」而不是「全排序」

一种直觉的做法是:把所有安全的挑出来一起并行,不安全的最后串行。但那样会打乱模型隐含的顺序语义

上面这个例子里,Read(c.ts) 排在 Edit(a.ts) 后面。如果把它提到前面和批次 1 合并,就变成了「先读 c 再改 a」—— 万一模型的意图是「改完 a 之后读 c 来验证」,逻辑就错了。

贪心分区只合并「相邻」的安全工具,从不跨越不安全的边界。顺序语义完整保留。

5.3 上下文修改要排队到批次结束

有些工具会修改共享的上下文对象(比如 EnterPlanMode 会切换权限模式)。并行批里如果每个工具立刻改,就有竞态。

if (isConcurrencySafe) {
  const queuedContextModifiers: Record<string, ((ctx) => ToolUseContext)[]> = {}

  for await (const update of runToolsConcurrently(blocks, ...)) {
    if (update.contextModifier) {
      const { toolUseID, modifyContext } = update.contextModifier
      if (!queuedContextModifiers[toolUseID]) queuedContextModifiers[toolUseID] = []
      queuedContextModifiers[toolUseID].push(modifyContext)      // ★ 先排队
    }
    yield { message: update.message, newContext: currentContext }  // 仍用旧上下文
  }

  // ★ 整批完成后,严格按工具调用的原始顺序应用修改
  for (const block of blocks) {
    const modifiers = queuedContextModifiers[block.id]
    if (!modifiers) continue
    for (const modifier of modifiers) currentContext = modifier(currentContext)
  }
  yield { newContext: currentContext }
}

Tool.ts 里有一条对应的兜底约束:

「contextModifier is only honored for tools that aren't concurrency safe.」
译:只有声明自己「不是并发安全」的工具,它的上下文修改才会被采纳。

这是一条很干脆的规则:要改共享上下文的工具,就别声明自己并发安全。两者不可兼得,在接口层面直接堵死,而不是留到运行时靠排队去缓解。上面那段排队逻辑是双保险

5.4 单次执行:runToolUse 的完整流程

第 ① 步:按名字找工具,支持别名

// 先在"模型能看到的工具"里找
let tool = findToolByName(toolUseContext.options.tools, toolName)

// 找不到 → 检查是不是一个已废弃的名字(老对话记录里可能还在用旧名)
// 例如老记录里调用 "KillShell",而它现在是 "TaskStop" 的别名
// 只有当名字匹配的是"别名"而不是"主名"时才回退

这个设计解决的问题是:工具改名之后,用户用 --resume 恢复的旧对话里还有旧名字的调用记录。如果直接报「工具不存在」,那条历史消息就永远无法被正确处理。

第 ③ 步:参数格式校验,附带一句诚实的注释

// Validate input types with zod
// (surprisingly, the model is not great at generating valid input)
const parsedInput = tool.inputSchema.safeParse(input)
if (!parsedInput.success) {
  let errorContent = formatZodValidationError(tool.name, parsedInput.error)
  const schemaHint = buildSchemaNotSentHint(tool, ...)   // 见第 4.5 节
  if (schemaHint) errorContent += schemaHint
  ...
  return [{ message: createUserMessage({
    content: [{ type:'tool_result',
                content: `<tool_use_error>InputValidationError: ${errorContent}</tool_use_error>`,
                is_error: true, tool_use_id: toolUseID }],
    ... }) }]
}

括号里那句 「surprisingly, the model is not great at generating valid input」(意外的是,模型并不太擅长生成合法参数)—— 这是源码里少见的直白吐槽,但它说明了一个重要事实:即使是最强的模型,工具参数也需要严格校验,不能信任。

注意错误结果的形式:它不是抛异常,而是作为一条正常的「工具结果」返回给模型,只是标记了 is_error: true,内容用 <tool_use_error> 标签包裹。这样模型能看到自己错在哪,下一轮自己改正。

第 ⑤ 步:投机性地提前启动分类器

// Speculatively start the bash allow classifier check early so it runs in
// parallel with pre-tool hooks, deny/ask classifiers, and permission dialog
// setup. The UI indicator (setClassifierChecking) is NOT set here — it's
// set in interactiveHandler.ts only when the permission check returns `ask`
// with a pendingClassifierCheck. This avoids flashing "classifier running"

译:投机性地提前启动 bash 放行分类器的检查,让它和「工具前钩子」「拒绝/询问分类器」「权限对话框的准备」并行跑。界面上的「分类器运行中」指示器不在这里设置 —— 只有当权限检查返回「需要询问」且带着一个待定的分类器检查时,才在交互处理器里设置。这避免了指示器闪一下就消失。

这里有两个独立的优化

① 投机执行。分类器要调一次模型(约 1 秒)。与其等权限判定走到「需要分类器」那一步再启动,不如一开始就启动 —— 反正大部分情况下都会用到。如果最后发现不需要,丢弃结果即可。这样分类器的耗时被前面几步的耗时覆盖掉了。

② 界面反馈的延迟设置。如果在启动分类器的同时就点亮「分类器运行中」的指示器,那么在「分类器其实没被采纳」的情况下,用户会看到指示器闪一下就消失 —— 这是一种糟糕的视觉噪音。所以指示器的点亮时机被推迟到「确认真的要用分类器结果」的那一刻。

投机执行提升性能,延迟反馈保护体验。两者互不干扰。

5.5 流式工具执行器

常规做法是等模型的整个响应流完再开始跑工具。Claude Code 的做法是:模型每写完一个工具调用就立刻开始执行它。

为什么能这么做

模型是一个 token 一个 token 往外吐的。如果这一轮要写三个工具调用,那么第一个写完时第二个还没开始 —— 这中间有几秒钟空档。

// query.ts 的流式循环内部
if (message.type === 'assistant') {
  const msgToolUseBlocks = message.message.content.filter(c => c.type === 'tool_use')
  if (msgToolUseBlocks.length > 0) {
    toolUseBlocks.push(...msgToolUseBlocks)
    needsFollowUp = true
  }
  if (streamingToolExecutor && !toolUseContext.abortController.signal.aborted) {
    for (const toolBlock of msgToolUseBlocks) {
      streamingToolExecutor.addTool(toolBlock, message)     // ★ 一到手就入队
    }
  }
}
// 同一个循环里持续收割已完成的
if (streamingToolExecutor && !toolUseContext.abortController.signal.aborted) {
  for (const result of streamingToolExecutor.getCompletedResults()) {
    if (result.message) {
      yield result.message
      toolResults.push(...normalizeMessagesForAPI([result.message], ...))
    }
  }
}

执行器的内部状态机

type ToolStatus = 'queued' | 'executing' | 'completed' | 'yielded'
//                 排队中     执行中       已完成        已发出

type TrackedTool = {
  id: string
  block: ToolUseBlock
  assistantMessage: AssistantMessage
  status: ToolStatus
  isConcurrencySafe: boolean
  promise?: Promise<void>
  results?: Message[]
  pendingProgress: Message[]      // 进度消息单独存,立刻发出
  contextModifiers?: Array<(ctx: ToolUseContext) => ToolUseContext>
}

并发规则

private canExecuteTool(isConcurrencySafe: boolean): boolean {
  const executingTools = this.tools.filter(t => t.status === 'executing')
  return (
    executingTools.length === 0                                   // 没人在跑 → 随便跑
    || (isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
                                        // 或者:我安全 且 正在跑的全都安全
  )
}

private async processQueue(): Promise<void> {
  for (const tool of this.tools) {
    if (tool.status !== 'queued') continue
    if (this.canExecuteTool(tool.isConcurrencySafe)) {
      await this.executeTool(tool)
    } else {
      // 跑不了这个工具。而不安全的工具必须保序,所以直接停在这里,
      // 不去尝试它后面的工具
      if (!tool.isConcurrencySafe) break
    }
  }
}

类注释总结了三条规则:

「- Concurrent-safe tools can execute in parallel with other concurrent-safe tools
- Non-concurrent tools must execute alone (exclusive access)
- Results are buffered and emitted in the order tools were received」


译:并发安全的工具可以和其他并发安全的工具并行;非并发工具必须独占执行;结果会被缓冲,并按工具被接收的顺序发出。

第三条很重要 —— 执行可以乱序,但结果必须按原始顺序发出,否则模型看到的工具结果顺序会和它发出调用的顺序对不上。

5.6 兄弟中止控制器:最漂亮的一处设计

// Child of toolUseContext.abortController. Fires when a Bash tool errors
// so sibling subprocesses die immediately instead of running to completion.
// Aborting this does NOT abort the parent — query.ts won't end the turn.
private siblingAbortController: AbortController

constructor(...) {
  this.siblingAbortController = createChildAbortController(
    toolUseContext.abortController      // ★ 父控制器
  )
}

译:这是主中止控制器的一个子控制器。当某个 Bash 工具出错时触发它,让同批的兄弟子进程立刻死掉,而不是白白跑到结束。中止这个子控制器不会中止父控制器 —— 所以主循环不会结束本轮。

为什么需要两级

场景:模型一次发出三个 Bash 调用,是同一个构建流程的三个步骤。第一个失败了(编译报错)。

  • 如果只有一个全局中止开关:你想让其他两个立刻停下省资源,只能拉那个开关 —— 但这样整个轮次就结束了,模型收不到错误信息,也就没法重试或换个方法
  • 如果什么都不做:另外两个继续跑完(可能几十秒),产生的结果毫无意义,纯浪费。

两级作用域同时解决了这两个问题:拉子开关 → 兄弟进程立刻死;父开关不动 → 本轮不结束 → 模型正常收到错误并重试。

任何有「批内失败」概念的并发执行器,都应该有一个可以独立触发的子作用域。

5.7 丢弃机制

/**
 * Discards all pending and in-progress tools. Called when streaming fallback
 * occurs and results from the failed attempt should be abandoned.
 * Queued tools won't start, and in-progress tools will receive synthetic errors.
 */
discard(): void {
  this.discarded = true
}

译:丢弃所有待定和进行中的工具。在流式请求失败回退时调用,此时失败那次尝试的结果应该被抛弃。排队中的工具不会启动,进行中的工具会收到合成的错误结果。

这个方法在两个地方被调用,而且两处的处理完全一样:

// 场景 1:流式请求失败,退回非流式重试
if (streamingFallbackOccured) {
  for (const msg of assistantMessages) yield { type:'tombstone', message: msg }
  logEvent('tengu_orphaned_messages_tombstoned', { orphanedMessageCount: ... })
  assistantMessages.length = 0; toolResults.length = 0; toolUseBlocks.length = 0
  if (streamingToolExecutor) {
    streamingToolExecutor.discard()
    streamingToolExecutor = new StreamingToolExecutor(...)    // ★ 重建一个新的
  }
}

// 场景 2:模型降级切换备用模型(见第 3.7 节)
// 同样的四步:打墓碑、清数组、丢弃执行器、重建

注释解释了为什么要重建而不是复用:

「Discard pending results from the failed streaming attempt and create a fresh executor. This prevents orphan tool_results (with old tool_use_ids) from being yielded after the fallback response arrives.」

译:丢弃失败那次流式尝试的待定结果,并创建一个全新的执行器。这防止了带着旧工具调用 id 的孤儿结果,在降级响应到达之后才被发出。

如果不重建:旧执行器里还有几个工具在跑,它们跑完后会发出带旧 id 的结果。而重试后的响应有全新的 id —— 于是历史里出现了「没有对应调用的结果」,同样会让接口报格式错误。

5.8 「墓碑」消息

上面出现了一个新概念:tombstone(墓碑)。它是一种控制信号消息,意思是「请从界面和对话记录里删除这条消息」

yield { type: 'tombstone' as const, message: msg }

为什么需要它?注释说明:

「Yield tombstones for orphaned messages so they're removed from UI and transcript. These partial messages (especially thinking blocks) have invalid signatures that would cause "thinking blocks cannot be modified" API errors.」

译:为孤儿消息发出墓碑,让它们从界面和对话记录里被移除。这些不完整的消息(尤其是思考块)带着无效的签名,会导致「思考块不可修改」的接口错误。

场景是:流式请求失败时,模型已经吐出了一部分内容,界面上也已经显示出来了。这些内容不能留着 —— 它们不完整、签名无效。所以要发一个墓碑把它们撤回。

这个设计对流式界面很重要:你已经把东西画到屏幕上了,现在需要一个「撤回」机制。而且撤回要同时作用于界面和落盘的记录。

5.9 结果的最终处理

工具执行完之后,结果还要经过两道处理才能回传给模型:

① 超限落盘

每个工具声明了 maxResultSizeChars。超限的结果被写到磁盘,模型收到的是「前 2000 字节预览 + 文件路径」。详见第 6.2 节。

② 序列化

mapToolResultToToolResultBlockParam(content: Output, toolUseID: string): ToolResultBlockParam

每个工具自己决定「我的输出该怎么变成给模型看的文字」。比如 Read 工具会加上行号,Bash 工具会分开标记标准输出和标准错误。

注意 Tool 接口里还有一个专门为对话记录搜索服务的方法:

/**
 * Flattened text of what renderToolResultMessage shows IN TRANSCRIPT MODE.
 * For transcript search indexing: the index counts occurrences in this string,
 * the highlight overlay scans the actual screen buffer. For count ≡ highlight,
 * this must return the text that ends up visible — not the model-facing
 * serialization from mapToolResultToToolResultBlockParam.
 *
 * Phantoms are not fine — text that's claimed here but doesn't render is a
 * count≠highlight bug.
 */
extractSearchText?(out: Output): string

译:这个方法返回「在对话记录模式下实际渲染出来的文字」的扁平化版本。用于搜索索引:索引统计这个字符串里的出现次数,而高亮层扫描的是真实的屏幕缓冲区。为了让「统计数」和「高亮数」相等,这里必须返回最终可见的文字 —— 而不是给模型看的那个序列化结果。……幽灵文本是不可接受的 —— 在这里声称存在但实际没渲染出来的文字,就是一个「统计数 ≠ 高亮数」的 bug。

这段注释揭示了一个很细的产品问题:用户在对话记录里搜索一个词,界面显示「找到 5 处」,但用户按 n 跳转时只高亮了 3 处 —— 因为索引统计的是「给模型看的文字」,而高亮扫描的是「渲染到屏幕上的文字」,两者不一致。

而且注释还明确了容错方向:漏统计(少报)可以接受,幽灵(多报)不可接受。因为少报只是搜不全,多报会让跳转功能直接失灵。

5 · Tool Execution

This chapter covers how the program runs a batch of tool calls to completion once the model has produced them.

5.1 The execution pipeline at a glance

The model returns some number of tool_use blocks (tool calls) │ ▼ [Orchestration] toolOrchestration.ts · 189 lines partitionToolCalls() greedy partitioning: adjacent safe tools are merged into a parallel batch │ ├─ parallel batch → runToolsConcurrently() up to 10 at once └─ serial batch → runToolsSerially() one at a time │ ▼ [Single execution] toolExecution.ts · runToolUse() ① Look up the tool by name (aliases supported, for tools that have been renamed) ② Tool doesn't exist → fabricate an error result and return ③ Zod parameter shape validation → on failure return InputValidationError ④ tool.validateInput() → the tool's own parameter validity check ⑤ Speculatively start the bash classifier early (runs in parallel with the steps below) ⑥ PreToolUse hooks ⑦ canUseTool() permission decision → Chapter 7 ⑧ tool.call() the actual execution ⑨ PostToolUse / PostToolUseFailure hooks ⑩ Spill the result to disk if it exceeds the limit → Chapter 6 ⑪ mapToolResultToToolResultBlockParam() serialize into the return format

5.2 Concurrency partitioning: a greedy algorithm

function partitionToolCalls(toolUseMessages, toolUseContext): Batch[] {
  return toolUseMessages.reduce((acc: Batch[], toolUse) => {
    const tool = findToolByName(toolUseContext.options.tools, toolUse.name)
    const parsedInput = tool?.inputSchema.safeParse(toolUse.input)

    const isConcurrencySafe = parsedInput?.success
      ? (() => {
          try { return Boolean(tool?.isConcurrencySafe(parsedInput.data)) }
          catch {
            // If the predicate throws (e.g. shell quote parsing failed),
            // conservatively treat it as "unsafe"
            return false
          }
        })()
      : false                        // parameters don't even parse → also treated as unsafe

    if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) {
      acc[acc.length - 1]!.blocks.push(toolUse)      // merge into the previous parallel batch
    } else {
      acc.push({ isConcurrencySafe, blocks: [toolUse] })   // open a new batch
    }
    return acc
  }, [])
}

claude-code/src/services/tools/toolOrchestration.ts

The effect in practice:

The 6 calls the model returned (in the model's order, which must not be shuffled): Read(a.ts) Read(b.ts) Grep("foo") Edit(a.ts) Read(c.ts) Bash("npm test") └── read-only, concurrency-safe ──┘ └ write, unsafe ┘ └ safe ┘ └── unsafe ──┘ Partition result: Batch 1 [parallel] Read(a.ts) + Read(b.ts) + Grep("foo") ← all three run at once Batch 2 [serial] Edit(a.ts) ← runs alone Batch 3 [parallel] Read(c.ts) ← just one, still its own batch Batch 4 [serial] Bash("npm test") ← runs alone
Why “greedy partitioning” rather than “sort everything”

One intuitive approach: pull out all the safe calls and run them in parallel, then run the unsafe ones serially at the end. But that would scramble the ordering semantics the model implied.

In the example above, Read(c.ts) comes after Edit(a.ts). If you hoisted it forward and merged it into batch 1, it would become “read c, then edit a” — and if the model’s intent was “after editing a, read c to verify,” the logic is now wrong.

Greedy partitioning merges only adjacent safe tools and never crosses an unsafe boundary. The ordering semantics are fully preserved.

5.3 Context modifications are queued until the batch ends

Some tools modify the shared context object (for instance EnterPlanMode switches the permission mode). If every tool in a parallel batch applied its change immediately, you would have a race.

if (isConcurrencySafe) {
  const queuedContextModifiers: Record<string, ((ctx) => ToolUseContext)[]> = {}

  for await (const update of runToolsConcurrently(blocks, ...)) {
    if (update.contextModifier) {
      const { toolUseID, modifyContext } = update.contextModifier
      if (!queuedContextModifiers[toolUseID]) queuedContextModifiers[toolUseID] = []
      queuedContextModifiers[toolUseID].push(modifyContext)      // ★ queue it first
    }
    yield { message: update.message, newContext: currentContext }  // still using the old context
  }

  // ★ Once the whole batch is done, apply the modifications strictly in the original tool-call order
  for (const block of blocks) {
    const modifiers = queuedContextModifiers[block.id]
    if (!modifiers) continue
    for (const modifier of modifiers) currentContext = modifier(currentContext)
  }
  yield { newContext: currentContext }
}

And Tool.ts has a matching backstop constraint:

“contextModifier is only honored for tools that aren't concurrency safe.”
In other words: a tool’s context modification is honored only if the tool declares itself “not concurrency-safe.”

A very blunt rule: if your tool needs to modify shared context, do not declare it concurrency-safe. You cannot have both, and this is shut down at the interface level rather than left to runtime queuing to mitigate. The queuing logic above is belt and suspenders.

5.4 A single execution: the full flow of runToolUse

Step ①: look up the tool by name, with alias support

// First look among the "tools the model can see"
let tool = findToolByName(toolUseContext.options.tools, toolName)

// Not found → check whether it's a deprecated name (old transcripts may still use the old name)
// e.g. an old transcript calls "KillShell", which is now an alias for "TaskStop"
// Fall back only when the name matches an "alias" rather than a "primary name"

The problem this design solves: after a tool is renamed, an old conversation the user resumes with --resume still contains call records under the old name. If that simply produced “tool does not exist,” that historical message could never be processed correctly.

Step ③: parameter shape validation, with an honest comment attached

// Validate input types with zod
// (surprisingly, the model is not great at generating valid input)
const parsedInput = tool.inputSchema.safeParse(input)
if (!parsedInput.success) {
  let errorContent = formatZodValidationError(tool.name, parsedInput.error)
  const schemaHint = buildSchemaNotSentHint(tool, ...)   // see Section 4.5
  if (schemaHint) errorContent += schemaHint
  ...
  return [{ message: createUserMessage({
    content: [{ type:'tool_result',
                content: `<tool_use_error>InputValidationError: ${errorContent}</tool_use_error>`,
                is_error: true, tool_use_id: toolUseID }],
    ... }) }]
}

That parenthetical — “surprisingly, the model is not great at generating valid input” — is a rare bit of candid griping in the source, but it states an important fact: even the strongest model’s tool parameters need strict validation and cannot be trusted.

Note the form of the error result: it is not a thrown exception; it is returned to the model as an ordinary “tool result”, just flagged with is_error: true and wrapped in a <tool_use_error> tag. That way the model can see what it got wrong and correct itself on the next turn.

Step ⑤: speculatively starting the classifier early

// Speculatively start the bash allow classifier check early so it runs in
// parallel with pre-tool hooks, deny/ask classifiers, and permission dialog
// setup. The UI indicator (setClassifierChecking) is NOT set here — it's
// set in interactiveHandler.ts only when the permission check returns `ask`
// with a pendingClassifierCheck. This avoids flashing "classifier running"

In other words: speculatively start the bash allow-classifier check early, so it runs in parallel with the pre-tool hooks, the deny/ask classifiers, and permission dialog setup. The “classifier running” UI indicator is not set here — it is set in the interactive handler only when the permission check returns “ask” along with a pending classifier check. That avoids the indicator flashing on and off.

Two independent optimizations here

① Speculative execution. The classifier makes a model call (about 1 second). Rather than wait until the permission decision reaches the “needs classifier” step to start it, start it right away — it will be needed most of the time anyway. If it turns out not to be needed, just throw the result away. The classifier’s latency is thereby hidden under the latency of the preceding steps.

② Deferring the UI feedback. If the “classifier running” indicator lit up the moment the classifier started, then in the case where “the classifier’s result was never actually used,” the user would see the indicator flash on and vanish — ugly visual noise. So the moment the indicator lights up is pushed back to “we have confirmed the classifier result will really be used.”

Speculative execution improves performance; deferred feedback protects the experience. Neither interferes with the other.

5.5 The streaming tool executor

The conventional approach is to wait for the model’s entire response to finish streaming before running any tools. Claude Code’s approach: the moment the model finishes writing one tool call, start executing it.

Why this is possible

The model emits one token at a time. If this turn is going to write three tool calls, then when the first one is finished, the second has not yet begun — there is a gap of several seconds in between.

// Inside the streaming loop in query.ts
if (message.type === 'assistant') {
  const msgToolUseBlocks = message.message.content.filter(c => c.type === 'tool_use')
  if (msgToolUseBlocks.length > 0) {
    toolUseBlocks.push(...msgToolUseBlocks)
    needsFollowUp = true
  }
  if (streamingToolExecutor && !toolUseContext.abortController.signal.aborted) {
    for (const toolBlock of msgToolUseBlocks) {
      streamingToolExecutor.addTool(toolBlock, message)     // ★ enqueue the moment it arrives
    }
  }
}
// In the same loop, keep harvesting the ones that have finished
if (streamingToolExecutor && !toolUseContext.abortController.signal.aborted) {
  for (const result of streamingToolExecutor.getCompletedResults()) {
    if (result.message) {
      yield result.message
      toolResults.push(...normalizeMessagesForAPI([result.message], ...))
    }
  }
}

The executor’s internal state machine

type ToolStatus = 'queued' | 'executing' | 'completed' | 'yielded'
//                 waiting    running       finished      emitted

type TrackedTool = {
  id: string
  block: ToolUseBlock
  assistantMessage: AssistantMessage
  status: ToolStatus
  isConcurrencySafe: boolean
  promise?: Promise<void>
  results?: Message[]
  pendingProgress: Message[]      // progress messages stored separately; emitted immediately
  contextModifiers?: Array<(ctx: ToolUseContext) => ToolUseContext>
}

Concurrency rules

private canExecuteTool(isConcurrencySafe: boolean): boolean {
  const executingTools = this.tools.filter(t => t.status === 'executing')
  return (
    executingTools.length === 0                                   // nothing running → go ahead
    || (isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
                                        // or: I'm safe AND everything running is safe
  )
}

private async processQueue(): Promise<void> {
  for (const tool of this.tools) {
    if (tool.status !== 'queued') continue
    if (this.canExecuteTool(tool.isConcurrencySafe)) {
      await this.executeTool(tool)
    } else {
      // Can't run this tool. Unsafe tools must preserve order, so stop right here
      // and don't try the tools after it
      if (!tool.isConcurrencySafe) break
    }
  }
}

The class comment sums up three rules:

“- Concurrent-safe tools can execute in parallel with other concurrent-safe tools
- Non-concurrent tools must execute alone (exclusive access)
- Results are buffered and emitted in the order tools were received”


In other words: concurrency-safe tools can run in parallel with other concurrency-safe tools; non-concurrent tools must run exclusively; results are buffered and emitted in the order the tools were received.

The third rule matters — execution may be out of order, but results must be emitted in the original order; otherwise the order of tool results the model sees would not line up with the order in which it issued the calls.

5.6 The sibling abort controller: the most elegant design in the file

// Child of toolUseContext.abortController. Fires when a Bash tool errors
// so sibling subprocesses die immediately instead of running to completion.
// Aborting this does NOT abort the parent — query.ts won't end the turn.
private siblingAbortController: AbortController

constructor(...) {
  this.siblingAbortController = createChildAbortController(
    toolUseContext.abortController      // ★ the parent controller
  )
}

In other words: this is a child of the main abort controller. It fires when a Bash tool errors, so the sibling subprocesses in the same batch die immediately instead of pointlessly running to completion. Aborting this child does not abort the parent — so the main loop does not end the turn.

Why two levels are needed

Scenario: the model issues three Bash calls at once, three steps of the same build process. The first one fails (a compile error).

  • With only one global abort switch: to stop the other two right away and save resources, all you can do is pull that switch — but then the whole turn ends, and the model never receives the error, so it cannot retry or try another approach.
  • If you do nothing: the other two run to completion (possibly tens of seconds), producing results that are meaningless. Pure waste.

Two-level scoping solves both problems at once: pull the child switch → the sibling processes die immediately; leave the parent switch alone → the turn does not end → the model receives the error normally and retries.

Any concurrent executor that has a notion of “failure within a batch” should have an independently triggerable child scope.

5.7 The discard mechanism

/**
 * Discards all pending and in-progress tools. Called when streaming fallback
 * occurs and results from the failed attempt should be abandoned.
 * Queued tools won't start, and in-progress tools will receive synthetic errors.
 */
discard(): void {
  this.discarded = true
}

In other words: discard all pending and in-progress tools. Called when a streaming request fails and falls back, at which point the results of the failed attempt should be abandoned. Queued tools will not start, and in-progress tools will receive synthetic error results.

This method is called from two places, and both handle it identically:

// Scenario 1: the streaming request failed; fall back to a non-streaming retry
if (streamingFallbackOccured) {
  for (const msg of assistantMessages) yield { type:'tombstone', message: msg }
  logEvent('tengu_orphaned_messages_tombstoned', { orphanedMessageCount: ... })
  assistantMessages.length = 0; toolResults.length = 0; toolUseBlocks.length = 0
  if (streamingToolExecutor) {
    streamingToolExecutor.discard()
    streamingToolExecutor = new StreamingToolExecutor(...)    // ★ build a brand-new one
  }
}

// Scenario 2: model fallback to the backup model (see Section 3.7)
// The same four steps: emit tombstones, clear the arrays, discard the executor, rebuild

The comment explains why it rebuilds rather than reuses:

“Discard pending results from the failed streaming attempt and create a fresh executor. This prevents orphan tool_results (with old tool_use_ids) from being yielded after the fallback response arrives.”

In other words: discard the pending results of the failed streaming attempt and create a brand-new executor. This prevents orphaned results carrying old tool-call ids from being emitted after the fallback response arrives.

Without the rebuild: the old executor still has a few tools running, and when they finish they emit results with old ids. The retried response has brand-new ids — so the history now contains “results with no matching call,” which likewise makes the API report a format error.

5.8 “Tombstone” messages

A new concept appeared above: tombstone. It is a control-signal message meaning “please remove this message from the UI and the transcript.”

yield { type: 'tombstone' as const, message: msg }

Why is it needed? The comment explains:

“Yield tombstones for orphaned messages so they're removed from UI and transcript. These partial messages (especially thinking blocks) have invalid signatures that would cause "thinking blocks cannot be modified" API errors.”

In other words: emit tombstones for orphaned messages so they are removed from the UI and the transcript. These partial messages (especially thinking blocks) carry invalid signatures that would cause “thinking blocks cannot be modified” API errors.

The scenario: when a streaming request fails, the model has already emitted part of its content, and the UI has already displayed it. That content cannot stay — it is incomplete and its signatures are invalid. So a tombstone is sent to retract it.

This design matters for a streaming UI: you have already painted something on the screen, and now you need a “retract” mechanism. And the retraction has to apply to both the UI and the record on disk.

5.9 Final processing of results

After a tool finishes, its result goes through two more processing steps before it is returned to the model:

① Spilling oversized results to disk

Every tool declares maxResultSizeChars. A result over the limit is written to disk, and what the model receives is “a preview of the first 2000 bytes + the file path.” See Section 6.2 for details.

② Serialization

mapToolResultToToolResultBlockParam(content: Output, toolUseID: string): ToolResultBlockParam

Each tool decides for itself “how my output should become text for the model.” For instance, the Read tool adds line numbers, and the Bash tool marks standard output and standard error separately.

Note that the Tool interface also has a method dedicated to transcript search:

/**
 * Flattened text of what renderToolResultMessage shows IN TRANSCRIPT MODE.
 * For transcript search indexing: the index counts occurrences in this string,
 * the highlight overlay scans the actual screen buffer. For count ≡ highlight,
 * this must return the text that ends up visible — not the model-facing
 * serialization from mapToolResultToToolResultBlockParam.
 *
 * Phantoms are not fine — text that's claimed here but doesn't render is a
 * count≠highlight bug.
 */
extractSearchText?(out: Output): string

In other words: this method returns a flattened version of “the text actually rendered in transcript mode.” It serves the search index: the index counts occurrences in this string, while the highlight overlay scans the real screen buffer. For the “count” and the “highlights” to be equal, this must return the text that is ultimately visible — not the serialization meant for the model. … Phantom text is not acceptable — text claimed here that does not actually render is a “count ≠ highlight” bug.

This comment reveals a very fine-grained product problem: the user searches the transcript for a word, the UI says “5 matches found,” but when the user presses n to jump between them only 3 are highlighted — because the index counted “the text shown to the model” while the highlighter scanned “the text rendered to the screen,” and the two disagree.

The comment also pins down the direction of tolerance: undercounting (missing some) is acceptable; phantoms (over-reporting) are not. Under-reporting just means the search is incomplete; over-reporting breaks the jump feature outright.