Claude Code源码剖析 - Phase3

Phase 3: Tool 抽象

src/Tool.ts:Tool 抽象入口

ToolResult:工具执行后的结果容器

ts 复制代码
export type ToolResult<T> = {
  data: T
  newMessages?: (
    | UserMessage
    | AssistantMessage
    | AttachmentMessage
    | SystemMessage
  )[]
  // contextModifier is only honored for tools that aren't concurrency safe.
  contextModifier?: (context: ToolUseContext) => ToolUseContext
  /** MCP protocol metadata (structuredContent, _meta) to pass through to SDK consumers */
  mcpMeta?: {
    _meta?: Record<string, unknown>
    structuredContent?: Record<string, unknown>
  }
}

ToolResult<T> 表示一个工具执行完成后交还给 agent runtime 的结果。

它最核心的字段是 data: T,也就是工具真正产出的内容。但工具结果还可以携带额外信息:

  • newMessages:工具执行后要补充进对话历史的消息。
  • contextModifier:修改下一轮工具执行上下文。
  • mcpMeta:给 MCP / SDK 使用的结构化元数据。

所以工具结果不是简单字符串,而是"结果数据 + 运行时附加信息"。

findToolByName:从工具列表中找到模型请求的工具

ts 复制代码
/**
 * Checks if a tool matches the given name (primary name or alias).
 */
export function toolMatchesName(
  tool: { name: string; aliases?: string[] },
  name: string,
): boolean {
  return tool.name === name || (tool.aliases?.includes(name) ?? false)
}

/**
 * Finds a tool by name or alias from a list of tools.
 */
export function findToolByName(tools: Tools, name: string): Tool | undefined {
  return tools.find(t => toolMatchesName(t, name))
}

模型发出的 tool_use 会带一个工具名,本地 runtime 需要用这个名字在工具列表里找到对应工具。

源码支持两种匹配:

  • 主名:tool.name
  • 别名:tool.aliases

这说明工具可以重命名,同时保留旧名字兼容。

Tool:工具对象的核心接口

ts 复制代码
export type Tool<Input, Output, P> = {
  aliases?: string[]
  searchHint?: string
  call(...): Promise<ToolResult<Output>>
  description(...): Promise<string>
  readonly inputSchema: Input
  ...
}

Tool 是 Claude Code 中工具系统的核心抽象。一个工具至少要能说明:

  • 自己接收什么输入:inputSchema
  • 自己如何执行:call(...)
  • 执行后返回什么:ToolResult<Output>

agent loop 里的关系可以这样记:

text 复制代码
assistant/tool_use
-> 本地 runtime 根据 name 找 Tool
-> 用 inputSchema 校验输入
-> 调用 tool.call()
-> 得到 ToolResult
-> 转成 user/tool_result
-> 回填 messages
-> 下一轮模型继续

Tool 核心字段:schema、权限、执行、结果回填

ts 复制代码
// 这段继续定义一个工具对象必须具备的"运行时能力":输入校验、是否只读、权限检查、工具名、以及如何把工具执行结果转成 API 能理解的 tool_result block
readonly inputSchema: Input
// Type for MCP tools that can specify their input schema directly in JSON Schema format
// rather than converting from Zod schema
readonly inputJSONSchema?: ToolInputJSONSchema
// Optional because TungstenTool doesn't define this. TODO: Make it required.
// When we do that, we can also go through and make this a bit more type-safe.
outputSchema?: z.ZodType<unknown>


// 这里开始体现 Claude Code 对工具的"安全分层"。
// isEnabled():这个工具当前是否可用。
// isReadOnly():这个工具是否只读,比如 Read、Grep、Glob。
// isDestructive?():这个工具是否可能造成不可逆操作,比如删除、覆盖、发送。
// isConcurrencySafe():这个工具能不能和别的工具并发跑。
// 你可以这样理解:工具系统不是只负责"能不能调用",还要负责"该不该并发、该不该询问用户、风险有多高"
inputsEquivalent?(a: z.infer<Input>, b: z.infer<Input>): boolean
isConcurrencySafe(input: z.infer<Input>): boolean
isEnabled(): boolean
isReadOnly(input: z.infer<Input>): boolean
/** Defaults to false. Only set when the tool performs irreversible operations (delete, overwrite, send). */
isDestructive?(input: z.infer<Input>): boolean


// 权限相关
// 这两个要区分
// validateInput() 更像"这个输入本身合不合法"。比如路径是不是空、参数组合是否冲突。
// checkPermissions() 更像"这个合法输入在当前上下文里允不允许执行"。比如读文件可能允许,写文件可能要问用户,执行 shell 命令可能要走更复杂的权限策略。

validateInput?(input, context): Promise<ValidationResult>

checkPermissions(
  input,
  context,
): Promise<PermissionResult>


// 这就是工具系统和 message 系统接上的地方。
// 工具自己的 call() 返回的是内部结果:
// 但模型下一轮需要看到的是 Anthropic API 格式的:
// user message
//   content:
//     - type: "tool_result"
//       tool_use_id: ...
//       content: ...
// 所以这个函数的责任是
// 工具内部 Output
// -> API 可用的 tool_result block

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

Tool 类型里最重要的字段可以分成四类。

第一类是输入输出结构:

  • inputSchema:工具输入的 Zod schema,告诉模型这个工具需要哪些参数。
  • inputJSONSchema:给 MCP 工具使用的 JSON Schema 形式。
  • outputSchema:可选的输出结构约束。

第二类是工具行为判断:

  • isEnabled():工具当前是否启用。
  • isReadOnly(input):工具是否只读。
  • isDestructive?(input):工具是否可能造成不可逆修改。
  • isConcurrencySafe(input):工具是否可以和其他工具并发执行。

这些字段说明 Claude Code 的工具不是普通函数,而是带有安全属性和调度属性的 runtime 对象。

第三类是权限检查:

  • validateInput(input, context):判断输入本身是否合法。
  • checkPermissions(input, context):判断当前上下文是否允许执行这个工具。

可以这样区分:

text 复制代码
validateInput:参数对不对
checkPermissions:这次能不能做

第四类是结果回填:

mapToolResultToToolResultBlockParam(content, toolUseID)

这个函数把工具内部输出转换成模型 API 需要的 tool_result block。

schema 负责告诉模型怎么调用

permission 负责决定本地是否允许

call 负责真正执行

mapToolResult... 负责把结果塞回消息系统

完整链路是:

text 复制代码
assistant/tool_use
-> findToolByName(name)
-> inputSchema 校验
-> validateInput
-> checkPermissions
-> call
-> ToolResult<Output>
-> mapToolResultToToolResultBlockParam
-> user/tool_result message
-> 下一轮模型调用

src/tools.ts:工具注册表与工具池过滤

getAllBaseTools:内置工具候选全集

ts 复制代码
// 内置工具集你可以把它理解成一个静态工具注册表:所有基础工具对象都在这里集中列出来。每个元素,比如 BashTool、FileReadTool、GrepTool,本质上都应该符合上一节看到的 Tool 接口
export function getAllBaseTools(): Tools {
  return [
    AgentTool,
    TaskOutputTool,
    BashTool,
    ...
    FileReadTool,
    FileEditTool,
    FileWriteTool,
    ...
    WebFetchTool,
    TodoWriteTool,
    WebSearchTool,
    ...
  ]
}

// 这里并不是简单塞进去,而是进行了严格的权限校验
...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool])
...(isTodoV2Enabled() ? [TaskCreateTool, TaskGetTool, ...] : [])
...(isEnvTruthy(process.env.ENABLE_LSP_TOOL) ? [LSPTool] : [])
...(isWorktreeModeEnabled() ? [EnterWorktreeTool, ExitWorktreeTool] : [])

// 这说明工具池会根据环境、feature flag、模式动态变化。
// 比如:
// 有内置搜索能力时,就不需要额外暴露 GlobTool / GrepTool。
// Todo v2 开启时,才加入任务相关工具
// LSP 开启时,才加入语言服务工具
// worktree 模式开启时,才加入工作树工具

`getAllBaseTools()` 是 Claude Code 内置工具的集中注册位置。

它返回一个 `Tools` 数组,里面包含很多符合 `Tool` 接口的工具对象,例如:

- `AgentTool`
- `BashTool`
- `FileReadTool`
- `FileEditTool`
- `FileWriteTool`
- `GlobTool`
- `GrepTool`
- `WebFetchTool`
- `WebSearchTool`
- `TodoWriteTool`

但这个列表不是完全静态的。源码会根据 feature flag、环境变量、运行模式动态加入或移除工具。

所以可以这样记:

```text
getAllBaseTools()
= 源码层面的内置工具候选池

getTools:当前会暴露给模型的工具箱

ts 复制代码
// 这个函数才更接近"本次真正可用的工具列表"
export const getTools = (permissionContext: ToolPermissionContext): Tools => {
  ...
  const tools = getAllBaseTools().filter(tool => !specialTools.has(tool.name))
  let allowedTools = filterToolsByDenyRules(tools, permissionContext)
  ...
  const isEnabled = allowedTools.map(_ => _.isEnabled())
  return allowedTools.filter((_, i) => isEnabled[i])
}

// 它做了几层过滤:
// 先拿 getAllBaseTools() 的基础工具池。
// 排除一些特殊工具,比如 MCP resource 工具、SyntheticOutput 工具。
// 用 permission context 过滤掉被 deny rule 完全禁止的工具。
// 如果 REPL 模式开启,就隐藏一些底层 primitive tools。
// 最后调用每个工具自己的 isEnabled(),只保留当前启用的工具。
// 这一步很重要:模型最终看到的工具列表,不等于源码里存在的全部工具。

getTools(permissionContext) 会基于 getAllBaseTools() 生成当前真正可用的工具列表。

它主要做几件事:

simple mode 下只暴露少量基础工具。

排除特殊工具。

根据 permission context 过滤被 deny rule 禁用的工具。

REPL 模式下隐藏底层 primitive tools。

调用每个工具自己的 isEnabled(),只保留启用中的工具。

因此模型最终看到的工具列表不是"所有源码里实现过的工具",而是经过运行环境、权限规则、模式开关过滤后的结果。

完整关系:

text 复制代码
Tool 接口
-> 各个具体工具实现
-> getAllBaseTools() 汇总候选工具
-> getTools(permissionContext) 过滤当前可用工具
-> agent loop / API 调用使用这批 tools

这说明工具系统有两层抽象

  1. 单个工具:定义 schema、权限、执行、结果回填。
  2. 工具池:决定当前有哪些工具可以被模型看到和调用。

assembleToolPool:合并内置工具和 MCP 工具

ts 复制代码
export function assembleToolPool(
  permissionContext: ToolPermissionContext,
  mcpTools: Tools,
): Tools {
  const builtInTools = getTools(permissionContext)

  // Filter out MCP tools that are in the deny list
  const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)

  // Sort each partition for prompt-cache stability, keeping built-ins as a
  // contiguous prefix. The server's claude_code_system_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. uniqBy
  // preserves insertion order, so built-ins win on name conflict.
  // Avoid Array.toSorted (Node 20+) --- we support Node 18. builtInTools is
  // readonly so copy-then-sort; allowedMcpTools is a fresh .filter() result.
  const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
  return uniqBy(
    [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
    'name',
  )
}

assembleToolPool() 是把内置工具和 MCP 工具合并成一个工具池的入口。

它做三件事:

  1. 获取内置工具:

    ts 复制代码
    const builtInTools = getTools(permissionContext)

    这里使用的是 getTools(),所以内置工具已经经过运行模式、权限规则、isEnabled() 过滤。

  2. 过滤 MCP 工具:

    ts 复制代码
    const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)

    MCP 工具也必须遵守 deny rule。外部工具不是接入后就一定能被模型看到。

  3. 排序并去重:

    ts 复制代码
    return uniqBy(
      [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
      'name',
    )

    排序是为了让工具列表顺序稳定,减少 prompt cache 被破坏。

    去重按 name 进行。因为内置工具排在 MCP 工具前面,所以同名冲突时内置工具优先。

可以这样记:

text 复制代码
built-in tools
-> getTools(permissionContext)
-> allowed built-in tools

MCP tools
-> filterToolsByDenyRules(permissionContext)
-> allowed MCP tools

allowed built-in tools + allowed MCP tools
-> sort
-> uniqBy(name)
-> final tool pool

getMergedTools:完整拼接工具列表

ts 复制代码
export function getMergedTools(
  permissionContext: ToolPermissionContext,
  mcpTools: Tools,
): Tools {
  const builtInTools = getTools(permissionContext)
  return [...builtInTools, ...mcpTools]
}

getMergedTools() 只是把内置工具和 MCP 工具拼在一起:

ts 复制代码
return [...builtInTools, ...mcpTools]

它不负责排序、去重、过滤 MCP deny rules。注释说明它更适合用于统计、token counting、tool search 阈值计算等场景。

所以两个函数的区别是:

text 复制代码
assembleToolPool:生成规范工具池,更接近最终暴露/执行用
getMergedTools:生成完整列表,更适合统计和计算用

这说明 Claude Code 的工具系统不是封闭的。内置工具和 MCP 外部工具最终都会被抽象成统一的 Tool,然后进入同一个工具池

src/services/tools/toolExecution.ts:工具执行模块

runToolUse:单次工具调用的入口

ts 复制代码
// 这对工具系统很重要,因为工具执行可能很慢,比如 Bash、WebFetch、AgentTool。运行中可能要产出 progress message,最后才产出真正的 tool_result
export async function* runToolUse(
  toolUse: ToolUseBlock,
  assistantMessage: AssistantMessage,
  canUseTool: CanUseToolFn,
  toolUseContext: ToolUseContext,
): AsyncGenerator<MessageUpdateLazy, void>

// 一、拿到工具名
const toolName = toolUse.name
let tool = findToolByName(toolUseContext.options.tools, toolName)

// 二、如果没找到,会做一个兼容旧名字的 fallback
if (!tool) {
  const fallbackTool = findToolByName(getAllBaseTools(), toolName)
  if (fallbackTool && fallbackTool.aliases?.includes(toolName)) {
    tool = fallbackTool
  }
}

// 三、如果工具还是不存在,就构造一个错误型 tool_result
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}`,
    sourceToolAssistantUUID: assistantMessage.uuid,
  }),
}
return

// 工具不存在时,runtime 不是直接崩掉,也不是静默忽略,而是把错误包装成 user message 里的 tool_result block。
// 这样模型下一轮能看到: 你刚才调用的工具不存在

// 四、处理取消
if (toolUseContext.abortController.signal.aborted) {
  const content = createToolResultStopMessage(toolUse.id)
  content.content = withMemoryCorrectionHint(CANCEL_MESSAGE)
  yield {
    message: createUserMessage({
      content: [content],
      toolUseResult: CANCEL_MESSAGE,
      sourceToolAssistantUUID: assistantMessage.uuid,
    }),
  }
  return
}

// 五、真正进入权限检查和调用流程
for await (const update of streamedCheckPermissionsAndCallTool(...)) {
  yield update
}

// 这里还没有真正执行工具,而是把后续流程交给 streamedCheckPermissionsAndCallTool()。
// 从名字能看出来,下一层会做
// check permissions
// -> call tool
// -> stream updates

// 最后,如果中间抛异常,会 catch 住,并生成错误型 tool_result:
content: `<tool_use_error>${detailedError}</tool_use_error>`,
is_error: true,
tool_use_id: toolUse.id,

// tool_use
// -> 根据 name 找工具
// -> 找不到:生成 error tool_result
// -> 已取消:生成 cancel tool_result
// -> 找到:进入权限检查 + 执行
// -> 执行异常:生成 error tool_result

runToolUse() 是模型发出一次 tool_use 后,本地 runtime 处理这次工具调用的入口。

它是一个异步生成器:

ts 复制代码
export async function* runToolUse(...): AsyncGenerator<MessageUpdateLazy, void>

这表示工具调用过程可以持续产出消息更新,例如 progress message、最终 tool_result message。

它的主流程是:

text 复制代码
tool_use
-> 读取 toolUse.name
-> 在当前可用工具池 toolUseContext.options.tools 里查找工具
-> 如果没找到,尝试用 alias 兼容旧工具名
-> 如果仍然没找到,生成错误型 tool_result
-> 如果已取消,生成取消型 tool_result
-> 如果找到工具,进入权限检查和执行流程
-> 如果执行抛异常,生成错误型 tool_result

重点一:工具查找使用的是当前可用工具池

ts 复制代码
let tool = findToolByName(toolUseContext.options.tools, toolName)

这里不是从源码全部工具里随便找,而是从当前上下文允许暴露给模型的工具列表里找。

重点二:旧工具名只通过 alias 兼容

ts 复制代码
const fallbackTool = findToolByName(getAllBaseTools(), toolName)
if (fallbackTool && fallbackTool.aliases?.includes(toolName)) {
  tool = fallbackTool
}

这用于兼容旧 transcript 中的工具名,但只有匹配到 aliases 时才允许 fallback。

重点三:工具不存在也会回填 tool_result

ts 复制代码
{
  type: 'tool_result',
  content: `<tool_use_error>Error: No such tool available: ${toolName}</tool_use_error>`,
  is_error: true,
  tool_use_id: toolUse.id,
}

这说明工具失败不是直接让 agent loop 崩掉,而是作为 user/tool_result 返回给模型,让模型下一轮能理解失败原因。

重点四:取消和异常也会转成 tool_result

用户中断、abort、工具执行异常,都会被包装成 user message 里的 tool_result block。

所以可以这样记:

text 复制代码
runToolUse() 的责任不是只执行成功工具,
而是把一次 tool_use 的所有结果路径都规范化成 message update。

checkPermissionsAndCallTool:输入校验阶段

ts 复制代码
async function checkPermissionsAndCallTool(
  tool: Tool,
  toolUseID: string,
  input: { [key: string]: boolean | string | number },
  ...
): Promise<MessageUpdateLazy[]>

checkPermissionsAndCallTool() 是单次工具调用进入真正执行前的核心函数。

这一段主要做两层输入校验:

text 复制代码
模型生成 tool_use.input
-> tool.inputSchema.safeParse(input)
-> tool.validateInput?.(parsedInput.data, context)
-> 校验失败时生成错误型 tool_result
-> 校验成功后继续进入权限检查、hook、tool.call()
第一层:inputSchema 结构校验

源码使用 Zod schema 校验模型生成的输入:

ts 复制代码
const parsedInput = tool.inputSchema.safeParse(input)
if (!parsedInput.success) {
  ...
}

虽然模型会看到工具 schema,但它仍然可能生成错误输入。所以 runtime 必须在本地重新校验。

如果校验失败,会格式化错误:

ts 复制代码
let errorContent = formatZodValidationError(tool.name, parsedInput.error)

然后返回一个错误型 tool_result

ts 复制代码
return [
  {
    message: createUserMessage({
      content: [
        {
          type: 'tool_result',
          content: `<tool_use_error>InputValidationError: ${errorContent}</tool_use_error>`,
          is_error: true,
          tool_use_id: toolUseID,
        },
      ],
      toolUseResult: `InputValidationError: ${parsedInput.error.message}`,
      sourceToolAssistantUUID: assistantMessage.uuid,
    }),
  },
]

这说明 schema 错误不会直接让 agent loop 崩掉,而是会作为 user/tool_result 回填给模型,让模型下一轮有机会修正输入。

buildSchemaNotSentHint:deferred tool 的特殊提示

源码还会调用:

ts 复制代码
const schemaHint = buildSchemaNotSentHint(
  tool,
  toolUseContext.messages,
  toolUseContext.options.tools,
)

这个提示和 deferred tool / ToolSearch 有关。

如果某个工具 schema 没有真正发给模型,模型可能会把数组、数字、布尔值等参数错误地生成成字符串。这个 hint 会提醒模型先加载工具 schema,再重新调用工具。

第二层:validateInput 语义校验

结构校验通过后,源码会调用工具自己的语义校验:

ts 复制代码
const isValidCall = await tool.validateInput?.(
  parsedInput.data,
  toolUseContext,
)

这里的 ?. 表示 validateInput() 是可选的,不是每个工具都必须实现。

可以这样区分:

text 复制代码
inputSchema:检查输入结构和类型
validateInput:检查工具自己的业务语义

例如:

text 复制代码
inputSchema:file_path 必须是 string
validateInput:这个路径在当前上下文里是否合理

如果 validateInput() 失败,也会生成错误型 tool_result

ts 复制代码
{
  type: 'tool_result',
  content: `<tool_use_error>${isValidCall.message}</tool_use_error>`,
  is_error: true,
  tool_use_id: toolUseID,
}

所以这一段可以这样记:

text 复制代码
工具执行前先过两道输入门:

1. schema 门:参数形状对不对
2. validation 门:参数语义能不能用

只要失败,就把错误包装成 tool_result 回给模型。

模型生成 input
-> inputSchema.safeParse:结构校验
-> tool.validateInput:语义校验
-> 失败就生成 tool_result error
-> 成功才继续进入权限 / hook / call

checkPermissionsAndCallTool:PreToolUse hook 与输入预处理

ts 复制代码
if (
  tool.name === BASH_TOOL_NAME &&
  parsedInput.data &&
  'command' in parsedInput.data
) {
  const appState = toolUseContext.getAppState()
  startSpeculativeClassifierCheck(...)
}

这一段发生在输入校验之后、权限检查和真正执行工具之前。

它的作用是:

text 复制代码
提前启动 Bash 安全分类器
-> 清理内部字段
-> 准备 hook / permission 可见输入
-> 执行 PreToolUse hooks
-> hook 可以追加消息、修改输入、给出权限结果、阻止继续
Bash 安全分类器提前启动

如果当前工具是 Bash,并且输入里有 command,源码会提前启动安全分类器:

ts 复制代码
startSpeculativeClassifierCheck(
  (parsedInput.data as BashToolInput).command,
  appState.toolPermissionContext,
  toolUseContext.abortController.signal,
  toolUseContext.options.isNonInteractiveSession,
)

这是一个并行优化。Bash 权限判断可能要等待安全分类器,所以源码提前启动它,让它和 pre-tool hooks、权限检查准备工作并行。

这里不会直接更新 UI 状态,避免某些自动允许的命令让界面闪一下"classifier running"。

清理内部字段

源码会从模型提供的 Bash input 中移除 _simulatedSedEdit

ts 复制代码
let processedInput = parsedInput.data

if (
  tool.name === BASH_TOOL_NAME &&
  processedInput &&
  typeof processedInput === 'object' &&
  '_simulatedSedEdit' in processedInput
) {
  const { _simulatedSedEdit: _, ...rest } = processedInput
  processedInput = rest
}

_simulatedSedEdit 是内部字段,只应该由权限系统在用户批准后注入,不应该由模型自己提供。

这是一层 defense-in-depth:

text 复制代码
即使 schema 理论上会拒绝这个字段,
runtime 仍然再清理一次,
防止未来 schema 变化造成安全回归。
backfillObservableInput:给 hook 和权限系统看的输入补字段
ts 复制代码
let callInput = processedInput
const backfilledClone =
  tool.backfillObservableInput &&
  typeof processedInput === 'object' &&
  processedInput !== null
    ? ({ ...processedInput } as typeof processedInput)
    : null

if (backfilledClone) {
  tool.backfillObservableInput!(backfilledClone as Record<string, unknown>)
  processedInput = backfilledClone
}

源码区分了两个输入:

ts 复制代码
let callInput = processedInput
  • processedInput:给 hooks、permission、observers 看的输入。
  • callInput:最终传给 tool.call() 的输入。

如果工具实现了 backfillObservableInput(),源码会先浅拷贝一份输入,再对 clone 做补字段:

ts 复制代码
const backfilledClone =
  tool.backfillObservableInput &&
  typeof processedInput === 'object' &&
  processedInput !== null
    ? ({ ...processedInput } as typeof processedInput)
    : null

if (backfilledClone) {
  tool.backfillObservableInput!(backfilledClone as Record<string, unknown>)
  processedInput = backfilledClone
}

这样做是为了让 hook / permission 看到兼容字段或派生字段,但不污染真正传给 tool.call() 的原始输入。

可以这样记:

text 复制代码
processedInput:观察和权限用
callInput:真正执行用
runPreToolUseHooks:工具执行前的 hook 介入点

源码位置:src/services/tools/toolExecution.ts:795-891

源码执行 PreToolUse hooks:

ts 复制代码
for await (const result of runPreToolUseHooks(...)) {
  switch (result.type) {
    ...
  }
}

hook 可以返回多种结果:

text 复制代码
message:追加消息或进度
hookPermissionResult:直接给出权限结果
hookUpdatedInput:修改工具输入
preventContinuation:标记阻止继续
stopReason:提供停止原因
additionalContext:追加上下文
stop:直接终止本次工具执行

如果 hook 返回 stop,runtime 会创建一个停止型工具结果:

ts 复制代码
createToolResultStopMessage(toolUseID)

然后直接返回 resultingMessages,不再进入后续权限检查和 tool.call()

这一段说明:Claude Code 的工具执行不是模型说调用就马上调用。真正执行前,runtime 会先经过 hook 系统,让外部规则有机会观察、补充、修改或阻止工具调用。

可以这样记:

text 复制代码
PreToolUse hook 是 tool.call() 前的一道扩展点和安全门。

checkPermissionsAndCallTool:权限决策阶段

这一段发生在输入校验和 PreToolUse hooks 之后、真正 tool.call() 之前。

它要解决的问题是:

text 复制代码
这个工具调用在当前上下文里到底允不允许执行?

在此之前已经完成了

text 复制代码
schema 校验
-> validateInput 语义校验
-> PreToolUse hooks

主流程是:

text 复制代码
读取当前 permission mode
-> resolveHookPermissionDecision()
-> 得到 permissionDecision
-> 如果不是 allow,生成错误型 tool_result 并返回
-> 如果是 allow,继续往后进入真正执行阶段
第一步: 记录当前权限模式
ts 复制代码
const permissionMode = toolUseContext.getAppState().toolPermissionContext.mode
const permissionStart = Date.now()

permissionMode 可能影响权限判断方式,比如 default、auto、non-interactive 之类。这里也开始计时,方便后面记录慢权限判断

第二步: 解析 hook 权限结果和常规权限系统

源码调用:

ts 复制代码
const resolved = await resolveHookPermissionDecision(
  hookPermissionResult,
  tool,
  processedInput,
  toolUseContext,
  canUseTool,
  assistantMessage,
  toolUseID,
)
const permissionDecision = resolved.decision
processedInput = resolved.input

上一阶段的 PreToolUse hook 可能已经给出 hookPermissionResult

resolveHookPermissionDecision() 会综合:

text 复制代码
hookPermissionResult
+ tool.checkPermissions / canUseTool 常规权限流程

最后得到统一的权限结果:

ts 复制代码
const permissionDecision = resolved.decision
processedInput = resolved.input

可以这样记:

text 复制代码
不管权限来自 hook、规则、用户确认、auto classifier,
最后都会归一化成 permissionDecision。
第三步,记录权限决策事件
ts 复制代码
if (
  permissionDecision.behavior !== 'ask' &&
  !toolUseContext.toolDecisions?.has(toolUseID)
) {
  const decision =
    permissionDecision.behavior === 'allow' ? 'accept' : 'reject'
  ...
  void logOTelEvent('tool_decision', ...)
}

它决定工具是否继续执行。

可以简化理解为:

text 复制代码
allow:允许执行
ask:没有直接允许,需要询问或被视为不能继续
deny:拒绝执行

源码判断很直接:

ts 复制代码
if (permissionDecision.behavior !== 'allow') {
  ...
  return resultingMessages
}

也就是说,只有 allow 才能进入真正的 tool.call()

第四步,如果权限结果来自 PermissionRequest hook,还会追加一条 attachment message
ts 复制代码
if (
  permissionDecision.decisionReason?.type === 'hook' &&
  permissionDecision.decisionReason.hookName === 'PermissionRequest' &&
  permissionDecision.behavior !== 'ask'
) {
  resultingMessages.push({
    message: createAttachmentMessage({
      type: 'hook_permission_decision',
      decision: permissionDecision.behavior,
      toolUseID,
      hookEvent: 'PermissionRequest',
    }),
  })
}

这不是给模型的核心工具结果,而是用于记录 hook 权限决策过程。

第五步,权限不是 allow 就停止

如果权限不是 allow,源码会构造一个 user message:

ts 复制代码
if (permissionDecision.behavior !== 'allow') {
  ...
  const messageContent: ContentBlockParam[] = [
    {
      type: 'tool_result',
      content: errorMessage,
      is_error: true,
      tool_use_id: toolUseID,
    },
  ]
  ...
  resultingMessages.push({
    message: createUserMessage({
      content: messageContent,
      imagePasteIds: rejectImageIds,
      toolUseResult: `Error: ${errorMessage}`,
      sourceToolAssistantUUID: assistantMessage.uuid,
    }),
  })
  ...
  return resultingMessages
}

这说明权限拒绝不是让程序崩掉,而是作为 user/tool_result 回填给模型。

模型下一轮能看到:

text 复制代码
刚才的工具调用被拒绝了,原因是某个 permission message。
第六步,如果拒绝来自 auto-mode classifier,可能会跑 PermissionDenied hook

如果拒绝来自 auto-mode classifier,源码可能执行 PermissionDenied hooks:

ts 复制代码
if (
  feature('TRANSCRIPT_CLASSIFIER') &&
  permissionDecision.decisionReason?.type === 'classifier' &&
  permissionDecision.decisionReason.classifier === 'auto-mode'
) {
  for await (const result of executePermissionDeniedHooks(...)) {
    if (result.retry) hookSaysRetry = true
  }
  ...
}

如果 hook 返回 retry: true,runtime 会额外追加一条 meta user message:

text 复制代码
The PermissionDenied hook indicated this command is now approved. You may retry it if you would like.

这表示某些拒绝并不是最终死路,hook 可能允许模型重新尝试。

第七步,如果权限允许

如果权限允许,源码还会检查:

ts 复制代码
logEvent('tengu_tool_use_can_use_tool_allowed', ...)

if (permissionDecision.updatedInput !== undefined) {
  processedInput = permissionDecision.updatedInput
}

这说明权限系统不只是做 allow / deny,也可能返回修改后的工具输入。

所以这一段可以这样记:

text 复制代码
权限阶段不是简单 if allowed。

它会综合 hook、规则、用户决策、auto classifier,
统一得到 permissionDecision;
如果拒绝,就转成 tool_result;
如果允许,还可能带着更新后的 input 继续执行。

checkPermissionsAndCallTool:真正调用 tool.call 并回填 tool_result

这一段发生在权限决策允许之后,是工具执行模块的核心路径。

前面已经完成:

text 复制代码
inputSchema 校验
-> validateInput 语义校验
-> PreToolUse hooks
-> permissionDecision.behavior === "allow"

现在 runtime 开始真正执行工具:

text 复制代码
准备 telemetry / tracing
-> 确定 callInput
-> 调用 tool.call()
-> 得到 ToolResult
-> mapToolResultToToolResultBlockParam()
-> createUserMessage()
-> 放入 resultingMessages
准备 telemetry / tracing 参数
ts 复制代码
const telemetryToolInput = extractToolInputForTelemetry(processedInput)
let toolParameters: Record<string, unknown> = {}

这些主要是观测用途,不是 agent loop 主线。源码很谨慎:工具参数可能包含敏感内容,比如 bash 命令、MCP server 名,所以详细日志受 isToolDetailsLoggingEnabled() 控制。

然后结束"等待用户权限"的 span,开始"工具执行"的 span

ts 复制代码
endToolBlockedOnUserSpan(...)
startToolExecutionSpan()
const startTime = Date.now()
startSessionActivity('tool_exec')

这说明工具执行被拆成几个阶段观测:

text 复制代码
blocked on user / permission
-> execution
-> result
确定真正传给 tool.call 的输入

源码继续区分两个输入:

ts 复制代码
if (...) {
  callInput = {
    ...processedInput,
    file_path: (callInput as Record<string, unknown>).file_path,
  }
} else if (processedInput !== backfilledClone) {
  callInput = processedInput
}

如果只是 backfillObservableInput() 造成了路径展开,源码会尽量恢复模型原始的 file_path,避免工具结果和 transcript 因路径变化而不稳定。

但如果 hook 或 permission 明确返回了新的输入,callInput 会跟着更新。

可以这样记:

text 复制代码
观察用输入可以补字段,
真正执行用输入要尽量保持模型原始语义;
除非 hook / permission 明确更新了 input。
tool.call:工具真正执行点

核心调用是:

ts 复制代码
const result = await tool.call(
  callInput,
  {
    ...toolUseContext,
    toolUseId: toolUseID,
    userModified: permissionDecision.userModified ?? false,
  },
  canUseTool,
  assistantMessage,
  progress => {
    onToolProgress({
      toolUseID: progress.toolUseID,
      data: progress.data,
    })
  },
)

tool.call() 接收:

text 复制代码
callInput:工具输入
toolUseContext:工具执行上下文
canUseTool:权限检查函数
assistantMessage:发起 tool_use 的 assistant message
progress callback:工具执行过程中的进度回调

它返回:

ts 复制代码
ToolResult<Output>

也就是工具内部结果容器。

mapToolResultToToolResultBlockParam:内部结果转 API block

工具执行成功后,源码调用:

ts 复制代码
const mappedToolResultBlock = tool.mapToolResultToToolResultBlockParam(
  result.data,
  toolUseID,
)

这一步把工具内部输出转换成 Anthropic API message 需要的 tool_result block。

转换关系是:

text 复制代码
ToolResult.data
-> ToolResultBlockParam

这正好对应 Tool 接口里的核心方法:

ts 复制代码
mapToolResultToToolResultBlockParam(
  content: Output,
  toolUseID: string,
): ToolResultBlockParam
addToolResult:把 tool_result block 包成 user message

源码内部定义了 addToolResult()

ts 复制代码
async function addToolResult(
  toolUseResult: unknown,
  preMappedBlock?: ToolResultBlockParam,
) {
  const toolResultBlock = preMappedBlock
    ? await processPreMappedToolResultBlock(...)
    : await processToolResultBlock(tool, toolUseResult, toolUseID)

  const contentBlocks: ContentBlockParam[] = [toolResultBlock]

  resultingMessages.push({
    message: createUserMessage({
      content: contentBlocks,
      toolUseResult,
      sourceToolAssistantUUID: assistantMessage.uuid,
    }),
    contextModifier: ...
  })
}

它完成最终回填:

text 复制代码
ToolResultBlockParam
-> createUserMessage({ content: [tool_result] })
-> resultingMessages.push(...)

这就是工具结果重新进入 messages 的地方。

普通工具和 MCP 工具的差异

源码里普通内置工具会先添加结果:

ts 复制代码
if (!isMcpTool(tool)) {
  await addToolResult(toolOutput, mappedToolResultBlock)
}

MCP 工具暂时不立刻添加,因为后面的 PostToolUse hook 可能修改 MCP tool output。

所以可以这样记:

text 复制代码
普通工具:
tool.call -> map result -> addToolResult

MCP 工具:
tool.call -> 等 PostToolUse hooks 可能修改 output -> addToolResult

这一段是 Phase 3 的关键闭环:

text 复制代码
assistant/tool_use
-> runtime 执行 tool.call()
-> ToolResult.data
-> tool_result block
-> user message
-> 下一轮模型调用

checkPermissionsAndCallTool:成功收尾、失败回填与清理

这一段是 checkPermissionsAndCallTool() 的收尾部分。

它负责处理:

text 复制代码
工具成功后的额外消息
-> hook 要求阻止后续继续
-> 工具异常失败
-> PostToolUseFailure hooks
-> finally 清理执行状态
工具成功后追加 newMessages

如果工具返回了 newMessages

ts 复制代码
if (result.newMessages && result.newMessages.length > 0) {
  for (const message of result.newMessages) {
    resultingMessages.push({ message })
  }
}

这对应 ToolResult<T> 里的字段:

ts 复制代码
newMessages?: (...)

说明工具除了主要输出 data,还可以额外返回一些消息,让 runtime 一并加入结果消息列表。

shouldPreventContinuation:工具成功后阻止继续
ts 复制代码
if (shouldPreventContinuation) {
  resultingMessages.push({
    message: createAttachmentMessage({
      type: 'hook_stopped_continuation',
      message: stopReason || 'Execution stopped by hook',
      hookName: `PreToolUse:${tool.name}`,
      toolUseID: toolUseID,
      hookEvent: 'PreToolUse',
    }),
  })
}

如果 PreToolUse hook 设置了 shouldPreventContinuation

这里不是阻止工具执行,因为工具已经执行成功了。

它表示:

text 复制代码
工具可以执行,
但执行完以后不要继续推进 agent loop。

所以源码追加一条 attachment message,记录 hook 停止继续的原因。

成功路径最终返回 resultingMessages

源码把暂存的 hook results 追加回去:

ts 复制代码
for (const hookResult of hookResults) {
  resultingMessages.push(hookResult)
}
return resultingMessages

到这里,成功路径完成。

工具执行异常会转成错误型 tool_result
ts 复制代码
catch (error) {
  const durationMs = Date.now() - startTime
  addToToolDuration(durationMs)

  endToolExecutionSpan({
    success: false,
    error: errorMessage(error),
  })
  endToolSpan()
  ...
}

如果 tool.call() 或后续处理抛出异常,会进入 catch

源码先记录失败状态:

ts 复制代码
endToolExecutionSpan({
  success: false,
  error: errorMessage(error),
})
endToolSpan()

然后把异常格式化:

ts 复制代码
const content = formatError(error)

最后返回错误型 tool_result

ts 复制代码
return [
  {
    message: createUserMessage({
      content: [
        {
          type: 'tool_result',
          content,
          is_error: true,
          tool_use_id: toolUseID,
        },
      ],
      toolUseResult: `Error: ${content}`,
      sourceToolAssistantUUID: assistantMessage.uuid,
    }),
  },
  ...hookMessages,
]

这说明真正执行工具时抛出的异常,也不会直接让 agent loop 崩掉,而是会作为 user/tool_result 回填给模型。

MCP 授权错误会更新 MCP client 状态
ts 复制代码
if (error instanceof McpAuthError) {
  toolUseContext.setAppState(prevState => {
    ...
    updatedClients[existingClientIndex] = {
      name: serverName,
      type: 'needs-auth' as const,
      config: existingClient.config,
    }
    ...
  })
}

如果错误是 McpAuthError,源码会把对应 MCP client 状态改成:

ts 复制代码
type: 'needs-auth'

这样 UI 或 /mcp 状态展示可以提示用户重新授权。

PostToolUseFailure hooks:失败后的 hook 机会

源码位置:src/services/tools/toolExecution.ts:1696-1713

工具失败后,源码还会运行:

ts 复制代码
runPostToolUseFailureHooks(...)

这给 hook 系统一次处理失败的机会,可以追加 attachment message 或 progress message。

finally:清理执行状态

无论成功还是失败,最后都会执行:

ts 复制代码
finally {
  stopSessionActivity('tool_exec')
  if (decisionInfo) {
    toolUseContext.toolDecisions?.delete(toolUseID)
  }
}

这表示工具执行活动结束,并清理这次工具调用对应的权限决策缓存。

可以这样总结这一段:

text 复制代码
成功:
tool.call -> tool_result -> newMessages -> hook results -> return

失败:
error -> formatError -> error tool_result -> failure hooks -> return

最终:
stopSessionActivity -> cleanup decisionInfo

到这里,checkPermissionsAndCallTool() 把一次工具执行的成功和失败路径都收束成 MessageUpdateLazy[]

src/services/tools/toolOrchestration.ts:多工具编排模块

runTools:编排一批 tool_use 的执行

前面 runToolUse() 处理的是单个 tool_use

runTools() 处理的是模型一轮输出的多个 tool_use

ts 复制代码
export async function* runTools(
  toolUseMessages: ToolUseBlock[],
  assistantMessages: AssistantMessage[],
  canUseTool: CanUseToolFn,
  toolUseContext: ToolUseContext,
): AsyncGenerator<MessageUpdate, void>

它也是异步生成器,说明多个工具执行过程中可以持续向上层产出 message update。

主流程是:

text 复制代码
toolUseMessages
-> partitionToolCalls()
-> concurrency-safe batch 并发执行
-> non-concurrency-safe batch 串行执行
-> 每个工具内部交给 runToolUse()
-> 持续 yield MessageUpdate
currentContext:工具执行过程中的上下文状态

源码先保存当前工具上下文:

ts 复制代码
let currentContext = toolUseContext

工具执行过程中可能返回 contextModifier,修改后续工具看到的 ToolUseContext

所以 runTools() 不只是执行工具,也负责维护工具执行之间的上下文传递。

并发安全工具:并发执行,延迟应用 contextModifier

如果当前 batch 是 concurrency-safe:

ts 复制代码
for await (const update of runToolsConcurrently(...)) {
  if (update.contextModifier) {
    const { toolUseID, modifyContext } = update.contextModifier
    ...
    queuedContextModifiers[toolUseID].push(modifyContext)
  }

  yield {
    message: update.message,
    newContext: currentContext,
  }
}

并发执行时,contextModifier 不会立刻修改 currentContext,而是先缓存到 queuedContextModifiers

等这个并发 batch 结束后,再按原始 blocks 顺序应用:

ts 复制代码
for (const block of blocks) {
  const modifiers = queuedContextModifiers[block.id]
  if (!modifiers) continue

  for (const modifier of modifiers) {
    currentContext = modifier(currentContext)
  }
}

这样做是为了避免并发工具同时修改共享上下文导致顺序不稳定。

可以这样记:

text 复制代码
并发工具:
结果可以并发产出,
但上下文修改要排队,
最后按 tool_use 原顺序应用。
非并发安全工具:串行执行,实时更新 context

如果当前 batch 不是 concurrency-safe:

ts 复制代码
for await (const update of runToolsSerially(...)) {
  if (update.newContext) {
    currentContext = update.newContext
  }

  yield {
    message: update.message,
    newContext: currentContext,
  }
}

串行工具一次只执行一个,所以可以边执行边更新 currentContext

runTools 和 runToolUse 的关系

可以这样理解:

text 复制代码
runTools()
= 管一批 tool_use 的调度和上下文顺序

runToolUse()
= 管单个 tool_use 的查找、校验、权限、执行、结果回填

整体链路是:

text 复制代码
assistant message
-> 提取多个 tool_use blocks
-> runTools(toolUseMessages)
-> runToolUse(single toolUse)
-> checkPermissionsAndCallTool()
-> tool.call()
-> user/tool_result messages

这一段说明 Claude Code 的工具执行不是简单 for 循环。它会根据工具是否并发安全,把 tool_use 分批执行,同时保证共享上下文修改顺序稳定。

partitionToolCalls:把 tool_use 分成并发批和串行批

partitionToolCalls() 负责把一轮模型输出的多个 tool_use 分批。

它对每个 toolUse 做三步判断:

text 复制代码
findToolByName(toolUse.name)
-> tool.inputSchema.safeParse(toolUse.input)
-> tool.isConcurrencySafe(parsedInput.data)

源码:

ts 复制代码
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 {
        return false
      }
    })()
  : false

如果输入 schema 解析失败,或者 isConcurrencySafe() 抛错,源码都会保守地认为这个工具不并发安全。

可以这样记:

text 复制代码
不确定能不能并发时,就不要并发。

分批策略是:

text 复制代码
连续的 concurrency-safe 工具合并成一批
非 concurrency-safe 工具单独成批

例如:

text 复制代码
Read, Grep, Glob, Bash(write), Read

可能分成:

text 复制代码
[Read, Grep, Glob]  并发批
[Bash(write)]       串行批
[Read]              并发批

runToolsSerially:串行执行工具

runToolsSerially() 一个一个执行工具。

主流程:

text 复制代码
for each toolUse
-> 标记 toolUse.id 为 in-progress
-> runToolUse(toolUse)
-> 如果 update 带 contextModifier,立即应用到 currentContext
-> yield message + newContext
-> 标记 toolUse.id complete

源码里串行执行时可以立刻应用 context 修改:

ts 复制代码
if (update.contextModifier) {
  currentContext = update.contextModifier.modifyContext(currentContext)
}

因为串行模式下一次只跑一个工具,不会有多个工具同时修改共享 context 的问题。

runToolsConcurrently:并发执行工具

runToolsConcurrently() 使用 all() 并发运行多个 runToolUse()

ts 复制代码
yield* all(
  toolUseMessages.map(async function* (toolUse) {
    toolUseContext.setInProgressToolUseIDs(prev =>
      new Set(prev).add(toolUse.id),
    )

    yield* runToolUse(...)

    markToolUseAsComplete(toolUseContext, toolUse.id)
  }),
  getMaxToolUseConcurrency(),
)

最大并发数来自:

ts 复制代码
getMaxToolUseConcurrency()

默认是 10,也可以通过环境变量配置:

text 复制代码
CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY

并发执行时,runToolsConcurrently() 不直接处理 contextModifier

原因是:

text 复制代码
并发工具同时修改共享 context 会导致顺序不稳定。

所以 contextModifier 会交给上一层 runTools() 收集,等整个并发 batch 结束后,再按原始 tool_use 顺序统一应用。

markToolUseAsComplete:更新运行中工具集合

工具执行结束后,源码会从 in-progress 集合中删除当前工具 id:

ts 复制代码
function markToolUseAsComplete(
  toolUseContext: ToolUseContext,
  toolUseID: string,
) {
  toolUseContext.setInProgressToolUseIDs(prev => {
    const next = new Set(prev)
    next.delete(toolUseID)
    return next
  })
}

这让 UI 或状态层知道该工具已经不再运行。

整体可以这样记:

text 复制代码
runTools()
-> partitionToolCalls()
-> runToolsSerially() / runToolsConcurrently()
-> 每个工具仍然交给 runToolUse()
-> 最终产出 message updates + newContext

src/query.ts:工具执行回接 agent loop

queryLoop:收集 tool_use、执行工具、回填 tool_result

源码位置:

  • src/query.ts:557-567
  • src/query.ts:837-855
  • src/query.ts:1392-1420
  • src/query.ts:1727-1731

这一段把 Phase 3 的工具系统接回 Phase 1 的 agent loop。

整体链路是:

text 复制代码
模型流式输出 assistant message
-> query loop 收集 assistantMessages
-> 从 assistant message 中提取 tool_use blocks
-> runTools(toolUseBlocks, ...)
-> 工具执行产生 user/tool_result messages
-> toolResults 收集这些 user messages
-> state.messages = messagesForQuery + assistantMessages + toolResults
-> 下一轮模型调用
每轮 query loop 的工具相关容器

源码先准备几个容器:

ts 复制代码
const assistantMessages: AssistantMessage[] = []
const toolResults: (UserMessage | AttachmentMessage)[] = []
const toolUseBlocks: ToolUseBlock[] = []
let needsFollowUp = false

它们的职责是:

text 复制代码
assistantMessages:本轮模型输出的 assistant message
toolUseBlocks:assistant message 里提取出来的 tool_use blocks
toolResults:工具执行后生成的 user/tool_result messages
needsFollowUp:是否需要下一轮模型调用
从 assistant message 中提取 tool_use

当 streaming 中出现 assistant message:

ts 复制代码
assistantMessages.push(message)

const msgToolUseBlocks = message.message.content.filter(
  content => content.type === 'tool_use',
) as ToolUseBlock[]

if (msgToolUseBlocks.length > 0) {
  toolUseBlocks.push(...msgToolUseBlocks)
  needsFollowUp = true
}

这说明模型不会执行工具。

模型只会输出:

text 复制代码
assistant message
  content:
    - tool_use

本地 runtime 负责把这些 tool_use blocks 提取出来。

只要发现 tool_use,就说明当前轮还没结束,需要执行工具并进入后续模型调用。

runTools:工具系统接入 query loop 的入口

工具执行阶段:

ts 复制代码
const toolUpdates = streamingToolExecutor
  ? streamingToolExecutor.getRemainingResults()
  : runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)

非 streaming 路径下,query loop 会调用:

ts 复制代码
runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)

这会进入 Phase 3 读过的工具执行链:

text 复制代码
runTools()
-> partitionToolCalls()
-> runToolsSerially() / runToolsConcurrently()
-> runToolUse()
-> checkPermissionsAndCallTool()
-> tool.call()
-> tool_result message
收集工具执行结果

query loop 消费工具执行更新:

ts 复制代码
for await (const update of toolUpdates) {
  if (update.message) {
    yield update.message

    toolResults.push(
      ...normalizeMessagesForAPI(
        [update.message],
        toolUseContext.options.tools,
      ).filter(_ => _.type === 'user'),
    )
  }

  if (update.newContext) {
    updatedToolUseContext = {
      ...update.newContext,
      queryTracking,
    }
  }
}

工具执行产生的 message 会先 yield 出去,用于 UI / transcript。

然后源码会把它 normalize 成 API 可用消息,并只保留 user message:

ts 复制代码
.filter(_ => _.type === 'user')

原因是:工具结果回填给模型时,是 user message 里的 tool_result block。

也就是:

text 复制代码
assistant/tool_use
-> 本地执行工具
-> user/tool_result
重建 state,进入下一轮

工具执行结束后,query loop 会重建下一轮状态:

ts 复制代码
const next: State = {
  messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
  toolUseContext: toolUseContextWithQueryTracking,
  ...
}

这就是 agent loop 的闭环:

text 复制代码
上一轮 messages
+ 本轮 assistant/tool_use
+ 本轮 user/tool_result
= 下一轮 messages

下一轮模型调用时,模型能看到自己刚才请求的工具以及本地 runtime 返回的结果,然后继续推理或给最终回答。

可以这样总结 Phase 3 的回接点:

text 复制代码
Tool 抽象定义工具是什么
tools.ts 决定当前有哪些工具
toolExecution.ts 执行单个工具
toolOrchestration.ts 编排多个工具
query.ts 把工具执行结果塞回 agent loop

Phase 3 总结:Tool 抽象与工具执行闭环

Phase 3 解决的核心问题是:

text 复制代码
模型说"我要用工具"
Claude Code runtime 如何把它变成本地动作,
再把结果变回模型能理解的消息?

完整答案是:

text 复制代码
assistant/tool_use
-> 本地 runtime 找工具
-> 校验输入
-> 权限检查
-> 执行工具
-> 生成 user/tool_result
-> 回填 messages
-> 下一轮模型调用

第一层:Tool 接口定义

源码位置:src/Tool.ts

Tool 定义了一个工具对象必须具备的能力:

text 复制代码
name
inputSchema
isReadOnly
isConcurrencySafe
checkPermissions
call
mapToolResultToToolResultBlockParam

可以这样记:

text 复制代码
工具不是普通函数。
工具 = schema + 权限 + 执行 + 结果映射 + 上下文/展示能力。

其中最关键的是:

text 复制代码
inputSchema:告诉模型和 runtime 这个工具接收什么参数
checkPermissions:判断这次调用是否允许执行
call:真正执行本地动作
mapToolResultToToolResultBlockParam:把内部结果转成 API tool_result block

第二层:工具池

源码位置:src/tools.ts

工具池决定当前有哪些工具可以被模型看到和调用。

核心函数:

text 复制代码
getAllBaseTools()
-> 内置工具候选全集

getTools(permissionContext)
-> 根据模式、权限、isEnabled 过滤当前可用工具

assembleToolPool(permissionContext, mcpTools)
-> 合并内置工具和 MCP 外部工具

可以这样记:

text 复制代码
源码里实现了很多工具,
但模型最终看到的是当前上下文过滤后的工具池。

第三层:单个工具执行

源码位置:src/services/tools/toolExecution.ts

单个工具执行入口是:

text 复制代码
runToolUse()

它处理一条模型发出的 tool_use

text 复制代码
tool_use.name
-> findToolByName()
-> 找不到:生成 error tool_result
-> 找到:进入 checkPermissionsAndCallTool()

checkPermissionsAndCallTool() 是真正执行工具的主流程:

text 复制代码
inputSchema.safeParse()
-> tool.validateInput()
-> PreToolUse hooks
-> resolveHookPermissionDecision()
-> permissionDecision.behavior === allow?
-> tool.call()
-> mapToolResultToToolResultBlockParam()
-> createUserMessage({ content: [tool_result] })

关键点:

text 复制代码
工具执行成功、失败、权限拒绝、取消、异常,
最后都会被规范化成 message update。

第四层:多工具编排

源码位置:src/services/tools/toolOrchestration.ts

多工具执行入口是:

text 复制代码
runTools()

它处理一批 tool_use

text 复制代码
toolUseBlocks
-> partitionToolCalls()
-> concurrency-safe batch 并发执行
-> non-concurrency-safe batch 串行执行
-> 每个 toolUse 交给 runToolUse()

并发安全的工具可以一起跑,但共享上下文修改要保持稳定顺序。

可以这样记:

text 复制代码
结果可以并发返回,
但 context 修改要有顺序。

第五层:回接 agent loop

源码位置:src/query.ts

query loop 把工具系统接回主循环:

text 复制代码
收集 assistantMessages
-> 从 assistant message 中提取 tool_use blocks
-> runTools(toolUseBlocks, ...)
-> 收集 user/tool_result messages 到 toolResults
-> state.messages = messagesForQuery + assistantMessages + toolResults
-> 下一轮模型调用

这就是 agent loop 的最小工具闭环:

text 复制代码
assistant/tool_use
-> local tool execution
-> user/tool_result
-> next model call

Phase 3 完整调用链

text 复制代码
assistant message
  content: tool_use(name, input, id)
        |
        v
query.ts 提取 toolUseBlocks
        |
        v
runTools()
        |
        v
partitionToolCalls()
        |
        v
runToolsSerially() / runToolsConcurrently()
        |
        v
runToolUse()
        |
        v
findToolByName()
        |
        v
checkPermissionsAndCallTool()
        |
        v
inputSchema.safeParse()
        |
        v
validateInput()
        |
        v
PreToolUse hooks
        |
        v
resolveHookPermissionDecision()
        |
        v
tool.call()
        |
        v
ToolResult.data
        |
        v
mapToolResultToToolResultBlockParam()
        |
        v
createUserMessage({ content: [tool_result] })
        |
        v
toolResults
        |
        v
next state.messages
        |
        v
下一轮模型调用

你可以这样记

Claude Code 的工具系统不是"模型调用函数"。

更准确地说:

text 复制代码
模型只提出结构化请求:tool_use
runtime 负责查找工具、校验输入、检查权限、执行本地动作
runtime 把结果包装成 tool_result
模型在下一轮根据 tool_result 继续推理

这就是 AI coding agent 能安全使用本地能力的关键。

相关推荐
yzx9910133 小时前
人工智能写作开发:从自动化内容到真正的创造力
人工智能·自动化·ai写作
Rauser Mack3 小时前
编程零基础?一下午用AI做了两个小游戏(附prompt)
人工智能
Bode_20023 小时前
AIoT/大模型驱动的敏捷研发蓝图
人工智能·制造
C137的本贾尼3 小时前
Spring AI Alibaba 开箱:国产百炼大模型初体验
java·人工智能·spring
有为少年3 小时前
Welford算法 | 从单一到批次
大数据·人工智能·深度学习·神经网络·算法·机器学习
godspeed_lucip3 小时前
LLM和Agent——专题3: Agentic Workflow 入门(1)
大数据·数据库·人工智能
打小就很皮...3 小时前
基于 Python + LangChain + React 的 AI 流式对话与历史存储实战
人工智能·langchain·flask·react·sse
小沈跨境3 小时前
Temu 运营进阶之路 工具选型与凌风体系分析
大数据·人工智能·产品运营·跨境电商·temu