通过源码学习思路:CLaude Code 如何实现Agent Tools 工具调用
目录
- 总览:工具系统的组成与调度模型
- 三层执行流水线:一次工具调用的完整旅程
- tool.call 与 ToolUseContext:工具与环境的接口
- 中止机制:abortController 父子链与监听实现
- 扩展点:钩子(Hooks)系统
- 失败处理:子 Agent 的错误捕获与内容处置
- 决策机制:模型如何选择工具、每轮看到什么
- 总结:设计哲学与关键取舍
第 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 步:
- 找工具(345):
findToolByName(tools, toolName)。找不到 → 返回 “No such tool” 错误结果,还支持别名(老名字)兜底。 - 中止检查(415):已中止 → 返回取消消息,不真正调用工具。
- schema 校验输入类型(615):
tool.inputSchema.safeParse(input)。模型给的参数类型不对(该传数组传了字符串)→ 返回 InputValidationError。源码注释很坦白:“surprisingly, the model is not great at generating valid input”。 - 工具自己的值校验(683):
tool.validateInput?.(parsedInput, context)。每个工具有自己的校验逻辑(比如 Read 校验文件路径合法性),不过 → 错误结果。 - (仅 Bash)提前启动分类器(746):
startSpeculativeClassifierCheck,提前跑"这条命令该不该自动放行"的判断,与后续步骤并行,省时间。 - 回填可观察字段(784-793):文件工具把
file_path展开成绝对路径,在一个克隆上回填,给钩子/权限看,但不污染传给call()的原始输入——否则会改变工具结果里嵌入的路径,破坏记录哈希。 - PreToolUse 钩子(800-862):跑用户配置的"工具执行前"钩子,能改输入、阻止继续、直接做权限决定、补充上下文、直接叫停(详见第 5 章)。
- 权限校验(921-930):
resolveHookPermissionDecision得出allow / deny / ask三选一。这就是"要不要问用户能不能执行"的地方:allow 放行;deny 拒绝并返回 “permission denied” 错误结果(995-1104);ask 弹窗问用户。 - 真正调用工具(1207):
const result = await tool.call(callInput, { ...toolUseContext, toolUseId, userModified }, canUseTool, assistantMessage, progress => onToolProgress(...))。Read 真去读文件、Bash 真去跑命令、Agent 真去派子 agent。期间可通过progress回调报进度(Bash 边跑边输出)。 - 结果映射(1292):
tool.mapToolResultToToolResultBlockParam(result.data, toolUseID)把工具结果转成 API 能识别的格式。 - PostToolUse 钩子(1483-1531):跑"工具执行后"钩子,能改 MCP 工具的输出、补充消息等。
- 组装最终消息(1403-1474):把
tool_result块 + 用户批准时写的反馈 + 权限给的附加内容(如图片)组装成用户消息返回。这个消息会作为"工具结果"喂给下一轮模型。 - 异常处理(1589-1745):工具执行抛错 → 跑 PostToolUseFailure 钩子 → 返回
is_error: true的工具结果。MCP 鉴权错误会把该 MCP 服务器标记成"需要重新登录"。
2.4 结果回流
第 3 层产出的消息,经第 2 层收集到 tool.results,经 getCompletedResults() 产出,query loop 把它们加进 toolResults 并推给界面。模型流式输出结束后,若 needsFollowUp = true,这些工具结果连同模型消息一起进 messages,turnCount++,回到 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 是顶层中止信号,一路传给两处:
- 模型 API 请求(query.ts:664):
signal: toolUseContext.abortController.signal传给 callModel,中止时取消在飞的网络请求。 - 每个工具的执行:工具内部检查
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:propagateAbort 和 removeAbortHandler 是模块作用域函数(不每次新建),用 .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 中止后发生什么(五步效果)
- 取消在飞的模型 API 请求:顶层 signal 传给了 callModel,网络请求被取消。
- 工具停止:runToolUse 一进来就检查
signal.aborted(toolExecution.ts:415),已中止直接返回取消消息,不真正调用工具。 - 生成合成 tool_result 块:API 要求每个 tool_use 必须有匹配的 tool_result。中止的工具没真正结果,StreamingToolExecutor 给它造一个合成错误结果(
createSyntheticErrorMessage),或用yieldMissingToolResultBlocks补齐。 - 清理:比如 computer use 解锁、释放锁(
cleanupComputerUseAfterTurn,query.ts:1033)。 - 结束本轮: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 })
}
执行流程四步:
- 喂输入:把钩子输入(工具名、工具输入、会话信息等)以 JSON 形式通过标准输入喂给命令。
- 跑命令:spawn 拉起子进程跑用户的命令(带 timeout、signal 中止)。
- 解析输出:命令的标准输出第一行解析成 JSON(钩子输出)。钩子通过这个 JSON 控制行为:
{ permissionDecision: "allow"/"deny"/"ask", updatedInput: {...}, additionalContext: "...", ... }。 - 退出码:退出码 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)直接调 executeExtractMemories、executePromptSuggestion、executeAutoDream——这些是"停止钩子"位置直接调的系统逻辑(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) # 不输出 = 不干预
执行追踪:
- 模型调
Bash(command="rm -rf /tmp/foo")。 - toolExecution.ts 走到 PreToolUse:runPreToolUseHooks → executePreToolHooks(hooks.ts:3394)。
- executePreToolHooks 找到匹配的钩子,spawn 拉起 python3 check_bash.py,把
{tool_name:"Bash", tool_input:{command:"rm -rf /tmp/foo"}, ...}通过标准输入喂进去。 - 脚本检查到 rm -rf,标准输出
{"permissionDecision":"deny","permissionDecisionReason":"禁止 rm -rf"}。 - executePreToolHooks 解析输出,返回 permissionBehavior=‘deny’。
- runPreToolUseHooks 收到,yield hookPermissionResult(deny)(toolHooks.ts:541)。
- resolveHookPermissionDecision:钩子 deny → 直接 deny(toolHooks.ts:408,deny 不绕过)。
- toolExecution.ts:权限不是 allow → 返回 “permission denied” 错误结果(toolExecution.ts:995),
tool.call()根本不执行。 - 这个错误结果作为 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 -rf、git reset --hard 只靠 destructiveCommandWarning 做信息提示,不标 destructive——真正拦截靠权限层和 prompt 里的 Git Safety Protocol。这是设计上的有意取舍:命令的破坏性难以静态判定,与其误标误放,不如交给权限层动态裁决。
本文基于 Claude Code 源码(query.ts / StreamingToolExecutor.ts / toolExecution.ts / abortController.ts / toolHooks.ts / AgentTool.tsx 等)整理。
更多推荐




所有评论(0)