全文目录Contents
- 0 · The Project at a Glance, and a Map of the Code
- 1 · The Entry Layer and Startup
- 2 · The Session Layer: QueryEngine
- 2.1 The problem it solves
- 2.2 What state it holds
- 2.3 The full flow of one submitMessage
- 2.4 Why the user message must hit disk first
- 2.5 Consuming the main loop’s output: one big switch
- 2.6 The compact boundary: proactively releasing memory
- 2.7 Three kinds of exit result
- 2.8 ask(): a convenience wrapper for one-shot calls
- 3 · The Agent Main Loop ★
- 3.1 The skeleton of the loop
- 3.2 State: centralizing cross-iteration state
- 3.3 transition: a field that exists purely for testability
- 3.4 The seven transition edges, one by one
- 3.5 The error-withholding mechanism
- 3.6 Interrupt handling
- 3.7 Model fallback: three actions
- 3.8 The three laws of thinking blocks
- 3.9 Other mechanisms in the loop
- 3.10 Every exit point of the loop
- 4 · The Tool Model
- 4.1 The Tool interface: seven orthogonal capability groups
- 4.2 Why the “safety predicates” deserve their own group
- 4.3 Fail-safe defaults
- 4.4 The 40 built-in tools, by category
- 4.5 Progressive tool loading
- 4.6 Tool list assembly: a hidden constraint about caching
- 4.7 backfillObservableInput: an extreme example of cache protection
- 5 · Tool Execution
- 5.1 The execution pipeline at a glance
- 5.2 Concurrency partitioning: a greedy algorithm
- 5.3 Context modifications are queued until the batch ends
- 5.4 A single execution: the full flow of runToolUse
- 5.5 The streaming tool executor
- 5.6 The sibling abort controller: the most elegant design in the file
- 5.7 The discard mechanism
- 5.8 “Tombstone” messages
- 5.9 Final processing of results
- 6 · Context Management ★
- 7 · The Permission System
- 8 · Subagents
- 9 · The Extension System
- 10 · The Terminal UI Layer
- 10.1 Writing a Terminal UI in React
- 10.2 The Four Biggest Components
- 10.3 Why the Input Box Is 347 KB
- 10.4 The Virtualized Message List
- 10.5 Six Rendering States for Tool Results
- 10.6 Collapsing: Avoiding Screen Flood
- 10.7 87 State-Management Units
- 10.8 The Interface Between UI and Kernel: Callbacks in ToolUseContext
- 10.9 A Fun Detail: ANSI to PNG
- 11 · Persistence and Resume
- 12 · The Observability System
- 13 · Build and Distribution
3 · 智能体主循环 ★
query.ts,1,730 行。这是整个系统最重要的一个文件。
3.1 循环的骨架
先把最外层结构剥出来看,去掉所有细节:
async function* queryLoop(params, consumedCommandUuids) {
// —— 不变的参数,整个循环期间不会重新赋值 ——
const { systemPrompt, userContext, systemContext, canUseTool,
fallbackModel, querySource, maxTurns, skipCacheWrite } = params
// —— 跨迭代的可变状态,集中在一个结构体里 ——
let state: State = { messages: params.messages, ... }
// —— 只跑一次的准备工作 ——
const config = buildQueryConfig() // 快照环境和特性开关
using pendingMemoryPrefetch = startRelevantMemoryPrefetch(...) // 记忆预取
while (true) {
// 1. 从 state 解构出本轮要用的东西
// 2. 上下文治理五级流水线 → 第 6 章
// 3. 调用模型(流式)
// └─ 边流边执行工具 → 第 5 章
// 4. 错误恢复判断 → 可能 continue 回到循环开头
// 5. 没有工具调用 → return(结束)
// 6. 执行剩余工具 → 第 5 章
// 7. 收集附件、处理队列中的消息
// 8. state = {...新状态}; 进入下一轮
}
}
(using 是 JavaScript 较新的「显式资源管理」语法:无论函数从哪条路径退出 —— 正常返回、抛异常、被外部关闭 —— 这个资源都会被清理。生成器函数有很多退出路径,漏掉任何一条就会资源泄漏。)
3.2 State:把跨轮次状态集中管理
type State = {
messages: Message[] // 当前完整消息历史
toolUseContext: ToolUseContext // 工具执行需要的上下文对象
// —— 恢复记账 ——
autoCompactTracking: AutoCompactTrackingState | undefined
maxOutputTokensRecoveryCount: number // 输出截断已重试几次
hasAttemptedReactiveCompact: boolean // 本轮是否已做过反应式压缩(幂等锁)
maxOutputTokensOverride: number | undefined // 是否已升过输出上限档
stopHookActive: boolean | undefined
pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
turnCount: number
transition: Continue | undefined // ★ 上一轮"因为什么"继续的
}
claude-code/src/query.ts
源码里有一段注释说明了为什么要集中:
「Mutable cross-iteration state. The loop body destructures this at the top of each iteration so reads stay bare-name (messages, toolUseContext). Continue sites write state = { ... } instead of 9 separate assignments.」
译:跨迭代的可变状态。循环体在每次迭代开头解构它,这样读取时还是简短的变量名。而每个 continue 处写的是「整体替换 state」而不是 9 条独立的赋值语句。
假设有 9 个跨轮次状态字段。如果用 9 条独立的赋值语句,那么每一条恢复路径都要记得把这 9 个字段各自设成什么。漏掉一个 —— 比如忘了重置计数器 —— 就是一个极难排查的 bug。
改成「整体替换」之后,每条恢复路径必须显式写出全部 9 个字段的值。漏写一个,TypeScript 编译器直接报错。把「容易忘」的问题转成了「编译不过」的问题。
3.3 transition:只为可测试而存在的字段
transition 不参与任何业务逻辑。它存在的唯一目的是记录「上一轮循环因为什么原因继续」。注释直说:
「Why the previous iteration continued. Undefined on first iteration. Lets tests assert recovery paths fired without inspecting message contents.」
译:上一轮为什么继续。第一轮时为空。它让测试可以直接断言某条恢复路径被触发了,而不必去检查消息内容。
| 没有这个字段时,测试只能这么写 | 有了之后 |
|---|---|
| 翻消息数组,检查里面有没有出现那句提示文案。 问题:测试和文案强耦合。有人改一个字,测试就红了 —— 但功能没坏。这种脆弱的测试最终会被团队禁用掉。 |
expect(transition.reason)好处:测的是「哪条路径被走了」这个事实,和文案、消息格式完全解耦。 |
3.4 七条转移边逐条详解
① next_turn — 正常推进
const next: State = {
messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
toolUseContext: toolUseContextWithQueryTracking,
autoCompactTracking: tracking,
turnCount: nextTurnCount,
maxOutputTokensRecoveryCount: 0, // ★ 重置
hasAttemptedReactiveCompact: false, // ★ 重置
pendingToolUseSummary: nextPendingToolUseSummary,
maxOutputTokensOverride: undefined, // ★ 重置
stopHookActive,
transition: { reason: 'next_turn' },
}
state = next
注意这是唯一一条会重置恢复计数器的路径。后面六条都不重置。这个规则是防死循环的核心。
② collapse_drain_retry — 上下文超长,先排空折叠
if (isWithheld413) {
if (feature('CONTEXT_COLLAPSE') && contextCollapse &&
state.transition?.reason !== 'collapse_drain_retry') { // ★ 上轮不是这条才试
const drained = contextCollapse.recoverFromOverflow(messagesForQuery, querySource)
if (drained.committed > 0) { // 真的排空了东西
state = { messages: drained.messages, ...,
transition: { reason: 'collapse_drain_retry', committed: drained.committed } }
continue
}
}
}
限次方式很有意思:它不用布尔锁,而是检查「上一轮的 transition 是不是就是这条路径」。如果上轮已经排空过一次、这轮还是 413,说明排空解决不了问题,直接跳过去试下一级。
③ reactive_compact_retry — 反应式全量摘要
if ((isWithheld413 || isWithheldMedia) && reactiveCompact) {
const compacted = await reactiveCompact.tryReactiveCompact({
hasAttempted: hasAttemptedReactiveCompact, // ★ 幂等锁传进去
querySource, aborted: ..., messages: messagesForQuery,
cacheSafeParams: { systemPrompt, userContext, systemContext,
toolUseContext, forkContextMessages: messagesForQuery },
})
if (compacted) {
const postCompactMessages = buildPostCompactMessages(compacted)
for (const msg of postCompactMessages) yield msg
state = { messages: postCompactMessages, ...,
hasAttemptedReactiveCompact: true, // ★ 上锁
transition: { reason: 'reactive_compact_retry' } }
continue
}
// 恢复失败 —— 把扣留的错误吐出去并结束
yield lastMessage
void executeStopFailureHooks(lastMessage, toolUseContext)
return { reason: isWithheldMedia ? 'image_error' : 'prompt_too_long' }
}
注意 cacheSafeParams(缓存安全参数)—— 压缩本身要调一次模型,而这次调用会复用当前会话的缓存前缀,所以必须原样传入系统提示词等参数。第 6 章详述。
④ max_output_tokens_escalate — 输出上限一次性升档
const capEnabled = getFeatureValue_CACHED_MAY_BE_STALE('tengu_otk_slot_v1', false)
if (capEnabled &&
maxOutputTokensOverride === undefined && // ★ 还没升过档
!process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) { // ★ 用户没手动指定
logEvent('tengu_max_tokens_escalate', { escalatedTo: ESCALATED_MAX_TOKENS })
state = { ..., maxOutputTokensOverride: ESCALATED_MAX_TOKENS,
transition: { reason: 'max_output_tokens_escalate' } }
continue
}
逻辑:默认的输出上限是 8,000 token(为了控制成本)。如果被截断了,先原样重发一次,只把上限提到 64,000 —— 不加任何提示消息,不打扰模型的思路。这一步每轮只做一次。
⑤ max_output_tokens_recovery — 多轮续写
if (maxOutputTokensRecoveryCount < MAX_OUTPUT_TOKENS_RECOVERY_LIMIT) { // 上限 3
const recoveryMessage = createUserMessage({
content:
`Output token limit hit. Resume directly — no apology, no recap ` +
`of what you were doing. Pick up mid-thought if that is where the ` +
`cut happened. Break remaining work into smaller pieces.`,
isMeta: true, // ★ 只发给模型,不显示给用户
})
state = { messages: [...messagesForQuery, ...assistantMessages, recoveryMessage],
maxOutputTokensRecoveryCount: maxOutputTokensRecoveryCount + 1,
maxOutputTokensOverride: undefined, // 升档标记清掉,允许下次再升
transition: { reason: 'max_output_tokens_recovery',
attempt: maxOutputTokensRecoveryCount + 1 } }
continue
}
// 3 次都用完了 —— 把扣留的错误吐出去
yield lastMessage
那条续写指令的三句话各自解决一个问题:
| 指令 | 解决什么 |
|---|---|
| 「不要道歉」 | 模型的默认行为是先说「抱歉,我的回复被截断了」。这句话要花钱且零信息量 |
| 「从句子中间接上」 | 不明确许可的话,模型倾向于把刚才那段重说一遍再往下写,浪费更多 token |
| 「拆成更小的块」 | 防止下一次又被截断,陷入反复截断的循环 |
⑥ stop_hook_blocking — 结束前检查不通过
const stopHookResult = yield* handleStopHooks(...)
if (stopHookResult.preventContinuation) return { reason: 'stop_hook_prevented' }
if (stopHookResult.blockingErrors.length > 0) {
state = {
messages: [...messagesForQuery, ...assistantMessages, ...stopHookResult.blockingErrors],
maxOutputTokensRecoveryCount: 0,
// ★★★ 这一行是全文最重要的注释所在
hasAttemptedReactiveCompact, // 保留!不重置!
stopHookActive: true,
transition: { reason: 'stop_hook_blocking' },
}
continue
}
「Preserve the reactive compact guard — if compact already ran and couldn't recover from prompt-too-long, retrying after a stop-hook blocking error will produce the same result. Resetting to false here caused an infinite loop: compact → still too long → error → stop hook blocking → compact → … burning thousands of API calls.」
译:保留反应式压缩的守卫标记 —— 如果压缩已经跑过而且没能从「上下文过长」中恢复,那么在结束钩子阻断错误之后重试会得到同样的结果。在这里把它重置成 false 曾造成一个无限循环:压缩 → 还是太长 → 报错 → 结束钩子阻断 → 又去压缩 → …… 烧掉了几千次 API 调用。
请注意这个循环的形状:它不是一条路径自己转圈,而是两条恢复路径互相触发。压缩路径有「每轮一次」的锁,钩子路径有自己的终止条件,两条单看都是安全的 —— 但组合起来,钩子路径把压缩路径的锁清掉了,于是形成了闭环。
⑦ token_budget_continuation — 预算没用完,继续深挖
if (feature('TOKEN_BUDGET')) {
const decision = checkTokenBudget(budgetTracker!, toolUseContext.agentId,
getCurrentTurnTokenBudget(), getTurnOutputTokens())
if (decision.action === 'continue') {
incrementBudgetContinuationCount()
state = { messages: [...messagesForQuery, ...assistantMessages,
createUserMessage({ content: decision.nudgeMessage, isMeta: true })],
transition: { reason: 'token_budget_continuation' } }
continue
}
if (decision.completionEvent?.diminishingReturns) {
logForDebugging(`Token budget early stop: diminishing returns at ${...}%`)
}
}
这是一个反向的机制:不是「防止用太多」,而是「用户明确给了预算,就把它用足」。而且有「收益递减」检测 —— 如果发现继续深挖已经产出不了新东西,提前停止而不是把预算烧完。
3.5 错误扣留机制
在流式循环内部,三类可恢复错误不会被吐给外部调用方:
let withheld = false // withheld = 被扣留
if (feature('CONTEXT_COLLAPSE')) {
if (contextCollapse?.isWithheldPromptTooLong(message, isPromptTooLongMessage, querySource))
withheld = true
}
if (reactiveCompact?.isWithheldPromptTooLong(message)) withheld = true
if (mediaRecoveryEnabled &&
reactiveCompact?.isWithheldMediaSizeError(message)) withheld = true
if (isWithheldMaxOutputTokens(message)) withheld = true
if (!withheld) { yield yieldMessage } // 只有没被扣的才吐出去
// ★ 但无论扣不扣,都要放进内部数组,供下面的恢复逻辑找到它
if (message.type === 'assistant') assistantMessages.push(message)
「Yielding early leaks an intermediate error to SDK callers (e.g. cowork/desktop) that terminate the session on any error field — the recovery loop keeps running but nobody is listening.」
译:过早吐出会把一个中间状态的错误泄露给外部调用方(比如桌面应用),而那些调用方看到任何 error 字段就终止会话 —— 于是恢复循环还在勤勤恳恳地跑,但已经没有人在听了。
这是「内部可恢复状态不应泄露到外部协议」的经典案例。任何做流式接口的服务端都会遇到同类问题。
只有当所有恢复手段都用尽时,才把它吐出去:yield lastMessage。
3.6 中断处理
这是自建智能体最容易漏、也最容易在生产暴雷的地方。
问题的形状
解法
if (toolUseContext.abortController.signal.aborted) {
if (streamingToolExecutor) {
// 用了流式执行器:消费 getRemainingResults()
// 它会为"排队中"和"执行中"的工具生成合成的(假造的)执行结果
for await (const update of streamingToolExecutor.getRemainingResults()) {
if (update.message) yield update.message
}
} else {
// 没用流式执行器:为每个 tool_use 兜底造一条标记为错误的结果
yield* yieldMissingToolResultBlocks(assistantMessages, 'Interrupted by user')
}
...
// 中断消息:如果是"提交式中断"(用户发了新消息导致的中断)就不发,
// 因为紧随其后的那条用户消息本身就说明了情况
if (toolUseContext.abortController.signal.reason !== 'interrupt') {
yield createUserInterruptionMessage({ toolUse: false })
}
return { reason: 'aborted_streaming' }
}
那个兜底函数很短,但它是整个系统的安全网:
function* yieldMissingToolResultBlocks(assistantMessages, errorMessage) {
for (const assistantMessage of assistantMessages) {
const toolUseBlocks = assistantMessage.message.content
.filter(content => content.type === 'tool_use')
for (const toolUse of toolUseBlocks) {
yield createUserMessage({
content: [{ type:'tool_result', content: errorMessage,
is_error: true,
tool_use_id: toolUse.id }], // ★ id 必须对上
toolUseResult: errorMessage,
sourceToolAssistantUUID: assistantMessage.uuid,
})
}
}
}
它在四个地方被调用:用户中断时、切换备用模型时、流式请求失败回退时、以及最外层的异常捕获里。
凡是可能在「已经发出工具调用、但还没产生执行结果」这个时间窗口里退出的代码路径,都必须补齐合成结果。
规则只有一条:每一个 tool_use 的 id,必须有一个 tool_result 带着同一个 id 回应它。内容是什么不重要,可以标记为错误 —— 但配对必须完整。
这个 bug 的症状很有迷惑性:「用户一按 Ctrl+C,会话就再也恢复不了了」,而报错信息通常只说「请求格式不合法」,完全不提是哪里不合法。
3.7 模型降级:三个动作
主模型过载(服务器返回容量不足)时切换到备用模型。Claude Code 做三件事:
catch (innerError) {
if (innerError instanceof FallbackTriggeredError && fallbackModel) {
currentModel = fallbackModel
attemptWithFallback = true
// 动作 1:为所有已发出的工具调用补合成结果
yield* yieldMissingToolResultBlocks(assistantMessages, 'Model fallback triggered')
assistantMessages.length = 0
toolResults.length = 0
toolUseBlocks.length = 0
needsFollowUp = false
// 动作 2:丢弃流式执行器里的待定结果,重建一个
// 避免带旧 tool_use_id 的孤儿结果泄露到重试后的请求里
if (streamingToolExecutor) {
streamingToolExecutor.discard()
streamingToolExecutor = new StreamingToolExecutor(...)
}
toolUseContext.options.mainLoopModel = fallbackModel
// 动作 3:★ 剥离思考块签名
if (process.env.USER_TYPE === 'ant') {
messagesForQuery = stripSignatureBlocks(messagesForQuery)
}
...
yield createSystemMessage(
`Switched to ${renderModelName(innerError.fallbackModel)} due to high demand for ...`,
'warning')
continue
}
throw innerError
}
第三个动作的注释:
「Thinking signatures are model-bound: replaying a protected-thinking block (e.g. capybara) to an unprotected fallback (e.g. opus) 400s. Strip before retry so the fallback model gets clean history.」
译:思考块的签名是和模型绑定的:把一个受保护的思考块重放给一个不受保护的备用模型会返回 400 错误。所以重试前先剥掉签名,让备用模型拿到干净的历史。
(思考块:较新的模型在正式回答前会做一段内部推理,这段推理可以被返回给调用方。为防篡改它带有加密签名,而签名是特定模型生成的,换模型验证不通过。)
3.8 思考块三定律
文件上方有一段写得像魔法书的注释,但内容是真实的硬约束:
- 含有 thinking 或 redacted_thinking 块的消息,必须出现在一个
max_thinking_length > 0的请求里。 - thinking 块不能是内容序列里的最后一个元素。
- thinking 块必须在整条模型轨迹期间完整保留 —— 一条轨迹指:一个轮次,如果这个轮次里含有工具调用,那么还要包括其后的工具执行结果以及紧接着的下一条模型回复。
「Heed these rules well, young wizard. For they are the rules of thinking, and the rules of thinking are the rules of the universe. If ye does not heed these rules, ye will be punished with an entire day of debugging and hair pulling.」
译:好好遵守这些规则,年轻的巫师。因为这是思考的法则,而思考的法则就是宇宙的法则。若你不遵守,你将受到整整一天调试与揪头发的惩罚。
第 3 条直接约束了第 6 章所有上下文压缩的实现:压缩、截断、重放这三种操作,任何一处如果切在了「思考块轨迹」的中间,接口就会拒绝整个请求。
这意味着不能简单地说「保留最后 6 条消息,前面全压缩」—— 如果第 7 条是思考块、第 6 条是它对应的工具结果,你就把一条完整轨迹劈成了两半。保护窗口的边界必须落在轨迹的缝隙上。
3.9 循环里的其他机制
查询链路追踪
const queryTracking = toolUseContext.queryTracking
? { chainId: toolUseContext.queryTracking.chainId,
depth: toolUseContext.queryTracking.depth + 1 } // 子智能体深度 +1
: { chainId: deps.uuid(), depth: 0 } // 顶层,新建链路
每一次模型调用都带一个「链路 ID + 深度」。主智能体深度 0,它派生的子智能体深度 1,以此类推。所有埋点都带上这两个字段 —— 这样在分析数据时可以把一次用户请求引发的所有模型调用(包括所有子智能体的)串成一棵树。
队列中的消息:轮次中途注入
const sleepRan = toolUseBlocks.some(b => b.name === SLEEP_TOOL_NAME)
const isMainThread = querySource.startsWith('repl_main_thread') || querySource === 'sdk'
const currentAgentId = toolUseContext.agentId
const queuedCommandsSnapshot = getCommandsByMaxPriority(sleepRan ? 'later' : 'next')
.filter(cmd => {
if (isSlashCommand(cmd)) return false // 斜杠命令不能中途注入
if (isMainThread) return cmd.agentId === undefined
// ★ 子智能体只取发给自己的任务通知,永远拿不到用户提问
return cmd.mode === 'task-notification' && cmd.agentId === currentAgentId
})
这段处理的是:用户在模型思考期间又发了一条消息,或者某个后台任务完成了要通知模型。
关键的隔离规则在注释里:
「Agent scoping: the queue is a process-global singleton shared by the coordinator and all in-process subagents. Each loop drains only what's addressed to it — main thread drains agentId===undefined, subagents drain their own agentId. User prompts (mode:'prompt') still go to main only; subagents never see the prompt stream.」
译:智能体作用域隔离:这个队列是进程级的全局单例,被协调者和所有进程内子智能体共享。每个循环只取走发给自己的东西 —— 主线程取 agentId 为空的,子智能体取自己 id 的。用户提问仍然只发给主线程;子智能体永远看不到提问流。
工具摘要:用便宜的小模型异步生成
if (config.gates.emitToolUseSummaries && toolUseBlocks.length > 0 &&
!toolUseContext.abortController.signal.aborted &&
!toolUseContext.agentId) { // ★ 子智能体不生成(不会显示在界面上)
...
// 发起摘要生成,但不等它 —— 结果传给下一轮
nextPendingToolUseSummary = generateToolUseSummary({...})
.then(summary => summary ? createToolUseSummaryMessage(summary, toolUseIds) : null)
.catch(() => null)
}
这个摘要用的是 Haiku(更小更便宜的模型),耗时约 1 秒。而它是在上一轮发起、下一轮消费的:
// 下一轮开头
if (pendingToolUseSummary) {
const summary = await pendingToolUseSummary
if (summary) yield summary
}
注释:「Yield tool use summary from previous turn — haiku (~1s) resolved during model streaming (5-30s)」(发出上一轮的工具摘要 —— Haiku 约 1 秒,在模型流式输出的 5 到 30 秒期间就完成了)。
把慢操作藏进主流程的等待窗口里。模型流式返回要 5 到 30 秒,这段时间程序基本闲着。
主循环里至少有三件事藏在这个窗口:记忆预取、技能发现预取、工具摘要生成。
关键前提是:消费点必须设计成「好了就用,没好就跳过」,绝不阻塞。一旦开始等待,这个优化就变成了负优化。
记忆预取的消费方式
if (pendingMemoryPrefetch &&
pendingMemoryPrefetch.settledAt !== null && // ★ 已完成才消费
pendingMemoryPrefetch.consumedOnIteration === -1) { // ★ 还没消费过
const memoryAttachments = filterDuplicateMemoryAttachments(
await pendingMemoryPrefetch.promise,
toolUseContext.readFileState, // ★ 用已读文件状态过滤,避免重复注入
)
for (const memAttachment of memoryAttachments) { ... }
pendingMemoryPrefetch.consumedOnIteration = turnCount - 1
}
注释:「only if settled and not already consumed on an earlier iteration. If not settled yet, skip (zero-wait) and retry next iteration — the prefetch gets as many chances as there are loop iterations before the turn ends.」
译:只有在已完成且尚未在更早的迭代里被消费过时才消费。如果还没完成,跳过(零等待),下次迭代再试 —— 预取有多少次迭代就有多少次机会。
3.10 循环的所有退出点
| 返回值 | 什么情况 |
|---|---|
{ reason: 'completed' } | 模型这轮没有工具调用,任务完成 |
{ reason: 'blocking_limit' } | 上下文超过硬阻断线(只在自动压缩被关闭时可能发生) |
{ reason: 'model_error', error } | 模型调用抛了未预期的异常 |
{ reason: 'image_error' } | 图片尺寸或大小超限,且恢复失败 |
{ reason: 'prompt_too_long' } | 上下文过长,三级恢复全部失败 |
{ reason: 'aborted_streaming' } | 模型流式返回期间被中断 |
{ reason: 'aborted_tools' } | 工具执行期间被中断 |
{ reason: 'hook_stopped' } | 某个钩子明确要求停止 |
{ reason: 'stop_hook_prevented' } | 结束钩子阻止了继续 |
{ reason: 'max_turns', turnCount } | 达到最大轮次 |
十个具名退出点。每一个都能在数据分析里被单独统计 —— 这是可观测性的基础(第 12 章)。
3 · The Agent Main Loop ★
query.ts, 1,730 lines. This is the single most important file in the whole system.
3.1 The skeleton of the loop
Start by peeling out the outermost structure, with every detail stripped away:
async function* queryLoop(params, consumedCommandUuids) {
// —— Immutable parameters; never reassigned for the life of the loop ——
const { systemPrompt, userContext, systemContext, canUseTool,
fallbackModel, querySource, maxTurns, skipCacheWrite } = params
// —— Mutable cross-iteration state, gathered into one struct ——
let state: State = { messages: params.messages, ... }
// —— One-time setup ——
const config = buildQueryConfig() // snapshot the environment and feature flags
using pendingMemoryPrefetch = startRelevantMemoryPrefetch(...) // memory prefetch
while (true) {
// 1. Destructure what this iteration needs out of state
// 2. The five-tier context management pipeline → Chapter 6
// 3. Call the model (streaming)
// └─ execute tools while streaming → Chapter 5
// 4. Error recovery decisions → may continue back to the top of the loop
// 5. No tool calls → return (done)
// 6. Execute the remaining tools → Chapter 5
// 7. Collect attachments, process queued messages
// 8. state = {...new state}; on to the next iteration
}
}
(using is JavaScript’s newer “explicit resource management” syntax: no matter which path the function exits through — normal return, thrown exception, closed from outside — the resource gets cleaned up. Generator functions have many exit paths, and missing any one of them means a resource leak.)
3.2 State: centralizing cross-iteration state
type State = {
messages: Message[] // the current full message history
toolUseContext: ToolUseContext // the context object tool execution needs
// —— Recovery bookkeeping ——
autoCompactTracking: AutoCompactTrackingState | undefined
maxOutputTokensRecoveryCount: number // how many times output truncation has been retried
hasAttemptedReactiveCompact: boolean // whether reactive compaction already ran this turn (idempotency guard)
maxOutputTokensOverride: number | undefined // whether the output cap has already been escalated
stopHookActive: boolean | undefined
pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
turnCount: number
transition: Continue | undefined // ★ "why" the previous iteration continued
}
claude-code/src/query.ts
A comment in the source explains why it is centralized:
“Mutable cross-iteration state. The loop body destructures this at the top of each iteration so reads stay bare-name (messages, toolUseContext). Continue sites write state = { ... } instead of 9 separate assignments.”
In other words: mutable cross-iteration state. The loop body destructures it at the top of each iteration, so reads stay as short bare names. Each continue site writes “replace state wholesale” rather than 9 separate assignment statements.
Suppose there are 9 cross-iteration state fields. With 9 separate assignment statements, every recovery path has to remember what to set each of those 9 fields to. Miss one — say, forget to reset a counter — and you have a bug that is extremely hard to track down.
Switch to “replace wholesale,” and every recovery path must spell out the value of all 9 fields explicitly. Leave one out and the TypeScript compiler errors on the spot. An “easy to forget” problem becomes a “does not compile” problem.
3.3 transition: a field that exists purely for testability
transition takes part in no business logic. Its sole purpose is to record “why the previous loop iteration continued.” The comment says so directly:
“Why the previous iteration continued. Undefined on first iteration. Lets tests assert recovery paths fired without inspecting message contents.”
In other words: why the previous iteration continued. Undefined on the first iteration. It lets tests assert directly that a given recovery path fired, without having to inspect message contents.
| Without this field, a test has to be written like this | With it |
|---|---|
| Dig through the message array and check whether a particular prompt string shows up. Problem: the test is tightly coupled to the copy. Someone changes one word and the test goes red — even though nothing is broken. Brittle tests like this eventually get disabled by the team. |
expect(transition.reason)Benefit: it tests the fact of “which path was taken,” fully decoupled from copy and message format. |
3.4 The seven transition edges, one by one
① next_turn — normal progress
const next: State = {
messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
toolUseContext: toolUseContextWithQueryTracking,
autoCompactTracking: tracking,
turnCount: nextTurnCount,
maxOutputTokensRecoveryCount: 0, // ★ reset
hasAttemptedReactiveCompact: false, // ★ reset
pendingToolUseSummary: nextPendingToolUseSummary,
maxOutputTokensOverride: undefined, // ★ reset
stopHookActive,
transition: { reason: 'next_turn' },
}
state = next
Note that this is the only path that resets the recovery counters. None of the six that follow do. That rule is the heart of the defense against infinite loops.
② collapse_drain_retry — context too long; drain the collapse first
if (isWithheld413) {
if (feature('CONTEXT_COLLAPSE') && contextCollapse &&
state.transition?.reason !== 'collapse_drain_retry') { // ★ only try if the last iteration wasn't this path
const drained = contextCollapse.recoverFromOverflow(messagesForQuery, querySource)
if (drained.committed > 0) { // something was actually drained
state = { messages: drained.messages, ...,
transition: { reason: 'collapse_drain_retry', committed: drained.committed } }
continue
}
}
}
The way it limits retries is interesting: instead of a boolean lock, it checks “was the previous iteration’s transition this very path?” If the last iteration already drained once and this one is still a 413, draining is not going to solve the problem, so skip straight to the next tier.
③ reactive_compact_retry — reactive full summarization
if ((isWithheld413 || isWithheldMedia) && reactiveCompact) {
const compacted = await reactiveCompact.tryReactiveCompact({
hasAttempted: hasAttemptedReactiveCompact, // ★ the idempotency guard is passed in
querySource, aborted: ..., messages: messagesForQuery,
cacheSafeParams: { systemPrompt, userContext, systemContext,
toolUseContext, forkContextMessages: messagesForQuery },
})
if (compacted) {
const postCompactMessages = buildPostCompactMessages(compacted)
for (const msg of postCompactMessages) yield msg
state = { messages: postCompactMessages, ...,
hasAttemptedReactiveCompact: true, // ★ lock it
transition: { reason: 'reactive_compact_retry' } }
continue
}
// Recovery failed — emit the withheld error and finish
yield lastMessage
void executeStopFailureHooks(lastMessage, toolUseContext)
return { reason: isWithheldMedia ? 'image_error' : 'prompt_too_long' }
}
Note cacheSafeParams — compaction itself makes a model call, and that call reuses the current session’s cache prefix, so the system prompt and the other parameters must be passed through unchanged. Chapter 6 goes into detail.
④ max_output_tokens_escalate — a one-time bump of the output cap
const capEnabled = getFeatureValue_CACHED_MAY_BE_STALE('tengu_otk_slot_v1', false)
if (capEnabled &&
maxOutputTokensOverride === undefined && // ★ hasn't been escalated yet
!process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) { // ★ the user hasn't set it manually
logEvent('tengu_max_tokens_escalate', { escalatedTo: ESCALATED_MAX_TOKENS })
state = { ..., maxOutputTokensOverride: ESCALATED_MAX_TOKENS,
transition: { reason: 'max_output_tokens_escalate' } }
continue
}
The logic: the default output cap is 8,000 tokens (to control cost). If the output gets truncated, first resend the request unchanged, raising only the cap to 64,000 — no extra prompt message, nothing to disturb the model’s train of thought. This step happens at most once per turn.
⑤ max_output_tokens_recovery — multi-round continuation
if (maxOutputTokensRecoveryCount < MAX_OUTPUT_TOKENS_RECOVERY_LIMIT) { // limit is 3
const recoveryMessage = createUserMessage({
content:
`Output token limit hit. Resume directly — no apology, no recap ` +
`of what you were doing. Pick up mid-thought if that is where the ` +
`cut happened. Break remaining work into smaller pieces.`,
isMeta: true, // ★ sent to the model only; not shown to the user
})
state = { messages: [...messagesForQuery, ...assistantMessages, recoveryMessage],
maxOutputTokensRecoveryCount: maxOutputTokensRecoveryCount + 1,
maxOutputTokensOverride: undefined, // clear the escalation flag so it can escalate again next time
transition: { reason: 'max_output_tokens_recovery',
attempt: maxOutputTokensRecoveryCount + 1 } }
continue
}
// All 3 attempts used up — emit the withheld error
yield lastMessage
Each of the three sentences in that continuation instruction solves a specific problem:
| Instruction | What it solves |
|---|---|
| “No apology” | The model’s default is to open with “Sorry, my response was cut off.” That sentence costs money and carries zero information |
| “Pick up mid-thought” | Without explicit permission, the model tends to restate the previous chunk before moving on, wasting even more tokens |
| “Break it into smaller pieces” | Prevents the next response from being truncated too, which would trap it in a cycle of repeated truncation |
⑥ stop_hook_blocking — a pre-finish check fails
const stopHookResult = yield* handleStopHooks(...)
if (stopHookResult.preventContinuation) return { reason: 'stop_hook_prevented' }
if (stopHookResult.blockingErrors.length > 0) {
state = {
messages: [...messagesForQuery, ...assistantMessages, ...stopHookResult.blockingErrors],
maxOutputTokensRecoveryCount: 0,
// ★★★ This line is where the most important comment in the whole book lives
hasAttemptedReactiveCompact, // Preserve it! Do NOT reset!
stopHookActive: true,
transition: { reason: 'stop_hook_blocking' },
}
continue
}
“Preserve the reactive compact guard — if compact already ran and couldn't recover from prompt-too-long, retrying after a stop-hook blocking error will produce the same result. Resetting to false here caused an infinite loop: compact → still too long → error → stop hook blocking → compact → … burning thousands of API calls.”
In other words: preserve the reactive compaction guard flag — if compaction has already run and could not recover from “prompt too long,” retrying after a stop-hook blocking error will produce the same result. Resetting it to false here once caused an infinite loop: compact → still too long → error → stop hook blocks → compact again → … burning thousands of API calls.
Pay attention to the shape of this loop: it is not one path spinning on itself; it is two recovery paths triggering each other. The compaction path has a “once per turn” lock, and the hook path has its own termination condition — each is safe on its own. But combined, the hook path cleared the compaction path’s lock, and a closed cycle formed.
⑦ token_budget_continuation — budget not exhausted; keep digging
if (feature('TOKEN_BUDGET')) {
const decision = checkTokenBudget(budgetTracker!, toolUseContext.agentId,
getCurrentTurnTokenBudget(), getTurnOutputTokens())
if (decision.action === 'continue') {
incrementBudgetContinuationCount()
state = { messages: [...messagesForQuery, ...assistantMessages,
createUserMessage({ content: decision.nudgeMessage, isMeta: true })],
transition: { reason: 'token_budget_continuation' } }
continue
}
if (decision.completionEvent?.diminishingReturns) {
logForDebugging(`Token budget early stop: diminishing returns at ${...}%`)
}
}
This mechanism runs in the opposite direction: not “prevent using too much,” but “the user explicitly gave a budget, so use it fully.” And it has “diminishing returns” detection — if continuing to dig is no longer producing anything new, it stops early instead of burning through the budget.
3.5 The error-withholding mechanism
Inside the streaming loop, three kinds of recoverable error are never emitted to the external caller:
let withheld = false // withheld = held back
if (feature('CONTEXT_COLLAPSE')) {
if (contextCollapse?.isWithheldPromptTooLong(message, isPromptTooLongMessage, querySource))
withheld = true
}
if (reactiveCompact?.isWithheldPromptTooLong(message)) withheld = true
if (mediaRecoveryEnabled &&
reactiveCompact?.isWithheldMediaSizeError(message)) withheld = true
if (isWithheldMaxOutputTokens(message)) withheld = true
if (!withheld) { yield yieldMessage } // only messages that weren't withheld get emitted
// ★ But withheld or not, it goes into the internal array so the recovery logic below can find it
if (message.type === 'assistant') assistantMessages.push(message)
“Yielding early leaks an intermediate error to SDK callers (e.g. cowork/desktop) that terminate the session on any error field — the recovery loop keeps running but nobody is listening.”
In other words: emitting too early leaks an intermediate-state error to external callers (such as the desktop app), and those callers terminate the session on sight of any error field — so the recovery loop keeps dutifully running, but nobody is listening anymore.
This is the classic case of “internal recoverable state must not leak into the external protocol.” Anyone building the server side of a streaming interface will run into the same class of problem.
Only when every recovery option is exhausted is it emitted: yield lastMessage.
3.6 Interrupt handling
This is the part that home-grown agents most often miss, and the part most likely to blow up in production.
The shape of the problem
The fix
if (toolUseContext.abortController.signal.aborted) {
if (streamingToolExecutor) {
// Streaming executor in use: consume getRemainingResults()
// It generates synthetic (fabricated) results for tools that are "queued" or "executing"
for await (const update of streamingToolExecutor.getRemainingResults()) {
if (update.message) yield update.message
}
} else {
// No streaming executor: fabricate a fallback result marked as an error for every tool_use
yield* yieldMissingToolResultBlocks(assistantMessages, 'Interrupted by user')
}
...
// Interruption message: skip it if this is a "submit interrupt" (caused by the user sending a new message),
// because the user message that follows immediately explains the situation on its own
if (toolUseContext.abortController.signal.reason !== 'interrupt') {
yield createUserInterruptionMessage({ toolUse: false })
}
return { reason: 'aborted_streaming' }
}
That fallback function is short, but it is the safety net for the entire system:
function* yieldMissingToolResultBlocks(assistantMessages, errorMessage) {
for (const assistantMessage of assistantMessages) {
const toolUseBlocks = assistantMessage.message.content
.filter(content => content.type === 'tool_use')
for (const toolUse of toolUseBlocks) {
yield createUserMessage({
content: [{ type:'tool_result', content: errorMessage,
is_error: true,
tool_use_id: toolUse.id }], // ★ the id must match
toolUseResult: errorMessage,
sourceToolAssistantUUID: assistantMessage.uuid,
})
}
}
}
It is called from four places: on user interrupt, on switching to the fallback model, on falling back after a streaming request fails, and in the outermost exception handler.
Any code path that can exit inside the window between “tool calls have been issued” and “execution results have been produced” must fill in synthetic results.
There is only one rule: every tool_use id must be answered by a tool_result carrying the same id. The content does not matter, and it can be marked as an error — but the pairing must be complete.
The symptom of this bug is deceptive: “the moment the user presses Ctrl+C, the session can never be resumed again,” and the error message usually says only “malformed request,” with no hint of what is malformed.
3.7 Model fallback: three actions
When the primary model is overloaded (the server reports insufficient capacity), the loop switches to the fallback model. Claude Code does three things:
catch (innerError) {
if (innerError instanceof FallbackTriggeredError && fallbackModel) {
currentModel = fallbackModel
attemptWithFallback = true
// Action 1: fill in synthetic results for every tool call already issued
yield* yieldMissingToolResultBlocks(assistantMessages, 'Model fallback triggered')
assistantMessages.length = 0
toolResults.length = 0
toolUseBlocks.length = 0
needsFollowUp = false
// Action 2: discard the pending results in the streaming executor and build a fresh one
// so orphaned results carrying old tool_use_ids don't leak into the retried request
if (streamingToolExecutor) {
streamingToolExecutor.discard()
streamingToolExecutor = new StreamingToolExecutor(...)
}
toolUseContext.options.mainLoopModel = fallbackModel
// Action 3: ★ strip thinking-block signatures
if (process.env.USER_TYPE === 'ant') {
messagesForQuery = stripSignatureBlocks(messagesForQuery)
}
...
yield createSystemMessage(
`Switched to ${renderModelName(innerError.fallbackModel)} due to high demand for ...`,
'warning')
continue
}
throw innerError
}
The comment on the third action:
“Thinking signatures are model-bound: replaying a protected-thinking block (e.g. capybara) to an unprotected fallback (e.g. opus) 400s. Strip before retry so the fallback model gets clean history.”
In other words: thinking-block signatures are bound to a model: replaying a protected thinking block to an unprotected fallback model returns a 400 error. So strip the signatures before retrying, and the fallback model gets a clean history.
(Thinking blocks: newer models do a stretch of internal reasoning before the actual answer, and that reasoning can be returned to the caller. To prevent tampering it carries a cryptographic signature, and since the signature is generated by a specific model, verification fails when you switch models.)
3.8 The three laws of thinking blocks
Near the top of the file is a comment written like a spellbook, but its content is a set of real hard constraints:
- A message containing a thinking or redacted_thinking block must appear in a request with
max_thinking_length > 0. - A thinking block cannot be the last element in the content sequence.
- Thinking blocks must be preserved intact for the entire model trajectory — where a trajectory means: one turn, and if that turn contains tool calls, also the tool results that follow and the very next model reply after them.
“Heed these rules well, young wizard. For they are the rules of thinking, and the rules of thinking are the rules of the universe. If ye does not heed these rules, ye will be punished with an entire day of debugging and hair pulling.”
In other words: heed these rules well, young wizard. For they are the laws of thinking, and the laws of thinking are the laws of the universe. Ignore them, and your punishment will be a full day of debugging and hair-pulling.
Rule 3 directly constrains every context compaction implementation in Chapter 6: compaction, truncation, and replay — if any one of them cuts through the middle of a “thinking-block trajectory,” the API rejects the entire request.
That means you cannot simply say “keep the last 6 messages and compact everything before them” — if message 7 is a thinking block and message 6 is its corresponding tool result, you have chopped a complete trajectory in half. The boundary of the protected window must fall in a gap between trajectories.
3.9 Other mechanisms in the loop
Query chain tracing
const queryTracking = toolUseContext.queryTracking
? { chainId: toolUseContext.queryTracking.chainId,
depth: toolUseContext.queryTracking.depth + 1 } // subagent depth +1
: { chainId: deps.uuid(), depth: 0 } // top level: start a new chain
Every model call carries a “chain ID + depth.” The main agent is depth 0, a subagent it spawns is depth 1, and so on. Every telemetry event carries these two fields — so when analyzing the data, every model call triggered by a single user request (including all the subagents’) can be strung together into a tree.
Queued messages: mid-turn injection
const sleepRan = toolUseBlocks.some(b => b.name === SLEEP_TOOL_NAME)
const isMainThread = querySource.startsWith('repl_main_thread') || querySource === 'sdk'
const currentAgentId = toolUseContext.agentId
const queuedCommandsSnapshot = getCommandsByMaxPriority(sleepRan ? 'later' : 'next')
.filter(cmd => {
if (isSlashCommand(cmd)) return false // slash commands can't be injected mid-turn
if (isMainThread) return cmd.agentId === undefined
// ★ Subagents only take task notifications addressed to them; they never get user prompts
return cmd.mode === 'task-notification' && cmd.agentId === currentAgentId
})
This handles the case where the user sends another message while the model is thinking, or a background task finishes and needs to notify the model.
The key isolation rule is in the comment:
“Agent scoping: the queue is a process-global singleton shared by the coordinator and all in-process subagents. Each loop drains only what's addressed to it — main thread drains agentId===undefined, subagents drain their own agentId. User prompts (mode:'prompt') still go to main only; subagents never see the prompt stream.”
In other words: agent scoping: the queue is a process-wide global singleton shared by the coordinator and every in-process subagent. Each loop drains only what is addressed to it — the main thread takes entries with an empty agentId, subagents take their own id. User prompts still go only to the main thread; subagents never see the prompt stream.
Tool summaries: generated asynchronously by a cheap small model
if (config.gates.emitToolUseSummaries && toolUseBlocks.length > 0 &&
!toolUseContext.abortController.signal.aborted &&
!toolUseContext.agentId) { // ★ subagents don't generate them (they're not shown in the UI)
...
// Kick off summary generation but don't wait for it — the result is passed to the next iteration
nextPendingToolUseSummary = generateToolUseSummary({...})
.then(summary => summary ? createToolUseSummaryMessage(summary, toolUseIds) : null)
.catch(() => null)
}
This summary uses Haiku (a smaller, cheaper model) and takes about 1 second. It is kicked off in one iteration and consumed in the next:
// At the top of the next iteration
if (pendingToolUseSummary) {
const summary = await pendingToolUseSummary
if (summary) yield summary
}
Comment: “Yield tool use summary from previous turn — haiku (~1s) resolved during model streaming (5-30s)” (emit the previous turn’s tool summary — Haiku takes about 1 second, and it finishes during the 5 to 30 seconds the model spends streaming).
Hide slow operations inside the main flow’s waiting window. The model takes 5 to 30 seconds to stream a response, and during that time the program is essentially idle.
At least three things in the main loop are hidden in that window: memory prefetch, skill discovery prefetch, and tool summary generation.
The key precondition: the consumption point must be designed as “use it if it’s ready, skip it if not,” and never block. The moment you start waiting, the optimization turns into a pessimization.
How the memory prefetch is consumed
if (pendingMemoryPrefetch &&
pendingMemoryPrefetch.settledAt !== null && // ★ consume only once settled
pendingMemoryPrefetch.consumedOnIteration === -1) { // ★ and not yet consumed
const memoryAttachments = filterDuplicateMemoryAttachments(
await pendingMemoryPrefetch.promise,
toolUseContext.readFileState, // ★ filter by already-read file state to avoid duplicate injection
)
for (const memAttachment of memoryAttachments) { ... }
pendingMemoryPrefetch.consumedOnIteration = turnCount - 1
}
Comment: “only if settled and not already consumed on an earlier iteration. If not settled yet, skip (zero-wait) and retry next iteration — the prefetch gets as many chances as there are loop iterations before the turn ends.”
In other words: consume it only if it has settled and was not already consumed on an earlier iteration. If it has not settled yet, skip it (zero wait) and try again next iteration — the prefetch gets as many chances as there are iterations before the turn ends.
3.10 Every exit point of the loop
| Return value | When |
|---|---|
{ reason: 'completed' } | The model made no tool calls this turn; the task is done |
{ reason: 'blocking_limit' } | Context exceeded the hard blocking threshold (only possible when auto-compaction is disabled) |
{ reason: 'model_error', error } | The model call threw an unexpected exception |
{ reason: 'image_error' } | An image exceeded the dimension or size limit, and recovery failed |
{ reason: 'prompt_too_long' } | Context too long; all three tiers of recovery failed |
{ reason: 'aborted_streaming' } | Interrupted while the model was streaming |
{ reason: 'aborted_tools' } | Interrupted during tool execution |
{ reason: 'hook_stopped' } | A hook explicitly asked to stop |
{ reason: 'stop_hook_prevented' } | A stop hook prevented continuation |
{ reason: 'max_turns', turnCount } | Reached the maximum number of turns |
Ten named exit points. Each one can be counted separately in data analysis — the foundation of observability (Chapter 12).