中军帐
深夜,中军帐。军师被一道军令请进帐来:查清敌军粮道。
帐外站着行营长史。他先把军令誊入官册,把此前所有战报按序排好,才掀帘通报:"升帐。"
每一轮议事都是同一套规矩。开帐之前,主簿先收拾案几:过期的斥候回报捆起来塞进箱底,太长的军报裁去冗余,实在堆不下,就把整段旧事浓缩成一页纪要——案几就那么大,今晚要议的东西必须摆得下。
然后军师开口。他边想边说,传令官守在帐口,一句一句往外传。说话间,军师会随手抽出令箭:"派斥候探北谷。""调粮册来对账。"令箭一出帐,自有人领命而去——甚至不等军师把这轮话说完,先出的令箭已经有人在办了。
散不散会,规矩很怪,但很牢靠:不听军师收尾那句话——他有时说"且容我再想",有时干脆什么也不说——只数这一轮他到底发没发令箭。发了,就必有回执:斥候的回报、粮官的册子,一件件摆上案几,下一轮升帐接着议。一支都没发,才算议毕。
也有例外。帐中有位监军,散会前要过目:他若摇头,军师就得硬着头皮再来一轮。轮数有上限,粮草也有——长史在帐外掐着数。还有些坏消息,传令官听到了先压着不外报:比如案几真的摆不下了,先让主簿再狠狠收拾一遍、重试一次,实在无计可施,才放坏消息出帐。
天亮,议事结束。长史合上官册写公文。有趣的是,他不问帐里最后喊的是哪个散会的名目——他只翻官册,看最后一条记的是什么,以此定成败。
这座一轮一轮升帐、以令箭定去留的中军帐,就是 Claude Code 的心脏:回合循环(the turn loop)。
先猜揭晓前,先押一个猜测 · 你觉得这讲的是?
谜底 · The Concept
The turn loop — queryLoop's while(true) heartbeat: compact, call the model, run tools, loop or return
The Agent Loop · 智能体循环
中文速览 · Quick read
The turn loop is the harness's heartbeat: a single while (true) inside the generator queryLoop (claude-code/src/query.ts:307). One iteration is one model request plus the batch of tools that request asked for. Per iteration the loop (1) shrinks the conversation until it fits, (2) streams one model response, and (3) either terminates — the model issued no tool calls — or executes the tools and feeds their results into a fresh iteration. There is no recursion and no scheduler: the loop "recurses" by rebuilding a mutable State struct (claude-code/src/query.ts:204-217) and continuing.
Two thin layers wrap it. query() delegates straight to queryLoop and, only when the loop returns normally, marks consumed slash-commands as completed (claude-code/src/query.ts:219-239). Above that, QueryEngine.submitMessage is the outer driver that seeds the loop and re-emits its stream as SDK messages (claude-code/src/QueryEngine.ts:209-686); ask() constructs the engine and yields from it (claude-code/src/QueryEngine.ts:1249-1291).
How it works
submitMessage runs processUserInput to expand slash commands and attachments and pushes the results into this.mutableMessages (claude-code/src/QueryEngine.ts:410-434) — the durable, cross-turn conversation store on the engine (claude-code/src/QueryEngine.ts:184-207) — then drives query() in a for await loop (claude-code/src/QueryEngine.ts:209-686). Before its first iteration, queryLoop snapshots the immutable params, injected deps, config, and starts a once-per-turn memory prefetch (claude-code/src/query.ts:241-305). All I/O flows through the QueryDeps seam — callModel, microcompact, autocompact, uuid — with productionDeps() wiring the real implementations (claude-code/src/query/deps.ts:21-40); env/statsig gates are snapshotted exactly once into QueryConfig (claude-code/src/query/config.ts:15-46). A taskBudgetRemaining counter lives as a loop-local, decremented across compaction boundaries rather than stored on State (claude-code/src/query.ts:291-292).
The iteration destructures State (claude-code/src/query.ts:311-321) and yields a stream_request_start event (claude-code/src/query.ts:337). Then the compaction pipeline runs in a deliberately load-bearing order — tool-result budget, snip, microcompact, context-collapse, autocompact — each stage feeding what it freed to the next so a cheap stage can turn the expensive one into a no-op (claude-code/src/query.ts:379-468). A hard blocking-limit check can return {reason:'blocking_limit'} before any API call, but it is skipped whenever reactive compact or context collapse own overflow recovery: a synthetic pre-API error would starve both of the real prompt-too-long they need to react to (claude-code/src/query.ts:592-648).
deps.callModel streams inside an inner while (attemptWithFallback) wrapper (claude-code/src/query.ts:654-708). Three things happen per streamed message. First, recoverable errors — prompt-too-long, media-size, max_output_tokens — are withheld from the yield and surfaced only if recovery later exhausts (claude-code/src/query.ts:799-825); yielding early would leak an intermediate error to SDK callers that terminate the session on any error field (claude-code/src/query.ts:166-179). Second, assistant messages are accumulated and any tool_use block sets needsFollowUp = true (claude-code/src/query.ts:826-845). Third, if the streamingToolExecution gate is on (claude-code/src/query.ts:561-568), those tools start executing while the model is still streaming (claude-code/src/query.ts:837-844). A FallbackTriggeredError swaps in the fallback model and retries the whole request (claude-code/src/query.ts:894-951); any other throw yields an API-error message and returns {reason:'model_error'} (claude-code/src/query.ts:955-997). An abort during streaming first emits synthetic tool_result blocks so no tool_use is left unmatched, then returns aborted_streaming (claude-code/src/query.ts:1015-1052).
needsFollowUp is the sole loop-exit signal — derived from tool_use blocks seen in the stream, not from stop_reason, which the code comments is "unreliable — it's not always set correctly" (claude-code/src/query.ts:553-558).
No follow-up (claude-code/src/query.ts:1062): withheld errors get their recovery here. A withheld 413 tries the context-collapse drain first (cheap, keeps granular history), then reactive compact (full summary), each continuing the loop with a rebuilt State (claude-code/src/query.ts:1085-1183). A withheld max_output_tokens escalates the output cap and retries, up to a recovery limit (claude-code/src/query.ts:1188-1256). Then stop hooks run: handleStopHooks returns {blockingErrors, preventContinuation} (claude-code/src/query/stopHooks.ts:60-81) and can end the turn outright or inject blocking-error messages and force another iteration — carefully preserving the reactive-compact guard, since resetting it here once caused an infinite compact→error→hook→compact spiral (claude-code/src/query.ts:1267-1306). If nothing intervenes, the loop returns {reason:'completed'} (claude-code/src/query.ts:1357).
Follow-up: tool results are drained from the streaming executor or executed as a batch via runTools (claude-code/src/query.ts:1366-1408); an async tool-use summary is kicked off without blocking the next API call (claude-code/src/query.ts:1469-1481); queued commands, prefetched memories, and skill discoveries are appended as attachment messages (claude-code/src/query.ts:1580-1628); maxTurns is enforced (claude-code/src/query.ts:1679-1712) — and enforced a second time on the aborted-during-tools exit (claude-code/src/query.ts:1506-1515). Finally the next State is built with messages = [...messagesForQuery, ...assistantMessages, ...toolResults] and control loops (claude-code/src/query.ts:1714-1728). Note the discipline: State is never field-mutated across iterations; every continue site rebuilds the entire struct, so forgetting a field there would silently reset it.
submitMessage's for await switches on each yielded message type, records the transcript, and re-yields normalized SDK messages (claude-code/src/QueryEngine.ts:757-969). Assistant messages are persisted fire-and-forget because the streaming layer mutates the last message's usage on message_delta; awaiting the write would deadlock against the write queue's drain timer (claude-code/src/QueryEngine.ts:717-732). Two details are easy to miss. First, there are two independent turn counters: queryLoop's state.turnCount increments only on the follow-up path and drives maxTurns (claude-code/src/query.ts:1679-1712), while the engine's local turnCount increments on every yielded user message and feeds result.num_turns (claude-code/src/QueryEngine.ts:753-755). Second, the Terminal reason queryLoop returns is discarded by the for await; the SDK result subtype is instead derived from messages.findLast(assistant|user) via isResultSuccessful (claude-code/src/QueryEngine.ts:1058-1118). Fittingly, Terminal and Continue are empty stub interfaces not present in the leaked source — the reason codes are internal, structurally-typed bookkeeping (claude-code/src/query/transitions.ts:1-3).
Why it matters
Every other harness mechanism is a passenger on this loop. Compaction is a phase at the top of each iteration (claude-code/src/query.ts:379-468); hooks are a gate on the terminal path (claude-code/src/query.ts:1267-1306); memory and skills ride in as attachments before the next iteration (claude-code/src/query.ts:1580-1628); budgets and turn limits are checked at its continue/return sites (claude-code/src/query.ts:1679-1712). Understand this file and you have the map on which everything else is a landmark.
It also encodes four transferable harness-design lessons. Trust data over metadata: the loop continues on observed tool_use blocks, not the model-reported stop_reason (claude-code/src/query.ts:553-558). Rebuild state, don't mutate it: every loop-back site constructs a whole new State (claude-code/src/query.ts:1714-1728), making each transition auditable — down to a transition field that exists so tests can assert why the loop continued (claude-code/src/query.ts:204-217). Withhold what you might fix: recoverable errors stay out of the caller-visible stream until recovery is truly exhausted (claude-code/src/query.ts:799-825, claude-code/src/query.ts:166-179). And keep the seams injectable: the model call and both compactors enter through QueryDeps (claude-code/src/query/deps.ts:21-40), which is what makes a while (true) with a dozen exit paths testable at all.
Read the source
Start at the wrapper and descend:
ask()builds aQueryEngineand yields fromsubmitMessage— claude-code/src/QueryEngine.ts:1249-1291.submitMessageseeds the loop (input expansion at claude-code/src/QueryEngine.ts:410-434) and consumes it (claude-code/src/QueryEngine.ts:757-969); note the durablemutableMessagesstore (claude-code/src/QueryEngine.ts:184-207).query()→queryLoophandoff — claude-code/src/query.ts:219-239; loop setup at claude-code/src/query.ts:241-305.- The
while (true)itself — claude-code/src/query.ts:307 — then read one full iteration in order: compaction (claude-code/src/query.ts:379-468), preempt (claude-code/src/query.ts:592-648), model call (claude-code/src/query.ts:654-708), the withhold/collect stream body (claude-code/src/query.ts:799-845), the branch (claude-code/src/query.ts:1062 vs claude-code/src/query.ts:1366-1408), and theStaterebuild (claude-code/src/query.ts:1714-1728). - Finish with the helpers: claude-code/src/query/deps.ts:21-40, claude-code/src/query/config.ts:15-46, claude-code/src/query/stopHooks.ts:60-81, and the telling stub claude-code/src/query/transitions.ts:1-3.
classDiagram
class QueryEngine {
<<caller>>
+submitMessage() AsyncGenerator
-messageBuffer
}
class query {
<<async generator>>
+runLoop() Terminal
}
class State {
+messages Message
+transition string
+turnCount number
+needsFollowUp bool
+assistantMessages Message
+toolUseBlocks ToolUse
+toolResults ToolResult
}
class QueryParams {
<<immutable>>
+tools
+maxTurns
+querySource
}
class Terminal {
+reason
}
QueryEngine ..> query : drives
query *-- State : mutates each lap
query ..> QueryParams : reads
query ..> Terminal : returns completed
State ..> State : rebuilds next messages 读法:query() 的 while(true) 每一圈都读写一份可变的 State,
向模型流式调用 deps.callModel()(q.ts:659),收集 tool_use 块后在
needsFollowUp 处分叉(q.ts:1062):有工具→执行→把 toolResults 拼回
State.messages 重入(q.ts:1716);无工具→走恢复/钩子/预算检查后返回
Terminal{reason}。共 7 条回边:next_turn(q.ts:1728)+ 6 条恢复
(q.ts:1085-1341)。
你来当那个不知疲倦的助理——循环的引擎。每按一次推进一轮,就跑完
while(true) 循环体的一程:整理消息 → 调用模型 → 看模型这次回了什么形状。
你能拨的唯一旋钮就是这个岔口:让模型挑个工具去调用(带 tool_use,
needsFollowUp=true),还是给最终答复(只有文字、没有工具)。
也可以先埋一个故障,看恢复梯队怎样不计回合地把这一程重来。
State (307)messagesForQuery · compaction (365)deps.callModel() 流式收集 assistantMessages[] + toolUseBlocks[] (659, 826-844)toolResults[] (1380-1400)turnCount++ (1716, 1679)Terminal{reason} (1357)选一个回复形状,按「推进一轮」,看这一程怎么走。
这就是 turn loop 的全部秘密:它从不 break,只在「无工具且诸关都过」时
return Terminal (1357)。有工具就把
[...messagesForQuery, ...assistantMessages, ...toolResults] 拼成下一程的
State、turnCount++,再 continue next_turn (1716, 1728)。
而恢复梯队的 continue不累加回合——这正是「真回合」与「重试」的分界,
也是 while(true) 能终止的那一个比特。
The turn loop is the harness's heartbeat: a single while (true) inside the generator queryLoop (claude-code/src/query.ts:307). One iteration is one model request plus the batch of tools that request asked for. Per iteration the loop (1) shrinks the conversation until it fits, (2) streams one model response, and (3) either terminates — the model issued no tool calls — or executes the tools and feeds their results into a fresh iteration. There is no recursion and no scheduler: the loop "recurses" by rebuilding a mutable State struct (claude-code/src/query.ts:204-217) and continuing.
Two thin layers wrap it. query() delegates straight to queryLoop and, only when the loop returns normally, marks consumed slash-commands as completed (claude-code/src/query.ts:219-239). Above that, QueryEngine.submitMessage is the outer driver that seeds the loop and re-emits its stream as SDK messages (claude-code/src/QueryEngine.ts:209-686); ask() constructs the engine and yields from it (claude-code/src/QueryEngine.ts:1249-1291).
How it works
Seeding. submitMessage runs processUserInput to expand slash commands and attachments and pushes the results into this.mutableMessages (claude-code/src/QueryEngine.ts:410-434) — the durable, cross-turn conversation store on the engine (claude-code/src/QueryEngine.ts:184-207) — then drives query() in a for await loop (claude-code/src/QueryEngine.ts:209-686). Before its first iteration, queryLoop snapshots the immutable params, injected deps, config, and starts a once-per-turn memory prefetch (claude-code/src/query.ts:241-305). All I/O flows through the QueryDeps seam — callModel, microcompact, autocompact, uuid — with productionDeps() wiring the real implementations (claude-code/src/query/deps.ts:21-40); env/statsig gates are snapshotted exactly once into QueryConfig (claude-code/src/query/config.ts:15-46). A taskBudgetRemaining counter lives as a loop-local, decremented across compaction boundaries rather than stored on State (claude-code/src/query.ts:291-292).
Top of each iteration: make it fit. The iteration destructures State (claude-code/src/query.ts:311-321) and yields a stream_request_start event (claude-code/src/query.ts:337). Then the compaction pipeline runs in a deliberately load-bearing order — tool-result budget, snip, microcompact, context-collapse, autocompact — each stage feeding what it freed to the next so a cheap stage can turn the expensive one into a no-op (claude-code/src/query.ts:379-468). A hard blocking-limit check can return {reason:'blocking_limit'} before any API call, but it is skipped whenever reactive compact or context collapse own overflow recovery: a synthetic pre-API error would starve both of the real prompt-too-long they need to react to (claude-code/src/query.ts:592-648).
The model call. deps.callModel streams inside an inner while (attemptWithFallback) wrapper (claude-code/src/query.ts:654-708). Three things happen per streamed message. First, recoverable errors — prompt-too-long, media-size, max_output_tokens — are withheld from the yield and surfaced only if recovery later exhausts (claude-code/src/query.ts:799-825); yielding early would leak an intermediate error to SDK callers that terminate the session on any error field (claude-code/src/query.ts:166-179). Second, assistant messages are accumulated and any tool_use block sets needsFollowUp = true (claude-code/src/query.ts:826-845). Third, if the streamingToolExecution gate is on (claude-code/src/query.ts:561-568), those tools start executing while the model is still streaming (claude-code/src/query.ts:837-844). A FallbackTriggeredError swaps in the fallback model and retries the whole request (claude-code/src/query.ts:894-951); any other throw yields an API-error message and returns {reason:'model_error'} (claude-code/src/query.ts:955-997). An abort during streaming first emits synthetic tool_result blocks so no tool_use is left unmatched, then returns aborted_streaming (claude-code/src/query.ts:1015-1052).
The branch. needsFollowUp is the sole loop-exit signal — derived from tool_use blocks seen in the stream, not from stop_reason, which the code comments is "unreliable — it's not always set correctly" (claude-code/src/query.ts:553-558).
No follow-up (claude-code/src/query.ts:1062): withheld errors get their recovery here. A withheld 413 tries the context-collapse drain first (cheap, keeps granular history), then reactive compact (full summary), each continuing the loop with a rebuilt State (claude-code/src/query.ts:1085-1183). A withheld max_output_tokens escalates the output cap and retries, up to a recovery limit (claude-code/src/query.ts:1188-1256). Then stop hooks run: handleStopHooks returns {blockingErrors, preventContinuation} (claude-code/src/query/stopHooks.ts:60-81) and can end the turn outright or inject blocking-error messages and force another iteration — carefully preserving the reactive-compact guard, since resetting it here once caused an infinite compact→error→hook→compact spiral (claude-code/src/query.ts:1267-1306). If nothing intervenes, the loop returns {reason:'completed'} (claude-code/src/query.ts:1357).
Follow-up: tool results are drained from the streaming executor or executed as a batch via runTools (claude-code/src/query.ts:1366-1408); an async tool-use summary is kicked off without blocking the next API call (claude-code/src/query.ts:1469-1481); queued commands, prefetched memories, and skill discoveries are appended as attachment messages (claude-code/src/query.ts:1580-1628); maxTurns is enforced (claude-code/src/query.ts:1679-1712) — and enforced a second time on the aborted-during-tools exit (claude-code/src/query.ts:1506-1515). Finally the next State is built with messages = [...messagesForQuery, ...assistantMessages, ...toolResults] and control loops (claude-code/src/query.ts:1714-1728). Note the discipline: State is never field-mutated across iterations; every continue site rebuilds the entire struct, so forgetting a field there would silently reset it.
Back outside. submitMessage's for await switches on each yielded message type, records the transcript, and re-yields normalized SDK messages (claude-code/src/QueryEngine.ts:757-969). Assistant messages are persisted fire-and-forget because the streaming layer mutates the last message's usage on message_delta; awaiting the write would deadlock against the write queue's drain timer (claude-code/src/QueryEngine.ts:717-732). Two details are easy to miss. First, there are two independent turn counters: queryLoop's state.turnCount increments only on the follow-up path and drives maxTurns (claude-code/src/query.ts:1679-1712), while the engine's local turnCount increments on every yielded user message and feeds result.num_turns (claude-code/src/QueryEngine.ts:753-755). Second, the Terminal reason queryLoop returns is discarded by the for await; the SDK result subtype is instead derived from messages.findLast(assistant|user) via isResultSuccessful (claude-code/src/QueryEngine.ts:1058-1118). Fittingly, Terminal and Continue are empty stub interfaces not present in the leaked source — the reason codes are internal, structurally-typed bookkeeping (claude-code/src/query/transitions.ts:1-3).
Why it matters
Every other harness mechanism is a passenger on this loop. Compaction is a phase at the top of each iteration (claude-code/src/query.ts:379-468); hooks are a gate on the terminal path (claude-code/src/query.ts:1267-1306); memory and skills ride in as attachments before the next iteration (claude-code/src/query.ts:1580-1628); budgets and turn limits are checked at its continue/return sites (claude-code/src/query.ts:1679-1712). Understand this file and you have the map on which everything else is a landmark.
It also encodes four transferable harness-design lessons. Trust data over metadata: the loop continues on observed tool_use blocks, not the model-reported stop_reason (claude-code/src/query.ts:553-558). Rebuild state, don't mutate it: every loop-back site constructs a whole new State (claude-code/src/query.ts:1714-1728), making each transition auditable — down to a transition field that exists so tests can assert why the loop continued (claude-code/src/query.ts:204-217). Withhold what you might fix: recoverable errors stay out of the caller-visible stream until recovery is truly exhausted (claude-code/src/query.ts:799-825, claude-code/src/query.ts:166-179). And keep the seams injectable: the model call and both compactors enter through QueryDeps (claude-code/src/query/deps.ts:21-40), which is what makes a while (true) with a dozen exit paths testable at all.
Read the source
Start at the wrapper and descend:
ask()builds aQueryEngineand yields fromsubmitMessage— claude-code/src/QueryEngine.ts:1249-1291.submitMessageseeds the loop (input expansion at claude-code/src/QueryEngine.ts:410-434) and consumes it (claude-code/src/QueryEngine.ts:757-969); note the durablemutableMessagesstore (claude-code/src/QueryEngine.ts:184-207).query()→queryLoophandoff — claude-code/src/query.ts:219-239; loop setup at claude-code/src/query.ts:241-305.- The
while (true)itself — claude-code/src/query.ts:307 — then read one full iteration in order: compaction (claude-code/src/query.ts:379-468), preempt (claude-code/src/query.ts:592-648), model call (claude-code/src/query.ts:654-708), the withhold/collect stream body (claude-code/src/query.ts:799-845), the branch (claude-code/src/query.ts:1062 vs claude-code/src/query.ts:1366-1408), and theStaterebuild (claude-code/src/query.ts:1714-1728). - Finish with the helpers: claude-code/src/query/deps.ts:21-40, claude-code/src/query/config.ts:15-46, claude-code/src/query/stopHooks.ts:60-81, and the telling stub claude-code/src/query/transitions.ts:1-3.
来源 · Source citations
- [1]
claude-code/src/query.ts:307 - [2]
claude-code/src/query.ts:204-217 - [3]
claude-code/src/query.ts:219-239 - [4]
claude-code/src/query.ts:241-305 - [5]
claude-code/src/query.ts:291-292 - [6]
claude-code/src/query.ts:311-321 - [7]
claude-code/src/query.ts:337 - [8]
claude-code/src/query.ts:379-468 - [9]
claude-code/src/query.ts:553-558 - [10]
claude-code/src/query.ts:561-568 - [11]
claude-code/src/query.ts:592-648 - [12]
claude-code/src/query.ts:654-708 - [13]
claude-code/src/query.ts:166-179 - [14]
claude-code/src/query.ts:799-825 - [15]
claude-code/src/query.ts:826-845 - [16]
claude-code/src/query.ts:837-844 - [17]
claude-code/src/query.ts:894-951 - [18]
claude-code/src/query.ts:955-997 - [19]
claude-code/src/query.ts:1015-1052 - [20]
claude-code/src/query.ts:1062 - [21]
claude-code/src/query.ts:1085-1183 - [22]
claude-code/src/query.ts:1188-1256 - [23]
claude-code/src/query.ts:1267-1306 - [24]
claude-code/src/query.ts:1357 - [25]
claude-code/src/query.ts:1366-1408 - [26]
claude-code/src/query.ts:1469-1481 - [27]
claude-code/src/query.ts:1506-1515 - [28]
claude-code/src/query.ts:1580-1628 - [29]
claude-code/src/query.ts:1679-1712 - [30]
claude-code/src/query.ts:1714-1728 - [31]
claude-code/src/QueryEngine.ts:184-207 - [32]
claude-code/src/QueryEngine.ts:209-686 - [33]
claude-code/src/QueryEngine.ts:410-434 - [34]
claude-code/src/QueryEngine.ts:717-732 - [35]
claude-code/src/QueryEngine.ts:753-755 - [36]
claude-code/src/QueryEngine.ts:757-969 - [37]
claude-code/src/QueryEngine.ts:1058-1118 - [38]
claude-code/src/QueryEngine.ts:1249-1291 - [39]
claude-code/src/query/deps.ts:21-40 - [40]
claude-code/src/query/config.ts:15-46 - [41]
claude-code/src/query/transitions.ts:1-3 - [42]
claude-code/src/query/stopHooks.ts:60-81