第9章:工具执行链 —— 一次调用要过多少道关
约 12 分钟 · 更新于 2026-09-01
第9章:工具执行链 —— 一次调用要过多少道关
本章拆 src/services/tools/toolExecution.ts 那 1745 行。它回答一个问题:从模型吐出一个 tool_use block,到 tool.call() 真正执行,中间隔着什么。 答案是 14 道关,而且每一道都能改写输入或直接中止。
一、全景
text
模型吐出 tool_use block
↓
① 找工具(找不到就试别名 → 还找不到就返回错误结果)
↓
② 中断检查
↓
③ Zod schema 校验 ← 失败:返回错误 + 「schema 没发」诊断
↓
④ tool.validateInput() ← 失败:返回错误
↓
⑤ Bash 分类器预热(并发启动,不等)
↓
⑥ 剥掉内部字段(防御性)
↓
⑦ backfillObservableInput → 克隆一份「观察者输入」
↓
⑧ PreToolUse 钩子(可产出 7 种结果)
↓
⑨ 开 span / 遥测计时
↓
⑩ resolveHookPermissionDecision → 权限决策
↓
├─ 非 allow → 记录、组装拒绝消息、跑 PermissionDenied 钩子 → 返回
↓
⑪ 用权限返回的 updatedInput 覆盖输入
↓
⑫ callInput 收敛(决定 call() 到底拿哪一份)
↓
⑬ tool.call() ← 抛异常:走失败分支
↓
⑭ 结果处理 → PostToolUse 钩子 → 组装消息
14 步里有 5 步能中止、4 步能改写输入。 这就是第 1 章那句"Tool 不是直接裸调"的具体含义。
二、前四道关:先挡低级错误
2.1 找不到工具时的两次尝试
ts
let tool = findToolByName(toolUseContext.options.tools, toolName)
// If not found, check if it's a deprecated tool being called by alias
// (e.g., old transcripts calling "KillShell" which is now an alias for "TaskStop")
// Only fall back for tools where the name matches an alias, not the primary name
if (!tool) {
const fallbackTool = findToolByName(getAllBaseTools(), toolName)
if (fallbackTool && fallbackTool.aliases?.includes(toolName)) {
tool = fallbackTool
}
}
[源码 src/services/tools/toolExecution.ts:345]
第一次在"这个会话可用的工具"里找,第二次在"所有基础工具"里找但只认别名。
第二次为什么要限制成"只认别名"?因为如果不限制,一个被权限规则过滤掉的工具会被这条兜底路径救回来——那就是权限绕过。注释写得很清楚:Only fall back for tools where the name matches an alias, not the primary name。
找不到时返回的是一条工具结果而不是抛异常:
ts
yield {
message: createUserMessage({
content: [{ type: 'tool_result', content: `<tool_use_error>Error: No such tool available: ${toolName}</tool_use_error>`, is_error: true, tool_use_id: toolUse.id }],
toolUseResult: `Error: No such tool available: ${toolName}`,
}),
}
每一条错误路径都必须产出一个 tool_result。 因为 API 要求每个 tool_use 有对应的 tool_result——少一个就是 400。这个约束在第 3 章的 yieldMissingToolResultBlocks 那里也出现过。
2.2 Zod 校验:模型经常写错参数
ts
// Validate input types with zod (surprisingly, the model is not great at generating valid input)
const parsedInput = tool.inputSchema.safeParse(input)
[源码 src/services/tools/toolExecution.ts:614]
括号里那句是原文:"令人意外的是,模型并不擅长生成合法输入"。
失败时除了格式化 Zod 错误,还会追加第 8 章讲过的 buildSchemaNotSentHint()。
2.3 validateInput 与 checkPermissions 的分工
ts
/**
* Determines if this tool is allowed to run with this input in the current context.
* It informs the model of why the tool use failed, and does not directly display any UI.
*/
validateInput?(input, context): Promise<ValidationResult>
/**
* Determines if the user is asked for permission. Only called after validateInput() passes.
* General permission logic is in permissions.ts. This method contains tool-specific logic.
*/
checkPermissions(input, context): Promise<PermissionResult>
[源码 src/Tool.ts:489, 508]
分工线是"这是模型的问题还是用户的问题":
- validateInput 失败 = 模型搞错了(路径不存在、参数组合非法)→ 告诉模型,不打扰用户
- checkPermissions 需要 ask = 用户得拿主意 → 弹对话框
而且顺序是固定的:validateInput 通过之后才轮到 checkPermissions。不要为一个本来就非法的调用去问用户。
三、第 ⑤ 道关:投机式分类器
ts
// 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"
// for commands that auto-allow via prefix rules.
if (tool.name === BASH_TOOL_NAME && 'command' in parsedInput.data) {
startSpeculativeClassifierCheck(
(parsedInput.data as BashToolInput).command,
appState.toolPermissionContext,
toolUseContext.abortController.signal,
toolUseContext.options.isNonInteractiveSession,
)
}
[源码 src/services/tools/toolExecution.ts:740]
Bash 命令的安全分类器(一次模型调用)提前启动,和 PreToolUse 钩子、权限对话框构建并发跑。
这是第 3 章那三处预取的同一个手法:把一个可能需要的慢操作藏在另一件必须做的事的延迟里。
但注释里那半句更值得注意:"UI 提示不在这里设"。因为如果一条命令通过前缀规则自动放行了,分类器的结果根本用不上——这时闪一下"分类器运行中"是纯噪音。投机执行可以,投机地告诉用户不行。
可迁移的判断 ⑱
投机执行要和投机的 UI 反馈分开。 前者的代价是浪费一点计算,后者的代价是用户看到一个最终没发生的状态——后者贵得多。
这是执行链上权力最大的一环。钩子可以产出七种结果 [源码 src/services/tools/toolHooks.ts:444]:
ts
AsyncGenerator<
| { type: 'message'; message: … } // 普通消息
| { type: 'hookPermissionResult'; hookPermissionResult: PermissionResult } // 权限决定
| { type: 'hookUpdatedInput'; updatedInput: Record<string, unknown> } // 改写输入
| { type: 'preventContinuation'; shouldPreventContinuation: boolean } // 阻止继续
| { type: 'stopReason'; stopReason: string } // 停止理由
| { type: 'additionalContext'; message: … } // 追加上下文
// stop execution ↓
| { type: 'stop' } // 直接中止
>
调用方对每种做不同处理 [源码 src/services/tools/toolExecution.ts:800-862]:
ts
switch (result.type) {
case 'message': … // 累积到结果消息
case 'hookPermissionResult': hookPermissionResult = … // 存下来给第 ⑩ 步
case 'hookUpdatedInput': processedInput = result.updatedInput // 直接改写
case 'preventContinuation': shouldPreventContinuation = …
case 'stopReason': stopReason = …
case 'additionalContext': …
case 'stop':
// 立刻返回一条 tool_result stop 消息,工具根本不执行
return resultingMessages
}
注意 hookUpdatedInput 和 hookPermissionResult 是分开的两种结果。 前者是"我改一下参数但不做权限决定"(passthrough),后者是"我来决定放不放行"。一个钩子可以只做前者。
4.1 钩子耗时被测量并可见
ts
const preToolHookDurationMs = Date.now() - preToolHookStart
getStatsStore()?.observe('pre_tool_hook_duration_ms', preToolHookDurationMs)
if (preToolHookDurationMs >= SLOW_PHASE_LOG_THRESHOLD_MS) {
logForDebugging(`Slow PreToolUse hooks: ${preToolHookDurationMs}ms for ${tool.name} (${preToolHookInfos.length} hooks)`, { level: 'info' })
}
ts
/** Minimum total hook duration (ms) to show inline timing summary */
export const HOOK_TIMING_DISPLAY_THRESHOLD_MS = 500
/** Log a debug warning when hooks/permission-decision block for this long. Matches
* BashTool's PROGRESS_THRESHOLD_MS — the collapsed view feels stuck past this. */
const SLOW_PHASE_LOG_THRESHOLD_MS = 2000
[源码 src/services/tools/toolExecution.ts:133-137, 863-891]
超过 500ms 就在 UI 里显示钩子耗时汇总,超过 2000ms 就打调试日志。 后者的阈值理由是"折叠视图超过这个时间会让人觉得卡住了"。
而且汇总用的是墙钟时间不是各钩子耗时之和——"since hooks run in parallel"。
五、第 ⑩ 道关:钩子权限与规则权限的合流
resolveHookPermissionDecision() 是整条链上语义最精细的地方 [源码 src/services/tools/toolHooks.ts:332]。
它要回答:钩子说 allow,就真的 allow 吗?
ts
if (hookPermissionResult?.behavior === 'allow') {
const hookInput = hookPermissionResult.updatedInput ?? input
// Hook provided updatedInput for an interactive tool — the hook IS the
// user interaction (e.g. headless wrapper that collected AskUserQuestion
// answers). Treat as non-interactive for the rule-check path.
const interactionSatisfied = requiresInteraction && hookPermissionResult.updatedInput !== undefined
if ((requiresInteraction && !interactionSatisfied) || requireCanUseTool) {
// 工具本来就需要真人交互,而钩子没提供答案 → 还是得走正常流程
return { decision: await canUseTool(…), input: hookInput }
}
// Hook allow skips the interactive prompt, but deny/ask rules still apply.
const ruleCheck = await checkRuleBasedPermissions(tool, hookInput, toolUseContext)
if (ruleCheck === null) {
return { decision: hookPermissionResult, input: hookInput } // 没有规则拦 → 钩子说了算
}
if (ruleCheck.behavior === 'deny') {
// Hook approved tool use, but deny rule overrides
return { decision: ruleCheck, input: hookInput } // deny 规则赢
}
// ask rule — dialog required despite hook approval
return { decision: await canUseTool(…), input: hookInput } // ask 规则赢
}
if (hookPermissionResult?.behavior === 'deny') {
return { decision: hookPermissionResult, input } // 钩子 deny 直接生效
}
// 没有钩子决定,或者钩子说 ask → 正常流程,但把钩子的 ask 消息透传下去
const forceDecision = hookPermissionResult?.behavior === 'ask' ? hookPermissionResult : undefined
return { decision: await canUseTool(…, forceDecision), input: askInput }
整理成一张表:
| 钩子说 | 规则说 | 结果 |
|---|
| allow | (无规则) | 允许,跳过弹窗 |
| allow | deny | 拒绝(规则赢) |
| allow | ask | 弹窗(规则赢) |
| allow | —— 但工具需要真人交互且钩子没给答案 | 弹窗 |
| deny | 任意 | 拒绝(钩子赢) |
| ask | —— | 弹窗,用钩子的提示文案 |
| (无) | 正常规则 | 正常流程 |
核心不对称:钩子的 deny 是绝对的,钩子的 allow 是有条件的。
这是安全设计的正确方向——放宽权限的能力要受约束,收紧权限的能力不需要。
还有一条更细的:"钩子提供了 updatedInput 就算满足了真人交互"。这是给无头场景留的口子——一个包装脚本代替用户回答了 AskUserQuestion,那这次交互就算完成了。
可迁移的判断 ⑲
扩展点对权限的影响必须是不对称的:收紧无条件生效,放宽必须再过一遍中央策略。
而且要把这个不对称写成一张真值表放进代码注释或文档里——因为它是扩展作者最容易搞错的地方。一个插件作者写了个 allow 钩子,发现有时不生效,如果没有这张表他只能靠猜。
5.1 决策来源被完整记录
拒绝和放行都会打点,而且带上决策来源 [源码 src/services/tools/toolExecution.ts:207]:
ts
function decisionReasonToOTelSource(reason, behavior): string {
switch (reason.type) {
case 'permissionPromptTool': {
// SDK host 可以直接告诉我们是「这次允许」还是「永久允许」还是「缓存命中」
const classified = reason.toolResult?.decisionClassification
if (classified === 'user_temporary' || classified === 'user_permanent' || classified === 'user_reject') return classified
return behavior === 'allow' ? 'user_temporary' : 'user_reject' // 保守兜底
}
case 'rule': return ruleSourceToOTelSource(reason.rule.source, behavior)
case 'hook': return 'hook'
case 'mode': case 'classifier': case 'subcommandResults': case 'asyncAgent':
case 'sandboxOverride': case 'workingDir': case 'safetyCheck': case 'other':
return 'config'
default: { const _exhaustive: never = reason; return 'config' }
}
}
PermissionDecisionReason 有 11 种:规则、钩子、模式、分类器、子命令结果、异步 agent、沙箱覆盖、工作目录、安全检查、权限提示工具、其它。第 10 章会逐个讲。
注意末尾那个 const _exhaustive: never = reason —— 穷尽性检查。新增一种决策原因而忘了在这里处理,TypeScript 直接编译报错。
六、第 ⑫ 道关:三份输入的收敛
第 8 章讲过"观察者输入"和"API 绑定输入"是两份。到了要真正调用时,还有第三份——权限系统可能返回的 updatedInput。收敛逻辑 [源码 src/services/tools/toolExecution.ts:1181-1205]:
ts
// If processedInput still points at the backfill clone, no hook/permission
// replaced it — pass the pre-backfill callInput so call() sees the model's
// original field values. Otherwise converge on the hook-supplied input.
// Permission/hook flows may return a fresh object derived from the
// backfilled clone (e.g. via inputSchema.parse). If its file_path matches
// the backfill-expanded value, restore the model's original so the tool
// result string embeds the path the model emitted — keeps transcript/VCR
// hashes stable. Other hook modifications flow through unchanged.
if (backfilledClone && processedInput !== callInput
&& 'file_path' in processedInput && 'file_path' in callInput
&& processedInput.file_path === backfilledClone.file_path) {
callInput = { ...processedInput, file_path: callInput.file_path }
} else if (processedInput !== backfilledClone) {
callInput = processedInput
}
三种情况:
| 情况 | call() 拿到什么 |
|---|
| 没人动过输入 | 模型原始输入 |
| 钩子/权限换了新对象,但 file_path 还是回填后的值 | 新对象 + 换回模型原始的 file_path |
| 钩子/权限真的改了 | 新对象,原样透传 |
中间那种是最微妙的:权限流程内部做了一次 inputSchema.parse(),产出了一个新对象,但内容其实没变——如果直接用它,file_path 就变成展开后的绝对路径了,工具结果字符串("File created successfully at: {path}")跟着变,transcript 字节跟着变,VCR fixture 哈希跟着挂。
为了一个测试夹具的哈希稳定性,写了 15 行的判断。 值不值另说,但它说明这个团队把"transcript 的字节稳定性"当成了一等约束。
七、第 ⑭ 道关:结果处理与 PostToolUse
7.1 成功路径
ts
async function addToolResult(toolUseResult, preMappedBlock?) {
const toolResultBlock = preMappedBlock
? await processPreMappedToolResultBlock(preMappedBlock, tool.name, tool.maxResultSizeChars)
: await processToolResultBlock(tool, toolUseResult, toolUseID)
const contentBlocks: ContentBlockParam[] = [toolResultBlock]
// Add accept feedback if user provided feedback when approving
if ('acceptFeedback' in permissionDecision && permissionDecision.acceptFeedback) {
contentBlocks.push({ type: 'text', text: permissionDecision.acceptFeedback })
}
// Add content blocks (e.g., pasted images) from the permission decision
…
}
[源码 src/services/tools/toolExecution.ts:1402]
用户批准工具时可以顺便写一句反馈,这句反馈会跟在工具结果后面进上下文。 这是个很好的产品设计:用户点"允许"的同时说"但注意别动 config 目录",模型立刻就知道了。
用户还可以在批准时粘贴图片,图片也会作为额外内容块进去,并且分配连续的 imagePasteIds 好让每张图有独立标签。
toolUseResult 字段有个上下文预算判断:
ts
toolUseResult: toolUseContext.agentId && !toolUseContext.preserveToolUseResults
? undefined
: toolUseResult,
mcpMeta: toolUseContext.agentId ? undefined : mcpMeta,
子 agent 默认不保留完整的 toolUseResult 和 mcpMeta。 因为这些字段是给 UI 和 transcript 用的,子 agent 的 UI 不显示这些,存了纯浪费。
7.2 MCP 工具走不同的路
ts
// TOOD(hackyon): refactor so we don't have different experiences for MCP tools
if (!isMcpTool(tool)) {
await addToolResult(toolOutput, mappedToolResultBlock)
}
非 MCP 工具的结果在跑 PostToolUse 钩子之前就加进去了,MCP 工具要等钩子跑完。 因为 PostToolUse 钩子可以修改 MCP 工具的输出(updatedMCPToolOutput),而非 MCP 工具的输出不给改。
那个 TOOD 是原文的拼写错误——这条 TODO 承认了这是个应该被重构掉的不一致。
7.3 失败路径
ts
} catch (error) {
// MCP 认证错误 → 把客户端状态改成 needs-auth,/mcp 面板会显示要重新授权
if (error instanceof McpAuthError) { toolUseContext.setAppState(…) }
if (!(error instanceof AbortError)) {
// 打日志、打点、发 OTel —— 但 AbortError 不打,因为那是用户主动中断
if (!(error instanceof ShellError)) logError(error) // shell 出错不算异常
logEvent('tengu_tool_use_error', { error: classifyToolError(error), … })
void logOTelEvent('tool_result', { success: 'false', … })
}
const content = formatError(error)
const isInterrupt = error instanceof AbortError
// PostToolUseFailure 钩子
for await (const hookResult of runPostToolUseFailureHooks(…, isInterrupt, …)) { … }
return [{ message: createUserMessage({ content: [{ type: 'tool_result', content, is_error: true, … }] }) }, ...hookMessages]
} finally {
stopSessionActivity('tool_exec')
if (decisionInfo) toolUseContext.toolDecisions?.delete(toolUseID)
}
三处判断值得注意:
① AbortError 不打日志、不打点。 用户按 Ctrl+C 不是错误。
② ShellError 不走 logError。 shell 命令返回非零是常态,不该污染错误日志——但仍然打业务打点。
③ MCP 认证失败会改全局状态。 一次工具调用失败,副作用是 /mcp 面板上那个服务器变成"需要重新授权"。错误处理不只是返回错误,还要修正系统对世界的认知。
7.4 错误分类是为了压缩后还能用
ts
/**
* Classify a tool execution error into a telemetry-safe string.
*
* In minified/external builds, `error.constructor.name` is mangled into
* short identifiers like "nJT" or "Chq" — useless for diagnostics.
* This function extracts structured, telemetry-safe information instead:
* - TelemetrySafeError: use its telemetryMessage (already vetted)
* - Node.js fs errors: log the error code (ENOENT, EACCES, etc.)
* - Known error types: use their unminified name
* - Fallback: "Error" (better than a mangled 3-char identifier)
*/
export function classifyToolError(error: unknown): string
[源码 src/services/tools/toolExecution.ts:150]
压缩后 constructor.name 变成 nJT 这种垃圾,所以遥测不能靠它。 于是分三级降级:专门的遥测安全错误类型 → Node 的 errno 码 → 构造函数里显式设过的 .name → 兜底 "Error"。
那个 TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 类型名值得单独说——它把"我确认过这不含代码和文件路径"编码进了类型名里。同样的后缀出现在 AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 上,全仓到处都是。
可迁移的判断 ⑳
把"我人工确认过某个安全属性"编码进类型名,比写在文档或注释里有效得多。
因为每一次赋值都要显式写出那个又长又刺眼的名字,作者不可能不注意到自己在声明什么。这是"让危险操作难写"这条原则的第三个变体(前两个是第 5 章的 DANGEROUS_ 前缀和那个不被读取的 _reason 参数)。
八、这条链的一个观察:每一道关都产出可观测数据
从头到尾数一遍打点:
| 阶段 | 事件 |
|---|
| 工具不存在 | tengu_tool_use_error |
| 已中断 | tengu_tool_use_cancelled |
| Zod 失败 | tengu_tool_use_error (InputValidationError) |
| schema 没发 | tengu_deferred_tool_schema_not_sent |
| validateInput 失败 | tengu_tool_use_error |
| PreToolUse 耗时 | pre_tool_hook_duration_ms(直方图) |
| 权限拒绝 | tengu_tool_use_can_use_tool_rejected + OTel tool_decision |
| 权限放行 | tengu_tool_use_can_use_tool_allowed + OTel tool_decision |
| 执行中进度 | tengu_tool_use_progress |
| 执行成功 | OTel tool_result(含参数、耗时、决策来源) |
| 执行失败 | tengu_tool_use_error + OTel tool_result(success=false) |
| PostToolUse 耗时 | post_tool_hook_duration_ms |
每一道关都能被单独观测。 而且大部分事件都带 queryChainId 和 queryDepth——这两个字段来自第 3 章的 queryTracking,作用是把子 agent 的工具调用挂回它的调用链。
有了这两个字段,"某个 fork 里的工具调用失败率"这种问题才能被回答。
九、动手复核
bash
cd claude-code-deep-dive/extracted-source
# 1. 主入口与前四道关
sed -n '337,500p' src/services/tools/toolExecution.ts
sed -n '599,760p' src/services/tools/toolExecution.ts
# 2. PreToolUse 钩子的七种结果
sed -n '795,895p' src/services/tools/toolExecution.ts
sed -n '435,470p' src/services/tools/toolHooks.ts
# 3. 钩子权限 × 规则权限 的真值表
sed -n '332,433p' src/services/tools/toolHooks.ts
# 4. 三份输入的收敛
sed -n '1178,1210p' src/services/tools/toolExecution.ts
# 5. 成功/失败两条尾巴
sed -n '1395,1500p' src/services/tools/toolExecution.ts
sed -n '1588,1745p' src/services/tools/toolExecution.ts
# 6. 错误分类的三级降级
sed -n '139,172p' src/services/tools/toolExecution.ts
# 7. 那两个把安全声明写进名字的类型
grep -rn 'I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS' src/ | wc -l
本机侧:
bash
# 打开 OTel 导出后能看到 tool_decision / tool_result 事件
# 参数详情默认关闭,需要显式打开(可能含敏感内容)
OTEL_LOG_TOOL_DETAILS=1 claude
十、总结
- 14 道关,5 道能中止、4 道能改写输入。 "模型决定 → 直接跑函数"在这个系统里不存在
- 每条错误路径都必须产出一个 tool_result,否则 API 报 400;找不到工具时的别名兜底只认别名不认主名,否则就是权限绕过
- validateInput 和 checkPermissions 的分工线是"这是模型的问题还是用户的问题"——前者失败只告诉模型,不打扰用户
- Bash 分类器投机启动,但 UI 提示不投机——"投机执行可以,投机地告诉用户不行"
- PreToolUse 钩子有七种结果,其中"改写输入"和"做权限决定"是分开的两种
- 钩子权限和规则权限的合流是不对称的:钩子的 deny 绝对生效,钩子的 allow 仍要过 deny/ask 规则;钩子提供 updatedInput 可以顶替真人交互
- PermissionDecisionReason 有 11 种,并且用 never 做了穷尽性检查
- 三份输入的收敛里最微妙的一支,是为了让工具结果字符串里的路径保持模型原样——transcript 字节稳定性被当成一等约束
- 失败路径分三档:AbortError 不打点(用户主动)、ShellError 不进错误日志(非零退出是常态)、MCP 认证失败要改全局状态
- 错误分类做三级降级,因为压缩后 constructor.name 变成三个乱码字符
- 每一道关都打点,且带 queryChainId / queryDepth——这是"某个 fork 里的失败率"这类问题能被回答的前提
下一章展开第 ⑩ 道关里那个 canUseTool:六种权限模式、三类规则、十种决策来源,以及一个被外包出去的沙箱。
- 第8章-工具系统-47个成员的接口与延迟加载
- 第10章-权限模型-六种模式与被外包的沙箱
- 第11章-钩子系统-27个事件的治理层 —— PreToolUse / PostToolUse 的完整能力面
- 第3章-Agent-Loop-query这一个循环 —— runTools 的上游
- Codex 教程第 11 章 —— 审批与 Guardian 的对照
- dsh 教程第 7 章 —— 五段流水线的对照