Skip to content

Commit 8b2532a

Browse files
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),解释其分割静态区/动态区以实现全局缓存的设计意图
1 parent 2da6514 commit 8b2532a

7 files changed

Lines changed: 159 additions & 105 deletions

File tree

docs/context/system-prompt.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,11 @@ export function asSystemPrompt(value: readonly string[]): SystemPrompt {
4343
| 阶段 | 内容 | 缓存策略 |
4444
|------|------|----------|
4545
| **静态区** | Intro Section、System Rules、Doing Tasks、Actions、Using Tools、Tone & Style、Output Efficiency | 可跨组织缓存(`scope: 'global'`|
46-
| **BOUNDARY** | `SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'` | 分界标记(不发送给 API) |
46+
| **BOUNDARY** | `SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'` | 分界标记(不发送给 API,仅用于分割静态区与动态区以实现全局缓存|
4747
| **动态区** | Session Guidance、Memory、Model Override、Env Info、Language、Output Style、MCP Instructions、Scratchpad、FRC、Summarize Tool Results、Token Budget、Brief | 每次会话不同(`scope: 'org'` 或无缓存) |
4848

49+
> **Boundary 是什么**:它把 System Prompt 分成"不变的静态区"和"因用户/会话而异的动态区"。静态区对所有用户相同,可获得 `scope: 'global'` 跨组织缓存;动态区每次不同,只能 `scope: 'org'` 或不缓存。它本身是一个特殊字符串,在发送给 API 前被移除,AI 永远看不到。
50+
4951
### 动态区的 Section 注册表
5052

5153
动态区通过 `systemPromptSection()` / `DANGEROUS_uncachedSystemPromptSection()` 注册,这两个工厂函数定义于 `src/constants/systemPromptSections.ts`
@@ -151,9 +153,7 @@ MCP 工具列表在会话中可能变化(连接/断开),破坏了跨组织
151153
152154
这是缓存效率最高的模式。`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 之前的静态内容(Intro、Rules、Tone & Style 等)对所有用户相同,可跨组织缓存。
153155
154-
### Boundary 插入条件
155-
156-
`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 标记**仅在特定条件**下插入:
156+
> **Boundary 插入条件**:`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 标记**仅在特定条件**下插入:
157157
158158
```typescript
159159
// src/utils/betas.ts:226-229

docs/conversation/multi-turn.mdx

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
title: "多轮对话管理 - QueryEngine 会话编排与持久化"
33
description: "从源码角度解析 Claude Code 多轮对话管理:QueryEngine 的会话状态机、JSONL transcript 持久化、成本追踪模型和模型热切换机制。"
44
keywords: ["多轮对话", "会话管理", "QueryEngine", "transcript", "成本追踪"]
5+
sourceRef: "3ec5675 (2026-04-08)"
56
---
67

78
{/* 本章目标:从源码角度揭示会话编排、持久化存储、成本追踪和模型切换的完整链路 */}
@@ -11,15 +12,17 @@ keywords: ["多轮对话", "会话管理", "QueryEngine", "transcript", "成本
1112
- **单轮**(一次 Agentic Loop):`query()` 函数的一次完整执行——组装上下文 → 调 API → 处理工具调用 → 循环直到结束
1213
- **多轮**(一个 Session):`QueryEngine` 类管理的一次会话——跨越数十轮 `submitMessage()` 调用,持续数小时
1314

14-
`QueryEngine``src/QueryEngine.ts:186`)是单轮 Agentic Loop 之上的**会话编排器**,它管理的状态远不止消息列表:
15+
`QueryEngine``src/QueryEngine.ts`,类定义)是单轮 Agentic Loop 之上的**会话编排器**,它管理的状态远不止消息列表:
1516

1617
```
17-
QueryEngine 内部状态
18+
QueryEngine 内部状态(src/QueryEngine.ts 构造函数)
1819
├── mutableMessages: Message[] ← 完整对话历史,跨 turn 累积
1920
├── readFileState: FileStateCache ← 已读文件内容缓存,避免重复读取
2021
├── totalUsage: NonNullableUsage ← 累计 token 消耗(input/output/cache)
2122
├── permissionDenials: SDKPermissionDenial[] ← 权限拒绝记录
2223
├── discoveredSkillNames: Set<string> ← 当前 turn 已发现的 skill
24+
├── loadedNestedMemoryPaths: Set<string> ← 已加载的嵌套 memory 路径(防重复)
25+
├── hasHandledOrphanedPermission: boolean ← 是否已处理孤立权限请求
2326
└── abortController: AbortController ← 会话级中断控制
2427
```
2528

@@ -28,29 +31,37 @@ QueryEngine 内部状态
2831
每次用户输入一条消息,REPL 或 SDK 调用 `submitMessage()`,它会执行完整的 turn 初始化链路:
2932

3033
```typescript
31-
// src/QueryEngine.ts:211 — 简化的 submitMessage 流程
32-
async *submitMessage(prompt, options?): AsyncGenerator<SDKMessage> {
34+
// src/QueryEngine.ts — QueryEngine.submitMessage() 简化流程
35+
async *submitMessage(
36+
prompt: string | ContentBlockParam[],
37+
options?: { uuid?: string; isMeta?: boolean },
38+
): AsyncGenerator<SDKMessage> {
3339
// 1. 清除 turn 级追踪状态
3440
this.discoveredSkillNames.clear()
35-
36-
// 2. 解析模型(用户可能中途切换了模型
37-
const mainLoopModel = userSpecifiedModel
38-
? parseUserSpecifiedModel(userSpecifiedModel)
41+
42+
// 2. 解析模型(用户可能中途通过 setModel() 切换了模型
43+
const mainLoopModel = this.config.userSpecifiedModel
44+
? parseUserSpecifiedModel(this.config.userSpecifiedModel)
3945
: getMainLoopModel()
40-
46+
4147
// 3. 动态组装 System Prompt(每次 turn 都重新构建)
4248
const { defaultSystemPrompt, userContext, systemContext } =
4349
await fetchSystemPromptParts({ tools, mainLoopModel, mcpClients })
44-
50+
4551
// 4. 包装权限检查(追踪每次拒绝)
4652
const wrappedCanUseTool = async (tool, input, ...) => {
4753
const result = await canUseTool(tool, input, ...)
4854
if (result.behavior !== 'allow') {
49-
this.permissionDenials.push({ tool_name: tool.name, ... })
55+
this.permissionDenials.push({
56+
type: 'permission_denial',
57+
tool_name: sdkCompatToolName(tool.name),
58+
tool_use_id: toolUseID,
59+
tool_input: input,
60+
})
5061
}
5162
return result
5263
}
53-
64+
5465
// 5. 调用核心 query() 函数执行 agentic loop
5566
yield* query({
5667
systemPrompt, messages: this.mutableMessages,
@@ -68,36 +79,43 @@ async *submitMessage(prompt, options?): AsyncGenerator<SDKMessage> {
6879
### 存储路径
6980

7081
```
71-
~/.claude/projects/<project-hash>/<session-id>.jsonl
82+
~/.claude/projects/<sanitized-cwd>/<session-uuid>.jsonl
7283
```
7384

74-
- `project-hash``getProjectDir(originalCwd)` 生成,同一项目目录的会话归入同一子目录
85+
- 路径由 `getProjectDir(originalCwd)` 生成,使用 `sanitizePath()` 将项目目录路径转换为安全的目录名(非 hash),同一项目目录的会话归入同一子目录
7586
- 每条记录是一行 JSON(JSONL 格式),支持追加写入而不需要读取-修改-写入整个文件
76-
- 读取上限为 50MB(`MAX_TRANSCRIPT_READ_BYTES`),防止超大会话导致 OOM
87+
- 读取上限为 50MB(`MAX_TRANSCRIPT_READ_BYTES` 常量,`src/utils/sessionStorage.ts`),防止超大会话导致 OOM
7788

7889
### Transcript 写入器
7990

80-
`TranscriptWriter``src/utils/sessionStorage.ts:1200+`)是一个写队列,确保并发的消息追加不会互相覆盖
91+
`Project``src/utils/sessionStorage.ts`,私有类)管理 transcript 的写入。它通过 `writeQueues`(按文件分组的写队列)和 `drainWriteQueue()`(定时批量刷写)确保并发消息追加不会互相覆盖
8192

8293
```
83-
写入流程:
84-
appendEntryToFile(sessionId, entry)
94+
写入流程(异步排队路径)
95+
recordTranscript(sessionId, entry)
8596
86-
ensureCurrentSessionFile() ← 懒初始化:首次写入时才创建文件
97+
project.enqueueWrite(filePath, entry) ← 入列到 writeQueues
8798
88-
序列化为 JSON + 换行符
99+
scheduleDrain() ← 设置定时器(FLUSH_INTERVAL_MS)
89100
90-
appendFile(path, line) ← 原子追加
101+
drainWriteQueue() ← 按 MAX_CHUNK_BYTES 分批
102+
↓ 写入每批
103+
appendToFile(path, batchContent) ← 批量追加
91104
92105
如果配置了远程持久化:
93106
persistToRemote(sessionId, entry)
94107
├── CCR v2: internalEventWriter('transcript', entry)
95108
└── v1 Ingress: sessionIngress.appendSessionLog(...)
109+
110+
同步直写路径(用于元数据重写等场景):
111+
appendEntryToFile(fullPath, entry) ← 同步 appendFileSync
112+
113+
失败时 mkdir + 重试
96114
```
97115

98116
### 会话恢复链路
99117

100-
`--resume` 参数触发的恢复流程(`src/main.tsx:3620+`):
118+
`--resume` 参数触发的恢复流程(`src/main.tsx``--resume` 分支):
101119

102120
```
103121
1. 解析 resume 参数:
@@ -130,15 +148,16 @@ async *submitMessage(prompt, options?): AsyncGenerator<SDKMessage> {
130148
### 累计层:cost-tracker.ts
131149

132150
```typescript
133-
// src/cost-tracker.ts — StoredCostState 数据模型
151+
// src/cost-tracker.ts — StoredCostState 类型定义
134152
type StoredCostState = {
135153
totalCostUSD: number // 累计美元花费
136154
totalAPIDuration: number // API 调用总时长(含重试)
137155
totalAPIDurationWithoutRetries: number // 不含重试的纯推理时间
138156
totalToolDuration: number // 工具执行总时长
139157
totalLinesAdded: number // 代码增加行数
140158
totalLinesRemoved: number // 代码删除行数
141-
modelUsage: { [modelName: string]: ModelUsage } // 按模型分拆的用量
159+
lastDuration: number | undefined // 最近一次会话时长
160+
modelUsage: { [modelName: string]: ModelUsage } | undefined // 按模型分拆的用量
142161
}
143162
```
144163
@@ -156,18 +175,18 @@ saveCurrentSessionCosts(sessionId)
156175
157176
### 预算熔断
158177
159-
`QueryEngineConfig.maxBudgetUsd` 提供了会话级的硬性预算上限。在 REPL 中,当累计费用超过 $5 时(`src/screens/REPL.tsx:2208`),弹出费用提醒对话框——这不是硬性阻断,而是"软提醒"。
178+
`QueryEngineConfig.maxBudgetUsd` 提供了会话级的硬性预算上限。在 REPL 中,当累计费用超过 $5 时(`src/screens/REPL.tsx` 中费用阈值 `useEffect`),弹出费用提醒对话框——这不是硬性阻断,而是"软提醒",且仅在 `hasConsoleBillingAccess()` 为 true 时显示
160179
161180
## 模型热切换
162181
163182
在一个会话中切换模型不会丢失对话历史——因为 `mutableMessages` 与模型选择是解耦的:
164183
165184
```
166-
/model sonnetsetMainLoopModelOverride('claude-sonnet-4-20250514')
167-
185+
/model sonnetQueryEngine.setModel('claude-sonnet-4-20250514')
186+
实际操作:this.config.userSpecifiedModel = modelQueryEngine.setModel() 方法)
168187
下一次 submitMessage() 开始时:
169188
170-
parseUserSpecifiedModel(userSpecifiedModel)
189+
parseUserSpecifiedModel(this.config.userSpecifiedModel)
171190
→ 返回新的模型配置
172191
173192
fetchSystemPromptParts({ mainLoopModel: newModel })

docs/conversation/streaming.mdx

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
title: "流式响应机制 - Claude Code 打字机效果原理"
33
description: "解析 Claude Code 流式响应实现:如何通过 SSE 逐 token 接收 AI 输出,实现实时打字机效果,提升用户等待体验。"
44
keywords: ["流式响应", "SSE", "streaming", "实时输出", "API streaming"]
5+
sourceRef: "3ec5675 (2026-04-08)"
56
---
67

78
## 为什么需要流式
@@ -31,7 +32,7 @@ message_stop ← 消息结束
3132

3233
### 事件处理状态机
3334

34-
`src/services/api/claude.ts:1980-2298` 实现了一个基于 `switch(part.type)` 的状态机:
35+
`src/services/api/claude.ts``queryStreamRaw()` 函数的事件处理循环实现了一个基于 `switch(part.type)` 的状态机:
3536

3637
| 事件类型 | 处理逻辑 | 状态变更 |
3738
|----------|----------|----------|
@@ -76,7 +77,7 @@ content_block_stop (index=2)
7677
`stop_reason` 要等到 `message_delta` 才确定(可能是 `end_turn``tool_use``max_tokens` 等),所以最后一条消息的 `stop_reason`**回写**的:
7778

7879
```typescript
79-
// claude.ts:2246 — 直接属性修改,不用对象替换
80+
// claude.ts — stop_reason 回写逻辑(直接属性修改,不用对象替换
8081
// 因为 transcript 写队列持有 message.message 的引用
8182
const lastMsg = newMessages.at(-1)
8283
if (lastMsg) {
@@ -89,16 +90,21 @@ if (lastMsg) {
8990

9091
### 网络断开
9192

92-
流式连接依赖 SSE(Server-Sent Events)。当连接中断时:
93+
流式连接依赖 SSE(Server-Sent Events)。当连接中断时,系统有两层检测机制
9394

94-
1. **Stream idle watchdog**:定时检测事件间隔,超过阈值(stall)触发告警和重试
95-
2. **Stream abort**:如果 watchdog 检测到长时间无事件,抛出错误进入重试流程
96-
3. **非流式降级**:作为最后手段,回退到非流式请求(一次性获取完整响应)
95+
1. **被动停滞检测**`src/services/api/claude.ts` 中 stall 检测逻辑):当下一个事件到达时,计算与上一个事件的时间间隔。超过阈值(30 秒,`STALL_THRESHOLD_MS = 30_000`)记录为一次 stall,累积计数并写入遥测日志。这是被动检测——仅在下一个 chunk 到达时才触发,不会主动中断流。
96+
2. **主动空闲超时看门狗**`src/services/api/claude.ts``STREAM_IDLE_TIMEOUT_MS` 看门狗逻辑):使用 `setTimeout` 设置 90 秒(可通过 `CLAUDE_STREAM_IDLE_TIMEOUT_MS` 环境变量覆盖)的硬性超时。如果在此期间没有收到任何事件,主动终止流并抛出错误进入重试流程。
97+
3. **非流式降级**:作为最后手段,设置 `didFallBackToNonStreaming` 标志,通过 `executeNonStreamingRequest()` 回退到非流式请求(一次性获取完整响应)
9798

9899
```typescript
99-
// claude.ts:2338-2355 — 检测空流
100-
// 1. 完全没有事件 → 代理返回了非 SSE 响应
101-
// 2. 有 message_start 但没有 content_block_stop → 流被截断
100+
// claude.ts — 被动停滞检测
101+
const STALL_THRESHOLD_MS = 30_000 // 30 秒无事件视为停滞
102+
let totalStallTime = 0
103+
let stallCount = 0
104+
105+
// claude.ts — 主动空闲超时
106+
const STREAM_IDLE_TIMEOUT_MS =
107+
parseInt(process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS || '', 10) || 90_000
102108
```
103109

104110
### API 限流
@@ -118,7 +124,7 @@ if (lastMsg) {
118124
| **上下文窗口超限** | `model_context_window_exceeded` | 触发 compaction 压缩对话历史后重试 |
119125

120126
```typescript
121-
// claude.ts:2267-2293
127+
// claude.ts — stop_reason 处理
122128
if (stopReason === 'max_tokens') {
123129
yield createAssistantAPIErrorMessage({ error: 'max_output_tokens', ... })
124130
}
@@ -133,16 +139,16 @@ if (stopReason === 'model_context_window_exceeded') {
133139
系统持续监控事件到达间隔,检测"停滞"(stall):
134140

135141
```typescript
136-
// claude.ts:1940-1966
137-
const STALL_THRESHOLD_MS = 10_000 // 10 秒无事件视为停滞
142+
// claude.ts — stall 检测逻辑
143+
const STALL_THRESHOLD_MS = 30_000 // 30 秒无事件视为停滞
138144
if (timeSinceLastEvent > STALL_THRESHOLD_MS) {
139145
stallCount++
140146
totalStallTime += timeSinceLastEvent
141147
logEvent('tengu_streaming_stall', { stall_duration_ms, stall_count, ... })
142148
}
143149
```
144150

145-
多个 stall 累积后,watchdog 可能决定中断流并触发重试
151+
这是**被动检测**——仅在下一个 chunk 到达时才触发比较。与之互补的是 90 秒主动空闲超时看门狗(`STREAM_IDLE_TIMEOUT_MS`),会直接中断长时间无响应的流
146152

147153
## 工具执行的流式反馈
148154

0 commit comments

Comments
 (0)