3.7 停止条件 — while(true) 的安全出口

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 列出了"五层保护":

  1. maxTurns 限制
  2. Token Budget 检查
  3. 错误恢复上限
  4. LLM 自然终止
  5. 外部中断

但在源码中,我们发现了七层------原书遗漏了 QueryEngine 层的两层:

  1. USD 预算QueryEngine.ts:972
  2. 结构化输出重试限制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 内部,⑥~⑦在 QueryEnginefor-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 问题场景

考虑以下场景:

  1. Agent 因 413(Prompt Too Long)触发 Reactive Compact
  2. Compact 后重试,仍然 413
  3. 此时 Stop Hook 执行(因为 !needsFollowUp),返回 blockingError
  4. Agent 带着 blockingError 回到循环
  5. 又遇到 413 → 又触发 Compact → 又失败 → Stop Hook 又返回 blockingError...
  6. 无限循环

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"即将停下来"的唯一拦截点。在这个时机:

  1. LLM 的本轮输出已完整:可以基于完整响应做分析
  2. 工具执行已完成:所有工具结果已收集
  3. 循环可能继续也可能终止:无论哪种情况,后台任务都应该执行

这使 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 响应后、gracefulShutdownSyncdrainPendingExtraction------确保记忆提取在进程退出前完成。

7.4 模板作业分类的超时保护

typescript 复制代码
// stopHooks.ts:127-131
await Promise.race([
  p,
  new Promise<void>(r => setTimeout(r, 60_000).unref()),
])

模板作业分类是唯一 await 的后台任务(因为它需要写 state.jsonclaude 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 在接近预算上限时做无意义的延续。设想一个场景:

  1. Agent 在处理一个大型重构任务
  2. 每轮消耗 1000 tokens 的工具调用
  3. 接近预算上限时,每轮只产出 200 tokens 的新内容(大部分是重复的上下文)
  4. 递减收益检测触发,停止循环

如果没有这个检测,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.submitMessagefor-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' }
}

注释完美描述了死螺旋的场景:

  1. API 返回错误(如 rate limit)
  2. Stop Hook 执行,评估"错误消息",返回 blockingError
  3. Agent 带着 blockingError 重试
  4. API 再次返回同样的错误
  5. Stop Hook 再次返回 blockingError
  6. 无限循环

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.blockingErrorstransition: 'stop_hook_blocking' + continue ✅ 准确
4 "preventContinuation:阻止 Agent 自动继续,强制等待用户输入" (§3.10.3) stopHookResult.preventContinuationreturn { 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 > maxTurnsreturn { 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 类型),但源码揭示了更多细节:

  1. 七层保护(不是五层)
  2. Stop Hook 的隐藏职责(后台任务编排)
  3. 死螺旋防护(isApiErrorMessage 检查)
  4. 防死循环设计(hasAttemptedReactiveCompact 保留)
  5. StopFailure Hook(API 错误时的通知机制)
  6. 递减收益检测(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 权限检查等。如果您准备继续,请告诉我!

相关推荐
笨蛋©4 小时前
[实战] 2026年制造业图纸数字化:图片PDF转DXF的精度控制与质量管理流程
ai·数字化·cad·质量管理·制造业
HZZD_HZZD4 小时前
DL/T 645-2026新国标深度解读与智能电表协议适配实战:从帧结构变化到Java采集器升级的全链路改造方案
java·开发语言
xcLeigh6 小时前
提示词基础:什么是Prompt以及它为何如此重要
人工智能·ai·大模型·prompt·ai写作·提示词
多加点辣也没关系6 小时前
JavaScript|第4章:类型转换
开发语言·javascript
聪慧的水蜜桃6 小时前
【YFIOs】用C#开发硬件之设备上云
开发语言·c#
yqcoder6 小时前
httpOnly 是什么,又有什么用?
开发语言·前端·javascript
一叶飘零_sweeeet7 小时前
Codex 与 Claude Code 深度拆解:两代 AI 编程智能体的技术本质与 Java 实战指南
java·ai·codex·claude code
wuqingshun3141597 小时前
重写equals而不重写hashCode,会出什么问题?
java·开发语言
飞猪~7 小时前
LangChain python 版本 第一集
开发语言·python·langchain
AI 小老六8 小时前
Agent 评测系统架构:从指标分层到 GT/Judge 闭环的工程化落地
人工智能·ai·架构·系统架构·可用性测试