diff --git a/README.md b/README.md index ef2ccac..dac4143 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Claude Code Best V2 (CCB) +# Claude Code Best V3 (CCB) Anthropic 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现。虽然很难绷, 但是它叫做 CCB(踩踩背)... @@ -115,9 +115,14 @@ bun run build | BriefTool | ✅ | 简短消息 + 附件发送 | | TaskOutputTool | ✅ | 后台任务输出读取 | | TaskStopTool | ✅ | 后台任务停止 | -| ListMcpResourcesTool | ✅ | MCP 资源列表 | -| ReadMcpResourceTool | ✅ | MCP 资源读取 | -| SyntheticOutputTool | ✅ | 非交互会话结构化输出 | +| ListMcpResourcesTool | ⚠️ | MCP 资源列表(被 specialTools 过滤,特定条件下加入) | +| ReadMcpResourceTool | ⚠️ | MCP 资源读取(同上) | +| SyntheticOutputTool | ⚠️ | 仅在非交互会话(SDK/pipe 模式)下创建 | +| CronCreateTool | ✅ | 定时任务创建(已移除 AGENT_TRIGGERS gate) | +| CronDeleteTool | ✅ | 定时任务删除 | +| CronListTool | ✅ | 定时任务列表 | +| EnterWorktreeTool | ✅ | 进入 Git Worktree(`isWorktreeModeEnabled()` 已硬编码为 true) | +| ExitWorktreeTool | ✅ | 退出 Git Worktree | ### 工具 — 条件启用 @@ -129,8 +134,6 @@ bun run build | TaskGetTool | ⚠️ | 同上 | | TaskUpdateTool | ⚠️ | 同上 | | TaskListTool | ⚠️ | 同上 | -| EnterWorktreeTool | ⚠️ | `isWorktreeModeEnabled()` | -| ExitWorktreeTool | ⚠️ | 同上 | | TeamCreateTool | ⚠️ | `isAgentSwarmsEnabled()` | | TeamDeleteTool | ⚠️ | 同上 | | ToolSearchTool | ⚠️ | `isToolSearchEnabledOptimistic()` | @@ -143,7 +146,6 @@ bun run build | 工具 | Feature Flag | |------|-------------| | SleepTool | `PROACTIVE` / `KAIROS` | -| CronCreate/Delete/ListTool | `AGENT_TRIGGERS` | | RemoteTriggerTool | `AGENT_TRIGGERS_REMOTE` | | MonitorTool | `MONITOR_TOOL` | | SendUserFileTool | `KAIROS` | @@ -152,7 +154,7 @@ bun run build | WebBrowserTool | `WEB_BROWSER_TOOL` | | SnipTool | `HISTORY_SNIP` | | WorkflowTool | `WORKFLOW_SCRIPTS` | -| PushNotificationTool | `KAIROS` | +| PushNotificationTool | `KAIROS` / `KAIROS_PUSH_NOTIFICATION` | | SubscribePRTool | `KAIROS_GITHUB_WEBHOOKS` | | ListPeersTool | `UDS_INBOX` | | CtxInspectTool | `CONTEXT_COLLAPSE` | @@ -194,7 +196,7 @@ bun run build | `/extra-usage` | ✅ | 额外用量信息 | | `/fast` | ✅ | 切换 fast 模式 | | `/feedback` | ✅ | 反馈 | -| `/files` | ✅ | 已跟踪文件 | +| `/loop` | ✅ | 定时循环执行(bundled skill,可通过 `CLAUDE_CODE_DISABLE_CRON` 关闭) | | `/heapdump` | ✅ | Heap dump(调试) | | `/help` | ✅ | 帮助 | | `/hooks` | ✅ | Hook 管理 | @@ -248,7 +250,7 @@ bun run build | `/proactive` | `PROACTIVE` / `KAIROS` | | `/brief` | `KAIROS` / `KAIROS_BRIEF` | | `/assistant` | `KAIROS` | -| `/bridge` | `BRIDGE_MODE` | +| `/remote-control` (alias `rc`) | `BRIDGE_MODE` | | `/remote-control-server` | `DAEMON` + `BRIDGE_MODE` | | `/force-snip` | `HISTORY_SNIP` | | `/workflows` | `WORKFLOW_SCRIPTS` | @@ -262,7 +264,7 @@ bun run build ### 斜杠命令 — ANT-ONLY(不可用) -`/tag` `/backfill-sessions` `/break-cache` `/bughunter` `/commit` `/commit-push-pr` `/ctx_viz` `/good-claude` `/issue` `/init-verifiers` `/mock-limits` `/bridge-kick` `/version` `/reset-limits` `/onboarding` `/share` `/summary` `/teleport` `/ant-trace` `/perf-issue` `/env` `/oauth-refresh` `/debug-tool-call` `/agents-platform` `/autofix-pr` +`/files` `/tag` `/backfill-sessions` `/break-cache` `/bughunter` `/commit` `/commit-push-pr` `/ctx_viz` `/good-claude` `/issue` `/init-verifiers` `/mock-limits` `/bridge-kick` `/version` `/reset-limits` `/onboarding` `/share` `/summary` `/teleport` `/ant-trace` `/perf-issue` `/env` `/oauth-refresh` `/debug-tool-call` `/agents-platform` `/autofix-pr` ### CLI 子命令 @@ -290,7 +292,7 @@ bun run build | 服务 | 状态 | 说明 | |------|------|------| | API 客户端 (`services/api/`) | ✅ | 3400+ 行,4 个 provider | -| MCP (`services/mcp/`) | ✅ | 24 个文件,12000+ 行 | +| MCP (`services/mcp/`) | ✅ | 34 个文件,12000+ 行 | | OAuth (`services/oauth/`) | ✅ | 完整 OAuth 流程 | | 插件 (`services/plugins/`) | ✅ | 基础设施完整,无内置插件 | | LSP (`services/lsp/`) | ⚠️ | 实现存在,默认关闭 | @@ -307,17 +309,17 @@ bun run build | 包 | 状态 | 说明 | |------|------|------| -| `color-diff-napi` | ✅ | 997 行完整 TypeScript 实现(语法高亮 diff) | -| `audio-capture-napi` | ❌ | stub,`isNativeAudioAvailable()` 返回 false | -| `image-processor-napi` | ❌ | stub,`getNativeModule()` 返回 null | -| `modifiers-napi` | ❌ | stub,`isModifierPressed()` 返回 false | +| `color-diff-napi` | ✅ | 1006 行完整 TypeScript 实现(语法高亮 diff) | +| `audio-capture-napi` | ✅ | 151 行完整实现(跨平台音频录制,使用 SoX/arecord) | +| `image-processor-napi` | ✅ | 125 行完整实现(macOS 剪贴板图片读取,使用 osascript + sharp) | +| `modifiers-napi` | ✅ | 67 行完整实现(macOS 修饰键检测,bun:ffi + CoreGraphics) | | `url-handler-napi` | ❌ | stub,`waitForUrlEvent()` 返回 null | | `@ant/claude-for-chrome-mcp` | ❌ | stub,`createServer()` 返回 null | -| `@ant/computer-use-mcp` | ❌ | stub,`buildTools()` 返回 [] | -| `@ant/computer-use-input` | ❌ | stub,仅类型声明 | -| `@ant/computer-use-swift` | ❌ | stub,仅类型声明 | +| `@ant/computer-use-mcp` | ⚠️ | 类型安全 stub(265 行,完整类型定义但函数返回空值) | +| `@ant/computer-use-input` | ✅ | 183 行完整实现(macOS 键鼠模拟,AppleScript/JXA/CGEvent) | +| `@ant/computer-use-swift` | ✅ | 388 行完整实现(macOS 显示器/应用管理/截图,JXA/screencapture) | -### Feature Flags(30 个,全部返回 `false`) +### Feature Flags(31 个,全部返回 `false`) `ABLATION_BASELINE` `AGENT_MEMORY_SNAPSHOT` `BG_SESSIONS` `BRIDGE_MODE` `BUDDY` `CCR_MIRROR` `CCR_REMOTE_SETUP` `CHICAGO_MCP` `COORDINATOR_MODE` `DAEMON` `DIRECT_CONNECT` `EXPERIMENTAL_SKILL_SEARCH` `FORK_SUBAGENT` `HARD_FAIL` `HISTORY_SNIP` `KAIROS` `KAIROS_BRIEF` `KAIROS_CHANNELS` `KAIROS_GITHUB_WEBHOOKS` `LODESTONE` `MCP_SKILLS` `PROACTIVE` `SSH_REMOTE` `TORCH` `TRANSCRIPT_CLASSIFIER` `UDS_INBOX` `ULTRAPLAN` `UPLOAD_USER_SETTINGS` `VOICE_MODE` `WEB_BROWSER_TOOL` `WORKFLOW_SCRIPTS` diff --git a/docs/agent/coordinator-and-swarm.mdx b/docs/agent/coordinator-and-swarm.mdx index 100e6e5..1fe9617 100644 --- a/docs/agent/coordinator-and-swarm.mdx +++ b/docs/agent/coordinator-and-swarm.mdx @@ -1,59 +1,196 @@ --- title: "协调者与蜂群模式 - 多 Agent 高级编排" -description: "详解 Claude Code 多 Agent 高级协作模式:Coordinator Mode 协调者模式和 Agent Swarms 蜂群模式的设计理念、调度策略和适用场景。" +description: "从源码角度解析 Claude Code 多 Agent 协作:Coordinator Mode 的 System Prompt 设计、Worker 生命周期、Task 通信协议和 Swarm 蜂群的任务分配机制。" keywords: ["协调者模式", "蜂群模式", "Agent Swarm", "多 Agent 协作", "任务编排"] --- -{/* 本章目标:介绍 Coordinator Mode 和 Agent Swarms */} +{/* 本章目标:从源码角度揭示 Coordinator Mode 和 Agent Swarms 的架构设计 */} -## 两种协作模式 +## 两种协作模式的架构差异 -子 Agent 是"临时帮手"——主 Agent 派出去做一件事就回来。对于更复杂的协作需求,Claude Code 提供了两种高级模式: +| 维度 | Coordinator Mode | Agent Swarms | +|------|-----------------|--------------| +| **门控** | `feature('COORDINATOR_MODE')` + `CLAUDE_CODE_COORDINATOR_MODE=1` | 任务系统 V2(默认启用) | +| **拓扑** | 星型:Coordinator 居中,Worker 外围 | 网状:对等 Agent 共享任务列表 | +| **角色** | 明确分工:Coordinator 编排、Worker 执行 | 模糊:每个 Agent 自主认领任务 | +| **通信** | `SendMessage` 定向通信 + `` | 任务文件系统 + 邮箱广播 | +| **适用** | 需要集中决策的复杂任务 | 并行度高的独立子任务 | -## Coordinator Mode:一个指挥,多个执行 +两者不是互斥的——Coordinator Mode 可以在 Swarm 架构之上运行,将 Coordinator 作为特殊的 Leader Agent。 -就像一个团队 leader 带着几个开发者: +## Coordinator Mode:星型编排架构 -- **Coordinator**(协调者):负责理解需求、拆解任务、分配工作、汇总结果 -- **Workers**(执行者):各自领取任务独立执行,通过邮箱向 Coordinator 汇报 +### 激活机制 -``` - ┌─── Worker A (重构 API) - │ -Coordinator ──┼─── Worker B (更新测试) - │ - └─── Worker C (更新文档) +```typescript +// src/coordinator/coordinatorMode.ts:36 +export function isCoordinatorMode(): boolean { + if (feature('COORDINATOR_MODE')) { + return isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) + } + return false // 外部构建始终 false +} ``` -Coordinator 不自己写代码,它的职责是**编排**——确保所有 Worker 的工作能拼合在一起。 +Coordinator Mode 需要双重门控:构建时 `feature('COORDINATOR_MODE')` 和运行时环境变量。`matchSessionMode()` 在会话恢复时自动同步模式状态——如果恢复的会话是 coordinator 模式,它会翻转环境变量以确保一致性。 + +### Coordinator 的工具集 + +Coordinator 被剥夺了所有"动手"工具,只保留编排能力: + +| 工具 | 用途 | +|------|------| +| **Agent** | 启动新 Worker(`subagent_type: "worker"`) | +| **SendMessage** | 向已有 Worker 发送后续指令 | +| **TaskStop** | 中途停止走错方向的 Worker | +| **subscribe_pr_activity** | 订阅 GitHub PR 事件(review comments、CI 结果) | + +Coordinator **不写代码、不读文件、不执行命令**——它只做三件事:理解需求、分配任务、综合结果。 + +### Worker 的工具权限 + +Worker 的可用工具由 `getCoordinatorUserContext()`(`coordinatorMode.ts:80`)动态注入到 System Prompt: + +```typescript +// 简化模式下:只有 Bash + Read + Edit +const workerTools = isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE') + ? [BASH_TOOL_NAME, FILE_READ_TOOL_NAME, FILE_EDIT_TOOL_NAME] + : Array.from(ASYNC_AGENT_ALLOWED_TOOLS) + .filter(name => !INTERNAL_WORKER_TOOLS.has(name)) +``` + +`INTERNAL_WORKER_TOOLS`(TeamCreate、TeamDelete、SendMessage、SyntheticOutput)被显式排除——Worker 不能嵌套创建团队或发送消息,防止不可控的递归。 + +### Scratchpad:跨 Worker 的共享知识库 + +当 `tengu_scratch` feature flag 启用时,Coordinator 拥有一个 Scratchpad 目录: + +``` +Scratchpad 目录: + - Workers 可自由读写,无需权限审批 + - 用于持久化的跨 Worker 知识 + - 结构由 Coordinator 决定(无固定格式) +``` + +这是一个关键的协作原语——Worker A 的研究结果可以写入 Scratchpad,Worker B 直接读取,无需通过 Coordinator 中转。 + +### `` 通信协议 + +Worker 完成后,Coordinator 收到 XML 格式的通知: + +```xml + + agent-a1b ← Worker 的 agentId + completed|failed|killed + Agent "Investigate auth bug" completed + Found null pointer in src/auth/validate.ts:42... + + N + N + N + + +``` + +通知以 `user-role message` 形式送达,Coordinator 通过 `` 标签区分它和用户消息。`` 用于 `SendMessage` 的 `to` 参数,实现定向续传。 + +### Coordinator 的核心职责:综合(Synthesis) + +Coordinator System Prompt(`coordinatorMode.ts:111-369`,约 260 行)明确要求 Coordinator **不能懒惰地委派理解**: + +``` +反模式(禁止): + "Based on your findings, fix the auth bug" + → 把理解的责任推给了 Worker + +正确做法: + "Fix the null pointer in src/auth/validate.ts:42. + The user field on Session (src/auth/types.ts:15) is + undefined when sessions expire but the token remains cached. + Add a null check before user.id access." + → Coordinator 自己理解了问题,给出精确指令 +``` + +这是 Coordinator Mode 最核心的设计约束:Coordinator 必须先理解,再分配。 ## Agent Swarms:蜂群式协作 -比 Coordinator 更松散的协作模式: +Swarm 模式基于任务系统 V2(详见[任务管理](../tools/task-management.mdx)),核心机制是**共享任务列表 + 竞争认领**: -- 多个 Agent 以对等身份同时工作 -- 没有中心化的指挥者 -- 通过消息邮箱互相通信和协调 -- 适合"各自负责一块、偶尔需要沟通"的场景 +### 团队初始化 -## Teammate 机制 +``` +Leader 创建团队(TeamCreateTool) + ↓ +设置 teamName → setLeaderTeamName() + ↓ +所有 teammate 自动获得相同的 taskListId + ↓ +teammate 启动时: + 1. CLAUDE_CODE_TASK_LIST_ID 环境变量(显式覆盖) + 2. teammate 上下文的 teamName(共享 leader 的任务列表) + 3. CLAUDE_CODE_TEAM_NAME 环境变量 + 4. leader 设置的 teamName + 5. getSessionId()(兜底) +``` -进程内的"队友"——一种更轻量的协作方式: +多级优先级确保了 Leader 和所有 Teammate 指向同一个任务列表,无需额外协调。 -- 在同一个进程内运行,共享部分基础设施状态 -- 有独立的对话上下文和工具权限 -- 适合"我需要一个搭档帮忙看看这段代码"的场景 +### 任务认领与竞争 -## 任务类型 +`claimTask()` 是 Swarm 的核心并发原语: -支撑多 Agent 协作的是丰富的任务类型: +``` +Teammate A 调用 TaskList → 发现 task #3 是 pending +Teammate B 同时发现 task #3 是 pending + ↓ +两者同时尝试 TaskUpdate(task #3, {status: "in_progress"}) + ↓ +文件锁 + 高水位标记保证原子性: + - 第一个写入者获得 owner 锁定 + - 第二个写入者收到 already_claimed 错误 + ↓ +获得任务的 teammate 执行工作 + ↓ +完成后 TaskUpdate(task #3, {status: "completed"}) + → 依赖此任务的其他任务自动解锁 + → tool_result 提示 "Call TaskList to find your next task" +``` -| 任务类型 | 用途 | -|----------|------| -| **LocalAgentTask** | 本地子 Agent 任务 | -| **LocalShellTask** | 后台 shell 命令 | -| **InProcessTeammateTask** | 进程内队友 | -| **RemoteAgentTask** | 远程 Agent | -| **DreamTask** | 后台自主任务 | +### Teammate 的生命周期管理 -每种任务类型都有自己的生命周期管理、状态追踪和通信方式。 +``` +Teammate 异常退出 + ↓ +unassignTeammateTasks() + → 扫描任务列表,找到 owner === teammateName 的未完成任务 + → 重置为 pending + owner=undefined + ↓ +Leader 通过 mailbox 收到通知 + → 重新分配或创建新 Teammate +``` + +## 任务类型全景 + +支撑多 Agent 协作的是 7 种任务类型(`src/tasks/types.ts`): + +| 任务类型 | 运行位置 | 状态管理 | 适用场景 | +|----------|---------|---------|---------| +| **LocalAgentTask** | 本地子进程 | `LocalAgentTaskState` | 标准子 Agent 任务 | +| **LocalShellTask** | 本地 shell | `LocalShellTaskState` | 后台 shell 命令 | +| **InProcessTeammateTask** | 同进程内 | `InProcessTeammateTaskState` | 轻量级进程内队友 | +| **RemoteAgentTask** | 远程服务器 | `RemoteAgentTaskState` | 分布式 Agent(CCR) | +| **DreamTask** | 后台静默 | `DreamTaskState` | 后台自主整理记忆 | +| **LocalWorkflowTask** | 本地 | `LocalWorkflowTaskState` | 工作流编排 | +| **MonitorMcpTask** | 本地 | `MonitorMcpTaskState` | MCP 监控任务 | + +`InProcessTeammateTask` 与 `LocalAgentTask` 的关键差异:前者共享进程的内存空间和基础设施状态(如 MCP 连接池),但有独立的对话上下文和工具权限;后者是完全隔离的子进程,启动开销更大但更安全。 + +## Coordinator vs Swarm 的选择 + +| 场景 | 推荐模式 | 原因 | +|------|---------|------| +| "重构认证系统,需要多模块协调" | Coordinator | 需要集中决策,Worker 间有依赖 | +| "修复 10 个独立的 lint 警告" | Swarm | 任务独立,可完全并行 | +| "研究方案 A 和方案 B,然后选一个实现" | Coordinator | 先并行研究,再集中决策 | +| "在大仓库中搜索所有 TODO 并分类" | Swarm | 无依赖,各自领任务即可 | diff --git a/docs/context/compaction.mdx b/docs/context/compaction.mdx index cdfba2a..bfa66ca 100644 --- a/docs/context/compaction.mdx +++ b/docs/context/compaction.mdx @@ -1,68 +1,239 @@ --- -title: "上下文压缩 - Compaction 优雅遗忘机制" -description: "详解 Claude Code 上下文压缩策略:当对话 token 接近 200K 上限时,如何通过 Compaction 机制智能压缩历史消息,保留关键信息。" -keywords: ["上下文压缩", "Compaction", "token 管理", "对话压缩", "上下文窗口"] +title: "上下文压缩 - Compaction 三层策略与边界机制" +description: "深度解析 Claude Code 上下文压缩的完整实现:Session Memory 压缩、传统 API 摘要压缩、MicroCompact 局部压缩三层策略,以及 CompactBoundary 消息、工具对保持、PTL 紧急降级等关键机制。" +keywords: ["上下文压缩", "Compaction", "token 管理", "对话压缩", "上下文窗口", "MicroCompact"] --- -{/* 本章目标:解释 Compaction 机制的设计和策略 */} +{/* 本章目标:从源码层面剖析压缩的三层策略、边界机制和关键常量 */} -## 为什么需要压缩 +## 压缩的触发时机 -每次 API 调用的 token 有上限(通常 200K)。一场长时间的编程对话可能产生: +上下文压缩不是单一操作,而是**三层递进**的策略系统,对应不同的触发条件和严重程度: -- 大量的文件内容(AI 读了几十个文件) -- 长篇的命令输出(构建日志、测试结果) -- 往返的对话历史 +| 层级 | 触发条件 | 实现位置 | 是否需要 API 调用 | +|------|---------|---------|:---:| +| **MicroCompact** | 单个工具输出过长 | `microCompact.ts` | 否 | +| **Session Memory Compact** | 自动压缩触发(需 feature flag) | `sessionMemoryCompact.ts` | 否 | +| **传统 API 摘要** | 手动 `/compact` 或 SM 不可用时的自动回退 | `compact.ts` | 是 | -不压缩的话,很快就会撞到 token 上限,对话被迫终止。 +### 压缩入口的优先级链 - - 上下文压缩示意图 - +源码路径:`src/commands/compact/compact.ts` -## 压缩的策略 +当用户执行 `/compact` 或系统触发自动压缩时,压缩命令按以下优先级尝试: -Claude Code 提供了多层压缩机制: +```typescript +// compact.ts:55-99 — 简化后的优先级链 +if (!customInstructions) { + const sessionMemoryResult = await trySessionMemoryCompaction(messages, ...) + if (sessionMemoryResult) return sessionMemoryResult // 优先:SM 压缩 +} - - - 当 token 接近上限时,系统自动触发压缩。AI 生成一份当前对话的**摘要**,替换掉早期的详细消息。效果就像人类的"记忆"——记住要点,忘记细节。 - - - 用户可以随时通过 `/compact` 命令主动触发压缩。可以附带提示语(如 `/compact 聚焦在认证模块的修改上`),引导 AI 保留特定信息。 - - - 更细粒度的局部压缩——不是压缩整个对话,而是压缩某些特别长的工具输出(比如一个 5000 行的测试日志)。 - - +if (reactiveCompact?.isReactiveOnlyMode()) { + return await compactViaReactive(messages, ...) // 次选:Reactive 压缩 +} -## 压缩边界 - -压缩后,系统在消息历史中插入一个"边界标记"。后续的 API 调用只发送边界之后的消息: - -``` -[早期的 50 条消息] ← 被压缩 -[压缩摘要边界] ← 一段浓缩的摘要 -[后续的 10 条消息] ← 正常发送 +// 兜底:传统 API 摘要 +const microcompactResult = await microcompactMessages(messages, context) +const messagesForCompact = microcompactResult.messages +// → 调用 AI 模型生成摘要 ``` -这个设计保证了: -- 压缩后的摘要为 AI 提供了历史上下文 -- 新的对话不受旧消息的 token 负担 -- 用户无感知——对话继续自然进行 +注意:SM 压缩不支持自定义指令(`/compact 聚焦在认证模块`),有自定义指令时直接走传统路径。 -## 压缩前后的 Hooks +## 第一层:MicroCompact — 局部压缩 -压缩是一个可能丢失信息的操作,因此系统允许用户在压缩前后执行自定义脚本: +源码路径:`src/services/compact/microCompact.ts` -- **Pre-compact Hook**:压缩前执行,可以标记"这些信息不能丢" -- **Post-compact Hook**:压缩后执行,可以验证关键信息是否保留 +MicroCompact 不压缩整个对话,而是**清除旧工具输出的内容**。它维护一个白名单: -## 什么信息会被保留 +```typescript +const COMPACTABLE_TOOLS = new Set([ + 'Read', // 文件读取 + 'Bash', // 命令输出 + 'Grep', // 搜索结果 + 'Glob', // 文件列表 + 'WebSearch', // 搜索结果 + 'WebFetch', // 网页内容 + 'Edit', // 编辑输出 + 'Write', // 写入输出 +]) +``` -压缩不是简单的截断,AI 会智能地决定保留什么: +替换策略:将超过时间窗口的工具输出内容替换为 `[Old tool result content cleared]`。这不是简单的截断——原始内容仍保留在 JSONL transcript 中,只是不再发送给 API。 -- 用户的核心需求和目标 -- 重要的决策和原因 -- 当前工作的状态(改了哪些文件、做到哪一步) -- 之前犯过的错误(避免重蹈覆辙) +MicroCompact 还有一个**时间衰减配置**(`timeBasedMCConfig.ts`):越旧的工具输出越容易被清除,最近的优先保留。 + +### 图片和文档的特殊处理 + +```typescript +const IMAGE_MAX_TOKEN_SIZE = 2000 +``` + +图片 block 如果超过 2000 token 估算值,也会被 MicroCompact 清除。PDF document block 同理。 + +## 第二层:Session Memory Compact — 无 API 调用的压缩 + +源码路径:`src/services/compact/sessionMemoryCompact.ts` + +当 `tengu_session_memory` + `tengu_sm_compact` 两个 feature flag 启用时,系统优先使用 Session Memory 进行压缩——**不需要调用摘要模型**,直接使用已经提取好的 Session Memory 作为对话摘要。 + +### 保留窗口的计算 + +```typescript +// sessionMemoryCompact.ts:324-397 +export function calculateMessagesToKeepIndex(messages, lastSummarizedIndex) { + const config = getSessionMemoryCompactConfig() + // 默认: minTokens=10K, minTextBlockMessages=5, maxTokens=40K + + let startIndex = lastSummarizedIndex + 1 + // 从 lastSummarizedIndex 向前扩展,直到满足两个下限或命中上限 + for (let i = startIndex - 1; i >= floor; i--) { + totalTokens += estimateMessageTokens([msg]) + if (hasTextBlocks(msg)) textBlockMessageCount++ + startIndex = i + if (totalTokens >= config.maxTokens) break + if (totalTokens >= config.minTokens && textBlockMessageCount >= config.minTextBlockMessages) break + } + return adjustIndexToPreserveAPIInvariants(messages, startIndex) +} +``` + +这个算法确保压缩后保留的消息窗口满足: +- 至少 10,000 token(有上下文深度) +- 至少 5 条包含文本的消息(有对话连续性) +- 最多 40,000 token(不会太大又触发下一次压缩) + +### 工具对完整性保护 + +`adjustIndexToPreserveAPIInvariants()` 是压缩中一个**关键的正确性保证**: + +API 要求每个 `tool_result` 都有对应的 `tool_use`,反之亦然。如果压缩恰好切在一条 `tool_result` 消息处,会导致 API 报错。 + +```typescript +// sessionMemoryCompact.ts:232-314 +// Step 1: 向前扫描,找到所有被保留消息中 tool_result 引用的 tool_use +// Step 2: 向前扫描,找到与被保留 assistant 消息共享 message.id 的 thinking block +// 两种情况都需要将 startIndex 向前移动 +``` + +流式传输会将一个 assistant 消息拆分为多条存储记录(thinking、tool_use 等各有独立 uuid 但共享 `message.id`),这增加了边界情况的复杂度。 + +## 第三层:传统 API 摘要压缩 + +源码路径:`src/services/compact/compact.ts` + +当 SM 压缩不可用时,系统回退到传统方式:调用 AI 模型生成对话摘要。 + +### 压缩前处理 + +发送给摘要模型之前,消息会经过多层预处理: + +```typescript +// compact.ts:147-202 +const stripped = stripImagesFromMessages(messages) // 图片→[image] 文字标记 +const stripped2 = stripReinjectedAttachments(stripped) // 移除会被重新注入的附件 +``` + +图片被替换为 `[image]` 标记,防止摘要 API 调用本身也触发 prompt-too-long 错误。 + +### 压缩后的重新注入 + +压缩后,系统会从摘要中**重新注入关键上下文**: + +```typescript +// compact.ts:124-132 +export const POST_COMPACT_TOKEN_BUDGET = 50_000 // 总预算 +export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5 // 最多恢复 5 个文件 +export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000 // 每文件 5K token +export const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000 // 每技能 5K token +export const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000 // 技能总预算 25K +``` + +这 50K token 的重新注入预算用于: +1. 恢复最近读取的文件内容(最多 5 个文件,每个截断到 5K token) +2. 恢复已激活的技能指令(每个技能截断到 5K token,总计 25K) +3. 重新注入 CLAUDE.md 内容 +4. 恢复 MCP 工具发现结果 + +## CompactBoundary:压缩的边界标记 + +源码路径:`src/utils/messages.ts`(`createCompactBoundaryMessage`) + +每次压缩后,系统在消息流中插入一条 `SystemCompactBoundaryMessage`: + +```typescript +type SystemCompactBoundaryMessage = { + type: 'system' + message: { + type: 'compact_boundary' + compactMetadata: { + compactType: 'auto' | 'manual' | 'micro' + preCompactTokenCount: number + lastUserMessageUuid: string + preCompactDiscoveredTools?: string[] + } + } +} +``` + +后续所有操作只处理**最后一条 boundary 之后**的消息: + +```typescript +// messages.ts +export function getMessagesAfterCompactBoundary(messages: Message[]): Message[] { + const lastBoundary = messages.findLastIndex(m => isCompactBoundaryMessage(m)) + return lastBoundary >= 0 ? messages.slice(lastBoundary + 1) : messages +} +``` + +### Preserved Segment 注解 + +boundary 消息上还附加了 `preservedSegment` 注解,记录哪些消息被保留而非压缩: + +```typescript +// compact.ts — annotateBoundaryWithPreservedSegment +boundaryMarker.compactMetadata.preservedSegment = { + summaryMessageUuid: string + preservedMessageUuids: string[] +} +``` + +这在会话恢复时帮助加载器正确重建消息链,避免重复压缩已保留的消息。 + +## PTL 紧急降级:Prompt Too Long + +当压缩后仍然超出 token 限制(`PROMPT_TOO_LONG` 错误),系统会进入紧急降级路径: + +1. **Reactive Compact**:`reactiveCompactOnPromptTooLong()` 尝试更激进的压缩 +2. **截断重试**:如果 reactive 也失败,`truncateHeadForPTLRetry()` 直接截断最早的消息 +3. 放弃并报错 + +Reactive Compact 目前在反编译版本中是 stub(`isReactiveOnlyMode() → false`),表明这是 Anthropic 内部的实验性功能。 + +## 压缩的 Hook 机制 + +压缩前后可以执行自定义 Hook: + +- **Pre-compact Hook**(`executePreCompactHooks`):在压缩前执行,可以注入"必须保留"的标记 +- **Post-compact Hook**(`executePostCompactHooks`):在压缩后执行,可以验证关键信息是否保留 +- **Session Start Hook**(`processSessionStartHooks('compact')`):SM 压缩使用此 Hook 恢复 CLAUDE.md 等上下文 + +Hook 结果以 `HookResultMessage` 的形式附加到压缩结果中,确保用户的自定义逻辑在压缩过程中被尊重。 + +## Snip Compact(实验性) + +源码路径:`src/services/compact/snipCompact.ts`(stub) + +Snip Compact 是另一种实验性压缩策略,在反编译版本中为空壳实现。从 stub 的类型签名推断: + +```typescript +snipCompactIfNeeded(messages, options?: { force?: boolean }) → { + messages: Message[] + executed: boolean + tokensFreed: number + boundaryMessage?: Message +} +``` + +它似乎是一种**更细粒度的消息级裁剪**(snip = 剪切),可能是对单条消息的进一步压缩,而非整个对话。`shouldNudgeForSnips()` 和 `SNIP_NUDGE_TEXT` 暗示它可能会提示用户触发。 diff --git a/docs/context/project-memory.mdx b/docs/context/project-memory.mdx index 20649b5..5af5865 100644 --- a/docs/context/project-memory.mdx +++ b/docs/context/project-memory.mdx @@ -1,59 +1,226 @@ --- -title: "项目记忆系统 - AI 跨对话记忆机制" -description: "解析 Claude Code 项目记忆系统:CLAUDE.md 文件、用户偏好存储和上下文缓存如何让 AI 跨对话记住项目特性和个人偏好。" -keywords: ["项目记忆", "CLAUDE.md", "AI 记忆", "跨对话", "上下文缓存"] +title: "项目记忆系统 - 文件级跨对话记忆架构" +description: "深度解析 Claude Code 记忆系统:基于文件的持久化存储、MEMORY.md 索引结构、四类型分类法、Sonnet 智能召回、Session Memory 压缩集成。" +keywords: ["项目记忆", "MEMORY.md", "AI 记忆", "跨对话", "自动记忆", "memdir"] --- -{/* 本章目标:解释记忆系统如何让 AI 变得'有记忆' */} +{/* 本章目标:从源码层面剖析记忆系统的存储架构、召回机制和注入链路 */} -## AI 的记忆困境 +## 记忆系统的存储架构 -大语言模型没有真正的记忆。每次新对话,它都是一张白纸。用户不得不反复解释"我的项目用 Bun 不用 Node"、"commit 消息用中文"。 +源码路径:`src/memdir/paths.ts`、`src/memdir/memdir.ts` -## 记忆系统的解决方案 +Claude Code 的记忆系统是**纯文件**的——没有数据库、没有向量存储,只有 Markdown 文件和目录结构。 -Claude Code 通过一个基于文件的持久化记忆系统来模拟"跨会话记忆": +### 目录布局 - - - 关于用户的信息:角色、偏好、技术背景 - - - 用户对 AI 行为的纠正和肯定 - - - 项目中的非代码信息:谁负责什么、截止日期 - - - 外部资源的位置:Issue tracker、Dashboard URL - - +``` +~/.claude/projects//memory/ +├── MEMORY.md ← 入口索引(每次对话加载) +├── user_role.md ← 用户记忆 +├── feedback_testing.md ← 反馈记忆 +├── project_mobile_release.md ← 项目记忆 +├── reference_linear_ingest.md ← 参考记忆 +└── logs/ ← KAIROS 模式:每日日志 + └── 2026/ + └── 04/ + └── 2026-04-01.md +``` -## 记忆的读写时机 +路径解析链路(`getAutoMemPath()`): +1. `CLAUDE_COWORK_MEMORY_PATH_OVERRIDE` 环境变量(Cowork SDK 全路径覆盖) +2. `autoMemoryDirectory` 设置(仅限 `policySettings`/`localSettings`/`userSettings`——**故意排除** `projectSettings`,防止恶意仓库将记忆路径指向 `~/.ssh`) +3. 默认:`/projects//memory/` -| 时机 | 动作 | -|------|------| -| 每次对话开始 | 加载记忆索引(MEMORY.md),相关记忆注入 System Prompt | -| 用户纠正 AI | AI 自动判断是否值得记住,写入反馈记忆 | -| 用户说"记住这个" | 立即保存到对应类型的记忆文件 | -| 用户说"忘掉这个" | 找到并删除对应的记忆条目 | -| 记忆可能过期时 | 使用前先验证(文件还在?函数还存在?),过期则更新或删除 | +同一个 Git 仓库的所有 worktree 共享一个记忆目录(通过 `findCanonicalGitRoot()` 找到真正的 `.git` 根)。 -## 记忆 vs 代码注释 vs CLAUDE.md +### MEMORY.md 索引 -| | 记忆 | 代码注释 | CLAUDE.md | -|---|---|---|---| -| 存储位置 | `~/.claude/` 目录 | 代码文件中 | 项目目录中 | -| 谁能看到 | 只有当前用户 | 所有开发者 | 所有使用 Claude Code 的人 | -| 适合存什么 | 个人偏好、非公开的上下文 | 代码逻辑解释 | 项目约定、开发指南 | -| 跨项目 | 是 | 否 | 否 | +`MEMORY.md` 是记忆的入口索引,每次对话都完整加载到上下文中: -## 不该存什么 +```typescript +// memdir.ts:35-38 +export const ENTRYPOINT_NAME = 'MEMORY.md' +export const MAX_ENTRYPOINT_LINES = 200 +export const MAX_ENTRYPOINT_BYTES = 25_000 +``` -记忆系统明确规定了不应存储的内容: +索引有**双重上限**:200 行 AND 25KB。超过任何一条都会被 `truncateEntrypointContent()` 截断并追加警告。设计原因:p97 的索引文件用 200 行就能覆盖,但有些索引条目特别长(p100 观测到 197KB/200 行),字节上限捕捉这种长行异常。 -- 代码结构和架构(读代码就知道) -- git 历史(`git log` 就能查) -- 调试方案(修复已在代码中) -- CLAUDE.md 里已有的内容(避免重复) -- 临时性任务状态(用任务系统) +索引条目格式: +```markdown +- [Title](file.md) — one-line hook +``` + +每条一行,~150 字符以内。`MEMORY.md` 本身没有 frontmatter——它只是一个链接列表,不是记忆内容。 + +## 四类型分类法 + +源码路径:`src/memdir/memoryTypes.ts` + +记忆被约束为一个**封闭的四类型系统**,每种类型有明确的 ``、`` 和 `` 规范: + +| 类型 | 存储内容 | 典型触发 | +|------|---------|---------| +| **user** | 用户角色、偏好、技术背景 | "我是数据科学家"、"我写了十年 Go" | +| **feedback** | 用户对 AI 行为的纠正和确认 | "别 mock 数据库"、"单 PR 更好" | +| **project** | 非代码可推导的项目上下文 | "合并冻结从周四开始"、"auth 重写是合规要求" | +| **reference** | 外部系统指针 | "pipeline bugs 在 Linear INGEST 项目" | + +关键设计约束:**只存储无法从当前项目状态推导的信息**。代码架构、文件路径、git 历史都可以实时获取,不需要记忆。 + +### 反馈类型的双通道捕获 + +`feedback` 类型的 `when_to_save` 指令特别强调: + +> Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious. + +这意味着 AI 不仅在用户说"不要这样做"时保存,也在用户说"对,就是这样"时保存。后一种更难捕捉,但同等重要——它防止 AI 的行为随时间漂移。 + +### 每条记忆的 Frontmatter 格式 + +```markdown +--- +name: {{memory name}} +description: {{one-line description — 用于未来判断相关性}} +type: {{user, feedback, project, reference}} +--- + +{{memory content — feedback/project 类型建议包含 **Why:** 和 **How to apply:** 行}} +``` + +`description` 字段是关键:它不是给人读的摘要,而是给 AI 召回系统做相关性判断的搜索关键词。 + +## 智能召回机制 + +源码路径:`src/memdir/findRelevantMemories.ts`、`src/memdir/memoryScan.ts` + +不是所有记忆都适合每次对话。系统使用一个**轻量级 Sonnet 侧查询**来筛选最相关的记忆。 + +### 召回流程 + +``` +用户消息 → findRelevantMemories(query, memoryDir) + ├── scanMemoryFiles() — 扫描所有记忆文件的 frontmatter + ├── selectRelevantMemories() — Sonnet 侧查询,从清单中选出 ≤5 条 + └── 返回 [{path, mtimeMs}, ...] +``` + +核心是 `selectRelevantMemories()` 函数,它调用 `sideQuery()`(一个独立的轻量 API 调用): + +```typescript +// findRelevantMemories.ts:98-121 +const result = await sideQuery({ + model: getDefaultSonnetModel(), // 用 Sonnet 做筛选(非主模型) + system: SELECT_MEMORIES_SYSTEM_PROMPT, + messages: [{ + role: 'user', + content: `Query: ${query}\n\nAvailable memories:\n${manifest}${toolsSection}` + }], + max_tokens: 256, + output_format: { type: 'json_schema', schema: { ... } }, +}) +``` + +### 近期工具去噪 + +当 AI 正在使用某个工具时,召回该工具的使用文档是噪音(对话中已有工作上下文)。`recentTools` 参数让召回系统跳过这些记忆: + +```typescript +// findRelevantMemories.ts:92-95 +const toolsSection = recentTools.length > 0 + ? `\n\nRecently used tools: ${recentTools.join(', ')}` + : '' +``` + +System Prompt 明确指示:"如果已提供最近使用的工具列表,不要选择该工具的使用参考或 API 文档。**仍然要选择**关于这些工具的警告、陷阱或已知问题——这正是使用时最关键的信息。" + +### 已展示去重 + +`alreadySurfaced` 参数过滤之前轮次已展示过的文件路径,让 Sonnet 的 5 槽预算花在新的候选上,而不是重复召回同一文件。 + +## 记忆注入 System Prompt 的链路 + +源码路径:`src/memdir/memdir.ts` → `src/context.ts` + +`loadMemoryPrompt()` 是记忆注入的入口,每会话调用一次(通过 `systemPromptSection('memory', ...)` 缓存): + +```typescript +// memdir.ts:419-507 +export async function loadMemoryPrompt(): Promise { + // 优先级:KAIROS 日志模式 → TEAMMEM 组合模式 → 纯自动记忆 + if (feature('KAIROS') && autoEnabled && getKairosActive()) { + return buildAssistantDailyLogPrompt(skipIndex) + } + if (feature('TEAMMEM') && teamMemPaths!.isTeamMemoryEnabled()) { + return teamMemPrompts!.buildCombinedMemoryPrompt(...) + } + if (autoEnabled) { + return buildMemoryLines('auto memory', autoDir, ...).join('\n') + } + return null +} +``` + +注入时机:`context.ts` 中 `getSystemContext()` 调用时,记忆 Prompt 作为 system prompt 的一个 section 被组装。`MEMORY.md` 的内容作为 **user context message** 注入(而非 system prompt),这样可以利用 Prompt Cache 的 prefix 共享。 + +## KAIROS 模式:每日日志 + +源码路径:`src/memdir/memdir.ts`(`buildAssistantDailyLogPrompt`) + +长期运行的 assistant 会话使用不同的记忆策略: + +- **标准模式**:AI 维护 `MEMORY.md` 作为实时索引 + 独立记忆文件 +- **KAIROS 模式**:AI 只往日期文件追加日志(`logs/YYYY/MM/YYYY-MM-DD.md`),不做重组 + +```typescript +// 日志路径模式(非字面路径——因为 Prompt 被缓存) +const logPathPattern = join(memoryDir, 'logs', 'YYYY', 'MM', 'YYYY-MM-DD.md') +``` + +一个独立的夜间 `/dream` 技能负责将日志蒸馏为主题文件 + `MEMORY.md` 索引。 + +## 记忆漂移防御 + +源码路径:`src/memdir/memoryTypes.ts`(`TRUSTING_RECALL_SECTION`) + +记忆可能过期。系统在 Prompt 中设置了一个专门的 section "Before recommending from memory": + +``` +A memory that names a specific function, file, or flag is a claim +that it existed *when the memory was written*. It may have been +renamed, removed, or never merged. Before recommending it: + +- If the memory names a file path: check the file exists. +- If the memory names a function or flag: grep for it. +``` + +这个 section 的标题经过 A/B 测试验证:"Before recommending from memory"(行动导向)比 "Trusting what you recall"(抽象描述)效果好(3/3 vs 0/3)。 + +### 忽略记忆的严格语义 + +``` +If the user says to *ignore* or *not use* memory: +proceed as if MEMORY.md were empty. +Do not apply remembered facts, cite, compare against, +or mention memory content. +``` + +这解决了 AI 的一个常见反模式:用户说"忽略关于 X 的记忆",AI 虽然正确识别了代码但仍然加上"不像记忆中说的 Y"——这不是"忽略",而是"承认然后覆盖"。 + +## Session Memory 与压缩的联动 + +源码路径:`src/services/compact/sessionMemoryCompact.ts` + +记忆系统与上下文压缩有深度集成。当 `tengu_session_memory` 和 `tengu_sm_compact` 两个 feature flag 同时开启时,压缩优先使用 Session Memory 而非传统摘要: + +```typescript +// sessionMemoryCompact.ts:57-61 +const DEFAULT_SM_COMPACT_CONFIG = { + minTokens: 10_000, // 压缩后至少保留 10K token + minTextBlockMessages: 5, // 至少保留 5 条文本消息 + maxTokens: 40_000, // 最多保留 40K token +} +``` + +SM-compact 不调用压缩 API(没有摘要模型),而是直接使用已有的 Session Memory 作为摘要——更快、更便宜、且不会丢失信息。 diff --git a/docs/conversation/multi-turn.mdx b/docs/conversation/multi-turn.mdx index 0aad68c..393f6ec 100644 --- a/docs/conversation/multi-turn.mdx +++ b/docs/conversation/multi-turn.mdx @@ -1,60 +1,184 @@ --- -title: "多轮对话管理 - 会话编排、持久化与成本追踪" -description: "详解 Claude Code 多轮对话管理机制:会话编排、持久化存储、成本追踪和上下文累积策略,理解跨小时级编程对话的状态管理。" -keywords: ["多轮对话", "会话管理", "上下文累积", "对话持久化", "成本追踪"] +title: "多轮对话管理 - QueryEngine 会话编排与持久化" +description: "从源码角度解析 Claude Code 多轮对话管理:QueryEngine 的会话状态机、JSONL transcript 持久化、成本追踪模型和模型热切换机制。" +keywords: ["多轮对话", "会话管理", "QueryEngine", "transcript", "成本追踪"] --- -{/* 本章目标:解释会话编排、持久化、成本追踪 */} +{/* 本章目标:从源码角度揭示会话编排、持久化存储、成本追踪和模型切换的完整链路 */} -## 单轮 vs 多轮 +## 单轮 vs 多轮:架构层面的差异 -- **单轮**(一次 Agentic Loop):用户说一句 → AI 执行一系列操作 → 回答 -- **多轮**(一个 Session):用户和 AI 来回对话几十轮,持续数小时 +- **单轮**(一次 Agentic Loop):`query()` 函数的一次完整执行——组装上下文 → 调 API → 处理工具调用 → 循环直到结束 +- **多轮**(一个 Session):`QueryEngine` 类管理的一次会话——跨越数十轮 `submitMessage()` 调用,持续数小时 -多轮对话带来的挑战远超单轮:消息越来越多、token 不断累积、上下文逐渐模糊。 +`QueryEngine`(`src/QueryEngine.ts:186`)是单轮 Agentic Loop 之上的**会话编排器**,它管理的状态远不止消息列表: -## 会话编排器的职责 +``` +QueryEngine 内部状态 +├── mutableMessages: Message[] ← 完整对话历史,跨 turn 累积 +├── readFileState: FileStateCache ← 已读文件内容缓存,避免重复读取 +├── totalUsage: NonNullableUsage ← 累计 token 消耗(input/output/cache) +├── permissionDenials: SDKPermissionDenial[] ← 权限拒绝记录 +├── discoveredSkillNames: Set ← 当前 turn 已发现的 skill +└── abortController: AbortController ← 会话级中断控制 +``` -在单轮 Agentic Loop 之上,有一个编排器负责管理整个会话生命周期: +## QueryEngine 的核心方法:submitMessage() - - - 维护完整的消息历史,包括用户消息、AI 回复、工具调用结果 - - - 自动保存对话记录到磁盘,支持断线重连、历史回顾 - - - 在 AI 修改文件前自动保存快照,支持回滚 - - - 精确记录每轮的 token 消耗和 API 费用 - - +每次用户输入一条消息,REPL 或 SDK 调用 `submitMessage()`,它会执行完整的 turn 初始化链路: -## 会话恢复 +```typescript +// src/QueryEngine.ts:211 — 简化的 submitMessage 流程 +async *submitMessage(prompt, options?): AsyncGenerator { + // 1. 清除 turn 级追踪状态 + this.discoveredSkillNames.clear() + + // 2. 解析模型(用户可能中途切换了模型) + const mainLoopModel = userSpecifiedModel + ? parseUserSpecifiedModel(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, ... }) + } + return result + } + + // 5. 调用核心 query() 函数执行 agentic loop + yield* query({ + systemPrompt, messages: this.mutableMessages, + tools, model: mainLoopModel, ... + }) +} +``` -意外退出?网络断了?没关系: +关键设计:`submitMessage()` 是 `async *Generator`——它逐步 yield `SDKMessage`,让调用方(REPL/SDK)能实时展示进度,而不是等整个 turn 结束。 -- 每轮对话结束后,完整的 transcript 会被写入磁盘 -- 下次启动时,可以选择恢复之前的对话 -- 恢复时,系统重建消息历史和上下文状态 +## 会话持久化:JSONL Transcript -## 成本感知 +每次对话事件都被追加写入 transcript 文件(`src/utils/sessionStorage.ts`): -AI 编程助手的一个现实问题是**费用可能失控**。Claude Code 内建了多层成本控制: +### 存储路径 -| 机制 | 作用 | -|------|------| -| Token 计数器 | 实时显示本次会话已消耗的输入/输出 token | -| 费用估算 | 根据模型定价计算累计美元花费 | -| 预算上限 | 用户可设定最大花费,到达后自动停止 | -| 压缩提醒 | Token 接近上限时提示用户触发压缩 | +``` +~/.claude/projects//.jsonl +``` -## 模型切换 +- `project-hash` 由 `getProjectDir(originalCwd)` 生成,同一项目目录的会话归入同一子目录 +- 每条记录是一行 JSON(JSONL 格式),支持追加写入而不需要读取-修改-写入整个文件 +- 读取上限为 50MB(`MAX_TRANSCRIPT_READ_BYTES`),防止超大会话导致 OOM -在一个会话中,用户可以随时切换模型或调整参数: +### Transcript 写入器 -- `/model` 切换到不同的模型(Sonnet / Opus / Haiku) -- `/fast` 切换快速模式 -- 模型切换不会丢失对话历史 +`TranscriptWriter`(`src/utils/sessionStorage.ts:1200+`)是一个写队列,确保并发的消息追加不会互相覆盖: + +``` +写入流程: + appendEntryToFile(sessionId, entry) + ↓ + ensureCurrentSessionFile() ← 懒初始化:首次写入时才创建文件 + ↓ + 序列化为 JSON + 换行符 + ↓ + appendFile(path, line) ← 原子追加 + ↓ + 如果配置了远程持久化: + persistToRemote(sessionId, entry) + ├── CCR v2: internalEventWriter('transcript', entry) + └── v1 Ingress: sessionIngress.appendSessionLog(...) +``` + +### 会话恢复链路 + +`--resume` 参数触发的恢复流程(`src/main.tsx:3620+`): + +``` +1. 解析 resume 参数: + ├── UUID 格式 → getTranscriptPathForSession(uuid) + ├── .jsonl 文件路径 → 直接使用 + └── boolean → 最近一次会话的 picker + +2. loadTranscriptFromFile(path) + ├── 按 JSONL 行解析 + ├── 过滤出消息类型记录 + └── 重建 Message[] 数组 + +3. 恢复上下文状态: + ├── restoreCostStateForSession(sessionId) ← 恢复累计费用 + ├── 恢复 agentSetting(用户选择的 Agent 类型) + └── 如果有 --rewind-files,恢复文件到指定消息时的快照 + +4. 创建 QueryEngine({ initialMessages: restoredMessages }) + └── 从恢复的消息继续对话 +``` + +## 成本追踪:从 API Usage 到美元 + +成本追踪贯穿三个模块,形成完整的记录→累计→展示链路: + +### 记录层:API 响应中的 Usage + +每个 `message_delta` 事件携带 `usage` 字段(`input_tokens`、`output_tokens`、`cache_creation_input_tokens`、`cache_read_input_tokens`)。`accumulateUsage()` 将增量 usage 累加到会话总量。 + +### 累计层:cost-tracker.ts + +```typescript +// src/cost-tracker.ts — StoredCostState 数据模型 +type StoredCostState = { + totalCostUSD: number // 累计美元花费 + totalAPIDuration: number // API 调用总时长(含重试) + totalAPIDurationWithoutRetries: number // 不含重试的纯推理时间 + totalToolDuration: number // 工具执行总时长 + totalLinesAdded: number // 代码增加行数 + totalLinesRemoved: number // 代码删除行数 + modelUsage: { [modelName: string]: ModelUsage } // 按模型分拆的用量 +} +``` + +`addToTotalSessionCost()` 根据模型定价计算每次 API 调用的费用,累计到 `totalCostUSD`。按模型的 `ModelUsage` 支持在同一会话中切换模型后分别统计。 + +### 持久化:跨重启保留 + +```typescript +// 每次会话结束时保存到项目配置 +saveCurrentSessionCosts(sessionId) + → projectConfig.lastCost = totalCostUSD + → projectConfig.lastSessionId = sessionId + → projectConfig.lastModelUsage = modelUsage +``` + +### 预算熔断 + +`QueryEngineConfig.maxBudgetUsd` 提供了会话级的硬性预算上限。在 REPL 中,当累计费用超过 $5 时(`src/screens/REPL.tsx:2208`),弹出费用提醒对话框——这不是硬性阻断,而是"软提醒"。 + +## 模型热切换 + +在一个会话中切换模型不会丢失对话历史——因为 `mutableMessages` 与模型选择是解耦的: + +``` +/model sonnet → setMainLoopModelOverride('claude-sonnet-4-20250514') + ↓ +下一次 submitMessage() 开始时: + ↓ +parseUserSpecifiedModel(userSpecifiedModel) + → 返回新的模型配置 + ↓ +fetchSystemPromptParts({ mainLoopModel: newModel }) + → System Prompt 根据新模型能力重新组装 + ↓ +query({ model: newModel, messages: this.mutableMessages }) + → 使用完整历史 + 新模型继续对话 +``` + +切换模型时,`contextWindowTokens` 和 `maxOutputTokens` 也会根据新模型的规格重新计算——例如从 Sonnet 切换到 Opus 时,上下文窗口可能从 200K 变为 1M。 + +## 文件快照与回滚 + +`fileHistoryMakeSnapshot()`(`src/utils/fileHistory.ts`)在 AI 每次修改文件前自动保存当前内容。快照绑定到具体的 `message.id`,使得 `--rewind-files ` 可以精确恢复到对话中任意时间点的文件状态——这比 git 更细粒度(git 只追踪已提交的内容)。 diff --git a/docs/tools/file-operations.mdx b/docs/tools/file-operations.mdx index db54669..1c7fefd 100644 --- a/docs/tools/file-operations.mdx +++ b/docs/tools/file-operations.mdx @@ -1,55 +1,220 @@ --- -title: "文件操作工具 - AI 如何安全读写代码" -description: "解析 Claude Code 的文件操作工具设计:FileRead、FileEdit、FileWrite 三大工具的职责划分、安全策略和实现细节。" -keywords: ["文件操作", "FileRead", "FileEdit", "FileWrite", "代码编辑"] +title: "文件操作工具 - 三大工具的源码级解剖" +description: "逆向分析 FileRead、FileEdit、FileWrite 三大工具的完整执行链路:去重缓存、AST 安全编辑、原子性读写、文件历史快照的实现细节。" +keywords: ["文件操作", "FileRead", "FileEdit", "FileWrite", "代码编辑", "原子写入"] --- -{/* 本章目标:介绍文件类工具的设计理念 */} +{/* 本章目标:从源码层面解剖三大文件工具的完整执行链路 */} -## 读、写、改——三种操作模式 +## 三大工具的职责分化 -Claude Code 把文件操作拆分为三个独立工具,而不是一个万能的"文件工具": +Claude Code 将文件操作拆分为三个独立工具——这不是功能划分,而是**风险分级**: -| 工具 | 功能 | 设计考量 | -|------|------|---------| -| **Read** | 读取文件内容 | 只读操作,权限最低,AI 可以随意使用 | -| **Write** | 创建新文件或完全重写 | 高风险操作,需要确认 | -| **Edit** | 精确替换文件中的特定片段 | 中等风险,但比 Write 安全——只改你指定的部分 | +| 工具 | 权限级别 | 核心方法 | 关键属性 | +|------|---------|---------|---------| +| **Read** | 只读(免审批) | `isReadOnly() → true` | `maxResultSizeChars: Infinity` | +| **Edit** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` | +| **Write** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` | -为什么 Edit 和 Write 要分开?因为"编辑一行"和"重写整个文件"的风险完全不同。分离后,权限系统可以对它们施加不同的控制策略。 +Read 的 `maxResultSizeChars` 是 `Infinity`,但这并不意味着无限制输出——真正的截断发生在 `validateContentTokens()` 中基于 token 预算的动态判定,而非字符数硬限制。 -## 文件读取的智慧 +## FileRead:多模态文件读取引擎 -Read 工具不是简单的 `cat` 命令,它有很多精细的设计: +源码路径:`src/tools/FileReadTool/FileReadTool.ts` -- **分页读取**:超大文件不会一次性全部读入,支持 offset + limit 指定范围 -- **多格式支持**:除了文本文件,还能读取图片(多模态展示)、PDF、Jupyter Notebook -- **文件状态缓存**:记住已读过的文件内容,避免重复读取浪费 token -- **Token 感知**:文件内容计入 token 预算,系统会自动评估是否"读得起" +### 读取去重机制 -## 精确编辑 vs 全量重写 +Read 工具有一个常被忽视但至关重要的**去重层**。当 AI 重复读取同一个文件的同一范围时,系统不会浪费 token 发送两份完整内容: -Edit 工具的核心设计是**精确字符串替换**: +```typescript +// FileReadTool.ts:530-573 — 去重逻辑 +const existingState = readFileState.get(fullFilePath) +if (existingState && !existingState.isPartialView && existingState.offset !== undefined) { + const rangeMatch = existingState.offset === offset && existingState.limit === limit + if (rangeMatch) { + const mtimeMs = await getFileModificationTimeAsync(fullFilePath) + if (mtimeMs === existingState.timestamp) { + return { data: { type: 'file_unchanged', file: { filePath: file_path } } } + } + } +} +``` -- AI 指定 `old_string`(要被替换的原文)和 `new_string`(替换后的新文) -- 系统确保 `old_string` 在文件中**唯一匹配**——如果匹配到多处或零处,操作失败 -- 这个设计确保 AI 不会"改错地方" +关键设计点: +- 去重仅对 **Read 工具自身的读取**生效(通过 `offset !== undefined` 判定) +- Edit/Write 也会写入 `readFileState`,但它们的 `offset` 为 `undefined`,所以不会误命中去重 +- 通过 mtime 比对确保文件未被外部修改 +- 有 GrowthBook killswitch(`tengu_read_dedup_killswitch`)可紧急关闭 -## 搜索与导航 +实测数据:BQ proxy 显示约 18% 的 Read 调用是同文件碰撞,占 fleet `cache_creation` 的 2.64%。 -在动手修改之前,AI 通常需要先"找到目标"。两个搜索工具分工明确: +### 多格式分发:文本、图片、PDF、Notebook 四条路径 -- **Glob**:按文件名模式搜索("找到所有 `.ts` 文件"),替代 `find` 命令 -- **Grep**:按文件内容搜索("找到所有包含 `TODO` 的行"),替代 `grep/rg` 命令 +Read 工具的 `callInner()` 按 `ext` 分发到四条完全不同的处理路径: -两者都经过优化,能在大型项目中快速返回结果,并自动截断过长的输出。 +``` +.ipynb → readNotebook() → JSON cell 解析 → token 校验 +.png/.jpg/.gif/.webp → readImageWithTokenBudget() → 压缩+降采样 +.pdf → extractPDFPages() / readPDF() → 页面级提取 +其他 → readFileInRange() → 分页读取 +``` -## 文件历史快照 +**图片路径的压缩策略**特别精细: +1. 先用 `maybeResizeAndDownsampleImageBuffer()` 标准缩放 +2. 用 `base64.length * 0.125` 估算 token 数 +3. 超出预算时调用 `compressImageBufferWithTokenLimit()` 激进压缩 +4. 仍然超限时用 sharp 做最后兜底:`resize(400,400).jpeg({quality:20})` -每当 AI 准备修改文件时,系统会自动保存一份快照。这意味着: +**PDF 路径**有页数阈值:超过 `PDF_AT_MENTION_INLINE_THRESHOLD`(默认值在 `apiLimits.ts`)时强制分页读取,每请求最多 `PDF_MAX_PAGES_PER_READ` 页。 -- 用户可以随时回滚到 AI 修改前的状态 -- 即使 AI 做了错误的编辑,原始内容不会丢失 -- 快照与 git 互补——git 追踪已提交的变更,快照保护未提交的工作 +### 安全防线 + +Read 工具在 `validateInput()` 中设置了多层安全门: + +1. **设备文件屏蔽**(`BLOCKED_DEVICE_PATHS`):`/dev/zero`、`/dev/random`、`/dev/tty` 等——防止无限输出或阻塞挂起 +2. **二进制文件拒绝**(`hasBinaryExtension`):排除 PDF 和图片扩展名后,阻止读取 `.exe`、`.so` 等二进制文件 +3. **UNC 路径跳过**:Windows 下 `\\server\share` 路径跳过文件系统操作,防止 SMB NTLM 凭据泄露 +4. **权限拒绝规则**(`matchingRuleForInput`):匹配 `deny` 规则后直接拒绝 + +### 文件未找到时的智能建议 + +当文件不存在时,Read 不会只报一个 "file not found": + +```typescript +// FileReadTool.ts:639-647 +const similarFilename = findSimilarFile(fullFilePath) // 相似扩展名 +const cwdSuggestion = await suggestPathUnderCwd(fullFilePath) // cwd 相对路径建议 +// macOS 截图特殊处理:薄空格(U+202F) vs 普通空格 +const altPath = getAlternateScreenshotPath(fullFilePath) +``` + +对 macOS 截图文件名中 AM/PM 前的薄空格(U+202F)做了特殊处理——这是实测中发现的跨 macOS 版本兼容性问题。 + +## FileEdit:精确字符串替换引擎 + +源码路径:`src/tools/FileEditTool/FileEditTool.ts` + `utils.ts` + +### 引号标准化:AI 无法输出的字符怎么办 + +AI 模型只能输出直引号(`'` `"`),但源码中可能使用弯引号(`'` `'` `"` `"`)。`findActualString()` 函数处理了这个不对齐: + +```typescript +// utils.ts:73-93 +export function findActualString(fileContent: string, searchString: string): string | null { + if (fileContent.includes(searchString)) return searchString // 精确匹配 + const normalizedSearch = normalizeQuotes(searchString) // 弯引号→直引号 + const normalizedFile = normalizeQuotes(fileContent) + const idx = normalizedFile.indexOf(normalizedSearch) + if (idx !== -1) return fileContent.substring(idx, idx + searchString.length) + return null +} +``` + +匹配后还有**反向引号保持**(`preserveQuoteStyle`):如果文件用弯引号,替换后的新字符串也自动转换为弯引号,包括缩写中的撇号(如 "don't")。 + +### 原子性读-改-写 + +Edit 工具的 `call()` 方法实现了一个**无锁原子更新**协议: + +``` +1. await fs.mkdir(dir) ← 确保目录存在(异步,在临界区外) +2. await fileHistoryTrackEdit() ← 备份旧内容(异步,在临界区外) +3. readFileSyncWithMetadata() ← 同步读取当前文件内容(临界区开始) +4. getFileModificationTime() ← mtime 校验 +5. findActualString() ← 引号标准化匹配 +6. getPatchForEdit() ← 计算 diff +7. writeTextContent() ← 写入磁盘 +8. readFileState.set() ← 更新缓存(临界区结束) +``` + +步骤 3-8 之间**不允许任何异步操作**(源码注释明确写道:"Please avoid async operations between here and writing to disk to preserve atomicity")。这确保了在 mtime 校验和实际写入之间不会有其他进程修改文件。 + +### 防覆写校验 + +Edit 工具在 `validateInput()` 中检查两个条件: +1. **必须先读取**(`readFileState` 中有记录且不是局部视图) +2. **文件未被外部修改**(`mtime` 未变,或全量读取时内容完全一致) + +```typescript +// FileEditTool.ts:290-311 — Windows 特殊处理 +const isFullRead = readTimestamp.offset === undefined && readTimestamp.limit === undefined +if (isFullRead && fileContent === readTimestamp.content) { + // 内容不变,安全继续(Windows 云同步/杀毒可能改 mtime) +} +``` + +Windows 上的 mtime 可能因云同步、杀毒软件等被修改而不改变内容,因此对全量读取做了内容级比对作为兜底。 + +### 编辑大小限制 + +```typescript +const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024 // 1 GiB +``` + +超过 1 GiB 的文件直接拒绝编辑——这是 V8 字符串长度限制(~2^30 字符)的安全边界。 + +## FileWrite:全量写入与创建 + +源码路径:`src/tools/FileWriteTool/FileWriteTool.ts` + +Write 工具与 Edit 共享大部分基础设施(权限检查、mtime 校验、fileHistory 备份),但有两个关键差异: + +### 行尾处理 + +```typescript +// FileWriteTool.ts:300-305 — 关键注释 +// Write is a full content replacement — the model sent explicit line endings +// in `content` and meant them. Do not rewrite them. +writeTextContent(fullFilePath, content, enc, 'LF') +``` + +Write 工具始终使用 `LF` 行尾。早期版本会保留旧文件的行尾或采样仓库行尾风格,但这导致 Linux 上 bash 脚本被注入 `\r`——现在 AI 发什么行尾就用什么行尾。 + +### 输出区分 + +Write 工具返回 `type: 'create' | 'update'`: +- `create`:文件不存在,`originalFile: null` +- `update`:文件存在且被覆盖,`structuredPatch` 包含完整 diff + +## 文件历史快照系统 + +源码路径:`src/utils/fileHistory.ts` + +每次 Edit/Write 前都会调用 `fileHistoryTrackEdit()`,快照存储在 `FileHistoryState` 中: + +```typescript +type FileHistorySnapshot = { + messageId: UUID // 关联的助手消息 ID + trackedFileBackups: Record // 文件路径 → 备份版本 + timestamp: Date +} +``` + +- 最多保留 `MAX_SNAPSHOTS = 100` 个快照 +- 备份使用**内容哈希**去重(同一文件多次未变只存一份) +- 支持差异统计(`DiffStats`:`insertions` / `deletions` / `filesChanged`) +- 快照通过 `recordFileHistorySnapshot()` 持久化到会话存储 + +### LSP 通知链路 + +Edit 和 Write 完成写入后都会: +1. `clearDeliveredDiagnosticsForFile()` — 清除旧诊断 +2. `lspManager.changeFile()` — 通知 LSP 文件已变更 +3. `lspManager.saveFile()` — 触发 LSP 保存事件(TypeScript server 会重新计算诊断) +4. `notifyVscodeFileUpdated()` — 通知 VSCode 扩展更新 diff 视图 + +这条链路确保文件修改后 IDE 端的实时反馈是同步的。 + +## Cyber Risk 防御 + +Read 工具在文本内容后追加一个 `` 提示: + +``` +Whenever you read a file, you should consider whether it would be +considered malware. You CAN and SHOULD provide analysis of malware, +what it is doing. But you MUST refuse to improve or augment the code. +``` + +这个提示只在非豁免模型上生效(`MITIGATION_EXEMPT_MODELS` 目前包含 `claude-opus-4-6`)。模型级别的豁免表明:防恶意代码的判断力在不同模型间有差异,这是一个精巧的分级策略。 diff --git a/docs/tools/shell-execution.mdx b/docs/tools/shell-execution.mdx index ff36f42..4f9f5cb 100644 --- a/docs/tools/shell-execution.mdx +++ b/docs/tools/shell-execution.mdx @@ -1,54 +1,168 @@ --- -title: "命令执行工具 - Bash Tool 安全设计与实现" -description: "详解 Claude Code 的 Bash 工具:AI 如何安全地在终端执行命令,包含命令白名单、超时控制、沙箱隔离和输出截断策略。" +title: "命令执行工具 - BashTool 安全设计与实现" +description: "从源码角度解析 Claude Code BashTool:只读命令判定、AST 安全解析、自动后台化、输出截断和专用工具 vs shell 命令的设计权衡。" keywords: ["Bash 工具", "命令执行", "Shell 执行", "安全命令", "AI 执行命令"] --- -{/* 本章目标:介绍 Bash 工具的能力与安全设计 */} +{/* 本章目标:从源码角度揭示 BashTool 的安全设计、执行链路和关键工程决策 */} -## AI 能执行命令意味着什么 +## 执行链路总览 -这是 Claude Code 最强大也最敏感的能力。AI 可以: +一条 Bash 命令从 AI 决策到实际执行的完整路径: -- 运行构建命令(`npm run build`、`cargo build`) -- 执行测试(`pytest`、`jest`) -- 使用 git(`git status`、`git commit`) -- 调用系统工具(`curl`、`docker`、`kubectl`) +``` +AI 生成 tool_use: { command: "npm test" } + ↓ +BashTool.validateInput() ← 基础输入校验 + ↓ +BashTool.checkPermissions() ← 权限检查(详见安全体系章节) + ├── isReadOnly()? → 自动 allow(只读命令免审批) + ├── bashToolHasPermission() ← AST 解析 + 语义检查 + 规则匹配 + └── 未匹配 → 弹窗确认 + ↓ +BashTool.call() → runShellCommand() + ↓ +shouldUseSandbox(input) ← 是否需要沙箱包裹 + ↓ +Shell.exec(command, { shouldUseSandbox, shouldAutoBackground }) + ↓ +spawn(wrapped_command) ← 实际进程创建 +``` -几乎你在终端里能做的事,AI 都能做。 +## 只读命令的判定:为什么 Read 免审批而 Bash 不一定 -## 安全设计 +BashTool 的 `isReadOnly()` 方法(`BashTool.tsx:437`)决定一条命令是否被视为"只读": -强大的能力需要严格的控制: +```typescript +isReadOnly(input) { + const compoundCommandHasCd = commandHasAnyCd(input.command) + const result = checkReadOnlyConstraints(input, compoundCommandHasCd) + return result.behavior === 'allow' +} +``` - - - 默认情况下,每条命令执行前都需要用户手动确认。用户可以设置白名单规则,让特定命令自动放行。 - - - 在支持的平台上,命令可以运行在沙箱环境中——限制文件系统访问范围、禁止网络请求、阻止危险操作。 - - - 每条命令都有超时限制(默认 2 分钟,最长 10 分钟),防止 AI 启动一个永远不会结束的进程。 - - - 命令输出过长时自动截断,避免把海量日志全部塞进 AI 的上下文。 - - +判定逻辑基于 4 个命令集合(`BashTool.tsx:60-78`): -## 前台与后台 +| 集合 | 命令 | 性质 | +|------|------|------| +| `BASH_SEARCH_COMMANDS` | find, grep, rg, ag, ack, locate, which, whereis | 搜索类 | +| `BASH_READ_COMMANDS` | cat, head, tail, wc, stat, file, jq, awk, sort, uniq... | 读取/分析类 | +| `BASH_LIST_COMMANDS` | ls, tree, du | 列表类 | +| `BASH_SEMANTIC_NEUTRAL_COMMANDS` | echo, printf, true, false, : | 语义中性(不影响判定) | -有些命令需要等待结果(比如 `git status`),有些适合在后台运行(比如 `npm install`): +对于复合命令(`ls dir && echo "---" && ls dir2`),系统拆分后逐段检查——**所有非中性段都必须属于上述集合**,整条命令才被视为只读。 -- **前台执行**:AI 等待命令完成,拿到输出后继续思考 -- **后台执行**:命令在后台运行,AI 可以继续做其他事,稍后再检查结果 +```typescript +// BashTool.tsx:95 — 简化的判定逻辑 +for (const part of partsWithOperators) { + if (BASH_SEMANTIC_NEUTRAL_COMMANDS.has(baseCommand)) continue // 跳过中性段 + if (!isPartSearch && !isPartRead && !isPartList) { + return { isSearch: false, isRead: false, isList: false } // 有任何一段不通过 → 非只读 + } +} +``` + +## AST 安全解析:tree-sitter bash 解析 + +`preparePermissionMatcher()`(`BashTool.tsx:445`)在权限检查前用 `parseForSecurity()` 解析命令结构: + +```typescript +async preparePermissionMatcher({ command }) { + const parsed = await parseForSecurity(command) + if (parsed.kind !== 'simple') { + return () => true // 解析失败 → fail-safe,触发所有 hook + } + // 提取子命令列表,剥离 VAR=val 前缀 + const subcommands = parsed.commands.map(c => c.argv.join(' ')) + return pattern => { + return subcommands.some(cmd => matchWildcardPattern(pattern, cmd)) + } +} +``` + +关键安全点:对于复合命令 `ls && git push`,解析后拆分为 `["ls", "git push"]`,确保 `git push` 不会因为前半段是只读命令而绕过权限检查。解析失败时采用 fail-safe 策略——假设不安全,触发所有安全 hook。 + +## 超时控制:分级策略 + +``` +用户指定 timeout → 直接使用 + ↓ 未指定 +getDefaultTimeoutMs() + ├── 默认上限:120,000ms(2 分钟) + └── 最大上限:600,000ms(10 分钟,用户显式设置时) +``` + +超时后系统不会直接杀进程——`ShellCommand`(`src/utils/ShellCommand.ts:129`)通过 `onTimeout` 回调通知调用方,由调用方决定是终止还是后台化。 + +## 自动后台化 + +长时间运行的命令可以自动转为后台任务,不阻塞 AI 的 agentic loop: + +```typescript +// BashTool.tsx:880 +const shouldAutoBackground = !isBackgroundTasksDisabled + && isAutobackgroundingAllowed(command) +``` + +自动后台化的完整链路: + +``` +命令开始执行 + ↓ 进度轮询 +15 秒内未完成(ASSISTANT_BLOCKING_BUDGET_MS) + ↓ +检查 isAutobackgroundingAllowed(command) + ↓ 允许 +将前台任务转为后台任务(backgroundExistingForegroundTask) + ↓ +shellCommand.onTimeout → spawnBackgroundTask() + ↓ +返回 taskId 给 AI,AI 可以继续做其他事 + ↓ +后台任务完成后通过通知机制汇报结果 +``` + +主线程 Agent 有 15 秒的阻塞预算——超过这个时间,系统自动将命令后台化。这防止了一个 `npm install` 阻塞整个 agentic loop 数分钟。 + +## 输出截断策略 + +命令输出过长时会触发截断,防止把海量日志塞进 AI 的上下文窗口: + +| 截断点 | 位置 | 行为 | +|--------|------|------| +| `maxResultSizeChars` | 工具级(通常 100K 字符) | 超长输出在写入消息前截断 | +| 进度轮询截断 | `onProgress` 回调 | 只传递最后几行作为进度显示 | +| `totalBytes` 标记 | `isIncomplete` 参数 | 告知 AI 输出被截断 | + +截断不是简单砍尾——`isIncomplete` 标记确保 AI 知道输出不完整,可以决定是否需要用更精确的命令重新获取。 ## 为什么用专用工具而不是直接调 shell - -Claude Code 为文件读写、代码搜索等操作提供了专用工具(Read、Grep、Glob),而不是让 AI 用 `cat`、`grep` 等 shell 命令。原因有三: - +Claude Code 为文件读写、代码搜索等操作提供了专用工具(Read、Grep、Glob),而不是让 AI 用 `cat`、`grep` 等 shell 命令。这不仅是用户体验的选择,更是架构层面的设计决策: -1. **权限粒度更细**:`Read` 是只读操作可以自动放行,但 `Bash: cat file` 需要审批整条命令 -2. **输出结构化**:专用工具的返回值是结构化的,方便 UI 渲染(高亮、diff 视图等) -3. **性能优化**:专用工具可以做缓存、分页、token 预算控制,shell 命令做不到 +| 维度 | 专用工具 | Bash 命令 | +|------|---------|----------| +| **权限粒度** | `Read` 是只读操作 → 自动放行 | `Bash: cat file` 需要审批整条命令(cat 在只读集合中但走不同路径) | +| **输出结构化** | 返回结构化数据,UI 可渲染 diff、高亮 | 纯文本输出,无渲染优化 | +| **性能优化** | 文件缓存、分页、token 预算控制 | 每次都是新进程,无缓存 | +| **并发安全** | `isConcurrencySafe()` 返回 `true` → 可并行执行 | Bash 命令可能有副作用,串行执行 | +| **安全审计** | 工具名精确匹配权限规则 | 需 AST 解析命令结构后匹配 | + +`isConcurrencySafe()`(`BashTool.tsx:434`)是一个常被忽视但重要的设计——只有只读命令可以在 agentic loop 中并行执行,有副作用的命令必须串行,防止竞态条件。 + +## 进度反馈的流式设计 + +BashTool 的命令执行是流式的,通过 `onProgress` 回调逐行推送输出: + +``` +runShellCommand() + ├── Shell.exec() 启动子进程 + ├── 每秒轮询输出文件 + ├── onProgress(lastLines, allLines, totalLines, totalBytes, isIncomplete) + │ ├── 更新 lastProgressOutput / fullOutput + │ └── resolveProgress() → 唤醒 generator yield + ├── yield { type: 'progress', output, fullOutput, elapsedTimeSeconds } + └── return { code, stdout, interrupted, ... } +``` + +UI 层通过 `useToolCallProgress` hook 实时展示命令输出。`resolveProgress()` 信号机制让 generator 在有新数据时才 yield,避免了忙等待。 diff --git a/mint.json b/mint.json index bdc831b..3f78e45 100644 --- a/mint.json +++ b/mint.json @@ -22,7 +22,7 @@ }, "topbarCtaButton": { "type": "github", - "url": "https://github.com/anthropics/claude-code" + "url": "https://github.com/claude-code-best/claude-code" }, "search": { "prompt": "搜索 Claude Code 架构文档..."