目录

  1. 总览:工具系统的组成与调度模型
  2. 三层执行流水线:一次工具调用的完整旅程
  3. tool.call 与 ToolUseContext:工具与环境的接口
  4. 中止机制:abortController 父子链与监听实现
  5. 扩展点:钩子(Hooks)系统
  6. 失败处理:子 Agent 的错误捕获与内容处置
  7. 决策机制:模型如何选择工具、每轮看到什么
  8. 总结:设计哲学与关键取舍

第 1 章:总览:工具系统的组成与调度模型

1.1 工具分类:八类内置工具

src/tools/ 目录与 src/constants/tools.ts 的引入看,内置工具按用途分为八类:

类别 工具
文件 Read(读)、Write(写)、Edit(改)、NotebookEdit(改笔记本)
搜索 Glob(按文件名模式找)、Grep(按内容搜)
命令行 Bash、PowerShell
子 Agent / 任务 Agent(派子 agent)、SendMessage(给子 agent 发消息续跑)、TaskCreate/Get/List/Update/Stop/Output(任务清单)、TodoWrite(待办)
计划 EnterPlanMode、ExitPlanMode
网页 WebFetch(抓网页)、WebSearch(搜索)
MCP(外部工具协议) MCPTool、ListMcpResources、ReadMcpResource、McpAuth
其他 REPL、Skill、ToolSearch、AskUserQuestion、ScheduleCron(3 个)、EnterWorktree/ExitWorktree、Workflow、LSP、Sleep 等

模型实际看到的工具列表是动态的toolUseContext.options.tools 决定本轮可见的工具。MCP 工具、子 agent 专属工具、按需加载的 ToolSearch 工具都会增减这个列表;子 agent 的工具表还会被收窄(例如计划 agent 被禁掉 Edit/Write,见 7.2 节)。

1.2 每个工具的共性:Tool 接口与标志位体系

所有工具都实现 Tool 接口(Tool.ts:362),核心是 call()(真正干活)+ 一组标志位(框架据此调度)。这组标志位是整个调度系统的"元数据":

标志位 作用
isConcurrencySafe(input) 该调用能否与其他调用并发跑(只读工具 = true,写/命令 = false)
isReadOnly(input) 是否只读(影响权限自动放行)
isDestructive(input) 是否破坏性(删除/覆盖)
shouldDefer 是否延迟加载(首屏不给 schema,要 ToolSearch 才能调)
alwaysLoad 永不延迟(MCP 工具可声明)
maxResultSizeChars 结果超此大小就落盘,只给模型预览 + 路径
validateInput 工具自己的值校验(在权限和 call 之前)
inputSchema 参数 schema(zod),框架先校验类型

框架对每个工具的统一流程是:

findToolByName → inputSchema.safeParse → validateInput → PreToolUse 钩子 → 权限校验 → call() → 结果映射 → PostToolUse 钩子

这套标志位决定了调度层怎么对待一个工具:并发安全与否决定它能否与兄弟工具并行;只读与否决定权限是否自动放行;shouldDefer 决定它的 schema 是否在首屏就发给模型。工具本身只需实现 call() 与这些声明,其余横切关注点全部由框架统一处理——这是整套设计的核心分层思想。


第 2 章:三层执行流水线:一次工具调用的完整旅程

整个调用分为三层:query loop 收集工具调用 → 流式执行器排队并发 → 单个工具执行(校验 + 权限 + 真正调用 + 钩子)。

2.1 第 1 层:query loop 边收边发(query.ts:654-863)

模型流式输出时,每来一条消息都进入这个循环:

for await (const message of deps.callModel({ messages, systemPrompt, tools, ... })) {
  yield yieldMessage                              // 推给界面显示
  if (message.type === 'assistant') {
    assistantMessages.push(message)
    // 抽出工具调用块
    const msgToolUseBlocks = message.message.content.filter(c => c.type === 'tool_use')
    if (msgToolUseBlocks.length > 0) {
      toolUseBlocks.push(...msgToolUseBlocks)
      needsFollowUp = true                       // 标记:模型调了工具,这轮还没完
    }
    // 边收边丢给执行器
    if (streamingToolExecutor && !aborted) {
      for (const toolBlock of msgToolUseBlocks) {
        streamingToolExecutor.addTool(toolBlock, message)   // 见第 2 层
      }
    }
    // 顺便把已完成的工具结果捞出来推给界面
    for (const result of streamingToolExecutor.getCompletedResults()) {
      yield result.message
      toolResults.push(...)
    }
  }
}

关键设计: 模型还在流式输出时,工具就已经开始执行了——不等模型说完。needsFollowUp = true 意味着这轮结束后还要再来一轮(让模型看到工具结果后继续)。

2.2 第 2 层:流式执行器排队并发(StreamingToolExecutor.ts)

① 入队 addTool(76-124)

addTool(block, assistantMessage) {
  const toolDefinition = findToolByName(this.toolDefinitions, block.name)  // 按名字找工具定义
  if (!toolDefinition) { /* 造一个 "No such tool" 的错误结果,直接完成 */ }
  const parsedInput = toolDefinition.inputSchema.safeParse(block.input)     // schema 校验输入类型
  const isConcurrencySafe = parsedInput.success
    ? Boolean(toolDefinition.isConcurrencySafe(parsedInput.data))           // 这个输入能不能并发跑
    : false
  this.tools.push({ id, block, status: 'queued', isConcurrencySafe, ... })
  void this.processQueue()                                                   // 触发执行
}

② 排队并发判断 canExecuteTool(129-135)

canExecuteTool(isConcurrencySafe) {
  const executing = this.tools.filter(t => t.status === 'executing')
  return executing.length === 0                                            // 没人在跑
    || (isConcurrencySafe && executing.every(t => t.isConcurrencySafe))   // 或:我并发安全 且 在跑的也都并发安全
}

并发规则: 并发安全的工具(读、搜)可以并行;不安全的(改文件、跑命令)独占,一次一个。不安全的工具轮不到跑就 break,保持入队顺序。

③ 执行 executeTool(265-405)

executeTool(tool) {
  tool.status = 'executing'
  // 造一个子中止控制器(一个工具出错能杀掉兄弟工具)
  const toolAbortController = createChildAbortController(this.siblingAbortController)
  const generator = runToolUse(tool.block, tool.assistantMessage, this.canUseTool,
    { ...this.toolUseContext, abortController: toolAbortController })   // 见第 3 层
  for await (const update of generator) {
    // 如果是错误结果,且是 Bash 工具 → 标记 hasErrored,中止兄弟工具
    if (isErrorResult && tool.block.name === BASH_TOOL_NAME) {
      this.hasErrored = true
      this.siblingAbortController.abort('sibling_error')   // 只有 Bash 出错才连累兄弟
    }
  }
  tool.results = messages; tool.status = 'completed'
  void promise.finally(() => this.processQueue())   // 跑完再看看队列里还有没有能跑的
}

细节: 只有 Bash 出错才取消并行的兄弟工具——因为 Bash 命令常有隐式依赖链(mkdir 失败了后面的就没意义)。Read/WebFetch 这类独立工具失败不连累别人。

2.3 第 3 层:单个工具执行(toolExecution.ts)

runToolUse(337-490)→ checkPermissionsAndCallTool(599-1745)。这是单个工具从"调用块"到"结果"的完整过程,共 13 步:

  1. 找工具(345):findToolByName(tools, toolName)。找不到 → 返回 “No such tool” 错误结果,还支持别名(老名字)兜底。
  2. 中止检查(415):已中止 → 返回取消消息,不真正调用工具。
  3. schema 校验输入类型(615):tool.inputSchema.safeParse(input)。模型给的参数类型不对(该传数组传了字符串)→ 返回 InputValidationError。源码注释很坦白:“surprisingly, the model is not great at generating valid input”。
  4. 工具自己的值校验(683):tool.validateInput?.(parsedInput, context)。每个工具有自己的校验逻辑(比如 Read 校验文件路径合法性),不过 → 错误结果。
  5. (仅 Bash)提前启动分类器(746):startSpeculativeClassifierCheck,提前跑"这条命令该不该自动放行"的判断,与后续步骤并行,省时间。
  6. 回填可观察字段(784-793):文件工具把 file_path 展开成绝对路径,在一个克隆上回填,给钩子/权限看,但不污染传给 call() 的原始输入——否则会改变工具结果里嵌入的路径,破坏记录哈希。
  7. PreToolUse 钩子(800-862):跑用户配置的"工具执行前"钩子,能改输入、阻止继续、直接做权限决定、补充上下文、直接叫停(详见第 5 章)。
  8. 权限校验(921-930):resolveHookPermissionDecision 得出 allow / deny / ask 三选一。这就是"要不要问用户能不能执行"的地方:allow 放行;deny 拒绝并返回 “permission denied” 错误结果(995-1104);ask 弹窗问用户。
  9. 真正调用工具(1207):const result = await tool.call(callInput, { ...toolUseContext, toolUseId, userModified }, canUseTool, assistantMessage, progress => onToolProgress(...))。Read 真去读文件、Bash 真去跑命令、Agent 真去派子 agent。期间可通过 progress 回调报进度(Bash 边跑边输出)。
  10. 结果映射(1292):tool.mapToolResultToToolResultBlockParam(result.data, toolUseID) 把工具结果转成 API 能识别的格式。
  11. PostToolUse 钩子(1483-1531):跑"工具执行后"钩子,能改 MCP 工具的输出、补充消息等。
  12. 组装最终消息(1403-1474):把 tool_result 块 + 用户批准时写的反馈 + 权限给的附加内容(如图片)组装成用户消息返回。这个消息会作为"工具结果"喂给下一轮模型。
  13. 异常处理(1589-1745):工具执行抛错 → 跑 PostToolUseFailure 钩子 → 返回 is_error: true 的工具结果。MCP 鉴权错误会把该 MCP 服务器标记成"需要重新登录"。

2.4 结果回流

第 3 层产出的消息,经第 2 层收集到 tool.results,经 getCompletedResults() 产出,query loop 把它们加进 toolResults 并推给界面。模型流式输出结束后,若 needsFollowUp = true,这些工具结果连同模型消息一起进 messagesturnCount++,回到 query loop 顶部再来一轮——模型看到工具结果后继续回应。


2.5 完整示例:登录按钮 bug 修复的迭代过程

任务:用户说"看看 login.tsx 里 handleSubmit 的问题"。模型第一轮决定先搜内容、再读文件。

第 1 轮迭代:Grep + Read 并行

模型流式输出,产生一条 assistant 消息,含两个工具调用块:Grep(pattern="handleSubmit", glob="**/login.tsx")Read(file_path="src/login.tsx", offset=0, limit=50)

  • 第 1 层needsFollowUp=true,对每个块调 addTool。模型还在说别的话,工具已经入队。
  • 第 2 层:Grep 与 Read 的 isConcurrencySafe 均为 true(只读搜索/读取),canExecuteTool 判定没人执行 → 两个都开始执行。
  • 第 3 层(两个并行):各自走 ① 找工具 → ③ schema 校验 → ④ validateInput → ⑦ PreToolUse 钩子 → ⑧ 权限返回 allow(只读通常自动放行)→ ⑨ GrepTool.call() / ReadTool.call() 真正执行 → ⑩ 映射 → ⑫ 返回 tool_result(命中行号 / 文件内容)。
  • 回流:两个结果先后产出,模型说完话,needsFollowUp=true → 两个 tool_result 进消息,turnCount++,回顶部进入第 2 轮。

第 2 轮迭代:Edit 触发权限弹窗

模型看到 Grep 命中第 42 行、Read 返回的文件内容,判断"handleSubmit 没 await",产生一个工具调用块 Edit(file_path="src/login.tsx", old_string="...", new_string="...")

  • 第 2 层isConcurrencySafe(EditInput) 返回 false(改文件,不并发安全)。canExecuteTool:没人在跑 → 开始执行。
  • 第 3 层:走到 ⑧ 权限校验——Edit 改文件,canUseTool 返回 ask → 弹窗问用户。用户点"允许" → allow → ⑨ EditTool.call() 真去改文件 → 返回成功结果(含 diff)。
  • 回流:tool_result(成功 + diff)进消息,turnCount++,第 3 轮迭代。

第 3 轮迭代:收尾

模型看到 Edit 成功,给最终回复"已经把 handleSubmit 的 await 补上了"。这次没有工具调用块,needsFollowUp=false。流式结束后进收尾:采样后钩子(session memory 尝试更新)、停止钩子(auto memory 抽取),返回 completed


第 3 章:tool.call 与 ToolUseContext:工具与环境的接口

call 是工具真正干活的入口,toolUseContext 是工具接触会话环境的句柄。逐层拆解,最后用 Read 工具走一遍。

3.1 call 是什么

它是工具自己的执行逻辑。在它之前,框架已经做了一串"调用前"的事:schema 校验(inputSchema.safeParse)、值校验(validateInput)、PreToolUse 钩子、权限校验(canUseTool)。call() 跑完后,框架还有"调用后"的事:结果映射(mapToolResultToToolResultBlockParam)、PostToolUse 钩子。call() 夹在中间,只负责"真正干活"。

签名(Tool.ts:379-385):

call(
  args: z.infer<Input>,            // 校验过的输入
  context: ToolUseContext,        // 执行上下文(见 3.3)
  canUseTool: CanUseToolFn,        // 权限校验函数
  parentMessage: AssistantMessage, // 触发本次调用的 assistant 消息
  onProgress?: ToolCallProgress<P>, // 进度回调
): Promise<ToolResult<Output>>

3.2 五个参数逐个讲

参数 说明
args 模型给的参数,经过 schema 校验 + validateInput + 钩子可能改写后的版本(如 Read 的 { file_path, offset, limit, pages })。框架保证传进来的是合法的,工具不用再校验类型。
context ToolUseContext——工具不直接碰全局状态,全通过它拿:会话配置、中止信号、文件读取缓存、应用状态、界面回调……它是一个"会话环境句柄"。
canUseTool 为什么 call 里还要权限函数?因为工具内部可能要再发起需要权限的操作(嵌套权限)。比如 Agent 工具派出的子 agent 里再调 Edit,那次 Edit 的权限要校验;某工具内部要跑一条命令,那条命令也要权限。把 canUseTool 传进去,让工具能做嵌套权限检查。只读工具(如 Read)用不到,参数名写成 _canUseTool? 表示忽略。
parentMessage 触发这次工具调用的 assistant 消息。工具结果要关联到它(工具结果的 sourceToolAssistantUUID),便于追溯"这个结果是谁要的"。
onProgress 长任务边跑边报进度。Bash 跑命令时边输出边调它,UI 显示"正在运行…(Ns)";子 agent 也能通过它报进度。短任务(读个小文件)用不到。

3.3 返回值 ToolResult

call 返回的是结构化结果(Tool.ts:321-336):

type ToolResult<T> = {
  data: T                                    // 结果数据
  newMessages?: (...)[]                      // 额外要塞进对话的消息
  contextModifier?: (context) => ToolUseContext  // 改上下文(仅非并发工具)
  mcpMeta?: {...}                            // MCP 元数据透传
}
  • data:工具结果。Read 返回文件内容,Bash 返回 stdout,Agent 返回子 agent 总结。
  • newMessages:工具想额外发的消息(比如 Read 读到图片要附带说明)。
  • contextModifier:工具改上下文。比如 EnterWorktree 改了工作目录,后续工具得知道。注意只对非并发工具生效——并发工具不支持改上下文(StreamingToolExecutor 注释明说)。
  • mcpMeta:MCP 工具的 structuredContent/_meta 透传给 SDK 消费者。

3.4 ToolUseContext:七组字段

ToolUseContext(Tool.ts:158-300)有几十个字段,是工具能接触的整个会话环境。不直接拿全局变量——避免耦合、方便测试、子 agent 能换一份。按用途分七组:

A. 会话配置 options(只读配置包)

commands, debug, mainLoopModel, tools, verbose, thinkingConfig, mcpClients, mcpResources, isNonInteractiveSession, agentDefinitions, maxBudgetUsd, customSystemPrompt, appendSystemPrompt, querySource, refreshTools

工具靠它知道"在什么环境跑"。例如:isNonInteractiveSession 决定能不能弹窗交互(脚本模式不能);agentDefinitions 是 Agent 工具派子 agent 时的可选类型清单;tools 是当前可用工具表(子 agent 的工具表已被收窄过);mcpClients 让 MCP 工具按名字找到对应的服务器连接。

B. 控制与生命周期

字段 作用
abortController 中止信号。工具要随时检查,用户按 Escape 能停掉
agentId / agentType 是不是子 agent、哪种类型。钩子靠它区分(如 extractMemories 跳过子 agent)
toolUseId 本次调用的 ID。结果、进度都关联到它
queryTracking 调用链追踪(chainId + depth),嵌套调用分析用
messages 当前对话消息。工具要读历史(如 Read 去重要看之前读过没)

C. 状态读写

字段 作用
getAppState() / setAppState 全局应用状态(权限模式、MCP 客户端状态等)
readFileState 文件读取缓存(LRU)。Read 用它去重
toolDecisions 权限决定缓存(这次放行/拒绝的记录)
updateFileHistoryState 文件历史(Edit/Write 要更新)
updateAttributionState 归因状态(改了谁的代码)
contentReplacementState 工具结果体积预算状态

D. 界面与通知(回调集合)

setToolJSX, addNotification, appendSystemMessage, sendOSNotification, setStreamMode, onCompactProgress, setSDKStatus, openMessageSelector, setInProgressToolUseIDs, setHasInterruptibleToolInProgress, setResponseLength, pushApiMetricsEntry, setConversationId——工具向界面/系统发信号。比如 Bash 边跑边 setStreamMode 显示转圈,跑完 sendOSNotification 响铃。

E. 交互

字段 作用
requestPrompt 向用户要交互输入(只交互模式有)
handleElicitation MCP 的 URL 引出(-32042 错误时)

F. 记忆/技能去重集合

nestedMemoryAttachmentTriggers, loadedNestedMemoryPaths, dynamicSkillDirTriggers, discoveredSkillNames——防止重复注入。比如 Read 读到技能目录,会 context.dynamicSkillDirTriggers?.add(dir)

G. 限制与标志

字段 作用
fileReadingLimits / globLimits 各工具的读取上限
userModified 用户是否改过输入
requireCanUseTool 强制走权限(即使钩子自动批准)
preserveToolUseResults 子 agent 是否保留工具结果
renderedSystemPrompt 分叉子 agent 共享父提示缓存用
localDenialTracking 异步子 agent 的本地拒绝计数

3.5 案例:FileReadTool.call 走一遍

模型调 Read(file_path="src/query.ts", offset=1, limit=50)。框架做完校验和权限(只读自动放行)后,进入 call(FileReadTool.ts:496):

async call({ file_path, offset = 1, limit, pages }, context, _canUseTool, parentMessage) {
  const { readFileState, fileReadingLimits } = context          // ① 从 context 拿去重缓存 + 限制
  const maxSizeBytes = fileReadingLimits?.maxSizeBytes ?? defaults.maxSizeBytes
  const maxTokens = fileReadingLimits?.maxTokens ?? defaults.maxTokens
  const fullFilePath = expandPath(file_path)                     // ② 路径规范化

  // ③ 去重:之前读过同范围且文件没改 → 返回占位结果
  const existingState = readFileState.get(fullFilePath)
  if (existingState && !existingState.isPartialView && existingState.offset !== undefined) {
    if (existingState.offset === offset && existingState.limit === limit) {
      const mtimeMs = await getFileModificationTimeAsync(fullFilePath)
      if (mtimeMs === existingState.timestamp) {
        return { data: { type: 'file_unchanged', file: { filePath: file_path } } }
      }
    }
  }

  // ④ 发现技能目录(读到某路径触发技能加载)
  context.dynamicSkillDirTriggers?.add(dir)

  // ⑤ 真正读文件...
  return { data: { content, ... } }
}
context 字段 这里怎么用 意义
readFileState readFileState.get(fullFilePath) 查之前读过没 去重:避免重复读同一文件浪费缓存 token(注释说同文件碰撞占 Read 调用 18%)
fileReadingLimits 读 maxSizeBytes / maxTokens 上限 控制读取量,防爆
dynamicSkillDirTriggers .add(dir) 记录发现的技能目录 触发技能加载,且去重防重复触发

没用到的: abortController——读文件太快,没检查,但长操作(Bash、子 agent)要随时查;canUseTool——Read 不需要嵌套权限,参数名写成 _canUseTool? 忽略;agentId/toolUseId/parentMessage——框架层面用,Read 自己不直接用。

返回什么: 返回 ToolResult: { data: { content, ... } }(或去重命中时 { data: { type: 'file_unchanged', ... } })。这个 data 后面被 mapToolResultToToolResultBlockParam 转成 tool_result 块,塞进对话,下一轮模型看到。

3.6 为什么这样设计

call 只收"校验过的输入 + 上下文 + 权限函数 + 触发消息 + 进度回调",干完返回结构化结果。框架包揽校验、钩子、权限、并发、结果映射——工具只管自己的核心逻辑。context 这么大一坨,是因为工具要接触会话方方面面,但通过句柄而非全局变量(便于测试、子 agent 能换一份隔离的上下文)。这种分层让工具实现简单(只关心 args + context),而框架能统一处理横切关注点(权限、并发、钩子、去重、进度)。


第 4 章:中止机制:abortController 父子链与监听实现

钩子是"在某个时机插入一段逻辑",而 abortController 是另一回事——它是中止信号,一套标准的取消机制(Web/Node 内置的 AbortController/AbortSignal):广播一个"停下"的信号,谁在跑谁自己听。

4.1 它是什么

AbortController 有两个核心:controller.abort(reason) 触发中止并带一个原因;controller.signal.aborted 检查是否已中止、signal.reason 看中止原因。

在这个系统里,toolUseContext.abortController 是顶层中止信号,一路传给两处:

  1. 模型 API 请求(query.ts:664):signal: toolUseContext.abortController.signal 传给 callModel,中止时取消在飞的网络请求。
  2. 每个工具的执行:工具内部检查 signal.aborted,或把 signal 传给底层操作(子进程、网络请求)来停止。

4.2 父子链:createChildAbortController

中止信号不是扁平一个,而是父子链(abortController.ts):

顶层 abortController (toolUseContext, 整个会话)
  └─ siblingAbortController (流式执行器内:一个 Bash 出错连累兄弟)
       └─ toolAbortController (每个工具一个:权限拒绝等)
            └─ 工具的底层操作 (子进程/网络请求监听它)

核心传播规则:单向,父 → 子。 父中止,所有子跟着中止;子中止,一般不影响父。这靠 createChildAbortController 实现:它给父的 signal 挂个监听器,父一中止就调 child.abort(parent.signal.reason)

为什么单向?这样能做局部中止:一个工具出错只杀它的兄弟工具,不杀整个会话;而用户按 Escape(中止顶层)才杀整个会话。还有个内存安全细节:用弱引用挂监听器,父不留 abandoned 的子,能被垃圾回收(源码注释明说)。

4.3 六个触发点

触发场景 触发方式 代码位置
① 用户按 Escape abort(‘user-cancel’) REPL(捕获按键)
② 工具运行时用户发新消息(优先级 ‘now’) abortControllerRef.current?.abort(‘interrupt’) REPL.tsx:4102
③ Bash 工具出错连累并行兄弟 siblingAbortController.abort(‘sibling_error’) StreamingToolExecutor.ts:362
④ 权限弹窗被拒 中止该工具的 toolAbortController StreamingToolExecutor 注释 296-300
⑤ 流式回退(模型切换兜底) discard() 置标志,工具得 ‘streaming_fallback’ 合成错误 StreamingToolExecutor.ts:69
⑥ QueryEngine 主动停查询 this.abortController.abort() QueryEngine.ts:1159

注意 ③ 的细节:只有 Bash 出错才连累兄弟(tool.block.name === BASH_TOOL_NAME 才设 hasErrored 并中止兄弟)。因为 Bash 命令常有依赖链(mkdir 失败后面没意义),Read/WebFetch 这类独立工具失败不连累别人。

4.4 监听机制的代码实现

AbortSignal 是事件目标(继承自 EventTarget),支持 signal.addEventListener('abort', handler)。"监听中止"就是挂监听器:父一中止,事件触发,挂在父 signal 上的处理函数被调用,处理函数再去中止子——这就是传播的实现。

createChildAbortController 逐行拆解:

export function createChildAbortController(parent, maxListeners?): AbortController {
  const child = createAbortController(maxListeners)          // ① 建子控制器

  if (parent.signal.aborted) {                               // ② 快路径:父已中止
    child.abort(parent.signal.reason)                        //    直接中止子,不用挂监听
    return child
  }

  const weakChild = new WeakRef(child)                       // ③ 弱引用包住子(内存安全)
  const weakParent = new WeakRef(parent)                     //    弱引用包住父
  const handler = propagateAbort.bind(weakParent, weakChild) // ④ 造处理函数(绑好弱引用)

  parent.signal.addEventListener('abort', handler, { once: true })  // ⑤ 核心:在父 signal 上挂监听

  child.signal.addEventListener(                              // ⑥ 自动清理:子中止时移除父上的 handler
    'abort',
    removeAbortHandler.bind(weakParent, new WeakRef(handler)),
    { once: true },
  )
  return child
}
  • 第 ⑤ 行就是监听的建立:从这一刻起,父 signal 上挂了一个 ‘abort’ 监听器,父任何时候调 abort(),这个 handler 就会被调用。
  • 第 ② 行快路径很关键:挂监听前先检查父是不是已经中止了。如果父已经中止(比如用户在挂监听前一瞬间按了 Escape),直接把子也中止掉返回,不挂监听——否则会漏(挂监听时事件已经触发过了,再挂也收不到)。

挂上去的 handler 就是 propagateAbort(30-36)——"父中止 → 子中止"的实现:

function propagateAbort(this: WeakRef<AbortController>, weakChild: WeakRef<AbortController>): void {
  const parent = this.deref()                      // 解引用拿到父
  weakChild.deref()?.abort(parent?.signal.reason)  // 解引用拿子,调子的 abort,带上父的中止原因
}

父触发 ‘abort’ 事件 → handler 被调 → 拿到子控制器 → child.abort(parent.signal.reason) → 子也中止且带上父的原因(比如 ‘user-cancel’ 一路传下来)。注意 weakChild.deref()?.abort(...) 的可选链:如果子已被垃圾回收,deref() 返回 undefined,?. 直接跳过,不会崩。

4.5 为什么用 WeakRef(内存安全)

这是这套监听最精巧的地方。假设不用 WeakRef,直接写:

// 假想的"不安全"写法
const handler = () => { child.abort(parent.signal.reason) }
parent.signal.addEventListener('abort', handler, { once: true })

问题:handler 闭包强引用 child,而 parent.signal 持有 handler(监听器列表),于是父 signal 一直持有 child 的强引用。父可能活得很久(整个会话),期间创建过几百个子(每个工具一个),这些子即使早用完了也无法被垃圾回收,内存泄漏。

用 WeakRef 后:handler 只持有 weakChild(弱引用),不阻止 child 被回收;child 没人强引用了 → 被 GC → 下次父中止时 weakChild.deref() 返回 undefined → ?.abort() 跳过 → 无害。父也用 weakParent 包,双向都不留强引用。源码注释原话:“WeakRef prevents the parent from keeping an abandoned child alive.”

4.6 自动清理 + once + 模块级函数

第 ⑥ 行自动清理:子被中止时(无论从哪来——父传下来的,或权限拒绝单独中止的),在子的 signal 上挂的监听触发,从父 signal 上移除 handler:

function removeAbortHandler(this: WeakRef<AbortController>, weakHandler): void {
  const parent = this.deref()
  const handler = weakHandler.deref()
  if (parent && handler) {
    parent.signal.removeEventListener('abort', handler)   // 从父上摘掉
  }
}

防止死的 handler 在父 signal 上堆积。虽然 { once: true } 保证父中止时 handler 只触发一次就自动摘,但子提前中止了(没轮到父中止)的情况下,handler 还挂在父上,这步清理就必要了。{ once: true } 的另一重意义:AbortSignal 中止后不可逆(只触发一次),once 是双保险。

模块级函数 + bind:propagateAbortremoveAbortHandler 是模块作用域函数(不每次新建),用 .bind(weakParent, weakChild) 把参数绑进去——“avoids per-call closure allocation”,避免每次调 createChildAbortController 都新建一个闭包对象(几百个工具就省几百个闭包)。

4.7 消费侧怎么"听"(两种姿势)

监听建好了(传播侧),消费侧(工具、网络请求)也有两种"听"的方式:

姿势一:主动检查 signal.aborted(轮询)

// toolExecution.ts:415
if (toolUseContext.abortController.signal.aborted) {
  // 已中止,返回取消消息,不真正调用工具
  yield { message: createToolResultStopMessage(...) }
  return
}

工具在关键节点查一下标志位,中止了就停。简单,但只能检查的那一瞬间发现。

姿势二:把 signal 传给底层 API(它们内部监听)

// query.ts:664 - 模型 API 请求
signal: toolUseContext.abortController.signal   // 传给 callModel → 传给 fetch

// Bash 工具内部(示意)
child_process.spawn(cmd, { signal: toolAbortController.signal })  // 子进程监听 signal

fetch、child_process.spawn 等底层 API 原生支持 signal 参数——它们内部自己 addEventListener(‘abort’, …),中止时取消网络请求、杀子进程。工具不用写监听代码,传进去就行。这是最优雅的姿势——真正"中断"在飞的底层操作。

4.8 特殊"冒泡":子 → 父(受控例外)

虽然规则是单向(子不冒泡到父),但有个例外(StreamingToolExecutor.ts:304-318):

toolAbortController.signal.addEventListener('abort', () => {
  if (toolAbortController.signal.reason !== 'sibling_error'
      && !this.toolUseContext.abortController.signal.aborted
      && !this.discarded) {
    this.toolUseContext.abortController.abort(toolAbortController.signal.reason)  // 冒泡到父
  }
})

权限拒绝等场景下,工具的 toolAbortController 被中止后,会冒泡到顶层 abortController,让 query loop 的"工具后中止检查"结束本轮。源码注释说这是为修 #21056 回归:ExitPlanMode 的"清空上下文 + 自动"被拒时,要中止本轮,而不是给模型发一条 REJECT_MESSAGE 让它继续。

条件很严:不是 sibling_error、父没被中止过、不是 discard——才冒泡,避免兄弟错误反复冒泡乱杀。

4.9 中止后发生什么(五步效果)

  1. 取消在飞的模型 API 请求:顶层 signal 传给了 callModel,网络请求被取消。
  2. 工具停止:runToolUse 一进来就检查 signal.aborted(toolExecution.ts:415),已中止直接返回取消消息,不真正调用工具。
  3. 生成合成 tool_result 块:API 要求每个 tool_use 必须有匹配的 tool_result。中止的工具没真正结果,StreamingToolExecutor 给它造一个合成错误结果(createSyntheticErrorMessage),或用 yieldMissingToolResultBlocks 补齐。
  4. 清理:比如 computer use 解锁、释放锁(cleanupComputerUseAfterTurn,query.ts:1033)。
  5. 结束本轮:query loop 检测 signal.aborted → 返回 'aborted_streaming'(query.ts:1051)或 'aborted_tools'(1515)。

4.10 案例:Bash 跑长命令时用户按 Escape

假设模型调了 Bash(command="npm test"),测试要跑 30 秒,用户 5 秒时按 Escape:

用户按 Escape
   │
   ▼ REPL 调 顶层 controller.abort('user-cancel')
   │
   ▼ 顶层 signal 触发 'abort' 事件
   │
   ▼ 挂在顶层 signal 上的 handler 被调(siblingAbortController 建监听时挂的)
   │   propagateAbort: weakChild.deref()?.abort('user-cancel')
   │
   ▼ siblingAbortController.abort('user-cancel') → 它的 signal 触发 'abort'
   │
   ▼ 挂在 siblingAbortController 上的 handler 被调(toolAbortController 建监听时挂的)
   │   propagateAbort: weakChild.deref()?.abort('user-cancel')
   │
   ▼ toolAbortController.abort('user-cancel') → 它的 signal 触发 'abort'
   │
   ▼ 两个消费点同时响应:
   │   ① BashTool.call 把 signal 传给了 child_process.spawn → 子进程被杀(底层 API 内部监听)
   │   ② runToolUse 检查 signal.aborted → 返回取消消息(姿势一)
   │   ③ 自动清理:toolAbortController 的 'abort' 触发 removeAbortHandler,从 sibling 上摘 handler
   │
   ▼ query loop 检测 signal.aborted → 返回 'aborted_streaming',本轮结束

每一层"父→子"的传播,都是靠 addEventListener('abort', propagateAbort) 这个监听实现的。父 abort() → 事件 → handler → 子 abort() → 事件 → 孙 handler → ……整个传播链是一串事件监听器接力触发的。用户看到的效果:Bash 转圈停了,显示 “Interrupted by user”,回到输入状态。


第 5 章:扩展点:钩子(Hooks)系统

钩子和 abortController 完全不同——它是"在特定时机插入用户/系统自定义逻辑"的机制,分两类讲。

5.1 两类钩子

① 用户配置的钩子:用户在 settings.json / CLAUDE.md 里配,在特定事件点跑外部命令(shell)或提示词,通过命令的输出控制行为。主体是 src/utils/hooks.ts(3800+ 行)。

② 进程内钩子:代码内部用 registerXxxHook 注册的回调函数(不跑外部命令)。比如 session memory 就注册了一个"采样后钩子"。主体是 src/utils/hooks/postSamplingHooks.ts 等。

两者机制不同,但都是"在某个时机插入逻辑"。

5.2 钩子事件:27 个(HOOK_EVENTS,coreTypes.ts:25-53)

类别 事件
工具 PreToolUse(执行前)、PostToolUse(后)、PostToolUseFailure(失败后)、PermissionRequest、PermissionDenied
会话 SessionStart、SessionEnd、Setup、InstructionsLoaded、ConfigChange、CwdChanged
对话 UserPromptSubmit(提交前)、Stop(模型停止)、StopFailure、SubagentStart、SubagentStop、Notification
压缩 PreCompact、PostCompact
任务/团队 TaskCreated、TaskCompleted、TeammateIdle
其他 Elicitation、ElicitationResult、WorktreeCreate、WorktreeRemove、FileChanged

最常用的是 PreToolUse 和 PostToolUse。

5.3 如何配置(用户配置的钩子)

在 settings.json 的 hooks 字段配置,几种类型(schemas/hooks.ts):command(跑 shell 命令)、prompt(跑 LLM 提示词)。字段包括:command/prompt、if(条件过滤,如 “Bash(git *)”)、shell(bash/powershell)、timeout、statusMessage、once(跑一次就移除)、async(后台跑不阻塞)、asyncRewake(后台跑,退出码 2 时唤醒模型)。

{
  "hooks": {
    "PreToolUse": [{
      "type": "command",
      "command": "./check-dangerous.sh",
      "if": "Bash(*)"
    }],
    "PostToolUse": [{
      "matcher": "Edit",
      "hooks": [{ "type": "command", "command": "npx prettier --write $CLAUDE_FILE_PATHS" }]
    }],
    "Stop": [{
      "type": "command",
      "command": "notify-send 'Claude 完成了'"
    }]
  }
}

5.4 如何执行(命令钩子)

钩子命令在 hooks.ts:967-983 用 spawn(拉起子进程)执行:

if (shellType === 'powershell') {
  child = spawn(pwshPath, buildPowerShellArgs(finalCommand), { env, cwd, windowsHide: true })
} else {
  const shell = isWindows ? findGitBashPath() : true   // Windows 用 Git Bash
  child = spawn(finalCommand, [], { env, cwd, shell, windowsHide: true })
}

执行流程四步:

  1. 喂输入:把钩子输入(工具名、工具输入、会话信息等)以 JSON 形式通过标准输入喂给命令。
  2. 跑命令:spawn 拉起子进程跑用户的命令(带 timeout、signal 中止)。
  3. 解析输出:命令的标准输出第一行解析成 JSON(钩子输出)。钩子通过这个 JSON 控制行为:{ permissionDecision: "allow"/"deny"/"ask", updatedInput: {...}, additionalContext: "...", ... }
  4. 退出码:退出码 2 = 阻塞错误(阻止);0 = 成功;其他 = 非阻塞。

async / asyncRewake 的钩子后台跑,不阻塞工具执行。

5.5 PreToolUse / PostToolUse 怎么包住工具调用

回顾 toolExecution.ts 的工具执行流程,钩子夹在两头:

schema 校验 → 值校验 → ① PreToolUse 钩子 → ② 权限校验 → ③ tool.call() → ④ PostToolUse 钩子 → 结果映射

① PreToolUse 钩子(toolHooks.ts:435 的 runPreToolUseHooks)在权限校验前跑,能干的七件事:

能力 作用
hookPermissionResult 做权限决定(allow/deny/ask)——能绕过权限弹窗
hookUpdatedInput 改输入(比如规范化参数)
preventContinuation 阻止本轮继续
stopReason / stop 直接叫停
additionalContext 给模型加额外上下文
message 加消息
blockingError 阻塞错误(转成 deny)

④ PostToolUse 钩子(toolHooks.ts:39 的 runPostToolUseHooks)在 tool.call() 后跑,能干的五件事:

能力 作用
blockingError 阻塞错误
preventContinuation 阻止继续
additionalContext 加上下文
updatedMCPToolOutput 改 MCP 工具的输出(后处理)
hook_cancelled 中止时通知

5.6 关键不变量:钩子 allow 不绕过一切

resolveHookPermissionDecision(toolHooks.ts:332)有个重要不变量——钩子 allow 不绕过 settings.json 的 deny/ask 规则:

if (hookPermissionResult?.behavior === 'allow') {
  // 钩子说放行,但还要过规则检查
  const ruleCheck = await checkRuleBasedPermissions(tool, hookInput, toolUseContext)
  if (ruleCheck === null) return { decision: hookPermissionResult, input: hookInput }  // 规则也通过 → 放行
  if (ruleCheck.behavior === 'deny') return { decision: ruleCheck, input: hookInput }  // deny 规则仍拒绝
  // ask 规则 → 仍弹窗
  return { decision: await canUseTool(...), input: hookInput }
}

源码注释原话:“Hook ‘allow’ does NOT bypass settings.json deny/ask rules”。 钩子是"加速放行",但不能越过用户明确写的禁令;而钩子 deny 是直接拒绝(不绕过)。钩子是加速器,不是后门。

5.7 进程内钩子(postSamplingHooks)

对比用户配置钩子(跑外部命令),进程内钩子是代码注册的回调:

// postSamplingHooks.ts
const postSamplingHooks: PostSamplingHook[] = []
export function registerPostSamplingHook(hook) { postSamplingHooks.push(hook) }

export async function executePostSamplingHooks(messages, ...) {
  for (const hook of postSamplingHooks) {
    try { await hook(context) } catch { /* 不因钩子错失败 */ }
  }
}

session memory 就是这么接进来的:initSessionMemory 里 registerPostSamplingHook(extractSessionMemory),每次模型输出后 executePostSamplingHooks 遍历调它。同理,query loop 结束的 handleStopHooks(stopHooks.ts)直接调 executeExtractMemoriesexecutePromptSuggestionexecuteAutoDream——这些是"停止钩子"位置直接调的系统逻辑(auto memory 抽取等),不是用户配置的。

5.8 常见场景速查

场景 钩子 做法
阻止危险命令 PreToolUse if: “Bash(*)”,命令检查到 rm -rf 就 deny
自动格式化 PostToolUse Edit 后跑 prettier --write
自动放行安全操作 PreToolUse 匹配的命令 allow(省弹窗,但 deny 规则仍生效)
改输入 PreToolUse updatedInput 规范化参数
完成通知 Stop 会话结束发系统通知
提交前过滤 UserPromptSubmit 用户消息提交前修改/拒绝
会话初始化 SessionStart 启动时跑环境检查脚本
压缩前快照 PreCompact 压缩前保存状态
子 agent 监控 SubagentStart/Stop 记录子 agent 生命周期

5.9 完整例子:PreToolUse 钩子阻止危险命令

配置(settings.json):

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "python3 ~/.claude/check_bash.py"
      }]
    }]
  }
}

check_bash.py 读标准输入的 JSON,检查命令,危险就输出 deny:

import json, sys
data = json.load(sys.stdin)
cmd = data["tool_input"]["command"]
if "rm -rf" in cmd:
    print(json.dumps({
        "permissionDecision": "deny",
        "permissionDecisionReason": "禁止 rm -rf"
    }))
    sys.exit(0)
sys.exit(0)  # 不输出 = 不干预

执行追踪:

  1. 模型调 Bash(command="rm -rf /tmp/foo")
  2. toolExecution.ts 走到 PreToolUse:runPreToolUseHooks → executePreToolHooks(hooks.ts:3394)。
  3. executePreToolHooks 找到匹配的钩子,spawn 拉起 python3 check_bash.py,把 {tool_name:"Bash", tool_input:{command:"rm -rf /tmp/foo"}, ...} 通过标准输入喂进去。
  4. 脚本检查到 rm -rf,标准输出 {"permissionDecision":"deny","permissionDecisionReason":"禁止 rm -rf"}
  5. executePreToolHooks 解析输出,返回 permissionBehavior=‘deny’。
  6. runPreToolUseHooks 收到,yield hookPermissionResult(deny)(toolHooks.ts:541)。
  7. resolveHookPermissionDecision:钩子 deny → 直接 deny(toolHooks.ts:408,deny 不绕过)。
  8. toolExecution.ts:权限不是 allow → 返回 “permission denied” 错误结果(toolExecution.ts:995),tool.call() 根本不执行。
  9. 这个错误结果作为 tool_result 喂给下一轮模型,模型看到"禁止 rm -rf",换别的方式。

整个过程中,危险命令从未真正执行——钩子在 tool.call() 之前就拦下了。


第 6 章:失败处理:子 Agent 的错误捕获与内容处置

子 agent 出错分两层处理:错误怎么捕获兜底 + 已经生成的内容怎么处置。还有个关键点:文件改动和"返回给父 agent 的总结"是两回事,处置不同。

6.1 错误捕获的层次

子 agent 出错可能发生在:① 子 agent 自己的 query loop 里(API 错误、超过最大轮数、被中止);② Agent 工具的 call() 里(派生前的校验、MCP 准备失败等)。AgentTool.tsx 的同步 agent 循环用 try/catch/finally 包住整个执行(1127-1234),runAgent.ts 的生成器还有自己的 try/finally(747-859)。从主循环看,Agent 就是个工具,它的结果(成功/部分/错误)最终变成一个 tool_result 喂给父 agent。

6.2 sync agent 出错怎么处理(AgentTool.tsx:1127-1234)

} catch (error) {
  if (error instanceof AbortError) {          // ① 用户取消
    wasAborted = true
    throw error                                // 重新抛出,走中止流程
  }
  logForDebugging(`Sync agent error: ...`)
  syncAgentError = toError(error)              // ② 其他错误:先存着,不立刻抛
} finally {
  // ③ 无论成败/中止都清理(见 6.3)
  ...
}

// ④ finally 之后,决定怎么收尾:
if (syncAgentError) {
  const hasAssistantMessages = agentMessages.some(msg => msg.type === 'assistant')
  if (!hasAssistantMessages) {
    throw syncAgentError                        // 没收集到任何内容 → 重新抛错,工具框架包成错误结果
  }
  logForDebugging(`Sync agent recovering from error with ${agentMessages.length} messages`)
  // 有内容 → 不抛错,继续往下用已有消息生成部分结果
}
const agentResult = finalizeAgentTool(agentMessages, ...)   // ⑤ 用已收集的消息出结果

核心策略:能救就救。 出错时如果子 agent 已经说过话(有 assistant 消息),就不当失败处理,而是把这些已有的内容封装成"部分结果"返回给父 agent——让父 agent 看到部分进展。只有啥都没说就崩了,才当真正的错误抛出。

6.3 finally 清理:无论成败都跑(runAgent.ts:816-859)

源码注释明说 “runs on normal completion, abort, or error”(正常完成、中止、出错都跑)。清理一堆资源,防止泄漏:

} finally {
  await mcpCleanup()                            // 关掉 agent 专属的 MCP 服务器
  clearSessionHooks(rootSetAppState, agentId)   // 清 agent 的会话钩子
  cleanupAgentTracking(agentId)                 // 清提示缓存追踪
  agentToolUseContext.readFileState.clear()     // 释放克隆的文件读取缓存
  initialMessages.length = 0                    // 释放克隆的分叉上下文消息
  unregisterPerfettoAgent(agentId)              // 注销性能追踪
  clearAgentTranscriptSubdir(agentId)           // 清记录子目录映射
  // 删 todos 条目(防止每个子 agent 留个空 key 累积泄漏)
  rootSetAppState(prev => { ...删除该 agent 的 todos... })
  // 杀掉这个 agent 派生的后台 bash 任务(防止变僵尸进程)
  killShellTasksForAgent(agentId, ...)
}

这些是"善后"——子 agent 占的资源必须释放,不然长会话派几百个子 agent 会泄漏内存/进程。

6.4 之前生成的内容怎么办(四种内容,处置不同)

这是问题的关键。"内容"分四种,各自处置:

① 文件改动(普通模式,无隔离)——不回滚,落盘即持久

普通模式下子 agent 直接操作真实文件,用 Edit/Write 改的文件已经真实写在磁盘上。出错不会回滚这些改动——文件系统的写操作不可逆。出错只影响"返回给父 agent 的总结",不影响已经落盘的文件。

② 文件改动(worktree 隔离模式)——有改动保留,无改动删除

如果派生时设了 isolation: "worktree",子 agent 在 git 工作树(隔离副本)里干活。清理逻辑(AgentTool.tsx:644-685):

const cleanupWorktreeIfNeeded = async () => {
  if (headCommit) {
    const changed = await hasWorktreeChanges(worktreePath, headCommit)
    if (!changed) {
      await removeAgentWorktree(...)            // 没改动 → 删掉隔离副本
      return {}
    }
  }
  // 有改动 → 保留,返回路径让父 agent/用户能恢复
  return { worktreePath, worktreeBranch }
}

有改动就保留工作树(返回路径和分支),没改动才删。所以隔离模式下,出错前的文件改动不会丢——工作树还在,可以合并/查看。

③ 对话内容——边跑边记旁路记录,可恢复

runAgent.ts:792-804 每来一条消息就记到旁路记录(sidechain transcript):

if (isRecordableMessage(message)) {
  await recordSidechainTranscript([message], agentId, lastRecordedUuid)  // 边跑边写盘
  ...
}

所以子 agent 哪怕中途崩了,出错前的对话已经写在磁盘上。可以用 resumeAgent(SendMessage 续跑)从这个记录恢复,带着完整上下文继续。

④ 返回给父 agent 的总结——取最后一条 assistant 消息

出错/被中止时,extractPartialResult(agentToolUtils.ts:488-500)从后往前找最后一条有文本的 assistant 消息,作为"部分结果"返回:

export function extractPartialResult(messages): string | undefined {
  for (let i = messages.length - 1; i >= 0; i--) {
    if (messages[i].type !== 'assistant') continue
    const text = extractTextContent(messages[i].message.content, '\n')
    if (text) return text          // 最后一条 assistant 说的话
  }
  return undefined
}

后台 agent 被中止时(AgentTool.tsx:1006-1013),就用这个作为 finalMessage 通知父 agent:

if (error instanceof AbortError) {
  killAsyncAgent(backgroundedTaskId, rootSetAppState)
  const partialResult = extractPartialResult(agentMessages)   // 取部分结果
  enqueueAgentNotification({ status: 'killed', finalMessage: partialResult, ... })
  return
}

6.5 父 agent / 主循环最终看到什么

综合下来,Agent 工具返回给主循环的 tool_result 有四种情况:

情况 父 agent 看到
正常完成 子 agent 的最终总结
出错但有部分内容 最后一条 assistant 消息(部分进展)
出错且无内容 错误信息(is_error: true 的 tool_result)
用户取消 中止错误,走中止流程

父 agent 拿到这个 tool_result 后,自己决定怎么办:重试、自己接着干、或告诉用户。比如父 agent 看到"子 agent 改到第 2 个文件时出错了,已完成: …",它可以再派一次让它改第 3 个,或自己改。

注意:Agent 工具是非并发安全的重工具,独占执行。它出错不会连累兄弟工具(只有 Bash 出错才连累),错误只变成它自己的 tool_result。

6.6 一个具体例子

任务:派子 agent 改 3 个文件(a.ts、b.ts、c.ts)。改到 b.ts 时模型 API 调用出错。

普通模式(无隔离):

  • a.ts、b.ts 的改动已落盘,保留(不回滚)。
  • c.ts 没改。
  • 旁路记录里有改 a、b 的完整对话,可 resumeAgent 续跑改 c。
  • 父 agent 收到的 tool_result:子 agent 最后一条 assistant 消息(可能是"我改完了 a.ts 和 b.ts,正在改 c.ts…")——部分结果。
  • 父 agent 知道 a、b 已改,可以自己改 c 或再派一次。

worktree 隔离模式:

  • a.ts、b.ts 的改动在隔离副本里,主仓库没动。
  • hasWorktreeChanges 检测到有改动 → 保留工作树,返回路径和分支。
  • 父 agent/用户可以查看、合并这个工作树里的改动。
  • 旁路记录同样在,可续跑。

两种模式下,已经完成的工作都不会因为出错而丢失——要么在真实磁盘上(普通模式),要么在保留的工作树里(隔离模式),对话也都在旁路记录里。


第 7 章:决策机制:模型如何选择工具、每轮看到什么

这一章串一条线:模型靠什么决定调工具 → 何时用 Agent 而非 Read/Write → 每轮给模型发什么。关键在 query.ts:1716 的消息累积。

7.1 模型靠什么决定调哪个工具

调模型时(query.ts:659-707 的 callModel)发三样东西:

for await (const message of deps.callModel({
  messages: prependUserContext(messagesForQuery, userContext),  // ① 对话历史
  systemPrompt: fullSystemPrompt,                                // ② 系统提示
  tools: toolUseContext.options.tools,                           // ③ 工具定义
  ...
})) { ... }
  • 系统提示:告诉模型它的角色、行为规范。
  • 工具定义:每个工具的 name、description、input_schema(参数结构)。关键是 description 里写满了"什么时候用/什么时候不用"。
  • 对话历史:用户消息 + 之前的 assistant 回复 + 工具结果。

模型读完这三样,自己决定要不要调工具、调哪个,然后在回复里输出 tool_use 块。query.ts:829-835 把这些块抽出来:

const msgToolUseBlocks = message.message.content.filter(c => c.type === 'tool_use')
if (msgToolUseBlocks.length > 0) {
  toolUseBlocks.push(...msgToolUseBlocks)
  needsFollowUp = true   // 有工具调用,这轮还没完
}

所以"决定调哪个工具"是模型的活,代码只负责把工具描述和历史喂给它,然后执行它选的工具。工具描述里的"何时用/何时不用"是真正的导航。

7.2 为什么用 Agent 工具,而不是读写工具

因为工具描述里明确写了边界。Agent 工具的描述(prompt.ts)有"When NOT to use"一节:

  • 如果你想读某个具体文件路径 → 用 Read 工具,更快
  • 如果搜某个类定义如 “class Foo” → 用 Grep,更快
  • 如果在某个或 2-3 个文件里搜代码 → 用 Read,更快

以及何时该用("何时派子 agent"四条):独立可并行、读多文件只要结论、上下文隔离、匹配专门类型。

决策逻辑就一条:主 agent 负责理解和综合,子 agent 负责跑腿和查证。

  • 改一个已知文件的一个 bug:主 agent 自己 Read+Edit——就一两个文件,直接读更快,且要自己判断。
  • 审查两个独立模块的安全:派两个 Agent 并行——要读 20 个文件但只要结论(不读全部进自己上下文),且两块独立可并行。

如果该用 Read 时用了 Agent,浪费一次派生 + 上下文传递;该用 Agent 时用 Read,主 agent 上下文被 20 个文件塞爆,还慢(串行)。

7.3 一轮工具调用返回什么

工具执行完,返回的是 tool_result 块,包在 user 消息里(对模型而言,工具结果是"用户角色"发的)。query.ts:1395-1400 累积这些:

toolResults.push(
  ...normalizeMessagesForAPI([update.message], toolUseContext.options.tools)
    .filter(_ => _.type === 'user'),   // 工具结果作为 user 消息
)

不同工具的 tool_result 内容不同:Read 是文件内容;Grep 是命中的行号 + 内容;Bash 是命令的 stdout/stderr;Edit 是成功标记 + diff;Agent 是子 agent 的总结(最后一条 assistant 消息),不是子 agent 读过的文件。这个 tool_result 就是下一轮模型看到的"工具结果"。

7.4 每一轮给模型说什么(关键)

不是每轮"说新话",而是把系统提示 + 工具定义 + 累积的对话历史重发一次,每轮新增的只是上一轮的工具结果。看 query.ts:1716,下一轮的 messages 怎么累积:

const next: State = {
  messages: [...messagesForQuery, ...assistantMessages, ...toolResults],  // ← 关键
  turnCount: nextTurnCount,
  ...
}
state = next   // 回到 while 顶部,用这批 messages 再调一次模型

即:下一轮的对话历史 = 上一轮的历史 + 这轮的 assistant 回复(含 tool_use)+ 这轮的工具结果。系统提示和工具定义每轮都一样(靠 prompt 缓存,不重算)。所以每轮模型看到的是"越来越长的历史",新看到的就是上一轮工具干了啥、结果是什么,据此决定下一步。

7.5 具体例子(两种流程对比)

例 A:简单 bug 修复(主 agent 自己用 Read/Grep/Edit)

用户:“找出 login.tsx 里 handleSubmit 的 bug 并修复”

第 1 轮发给模型: system(含各工具描述)、tools([Read, Write, Edit, Grep, Glob, Bash, Agent, …])、messages([user: “找出 login.tsx 里 handleSubmit 的 bug 并修复”])。模型读请求 + 工具描述,决定先搜 handleSubmit 在哪,返回:

assistant: [text "我先搜一下 handleSubmit 的位置",
            tool_use(Grep, pattern="handleSubmit", glob="**/login.tsx")]
// needsFollowUp=true → 执行 Grep

第 2 轮(system、tools 不变): messages = [user, assistant(text + tool_use Grep), user(tool_result “login.tsx:42: const handleSubmit = …”)]。模型看到第 42 行,决定读文件,返回 tool_use(Read, file_path=“login.tsx”, offset=1, limit=80)。执行 Read。

第 3 轮: messages 追加 user(tool_result 文件内容)。模型发现 handleSubmit 没 await,决定改,返回 tool_use(Edit, file_path=“login.tsx”, old_string=“…”, new_string=“…”)。执行 Edit(可能弹权限窗)。

第 4 轮: messages 追加 user(tool_result “已修改,diff: …”)。模型看到改成功了,直接回复(不再调工具):

assistant: [text "已经把 await 补上了,handleSubmit 现在会等 fetch 完成再继续"]
// 没有 tool_use → needsFollowUp=false → 进停止钩子(auto memory 抽取等),结束

例 B:多区域安全审查(用 Agent 工具)

用户:“审查后端安全,重点支付和认证两块,给修复方案”

第 1 轮: 模型读 Agent 工具描述(“independent work to run in parallel”、“reading across several files - delegate and keep the conclusion not the file dumps”),判断支付和认证独立、各要读十几个文件但只要结论,决定派两个子 agent 并行,返回:

assistant: [text "支付和认证独立,我派两个子 agent 并行审,自己等结论",
            tool_use(Agent, description="审支付安全", prompt="审查支付模块...报告 ≤200 字"),
            tool_use(Agent, description="审认证安全", prompt="审查认证模块...报告 ≤200 字")]
// 两个 Agent 调用在同一条消息里 = 并行。needsFollowUp=true,两个子 agent 并行跑(各自有自己的 query loop)

第 2 轮: messages = [user, assistant(text + 2 个 tool_use Agent), user(tool_result 支付报告), user(tool_result 认证报告)]。模型看到的是两个子 agent 的总结报告(不是它们读过的 20 个文件),综合两份报告给修复方案,没有 tool_use → 结束。

两个例子对比

例 A(自己读写) 例 B(派子 agent)
场景 单区域、文件少 多区域、文件多、要并行
工具 Grep → Read → Edit Agent ×2(并行)
主 agent 上下文 装下文件内容、diff 只装两份总结报告
决策依据 Read 描述:“读具体文件用 Read” Agent 描述:“独立可并行、读多文件只要结论”

一句话串起来: 模型靠系统提示 + 工具描述 + 对话历史三样输入决定调哪个工具,工具描述里的"何时用/何时不用"是导航(读具体文件用 Read,多区域并行只要结论用 Agent)。每轮工具返回 tool_result(包在 user 消息里)。下一轮不是发新指令,而是把累积的历史(上一轮的 assistant 工具调用 + 这轮的工具结果)连同相同的系统提示和工具定义重发(query.ts:1716),模型看到工具结果后继续,直到某轮不调工具就收尾。


第 8 章:总结:设计哲学与关键取舍

8.1 一套标志位驱动的调度模型

所有工具统一实现 Tool 接口,用八个标志位(isConcurrencySafe / isReadOnly / isDestructive / shouldDefer / alwaysLoad / maxResultSizeChars / validateInput / inputSchema)向框架声明自己的特性。调度层据此决定:能不能并行、要不要权限、是否首屏加载、结果怎么截断。工具作者只写 call(),框架包揽所有横切关注点。

8.2 三个关键的横切机制

机制 设计 解决的问题
并发分级 只读工具(Read/Grep/Glob)并发安全并行跑;写/命令(Edit/Write/Bash)独占,靠工具自己声明 isConcurrencySafe(input) 吞吐与安全之间的平衡
中止链 AbortController 父子链(顶层→兄弟→每工具),父→子单向传播 + 受控冒泡 长操作(Bash/子 agent/API)可取消,且支持"Bash 出错杀兄弟"这类局部中止
钩子扩展 27 个事件点插入用户逻辑(spawn 外部命令 + JSON 输入输出),进程内钩子注册回调 无需改源码即可拦截/改写/放行工具行为;钩子 allow 不绕过 deny/ask 规则

8.3 失败的哲学:能救就救,不丢已有工作

  • 子 agent 出错:有 assistant 消息就封装成部分结果返回,只有啥都没说才当错误抛出。
  • 资源无论成败都清理(finally:关 MCP、杀后台 bash、删 todos、释放缓存)。
  • 四种内容差异化处置:文件改动普通模式不回滚(落盘即持久)、worktree 模式有改动就保留隔离副本、对话边跑边记旁路记录可续跑、返回总结取最后一条 assistant 消息。

8.4 决策靠描述,不靠硬编码

模型选择工具完全依赖系统提示 + 工具描述 + 对话历史三样输入。工具描述里的"When NOT to use"是真正的导航——主 agent 负责理解和综合,子 agent 负责跑腿和查证。每轮重发"历史 + 工具结果",让模型在越来越长的上下文里自行推进,直到某轮不调工具就收尾。

8.5 全景速查:标志位对照

工具 并发安全 只读 破坏性 延迟加载 结果上限
Read - - ∞(token 约束)
Write / Edit - - - - 100K
NotebookEdit - - - 100K
Glob / Grep - - 100K / 20K
Bash / PowerShell = 只读 动态 -(靠警告) - 30K
Agent - - - - -
WebFetch / WebSearch - 100K
MCP 工具 服务器注解决定 同左 同左 ✓(默认) -
ListMcp / ReadMcp - -
McpAuth - - - - 10K
ToolSearch / LSP - LSP ✓ -
AskUserQuestion - -
Cron / Worktree - List ✓ Exit(remove) ✓ -

8.6 有意取舍:破坏性标记的缺席

值得注意的设计细节:破坏性操作(ExitWorktree remove)是唯一显式标 isDestructive 的工具,而 Bash 的 rm -rfgit reset --hard 只靠 destructiveCommandWarning 做信息提示,不标 destructive——真正拦截靠权限层和 prompt 里的 Git Safety Protocol。这是设计上的有意取舍:命令的破坏性难以静态判定,与其误标误放,不如交给权限层动态裁决。


本文基于 Claude Code 源码(query.ts / StreamingToolExecutor.ts / toolExecution.ts / abortController.ts / toolHooks.ts / AgentTool.tsx 等)整理。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐