本章总览
queryLoop 是 while (true) 驱动的协程式状态机:每次 continue 开始新 iteration;return 产出 Terminal 完成值。State.transition 记录上一轮为何 continue,供测试断言 recovery 路径而无需解析 messages。query/transitions.ts 当前导出 identity 函数 transitionQueryState 与类型锚点;Continue/Terminal discriminated union 由 query.ts 消费。本章绘制完整转移图并逐条解释 seven+ continue 站点。
学完本章你应该能
- 解释 query() 与 queryLoop() 的双层 generator 与 Terminal return
- 列举全部 transition.reason 枚举及触发条件
- 理解 State 批量更新模式(state = next; continue)
- 说明 transitionQueryState 占位与未来 step() reducer 路线
- 能在测试中 assert state.transition.reason
核心概念(先读懂这些)
Continue = 新 iteration,不是递归 query()
queryLoop 内所有 continue 仍在同一 async generator 实例内。messages 与 toolUseContext 写入 State 后跳回 while 顶。仅 maxTurns 或正常 completed 才 return Terminal。这与「递归调用 query()」不同——chainId 在 loop 内保持一致。
transition 是 observability,不是调度器
代码不用 switch(state.transition.reason) 决定下一分支;reason 主要给测试与注释。实际调度由 loop 内 if/else(api error、tool results、stop hooks)决定。transition 字段是「上次 exit edge 的标签」。
Terminal reason 与 SDK 契约
return { reason, ... } 是 queryLoop 的 completion value。外层 query() yield* queryLoop 后 notifyCommandLifecycle。SDK 模式依赖 generator return value 判断 turn 是正常完成、被 hook 截断、被 abort、还是因 API / prompt / image 问题提前终止。
建议学习步骤
- 阅读 query/queryLoop 签名与 State 类型源码块 A
- 阅读 query() 包装层与 Terminal return 源码块 B
- 按表格逐个 continue 站点对照 query.ts 行号
- 阅读 transitions.ts 占位实现
- 画状态图对照本文 Mermaid
常见误区
注意
不要把 ink Terminal 类型与 query Terminal 混淆——后者是 { reason: string }
注意
collapse_drain_retry 仅在 CONTEXT_COLLAPSE feature 存在
注意
stop_hook_blocking continue 会设 stopHookActive: true,影响下一轮 Stop hook
状态机总览
两层 generator:
query(params)
yield* queryLoop(...) // 所有 StreamEvent/Message
notifyCommandLifecycle(completed)
return terminal // Terminal 传给 caller
query() 的 AsyncGenerator 既 yield 中间事件,又 return Terminal——REPL for-await 取 final return value 更新 session 状态。
State 与 transition 字段
query.ts L204-217 定义 loop 可变状态:
| 字段 | 用途 |
|---|---|
| messages | 下一轮输入 transcript |
| toolUseContext | 含 queryTracking、readFileState 等 |
| autoCompactTracking | autocompact 连续失败计数 |
| maxOutputTokensRecoveryCount | max_output_tokens 恢复次数 |
| hasAttemptedReactiveCompact | 413 reactive 只尝试一次 |
| maxOutputTokensOverride | _escalate 临时提高 max_tokens |
| pendingToolUseSummary | 异步工具摘要 Promise |
| stopHookActive | 嵌套 Stop hook 标记 |
| turnCount | agentic turn 计数(对比 maxTurns) |
| transition | Continue | undefined |
注释 L214-215:Why the previous iteration continued. Undefined on first iteration. Lets tests assert recovery paths.
Continue 类型从 transitions.ts import;当前源码 transitions.ts 仅 identity 函数——类型可能由 TS 声明合并或在 bundle 中内联。语义上以 query.ts 中 transition: { reason: ... } 字面量为准。
源码引用: src/query.ts · 第 201–217 行(共 1730 行)
201| // -- query loop state
202|
203| // Mutable state carried between loop iterations
204| type State = {
205| messages: Message[]
206| toolUseContext: ToolUseContext
207| autoCompactTracking: AutoCompactTrackingState | undefined
208| maxOutputTokensRecoveryCount: number
209| hasAttemptedReactiveCompact: boolean
210| maxOutputTokensOverride: number | undefined
211| pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
212| stopHookActive: boolean | undefined
213| turnCount: number
214| // Why the previous iteration continued. Undefined on first iteration.
215| // Lets tests assert recovery paths fired without inspecting message contents.
216| transition: Continue | undefined
217| }
源码引用: src/query.ts · 第 219–239 行(共 1730 行)
219| export async function* query(
220| params: QueryParams,
221| ): AsyncGenerator<
222| | StreamEvent
223| | RequestStartEvent
224| | Message
225| | TombstoneMessage
226| | ToolUseSummaryMessage,
227| Terminal
228| > {
229| const consumedCommandUuids: string[] = []
230| const terminal = yield* queryLoop(params, consumedCommandUuids)
231| // Only reached if queryLoop returned normally. Skipped on throw (error
232| // propagates through yield*) and on .return() (Return completion closes
233| // both generators). This gives the same asymmetric started-without-completed
234| // signal as print.ts's drainCommandQueue when the turn fails.
235| for (const uuid of consumedCommandUuids) {
236| notifyCommandLifecycle(uuid, 'completed')
237| }
238| return terminal
239| }
源码引用: src/query/transitions.ts · 第 1–3 行(共 4 行)
1| export function transitionQueryState<T>(value: T): T {
2| return value
3| }
queryLoop 入口与 state 初始化
queryLoop L268-279 初始化 state.transition = undefined。
L265-267 注释揭示 continue 站点模式:
// Continue sites write `state = { ... }` instead of 9 separate assignments.
每个 continue 必须 spread 完整 State——遗漏字段会导致 silent reset(如 hasAttemptedReactiveCompact 被误清零)。
taskBudgetRemaining 故意不在 State 上(L289-290):7+ continue 站点不必同步更新 API task_budget 局部变量。
destructure 模式(L307-321):每 iteration 顶部从 state 解构 messages、turnCount 等;toolUseContext 可在 iteration 内 reassignment。
源码引用: src/query.ts · 第 241–279 行(共 1730 行)
241| async function* queryLoop(
242| params: QueryParams,
243| consumedCommandUuids: string[],
244| ): AsyncGenerator<
245| | StreamEvent
246| | RequestStartEvent
247| | Message
248| | TombstoneMessage
249| | ToolUseSummaryMessage,
250| Terminal
251| > {
252| // Immutable params — never reassigned during the query loop.
253| const {
254| systemPrompt,
255| userContext,
256| systemContext,
257| canUseTool,
258| fallbackModel,
259| querySource,
260| maxTurns,
261| skipCacheWrite,
262| } = params
263| const deps = params.deps ?? productionDeps()
264|
265| // Mutable cross-iteration state. The loop body destructures this at the top
266| // of each iteration so reads stay bare-name (`messages`, `toolUseContext`).
267| // Continue sites write `state = { ... }` instead of 9 separate assignments.
268| let state: State = {
269| messages: params.messages,
270| toolUseContext: params.toolUseContext,
271| maxOutputTokensOverride: params.maxOutputTokensOverride,
272| autoCompactTracking: undefined,
273| stopHookActive: undefined,
274| maxOutputTokensRecoveryCount: 0,
275| hasAttemptedReactiveCompact: false,
276| turnCount: 1,
277| pendingToolUseSummary: undefined,
278| transition: undefined,
279| }
源码引用: src/query.ts · 第 306–321 行(共 1730 行)
306| // eslint-disable-next-line no-constant-condition
307| while (true) {
308| // Destructure state at the top of each iteration. toolUseContext alone
309| // is reassigned within an iteration (queryTracking, messages updates);
310| // the rest are read-only between continue sites.
311| let { toolUseContext } = state
312| const {
313| messages,
314| autoCompactTracking,
315| maxOutputTokensRecoveryCount,
316| hasAttemptedReactiveCompact,
317| maxOutputTokensOverride,
318| pendingToolUseSummary,
319| stopHookActive,
320| turnCount,
321| } = state
Continue 站点一览(recovery 与 multi-turn)
| reason | 触发场景 | 关键 state 变化 |
|---|---|---|
| collapse_drain_retry | CONTEXT_COLLAPSE 413 前先 drain collapses | messages 替换为 drained |
| reactive_compact_retry | reactiveCompact 成功压缩后重试 API | hasAttemptedReactiveCompact: true, messages 为 post-compact |
| max_output_tokens_escalate | max_output_tokens 且可 escalate | maxOutputTokensOverride: ESCALATED_MAX_TOKENS |
| max_output_tokens_recovery | 未 escalate 时注入 meta recovery user msg | recoveryCount++ |
| stop_hook_blocking | Stop hook exit 2 blocking errors | stopHookActive: true, messages 含 blocking |
| token_budget_continuation | TOKEN_BUDGET continue | meta nudge user message |
| next_turn | tool results 后进入下一 agentic turn | turnCount++, messages 含 tool_results |
guard 示例: collapse_drain_retry 要求 state.transition?.reason !== 'collapse_drain_retry'——已 drain 仍 413 则 fall through reactive compact,避免 drain 死循环。
stop_hook_blocking 保留 hasAttemptedReactiveCompact——注释 L1292-1296 描述 infinite compact loop bug 若重置为 false。
源码引用: src/query.ts · 第 1098–1115 行(共 1730 行)
1098| if (drained.committed > 0) {
1099| const next: State = {
1100| messages: drained.messages,
1101| toolUseContext,
1102| autoCompactTracking: tracking,
1103| maxOutputTokensRecoveryCount,
1104| hasAttemptedReactiveCompact,
1105| maxOutputTokensOverride: undefined,
1106| pendingToolUseSummary: undefined,
1107| stopHookActive: undefined,
1108| turnCount,
1109| transition: {
1110| reason: 'collapse_drain_retry',
1111| committed: drained.committed,
1112| },
1113| }
1114| state = next
1115| continue
源码引用: src/query.ts · 第 1152–1163 行(共 1730 行)
1152| const next: State = {
1153| messages: postCompactMessages,
1154| toolUseContext,
1155| autoCompactTracking: undefined,
1156| maxOutputTokensRecoveryCount,
1157| hasAttemptedReactiveCompact: true,
1158| maxOutputTokensOverride: undefined,
1159| pendingToolUseSummary: undefined,
1160| stopHookActive: undefined,
1161| turnCount,
1162| transition: { reason: 'reactive_compact_retry' },
1163| }
源码引用: src/query.ts · 第 1207–1220 行(共 1730 行)
1207| const next: State = {
1208| messages: messagesForQuery,
1209| toolUseContext,
1210| autoCompactTracking: tracking,
1211| maxOutputTokensRecoveryCount,
1212| hasAttemptedReactiveCompact,
1213| maxOutputTokensOverride: ESCALATED_MAX_TOKENS,
1214| pendingToolUseSummary: undefined,
1215| stopHookActive: undefined,
1216| turnCount,
1217| transition: { reason: 'max_output_tokens_escalate' },
1218| }
1219| state = next
1220| continue
源码引用: src/query.ts · 第 1231–1251 行(共 1730 行)
1231| const next: State = {
1232| messages: [
1233| ...messagesForQuery,
1234| ...assistantMessages,
1235| recoveryMessage,
1236| ],
1237| toolUseContext,
1238| autoCompactTracking: tracking,
1239| maxOutputTokensRecoveryCount: maxOutputTokensRecoveryCount + 1,
1240| hasAttemptedReactiveCompact,
1241| maxOutputTokensOverride: undefined,
1242| pendingToolUseSummary: undefined,
1243| stopHookActive: undefined,
1244| turnCount,
1245| transition: {
1246| reason: 'max_output_tokens_recovery',
1247| attempt: maxOutputTokensRecoveryCount + 1,
1248| },
1249| }
1250| state = next
1251| continue
next_turn:tool 执行后的主循环边
当 assistant 含 tool_use 且 tools 执行完毕,query.ts L1714-1727 更新 state:
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
// implicit continue at end of while body when tools path loops
maxTurns 检查(L1704-1711)在 next_turn 赋值前:若 nextTurnCount > maxTurns,yield max_turns_reached attachment 并 return { reason: 'max_turns', turnCount }——这是 Terminal 而非 Continue。
tool 路径与 no-tool 路径汇合:no-tool 走 stop hooks → budget → return completed;tool 路径走 next_turn continue 开始新 iteration(可能再次 compact + API)。
源码引用: src/query.ts · 第 1704–1728 行(共 1730 行)
1704| // Check if we've reached the max turns limit
1705| if (maxTurns && nextTurnCount > maxTurns) {
1706| yield createAttachmentMessage({
1707| type: 'max_turns_reached',
1708| maxTurns,
1709| turnCount: nextTurnCount,
1710| })
1711| return { reason: 'max_turns', turnCount: nextTurnCount }
1712| }
1713|
1714| queryCheckpoint('query_recursive_call')
1715| const next: State = {
1716| messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
1717| toolUseContext: toolUseContextWithQueryTracking,
1718| autoCompactTracking: tracking,
1719| turnCount: nextTurnCount,
1720| maxOutputTokensRecoveryCount: 0,
1721| hasAttemptedReactiveCompact: false,
1722| pendingToolUseSummary: nextPendingToolUseSummary,
1723| maxOutputTokensOverride: undefined,
1724| stopHookActive,
1725| transition: { reason: 'next_turn' },
1726| }
1727| state = next
1728| } // while (true)
Terminal 出口
queryLoop 的 Terminal 不是只有正常完成。它覆盖正常结束、hook 截断、用户中断、API/recovery 失败、工具 hook 拦截等多种出口:
| reason | 条件 |
|---|---|
| completed | assistant 无 tool、stop hooks 通过、budget 不 continue;或 api error 跳过 hooks |
| stop_hook_prevented | stopHookResult.preventContinuation |
| max_turns | turnCount 超限 |
| blocking_limit | 超 hard token 上限且无法继续 compact |
| aborted_streaming | 流式阶段收到 abort |
| aborted_tools | 工具执行阶段 abort |
| hook_stopped | 工具路径 PreToolUse 等 hook 阻止继续 |
| image_error | 图片错误或 withheld media |
| model_error | callModel 抛错 |
| prompt_too_long | 413 recovery 耗尽 |
completed 双路径:
- L1264:lastMessage.isApiErrorMessage → executeStopFailureHooks → return completed(不跑 Stop hooks,防 death spiral)
- L1357:正常 turn 结束
stop_hook_prevented(L1278-1280):Stop hook 或 TeammateIdle/TaskCompleted 设置 preventContinuation;blocking errors 为空也可能 prevent。
外层 query() L235-237:consumedCommandUuids notifyCommandLifecycle completed——throw 或 .return() 跳过,保证 slash command 生命周期 asymmetric signal。
源码引用: src/query.ts · 第 1258–1280 行(共 1730 行)
1258| // Skip stop hooks when the last message is an API error (rate limit,
1259| // prompt-too-long, auth failure, etc.). The model never produced a
1260| // real response — hooks evaluating it create a death spiral:
1261| // error → hook blocking → retry → error → …
1262| if (lastMessage?.isApiErrorMessage) {
1263| void executeStopFailureHooks(lastMessage, toolUseContext)
1264| return { reason: 'completed' }
1265| }
1266|
1267| const stopHookResult = yield* handleStopHooks(
1268| messagesForQuery,
1269| assistantMessages,
1270| systemPrompt,
1271| userContext,
1272| systemContext,
1273| toolUseContext,
1274| querySource,
1275| stopHookActive,
1276| )
1277|
1278| if (stopHookResult.preventContinuation) {
1279| return { reason: 'stop_hook_prevented' }
1280| }
源码引用: src/query.ts · 第 1355–1358 行(共 1730 行)
1355| }
1356|
1357| return { reason: 'completed' }
1358| }
transitionQueryState 与未来 step() 提取
transitions.ts 全文:
export function transitionQueryState<T>(value: T): T {
return value
}
identity 函数是 refactor 占位:未来 reducer 可能在 commit transition 时调用 transitionQueryState(state) 做 invariant 检查或 dev-only 日志,当前 no-op 保持 zero overhead。
config.ts 注释描绘目标签名:
step(state, event, config) where config is plain data
Continue 事件对应:CompactApplied、ApiErrorRecovered、StopHookBlocking、TokenBudgetNudge、ToolResultsCommitted 等;Terminal 对应 TurnCompleted、StopHookPrevented、MaxTurnsExceeded。
迁移策略(从注释推断):
- ✅ 提取 config/deps
- ✅ 统一 State + transition reason
- ⬜ 把 continue 站点改为 step() 返回 { state, outcome: 'continue' | 'terminal' }
- ⬜ transitionQueryState 添加 runtime assert(如 messages 非空)
读源码时把每个 state = next; continue 标号,即 future event handler 列表。
源码引用: src/query/transitions.ts · 第 1–3 行(共 4 行)
1| export function transitionQueryState<T>(value: T): T {
2| return value
3| }
源码引用: src/query/config.ts · 第 8–14 行(共 47 行)
8| // Immutable values snapshotted once at query() entry. Separating these from
9| // the per-iteration State struct and the mutable ToolUseContext makes future
10| // step() extraction tractable — a pure reducer can take (state, event, config)
11| // where config is plain data.
12| //
13| // Intentionally excludes feature() gates — those are tree-shaking boundaries
14| // and must stay inline at the guarded blocks for dead-code elimination.
测试:用 transition 断言 recovery
集成测试模式:
// 伪代码:收集 queryLoop 内部 state 需 hook 或 deps.callModel 序列
expect(finalTransition.reason).toBe('reactive_compact_retry')
比断言 messages 含 compact boundary 更稳定——boundary 格式可能变。
场景对照:
- 413 → reactive compact → 应看到 transition reactive_compact_retry 后再 success
- max_output_tokens → 先 max_output_tokens_recovery 最多 3 次,可能 escalate
- Stop hook blocking → stop_hook_blocking continue 且 stopHookActive true
勿测 transition 顺序跨 turn——collapse_drain 与 reactive 互斥由 transition.reason guard 保证,测单次 413 输入即可。
query.ts import type { Terminal, Continue } from './query/transitions.js'——若 TS 报错缺失 export,以 query.ts runtime 行为为准;文档站源码快照 v2.1.88 可能 transitions 类型在 .d.ts 或未提取到 complete 包。
源码目录(本主题)
主状态机在 query.ts;transitions.ts 为辅助模块。
动手练习
- 在 query.ts 搜索
transition:列出全部 reason 字面量 - 画 iteration 内 phase 时间线:compact → API → tools → stop → budget
- 说明 stop_hook_blocking continue 与 stop_hook_prevented return 区别
- 设计 step() 的 Event union 类型,覆盖表中 7 种 continue reason
本章小结与延伸
transitions = loop 边的命名与 future reducer 锚点。下一章 stop-hooks 详解 turn 结束两条 continue/return 分支。 继续学习: