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
这说明工具系统有两层抽象
- 单个工具:定义 schema、权限、执行、结果回填。
- 工具池:决定当前有哪些工具可以被模型看到和调用。
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 工具合并成一个工具池的入口。
它做三件事:
-
获取内置工具:
tsconst builtInTools = getTools(permissionContext)这里使用的是
getTools(),所以内置工具已经经过运行模式、权限规则、isEnabled()过滤。 -
过滤 MCP 工具:
tsconst allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)MCP 工具也必须遵守 deny rule。外部工具不是接入后就一定能被模型看到。
-
排序并去重:
tsreturn 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-567src/query.ts:837-855src/query.ts:1392-1420src/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 能安全使用本地能力的关键。