Agent X-Ray
RuntimeNotesAbout
Notes/源码拆解/Claude Code Harness/第9章

第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 validateInputcheckPermissions 的分工

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 反馈分开。 前者的代价是浪费一点计算,后者的代价是用户看到一个最终没发生的状态——后者贵得多。


四、第 ⑧ 道关:PreToolUse 钩子的七种结果

这是执行链上权力最大的一环。钩子可以产出七种结果 [源码 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
}

注意 hookUpdatedInputhookPermissionResult 是分开的两种结果。 前者是"我改一下参数但不做权限决定"(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(无规则)允许,跳过弹窗
allowdeny拒绝(规则赢)
allowask弹窗(规则赢)
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' }
  }
}

PermissionDecisionReason11 种:规则、钩子、模式、分类器、子命令结果、异步 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 默认不保留完整的 toolUseResultmcpMeta 因为这些字段是给 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

每一道关都能被单独观测。 而且大部分事件都带 queryChainIdqueryDepth——这两个字段来自第 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

十、总结

  1. 14 道关,5 道能中止、4 道能改写输入。 "模型决定 → 直接跑函数"在这个系统里不存在
  2. 每条错误路径都必须产出一个 tool_result,否则 API 报 400;找不到工具时的别名兜底只认别名不认主名,否则就是权限绕过
  3. validateInputcheckPermissions 的分工线是"这是模型的问题还是用户的问题"——前者失败只告诉模型,不打扰用户
  4. Bash 分类器投机启动,但 UI 提示不投机——"投机执行可以,投机地告诉用户不行"
  5. PreToolUse 钩子有七种结果,其中"改写输入"和"做权限决定"是分开的两种
  6. 钩子权限和规则权限的合流是不对称的:钩子的 deny 绝对生效,钩子的 allow 仍要过 deny/ask 规则;钩子提供 updatedInput 可以顶替真人交互
  7. PermissionDecisionReason 有 11 种,并且用 never 做了穷尽性检查
  8. 三份输入的收敛里最微妙的一支,是为了让工具结果字符串里的路径保持模型原样——transcript 字节稳定性被当成一等约束
  9. 失败路径分三档AbortError 不打点(用户主动)、ShellError 不进错误日志(非零退出是常态)、MCP 认证失败要改全局状态
  10. 错误分类做三级降级,因为压缩后 constructor.name 变成三个乱码字符
  11. 每一道关都打点,且带 queryChainId / queryDepth——这是"某个 fork 里的失败率"这类问题能被回答的前提

下一章展开第 ⑩ 道关里那个 canUseTool六种权限模式、三类规则、十种决策来源,以及一个被外包出去的沙箱。


  • 第8章-工具系统-47个成员的接口与延迟加载
  • 第10章-权限模型-六种模式与被外包的沙箱
  • 第11章-钩子系统-27个事件的治理层 —— PreToolUse / PostToolUse 的完整能力面
  • 第3章-Agent-Loop-query这一个循环 —— runTools 的上游
  • Codex 教程第 11 章 —— 审批与 Guardian 的对照
  • dsh 教程第 7 章 —— 五段流水线的对照

本章目录
一、全景二、前四道关:先挡低级错误三、第 ⑤ 道关:投机式分类器四、第 ⑧ 道关:PreToolUse 钩子的七种结果五、第 ⑩ 道关:钩子权限与规则权限的合流六、第 ⑫ 道关:三份输入的收敛七、第 ⑭ 道关:结果处理与 PostToolUse八、这条链的一个观察:每一道关都产出可观测数据九、动手复核十、总结Related Documents
苏ICP备2025204887号-2