diff --git a/docs/context/system-prompt.mdx b/docs/context/system-prompt.mdx index 002c8be..bc6e3de 100644 --- a/docs/context/system-prompt.mdx +++ b/docs/context/system-prompt.mdx @@ -1,36 +1,225 @@ --- title: "System Prompt 动态组装 - AI 工作记忆构建" -description: "深入解析 Claude Code 的 System Prompt 动态组装过程:如何将 CLAUDE.md、项目上下文、工具定义和用户偏好拼装为 AI 的工作记忆。" -keywords: ["System Prompt", "系统提示词", "动态组装", "CLAUDE.md", "上下文构建"] +description: "深入解析 Claude Code 的 System Prompt 动态组装过程:缓存策略、分界标记、Section 注册表、CLAUDE.md 多级合并,以及如何将零散上下文拼装为 API 可消费的缓存友好结构。" +keywords: ["System Prompt", "系统提示词", "动态组装", "CLAUDE.md", "Prompt Cache", "缓存策略"] --- -{/* 本章目标:解释 System Prompt 的组装过程和设计思想 */} +## 从数组到 API 调用:System Prompt 的完整链路 -## 什么是 System Prompt +System Prompt 在 Claude Code 中不是一段写死的文本,而是一个 **`string[]` 数组**(品牌类型 `SystemPrompt`,定义于 `src/utils/systemPromptType.ts:8`),经过组装、分块、缓存标记后发送给 API。 -每次调用 AI API 时,都需要发送一个 System Prompt——它是 AI 的"人设说明书",告诉 AI: +### 三阶段管道 -- 你是谁(Claude Code,一个编程助手) -- 你能做什么(可用工具列表) -- 你在什么环境(操作系统、当前目录、git 状态) -- 你需要遵守什么规则(安全规范、输出格式) +``` +getSystemPrompt() → string[] (组装内容) + ↓ +buildEffectiveSystemPrompt() → SystemPrompt (选择优先级路径) + ↓ +buildSystemPromptBlocks() → TextBlockParam[] (分块 + cache_control 标记) +``` -## 不是静态模板,而是动态组装 +1. **`getSystemPrompt()`**(`src/constants/prompts.ts:444`)—— 收集静态段 + 动态段,插入 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分界标记 +2. **`buildEffectiveSystemPrompt()`**(`src/utils/systemPrompt.ts:41`)—— 按 Override > Coordinator > Agent > Custom > Default 优先级选择 +3. **`buildSystemPromptBlocks()`**(`src/services/api/claude.ts:3214`)—— 调用 `splitSysPromptPrefix()` 分块,为每个块附加 `cache_control` -Claude Code 的 System Prompt 不是一段写死的文本,而是根据当前环境**实时组装**的: +## SystemPrompt 品牌类型 - - System Prompt 动态组装图 - +```typescript +// src/utils/systemPromptType.ts:8 +export type SystemPrompt = readonly string[] & { + readonly __brand: 'SystemPrompt' +} +export function asSystemPrompt(value: readonly string[]): SystemPrompt { + return value as SystemPrompt // 零开销类型断言 +} +``` -| 组成部分 | 内容 | 来源 | -|----------|------|------| -| 基础人设 | 角色定义、行为准则 | 内置模板 | -| 环境信息 | 操作系统、shell 类型、当前日期 | 运行时检测 | -| Git 状态 | 当前分支、最近提交、工作区状态 | `git` 命令输出 | -| 项目知识 | CLAUDE.md 文件内容 | 项目目录层级扫描 | -| 记忆文件 | 用户偏好、项目约定 | 持久化记忆系统 | -| 工具说明 | 每个可用工具的描述和参数 | 工具注册表 | +品牌类型(branded type)防止普通 `string[]` 被意外传入 API 调用——只有通过 `asSystemPrompt()` 显式转换才能获得 `SystemPrompt` 类型。 + +## getSystemPrompt():内容组装的全景 + +`src/constants/prompts.ts:444` 是 System Prompt 的核心工厂函数,返回一个有序数组: + +| 阶段 | 内容 | 缓存策略 | +|------|------|----------| +| **静态区** | Intro Section、System Rules、Doing Tasks、Actions、Using Tools、Tone & Style、Output Efficiency | 可跨组织缓存(`scope: 'global'`) | +| **BOUNDARY** | `SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'` | 分界标记(不发送给 API) | +| **动态区** | Session Guidance、Memory、Model Override、Env Info、Language、Output Style、MCP Instructions、Scratchpad、FRC、Summarize Tool Results、Token Budget、Brief | 每次会话不同(`scope: 'org'` 或无缓存) | + +### 动态区的 Section 注册表 + +动态区通过 `systemPromptSection()` / `DANGEROUS_uncachedSystemPromptSection()` 注册,这两个工厂函数定义于 `src/constants/systemPromptSections.ts`: + +```typescript +// 缓存式 Section:计算一次,/clear 或 /compact 后才重新计算 +systemPromptSection('memory', () => loadMemoryPrompt()) + +// 危险:每轮重新计算,会破坏 Prompt Cache +DANGEROUS_uncachedSystemPromptSection( + 'mcp_instructions', + () => isMcpInstructionsDeltaEnabled() ? null : getMcpInstructionsSection(mcpClients), + 'MCP servers connect/disconnect between turns' // 必须给出破坏缓存的理由 +) +``` + +`resolveSystemPromptSections()` 在每轮查询时解析所有 Section,对于 `cacheBreak: false` 的 Section,优先使用 `getSystemPromptSectionCache()` 中的缓存值。只有 MCP 指令等真正动态的内容使用 `DANGEROUS_uncachedSystemPromptSection`。 + +### `CLAUDE_CODE_SIMPLE` 快速路径 + +当环境变量 `CLAUDE_CODE_SIMPLE` 为真时,整个 System Prompt 缩减为一行: + +```typescript +`You are Claude Code, Anthropic's official CLI for Claude.\n\nCWD: ${getCwd()}\nDate: ${getSessionStartDate()}` +``` + +跳过所有 Section 注册、缓存分块、动态组装——用于最小化 token 消耗的测试场景。 + +## buildEffectiveSystemPrompt():五级优先级 + +`src/utils/systemPrompt.ts:41` 决定最终使用哪个 System Prompt: + +| 优先级 | 条件 | 行为 | +|--------|------|------| +| **0. Override** | `overrideSystemPrompt` 非空 | 完全替换,返回 `[override]` | +| **1. Coordinator** | `COORDINATOR_MODE` feature + 环境变量 | 使用协调者专用提示词 | +| **2. Agent** | `mainThreadAgentDefinition` 存在 | Proactive 模式:追加到默认提示词尾部;否则:替换默认提示词 | +| **3. Custom** | `--system-prompt` 参数指定 | 替换默认提示词 | +| **4. Default** | 无特殊条件 | 使用 `getSystemPrompt()` 完整输出 | + +`appendSystemPrompt` 始终追加到末尾(Override 除外)。 + +## 缓存策略:分块、标记、命中 + +这是 System Prompt 设计中最精密的部分。 + +### Anthropic Prompt Cache 基础 + +Anthropic API 的 Prompt Cache 允许跨请求复用相同的 System Prompt 前缀,按缓存命中量计费(远低于完整输入价格)。缓存键由内容的 Blake2b 哈希决定——任何字符变化都会导致缓存失效。 + +### `splitSysPromptPrefix()`:三种分块模式 + +`src/utils/api.ts:321` 是缓存策略的核心,根据条件选择三种分块模式: + +#### 模式 1:MCP 工具存在时(`skipGlobalCacheForSystemPrompt=true`) + +``` +[attribution header] → cacheScope: null (不缓存) +[system prompt prefix] → cacheScope: 'org' (组织级缓存) +[everything else] → cacheScope: 'org' (组织级缓存) +``` + +MCP 工具列表在会话中可能变化(连接/断开),破坏了跨组织缓存的基础,因此降级为组织级。 + +#### 模式 2:Global Cache + Boundary 存在(1P 专用) + +``` +[attribution header] → cacheScope: null (不缓存) +[system prompt prefix] → cacheScope: null (不缓存) +[static content] → cacheScope: 'global' (全局缓存!跨组织共享) +[dynamic content] → cacheScope: null (不缓存) +``` + +这是缓存效率最高的模式。`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 之前的静态内容(Intro、Rules、Tone & Style 等)对所有用户相同,可跨组织缓存。 + +#### 模式 3:默认(3P 提供商 或 Boundary 缺失) + +``` +[attribution header] → cacheScope: null (不缓存) +[system prompt prefix] → cacheScope: 'org' (组织级缓存) +[everything else] → cacheScope: 'org' (组织级缓存) +``` + +### `getCacheControl()`:TTL 决策 + +`src/services/api/claude.ts:359` 生成的 `cache_control` 对象: + +```typescript +{ + type: 'ephemeral', + ttl?: '1h', // 仅特定 querySource 符合条件时 + scope?: 'global', // 仅静态区 +} +``` + +1 小时 TTL 的判定逻辑(`should1hCacheTTL()`,第 394 行): +- **Bedrock 用户**:通过环境变量 `ENABLE_PROMPT_CACHING_1H_BEDROCK` 启用 +- **1P 用户**:通过 GrowthBook 配置的 `allowlist` 数组匹配 `querySource`,支持前缀通配符(如 `"repl_main_thread*"`) +- **会话级锁定**:资格判定结果在 bootstrap state 中缓存,防止 GrowthBook 配置中途变化导致同一会话内 TTL 不一致 + +### 缓存破坏:Session-Specific Guidance 的放置 + +`getSessionSpecificGuidanceSection()`(`src/constants/prompts.ts:352`)的内容必须放在 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` **之后**。因为它包含: +- 当前会话的 enabledTools 集合 +- `isForkSubagentEnabled()` 的运行时判定 +- `getIsNonInteractiveSession()` 的结果 + +这些运行时 bit 如果放在静态区,会产生 2^N 种 Blake2b 哈希变体(N = 运行时条件数),完全破坏缓存命中率。源码注释明确警告: + +> Each conditional here is a runtime bit that would otherwise multiply the Blake2b prefix hash variants (2^N). See PR #24490, #24171 for the same bug class. + +### `CLAUDE_CODE_SIMPLE` 模式 + +当设置了 `CLAUDE_CODE_SIMPLE` 环境变量时,整个系统提示词会大幅缩减: + +```typescript +return [`You are Claude Code, Anthropic's official CLI for Claude.\n\nCWD: ${getCwd()}\nDate: ${getSessionStartDate()}`] +``` + +## 上下文注入:System Context 与 User Context + +System Prompt 数组本身不包含运行时上下文(git 状态、CLAUDE.md 内容)。上下文通过两个独立的管道注入: + +### System Context(`src/context.ts:116`) + +```typescript +export const getSystemContext = memoize(async () => { + return { + gitStatus, // git 分支、状态、最近提交(截断至 MAX_STATUS_CHARS=2000) + cacheBreaker, // 仅 ant 用户的缓存破坏器 + } +}) +``` + +- 使用 `lodash.memoize` 缓存——**整个会话期间只计算一次** +- Git 状态快照包含 5 个并行 `git` 命令(branch、defaultBranch、status、log、userName) +- `status` 超过 2000 字符时截断并附加提示使用 BashTool 获取更多信息 +- `systemPromptInjection` 变更时,通过 `getUserContext.cache.clear?.()` 清除所有上下文缓存 + +### User Context(`src/context.ts:155`) + +```typescript +export const getUserContext = memoize(async () => { + return { + claudeMd, // 合并后的 CLAUDE.md 内容 + currentDate, // "Today's date is YYYY-MM-DD." + } +}) +``` + +- **CLAUDE.md 禁用条件**:`CLAUDE_CODE_DISABLE_CLAUDE_MDS` 环境变量,或 `--bare` 模式(除非通过 `--add-dir` 显式指定目录) +- `--bare` 模式的语义是"跳过我没要求的东西"而非"忽略所有" + +### 注入位置 + +在 `src/query.ts:449`: + +```typescript +// System Context 追加到 System Prompt 尾部 +const fullSystemPrompt = asSystemPrompt( + appendSystemContext(systemPrompt, systemContext) // 简单拼接 +) +``` + +User Context 通过 `prependUserContext()`(`src/utils/api.ts:449`)注入为 `` 标签包裹的首条用户消息,放在所有对话消息之前。 + +## Attribution Header:计费与安全 + +每个 API 请求的 System Prompt 首块是 Attribution Header(`src/constants/system.ts:30`),包含: +- **`cc_version`**:Claude Code 版本 + 指纹 +- **`cc_entrypoint`**:入口点标识(REPL / SDK / pipe 等) +- **`cch=00000`**(NATIVE_CLIENT_ATTESTATION 启用时):Bun 原生 HTTP 层在发送前将零替换为计算出的哈希值,服务器验证此 token 确认请求来自真实 Claude Code 客户端 + +Header 始终 `cacheScope: null`——它因版本和指纹不同而变化,不适合缓存。 ## CLAUDE.md:项目级知识注入 @@ -49,10 +238,15 @@ Claude Code 的 System Prompt 不是一段写死的文本,而是根据当前 └── /project/src/CLAUDE.md ← 子目录(模块特定) ``` -## 缓存策略 +加载逻辑在 `src/utils/claudemd.ts` 中的 `getClaudeMds()` 和 `getMemoryFiles()` 实现——从 CWD 向上遍历目录树,合并所有匹配的 CLAUDE.md 文件内容。 -System Prompt 的 token 消耗不小(可能占总量的 30%+)。为了降低成本,系统使用了缓存机制: +## 设计洞察:为什么是 `string[]` 而非单个 `string` -- 不变的部分(基础人设、工具说明)可以跨请求复用 -- 变化的部分(git 状态、记忆文件)每次重新生成 -- 缓存节点的位置经过精心设计,最大化缓存命中率 +将 System Prompt 设计为数组而非单段文本,是为了 **缓存分块**: + +1. Anthropic Prompt Cache 以 **内容块**(TextBlock)为缓存单位 +2. 将 System Prompt 拆为多个块,可以让不变的部分(Intro、Rules)获得独立的缓存命中 +3. 如果是单个 `string`,任何一个字符变化(如日期更新)都会导致整个 System Prompt 的缓存失效 +4. `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 标记允许 `splitSysPromptPrefix()` 精确地将静态区标记为 `scope: 'global'`,动态区不标记或标记为 `scope: 'org'` + +这是 Claude Code 在 token 成本优化上的核心设计——一次典型的 System Prompt 约 20K+ tokens,通过缓存分块可以节省 30-50% 的输入 token 费用。 diff --git a/docs/conversation/the-loop.mdx b/docs/conversation/the-loop.mdx index 744e024..9166ad2 100644 --- a/docs/conversation/the-loop.mdx +++ b/docs/conversation/the-loop.mdx @@ -1,69 +1,182 @@ --- title: "Agentic Loop:AI 自主循环的核心机制" -description: "深入解析 Claude Code 的 Agentic Loop 机制——AI 如何通过思考-行动-观察的循环,自主决策工具调用链,直到任务完成。包含源码级流程分析。" -keywords: ["Agentic Loop", "AI 循环", "工具调用", "自主决策", "ReAct 模式"] +description: "深入解析 Claude Code 的 query() 异步生成器循环——从流式 API 调用、工具并行执行、上下文压缩、错误恢复到终止条件的完整状态机,基于 src/query.ts 的源码级分析。" +keywords: ["Agentic Loop", "query loop", "tool_use", "状态机", "auto-compact", "streaming", "recovery"] --- -{/* 本章目标:解释 Agentic Loop 这个核心机制 */} +{/* 本章目标:基于 src/query.ts 揭示 Agentic Loop 的完整状态机 */} ## 什么是 Agentic Loop 传统聊天机器人:你问一句,它答一句。 Claude Code 不一样:你说一个需求,它可能连续执行十几步操作才给你最终结果。 -这背后的机制叫做 **Agentic Loop**(智能体循环): +这背后的机制叫做 **Agentic Loop**(智能体循环),核心实现在 `src/query.ts` 的 `queryLoop()` 异步生成器函数(第 241 行)。它是一个 `while(true)` 无限循环,每次迭代代表一次"思考→行动→观察"周期。 Agentic Loop 循环图 - - - AI 分析当前上下文,决定下一步该做什么 - - - AI 发出工具调用请求(比如"读取这个文件"、"执行这条命令") - - - 工具执行完毕,结果回传给 AI - - - AI 根据观察结果决定:继续下一步操作,还是任务已完成、直接回答用户 - - +## 循环的完整结构 -## 一个真实的例子 +`queryLoop()` 的每次迭代(`src/query.ts:307` `while(true)`)包含以下阶段: -> 用户:"帮我找到项目里所有未使用的导入语句,然后删掉它们" +### 阶段 1:上下文预处理(Pre-Processing Pipeline) -AI 的内部过程: +在调用 API 之前,依次执行 5 个压缩/优化步骤: -1. **思考**:我需要先了解项目结构 → **行动**:调用 Glob 工具扫描所有源文件 -2. **观察**:拿到文件列表 → **思考**:逐个检查 → **行动**:调用 Grep 搜索 import 语句 -3. **观察**:发现 3 个文件有未使用导入 → **行动**:调用 FileEdit 逐个删除 -4. **观察**:编辑成功 → **结束**:告诉用户"已清理 3 个文件中的 5 条未使用导入" +``` +messagesForQuery(原始消息) + ↓ applyToolResultBudget() — 工具结果预算截断(按 maxResultSizeChars) + ↓ snipCompactIfNeeded() — 历史 Snip 压缩(HISTORY_SNIP feature) + ↓ microcompact() — 微压缩(工具结果摘要) + ↓ applyCollapsesIfNeeded() — 上下文折叠(CONTEXT_COLLAPSE feature) + ↓ autocompact() — 自动压缩(超出阈值时触发) +messagesForQuery(处理后的消息)→ 发往 API +``` -整个过程可能涉及 10+ 次工具调用,但用户只需要说一句话。 +每个步骤的输出是下一步的输入,形成串行管道。Snip 和 Microcompact 的释放 token 数会传递给 autocompact 的阈值计算(`snipTokensFreed`),避免重复压缩。 + +### 阶段 2:流式 API 调用(Streaming Loop) + +`deps.callModel()` 发起流式请求(第 659 行),返回一个 AsyncGenerator。在流式过程中: + +- **AssistantMessage** 被收集到 `assistantMessages[]` 数组 +- **tool_use 块** 被提取到 `toolUseBlocks[]`,设置 `needsFollowUp = true` +- **StreamingToolExecutor** 在流式过程中就开始并行执行工具(不等流结束) +- 可恢复的错误(prompt-too-long、max-output-tokens)被**暂扣**(withheld),先尝试恢复 + +流式回调中的关键守卫: +- `backfillObservableInput()`(第 763 行)—— 为 tool_use 块回填可观察字段(如文件路径展开),但只在添加了新字段时才克隆消息,避免破坏 prompt cache 的字节一致性 +- 流式降级检测——如果 `streamingFallbackOccured`,已收集的消息被标记为 tombstone(第 717 行),清空后重试 + +### 阶段 3:工具执行(Tool Execution) + +如果 `needsFollowUp` 为 true,循环不会终止,而是执行工具: + +```typescript +// 两种工具执行器(互斥) +const toolUpdates = streamingToolExecutor + ? streamingToolExecutor.getRemainingResults() // 流式:获取已完成的+等待中的 + : runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext) +``` + +工具结果通过 `normalizeMessagesForAPI()` 标准化后,与原始消息合并,进入**下一轮循环迭代**。 + +### 阶段 4:终止或继续 + +每次迭代结束时,根据条件决定 `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 行 | 图片尺寸/大小错误 → 直接返回 | +| **stop_hook_prevented** | 第 1282 行 | Stop hook 返回 `preventContinuation: true` → 返回 | + +## 4 种继续条件(恢复路径) + +循环不仅是一个简单的"有 tool_use 就继续",它还包含多种恢复/重试路径: + +### 1. 正常工具循环 +`needsFollowUp = true` → 执行工具 → 新消息追加到 `messagesForQuery` → `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` 次 +- 恢复耗尽后,暂扣的错误消息被释放 + +### 3. Prompt-Too-Long 恢复(第 1088-1186 行) +当遇到 413 错误时,有两个恢复阶段: +- **Context Collapse Drain**(第 1097 行):提交所有已暂存的折叠,释放空间后重试。如果上一轮已经是 collapse_drain_retry 则跳过 +- **Reactive Compact**(第 1123 行):触发即时压缩,生成摘要后重试。`hasAttemptedReactiveCompact` 防止无限循环 + +### 4. Stop Hook 阻塞重试(第 1285-1308 行) +Stop hook 可以注入阻塞错误消息,强制 AI 重新思考。新的消息(包含阻塞错误)被追加到对话中,`stopHookActive = true`,进入下一轮迭代。 + +## 模型降级(Fallback) + +当主模型不可用时(`FallbackTriggeredError`,第 897 行): + +1. 已收集的 `assistantMessages` 被清空,tool_use 块收到合成 tool_result:"Model fallback triggered" +2. 思维签名块被移除(`stripSignatureBlocks`)—— 因为思维签名与模型绑定,跨模型回放会 400 +3. 切换到 `fallbackModel`,更新 `toolUseContext.options.mainLoopModel` +4. 生成系统消息:"Switched to {fallback} due to high demand for {original}" +5. 重新发起流式请求 + +## 状态机:State 对象 + +每次迭代的状态通过 `State` 类型(第 204 行)传递: + +```typescript +type State = { + messages: Message[] // 当前对话消息 + toolUseContext: ToolUseContext // 工具上下文(含权限) + autoCompactTracking: AutoCompactTrackingState // 压缩跟踪 + maxOutputTokensRecoveryCount: number // 输出截断恢复计数 + hasAttemptedReactiveCompact: boolean // 是否已尝试即时压缩 + maxOutputTokensOverride: number | undefined // 输出 token 上限覆盖 + pendingToolUseSummary: Promise<...> | undefined // 异步工具摘要 + stopHookActive: boolean | undefined // Stop hook 是否激活 + turnCount: number // 轮次计数 + transition: Continue | undefined // 上一次继续的原因 +} +``` + +每次 `continue` 都创建新的 State 对象(不可变更新),而非就地修改。`transition` 字段记录了为什么继续——让后续迭代能检测特定恢复路径(如 `collapse_drain_retry`)避免循环。 + +## Token Budget(实验性) + +当 `TOKEN_BUDGET` feature 启用时(第 1311 行),循环在终止前会检查 token 消耗: + +- **continuation**:未达到预算但超过阈值 → 注入 nudge 消息,让 AI 加速收尾 +- **diminishing_returns**:检测到收益递减 → 提前终止 +- 预算数据来自 `createBudgetTracker()`,跨迭代累计 ## 为什么不是"一次规划,批量执行" -一个常见的替代方案是:AI 先生成一个完整的计划,然后一次性执行所有步骤。Claude Code 选择了逐步循环,原因是: +源码揭示了为什么 Claude Code 选择逐步循环: -- **每一步都能看到真实结果**:文件内容、命令输出、错误信息——这些只有执行后才知道 -- **动态调整**:如果第 3 步发现了意外情况,AI 可以立刻改变策略 -- **错误恢复**:某步失败了,AI 可以当场诊断和修复,不需要推倒重来 -- **用户可控**:用户可以在任何一步中断,AI 的循环不会失控 +- **每一步都产生真实信息**:`runTools()` 返回的 `toolResults` 是 API 不可能预知的——命令输出、文件内容、错误信息 +- **动态上下文管理**:每轮迭代前都重新评估压缩需求(autocompact → microcompact → snip),基于最新的 token 计数 +- **错误即时恢复**:工具失败不需要推倒重来——stop hook 可以注入阻塞错误让 AI 修正策略 +- **用户可控**:`abortController.signal` 在循环的多个检查点被检测(第 1018、1048、1488 行),用户按 ESC 可以优雅中断 +- **成本控制**:Token Budget 在每轮终止前检查,防止 AI 无效循环 -## 循环的终止条件 +## 一个完整的迭代示例 -Agentic Loop 不会无限运行,以下情况会让循环停止: +> 用户:"帮我找到项目里所有未使用的导入语句,然后删掉它们" -| 条件 | 说明 | -|------|------| -| AI 主动结束 | AI 判断任务完成,返回纯文本回答(不再调用工具) | -| 用户中断 | 用户按 Ctrl+C 或 ESC 打断当前操作 | -| Token 预算耗尽 | 单轮对话的 token 用量达到上限 | -| 输出过长自动续写 | AI 回复被截断时,系统自动发起续写请求(有次数上限) | -| 成本上限 | 累计 API 花费超过用户设定的预算 | +``` +迭代 1: 思考→行动 + 预处理: 无需压缩(上下文很短) + API 调用: 返回 tool_use(Glob, "**/*.ts") + 工具执行: 返回 42 个文件路径 + → needsFollowUp = true, continue + +迭代 2: 思考→行动 + 预处理: 42 个文件结果仍在预算内 + API 调用: 返回 tool_use(Grep, "import.*from") + 工具执行: 在 15 个文件中找到 120 条 import + → needsFollowUp = true, continue + +迭代 3: 思考→行动(多轮) + 预处理: 120 条 Grep 结果触发 microcompact → 摘要化 + API 调用: 返回 3 个 tool_use(FileEdit, ...) + 工具执行: 删除 5 条未使用导入 + → needsFollowUp = true, continue + +迭代 4: 总结 + API 调用: 返回纯文本"已清理 3 个文件中的 5 条未使用导入" + → needsFollowUp = false + → Stop hooks 通过 + → return { reason: 'completed' } +``` diff --git a/docs/extensibility/skills.mdx b/docs/extensibility/skills.mdx index 7243475..9614ea2 100644 --- a/docs/extensibility/skills.mdx +++ b/docs/extensibility/skills.mdx @@ -1,61 +1,221 @@ --- -title: "Skills 技能系统 - 可复用的 AI 工作流封装" -description: "详解 Claude Code Skills 系统:如何将常用工作流封装为可复用的技能包,Skills 与 Tools 的区别,以及技能定义和加载机制。" -keywords: ["Skills", "技能系统", "工作流封装", "可复用技能", "AI 工作流"] +title: "Skills 技能系统 - Prompt 即能力的架构哲学" +description: "深入剖析 Claude Code Skills 系统的完整实现:从磁盘加载、Frontmatter 解析、预算感知描述截断、双模式执行(inline/fork)、权限白名单、条件激活、动态发现到远程技能加载,揭示一条完整的 Skill 生命周期链路。" +keywords: ["Skills", "SkillTool", "技能加载", "Frontmatter", "whenToUse", "allowedTools", "fork执行", "动态发现"] --- -{/* 本章目标:解释 Skills 系统的设计思想 */} +{/* 本章目标:揭示 Skill 系统从文件到执行的全链路实现 */} -## Tool vs Skill +## Tool vs Skill:本质差异 | | Tool | Skill | |---|---|---| | 粒度 | 单个原子操作(读文件、执行命令) | 一套完整的工作流(代码审查、创建 PR) | -| 触发方式 | AI 自主选择 | 用户主动调用(`/skill-name`)或 AI 根据场景推荐 | -| 本质 | 执行逻辑 | 预制的 Prompt + 工具权限配置 | +| 触发方式 | AI 自主选择 | 用户 `/skill-name` 或 AI 通过 `SkillTool` 自动匹配 | +| 本质 | TypeScript 执行逻辑 | **Prompt + 权限配置**的声明式封装 | +| 注册位置 | `src/tools.ts` → `getTools()` | `src/commands.ts` → `getCommands()` | +| 执行器 | 各 Tool 的 `call()` 方法 | `SkillTool.call()` → 两条分支(inline / fork) | -## Skill 的三个来源 +Skill 的核心洞见:**复杂任务的关键不在代码逻辑,而在 Prompt 质量**。一个代码审查 Skill 不需要审查引擎,只需告诉 AI "审查什么、按什么顺序、输出什么格式"——Skill 把这种"经验"封装为可复用的 Markdown。 - - - 编译进 CLI 的技能包。如 `/commit`、`/review`、`/debug` - - - 项目 `.claude/skills/` 目录中的 Markdown 文件。团队共享 - - - 通过 MCP Server 提供的技能。动态发现 - - +## Skill 的五个来源与加载链路 -## 一个 Skill 包含什么 +### 1. 内置命令(Built-in Commands) -每个 Skill 本质上是一个"AI 行为的预设": +硬编码在 `src/commands.ts:258` 的 `COMMANDS` memoize 数组中,包含 70+ 条命令(`/commit`、`/review`、`/compact` 等)。这些是 TypeScript 模块而非 Markdown,但实现了相同的 `Command` 接口(`src/types/command.ts`)。 -| 组成部分 | 作用 | -|----------|------| -| **名称和描述** | 告诉 AI 和用户这个技能做什么 | -| **whenToUse** | 什么场景下应该使用这个技能(AI 据此自动推荐) | -| **Prompt 模板** | 注入给 AI 的详细指令——相当于"操作手册" | -| **allowedTools** | 这个技能允许使用哪些工具(能力边界) | -| **model** | 可选指定使用的模型 | +### 2. Bundled Skills(编译时打包) -## 设计精妙之处 +通过 `registerBundledSkill()`(`src/skills/bundledSkills.ts:53`)在模块初始化时注册。关键特性: -Skill 的核心洞见是:**很多复杂任务的关键不在于代码逻辑,而在于 Prompt 的质量**。 +- **延迟文件提取**:如果 Skill 声明了 `files`(参考文件),首次调用时才解压到临时目录(`getBundledSkillExtractDir()`),使用 `O_NOFOLLOW | O_EXCL` 防止符号链接攻击(`safeWriteFile`,第 186 行) +- **闭包级 memoize**:并发调用共享同一个 extraction promise,避免竞态写入 +- 来源标记为 `source: 'bundled'`,在 Prompt 预算中享有**不可截断**的特权 -一个好的代码审查,不是写了什么代码来审查,而是: -- 告诉 AI 审查的标准是什么 -- 告诉 AI 按什么顺序审查 -- 告诉 AI 输出什么格式的报告 -- 限制 AI 只能用读取类工具(不要边审查边改代码) +### 3. 磁盘 Skills(`.claude/skills/`) -Skill 把这些"经验"封装起来,任何人都能一键调用。 +由 `loadSkillsFromSkillsDir()`(`src/skills/loadSkillsDir.ts:407`)加载,这是最重要的加载路径: -## 技能发现 +``` +管理策略: $MANAGED_DIR/.claude/skills/ (policySettings) +用户全局: ~/.claude/skills/ (userSettings) +项目级: .claude/skills/ (projectSettings, 向上遍历至 home) +附加目录: --add-dir 指定的路径下 .claude/skills/ +``` -当可用技能很多时,AI 可以通过 **SkillTool** 搜索匹配的技能: +**加载协议**:只识别 `skill-name/SKILL.md` 目录格式,不再支持单文件 `.md`。加载流程: -- 用户说"帮我做代码审查" -- AI 搜索已注册的技能,发现 `code-review` 匹配 -- AI 调用该技能,按预设的流程执行 +1. `readdir` 扫描目录 → 仅保留 `isDirectory()` 或 `isSymbolicLink()` 的条目 +2. 在每个子目录中查找 `SKILL.md`,未找到则跳过 +3. `parseFrontmatter()` 解析 YAML 头部,提取 `whenToUse`、`allowedTools`、`context` 等字段 +4. `parseSkillFrontmatterFields()`(第 185 行)统一解析 17 个 frontmatter 字段 +5. `createSkillCommand()`(第 270 行)构造 `Command` 对象 + +**去重机制**:使用 `realpath()` 解析符号链接获得规范路径(`getFileIdentity`,第 118 行),避免通过符号链接或重叠父目录导致的重复加载。 + +### 4. MCP Skills(动态发现) + +通过 `registerMCPSkillBuilders()` 注册构建器,MCP Server 的 prompt 被 `mcpSkillBuilders.ts` 转换为 `Command` 对象。标记为 `loadedFrom: 'mcp'`。 + +**安全边界**:MCP Skills 的 Prompt 内容**禁止执行内联 shell 命令**(`loadSkillsDir.ts:374` 的 `loadedFrom !== 'mcp'` 守卫),因为远程内容不可信。 + +### 5. Legacy Commands(`/commands/` 目录) + +向后兼容的旧格式,由 `loadSkillsFromCommandsDir()`(第 566 行)加载。同时支持 `SKILL.md` 目录格式和单 `.md` 文件格式。 + +## Frontmatter 字段全景 + +一个 `SKILL.md` 的完整 frontmatter(`parseSkillFrontmatterFields`,第 185 行): + +```yaml +--- +name: code-review # 显示名称(覆盖目录名) +description: 系统性代码审查 # 描述(或从 Markdown 首段提取) +when_to_use: "用户说审查代码、找 bug" # AI 自动匹配依据 +allowed-tools: # 工具白名单 + - Read + - Grep + - Glob +argument-hint: "" # 参数提示 +arguments: [path] # 声明式参数名(用于 $ARGUMENTS 替换) +model: opus # 模型覆盖 +effort: high # 努力级别 +context: fork # 执行模式:inline(默认)| fork +agent: code-reviewer # 指定 Agent 定义文件 +user-invocable: true # 用户是否可 /调用 +disable-model-invocation: false # 禁止 AI 自主调用 +version: "1.0" # 版本号 +paths: # 条件激活的文件路径模式 + - "src/**/*.ts" +hooks: # Hook 配置 + PreToolUse: + - command: ["echo", "checking"] +shell: ["bash"] # Shell 执行环境 +--- +``` + +解析后有 17 个字段被提取,其中 `allowedTools`、`model`、`effort` 在执行时动态修改 `toolPermissionContext`。 + +## 两条执行路径:Inline vs Fork + +SkillTool(`src/tools/SkillTool/SkillTool.ts:332`)在 `call()` 中根据 `command.context` 分流: + +### Inline 模式(默认) + +Skill 的 Prompt 内容被注入为 **UserMessage**,在主对话流中继续执行: + +1. `processPromptSlashCommand()` 处理参数替换(`$ARGUMENTS`)和 shell 命令展开(`` !`...` ``) +2. `${CLAUDE_SKILL_DIR}` 被替换为 Skill 所在目录的绝对路径 +3. `${CLAUDE_SESSION_ID}` 被替换为当前会话 ID +4. 返回 `newMessages`(注入到对话流)+ `contextModifier`(修改权限上下文) + +`contextModifier`(第 776 行)做了三件事: +- **工具白名单注入**:将 `allowedTools` 合并到 `alwaysAllowRules.command` +- **模型切换**:`resolveSkillModelOverride()` 处理模型覆盖,保留 `[1m]` 后缀以避免 200K 窗口截断 +- **努力级别覆盖**:修改 `effortValue` + +### Fork 模式(`context: fork`) + +Skill 在**独立子 Agent** 中执行(`executeForkedSkill`,第 122 行): + +1. `prepareForkedCommandContext()` 构建隔离的 Agent 定义和 Prompt +2. `runAgent()` 启动子 Agent 循环,拥有独立的 token 预算 +3. 通过 `onProgress` 回调报告工具使用进度 +4. 结果通过 `extractResultText()` 提取,子 Agent 的全部消息在提取后被释放(`agentMessages.length = 0`) +5. 最终通过 `clearInvokedSkillsForAgent()` 清理状态 + +Fork 模式适用于需要强隔离的场景(如长时间运行的审查任务),避免污染主对话的上下文。 + +## 权限模型:Safe Properties 白名单 + +`checkPermissions()`(第 433 行)实现了一个四层权限检查: + +``` +1. Deny 规则匹配(支持精确匹配和 prefix:* 通配符) + ↓ 未命中 +2. 官方市场 Skill 自动放行(plugin + isOfficialMarketplaceName) + ↓ 未命中 +3. Allow 规则匹配 + ↓ 未命中 +4. Safe Properties 白名单检查(skillHasOnlySafeProperties,第 911 行) + ↓ 有非安全属性 +5. Ask 用户确认(附带精确匹配和前缀匹配两条建议规则) +``` + +**Safe Properties**(`SAFE_SKILL_PROPERTIES`,第 876 行)是一个包含 28 个属性名的白名单。任何不在白名单中的**有意义的属性值**(排除 `undefined`、`null`、空数组、空对象)都会触发权限请求。这是**正向安全**设计——未来新增的属性默认需要权限。 + +## Prompt 预算:1% 上下文窗口的截断策略 + +Skill 列表注入 System Prompt 时有严格的字符预算(`prompt.ts`): + +- **预算计算**:`contextWindowTokens × 4 chars/token × 1%`(约 8000 字符) +- **单条上限**:`MAX_LISTING_DESC_CHARS = 250` 字符(超出截断为 `…`) +- **Bundled Skills 不可截断**:它们始终保留完整描述,预算不足时只截断非 bundled 的 +- **降级策略**: + 1. 尝试完整描述 → 超预算? + 2. Bundled 保留完整,非 bundled 均分剩余预算 → 每条描述低于 20 字符? + 3. 非 bundled 仅保留名称 + +`formatCommandsWithinBudget()`(`prompt.ts:70`)实现了这个三级降级。 + +## 动态发现与条件激活 + +### 基于文件路径的动态发现 + +`discoverSkillDirsForPaths()`(`loadSkillsDir.ts:861`)在文件操作时触发: + +1. 从被操作的文件路径开始,**向上遍历**至 CWD(不包含 CWD 本身) +2. 在每层查找 `.claude/skills/` 目录 +3. 使用 `realpath` 去重,`git check-ignore` 过滤 gitignored 目录 +4. 按路径深度排序(**深层优先**),更接近文件的 Skill 优先级更高 + +### 条件激活(paths frontmatter) + +带有 `paths` 模式的 Skill 在加载时不会立即可用,而是存入 `conditionalSkills` Map。当被操作的文件路径匹配某个 Skill 的 paths 模式时(使用 `ignore` 库做 gitignore 风格匹配),该 Skill 才被**激活**——从 `conditionalSkills` 移入 `dynamicSkills`。 + +这意味着一个只在 `*.test.ts` 上激活的测试 Skill,平时完全不可见,只有当 AI 读取或编辑测试文件时才会出现。 + +## 使用频率排名 + +`recordSkillUsage()`(`skillUsageTracking.ts`)使用指数衰减算法计算 Skill 排名分数: + +``` +score = usageCount × max(0.5^(daysSinceUse / 7), 0.1) +``` + +- **7 天半衰期**:一周前的使用权重减半 +- **最低 0.1 保底**:避免老但高频使用的 Skill 完全沉底 +- **60 秒去抖**:同一 Skill 在 1 分钟内的多次调用只计一次,减少文件 I/O + +排名数据持久化在全局配置的 `skillUsage` 字段中。 + +## 远程技能加载(Experimental) + +通过 `EXPERIMENTAL_SKILL_SEARCH` feature flag 控制,支持从远程(AKI/GCS/S3)加载 `_canonical_` 格式的 Skill: + +1. `validateInput()` 中 `stripCanonicalPrefix()` 拦截 canonical 名称 +2. `executeRemoteSkill()`(第 970 行)从远程 URL 加载 SKILL.md +3. 支持 `gs://`、`https://`、`s3://` 等 URL 协议 +4. 内容经过 frontmatter 剥离、`${CLAUDE_SKILL_DIR}` 替换后直接注入 +5. 通过 `addInvokedSkill()` 注册到 compaction 保留状态,确保压缩后仍可恢复 +6. 远程 Skill 不经过 `processPromptSlashCommand`——无 `!command` 替换、无 `$ARGUMENTS` 展开 + +## 完整生命周期总结 + +``` +磁盘 SKILL.md + ↓ parseFrontmatter() + ↓ parseSkillFrontmatterFields() → 17 个字段 + ↓ createSkillCommand() → Command 对象 + ↓ 去重(realpath + seenFileIds) + ↓ 条件 Skill → conditionalSkills Map(等待路径匹配激活) + ↓ getSkillDirCommands() memoize 缓存 + ↓ getAllCommands() 合并 local + MCP + ↓ formatCommandsWithinBudget() → 截断后的 Skill 列表注入 System Prompt + ↓ AI 选择匹配的 Skill + ↓ SkillTool.validateInput() → 名称校验 + 存在性检查 + ↓ SkillTool.checkPermissions() → 四层权限检查 + ↓ SkillTool.call() → inline 或 fork 执行 + ↓ contextModifier() → 注入 allowedTools + model + effort + ↓ recordSkillUsage() → 更新使用频率排名 +``` diff --git a/docs/introduction/architecture-overview.mdx b/docs/introduction/architecture-overview.mdx index 7cfbbbb..dfbf6a3 100644 --- a/docs/introduction/architecture-overview.mdx +++ b/docs/introduction/architecture-overview.mdx @@ -1,7 +1,7 @@ --- title: "架构全景 - Claude Code 五层架构详解" -description: "从交互层到基础设施层,详解 Claude Code 的五层架构设计。涵盖 React/Ink UI、QueryEngine 编排、Agentic Loop 核心循环、Tool 系统和 API 层。" -keywords: ["Claude Code 架构", "五层架构", "QueryEngine", "Agentic Loop", "系统设计"] +description: "从交互层到基础设施层,详解 Claude Code 的五层架构设计。基于 src/main.tsx、src/QueryEngine.ts、src/query.ts、src/tools.ts、src/services/api/claude.ts 的源码级数据流分析。" +keywords: ["Claude Code 架构", "五层架构", "QueryEngine", "Agentic Loop", "数据流"] --- {/* 本章目标:一张图讲清楚整体架构,为后续章节建立坐标系 */} @@ -14,53 +14,99 @@ Claude Code 从上到下分为五个层次,每一层职责清晰、边界分 Claude Code 五层架构图 -| 层次 | 职责 | 关键词 | -|------|------|--------| -| **交互层** | 终端 UI、用户输入、消息展示 | React/Ink、REPL | -| **编排层** | 管理多轮对话、会话生命周期、成本追踪 | QueryEngine、会话持久化 | -| **核心循环层** | 单轮对话:发请求 → 拿响应 → 执行工具 → 再发请求 | Agentic Loop | -| **工具层** | AI 的"双手"——读写文件、执行命令、搜索代码 | Tool System、MCP | -| **通信层** | 与 Claude API 的流式通信、多云 Provider 适配 | Streaming、Bedrock/Vertex | +| 层次 | 职责 | 入口源码 | 关键词 | +|------|------|---------|--------| +| **交互层** | 终端 UI、用户输入、消息展示 | `src/screens/REPL.tsx` | React/Ink、PromptInput | +| **编排层** | 多轮对话、会话持久化、成本追踪 | `src/QueryEngine.ts` | QueryEngine、transcript | +| **核心循环层** | 单轮:发请求 → 拿响应 → 执行工具 → 循环 | `src/query.ts` | Agentic Loop、State | +| **工具层** | AI 的"双手"——读写文件、执行命令 | `src/tools.ts` → `src/Tool.ts` | Tool 接口、MCP | +| **通信层** | 与 Claude API 的流式通信 | `src/services/api/claude.ts` | Streaming、Provider | -## 一条主数据流 +## 一条主数据流的源码追踪 Claude Code 核心数据流 -整个系统的运转可以浓缩为一条核心数据流: +整个系统的运转可以浓缩为一条核心数据流,以下是每一步对应的源码路径: - - - 用户在终端键入自然语言需求 - - - 系统自动拼接项目信息、git 状态、配置文件、记忆,形成完整的 System Prompt - - - 将 System Prompt + 对话历史发送给 Claude API,流式接收响应 - - - 若 AI 返回工具调用请求 → 权限检查 → 执行工具 → 结果回传 → AI 继续思考 → 循环 - - - AI 不再调用工具,输出最终回答,等待用户下一条输入 - - +### 1. 用户输入 → REPL + +`src/screens/REPL.tsx` 是基于 React/Ink 的终端 UI 组件。用户输入经 `processUserInput()`(`src/utils/processUserInput/processUserInput.ts`)处理,支持斜杠命令、文件附件、图片等。 + +### 2. QueryEngine 编排 + +`src/QueryEngine.ts` 是 REPL 与 `query()` 之间的中间层,管理: +- **会话状态**:消息数组、工具权限上下文(`ToolPermissionContext`)、文件历史快照 +- **成本追踪**:`accumulateUsage()` / `getTotalCost()` 累计 token 用量 +- **Transcript 持久化**:`recordTranscript()` 将对话序列化到磁盘,支持 `--resume` +- **文件历史**:`fileHistoryMakeSnapshot()` 在修改前创建快照,支持 undo + +关键方法:`queryEngine.query()` 构造 `QueryParams`,调用 `query()` 异步生成器。 + +### 3. Agentic Loop(`src/query.ts`) + +`query()` 是一个 `AsyncGenerator`,`while(true)` 循环的每次迭代包含: + +``` +① 上下文预处理管道: + applyToolResultBudget → snipCompact → microcompact → contextCollapse → autocompact + +② 流式 API 调用: + deps.callModel() → AsyncGenerator + 收集 assistantMessages[]、toolUseBlocks[] + +③ 工具执行: + StreamingToolExecutor(并行) 或 runTools(串行) + → toolResults[] + +④ 终止/继续判定: + needsFollowUp ? continue : return { reason } +``` + +完整的状态机通过 `State` 类型(`src/query.ts:204`)在迭代间传递,包含 10 个字段(messages、autoCompactTracking、maxOutputTokensRecoveryCount 等)。 + +### 4. 工具层(`src/tools.ts` → `src/Tool.ts`) + +`getAllBaseTools()`(`src/tools.ts:191`)组装 50+ 工具列表,经过 `filterToolsByDenyRules()` 权限过滤后传给 API。 + +每个工具实现 `Tool` 接口(`src/Tool.ts:362`),核心方法链: +``` +validateInput() → canUseTool()(UI 层)→ checkPermissions() → call() → ToolResult +``` + +### 5. 通信层(`src/services/api/claude.ts`) + +API 客户端支持 4 种 Provider: +- **Anthropic Direct**:默认 +- **AWS Bedrock**:`ANTHROPIC_BEDROCK_BASE_URL` +- **Google Vertex**:`ANTHROPIC_VERTEX_PROJECT_ID` +- **Azure**:通过自定义 base URL + +`deps.callModel()` 发起流式请求,返回 `BetaRawMessageStreamEvent` 事件流。支持 Prompt Cache(`cache_control`)、thinking blocks、multi-turn tool use。 ## 四个核心设计原则 - 所有 API 通信都是流式的——用户看到 AI "逐字打出"回答,而不是等待完整响应。工具执行结果也实时反馈。这不仅提升体验,更是处理长时间任务的工程必需。 + 所有 API 通信都是流式的——`deps.callModel()` 返回 AsyncGenerator,用户看到 AI "逐字打出"回答。StreamingToolExecutor 在流式过程中就开始并行执行工具,不等流结束。模型降级(Fallback)时,已收集的 assistantMessages 被标记为 tombstone 并清空,重试整个流式请求。 - AI 的每一项"动手能力"都被抽象为一个 Tool。想让 AI 能做新事情?注册一个新 Tool 就好。统一的接口让能力可插拔、可组合。 + 每个工具是 `Tool` 结构化类型,通过 `buildTool()` 工厂创建。`getTools()` 在每次 API 调用时组装(非全局缓存),因为 `isEnabled()` 可能随运行时状态变化。MCP 工具通过 `mcpInfo` 字段标记来源,支持 server 级别的 blanket deny。 - AI 能操作真实环境是强大的,也是危险的。每个工具调用都经过权限系统的裁决——该放行的自动放行,该拦截的坚决拦截。 + 每次工具调用经过 `validateInput() → checkPermissions()` 双重检查。权限规则从 5 个来源汇聚(session → project → user → managed → default),支持工具名、命令模式、路径前缀等匹配方式。Plan Mode 通过 `prepareContextForPlanMode()` 切换为只读模式,退出时自动恢复。 - AI 没有真正的记忆,但通过精心的 System Prompt 组装、对话压缩、持久化记忆文件,系统营造出"AI 理解你的项目"的效果。这是上下文工程的核心。 + System Prompt 由 `fetchSystemPromptParts()` 动态组装,包含 CLAUDE.md、git 状态、日期、MCP 服务器列表。Auto-compact 在每轮迭代前评估 token 阈值,超出时触发压缩。压缩后的摘要通过 `buildPostCompactMessages()` 替换原始消息,`taskBudgetRemaining` 跨压缩边界累计。 + +## 入口与引导 + +| 入口 | 文件 | 说明 | +|------|------|------| +| CLI 启动 | `src/entrypoints/cli.tsx` | 注入 `feature()` polyfill(始终返回 false)、MACRO 全局变量 | +| 命令定义 | `src/main.tsx` | Commander.js 解析参数,初始化 auth/analytics/policy | +| 一次性初始化 | `src/entrypoints/init.ts` | 遥测配置、信任对话框 | +| 管道模式 | `src/main.tsx` `-p` flag | `echo "say hello" \| bun run dev -p` | diff --git a/docs/safety/permission-model.mdx b/docs/safety/permission-model.mdx index ada9ed0..87f7f79 100644 --- a/docs/safety/permission-model.mdx +++ b/docs/safety/permission-model.mdx @@ -1,69 +1,170 @@ --- title: "权限模型 - Allow/Ask/Deny 三级权限体系" -description: "详解 Claude Code 的三级权限模型:Allow 自动放行、Ask 确认对话框、Deny 直接拒绝。权限规则的层级来源与优先级机制。" -keywords: ["权限模型", "Allow Ask Deny", "权限控制", "安全权限", "工具权限"] +description: "详解 Claude Code 的三级权限模型实现:基于 src/utils/permissions/permissions.ts 的规则匹配引擎、五层规则来源优先级、工具名/命令/路径三维度匹配、Denial Tracking 死循环防护、权限模式切换机制。" +keywords: ["权限模型", "Allow Ask Deny", "PermissionRule", "checkPermissions", "Denial Tracking", "权限规则"] --- -{/* 本章目标:详解权限系统的设计 */} +{/* 本章目标:基于源码揭示权限系统的完整实现 */} ## 三种权限行为 每一次工具调用,系统都会做出三种裁决之一: -| 行为 | 含义 | 典型场景 | -|------|------|---------| -| **Allow** | 自动放行,用户无感知 | Read 工具读取项目内的文件 | -| **Ask** | 弹出确认对话框,等待用户决定 | Bash 执行一条未知命令 | -| **Deny** | 直接拒绝,AI 收到"权限被拒"的反馈 | 尝试执行被禁止的命令 | +| 行为 | 含义 | 返回类型 | 典型场景 | +|------|------|---------|---------| +| **Allow** | 自动放行,用户无感知 | `{ behavior: 'allow', updatedInput, decisionReason }` | Read 读取项目内文件 | +| **Ask** | 弹出确认对话框 | `{ behavior: 'ask', message, suggestions, metadata }` | Bash 执行未知命令 | +| **Deny** | 直接拒绝 | `{ behavior: 'deny', message, decisionReason }` | 尝试执行被禁止的命令 | -## 权限规则的层级 +这些行为由 `PermissionResult` 类型定义(`src/utils/permissions/PermissionResult.ts`)。 -规则可以来自多个来源,优先级从高到低: +## 权限规则的五层来源 - - 权限层级图 - +规则从 5 个来源汇聚(`PERMISSION_RULE_SOURCES`,`permissions.ts:109`),优先级从高到低: - - - 用户在当前对话中手动授权的("对这个工具始终允许") - - - 项目目录中的 `.claude/settings.json`,团队共享 - - - `~/.claude/settings.json`,跨项目生效 - - - 企业管理员下发的策略,用户不可覆盖 - - - 系统内置的基线规则 - - +``` +1. session — 用户在当前对话中手动授权("Always allow") +2. cliArg — 命令行 --allow/--deny 参数 +3. command — Skill 工具的 allowedTools 白名单 +4. projectSettings — .claude/settings.json(团队共享) +5. userSettings — ~/.claude/settings.json(跨项目) +6. policySettings — 企业管理员下发的策略(用户不可覆盖) +``` -## 规则的匹配方式 +每个来源维护三个数组:`alwaysAllowRules[source]`、`alwaysAskRules[source]`、`alwaysDenyRules[source]`。 -权限规则不是简单的"允许/禁止某个工具",它支持丰富的匹配条件: +规则数据结构为 `PermissionRule`: +```typescript +{ + source: PermissionRuleSource // 来自哪个层级 + ruleBehavior: 'allow' | 'ask' | 'deny' + ruleValue: { + toolName: string // 如 "Bash"、"mcp__server1" + ruleContent?: string // 如 "git *"、"src/**" + } +} +``` -- **工具名匹配**:`"tool": "Bash"` → 针对所有 Bash 命令 -- **命令模式匹配**:`"command": "git *"` → 只针对 git 开头的命令 -- **路径匹配**:`"path": "src/**"` → 只允许操作 src 目录下的文件 -- **组合条件**:以上条件可以组合使用 +## 规则匹配引擎 + +### 三维度匹配 + +`permissions.ts` 实现了三种匹配维度: + +**1. 工具名匹配**(`toolMatchesRule()`,第 238 行) + +匹配整个工具,仅当规则没有 `ruleContent`: +```typescript +// 精确匹配 +rule "Bash" → 匹配 BashTool +rule "mcp__server1" → 匹配该 MCP Server 的所有工具(server 级别) +rule "mcp__server1__*" → 通配符匹配(同上) +``` + +MCP 工具使用 `getToolNameForPermissionCheck()` 获取匹配名称,支持有前缀(`mcp__server__tool`)和无前缀模式。 + +**2. 命令模式匹配**(BashTool 的 `checkPermissions()`) + +BashTool 通过 `preparePermissionMatcher()`(`Tool.ts:514`)解析命令模式: +```json +{"tool": "Bash", "ruleContent": "git *"} → 匹配 "git commit -m 'fix'" +``` + +命令通过 AST 解析(`readOnlyValidation.ts` 使用 tree-sitter bash),提取第一个子命令进行匹配。 + +**3. 路径匹配**(文件工具的 `checkPermissions()`) + +Read/Edit/Write 工具通过 `getPath()` 提取文件路径,与 `ruleContent` 中的 glob 模式匹配: +```json +{"tool": "Edit", "ruleContent": "src/**"} → 匹配 "src/utils/foo.ts" +``` + +### 权限检查的完整流程 + +每次工具调用的权限检查(`canUseTool()` → `checkPermissions()`)经过以下步骤: + +``` +1a. Blanket deny 检查 + getDenyRuleForTool() → 工具名完全匹配 deny 规则? + ↓ 命中 → deny(工具在 getTools() 阶段就被过滤掉) + +1b. Blanket allow 检查 + toolAlwaysAllowedRule() → 工具名完全匹配 allow 规则? + ↓ 命中 → allow + +2. 工具自身 checkPermissions() + 各工具有自定义逻辑: + - BashTool: readOnlyValidation → sandbox 判定 → AST 解析 → 模式匹配 + - FileEditTool: 路径白名单检查 + - SkillTool: safe properties 白名单 + 精确/前缀匹配 + ↓ 返回 PermissionResult + +3. Hook 系统 + executePermissionRequestHooks() → PreToolUse hook 可以 override + ↓ hook 返回 deny → deny + ↓ hook 返回 ask → 升级为 ask + +4. Ask 规则检查 + getAskRules() → 命中 → ask + +5. 默认行为 + 根据当前 permissionMode 决定默认行为 + - 'default': 大部分工具 ask + - 'plan': 写操作 deny,读操作 allow + - 'bypass': 全部 allow +``` ## 权限模式 -系统提供几种预设的权限模式,适应不同信任级别: +| 模式 | `PermissionMode` 值 | 适用场景 | 行为 | +|------|---------------------|---------|------| +| **Default** | `'default'` | 日常使用 | 敏感操作逐一确认 | +| **Plan Mode** | `'plan'` | 探索阶段 | 只能读不能写(`isReadOnly()` 检查) | +| **Auto** | `'auto'` | 信任 AI | 通过 transcript classifier 自动决策 | +| **Bypass** | `'bypassPermissions'` | 完全信任 | 所有操作自动放行(需显式 `--dangerously-skip-permissions`) | -| 模式 | 适用场景 | -|------|---------| -| **Default** | 日常使用,敏感操作逐一确认 | -| **Plan Mode** | 探索阶段,AI 只能读不能写 | -| **Bypass** | 完全信任 AI,所有操作自动放行(需显式开启) | +Plan Mode 切换由 `EnterPlanModeTool.call()` 触发: +```typescript +// EnterPlanModeTool.ts:88 +context.setAppState(prev => ({ + ...prev, + toolPermissionContext: applyPermissionUpdate( + prepareContextForPlanMode(prev.toolPermissionContext), + { type: 'setMode', mode: 'plan', destination: 'session' }, + ), +})) +``` -## Denial Tracking +退出时由 `ExitPlanModeV2Tool` 恢复为之前的模式。 -系统不仅记录"允许了什么",还追踪"拒绝了什么": +## Denial Tracking:死循环防护 -- 如果 AI 被连续拒绝多次同一类操作,系统会调整策略 -- 这防止 AI 陷入"反复请求同一个被拒操作"的死循环 +`src/utils/permissions/denialTracking.ts` 实现了拒绝追踪机制: + +```typescript +const DENIAL_LIMITS = { + maxDenialsPerTool: 3, // 同一工具连续拒绝上限 + cooldownPeriodMs: 30_000, // 冷却期 30 秒 +} +``` + +当 AI 被连续拒绝同一类操作达到上限时: +1. `recordDenial()` 记录拒绝,增加计数 +2. `shouldFallbackToPrompting()` 检测到连续拒绝,返回 true +3. 系统向 AI 注入消息:"Your previous tool call was rejected..." +4. AI 被迫改变策略,避免"反复请求同一个被拒操作"的死循环 + +操作成功时调用 `recordSuccess()` 重置计数。 + +## 规则的运行时更新 + +权限规则可以在运行时动态更新(`applyPermissionUpdate()`,`PermissionUpdate.ts`): + +```typescript +type PermissionUpdate = + | { type: 'addRule', behavior, rule, destination } + | { type: 'removeRule', behavior, rule, destination } + | { type: 'setMode', mode, destination } +``` + +当用户在 Ask 对话框中选择 "Always allow",系统调用 `persistPermissionUpdates()` 将规则写入对应层级的 settings 文件(project/user/managed),同时更新内存中的 `toolPermissionContext`。 diff --git a/docs/safety/plan-mode.mdx b/docs/safety/plan-mode.mdx index 8e37506..bc8739f 100644 --- a/docs/safety/plan-mode.mdx +++ b/docs/safety/plan-mode.mdx @@ -1,10 +1,10 @@ --- title: "计划模式 - Plan Mode 先看后做的安全机制" -description: "解析 Claude Code Plan Mode 设计:给 AI 一个只读探索阶段,先分析再执行,避免不可逆操作,让用户在 AI 行动前审查方案。" -keywords: ["Plan Mode", "计划模式", "只读模式", "安全执行", "预览方案"] +description: "基于源码解析 Claude Code Plan Mode 的完整实现:EnterPlanModeTool/ExitPlanModeV2Tool 的工具设计、权限上下文切换机制、Prompt-based 权限请求、计划文件持久化、Teammate 审批流程。" +keywords: ["Plan Mode", "计划模式", "EnterPlanMode", "ExitPlanMode", "prepareContextForPlanMode", "allowedPrompts"] --- -{/* 本章目标:解释 Plan Mode 的设计理念 */} +{/* 本章目标:基于源码揭示 Plan Mode 的完整实现 */} ## 问题场景 @@ -12,50 +12,140 @@ keywords: ["Plan Mode", "计划模式", "只读模式", "安全执行", "预览 ## Plan Mode 的解决方案 -计划模式给对话加了一个"只读阶段": +计划模式给对话加了一个"只读阶段",通过两个工具实现闭环: - - AI 自主判断任务需要规划(或用户主动触发),进入计划模式 + + AI 自主判断(或用户触发)任务需要规划,调用 `EnterPlanModeTool`(`src/tools/EnterPlanModeTool/EnterPlanModeTool.ts:36`)。该工具需要**用户审批**(`checkPermissions` 返回 `ask`)。 - - 在这个阶段,AI 只能使用读取和搜索类工具——不能编辑文件、不能执行命令 + + 权限模式切换为 `'plan'`,AI 只能使用 `isReadOnly()` 为 true 的工具(Read、Grep、Glob、Agent 等)。写操作被自动拒绝。 - - AI 把理解和方案写入计划文件,提交给用户审阅 + + AI 完成探索后,调用 `ExitPlanModeV2Tool`(`src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts:147`),将计划文件提交给用户审阅。这是第二个**需要用户审批**的节点。 - - 用户阅读计划,提出修改意见或直接批准 - - - 计划被批准后,AI 恢复全部工具权限,按计划执行 + + 用户批准后,权限模式恢复为进入前的状态,AI 按计划执行。 ## 权限的自动收窄与恢复 -计划模式的精妙之处在于**自动改变权限上下文**: +### 进入:`prepareContextForPlanMode()` -- 进入时:系统自动保存当前权限模式,切换为"只读" -- 退出时:系统自动恢复之前的权限模式 +`EnterPlanModeTool.call()`(第 77 行)的核心逻辑: -AI 和用户都不需要手动调整权限设置。 +```typescript +// 1. 记录转换状态(保存之前的模式) +handlePlanModeTransition(currentMode, 'plan') + +// 2. 切换权限上下文为 plan 模式 +context.setAppState(prev => ({ + ...prev, + toolPermissionContext: applyPermissionUpdate( + prepareContextForPlanMode(prev.toolPermissionContext), + { type: 'setMode', mode: 'plan', destination: 'session' }, + ), +})) +``` + +`prepareContextForPlanMode()`(`src/utils/permissions/permissionSetup.ts`)做了什么: +- 创建新的 `ToolPermissionContext`,`mode` 设为 `'plan'` +- 在 plan 模式下,工具的 `isReadOnly()` 检查成为唯一准入条件 +- 如果用户的默认模式是 `'auto'`,还会激活 classifier 的副作用 + +### 退出:权限恢复 + Prompt-based 权限 + +`ExitPlanModeV2Tool` 的退出逻辑做了两件关键的事: + +**1. 恢复权限模式** + +通过 `handlePlanModeTransition()` 和 `applyPermissionUpdate()` 恢复到进入前的模式。 + +**2. 注入 Prompt-based 权限** + +这是 Plan Mode 最精妙的设计——AI 可以在计划中声明它需要执行的命令类别: + +```typescript +// ExitPlanModeV2Tool 的 inputSchema +allowedPrompts: z.array(z.object({ + tool: z.enum(['Bash']), + prompt: z.string().describe('Semantic description, e.g. "run tests"'), +})).optional() +``` + +当 AI 提交计划时,如果声明了 `allowedPrompts: [{ tool: 'Bash', prompt: 'run tests' }]`,用户批准后,"run tests" 这类 Bash 命令会被自动放行——不再需要逐个确认。 + +## 计划文件的持久化 + +计划内容被写入磁盘文件(由 `getPlanFilePath()` 确定路径),这与简单的"AI 说一段话然后开始执行"有本质区别: + +1. `ExitPlanModeV2Tool` 的 `normalizeToolInput` 从磁盘读取计划内容,注入到 `input.plan` 和 `input.planFilePath` +2. 计划文件是用户**可编辑**的——用户可以在审批前修改 AI 的方案 +3. `planWasEdited` 字段标记用户是否修改了计划,影响后续的 tool_result 回显 +4. `persistFileSnapshotIfRemote()` 在远程场景下保存文件快照 + +## Teammate 场景下的计划审批 + +在 Agent Swarms(`isAgentSwarmsEnabled()`)模式下,计划审批有额外的协作流程: + +```typescript +// 如果是 Teammate 角色 +if (isTeammate()) { + // 发送计划到 Team Leader 的 mailbox 等待审批 + const requestId = generateRequestId() + writeToMailbox(getTeamName(), { + type: 'plan_approval_request', + plan, requestId, ... + }) + // 返回 awaitingLeaderApproval: true + // Team Leader 审批后通过 mailbox 通知 Teammate +} +``` + +这意味着在蜂群模式下,计划可能不是由直接用户审批,而是由 Team Leader 审批。 ## 什么时候该用计划模式 -| 场景 | 是否需要计划 | -|------|-------------| -| 修复一个明确的 typo | 不需要,直接修 | -| 添加一个简单的函数 | 不需要 | -| 重构一个大模块 | 需要——先搞清楚影响范围 | -| 涉及多个文件的 feature | 需要——先统一方案 | -| 不确定该怎么做 | 需要——让 AI 先调研 | +`EnterPlanModeTool` 的 Prompt(`src/tools/EnterPlanModeTool/prompt.ts`)定义了两套触发标准——外部版本更积极(鼓励规划),内部版本更克制(仅在真正模糊时使用): + +| 场景 | 外部版本 | 内部版本 | +|------|---------|---------| +| 修复 typo | 跳过 | 跳过 | +| 添加删除按钮 | **进入**(涉及多个文件) | **跳过**(路径明确) | +| 重构认证系统 | **进入** | **进入**(高影响重构) | +| "开始做 X" | — | **跳过**(直接开始) | +| 架构决策(Redis vs 内存缓存) | **进入** | **进入**(真正模糊) | ## 计划模式 + 任务系统 计划模式通常与任务系统配合使用: -1. 在计划模式中,AI 把实施步骤创建为任务列表 +1. 在计划模式中,AI 把实施步骤创建为任务列表(`TodoWrite`) 2. 用户审批计划(包含任务列表) 3. 退出计划模式后,AI 按任务列表逐项执行 4. 用户可以通过任务列表追踪进度 + +## 完整生命周期 + +``` +用户: "重构这个模块" + ↓ +AI 判断需要规划 → 调用 EnterPlanModeTool + ↓ 用户审批(Ask 对话框) +handlePlanModeTransition(default, 'plan') // 保存 default +prepareContextForPlanMode() // 创建只读上下文 + ↓ +AI 使用 Read/Grep/Glob/Agent 探索代码库 + ↓ (可能 10+ 轮只读工具调用) +AI 形成方案 → 调用 ExitPlanModeV2Tool({ + allowedPrompts: [ + { tool: 'Bash', prompt: 'run tests' }, + { tool: 'Bash', prompt: 'install dependencies' } + ] +}) + ↓ 用户审批计划(可编辑计划文件) +恢复权限模式 → 注入 prompt-based 权限 + ↓ +AI 使用全部工具执行计划,"run tests" 等命令自动放行 +``` diff --git a/docs/tools/what-are-tools.mdx b/docs/tools/what-are-tools.mdx index 14f4e96..c6e4d00 100644 --- a/docs/tools/what-are-tools.mdx +++ b/docs/tools/what-are-tools.mdx @@ -1,10 +1,10 @@ --- title: "工具系统设计 - AI 如何从说到做" -description: "深入理解 Claude Code 的 Tool 抽象设计。每个工具是标准化的能力单元,包含名称、描述、输入 Schema 和执行函数。了解工具注册与调用机制。" -keywords: ["工具系统", "Tool 抽象", "AI 工具", "function calling", "工具调用"] +description: "深入理解 Claude Code 的 Tool 抽象设计:从类型定义、注册机制、调用链路到渲染系统,揭示 50+ 内置工具如何通过统一的 Tool 接口协同工作。" +keywords: ["工具系统", "Tool 抽象", "AI 工具", "function calling", "buildTool", "getTools"] --- -{/* 本章目标:让读者理解 Tool 抽象的设计思想 */} +{/* 本章目标:基于 src/Tool.ts 和 src/tools.ts 揭示工具系统的完整架构 */} ## AI 为什么需要工具 @@ -14,33 +14,161 @@ keywords: ["工具系统", "Tool 抽象", "AI 工具", "function calling", "工 工具是 AI 的双手。AI 说"我想读这个文件",工具系统替它真正去读;AI 说"我想执行这条命令",工具系统替它真正去跑。 -## 一个工具长什么样 +## Tool 类型:35 个字段的统一接口 -每个工具都是一个标准化的"能力单元",包含四个要素: +所有工具都实现 `src/Tool.ts:362` 的 `Tool` 类型。这不是一个 class,而是一个包含 35+ 字段的**结构化类型**(structural typing),任何满足该接口的对象就是一个工具: -| 要素 | 说明 | 示例(FileRead 工具) | -|------|------|----------------------| -| **名称** | 工具的唯一标识 | `Read` | -| **描述** | 告诉 AI 这个工具能做什么(AI 据此决定是否使用) | "读取本地文件系统中的文件" | -| **参数定义** | 工具接受什么输入 | `file_path`(必填)、`offset`、`limit` | -| **执行逻辑** | 工具被调用时实际做什么 | 读取文件内容并返回 | +### 核心四要素 -## AI 如何选择工具 +| 字段 | 类型 | 说明 | +|------|------|------| +| `name` | `string` | 唯一标识(如 `Read`、`Bash`、`Agent`) | +| `description()` | `(input) => Promise` | **动态描述**——根据输入参数返回不同描述(如 `Execute skill: ${skill}`) | +| `inputSchema` | `z.ZodType` | Zod schema,定义参数类型和校验规则 | +| `call()` | `(args, context, canUseTool, parentMessage, onProgress?) => Promise>` | 执行函数 | -AI 不是从下拉菜单里选工具——它是根据**工具描述**和**当前任务**自主决策的: +### 注册与发现 -1. 系统把所有可用工具的名称、描述、参数告诉 AI -2. AI 在思考过程中决定"我需要用某个工具" -3. AI 生成一个结构化的工具调用请求(工具名 + 参数) -4. 系统执行工具,将结果返回给 AI +| 字段 | 说明 | +|------|------| +| `aliases` | 别名数组(向后兼容重命名) | +| `searchHint` | 3-10 词的短语,供 ToolSearch 关键词匹配(如 `"jupyter"` for NotebookEdit) | +| `shouldDefer` | 是否延迟加载(配合 ToolSearch 按需加载) | +| `alwaysLoad` | 永不延迟加载(如 SkillTool 必须在 turn 1 可见) | +| `isEnabled()` | 运行时开关(如 PowerShellTool 检查平台) | - -工具描述的质量直接影响 AI 的决策准确性。一段好的描述不仅说明"能做什么",还说明"什么时候该用、什么时候不该用"。 - +### 安全与权限 -## 50+ 内置工具 +| 字段 | 说明 | +|------|------| +| `validateInput()` | 输入校验(在权限检查之前),返回 `ValidationResult` | +| `checkPermissions()` | 权限检查(在校验之后),返回 `PermissionResult` | +| `isReadOnly()` | 是否只读操作(影响权限模式) | +| `isDestructive()` | 是否不可逆操作(删除、覆盖、发送) | +| `isConcurrencySafe()` | 相同输入是否可以并行执行 | +| `preparePermissionMatcher()` | 为 Hook 的 `if` 条件准备模式匹配器 | +| `interruptBehavior()` | 用户中断时的行为:`'cancel'` 或 `'block'` | -Claude Code 内置了覆盖软件开发全流程的工具集: +### 输出与渲染 + +| 字段 | 说明 | +|------|------| +| `maxResultSizeChars` | 结果字符上限(超出则持久化到磁盘,如 `100_000`) | +| `mapToolResultToToolResultBlockParam()` | 将 Output 映射为 API 格式的 `ToolResultBlockParam` | +| `renderToolResultMessage()` | React 组件渲染工具结果到终端 | +| `renderToolUseMessage()` | React 组件渲染工具调用过程 | +| `backfillObservableInput()` | 在不破坏 prompt cache 的前提下回填可观察字段 | + +### 上下文与 Prompt + +| 字段 | 说明 | +|------|------| +| `prompt()` | 返回该工具的详细使用说明,注入到 System Prompt | +| `outputSchema` | 输出 Zod schema(用于类型安全的结果处理) | +| `getPath()` | 提取操作的文件路径(用于权限匹配和 UI 显示) | + +## 工具注册:`getTools()` 的分层组装 + +`src/tools.ts` 的 `getAllBaseTools()`(第 191 行)是工具注册的核心: + +``` +固定工具(始终可用): + AgentTool, BashTool, FileReadTool, FileEditTool, FileWriteTool, + NotebookEditTool, WebFetchTool, WebSearchTool, TodoWriteTool, + AskUserQuestionTool, SkillTool, EnterPlanModeTool, ExitPlanModeV2Tool, + TaskOutputTool, BriefTool, ListMcpResourcesTool, ReadMcpResourceTool + +条件工具(运行时检查): + ← hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool] + ← isTodoV2Enabled() ? V2 Tasks : [] + ← isWorktreeModeEnabled() ? Worktree : [] + ← isAgentSwarmsEnabled() ? Teams : [] + ← isToolSearchEnabled() ? ToolSearch: [] + ← isPowerShellToolEnabled() ? PowerShell: [] + +Feature-flag 工具: + ← feature('COORDINATOR_MODE') ? [coordinatorMode tools] + ← feature('KAIROS') ? [SleepTool, SendUserFileTool, ...] + ← feature('WEB_BROWSER_TOOL') ? [WebBrowserTool] + ← feature('HISTORY_SNIP') ? [SnipTool] + +Ant-only 工具: + ← process.env.USER_TYPE === 'ant' ? [REPLTool, ConfigTool, TungstenTool] +``` + +`getTools()`(第 269 行)在 `getAllBaseTools()` 基础上应用权限过滤: + +```typescript +export const getTools = (permissionContext): Tools => { + const base = getAllBaseTools() + // 过滤 blanket deny 规则命中的工具 + return filterToolsByDenyRules(base, permissionContext) +} +``` + +**关键设计**:工具列表在每次 API 调用时组装(而非全局缓存),因为 `isEnabled()` 的结果可能随运行时状态变化。 + +## `buildTool()` 工厂函数 + +大多数工具通过 `buildTool()` 创建(`src/Tool.ts:721`),它是一个类型安全的构造器: + +```typescript +export const BashTool: Tool<...> = buildTool({ + name: 'Bash', + inputSchema: lazySchema(() => z.object({command: z.string(), ...})), + // ...其他字段 +}) satisfies ToolDef +``` + +`satisfies ToolDef` 确保编译时类型检查,`lazySchema` 延迟 Zod schema 解析(避免循环依赖)。 + +## 工具调用的完整链路 + +从 AI 发出 `tool_use` 到结果回传,经过以下步骤: + +``` +1. API 返回 tool_use block(包含 name + input) + ↓ +2. StreamingToolExecutor.addTool() / runTools() + ↓ +3. findToolByName() 查找工具 + ↓ +4. validateInput() — 输入校验 + ↓ 失败 → 返回错误 tool_result +5. canUseTool() — 权限 UI(Ask 模式下弹确认) + ↓ 拒绝 → 返回拒绝 tool_result +6. checkPermissions() — 规则匹配 + ↓ +7. call() — 执行实际操作 + ↓ onProgress() 回调实时更新 UI +8. 返回 ToolResult + ↓ +9. mapToolResultToToolResultBlockParam() — 转为 API 格式 + ↓ +10. 新消息追加到对话 → 进入下一轮迭代 +``` + +## 工具结果的预算控制 + +每个工具通过 `maxResultSizeChars` 声明输出上限: + +- **BashTool**:`30_000`(命令输出) +- **SkillTool**:`100_000`(技能执行结果) +- **FileReadTool**:`Infinity`(文件内容不走持久化,避免 Read→file→Read 循环) + +超出上限的结果被 `applyToolResultBudget()`(`src/utils/toolResultStorage.ts`)持久化到磁盘,AI 只收到预览 + 文件路径。 + +## MCP 工具的扩展 + +MCP Server 提供的工具通过 `mcpInfo` 字段标记来源: + +```typescript +mcpInfo?: { serverName: string; toolName: string } +``` + +MCP 工具的 `inputJSONSchema` 直接使用 JSON Schema(而非 Zod),因为 schema 来自远程协议。它们通过 `filterToolsByDenyRules()` 支持 `mcp__server` 前缀的 blanket deny 规则。 + +## 50+ 内置工具全景 @@ -53,23 +181,26 @@ Claude Code 内置了覆盖软件开发全流程的工具集: Agent / SendMessage / AskUserQuestion - TaskCreate / TaskUpdate / TaskList / TaskGet + TaskCreate / TaskUpdate / TaskList / TaskGet / TaskOutput / TaskStop - WebFetch / WebSearch + WebFetch / WebSearch / WebBrowser - EnterPlanMode / Worktree / TodoWrite + EnterPlanMode / ExitPlanMode / Worktree / TodoWrite / ToolSearch ## 工具的可视化渲染 -工具不仅能"做事",还能"展示"。每个工具可以定义自己的 UI 渲染方式: +工具不仅能"做事",还能"展示"。每个工具通过 React 组件定义 UI 渲染: -- **FileEdit** → 在终端里展示语法高亮的 diff 视图 -- **Bash** → 实时显示命令输出,带进度指示 +- **FileEdit** → `renderToolResultMessage` 展示语法高亮的 diff 视图 +- **Bash** → 实时显示命令输出(通过 `onProgress` 回调),带进度指示 - **Grep** → 高亮匹配结果,显示文件路径和行号链接 - **Agent** → 显示子 Agent 的进度条和状态 +- **SkillTool** → 渲染技能执行进度 -这让用户能直观地看到"AI 在做什么、做到哪了"。 +`isSearchOrReadCommand()` 允许工具声明自己是搜索/读取操作,触发 UI 的折叠显示模式(避免大量搜索结果占满屏幕)。 + +`getActivityDescription()` 为 spinner 提供活动描述(如 "Reading src/foo.ts"、"Running bun test"),替代默认的工具名显示。