mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
docs: fix documentation deviations from source code (#220)
* docs: 修正 docs/conversation 文档与源码的偏差(multi-turn/streaming/the-loop) - multi-turn: TranscriptWriter→Project 私有类, 会话路径改用 sanitized-cwd, 补充 StoredCostState.lastDuration 字段, 模型切换改为 setModel(), QueryEngine 状态补全 loadedNestedMemoryPaths/hasHandledOrphanedPermission, 行号改为符号引用 - streaming: STALL_THRESHOLD_MS 10s→30s, 新增 90s 主动空闲看门狗描述, 非流式降级补充 didFallBackToNonStreaming/executeNonStreamingRequest, 行号改为符号引用 - the-loop: 终止条件 7→11, 继续条件重整为 5 组层级结构, max_output_tokens 拆分 escalate/recovery 子阶段, prompt-too-long 拆分 collapse_drain/reactive_compact 子策略, State 类型修正 autoCompactTracking 为可选, 行号改为符号引用 - 全部: 添加 sourceRef 版本锚定(3ec5675) * docs: 修正 docs/extensibility 文档与源码的偏差(custom-agents/hooks/skills) - custom-agents: Verification 模型修正为 inherit, 补充 Plugin Agent 字段限制 (permissionMode/hooks/mcpServers 被安全忽略, isolation 仅 worktree), 加载流程修正为 6 层优先级, 补充 memory snapshot 门控条件 - hooks: 事件数 22→27(补充 Notification), Hook 类型定义位置修正为 3 个文件, 行号改为符号引用, Zod schema 范围修正, 去重键修正为四部分复合键, registerFrontmatterHooks/clearSessionHooks 区分定义位置和调用位置 - skills: 字段数 17→16, 权限层级 4→5(补充 remote canonical auto-allow), SAFE_SKILL_PROPERTIES 28→30, skillUsageTracking 路径修正, 行号改为符号引用 - mcp-protocol: 全部验证通过, 无需修改 - 全部: 添加 sourceRef 版本锚定(3ec5675) * Revert "docs: 修正 docs/extensibility 文档与源码的偏差(custom-agents/hooks/skills)" * docs: 修正 docs/extensibility 文档与源码的偏差(hooks/skills/mcp-protocol) hooks: - 事件数 22→27(补充 Notification 事件) - Hook 类型定义位置修正为 3 个文件分布 (schemas/hooks.ts / types/hooks.ts / utils/hooks/sessionHooks.ts) - Zod schema 引用从硬编码行号改为符号引用 - hookSpecificOutput 表从 6 扩展至 15 个事件 (补全 permissionDecisionReason / PostToolUseFailure / SubagentStart 等) - 去重键从 pluginRoot\0command 修正为四部分复合键 (pluginRoot\0shell\0command\0ifCondition) - 全部硬编码行号改为符号引用以避免版本漂移 skills: - parseSkillFrontmatterFields 字段数 17→16 - SAFE_SKILL_PROPERTIES 属性数 28→30 - checkPermissions 层级 4→5 - 第 2 层描述从"官方市场"修正为"远程 canonical" mcp-protocol: - 配置层级从"三级"修正为 "enterprise 独占或合并 user/project/local + plugin + claude.ai" * docs: 修正 system-prompt.mdx 中 Boundary 章节的层级与可读性 - Boundary 插入条件从 ### 降为 blockquote,不再打断三种分块模式的并列结构 - 表格中 Boundary 缓存策略列补充说明其分割作用 - 新增 Boundary 概念释义(blockquote),解释其分割静态区/动态区以实现全局缓存的设计意图
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
title: "多轮对话管理 - QueryEngine 会话编排与持久化"
|
||||
description: "从源码角度解析 Claude Code 多轮对话管理:QueryEngine 的会话状态机、JSONL transcript 持久化、成本追踪模型和模型热切换机制。"
|
||||
keywords: ["多轮对话", "会话管理", "QueryEngine", "transcript", "成本追踪"]
|
||||
sourceRef: "3ec5675 (2026-04-08)"
|
||||
---
|
||||
|
||||
{/* 本章目标:从源码角度揭示会话编排、持久化存储、成本追踪和模型切换的完整链路 */}
|
||||
@@ -11,15 +12,17 @@ keywords: ["多轮对话", "会话管理", "QueryEngine", "transcript", "成本
|
||||
- **单轮**(一次 Agentic Loop):`query()` 函数的一次完整执行——组装上下文 → 调 API → 处理工具调用 → 循环直到结束
|
||||
- **多轮**(一个 Session):`QueryEngine` 类管理的一次会话——跨越数十轮 `submitMessage()` 调用,持续数小时
|
||||
|
||||
`QueryEngine`(`src/QueryEngine.ts:186`)是单轮 Agentic Loop 之上的**会话编排器**,它管理的状态远不止消息列表:
|
||||
`QueryEngine`(`src/QueryEngine.ts`,类定义)是单轮 Agentic Loop 之上的**会话编排器**,它管理的状态远不止消息列表:
|
||||
|
||||
```
|
||||
QueryEngine 内部状态
|
||||
QueryEngine 内部状态(src/QueryEngine.ts 构造函数)
|
||||
├── mutableMessages: Message[] ← 完整对话历史,跨 turn 累积
|
||||
├── readFileState: FileStateCache ← 已读文件内容缓存,避免重复读取
|
||||
├── totalUsage: NonNullableUsage ← 累计 token 消耗(input/output/cache)
|
||||
├── permissionDenials: SDKPermissionDenial[] ← 权限拒绝记录
|
||||
├── discoveredSkillNames: Set<string> ← 当前 turn 已发现的 skill
|
||||
├── loadedNestedMemoryPaths: Set<string> ← 已加载的嵌套 memory 路径(防重复)
|
||||
├── hasHandledOrphanedPermission: boolean ← 是否已处理孤立权限请求
|
||||
└── abortController: AbortController ← 会话级中断控制
|
||||
```
|
||||
|
||||
@@ -28,29 +31,37 @@ QueryEngine 内部状态
|
||||
每次用户输入一条消息,REPL 或 SDK 调用 `submitMessage()`,它会执行完整的 turn 初始化链路:
|
||||
|
||||
```typescript
|
||||
// src/QueryEngine.ts:211 — 简化的 submitMessage 流程
|
||||
async *submitMessage(prompt, options?): AsyncGenerator<SDKMessage> {
|
||||
// src/QueryEngine.ts — QueryEngine.submitMessage() 简化流程
|
||||
async *submitMessage(
|
||||
prompt: string | ContentBlockParam[],
|
||||
options?: { uuid?: string; isMeta?: boolean },
|
||||
): AsyncGenerator<SDKMessage> {
|
||||
// 1. 清除 turn 级追踪状态
|
||||
this.discoveredSkillNames.clear()
|
||||
|
||||
// 2. 解析模型(用户可能中途切换了模型)
|
||||
const mainLoopModel = userSpecifiedModel
|
||||
? parseUserSpecifiedModel(userSpecifiedModel)
|
||||
|
||||
// 2. 解析模型(用户可能中途通过 setModel() 切换了模型)
|
||||
const mainLoopModel = this.config.userSpecifiedModel
|
||||
? parseUserSpecifiedModel(this.config.userSpecifiedModel)
|
||||
: getMainLoopModel()
|
||||
|
||||
|
||||
// 3. 动态组装 System Prompt(每次 turn 都重新构建)
|
||||
const { defaultSystemPrompt, userContext, systemContext } =
|
||||
await fetchSystemPromptParts({ tools, mainLoopModel, mcpClients })
|
||||
|
||||
|
||||
// 4. 包装权限检查(追踪每次拒绝)
|
||||
const wrappedCanUseTool = async (tool, input, ...) => {
|
||||
const result = await canUseTool(tool, input, ...)
|
||||
if (result.behavior !== 'allow') {
|
||||
this.permissionDenials.push({ tool_name: tool.name, ... })
|
||||
this.permissionDenials.push({
|
||||
type: 'permission_denial',
|
||||
tool_name: sdkCompatToolName(tool.name),
|
||||
tool_use_id: toolUseID,
|
||||
tool_input: input,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// 5. 调用核心 query() 函数执行 agentic loop
|
||||
yield* query({
|
||||
systemPrompt, messages: this.mutableMessages,
|
||||
@@ -68,36 +79,43 @@ async *submitMessage(prompt, options?): AsyncGenerator<SDKMessage> {
|
||||
### 存储路径
|
||||
|
||||
```
|
||||
~/.claude/projects/<project-hash>/<session-id>.jsonl
|
||||
~/.claude/projects/<sanitized-cwd>/<session-uuid>.jsonl
|
||||
```
|
||||
|
||||
- `project-hash` 由 `getProjectDir(originalCwd)` 生成,同一项目目录的会话归入同一子目录
|
||||
- 路径由 `getProjectDir(originalCwd)` 生成,使用 `sanitizePath()` 将项目目录路径转换为安全的目录名(非 hash),同一项目目录的会话归入同一子目录
|
||||
- 每条记录是一行 JSON(JSONL 格式),支持追加写入而不需要读取-修改-写入整个文件
|
||||
- 读取上限为 50MB(`MAX_TRANSCRIPT_READ_BYTES`),防止超大会话导致 OOM
|
||||
- 读取上限为 50MB(`MAX_TRANSCRIPT_READ_BYTES` 常量,`src/utils/sessionStorage.ts`),防止超大会话导致 OOM
|
||||
|
||||
### Transcript 写入器
|
||||
|
||||
`TranscriptWriter`(`src/utils/sessionStorage.ts:1200+`)是一个写队列,确保并发的消息追加不会互相覆盖:
|
||||
`Project` 类(`src/utils/sessionStorage.ts`,私有类)管理 transcript 的写入。它通过 `writeQueues`(按文件分组的写队列)和 `drainWriteQueue()`(定时批量刷写)确保并发消息追加不会互相覆盖:
|
||||
|
||||
```
|
||||
写入流程:
|
||||
appendEntryToFile(sessionId, entry)
|
||||
写入流程(异步排队路径):
|
||||
recordTranscript(sessionId, entry)
|
||||
↓
|
||||
ensureCurrentSessionFile() ← 懒初始化:首次写入时才创建文件
|
||||
project.enqueueWrite(filePath, entry) ← 入列到 writeQueues
|
||||
↓
|
||||
序列化为 JSON + 换行符
|
||||
scheduleDrain() ← 设置定时器(FLUSH_INTERVAL_MS)
|
||||
↓
|
||||
appendFile(path, line) ← 原子追加
|
||||
drainWriteQueue() ← 按 MAX_CHUNK_BYTES 分批
|
||||
↓ 写入每批
|
||||
appendToFile(path, batchContent) ← 批量追加
|
||||
↓
|
||||
如果配置了远程持久化:
|
||||
persistToRemote(sessionId, entry)
|
||||
├── CCR v2: internalEventWriter('transcript', entry)
|
||||
└── v1 Ingress: sessionIngress.appendSessionLog(...)
|
||||
|
||||
同步直写路径(用于元数据重写等场景):
|
||||
appendEntryToFile(fullPath, entry) ← 同步 appendFileSync
|
||||
↓
|
||||
失败时 mkdir + 重试
|
||||
```
|
||||
|
||||
### 会话恢复链路
|
||||
|
||||
`--resume` 参数触发的恢复流程(`src/main.tsx:3620+`):
|
||||
`--resume` 参数触发的恢复流程(`src/main.tsx` 中 `--resume` 分支):
|
||||
|
||||
```
|
||||
1. 解析 resume 参数:
|
||||
@@ -130,7 +148,7 @@ async *submitMessage(prompt, options?): AsyncGenerator<SDKMessage> {
|
||||
### 累计层:cost-tracker.ts
|
||||
|
||||
```typescript
|
||||
// src/cost-tracker.ts — StoredCostState 数据模型
|
||||
// src/cost-tracker.ts — StoredCostState 类型定义
|
||||
type StoredCostState = {
|
||||
totalCostUSD: number // 累计美元花费
|
||||
totalAPIDuration: number // API 调用总时长(含重试)
|
||||
@@ -138,7 +156,8 @@ type StoredCostState = {
|
||||
totalToolDuration: number // 工具执行总时长
|
||||
totalLinesAdded: number // 代码增加行数
|
||||
totalLinesRemoved: number // 代码删除行数
|
||||
modelUsage: { [modelName: string]: ModelUsage } // 按模型分拆的用量
|
||||
lastDuration: number | undefined // 最近一次会话时长
|
||||
modelUsage: { [modelName: string]: ModelUsage } | undefined // 按模型分拆的用量
|
||||
}
|
||||
```
|
||||
|
||||
@@ -156,18 +175,18 @@ saveCurrentSessionCosts(sessionId)
|
||||
|
||||
### 预算熔断
|
||||
|
||||
`QueryEngineConfig.maxBudgetUsd` 提供了会话级的硬性预算上限。在 REPL 中,当累计费用超过 $5 时(`src/screens/REPL.tsx:2208`),弹出费用提醒对话框——这不是硬性阻断,而是"软提醒"。
|
||||
`QueryEngineConfig.maxBudgetUsd` 提供了会话级的硬性预算上限。在 REPL 中,当累计费用超过 $5 时(`src/screens/REPL.tsx` 中费用阈值 `useEffect`),弹出费用提醒对话框——这不是硬性阻断,而是"软提醒",且仅在 `hasConsoleBillingAccess()` 为 true 时显示。
|
||||
|
||||
## 模型热切换
|
||||
|
||||
在一个会话中切换模型不会丢失对话历史——因为 `mutableMessages` 与模型选择是解耦的:
|
||||
|
||||
```
|
||||
/model sonnet → setMainLoopModelOverride('claude-sonnet-4-20250514')
|
||||
↓
|
||||
/model sonnet → QueryEngine.setModel('claude-sonnet-4-20250514')
|
||||
↓ 实际操作:this.config.userSpecifiedModel = model(QueryEngine.setModel() 方法)
|
||||
下一次 submitMessage() 开始时:
|
||||
↓
|
||||
parseUserSpecifiedModel(userSpecifiedModel)
|
||||
parseUserSpecifiedModel(this.config.userSpecifiedModel)
|
||||
→ 返回新的模型配置
|
||||
↓
|
||||
fetchSystemPromptParts({ mainLoopModel: newModel })
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: "流式响应机制 - Claude Code 打字机效果原理"
|
||||
description: "解析 Claude Code 流式响应实现:如何通过 SSE 逐 token 接收 AI 输出,实现实时打字机效果,提升用户等待体验。"
|
||||
keywords: ["流式响应", "SSE", "streaming", "实时输出", "API streaming"]
|
||||
sourceRef: "3ec5675 (2026-04-08)"
|
||||
---
|
||||
|
||||
## 为什么需要流式
|
||||
@@ -31,7 +32,7 @@ message_stop ← 消息结束
|
||||
|
||||
### 事件处理状态机
|
||||
|
||||
`src/services/api/claude.ts:1980-2298` 实现了一个基于 `switch(part.type)` 的状态机:
|
||||
`src/services/api/claude.ts` 中 `queryStreamRaw()` 函数的事件处理循环实现了一个基于 `switch(part.type)` 的状态机:
|
||||
|
||||
| 事件类型 | 处理逻辑 | 状态变更 |
|
||||
|----------|----------|----------|
|
||||
@@ -76,7 +77,7 @@ content_block_stop (index=2)
|
||||
`stop_reason` 要等到 `message_delta` 才确定(可能是 `end_turn`、`tool_use`、`max_tokens` 等),所以最后一条消息的 `stop_reason` 是**回写**的:
|
||||
|
||||
```typescript
|
||||
// claude.ts:2246 — 直接属性修改,不用对象替换
|
||||
// claude.ts — stop_reason 回写逻辑(直接属性修改,不用对象替换)
|
||||
// 因为 transcript 写队列持有 message.message 的引用
|
||||
const lastMsg = newMessages.at(-1)
|
||||
if (lastMsg) {
|
||||
@@ -89,16 +90,21 @@ if (lastMsg) {
|
||||
|
||||
### 网络断开
|
||||
|
||||
流式连接依赖 SSE(Server-Sent Events)。当连接中断时:
|
||||
流式连接依赖 SSE(Server-Sent Events)。当连接中断时,系统有两层检测机制:
|
||||
|
||||
1. **Stream idle watchdog**:定时检测事件间隔,超过阈值(stall)触发告警和重试
|
||||
2. **Stream abort**:如果 watchdog 检测到长时间无事件,抛出错误进入重试流程
|
||||
3. **非流式降级**:作为最后手段,回退到非流式请求(一次性获取完整响应)
|
||||
1. **被动停滞检测**(`src/services/api/claude.ts` 中 stall 检测逻辑):当下一个事件到达时,计算与上一个事件的时间间隔。超过阈值(30 秒,`STALL_THRESHOLD_MS = 30_000`)记录为一次 stall,累积计数并写入遥测日志。这是被动检测——仅在下一个 chunk 到达时才触发,不会主动中断流。
|
||||
2. **主动空闲超时看门狗**(`src/services/api/claude.ts` 中 `STREAM_IDLE_TIMEOUT_MS` 看门狗逻辑):使用 `setTimeout` 设置 90 秒(可通过 `CLAUDE_STREAM_IDLE_TIMEOUT_MS` 环境变量覆盖)的硬性超时。如果在此期间没有收到任何事件,主动终止流并抛出错误进入重试流程。
|
||||
3. **非流式降级**:作为最后手段,设置 `didFallBackToNonStreaming` 标志,通过 `executeNonStreamingRequest()` 回退到非流式请求(一次性获取完整响应)。
|
||||
|
||||
```typescript
|
||||
// claude.ts:2338-2355 — 检测空流
|
||||
// 1. 完全没有事件 → 代理返回了非 SSE 响应
|
||||
// 2. 有 message_start 但没有 content_block_stop → 流被截断
|
||||
// claude.ts — 被动停滞检测
|
||||
const STALL_THRESHOLD_MS = 30_000 // 30 秒无事件视为停滞
|
||||
let totalStallTime = 0
|
||||
let stallCount = 0
|
||||
|
||||
// claude.ts — 主动空闲超时
|
||||
const STREAM_IDLE_TIMEOUT_MS =
|
||||
parseInt(process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS || '', 10) || 90_000
|
||||
```
|
||||
|
||||
### API 限流
|
||||
@@ -118,7 +124,7 @@ if (lastMsg) {
|
||||
| **上下文窗口超限** | `model_context_window_exceeded` | 触发 compaction 压缩对话历史后重试 |
|
||||
|
||||
```typescript
|
||||
// claude.ts:2267-2293
|
||||
// claude.ts — stop_reason 处理
|
||||
if (stopReason === 'max_tokens') {
|
||||
yield createAssistantAPIErrorMessage({ error: 'max_output_tokens', ... })
|
||||
}
|
||||
@@ -133,8 +139,8 @@ if (stopReason === 'model_context_window_exceeded') {
|
||||
系统持续监控事件到达间隔,检测"停滞"(stall):
|
||||
|
||||
```typescript
|
||||
// claude.ts:1940-1966
|
||||
const STALL_THRESHOLD_MS = 10_000 // 10 秒无事件视为停滞
|
||||
// claude.ts — stall 检测逻辑
|
||||
const STALL_THRESHOLD_MS = 30_000 // 30 秒无事件视为停滞
|
||||
if (timeSinceLastEvent > STALL_THRESHOLD_MS) {
|
||||
stallCount++
|
||||
totalStallTime += timeSinceLastEvent
|
||||
@@ -142,7 +148,7 @@ if (timeSinceLastEvent > STALL_THRESHOLD_MS) {
|
||||
}
|
||||
```
|
||||
|
||||
多个 stall 累积后,watchdog 可能决定中断流并触发重试。
|
||||
这是**被动检测**——仅在下一个 chunk 到达时才触发比较。与之互补的是 90 秒主动空闲超时看门狗(`STREAM_IDLE_TIMEOUT_MS`),会直接中断长时间无响应的流。
|
||||
|
||||
## 工具执行的流式反馈
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: "Agentic Loop:AI 自主循环的核心机制"
|
||||
description: "深入解析 Claude Code 的 query() 异步生成器循环——从流式 API 调用、工具并行执行、上下文压缩、错误恢复到终止条件的完整状态机,基于 src/query.ts 的源码级分析。"
|
||||
keywords: ["Agentic Loop", "query loop", "tool_use", "状态机", "auto-compact", "streaming", "recovery"]
|
||||
sourceRef: "3ec5675 (2026-04-08)"
|
||||
---
|
||||
|
||||
{/* 本章目标:基于 src/query.ts 揭示 Agentic Loop 的完整状态机 */}
|
||||
@@ -11,7 +12,7 @@ keywords: ["Agentic Loop", "query loop", "tool_use", "状态机", "auto-compact"
|
||||
传统聊天机器人:你问一句,它答一句。
|
||||
Claude Code 不一样:你说一个需求,它可能连续执行十几步操作才给你最终结果。
|
||||
|
||||
这背后的机制叫做 **Agentic Loop**(智能体循环),核心实现在 `src/query.ts` 的 `queryLoop()` 异步生成器函数(第 241 行)。它是一个 `while(true)` 无限循环,每次迭代代表一次"思考→行动→观察"周期。
|
||||
这背后的机制叫做 **Agentic Loop**(智能体循环),核心实现在 `src/query.ts` 的 `queryLoop()` 异步生成器函数。它是一个 `while(true)` 无限循环,每次迭代代表一次"思考→行动→观察"周期。
|
||||
|
||||
<Frame caption="Agentic Loop 循环示意">
|
||||
<img src="/docs/images/agentic-loop.png" alt="Agentic Loop 循环图" />
|
||||
@@ -19,7 +20,7 @@ Claude Code 不一样:你说一个需求,它可能连续执行十几步操
|
||||
|
||||
## 循环的完整结构
|
||||
|
||||
`queryLoop()` 的每次迭代(`src/query.ts:307` `while(true)`)包含以下阶段:
|
||||
`queryLoop()` 的每次迭代(`src/query.ts` 中 `while(true)` 主循环)包含以下阶段:
|
||||
|
||||
### 阶段 1:上下文预处理(Pre-Processing Pipeline)
|
||||
|
||||
@@ -39,7 +40,7 @@ messagesForQuery(处理后的消息)→ 发往 API
|
||||
|
||||
### 阶段 2:流式 API 调用(Streaming Loop)
|
||||
|
||||
`deps.callModel()` 发起流式请求(第 659 行),返回一个 AsyncGenerator。在流式过程中:
|
||||
`deps.callModel()` 发起流式请求(`src/query.ts` 中 `attemptWithFallback` 循环内),返回一个 AsyncGenerator。在流式过程中:
|
||||
|
||||
- **AssistantMessage** 被收集到 `assistantMessages[]` 数组
|
||||
- **tool_use 块** 被提取到 `toolUseBlocks[]`,设置 `needsFollowUp = true`
|
||||
@@ -47,8 +48,8 @@ messagesForQuery(处理后的消息)→ 发往 API
|
||||
- 可恢复的错误(prompt-too-long、max-output-tokens)被**暂扣**(withheld),先尝试恢复
|
||||
|
||||
流式回调中的关键守卫:
|
||||
- `backfillObservableInput()`(第 763 行)—— 为 tool_use 块回填可观察字段(如文件路径展开),但只在添加了新字段时才克隆消息,避免破坏 prompt cache 的字节一致性
|
||||
- 流式降级检测——如果 `streamingFallbackOccured`,已收集的消息被标记为 tombstone(第 717 行),清空后重试
|
||||
- `backfillObservableInput()` —— 为 tool_use 块回填可观察字段(如文件路径展开),但只在添加了新字段时才克隆消息,避免破坏 prompt cache 的字节一致性
|
||||
- 流式降级检测——如果 `streamingFallbackOccured`,已收集的消息被标记为 tombstone,清空后重试
|
||||
|
||||
### 阶段 3:工具执行(Tool Execution)
|
||||
|
||||
@@ -67,42 +68,50 @@ const toolUpdates = streamingToolExecutor
|
||||
|
||||
每次迭代结束时,根据条件决定 `return`(终止)或 `continue`(继续):
|
||||
|
||||
## 7 种终止条件(源码级)
|
||||
## 终止条件(源码级)
|
||||
|
||||
循环有多种终止路径,按触发时机排列:
|
||||
|
||||
| 终止原因 | 触发位置 | 机制 |
|
||||
|----------|---------|------|
|
||||
| **completed** | 第 1360 行 | AI 未发出 tool_use → `needsFollowUp = false` → 经过 stop hooks → 返回 |
|
||||
| **blocking_limit** | 第 646 行 | Token 计数超过硬限制(非 autocompact 模式)→ 生成 PTL 错误消息 → 返回 |
|
||||
| **aborted_streaming** | 第 1054 行 | `abortController.signal.aborted` → 为未完成的 tool_use 生成合成 tool_result → 返回 |
|
||||
| **model_error** | 第 999 行 | `callModel()` 抛出异常 → 生成错误消息 → 返回 |
|
||||
| **prompt_too_long** | 第 1178 行 | 413 错误且 reactive compact 无法恢复 → 暂扣的错误消息被释放 → 返回 |
|
||||
| **image_error** | 第 980/1178 行 | 图片尺寸/大小错误 → 直接返回 |
|
||||
| **image_error** | 第 980 行 | `ImageSizeError` / `ImageResizeError` 异常 → 直接返回 |
|
||||
| **model_error** | 第 999 行 | `callModel()` 抛出不可恢复异常 → 生成错误消息 → 返回 |
|
||||
| **aborted_streaming** | 第 1054 行 | `abortController.signal.aborted`(流式阶段)→ 为未完成的 tool_use 生成合成 tool_result → 返回 |
|
||||
| **prompt_too_long** | 第 1178/1185 行 | 413 错误且 reactive compact 无法恢复 → 暂扣的错误消息被释放 → 返回 |
|
||||
| **completed** | 第 1267 行 | API 错误(限流、认证失败等)导致无法继续 → 返回 |
|
||||
| **stop_hook_prevented** | 第 1282 行 | Stop hook 返回 `preventContinuation: true` → 返回 |
|
||||
| **completed** | 第 1360 行 | 正常完成:AI 未发出 tool_use → `needsFollowUp = false` → 经过 stop hooks → 返回 |
|
||||
| **aborted_tools** | 第 1518 行 | `abortController.signal.aborted`(工具执行阶段)→ 返回 |
|
||||
| **hook_stopped** | 第 1523 行 | 工具执行期间 hook 返回 `shouldPreventContinuation` → 返回 |
|
||||
| **max_turns** | 第 1714 行 | 轮次计数超过 `maxTurns` 限制 → 返回 |
|
||||
|
||||
## 4 种继续条件(恢复路径)
|
||||
## 继续条件(恢复路径)
|
||||
|
||||
循环不仅是一个简单的"有 tool_use 就继续",它还包含多种恢复/重试路径:
|
||||
|
||||
### 1. 正常工具循环
|
||||
`needsFollowUp = true` → 执行工具 → 新消息追加到 `messagesForQuery` → `continue`
|
||||
### 1. 正常工具循环(`next_turn`)
|
||||
`needsFollowUp = true` → 执行工具 → 新消息追加到 `messagesForQuery` → state 重新赋值 → `continue`
|
||||
|
||||
### 2. max_output_tokens 恢复(第 1191-1255 行)
|
||||
当 AI 输出被截断时(`apiError === 'max_output_tokens'`):
|
||||
- **首次**:尝试将 `maxOutputTokens` 从默认值提升到 `ESCALATED_MAX_TOKENS`(64K),无 meta 消息,静默重试
|
||||
- **后续**:注入恢复消息"Output token limit hit. Resume directly...",最多重试 `MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3` 次
|
||||
- 恢复耗尽后,暂扣的错误消息被释放
|
||||
### 2. max_output_tokens 恢复(`max_output_tokens_escalate` / `max_output_tokens_recovery`)
|
||||
当 AI 输出被截断时(`apiError === 'max_output_tokens'`),分两阶段恢复:
|
||||
- **提升阶段**(`max_output_tokens_escalate`):首次截断时,将 `maxOutputTokens` 从默认值提升到 `ESCALATED_MAX_TOKENS`(64K)。静默重试,不注入 meta 消息。
|
||||
- **恢复阶段**(`max_output_tokens_recovery`):提升后仍然截断时,注入恢复消息"Output token limit hit. Resume directly...",最多重试 `MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3` 次。恢复耗尽后,暂扣的错误消息被释放。
|
||||
|
||||
### 3. Prompt-Too-Long 恢复(第 1088-1186 行)
|
||||
当遇到 413 错误时,有两个恢复阶段:
|
||||
- **Context Collapse Drain**(第 1097 行):提交所有已暂存的折叠,释放空间后重试。如果上一轮已经是 collapse_drain_retry 则跳过
|
||||
- **Reactive Compact**(第 1123 行):触发即时压缩,生成摘要后重试。`hasAttemptedReactiveCompact` 防止无限循环
|
||||
### 3. Prompt-Too-Long 恢复(`collapse_drain_retry` / `reactive_compact_retry`)
|
||||
当遇到 413 错误时,按优先级尝试两种压缩策略:
|
||||
- **Context Collapse Drain**(`collapse_drain_retry`):提交所有已暂存的折叠(collapse),释放空间后重试。如果上一轮已经是 `collapse_drain_retry` 则跳过,避免无限循环。
|
||||
- **Reactive Compact**(`reactive_compact_retry`):如果 collapse drain 无法恢复,触发即时压缩(reactive compact),生成摘要后重试。`hasAttemptedReactiveCompact` 标志防止无限循环。
|
||||
|
||||
### 4. Stop Hook 阻塞重试(第 1285-1308 行)
|
||||
### 4. Stop Hook 阻塞重试(`stop_hook_blocking`)
|
||||
Stop hook 可以注入阻塞错误消息,强制 AI 重新思考。新的消息(包含阻塞错误)被追加到对话中,`stopHookActive = true`,进入下一轮迭代。
|
||||
|
||||
### 5. Token Budget 继续提示(`token_budget_continuation`)
|
||||
当 `TOKEN_BUDGET` feature 启用时,如果 token 消耗达到阈值但未超出预算,注入 nudge 消息让 AI 加速收尾,然后继续。
|
||||
|
||||
## 模型降级(Fallback)
|
||||
|
||||
当主模型不可用时(`FallbackTriggeredError`,第 897 行):
|
||||
当主模型不可用时(`FallbackTriggeredError`,`src/query.ts` 中 `attemptWithFallback` 循环的 catch 分支):
|
||||
|
||||
1. 已收集的 `assistantMessages` 被清空,tool_use 块收到合成 tool_result:"Model fallback triggered"
|
||||
2. 思维签名块被移除(`stripSignatureBlocks`)—— 因为思维签名与模型绑定,跨模型回放会 400
|
||||
@@ -112,13 +121,14 @@ Stop hook 可以注入阻塞错误消息,强制 AI 重新思考。新的消息
|
||||
|
||||
## 状态机:State 对象
|
||||
|
||||
每次迭代的状态通过 `State` 类型(第 204 行)传递:
|
||||
每次迭代的状态通过 `State` 类型(`src/query.ts`,类型定义)传递:
|
||||
|
||||
```typescript
|
||||
// src/query.ts — State 类型定义
|
||||
type State = {
|
||||
messages: Message[] // 当前对话消息
|
||||
toolUseContext: ToolUseContext // 工具上下文(含权限)
|
||||
autoCompactTracking: AutoCompactTrackingState // 压缩跟踪
|
||||
autoCompactTracking: AutoCompactTrackingState | undefined // 压缩跟踪
|
||||
maxOutputTokensRecoveryCount: number // 输出截断恢复计数
|
||||
hasAttemptedReactiveCompact: boolean // 是否已尝试即时压缩
|
||||
maxOutputTokensOverride: number | undefined // 输出 token 上限覆盖
|
||||
@@ -133,7 +143,7 @@ type State = {
|
||||
|
||||
## Token Budget(实验性)
|
||||
|
||||
当 `TOKEN_BUDGET` feature 启用时(第 1311 行),循环在终止前会检查 token 消耗:
|
||||
当 `TOKEN_BUDGET` feature 启用时(`src/query.ts` 中 `!needsFollowUp` 分支内的预算检查逻辑),循环在终止前会检查 token 消耗:
|
||||
|
||||
- **continuation**:未达到预算但超过阈值 → 注入 nudge 消息,让 AI 加速收尾
|
||||
- **diminishing_returns**:检测到收益递减 → 提前终止
|
||||
@@ -157,26 +167,31 @@ type State = {
|
||||
|
||||
```
|
||||
迭代 1: 思考→行动
|
||||
预处理: 无需压缩(上下文很短)
|
||||
预处理管道: applyToolResultBudget → snipCompact(HISTORY_SNIP feature) → microcompact → applyCollapses(CONTEXT_COLLAPSE feature) → autocompact
|
||||
→ 上下文很短,无需压缩
|
||||
API 调用: 返回 tool_use(Glob, "**/*.ts")
|
||||
工具执行: 返回 42 个文件路径
|
||||
→ needsFollowUp = true, continue
|
||||
→ needsFollowUp = true
|
||||
→ transition: { reason: 'next_turn' }, continue
|
||||
|
||||
迭代 2: 思考→行动
|
||||
预处理: 42 个文件结果仍在预算内
|
||||
预处理管道: 42 个文件结果仍在预算内
|
||||
API 调用: 返回 tool_use(Grep, "import.*from")
|
||||
工具执行: 在 15 个文件中找到 120 条 import
|
||||
→ needsFollowUp = true, continue
|
||||
→ needsFollowUp = true
|
||||
→ transition: { reason: 'next_turn' }, continue
|
||||
|
||||
迭代 3: 思考→行动(多轮)
|
||||
预处理: 120 条 Grep 结果触发 microcompact → 摘要化
|
||||
预处理管道: 120 条 Grep 结果触发 microcompact → 摘要化
|
||||
API 调用: 返回 3 个 tool_use(FileEdit, ...)
|
||||
工具执行: 删除 5 条未使用导入
|
||||
→ needsFollowUp = true, continue
|
||||
→ needsFollowUp = true
|
||||
→ transition: { reason: 'next_turn' }, continue
|
||||
|
||||
迭代 4: 总结
|
||||
API 调用: 返回纯文本"已清理 3 个文件中的 5 条未使用导入"
|
||||
→ needsFollowUp = false
|
||||
→ Stop hooks 通过
|
||||
→ Token Budget 检查通过(如果启用)
|
||||
→ return { reason: 'completed' }
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user