Claude Code 源码解析(三):queryLoop、Tool 执行与权限链
接续第一篇的主链路与第二篇的 Memory 体系,本文先展开 queryLoop 各 phase 的完整流程,再深入 Tool 执行、权限链与 API 归一化。
queryLoop 总览
query() 是对外的 async generator;真正循环在内部的 queryLoop()(query.ts ~241 行),结构是 while (true) + state 对象在 iteration 间传递,而不是简单的 while (needsFollowUp)。
一次用户输入可能触发 多轮 iteration(每次 tool 调用后 continue 递归),每轮 iteration 内部又分 预处理 → API 流式 → 收尾/Tool → 附件 → 状态更新 几个 phase。
1 | flowchart TB |
设置 CLAUDE_CODE_PROFILE_QUERY=1 可对照 queryCheckpoint 打点(见 utils/queryProfiler.ts)。
queryLoop 各 Phase 详解
Phase 0:Iteration 初始化
每次 while (true) 开头:
| 步骤 | 代码位置 | 说明 |
|---|---|---|
解构 state | ~311 | messages、toolUseContext、turnCount、pendingToolUseSummary 等 |
| Skill prefetch 启动 | ~331 | startSkillDiscoveryPrefetch,在模型 streaming + tool 执行期间后台跑 |
yield stream_request_start | ~337 | REPL spinner 切到 requesting |
queryTracking.depth++ | ~347 | 链式追踪,每 iteration 深度 +1 |
整 turn 只做一次(在 while 外):startRelevantMemoryPrefetch — relevant memory 后台检索,iteration 末尾 consume。
Phase 1:上下文预处理(API 调用前)
顺序固定,snip → microcompact → collapse → autocompact,越往后越「重」:
1 | messagesForQuery = getMessagesAfterCompactBoundary(messages) |
要点:
- snipTokensFreed 传给 autocompact,因为
tokenCountWithEstimation读不到 snip 释放的 token - context collapse 在 autocompact 之前:若 collapse 已把 token 压到阈值以下,autocompact 成为 no-op,保留更细粒度上下文
- autocompact 成功:yield
buildPostCompactMessages(),messagesForQuery替换为 compact 后消息,重置autoCompactTracking
Phase 2:API 前 Setup
| 步骤 | 说明 |
|---|---|
| 初始化 per-iteration 容器 | assistantMessages[]、toolResults[]、toolUseBlocks[]、needsFollowUp=false |
StreamingToolExecutor | feature gate streamingToolExecution 开启时创建 |
getRuntimeMainLoopModel | plan 模式 + 超 200k 可能换模型 |
| Blocking limit | autocompact 关闭时的硬拦截;compact/session_memory/reactive compact 等路径跳过 |
Phase 3:API 流式
内层 while (attemptWithFallback) 包一层 模型 fallback 重试:
1 | for await (message of deps.callModel({ messages: prependUserContext(...), ... })) |
流式循环内对每个 message:
| message 类型 | 行为 |
|---|---|
stream_event | yield 给 REPL → handleMessageFromStream 更新 streaming 预览 |
assistant | push 到 assistantMessages;含 tool_use → needsFollowUp=true |
tool_use block 完成 | streamingToolExecutor.addTool() + getCompletedResults() 增量 yield tool_result |
| streaming fallback | tombstone 旧 assistant、discard executor、清空状态、重试 |
| withheld 413/media/max_tokens | 暂 yield,留到 Phase 4 恢复 |
needsFollowUp 的语义:不依赖 API 的 stop_reason === 'tool_use'(不可靠),而是 流式收到 tool_use block 即置 true。
流式结束后:
- 若有
pendingCacheEdits→ yield microcompact boundary(用 API 回报的cache_deleted_input_tokens) executePostSamplingHooks(fire-and-forget)
Abort 路径(流式刚结束):getRemainingResults() 补 synthetic tool_result → cleanup → return { reason: 'aborted_streaming' }
Phase 4:无 Tool 收尾(!needsFollowUp)
模型本轮 没有 tool_use,进入收尾链:
1 | flowchart TD |
关键 recovery transition.reason:
| reason | 触发条件 |
|---|---|
collapse_drain_retry | withheld prompt-too-long,drain staged collapses |
reactive_compact_retry | 413/media 触发 reactive compact |
max_output_tokens_escalate | 8k cap 命中 → 单次升到 64k 重试 |
max_output_tokens_recovery | 注入 meta「继续写,不要 recap」 |
stop_hook_blocking | Stop hook 返回 blocking error → 带 error 再调 API |
token_budget_continuation | token budget 未耗尽 → meta nudge 继续 |
Stop hooks 不在 API error 上跑(防 death spiral:error → hook 加 token → 再 error)。
Phase 5:Tool 执行(needsFollowUp === true)
1 | toolUpdates = streamingToolExecutor |
- 逐个
yield update.message(含 progress、tool_result、hook attachment) normalizeMessagesForAPI过滤出 user 类型 push 到toolResultsupdate.newContext合并 tool 产生的 context 变更hook_stopped_continuation→shouldPreventContinuation=true→ 后续return { reason: 'hook_stopped' }
并行启动 generateToolUseSummary(Haiku),结果存 nextPendingToolUseSummary,下一轮 iteration Phase 4 开头 yield(在模型 streaming 的 5–30s 窗口内完成)。
Abort mid-tools → return { reason: 'aborted_tools' }
Phase 6:附件与 Prefetch
必须在 tool_result 全部完成之后——API 不允许 tool_result 与普通 user message 交错。
顺序:
getAttachmentMessages— IDE 上下文、queued command、file change 等- Memory prefetch consume —
settledAt !== null且未消费时注入 relevant memory attachment - Skill prefetch consume — 注入 skill discovery 结果
- Drain command queue — 已消费的 prompt/task-notification 从队列移除
然后 refreshTools() — MCP 新连接的工具在下一轮可用。
Phase 7:递归(下一轮 iteration)
1 | state = { |
maxTurns超限 → yieldmax_turns_reachedattachment → returnqueryCheckpoint('query_recursive_call')标记递归点
State 对象:iteration 间传递什么
1 | type State = { |
queryCheckpoint 对照表
| Checkpoint | Phase |
|---|---|
query_fn_entry | 0 |
query_snip_start/end | 1 |
query_microcompact_start/end | 1 |
query_autocompact_start/end | 1 |
query_setup_start/end | 2 |
query_api_loop_start | 3 |
query_api_streaming_start/end | 3 |
query_tool_execution_start/end | 5 |
query_recursive_call | 7 |
Tool 子系统总览
Tool 相关逻辑主要在 Phase 3(流式增量) 和 Phase 5(批量收尾):
1 | flowchart LR |
关键文件:
| 模块 | 路径 | 职责 |
|---|---|---|
| query 主循环 | src/query.ts | 流式消费、assistant 收集、tool 触发 |
| Tool 编排 | src/services/tools/toolOrchestration.ts | runTools、batch 分区、并发/串行 |
| 流式 Tool 执行 | src/services/tools/StreamingToolExecutor.ts | 流式收到完整 tool_use 即排队执行 |
| 单 Tool 执行 | src/services/tools/toolExecution.ts | runToolUse → checkPermissionsAndCallTool |
| API 流式 | src/services/api/claude.ts | SSE → content_block_stop yield assistant |
| 流式 UI | src/utils/messages.ts | handleMessageFromStream |
| 权限 Hook | src/hooks/useCanUseTool.tsx | 弹窗 / classifier / coordinator |
| API 归一化 | src/utils/messages.ts | normalizeMessagesForAPI |
一、checkPermissionsAndCallTool 执行管线
单 Tool 执行的入口是 runToolUse(async generator),内部调用 streamedCheckPermissionsAndCallTool,最终落到 checkPermissionsAndCallTool(toolExecution.ts ~599 行)。
1.1 完整流水线
1 | runToolUse |
1.2 各阶段要点
① Zod 校验:inputSchema.safeParse(input) 失败则直接返回 InputValidationError 的 tool_result,不调用 Tool。deferred tool 还会附加 schema-not-sent hint,提示模型先 ToolSearch。
② validateInput:各 Tool 自定义校验(路径是否存在、命令是否合法等),失败同样提前返回 error tool_result。
③ Bash 投机优化:对 Bash 提前 startSpeculativeClassifierCheck,与 PreToolUse hook、权限弹窗并行,减少 auto 模式等待。
④ 输入预处理:
- 去掉模型不应提供的
_simulatedSedEdit(仅权限系统在用户批准后注入) backfillObservableInput在浅拷贝上补全字段(如~展开为绝对路径),供 Hook/权限使用;不污染传给tool.call()的原始 input,避免 VCR hash 变化
⑤ PreToolUse hooks(runPreToolUseHooks)可能产生:
| 事件 | 效果 |
|---|---|
message / progress | 中间消息、进度 |
hookPermissionResult | Hook 直接给权限结论 |
hookUpdatedInput | 修改 input |
preventContinuation / stopReason | 阻止继续 |
stop | 直接返回 error tool_result |
⑥ 权限:见下文「canUseTool 权限链」。
⑦ tool.call():权限通过后调用,传入 toolUseId、userModified、progress 回调等。
⑧ PostToolUse hooks:MCP Tool 可能修改 output;非 MCP 先 addToolResult,MCP 在 Hook 后再 addToolResult。
⑨ 产出:resultingMessages: MessageUpdateLazy[],含 tool_result user message、attachment、hook summary 等;streamedCheckPermissionsAndCallTool 逐个 yield 给上层。
1.3 REPL 中的 wiring
canUseTool 在 REPL 中由 React hook 创建(REPL.tsx ~2382 行):
1 | const canUseTool = useCanUseTool(setToolUseConfirmQueue, setToolPermissionContext); |
经 getToolUseContext 注入 query 链路,最终传入 checkPermissionsAndCallTool。
二、Tool 并发:无读写锁,靠 isConcurrencySafe
Claude Code 没有通用读写锁,也没有 Tool 间依赖图。并发完全靠每个 Tool 声明的 isConcurrencySafe(input) + 批处理/队列规则。
2.1 谁可以并发?
每个 Tool 自己实现 isConcurrencySafe:
1 | // FileReadTool — 固定 true |
这不是 RW lock,而是 粗粒度「只读 vs 有副作用」 分类。
2.2 非流式:partitionToolCalls + batch
toolOrchestration.ts 的 partitionToolCalls 按模型输出顺序分组:
- 连续 safe batch →
runToolsConcurrently(默认最多 10,CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY) - 非 safe batch →
runToolsSerially逐个跑 - batch 之间串行;不会把
[Read, Edit, Read]里的两个 Read 跨 Edit 合并
1 | // 连续 safe tool 合并为一批 |
2.3 流式:StreamingToolExecutor 队列
默认开启 streamingToolExecution 时,queryLoop 使用 StreamingToolExecutor:每个 content_block_stop 产出的完整 tool_use 即 addTool 排队。
核心规则(canExecuteTool):
- 无 executing → 任意 tool 可启动
- 有 executing → 只有 当前 + 已在跑的全是 safe 才能再启动 safe tool
- 队首是非 safe 且前面有 executing → break,等前一个完成
结果按 模型输出顺序 yield(getCompletedResults 顺序遍历)。
2.4 「依赖」如何识别?
不识别。 没有 DAG、没有 file_path 冲突检测、没有「B 的 input 引用 A 的 output」分析。
实际策略:
- 假设模型按依赖顺序发 tool(Read 在前,Edit 在后)
- Edit/Bash-write 等非 safe → 强制串行
- Bash 出错会 cancel 兄弟 tool(
siblingAbortController),因为命令链常有隐式依赖 - Read 类失败不 cancel 兄弟(彼此独立)
三、流式消息如何拼接
分 API 层(claude.ts)和 UI 层(messages.ts)两层。
3.1 API 层:SSE → 逐 block 产出 assistant message
| SSE 事件 | 作用 |
|---|---|
message_start | 保存 partialMessage(id、role 等) |
content_block_start | contentBlocks[index] = { ...block, text/input/thinking: '' } |
content_block_delta | 追加 delta 到对应 block |
content_block_stop | yield 一条完整 assistant message(content 仅含该 block) |
message_delta | 回填最后一条 message 的 usage、stop_reason |
Text 拼接:content_block_delta 的 text_delta → contentBlock.text += delta.text
Tool input 拼接:input_json_delta → contentBlock.input += delta.partial_json(字符串累加,content_block_stop 时 parse 成 object)
要点:一个 turn 多个 block → 多条 assistant message(text、thinking、tool_use 各一条)。
3.2 UI 层:增量渲染
handleMessageFromStream 处理 stream_event:
- Text:
content_block_start(text) 重置streamingText;text_delta追加 - Tool input 预览:
content_block_start(tool_use) 加入streamingToolUses;input_json_delta追加unparsedToolInput - Turn 结束:
message_stop清空streamingToolUses
UI 的 streamingText / streamingToolUses 是 预览;持久化 transcript 靠 API 层 content_block_stop yield 的完整 message。
3.3 queryLoop 如何拼成下一轮 context
1 | if (message.type === 'assistant') { |
一轮 iteration 结束后:
1 | messagesForQuery |
Prompt cache 注意:queryLoop 刻意 不 mutate 原始 assistant message(只 clone 后 yield),注释写明 mutating 会破坏 prompt caching 的 byte 匹配。
四、canUseTool 权限链
4.1 完整调用栈
1 | checkPermissionsAndCallTool |
4.2 第一层:resolveHookPermissionDecision
PreToolUse hook 可能直接给 allow / deny / ask。关键约束:Hook 的 allow 不能绕过 settings.json 的 deny/ask 规则(inc-4788 类比)。
| Hook 结果 | 后续 |
|---|---|
allow + 无 deny/ask 规则 | 直接允许(跳过弹窗) |
allow + deny 规则 | deny 覆盖 hook |
allow + ask 规则 | 仍走 canUseTool 弹窗 |
allow + 需交互 Tool(如 AskUserQuestion) | 必须走 canUseTool |
deny | 直接拒绝 |
ask / 无 hook | 正常走 canUseTool,可带 forceDecision |
4.3 第二层:hasPermissionsToUseTool(规则引擎)
纯配置/规则判断,返回 allow / deny / ask,不负责 UI。
1 | flowchart TD |
规则来源(按优先级):
- 整 Tool 级:
getDenyRuleForTool/getAskRuleForTool(settings.json、session、cli 等) - Tool 实现级:
tool.checkPermissions()(如 Bash 子命令Bash(git *)) - 模式级:
bypassPermissions/ plan+bypass 可用 → 直接 allow - always-allow 规则:前缀匹配通过 → allow
- 默认:
passthrough→ 转成ask
免疫 bypass 的检查(即使在 yolo/bypass 下也要弹窗):
- 内容级 ask 规则(
ruleBehavior === 'ask') safetyCheck(.git/、.claude/等敏感路径)
4.4 第三层:模式变换与 auto classifier
规则层返回 ask 后:
| 模式 | 行为 |
|---|---|
dontAsk | ask → deny |
auto / plan+auto | 走 auto mode classifier(side query) |
| 其他 | 保持 ask,交给 UI |
auto 模式 fast-path(跳过 classifier API):
- acceptEdits 探测:用虚拟
acceptEdits模式再调checkPermissions,若 allow 则直接放行 - safe allowlist:Read/Grep 等安全 Tool 直接 allow
- 否则 →
classifyYoloAction()(YOLO classifier)
4.5 第四层:useCanUseTool(交互/UI)
规则层返回后,useCanUseTool 把决策变成 Promise:
1 | flowchart TD |
handleInteractivePermission 要点:
- 往
toolUseConfirmQueue推ToolUseConfirm组件 - 后台并行跑 PermissionRequest hooks + Bash classifier
- 用户先点 vs classifier 先出 →
resolveOnce防重复 - Bridge/Kairos channel 可远程审批
Bash 投机 classifier 与 checkPermissionsAndCallTool 里的 startSpeculativeClassifierCheck 配合:弹窗前最多等 2s,high confidence match → 静默 allow。
4.6 决策如何回到 Tool 执行
permissionDecision.behavior | 结果 |
|---|---|
allow | tool.call(),可能带 updatedInput、acceptFeedback、粘贴图片 |
deny / ask 被拒 | error tool_result,不执行 Tool |
五、normalizeMessagesForAPI 合并逻辑
运行时 transcript 里 message 类型很多(progress、attachment、system、virtual…),API 只接受 user / assistant 且格式严格。normalizeMessagesForAPI(utils/messages.ts ~1989 行)是 transcript → API payload 的转换器。
5.1 何时调用
claude.ts:每次callModel发 API 前query.ts:tool result 写入 messages 前(局部 normalize)
5.2 整体流水线
1 | flowchart LR |
5.3 核心 merge 规则
User message:相邻合并
连续两条 user → mergeUserMessages:
- 拼接
content[]数组 - text-text 接缝处插入
\n(防"2 + 2" + "3 + 3"→"2 + 23 + 3") hoistToolResults:tool_result 块必须排在前面(API 要求)- attachment 也会 merge 进上一条 user
Assistant message:按 message.id 合并
流式阶段 一 block 一条 assistant;normalize 时 同 id 合并 content:
1 | export function mergeAssistantMessages(a, b): AssistantMessage { |
向后遍历时 跳过中间的 tool_result user message,所以典型 turn:
1 | 流式 yield: |
过滤掉的消息类型
不进入 API:progress、普通 system(local_command 例外 → 转 user)、isSyntheticApiErrorMessage、isVirtual。
Tool input 规范化
assistant 的 tool_use block 会调 normalizeToolInputForAPI;tool search 关闭时 strip caller 等非标准字段。
5.4 后处理(防 API 400)
| 步骤 | 目的 |
|---|---|
filterOrphanedThinkingOnlyMessages | 去掉 compaction 后孤立的 thinking-only assistant |
filterTrailingThinkingFromLastAssistant | 最后一条 assistant 不能有 trailing thinking |
filterWhitespaceOnlyAssistantMessages | 去掉纯空白 assistant |
smooshSystemReminderSiblings | 把 <system-reminder> 文本 fold 进 tool_result |
sanitizeErrorToolResultContent | error tool_result 里不能有 image 等 |
validateImagesForAPI | 图片尺寸校验 |
5.5 运行时细粒度 vs API 粗粒度
| 阶段 | assistant 表示 |
|---|---|
| 流式 yield | N 条 message,各 1 个 content block |
| transcript 存储 | 同上(利于 prompt cache byte 匹配) |
| 发 API 前 normalize | 1 条 message,content 数组拼接 |
六、三条链路交汇
1 | ┌─────────────────────────────────┐ |
关键文件索引
| 主题 | 文件 |
|---|---|
| query 主循环 | src/query.ts |
| Tool 编排 | src/services/tools/toolOrchestration.ts |
| 流式 Tool | src/services/tools/StreamingToolExecutor.ts |
| 单 Tool 执行 | src/services/tools/toolExecution.ts |
| Hook 权限桥接 | src/services/tools/toolHooks.ts |
| canUseTool | src/hooks/useCanUseTool.tsx |
| 规则引擎 | src/utils/permissions/permissions.ts |
| 交互弹窗 | src/hooks/toolPermission/handlers/interactiveHandler.ts |
| API 流式 | src/services/api/claude.ts |
| 流式 UI + normalize | src/utils/messages.ts |
| Tool 接口 | src/Tool.ts |
小结
Claude Code 的 queryLoop + Tool 子系统可以概括为:
- queryLoop 七 Phase:初始化 → 预处理(snip/microcompact/collapse/autocompact)→ setup → API 流式 → 无 Tool 收尾 / Tool 执行 → 附件 prefetch → 递归
continue - needsFollowUp:流式收到
tool_use即 true,不依赖stop_reason - 执行管线:Zod → validateInput → PreHook → canUseTool → call → PostHook → tool_result
- 并发策略:无 RW lock、无依赖图;
isConcurrencySafe+ batch/队列 - 流式拼接:delta 累加 →
content_block_stopyield;normalize 时按message.idmerge - 权限链:Hook 不 bypass deny/ask → 规则引擎 → auto classifier / 弹窗
下一篇:第四篇 专讲 上下文预处理全链路(budget / snip / microcompact / collapse / autocompact / cache editing)。再往后可写 compact 摘要 agent 内部流程或 reactive compact + 413 恢复。