3.7 停止条件 --- while(true) 的安全出口
对应原书 :第3章 §3.3.3 循环不变式 + §3.8 分级错误恢复 + §3.10.3 stopHooks.ts
源码文件 :
query/stopHooks.ts(474行)、query.ts(1729行)、query/tokenBudget.ts(93行)、QueryEngine.ts(1295行)、utils/hooks.ts(3627+行)核心概念:七层终止保护、Stop Hook 编排、三级错误恢复、Withheld 暂存、防死循环机制、StopFailure Hook
一、导语:while(true) 怎么停下来?
在前几篇笔记中,我们已经多次提及 while(true) 循环的"终止保护"。但直到本篇,我们才完整地拆解这套体系------queryLoop 有 17 条退出路径(7 种 continue + 10 种 return),每一条都经过精心设计。
原书 §3.3.3 列出了"五层保护":
- maxTurns 限制
- Token Budget 检查
- 错误恢复上限
- LLM 自然终止
- 外部中断
但在源码中,我们发现了七层------原书遗漏了 QueryEngine 层的两层:
- USD 预算 (
QueryEngine.ts:972) - 结构化输出重试限制 (
QueryEngine.ts:1004)
更重要的是,原书对 Stop Hook 的描述较为简略(§3.10.3,仅半页)。而在源码中,stopHooks.ts 是一个 474 行的完整模块,承载了远超"停止钩子"字面含义的职责------它不仅判断是否允许 Agent 停下来,还编排了记忆提取、模板分类、AutoDream 等后台任务。
本篇笔记将完整解剖这套停止条件体系。
二、七层终止保护全景
┌─────────────────────────────────────────────────────────────────┐
│ while(true) 的安全出口 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ queryLoop 层(query.ts) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ ① LLM 自然终止 │ │
│ │ needsFollowUp === false → 进入 Stop Hook 流程 │ │
│ │ → return { reason: 'completed' } │ │
│ │ │ │
│ │ ② maxTurns 限制 │ │
│ │ nextTurnCount > maxTurns │ │
│ │ → return { reason: 'max_turns', turnCount } │ │
│ │ │ │
│ │ ③ Token Budget 检查 │ │
│ │ checkTokenBudget() → action: 'stop' │ │
│ │ → return { reason: 'completed' } │ │
│ │ │ │
│ │ ④ 错误恢复上限 │ │
│ │ MaxOutput: 3次恢复上限 → return { reason: 'completed' }│ │
│ │ 413: Context Collapse + Reactive Compact 各一次 │ │
│ │ → return { reason: 'prompt_too_long' } │ │
│ │ │ │
│ │ ⑤ 外部中断 │ │
│ │ AbortController.abort() │ │
│ │ → return { reason: 'aborted_streaming' } │ │
│ │ → return { reason: 'aborted_tools' } │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ QueryEngine 层(QueryEngine.ts) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ ⑥ USD 预算 │ │
│ │ getTotalCost() >= maxBudgetUsd │ │
│ │ → yield { subtype: 'error_max_budget_usd' } + return │ │
│ │ │ │
│ │ ⑦ 结构化输出重试限制 │ │
│ │ callsThisQuery >= MAX_STRUCTURED_OUTPUT_RETRIES (5) │ │
│ │ → yield { subtype: 'error_max_structured_output_retries' }│
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ Stop Hook 层(stopHooks.ts) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ ⑧ preventContinuation │ │
│ │ → return { reason: 'stop_hook_prevented' } │ │
│ │ (不是"终止"而是"阻止自动继续",等待用户输入) │ │
│ │ │ │
│ │ ⑨ blockingErrors → continue │ │
│ │ → transition: 'stop_hook_blocking'(回到循环重试) │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
七层保护的精确代码位置
| # | 保护层 | 代码位置 | 触发条件 | 退出方式 |
|---|---|---|---|---|
| ① | LLM 自然终止 | query.ts:1062 !needsFollowUp |
LLM 未返回 tool_use 块 | return { reason: 'completed' } (L1357) |
| ② | maxTurns | query.ts:1705 |
nextTurnCount > maxTurns |
return { reason: 'max_turns' } |
| ③ | Token Budget | query.ts:1308-1355 |
checkTokenBudget() 返回 action: 'stop' |
return { reason: 'completed' } (L1357) |
| ④ | 错误恢复上限 | query.ts:1188-1256 |
maxOutputTokensRecoveryCount >= 3 |
return { reason: 'completed' } (L1264) |
| ⑤ | 外部中断 | query.ts:1015, 1480 |
abortController.signal.aborted |
return { reason: 'aborted_streaming' / 'aborted_tools' } |
| ⑥ | USD 预算 | QueryEngine.ts:972 |
getTotalCost() >= maxBudgetUsd |
yield { subtype: 'error_max_budget_usd' } + return |
| ⑦ | 结构化输出限制 | QueryEngine.ts:1004-1047 |
callsThisQuery >= 5 |
yield { subtype: 'error_max_structured_output_retries' } + return |
关键洞察 :①~⑤在 queryLoop 内部,⑥~⑦在 QueryEngine 的 for-await 消费层。这意味着终止检查是分层的------queryLoop 自己能终止,但 QueryEngine 还会在消费输出时做第二轮检查。
三、Stop Hook 编排:handleStopHooks 完整解析
3.1 函数签名
typescript
// stopHooks.ts:65-81
export async function* handleStopHooks(
messagesForQuery: Message[], // 当前查询的输入消息
assistantMessages: AssistantMessage[], // 本轮 LLM 的响应消息
systemPrompt: SystemPrompt, // 系统提示
userContext: { [k: string]: string },// 用户上下文
systemContext: { [k: string]: string },// 系统上下文
toolUseContext: ToolUseContext, // 工具上下文
querySource: QuerySource, // 调用来源
stopHookActive?: boolean, // 是否已在 Stop Hook 循环中
): AsyncGenerator<
| StreamEvent
| RequestStartEvent
| Message
| TombstoneMessage
| ToolUseSummaryMessage,
StopHookResult // 返回值
>
这是一个 AsyncGenerator ,yield 多种消息类型,最终 return 一个 StopHookResult。
3.2 调用点
typescript
// query.ts:1267-1276
const stopHookResult = yield* handleStopHooks(
messagesForQuery,
assistantMessages,
systemPrompt,
userContext,
systemContext,
toolUseContext,
querySource,
stopHookActive,
)
注意 yield*------这会将 handleStopHooks 内部 yield 的所有消息透传给 queryLoop 的消费者。这是 AsyncGenerator 组合性的典型应用。
3.3 触发条件:何时进入 Stop Hook?
typescript
// query.ts:1062
if (!needsFollowUp) {
// LLM 没有返回 tool_use 块 → 自然终止
// → 进入 Stop Hook 流程
const lastMessage = assistantMessages.at(-1)
// 但先检查 withheld 错误...
// 详见 §九
// query.ts:1262-1265
if (lastMessage?.isApiErrorMessage) {
// API 错误 → 跳过 Stop Hook
void executeStopFailureHooks(lastMessage, toolUseContext)
return { reason: 'completed' }
}
// query.ts:1267
const stopHookResult = yield* handleStopHooks(...)
}
关键设计 :Stop Hook 只在 LLM 自然终止时触发 。如果 LLM 返回了工具调用(needsFollowUp = true),直接进入工具执行阶段,不触发 Stop Hook。如果最后一条消息是 API 错误(如 413、rate limit),也跳过 Stop Hook(详见 §十二)。
3.4 handleStopHooks 内部流程
handleStopHooks 内部可分为四个阶段:
handleStopHooks 执行流程
│
├── 阶段 A:上下文准备(L82-98)
│ ├── 构建 stopHookContext
│ └── saveCacheSafeParams(仅主线程/SDK)
│
├── 阶段 B:后台任务编排(L100-173)
│ ├── 模板作业分类(TEMPLATES feature)
│ ├── Prompt Suggestion(fire-and-forget)
│ ├── 记忆提取(EXTRACT_MEMORIES feature)
│ ├── AutoDream(fire-and-forget)
│ └── Chicago MCP 清理(CHICAGO_MCP feature)
│
├── 阶段 C:Stop Hook 执行(L175-323)
│ ├── executeStopHooks() 消费
│ ├── 逐条 yield 进度消息
│ ├── 收集 blockingErrors
│ ├── 跟踪 preventContinuation
│ ├── 生成 StopHookSummary 消息
│ └── 中断检查(abortController)
│
└── 阶段 D:Teammate Hook(L334-453,仅 teammate 执行)
├── TaskCompleted hooks(对每个 in_progress 任务)
└── TeammateIdle hooks
四、StopHookResult:两种控制语义
typescript
// stopHooks.ts:60-63
type StopHookResult = {
blockingErrors: Message[] // 阻塞性错误消息
preventContinuation: boolean // 是否阻止自动继续
}
4.1 blockingErrors --- "回去修"
当 Stop Hook 返回 blockingErrors 时,Agent 不终止,而是带着错误消息回到循环顶部重试:
typescript
// query.ts:1282-1306
if (stopHookResult.blockingErrors.length > 0) {
const next: State = {
messages: [
...messagesForQuery,
...assistantMessages,
...stopHookResult.blockingErrors, // ← 注入 Hook 错误消息
],
toolUseContext,
autoCompactTracking: tracking,
maxOutputTokensRecoveryCount: 0,
hasAttemptedReactiveCompact, // ← 关键:保留!见 §五
maxOutputTokensOverride: undefined,
pendingToolUseSummary: undefined,
stopHookActive: true, // ← 标记"正在 Stop Hook 模式"
turnCount,
transition: { reason: 'stop_hook_blocking' },
}
state = next
continue // ← 回到 while(true) 顶部
}
典型场景:用户配置了"Agent 完成后运行测试"的 Stop Hook,测试失败时 Hook 返回 blockingError,Agent 带着测试失败信息回到循环,继续修复。
4.2 preventContinuation --- "停下来等用户"
当 Stop Hook 返回 preventContinuation = true 时,Agent 终止循环,等待用户输入:
typescript
// query.ts:1278-1280
if (stopHookResult.preventContinuation) {
return { reason: 'stop_hook_prevented' }
}
典型场景:用户配置了"Agent 完成后需要人工审核"的 Hook,Hook 返回 preventContinuation,Agent 停止,等待用户审核后手动继续。
4.3 两种语义的对比
| 维度 | blockingErrors | preventContinuation |
|---|---|---|
| 行为 | continue(回到循环) | return(终止循环) |
| Agent 动作 | 带错误信息重试 | 停下,等用户 |
stop_hook_active |
设为 true |
不影响 |
| transition | stop_hook_blocking |
无(直接 return) |
| Terminal reason | 无(不是终止) | stop_hook_prevented |
| 用户体验 | Agent 自动修复 | 需要用户手动继续 |
五、stop_hook_blocking:防死循环的关键设计
这是整个停止条件体系中最精妙的防死循环设计。
5.1 问题场景
考虑以下场景:
- Agent 因 413(Prompt Too Long)触发 Reactive Compact
- Compact 后重试,仍然 413
- 此时 Stop Hook 执行(因为
!needsFollowUp),返回 blockingError - Agent 带着 blockingError 回到循环
- 又遇到 413 → 又触发 Compact → 又失败 → Stop Hook 又返回 blockingError...
- 无限循环!
5.2 解决方案:保留 hasAttemptedReactiveCompact
typescript
// query.ts:1292-1298
const next: State = {
// ...
// 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.
hasAttemptedReactiveCompact, // ← 保留而非重置!
// ...
}
注释中明确描述了这个 bug 的历史:重置 hasAttemptedReactiveCompact 会导致无限循环,每次循环都消耗一次 API 调用("burning thousands of API calls")。
5.3 对比:正常 next_turn 的重置
typescript
// query.ts:1715-1726(正常工具调用后的下一轮)
const next: State = {
// ...
maxOutputTokensRecoveryCount: 0,
hasAttemptedReactiveCompact: false, // ← 正常重置!
// ...
transition: { reason: 'next_turn' },
}
在正常的 next_turn 路径中,hasAttemptedReactiveCompact 被重置为 false ------因为这是一个全新的轮次,之前的压缩尝试不再相关。但在 stop_hook_blocking 路径中,它被保留------因为 Stop Hook 注入的消息可能使上下文更长,之前已经失败的压缩不会再成功。
5.4 重置策略矩阵
| Continue 路径 | maxOutputTokensRecoveryCount | hasAttemptedReactiveCompact | stopHookActive |
|---|---|---|---|
next_turn |
0 |
false |
保留 |
stop_hook_blocking |
0 |
保留 | true |
collapse_drain_retry |
保留 | 保留 | undefined |
reactive_compact_retry |
保留 | true |
undefined |
max_output_tokens_escalate |
保留 | 保留 | undefined |
max_output_tokens_recovery |
+1 |
保留 | undefined |
token_budget_continuation |
0 |
false |
undefined |
关键规律 :stop_hook_blocking 是唯一一个 hasAttemptedReactiveCompact 不重置的非压缩路径。这不是疏忽,而是精心设计的防死循环保护。
六、三类 Stop Hook:Stop / TaskCompleted / TeammateIdle
原书 §3.10.3 提到了三种 Hook 类型,但在源码中它们的执行顺序和条件更加精细:
6.1 执行顺序
typescript
// stopHooks.ts 内部执行顺序:
// 1. executeStopHooks() --- 总是执行
// 2. executeTaskCompletedHooks() --- 仅 teammate,对每个 in_progress 任务
// 3. executeTeammateIdleHooks() --- 仅 teammate
6.2 Stop Hook(所有人)
typescript
// stopHooks.ts:180-189
const generator = executeStopHooks(
permissionMode, // 权限模式影响 Hook 执行
toolUseContext.abortController.signal, // 支持中断
undefined, // timeoutMs(使用默认值)
stopHookActive ?? false, // 防止递归
toolUseContext.agentId, // 子 Agent 区分
toolUseContext, // 工具上下文
[...messagesForQuery, ...assistantMessages], // 完整对话
toolUseContext.agentType, // Agent 类型
)
6.3 TaskCompleted Hook(仅 Teammate)
typescript
// stopHooks.ts:346-400
if (isTeammate()) {
const taskListId = getTaskListId()
const tasks = await listTasks(taskListId)
const inProgressTasks = tasks.filter(
t => t.status === 'in_progress' && t.owner === teammateName,
)
for (const task of inProgressTasks) {
const taskCompletedGenerator = executeTaskCompletedHooks(
task.id,
task.subject,
task.description,
teammateName,
teamName,
permissionMode,
toolUseContext.abortController.signal,
undefined,
toolUseContext,
)
// ... 消费 generator
}
}
关键细节 :TaskCompleted Hook 对每个 in_progress 任务都执行一次,不是只执行一次。如果一个 teammate 拥有多个进行中的任务,每个任务都会触发独立的 Hook 执行。
6.4 TeammateIdle Hook(仅 Teammate)
typescript
// stopHooks.ts:402-441
const teammateIdleGenerator = executeTeammateIdleHooks(
teammateName,
teamName,
permissionMode,
toolUseContext.abortController.signal,
)
TeammeIdle Hook 在 TaskCompleted Hook 之后执行,且只执行一次(不针对特定任务)。
6.5 三类 Hook 的对比
| Hook 类型 | 触发条件 | 执行次数 | 典型用途 |
|---|---|---|---|
| Stop | LLM 自然终止(无 tool_use) | 1 次 | 运行测试、代码检查 |
| TaskCompleted | Teammate 拥有 in_progress 任务 | N 次(每任务一次) | 通知任务系统 |
| TeammateIdle | Teammate 的 Stop Hook 通过 | 1 次 | 通知协调器 |
七、后台任务编排:handleStopHooks 的隐藏职责
原书将 stopHooks.ts 描述为"用户定义的生命周期拦截"。但在源码中,handleStopHooks 还编排了大量后台任务------这些任务不是用户配置的 Hook,而是 Claude Code 内置的、在 Agent 即将停下来时触发的后台逻辑。
7.1 后台任务清单
typescript
// stopHooks.ts:100-173 中的后台任务
| 任务 | Feature Gate | 执行模式 | 条件 | 代码行 |
|---|---|---|---|---|
| 模板作业分类 | TEMPLATES |
await(60s 超时) | CLAUDE_JOB_DIR + 主线程 |
L108-132 |
| Prompt Suggestion | 无 gate | fire-and-forget | 非 bare mode | L137-139 |
| 记忆提取 | EXTRACT_MEMORIES |
fire-and-forget | 非 bare mode + 非 subagent + extract mode | L141-153 |
| AutoDream | 无 gate | fire-and-forget | 非 bare mode + 非 subagent | L154-156 |
| Chicago MCP 清理 | CHICAGO_MCP |
await import | 主线程 | L164-173 |
7.2 为什么放在 Stop Hook 中?
这些后台任务为什么放在 handleStopHooks 中而不是其他地方?
答案在于时机 ------handleStopHooks 是 Agent"即将停下来"的唯一拦截点。在这个时机:
- LLM 的本轮输出已完整:可以基于完整响应做分析
- 工具执行已完成:所有工具结果已收集
- 循环可能继续也可能终止:无论哪种情况,后台任务都应该执行
这使 handleStopHooks 成为一个"自然的 turn-end 钩子"------不仅仅是"Stop Hook",更是"轮次结束时的综合处理中心"。
7.3 fire-and-forget 模式
typescript
// Prompt Suggestion --- fire-and-forget
void executePromptSuggestion(stopHookContext)
// 记忆提取 --- fire-and-forget
void extractMemoriesModule!.executeExtractMemories(
stopHookContext,
toolUseContext.appendSystemMessage,
)
// AutoDream --- fire-and-forget
void executeAutoDream(stopHookContext, toolUseContext.appendSystemMessage)
这些任务用 void 标记为 fire-and-forget------不等待完成就继续执行 Stop Hook。它们的执行不会阻塞 Agent 的停止决策。
但注意 :对于非交互模式(-p / SDK),print.ts 会在 flush 响应后、gracefulShutdownSync 前 drainPendingExtraction------确保记忆提取在进程退出前完成。
7.4 模板作业分类的超时保护
typescript
// stopHooks.ts:127-131
await Promise.race([
p,
new Promise<void>(r => setTimeout(r, 60_000).unref()),
])
模板作业分类是唯一 await 的后台任务(因为它需要写 state.json,claude list 依赖此文件)。但它有 60 秒超时保护------如果分类器卡住,不会阻塞 Agent 停止。
7.5 saveCacheSafeParams:为 /btw 和 side_question 预留快照
typescript
// stopHooks.ts:92-98
if (querySource === 'repl_main_thread' || querySource === 'sdk') {
saveCacheSafeParams(createCacheSafeParams(stopHookContext))
}
这个调用保存了一份"安全的上下文快照",供 /btw 命令和 SDK 的 side_question control_request 使用。注释明确说明这不依赖于 Prompt Suggestion 是否启用------即使 Prompt Suggestion 被禁用,快照仍然需要保存。
7.6 主线程 vs 子 Agent 的区分
多个后台任务都有 !toolUseContext.agentId 守卫:
typescript
// 记忆提取
if (feature('EXTRACT_MEMORIES') && !toolUseContext.agentId && isExtractModeActive())
// AutoDream
if (!toolUseContext.agentId)
// Chicago MCP 清理
if (feature('CHICAGO_MCP') && !toolUseContext.agentId)
原因:子 Agent 与主线程共享进程和模块级状态 。如果子 Agent 的 Stop Hook 触发记忆提取或 Chicago MCP 清理,会污染主线程的状态。这和压缩模块中 isMainThreadSource 守卫的设计理念完全一致。
八、Token 预算决策:递减收益检测
8.1 checkTokenBudget 的三态决策
typescript
// tokenBudget.ts:45-93
export function checkTokenBudget(
tracker: BudgetTracker,
agentId: string | undefined,
budget: number | null,
globalTurnTokens: number,
): TokenBudgetDecision {
// 子 Agent 或无预算 → 直接停止
if (agentId || budget === null || budget <= 0) {
return { action: 'stop', completionEvent: null }
}
const turnTokens = globalTurnTokens
const pct = Math.round((turnTokens / budget) * 100)
const deltaSinceLastCheck = globalTurnTokens - tracker.lastGlobalTurnTokens
// 递减收益检测
const isDiminishing =
tracker.continuationCount >= 3 && // 至少已延续 3 次
deltaSinceLastCheck < DIMINISHING_THRESHOLD && // 本次增量 < 500 tokens
tracker.lastDeltaTokens < DIMINISHING_THRESHOLD // 上次增量也 < 500 tokens
// 决策 1:预算充足且非递减 → 继续
if (!isDiminishing && turnTokens < budget * COMPLETION_THRESHOLD) {
tracker.continuationCount++
return { action: 'continue', nudgeMessage: ... }
}
// 决策 2:有延续记录 → 停止(带 completionEvent)
if (isDiminishing || tracker.continuationCount > 0) {
return { action: 'stop', completionEvent: { ... } }
}
// 决策 3:从未延续过 → 静默停止
return { action: 'stop', completionEvent: null }
}
8.2 三个阈值
| 常量 | 值 | 含义 |
|---|---|---|
COMPLETION_THRESHOLD |
0.9 |
当 token 使用量达到预算的 90% 时,不再自动延续 |
DIMINISHING_THRESHOLD |
500 |
连续 3 次延续后,如果每次增量 < 500 tokens,视为递减收益 |
(隐含)maxTurns |
用户配置 | 最大轮次限制 |
8.3 递减收益检测的工程意义
递减收益检测防止 Agent 在接近预算上限时做无意义的延续。设想一个场景:
- Agent 在处理一个大型重构任务
- 每轮消耗 1000 tokens 的工具调用
- 接近预算上限时,每轮只产出 200 tokens 的新内容(大部分是重复的上下文)
- 递减收益检测触发,停止循环
如果没有这个检测,Agent 会一直延续到预算耗尽------浪费 token 且产出递减。
8.4 Token Budget 在 queryLoop 中的位置
typescript
// query.ts:1308-1355
if (feature('TOKEN_BUDGET')) {
const decision = checkTokenBudget(
budgetTracker!,
toolUseContext.agentId,
getCurrentTurnTokenBudget(),
getTurnOutputTokens(),
)
if (decision.action === 'continue') {
// 注入 nudge 消息,继续循环
state = {
messages: [
...messagesForQuery,
...assistantMessages,
createUserMessage({ content: decision.nudgeMessage, isMeta: true }),
],
// ...
transition: { reason: 'token_budget_continuation' },
}
continue
}
if (decision.completionEvent) {
// 记录完成事件(用于分析)
logEvent('tengu_token_budget_completed', { ... })
}
}
return { reason: 'completed' } // ← 最终终止
Token Budget 检查在 Stop Hook 之后执行。这意味着如果 Stop Hook 返回 blockingError,Agent 会先处理 blockingError(回到循环重试),只有在 Stop Hook 通过后才检查 Token Budget。
九、分级错误恢复与终止条件的关系
9.1 Withheld 暂存:错误不一定导致终止
前几篇笔记已详细分析了 Withheld 机制。这里从停止条件的角度重新审视:
typescript
// query.ts:799-825
let withheld = false
if (feature('CONTEXT_COLLAPSE') && contextCollapse?.isWithheldPromptTooLong(message)) {
withheld = true
}
if (reactiveCompact?.isWithheldPromptTooLong(message)) {
withheld = true
}
if (mediaRecoveryEnabled && reactiveCompact?.isWithheldMediaSizeError(message)) {
withheld = true
}
if (isWithheldMaxOutputTokens(message)) {
withheld = true
}
if (!withheld) {
yield yieldMessage // ← 只有不暂存的消息才 yield 给消费者
}
Withheld 的本质是:错误消息被暂存但不 yield,给恢复机制一个"无声重试"的机会。如果恢复成功,错误对消费者不可见;如果恢复失败,错误才 surface。
9.2 恢复路径与终止的关系
Withheld 错误类型 恢复路径 恢复失败 → 终止原因
────────────────────────────────────────────────────────────────
413 (Prompt Too Long) Context Collapse 'prompt_too_long'
→ Reactive Compact
Max Output Tokens Escalate 8K → 64K 'completed'(surface 错误)
→ Multi-turn Recovery (×3)
Media Size Error Reactive Compact 'image_error'
(strip + retry)
Model Unavailable Model Fallback throw → 'model_error'
9.3 恢复次数限制
| 错误类型 | 最大恢复次数 | 超过后行为 |
|---|---|---|
| Context Collapse | 1 次 | 进入 Reactive Compact |
| Reactive Compact | 1 次 | surface 错误,终止 |
| Max Output Tokens Escalate | 1 次 | 进入 Multi-turn Recovery |
| Max Output Tokens Recovery | 3 次 | surface 错误,终止 |
| Model Fallback | 1 次 | throw 错误 |
恢复次数限制是终止保护的关键组成------没有次数限制,恢复本身可能变成无限循环。
9.4 恢复路径的防递归
typescript
// query.ts:1089-1092
if (
feature('CONTEXT_COLLAPSE') &&
contextCollapse &&
state.transition?.reason !== 'collapse_drain_retry' // ← 防递归
)
transition.reason 在这里充当"我已经尝试过这个恢复路径"的标记。如果 transition 已经是 collapse_drain_retry,说明上次已经尝试过 Context Collapse 且失败了,不再重试,直接进入 Reactive Compact。
十、StopFailure Hook:失败也通知
10.1 什么是 StopFailure?
当 Agent 因 API 错误(413、rate limit、auth failure 等)终止时,正常的 Stop Hook 不会执行(因为最后一条消息是 API 错误消息,不是 LLM 的真实响应)。但用户可能仍希望被通知。
StopFailure Hook 就是为此设计的:
typescript
// utils/hooks.ts:3594-3627
export async function executeStopFailureHooks(
lastMessage: AssistantMessage,
toolUseContext?: ToolUseContext,
timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
): Promise<void> {
const appState = toolUseContext?.getAppState()
const sessionId = getSessionId()
if (!hasHookForEvent('StopFailure', appState, sessionId)) return
const hookInput: StopFailureHookInput = {
...createBaseHookInput(undefined, undefined, toolUseContext),
hook_event_name: 'StopFailure',
error: lastMessage.error ?? 'unknown',
error_details: lastMessage.errorDetails,
last_assistant_message: lastAssistantText,
}
await executeHooksOutsideREPL({
getAppState: toolUseContext?.getAppState,
hookInput,
timeoutMs,
matchQuery: error,
})
}
10.2 调用点
typescript
// query.ts:1174
void executeStopFailureHooks(lastMessage, toolUseContext)
return { reason: isWithheldMedia ? 'image_error' : 'prompt_too_long' }
// query.ts:1181
void executeStopFailureHooks(lastMessage, toolUseContext)
return { reason: 'prompt_too_long' }
// query.ts:1263
void executeStopFailureHooks(lastMessage, toolUseContext)
return { reason: 'completed' }
注意 void------StopFailure Hook 是 fire-and-forget,不等待完成就 return 终止。
10.3 StopFailure vs Stop
| 维度 | Stop Hook | StopFailure Hook |
|---|---|---|
| 触发条件 | LLM 自然终止 | API 错误导致终止 |
| 最后一条消息 | LLM 真实响应 | API 错误消息 |
| 返回值 | StopHookResult(可阻止) | void(不可阻止) |
| 执行方式 | AsyncGenerator(yield 消息) | Promise(静默执行) |
| 典型用途 | 运行测试、代码检查 | 发送失败通知、日志记录 |
十一、QueryEngine 层的终止保护
11.1 USD 预算检查
typescript
// QueryEngine.ts:972-1001
if (maxBudgetUsd !== undefined && getTotalCost() >= maxBudgetUsd) {
yield {
type: 'result',
subtype: 'error_max_budget_usd',
duration_ms: Date.now() - startTime,
duration_api_ms: getTotalAPIDuration(),
is_error: true,
num_turns: turnCount,
total_cost_usd: getTotalCost(),
usage: this.totalUsage,
// ...
errors: [`Reached maximum budget ($${maxBudgetUsd})`],
}
return
}
USD 预算检查在 QueryEngine.submitMessage 的 for-await 消费循环中执行------每消费一条 query() yield 的消息后检查一次。这意味着 USD 预算可以在任意时刻触发终止,而不只是轮次边界。
11.2 结构化输出重试限制
typescript
// QueryEngine.ts:1004-1047
if (message.type === 'user' && jsonSchema) {
const currentCalls = countToolCalls(
this.mutableMessages,
SYNTHETIC_OUTPUT_TOOL_NAME,
)
const callsThisQuery = currentCalls - initialStructuredOutputCalls
const maxRetries = parseInt(
process.env.MAX_STRUCTURED_OUTPUT_RETRIES || '5',
10,
)
if (callsThisQuery >= maxRetries) {
yield {
type: 'result',
subtype: 'error_max_structured_output_retries',
is_error: true,
errors: [
`Failed to provide valid structured output after ${maxRetries} attempts`,
],
// ...
}
return
}
}
结构化输出 是 Claude Code 的一个特性:用户可以指定一个 JSON Schema,Agent 必须输出符合该 Schema 的结构化数据。Agent 使用 SyntheticOutput 工具来生成结构化输出。如果 Agent 反复生成不符合 Schema 的输出,达到 5 次上限后终止。
11.3 两层终止的协作
queryLoop 层 QueryEngine 层
┌──────────┐ ┌──────────────┐
│ while(true) │ ──yield──→ │ for-await │
│ │ │ ↓ │
│ 5层终止保护 │ │ 消费消息 │
│ │ │ ↓ │
│ return ──── │───Terminal──→ │ 2层额外检查 │
│ │ │ ↓ │
│ │ │ USD 预算? │
│ │ │ 结构化输出? │
│ │ │ ↓ │
│ │ │ 构造 result │
└──────────┘ └──────────────┘
关键洞察 :queryLoop 的 return 值(Terminal 类型)不会直接到达用户。它先被 QueryEngine 的 for-await 循环接收,然后 QueryEngine 可能基于 Terminal 值和自己的检查构造最终的 result 消息。如果 QueryEngine 层的检查先触发(USD 预算/结构化输出),queryLoop 会被中断(因为消费者停止消费,generator 自然暂停)。
十二、isApiErrorMessage 死螺旋防护
12.1 什么是死螺旋?
typescript
// query.ts:1258-1265
// Skip stop hooks when the last message is an API error (rate limit,
// prompt-too-long, auth failure, etc.). The model never produced a
// real response --- hooks evaluating it create a death spiral:
// error → hook blocking → retry → error → ...
if (lastMessage?.isApiErrorMessage) {
void executeStopFailureHooks(lastMessage, toolUseContext)
return { reason: 'completed' }
}
注释完美描述了死螺旋的场景:
- API 返回错误(如 rate limit)
- Stop Hook 执行,评估"错误消息",返回 blockingError
- Agent 带着 blockingError 重试
- API 再次返回同样的错误
- Stop Hook 再次返回 blockingError
- 无限循环
12.2 解决方案
当最后一条消息是 API 错误消息时:
- 跳过 Stop Hook(
executeStopHooks) - 执行 StopFailure Hook(
executeStopFailureHooks,fire-and-forget) - 直接终止 (
return { reason: 'completed' })
这个设计区分了两种"停止":
- 正常停止:LLM 产生了真实响应 → Stop Hook 有意义
- 异常停止:API 错误 → Stop Hook 无意义,只通知失败
12.3 四个调用点的统一模式
executeStopFailureHooks 有 3 个调用点(§10.2),加上 isApiErrorMessage 检查的 1 个,总共 4 个位置处理 API 错误终止。它们都遵循同一模式:
typescript
void executeStopFailureHooks(lastMessage, toolUseContext)
return { reason: '...' }
即:通知失败(不等结果),然后终止。
十三、设计模式提炼
模式 1:分层终止保护
问题:长时间运行的循环需要多重安全保证,但不同层的检查粒度不同。
方案:将终止检查分为两层------循环内部做轮次级检查(maxTurns、错误恢复上限、Token Budget),循环外部做全局检查(USD 预算、结构化输出限制)。
适用条件:
- 循环内部和外部有不同的可见性(内部看不到全局成本,外部看不到循环状态)
- 需要在任意时刻终止(不仅仅是轮次边界)
模式 2:blockingError + preventContinuation 双语义
问题:用户需要在 Agent 停止时插入两种不同类型的干预------有些需要 Agent 继续修复,有些需要等待人工审核。
方案:Stop Hook 返回两种控制信号:
blockingErrors:注入错误消息,Agent 自动重试preventContinuation:终止循环,等待用户
适用条件:
- 生命周期 Hook 需要支持"修复"和"暂停"两种语义
- 两种语义的下游处理路径完全不同
模式 3:保留恢复标记防死循环
问题:Stop Hook 返回 blockingError 后 Agent 重试,但之前的错误恢复状态(如已尝试过的压缩)不应重置,否则会导致无限循环。
方案 :在 stop_hook_blocking 路径中不重置 hasAttemptedReactiveCompact,而在正常的 next_turn 路径中重置。
适用条件:
- 多条 continue 路径共享同一状态字段
- 不同路径对该字段的重置需求不同
- 重置不当会导致死循环
模式 4:死螺旋检测与短路
问题:API 错误消息进入 Stop Hook 后,Hook 无法有效评估"错误响应",返回 blockingError 导致无限循环。
方案 :检测最后一条消息是否是 API 错误消息(isApiErrorMessage),如果是则跳过 Stop Hook,执行 StopFailure Hook 后直接终止。
适用条件:
- Hook 的输入可能包含错误响应
- Hook 对错误响应的处理可能导致递归
- 需要区分"正常停止"和"异常停止"
模式 5:后台任务编排作为 Stop Hook 的隐藏职责
问题:多个后台任务(记忆提取、Prompt Suggestion、AutoDream)需要在"轮次结束"时触发,但它们不是用户配置的 Hook。
方案 :在 handleStopHooks 内部编排这些后台任务,大部分使用 fire-and-forget 模式,不阻塞 Stop Hook 的核心决策。
适用条件:
- 有多个需要在轮次结束时触发的后台任务
- 后台任务不应阻塞主流程
- 需要区分主线程和子 Agent(避免状态污染)
十四、原书对照验证
| # | 原书描述 | 源码验证 | 结论 |
|---|---|---|---|
| 1 | "五层保护确保循环最终终止" (§3.3.3) | 源码有七层(query 层五层 + QueryEngine 层两层) | ⚠️ 原书偏少,遗漏了 USD 预算和结构化输出重试 |
| 2 | "Stop Hook 允许用户在 Agent 即将停下来时插入自定义逻辑" (§3.10.3) | handleStopHooks 不仅执行用户 Hook,还编排 5 个后台任务 |
⚠️ 原书偏少,未描述后台任务编排职责 |
| 3 | "blocking error:Hook 返回错误,阻止 Agent 停止" (§3.10.3) | stopHookResult.blockingErrors → transition: 'stop_hook_blocking' + continue |
✅ 准确 |
| 4 | "preventContinuation:阻止 Agent 自动继续,强制等待用户输入" (§3.10.3) | stopHookResult.preventContinuation → return { reason: 'stop_hook_prevented' } |
✅ 准确 |
| 5 | "三种 Hook 类型:Stop / TaskCompleted / TeammateIdle" (§3.10.3) | 源码确认三种,但 TaskCompleted 和 TeammateIdle 仅对 teammate 执行 | ✅ 准确,但缺少执行条件细节 |
| 6 | "Stop:Agent 完成当前任务时触发" (§3.10.3) | 触发条件是 !needsFollowUp(LLM 未返回 tool_use),不仅是"完成任务" |
⚠️ 描述不够精确 |
| 7 | "TaskCompleted:任务明确完成时触发" (§3.10.3) | 源码显示它对每个 in_progress 任务执行,不只是"明确完成"的任务 | ⚠️ 描述不够精确 |
| 8 | "TeammateIdle:多 Agent 协作中,队友空闲时触发" (§3.10.3) | 在 Stop Hook 和 TaskCompleted Hook 之后执行,仅 teammate | ✅ 准确 |
| 9 | "maxTurns 限制:配置参数限制最大循环轮次" (§3.3.3) | maxTurns && nextTurnCount > maxTurns → return { reason: 'max_turns' } (L1705-1711) |
✅ 准确 |
| 10 | "Token Budget 检查:每轮结束后检查累计 token 使用量" (§3.3.3) | checkTokenBudget() 在 Stop Hook 之后执行,有递减收益检测 |
✅ 准确,但原书未提及递减收益检测 |
| 11 | "错误恢复上限:每种错误类型都有最大恢复次数" (§3.3.3) | MaxOutput 3次、Collapse 1次、Reactive Compact 1次 | ✅ 准确 |
| 12 | "LLM 的自然终止:如果 LLM 不返回工具调用,循环自然结束" (§3.3.3) | !needsFollowUp → 进入 Stop Hook 流程 → 最终 return { reason: 'completed' } |
✅ 准确 |
| 13 | "外部中断:用户按 Ctrl+C 会触发 AbortController" (§3.3.3) | abortController.signal.aborted 检查在多个位置:L1015(streaming abort)、L1480(tool abort)、L283(hook abort) |
✅ 准确 |
| 14 | 原书未提及 StopFailure Hook | executeStopFailureHooks 在 API 错误终止时触发 |
❌ 原书未描述 |
| 15 | 原书未提及 isApiErrorMessage 死螺旋防护 | L1258-1265 的注释明确描述了死螺旋问题和解决方案 | ❌ 原书未描述 |
| 16 | 原书未提及 hasAttemptedReactiveCompact 在 stop_hook_blocking 中的保留 | L1292-1298 的注释描述了这个 bug 的历史 | ❌ 原书未描述 |
| 17 | "这种设计让用户可以实现'Agent 完成修改后自动运行测试,测试失败则要求 Agent 修复'的工作流" (§3.10.3) | blockingErrors → continue 的模式正是为此设计 | ✅ 准确 |
验证总结
- ✅ 准确:8 项
- ⚠️ 偏少/不精确:5 项
- ❌ 未描述:4 项(StopFailure Hook、死螺旋防护、hasAttemptedReactiveCompact 保留、后台任务编排)
原书对停止条件的描述集中在 §3.3.3(五层保护)和 §3.10.3(Stop Hook 类型),但源码揭示了更多细节:
- 七层保护(不是五层)
- Stop Hook 的隐藏职责(后台任务编排)
- 死螺旋防护(isApiErrorMessage 检查)
- 防死循环设计(hasAttemptedReactiveCompact 保留)
- StopFailure Hook(API 错误时的通知机制)
- 递减收益检测(Token Budget 的智能停止)
附录:10 种 Terminal reason 完整清单
| # | reason | 代码位置 | 含义 |
|---|---|---|---|
| 1 | 'completed' |
L1264, L1357 | LLM 自然终止 + Stop Hook 通过 |
| 2 | 'stop_hook_prevented' |
L1279 | Stop Hook 返回 preventContinuation |
| 3 | 'max_turns' |
L1711 | 超过最大轮次限制 |
| 4 | 'aborted_streaming' |
L1051 | 流式响应期间被中断 |
| 5 | 'aborted_tools' |
L1515 | 工具执行期间被中断 |
| 6 | 'hook_stopped' |
L1520 | 工具执行期间 Hook 阻止继续 |
| 7 | 'prompt_too_long' |
L1175, L1182 | 413 恢复失败 |
| 8 | 'image_error' |
L977, L1175 | 图片大小错误恢复失败 |
| 9 | 'model_error' |
L996 | 模型不可用 + Fallback 失败 |
| 10 | 'blocking_limit' |
L646 | 权限阻止限制(toolUseContext 中的 blocking limit) |
附录:7 种 Continue reason 完整清单
| # | reason | 代码位置 | 含义 |
|---|---|---|---|
| 1 | 'next_turn' |
L1725 | 正常工具调用后继续 |
| 2 | 'collapse_drain_retry' |
L1110 | Context Collapse 后重试 |
| 3 | 'reactive_compact_retry' |
L1162 | Reactive Compact 后重试 |
| 4 | 'stop_hook_blocking' |
L1302 | Stop Hook 返回 blockingError 后重试 |
| 5 | 'token_budget_continuation' |
L1338 | Token 预算延续 |
| 6 | 'max_output_tokens_escalate' |
L1217 | Max Output Tokens 升级(8K→64K) |
| 7 | 'max_output_tokens_recovery' |
L1246 | Max Output Tokens 多轮恢复 |
阶段二笔记全部完成。 本篇是 3.7 停止条件,也是阶段二的最后一篇。
按照
SOURCE_CODE_READING_PLAN.md计划,下一阶段是阶段三:工具系统 (对应第4章),重点分析Tool.ts/tools.ts工具基类与注册表、BashTool七层安全防御、FileEditTool权限检查等。如果您准备继续,请告诉我!