Claude Code 源码解析(06)Tool 调度与工具结果生命周期
这篇源码解析基于本地 Claude Code 源码,拆解 tool_use 从模型流中出现到 tool_result 回填上下文的完整路径。重点讲清楚流式调度器如何保护并发和结果顺序,单工具执行管线如何接入 hooks、permission 与 abort,大输出如何落到 tool-results 并在下一轮模型调用前做稳定预算替换,以及 Bash、Agent、MCP、ToolSearch 这些特殊工具为什么仍能服从同一套生命周期外壳。
- 聚焦
- Tool 调度与工具结果生命周期:模型流式产生 tool_use 后,Claude Code 如何用 StreamingToolExecutor 与 runTools 判断并发/串行,如何在单个 tool.call 前后串起 schema、hooks、permission、progress、abort、result mapping,以及 tool_result 如何经过单次持久化、历史预算压缩、MCP/Agent/Bash 特殊映射后回到下一轮上下文。
- 源码
- assets/projects/claude-code
- 版本
- 4b9d30f
先给结论
Claude Code 的 tool call 不是把模型输出拿来顺序 for 一遍。它先从 assistant stream 中识别 tool_use,再由调度层判断流式执行、并发安全和独占顺序;单个工具进入统一执行管线,经过 schema、hook、permission、tool.call()、result mapping 和 failure hook;最后 tool_result 还要经过单次大输出持久化和历史 aggregate budget,才会进入下一轮模型上下文。
默认路径
工具默认独占。只有工具自己的 isConcurrencySafe(input) 返回 true,调度器才允许它和其他并发安全工具一起运行;最终 tool_result 仍按原 tool_use 顺序回填。
可选路径
StreamingToolExecutor、Agent 嵌套 query、Bash background/persisted output、MCP PostToolUse 输出更新、ToolSearch deferred schema 都是可选或特殊路径,但仍服从 tool_use -> tool_result 外壳。
源码证据地图
这条线要把“调度”和“调用”分开读。调度层处理多个 tool_use 的执行顺序;执行层处理单个工具的校验、权限和 hook;结果层处理大输出、历史预算和特殊工具 mapping。
1# 1 assets/projects/claude-code/src/query.ts2# 2 assets/projects/claude-code/src/services/tools/StreamingToolExecutor.ts3# 3 assets/projects/claude-code/src/services/tools/toolOrchestration.ts4# 4 assets/projects/claude-code/src/services/tools/toolExecution.ts5# 5 assets/projects/claude-code/src/services/tools/toolHooks.ts6# 6 assets/projects/claude-code/src/Tool.ts7# 7 assets/projects/claude-code/src/utils/toolResultStorage.ts8# 8 assets/projects/claude-code/src/tools/BashTool/BashTool.tsx9# 9 assets/projects/claude-code/src/tools/AgentTool/AgentTool.tsx10# 10 assets/projects/claude-code/src/tools/AgentTool/runAgent.ts11# 11 assets/projects/claude-code/src/tools/MCPTool/MCPTool.ts12# 12 assets/projects/claude-code/src/tools/ToolSearchTool/ToolSearchTool.tsQuery Loop 先发现工作项
模型返回的是 assistant stream,query.ts 要边消费边收集 tool_use block。启用 streaming tool execution 时,工具可以在 assistant 还没完全结束前交给 StreamingToolExecutor.addTool();否则等 assistant 消息结束后走 runTools()。
1// assets/projects/claude-code/src/query.ts2const useStreamingToolExecution = config.gates.streamingToolExecution3let streamingToolExecutor = useStreamingToolExecution4 ? new StreamingToolExecutor(5 toolUseContext.options.tools,6 canUseTool,7 toolUseContext,8 )9 : null模型流结束或被中断后,query loop 必须收尾工具结果。流式路径调用 getRemainingResults(),非流式路径调用 runTools()。这一步保证每个已经出现的 tool_use 都有匹配的 tool_result,否则 Claude API 的消息结构会断裂。
1// assets/projects/claude-code/src/query.ts2const toolUpdates = streamingToolExecutor3 ? streamingToolExecutor.getRemainingResults()4 : runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)模型边生成边请求工具。
每个 tool_use 保留 id、name、input。
流式路径可提前启动,并发安全工具可同时跑。
结果必须带回对应 tool_use_id。
assistant message 与 tool_result 一起构成下一轮输入。
Tool 接口声明调度与结果契约
Tool.ts 里的 Tool 类型不仅是 call()。调度器依赖 isConcurrencySafe(),权限层依赖 checkPermissions(),结果层依赖 maxResultSizeChars 和 mapToolResultToToolResultBlockParam()。
1// assets/projects/claude-code/src/Tool.ts2export type Tool<TInput extends zod.ZodRawShape = zod.ZodRawShape, TOutput = unknown> = {3 name: string4 call(...): Promise<ToolResult<TOutput>>5 isConcurrencySafe(input: zod.infer<Input>): boolean6 maxResultSizeChars: number7 mapToolResultToToolResultBlockParam(8 result: TOutput,9 toolUseId: string,10 ): ToolResultBlockParam11}buildTool() 的默认调度策略很保守:没有声明并发安全的工具默认不能并发。这让新工具不至于因为忘写策略而被并行执行。
1// assets/projects/claude-code/src/Tool.ts2isConcurrencySafe: options.isConcurrencySafe ?? (() => false),3isReadOnly: options.isReadOnly ?? (() => false),调度契约
isConcurrencySafe(input) 是按输入判断的。Bash 的 read-only 命令可并发,但写文件或改变状态的命令不能。
结果契约
maxResultSizeChars 和 mapToolResultToToolResultBlockParam 决定输出如何进入 tool_result,以及何时触发大输出持久化。
默认路径与可选路径
默认路径可以压成一句话:模型产生 tool_use,query loop 收集它,调度器按 isConcurrencySafe(input) 分批或排队,单工具走统一执行管线,结果映射成 tool_result,大输出按阈值持久化,历史上下文再做 aggregate budget。
可选路径改变的是内部时机和输出形态,不改变外壳。StreamingToolExecutor 让工具提前执行;Bash background task 让长命令继续跑;Agent 工具在内部启动嵌套 query;MCP 的 PostToolUse hook 可以更新输出;ToolSearch 返回 tool_reference 暴露 deferred schema。它们最终仍要让下一轮模型看到结构合法、顺序稳定的工具反馈。
非流式调度按批次运行
非流式 fallback 在 toolOrchestration.ts。它先把 tool_use 分批:连续并发安全工具放进同一批,独占工具单独成批。这样可以在不破坏顺序语义的前提下并行读文件、搜索等安全工具。
1// assets/projects/claude-code/src/services/tools/toolOrchestration.ts2if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) {3 acc[acc.length - 1]!.blocks.push(toolUse)4} else {5 acc.push({ isConcurrencySafe, blocks: [toolUse] })6}同一批并发工具会走 runToolsConcurrently(),并发上限来自 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY,默认 10;独占批次走 runToolsSerially()。
1// assets/projects/claude-code/src/services/tools/toolOrchestration.ts2function getMaxToolUseConcurrency(): number {3 const envValue = process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY4 if (envValue) {5 const parsed = parseInt(envValue, 10)6 if (!isNaN(parsed) && parsed > 0) return parsed7 }8 return 109}流式调度是状态机
StreamingToolExecutor 让工具更早开始执行,但它不是简单线程池。每个 tracked tool 有 queued、executing、completed、yielded 状态,并保存 pending progress 和最终 message。
1// assets/projects/claude-code/src/services/tools/StreamingToolExecutor.ts2type TrackedTool = {3 block: ToolUseBlock4 assistantMessage: AssistantMessage5 status: 'queued' | 'executing' | 'completed' | 'yielded'6 isConcurrencySafe: boolean7 messages: Array<MessageUpdate>8 pendingProgress: Array<MessageUpdate>9}能不能启动新工具由 canExecuteTool() 决定:当前没有 executing tool 可以启动;或者新工具并发安全,而且所有正在执行的工具也并发安全。只要有一个独占工具在跑,后续工具就排队。
1// assets/projects/claude-code/src/services/tools/StreamingToolExecutor.ts2private canExecuteTool(isConcurrencySafe: boolean): boolean {3 const executingTools = this.trackedTools.filter(t => t.status === 'executing')4 if (executingTools.length === 0) return true5 return isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe)6}最终输出仍要按原顺序。getCompletedResults() 可以先吐 progress,但最终 tool_result 只输出已经 completed 且轮到顺序的工具。遇到正在执行的独占工具时,它会停住,避免后面的结果越过它。
1// assets/projects/claude-code/src/services/tools/StreamingToolExecutor.ts2for (const tool of this.trackedTools) {3 if (tool.status === 'yielded') continue4 if (tool.status === 'completed') {5 tool.status = 'yielded'6 for (const message of tool.messages) yield message7 } else if (tool.status === 'executing' && !tool.isConcurrencySafe) {8 break9 } else {10 break11 }12}单个工具先过校验和权限
调度器只解决“什么时候跑”。单个工具真正执行时会进入 runToolUse(),先查 tool 是否存在,再进入统一执行管线。
1// assets/projects/claude-code/src/services/tools/toolExecution.ts2const tool = findToolByName(toolUseContext.options.tools, toolUse.name)3if (!tool) {4 return [createUnknownToolResult(toolUse)]5}6 7for await (const update of streamedCheckPermissionsAndCallTool(8 tool,9 toolUse.id,10 toolInput,11 toolUseContext,12 canUseTool,13 assistantMessage,14 messageId,15 requestId,16 mcpServerType,17 mcpServerBaseUrl,18)) {19 yield update20}输入校验发生在工具调用之前。schema 或 validateInput() 失败会直接产生 error tool_result,工具本体不会执行。
1// assets/projects/claude-code/src/services/tools/toolExecution.ts2const isValidResult = tool.inputSchema.safeParse(input)3if (!isValidResult.success) {4 return [{5 message: createUserMessage({6 content: [{ type: 'tool_result', tool_use_id: toolUseID, is_error: true, content }],7 toolUseResult: `Error: ${content}`,8 }),9 }]10}Hooks 和 Permission 包住 tool.call
PreToolUse hook 在 permission 前运行。它可以产出 progress/attachment、修改 input、阻断继续,或者给出权限结果。随后 resolveHookPermissionDecision() 把 hook result 和 canUseTool 合并。
1// assets/projects/claude-code/src/services/tools/toolExecution.ts2for await (const hookResult of runPreToolUseHooks(3 toolUseContext,4 tool,5 toolUseID,6 messageId,7 processedInput,8 requestId,9 mcpServerType,10 mcpServerBaseUrl,11)) {12 hookMessages.push(hookResult)13}14 15const resolved = await resolveHookPermissionDecision(...)16const permissionDecision = resolved.decision只有最终 permissionDecision 是 allow,才会调用 tool.call()。这里会传入 toolUseId、是否被用户修改过、canUseTool、parent assistant message 和 onProgress。
1// assets/projects/claude-code/src/services/tools/toolExecution.ts2const result = await tool.call(3 callInput as never,4 {5 ...toolUseContext,6 toolUseId: toolUseID,7 userModified: permissionDecision.updatedInput !== undefined,8 },9 canUseTool,10 assistantMessage,11 onProgress,12)类型和字段先过关。
工具可以做业务级校验。
hook 可以改 input 或阻断。
canUseTool 返回 allow/ask/deny。
工具真正执行并可发 progress。
成功后跑 PostToolUse,失败后跑 failure hook。
Result Mapping 决定模型看到什么
工具内部返回 ToolResult<TOutput>,但 Claude API 要的是 tool_result block。每个工具用自己的 mapToolResultToToolResultBlockParam() 做映射。
1// assets/projects/claude-code/src/services/tools/toolExecution.ts2const mappedToolResultBlock = tool.mapToolResultToToolResultBlockParam(3 result.data as never,4 toolUseID,5)6 7const addToolResult = async (overrideMappedToolResultBlock?: ToolResultBlockParam) => {8 const toolResultBlock = overrideMappedToolResultBlock9 ? await processPreMappedToolResultBlock(tool, overrideMappedToolResultBlock, toolUseID)10 : await processToolResultBlock(tool, result.data, toolUseID)11 toolResultMessages.push(createUserMessage({ content: [toolResultBlock] }))12}MCP 是一个关键分支。非 MCP 工具会先写 tool_result 再跑 PostToolUse hook;MCP 工具的 PostToolUse hook 可能更新输出,所以结果写入时机放到 hook 后。
1// assets/projects/claude-code/src/services/tools/toolExecution.ts2if (!isMcpTool(tool)) {3 await addToolResult(toolOutput, mappedToolResultBlock)4}5 6for await (const hookResult of runPostToolUseHooks(...)) {7 hookMessages.push(hookResult)8}9 10if (isMcpTool(tool)) {11 await addToolResult(toolOutput)12}第一层结果治理是单次大输出持久化
toolResultStorage.ts 先处理单个工具本次输出。如果超过工具自己的 maxResultSizeChars,完整输出会写入 session 下的 tool-results,上下文里只保留 <persisted-output> 路径和预览。
1// assets/projects/claude-code/src/utils/toolResultStorage.ts2export const TOOL_RESULTS_SUBDIR = 'tool-results'3 4export function getToolResultsDir(): string | null {5 const projectDir = getProjectDir(getOriginalCwd())6 const sessionId = getSessionId()7 if (!projectDir || !sessionId) return null8 return path.join(projectDir, sessionId, TOOL_RESULTS_SUBDIR)9}1// assets/projects/claude-code/src/utils/toolResultStorage.ts2export function buildLargeToolResultMessage(result: PersistedToolResult): string {3 return `<persisted-output>4Full output has been saved to: ${result.path}5 6Preview:7${result.preview}8</persisted-output>`9}这一步解决单次结果过大的问题。例如 Bash 打印 100k 行日志,模型不应该在下一轮上下文里看到完整日志;它看到路径、预览和明确的 persisted-output 标记即可。
第二层结果治理是历史预算
下一轮模型调用前,query.ts 会调用 applyToolResultBudget()。它处理的是历史 messages 中聚合后的 tool_result 预算,运行在 microcompact 之前。
1// assets/projects/claude-code/src/query.ts2messagesForQuery = await applyToolResultBudget(3 messagesForQuery,4 toolUseContext.contentReplacementState,5 persistReplacements6 ? records => void recordContentReplacement(records, toolUseContext.agentId).catch(logError)7 : undefined,8 new Set(9 toolUseContext.options.tools10 .filter(t => !Number.isFinite(t.maxResultSizeChars))11 .map(t => t.name),12 ),13)ContentReplacementState 保存 seenIds 和 replacements。已经替换过的 tool_use_id 会复用同一个 replacement,这样 resume 或下一轮请求不会因为路径、模板或选择变化破坏 prompt cache。
1// assets/projects/claude-code/src/utils/toolResultStorage.ts2export type ContentReplacementState = {3 seenIds: Set<string>4 replacements: Map<string, ContentReplacement>5}单次持久化
针对一个工具本次输出。超出 maxResultSizeChars 时写入 tool-results,模型只看到 persisted-output 预览。
历史 aggregate budget
针对下一轮请求里的历史 user message。相邻 tool_results 会按 API 语义合并,所以要按 message 级聚合预算稳定替换。
Bash、Agent、MCP、ToolSearch 都有特殊路径
Bash 默认结果上限是 30,000 字符,并且只有 read-only 输入可并发。它还可能把长输出在工具内部先持久化,然后映射成 <persisted-output>。
1// assets/projects/claude-code/src/tools/BashTool/BashTool.tsx2maxResultSizeChars: 30_000,3isConcurrencySafe(input) {4 return this.isReadOnly?.(input) ?? false5},Agent 表面上也是工具,外层有 tool_use_id 和 tool_result;内部却可能重新运行 query、拥有自己的工具池、权限、abort controller、transcript 和结果预算状态。
1// assets/projects/claude-code/src/tools/AgentTool/AgentTool.tsx2isConcurrencySafe() {3 return true4},5isReadOnly() {6 return true7},MCP 工具是动态工具,标记 isMcp=true,结果 mapping 支持 MCP 输出形态。ToolSearch 则返回 tool_reference blocks,让 deferred tools 在下一轮可见。
1// assets/projects/claude-code/src/tools/MCPTool/MCPTool.ts2isMcp: true,3maxResultSizeChars: 100_000,1// assets/projects/claude-code/src/tools/ToolSearchTool/ToolSearchTool.ts2description: 'Searches over deferred tool metadata with BM25 and exposes matching tools for the next model call.',本地命令、progress、background task、raw persisted output。
外层是 tool,内层是嵌套 query 和独立上下文。
动态 server tool,PostToolUse 可更新输出。
搜索 deferred schema,返回 tool_reference。
失败也是 tool_result
工具失败不会只变成 throw。toolExecution.ts 会运行 failure hooks,并产出带 is_error: true 和原 tool_use_id 的 tool_result。这样模型知道哪一次工具调用失败了,可以据此修正下一步。
1// assets/projects/claude-code/src/services/tools/toolExecution.ts2for await (const hookResult of runPostToolUseFailureHooks(3 toolUseContext,4 tool,5 toolUseID,6 messageId,7 processedInput,8 content,9 isInterrupt,10 requestId,11 mcpServerType,12 mcpServerBaseUrl,13)) {14 hookMessages.push(hookResult)15}1// assets/projects/claude-code/src/services/tools/toolExecution.ts2content: [3 {4 type: 'tool_result',5 content,6 is_error: true,7 tool_use_id: toolUseID,8 },9],可复用设计
- 并发能力由工具自声明:调度器不维护工具名白名单,只问
isConcurrencySafe(input)。 - 统一执行插槽包住工具业务:schema、validateInput、PreToolUse、permission、tool.call、PostToolUse、failure hook 各有位置。
- 最终顺序要稳定:流式执行可以提前启动工具和吐 progress,但最终 tool_result 要按原 tool_use 顺序进入上下文。
- 结果治理分两层:单次输出超限先落盘,历史聚合预算再做稳定替换。
- 特殊工具也服从外壳:Bash、Agent、MCP、ToolSearch 的内部很不同,但都必须回到 tool_result 或 tool_reference 协议。
