mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
Compare commits
61 Commits
memory-lea
...
feature/pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2006ab25ff | ||
|
|
0707284939 | ||
|
|
84f12f34bd | ||
|
|
2f86485d9c | ||
|
|
547ce9e848 | ||
|
|
2cf18c4c49 | ||
|
|
bd2253846f | ||
|
|
af0d7dc851 | ||
|
|
3ac866be98 | ||
|
|
c14b7eadd2 | ||
|
|
8c157f0767 | ||
|
|
4fc95bd5a7 | ||
|
|
7be08f53bd | ||
|
|
02dd796706 | ||
|
|
8ba51edec1 | ||
|
|
73e54d4bbc | ||
|
|
2fdfb844cb | ||
|
|
4230f0fff1 | ||
|
|
7fe448d9e9 | ||
|
|
aa06cea904 | ||
|
|
c43efecbab | ||
|
|
cb4a6e76cf | ||
|
|
f7f69b759c | ||
|
|
771e3dbcf0 | ||
|
|
e3c0699f5b | ||
|
|
e8759f3402 | ||
|
|
958ac3a0d5 | ||
|
|
5895362178 | ||
|
|
8cfe9b6dc3 | ||
|
|
12f5aedf99 | ||
|
|
c7efac6b8d | ||
|
|
2f150d3ecd | ||
|
|
68c7ebb242 | ||
|
|
9e299a7208 | ||
|
|
941bcbd240 | ||
|
|
fd66ddc45f | ||
|
|
5c107e5f8c | ||
|
|
c4e9efb7a8 | ||
|
|
26ddbda849 | ||
|
|
872ee280e3 | ||
|
|
f5c9880d7d | ||
|
|
3f1c8468bf | ||
|
|
100e9d2da0 | ||
|
|
0ad6349434 | ||
|
|
1ac18aec0d | ||
|
|
fcbc882232 | ||
|
|
a1108870e3 | ||
|
|
87b96199f9 | ||
|
|
18d6656a6a | ||
|
|
d0915fc880 | ||
|
|
cf2bf29dcd | ||
|
|
75952bde9c | ||
|
|
e7220c530f | ||
|
|
6ff839d625 | ||
|
|
88057b10d4 | ||
|
|
4d0048a60a | ||
|
|
8a5ef8c9cb | ||
|
|
f8a289b868 | ||
|
|
45c892fc18 | ||
|
|
5b333e2246 | ||
|
|
5dc4d8f8a2 |
@@ -1 +1 @@
|
||||
bunx lint-staged
|
||||
npx lint-staged
|
||||
|
||||
32
CLAUDE.md
32
CLAUDE.md
@@ -82,11 +82,11 @@ bun run docs:dev
|
||||
- **Vendor 路径解析**: 构建后 chunk 文件位于 `dist/` 或 `dist/chunks/` 下,vendor 二进制在 `dist/vendor/`。`src/utils/ripgrep.ts` 和 `packages/audio-capture-napi/src/index.ts` 均通过 `import.meta.url` 路径中 `lastIndexOf('dist')` 定位 dist 根目录,再拼接 `vendor/` 子路径,确保不同构建产物层级下路径一致。
|
||||
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
|
||||
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
|
||||
- **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
||||
- **Monorepo**: Bun workspaces — 17 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`。
|
||||
- **Lint/Format**: Biome (`biome.json`)。覆盖 `src/`、`scripts/`、`packages/` 全项目(含 `packages/@ant/`)。`bun run lint` / `bun run lint:fix` / `bun run format` / `bun run check` / `bun run check:fix`。42 条规则因 decompiled 代码被关闭,仅保留 `recommended` 基线。
|
||||
- **Pre-commit**: husky + lint-staged。提交时自动对暂存文件执行 `biome check --fix`(TS/JS)和 `biome format --write`(JSON)。
|
||||
- **CI Lint**: `ci.yml` 在依赖安装后、类型检查前执行 `bunx biome ci .`,lint 或格式化不达标则 CI 失败。
|
||||
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`。
|
||||
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.2.1`。
|
||||
- **CI**: GitHub Actions — `ci.yml`(lint + 构建 + 测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
|
||||
|
||||
### Entry & Bootstrap
|
||||
@@ -104,7 +104,7 @@ bun run docs:dev
|
||||
- `environment-runner` / `self-hosted-runner` — BYOC runner
|
||||
- `--tmux` + `--worktree` 组合
|
||||
- 默认路径:加载 `main.tsx` 启动完整 CLI
|
||||
2. **`src/main.tsx`** (~6981 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
|
||||
2. **`src/main.tsx`** (~5674 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
|
||||
3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。
|
||||
|
||||
### Core Loop
|
||||
@@ -123,15 +123,18 @@ bun run docs:dev
|
||||
|
||||
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
|
||||
- **`src/tools.ts`** — Tool registry. Assembles the tool list; tools are imported from `@claude-code-best/builtin-tools` package. Some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
|
||||
- **`packages/builtin-tools/src/tools/`** — 59 个子目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
|
||||
- **`src/constants/tools.ts`** — `CORE_TOOLS` 白名单常量(38 个核心工具名),用于 `isDeferredTool` 白名单制判定。
|
||||
- **`packages/builtin-tools/src/tools/`** — 60 个工具目录(含 shared/testing 等工具目录),通过 `@claude-code-best/builtin-tools` 包导出。主要分类:
|
||||
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
|
||||
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
|
||||
- **Agent 系统**: AgentTool, TaskCreateTool, TaskUpdateTool, TaskListTool, TaskGetTool
|
||||
- **规划**: EnterPlanModeTool, ExitPlanModeV2Tool, VerifyPlanExecutionTool
|
||||
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
|
||||
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
|
||||
- **工具发现**: SearchExtraToolsTool, ExecuteExtraTool, SyntheticOutput(CORE_TOOLS,用于延迟工具按需加载)
|
||||
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
|
||||
- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。
|
||||
- **`src/services/searchExtraTools/`** — TF-IDF 工具索引模块(`toolIndex.ts`),为延迟工具提供语义搜索能力。复用 `localSearch.ts` 的 TF-IDF 算法函数(`computeWeightedTf`、`computeIdf`、`cosineSimilarity` 已导出)。修改这些函数时需同步检查工具索引测试。`prefetch.ts` 的 `extractQueryFromMessages` 复用了 `skillSearch/prefetch.ts` 的同名导出函数,修改 skill prefetch 的该函数时需同步检查工具预取行为。工具预取使用独立的 `discoveredToolsThisSession` Set,与 skill prefetch 的去重集合互不影响。
|
||||
|
||||
### UI Layer (Ink)
|
||||
|
||||
@@ -166,18 +169,16 @@ bun run docs:dev
|
||||
| `packages/builtin-tools/` | 内置工具集(60 个 tool 实现,通过 `@claude-code-best/builtin-tools` 导出) |
|
||||
| `packages/agent-tools/` | Agent 工具集 |
|
||||
| `packages/acp-link/` | ACP 代理服务器(WebSocket → ACP agent 桥接) |
|
||||
| `packages/cc-knowledge/` | Claude Code 知识库(非 workspace 包) |
|
||||
| `packages/langfuse-dashboard/` | Langfuse 可观测性面板(非 workspace 包) |
|
||||
| `packages/mcp-client/` | MCP 客户端库 |
|
||||
| `packages/mcp-server/` | MCP 服务端库(非 workspace 包) |
|
||||
| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI)— Web UI 已重构为 React + Vite + Radix UI,支持 ACP agent 接入 |
|
||||
| `packages/swarm/` | Swarm 解耦模块(非 workspace 包) |
|
||||
| `packages/shell/` | Shell 抽象(非 workspace 包) |
|
||||
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
|
||||
| `packages/color-diff-napi/` | 颜色差异计算(完整实现,11 tests) |
|
||||
| `packages/image-processor-napi/` | 图像处理(已恢复) |
|
||||
| `packages/modifiers-napi/` | 键盘修饰键检测(macOS FFI 实现) |
|
||||
| `packages/url-handler-napi/` | URL scheme 处理(环境变量 + CLI 参数读取) |
|
||||
| `packages/weixin/` | 微信集成(非 workspace 包) |
|
||||
|
||||
辅助目录(无 package.json,非 workspace 包): `langfuse-dashboard`(Langfuse 面板)、`shared-web-ui`(共享 Web UI 组件)、`highlight-code`(代码高亮)、`claude-pencil`(编辑器)、`vscode-ide-bridge`(VS Code 桥接)、`pokemon`(示例/测试)。
|
||||
|
||||
### Bridge / Remote Control
|
||||
|
||||
@@ -208,12 +209,18 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
|
||||
**启用方式**: 环境变量 `FEATURE_<FLAG_NAME>=1`。例如 `FEATURE_BUDDY=1 bun run dev`。
|
||||
|
||||
**Build 默认 features**(19 个,见 `build.ts`):
|
||||
**Build 默认 features**(65+ 个,见 `build.ts` 中 `DEFAULT_BUILD_FEATURES`):
|
||||
- 基础: `BUDDY`, `TRANSCRIPT_CLASSIFIER`, `BRIDGE_MODE`, `AGENT_TRIGGERS_REMOTE`, `CHICAGO_MCP`, `VOICE_MODE`
|
||||
- 统计/缓存: `SHOT_STATS`, `PROMPT_CACHE_BREAK_DETECTION`, `TOKEN_BUDGET`
|
||||
- P0 本地: `AGENT_TRIGGERS`, `ULTRATHINK`, `BUILTIN_EXPLORE_PLAN_AGENTS`, `LODESTONE`
|
||||
- P1 API 依赖: `EXTRACT_MEMORIES`, `VERIFICATION_AGENT`, `KAIROS_BRIEF`, `AWAY_SUMMARY`, `ULTRAPLAN`
|
||||
- P2: `DAEMON`
|
||||
- P2: `DAEMON`, `ACP`
|
||||
- 工作流: `WORKFLOW_SCRIPTS`, `HISTORY_SNIP`, `MONITOR_TOOL`, `KAIROS`
|
||||
- 多 worker: `COORDINATOR_MODE`, `BG_SESSIONS`, `TEMPLATES`
|
||||
- 连接器: `CONNECTOR_TEXT`, `COMMIT_ATTRIBUTION`, `DIRECT_CONNECT`
|
||||
- 实验性: `EXPERIMENTAL_SKILL_SEARCH`, `EXPERIMENTAL_SEARCH_EXTRA_TOOLS`
|
||||
- 模式: `POOR`, `SSH_REMOTE`
|
||||
- 已禁用: `CONTEXT_COLLAPSE`, `FORK_SUBAGENT`, `UDS_INBOX`, `LAN_PIPES`, `REVIEW_ARTIFACT`, `TEAMMEM`, `SKILL_LEARNING`
|
||||
|
||||
**Dev mode 默认**: 全部启用(见 `scripts/dev.ts`)。
|
||||
|
||||
@@ -263,6 +270,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
| Voice Mode | Restored — Push-to-Talk 语音输入(需 Anthropic OAuth) |
|
||||
| OpenAI/Gemini/Grok 兼容层 | Restored |
|
||||
| Remote Control Server | Restored — 自托管 RCS + Web UI |
|
||||
| `packages/shell/`, `packages/swarm/`, `packages/mcp-server/`, `packages/cc-knowledge/` | Removed — 功能合并或废弃 |
|
||||
| Analytics / GrowthBook / Sentry | Empty implementations |
|
||||
| Magic Docs / LSP Server | Restored — Magic Docs 自动更新 + LSP 服务器管理器 |
|
||||
| Plugins / Marketplace | Restored — 插件安装/卸载/启用/禁用 + Marketplace 浏览 |
|
||||
@@ -279,7 +287,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
|
||||
- **框架**: `bun:test`(内置断言 + mock)
|
||||
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
|
||||
- **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain)
|
||||
- **集成测试**: `tests/integration/` — 6 个文件(cli-arguments, context-build, message-pipeline, tool-chain, autonomy-lifecycle-user-flow, dependency-overrides)
|
||||
- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/)
|
||||
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
|
||||
- **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests)
|
||||
|
||||
9
build.ts
9
build.ts
@@ -21,7 +21,14 @@ const result = await Bun.build({
|
||||
outdir,
|
||||
target: 'bun',
|
||||
splitting: true,
|
||||
define: getMacroDefines(),
|
||||
sourcemap: 'linked',
|
||||
define: {
|
||||
...getMacroDefines(),
|
||||
// React production mode — eliminates _debugStack Error objects
|
||||
// (6,889 objects × ~1.7KB = 12MB in development builds) and removes
|
||||
// prop-type / key warnings not useful in a production CLI tool.
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
},
|
||||
features,
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
323
docs/design/tool-search-design-guide.md
Normal file
323
docs/design/tool-search-design-guide.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# ToolSearch 设计指南
|
||||
|
||||
> 基于 feature/tool_search 分支的 4 次 commit 迭代,系统性地记录 ToolSearch 的架构、核心机制、演进历史和维护指南。
|
||||
|
||||
## 1. 问题背景
|
||||
|
||||
Claude Code 内置了 60+ 工具,加上用户连接的 MCP 服务器可能引入数十甚至上百个额外工具。将所有工具的完整 schema 一次性发送给模型,会产生几个严重问题:
|
||||
|
||||
1. **Token 爆炸** — 每个工具定义(name + description + inputSchema)平均消耗数百 token,60 个工具就是数万 token 的常量开销。
|
||||
2. **Prompt Cache 失效** — 工具列表作为 prompt 的一部分参与缓存计算。任何工具的增减(如 MCP 服务器连接/断开)都会导致整段缓存失效。
|
||||
3. **模型注意力稀释** — 过多的工具定义干扰模型对核心工具的选择准确性。
|
||||
|
||||
## 2. 解决方案概览
|
||||
|
||||
ToolSearch 采用 **延迟加载(Deferred Loading)** 模式:
|
||||
|
||||
- 将工具分为 **Core Tools**(始终加载)和 **Deferred Tools**(按需发现)
|
||||
- 模型通过 `SearchExtraTools` 工具搜索并发现 deferred tools
|
||||
- 通过 `ExecuteExtraTool` 工具代理执行发现的 deferred tools
|
||||
- **工具数组在会话中保持稳定**,不再动态注入已发现的 deferred tools(v3 修复的关键决策)
|
||||
|
||||
## 3. 核心架构
|
||||
|
||||
### 3.1 工具分类体系
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ All Tools (60+ built-in + MCP) │
|
||||
├───────────────────────────┬─────────────────────────────────┤
|
||||
│ Core Tools (29 个) │ Deferred Tools (其余全部) │
|
||||
│ 始终加载,直接调用 │ 不加载 schema,按需发现 │
|
||||
│ CORE_TOOLS 白名单定义 │ isDeferredTool() 判定 │
|
||||
└───────────────────────────┴─────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Core Tools**(`src/constants/tools.ts` 中的 `CORE_TOOLS` Set):
|
||||
|
||||
| 类别 | 工具 |
|
||||
|------|------|
|
||||
| 文件操作 | Bash/Shell, Read, Edit, Write, Glob, Grep, NotebookEdit |
|
||||
| Agent 交互 | Agent, AskUserQuestion |
|
||||
| 任务管理 | TaskOutput, TaskStop, TaskCreate, TaskGet, TaskList, TaskUpdate, TodoWrite |
|
||||
| 规划 | EnterPlanMode, ExitPlanMode, VerifyPlanExecution |
|
||||
| Web | WebFetch, WebSearch |
|
||||
| 代码智能 | LSP |
|
||||
| 技能 | Skill |
|
||||
| 调度/监控 | Sleep |
|
||||
| 工具发现 | SearchExtraTools, ExecuteExtraTool, SyntheticOutput |
|
||||
|
||||
**isDeferredTool 判定逻辑**(`packages/builtin-tools/src/tools/SearchExtraToolsTool/prompt.ts`):
|
||||
|
||||
```
|
||||
isDeferredTool(tool) =
|
||||
tool.alwaysLoad === true? → false(显式跳过延迟)
|
||||
CORE_TOOLS.has(tool.name)? → false(核心工具不延迟)
|
||||
otherwise → true(其余全部延迟)
|
||||
```
|
||||
|
||||
### 3.2 三层组件架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ API Layer (src/services/api/claude.ts) │
|
||||
│ ├─ 判定是否启用 ToolSearch │
|
||||
│ ├─ 过滤 deferred tools 不进入 API tools 数组 │
|
||||
│ ├─ 注入 <available-deferred-tools> 或 delta 附件 │
|
||||
│ └─ 处理 tool_reference/text 格式的消息归一化 │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ Query Loop (src/query.ts) │
|
||||
│ ├─ Turn-zero 预取:用户输入时触发 │
|
||||
│ └─ Inter-turn 预取:assistant turn 后异步触发 │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ Search Engine │
|
||||
│ ├─ SearchExtraToolsTool — 搜索入口(4 种查询模式) │
|
||||
│ ├─ TF-IDF Index (toolIndex.ts) — 语义搜索 │
|
||||
│ ├─ Keyword Search — 精确匹配 │
|
||||
│ └─ ExecuteExtraTool — 代理执行 │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.3 搜索引擎设计
|
||||
|
||||
SearchExtraToolsTool 支持四种查询模式:
|
||||
|
||||
| 模式 | 语法 | 行为 | 返回 |
|
||||
|------|------|------|------|
|
||||
| **Select** | `select:CronCreate,Snip` | 按名称直接获取,逗号分隔多选 | 精确匹配列表 |
|
||||
| **Discover** | `discover:schedule cron job` | 纯发现模式,返回描述+schema | 工具信息文本 |
|
||||
| **Keyword** | `notebook jupyter` | 关键词搜索 | 按相关性排序 |
|
||||
| **Required** | `+slack send` | `+` 前缀强制包含 | 包含必选词的结果 |
|
||||
|
||||
**混合搜索算法**:
|
||||
|
||||
```
|
||||
最终分数 = 关键词分数 × 0.4 + TF-IDF 分数 × 0.6
|
||||
```
|
||||
|
||||
- **Keyword Search**:基于工具名解析(CamelCase 分词、MCP 前缀拆解)、searchHint 匹配、描述文本匹配,加权计分
|
||||
- **TF-IDF Search**:复用 `skillSearch/localSearch.ts` 的算法,对 name (3.0)、searchHint (2.5)、description (1.0) 三个字段加权计算 TF-IDF 向量
|
||||
|
||||
**MCP 工具名解析**:
|
||||
|
||||
```
|
||||
mcp__slack__send_message → parts: ["slack", "send", "message"]
|
||||
CamelCase → parts: ["cron", "create"]
|
||||
```
|
||||
|
||||
### 3.4 执行管道
|
||||
|
||||
```
|
||||
模型调用 ExecuteExtraTool({tool_name: "CronCreate", params: {...}})
|
||||
↓
|
||||
ExecuteTool.call() 在全局工具注册表中查找 CronCreate
|
||||
↓
|
||||
检查目标工具 isEnabled() — 桥接/条件工具可能不可用
|
||||
↓
|
||||
委托目标工具的 checkPermissions() — 权限传递给实际工具
|
||||
↓
|
||||
调用目标工具的 call() — 与直接调用完全等价
|
||||
↓
|
||||
返回结果(包装为 ExecuteExtraTool 的 output schema)
|
||||
```
|
||||
|
||||
关键设计:ExecuteExtraTool 的 `checkPermissions()` 返回 `passthrough`,将权限决策完全委托给目标工具。它本身不引入额外的权限层。
|
||||
|
||||
### 3.5 Prompt Cache 稳定性策略(v3 关键修复)
|
||||
|
||||
**问题**:早期版本在发现 deferred tool 后会将其注入 API tools 数组,导致每次发现新工具时 tools JSON 变化,prompt cache 全面失效。
|
||||
|
||||
**修复**(commit `c14b7ead`):deferred tools **始终不进入 API tools 数组**。tools 数组在整个会话中只包含 core tools + SearchExtraTools + ExecuteExtraTool,保持稳定。
|
||||
|
||||
```
|
||||
API Tools 数组(会话期间不变):
|
||||
[Core Tools (29)] + [SearchExtraTools, ExecuteExtraTool, SyntheticOutput]
|
||||
|
||||
不包含: 任何 deferred tool(即使已被发现)
|
||||
执行方式: 通过 ExecuteExtraTool 代理调用
|
||||
```
|
||||
|
||||
## 4. 预取机制(Prefetch)
|
||||
|
||||
### 4.1 两个触发时机
|
||||
|
||||
1. **Turn-zero**(`getTurnZeroSearchExtraToolsPrefetch`)— 用户输入第一轮时,基于输入文本搜索相关 deferred tools,以 attachment 形式注入
|
||||
2. **Inter-turn**(`startSearchExtraToolsPrefetch`)— assistant turn 结束后,基于对话上下文异步搜索
|
||||
|
||||
### 4.2 Attachment 管道
|
||||
|
||||
```
|
||||
prefetch → Attachment(type: 'tool_discovery')
|
||||
→ messages.ts 转换为 system-reminder
|
||||
→ "The following tools were discovered... Use ExecuteExtraTool to invoke..."
|
||||
```
|
||||
|
||||
### 4.3 会话去重
|
||||
|
||||
`discoveredToolsThisSession` Set 跟踪已发现的工具,避免重复推荐。该 Set 独立于 skill prefetch 的去重集合,互不影响。使用 `addBoundedSessionEntry()` 保持上限 500 条,超出时裁剪到 400 条。
|
||||
|
||||
## 5. 模式切换系统
|
||||
|
||||
通过环境变量 `ENABLE_SEARCH_EXTRA_TOOLS` 控制:
|
||||
|
||||
| 环境变量值 | 模式 | 行为 |
|
||||
|-----------|------|------|
|
||||
| 未设置 | `tst` | 默认启用,始终延迟非核心工具 |
|
||||
| `true` | `tst` | 强制启用 |
|
||||
| `false` | `standard` | 完全禁用,所有工具内联加载 |
|
||||
| `auto` | `tst-auto` | 仅当 deferred tools 超过上下文窗口 10% 时启用 |
|
||||
| `auto:N` | `tst-auto` | 自定义阈值百分比(N=0 启用,N=100 禁用) |
|
||||
| `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1` | `standard` | 全局 kill switch |
|
||||
|
||||
`isSearchExtraToolsEnabledOptimistic()` — 快速判断(不检查阈值),用于工具注册
|
||||
`isSearchExtraToolsEnabled()` — 完整判断(含阈值检查),用于 API 调用
|
||||
|
||||
## 6. Deferred Tools Delta 机制
|
||||
|
||||
对于 Anthropic 内部用户(`USER_TYPE=ant`)或启用了 `tengu_glacier_2xr` feature flag 的用户,使用 **delta attachment** 替代 `<available-deferred-tools>` 头部注入:
|
||||
|
||||
- 首次:注入完整的 deferred tools 列表
|
||||
- 后续:只注入增量变化(新增/移除)
|
||||
- 优势:不会因为工具池变化导致整个头部缓存失效
|
||||
|
||||
Delta attachment 扫描历史消息中的 `deferred_tools_delta` 类型 attachment,重建已宣告集合,然后差分计算当前 deferred pool 的变化。
|
||||
|
||||
## 7. 演进历史
|
||||
|
||||
### v1: 基础设施层(`7be08f53`)
|
||||
|
||||
**34 个文件,+4040/-90 行**
|
||||
|
||||
- 定义 `CORE_TOOLS` 白名单(31 个核心工具)
|
||||
- 实现 TF-IDF 工具索引模块 `toolIndex.ts`
|
||||
- 创建 `ExecuteTool` 作为统一执行入口
|
||||
- 增强 ToolSearchTool:TF-IDF 搜索路径、discover 模式、并行搜索合并
|
||||
- 新增 27 个单元测试
|
||||
- 实现预取管道和 UI 组件
|
||||
|
||||
**关键文件**:
|
||||
- `src/services/toolSearch/toolIndex.ts` → 后续重命名为 `searchExtraTools/toolIndex.ts`
|
||||
- `packages/builtin-tools/src/tools/ExecuteTool/` — 执行入口
|
||||
- `src/constants/tools.ts` — CORE_TOOLS 定义
|
||||
|
||||
### v2: 统一自建搜索(`8c157f07`)
|
||||
|
||||
**17 个文件,+274/-395 行**(净减少 121 行)
|
||||
|
||||
- **移除 `tool_reference` blocks** — 不再依赖 Anthropic API 的 `tool_reference` 功能
|
||||
- **移除 `defer_loading` 字段** — 不再发送 API 级别的工具延迟加载标记
|
||||
- **移除 `modelSupportsToolReference()`** — 不再区分模型是否支持 tool_reference
|
||||
- **重命名 ExecuteTool → ExecuteExtraTool** — 更清晰地表达其作为代理执行器的角色
|
||||
- **输出改为纯文本** — 所有 provider 通用,无需特殊 API 功能支持
|
||||
- **简化 system prompt** — 工具使用指南从 ~120 行压缩到 ~10 行
|
||||
|
||||
**设计决策**:这次重构的核心洞察是 — 依赖 Anthropic 私有 API 特性(tool_reference、defer_loading、beta header)使得系统只能用于 first-party provider。自建 TF-IDF + keyword 搜索完全能满足需求,且对所有 provider(OpenAI、Gemini、Grok)通用。
|
||||
|
||||
### v3: Cache 稳定性修复(`c14b7ead`)
|
||||
|
||||
**7 个文件,+46/-31 行**
|
||||
|
||||
- **移除 "discover then include" 逻辑** — 发现的 deferred tools 不再注入 tools 数组
|
||||
- **tools 数组保持稳定** — 只有 core tools + SearchExtraTools + ExecuteExtraTool
|
||||
- **强化优先级引导** — core tools 直接调用,ToolSearch 仅作为发现 deferred tools 的手段
|
||||
- **已加载工具拒绝提示** — 搜索 core tool 时返回明确拒绝
|
||||
|
||||
**设计决策**:prompt cache 是 Claude Code 性能优化的关键。每次 tools JSON 变化都会导致缓存失效,代价远大于通过 ExecuteExtraTool 代理调用 deferred tools 的额外 token。因此选择牺牲一点直接调用的便利性,换取 cache 稳定性。
|
||||
|
||||
### v4: Agents/Teams 延迟化(`af0d7dc8`)
|
||||
|
||||
**7 个文件,+36/-18 行**
|
||||
|
||||
- 将 `TeamCreate`、`TeamDelete`、`SendMessage` 从 CORE_TOOLS 移除
|
||||
- 这些工具仅在 swarm 模式下常用,平时占用 context token
|
||||
- swarm 模式下 SendMessage 保持 always loaded
|
||||
- TeamCreate/TeamDelete 在 swarm 未启用时返回启用提示
|
||||
|
||||
**设计决策**:不是所有用户都需要团队功能。将其延迟化后,大部分用户可以节省约 3 个工具定义的 token 开销。
|
||||
|
||||
## 8. 文件索引
|
||||
|
||||
### 核心文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/constants/tools.ts` | CORE_TOOLS 白名单、工具权限集合 |
|
||||
| `src/utils/searchExtraTools.ts` | 模式判定、阈值计算、delta 差分、discovered tools 提取 |
|
||||
| `src/services/searchExtraTools/toolIndex.ts` | TF-IDF 索引构建和搜索 |
|
||||
| `src/services/searchExtraTools/prefetch.ts` | 预取管道(turn-zero + inter-turn) |
|
||||
| `packages/builtin-tools/src/tools/SearchExtraToolsTool/` | 搜索工具实现(4 种查询模式) |
|
||||
| `packages/builtin-tools/src/tools/ExecuteTool/` | 代理执行器实现 |
|
||||
| `src/services/api/claude.ts` | API 层集成(工具过滤、消息归一化) |
|
||||
| `src/query.ts` | 查询循环集成(预取触发点) |
|
||||
| `src/utils/messages.ts` | Attachment → system-reminder 转换 |
|
||||
|
||||
### 共享基础设施
|
||||
|
||||
| 文件 | 被复用的导出 |
|
||||
|------|-------------|
|
||||
| `src/services/skillSearch/localSearch.ts` | `tokenizeAndStem`, `computeWeightedTf`, `computeIdf`, `cosineSimilarity` |
|
||||
| `src/services/skillSearch/prefetch.ts` | `extractQueryFromMessages` |
|
||||
|
||||
### 测试文件
|
||||
|
||||
| 文件 | 覆盖范围 |
|
||||
|------|---------|
|
||||
| `src/services/searchExtraTools/__tests__/toolIndex.test.ts` | 索引构建、TF-IDF 搜索、CJK 处理 |
|
||||
| `src/services/searchExtraTools/__tests__/prefetch.test.ts` | 预取管道、去重、attachment 生成 |
|
||||
| `packages/builtin-tools/src/tools/SearchExtraToolsTool/__tests__/` | 搜索工具 4 种模式 |
|
||||
| `packages/builtin-tools/src/tools/ExecuteTool/__tests__/` | 代理执行 |
|
||||
|
||||
## 9. 维护指南
|
||||
|
||||
### 9.1 新增工具的延迟化决策
|
||||
|
||||
将新工具加入 deferred 状态的标准:
|
||||
- 工具仅在特定场景使用(如 swarm 模式、特定 MCP 集成)
|
||||
- 工具的 schema 较大(占用较多 context token)
|
||||
- 工具不是模型默认会尝试的核心操作
|
||||
|
||||
将已延迟的工具提升为 core tool:
|
||||
- 在 `src/constants/tools.ts` 的 `CORE_TOOLS` Set 中添加工具名常量
|
||||
- 确保导入对应的 `*_TOOL_NAME` 常量
|
||||
|
||||
### 9.2 修改注意事项
|
||||
|
||||
1. **修改 `localSearch.ts` 的 TF-IDF 函数**:需同步检查 `toolIndex.test.ts` 和 `localSearch.test.ts`
|
||||
2. **修改 `skillSearch/prefetch.ts` 的 `extractQueryFromMessages`**:需同步检查工具预取行为(`searchExtraTools/prefetch.ts` 调用同一函数)
|
||||
3. **修改 CORE_TOOLS**:需更新 `src/constants/__tests__/tools.test.ts` 测试
|
||||
4. **修改 `isDeferredTool`**:需更新 `src/constants/__tests__/tools.test.ts` 和 `SearchExtraToolsTool.test.ts`
|
||||
|
||||
### 9.3 性能优化配置
|
||||
|
||||
```bash
|
||||
# 环境变量调优
|
||||
ENABLE_SEARCH_EXTRA_TOOLS=auto:15 # 当 deferred tools 超过上下文 15% 时启用
|
||||
SEARCH_EXTRA_TOOLS_WEIGHT_KEYWORD=0.5 # 关键词搜索权重
|
||||
SEARCH_EXTRA_TOOLS_WEIGHT_TFIDF=0.5 # TF-IDF 搜索权重
|
||||
SEARCH_EXTRA_TOOLS_DISPLAY_MIN_SCORE=0.10 # 最低显示分数阈值
|
||||
```
|
||||
|
||||
### 9.4 搜索质量调优
|
||||
|
||||
- `TOOL_FIELD_WEIGHT`(`toolIndex.ts`):控制 name/searchHint/description 对 TF-IDF 分数的贡献权重
|
||||
- `KEYWORD_WEIGHT` / `TFIDF_WEIGHT`(`SearchExtraToolsTool.ts`):控制混合搜索中两种算法的最终权重比例
|
||||
- `searchHint` 属性:为工具添加精心编写的搜索提示,提高关键词匹配质量
|
||||
|
||||
## 10. 与 Skill Search 的关系
|
||||
|
||||
ToolSearch 和 SkillSearch 是平行的搜索系统,共享底层算法但服务于不同领域:
|
||||
|
||||
| 维度 | ToolSearch | SkillSearch |
|
||||
|------|-----------|-------------|
|
||||
| 搜索对象 | Deferred 工具(内置 + MCP) | 用户技能(skill) |
|
||||
| 执行方式 | `ExecuteExtraTool` 代理调用 | 直接注入 attachment 内容 |
|
||||
| 字段权重 | name:3.0, searchHint:2.5, desc:1.0 | name:3.0, whenToUse:2.0, desc:1.0 |
|
||||
| 缓存策略 | 按工具名列表缓存 | 按 cwd 缓存 |
|
||||
| 去重集合 | `discoveredToolsThisSession` | 独立的 Set |
|
||||
|
||||
共享的底层函数:
|
||||
- `tokenizeAndStem` — 统一的 CJK/ASCII 分词和词干提取
|
||||
- `computeWeightedTf` — 加权词频计算
|
||||
- `computeIdf` — 逆文档频率计算
|
||||
- `cosineSimilarity` — 向量余弦相似度
|
||||
- `extractQueryFromMessages` — 从对话历史中提取搜索查询文本
|
||||
225
docs/features/background-agent-selector.md
Normal file
225
docs/features/background-agent-selector.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Background Agent Selector — 底部统一后台 Agent 切换器
|
||||
|
||||
> Feature Flag: 无(直接启用)
|
||||
> 实现状态:完整可用
|
||||
> 依赖:`viewingAgentTaskId` / `enterTeammateView` / `exitTeammateView` 已有机制
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
Background Agent Selector 是渲染在 PromptInput 下方的常驻状态条,列出当前所有 **backgrounded 的 local_agent 任务**(包括 `/fork` 派生的 fork agent 和 Task/AgentTool 调用 `run_in_background: true` 派生的子 agent)。用户可以用 ↑/↓ 方向键在 `main` 和各 agent 之间切换焦点,按 Enter 把 REPL 主视图替换为所选 agent 的实时 transcript,再按 Enter 选中 `main` 即可回到主对话。
|
||||
|
||||
整个机制完全复用官方已有的 teammate transcript 查看基础设施,不引入新的视图层 / 数据流,仅新增一条 footer pill 类型。
|
||||
|
||||
### 核心特性
|
||||
|
||||
- **统一入口**:`/fork`、Task 派生的 subagent、所有 `run_in_background: true` 的 agent 都在同一栏显示
|
||||
- **就地切换**:prompt 为空时按 ↓ 溢出进入底部 selector,↑↓ 选中某行,Enter 即切主视图
|
||||
- **实时状态**:每行显示 agent 类型 + 描述 + 运行时长 + 已消耗 token;running 时圆点为绿色
|
||||
- **Keep-alive 视图**:agent 完成后在 `evictAfter` grace 窗口内保留一段时间,用户可回看
|
||||
- **零界面侵入**:tasks 数为 0 时 selector 完全不渲染,不占屏幕高度
|
||||
- **与旧 Dialog 共存**:Shift+↓ 打开的 `BackgroundTasksDialog` 原有行为保留,selector 只作为展示 + 快捷切换
|
||||
|
||||
## 二、用户交互
|
||||
|
||||
### 触发方式
|
||||
|
||||
有任何 background agent 时,selector 自动出现在 `bypass permissions on` 行下方:
|
||||
|
||||
```
|
||||
claude-code | Opus 4.7 (1M context) | ctx:4%
|
||||
▶▶ bypass permissions on (shift+tab to cycle)
|
||||
|
||||
○ main ↑/↓ to select · Enter to view
|
||||
● Explore Research src/hooks 23s · ↓ 10.9k tokens
|
||||
○ Explore Research src/components 22s · ↓ 9.5k tokens
|
||||
○ Explore Research src/utils 21s · ↓ 13.6k tokens
|
||||
```
|
||||
|
||||
### 键盘路由
|
||||
|
||||
| 位置 / 状态 | 按键 | 行为 |
|
||||
|---|---|---|
|
||||
| PromptInput 非空 | ↑↓ | 光标移动 / 翻历史(不变) |
|
||||
| PromptInput 空 + 历史底部 | ↓ | 焦点下放到 selector,高亮到 `● main` |
|
||||
| Selector 聚焦(`footerSelection === 'bg_agent'`) | ↓ | 高亮下移,-1 → 0 → ... → N-1 |
|
||||
| Selector 聚焦 | ↑ | 高亮上移;在 `main` 再 ↑ → 焦点回 PromptInput |
|
||||
| Selector 聚焦 | Enter | `-1` → `exitTeammateView`;`>=0` → `enterTeammateView(agentId)`。焦点保留在 pill |
|
||||
| Selector 聚焦 | Esc | `footer:clearSelection`,焦点回 PromptInput |
|
||||
|
||||
### 视觉规则
|
||||
|
||||
- `● main` / `● <agent>`:当前被**查看**(viewingAgentTaskId 指向)或被**光标聚焦**(pill focused 时以光标为准)的一行
|
||||
- running 状态的 agent:圆点渲染为 `success` 色(绿色),与 `BackgroundTasksDialog` 状态语义对齐
|
||||
- 右上角 hint 随状态变化:
|
||||
- pill 聚焦:`↑/↓ to select · Enter to view`
|
||||
- 已选中 running agent:`shift+↓ to manage · x to stop`
|
||||
- 已选中 terminal agent:`shift+↓ to manage · x to clear`
|
||||
- 未选中任何 agent:`shift+↓ to manage background agents`
|
||||
|
||||
## 三、实现架构
|
||||
|
||||
### 3.1 数据层:`useBackgroundAgentTasks`
|
||||
|
||||
文件:`src/hooks/useBackgroundAgentTasks.ts`
|
||||
|
||||
封装对 `useAppState(s => s.tasks)` 的过滤:
|
||||
|
||||
```ts
|
||||
export function useBackgroundAgentTasks(): LocalAgentTaskState[] {
|
||||
const tasks = useAppState(s => s.tasks)
|
||||
return useMemo(() => {
|
||||
const now = Date.now()
|
||||
return Object.values(tasks)
|
||||
.filter(isLocalAgentTask)
|
||||
.filter(t => t.agentType !== 'main-session')
|
||||
.filter(t => t.isBackgrounded !== false)
|
||||
.filter(t => t.evictAfter === undefined || t.evictAfter > now)
|
||||
.sort((a, b) => a.startTime - b.startTime)
|
||||
}, [tasks])
|
||||
}
|
||||
```
|
||||
|
||||
`/fork` 和 `AgentTool` 的 `run_in_background: true` 底层都走 `registerAsyncAgent → runAsyncAgentLifecycle`,最终写入同一个 `appState.tasks` Map;此 hook 是唯一数据源,Selector 和 PromptInput 的 `bgAgentList` 都消费它。
|
||||
|
||||
### 3.2 状态层:新增两个字段
|
||||
|
||||
文件:`src/state/AppStateStore.ts`
|
||||
|
||||
```ts
|
||||
export type FooterItem =
|
||||
| 'tasks' | 'tmux' | 'bagel' | 'teams' | 'bridge' | 'companion'
|
||||
| 'bg_agent' // ← 新增
|
||||
|
||||
export type AppState = DeepImmutable<{
|
||||
// ...
|
||||
selectedBgAgentIndex: number // -1 = main, 0..N-1 = 选中的 agent
|
||||
}>
|
||||
```
|
||||
|
||||
- `'bg_agent'` 作为 `FooterItem` 加入 footer pill 体系,享受既有的 `footer:up` / `footer:down` / `footer:openSelected` keybinding 路由
|
||||
- `selectedBgAgentIndex` 记录 selector 的光标位置,与 `viewingAgentTaskId`("正在看什么")独立;它不可从 `viewingAgentTaskId` 派生——Enter 后光标留在 pill 继续导航,查看目标才变
|
||||
|
||||
### 3.3 键盘路由:PromptInput footer pill 分支
|
||||
|
||||
文件:`src/components/PromptInput/PromptInput.tsx`
|
||||
|
||||
1. **`bg_agent` 进入 footerItems[0]**:保证 prompt ↓ 溢出时(`handleHistoryDown` → `selectFooterItem(footerItems[0])`)直接进入 selector,而不是 `tasks` 等其他 pill
|
||||
2. **`footer:up` 分支**:`bgAgentSelected` 时 `selectedBgAgentIndex > -1` 则递减;在 -1 → `selectFooterItem(null)` 退出 pill
|
||||
3. **`footer:down` 分支**:`selectedBgAgentIndex < bgAgentList.length - 1` 则递增,到底 clamp
|
||||
4. **`footer:openSelected` 分支**:index === -1 → `exitTeammateView`;否则 `enterTeammateView(bgAgentList[i].agentId)`。**不清理 pill 焦点**,光标留在 selector 上继续导航
|
||||
5. **`selectFooterItem('bg_agent')`**:入 pill 时重置 `selectedBgAgentIndex = -1`(光标落到 `main`)
|
||||
|
||||
### 3.4 渲染层:`BackgroundAgentSelector`
|
||||
|
||||
文件:`src/components/tasks/BackgroundAgentSelector.tsx`
|
||||
|
||||
纯展示组件,不订阅键盘:
|
||||
|
||||
```tsx
|
||||
const tasks = useBackgroundAgentTasks()
|
||||
const viewingId = useAppState(s => s.viewingAgentTaskId)
|
||||
const footerSelection = useAppState(s => s.footerSelection)
|
||||
const selectedBgIndex = useAppState(s => s.selectedBgAgentIndex)
|
||||
|
||||
if (tasks.length === 0) return null
|
||||
|
||||
const pillFocused = footerSelection === 'bg_agent'
|
||||
const highlightedId = pillFocused
|
||||
? (selectedBgIndex === -1 ? null : tasks[selectedBgIndex]?.agentId ?? null)
|
||||
: (viewingId ?? null)
|
||||
```
|
||||
|
||||
**高亮派生规则**:pill 聚焦 → 跟 `selectedBgAgentIndex`;未聚焦 → 镜像 `viewingAgentTaskId`。这样当用户通过 Shift+↓ Dialog 或 `enterTeammateView` 其它途径切换视图时,selector 也会正确反映。
|
||||
|
||||
### 3.5 主视图切换:复用 `viewingAgentTaskId`
|
||||
|
||||
REPL.tsx 主体仍复用原有查看逻辑:
|
||||
|
||||
```ts
|
||||
const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined
|
||||
const viewedAgentTask = ... (isLocalAgentTask(viewedTask) ? viewedTask : undefined)
|
||||
const displayedMessages = viewedAgentTask ? displayedAgentMessages : messages
|
||||
```
|
||||
|
||||
当 `enterTeammateView(agentId)` 把 `viewingAgentTaskId` 设成某个 local_agent 的 id:
|
||||
|
||||
- `viewedAgentTask` 解析成该 agent
|
||||
- `displayedMessages` 切换到 agent 的 messages
|
||||
- 消息列表、spinner、unseen divider 等一整套组件自动用 agent transcript 重渲染
|
||||
- 主对话流被"暂停"(并非销毁,回到 `main` 时仍在原处)
|
||||
|
||||
`enterTeammateView` 同步负责:设 `retain: true` 阻止 eviction、清 `evictAfter`、触发 disk bootstrap 从 `agent-<id>.jsonl` 加载完整 transcript 到 `task.messages`。
|
||||
|
||||
#### Fork agent prompt 归一化
|
||||
|
||||
`/fork` agent 的 transcript 和普通 subagent 不同:它继承 main agent 的上下文,真实初始消息形态是:
|
||||
|
||||
```text
|
||||
...parent messages
|
||||
assistant([...tool_use])
|
||||
user([tool_result..., text("<fork-boilerplate>...Your directive: <prompt>")])
|
||||
...fork live messages
|
||||
```
|
||||
|
||||
这里的 prompt 文本混在 `[tool_result..., text]` 多 block user message 里。消息渲染管线会优先把这条 user message 当作 tool-result plumbing 来处理,导致 `<fork-boilerplate>` 里的用户 prompt 不稳定可见。为保证切换到 fork agent 时总能看到用户发起的 fork prompt,REPL.tsx 对 fork 视图做一次展示层归一化:
|
||||
|
||||
1. 仅当 `viewedAgentTask.agentType === 'fork'` 时启用,不影响普通 Explore / Task subagent。
|
||||
2. 从原始 messages 中识别包含 `<fork-boilerplate>` 的 carrier message。
|
||||
3. 剥离 carrier message 里的 boilerplate text block,但保留 `tool_result` blocks,避免破坏父 assistant `tool_use` 的承接关系。
|
||||
4. 强制插入一条独立 `createUserMessage({ content: viewedAgentTask.prompt })` 作为可见用户 prompt。
|
||||
5. 插入位置优先为 boilerplate carrier 后;如果 sidechain bootstrap 还没读到 carrier,则插到最后一条 inherited `assistant tool_use` 后面,确保 prompt 接在 main 上下文之后,而不是跑到视图顶部。
|
||||
|
||||
这个归一化只影响 UI 展示用的 `displayedAgentMessages`,不回写 `task.messages`,也不改变发送给模型的 fork transcript。
|
||||
|
||||
### 3.6 生命周期
|
||||
|
||||
完全复用官方既有机制:
|
||||
|
||||
- **运行中**:`isBackgroundTask()` 谓词为真,selector 列出
|
||||
- **完成 / 失败 / 中止**:`completeAgentTask` / `failAgentTask` / `killAsyncAgent` 设 `status` 为 terminal
|
||||
- **回访后退出**:`exitTeammateView` 调 `release(task)`——清 `retain`、清 `messages`、terminal 状态下设 `evictAfter = now + PANEL_GRACE_MS (30s)`
|
||||
- **evictAfter 过期**:`useBackgroundAgentTasks` 过滤时自然剔除,selector 行消失
|
||||
- **手动清除**:`stopOrDismissAgent(taskId)` 设 `evictAfter = 0`,立即消失
|
||||
|
||||
## 四、设计决策
|
||||
|
||||
1. **数据源单一**:`useBackgroundAgentTasks` 是唯一过滤点,PromptInput 也复用,避免过滤条件散落
|
||||
2. **pill 聚焦保留**:Enter 切视图后不松焦,让 ↑↓ 连续导航,贴近官方体验
|
||||
3. **`bg_agent` 放 footerItems[0]**:确保 ↓ 溢出直接进入 selector 而非其它 pill
|
||||
4. **selector 不订阅键盘**:所有按键路由集中在 PromptInput 的 `footer:*` 分支,避免 selector 组件和 PromptInput 双重 `useInput` 的冲突
|
||||
5. **`selectedBgAgentIndex` 存 AppState 而非局部 state**:selector 和 PromptInput 分别在两棵不同子树,需要全局字段协调;该值不能从 `viewingAgentTaskId` 派生
|
||||
6. **与 `BackgroundTasksDialog` 共存**:Shift+↓ 行为完全不变,selector 是补充快捷入口;Dialog 仍管 shell / workflow / monitor_mcp 等 selector 不显示的 task 类型
|
||||
7. **fork prompt 展示层兜底**:fork prompt 不依赖 boilerplate 自身渲染,统一在 `displayedAgentMessages` 中合成独立用户消息;普通 subagent 不走该分支,避免 prompt 重复
|
||||
|
||||
## 五、关键 API 复用
|
||||
|
||||
| 官方已有能力 | selector 如何使用 |
|
||||
|---|---|
|
||||
| `AppState.tasks` | 单一数据源,无需 file watcher / output JSONL 订阅 |
|
||||
| `registerAsyncAgent` | `/fork` 和 AgentTool 共用,selector 不区分来源 |
|
||||
| `enterTeammateView(id)` | Enter 时调用,负责 retain + disk bootstrap |
|
||||
| `exitTeammateView` | Enter 选中 `main` 时调用 |
|
||||
| `release(task)` + `PANEL_GRACE_MS` | 30s keep-alive,selector 自动生效 |
|
||||
| `useElapsedTime` | 每行时长显示,非 running 自动停 interval |
|
||||
| `formatTokens` (`utils/format.ts`) | token 数 1k 缩写 |
|
||||
| `footer:up` / `footer:down` / `footer:openSelected` keybinding | 键盘路由复用 Footer context |
|
||||
|
||||
## 六、文件索引
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/hooks/useBackgroundAgentTasks.ts` | 数据过滤 hook(backgrounded local_agent + evictAfter 过滤 + startTime 排序) |
|
||||
| `src/components/tasks/BackgroundAgentSelector.tsx` | 底部 selector UI,纯展示 |
|
||||
| `src/components/PromptInput/PromptInput.tsx` | 新增 `'bg_agent'` footer pill + 对应的 `footer:up/down/openSelected` 分支 |
|
||||
| `src/state/AppStateStore.ts` | `FooterItem` 加 `'bg_agent'`;新增 `selectedBgAgentIndex` 字段 |
|
||||
| `src/main.tsx` | `getDefaultAppState` 同步初始化 `selectedBgAgentIndex: -1` |
|
||||
| `src/screens/REPL.tsx` | 在 PromptInput + SessionBackgroundHint 之后挂载 `<BackgroundAgentSelector />`;切换 agent 主视图;对 fork transcript 做 prompt 归一化 |
|
||||
| `src/components/messages/AssistantToolUseMessage.tsx` | 新增 `defaultCollapsed?: boolean` prop,为后续详情视图默认折叠工具块预留 |
|
||||
| `src/components/messages/UserTextMessage.tsx` | 识别 `<fork-boilerplate>`,交给 fork 专用 renderer 处理 |
|
||||
| `src/components/messages/UserForkBoilerplateMessage.tsx` | 将 fork boilerplate text 折叠为纯用户 prompt;作为 transcript 中原位渲染的兼容路径 |
|
||||
|
||||
## 七、已知限制
|
||||
|
||||
- `Date.now()` 在 `useBackgroundAgentTasks` 的 useMemo 里冻结于 `[tasks]` 触发时:若长时间没有新 task 变更事件,某个 terminal agent 的 grace 期过期后不会立即从 selector 消失,要等下一次 tasks 变化才刷新。在典型使用(主对话一直在产生消息)下感知不到,暂不额外加 interval。
|
||||
- Selector 当前不处理 Shell Task / Workflow / Monitor MCP 等类型——这些仍走 `BackgroundTasksDialog`(Shift+↓)管理。
|
||||
- `AssistantToolUseMessage` 的 `defaultCollapsed` prop 目前无调用方传值,保留作为后续"agent 详情视图内工具块默认折叠"扩展点。
|
||||
275
docs/features/status-line.mdx
Normal file
275
docs/features/status-line.mdx
Normal file
@@ -0,0 +1,275 @@
|
||||
---
|
||||
title: "StatusLine 底部状态栏 - 自定义 shell 渲染管线"
|
||||
description: "从源码角度解析 Claude Code 底部状态栏:自定义 shell 脚本 + JSON stdin 协议、三种触发源(event / settings / time)、debounce + abort、信任与 hook 开关、以及本仓库 refreshInterval 缺失修复。"
|
||||
keywords: ["statusLine", "状态栏", "自定义提示符", "refreshInterval", "Hooks"]
|
||||
---
|
||||
|
||||
{/* 本章目标:完整讲清 StatusLine 的渲染管线、触发模型、协议契约与安全网关,并记录本仓库相对官方版本的已知缺口与修复 */}
|
||||
|
||||
## 概述
|
||||
|
||||
StatusLine 是 Claude Code REPL 底部显示的一行自定义文本,由**用户提供的 shell 命令**渲染。主进程把运行时状态(模型、工作目录、token、限流、会话元数据等)打包成 JSON 通过 stdin 喂给脚本,脚本在 stdout 输出一行字符串,Ink 侧以 ANSI 转义渲染到 footer。
|
||||
|
||||
核心设计哲学:**语言无关 + 进程隔离 + Unix 管道**。用户可用 bash / python / node / 任意语言写脚本;脚本崩溃不影响主进程;输入输出都是纯文本,可以离线测试(`echo '{...}' | ./script.sh`)。
|
||||
|
||||
## 配置
|
||||
|
||||
`~/.claude/settings.json` 里添加 `statusLine` 字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "bash ~/.claude/statusline-command.sh",
|
||||
"refreshInterval": 1,
|
||||
"padding": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 作用 |
|
||||
|------|------|------|
|
||||
| `type` | `"command"` | 目前仅支持 command 型 |
|
||||
| `command` | `string` | shell 命令字符串;主进程用系统 shell 解释执行 |
|
||||
| `refreshInterval` | `number` (秒) | 定时刷新周期;缺省/0 表示不定时刷新 |
|
||||
| `padding` | `number` | 左右 padding,单位为 Ink cell |
|
||||
|
||||
Schema 定义在 `src/utils/settings/types.ts:550`(`statusLine` Zod object)。
|
||||
|
||||
## 渲染管线(整体图)
|
||||
|
||||
```
|
||||
┌─────────────────────── Ink 侧 ───────────────────────┐ ┌──────── 用户侧 ────────┐
|
||||
│ │ │ │
|
||||
│ buildStatusLineCommandInput() ──┐ │ │ ~/.claude/ │
|
||||
│ 收集运行时状态 │ │ │ statusline-*.sh │
|
||||
│ ▼ │ │ │
|
||||
│ executeStatusLineCommand() ─── JSON via stdin ────────────► jq '.model...' │
|
||||
│ execCommandHook() 拉起 shell │ │ 计算、格式化 │
|
||||
│ ▲ │ │ │
|
||||
│ stdout ◄──────────────────── 一行文本 ──────────────── printf '...' │
|
||||
│ │ │ │ │
|
||||
│ setAppState({ statusLineText }) ─┘ │ └────────────────────────┘
|
||||
│ zustand 存字段,组件 memo 订阅 │
|
||||
│ │
|
||||
│ <StatusLine /> → <Text><Ansi>{text}</Ansi></Text> │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Input 协议:主进程 → 脚本
|
||||
|
||||
`buildStatusLineCommandInput`(`src/components/StatusLine.tsx:53`)构造的 JSON 对象字段如下,**这是脚本可以 `jq` 读取的全部内容**:
|
||||
|
||||
| 字段 | 来源 | 备注 |
|
||||
|------|------|------|
|
||||
| `session_id` | `getSessionId()` | UUID,用于脚本侧 per-session 状态隔离 |
|
||||
| `session_name` | `getCurrentSessionTitle(sessionId)` | 用户命名的会话标题(可选) |
|
||||
| `model.id` / `model.display_name` | `getRuntimeMainLoopModel()` | 运行时真实模型(经 permission mode 降级/200k 升级) |
|
||||
| `workspace.current_dir` / `project_dir` / `added_dirs` | `getCwd()` / `getOriginalCwd()` / permission context | current_dir 随 `cd` 变化 |
|
||||
| `version` | `MACRO.VERSION` | 构建注入,如 `2.1.888` |
|
||||
| `output_style.name` | `settings.outputStyle` | 缺省 `DEFAULT_OUTPUT_STYLE_NAME` |
|
||||
| `cost.total_cost_usd` / `total_duration_ms` / `total_api_duration_ms` / `total_lines_added` / `total_lines_removed` | `cost-tracker.js` 聚合 | 会话累计 |
|
||||
| `context_window.total_input_tokens` / `total_output_tokens` | 同上 | 累计 token |
|
||||
| `context_window.context_window_size` | `getContextWindowForModel()` | 模型上下文上限 |
|
||||
| `context_window.current_usage` | `getCurrentUsage(messages)` | **最新一次 assistant message 的 usage**;含 `input_tokens` / `cache_creation_input_tokens` / `cache_read_input_tokens` / `output_tokens` |
|
||||
| `context_window.used_percentage` / `remaining_percentage` | `calculateContextPercentages()` | 0-100 浮点 |
|
||||
| `exceeds_200k_tokens` | 检查最近 assistant message | 用于 1M 上下文模型的展示 |
|
||||
| `rate_limits.five_hour` / `seven_day` | `getRawUtilization()` | `{ used_percentage, resets_at }`,来自 Claude.ai 限流 API |
|
||||
| `vim.mode` | 启用 vim 模式时 | `INSERT` / `NORMAL` / ... |
|
||||
| `agent.name` | 主线程 agent 类型 | 子 agent fork 时非空 |
|
||||
| `remote.session_id` | Bridge / Remote Control 模式 | 远程会话 |
|
||||
| `worktree` | 当前 worktree 元信息 | `name` / `path` / `branch` / `original_cwd` / `original_branch` |
|
||||
|
||||
类型签名目前在 `src/types/statusLine.ts` 是 `any` 的 stub(反编译残留),实际字段以上表为准。
|
||||
|
||||
## Output 协议:脚本 → 主进程
|
||||
|
||||
`executeStatusLineCommand`(`src/utils/hooks.ts:4752`)对脚本 stdout 做如下处理:
|
||||
|
||||
1. `trim()` 首尾空白
|
||||
2. 按 `\n` 拆行,每行再 `trim()`
|
||||
3. 空行丢弃,剩余用 `\n` 重新拼接
|
||||
|
||||
多行输出会被**保留为多行**(Ink 渲染时 `<Text>` 允许换行),但设计推荐**单行**——多行会挤占 REPL 高度,fullscreen 模式下可能挤掉 ScrollBox 行。
|
||||
|
||||
状态码约定:
|
||||
- `exit 0` + 有 stdout → 显示
|
||||
- `exit 0` + 空 stdout → 清空 statusLine(显示为空)
|
||||
- 非 0 → 忽略,保留上次内容;`logResult=true` 时 warn 级日志
|
||||
- 超时(默认 5000ms) → 忽略
|
||||
- 被 AbortController 取消 → 忽略
|
||||
|
||||
ANSI 颜色可用,Ink 通过 `<Ansi>{text}</Ansi>` 组件解析 SGR 序列。
|
||||
|
||||
## 三种触发源
|
||||
|
||||
StatusLine 的重算由**三类事件**驱动,全部经同一个 debounce 队列:
|
||||
|
||||
### 1. Event-driven(`src/components/StatusLine.tsx:275`)
|
||||
|
||||
监听这些状态变化,触发 `scheduleUpdate()`:
|
||||
|
||||
- `lastAssistantMessageId` — 新助手回复出现
|
||||
- `permissionMode` — `/mode` 切换权限模式
|
||||
- `vimMode` — vim insert/normal 切换
|
||||
- `mainLoopModel` — `/model` 切换
|
||||
|
||||
### 2. Settings-driven(`src/components/StatusLine.tsx:294`)
|
||||
|
||||
`settings.statusLine.command` 字符串变化时(热重载 settings.json),标记下一次结果 log 并立即 `doUpdate()`。
|
||||
|
||||
### 3. Time-driven(`src/components/StatusLine.tsx:292`,本仓库补丁)
|
||||
|
||||
读取 `settings.statusLine.refreshInterval`(秒),`setInterval` 每到点走一次 `scheduleUpdate()`。配置为 0 或缺省时不启定时器(零开销)。
|
||||
|
||||
> **本仓库历史缺口**:反编译出的 `StatusLine.tsx` 最初没有 Time-driven 触发路径,`refreshInterval` 字段也不在 Zod schema 里。导致脚本里 TTL 倒计时、时钟类动态内容不会秒刷,只有助手回复出现时才重算。已在 2026-05-06 补齐,细节见下方"已知缺口与修复"。
|
||||
|
||||
## Debounce + Abort
|
||||
|
||||
三种触发源都走 `scheduleUpdate`(`src/components/StatusLine.tsx:259`):
|
||||
|
||||
```
|
||||
scheduleUpdate() → setTimeout(300ms) → doUpdate()
|
||||
│
|
||||
└─ 再次 schedule 会 clearTimeout 前次
|
||||
```
|
||||
|
||||
300ms debounce 合并抖动事件(例如短时间连续切 vim/permission)。
|
||||
|
||||
`doUpdate()` 里:
|
||||
|
||||
```
|
||||
abortControllerRef.current?.abort() // 取消上一次 in-flight shell
|
||||
controller = new AbortController()
|
||||
executeStatusLineCommand(..., controller.signal, ...)
|
||||
```
|
||||
|
||||
**单飞(single-flight)语义**:任何新触发都会 abort 上一次未完成的 shell 调用,保证同一时刻最多一个子进程。这对 `refreshInterval: 1` 尤其关键——若脚本执行 > 1 秒,新 tick 到来时老进程被 kill,不会堆积。
|
||||
|
||||
## 安全网关
|
||||
|
||||
`executeStatusLineCommand`(`src/utils/hooks.ts:4752`)在执行前有**三层拦截**:
|
||||
|
||||
1. `shouldDisableAllHooksIncludingManaged()` → managed settings 全局禁用 hooks 时直接返回
|
||||
2. `shouldSkipHookDueToTrust()` → **工作区未接受信任对话框时跳过**,避免打开未知仓库时执行任意 shell 命令(RCE 防护)
|
||||
3. `shouldAllowManagedHooksOnly()` → 非 managed settings 禁用 hooks 但 managed 未禁用时,只读取 policySettings 源的 statusLine
|
||||
|
||||
组件侧配合(`src/components/StatusLine.tsx:318`):未接受 trust 时在通知中心提示 `"statusline skipped · restart to fix"`。
|
||||
|
||||
另外,`statusLineShouldDisplay`(`src/components/StatusLine.tsx:46`)在 **Kairos assistant mode** 下直接返回 false——因为那时 statusline 字段反映的是 REPL/daemon 进程状态,不是 agent 子进程在跑的东西,显示出来会误导用户。
|
||||
|
||||
## 渲染细节
|
||||
|
||||
### memo 隔离
|
||||
|
||||
```tsx
|
||||
export const StatusLine = memo(StatusLineInner)
|
||||
```
|
||||
|
||||
父组件 `PromptInputFooter` 每次 `setMessages` 都 rerender,但 `StatusLine` 的 props 只有 `lastAssistantMessageId` 会变,`memo` 阻断了无意义的重渲染。此前(未 memo 版本)一个 session 内大约 18 次冗余渲染。
|
||||
|
||||
### 订阅粒度
|
||||
|
||||
```tsx
|
||||
const statusLineText = useAppState(s => s.statusLineText)
|
||||
```
|
||||
|
||||
`useAppState` 是选择器订阅,仅在 `statusLineText` 字段变化时触发 rerender;`doUpdate()` 里还做了幂等检查(`prev.statusLineText === text` 则直接返回原 state),**文本不变就不更新 zustand**,连一次 notify 都省掉。
|
||||
|
||||
### Fullscreen 占位
|
||||
|
||||
```tsx
|
||||
{statusLineText ? (
|
||||
<Text dimColor wrap="truncate"><Ansi>{statusLineText}</Ansi></Text>
|
||||
) : isFullscreenEnvEnabled() ? (
|
||||
<Text> </Text> // 占位一行
|
||||
) : null}
|
||||
```
|
||||
|
||||
Fullscreen 模式下 footer `flexShrink:0`,statusline 从 0 行变 1 行会挤掉 ScrollBox 一行内容导致抖动。首次脚本还没返回时,用空格文本占住一行高度,脚本返回后原位替换。
|
||||
|
||||
## 内置 `/statusline` slash command
|
||||
|
||||
`src/commands/statusline.tsx` 定义了一个 **prompt 型 command**,展开成自然语言指令喂给主 Agent:
|
||||
|
||||
```
|
||||
Create an AgentTool with subagent_type "statusline-setup" and the prompt "<user-args>"
|
||||
```
|
||||
|
||||
默认 prompt 是 `"Configure my statusLine from my shell PS1 configuration"`。主 Agent 收到后会调用内置子 agent `statusline-setup`。该子 agent 权限极小:
|
||||
|
||||
- **Tools**: 仅 `Read`、`Edit`
|
||||
- **Allowed paths**: `Read(~/**)`、`Edit(~/.claude/settings.json)`
|
||||
|
||||
也就是说它**不能 Write 新文件、不能跑 Bash**。典型工作是读用户的 shell 配置、读/改 `settings.json`、增量编辑已有的 statusline 脚本。
|
||||
|
||||
## 编写自定义脚本的要点
|
||||
|
||||
1. **脚本必须无状态** — 每次 tick 主进程 fork 一次新 shell,进程内变量不跨调用保留。需要跨 tick 的状态(上次时间戳、上次 token 数)用 `~/.claude/statusline-state/<hash>.state` 文件持久化。
|
||||
2. **按 `session_id` 哈希隔离状态文件** — 多会话同时开着时共享一个 state 文件会串。典型做法:`md5(session_id) | head -c 16` 作为文件名。
|
||||
3. **防御性读取** — state 文件可能损坏/被截断,按行 read + 字段校验(数字字段用 `case "$var" in ''|*[!0-9]*) invalid ;;`)。
|
||||
4. **`refreshInterval` 不等于"脚本秒级调用"** — tick 和事件触发(新消息、模式切换)都走同一 debounce 队列,脚本实际被调用的频率介于"每 N 秒"和"每 N+0.3 秒"之间;且 abort 机制下,上一次没跑完会被 kill。
|
||||
5. **执行时间预算** — 默认 5000ms 超时;为避免 `refreshInterval=1` 时频繁超时,脚本热路径应在 100ms 内完成。重计算(curl、git log 拉取)需缓存。
|
||||
6. **颜色用 ANSI 转义** — 不要依赖 TERM 环境变量;Ink 的 `<Ansi>` 组件独立解析 SGR。
|
||||
7. **不要输出多行** — 单行文本,否则挤占 REPL 布局。
|
||||
8. **处理 `current_usage` 为 null 的情况** — 首次响应之前 `context_window.current_usage` 可能为 null,脚本应有 fallback(如读 state 里上次命中率)。
|
||||
|
||||
### 示例:Cache 命中率 + TTL 倒计时
|
||||
|
||||
本仓库默认安装了一个示例脚本 `~/.claude/statusline-command.sh`(用户侧),输出格式 `<dir> | <model> | ctx:N% | Cache 97% 59:43`:
|
||||
|
||||
- **命中率** = `cache_read / (input + cache_creation + cache_read)`(取自 `current_usage`)
|
||||
- **TTL** 从上次响应倒数 60 分钟,**只在 token signature 变化时重置时间戳**,避免秒级 tick 把 TTL 一直锁在 60:00
|
||||
- **颜色分段** — 命中率 ≥50% 绿 / <50% 灰;TTL 0-20m 绿 / 20-40m 黄 / 40-55m 红 / 最后 5m 闪红 / 过期 `exp` 灰
|
||||
- **Per-session state** — `~/.claude/statusline-state/<md5(session_id)[:16]>.state` 三行(signature、timestamp、hit),读前做 numeric 校验
|
||||
- **Fallback** — `current_usage` 为 null 时读 state 显示上次命中率
|
||||
|
||||
> 该脚本配合 `refreshInterval: 1` 即可秒刷 TTL,前提是 `refreshInterval` 触发路径已实现(见下节)。
|
||||
|
||||
## 已知缺口与修复(本仓库)
|
||||
|
||||
反编译版的 `StatusLine.tsx` 存在一处功能缺口:
|
||||
|
||||
| 项 | 官方 Claude Code | 本仓库原始 | 本仓库现状 |
|
||||
|----|-----------------|-----------|-----------|
|
||||
| `refreshInterval` Zod 字段 | ✅ 有 | ❌ 无 | ✅ 已补 |
|
||||
| Time-driven `setInterval` 触发 | ✅ 有 | ❌ 无 | ✅ 已补 |
|
||||
| Event-driven 触发 | ✅ 有 | ✅ 有 | — |
|
||||
| Settings-driven 触发 | ✅ 有 | ✅ 有 | — |
|
||||
| Debounce + Abort | ✅ 有 | ✅ 有 | — |
|
||||
| Trust 网关 | ✅ 有 | ✅ 有 | — |
|
||||
|
||||
修复(2026-05-06):
|
||||
|
||||
**1. `src/utils/settings/types.ts:554`** — statusLine schema 新增 `refreshInterval: z.number().optional()`,让字段进入类型系统而非被当未知键忽略。
|
||||
|
||||
**2. `src/components/StatusLine.tsx:292`** — 新增 Time-driven useEffect:
|
||||
|
||||
```tsx
|
||||
const refreshIntervalMs = (settings?.statusLine?.refreshInterval ?? 0) * 1000;
|
||||
useEffect(() => {
|
||||
if (refreshIntervalMs <= 0) return;
|
||||
const id = setInterval(() => scheduleUpdate(), refreshIntervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [refreshIntervalMs, scheduleUpdate]);
|
||||
```
|
||||
|
||||
关键点:
|
||||
- 走 `scheduleUpdate`(非 `doUpdate`)复用 300ms debounce,interval + event 双触发不会双跑
|
||||
- `refreshIntervalMs <= 0` 时不启定时器,对未启用该字段的用户零开销
|
||||
- 依赖数组含 `refreshIntervalMs`,settings 热重载会自动清理旧 interval 重建新的
|
||||
|
||||
**静默失效特征**:修复前 settings.json 写 `refreshInterval: 1` 无任何报错——JSON 解析通过,Zod schema 默认 strip 多余字段,官方文档又说支持这个字段,用户很容易以为生效了而没意识到 TTL/时钟类输出根本没秒刷。这是反编译版本的典型"文档与实现不一致"。
|
||||
|
||||
## 相关源码
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `src/components/StatusLine.tsx` | UI 组件、触发逻辑、buildStatusLineCommandInput |
|
||||
| `src/utils/hooks.ts:4752` | `executeStatusLineCommand`:shell 执行、输出处理、安全网关 |
|
||||
| `src/utils/settings/types.ts:550` | `statusLine` Zod schema |
|
||||
| `src/types/statusLine.ts` | `StatusLineCommandInput` 类型(当前为 stub) |
|
||||
| `src/commands/statusline.tsx` | `/statusline` slash command 定义 |
|
||||
| `src/state/AppStateStore.ts:95` | `statusLineText` 字段声明 |
|
||||
| `src/components/PromptInput/PromptInputFooter.tsx:159` | StatusLine 组件挂载点 |
|
||||
152
learn/LEARN.md
152
learn/LEARN.md
@@ -1,152 +0,0 @@
|
||||
# Claude Code 源码学习路线
|
||||
|
||||
> 基于反编译版 Claude Code CLI (v2.1.888) 的源码学习跟踪
|
||||
>
|
||||
> 各阶段详细笔记见同目录下的 `phase-*.md` 文件
|
||||
|
||||
## 第一阶段:启动流程(入口链路) ✅
|
||||
|
||||
详细笔记:[phase-1-startup-flow.md](phase-1-startup-flow.md)
|
||||
|
||||
理解程序从命令行启动到用户看到交互界面的完整路径。
|
||||
|
||||
- [x] `src/entrypoints/cli.tsx` — 真正入口,polyfill 注入 + 快速路径分发
|
||||
- [x] 全局 polyfill:`feature()` 永远返回 false、`MACRO` 全局对象、`BUILD_*` 常量
|
||||
- [x] 快速路径设计:按开销从低到高检查,能早返回就早返回
|
||||
- [x] 动态 import 模式:`await import()` 延迟加载,减少启动时间
|
||||
- [x] 最终出口:`import("../main.jsx")` → `cliMain()`
|
||||
- [x] `src/main.tsx` — Commander.js CLI 定义,重型初始化(4683 行)
|
||||
- [x] 三段式结构:辅助函数(1-584) → main()(585-856) → run()(884-4683)
|
||||
- [x] side-effect import:profileCheckpoint、startMdmRawRead、startKeychainPrefetch 并行预加载
|
||||
- [x] preAction 钩子:MDM 等待、init()、迁移、远程设置
|
||||
- [x] Commander 参数定义:40+ CLI 选项
|
||||
- [x] action handler(2800 行):参数解析 → 服务初始化 → showSetupScreens → launchRepl()
|
||||
- [x] --print 分支走 print.ts;交互分支走 launchRepl()(7 个场景分支)
|
||||
- [x] 子命令注册:mcp/auth/plugin/doctor/update/install 等
|
||||
- [x] `src/replLauncher.tsx` — 桥梁(22 行),组合 `<App>` + `<REPL>` 渲染到终端
|
||||
- [x] `src/screens/REPL.tsx` — 交互式 REPL 界面(5009 行)
|
||||
- [x] Props:commands、tools、messages、systemPrompt、thinkingConfig 等
|
||||
- [x] 50+ 状态:messages、inputValue、screen、streamingText、queryGuard 等
|
||||
- [x] 核心数据流:onSubmit → handlePromptSubmit → onQuery → onQueryImpl → query() → onQueryEvent
|
||||
- [x] QueryGuard 并发控制:idle → running → idle,防止重复查询
|
||||
- [x] 渲染:Transcript 模式(只读历史)/ Prompt 模式(Messages + PermissionRequest + PromptInput)
|
||||
|
||||
**数据流**:`bun run dev` → `package.json scripts.dev` → `bun run src/entrypoints/cli.tsx` → 快速路径检查 → `main.tsx:main()` → `launchRepl()` → `<App><REPL /></App>`
|
||||
|
||||
---
|
||||
|
||||
## 第二阶段:核心对话循环 ✅
|
||||
|
||||
详细笔记:[phase-2-conversation-loop.md](phase-2-conversation-loop.md)
|
||||
|
||||
理解用户发一句话后,如何变成 API 请求、如何处理流式响应和工具调用。
|
||||
|
||||
- [x] `src/query.ts` — 核心查询循环(1732 行)
|
||||
- [x] `query()` AsyncGenerator 入口,委托给 `queryLoop()`
|
||||
- [x] `queryLoop()` — while(true) 主循环,State 对象管理迭代状态
|
||||
- [x] 消息预处理(autocompact、compact boundary)
|
||||
- [x] `deps.callModel()` → 流式 API 调用
|
||||
- [x] StreamingToolExecutor — API 流式返回时并行执行工具
|
||||
- [x] 工具调用循环(tool use → 执行 → result → continue)
|
||||
- [x] 错误恢复(prompt-too-long、max_output_tokens 升级+多轮恢复)
|
||||
- [x] 模型降级(FallbackTriggeredError → 切换 fallbackModel)
|
||||
- [x] Withheld 消息模式(暂扣可恢复错误)
|
||||
- [x] `src/QueryEngine.ts` — 高层编排器(1320 行)
|
||||
- [x] QueryEngine 类 — 一个 conversation 一个实例
|
||||
- [x] `submitMessage()` — 处理用户输入 → 调用 `query()` → 消费事件流
|
||||
- [x] SDK/print 模式专用(REPL 直接调用 query())
|
||||
- [x] 会话持久化(recordTranscript)
|
||||
- [x] Usage 跟踪、权限拒绝记录
|
||||
- [x] `ask()` 便捷包装函数
|
||||
- [x] `src/services/api/claude.ts` — API 客户端(3420 行)
|
||||
- [x] `queryModelWithStreaming` / `queryModelWithoutStreaming` — 两个公开入口
|
||||
- [x] `queryModel()` — 核心私有函数(2400 行)
|
||||
- [x] 请求参数组装(system prompt、betas、tools、cache control)
|
||||
- [x] Anthropic SDK 流式调用(`anthropic.beta.messages.stream()`)
|
||||
- [x] `BetaRawMessageStreamEvent` 事件处理(message_start/content_block_*/message_delta/stop)
|
||||
- [x] withRetry 重试策略(429/500/529 + 模型降级)
|
||||
- [x] Prompt Caching 策略(ephemeral/1h TTL/global scope)
|
||||
- [x] 多 provider 支持(Anthropic / Bedrock / Vertex / Azure)
|
||||
|
||||
**数据流**:REPL.onSubmit → handlePromptSubmit → onQuery → onQueryImpl → `query()` AsyncGenerator → `queryLoop()` while(true) → `deps.callModel()` → `claude.ts queryModel()` → `anthropic.beta.messages.stream()` → 流式事件 → 收集 tool_use → 执行工具 → 结果追加到 messages → continue → 无工具调用时 return
|
||||
|
||||
---
|
||||
|
||||
## 第三阶段:工具系统
|
||||
|
||||
理解 Claude 如何定义、注册、调用工具。先读框架,再挑具体工具。
|
||||
|
||||
- [ ] `src/Tool.ts` — Tool 接口定义
|
||||
- [ ] `Tool` 类型结构(name、description、inputSchema、call)
|
||||
- [ ] `findToolByName`、`toolMatchesName` 工具函数
|
||||
- [ ] `src/tools.ts` — 工具注册表
|
||||
- [ ] 工具列表组装逻辑
|
||||
- [ ] 条件加载(feature flag、USER_TYPE)
|
||||
- [ ] 具体工具实现(挑选 2-3 个深入阅读):
|
||||
- [ ] `src/tools/BashTool/` — 执行 shell 命令,最常用的工具
|
||||
- [ ] `src/tools/FileReadTool/` — 读取文件,简单直观,适合理解工具模式
|
||||
- [ ] `src/tools/FileEditTool/` — 编辑文件,理解 diff/patch 机制
|
||||
- [ ] `src/tools/AgentTool/` — 子 Agent 机制,较复杂但核心
|
||||
|
||||
---
|
||||
|
||||
## 第四阶段:上下文与系统提示
|
||||
|
||||
理解 Claude 如何"知道"项目信息、用户偏好等上下文。
|
||||
|
||||
- [ ] `src/context.ts` — 系统/用户上下文构建
|
||||
- [ ] git 状态注入
|
||||
- [ ] CLAUDE.md 内容加载
|
||||
- [ ] 内存文件(memory)注入
|
||||
- [ ] 日期、平台等环境信息
|
||||
- [ ] `src/utils/claudemd.ts` — CLAUDE.md 发现与加载
|
||||
- [ ] 项目层级搜索逻辑
|
||||
- [ ] 多级 CLAUDE.md 合并
|
||||
|
||||
---
|
||||
|
||||
## 第五阶段:UI 层(按兴趣选读)
|
||||
|
||||
理解终端 UI 的渲染机制(React/Ink)。
|
||||
|
||||
- [ ] `src/components/App.tsx` — 根组件,Provider 注入
|
||||
- [ ] `src/state/AppState.tsx` — 全局状态类型与 Context
|
||||
- [ ] `src/components/permissions/` — 工具权限审批 UI
|
||||
- [ ] `src/components/messages/` — 消息渲染组件
|
||||
|
||||
---
|
||||
|
||||
## 第六阶段:外围系统(按需探索)
|
||||
|
||||
- [ ] `src/services/mcp/` — MCP 协议(Model Context Protocol)
|
||||
- [ ] `src/skills/` — 技能系统(/commit 等斜杠命令)
|
||||
- [ ] `src/commands/` — CLI 子命令
|
||||
- [ ] `src/tasks/` — 后台任务系统
|
||||
- [ ] `src/utils/model/providers.ts` — 多 provider 选择逻辑
|
||||
|
||||
---
|
||||
|
||||
## 学习笔记
|
||||
|
||||
### 关键设计模式
|
||||
|
||||
| 模式 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| 快速路径 | cli.tsx | 按开销从低到高逐级检查,减少不必要的模块加载 |
|
||||
| 动态 import | cli.tsx / main.tsx | `await import()` 延迟加载,优化启动时间 |
|
||||
| feature flag | 全局 | `feature()` 永远返回 false,所有内部功能禁用 |
|
||||
| React/Ink | UI 层 | 用 React 组件模型渲染终端 UI |
|
||||
| 工具循环 | query.ts | AI 返回工具调用 → 执行 → 结果回传 → 继续,直到无工具调用 |
|
||||
| AsyncGenerator 链 | query.ts → claude.ts | `yield*` 透传事件流,形成管道 |
|
||||
| State 对象 | query.ts queryLoop | 循环间通过不可变 State + transition 字段传递状态 |
|
||||
| StreamingToolExecutor | query.ts | API 流式返回时并行执行工具 |
|
||||
| Withheld 消息 | query.ts | 暂扣可恢复错误,恢复成功则吞掉 |
|
||||
| withRetry | claude.ts | 429/500/529 自动重试 + 模型降级 |
|
||||
| Prompt Caching | claude.ts | 缓存系统提示和历史消息,减少 token 消耗 |
|
||||
|
||||
### 需要忽略的内容
|
||||
|
||||
- `_c()` 调用 — React Compiler 反编译产物
|
||||
- `feature('...')` 后面的代码块 — 全部是死代码
|
||||
- tsc 类型错误 — 反编译导致,不影响 Bun 运行
|
||||
- `packages/@ant/` — stub 包,无实际实现
|
||||
@@ -1,273 +0,0 @@
|
||||
# 第一阶段 Q&A
|
||||
|
||||
## Q1:cli.tsx 的快速路径分发具体在做什么?
|
||||
|
||||
**核心思想**:根据用户输入的命令参数,尽早决定走哪条路,避免加载不需要的代码。cli.tsx 充当一个轻量级路由器,把简单请求就地处理,只有真正需要完整 CLI 时才加载 main.tsx。
|
||||
|
||||
### 场景对比
|
||||
|
||||
#### 场景 1:`claude --version`(命中快速路径)
|
||||
|
||||
```
|
||||
cli.tsx main() 开始执行
|
||||
├── args = ["--version"]
|
||||
├── 命中第 64 行: args[0] === "--version" ✅
|
||||
├── console.log("2.1.888 (Claude Code)")
|
||||
└── return ← 立即退出,零 import,~10ms
|
||||
```
|
||||
|
||||
#### 场景 2:`claude --claude-in-chrome-mcp`(命中中间路径)
|
||||
|
||||
```
|
||||
cli.tsx main() 开始执行
|
||||
├── 第 64 行: --version? ❌
|
||||
├── 第 75 行: 加载 profileCheckpoint(仅此一个 import)
|
||||
├── 第 81 行: feature("DUMP_SYSTEM_PROMPT") → false ❌
|
||||
├── 第 95 行: --claude-in-chrome-mcp? ✅ 命中
|
||||
├── await import("../utils/claudeInChrome/mcpServer.js") ← 只加载这一个模块
|
||||
└── return ← 没有加载 main.tsx 的 200+ import
|
||||
```
|
||||
|
||||
#### 场景 3:`claude`(无参数,最常见,全部未命中)
|
||||
|
||||
```
|
||||
cli.tsx main() 开始执行
|
||||
├── --version? ❌
|
||||
├── profileCheckpoint 加载
|
||||
├── feature(DUMP)? ❌ (feature=false)
|
||||
├── --chrome-mcp? ❌
|
||||
├── --chrome-native? ❌
|
||||
├── feature(CHICAGO)? ❌ (feature=false)
|
||||
├── feature(DAEMON)? ❌ (feature=false)
|
||||
├── feature(BRIDGE)? ❌ (feature=false)
|
||||
├── ... 所有快速路径逐一检查,全部未命中
|
||||
│
|
||||
├── 走到第 310 行 ← 最终出口
|
||||
├── await import("../main.jsx") ← 加载完整 CLI(200+ import,~135ms)
|
||||
└── await cliMain() ← 进入 main.tsx 重型初始化
|
||||
```
|
||||
|
||||
### 性能对比
|
||||
|
||||
| 方式 | `claude --version` 耗时 |
|
||||
|------|------------------------|
|
||||
| 无快速路径(全部走 main.tsx) | ~200ms(加载 200+ import → 初始化 Commander → 解析参数 → 打印) |
|
||||
| 有快速路径(cli.tsx 拦截) | ~10ms(读 args → 打印 → 退出) |
|
||||
|
||||
### feature() 的加速作用
|
||||
|
||||
大量快速路径被 `feature()` 守护:
|
||||
|
||||
```ts
|
||||
if (feature("DAEMON") && args[0] === "daemon") { ... }
|
||||
```
|
||||
|
||||
`feature()` 返回 false → `&&` 短路求值 → 连 `args[0]` 都不检查,直接跳过。在反编译版本中这些路径等于不存在,进一步加速了"全部没命中 → 走默认路径"的过程。
|
||||
|
||||
---
|
||||
|
||||
## Q2:main.tsx 中不同命令的具体执行流程是怎样的?
|
||||
|
||||
所有命令都会经过 main() → run(),但在 run() 内部根据 Commander 路由到不同分支。
|
||||
|
||||
### 场景 1:`claude`(无参数 — 启动交互 REPL)
|
||||
|
||||
最常见的场景,走完整条主命令路径:
|
||||
|
||||
```
|
||||
main() (第 585 行)
|
||||
├── 信号处理注册(SIGINT、exit)
|
||||
├── feature flag 路径全部跳过
|
||||
├── isNonInteractive = false(有 TTY,没有 -p)
|
||||
├── clientType = 'cli'
|
||||
└── await run()
|
||||
│
|
||||
▼
|
||||
run() (第 884 行)
|
||||
├── Commander 初始化 + preAction 钩子 + 主命令选项注册
|
||||
├── isPrintMode = false → 注册所有子命令
|
||||
└── program.parseAsync(process.argv)
|
||||
│ Commander 匹配到主命令,先执行 preAction
|
||||
▼
|
||||
preAction (第 907 行)
|
||||
├── await ensureMdmSettingsLoaded() ← 等 side-effect import 的子进程完成
|
||||
├── await ensureKeychainPrefetchCompleted() ← 等 keychain 预读完成
|
||||
├── await init() ← 遥测、配置、信任
|
||||
├── initSinks() ← 分析日志
|
||||
├── runMigrations() ← 数据迁移
|
||||
└── loadRemoteManagedSettings() / loadPolicyLimits() ← 非阻塞
|
||||
│ 然后执行 action handler
|
||||
▼
|
||||
action(undefined, options) (第 1007 行) ← prompt = undefined
|
||||
├── [参数解析] permissionMode, model, thinkingConfig...
|
||||
├── [工具加载] tools = getTools(toolPermissionContext)
|
||||
├── [并行初始化]
|
||||
│ ├── setup() ← worktree、CWD
|
||||
│ ├── getCommands() ← 加载斜杠命令
|
||||
│ └── getAgentDefinitionsWithOverrides() ← 加载 agent 定义
|
||||
├── [MCP 连接] 连接配置的 MCP 服务器
|
||||
├── [构建初始状态] initialState = { tools, mcp, permissions, ... }
|
||||
│
|
||||
├── [UI 初始化](交互模式专属)
|
||||
│ ├── createRoot() ← 创建 Ink 渲染根节点
|
||||
│ └── showSetupScreens() ← 信任对话框 / OAuth / 引导
|
||||
│
|
||||
├── [后续初始化] LSP、插件版本、session 注册
|
||||
│
|
||||
└── 默认分支 (第 3760 行) ← 没有 --continue/--resume/--print
|
||||
└── await launchRepl(root, {
|
||||
initialState
|
||||
}, {
|
||||
...sessionConfig,
|
||||
initialMessages: undefined ← 全新对话,无历史消息
|
||||
}, renderAndRun)
|
||||
│
|
||||
▼
|
||||
REPL.tsx 渲染,用户看到空白对话界面
|
||||
```
|
||||
|
||||
### 场景 2:`echo "explain this" | claude -p`(管道/非交互模式)
|
||||
|
||||
```
|
||||
main() →
|
||||
├── isNonInteractive = true(-p 标志 + stdin 不是 TTY)
|
||||
├── clientType = 'sdk-cli'
|
||||
└── run()
|
||||
│
|
||||
▼
|
||||
run()
|
||||
├── Commander 初始化 + preAction + 主命令选项
|
||||
├── isPrintMode = true
|
||||
│ → ★ 跳过所有子命令注册(节省 ~65ms)
|
||||
└── program.parseAsync() ← 直接解析,Commander 路由到主命令 action
|
||||
│
|
||||
▼
|
||||
preAction → init、迁移等(同场景 1)
|
||||
│
|
||||
▼
|
||||
action("", { print: true, ... })
|
||||
├── inputPrompt = await getInputPrompt("")
|
||||
│ ├── stdin.isTTY = false → 从 stdin 读数据
|
||||
│ ├── 等待最多 3s 读入: "explain this"
|
||||
│ └── 返回 "explain this"
|
||||
├── tools = getTools()
|
||||
├── setup() + getCommands()(并行)
|
||||
│
|
||||
├── isNonInteractiveSession = true → 走 --print 分支(第 2584 行)
|
||||
│ ├── applyConfigEnvironmentVariables() ← -p 模式信任隐含
|
||||
│ ├── 构建 headlessInitialState(无 UI)
|
||||
│ ├── headlessStore = createStore(headlessInitialState)
|
||||
│ │
|
||||
│ ├── await import('src/cli/print.js')
|
||||
│ └── runHeadless(inputPrompt, ...) ★ 不走 REPL
|
||||
│ ├── 发送 API 请求
|
||||
│ ├── 流式输出到 stdout
|
||||
│ └── 完成后 process.exit()
|
||||
│
|
||||
└── ← 不走 createRoot()、showSetupScreens()、launchRepl()
|
||||
```
|
||||
|
||||
**关键差异**:
|
||||
- 检测到 `-p` 后跳过子命令注册(节省 ~65ms)
|
||||
- 不创建 Ink UI,不调用 `showSetupScreens()`
|
||||
- 从 stdin 读取输入(`getInputPrompt` 第 857 行)
|
||||
- 走 `print.js` 路径直接执行查询输出到 stdout
|
||||
|
||||
### 场景 3:`claude -c`(继续最近对话)
|
||||
|
||||
```
|
||||
... main() → run() → preAction → action(前半部分同场景 1)
|
||||
│
|
||||
▼
|
||||
action(undefined, { continue: true, ... })
|
||||
├── [参数解析 + 工具加载 + 并行初始化 + UI 初始化](同场景 1)
|
||||
│
|
||||
├── options.continue = true → 命中第 3101 行
|
||||
│ ├── clearSessionCaches() ← 清除过期缓存
|
||||
│ ├── result = await loadConversationForResume()
|
||||
│ │ └── 从 ~/.claude/projects/<cwd>/ 读最近的会话 JSONL
|
||||
│ │
|
||||
│ ├── result 为 null? → exitWithError("No conversation found")
|
||||
│ │
|
||||
│ ├── loaded = await processResumedConversation(result)
|
||||
│ │ ├── 解析 JSONL → messages[]
|
||||
│ │ ├── 恢复文件历史快照
|
||||
│ │ └── 重建 initialState
|
||||
│ │
|
||||
│ └── await launchRepl(root, {
|
||||
│ initialState: loaded.initialState
|
||||
│ }, {
|
||||
│ ...sessionConfig,
|
||||
│ initialMessages: loaded.messages, ★ 带上历史消息
|
||||
│ initialFileHistorySnapshots: loaded.fileHistorySnapshots,
|
||||
│ initialAgentName: loaded.agentName
|
||||
│ }, renderAndRun)
|
||||
│ │
|
||||
│ ▼
|
||||
│ REPL.tsx 渲染,显示历史对话,用户继续聊天
|
||||
│
|
||||
└── ← 其他分支不执行
|
||||
```
|
||||
|
||||
**关键差异**:`initialMessages` 有值(历史消息),REPL 启动时会渲染之前的对话内容。
|
||||
|
||||
### 场景 4:`claude mcp list`(子命令)
|
||||
|
||||
```
|
||||
main() → run()
|
||||
│
|
||||
▼
|
||||
run()
|
||||
├── Commander 初始化 + preAction 钩子
|
||||
├── 注册主命令 .action(...)
|
||||
├── isPrintMode = false → 注册所有子命令
|
||||
│ ├── program.command('mcp') (第 3894 行)
|
||||
│ │ ├── mcp.command('serve').action(...)
|
||||
│ │ ├── mcp.command('add').action(...)
|
||||
│ │ ├── mcp.command('list').action(async () => { ★
|
||||
│ │ │ const { mcpListHandler } = await import('./cli/handlers/mcp.js');
|
||||
│ │ │ await mcpListHandler();
|
||||
│ │ │ })
|
||||
│ │ └── ...
|
||||
│ ├── program.command('auth')
|
||||
│ ├── program.command('doctor')
|
||||
│ └── ...
|
||||
│
|
||||
└── program.parseAsync(["node", "claude", "mcp", "list"])
|
||||
│ Commander 匹配到 mcp → list
|
||||
▼
|
||||
preAction (第 907 行) ← 子命令也触发 preAction
|
||||
├── await init()
|
||||
├── initSinks()
|
||||
├── runMigrations()
|
||||
└── ...
|
||||
│
|
||||
▼ 执行子命令自己的 action(不走主命令 action)
|
||||
mcp list action
|
||||
├── await import('./cli/handlers/mcp.js')
|
||||
└── await mcpListHandler()
|
||||
├── 读取 MCP 配置(user/project/local 三级)
|
||||
├── 连接每个服务器做健康检查
|
||||
├── 格式化输出到终端
|
||||
└── 退出
|
||||
|
||||
← 主命令的 action handler 完全不执行
|
||||
← 没有 REPL、没有 Ink UI、没有 showSetupScreens
|
||||
```
|
||||
|
||||
**关键差异**:
|
||||
- Commander 路由到子命令,**主命令 action 完全跳过**
|
||||
- `preAction` 仍然执行(基础初始化所有命令都需要)
|
||||
- 子命令有自己独立的轻量 action
|
||||
|
||||
### 四种场景对比
|
||||
|
||||
| | `claude` | `claude -p` | `claude -c` | `claude mcp list` |
|
||||
|---|---------|------------|------------|-------------------|
|
||||
| preAction | 执行 | 执行 | 执行 | 执行 |
|
||||
| 主命令 action | 执行 | 执行 | 执行 | **跳过** |
|
||||
| 子命令注册 | 注册 | **跳过** | 注册 | 注册 |
|
||||
| showSetupScreens | 执行 | **跳过** | 执行 | **跳过** |
|
||||
| createRoot (Ink) | 执行 | **跳过** | 执行 | **跳过** |
|
||||
| 加载历史消息 | 否 | 否 | **是** | 否 |
|
||||
| 最终出口 | launchRepl | print.js | launchRepl | 子命令 action |
|
||||
@@ -1,597 +0,0 @@
|
||||
# 第一阶段:启动流程详解
|
||||
|
||||
> 从 `bun run dev` 到用户看到交互界面的完整路径
|
||||
|
||||
## 启动链路总览
|
||||
|
||||
```
|
||||
bun run dev
|
||||
→ package.json scripts.dev: "bun run src/entrypoints/cli.tsx"
|
||||
→ cli.tsx: polyfill 注入 + 快速路径检查
|
||||
→ import("../main.jsx") → cliMain()
|
||||
→ main.tsx: main() → run()
|
||||
→ Commander 参数解析 → preAction 钩子
|
||||
→ action handler: 服务初始化 → showSetupScreens
|
||||
→ launchRepl()
|
||||
→ replLauncher.tsx: <App><REPL /></App>
|
||||
→ REPL.tsx: 渲染交互界面,等待用户输入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. cli.tsx(321 行)— 入口与快速路径分发
|
||||
|
||||
**文件路径**: `src/entrypoints/cli.tsx`
|
||||
|
||||
### 1.1 全局 Polyfill(第 1-53 行)
|
||||
|
||||
模块加载时立即执行的 side-effect,在 `main()` 之前运行。
|
||||
|
||||
#### feature() 桩函数(第 3 行)
|
||||
|
||||
```ts
|
||||
const feature = (_name: string) => false;
|
||||
```
|
||||
|
||||
原版 Claude Code 构建时,Bun bundler 通过 `bun:bundle` 提供 `feature()` 函数,用于**编译时 feature flag**(类似 C 的 `#ifdef`)。反编译版没有构建流程,所以直接定义为永远返回 `false`。
|
||||
|
||||
**效果**:所有 Anthropic 内部功能分支全部禁用,包括:
|
||||
- `COORDINATOR_MODE` — 协调器模式
|
||||
- `KAIROS` — 助手模式
|
||||
- `DAEMON` — 后台守护进程
|
||||
- `BRIDGE_MODE` — 远程控制
|
||||
- `SSH_REMOTE` — SSH 远程
|
||||
- `BG_SESSIONS` — 后台会话
|
||||
- ... 等 20+ 个 flag
|
||||
|
||||
#### MACRO 全局对象(第 4-14 行)
|
||||
|
||||
```ts
|
||||
globalThis.MACRO = {
|
||||
VERSION: "2.1.888",
|
||||
BUILD_TIME: new Date().toISOString(),
|
||||
FEEDBACK_CHANNEL: "",
|
||||
ISSUES_EXPLAINER: "",
|
||||
NATIVE_PACKAGE_URL: "",
|
||||
PACKAGE_URL: "",
|
||||
VERSION_CHANGELOG: "",
|
||||
};
|
||||
```
|
||||
|
||||
原版构建时 Bun 会把这些值内联到代码里。这里模拟注入,让后续代码读 `MACRO.VERSION` 时能拿到值。
|
||||
|
||||
#### 构建常量(第 16-18 行)
|
||||
|
||||
```ts
|
||||
BUILD_TARGET = "external"; // 标记为"外部"构建(非 Anthropic 内部)
|
||||
BUILD_ENV = "production"; // 生产环境
|
||||
INTERFACE_TYPE = "stdio"; // 标准输入输出模式
|
||||
```
|
||||
|
||||
这三个全局变量在代码各处被读取,用来区分运行环境。`"external"` 意味着很多 `("external" as string) === 'ant'` 的检查会返回 false。
|
||||
|
||||
#### 环境修补(第 22-33 行)
|
||||
|
||||
- 禁用 corepack 自动 pin(防止污染 package.json)
|
||||
- 远程模式下设置 Node.js 堆内存上限 8GB
|
||||
|
||||
#### ABLATION_BASELINE(第 40-53 行)
|
||||
|
||||
```ts
|
||||
if (feature("ABLATION_BASELINE") && ...) { ... }
|
||||
```
|
||||
|
||||
`feature()` 返回 false,**永远不执行**。Anthropic 内部 A/B 测试代码。
|
||||
|
||||
### 1.2 main() 函数(第 60-317 行)
|
||||
|
||||
设计模式:**分层快速路径(fast path cascading)**——按开销从低到高逐级检查,命中即返回。
|
||||
|
||||
#### 快速路径列表
|
||||
|
||||
| 优先级 | 行号 | 检查条件 | 功能 | 开销 | 可执行 |
|
||||
|--------|------|---------|------|------|--------|
|
||||
| 1 | 64-72 | `--version` / `-v` | 打印版本号退出 | **零 import** | 是 |
|
||||
| 2 | 81-94 | `feature("DUMP_SYSTEM_PROMPT")` | 导出系统提示 | - | 否(flag) |
|
||||
| 3 | 95-99 | `--claude-in-chrome-mcp` | Chrome MCP 服务 | 动态 import | 是 |
|
||||
| 4 | 101-105 | `--chrome-native-host` | Chrome Native Host | 动态 import | 是 |
|
||||
| 5 | 108-116 | `feature("CHICAGO_MCP")` | Computer Use MCP | - | 否(flag) |
|
||||
| 6 | 123-127 | `feature("DAEMON")` | Daemon Worker | - | 否(flag) |
|
||||
| 7 | 133-178 | `feature("BRIDGE_MODE")` | 远程控制 | - | 否(flag) |
|
||||
| 8 | 181-190 | `feature("DAEMON")` | Daemon 主进程 | - | 否(flag) |
|
||||
| 9 | 195-225 | `feature("BG_SESSIONS")` | ps/logs/attach/kill | - | 否(flag) |
|
||||
| 10 | 228-240 | `feature("TEMPLATES")` | 模板任务 | - | 否(flag) |
|
||||
| 11 | 244-253 | `feature("BYOC_ENVIRONMENT_RUNNER")` | BYOC 运行器 | - | 否(flag) |
|
||||
| 12 | 258-264 | `feature("SELF_HOSTED_RUNNER")` | 自托管运行器 | - | 否(flag) |
|
||||
| 13 | 267-293 | `--tmux` + `--worktree` | tmux worktree | 动态 import | 是 |
|
||||
|
||||
#### 参数修正(第 296-307 行)
|
||||
|
||||
```ts
|
||||
// --update/--upgrade → 重写为 update 子命令
|
||||
if (args[0] === "--update") process.argv = [..., "update"];
|
||||
// --bare → 设置简单模式环境变量
|
||||
if (args.includes("--bare")) process.env.CLAUDE_CODE_SIMPLE = "1";
|
||||
```
|
||||
|
||||
#### 最终出口(第 310-316 行)
|
||||
|
||||
```ts
|
||||
const { startCapturingEarlyInput } = await import("../utils/earlyInput.js");
|
||||
startCapturingEarlyInput(); // 捕获用户提前输入的内容
|
||||
const { main: cliMain } = await import("../main.jsx");
|
||||
await cliMain(); // 进入 main.tsx 重型初始化
|
||||
```
|
||||
|
||||
所有快速路径都没命中时(99% 的情况),才走到这里。
|
||||
|
||||
### 1.3 启动(第 320 行)
|
||||
|
||||
```ts
|
||||
void main();
|
||||
```
|
||||
|
||||
`void` 表示不关心 Promise 返回值。
|
||||
|
||||
### 1.4 关键设计思想
|
||||
|
||||
- **快速路径**:`--version` 零开销返回,不加载任何模块
|
||||
- **动态 import**:`await import()` 替代静态 import,每条路径只加载自己需要的模块
|
||||
- **feature flag 过滤**:`feature()` 返回 false 使大量内部功能成为死代码
|
||||
|
||||
---
|
||||
|
||||
## 2. main.tsx(4683 行)— 重型初始化与 Commander CLI
|
||||
|
||||
**文件路径**: `src/main.tsx`
|
||||
|
||||
整个项目最大的单文件,但结构清晰:**辅助函数 → main() → run()**。
|
||||
|
||||
### 2.1 Import 区(第 1-215 行)
|
||||
|
||||
200+ 行 import,加载几乎所有子系统。关键的是前三个 **side-effect import**(import 即执行):
|
||||
|
||||
```ts
|
||||
// 第 9 行:记录时间戳
|
||||
profileCheckpoint('main_tsx_entry');
|
||||
|
||||
// 第 16 行:启动 MDM 子进程读取(macOS plutil)
|
||||
startMdmRawRead();
|
||||
|
||||
// 第 20 行:启动 keychain 预读取(OAuth token、API key)
|
||||
startKeychainPrefetch();
|
||||
```
|
||||
|
||||
这三个在 import 阶段就**并行启动子进程**,和后续 ~135ms 的模块加载同时进行——**用并行隐藏延迟**。
|
||||
|
||||
### 2.2 辅助函数(第 216-584 行)
|
||||
|
||||
| 函数 | 行号 | 作用 |
|
||||
|------|------|------|
|
||||
| `logManagedSettings()` | 216 | 记录企业托管设置到分析日志 |
|
||||
| `isBeingDebugged()` | 232 | 检测调试模式,**外部构建下直接 exit(1)**(第 266 行) |
|
||||
| `logSessionTelemetry()` | 279 | Session 遥测(技能、插件) |
|
||||
| `getCertEnvVarTelemetry()` | 291 | SSL 证书环境变量收集 |
|
||||
| `runMigrations()` | 326 | 数据迁移(模型重命名、设置格式升级等) |
|
||||
| `prefetchSystemContextIfSafe()` | 360 | 信任关系建立后安全预取系统上下文 |
|
||||
| `startDeferredPrefetches()` | 388 | REPL 首次渲染后的延迟预取 |
|
||||
| `eagerLoadSettings()` | 502 | 在 init() 之前提前加载 `--settings` 参数 |
|
||||
| `initializeEntrypoint()` | 517 | 根据运行模式设置 `CLAUDE_CODE_ENTRYPOINT` |
|
||||
|
||||
还有 `_pendingConnect`、`_pendingSSH`、`_pendingAssistantChat` 三个状态变量(第 542-583 行),用于暂存子命令参数。
|
||||
|
||||
### 2.3 main() 函数(第 585-856 行)
|
||||
|
||||
`main()` 本身不长,做完环境检测后调用 `run()`:
|
||||
|
||||
```
|
||||
main()
|
||||
├── 安全设置(NoDefaultCurrentDirectoryInExePath)
|
||||
├── 信号处理(SIGINT → exit, exit → 恢复光标)
|
||||
├── feature flag 保护的特殊路径(全部跳过)
|
||||
├── 检测 -p/--print / --init-only → 判断是否交互模式
|
||||
├── clientType 判断(cli / sdk-typescript / remote / github-action 等)
|
||||
├── eagerLoadSettings()
|
||||
└── await run() ← 进入真正的逻辑
|
||||
```
|
||||
|
||||
### 2.4 run() 函数(第 884-4683 行)
|
||||
|
||||
占 3800 行,是整个文件的核心。
|
||||
|
||||
#### Commander 初始化 + preAction 钩子(第 884-967 行)
|
||||
|
||||
```ts
|
||||
const program = new CommanderCommand()
|
||||
.configureHelp(createSortedHelpConfig())
|
||||
.enablePositionalOptions();
|
||||
```
|
||||
|
||||
**preAction 钩子**(所有命令执行前都会运行):
|
||||
|
||||
```
|
||||
preAction
|
||||
├── await ensureMdmSettingsLoaded() ← 等 MDM 子进程完成
|
||||
├── await ensureKeychainPrefetchCompleted() ← 等 keychain 预读完成
|
||||
├── await init() ← 一次性初始化
|
||||
├── initSinks() ← 分析日志接收器
|
||||
├── runMigrations() ← 数据迁移
|
||||
├── loadRemoteManagedSettings() ← 企业远程设置(非阻塞)
|
||||
└── loadPolicyLimits() ← 策略限制(非阻塞)
|
||||
```
|
||||
|
||||
#### 主命令 Option 定义(第 968-1006 行)
|
||||
|
||||
定义了 40+ CLI 参数,关键的包括:
|
||||
|
||||
| 参数 | 作用 |
|
||||
|------|------|
|
||||
| `-p, --print` | 非交互模式,输出后退出 |
|
||||
| `--model <model>` | 指定模型(如 sonnet、opus) |
|
||||
| `--permission-mode <mode>` | 权限模式 |
|
||||
| `-c, --continue` | 继续最近对话 |
|
||||
| `-r, --resume` | 恢复指定对话 |
|
||||
| `--mcp-config` | MCP 服务器配置文件 |
|
||||
| `--allowedTools` | 允许的工具列表 |
|
||||
| `--system-prompt` | 自定义系统提示 |
|
||||
| `--dangerously-skip-permissions` | 跳过所有权限检查 |
|
||||
| `--output-format` | 输出格式(text/json/stream-json) |
|
||||
| `--effort <level>` | 推理努力级别(low/medium/high/max) |
|
||||
| `--bare` | 最小模式 |
|
||||
|
||||
#### action 处理器(第 1006-3808 行)
|
||||
|
||||
主命令的执行逻辑,内部按阶段和场景分支:
|
||||
|
||||
```
|
||||
action(async (prompt, options) => {
|
||||
│
|
||||
├── [1007-1600] 参数解析与预处理
|
||||
│ ├── --bare 模式
|
||||
│ ├── 解析 model / permission-mode / thinking / effort
|
||||
│ ├── 解析 MCP 配置、工具列表、系统提示
|
||||
│ └── 初始化工具权限上下文
|
||||
│
|
||||
├── [1600-2220] 服务初始化
|
||||
│ ├── MCP 客户端连接
|
||||
│ ├── 插件加载 + 技能初始化
|
||||
│ ├── 工具列表组装
|
||||
│ └── 初始 AppState 构建
|
||||
│
|
||||
├── [2220-2315] UI 初始化(交互模式)
|
||||
│ ├── createRoot() — 创建 Ink 渲染根节点
|
||||
│ ├── showSetupScreens() — 信任对话框、OAuth 登录、引导
|
||||
│ └── 登录后刷新各种服务
|
||||
│
|
||||
├── [2315-2582] 后续初始化
|
||||
│ ├── LSP 管理器、插件版本管理
|
||||
│ ├── session 注册、遥测日志
|
||||
│ └── 遥测上报
|
||||
│
|
||||
├── [2584-3050] --print 非交互模式分支
|
||||
│ ├── 构建 headless AppState + store
|
||||
│ └── 交给 print.ts 执行
|
||||
│
|
||||
└── [3050-3808] 交互模式:启动 REPL(7 个分支)
|
||||
├── --continue → 加载最近对话 → launchRepl()
|
||||
├── DIRECT_CONNECT → ❌ flag 关闭
|
||||
├── SSH_REMOTE → ❌ flag 关闭
|
||||
├── KAIROS assistant → ❌ flag 关闭
|
||||
├── --resume <id> → 恢复指定对话 → launchRepl()
|
||||
├── --resume 无 ID → 显示对话选择器
|
||||
└── 默认(无参数) → launchRepl() ★最常走的路径
|
||||
})
|
||||
```
|
||||
|
||||
#### 子命令注册(第 3808-4683 行)
|
||||
|
||||
| 子命令 | 行号 | 作用 |
|
||||
|--------|------|------|
|
||||
| `claude mcp` | 3892 | MCP 服务器管理(serve/add/remove/list/get) |
|
||||
| `claude server` | 3960 | Session 服务器(❌ flag 关闭) |
|
||||
| `claude auth` | 4098 | 认证管理(login/logout/status/token) |
|
||||
| `claude plugin` | 4148 | 插件管理(install/uninstall/list/update) |
|
||||
| `claude setup-token` | 4267 | 设置长期认证 token |
|
||||
| `claude agents` | 4278 | 列出已配置的 agents |
|
||||
| `claude doctor` | 4346 | 健康检查 |
|
||||
| `claude update` | 4362 | 检查更新 |
|
||||
| `claude install` | 4394 | 安装原生构建 |
|
||||
| `claude log` | 4411 | 查看对话日志(内部) |
|
||||
| `claude completion` | 4491 | Shell 自动补全 |
|
||||
|
||||
最后执行解析:
|
||||
|
||||
```ts
|
||||
await program.parseAsync(process.argv);
|
||||
```
|
||||
|
||||
### 2.5 main.tsx 学习建议
|
||||
|
||||
- **不要通读**。记住三段结构:辅助函数 → main() → run()
|
||||
- `feature()` 返回 false 的分支全部跳过,可忽略 50%+ 代码
|
||||
- `("external" as string) === 'ant'` 的分支也跳过(内部构建专用)
|
||||
- 需要深入某功能时,通过搜索定位对应代码段
|
||||
|
||||
---
|
||||
|
||||
## 3. replLauncher.tsx(22 行)— 胶水层
|
||||
|
||||
**文件路径**: `src/replLauncher.tsx`
|
||||
|
||||
极其简单,就做一件事:
|
||||
|
||||
```tsx
|
||||
export async function launchRepl(root, appProps, replProps, renderAndRun) {
|
||||
const { App } = await import('./components/App.js');
|
||||
const { REPL } = await import('./screens/REPL.js');
|
||||
await renderAndRun(root, <App {...appProps}><REPL {...replProps} /></App>);
|
||||
}
|
||||
```
|
||||
|
||||
- `App` — 全局 Provider(AppState、Stats、FpsMetrics)
|
||||
- `REPL` — 交互界面组件
|
||||
- `renderAndRun` — 把 React 元素渲染到 Ink 终端
|
||||
|
||||
动态 import 保持了按需加载的策略。
|
||||
|
||||
---
|
||||
|
||||
## 4. REPL.tsx(5009 行)— 交互界面
|
||||
|
||||
**文件路径**: `src/screens/REPL.tsx`
|
||||
|
||||
项目第二大文件,是用户直接交互的界面。一个巨型 React 函数组件。
|
||||
|
||||
### 4.1 文件结构
|
||||
|
||||
```
|
||||
REPL.tsx (5009 行)
|
||||
├── [1-310] Import 区(150+ import)
|
||||
├── [312-525] 辅助组件
|
||||
│ ├── median() — 数学工具函数
|
||||
│ ├── TranscriptModeFooter — 转录模式底栏
|
||||
│ ├── TranscriptSearchBar — 转录搜索栏
|
||||
│ └── AnimatedTerminalTitle — 终端标题动画
|
||||
├── [527-571] Props 类型定义
|
||||
└── [573-5009] REPL() 组件主体
|
||||
├── [600-900] 状态声明(50+ 个 useState/useRef/useAppState)
|
||||
├── [900-2750] 副作用与回调(useEffect/useCallback)
|
||||
├── [2750-2860] onQueryImpl — 核心:执行 API 查询
|
||||
├── [2860-3030] onQuery — 查询守卫与并发控制
|
||||
├── [3030-3145] 查询相关辅助回调
|
||||
├── [3146-3550] onSubmit — 用户提交处理
|
||||
├── [3550-4395] 更多副作用与状态管理
|
||||
└── [4396-5009] JSX 渲染
|
||||
```
|
||||
|
||||
### 4.2 Props
|
||||
|
||||
从 main.tsx 通过 launchRepl() 传入:
|
||||
|
||||
| Prop | 类型 | 含义 |
|
||||
|------|------|------|
|
||||
| `commands` | `Command[]` | 可用的斜杠命令 |
|
||||
| `debug` | `boolean` | 调试模式 |
|
||||
| `initialTools` | `Tool[]` | 初始工具集 |
|
||||
| `initialMessages` | `MessageType[]` | 初始消息(恢复对话时有值) |
|
||||
| `pendingHookMessages` | `Promise<...>` | 延迟加载的 hook 消息 |
|
||||
| `mcpClients` | `MCPServerConnection[]` | MCP 服务器连接 |
|
||||
| `systemPrompt` | `string` | 自定义系统提示 |
|
||||
| `appendSystemPrompt` | `string` | 追加系统提示 |
|
||||
| `onBeforeQuery` | `fn` | 查询前回调,返回 false 可阻止查询 |
|
||||
| `onTurnComplete` | `fn` | 轮次完成回调 |
|
||||
| `mainThreadAgentDefinition` | `AgentDefinition` | 主线程 Agent 定义 |
|
||||
| `thinkingConfig` | `ThinkingConfig` | 思考模式配置 |
|
||||
| `disabled` | `boolean` | 禁用输入 |
|
||||
|
||||
### 4.3 状态管理
|
||||
|
||||
分三层:
|
||||
|
||||
**全局 AppState(通过 useAppState 选择器读取):**
|
||||
|
||||
```ts
|
||||
const toolPermissionContext = useAppState(s => s.toolPermissionContext);
|
||||
const verbose = useAppState(s => s.verbose);
|
||||
const mcp = useAppState(s => s.mcp);
|
||||
const plugins = useAppState(s => s.plugins);
|
||||
const agentDefinitions = useAppState(s => s.agentDefinitions);
|
||||
```
|
||||
|
||||
**本地状态(useState):**
|
||||
|
||||
```ts
|
||||
const [messages, setMessages] = useState(initialMessages ?? []);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [screen, setScreen] = useState<Screen>('prompt');
|
||||
const [streamingText, setStreamingText] = useState(null);
|
||||
const [streamingToolUses, setStreamingToolUses] = useState([]);
|
||||
// ... 50+ 个状态
|
||||
```
|
||||
|
||||
**关键 Ref:**
|
||||
|
||||
```ts
|
||||
const queryGuard = useRef(new QueryGuard()).current; // 查询并发控制
|
||||
const messagesRef = useRef(messages); // 消息的同步引用(避免闭包问题)
|
||||
const abortController = ...; // 取消请求控制器
|
||||
const responseLengthRef = useRef(0); // 响应长度追踪
|
||||
```
|
||||
|
||||
### 4.4 核心数据流:用户输入 → API 调用
|
||||
|
||||
```
|
||||
用户按回车
|
||||
│
|
||||
▼
|
||||
onSubmit (第 3146 行)
|
||||
├── 斜杠命令?→ immediate command 直接执行 或 handlePromptSubmit 路由
|
||||
├── 空输入?→ 忽略
|
||||
├── 空闲检测 → 可能弹出"是否开始新对话"对话框
|
||||
├── 加入历史记录
|
||||
│
|
||||
▼
|
||||
handlePromptSubmit (外部函数,src/utils/handlePromptSubmit.ts)
|
||||
├── 斜杠命令 → 路由到对应 Command handler
|
||||
├── 普通文本 → 构建 UserMessage,调用 onQuery()
|
||||
│
|
||||
▼
|
||||
onQuery (第 2860 行) — 并发守卫层
|
||||
├── queryGuard.tryStart() → 已有查询?排队等待
|
||||
├── setMessages([...old, ...newMessages]) — 追加用户消息
|
||||
├── onQueryImpl()
|
||||
│
|
||||
▼
|
||||
onQueryImpl (第 2750 行) — 真正执行 API 调用
|
||||
│
|
||||
├── 1. 并行加载上下文:
|
||||
│ await Promise.all([
|
||||
│ getSystemPrompt(), // 构建系统提示
|
||||
│ getUserContext(), // 用户上下文
|
||||
│ getSystemContext(), // 系统上下文(git、平台等)
|
||||
│ ])
|
||||
│
|
||||
├── 2. buildEffectiveSystemPrompt() — 合成最终系统提示
|
||||
│
|
||||
├── 3. for await (const event of query({...})) ★核心★
|
||||
│ │ 调用 src/query.ts 的 query() AsyncGenerator
|
||||
│ │ 流式产出事件
|
||||
│ │
|
||||
│ └── onQueryEvent(event) — 处理每个流式事件
|
||||
│ ├── 更新 streamingText(打字机效果)
|
||||
│ ├── 更新 messages(工具调用结果)
|
||||
│ └── 更新 inProgressToolUseIDs
|
||||
│
|
||||
└── 4. 收尾:resetLoadingState()、onTurnComplete()
|
||||
```
|
||||
|
||||
**核心代码(第 2797-2807 行)**:
|
||||
|
||||
```ts
|
||||
for await (const event of query({
|
||||
messages: messagesIncludingNewMessages,
|
||||
systemPrompt,
|
||||
userContext,
|
||||
systemContext,
|
||||
canUseTool,
|
||||
toolUseContext,
|
||||
querySource: getQuerySourceForREPL()
|
||||
})) {
|
||||
onQueryEvent(event);
|
||||
}
|
||||
```
|
||||
|
||||
`query()` 来自 `src/query.ts`,是第二阶段要学的核心函数。
|
||||
|
||||
### 4.5 QueryGuard 并发控制
|
||||
|
||||
防止同时发起多个 API 请求的状态机:
|
||||
|
||||
```
|
||||
idle ──tryStart()──▶ running ──end()──▶ idle
|
||||
│
|
||||
└── tryStart() 返回 null(已在运行)
|
||||
→ 新消息排入队列
|
||||
```
|
||||
|
||||
- `tryStart()` — 原子操作,检查并转换 idle→running,返回 generation 号
|
||||
- `end(generation)` — 检查 generation 匹配后转换 running→idle
|
||||
- 防止 cancel+resubmit 竞态条件
|
||||
|
||||
### 4.6 JSX 渲染
|
||||
|
||||
两个互斥的渲染分支:
|
||||
|
||||
#### Transcript 模式(第 4396-4493 行)
|
||||
|
||||
按 `v` 键切换,只读浏览对话历史,支持搜索:
|
||||
|
||||
```tsx
|
||||
<KeybindingSetup>
|
||||
<AnimatedTerminalTitle />
|
||||
<GlobalKeybindingHandlers />
|
||||
<ScrollKeybindingHandler />
|
||||
<CancelRequestHandler />
|
||||
<FullscreenLayout
|
||||
scrollable={<Messages />}
|
||||
bottom={<TranscriptSearchBar /> 或 <TranscriptModeFooter />}
|
||||
/>
|
||||
</KeybindingSetup>
|
||||
```
|
||||
|
||||
#### Prompt 模式(第 4552-5009 行)
|
||||
|
||||
主交互界面,从上到下:
|
||||
|
||||
```tsx
|
||||
<KeybindingSetup>
|
||||
<AnimatedTerminalTitle /> // 终端 tab 标题
|
||||
<GlobalKeybindingHandlers /> // 全局快捷键
|
||||
<CommandKeybindingHandlers /> // 命令快捷键
|
||||
<ScrollKeybindingHandler /> // 滚动快捷键
|
||||
<CancelRequestHandler /> // Ctrl+C 取消
|
||||
<MCPConnectionManager> // MCP 连接管理
|
||||
<FullscreenLayout
|
||||
overlay={<PermissionRequest />} // 权限审批覆盖层
|
||||
scrollable={ // 可滚动区域
|
||||
<>
|
||||
<Messages /> // ★ 对话消息渲染
|
||||
<UserTextMessage /> // 用户输入占位
|
||||
{toolJSX} // 工具 UI
|
||||
<SpinnerWithVerb /> // 加载动画
|
||||
</>
|
||||
}
|
||||
bottom={ // 固定底部
|
||||
<>
|
||||
{/* 各种对话框 */}
|
||||
<SandboxPermissionRequest />
|
||||
<PromptDialog />
|
||||
<ElicitationDialog />
|
||||
<CostThresholdDialog />
|
||||
<FeedbackSurvey />
|
||||
|
||||
{/* ★ 用户输入框 */}
|
||||
<PromptInput
|
||||
onSubmit={onSubmit}
|
||||
commands={commands}
|
||||
isLoading={isLoading}
|
||||
messages={messages}
|
||||
// ... 20+ props
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</MCPConnectionManager>
|
||||
</KeybindingSetup>
|
||||
```
|
||||
|
||||
### 4.7 REPL.tsx 学习建议
|
||||
|
||||
- 核心只有一条线:`onSubmit → onQuery → query() → onQueryEvent → 更新消息`
|
||||
- 其余 4000+ 行是 UI 细节:快捷键、对话框、动画、边界情况处理
|
||||
- `feature('...')` 保护的 JSX 全部跳过
|
||||
- `("external" as string) === 'ant'` 的分支也跳过
|
||||
|
||||
---
|
||||
|
||||
## 关键设计模式总结
|
||||
|
||||
| 模式 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| 快速路径 | cli.tsx | 按开销从低到高逐级检查,零开销处理简单请求 |
|
||||
| 动态 import | cli.tsx / main.tsx | `await import()` 延迟加载,每条路径只加载需要的模块 |
|
||||
| Side-effect import | main.tsx 顶部 | import 阶段就并行启动子进程,用并行隐藏延迟 |
|
||||
| feature flag | 全局 | `feature()` 永远返回 false,编译时消除死代码 |
|
||||
| preAction 钩子 | main.tsx run() | Commander.js 命令执行前统一初始化 |
|
||||
| QueryGuard | REPL.tsx | 状态机防止并发 API 请求,带 generation 计数防竞态 |
|
||||
| React/Ink | UI 层 | 用 React 组件模型渲染终端 UI,支持全屏和虚拟滚动 |
|
||||
|
||||
## 需要忽略的代码模式
|
||||
|
||||
| 模式 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| `_c(N)` 调用 | React Compiler | 反编译产生的 memoization 样板代码 |
|
||||
| `feature('FLAG')` 后面的代码 | Bun bundler | 全部是死代码,在当前版本不会执行 |
|
||||
| `("external" as string) === 'ant'` | 构建目标检查 | 永远为 false(external !== ant) |
|
||||
| tsc 类型错误 | 反编译 | `unknown`/`never`/`{}` 类型,不影响 Bun 运行 |
|
||||
| `packages/@ant/` | stub 包 | 空实现,仅满足 import 依赖 |
|
||||
@@ -1,774 +0,0 @@
|
||||
# 第二阶段:核心对话循环详解
|
||||
|
||||
> 用户发一句话后,如何变成 API 请求、如何处理流式响应和工具调用
|
||||
|
||||
## 对话循环总览
|
||||
|
||||
```
|
||||
用户输入 "帮我读取 README.md"
|
||||
│
|
||||
▼
|
||||
REPL.tsx: onSubmit → onQuery → onQueryImpl
|
||||
│
|
||||
├── 1. 并行加载上下文:
|
||||
│ getSystemPrompt() + getUserContext() + getSystemContext()
|
||||
│
|
||||
├── 2. buildEffectiveSystemPrompt() — 合成最终系统提示
|
||||
│
|
||||
├── 3. for await (const event of query({...})) ★ 核心循环
|
||||
│ │
|
||||
│ │ query.ts: queryLoop()
|
||||
│ │ ├── while (true) {
|
||||
│ │ │ ├── autocompact / microcompact 处理
|
||||
│ │ │ ├── deps.callModel() → claude.ts 流式 API 调用
|
||||
│ │ │ │ └── for await (message of stream) { yield message }
|
||||
│ │ │ │
|
||||
│ │ │ ├── 收集 assistant 消息中的 tool_use 块
|
||||
│ │ │ │
|
||||
│ │ │ ├── needsFollowUp?
|
||||
│ │ │ │ ├── true → 执行工具 → 收集结果 → state = next → continue
|
||||
│ │ │ │ └── false → 检查错误恢复 → return { reason: 'completed' }
|
||||
│ │ │ }
|
||||
│ │
|
||||
│ └── onQueryEvent(event) — 更新 UI 状态
|
||||
│
|
||||
└── 4. 收尾: resetLoadingState(), onTurnComplete()
|
||||
```
|
||||
|
||||
### 两条数据路径
|
||||
|
||||
| 路径 | 调用方 | 说明 |
|
||||
|------|--------|------|
|
||||
| **交互式(REPL)** | REPL.tsx → `query()` | 直接调用 `query()` AsyncGenerator |
|
||||
| **非交互式(SDK/print)** | print.ts → `QueryEngine.submitMessage()` → `query()` | 通过 QueryEngine 包装,增加了会话持久化、usage 跟踪等 |
|
||||
|
||||
---
|
||||
|
||||
## 1. query.ts(1732 行)— 核心查询循环
|
||||
|
||||
**文件路径**: `src/query.ts`
|
||||
|
||||
### 1.1 文件结构
|
||||
|
||||
```
|
||||
query.ts (1732 行)
|
||||
├── [0-120] Import 区 + feature flag 条件模块加载
|
||||
├── [122-148] yieldMissingToolResultBlocks() — 为未配对的 tool_use 生成错误 tool_result
|
||||
├── [150-178] 常量与辅助函数 (MAX_OUTPUT_TOKENS_RECOVERY_LIMIT, isWithheldMaxOutputTokens)
|
||||
├── [180-198] QueryParams 类型定义
|
||||
├── [200-216] State 类型 — 循环迭代间的可变状态
|
||||
├── [218-238] query() — 导出的 AsyncGenerator,委托给 queryLoop()
|
||||
├── [240-1732] queryLoop() — 核心 while(true) 循环
|
||||
│ ├── [241-306] 初始化 State + 内存预取
|
||||
│ ├── [307-448] 循环开头:解构 state、消息预处理(snip/microcompact/context collapse)
|
||||
│ ├── [449-578] 系统提示构建(第449行) + autocompact(第453行) + StreamingToolExecutor 初始化(第562行)
|
||||
│ ├── [650-866] ★ deps.callModel()(第659行) + 流式响应处理 + tool_use 收集
|
||||
│ ├── [896-956] 错误处理(FallbackTriggeredError、通用错误)
|
||||
│ ├── [1002-1054] 中断处理(abortController.signal.aborted)
|
||||
│ ├── [1065-1360] 无 followUp 时的终止/恢复逻辑
|
||||
│ │ ├── prompt-too-long 恢复
|
||||
│ │ ├── max_output_tokens 恢复(升级 + 多轮)
|
||||
│ │ ├── stop hooks 执行
|
||||
│ │ └── return { reason: 'completed' }
|
||||
│ └── [1360-1732] 有 followUp 时的工具执行 + 下一轮准备
|
||||
│ ├── 工具执行(streaming 或 sequential)
|
||||
│ ├── attachment 注入(排队命令、内存预取、技能发现)
|
||||
│ ├── maxTurns 检查
|
||||
│ └── state = next → continue
|
||||
```
|
||||
|
||||
### 1.2 入口:query() 函数(第 219 行)
|
||||
|
||||
```ts
|
||||
export async function* query(params: QueryParams):
|
||||
AsyncGenerator<StreamEvent | Message | ..., Terminal> {
|
||||
const consumedCommandUuids: string[] = []
|
||||
const terminal = yield* queryLoop(params, consumedCommandUuids)
|
||||
// 通知所有消费的排队命令已完成
|
||||
for (const uuid of consumedCommandUuids) {
|
||||
notifyCommandLifecycle(uuid, 'completed')
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
```
|
||||
|
||||
`query()` 本身很薄,只做两件事:
|
||||
1. 委托给 `queryLoop()` 执行实际逻辑
|
||||
2. 在正常返回后通知排队命令的生命周期
|
||||
|
||||
### 1.3 QueryParams(第 181 行)
|
||||
|
||||
```ts
|
||||
type QueryParams = {
|
||||
messages: Message[] // 当前对话消息
|
||||
systemPrompt: SystemPrompt // 系统提示
|
||||
userContext: { [k: string]: string } // 用户上下文(CLAUDE.md 等)
|
||||
systemContext: { [k: string]: string } // 系统上下文(git 状态等)
|
||||
canUseTool: CanUseToolFn // 工具权限检查函数
|
||||
toolUseContext: ToolUseContext // 工具执行上下文
|
||||
fallbackModel?: string // 备用模型
|
||||
querySource: QuerySource // 查询来源标识
|
||||
maxTurns?: number // 最大轮次限制
|
||||
taskBudget?: { total: number } // 令牌预算
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 State — 循环迭代间的可变状态(第 204 行)
|
||||
|
||||
```ts
|
||||
type State = {
|
||||
messages: Message[] // 累积的消息列表
|
||||
toolUseContext: ToolUseContext // 工具执行上下文
|
||||
autoCompactTracking: ... // 自动压缩跟踪
|
||||
maxOutputTokensRecoveryCount: number // 输出令牌恢复尝试次数
|
||||
hasAttemptedReactiveCompact: boolean // 是否已尝试响应式压缩
|
||||
maxOutputTokensOverride: number | undefined // 输出令牌覆盖
|
||||
pendingToolUseSummary: Promise<...> // 待处理的工具使用摘要
|
||||
stopHookActive: boolean | undefined // stop hook 是否活跃
|
||||
turnCount: number // 当前轮次
|
||||
transition: Continue | undefined // 上一次迭代为何 continue
|
||||
}
|
||||
```
|
||||
|
||||
**设计关键**:每次 `continue` 时通过 `state = { ... }` 一次性更新所有状态,而不是分散的 9 个赋值。`transition` 字段记录了为什么要继续循环(便于调试和测试)。
|
||||
|
||||
### 1.5 queryLoop() 核心流程(第 241 行)
|
||||
|
||||
`while (true)` 循环(第 307 行)的每次迭代代表一次 API 调用。循环直到:
|
||||
- 模型不需要工具调用 → `return { reason: 'completed' }`
|
||||
- 被用户中断 → `return { reason: 'aborted_*' }`
|
||||
- 达到最大轮次 → `return { reason: 'max_turns' }`
|
||||
- 遇到不可恢复的错误 → `return { reason: 'model_error' }`
|
||||
|
||||
#### 步骤 1:消息预处理
|
||||
|
||||
```
|
||||
每次迭代开头:
|
||||
├── 解构 state → messages, toolUseContext, tracking, ...
|
||||
├── getMessagesAfterCompactBoundary() — 只保留压缩边界后的消息
|
||||
├── snip 处理(feature flag,跳过)
|
||||
├── microcompact 处理(feature flag,跳过)
|
||||
└── autocompact 检查 — 消息过长时自动压缩
|
||||
```
|
||||
|
||||
#### 步骤 2:系统提示构建(第 449 行)
|
||||
|
||||
```ts
|
||||
const fullSystemPrompt = asSystemPrompt(
|
||||
appendSystemContext(systemPrompt, systemContext),
|
||||
)
|
||||
```
|
||||
|
||||
将系统上下文(git 状态、日期等)追加到系统提示。注意:用户上下文(CLAUDE.md 等)不在这里注入,而是在 `deps.callModel()` 调用时通过 `prependUserContext(messagesForQuery, userContext)` 注入到消息数组的最前面(第 660 行)。
|
||||
|
||||
#### 步骤 3:Autocompact(第 454-543 行)
|
||||
|
||||
当消息历史过长时自动压缩:
|
||||
|
||||
```
|
||||
autocompact 流程:
|
||||
├── 检查 token 数量是否超过阈值
|
||||
├── 超过 → 调用 compact API(用 Haiku 总结历史)
|
||||
│ ├── yield compactBoundaryMessage ← 标记压缩边界
|
||||
│ └── 更新 messages 为压缩后的版本
|
||||
└── 未超过 → 继续
|
||||
```
|
||||
|
||||
#### 步骤 4:调用 API(第 559-708 行)— 核心
|
||||
|
||||
StreamingToolExecutor 在第 562 行初始化,API 调用在第 659 行开始:
|
||||
|
||||
```ts
|
||||
// 第 562 行:初始化流式工具执行器
|
||||
let streamingToolExecutor = useStreamingToolExecution
|
||||
? new StreamingToolExecutor(
|
||||
toolUseContext.options.tools, canUseTool, toolUseContext,
|
||||
)
|
||||
: null
|
||||
|
||||
// 第 659 行:调用 API
|
||||
for await (const message of deps.callModel({
|
||||
messages: prependUserContext(messagesForQuery, userContext), // ← 用户上下文注入到消息最前面
|
||||
systemPrompt: fullSystemPrompt,
|
||||
thinkingConfig: toolUseContext.options.thinkingConfig,
|
||||
tools: toolUseContext.options.tools,
|
||||
signal: toolUseContext.abortController.signal,
|
||||
options: { model: currentModel, querySource, fallbackModel, ... }
|
||||
})) {
|
||||
// 处理每条流式消息(第 708-866 行)
|
||||
}
|
||||
```
|
||||
|
||||
`deps.callModel()` 最终调用 `claude.ts` 的 `queryModelWithStreaming()`。
|
||||
|
||||
#### 步骤 5:流式响应处理(第 708-866 行)
|
||||
|
||||
处理逻辑在 `for await` 循环体内(第 708 行的 `})` 之后到第 866 行):
|
||||
|
||||
```
|
||||
for await (const message of stream):
|
||||
├── message.type === 'assistant'?
|
||||
│ ├── 记录到 assistantMessages[]
|
||||
│ ├── 提取 tool_use 块 → toolUseBlocks[]
|
||||
│ ├── needsFollowUp = true(如果有 tool_use)
|
||||
│ └── streamingToolExecutor.addTool() ← 流式工具并行执行
|
||||
│
|
||||
├── withheld? (prompt-too-long / max_output_tokens)
|
||||
│ └── 暂扣不 yield,等后面恢复逻辑处理
|
||||
│
|
||||
└── yield message ← 正常 yield 给上层(REPL/QueryEngine)
|
||||
```
|
||||
|
||||
**StreamingToolExecutor**:在 API 流式返回的同时就开始执行工具(如读文件),不等流结束。通过 `addTool()` 添加待执行工具,`getCompletedResults()` 获取已完成的结果。
|
||||
|
||||
#### 步骤 6A:无 followUp — 终止/恢复(第 1065-1360 行)
|
||||
|
||||
当模型没有请求工具调用时(`needsFollowUp === false`):
|
||||
|
||||
```
|
||||
无 followUp:
|
||||
├── prompt-too-long 恢复?
|
||||
│ ├── context collapse drain(feature flag,跳过)
|
||||
│ ├── reactive compact → 压缩消息重试
|
||||
│ └── 都失败 → yield 错误 + return
|
||||
│
|
||||
├── max_output_tokens 恢复?
|
||||
│ ├── 第一次 → 升级到 64k token 限制,continue
|
||||
│ ├── 后续 → 注入恢复消息("继续,别道歉"),continue
|
||||
│ └── 超过 3 次 → yield 错误 + return
|
||||
│
|
||||
├── stop hooks 执行
|
||||
│ ├── preventContinuation? → return
|
||||
│ └── blockingErrors? → 将错误加入消息,continue
|
||||
│
|
||||
└── return { reason: 'completed' } ★ 正常结束
|
||||
```
|
||||
|
||||
**恢复消息内容(第 1229 行)**:
|
||||
```
|
||||
"Output token limit hit. Resume directly — no apology, no recap of what
|
||||
you were doing. Pick up mid-thought if that is where the cut happened.
|
||||
Break remaining work into smaller pieces."
|
||||
```
|
||||
|
||||
#### 步骤 6B:有 followUp — 工具执行 + 下一轮(第 1363-1731 行)
|
||||
|
||||
当模型请求了工具调用时(`needsFollowUp === true`):
|
||||
|
||||
```
|
||||
有 followUp:
|
||||
├── 工具执行(两种模式)
|
||||
│ ├── streamingToolExecutor? → getRemainingResults()(流式已启动)
|
||||
│ └── 否 → runTools()(传统顺序执行)
|
||||
│
|
||||
├── for await (const update of toolUpdates):
|
||||
│ ├── yield update.message ← 工具结果消息
|
||||
│ └── toolResults.push(...) ← 收集工具结果
|
||||
│
|
||||
├── 中断检查(abortController.signal.aborted)
|
||||
│ └── return { reason: 'aborted_tools' }
|
||||
│
|
||||
├── attachment 注入
|
||||
│ ├── 排队命令(其他线程提交的消息)
|
||||
│ ├── 内存预取(相关记忆文件)
|
||||
│ └── 技能发现预取
|
||||
│
|
||||
├── maxTurns 检查
|
||||
│ └── 超过 → yield max_turns_reached + return
|
||||
│
|
||||
└── state = { messages: [...old, ...assistant, ...toolResults], turnCount: +1 }
|
||||
→ continue ★ 回到循环顶部,发起下一次 API 调用
|
||||
```
|
||||
|
||||
### 1.6 错误处理与模型降级(第 897-956 行)
|
||||
|
||||
```
|
||||
API 调用出错:
|
||||
├── FallbackTriggeredError(529 过载)?
|
||||
│ ├── 切换到 fallbackModel
|
||||
│ ├── 清空本轮 assistant/tool 消息
|
||||
│ ├── yield 系统消息 "Switched to X due to high demand for Y"
|
||||
│ └── continue(重试整个请求)
|
||||
│
|
||||
└── 其他错误
|
||||
├── ImageSizeError/ImageResizeError → yield 友好错误 + return
|
||||
├── yieldMissingToolResultBlocks() — 补全未配对的 tool_result
|
||||
└── yield API 错误消息 + return
|
||||
```
|
||||
|
||||
### 1.7 关键设计思想
|
||||
|
||||
| 设计 | 说明 |
|
||||
|------|------|
|
||||
| **AsyncGenerator 模式** | `query()` 是 `async function*`,通过 `yield` 逐条产出事件,调用者用 `for await` 消费 |
|
||||
| **while(true) + state 对象** | 每次 `continue` 构建新 State 对象,避免分散的状态修改 |
|
||||
| **transition 字段** | 记录为什么要 continue(`next_turn`、`max_output_tokens_recovery`、`reactive_compact_retry`...),便于调试 |
|
||||
| **StreamingToolExecutor** | API 流式返回时就并行执行工具,不等流结束 |
|
||||
| **Withheld 消息** | 可恢复错误先暂扣,恢复成功则不 yield 错误,失败才 yield |
|
||||
|
||||
---
|
||||
|
||||
## 2. QueryEngine.ts(1320 行)— 高层编排器
|
||||
|
||||
**文件路径**: `src/QueryEngine.ts`
|
||||
|
||||
### 2.1 定位
|
||||
|
||||
QueryEngine 是 `query()` 的**上层包装**,主要用于:
|
||||
- **print 模式**(`claude -p`):通过 `ask()` → `QueryEngine.submitMessage()`
|
||||
- **SDK 模式**:外部程序通过 SDK 调用
|
||||
- **REPL 不用它**:REPL 直接调用 `query()`
|
||||
|
||||
### 2.2 文件结构
|
||||
|
||||
```
|
||||
QueryEngine.ts (1320 行)
|
||||
├── [0-130] Import 区 + feature flag 条件模块
|
||||
├── [131-174] QueryEngineConfig 类型定义
|
||||
├── [185-1202] QueryEngine 类
|
||||
│ ├── [185-208] 成员变量 + constructor
|
||||
│ ├── [210-1181] submitMessage() — 核心方法(~970 行)
|
||||
│ │ ├── [210-400] 参数解析 + processUserInputContext 构建
|
||||
│ │ ├── [400-465] 用户输入处理 + 会话持久化
|
||||
│ │ ├── [465-660] 斜杠命令处理 + 无需查询的快速返回
|
||||
│ │ ├── [660-690] 文件历史快照
|
||||
│ │ ├── [679-1074] ★ for await (const message of query({...})) — 消费 query()
|
||||
│ │ └── [1074-1181] 结果提取 + yield result
|
||||
│ ├── [1183-1202] interrupt() / getMessages() / setModel() 辅助方法
|
||||
├── [1210-1320] ask() — 便捷包装函数
|
||||
```
|
||||
|
||||
### 2.3 QueryEngineConfig
|
||||
|
||||
```ts
|
||||
type QueryEngineConfig = {
|
||||
cwd: string // 工作目录
|
||||
tools: Tools // 工具列表
|
||||
commands: Command[] // 斜杠命令
|
||||
mcpClients: MCPServerConnection[] // MCP 服务器连接
|
||||
agents: AgentDefinition[] // Agent 定义
|
||||
canUseTool: CanUseToolFn // 权限检查
|
||||
getAppState / setAppState // 全局状态存取
|
||||
initialMessages?: Message[] // 初始消息(恢复对话)
|
||||
readFileCache: FileStateCache // 文件读取缓存
|
||||
customSystemPrompt?: string // 自定义系统提示
|
||||
thinkingConfig?: ThinkingConfig // 思考模式配置
|
||||
maxTurns?: number // 最大轮次
|
||||
maxBudgetUsd?: number // USD 预算上限
|
||||
jsonSchema?: Record<...> // 结构化输出 schema
|
||||
// ... 更多配置
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 submitMessage() 核心流程
|
||||
|
||||
```
|
||||
submitMessage(prompt)
|
||||
│
|
||||
├── 1. 参数准备
|
||||
│ ├── 解构 config 获取 tools, commands, model, ...
|
||||
│ ├── 构建 wrappedCanUseTool(包装权限检查,跟踪拒绝)
|
||||
│ ├── fetchSystemPromptParts() — 获取系统提示各部分
|
||||
│ └── 构建 processUserInputContext
|
||||
│
|
||||
├── 2. 用户输入处理
|
||||
│ ├── processUserInput(prompt) — 解析斜杠命令 / 普通文本
|
||||
│ ├── mutableMessages.push(...messagesFromUserInput)
|
||||
│ └── recordTranscript(messages) — 持久化到 JSONL
|
||||
│
|
||||
├── 3. yield buildSystemInitMessage() — SDK 初始化消息
|
||||
│
|
||||
├── 4. shouldQuery === false?(斜杠命令的本地执行结果)
|
||||
│ ├── yield 命令输出
|
||||
│ ├── yield { type: 'result', subtype: 'success' }
|
||||
│ └── return
|
||||
│
|
||||
├── 5. ★ for await (const message of query({...}))
|
||||
│ │ 消费 query() 产出的每条消息
|
||||
│ │
|
||||
│ ├── message.type === 'assistant'
|
||||
│ │ ├── mutableMessages.push(msg)
|
||||
│ │ ├── recordTranscript() ← fire-and-forget
|
||||
│ │ ├── yield* normalizeMessage(msg) — 转换为 SDK 格式
|
||||
│ │ └── 捕获 stop_reason
|
||||
│ │
|
||||
│ ├── message.type === 'user'(工具结果)
|
||||
│ │ ├── mutableMessages.push(msg)
|
||||
│ │ ├── turnCount++
|
||||
│ │ └── yield* normalizeMessage(msg)
|
||||
│ │
|
||||
│ ├── message.type === 'stream_event'
|
||||
│ │ ├── 跟踪 usage(message_start/delta/stop)
|
||||
│ │ └── includePartialMessages? → yield 流事件
|
||||
│ │
|
||||
│ ├── message.type === 'system'
|
||||
│ │ ├── compact_boundary → GC 旧消息 + yield 给 SDK
|
||||
│ │ └── api_error → yield 重试信息
|
||||
│ │
|
||||
│ └── maxBudgetUsd 检查 → 超预算则 yield error + return
|
||||
│
|
||||
└── 6. yield { type: 'result', subtype: 'success', result: textResult }
|
||||
```
|
||||
|
||||
### 2.5 ask() 便捷函数(第 1211 行)
|
||||
|
||||
```ts
|
||||
export async function* ask({ prompt, tools, ... }) {
|
||||
const engine = new QueryEngine({ ... })
|
||||
try {
|
||||
yield* engine.submitMessage(prompt)
|
||||
} finally {
|
||||
setReadFileCache(engine.getReadFileState())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ask()` 是 `QueryEngine` 的一次性包装,创建 engine → 提交消息 → 清理。用于 `print.ts` 的 `--print` 模式。
|
||||
|
||||
### 2.6 QueryEngine vs REPL 直接调用 query()
|
||||
|
||||
| 特性 | QueryEngine (SDK/print) | REPL 直接调用 query() |
|
||||
|------|------------------------|---------------------|
|
||||
| 会话持久化 | 自动 recordTranscript | 由 useLogMessages 处理 |
|
||||
| Usage 跟踪 | 内部 totalUsage 累积 | 由外层 cost-tracker 处理 |
|
||||
| 权限拒绝跟踪 | 记录 permissionDenials[] | 直接 UI 交互 |
|
||||
| 结果格式 | yield SDKMessage 格式 | 原始 Message 格式 |
|
||||
| 消息 GC | compact_boundary 后释放旧消息 | UI 需要保留完整历史 |
|
||||
|
||||
---
|
||||
|
||||
## 3. claude.ts(3420 行)— API 客户端
|
||||
|
||||
**文件路径**: `src/services/api/claude.ts`
|
||||
|
||||
### 3.1 文件结构
|
||||
|
||||
```
|
||||
claude.ts (3420 行)
|
||||
├── [0-260] Import 区(大量 SDK 类型、工具函数)
|
||||
├── [272-331] getExtraBodyParams() — 构建额外请求体参数
|
||||
├── [333-502] 缓存相关(getPromptCachingEnabled, getCacheControl, should1hCacheTTL, configureEffortParams, configureTaskBudgetParams)
|
||||
├── [504-587] verifyApiKey() — API 密钥验证
|
||||
├── [589-675] 消息转换(userMessageToMessageParam, assistantMessageToMessageParam)
|
||||
├── [677-708] Options 类型定义
|
||||
├── [710-781] queryModelWithoutStreaming / queryModelWithStreaming — 公开的两个入口
|
||||
├── [783-813] 辅助函数(shouldDeferLspTool, getNonstreamingFallbackTimeoutMs)
|
||||
├── [819-918] executeNonStreamingRequest() — 非流式请求辅助
|
||||
├── [920-999] 更多辅助函数(getPreviousRequestIdFromMessages, stripExcessMediaItems)
|
||||
├── [1018-3420] ★ queryModel() — 核心私有函数(2400 行)
|
||||
│ ├── [1018-1370] 前置检查 + 工具 schema 构建 + 消息归一化 + 系统提示组装
|
||||
│ ├── [1539-1730] paramsFromContext() — 构建 API 请求参数
|
||||
│ ├── [1777-2100] withRetry + 流式 API 调用(anthropic.beta.messages.create + stream)
|
||||
│ ├── [1941-2300] 流式事件处理(for await of stream)
|
||||
│ └── [2300-3420] 非流式降级 + 日志、分析、清理
|
||||
```
|
||||
|
||||
### 3.2 两个公开入口
|
||||
|
||||
```ts
|
||||
// 入口 1:流式(主要路径)
|
||||
export async function* queryModelWithStreaming({
|
||||
messages, systemPrompt, thinkingConfig, tools, signal, options
|
||||
}) {
|
||||
yield* withStreamingVCR(messages, async function* () {
|
||||
yield* queryModel(messages, systemPrompt, thinkingConfig, tools, signal, options)
|
||||
})
|
||||
}
|
||||
|
||||
// 入口 2:非流式(compact 等内部用途)
|
||||
export async function queryModelWithoutStreaming({
|
||||
messages, systemPrompt, thinkingConfig, tools, signal, options
|
||||
}) {
|
||||
let assistantMessage
|
||||
for await (const message of ...) {
|
||||
if (message.type === 'assistant') assistantMessage = message
|
||||
}
|
||||
return assistantMessage
|
||||
}
|
||||
```
|
||||
|
||||
两者都委托给内部的 `queryModel()`。`withStreamingVCR` 是一个 VCR(录像/回放)包装器,用于调试。
|
||||
|
||||
### 3.3 Options 类型(第 677 行)
|
||||
|
||||
```ts
|
||||
type Options = {
|
||||
getToolPermissionContext: () => Promise<ToolPermissionContext>
|
||||
model: string // 模型名称
|
||||
toolChoice?: BetaToolChoiceTool // 强制使用特定工具
|
||||
isNonInteractiveSession: boolean // 是否非交互模式
|
||||
fallbackModel?: string // 备用模型
|
||||
querySource: QuerySource // 查询来源
|
||||
agents: AgentDefinition[] // Agent 定义
|
||||
enablePromptCaching?: boolean // 启用提示缓存
|
||||
effortValue?: EffortValue // 推理努力级别
|
||||
mcpTools: Tools // MCP 工具
|
||||
fastMode?: boolean // 快速模式
|
||||
taskBudget?: { total: number; remaining?: number } // 令牌预算
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 queryModel() 核心流程(第 1018 行)
|
||||
|
||||
这是整个 API 调用的核心,2400 行。关键步骤:
|
||||
|
||||
#### 阶段 1:前置准备(1018-1400 行)
|
||||
|
||||
```
|
||||
queryModel()
|
||||
├── off-switch 检查(Opus 过载时的全局关闭开关)
|
||||
├── beta headers 组装(getMergedBetas)
|
||||
│ ├── 基础 betas
|
||||
│ ├── advisor beta(如果启用)
|
||||
│ ├── tool search beta(如果启用)
|
||||
│ ├── cache scope beta
|
||||
│ └── effort / task budget betas
|
||||
│
|
||||
├── 工具过滤
|
||||
│ ├── tool search 启用 → 只包含已发现的 deferred tools
|
||||
│ └── tool search 未启用 → 过滤掉 ToolSearchTool
|
||||
│
|
||||
├── toolToAPISchema() — 每个工具转为 API 格式
|
||||
│
|
||||
├── normalizeMessagesForAPI() — 消息转换为 API 格式
|
||||
│ ├── UserMessage → { role: 'user', content: ... }
|
||||
│ ├── AssistantMessage → { role: 'assistant', content: ... }
|
||||
│ └── 跳过 system/attachment/progress 等内部消息类型
|
||||
│
|
||||
└── 系统提示最终组装
|
||||
├── getAttributionHeader(fingerprint)
|
||||
├── getCLISyspromptPrefix()
|
||||
├── ...systemPrompt
|
||||
└── advisor 指令(如果启用)
|
||||
```
|
||||
|
||||
#### 阶段 2:构建请求参数 — paramsFromContext()(第 1539-1730 行)
|
||||
|
||||
```ts
|
||||
const paramsFromContext = (retryContext: RetryContext) => {
|
||||
// ... 动态 beta headers、effort、task budget 配置 ...
|
||||
|
||||
// 思考模式配置(adaptive 或 enabled + budget)
|
||||
let thinking = undefined
|
||||
if (hasThinking && modelSupportsThinking(options.model)) {
|
||||
if (modelSupportsAdaptiveThinking(options.model)) {
|
||||
thinking = { type: 'adaptive' }
|
||||
} else {
|
||||
thinking = { type: 'enabled', budget_tokens: thinkingBudget }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
model: normalizeModelStringForAPI(options.model),
|
||||
messages: addCacheBreakpoints(messagesForAPI, ...), // 带缓存标记的消息
|
||||
system, // 系统提示块(已构建好)
|
||||
tools: allTools, // 工具 schema
|
||||
tool_choice: options.toolChoice,
|
||||
max_tokens: maxOutputTokens,
|
||||
thinking,
|
||||
...(temperature !== undefined && { temperature }),
|
||||
...(useBetas && { betas: betasParams }),
|
||||
metadata: getAPIMetadata(),
|
||||
...extraBodyParams,
|
||||
...(speed !== undefined && { speed }), // 快速模式
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 阶段 3:流式 API 调用(第 1779-1858 行)
|
||||
|
||||
```ts
|
||||
// 使用 withRetry 包装,自动处理重试
|
||||
const generator = withRetry(
|
||||
() => getAnthropicClient({ maxRetries: 0, model, source: querySource }),
|
||||
async (anthropic, attempt, context) => {
|
||||
const params = paramsFromContext(context)
|
||||
|
||||
// ★ 核心 API 调用(第 1823 行)
|
||||
// 使用 .create() + stream: true(而非 .stream())
|
||||
// 避免 BetaMessageStream 的 O(n²) partial JSON 解析开销
|
||||
const result = await anthropic.beta.messages
|
||||
.create(
|
||||
{ ...params, stream: true },
|
||||
{ signal, ...(clientRequestId && { headers: { ... } }) },
|
||||
)
|
||||
.withResponse()
|
||||
|
||||
return result.data // Stream<BetaRawMessageStreamEvent>
|
||||
},
|
||||
{ model, fallbackModel, thinkingConfig, signal, querySource }
|
||||
)
|
||||
|
||||
// 消费 withRetry 的系统错误消息(重试通知等)
|
||||
let e
|
||||
do {
|
||||
e = await generator.next()
|
||||
if (!('controller' in e.value)) yield e.value // yield API 错误消息
|
||||
} while (!e.done)
|
||||
stream = e.value // 获取最终的 Stream 对象
|
||||
|
||||
// 处理流式事件(第 1941 行)
|
||||
for await (const part of stream) {
|
||||
switch (part.type) {
|
||||
case 'message_start': // 记录 request_id、usage
|
||||
case 'content_block_start': // 新的内容块开始(text/thinking/tool_use)
|
||||
case 'content_block_delta': // 增量内容 → yield stream_event 给 UI
|
||||
case 'content_block_stop': // 内容块完成 → yield AssistantMessage
|
||||
case 'message_delta': // stop_reason、usage 更新
|
||||
case 'message_stop': // 整条消息完成
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 阶段 4:withRetry 重试策略
|
||||
|
||||
```
|
||||
withRetry 逻辑:
|
||||
├── 429 (Rate Limit) → 等待 Retry-After 后重试
|
||||
├── 529 (Overloaded) → 切换到 fallbackModel,throw FallbackTriggeredError
|
||||
├── 500 (Server Error) → 指数退避重试
|
||||
├── 408 (Timeout) → 重试
|
||||
├── 其他错误 → 不重试,直接抛出
|
||||
└── 最大重试次数: 根据模型和错误类型动态计算
|
||||
```
|
||||
|
||||
#### 阶段 5:非流式降级
|
||||
|
||||
当流式请求中途失败时,可能降级为非流式请求:
|
||||
|
||||
```
|
||||
流式失败(部分响应已收到):
|
||||
├── 已接收的内容 → yield 给上层
|
||||
├── 剩余部分 → 降级为非流式请求(anthropic.beta.messages.create)
|
||||
└── 非流式结果 → 转换格式 yield
|
||||
```
|
||||
|
||||
### 3.5 消息转换函数
|
||||
|
||||
```ts
|
||||
// UserMessage → API 格式
|
||||
userMessageToMessageParam(message, addCache, enablePromptCaching, querySource)
|
||||
→ { role: 'user', content: [...] }
|
||||
// addCache=true 时最后一个 content block 添加 cache_control
|
||||
|
||||
// AssistantMessage → API 格式
|
||||
assistantMessageToMessageParam(message, addCache, enablePromptCaching, querySource)
|
||||
→ { role: 'assistant', content: [...] }
|
||||
// thinking/redacted_thinking 块不加 cache_control
|
||||
```
|
||||
|
||||
### 3.6 Prompt Caching 策略
|
||||
|
||||
```
|
||||
缓存策略:
|
||||
├── cache_control: { type: 'ephemeral' } — 默认,5 分钟 TTL
|
||||
├── cache_control: { type: 'ephemeral', ttl: '1h' } — 订阅用户/Ant,1 小时
|
||||
├── cache_control: { ..., scope: 'global' } — 跨会话共享(无 MCP 工具时)
|
||||
└── 禁用条件:
|
||||
├── DISABLE_PROMPT_CACHING 环境变量
|
||||
├── DISABLE_PROMPT_CACHING_HAIKU(仅 Haiku)
|
||||
└── DISABLE_PROMPT_CACHING_SONNET(仅 Sonnet)
|
||||
```
|
||||
|
||||
### 3.7 多 Provider 支持
|
||||
|
||||
`getAnthropicClient()` 根据配置返回不同的 SDK 客户端:
|
||||
|
||||
| Provider | 入口 | 说明 |
|
||||
|----------|------|------|
|
||||
| Anthropic | 直接 API | 默认,`api.anthropic.com` |
|
||||
| AWS Bedrock | 通过 Bedrock | 使用 `@anthropic-ai/bedrock-sdk` |
|
||||
| Google Vertex | 通过 Vertex | 使用 `@anthropic-ai/vertex-sdk` |
|
||||
| Azure | 通过 Azure | 类似 Bedrock 的包装 |
|
||||
|
||||
Provider 选择逻辑在 `src/utils/model/providers.ts` 的 `getAPIProvider()` 中。
|
||||
|
||||
---
|
||||
|
||||
## 完整数据流:一次工具调用的生命周期
|
||||
|
||||
以用户输入 "读取 README.md" 为例:
|
||||
|
||||
```
|
||||
1. REPL.tsx: 用户按回车
|
||||
onSubmit("读取 README.md")
|
||||
└── handlePromptSubmit()
|
||||
└── onQuery([userMessage])
|
||||
|
||||
2. REPL.tsx: onQueryImpl()
|
||||
├── getSystemPrompt() + getUserContext() + getSystemContext()
|
||||
└── for await (event of query({messages, systemPrompt, ...}))
|
||||
|
||||
3. query.ts: queryLoop() — 第 1 次迭代
|
||||
├── messagesForQuery = [...messages] // 包含用户消息
|
||||
├── deps.callModel({...})
|
||||
│ └── claude.ts: queryModel()
|
||||
│ ├── 构建 API 参数
|
||||
│ └── anthropic.beta.messages.create({ ...params, stream: true })
|
||||
│
|
||||
├── API 流式返回:
|
||||
│ content_block_start: { type: 'tool_use', name: 'Read', id: 'toolu_123' }
|
||||
│ content_block_delta: { input: '{"file_path": "/path/to/README.md"}' }
|
||||
│ content_block_stop
|
||||
│ message_delta: { stop_reason: 'tool_use' }
|
||||
│
|
||||
├── 收集: toolUseBlocks = [{ name: 'Read', id: 'toolu_123', input: {...} }]
|
||||
├── needsFollowUp = true
|
||||
│
|
||||
├── 工具执行:
|
||||
│ streamingToolExecutor.getRemainingResults()
|
||||
│ └── Read 工具执行 → 返回文件内容
|
||||
│ yield toolResultMessage ← 包含文件内容
|
||||
│
|
||||
└── state = { messages: [...old, assistantMsg, toolResultMsg], turnCount: 2 }
|
||||
→ continue
|
||||
|
||||
4. query.ts: queryLoop() — 第 2 次迭代
|
||||
├── messagesForQuery 现在包含:
|
||||
│ [userMsg, assistantMsg(tool_use), userMsg(tool_result)]
|
||||
│
|
||||
├── deps.callModel({...}) ← 再次调用 API
|
||||
│
|
||||
├── API 返回:
|
||||
│ content_block_start: { type: 'text' }
|
||||
│ content_block_delta: { text: "README.md 的内容是..." }
|
||||
│ content_block_stop
|
||||
│ message_delta: { stop_reason: 'end_turn' }
|
||||
│
|
||||
├── toolUseBlocks = [] ← 没有工具调用
|
||||
├── needsFollowUp = false
|
||||
│
|
||||
└── return { reason: 'completed' } ★ 循环结束
|
||||
|
||||
5. REPL.tsx: onQueryEvent(event)
|
||||
├── 更新 streamingText(打字机效果)
|
||||
├── 更新 messages 数组
|
||||
└── 重新渲染 UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键设计模式总结
|
||||
|
||||
| 模式 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| AsyncGenerator 链式传递 | query.ts → claude.ts | `yield*` 将底层事件透传给上层,形成事件流管道 |
|
||||
| while(true) + State 对象 | query.ts queryLoop | 循环迭代间通过不可变 State 传递,transition 字段记录原因 |
|
||||
| StreamingToolExecutor | query.ts | API 流式返回时并行执行工具,不等流结束 |
|
||||
| Withheld 消息 | query.ts | 可恢复错误先暂扣不 yield,恢复成功则吞掉错误 |
|
||||
| withRetry 重试 | claude.ts | 429/500/529 自动重试,529 触发模型降级 |
|
||||
| Prompt Caching | claude.ts | 缓存系统提示和历史消息,减少 API token 消耗 |
|
||||
| 非流式降级 | claude.ts | 流式请求中途失败时降级为非流式完成剩余部分 |
|
||||
| QueryEngine 包装 | QueryEngine.ts | 为 SDK/print 提供会话管理、持久化、usage 跟踪 |
|
||||
|
||||
## 需要忽略的代码
|
||||
|
||||
| 模式 | 说明 |
|
||||
|------|------|
|
||||
| `feature('REACTIVE_COMPACT')` / `feature('CONTEXT_COLLAPSE')` 等 | 所有 feature flag 保护的代码 — 全部是死代码 |
|
||||
| `feature('CACHED_MICROCOMPACT')` | 缓存微压缩 — 死代码 |
|
||||
| `feature('HISTORY_SNIP')` / `snipModule` | 历史截断 — 死代码 |
|
||||
| `feature('TOKEN_BUDGET')` / `budgetTracker` | 令牌预算 — 死代码 |
|
||||
| `feature('BG_SESSIONS')` / `taskSummaryModule` | 后台会话 — 死代码 |
|
||||
| `process.env.USER_TYPE === 'ant'` | Anthropic 内部专用代码 |
|
||||
| VCR (withStreamingVCR/withVCR) | 调试录像/回放包装器,不影响正常流程 |
|
||||
@@ -1,372 +0,0 @@
|
||||
# 第二阶段 Q&A
|
||||
|
||||
## Q1:query.ts 的流式消息处理具体是怎样的?
|
||||
|
||||
**核心问题**:`deps.callModel()` yield 出的每一条消息,在 `queryLoop()` 的 `for await` 循环体(L659-866)中具体经历了什么处理?
|
||||
|
||||
### 场景
|
||||
|
||||
用户说:**"帮我看看 package.json 的内容"**
|
||||
|
||||
模型回复:一段文字 "我来读取文件。" + 一个 Read 工具调用。
|
||||
|
||||
### callModel yield 的完整消息序列
|
||||
|
||||
claude.ts 的 `queryModel()` 会 yield 两种类型的消息:
|
||||
|
||||
| 类型标记 | 含义 | 产出时机 |
|
||||
|---------|------|---------|
|
||||
| `stream_event` | 原始 SSE 事件包装 | 每个 SSE 事件都产出一条 |
|
||||
| `assistant` | 完整的 AssistantMessage | 仅在 `content_block_stop` 时产出 |
|
||||
|
||||
本例中 callModel 依次 yield **共 13 条消息**:
|
||||
|
||||
```
|
||||
#1 { type: 'stream_event', event: { type: 'message_start', ... }, ttftMs: 342 }
|
||||
#2 { type: 'stream_event', event: { type: 'content_block_start', index: 0, content_block: { type: 'text' } } }
|
||||
#3 { type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: '我来' } } }
|
||||
#4 { type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: '读取文件。' } } }
|
||||
#5 { type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }
|
||||
#6 { type: 'assistant', uuid: 'uuid-1', message: { content: [{ type: 'text', text: '我来读取文件。' }], stop_reason: null } }
|
||||
#7 { type: 'stream_event', event: { type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'toolu_001', name: 'Read' } } }
|
||||
#8 { type: 'stream_event', event: { type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"file_path":' } } }
|
||||
#9 { type: 'stream_event', event: { type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '"/path/package.json"}' } } }
|
||||
#10 { type: 'stream_event', event: { type: 'content_block_stop', index: 1 } }
|
||||
#11 { type: 'assistant', uuid: 'uuid-2', message: { content: [{ type: 'tool_use', id: 'toolu_001', name: 'Read', input: { file_path: '/path/package.json' } }], stop_reason: null } }
|
||||
#12 { type: 'stream_event', event: { type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 87 } } }
|
||||
#13 { type: 'stream_event', event: { type: 'message_stop' } }
|
||||
```
|
||||
|
||||
注意 `#6` 和 `#11` 是 **assistant 类型**(content_block_stop 时由 claude.ts 组装),其余全是 **stream_event 类型**。
|
||||
|
||||
### 循环体结构
|
||||
|
||||
循环体在 L708-866,结构如下:
|
||||
|
||||
```
|
||||
for await (const message of deps.callModel({...})) { // L659
|
||||
// A. 降级检查 (L712)
|
||||
// B. backfill (L747-789)
|
||||
// C. withheld 检查 (L801-824)
|
||||
// D. yield (L825-827)
|
||||
// E. assistant 收集 + addTool (L828-848)
|
||||
// F. getCompletedResults (L850-865)
|
||||
}
|
||||
```
|
||||
|
||||
### 逐条走循环体
|
||||
|
||||
#### #1 stream_event (message_start)
|
||||
|
||||
```
|
||||
A. L712: streamingFallbackOccured = false → 跳过
|
||||
|
||||
B. L748: message.type === 'assistant'?
|
||||
→ 'stream_event' !== 'assistant' → 跳过整个 backfill 块
|
||||
|
||||
C. L801-824: withheld 检查
|
||||
→ 不是 assistant 类型,各项检查均为 false → withheld = false
|
||||
|
||||
D. L825: yield message ✅ → 透传给 REPL(REPL 记录 ttftMs)
|
||||
|
||||
E. L828: message.type === 'assistant'? → 否 → 跳过
|
||||
|
||||
F. L850-854: streamingToolExecutor.getCompletedResults()
|
||||
→ tools 数组为空 → 无结果
|
||||
```
|
||||
|
||||
**净效果**:`yield` 透传。
|
||||
|
||||
---
|
||||
|
||||
#### #2 stream_event (content_block_start, type: text)
|
||||
|
||||
```
|
||||
A-C. 同 #1
|
||||
D. yield message ✅ → REPL 设置 spinner 为 "Responding..."
|
||||
E-F. 同 #1
|
||||
```
|
||||
|
||||
**净效果**:`yield` 透传。
|
||||
|
||||
---
|
||||
|
||||
#### #3 stream_event (text_delta: "我来")
|
||||
|
||||
```
|
||||
A-C. 同 #1
|
||||
D. yield message ✅ → REPL 追加 streamingText += "我来"(打字机效果)
|
||||
E-F. 同 #1
|
||||
```
|
||||
|
||||
**净效果**:`yield` 透传。
|
||||
|
||||
---
|
||||
|
||||
#### #4 stream_event (text_delta: "读取文件。")
|
||||
|
||||
```
|
||||
同 #3
|
||||
D. yield message ✅ → REPL streamingText += "读取文件。"
|
||||
```
|
||||
|
||||
**净效果**:`yield` 透传。
|
||||
|
||||
---
|
||||
|
||||
#### #5 stream_event (content_block_stop, index:0)
|
||||
|
||||
```
|
||||
同 #2
|
||||
D. yield message ✅ → REPL 无特殊操作(真正的 AssistantMessage 在下一条 #6)
|
||||
```
|
||||
|
||||
**净效果**:`yield` 透传。
|
||||
|
||||
---
|
||||
|
||||
#### #6 assistant (text block 完整消息) ★
|
||||
|
||||
第一条 `type: 'assistant'` 的消息,走**完全不同的路径**:
|
||||
|
||||
```
|
||||
A. L712: streamingFallbackOccured = false → 跳过
|
||||
|
||||
B. L748: message.type === 'assistant'? → ✅ 进入 backfill
|
||||
L750: contentArr = [{ type: 'text', text: '我来读取文件。' }]
|
||||
L752: for i=0: block.type === 'text'
|
||||
L754: block.type === 'tool_use'? → 否 → 跳过
|
||||
L783: clonedContent 为 undefined → yieldMessage = message(原样不变)
|
||||
|
||||
C. L801: let withheld = false
|
||||
L802: feature('CONTEXT_COLLAPSE') → false → 跳过
|
||||
L813: reactiveCompact?.isWithheldPromptTooLong(message) → 否 → false
|
||||
L822: isWithheldMaxOutputTokens(message)
|
||||
→ message.message.stop_reason === null → false
|
||||
→ withheld = false
|
||||
|
||||
D. L825: yield message ✅ → REPL 清除 streamingText,添加完整 text 消息到列表
|
||||
|
||||
E. L828: message.type === 'assistant'? → ✅
|
||||
L830: assistantMessages.push(message)
|
||||
→ assistantMessages = [uuid-1(text)]
|
||||
|
||||
L832-834: msgToolUseBlocks = content.filter(type === 'tool_use')
|
||||
→ [](这是 text block,没有 tool_use)
|
||||
|
||||
L835: length > 0? → 否 → 不设 needsFollowUp
|
||||
L844: msgToolUseBlocks 为空 → 不调用 addTool
|
||||
|
||||
F. L854: getCompletedResults() → 空
|
||||
```
|
||||
|
||||
**净效果**:`yield` 消息 + `assistantMessages` 增加一条。`needsFollowUp` 仍为 `false`。
|
||||
|
||||
---
|
||||
|
||||
#### #7 stream_event (content_block_start, tool_use: Read)
|
||||
|
||||
```
|
||||
A-C. 同 stream_event 通用路径
|
||||
D. yield message ✅ → REPL 设置 spinner 为 "tool-input",添加 streamingToolUse
|
||||
E. 不是 assistant → 跳过
|
||||
F. getCompletedResults() → 空
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### #8 stream_event (input_json_delta: `'{"file_path":'`)
|
||||
|
||||
```
|
||||
D. yield message ✅ → REPL 追加工具输入 JSON 碎片
|
||||
F. getCompletedResults() → 空
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### #9 stream_event (input_json_delta: '"/path/package.json"}')
|
||||
|
||||
```
|
||||
D. yield message ✅
|
||||
F. getCompletedResults() → 空
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### #10 stream_event (content_block_stop, index:1)
|
||||
|
||||
```
|
||||
D. yield message ✅
|
||||
F. getCompletedResults() → 空
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### #11 assistant (tool_use block 完整消息) ★★
|
||||
|
||||
这条是**最关键的**——触发工具执行:
|
||||
|
||||
```
|
||||
A. L712: streamingFallbackOccured = false → 跳过
|
||||
|
||||
B. L748: message.type === 'assistant'? → ✅ 进入 backfill
|
||||
L750: contentArr = [{ type: 'tool_use', id: 'toolu_001', name: 'Read',
|
||||
input: { file_path: '/path/package.json' } }]
|
||||
L752: for i=0:
|
||||
L754: block.type === 'tool_use'? → ✅
|
||||
L756: typeof block.input === 'object' && !== null? → ✅
|
||||
L759: tool = findToolByName(tools, 'Read') → Read 工具定义
|
||||
L763: tool.backfillObservableInput 存在? → 假设存在
|
||||
L764-766: inputCopy = { file_path: '/path/package.json' }
|
||||
tool.backfillObservableInput(inputCopy)
|
||||
→ 可能添加 absolutePath 字段
|
||||
L773-776: addedFields? → 假设有新增字段
|
||||
clonedContent = [...contentArr]
|
||||
clonedContent[0] = { ...block, input: inputCopy }
|
||||
L783-788: yieldMessage = {
|
||||
...message, // uuid, type, timestamp 不变
|
||||
message: {
|
||||
...message.message, // stop_reason, usage 不变
|
||||
content: clonedContent // ★ 替换为带 absolutePath 的副本
|
||||
}
|
||||
}
|
||||
// ★ 原始 message 保持不变(回传 API 保证缓存一致)
|
||||
|
||||
C. L801-824: withheld 检查 → 全部 false → withheld = false
|
||||
|
||||
D. L825: yield yieldMessage ✅
|
||||
→ yield 的是克隆版(带 backfill 字段),给 REPL 和 SDK 用
|
||||
→ 原始 message 下面存进 assistantMessages,回传 API 保证缓存一致
|
||||
|
||||
E. L828: message.type === 'assistant'? → ✅
|
||||
L830: assistantMessages.push(message) // ★ push 原始 message,不是 yieldMessage
|
||||
→ assistantMessages = [uuid-1(text), uuid-2(tool_use)]
|
||||
|
||||
L832-834: msgToolUseBlocks = content.filter(type === 'tool_use')
|
||||
→ [{ type: 'tool_use', id: 'toolu_001', name: 'Read', input: {...} }]
|
||||
|
||||
L835: length > 0? → ✅
|
||||
L836: toolUseBlocks.push(...msgToolUseBlocks)
|
||||
→ toolUseBlocks = [Read_block]
|
||||
L837: needsFollowUp = true // ★★★ 决定 while(true) 不会终止
|
||||
|
||||
L840-842: streamingToolExecutor 存在 ✓ && !aborted ✓
|
||||
L844-846: for (const toolBlock of msgToolUseBlocks):
|
||||
streamingToolExecutor.addTool(Read_block, uuid-2消息)
|
||||
// ★★★ 工具开始执行!
|
||||
// → StreamingToolExecutor 内部:
|
||||
// isConcurrencySafe = true(Read 是安全的)
|
||||
// queued → processQueue() → canExecuteTool() → true
|
||||
// → executeTool() → runToolUse() → 后台异步读文件
|
||||
|
||||
F. L850-854: getCompletedResults()
|
||||
→ Read 刚开始执行,status = 'executing' → 无完成结果
|
||||
```
|
||||
|
||||
**净效果**:
|
||||
- `yield` 克隆消息(带 backfill 字段)
|
||||
- `assistantMessages` push 原始消息
|
||||
- `needsFollowUp = true`
|
||||
- **Read 工具在后台异步开始执行**
|
||||
|
||||
---
|
||||
|
||||
#### #12 stream_event (message_delta, stop_reason: 'tool_use')
|
||||
|
||||
```
|
||||
A-C. 同 stream_event 通用路径
|
||||
D. yield message ✅
|
||||
|
||||
E. 不是 assistant → 跳过
|
||||
|
||||
F. L854: getCompletedResults()
|
||||
→ ★ 此时 Read 可能已经完成了!(读文件通常 <1ms)
|
||||
→ 如果完成: status = 'completed', results 有值
|
||||
L428(StreamingToolExecutor): tool.status = 'yielded'
|
||||
L431-432: yield { message: UserMsg(tool_result) }
|
||||
→ 回到 query.ts:
|
||||
L855: result.message 存在
|
||||
L856: yield result.message ✅ → REPL 显示工具结果
|
||||
L857-862: toolResults.push(normalizeMessagesForAPI([result.message])...)
|
||||
→ toolResults = [Read 的 tool_result]
|
||||
```
|
||||
|
||||
**净效果**:`yield` stream_event + **可能 yield 工具结果**(如果工具已完成)。
|
||||
|
||||
---
|
||||
|
||||
#### #13 stream_event (message_stop)
|
||||
|
||||
```
|
||||
D. yield message ✅
|
||||
F. getCompletedResults()
|
||||
→ 如果 Read 在 #12 已被收割 → 空
|
||||
→ 如果 Read 此时才完成 → yield 工具结果(同 #12 的 F 逻辑)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### for await 循环退出后
|
||||
|
||||
```
|
||||
L1018: aborted? → false → 跳过
|
||||
|
||||
L1065: if (!needsFollowUp)
|
||||
→ needsFollowUp = true → 不进入 → 跳过终止逻辑
|
||||
|
||||
L1383: toolUpdates = streamingToolExecutor.getRemainingResults()
|
||||
→ 如果 Read 已在 #12/#13 被收割 → 立即返回空
|
||||
→ 如果 Read 还没完成 → 阻塞等待 → 完成后 yield 结果
|
||||
|
||||
L1387-1404: for await (const update of toolUpdates) {
|
||||
yield update.message → REPL 显示
|
||||
toolResults.push(...) → 收集
|
||||
}
|
||||
|
||||
L1718-1730: 构建 next State:
|
||||
state = {
|
||||
messages: [
|
||||
...messagesForQuery, // [UserMessage("帮我看看...")]
|
||||
...assistantMessages, // [AssistantMsg(text), AssistantMsg(tool_use)]
|
||||
...toolResults, // [UserMsg(tool_result)]
|
||||
],
|
||||
turnCount: 1,
|
||||
transition: { reason: 'next_turn' },
|
||||
}
|
||||
→ continue → while(true) 第 2 次迭代 → 带着工具结果再次调 API
|
||||
```
|
||||
|
||||
### 循环体判定树总结
|
||||
|
||||
```
|
||||
for await (const message of deps.callModel(...)) {
|
||||
│
|
||||
├─ message.type === 'stream_event'?
|
||||
│ │
|
||||
│ └─ YES → 几乎零操作
|
||||
│ ├─ yield message(透传给 REPL 做实时 UI)
|
||||
│ └─ getCompletedResults()(顺便检查有没有完成的工具)
|
||||
│
|
||||
└─ message.type === 'assistant'?
|
||||
│
|
||||
├─ B. backfill: 有 tool_use + backfillObservableInput?
|
||||
│ ├─ YES → 克隆消息,yield 克隆版(原始消息保留给 API)
|
||||
│ └─ NO → yield 原始消息
|
||||
│
|
||||
├─ C. withheld: prompt_too_long / max_output_tokens?
|
||||
│ ├─ YES → 不 yield(暂扣,等后面恢复逻辑处理)
|
||||
│ └─ NO → yield
|
||||
│
|
||||
├─ E. assistantMessages.push(原始 message)
|
||||
│
|
||||
├─ E. 有 tool_use block?
|
||||
│ ├─ YES → toolUseBlocks.push()
|
||||
│ │ + needsFollowUp = true
|
||||
│ │ + streamingToolExecutor.addTool() → ★ 立即开始执行工具
|
||||
│ └─ NO → 什么都不做
|
||||
│
|
||||
└─ F. getCompletedResults() → 收割已完成的工具结果
|
||||
}
|
||||
```
|
||||
|
||||
**一句话总结**:stream_event 透传不处理;assistant 消息才是"真正的货"——收集起来、判断要不要暂扣、有工具就立即开始执行、顺便收割已完成的工具结果。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "2.0.4",
|
||||
"version": "2.2.1",
|
||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||
"type": "module",
|
||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||
|
||||
@@ -523,7 +523,7 @@ async function runInputActionGates(
|
||||
`visible in screenshots only, no clicks or typing.` +
|
||||
(isBrowser
|
||||
? ' Use the Claude-in-Chrome MCP for browser interaction (tools ' +
|
||||
'named `mcp__Claude_in_Chrome__*`; load via ToolSearch if ' +
|
||||
'named `mcp__Claude_in_Chrome__*`; load via SearchExtraTools if ' +
|
||||
'deferred).'
|
||||
: ' No interaction is permitted; ask the user to take any ' +
|
||||
'actions in this app themselves.') +
|
||||
@@ -1308,7 +1308,7 @@ function buildTierGuidanceMessage(tiered: TieredApp[]): string {
|
||||
`typing). You can read what's on screen but cannot navigate, click, ` +
|
||||
`or type into ${readBrowsers.length === 1 ? 'it' : 'them'}. For browser ` +
|
||||
`interaction, use the Claude-in-Chrome MCP (tools named ` +
|
||||
`\`mcp__Claude_in_Chrome__*\`; load via ToolSearch if deferred).`,
|
||||
`\`mcp__Claude_in_Chrome__*\`; load via SearchExtraTools if deferred).`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -131,8 +131,13 @@ type Props = {
|
||||
const MULTI_CLICK_TIMEOUT_MS = 500;
|
||||
const MULTI_CLICK_DISTANCE = 1;
|
||||
|
||||
type ErrorInfo = {
|
||||
readonly message: string;
|
||||
readonly stack?: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
readonly error?: Error;
|
||||
readonly error?: ErrorInfo;
|
||||
};
|
||||
|
||||
// Root component for all Ink apps
|
||||
@@ -142,7 +147,7 @@ export default class App extends PureComponent<Props, State> {
|
||||
static displayName = 'InternalApp';
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
return { error: { message: error.message, stack: error.stack } };
|
||||
}
|
||||
|
||||
override state = {
|
||||
@@ -221,7 +226,7 @@ export default class App extends PureComponent<Props, State> {
|
||||
<TerminalFocusProvider>
|
||||
<ClockProvider>
|
||||
<CursorDeclarationContext.Provider value={this.props.onCursorDeclaration ?? (() => {})}>
|
||||
{this.state.error ? <ErrorOverview error={this.state.error as Error} /> : this.props.children}
|
||||
{this.state.error ? <ErrorOverview error={this.state.error} /> : this.props.children}
|
||||
</CursorDeclarationContext.Provider>
|
||||
</ClockProvider>
|
||||
</TerminalFocusProvider>
|
||||
|
||||
@@ -23,8 +23,13 @@ function getStackUtils(): StackUtils {
|
||||
|
||||
/* eslint-enable custom-rules/no-process-cwd */
|
||||
|
||||
type ErrorLike = {
|
||||
readonly message: string;
|
||||
readonly stack?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
readonly error: Error;
|
||||
readonly error: ErrorLike;
|
||||
};
|
||||
|
||||
export default function ErrorOverview({ error }: Props) {
|
||||
|
||||
@@ -29,7 +29,7 @@ export { SkillTool } from './tools/SkillTool/SkillTool.js'
|
||||
export { TaskOutputTool } from './tools/TaskOutputTool/TaskOutputTool.js'
|
||||
export { TaskStopTool } from './tools/TaskStopTool/TaskStopTool.js'
|
||||
export { TodoWriteTool } from './tools/TodoWriteTool/TodoWriteTool.js'
|
||||
export { ToolSearchTool } from './tools/ToolSearchTool/ToolSearchTool.js'
|
||||
export { SearchExtraToolsTool } from './tools/SearchExtraToolsTool/SearchExtraToolsTool.js'
|
||||
export { TungstenTool } from './tools/TungstenTool/TungstenTool.js'
|
||||
export { WebFetchTool } from './tools/WebFetchTool/WebFetchTool.js'
|
||||
export { WebSearchTool } from './tools/WebSearchTool/WebSearchTool.js'
|
||||
|
||||
@@ -57,13 +57,4 @@ describe('prompt.ts fork-related text verification', () => {
|
||||
expect(bgCondition[0]).not.toContain('!forkEnabled')
|
||||
}
|
||||
})
|
||||
|
||||
test('fork example includes fork: true parameter', () => {
|
||||
// The first fork example should have fork: true
|
||||
const forkExampleBlock = promptSource.match(
|
||||
/name: "ship-audit"[\s\S]*?Under 200 words/,
|
||||
)
|
||||
expect(forkExampleBlock).not.toBeNull()
|
||||
expect(forkExampleBlock![0]).toContain('fork: true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ import { isEnvDefinedFalsy, isEnvTruthy } from 'src/utils/envUtils.js'
|
||||
import { isTeammate } from 'src/utils/teammate.js'
|
||||
import { isInProcessTeammate } from 'src/utils/teammateContext.js'
|
||||
import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'
|
||||
import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js'
|
||||
import { GLOB_TOOL_NAME } from '../GlobTool/prompt.js'
|
||||
import { SEND_MESSAGE_TOOL_NAME } from '../SendMessageTool/constants.js'
|
||||
import { AGENT_TOOL_NAME } from './constants.js'
|
||||
@@ -84,11 +83,11 @@ export async function getPrompt(
|
||||
|
||||
When you need to delegate work that benefits from full conversation context (e.g., continuing a multi-file refactor where the child needs the same system prompt and history), use \`fork: true\`. For most tasks, prefer specialized agent types (Explore, Plan, general-purpose).
|
||||
|
||||
**Don't peek.** The tool result includes an \`output_file\` path — do not Read or tail it unless the user explicitly asks for a progress check. You get a completion notification; trust it. Reading the transcript mid-flight pulls the fork's tool noise into your context, which defeats the point of forking.
|
||||
**Don't peek.** The tool result includes an \`output_file\` path — do not Read or tail it unless the user explicitly asks for a progress check. You get a completion notification; trust it.
|
||||
|
||||
**Don't race.** After launching, you know nothing about what the fork found. Never fabricate or predict fork results in any format — not as prose, summary, or structured output. The notification arrives as a user-role message in a later turn; it is never something you write yourself. If the user asks a follow-up before the notification lands, tell them the fork is still running — give status, not a guess.
|
||||
**Don't race.** After launching, you know nothing about what the fork found. Never fabricate or predict fork results. If the user asks a follow-up before the notification lands, tell them the fork is still running.
|
||||
|
||||
**Writing a fork prompt.** Since the fork inherits your context, the prompt is a *directive* — what to do, not what the situation is. Be specific about scope: what's in, what's out, what another agent is handling. Don't re-explain background.
|
||||
**Writing a fork prompt.** Since the fork inherits your context, the prompt is a *directive* — what to do, not what the situation is. Be specific about scope. Don't re-explain background.
|
||||
`
|
||||
: ''
|
||||
|
||||
@@ -97,91 +96,13 @@ When you need to delegate work that benefits from full conversation context (e.g
|
||||
## Writing the prompt
|
||||
|
||||
${forkEnabled ? 'When spawning an agent without `fork: true`, it starts with zero context. ' : ''}Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
|
||||
- Explain what you're trying to accomplish and why.
|
||||
- Describe what you've already learned or ruled out.
|
||||
- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
|
||||
- Explain what you're trying to accomplish and why, what you've already learned or ruled out, and enough context for the agent to make judgment calls.
|
||||
- If you need a short response, say so ("report in under 200 words").
|
||||
- Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.
|
||||
|
||||
${forkEnabled ? 'For non-fork agents, terse' : 'Terse'} command-style prompts produce shallow, generic work.
|
||||
|
||||
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.
|
||||
`
|
||||
|
||||
const forkExamples = `Example usage:
|
||||
|
||||
<example>
|
||||
user: "What's left on this branch before we can ship?"
|
||||
assistant: <thinking>Forking this \u2014 it's a survey question. I want the punch list, not the git output in my context.</thinking>
|
||||
${AGENT_TOOL_NAME}({
|
||||
name: "ship-audit",
|
||||
description: "Branch ship-readiness audit",
|
||||
fork: true,
|
||||
prompt: "Audit what's left before this branch can ship. Check: uncommitted changes, commits ahead of main, whether tests exist, whether the GrowthBook gate is wired up, whether CI-relevant files changed. Report a punch list \u2014 done vs. missing. Under 200 words."
|
||||
})
|
||||
assistant: Ship-readiness audit running.
|
||||
<commentary>
|
||||
Turn ends here. The coordinator knows nothing about the findings yet. What follows is a SEPARATE turn \u2014 the notification arrives from outside, as a user-role message. It is not something the coordinator writes.
|
||||
</commentary>
|
||||
[later turn \u2014 notification arrives as user message]
|
||||
assistant: Audit's back. Three blockers: no tests for the new prompt path, GrowthBook gate wired but not in build_flags.yaml, and one uncommitted file.
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "so is the gate wired up or not"
|
||||
<commentary>
|
||||
User asks mid-wait. The audit fork was launched to answer exactly this, and it hasn't returned. The coordinator does not have this answer. Give status, not a fabricated result.
|
||||
</commentary>
|
||||
assistant: Still waiting on the audit \u2014 that's one of the things it's checking. Should land shortly.
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Can you get a second opinion on whether this migration is safe?"
|
||||
assistant: <thinking>I'll ask the code-reviewer agent — it won't see my analysis, so it can give an independent read.</thinking>
|
||||
<commentary>
|
||||
A subagent_type is specified, so the agent starts fresh. It needs full context in the prompt. The briefing explains what to assess and why.
|
||||
</commentary>
|
||||
${AGENT_TOOL_NAME}({
|
||||
name: "migration-review",
|
||||
description: "Independent migration review",
|
||||
subagent_type: "code-reviewer",
|
||||
prompt: "Review migration 0042_user_schema.sql for safety. Context: we're adding a NOT NULL column to a 50M-row table. Existing rows get a backfill default. I want a second opinion on whether the backfill approach is safe under concurrent writes — I've checked locking behavior but want independent verification. Report: is this safe, and if not, what specifically breaks?"
|
||||
})
|
||||
</example>
|
||||
`
|
||||
|
||||
const currentExamples = `Example usage:
|
||||
|
||||
<example_agent_descriptions>
|
||||
"test-runner": use this agent after you are done writing code to run tests
|
||||
"greeting-responder": use this agent to respond to user greetings with a friendly joke
|
||||
</example_agent_descriptions>
|
||||
|
||||
<example>
|
||||
user: "Please write a function that checks if a number is prime"
|
||||
assistant: I'm going to use the ${FILE_WRITE_TOOL_NAME} tool to write the following code:
|
||||
<code>
|
||||
function isPrime(n) {
|
||||
if (n <= 1) return false
|
||||
for (let i = 2; i * i <= n; i++) {
|
||||
if (n % i === 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
</code>
|
||||
<commentary>
|
||||
Since a significant piece of code was written and the task was completed, now use the test-runner agent to run the tests
|
||||
</commentary>
|
||||
assistant: Uses the ${AGENT_TOOL_NAME} tool to launch the test-runner agent
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Hello"
|
||||
<commentary>
|
||||
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke
|
||||
</commentary>
|
||||
assistant: "I'm going to use the ${AGENT_TOOL_NAME} tool to launch the greeting-responder agent"
|
||||
</example>
|
||||
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Write prompts that prove you understood: include file paths, line numbers, what specifically to change.
|
||||
`
|
||||
|
||||
// When the gate is on, the agent list lives in an agent_listing_delta
|
||||
@@ -273,7 +194,5 @@ Usage notes:
|
||||
? `
|
||||
- The name, team_name, and mode parameters are not available in this context — teammates cannot spawn other teammates. Omit them to spawn a subagent.`
|
||||
: ''
|
||||
}${whenToForkSection}${writingThePromptSection}
|
||||
|
||||
${forkEnabled ? forkExamples : currentExamples}`
|
||||
}${whenToForkSection}${writingThePromptSection}`
|
||||
}
|
||||
|
||||
@@ -314,15 +314,13 @@ export function getSimplePrompt(): string {
|
||||
'Use the Monitor tool to stream events from a background process (each stdout line is a notification). For one-shot "wait until done," use Bash with run_in_background instead.',
|
||||
]
|
||||
: []),
|
||||
'If your command is long running and you would like to be notified when it finishes — use `run_in_background`. No sleep needed.',
|
||||
'For long-running commands, use `run_in_background` — you will be notified when it completes. Do not poll.',
|
||||
'Do not retry failing commands in a sleep loop — diagnose the root cause.',
|
||||
'If waiting for a background task you started with `run_in_background`, you will be notified when it completes — do not poll.',
|
||||
...(feature('MONITOR_TOOL')
|
||||
? [
|
||||
'`sleep N` as the first command with N ≥ 2 is blocked. If you need a delay (rate limiting, deliberate pacing), keep it under 2 seconds.',
|
||||
]
|
||||
: [
|
||||
'If you must poll an external process, use a check command (e.g. `gh run view`) rather than sleeping first.',
|
||||
'If you must sleep, keep the duration short (1-5 seconds) to avoid blocking the user.',
|
||||
]),
|
||||
]
|
||||
|
||||
@@ -8,6 +8,7 @@ import { buildTool, type ToolDef } from 'src/Tool.js'
|
||||
import { isEnvTruthy } from 'src/utils/envUtils.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { plural } from 'src/utils/stringUtils.js'
|
||||
import { isBridgeEnabled } from 'src/bridge/bridgeEnabled.js'
|
||||
import { resolveAttachments, validateAttachmentPaths } from './attachments.js'
|
||||
import {
|
||||
BRIEF_TOOL_NAME,
|
||||
@@ -149,7 +150,7 @@ export const BriefTool = buildTool({
|
||||
return outputSchema()
|
||||
},
|
||||
isEnabled() {
|
||||
return isBriefEnabled()
|
||||
return isBridgeEnabled()
|
||||
},
|
||||
isConcurrencySafe() {
|
||||
return true
|
||||
|
||||
@@ -26,33 +26,13 @@ function getEnterPlanModeToolPromptExternal(): string {
|
||||
|
||||
**Prefer using EnterPlanMode** for implementation tasks unless they're simple. Use it when ANY of these conditions apply:
|
||||
|
||||
1. **New Feature Implementation**: Adding meaningful new functionality
|
||||
- Example: "Add a logout button" - where should it go? What should happen on click?
|
||||
- Example: "Add form validation" - what rules? What error messages?
|
||||
|
||||
2. **Multiple Valid Approaches**: The task can be solved in several different ways
|
||||
- Example: "Add caching to the API" - could use Redis, in-memory, file-based, etc.
|
||||
- Example: "Improve performance" - many optimization strategies possible
|
||||
|
||||
3. **Code Modifications**: Changes that affect existing behavior or structure
|
||||
- Example: "Update the login flow" - what exactly should change?
|
||||
- Example: "Refactor this component" - what's the target architecture?
|
||||
|
||||
4. **Architectural Decisions**: The task requires choosing between patterns or technologies
|
||||
- Example: "Add real-time updates" - WebSockets vs SSE vs polling
|
||||
- Example: "Implement state management" - Redux vs Context vs custom solution
|
||||
|
||||
5. **Multi-File Changes**: The task will likely touch more than 2-3 files
|
||||
- Example: "Refactor the authentication system"
|
||||
- Example: "Add a new API endpoint with tests"
|
||||
|
||||
6. **Unclear Requirements**: You need to explore before understanding the full scope
|
||||
- Example: "Make the app faster" - need to profile and identify bottlenecks
|
||||
- Example: "Fix the bug in checkout" - need to investigate root cause
|
||||
|
||||
7. **User Preferences Matter**: The implementation could reasonably go multiple ways
|
||||
- If you would use ${ASK_USER_QUESTION_TOOL_NAME} to clarify the approach, use EnterPlanMode instead
|
||||
- Plan mode lets you explore first, then present options with context
|
||||
1. **New Feature Implementation** — Adding meaningful new functionality where the implementation path isn't obvious
|
||||
2. **Multiple Valid Approaches** — The task can be solved in several different ways
|
||||
3. **Code Modifications** — Changes that affect existing behavior or structure, where the user should approve the approach
|
||||
4. **Architectural Decisions** — The task requires choosing between patterns or technologies
|
||||
5. **Multi-File Changes** — The task will likely touch more than 2-3 files
|
||||
6. **Unclear Requirements** — You need to explore before understanding the full scope
|
||||
7. **User Preferences Matter** — If you would use ${ASK_USER_QUESTION_TOOL_NAME} to clarify the approach, use EnterPlanMode instead
|
||||
|
||||
## When NOT to Use This Tool
|
||||
|
||||
@@ -62,35 +42,7 @@ Only skip EnterPlanMode for simple tasks:
|
||||
- Tasks where the user has given very specific, detailed instructions
|
||||
- Pure research/exploration tasks (use the Agent tool with explore agent instead)
|
||||
|
||||
${whatHappens}## Examples
|
||||
|
||||
### GOOD - Use EnterPlanMode:
|
||||
User: "Add user authentication to the app"
|
||||
- Requires architectural decisions (session vs JWT, where to store tokens, middleware structure)
|
||||
|
||||
User: "Optimize the database queries"
|
||||
- Multiple approaches possible, need to profile first, significant impact
|
||||
|
||||
User: "Implement dark mode"
|
||||
- Architectural decision on theme system, affects many components
|
||||
|
||||
User: "Add a delete button to the user profile"
|
||||
- Seems simple but involves: where to place it, confirmation dialog, API call, error handling, state updates
|
||||
|
||||
User: "Update the error handling in the API"
|
||||
- Affects multiple files, user should approve the approach
|
||||
|
||||
### BAD - Don't use EnterPlanMode:
|
||||
User: "Fix the typo in the README"
|
||||
- Straightforward, no planning needed
|
||||
|
||||
User: "Add a console.log to debug this function"
|
||||
- Simple, obvious implementation
|
||||
|
||||
User: "What files handle routing?"
|
||||
- Research task, not implementation planning
|
||||
|
||||
## Important Notes
|
||||
${whatHappens}## Important Notes
|
||||
|
||||
- This tool REQUIRES user approval - they must consent to entering plan mode
|
||||
- If unsure whether to use it, err on the side of planning - it's better to get alignment upfront than to redo work
|
||||
@@ -111,53 +63,23 @@ function getEnterPlanModeToolPromptAnt(): string {
|
||||
|
||||
Plan mode is valuable when the implementation approach is genuinely unclear. Use it when:
|
||||
|
||||
1. **Significant Architectural Ambiguity**: Multiple reasonable approaches exist and the choice meaningfully affects the codebase
|
||||
- Example: "Add caching to the API" - Redis vs in-memory vs file-based
|
||||
- Example: "Add real-time updates" - WebSockets vs SSE vs polling
|
||||
|
||||
2. **Unclear Requirements**: You need to explore and clarify before you can make progress
|
||||
- Example: "Make the app faster" - need to profile and identify bottlenecks
|
||||
- Example: "Refactor this module" - need to understand what the target architecture should be
|
||||
|
||||
3. **High-Impact Restructuring**: The task will significantly restructure existing code and getting buy-in first reduces risk
|
||||
- Example: "Redesign the authentication system"
|
||||
- Example: "Migrate from one state management approach to another"
|
||||
1. **Significant Architectural Ambiguity** — Multiple reasonable approaches exist and the choice meaningfully affects the codebase
|
||||
2. **Unclear Requirements** — You need to explore and clarify before you can make progress
|
||||
3. **High-Impact Restructuring** — The task will significantly restructure existing code and getting buy-in first reduces risk
|
||||
|
||||
## When NOT to Use This Tool
|
||||
|
||||
Skip plan mode when you can reasonably infer the right approach:
|
||||
- The task is straightforward even if it touches multiple files
|
||||
- The user's request is specific enough that the implementation path is clear
|
||||
- You're adding a feature with an obvious implementation pattern (e.g., adding a button, a new endpoint following existing conventions)
|
||||
- You're adding a feature with an obvious implementation pattern
|
||||
- Bug fixes where the fix is clear once you understand the bug
|
||||
- Research/exploration tasks (use the Agent tool instead)
|
||||
- The user says something like "can we work on X" or "let's do X" — just get started
|
||||
|
||||
When in doubt, prefer starting work and using ${ASK_USER_QUESTION_TOOL_NAME} for specific questions over entering a full planning phase.
|
||||
|
||||
${whatHappens}## Examples
|
||||
|
||||
### GOOD - Use EnterPlanMode:
|
||||
User: "Add user authentication to the app"
|
||||
- Genuinely ambiguous: session vs JWT, where to store tokens, middleware structure
|
||||
|
||||
User: "Redesign the data pipeline"
|
||||
- Major restructuring where the wrong approach wastes significant effort
|
||||
|
||||
### BAD - Don't use EnterPlanMode:
|
||||
User: "Add a delete button to the user profile"
|
||||
- Implementation path is clear; just do it
|
||||
|
||||
User: "Can we work on the search feature?"
|
||||
- User wants to get started, not plan
|
||||
|
||||
User: "Update the error handling in the API"
|
||||
- Start working; ask specific questions if needed
|
||||
|
||||
User: "Fix the typo in the README"
|
||||
- Straightforward, no planning needed
|
||||
|
||||
## Important Notes
|
||||
${whatHappens}## Important Notes
|
||||
|
||||
- This tool REQUIRES user approval - they must consent to entering plan mode
|
||||
`
|
||||
|
||||
147
packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts
Normal file
147
packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { z } from 'zod/v4'
|
||||
import {
|
||||
buildTool,
|
||||
findToolByName,
|
||||
type Tool,
|
||||
type ToolDef,
|
||||
type ToolUseContext,
|
||||
type ToolResult,
|
||||
type Tools,
|
||||
} from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { createUserMessage } from 'src/utils/messages.js'
|
||||
import { DESCRIPTION, getPrompt } from './prompt.js'
|
||||
import { EXECUTE_TOOL_NAME } from './constants.js'
|
||||
|
||||
export const inputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
tool_name: z
|
||||
.string()
|
||||
.describe(
|
||||
'The exact name of the target tool to execute (e.g., "CronCreate", "mcp__server__action")',
|
||||
),
|
||||
params: z
|
||||
.record(z.string(), z.unknown())
|
||||
.describe('The parameters to pass to the target tool'),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
|
||||
export const outputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
result: z.unknown(),
|
||||
tool_name: z.string(),
|
||||
}),
|
||||
)
|
||||
type OutputSchema = ReturnType<typeof outputSchema>
|
||||
|
||||
export type Output = z.infer<OutputSchema>
|
||||
|
||||
export const ExecuteTool = buildTool({
|
||||
name: EXECUTE_TOOL_NAME,
|
||||
searchHint: 'execute run invoke call a deferred tool by name with parameters',
|
||||
maxResultSizeChars: 100_000,
|
||||
isConcurrencySafe() {
|
||||
return false
|
||||
},
|
||||
get inputSchema(): InputSchema {
|
||||
return inputSchema()
|
||||
},
|
||||
get outputSchema(): OutputSchema {
|
||||
return outputSchema()
|
||||
},
|
||||
async description() {
|
||||
return DESCRIPTION
|
||||
},
|
||||
async prompt() {
|
||||
return getPrompt()
|
||||
},
|
||||
async call(input, context, canUseTool, parentMessage, onProgress) {
|
||||
const tools: Tools = context.options.tools ?? []
|
||||
|
||||
const targetTool = findToolByName(tools, input.tool_name)
|
||||
if (!targetTool) {
|
||||
return {
|
||||
data: {
|
||||
result: null,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
newMessages: [
|
||||
createUserMessage({
|
||||
content: `Tool "${input.tool_name}" not found. Use SearchExtraTools to discover available tools.`,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the target tool is currently enabled
|
||||
if (!targetTool.isEnabled()) {
|
||||
return {
|
||||
data: {
|
||||
result: null,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
newMessages: [
|
||||
createUserMessage({
|
||||
content: `工具 "${input.tool_name}" 当前不可用:Remote Control 未连接。`,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Check permissions on the target tool
|
||||
const permResult = await targetTool.checkPermissions?.(
|
||||
input.params as Record<string, unknown>,
|
||||
context,
|
||||
)
|
||||
if (permResult && permResult.behavior === 'deny') {
|
||||
return {
|
||||
data: {
|
||||
result: null,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
newMessages: [
|
||||
createUserMessage({
|
||||
content: `Permission denied for tool "${input.tool_name}": ${permResult.message ?? 'Permission denied'}`,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate execution to the target tool
|
||||
const targetResult: ToolResult<unknown> = await targetTool.call(
|
||||
input.params as Record<string, unknown>,
|
||||
context,
|
||||
canUseTool,
|
||||
parentMessage,
|
||||
onProgress,
|
||||
)
|
||||
|
||||
return {
|
||||
...targetResult,
|
||||
data: {
|
||||
result: targetResult.data,
|
||||
tool_name: input.tool_name,
|
||||
},
|
||||
}
|
||||
},
|
||||
async checkPermissions() {
|
||||
return {
|
||||
behavior: 'passthrough',
|
||||
message: 'ExecuteExtraTool delegates permission to the target tool.',
|
||||
}
|
||||
},
|
||||
renderToolUseMessage(input) {
|
||||
return `Executing ${input.tool_name}...`
|
||||
},
|
||||
userFacingName() {
|
||||
return 'ExecuteExtraTool'
|
||||
},
|
||||
mapToolResultToToolResultBlockParam(content, toolUseID) {
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: JSON.stringify(content),
|
||||
}
|
||||
},
|
||||
} satisfies ToolDef<InputSchema, Output>)
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { mock } from 'bun:test'
|
||||
import { logMock } from '../../../../../../tests/mocks/log'
|
||||
import { debugMock } from '../../../../../../tests/mocks/debug'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
// Mock all heavy dependencies before importing ExecuteTool
|
||||
mock.module('src/services/analytics/growthbook.js', () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
|
||||
checkStatsigFeatureGate_CACHED_MAY_BE_STALE: () => false,
|
||||
getFeatureValue_DEPRECATED: async () => undefined,
|
||||
getFeatureValue_CACHED_WITH_REFRESH: async () => undefined,
|
||||
hasGrowthBookEnvOverride: () => false,
|
||||
getAllGrowthBookFeatures: () => ({}),
|
||||
getGrowthBookConfigOverrides: () => ({}),
|
||||
setGrowthBookConfigOverride: () => {},
|
||||
clearGrowthBookConfigOverrides: () => {},
|
||||
getApiBaseUrlHost: () => undefined,
|
||||
onGrowthBookRefresh: () => {},
|
||||
initializeGrowthBook: async () => {},
|
||||
checkSecurityRestrictionGate: async () => false,
|
||||
checkGate_CACHED_OR_BLOCKING: async () => false,
|
||||
refreshGrowthBookAfterAuthChange: () => {},
|
||||
resetGrowthBook: () => {},
|
||||
refreshGrowthBookFeatures: async () => {},
|
||||
setupPeriodicGrowthBookRefresh: () => {},
|
||||
stopPeriodicGrowthBookRefresh: () => {},
|
||||
}))
|
||||
|
||||
mock.module('src/utils/searchExtraTools.js', () => ({
|
||||
isSearchExtraToolsEnabledOptimistic: () => true,
|
||||
getAutoSearchExtraToolsCharThreshold: () => 100,
|
||||
getSearchExtraToolsMode: () => 'tst' as const,
|
||||
isSearchExtraToolsToolAvailable: async () => true,
|
||||
isSearchExtraToolsEnabled: async () => true,
|
||||
isToolReferenceBlock: () => false,
|
||||
extractDiscoveredToolNames: () => new Set(),
|
||||
isDeferredToolsDeltaEnabled: () => false,
|
||||
getDeferredToolsDelta: () => null,
|
||||
}))
|
||||
|
||||
mock.module('src/constants/tools.js', () => ({
|
||||
CORE_TOOLS: new Set(['ExecuteExtraTool', 'SearchExtraTools']),
|
||||
}))
|
||||
|
||||
// Mock messages module
|
||||
mock.module('src/utils/messages.js', () => ({
|
||||
createUserMessage: ({ content }: { content: string }) => ({
|
||||
type: 'user' as const,
|
||||
content,
|
||||
uuid: 'test-uuid',
|
||||
}),
|
||||
}))
|
||||
|
||||
const { ExecuteTool } = await import('../ExecuteTool.js')
|
||||
const { EXECUTE_TOOL_NAME } = await import('../constants.js')
|
||||
|
||||
function makeContext(tools: unknown[] = []) {
|
||||
return {
|
||||
options: {
|
||||
tools,
|
||||
},
|
||||
cwd: '/tmp',
|
||||
sessionId: 'test',
|
||||
} as never
|
||||
}
|
||||
|
||||
function makeMockTool(name: string, callResult: unknown = 'ok') {
|
||||
return {
|
||||
name,
|
||||
call: async () => ({ data: callResult }),
|
||||
checkPermissions: async () => ({ behavior: 'allow' as const }),
|
||||
prompt: async () => `Description for ${name}`,
|
||||
description: async () => `Description for ${name}`,
|
||||
inputSchema: {},
|
||||
isEnabled: () => true,
|
||||
isConcurrencySafe: () => true,
|
||||
isReadOnly: () => false,
|
||||
isMcp: false,
|
||||
alwaysLoad: undefined,
|
||||
shouldDefer: undefined,
|
||||
searchHint: '',
|
||||
userFacingName: () => name,
|
||||
renderToolUseMessage: () => `Running ${name}`,
|
||||
mapToolResultToToolResultBlockParam: (content: unknown, id: string) => ({
|
||||
tool_use_id: id,
|
||||
type: 'tool_result',
|
||||
content,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('ExecuteTool', () => {
|
||||
test('executes a target tool by name', async () => {
|
||||
const mockTarget = makeMockTool('TestTool', { result: 'success' })
|
||||
const ctx = makeContext([mockTarget])
|
||||
|
||||
const result = await ExecuteTool.call(
|
||||
{ tool_name: 'TestTool', params: {} },
|
||||
ctx,
|
||||
async () => ({ behavior: 'allow' }),
|
||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.data).toEqual({
|
||||
result: { result: 'success' },
|
||||
tool_name: 'TestTool',
|
||||
})
|
||||
})
|
||||
|
||||
test('returns error when tool not found', async () => {
|
||||
const ctx = makeContext([])
|
||||
|
||||
const result = await ExecuteTool.call(
|
||||
{ tool_name: 'NonexistentTool', params: {} },
|
||||
ctx,
|
||||
async () => ({ behavior: 'allow' }),
|
||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.data).toEqual({
|
||||
result: null,
|
||||
tool_name: 'NonexistentTool',
|
||||
})
|
||||
expect(result.newMessages).toBeDefined()
|
||||
expect(result.newMessages!.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('returns permission denied when target denies', async () => {
|
||||
const mockTarget = makeMockTool('SecretTool', 'secret')
|
||||
mockTarget.checkPermissions = async () =>
|
||||
({
|
||||
behavior: 'deny' as const,
|
||||
message: 'Access denied',
|
||||
}) as never
|
||||
const ctx = makeContext([mockTarget])
|
||||
|
||||
const result = await ExecuteTool.call(
|
||||
{ tool_name: 'SecretTool', params: {} },
|
||||
ctx,
|
||||
async () => ({ behavior: 'allow' }),
|
||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.data).toEqual({
|
||||
result: null,
|
||||
tool_name: 'SecretTool',
|
||||
})
|
||||
expect(result.newMessages).toBeDefined()
|
||||
})
|
||||
|
||||
test('has correct name', () => {
|
||||
expect(ExecuteTool.name).toBe(EXECUTE_TOOL_NAME)
|
||||
})
|
||||
|
||||
test('searchHint contains keywords', () => {
|
||||
expect(ExecuteTool.searchHint).toContain('execute')
|
||||
expect(ExecuteTool.searchHint).toContain('tool')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* ExecuteTool.test.ts
|
||||
*
|
||||
* Thin subprocess wrapper that runs the actual tests in an isolated bun:test
|
||||
* process. This prevents mock.module() leaks from other test files
|
||||
* (e.g., agentToolUtils.test.ts mocking src/Tool.js) from affecting
|
||||
* ExecuteTool's tests.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { resolve, relative } from 'path'
|
||||
|
||||
const PROJECT_ROOT = resolve(__dirname, '..', '..', '..', '..', '..')
|
||||
const RUNNER_ABS = resolve(__dirname, 'ExecuteTool.runner.ts')
|
||||
const RUNNER_REL = './' + relative(PROJECT_ROOT, RUNNER_ABS).replace(/\\/g, '/')
|
||||
|
||||
describe('ExecuteTool', () => {
|
||||
test('runs all ExecuteTool tests in isolated subprocess', async () => {
|
||||
const proc = Bun.spawn(['bun', 'test', RUNNER_REL], {
|
||||
cwd: PROJECT_ROOT,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const code = await proc.exited
|
||||
if (code !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const output = (stderr + '\n' + stdout).slice(-3000)
|
||||
throw new Error(
|
||||
`ExecuteTool test subprocess failed (exit ${code}):\n${output}`,
|
||||
)
|
||||
}
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export const EXECUTE_TOOL_NAME = 'ExecuteExtraTool'
|
||||
19
packages/builtin-tools/src/tools/ExecuteTool/prompt.ts
Normal file
19
packages/builtin-tools/src/tools/ExecuteTool/prompt.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { EXECUTE_TOOL_NAME } from './constants.js'
|
||||
|
||||
export const DESCRIPTION =
|
||||
'ExecuteExtraTool — a first-class core tool that is always loaded and available. Execute any deferred tool by name with parameters. Use it after discovering a tool via SearchExtraTools. This is NOT a remote or external tool — it runs locally with full permissions.'
|
||||
|
||||
export function getPrompt(): string {
|
||||
return `ExecuteExtraTool — a first-class core tool, always loaded, always available in your tool list. Runs locally with full permissions — NOT a remote or external tool. You do NOT need to search for it.
|
||||
|
||||
This tool accepts a tool_name and params object, looks up the target tool in the global tool registry, and delegates execution to it. The target tool runs with the same permissions and capabilities as if it were called directly.
|
||||
|
||||
When to use: After SearchExtraTools discovers a deferred tool name, call this tool with {"tool_name": "<name>", "params": {...}} to invoke it immediately.
|
||||
When NOT to use: For core tools already in your tool list (Read, Edit, Write, Bash, Glob, Grep, Agent, WebFetch, WebSearch, Skill, etc.) — call those directly.
|
||||
|
||||
Inputs:
|
||||
- tool_name: The exact name of the target tool (string)
|
||||
- params: The parameters to pass to the target tool (object)
|
||||
|
||||
If the tool is not found, an error message will be returned suggesting to use SearchExtraTools to discover available tools.`
|
||||
}
|
||||
@@ -20,10 +20,4 @@ Ensure your plan is complete and unambiguous:
|
||||
- Once your plan is finalized, use THIS tool to request approval
|
||||
|
||||
**Important:** Do NOT use ${ASK_USER_QUESTION_TOOL_NAME} to ask "Is this plan okay?" or "Should I proceed?" - that's exactly what THIS tool does. ExitPlanMode inherently requests user approval of your plan.
|
||||
|
||||
## Examples
|
||||
|
||||
1. Initial task: "Search for and understand the implementation of vim mode in the codebase" - Do not use the exit plan mode tool because you are not planning the implementation steps of a task.
|
||||
2. Initial task: "Help me implement yank mode for vim" - Use the exit plan mode tool after you have finished planning the implementation steps of the task.
|
||||
3. Initial task: "Add a new feature to handle user authentication" - If unsure about auth method (OAuth, JWT, etc.), use ${ASK_USER_QUESTION_TOOL_NAME} first, then use exit plan mode tool after clarifying the approach.
|
||||
`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
|
||||
import type { StructuredPatchHunk } from 'diff';
|
||||
import * as React from 'react';
|
||||
import { Suspense, use, useState } from 'react';
|
||||
import { FileEditToolUseRejectedMessage } from 'src/components/FileEditToolUseRejectedMessage.js';
|
||||
import { MessageResponse } from 'src/components/MessageResponse.js';
|
||||
import { extractTag } from 'src/utils/messages.js';
|
||||
@@ -10,10 +12,15 @@ import { Text } from '@anthropic/ink';
|
||||
import { FilePathLink } from 'src/components/FilePathLink.js';
|
||||
import type { Tools } from 'src/Tool.js';
|
||||
import type { Message, ProgressMessage } from 'src/types/message.js';
|
||||
import { adjustHunkLineNumbers, CONTEXT_LINES } from 'src/utils/diff.js';
|
||||
import { FILE_NOT_FOUND_CWD_NOTE, getDisplayPath } from 'src/utils/file.js';
|
||||
import { logError } from 'src/utils/log.js';
|
||||
import { getPlansDirectory } from 'src/utils/plans.js';
|
||||
import { readEditContext } from 'src/utils/readEditContext.js';
|
||||
import { firstLineOf } from 'src/utils/stringUtils.js';
|
||||
import type { ThemeName } from 'src/utils/theme.js';
|
||||
import type { FileEditOutput } from './types.js';
|
||||
import { findActualString, getPatchForEdit, preserveQuoteStyle } from './utils.js';
|
||||
|
||||
export function userFacingName(
|
||||
input:
|
||||
@@ -84,6 +91,8 @@ export function renderToolResultMessage(
|
||||
<FileEditToolUpdatedMessage
|
||||
filePath={filePath}
|
||||
structuredPatch={structuredPatch}
|
||||
firstLine={originalFile.split('\n')[0] ?? null}
|
||||
fileContent={originalFile}
|
||||
style={style}
|
||||
verbose={verbose}
|
||||
previewHint={isPlanFile ? '/plan to preview' : undefined}
|
||||
@@ -99,7 +108,7 @@ export function renderToolUseRejectedMessage(
|
||||
replace_all?: boolean;
|
||||
edits?: unknown[];
|
||||
},
|
||||
_options: {
|
||||
options: {
|
||||
columns: number;
|
||||
messages: Message[];
|
||||
progressMessagesForMessage: ProgressMessage[];
|
||||
@@ -109,14 +118,40 @@ export function renderToolUseRejectedMessage(
|
||||
verbose: boolean;
|
||||
},
|
||||
): React.ReactElement {
|
||||
const { style, verbose } = _options;
|
||||
const { style, verbose } = options;
|
||||
const filePath = input.file_path;
|
||||
const isNewFile = input.old_string === '';
|
||||
const oldString = input.old_string ?? '';
|
||||
const newString = input.new_string ?? '';
|
||||
const replaceAll = input.replace_all ?? false;
|
||||
|
||||
// Defensive: if input has an unexpected shape, show a simple rejection message
|
||||
if ('edits' in input && input.edits != null) {
|
||||
return (
|
||||
<FileEditToolUseRejectedMessage file_path={filePath} operation="update" firstLine={null} verbose={verbose} />
|
||||
);
|
||||
}
|
||||
|
||||
const isNewFile = oldString === '';
|
||||
|
||||
// For new file creation, show content preview instead of diff
|
||||
if (isNewFile) {
|
||||
return (
|
||||
<FileEditToolUseRejectedMessage
|
||||
file_path={filePath}
|
||||
operation="write"
|
||||
content={newString}
|
||||
firstLine={firstLineOf(newString)}
|
||||
verbose={verbose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FileEditToolUseRejectedMessage
|
||||
file_path={filePath}
|
||||
operation={isNewFile ? 'write' : 'update'}
|
||||
<EditRejectionDiff
|
||||
filePath={filePath}
|
||||
oldString={oldString}
|
||||
newString={newString}
|
||||
replaceAll={replaceAll}
|
||||
style={style}
|
||||
verbose={verbose}
|
||||
/>
|
||||
@@ -149,3 +184,103 @@ export function renderToolUseErrorMessage(
|
||||
}
|
||||
return <FallbackToolUseErrorMessage result={result} verbose={verbose} />;
|
||||
}
|
||||
|
||||
type RejectionDiffData = {
|
||||
patch: StructuredPatchHunk[];
|
||||
firstLine: string | null;
|
||||
fileContent: string | undefined;
|
||||
};
|
||||
|
||||
function EditRejectionDiff({
|
||||
filePath,
|
||||
oldString,
|
||||
newString,
|
||||
replaceAll,
|
||||
style,
|
||||
verbose,
|
||||
}: {
|
||||
filePath: string;
|
||||
oldString: string;
|
||||
newString: string;
|
||||
replaceAll: boolean;
|
||||
style?: 'condensed';
|
||||
verbose: boolean;
|
||||
}): React.ReactNode {
|
||||
const [dataPromise] = useState(() => loadRejectionDiff(filePath, oldString, newString, replaceAll));
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<FileEditToolUseRejectedMessage file_path={filePath} operation="update" firstLine={null} verbose={verbose} />
|
||||
}
|
||||
>
|
||||
<EditRejectionBody promise={dataPromise} filePath={filePath} style={style} verbose={verbose} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function EditRejectionBody({
|
||||
promise,
|
||||
filePath,
|
||||
style,
|
||||
verbose,
|
||||
}: {
|
||||
promise: Promise<RejectionDiffData>;
|
||||
filePath: string;
|
||||
style?: 'condensed';
|
||||
verbose: boolean;
|
||||
}): React.ReactNode {
|
||||
const { patch, firstLine, fileContent } = use(promise);
|
||||
return (
|
||||
<FileEditToolUseRejectedMessage
|
||||
file_path={filePath}
|
||||
operation="update"
|
||||
patch={patch}
|
||||
firstLine={firstLine}
|
||||
fileContent={fileContent}
|
||||
style={style}
|
||||
verbose={verbose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadRejectionDiff(
|
||||
filePath: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
replaceAll: boolean,
|
||||
): Promise<RejectionDiffData> {
|
||||
try {
|
||||
// Chunked read — context window around the first occurrence. replaceAll
|
||||
// still shows matches *within* the window via getPatchForEdit; we accept
|
||||
// losing the all-occurrences view to keep the read bounded.
|
||||
const ctx = await readEditContext(filePath, oldString, CONTEXT_LINES);
|
||||
if (ctx === null || ctx.truncated || ctx.content === '') {
|
||||
// ENOENT / not found / truncated — diff just the tool inputs.
|
||||
const { patch } = getPatchForEdit({
|
||||
filePath,
|
||||
fileContents: oldString,
|
||||
oldString,
|
||||
newString,
|
||||
});
|
||||
return { patch, firstLine: null, fileContent: undefined };
|
||||
}
|
||||
const actualOld = findActualString(ctx.content, oldString) || oldString;
|
||||
const actualNew = preserveQuoteStyle(oldString, actualOld, newString);
|
||||
const { patch } = getPatchForEdit({
|
||||
filePath,
|
||||
fileContents: ctx.content,
|
||||
oldString: actualOld,
|
||||
newString: actualNew,
|
||||
replaceAll,
|
||||
});
|
||||
return {
|
||||
patch: adjustHunkLineNumbers(patch, ctx.lineOffset - 1),
|
||||
firstLine: ctx.lineOffset === 1 ? firstLineOf(ctx.content) : null,
|
||||
fileContent: ctx.content,
|
||||
};
|
||||
} catch (e) {
|
||||
// User may have manually applied the change while the diff was shown.
|
||||
logError(e as Error);
|
||||
return { patch: [], firstLine: null, fileContent: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
|
||||
import { relative } from 'path';
|
||||
import type { StructuredPatchHunk } from 'diff';
|
||||
import { isAbsolute, relative, resolve } from 'path';
|
||||
import * as React from 'react';
|
||||
import { Suspense, use, useState } from 'react';
|
||||
import { MessageResponse } from 'src/components/MessageResponse.js';
|
||||
import { extractTag } from 'src/utils/messages.js';
|
||||
import { CtrlOToExpand } from 'src/components/CtrlOToExpand.js';
|
||||
@@ -15,8 +17,11 @@ import { FilePathLink } from 'src/components/FilePathLink.js';
|
||||
import type { ToolProgressData } from 'src/Tool.js';
|
||||
import type { ProgressMessage } from 'src/types/message.js';
|
||||
import { getCwd } from 'src/utils/cwd.js';
|
||||
import { getPatchForDisplay } from 'src/utils/diff.js';
|
||||
import { getDisplayPath } from 'src/utils/file.js';
|
||||
import { logError } from 'src/utils/log.js';
|
||||
import { getPlansDirectory } from 'src/utils/plans.js';
|
||||
import { openForScan, readCapped } from 'src/utils/readEditContext.js';
|
||||
import type { Output } from './FileWriteTool.js';
|
||||
|
||||
const MAX_LINES_TO_RENDER = 10;
|
||||
@@ -122,10 +127,115 @@ export function renderToolUseMessage(
|
||||
}
|
||||
|
||||
export function renderToolUseRejectedMessage(
|
||||
{ file_path }: { file_path: string; content: string },
|
||||
{ file_path, content }: { file_path: string; content: string },
|
||||
{ style, verbose }: { style?: 'condensed'; verbose: boolean },
|
||||
): React.ReactNode {
|
||||
return <FileEditToolUseRejectedMessage file_path={file_path} operation="write" style={style} verbose={verbose} />;
|
||||
return <WriteRejectionDiff filePath={file_path} content={content} style={style} verbose={verbose} />;
|
||||
}
|
||||
|
||||
type RejectionDiffData =
|
||||
| { type: 'create' }
|
||||
| { type: 'update'; patch: StructuredPatchHunk[]; oldContent: string }
|
||||
| { type: 'error' };
|
||||
|
||||
function WriteRejectionDiff({
|
||||
filePath,
|
||||
content,
|
||||
style,
|
||||
verbose,
|
||||
}: {
|
||||
filePath: string;
|
||||
content: string;
|
||||
style?: 'condensed';
|
||||
verbose: boolean;
|
||||
}): React.ReactNode {
|
||||
const [dataPromise] = useState(() => loadRejectionDiff(filePath, content));
|
||||
const firstLine = content.split('\n')[0] ?? null;
|
||||
const createFallback = (
|
||||
<FileEditToolUseRejectedMessage
|
||||
file_path={filePath}
|
||||
operation="write"
|
||||
content={content}
|
||||
firstLine={firstLine}
|
||||
verbose={verbose}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Suspense fallback={createFallback}>
|
||||
<WriteRejectionBody
|
||||
promise={dataPromise}
|
||||
filePath={filePath}
|
||||
firstLine={firstLine}
|
||||
createFallback={createFallback}
|
||||
style={style}
|
||||
verbose={verbose}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function WriteRejectionBody({
|
||||
promise,
|
||||
filePath,
|
||||
firstLine,
|
||||
createFallback,
|
||||
style,
|
||||
verbose,
|
||||
}: {
|
||||
promise: Promise<RejectionDiffData>;
|
||||
filePath: string;
|
||||
firstLine: string | null;
|
||||
createFallback: React.ReactNode;
|
||||
style?: 'condensed';
|
||||
verbose: boolean;
|
||||
}): React.ReactNode {
|
||||
const data = use(promise);
|
||||
if (data.type === 'create') return createFallback;
|
||||
if (data.type === 'error') {
|
||||
return (
|
||||
<MessageResponse>
|
||||
<Text>(No changes)</Text>
|
||||
</MessageResponse>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FileEditToolUseRejectedMessage
|
||||
file_path={filePath}
|
||||
operation="update"
|
||||
patch={data.patch}
|
||||
firstLine={firstLine}
|
||||
fileContent={data.oldContent}
|
||||
style={style}
|
||||
verbose={verbose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadRejectionDiff(filePath: string, content: string): Promise<RejectionDiffData> {
|
||||
try {
|
||||
const fullFilePath = isAbsolute(filePath) ? filePath : resolve(getCwd(), filePath);
|
||||
const handle = await openForScan(fullFilePath);
|
||||
if (handle === null) return { type: 'create' };
|
||||
let oldContent: string | null;
|
||||
try {
|
||||
oldContent = await readCapped(handle);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
// File exceeds MAX_SCAN_BYTES — fall back to the create view rather than
|
||||
// OOMing on a diff of a multi-GB file.
|
||||
if (oldContent === null) return { type: 'create' };
|
||||
const patch = getPatchForDisplay({
|
||||
filePath,
|
||||
fileContents: oldContent,
|
||||
edits: [{ old_string: oldContent, new_string: content, replace_all: false }],
|
||||
});
|
||||
return { type: 'update', patch, oldContent };
|
||||
} catch (e) {
|
||||
// User may have manually applied the change while the diff was shown.
|
||||
logError(e as Error);
|
||||
return { type: 'error' };
|
||||
}
|
||||
}
|
||||
|
||||
export function renderToolUseErrorMessage(
|
||||
@@ -179,6 +289,8 @@ export function renderToolResultMessage(
|
||||
<FileEditToolUpdatedMessage
|
||||
filePath={filePath}
|
||||
structuredPatch={structuredPatch}
|
||||
firstLine={content.split('\n')[0] ?? null}
|
||||
fileContent={originalFile ?? undefined}
|
||||
style={style}
|
||||
verbose={verbose}
|
||||
previewHint={isPlanFile ? '/plan to preview' : undefined}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ToolResultBlockParam } from 'src/Tool.js'
|
||||
import { buildTool } from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import { isBridgeEnabled } from 'src/bridge/bridgeEnabled.js'
|
||||
|
||||
const PUSH_NOTIFICATION_TOOL_NAME = 'PushNotification'
|
||||
|
||||
@@ -48,6 +49,9 @@ Use this when:
|
||||
Requires Remote Control to be configured. Respects user notification settings (taskCompleteNotifEnabled, inputNeededNotifEnabled, agentPushNotifEnabled).`
|
||||
},
|
||||
|
||||
isEnabled() {
|
||||
return isBridgeEnabled()
|
||||
},
|
||||
isConcurrencySafe() {
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -15,8 +15,24 @@ import {
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { escapeRegExp } from 'src/utils/stringUtils.js'
|
||||
import { isToolSearchEnabledOptimistic } from 'src/utils/toolSearch.js'
|
||||
import { getPrompt, isDeferredTool, TOOL_SEARCH_TOOL_NAME } from './prompt.js'
|
||||
import { isSearchExtraToolsEnabledOptimistic } from 'src/utils/searchExtraTools.js'
|
||||
import {
|
||||
getPrompt,
|
||||
isDeferredTool,
|
||||
SEARCH_EXTRA_TOOLS_TOOL_NAME,
|
||||
} from './prompt.js'
|
||||
import {
|
||||
getToolIndex,
|
||||
searchTools,
|
||||
} from 'src/services/searchExtraTools/toolIndex.js'
|
||||
import type { SearchExtraToolsResult } from 'src/services/searchExtraTools/toolIndex.js'
|
||||
|
||||
const KEYWORD_WEIGHT = Number(
|
||||
process.env.SEARCH_EXTRA_TOOLS_WEIGHT_KEYWORD ?? '0.4',
|
||||
)
|
||||
const TFIDF_WEIGHT = Number(
|
||||
process.env.SEARCH_EXTRA_TOOLS_WEIGHT_TFIDF ?? '0.6',
|
||||
)
|
||||
|
||||
export const inputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
@@ -40,6 +56,8 @@ export const outputSchema = lazySchema(() =>
|
||||
query: z.string(),
|
||||
total_deferred_tools: z.number(),
|
||||
pending_mcp_servers: z.array(z.string()).optional(),
|
||||
/** Matches that are already loaded (core tools) and can be called directly. */
|
||||
already_loaded: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
type OutputSchema = ReturnType<typeof outputSchema>
|
||||
@@ -92,14 +110,14 @@ function maybeInvalidateCache(deferredTools: Tools): void {
|
||||
const currentKey = getDeferredToolsCacheKey(deferredTools)
|
||||
if (cachedDeferredToolNames !== currentKey) {
|
||||
logForDebugging(
|
||||
`ToolSearchTool: cache invalidated - deferred tools changed`,
|
||||
`SearchExtraToolsTool: cache invalidated - deferred tools changed`,
|
||||
)
|
||||
getToolDescriptionMemoized.cache.clear?.()
|
||||
cachedDeferredToolNames = currentKey
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToolSearchDescriptionCache(): void {
|
||||
export function clearSearchExtraToolsDescriptionCache(): void {
|
||||
getToolDescriptionMemoized.cache.clear?.()
|
||||
cachedDeferredToolNames = null
|
||||
}
|
||||
@@ -112,6 +130,7 @@ function buildSearchResult(
|
||||
query: string,
|
||||
totalDeferredTools: number,
|
||||
pendingMcpServers?: string[],
|
||||
alreadyLoaded?: string[],
|
||||
): { data: Output } {
|
||||
return {
|
||||
data: {
|
||||
@@ -121,6 +140,9 @@ function buildSearchResult(
|
||||
...(pendingMcpServers && pendingMcpServers.length > 0
|
||||
? { pending_mcp_servers: pendingMcpServers }
|
||||
: {}),
|
||||
...(alreadyLoaded && alreadyLoaded.length > 0
|
||||
? { already_loaded: alreadyLoaded }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -301,9 +323,9 @@ async function searchToolsWithKeywords(
|
||||
.map(item => item.name)
|
||||
}
|
||||
|
||||
export const ToolSearchTool = buildTool({
|
||||
export const SearchExtraToolsTool = buildTool({
|
||||
isEnabled() {
|
||||
return isToolSearchEnabledOptimistic()
|
||||
return isSearchExtraToolsEnabledOptimistic()
|
||||
},
|
||||
isConcurrencySafe() {
|
||||
return true
|
||||
@@ -311,7 +333,7 @@ export const ToolSearchTool = buildTool({
|
||||
isReadOnly() {
|
||||
return true
|
||||
},
|
||||
name: TOOL_SEARCH_TOOL_NAME,
|
||||
name: SEARCH_EXTRA_TOOLS_TOOL_NAME,
|
||||
maxResultSizeChars: 100_000,
|
||||
async description() {
|
||||
return getPrompt()
|
||||
@@ -343,7 +365,7 @@ export const ToolSearchTool = buildTool({
|
||||
matches: string[],
|
||||
queryType: 'select' | 'keyword',
|
||||
): void {
|
||||
logEvent('tengu_tool_search_outcome', {
|
||||
logEvent('tengu_search_extra_tools_outcome', {
|
||||
query:
|
||||
query as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
queryType:
|
||||
@@ -368,13 +390,18 @@ export const ToolSearchTool = buildTool({
|
||||
.filter(Boolean)
|
||||
|
||||
const found: string[] = []
|
||||
const alreadyLoaded: string[] = []
|
||||
const missing: string[] = []
|
||||
for (const toolName of requested) {
|
||||
const tool =
|
||||
findToolByName(deferredTools, toolName) ??
|
||||
findToolByName(tools, toolName)
|
||||
if (tool) {
|
||||
if (!found.includes(tool.name)) found.push(tool.name)
|
||||
const deferredMatch = findToolByName(deferredTools, toolName)
|
||||
const fullMatch = deferredMatch ?? findToolByName(tools, toolName)
|
||||
if (fullMatch) {
|
||||
if (!found.includes(fullMatch.name)) {
|
||||
found.push(fullMatch.name)
|
||||
if (!deferredMatch) {
|
||||
alreadyLoaded.push(fullMatch.name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
missing.push(toolName)
|
||||
}
|
||||
@@ -382,7 +409,7 @@ export const ToolSearchTool = buildTool({
|
||||
|
||||
if (found.length === 0) {
|
||||
logForDebugging(
|
||||
`ToolSearchTool: select failed — none found: ${missing.join(', ')}`,
|
||||
`SearchExtraToolsTool: select failed — none found: ${missing.join(', ')}`,
|
||||
)
|
||||
logSearchOutcome([], 'select')
|
||||
const pendingServers = getPendingServerNames()
|
||||
@@ -396,25 +423,88 @@ export const ToolSearchTool = buildTool({
|
||||
|
||||
if (missing.length > 0) {
|
||||
logForDebugging(
|
||||
`ToolSearchTool: partial select — found: ${found.join(', ')}, missing: ${missing.join(', ')}`,
|
||||
`SearchExtraToolsTool: partial select — found: ${found.join(', ')}, missing: ${missing.join(', ')}`,
|
||||
)
|
||||
} else {
|
||||
logForDebugging(`ToolSearchTool: selected ${found.join(', ')}`)
|
||||
logForDebugging(`SearchExtraToolsTool: selected ${found.join(', ')}`)
|
||||
}
|
||||
logSearchOutcome(found, 'select')
|
||||
return buildSearchResult(found, query, deferredTools.length)
|
||||
return buildSearchResult(
|
||||
found,
|
||||
query,
|
||||
deferredTools.length,
|
||||
undefined,
|
||||
alreadyLoaded.length > 0 ? alreadyLoaded : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
// Keyword search
|
||||
const matches = await searchToolsWithKeywords(
|
||||
query,
|
||||
deferredTools,
|
||||
tools,
|
||||
max_results,
|
||||
)
|
||||
// Check for discover: prefix — pure discovery search.
|
||||
// Returns tool info (name + description + schema) as text,
|
||||
// does NOT trigger deferred tool loading.
|
||||
const discoverMatch = query.match(/^discover:(.+)$/i)
|
||||
if (discoverMatch) {
|
||||
const discoverQuery = discoverMatch[1]!.trim()
|
||||
const index = await getToolIndex(deferredTools)
|
||||
const tfIdfResults = searchTools(discoverQuery, index, max_results)
|
||||
const textResults = tfIdfResults.map(r => {
|
||||
let line = `**${r.name}** (score: ${r.score.toFixed(2)})\n${r.description}`
|
||||
if (r.inputSchema) {
|
||||
line += `\nSchema: ${JSON.stringify(r.inputSchema)}`
|
||||
}
|
||||
return line
|
||||
})
|
||||
const text =
|
||||
textResults.length > 0
|
||||
? `Found ${textResults.length} tools:\n${textResults.join('\n\n')}`
|
||||
: 'No matching deferred tools found'
|
||||
logSearchOutcome(
|
||||
tfIdfResults.map(r => r.name),
|
||||
'keyword',
|
||||
)
|
||||
return buildSearchResult(
|
||||
tfIdfResults.map(r => r.name),
|
||||
query,
|
||||
deferredTools.length,
|
||||
)
|
||||
}
|
||||
|
||||
// Keyword search + TF-IDF search in parallel
|
||||
const deferredToolNames = new Set(deferredTools.map(t => t.name))
|
||||
const [keywordMatches, index] = await Promise.all([
|
||||
searchToolsWithKeywords(query, deferredTools, tools, max_results),
|
||||
getToolIndex(deferredTools),
|
||||
])
|
||||
const tfIdfResults = searchTools(query, index, max_results)
|
||||
|
||||
// Merge results: keyword score * 0.4 + TF-IDF score * 0.6
|
||||
const mergedScores = new Map<string, number>()
|
||||
// Add keyword results (assign scores inversely proportional to rank)
|
||||
keywordMatches.forEach((name, rank) => {
|
||||
const score = (keywordMatches.length - rank) / keywordMatches.length
|
||||
mergedScores.set(
|
||||
name,
|
||||
(mergedScores.get(name) ?? 0) + score * KEYWORD_WEIGHT,
|
||||
)
|
||||
})
|
||||
// Add TF-IDF results
|
||||
tfIdfResults.forEach(result => {
|
||||
mergedScores.set(
|
||||
result.name,
|
||||
(mergedScores.get(result.name) ?? 0) + result.score * TFIDF_WEIGHT,
|
||||
)
|
||||
})
|
||||
|
||||
// Sort by merged score, take top-N
|
||||
const matches = [...mergedScores.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, max_results)
|
||||
.map(([name]) => name)
|
||||
|
||||
// Identify already-loaded (core) tools among matches
|
||||
const alreadyLoaded = matches.filter(name => !deferredToolNames.has(name))
|
||||
|
||||
logForDebugging(
|
||||
`ToolSearchTool: keyword search for "${query}", found ${matches.length} matches`,
|
||||
`SearchExtraToolsTool: keyword search for "${query}", found ${matches.length} matches`,
|
||||
)
|
||||
|
||||
logSearchOutcome(matches, 'keyword')
|
||||
@@ -430,20 +520,29 @@ export const ToolSearchTool = buildTool({
|
||||
)
|
||||
}
|
||||
|
||||
return buildSearchResult(matches, query, deferredTools.length)
|
||||
return buildSearchResult(
|
||||
matches,
|
||||
query,
|
||||
deferredTools.length,
|
||||
undefined,
|
||||
alreadyLoaded.length > 0 ? alreadyLoaded : undefined,
|
||||
)
|
||||
},
|
||||
renderToolUseMessage() {
|
||||
return null
|
||||
renderToolUseMessage(input: Partial<{ query: string; max_results: number }>) {
|
||||
if (!input.query) return null
|
||||
return `"${input.query}"`
|
||||
},
|
||||
userFacingName() {
|
||||
return 'SearchExtraTools'
|
||||
},
|
||||
userFacingName: () => '',
|
||||
/**
|
||||
* Returns a tool_result with tool_reference blocks.
|
||||
* This format works on 1P/Foundry. Bedrock/Vertex may not support
|
||||
* client-side tool_reference expansion yet.
|
||||
* Returns a tool_result with text output guiding the model to use ExecuteExtraTool.
|
||||
* No longer uses tool_reference blocks — unified self-built tool search for all providers.
|
||||
*/
|
||||
mapToolResultToToolResultBlockParam(
|
||||
content: Output,
|
||||
toolUseID: string,
|
||||
_context?: { mainLoopModel?: string },
|
||||
): ToolResultBlockParam {
|
||||
if (content.matches.length === 0) {
|
||||
let text = 'No matching deferred tools found'
|
||||
@@ -459,13 +558,45 @@ export const ToolSearchTool = buildTool({
|
||||
content: text,
|
||||
}
|
||||
}
|
||||
|
||||
// Separate already-loaded (core) tools from truly deferred tools
|
||||
const alreadyLoadedNames = content.already_loaded ?? []
|
||||
const deferredNames = content.matches.filter(
|
||||
n => !alreadyLoadedNames.includes(n),
|
||||
)
|
||||
|
||||
// If ALL results are already-loaded core tools, there's nothing to discover
|
||||
if (deferredNames.length === 0 && alreadyLoadedNames.length > 0) {
|
||||
return {
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseID,
|
||||
content: `No deferred tools found. ${alreadyLoadedNames.join(', ')} ${alreadyLoadedNames.length === 1 ? 'is' : 'are'} already loaded as core tool(s) — call directly, do NOT search for or wrap in ExecuteExtraTool. SearchExtraTools is only for discovering tools NOT already in your tool list.`,
|
||||
}
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
// Core tools: clear "call directly" message, NO ExecuteExtraTool hint
|
||||
if (alreadyLoadedNames.length > 0) {
|
||||
parts.push(
|
||||
`Already loaded as core tool(s): ${alreadyLoadedNames.join(', ')}. Call these directly using your normal tool interface — do NOT use ExecuteExtraTool for them.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Deferred tools: guide to ExecuteExtraTool
|
||||
if (deferredNames.length > 0) {
|
||||
parts.push(
|
||||
`Found ${deferredNames.length} deferred tool(s): ${deferredNames.join(', ')}.` +
|
||||
`\nUse ExecuteExtraTool with {"tool_name": "<name>", "params": {...}} to invoke any of these deferred tools.`,
|
||||
)
|
||||
}
|
||||
|
||||
const text = parts.join('\n')
|
||||
|
||||
return {
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseID,
|
||||
content: content.matches.map(name => ({
|
||||
type: 'tool_reference' as const,
|
||||
tool_name: name,
|
||||
})),
|
||||
} as unknown as ToolResultBlockParam
|
||||
content: text,
|
||||
}
|
||||
},
|
||||
} satisfies ToolDef<InputSchema, Output>)
|
||||
@@ -0,0 +1,235 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { mock } from 'bun:test'
|
||||
import { logMock } from '../../../../../../tests/mocks/log'
|
||||
import { debugMock } from '../../../../../../tests/mocks/debug'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
mock.module('src/services/analytics/growthbook.js', () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
|
||||
checkStatsigFeatureGate_CACHED_MAY_BE_STALE: () => false,
|
||||
getFeatureValue_DEPRECATED: async () => undefined,
|
||||
getFeatureValue_CACHED_WITH_REFRESH: async () => undefined,
|
||||
hasGrowthBookEnvOverride: () => false,
|
||||
getAllGrowthBookFeatures: () => ({}),
|
||||
getGrowthBookConfigOverrides: () => ({}),
|
||||
setGrowthBookConfigOverride: () => {},
|
||||
clearGrowthBookConfigOverrides: () => {},
|
||||
getApiBaseUrlHost: () => undefined,
|
||||
onGrowthBookRefresh: () => {},
|
||||
initializeGrowthBook: async () => {},
|
||||
checkSecurityRestrictionGate: async () => false,
|
||||
checkGate_CACHED_OR_BLOCKING: async () => false,
|
||||
refreshGrowthBookAfterAuthChange: () => {},
|
||||
resetGrowthBook: () => {},
|
||||
refreshGrowthBookFeatures: async () => {},
|
||||
setupPeriodicGrowthBookRefresh: () => {},
|
||||
stopPeriodicGrowthBookRefresh: () => {},
|
||||
}))
|
||||
|
||||
mock.module('src/utils/searchExtraTools.js', () => ({
|
||||
isSearchExtraToolsEnabledOptimistic: () => true,
|
||||
getAutoSearchExtraToolsCharThreshold: () => 100,
|
||||
getSearchExtraToolsMode: () => 'tst' as const,
|
||||
isSearchExtraToolsToolAvailable: async () => true,
|
||||
isSearchExtraToolsEnabled: async () => true,
|
||||
isToolReferenceBlock: () => false,
|
||||
extractDiscoveredToolNames: () => new Set(),
|
||||
isDeferredToolsDeltaEnabled: () => false,
|
||||
getDeferredToolsDelta: () => null,
|
||||
}))
|
||||
|
||||
mock.module('src/constants/tools.js', () => ({
|
||||
CORE_TOOLS: new Set(['Read', 'Edit', 'SearchExtraTools', 'ExecuteExtraTool']),
|
||||
}))
|
||||
|
||||
// Mock toolIndex module
|
||||
type MockSearchExtraToolsResult = {
|
||||
name: string
|
||||
description: string
|
||||
searchHint: string | undefined
|
||||
score: number
|
||||
isMcp: boolean
|
||||
isDeferred: boolean
|
||||
inputSchema: object | undefined
|
||||
}
|
||||
const mockSearchTools = mock(
|
||||
(
|
||||
_query: string,
|
||||
_index: unknown,
|
||||
_limit?: number,
|
||||
): MockSearchExtraToolsResult[] => [],
|
||||
)
|
||||
const mockGetToolIndex = mock(async (_tools: unknown) => [])
|
||||
|
||||
mock.module('src/services/searchExtraTools/toolIndex.js', () => ({
|
||||
getToolIndex: mockGetToolIndex,
|
||||
searchTools: mockSearchTools,
|
||||
}))
|
||||
|
||||
// Mock analytics
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
}))
|
||||
|
||||
const { SearchExtraToolsTool } = await import('../SearchExtraToolsTool.js')
|
||||
|
||||
function makeDeferredTool(name: string, desc: string = 'A tool') {
|
||||
return {
|
||||
name,
|
||||
isMcp: false,
|
||||
alwaysLoad: undefined,
|
||||
shouldDefer: undefined,
|
||||
searchHint: '',
|
||||
prompt: async () => desc,
|
||||
description: async () => desc,
|
||||
inputSchema: {},
|
||||
isEnabled: () => true,
|
||||
}
|
||||
}
|
||||
|
||||
function makeContext(tools: unknown[] = []) {
|
||||
return {
|
||||
options: { tools },
|
||||
cwd: '/tmp',
|
||||
sessionId: 'test',
|
||||
getAppState: () => ({
|
||||
mcp: { clients: [] },
|
||||
}),
|
||||
} as never
|
||||
}
|
||||
|
||||
describe('SearchExtraToolsTool search enhancements', () => {
|
||||
test('discover: prefix triggers TF-IDF search and returns matches', async () => {
|
||||
const mockTool = makeDeferredTool('CronCreate', 'Schedule cron jobs')
|
||||
mockGetToolIndex.mockResolvedValueOnce([])
|
||||
mockSearchTools.mockReturnValueOnce([
|
||||
{
|
||||
name: 'CronCreate',
|
||||
description: 'Schedule cron jobs',
|
||||
searchHint: undefined,
|
||||
score: 0.85,
|
||||
isMcp: false,
|
||||
isDeferred: true,
|
||||
inputSchema: undefined,
|
||||
},
|
||||
])
|
||||
|
||||
const result: { data: { matches: string[] } } = await (
|
||||
SearchExtraToolsTool as any
|
||||
).call(
|
||||
{ query: 'discover:schedule cron job', max_results: 5 },
|
||||
makeContext([mockTool]),
|
||||
async () => ({ behavior: 'allow' }),
|
||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
||||
undefined,
|
||||
)
|
||||
|
||||
expect(result.data.matches).toContain('CronCreate')
|
||||
})
|
||||
|
||||
test('keyword + TF-IDF parallel search merges results', async () => {
|
||||
const toolA = makeDeferredTool('ToolA', 'Tool A description')
|
||||
const toolB = makeDeferredTool('ToolB', 'Tool B description')
|
||||
const toolC = makeDeferredTool('ToolC', 'Tool C description')
|
||||
|
||||
// getToolIndex returns tools, searchTools returns different ranking
|
||||
mockGetToolIndex.mockResolvedValueOnce([])
|
||||
mockSearchTools.mockReturnValueOnce([
|
||||
{
|
||||
name: 'ToolB',
|
||||
description: 'Tool B',
|
||||
searchHint: undefined,
|
||||
score: 0.9,
|
||||
isMcp: false,
|
||||
isDeferred: true,
|
||||
inputSchema: undefined,
|
||||
},
|
||||
{
|
||||
name: 'ToolC',
|
||||
description: 'Tool C',
|
||||
searchHint: undefined,
|
||||
score: 0.8,
|
||||
isMcp: false,
|
||||
isDeferred: true,
|
||||
inputSchema: undefined,
|
||||
},
|
||||
])
|
||||
|
||||
const result: { data: { matches: string[] } } = await (
|
||||
SearchExtraToolsTool as any
|
||||
).call(
|
||||
{ query: 'tool B', max_results: 5 },
|
||||
makeContext([toolA, toolB, toolC]),
|
||||
async () => ({ behavior: 'allow' }),
|
||||
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
|
||||
undefined,
|
||||
)
|
||||
|
||||
// ToolB should be in results (matched by both keyword and TF-IDF)
|
||||
expect(result.data.matches).toContain('ToolB')
|
||||
})
|
||||
|
||||
test('text mode output for all models (unified self-built search)', async () => {
|
||||
const tool = makeDeferredTool('TestTool', 'A test tool')
|
||||
mockGetToolIndex.mockResolvedValueOnce([])
|
||||
mockSearchTools.mockReturnValueOnce([])
|
||||
|
||||
// First call: search returns matches
|
||||
mockSearchTools.mockReturnValueOnce([
|
||||
{
|
||||
name: 'TestTool',
|
||||
description: 'A test',
|
||||
searchHint: undefined,
|
||||
score: 0.9,
|
||||
isMcp: false,
|
||||
isDeferred: true,
|
||||
inputSchema: undefined,
|
||||
},
|
||||
])
|
||||
|
||||
// mapToolResultToToolResultBlockParam always returns text, not tool_reference
|
||||
const blockParam = SearchExtraToolsTool.mapToolResultToToolResultBlockParam(
|
||||
{ matches: ['TestTool'], query: 'test', total_deferred_tools: 1 },
|
||||
'tool-use-123',
|
||||
{ mainLoopModel: 'claude-3-haiku-20240307' },
|
||||
)
|
||||
|
||||
expect(typeof blockParam.content).toBe('string')
|
||||
expect(blockParam.content as string).toContain('TestTool')
|
||||
expect(blockParam.content as string).toContain('ExecuteExtraTool')
|
||||
})
|
||||
|
||||
test('text output works for any model without distinction', async () => {
|
||||
const blockParam = SearchExtraToolsTool.mapToolResultToToolResultBlockParam(
|
||||
{ matches: ['TestTool'], query: 'test', total_deferred_tools: 1 },
|
||||
'tool-use-123',
|
||||
{ mainLoopModel: 'claude-sonnet-4-20250514' },
|
||||
)
|
||||
|
||||
expect(typeof blockParam.content).toBe('string')
|
||||
expect(blockParam.content as string).toContain('TestTool')
|
||||
expect(blockParam.content as string).toContain('ExecuteExtraTool')
|
||||
})
|
||||
|
||||
test('backwards compatible without context parameter', async () => {
|
||||
const blockParam = SearchExtraToolsTool.mapToolResultToToolResultBlockParam(
|
||||
{ matches: ['TestTool'], query: 'test', total_deferred_tools: 1 },
|
||||
'tool-use-123',
|
||||
)
|
||||
|
||||
expect(typeof blockParam.content).toBe('string')
|
||||
expect(blockParam.content as string).toContain('TestTool')
|
||||
expect(blockParam.content as string).toContain('ExecuteExtraTool')
|
||||
})
|
||||
|
||||
test('empty results return helpful message', async () => {
|
||||
const blockParam = SearchExtraToolsTool.mapToolResultToToolResultBlockParam(
|
||||
{ matches: [], query: 'nonexistent', total_deferred_tools: 5 },
|
||||
'tool-use-123',
|
||||
)
|
||||
|
||||
expect(blockParam.content).toContain('No matching deferred tools found')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export const SEARCH_EXTRA_TOOLS_TOOL_NAME = 'SearchExtraTools'
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
||||
import type { Tool } from 'src/Tool.js'
|
||||
import { CORE_TOOLS } from 'src/constants/tools.js'
|
||||
|
||||
export { SEARCH_EXTRA_TOOLS_TOOL_NAME } from './constants.js'
|
||||
|
||||
import { SEARCH_EXTRA_TOOLS_TOOL_NAME } from './constants.js'
|
||||
|
||||
const PROMPT_HEAD = `Search for deferred tools by name or keyword. LOW PRIORITY — only use this tool when no core tool can accomplish the task. Core tools (Read, Edit, Write, Bash, Glob, Grep, Agent, WebFetch, WebSearch, Skill) are always available and should be used directly. This tool is for discovering additional capabilities like MCP tools, cron scheduling, worktree management, agent teams (TeamCreate, TeamDelete, SendMessage), etc.
|
||||
|
||||
`
|
||||
|
||||
// Matches isDeferredToolsDeltaEnabled in searchExtraTools.ts (not imported —
|
||||
// searchExtraTools.ts imports from this file). When enabled: tools announced
|
||||
// via system-reminder attachments. When disabled: prepended
|
||||
// <available-deferred-tools> block (pre-gate behavior).
|
||||
function getToolLocationHint(): string {
|
||||
const deltaEnabled =
|
||||
process.env.USER_TYPE === 'ant' ||
|
||||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_glacier_2xr', false)
|
||||
return deltaEnabled
|
||||
? 'Deferred tools appear by name in <system-reminder> messages.'
|
||||
: 'Deferred tools appear by name in <available-deferred-tools> messages.'
|
||||
}
|
||||
|
||||
const PROMPT_TAIL = ` Returns matching tool names.
|
||||
|
||||
IMPORTANT: ExecuteExtraTool is always available in your tool list. After this search returns tool names, you MUST call ExecuteExtraTool with {"tool_name": "<returned_name>", "params": {...}} to invoke the deferred tool. This is the ONLY way to execute deferred tools — do not read source code or analyze whether the tool is callable, just use ExecuteExtraTool directly.
|
||||
|
||||
Query forms:
|
||||
- "select:CronCreate,Snip" — fetch these exact tools by name
|
||||
- "discover:schedule cron job" — pure discovery, returns tool info (name, description) without loading. Use when you want to understand available tools before deciding which to invoke.
|
||||
- "notebook jupyter" — keyword search, up to max_results best matches
|
||||
- "+slack send" — require "slack" in the name, rank by remaining terms`
|
||||
|
||||
/**
|
||||
* Check if a tool should be deferred (requires SearchExtraTools to load).
|
||||
* A tool is deferred if it is NOT in CORE_TOOLS and does NOT have alwaysLoad: true.
|
||||
* Core tools are always loaded — never deferred.
|
||||
* All other tools (non-core built-in + all MCP tools) are deferred
|
||||
* and must be discovered via SearchExtraToolsTool / ExecuteExtraTool.
|
||||
*/
|
||||
export function isDeferredTool(tool: Tool): boolean {
|
||||
// Explicit opt-out via _meta['anthropic/alwaysLoad']
|
||||
if (tool.alwaysLoad === true) return false
|
||||
|
||||
// Core tools are always loaded — never deferred
|
||||
if (CORE_TOOLS.has(tool.name)) return false
|
||||
|
||||
// Everything else (non-core built-in + all MCP tools) is deferred
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Format one deferred-tool line for the <available-deferred-tools> user
|
||||
* message. Search hints (tool.searchHint) are not rendered — the
|
||||
* hints A/B (exp_xenhnnmn0smrx4, stopped Mar 21) showed no benefit.
|
||||
*/
|
||||
export function formatDeferredToolLine(tool: Tool): string {
|
||||
return tool.name
|
||||
}
|
||||
|
||||
export function getPrompt(): string {
|
||||
return PROMPT_HEAD + getToolLocationHint() + PROMPT_TAIL
|
||||
}
|
||||
@@ -553,7 +553,8 @@ async function handlePlanRejection(
|
||||
export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
||||
buildTool({
|
||||
name: SEND_MESSAGE_TOOL_NAME,
|
||||
searchHint: 'send messages to agent teammates (swarm protocol)',
|
||||
searchHint:
|
||||
'send message to teammate agent, broadcast, inter-agent communication, swarm messaging, agent coordination',
|
||||
maxResultSizeChars: 100_000,
|
||||
|
||||
userFacingName() {
|
||||
@@ -564,9 +565,10 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
||||
return inputSchema()
|
||||
},
|
||||
shouldDefer: true,
|
||||
alwaysLoad: isAgentSwarmsEnabled(),
|
||||
|
||||
isEnabled() {
|
||||
return isAgentSwarmsEnabled()
|
||||
return true
|
||||
},
|
||||
|
||||
isReadOnly(input) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ToolResultBlockParam } from 'src/Tool.js'
|
||||
import { buildTool } from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { SEND_USER_FILE_TOOL_NAME } from './prompt.js'
|
||||
import { isBridgeEnabled } from 'src/bridge/bridgeEnabled.js'
|
||||
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
@@ -42,6 +43,9 @@ Guidelines:
|
||||
- Large files may take time to transfer`
|
||||
},
|
||||
|
||||
isEnabled() {
|
||||
return isBridgeEnabled()
|
||||
},
|
||||
isConcurrencySafe() {
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -73,7 +73,8 @@ function generateUniqueTeamName(providedName: string): string {
|
||||
|
||||
export const TeamCreateTool: Tool<InputSchema, Output> = buildTool({
|
||||
name: TEAM_CREATE_TOOL_NAME,
|
||||
searchHint: 'create a multi-agent swarm team',
|
||||
searchHint:
|
||||
'create multi-agent swarm team, collaborate, parallel agents, task distribution, agent coordination, team management',
|
||||
maxResultSizeChars: 100_000,
|
||||
shouldDefer: true,
|
||||
|
||||
@@ -86,7 +87,7 @@ export const TeamCreateTool: Tool<InputSchema, Output> = buildTool({
|
||||
},
|
||||
|
||||
isEnabled() {
|
||||
return isAgentSwarmsEnabled()
|
||||
return true
|
||||
},
|
||||
|
||||
toAutoClassifierInput(input) {
|
||||
@@ -126,6 +127,12 @@ export const TeamCreateTool: Tool<InputSchema, Output> = buildTool({
|
||||
},
|
||||
|
||||
async call(input, context) {
|
||||
if (!isAgentSwarmsEnabled()) {
|
||||
throw new Error(
|
||||
'Agent Teams 功能未启用。请确保未设置 CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS_DISABLED 环境变量。',
|
||||
)
|
||||
}
|
||||
|
||||
const { setAppState, getAppState } = context
|
||||
const { team_name, description: _description, agent_type } = input
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@ export type Input = z.infer<InputSchema>
|
||||
|
||||
export const TeamDeleteTool: Tool<InputSchema, Output> = buildTool({
|
||||
name: TEAM_DELETE_TOOL_NAME,
|
||||
searchHint: 'disband a swarm team and clean up',
|
||||
searchHint:
|
||||
'disband delete swarm team cleanup, remove team, end team collaboration, cleanup team resources',
|
||||
maxResultSizeChars: 100_000,
|
||||
shouldDefer: true,
|
||||
|
||||
@@ -63,7 +64,7 @@ export const TeamDeleteTool: Tool<InputSchema, Output> = buildTool({
|
||||
},
|
||||
|
||||
isEnabled() {
|
||||
return isAgentSwarmsEnabled()
|
||||
return true
|
||||
},
|
||||
|
||||
async description() {
|
||||
@@ -88,6 +89,12 @@ export const TeamDeleteTool: Tool<InputSchema, Output> = buildTool({
|
||||
},
|
||||
|
||||
async call(input, context) {
|
||||
if (!isAgentSwarmsEnabled()) {
|
||||
throw new Error(
|
||||
'Agent Teams 功能未启用。请确保未设置 CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS_DISABLED 环境变量。',
|
||||
)
|
||||
}
|
||||
|
||||
const { setAppState, getAppState } = context
|
||||
const appState = getAppState()
|
||||
const teamName = appState.teamContext?.teamName
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const TOOL_SEARCH_TOOL_NAME = 'ToolSearch'
|
||||
@@ -1,121 +0,0 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { isReplBridgeActive } from 'src/bootstrap/state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
||||
import type { Tool } from 'src/Tool.js'
|
||||
import { AGENT_TOOL_NAME } from '../AgentTool/constants.js'
|
||||
|
||||
// Dead code elimination: Brief tool name only needed when KAIROS or KAIROS_BRIEF is on
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const BRIEF_TOOL_NAME: string | null =
|
||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? (
|
||||
require('../BriefTool/prompt.js') as typeof import('../BriefTool/prompt.js')
|
||||
).BRIEF_TOOL_NAME
|
||||
: null
|
||||
const SEND_USER_FILE_TOOL_NAME: string | null = feature('KAIROS')
|
||||
? (
|
||||
require('../SendUserFileTool/prompt.js') as typeof import('../SendUserFileTool/prompt.js')
|
||||
).SEND_USER_FILE_TOOL_NAME
|
||||
: null
|
||||
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
export { TOOL_SEARCH_TOOL_NAME } from './constants.js'
|
||||
|
||||
import { TOOL_SEARCH_TOOL_NAME } from './constants.js'
|
||||
|
||||
const PROMPT_HEAD = `Fetches full schema definitions for deferred tools so they can be called.
|
||||
|
||||
`
|
||||
|
||||
// Matches isDeferredToolsDeltaEnabled in toolSearch.ts (not imported —
|
||||
// toolSearch.ts imports from this file). When enabled: tools announced
|
||||
// via system-reminder attachments. When disabled: prepended
|
||||
// <available-deferred-tools> block (pre-gate behavior).
|
||||
function getToolLocationHint(): string {
|
||||
const deltaEnabled =
|
||||
process.env.USER_TYPE === 'ant' ||
|
||||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_glacier_2xr', false)
|
||||
return deltaEnabled
|
||||
? 'Deferred tools appear by name in <system-reminder> messages.'
|
||||
: 'Deferred tools appear by name in <available-deferred-tools> messages.'
|
||||
}
|
||||
|
||||
const PROMPT_TAIL = ` Until fetched, only the name is known — there is no parameter schema, so the tool cannot be invoked. This tool takes a query, matches it against the deferred tool list, and returns the matched tools' complete JSONSchema definitions inside a <functions> block. Once a tool's schema appears in that result, it is callable exactly like any tool defined at the top of the prompt.
|
||||
|
||||
Result format: each matched tool appears as one <function>{"description": "...", "name": "...", "parameters": {...}}</function> line inside the <functions> block — the same encoding as the tool list at the top of this prompt.
|
||||
|
||||
Query forms:
|
||||
- "select:Read,Edit,Grep" — fetch these exact tools by name
|
||||
- "notebook jupyter" — keyword search, up to max_results best matches
|
||||
- "+slack send" — require "slack" in the name, rank by remaining terms`
|
||||
|
||||
/**
|
||||
* Check if a tool should be deferred (requires ToolSearch to load).
|
||||
* A tool is deferred if:
|
||||
* - It's an MCP tool (always deferred - workflow-specific)
|
||||
* - It has shouldDefer: true
|
||||
*
|
||||
* A tool is NEVER deferred if it has alwaysLoad: true (MCP tools set this via
|
||||
* _meta['anthropic/alwaysLoad']). This check runs first, before any other rule.
|
||||
*/
|
||||
export function isDeferredTool(tool: Tool): boolean {
|
||||
// Explicit opt-out via _meta['anthropic/alwaysLoad'] — tool appears in the
|
||||
// initial prompt with full schema. Checked first so MCP tools can opt out.
|
||||
if (tool.alwaysLoad === true) return false
|
||||
|
||||
// MCP tools are always deferred (workflow-specific)
|
||||
if (tool.isMcp === true) return true
|
||||
|
||||
// Never defer ToolSearch itself — the model needs it to load everything else
|
||||
if (tool.name === TOOL_SEARCH_TOOL_NAME) return false
|
||||
|
||||
// Fork-first experiment: Agent must be available turn 1, not behind ToolSearch.
|
||||
// Lazy require: static import of forkSubagent → coordinatorMode creates a cycle
|
||||
// through constants/tools.ts at module init.
|
||||
if (feature('FORK_SUBAGENT') && tool.name === AGENT_TOOL_NAME) {
|
||||
type ForkMod = typeof import('../AgentTool/forkSubagent.js')
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const m = require('../AgentTool/forkSubagent.js') as ForkMod
|
||||
if (m.isForkSubagentEnabled()) return false
|
||||
}
|
||||
|
||||
// Brief is the primary communication channel whenever the tool is present.
|
||||
// Its prompt contains the text-visibility contract, which the model must
|
||||
// see without a ToolSearch round-trip. No runtime gate needed here: this
|
||||
// tool's isEnabled() IS isBriefEnabled(), so being asked about its deferral
|
||||
// status implies the gate already passed.
|
||||
if (
|
||||
(feature('KAIROS') || feature('KAIROS_BRIEF')) &&
|
||||
BRIEF_TOOL_NAME &&
|
||||
tool.name === BRIEF_TOOL_NAME
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// SendUserFile is a file-delivery communication channel (sibling of Brief).
|
||||
// Must be immediately available without a ToolSearch round-trip.
|
||||
if (
|
||||
feature('KAIROS') &&
|
||||
SEND_USER_FILE_TOOL_NAME &&
|
||||
tool.name === SEND_USER_FILE_TOOL_NAME &&
|
||||
isReplBridgeActive()
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return tool.shouldDefer === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Format one deferred-tool line for the <available-deferred-tools> user
|
||||
* message. Search hints (tool.searchHint) are not rendered — the
|
||||
* hints A/B (exp_xenhnnmn0smrx4, stopped Mar 21) showed no benefit.
|
||||
*/
|
||||
export function formatDeferredToolLine(tool: Tool): string {
|
||||
return tool.name
|
||||
}
|
||||
|
||||
export function getPrompt(): string {
|
||||
return PROMPT_HEAD + getToolLocationHint() + PROMPT_TAIL
|
||||
}
|
||||
@@ -179,10 +179,10 @@ export const WebFetchTool = buildTool({
|
||||
}
|
||||
},
|
||||
async prompt(_options) {
|
||||
// Always include the auth warning regardless of whether ToolSearch is
|
||||
// Always include the auth warning regardless of whether SearchExtraTools is
|
||||
// currently in the tools list. Conditionally toggling this prefix based
|
||||
// on ToolSearch availability caused the tool description to flicker
|
||||
// between SDK query() calls (when ToolSearch enablement varies due to
|
||||
// on SearchExtraTools availability caused the tool description to flicker
|
||||
// between SDK query() calls (when SearchExtraTools enablement varies due to
|
||||
// MCP tool count thresholds), invalidating the Anthropic API prompt
|
||||
// cache on each toggle — two consecutive cache misses per flicker event.
|
||||
return `IMPORTANT: WebFetch WILL FAIL for authenticated or private URLs. Before using this tool, check if the URL points to an authenticated service (e.g. Google Docs, Confluence, Jira, GitHub). If so, look for a specialized MCP tool that provides authenticated access.
|
||||
|
||||
63
progress.md
63
progress.md
@@ -13,3 +13,66 @@
|
||||
- settings.ts 依赖链过深(MDM/远程管理/文件系统),63 个现有测试覆盖良好
|
||||
- installedPluginsManager.ts V1→V2 迁移逻辑清晰,内存/磁盘状态分离设计良好
|
||||
- teammateMailbox.ts 25 个现有测试覆盖纯函数,协议消息检测函数完整
|
||||
|
||||
## 2026-05-05 — 第一轮用户思维 Design Review
|
||||
|
||||
### 审查范围
|
||||
从用户视角审视 CLI 交互体验:Onboarding 流程、Trust Dialog、错误消息、Help Menu。聚焦非代码层面的用户友好性问题。
|
||||
|
||||
### 发现的不友好问题
|
||||
1. **错误消息缺乏可操作提示**:budget 超限/max turns 用尽时仅告知"出错了",未指导用户如何继续
|
||||
2. **Onboarding 安全说明冰冷**:"Security notes"标题过于技术化,用户容易跳过
|
||||
3. **Trust Dialog 文案冗长**:安全检查对话框用语偏官方,核心信息被淹没
|
||||
|
||||
### 变更内容
|
||||
1. **`src/cli/print.ts`** — 为 3 种错误子类型(budget/turns/structured-output)添加 Tip 提示行,告知用户具体的解决方式
|
||||
2. **`src/QueryEngine.ts`** — 预算超限错误消息添加 `--max-budget-usd` 指引
|
||||
3. **`src/components/Onboarding.tsx`** — 安全步骤标题改为 "Before you start, keep in mind",条目文案更口语化
|
||||
4. **`src/components/TrustDialog/TrustDialog.tsx`** — 精简为两句核心信息,降低认知负荷
|
||||
5. **`src/cli/__tests__/userFacingErrorMessages.test.ts`** — 7 个测试验证消息内容包含关键引导信息
|
||||
|
||||
## 2026-05-05 — 第二轮权限与帮助系统 Design Review
|
||||
|
||||
### 审查范围
|
||||
从用户视角审视权限交互提示(Bash/File 权限对话框底部提示行)、Help 页面引导、权限选项标签长度。
|
||||
|
||||
### 发现的不友好问题
|
||||
1. **权限对话框底部提示语义模糊**:"Esc to cancel" 不如 "Esc to reject" 明确,"Tab to amend" 用户不知能做什么
|
||||
2. **Help General 页面缺乏新手引导**:只有一句话 + 全部快捷键,新用户不知从何开始
|
||||
3. **.claude/ 文件夹权限选项标签过长**(60+ 字符),窄终端截断
|
||||
|
||||
### 变更内容
|
||||
1. **`src/components/HelpV2/General.tsx`** — 添加 3 步"Getting started"引导,取代原来的单段描述
|
||||
2. **`src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx`** — 底部 "cancel"→"reject","amend"→"add feedback"
|
||||
3. **`src/components/permissions/FilePermissionDialog/FilePermissionDialog.tsx`** — 同步底部提示用词
|
||||
4. **`src/components/permissions/FilePermissionDialog/permissionOptions.tsx`** — .claude/ 选项标签从 60 字符缩至 49 字符
|
||||
5. **`src/components/HelpV2/__tests__/General.test.ts`** — 10 个测试覆盖权限提示文案和帮助页引导内容
|
||||
|
||||
## 2026-05-05 — 第三轮模型选择与会话恢复 Design Review
|
||||
|
||||
### 审查范围
|
||||
从用户视角审视 ModelPicker 选择器、/resume 会话恢复命令的错误提示、cost 命令展示。
|
||||
|
||||
### 发现的不友好问题
|
||||
1. **ModelPicker 副标题信息过载**:一句话里混合了模型切换说明和 --model 参数提示,新用户容易困惑
|
||||
2. **Resume 错误提示缺乏操作指导**:"Session X was not found" 没告诉用户怎么列出所有会话
|
||||
|
||||
### 变更内容
|
||||
1. **`src/components/ModelPicker.tsx`** — 副标题从技术说明改为操作提示("← → 调整 effort,Space 切换 1M context"),控制在 120 字符内
|
||||
2. **`src/commands/resume/resume.tsx`** — 错误提示添加 "Run /resume to browse" 操作引导
|
||||
3. **`src/commands/resume/__tests__/resume.test.ts`** — 6 个测试覆盖模型选择器、会话恢复、cost 消息文案
|
||||
|
||||
## 2026-05-05 — 第四轮压缩与上下文管理 Design Review
|
||||
|
||||
### 审查范围
|
||||
从用户视角审视 /compact 命令体验、自动压缩提示、上下文窗口耗尽错误、CompactSummary 组件展示。
|
||||
|
||||
### 发现的不友好问题
|
||||
1. **"Not enough messages to compact" 缺乏指导**:用户不知下一步该做什么
|
||||
2. **"Conversation too long" 提示的 "Press esc twice" 操作不直观**:esc twice 对用户来说是模糊的操作
|
||||
3. **"Compact summary" 标题对用户没有信息量**:自动压缩时用户不知道发生了什么
|
||||
|
||||
### 变更内容
|
||||
1. **`src/services/compact/compact.ts`** — "Not enough messages" 添加 "Send a few more messages first" 引导;"Conversation too long" 改为建议 `/compact` 或 `/clear`
|
||||
2. **`src/components/CompactSummary.tsx`** — 自动压缩标题从 "Compact summary" 改为 "Conversation summarized to free up context",快捷键提示从 "expand" 改为 "view summary"
|
||||
3. **`src/components/__tests__/compactMessages.test.ts`** — 7 个测试覆盖压缩错误消息和展示文案
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const pkgPath = resolve(__dirname, '..', 'package.json')
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
||||
|
||||
/**
|
||||
* Shared MACRO define map used by both dev.ts (runtime -d flags)
|
||||
* and build.ts (Bun.build define option).
|
||||
*
|
||||
* Each value is a JSON-stringified expression that replaces the
|
||||
* corresponding MACRO.* identifier at transpile / bundle time.
|
||||
*
|
||||
* VERSION is read from package.json to avoid version drift.
|
||||
*/
|
||||
export function getMacroDefines(): Record<string, string> {
|
||||
return {
|
||||
'MACRO.VERSION': JSON.stringify('2.1.888'),
|
||||
'MACRO.VERSION': JSON.stringify(pkg.version),
|
||||
'MACRO.BUILD_TIME': JSON.stringify(new Date().toISOString()),
|
||||
'MACRO.FEEDBACK_CHANNEL': JSON.stringify(''),
|
||||
'MACRO.ISSUES_EXPLAINER': JSON.stringify(''),
|
||||
@@ -49,14 +59,14 @@ export const DEFAULT_BUILD_FEATURES = [
|
||||
'DAEMON', // 守护进程模式,长驻 supervisor 管理后台 worker(非 GB 级主因)
|
||||
'ACP', // ACP 代理协议,支持外部 agent 接入
|
||||
'WORKFLOW_SCRIPTS', // 工作流脚本(.claude/workflows/ 中的 YAML/MD)
|
||||
// 'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
|
||||
'HISTORY_SNIP', // 历史消息裁剪,压缩上下文窗口
|
||||
// 'CONTEXT_COLLAPSE', // 已禁用:实现是空壳 stub,启用后会抑制 auto compact 导致上下文管理完全失效
|
||||
'MONITOR_TOOL', // Monitor 工具,流式监控后台进程输出
|
||||
// 'FORK_SUBAGENT', // 已禁用:显式 `fork: true` 参数触发 fork 路径(继承父级上下文和模型),不影响 forceAsync 和探索任务模型选择
|
||||
// 'UDS_INBOX', // inbox 数组只增不减(非 GB 级主因)
|
||||
// 'FORK_SUBAGENT', // 已禁用:通过 Agent tool 的特殊方式实现了等效功能,无需再开
|
||||
'KAIROS', // Kairos 定时任务系统核心
|
||||
// 'COORDINATOR_MODE', // 已禁用:AgentSummary 30s fork 循环,GB 级泄露主因
|
||||
// 'LAN_PIPES', // 依赖 UDS_INBOX(已随 UDS_INBOX 恢复)
|
||||
'COORDINATOR_MODE', // 多 worker 编排模式(AgentSummary 泄露已在 52b61c2c 修复)
|
||||
// 'UDS_INBOX', // 进程间通信管道(inbox/pipe/peers 等命令)构建后 nodejs 环境卡住
|
||||
// 'LAN_PIPES', // 局域网管道,依赖 UDS_INBOX 构建后 nodejs 环境卡住
|
||||
'BG_SESSIONS', // 后台会话管理(ps/logs/attach/kill)
|
||||
'TEMPLATES', // 模板任务(new/list/reply 子命令)
|
||||
// 'REVIEW_ARTIFACT', // 代码审查产物(API 请求无响应,待排查 schema 兼容性)
|
||||
@@ -74,7 +84,8 @@ export const DEFAULT_BUILD_FEATURES = [
|
||||
// this branch (see docs/agent/sur-skill-overflow-bugs.md) close the
|
||||
// overflow risk, but Haiku-on-first-Chinese-query and disk-side
|
||||
// observation accumulation remain operator-discretion concerns.
|
||||
// 'EXPERIMENTAL_SKILL_SEARCH',
|
||||
'EXPERIMENTAL_SKILL_SEARCH', // 技能搜索(bounded caches 已修复 overflow,内存问题已解决)
|
||||
'EXPERIMENTAL_SEARCH_EXTRA_TOOLS', // 工具搜索预取管道(TF-IDF 索引 + inter-turn 异步预取)
|
||||
// 'SKILL_LEARNING',
|
||||
// P3: poor mode
|
||||
'POOR', // 穷鬼模式,跳过 extract_memories/prompt_suggestion 减少消耗
|
||||
|
||||
@@ -14,7 +14,13 @@ const __dirname = dirname(__filename)
|
||||
const projectRoot = join(__dirname, '..')
|
||||
const cliPath = join(projectRoot, 'src/entrypoints/cli.tsx')
|
||||
|
||||
const defines = getMacroDefines()
|
||||
const defines = {
|
||||
...getMacroDefines(),
|
||||
// React production mode — prevents 6,889+ _debugStack Error objects
|
||||
// (12MB) from accumulating during long-running sessions.
|
||||
// dev 模式使用 development 模式
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
}
|
||||
|
||||
const defineArgs = Object.entries(defines).flatMap(([k, v]) => [
|
||||
'-d',
|
||||
|
||||
451
spec/feature_20260508_F001_tool-search/spec-design.md
Normal file
451
spec/feature_20260508_F001_tool-search/spec-design.md
Normal file
@@ -0,0 +1,451 @@
|
||||
# Feature: 20260508_F001 - tool-search
|
||||
|
||||
## 需求背景
|
||||
|
||||
当前 Claude Code 有 60+ 内置工具和无限 MCP 工具,Agent 在处理任务时缺乏"根据任务描述自动发现最匹配工具"的能力。现有 `ToolSearchTool` 仅处理延迟加载(按需加载 schema via `tool_reference`),不做语义发现。`tool_reference` 机制存在以下局限:
|
||||
|
||||
1. **仅 Anthropic 一方 API 支持** — OpenAI/Gemini/Grok 兼容层不支持 `tool_reference` beta 特性
|
||||
2. **破坏 prompt cache** — 动态注入工具 schema 导致缓存失效
|
||||
3. **工具列表固定** — 每次请求的工具集在请求开始时就确定了,临时添加工具触发缓存全部失效
|
||||
|
||||
用户也无法直观了解哪些工具适合当前任务,缺乏推荐机制。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 激进精简初始化工具注入,从 60+ 精简到 ~10 个核心工具 + 2 个入口工具(ToolSearch + ExecuteTool)
|
||||
2. 增强 `ToolSearchTool`,增加 TF-IDF 文本匹配的"工具发现"能力
|
||||
3. 新建 `ExecuteTool`,提供跨 API provider 的统一工具执行入口
|
||||
4. 支持用户输入提示词后自动预取推荐工具(类似 skill prefetch)
|
||||
5. 在 REPL 中展示工具推荐提示条(类似 skill search tips)
|
||||
6. 搜索范围覆盖:MCP 工具、自定义工具、所有延迟加载的内置工具
|
||||
7. 复用 `localSearch.ts` 的 tokenize/stem/cosineSimilarity 基础设施
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 整体架构
|
||||
|
||||
四层设计:初始化精简 + 搜索层 + 执行层 + UI 层。
|
||||
|
||||
```text
|
||||
初始化阶段(激进精简):
|
||||
核心工具(~10个,始终加载 schema) 延迟工具(其余全部,仅注入名称列表)
|
||||
Bash / Read / Edit / Write / Glob WebFetch / WebSearch / NotebookEdit
|
||||
Grep / Agent / AskUser / ToolSearch TodoWrite / CronTools / TeamCreate
|
||||
ExecuteTool SkillTool / PlanMode / ...(50+ 工具)
|
||||
↓ MCP 工具也延迟加载
|
||||
|
||||
运行时发现与执行:
|
||||
用户输入 → 预取管道(异步) → TF-IDF 搜索 → UI 推荐提示
|
||||
↓
|
||||
模型处理任务 → ToolSearchTool(TF-IDF搜索) → 返回工具信息文本
|
||||
↓
|
||||
模型构造参数 → ExecuteTool(tool_name + params) → 路由执行 → 返回结果
|
||||
```
|
||||
|
||||
### 1. 初始化精简(激进策略)
|
||||
|
||||
**核心思路**: 将初始化时注入的工具从 60+ 精简到 ~10 个核心工具 + 2 个入口工具(ToolSearch + ExecuteTool)。其余 50+ 工具全部延迟加载,仅注入名称列表到延迟工具清单。
|
||||
|
||||
**始终加载的核心工具**(31 个):
|
||||
|
||||
| 工具 | 始终加载的理由 |
|
||||
|------|----------------|
|
||||
| `BashTool` | 几乎所有任务都需要 shell 执行 |
|
||||
| `FileReadTool` | 读取文件是基础操作 |
|
||||
| `FileEditTool` | 编辑文件是核心能力 |
|
||||
| `FileWriteTool` | 写入文件是核心能力 |
|
||||
| `GlobTool` | 文件搜索是基础操作 |
|
||||
| `GrepTool` | 内容搜索是基础操作 |
|
||||
| `AgentTool` | 子 agent 调度是核心架构 |
|
||||
| `AskUserQuestionTool` | 用户交互是基础能力 |
|
||||
| `ToolSearchTool` | 工具发现入口 |
|
||||
| `ExecuteTool` | 延迟工具执行入口(新增) |
|
||||
| `TaskOutputTool` | 任务输出查询是高频操作 |
|
||||
| `TaskStopTool` | 任务停止是 agent 生命周期管理 |
|
||||
| `EnterPlanModeTool` | 进入计划模式是常见工作流 |
|
||||
| `ExitPlanModeV2Tool` | 退出计划模式是常见工作流 |
|
||||
| `VerifyPlanExecutionTool` | 计划执行验证与 ExitPlanMode 配套 |
|
||||
| `TaskCreateTool` | 任务创建(TodoV2)是高频操作 |
|
||||
| `TaskGetTool` | 任务查询(TodoV2)是高频操作 |
|
||||
| `TaskUpdateTool` | 任务更新(TodoV2)是高频操作 |
|
||||
| `TaskListTool` | 任务列表(TodoV2)是高频操作 |
|
||||
| `TodoWriteTool` | 待办写入是任务跟踪基础 |
|
||||
| `SendMessageTool` | 团队内 agent 通信 |
|
||||
| `TeamCreateTool` | 团队创建(swarm 模式核心) |
|
||||
| `TeamDeleteTool` | 团队删除(swarm 模式核心) |
|
||||
| `ListPeersTool` | 跨会话通信发现 |
|
||||
| `SkillTool` | 技能调用(/skill 命令) |
|
||||
| `WebFetchTool` | Web 内容获取是常见需求 |
|
||||
| `WebSearchTool` | Web 搜索是常见需求 |
|
||||
| `NotebookEditTool` | Notebook 编辑是数据分析基础 |
|
||||
| `LSPTool` | LSP 代码智能是开发基础 |
|
||||
| `MonitorTool` | 后台监控进程(日志/轮询) |
|
||||
| `SleepTool` | 等待时长(轮询 deploy 等场景) |
|
||||
|
||||
**延迟加载的工具**(约 26 个内置工具 + 全部 MCP 工具):
|
||||
|
||||
所有未在核心列表中的内置工具,包括:
|
||||
|
||||
| 工具 | 延迟加载的理由 |
|
||||
|------|----------------|
|
||||
| `ConfigTool` | 配置操作低频(ant only) |
|
||||
| `TungstenTool` | 专用工具低频(ant only) |
|
||||
| `SuggestBackgroundPRTool` | PR 建议低频 |
|
||||
| `WebBrowserTool` | 浏览器操作低频(feature-gated) |
|
||||
| `OverflowTestTool` | 测试专用(feature-gated) |
|
||||
| `CtxInspectTool` | 上下文检查低频(debug/feature-gated) |
|
||||
| `TerminalCaptureTool` | 终端捕获低频(feature-gated) |
|
||||
| `EnterWorktreeTool` | worktree 操作低频 |
|
||||
| `ExitWorktreeTool` | worktree 操作低频 |
|
||||
| `REPLTool` | REPL 模式低频(ant only) |
|
||||
| `WorkflowTool` | 工作流脚本低频(feature-gated) |
|
||||
| `CronCreateTool` | 调度创建低频 |
|
||||
| `CronDeleteTool` | 调度删除低频 |
|
||||
| `CronListTool` | 调度列表低频 |
|
||||
| `RemoteTriggerTool` | 远程触发低频 |
|
||||
| `BriefTool` | 通信通道低频(KAIROS) |
|
||||
| `SendUserFileTool` | 文件发送低频(KAIROS) |
|
||||
| `PushNotificationTool` | 推送通知低频(KAIROS) |
|
||||
| `SubscribePRTool` | PR 订阅低频 |
|
||||
| `ReviewArtifactTool` | 产物审查低频 |
|
||||
| `PowerShellTool` | PowerShell 低频(需显式启用) |
|
||||
| `SnipTool` | 上下文裁剪低频(HISTORY_SNIP) |
|
||||
| `DiscoverSkillsTool` | 技能发现低频(feature-gated) |
|
||||
| `ListMcpResourcesTool` | MCP 资源列表低频 |
|
||||
| `ReadMcpResourceTool` | MCP 资源读取低频 |
|
||||
| `TestingPermissionTool` | 仅测试环境 |
|
||||
| 全部 MCP 工具 | 按连接动态加载 |
|
||||
|
||||
**实现方式**:
|
||||
|
||||
1. **系统提示词增强**(`src/context.ts` 或 `src/constants/prompts.ts`):
|
||||
|
||||
在系统提示词中加入工具发现引导指令,确保模型始终知道如何获取延迟工具:
|
||||
|
||||
```text
|
||||
When you need a capability that isn't in your available tools, use ToolSearch
|
||||
to discover and load it. ToolSearch can find all deferred tools by keyword or
|
||||
task description. After discovering a tool, use ExecuteTool to invoke it with
|
||||
the appropriate parameters.
|
||||
|
||||
Common deferred tools include: CronTools (scheduling), WorktreeTools (git
|
||||
isolation), SnipTool (context management), DiscoverSkills (skill search),
|
||||
MCP resource tools, and many more. Always search first rather than assuming
|
||||
a capability is unavailable.
|
||||
```
|
||||
|
||||
2. **新增核心工具集合常量**(`src/constants/tools.ts`):
|
||||
|
||||
```typescript
|
||||
export const CORE_TOOLS = new Set([
|
||||
// 文件操作
|
||||
'Bash', 'Read', 'Edit', 'Write', 'Glob', 'Grep',
|
||||
// Agent 与交互
|
||||
'Agent', 'AskUserQuestion', 'SendMessage', 'ListPeers',
|
||||
// 团队(swarm)
|
||||
'TeamCreate', 'TeamDelete',
|
||||
// 任务管理
|
||||
'TaskOutput', 'TaskStop',
|
||||
'TaskCreate', 'TaskGet', 'TaskUpdate', 'TaskList',
|
||||
'TodoWrite',
|
||||
// 规划
|
||||
'EnterPlanMode', 'ExitPlanMode', 'VerifyPlanExecution',
|
||||
// Web
|
||||
'WebFetch', 'WebSearch',
|
||||
// 编辑器
|
||||
'NotebookEdit',
|
||||
// 代码智能
|
||||
'LSP',
|
||||
// 技能
|
||||
'Skill',
|
||||
// 调度与监控
|
||||
'Sleep', 'Monitor',
|
||||
// 工具发现与执行(新增)
|
||||
'ToolSearch', 'ExecuteTool',
|
||||
])
|
||||
```
|
||||
|
||||
2. **修改 `isDeferredTool` 判定逻辑**(`ToolSearchTool/prompt.ts`):
|
||||
|
||||
```typescript
|
||||
export function isDeferredTool(tool: Tool): boolean {
|
||||
if (tool.alwaysLoad === true) return false
|
||||
if (tool.name === TOOL_SEARCH_TOOL_NAME) return false
|
||||
if (tool.name === EXECUTE_TOOL_NAME) return false
|
||||
// 核心工具不延迟
|
||||
if (CORE_TOOLS.has(tool.name)) return false
|
||||
// MCP 工具和其余内置工具全部延迟
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
3. **修改 `getAllBaseTools()` 注册逻辑**(`src/tools.ts`):
|
||||
|
||||
核心工具直接注册(带完整 schema),延迟工具也注册到工具池(用于 ExecuteTool 查找),但标记为 deferred。
|
||||
|
||||
4. **延迟工具名称列表注入**(`src/services/api/claude.ts`):
|
||||
|
||||
构建 API 请求时,核心工具的 schema 正常注入。延迟工具仅注入名称列表到 `<available-deferred-tools>` 或 `system-reminder` 附件中,模型通过 ToolSearchTool 获取详情。
|
||||
|
||||
**收益**:
|
||||
- 初始 prompt 体积减少约 30-40%(26 个内置工具 schema → 名称列表,加上 MCP 工具全延迟)
|
||||
- Prompt cache 命中率提升(核心 31 工具列表稳定,延迟工具仅名称列表)
|
||||
- 支持无限工具扩展(不受 context window 限制)
|
||||
|
||||
**权衡**:
|
||||
- 非核心工具首次使用需要一轮 ToolSearch → ExecuteTool 的额外交互
|
||||
- 模型需要更积极地使用 ToolSearchTool 发现可用工具
|
||||
|
||||
### 2. 工具索引层
|
||||
|
||||
**新增文件**: `src/services/toolSearch/toolIndex.ts`
|
||||
|
||||
从 `src/services/skillSearch/localSearch.ts` 直接 import 复用 `tokenizeAndStem`、`computeWeightedTf`、`computeIdf`、`cosineSimilarity` 算法,新建工具索引。不提取为独立共享模块——skill 和 tool 的索引结构不同(`SkillIndexEntry` vs `ToolIndexEntry`),强行抽象反而增加复杂度。
|
||||
|
||||
**索引条目结构**:
|
||||
|
||||
```typescript
|
||||
interface ToolIndexEntry {
|
||||
name: string // 工具名(如 "FileEditTool" 或 "mcp__server__action")
|
||||
normalizedName: string // 小写 + 连字符替换
|
||||
description: string // 工具描述文本
|
||||
searchHint: string | undefined // buildTool 中定义的 searchHint
|
||||
isMcp: boolean // 是否 MCP 工具
|
||||
isDeferred: boolean // 是否延迟加载工具
|
||||
inputSchema: object | undefined // 参数 schema(JSON Schema 格式,供 discover 模式返回)
|
||||
tokens: string[] // 分词后的 token 列表
|
||||
tfVector: Map<string, number> // TF-IDF 向量
|
||||
}
|
||||
```
|
||||
|
||||
**字段权重**:
|
||||
|
||||
| 字段 | 权重 | 说明 |
|
||||
|------|------|------|
|
||||
| name | 3.0 | 工具名称(CamelCase 拆分、MCP `__` 拆分) |
|
||||
| searchHint | 2.5 | 工具的 `searchHint` 字段(高信号) |
|
||||
| description | 1.0 | 工具描述文本 |
|
||||
|
||||
**索引生命周期**:
|
||||
- 按需构建,缓存在会话中
|
||||
- MCP 工具连接/断开时触发增量更新(复用 `DeferredToolsDelta` 机制)
|
||||
- 内置工具在首次构建时全量索引
|
||||
- 仅索引延迟工具(核心工具已在模型上下文中,无需发现)
|
||||
|
||||
**工具名解析**:
|
||||
- MCP 工具:`mcp__server__action` → 拆分为 `["mcp", "server", "action"]`
|
||||
- 内置工具:`FileEditTool` → CamelCase 拆分为 `["file", "edit", "tool"]`
|
||||
- 与现有 `ToolSearchTool.parseToolName` 逻辑对齐
|
||||
|
||||
### 3. 搜索层增强
|
||||
|
||||
**修改文件**: `packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts`
|
||||
|
||||
在现有 `searchToolsWithKeywords` 基础上,新增 TF-IDF 搜索路径:
|
||||
|
||||
**增强的搜索流程**:
|
||||
|
||||
```
|
||||
query 输入
|
||||
│
|
||||
├── select: 前缀 → 直接选择(保留现有逻辑)
|
||||
│
|
||||
└── 关键词搜索 → 并行执行两路搜索
|
||||
│
|
||||
├── searchToolsWithKeywords(现有,关键词匹配 + 评分)
|
||||
│
|
||||
└── searchToolsWithTfIdf(新增,TF-IDF 余弦相似度)
|
||||
│
|
||||
└── 合并结果 → 加权求和 → 排序 → top-N
|
||||
```
|
||||
|
||||
**结果合并策略**:
|
||||
- 关键词匹配分数 × 0.4 + TF-IDF 相似度分数 × 0.6
|
||||
- 权重可通过环境变量 `TOOL_SEARCH_WEIGHT_KEYWORD` / `TOOL_SEARCH_WEIGHT_TFIDF` 调整
|
||||
- 去重:同一工具取两路中最高分
|
||||
|
||||
**输出格式变更**:
|
||||
|
||||
`mapToolResultToToolResultBlockParam` 增加文本模式返回(当 `tool_reference` 不可用时):
|
||||
|
||||
```typescript
|
||||
// 当 tool_reference 可用时(现有逻辑,保持不变)
|
||||
{ type: 'tool_reference', tool_name: "..." }
|
||||
|
||||
// 当 tool_reference 不可用时(新增)
|
||||
{ type: 'text', text: "Found 3 tools:\n1. **ToolName** (score: 0.85)\n Description...\n Schema: {...}" }
|
||||
```
|
||||
|
||||
判断条件:复用 `modelSupportsToolReference()` 或检测当前 provider 是否支持。
|
||||
|
||||
**新增 `discover` 查询模式**:
|
||||
|
||||
```
|
||||
discover:<任务描述> — 纯发现搜索,不触发延迟加载,只返回工具信息
|
||||
```
|
||||
|
||||
与现有 `select:` 模式互补。`discover:` 返回工具名 + 描述 + 参数 schema(文本形式),供 ExecuteTool 使用。
|
||||
|
||||
### 4. 执行层(ExecuteTool)
|
||||
|
||||
**新增文件**: `packages/builtin-tools/src/tools/ExecuteTool/`
|
||||
|
||||
**工具定义**:
|
||||
|
||||
```typescript
|
||||
const ExecuteTool = buildTool({
|
||||
name: 'ExecuteTool',
|
||||
searchHint: 'execute run invoke a tool by name with parameters',
|
||||
|
||||
inputSchema: z.object({
|
||||
tool_name: z.string().describe('Name of the tool to execute'),
|
||||
params: z.record(z.unknown()).describe('Parameters to pass to the tool'),
|
||||
}),
|
||||
|
||||
async call(input, context) {
|
||||
// 1. 在全局工具注册表中查找目标工具
|
||||
// 2. 验证 params 是否符合目标工具的 inputSchema
|
||||
// 3. 调用目标工具的 call 方法
|
||||
// 4. 返回执行结果
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**核心逻辑**:
|
||||
|
||||
1. **工具查找**: 通过 `findToolByName` 在完整工具池(built-in + MCP)中查找
|
||||
2. **参数验证**: 用目标工具的 `inputSchema` 验证传入参数
|
||||
3. **权限继承**: 复用目标工具的 `checkPermissions` 方法
|
||||
4. **执行委托**: 调用目标工具的 `call(input, context)` 方法
|
||||
5. **结果透传**: 直接返回目标工具的执行结果
|
||||
|
||||
**权限模型**:
|
||||
- ExecuteTool 本身不做额外权限检查
|
||||
- 权限检查委托给目标工具的 `checkPermissions`
|
||||
- 用户审批时显示实际工具名和操作内容(而非 "ExecuteTool")
|
||||
|
||||
**工具注册**:
|
||||
- 在 `src/tools.ts` 的 `getAllBaseTools()` 中注册
|
||||
- 与 ToolSearchTool 关联启用:当 `isToolSearchEnabledOptimistic()` 为 true 时注册
|
||||
|
||||
### 5. 预取管道
|
||||
|
||||
**新增文件**: `src/services/toolSearch/prefetch.ts`
|
||||
|
||||
**触发时机**: 用户提交输入后、发送 API 请求前
|
||||
|
||||
**流程**:
|
||||
|
||||
```
|
||||
用户输入提交
|
||||
│
|
||||
├── 异步启动预取(不阻塞主流程)
|
||||
│ │
|
||||
│ ├── 提取用户消息文本
|
||||
│ ├── 调用 toolIndex.search(message, limit: 3)
|
||||
│ └── 存储结果到模块级缓存
|
||||
│
|
||||
└── API 请求构建时
|
||||
│
|
||||
└── collectToolSearchPrefetch()
|
||||
│
|
||||
├── 有结果 → 注入 system-reminder 或 <available-tools-hint>
|
||||
└── 无结果 → 不做任何附加
|
||||
```
|
||||
|
||||
**Hook 集成点**: 在 `REPL.tsx` 的消息提交流程或 `QueryEngine` 的请求构建环节中集成。
|
||||
|
||||
**并发安全**: 预取为异步操作,不阻塞主请求流程。如果预取未完成则跳过推荐。
|
||||
|
||||
### 6. 用户推荐 UI
|
||||
|
||||
**新增文件**: `src/components/ToolSearchHint.tsx`
|
||||
|
||||
**展示形式**: 在 REPL 输入区域上方渲染推荐提示条(类似现有 skill search tips 的设计)。
|
||||
|
||||
**UI 规格**:
|
||||
- 显示匹配度最高的 2-3 个工具
|
||||
- 每个工具显示:工具名 + 简短描述(一行截断) + 匹配分数
|
||||
- 样式与现有 skill search tips 对齐(Ink 组件,使用 theme 色系)
|
||||
- 可通过键盘快捷键选择(Tab 切换、Enter 确认)
|
||||
- 选择后将工具信息追加到当前消息的上下文中
|
||||
|
||||
**条件显示**:
|
||||
- 仅当预取结果非空时显示
|
||||
- 匹配分数低于阈值(默认 0.15)时不显示
|
||||
- 用户可通过 `settings.json` 关闭推荐提示
|
||||
|
||||
### 7. 搜索范围控制
|
||||
|
||||
采用激进精简策略后,搜索范围逻辑简化为:
|
||||
|
||||
- **索引范围**: 所有延迟工具(即核心工具列表之外的全部工具),包括所有 MCP 工具和所有非核心内置工具
|
||||
- **排除范围**: 核心工具(`CORE_TOOLS` 集合中的工具)不索引
|
||||
- **动态更新**: MCP 工具连接/断开时增量更新索引
|
||||
|
||||
可通过环境变量 `TOOL_SEARCH_EXCLUDE` 追加排除项,`TOOL_SEARCH_INCLUDE_FORCE` 强制索引某些工具。
|
||||
|
||||
## 实现要点
|
||||
|
||||
### 关键技术决策
|
||||
|
||||
1. **复用 vs 重写 TF-IDF 基础设施**: 直接 import `localSearch.ts` 的 `tokenizeAndStem`、`computeWeightedTf`、`computeIdf`、`cosineSimilarity` 函数。不提取为独立模块,因为 skill 和 tool 的索引结构不同(SkillIndexEntry vs ToolIndexEntry),强行抽象会增加复杂度。
|
||||
|
||||
2. **ExecuteTool vs tool_reference**: ExecuteTool 是通用方案,兼容所有 API provider。当 provider 支持 `tool_reference` 时,优先使用 `tool_reference`(性能更好,模型认知负担更低)。当不支持时,回退到 ExecuteTool。
|
||||
|
||||
3. **索引更新策略**: MCP 工具连接/断开时,通过 `DeferredToolsDelta` 机制检测变化,增量更新索引而非全量重建。
|
||||
|
||||
4. **预取不阻塞主流程**: 预取为 fire-and-forget 异步操作。如果预取未完成,API 请求正常发送,不做任何等待。
|
||||
|
||||
### 难点
|
||||
|
||||
1. **权限透传**: ExecuteTool 调用目标工具时需要正确透传权限上下文,确保用户审批流程与直接调用目标工具一致。
|
||||
|
||||
2. **参数 schema 验证**: MCP 工具的 schema 可能非常复杂(嵌套对象、oneOf 等),ExecuteTool 需要优雅地处理 schema 验证失败的情况。
|
||||
|
||||
3. **缓存一致性**: 工具索引缓存需要在 MCP 连接变化时及时更新,避免搜索到已失效的工具。
|
||||
|
||||
### 依赖
|
||||
|
||||
- `src/services/skillSearch/localSearch.ts` — TF-IDF 算法复用
|
||||
- `packages/builtin-tools/src/tools/ToolSearchTool/` — 现有搜索逻辑基础
|
||||
- `src/utils/toolSearch.ts` — 工具搜索基础设施(模式判断、阈值计算)
|
||||
- `packages/builtin-tools/src/tools/MCPTool/MCPTool.ts` — MCP 工具执行参考
|
||||
|
||||
### 新增文件清单
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/services/toolSearch/toolIndex.ts` | TF-IDF 工具索引构建与查询 |
|
||||
| `src/services/toolSearch/prefetch.ts` | 用户输入预取管道 |
|
||||
| `packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts` | 工具执行入口 |
|
||||
| `packages/builtin-tools/src/tools/ExecuteTool/prompt.ts` | ExecuteTool prompt 定义 |
|
||||
| `packages/builtin-tools/src/tools/ExecuteTool/constants.ts` | 常量定义 |
|
||||
| `src/components/ToolSearchHint.tsx` | 用户推荐 UI 组件 |
|
||||
|
||||
### 修改文件清单
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` | 新增 TF-IDF 搜索路径、discover 模式 |
|
||||
| `packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` | 更新 prompt 文档、修改 `isDeferredTool` 判定逻辑 |
|
||||
| `src/constants/tools.ts` | 新增 `CORE_TOOLS` 常量集合 |
|
||||
| `src/tools.ts` | 注册 ExecuteTool、调整 `getAllBaseTools()` 工具注册 |
|
||||
| `src/utils/toolSearch.ts` | 适配新的延迟判定逻辑 |
|
||||
| `src/constants/prompts.ts` | 添加 ToolSearch 引导指令到系统提示词 |
|
||||
| `src/services/api/claude.ts` | 集成预取管道、调整延迟工具注入方式 |
|
||||
| `src/screens/REPL.tsx` | 集成 ToolSearchHint 组件 |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 初始化时仅加载 ~10 个核心工具 schema,其余工具延迟加载
|
||||
- [ ] 延迟工具名称列表正确注入到 API 请求中
|
||||
- [ ] ToolSearchTool 支持基于 TF-IDF 的工具发现搜索(`discover:` 模式)
|
||||
- [ ] ToolSearchTool 支持关键词 + TF-IDF 混合搜索
|
||||
- [ ] ExecuteTool 可通过 tool_name + params 执行任意已注册工具
|
||||
- [ ] ExecuteTool 在所有 API provider(Anthropic/OpenAI/Gemini/Grok)下均可工作
|
||||
- [ ] MCP 工具连接/断开时索引自动更新
|
||||
- [ ] 用户输入后预取管道异步工作,不阻塞主流程
|
||||
- [ ] REPL 中展示工具推荐提示条(可配置开关)
|
||||
- [ ] `bun run precheck` 零错误通过
|
||||
- [ ] 新增单元测试覆盖:初始化精简验证、工具索引构建、TF-IDF 搜索、结果合并、ExecuteTool 执行
|
||||
262
spec/feature_20260508_F001_tool-search/spec-human-verify.md
Normal file
262
spec/feature_20260508_F001_tool-search/spec-human-verify.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# Tool Search 基础设施层 人工验收清单
|
||||
|
||||
**生成时间:** 2026-05-08
|
||||
**关联计划:** spec/feature_20260508_F001_tool-search/spec-plan-1.md
|
||||
**关联设计:** spec/feature_20260508_F001_tool-search/spec-design.md
|
||||
|
||||
> 所有验收项均可自动化验证,无需人类参与。仍将生成清单用于自动执行。
|
||||
|
||||
---
|
||||
|
||||
## 验收前准备
|
||||
|
||||
### 环境要求
|
||||
- [ ] [AUTO] 检查 Bun 运行时: `bun --version`
|
||||
- [ ] [AUTO] 检查 TypeScript 编译: `bunx tsc --noEmit --pretty 2>&1 | tail -5`
|
||||
|
||||
---
|
||||
|
||||
## 验收项目
|
||||
|
||||
### 场景 1:核心工具白名单与延迟判定
|
||||
|
||||
> 验证 `CORE_TOOLS` 常量正确定义,`isDeferredTool` 已重构为白名单制判定。
|
||||
|
||||
#### - [x] 1.1 CORE_TOOLS 常量已定义且被引用
|
||||
- **来源:** spec-plan-1.md Task 1 / spec-design.md §1
|
||||
- **目的:** 确认核心工具白名单已建立
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -c "CORE_TOOLS" src/constants/tools.ts` → 期望包含: 数字 ≥ 2
|
||||
2. [A] `grep -rn "CORE_TOOLS" src/ packages/builtin-tools/src/ --include="*.ts" 2>/dev/null | wc -l` → 期望包含: 数字 ≥ 3
|
||||
|
||||
#### - [x] 1.2 isDeferredTool 函数体仅含白名单逻辑
|
||||
- **来源:** spec-plan-1.md Task 1
|
||||
- **目的:** 确认延迟判定从"排除例外"改为"包含准入"
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -A 8 "export function isDeferredTool" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` → 期望包含: `CORE_TOOLS.has`
|
||||
2. [A] `grep -A 8 "export function isDeferredTool" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` → 期望包含: `return true`
|
||||
|
||||
#### - [x] 1.3 isDeferredTool 不再依赖旧 feature flag 逻辑
|
||||
- **来源:** spec-plan-1.md Task 1
|
||||
- **目的:** 确认旧的分散特判规则已清理
|
||||
- **操作步骤:**
|
||||
1. [A] `grep "feature(" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` → 期望精确: ""
|
||||
2. [A] `grep "shouldDefer" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` → 期望精确: ""
|
||||
|
||||
#### - [x] 1.4 CORE_TOOLS 与 isDeferredTool 单元测试通过
|
||||
- **来源:** spec-plan-1.md Task 1
|
||||
- **目的:** 确认白名单制逻辑正确
|
||||
- **操作步骤:**
|
||||
1. [A] `bun test src/constants/__tests__/tools.test.ts 2>&1 | tail -5` → 期望包含: `pass`
|
||||
|
||||
---
|
||||
|
||||
### 场景 2:TF-IDF 工具索引
|
||||
|
||||
> 验证 `localSearch.ts` 算法函数已导出,`toolIndex.ts` 正确构建 TF-IDF 索引并支持搜索。
|
||||
|
||||
#### - [x] 2.1 localSearch.ts 三个 TF-IDF 核心函数已导出
|
||||
- **来源:** spec-plan-1.md Task 2
|
||||
- **目的:** 确认算法复用基础已建立
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -c "export function computeWeightedTf\|export function computeIdf\|export function cosineSimilarity" src/services/skillSearch/localSearch.ts` → 期望精确: "3"
|
||||
|
||||
#### - [x] 2.2 toolIndex.ts 导出正确的接口与函数
|
||||
- **来源:** spec-plan-1.md Task 2
|
||||
- **目的:** 确认索引模块 API 完整
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -c "export function\|export interface" src/services/toolSearch/toolIndex.ts` → 期望包含: 数字 ≥ 6
|
||||
|
||||
#### - [x] 2.3 toolIndex.ts TypeScript 编译无错误
|
||||
- **来源:** spec-plan-1.md Task 2
|
||||
- **目的:** 确认类型安全
|
||||
- **操作步骤:**
|
||||
1. [A] `bunx tsc --noEmit src/services/toolSearch/toolIndex.ts 2>&1 | head -20` → 期望包含: 无 error 行(或为空输出)
|
||||
|
||||
#### - [x] 2.4 toolIndex.ts 单元测试通过
|
||||
- **来源:** spec-plan-1.md Task 2
|
||||
- **目的:** 确认索引构建和搜索逻辑正确
|
||||
- **操作步骤:**
|
||||
1. [A] `bun test src/services/toolSearch/__tests__/toolIndex.test.ts 2>&1 | tail -10` → 期望包含: `pass`
|
||||
|
||||
#### - [x] 2.5 localSearch.ts 原有测试未回归
|
||||
- **来源:** spec-plan-1.md Task 2
|
||||
- **目的:** 确认导出变更未破坏现有功能
|
||||
- **操作步骤:**
|
||||
1. [A] `bun test src/services/skillSearch/__tests__/localSearch.test.ts 2>&1 | tail -10` → 期望包含: `pass`
|
||||
|
||||
---
|
||||
|
||||
### 场景 3:ExecuteTool 执行入口
|
||||
|
||||
> 验证 ExecuteTool 工具包文件齐全、实现符合 buildTool 规范、权限透传正确。
|
||||
|
||||
#### - [x] 3.1 ExecuteTool 常量文件正确
|
||||
- **来源:** spec-plan-1.md Task 3
|
||||
- **目的:** 确认工具名常量已定义
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n 'EXECUTE_TOOL_NAME' packages/builtin-tools/src/tools/ExecuteTool/constants.ts` → 期望包含: `export const EXECUTE_TOOL_NAME`
|
||||
|
||||
#### - [x] 3.2 ExecuteTool prompt 文件正确
|
||||
- **来源:** spec-plan-1.md Task 3
|
||||
- **目的:** 确认 prompt 与 description 已导出
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n 'export' packages/builtin-tools/src/tools/ExecuteTool/prompt.ts` → 期望包含: `DESCRIPTION`
|
||||
2. [A] `grep -n 'export' packages/builtin-tools/src/tools/ExecuteTool/prompt.ts` → 期望包含: `getPrompt`
|
||||
|
||||
#### - [x] 3.3 ExecuteTool 使用 buildTool 构建
|
||||
- **来源:** spec-plan-1.md Task 3 / spec-design.md §4
|
||||
- **目的:** 确认遵循工具框架规范
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n 'buildTool\|satisfies ToolDef' packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts` → 期望包含: `buildTool`
|
||||
2. [A] `grep -n 'buildTool\|satisfies ToolDef' packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts` → 期望包含: `satisfies ToolDef`
|
||||
|
||||
#### - [x] 3.4 isDeferredTool 正确排除 ExecuteTool
|
||||
- **来源:** spec-plan-1.md Task 3
|
||||
- **目的:** 确认执行入口不被延迟加载
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n 'EXECUTE_TOOL_NAME' packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` → 期望包含: `EXECUTE_TOOL_NAME`
|
||||
|
||||
#### - [x] 3.5 ExecuteTool 单元测试通过
|
||||
- **来源:** spec-plan-1.md Task 3
|
||||
- **目的:** 确认工具执行、权限透传、错误处理正确
|
||||
- **操作步骤:**
|
||||
1. [A] `bun test packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts 2>&1 | tail -5` → 期望包含: `pass`
|
||||
|
||||
---
|
||||
|
||||
### 场景 4:ToolSearchTool 搜索增强
|
||||
|
||||
> 验证 TF-IDF 搜索路径、discover 模式、并行搜索合并、文本模式输出均已实现。
|
||||
|
||||
#### - [x] 4.1 TF-IDF 搜索依赖已正确导入
|
||||
- **来源:** spec-plan-1.md Task 4
|
||||
- **目的:** 确认搜索层依赖就位
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n "getToolIndex\|searchTools\|modelSupportsToolReference" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `getToolIndex`
|
||||
2. [A] `grep -n "getToolIndex\|searchTools\|modelSupportsToolReference" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `searchTools`
|
||||
3. [A] `grep -n "getToolIndex\|searchTools\|modelSupportsToolReference" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `modelSupportsToolReference`
|
||||
|
||||
#### - [x] 4.2 discover: 查询模式已实现
|
||||
- **来源:** spec-plan-1.md Task 4 / spec-design.md §3
|
||||
- **目的:** 确认纯发现搜索路径可用
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n "discoverMatch\|discover:" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `discoverMatch`
|
||||
|
||||
#### - [x] 4.3 关键词搜索与 TF-IDF 搜索并行执行
|
||||
- **来源:** spec-plan-1.md Task 4 / spec-design.md §3
|
||||
- **目的:** 确认两路搜索并行而非串行
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n "Promise.all" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `Promise.all`
|
||||
2. [A] `grep -n "searchToolsWithKeywords\|getToolIndex" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts | grep -i promise` → 期望包含: `searchToolsWithKeywords`
|
||||
|
||||
#### - [x] 4.4 结果合并使用加权求和
|
||||
- **来源:** spec-plan-1.md Task 4 / spec-design.md §3
|
||||
- **目的:** 确认混合搜索结果正确排序
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n "KEYWORD_WEIGHT\|TFIDF_WEIGHT" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `KEYWORD_WEIGHT`
|
||||
2. [A] `grep -n "KEYWORD_WEIGHT\|TFIDF_WEIGHT" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `TFIDF_WEIGHT`
|
||||
|
||||
#### - [x] 4.5 mapToolResultToToolResultBlockParam 支持文本模式回退
|
||||
- **来源:** spec-plan-1.md Task 4 / spec-design.md §3(跨 API provider 兼容)
|
||||
- **目的:** 确认非 Anthropic provider 下返回文本格式
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n "supportsToolRef\|ExecuteTool" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `supportsToolRef`
|
||||
2. [A] `grep -n "supportsToolRef\|ExecuteTool" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` → 期望包含: `ExecuteTool`
|
||||
|
||||
#### - [x] 4.6 prompt.ts 包含 discover: 模式文档
|
||||
- **来源:** spec-plan-1.md Task 4
|
||||
- **目的:** 确认模型可知晓 discover 查询模式
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -n "discover:" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` → 期望包含: `discover:`
|
||||
|
||||
#### - [x] 4.7 ToolSearchTool 增强后 TypeScript 编译无新增错误
|
||||
- **来源:** spec-plan-1.md Task 4
|
||||
- **目的:** 确认类型安全
|
||||
- **操作步骤:**
|
||||
1. [A] `bunx tsc --noEmit --pretty 2>&1 | head -30` → 期望包含: 无新增 error 行
|
||||
|
||||
#### - [x] 4.8 ToolSearchTool 搜索增强单元测试通过
|
||||
- **来源:** spec-plan-1.md Task 4
|
||||
- **目的:** 确认 discover 模式、并行搜索、文本回退均正确
|
||||
- **操作步骤:**
|
||||
1. [A] `bun test packages/builtin-tools/src/tools/ToolSearchTool/__tests__/ToolSearchTool.test.ts 2>&1 | tail -10` → 期望包含: `pass`
|
||||
|
||||
---
|
||||
|
||||
### 场景 5:端到端集成验证
|
||||
|
||||
> 验证全量测试、类型检查、构建产物均无回归。
|
||||
|
||||
#### - [x] 5.1 全量测试套件通过
|
||||
- **来源:** spec-plan-1.md Task 5 / spec-design.md 验收标准
|
||||
- **目的:** 确认所有新增测试无回归
|
||||
- **操作步骤:**
|
||||
1. [A] `bun test src/constants/__tests__/tools.test.ts src/services/toolSearch/__tests__/toolIndex.test.ts packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts packages/builtin-tools/src/tools/ToolSearchTool/__tests__/ 2>&1 | tail -10` → 期望包含: `pass`
|
||||
|
||||
#### - [x] 5.2 TypeScript 全量类型检查通过
|
||||
- **来源:** spec-plan-1.md Task 5 / spec-design.md 验收标准
|
||||
- **目的:** 确认无新增类型错误
|
||||
- **操作步骤:**
|
||||
1. [A] `bunx tsc --noEmit --pretty 2>&1 | grep -i "error" | head -20` → 期望包含: 无新增 error 行(或为空输出)
|
||||
|
||||
#### - [x] 5.3 CORE_TOOLS 在关键文件中被引用
|
||||
- **来源:** spec-plan-1.md Task 5
|
||||
- **目的:** 确认白名单常量已集成到延迟判定和工具索引
|
||||
- **操作步骤:**
|
||||
1. [A] `grep -rn "CORE_TOOLS" src/ packages/builtin-tools/src/ --include="*.ts" 2>/dev/null` → 期望包含: `tools.ts`
|
||||
2. [A] `grep -rn "CORE_TOOLS" src/ packages/builtin-tools/src/ --include="*.ts" 2>/dev/null` → 期望包含: `prompt.ts`
|
||||
|
||||
#### - [x] 5.4 项目构建成功
|
||||
- **来源:** spec-plan-1.md Task 5 / spec-design.md 验收标准
|
||||
- **目的:** 确认构建产物可用
|
||||
- **操作步骤:**
|
||||
1. [A] `bun run build 2>&1 | tail -5` → 期望包含: `dist/cli.js`
|
||||
|
||||
#### - [x] 5.5 precheck 零错误通过
|
||||
- **来源:** spec-design.md 验收标准 / CLAUDE.md
|
||||
- **目的:** 确认 typecheck + lint fix + test 全通过
|
||||
- **操作步骤:**
|
||||
1. [A] `bun run precheck 2>&1 | tail -10` → 期望包含: 无 error 或 fail
|
||||
|
||||
---
|
||||
|
||||
## 验收后清理
|
||||
|
||||
本功能为纯库代码变更,无后台服务启动,无需清理。
|
||||
|
||||
---
|
||||
|
||||
## 验收结果汇总
|
||||
|
||||
| 场景 | 序号 | 验收项 | [A] | [H] | 结果 |
|
||||
|------|------|--------|-----|-----|------|
|
||||
| 场景 1 | 1.1 | CORE_TOOLS 常量已定义且被引用 | 2 | 0 | ✅ |
|
||||
| 场景 1 | 1.2 | isDeferredTool 函数体仅含白名单逻辑 | 2 | 0 | ✅ |
|
||||
| 场景 1 | 1.3 | isDeferredTool 不再依赖旧 feature flag 逻辑 | 2 | 0 | ✅ |
|
||||
| 场景 1 | 1.4 | CORE_TOOLS 与 isDeferredTool 单元测试通过 | 1 | 0 | ✅ |
|
||||
| 场景 2 | 2.1 | localSearch.ts 三个 TF-IDF 核心函数已导出 | 1 | 0 | ✅ |
|
||||
| 场景 2 | 2.2 | toolIndex.ts 导出正确的接口与函数 | 1 | 0 | ✅ |
|
||||
| 场景 2 | 2.3 | toolIndex.ts TypeScript 编译无错误 | 1 | 0 | ✅ |
|
||||
| 场景 2 | 2.4 | toolIndex.ts 单元测试通过 | 1 | 0 | ✅ |
|
||||
| 场景 2 | 2.5 | localSearch.ts 原有测试未回归 | 1 | 0 | ✅ |
|
||||
| 场景 3 | 3.1 | ExecuteTool 常量文件正确 | 1 | 0 | ✅ |
|
||||
| 场景 3 | 3.2 | ExecuteTool prompt 文件正确 | 2 | 0 | ✅ |
|
||||
| 场景 3 | 3.3 | ExecuteTool 使用 buildTool 构建 | 2 | 0 | ✅ |
|
||||
| 场景 3 | 3.4 | isDeferredTool 正确排除 ExecuteTool | 1 | 0 | ✅ |
|
||||
| 场景 3 | 3.5 | ExecuteTool 单元测试通过 | 1 | 0 | ✅ |
|
||||
| 场景 4 | 4.1 | TF-IDF 搜索依赖已正确导入 | 3 | 0 | ✅ |
|
||||
| 场景 4 | 4.2 | discover: 查询模式已实现 | 1 | 0 | ✅ |
|
||||
| 场景 4 | 4.3 | 关键词搜索与 TF-IDF 搜索并行执行 | 2 | 0 | ✅ |
|
||||
| 场景 4 | 4.4 | 结果合并使用加权求和 | 2 | 0 | ✅ |
|
||||
| 场景 4 | 4.5 | 文本模式回退支持跨 API provider | 2 | 0 | ✅ |
|
||||
| 场景 4 | 4.6 | prompt.ts 包含 discover: 模式文档 | 1 | 0 | ✅ |
|
||||
| 场景 4 | 4.7 | 搜索增强后 TypeScript 编译无新增错误 | 1 | 0 | ✅ |
|
||||
| 场景 4 | 4.8 | ToolSearchTool 搜索增强单元测试通过 | 1 | 0 | ✅ |
|
||||
| 场景 5 | 5.1 | 全量测试套件通过 | 1 | 0 | ✅ |
|
||||
| 场景 5 | 5.2 | TypeScript 全量类型检查通过 | 1 | 0 | ✅ |
|
||||
| 场景 5 | 5.3 | CORE_TOOLS 在关键文件中被引用 | 2 | 0 | ✅ |
|
||||
| 场景 5 | 5.4 | 项目构建成功 | 1 | 0 | ✅ |
|
||||
| 场景 5 | 5.5 | precheck 零错误通过 | 1 | 0 | ✅ |
|
||||
|
||||
**验收结论:** ✅ 全部通过
|
||||
650
spec/feature_20260508_F001_tool-search/spec-plan-1.md
Normal file
650
spec/feature_20260508_F001_tool-search/spec-plan-1.md
Normal file
@@ -0,0 +1,650 @@
|
||||
# Tool Search 执行计划(一)— 基础设施层
|
||||
|
||||
**目标:** 建立 tool search 的基础能力——核心工具常量、TF-IDF 工具索引、ExecuteTool 执行工具、ToolSearchTool 搜索增强
|
||||
|
||||
**技术栈:** TypeScript, Bun, Zod, TF-IDF (复用 localSearch.ts), buildTool 框架
|
||||
|
||||
**设计文档:** spec/feature_20260508_F001_tool-search/spec-design.md
|
||||
|
||||
## 改动总览
|
||||
|
||||
- 新增 `CORE_TOOLS` 常量集合(31 个核心工具名)到 `src/constants/tools.ts`,重构 `isDeferredTool` 为白名单制;新建 TF-IDF 工具索引 `toolIndex.ts`(复用 `localSearch.ts` 算法);新建 `ExecuteTool` 工具包(3 个文件);增强 `ToolSearchTool` 搜索层(TF-IDF + discover 模式)
|
||||
- Task 1(CORE_TOOLS)是 Task 2/3/4 的共同前置依赖;Task 2(toolIndex)被 Task 4(搜索增强)依赖
|
||||
- 关键决策:`isDeferredTool` 从"排除例外"改为"包含准入"白名单制,所有非核心工具默认延迟;TF-IDF 算法直接 import `localSearch.ts` 的导出函数,不创建独立共享模块
|
||||
|
||||
---
|
||||
|
||||
### Task 0: 环境准备
|
||||
|
||||
**背景:**
|
||||
确保构建和测试工具链在当前开发环境中可用,避免后续 Task 因环境问题阻塞。
|
||||
|
||||
**执行步骤:**
|
||||
- [x] 验证 Bun 运行时可用
|
||||
- `bun --version`
|
||||
- 预期: 输出 Bun 版本号
|
||||
- [x] 验证 TypeScript 编译可用
|
||||
- `bunx tsc --noEmit --pretty 2>&1 | tail -5`
|
||||
- 预期: 无新增类型错误(已有错误可忽略)
|
||||
- [x] 验证测试框架可用
|
||||
- `bun test --help 2>&1 | head -3`
|
||||
- 预期: 输出 bun test 帮助信息
|
||||
|
||||
**检查步骤:**
|
||||
- [x] 构建命令执行成功
|
||||
- `bun run build 2>&1 | tail -10`
|
||||
- 预期: 构建成功,输出 dist/cli.js
|
||||
- [x] 现有测试可通过
|
||||
- `bun test src/constants/__tests__/ 2>&1 | tail -5 || echo "no existing tests in this dir"`
|
||||
- 预期: 测试框架可用,无配置错误
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 核心工具常量与延迟判定
|
||||
|
||||
**背景:**
|
||||
当前 `isDeferredTool` 使用一组分散的特判规则(`shouldDefer`、MCP 检测、feature flag 特判)来决定工具是否延迟加载,缺少统一的"核心工具"概念。设计文档要求引入 `CORE_TOOLS` 白名单常量,将始终加载的核心工具(31 个)显式列出,并将 `isDeferredTool` 改为白名单制判定:核心工具 + alwaysLoad 工具 + ToolSearchTool/ExecuteTool 不延迟,其余全部延迟。本 Task 的输出(`CORE_TOOLS` 常量和重构后的 `isDeferredTool`)被 Task 2(TF-IDF 工具索引)、Task 3(ExecuteTool)、Task 4(ToolSearchTool 搜索增强)直接依赖。
|
||||
|
||||
**涉及文件:**
|
||||
- 修改: `src/constants/tools.ts`
|
||||
- 修改: `packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts`
|
||||
- 新建: `src/constants/__tests__/tools.test.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 在 `src/constants/tools.ts` 中新增 `CORE_TOOLS` 常量集合
|
||||
- 位置: `src/constants/tools.ts` 文件末尾(`COORDINATOR_MODE_ALLOWED_TOOLS` 之后,~L113)
|
||||
- 新增以下 import(文件顶部 import 区域,与现有 import 风格一致):
|
||||
```typescript
|
||||
import { SLEEP_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SleepTool/prompt.js'
|
||||
import { LSP_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/LSPTool/prompt.js'
|
||||
import { VERIFY_PLAN_EXECUTION_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/VerifyPlanExecutionTool/constants.js'
|
||||
import { TEAM_CREATE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TeamCreateTool/constants.js'
|
||||
import { TEAM_DELETE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TeamDeleteTool/constants.js'
|
||||
```
|
||||
- 在文件末尾新增 `CORE_TOOLS` 导出常量:
|
||||
```typescript
|
||||
/**
|
||||
* Core tools that are always loaded with full schema at initialization.
|
||||
* These tools are never deferred — they appear in the initial prompt.
|
||||
* All other tools (non-core built-in + all MCP tools) are deferred
|
||||
* and must be discovered via ToolSearchTool / ExecuteTool.
|
||||
*/
|
||||
export const CORE_TOOLS = new Set([
|
||||
// File operations
|
||||
...SHELL_TOOL_NAMES, // 'Bash', 'Shell'
|
||||
FILE_READ_TOOL_NAME, // 'Read'
|
||||
FILE_EDIT_TOOL_NAME, // 'Edit'
|
||||
FILE_WRITE_TOOL_NAME, // 'Write'
|
||||
GLOB_TOOL_NAME, // 'Glob'
|
||||
GREP_TOOL_NAME, // 'Grep'
|
||||
NOTEBOOK_EDIT_TOOL_NAME,// 'NotebookEdit'
|
||||
// Agent & interaction
|
||||
AGENT_TOOL_NAME, // 'Agent'
|
||||
ASK_USER_QUESTION_TOOL_NAME, // 'AskUserQuestion'
|
||||
SEND_MESSAGE_TOOL_NAME, // 'SendMessage'
|
||||
// Team (swarm)
|
||||
TEAM_CREATE_TOOL_NAME, // 'TeamCreate'
|
||||
TEAM_DELETE_TOOL_NAME, // 'TeamDelete'
|
||||
// Task management
|
||||
TASK_OUTPUT_TOOL_NAME, // 'TaskOutput'
|
||||
TASK_STOP_TOOL_NAME, // 'TaskStop'
|
||||
TASK_CREATE_TOOL_NAME, // 'TaskCreate'
|
||||
TASK_GET_TOOL_NAME, // 'TaskGet'
|
||||
TASK_LIST_TOOL_NAME, // 'TaskList'
|
||||
TASK_UPDATE_TOOL_NAME, // 'TaskUpdate'
|
||||
TODO_WRITE_TOOL_NAME, // 'TodoWrite'
|
||||
// Planning
|
||||
ENTER_PLAN_MODE_TOOL_NAME, // 'EnterPlanMode'
|
||||
EXIT_PLAN_MODE_V2_TOOL_NAME, // 'ExitPlanMode'
|
||||
VERIFY_PLAN_EXECUTION_TOOL_NAME, // 'VerifyPlanExecution'
|
||||
// Web
|
||||
WEB_FETCH_TOOL_NAME, // 'WebFetch'
|
||||
WEB_SEARCH_TOOL_NAME, // 'WebSearch'
|
||||
// Code intelligence
|
||||
LSP_TOOL_NAME, // 'LSP'
|
||||
// Skills
|
||||
SKILL_TOOL_NAME, // 'Skill'
|
||||
// Scheduling & monitoring
|
||||
SLEEP_TOOL_NAME, // 'Sleep'
|
||||
// Tool discovery (always loaded)
|
||||
TOOL_SEARCH_TOOL_NAME, // 'ToolSearch'
|
||||
SYNTHETIC_OUTPUT_TOOL_NAME, // 'SyntheticOutput'
|
||||
]) as ReadonlySet<string>
|
||||
```
|
||||
- 说明: `ListPeers` 和 `Monitor` 工具名在各自工具文件内以局部常量定义(非 export),无法在 `tools.ts` 中 import。`ListPeers` 频率较低,`Monitor` 受 `MONITOR_TOOL` feature gate 控制,两者暂不纳入 CORE_TOOLS,待后续 Task 按需加入。
|
||||
- 原因: 建立统一的"核心工具"白名单,为后续 Task 的延迟判定、工具索引排除提供单一数据源
|
||||
|
||||
- [x] 重构 `packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` 中的 `isDeferredTool` 函数
|
||||
- 位置: `packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` 的 `isDeferredTool` 函数体(L62-L108)
|
||||
- 新增 import(文件顶部):
|
||||
```typescript
|
||||
import { CORE_TOOLS } from 'src/constants/tools.js'
|
||||
```
|
||||
- 替换整个 `isDeferredTool` 函数体为白名单制逻辑:
|
||||
```typescript
|
||||
export function isDeferredTool(tool: Tool): boolean {
|
||||
// Explicit opt-out via _meta['anthropic/alwaysLoad']
|
||||
if (tool.alwaysLoad === true) return false
|
||||
|
||||
// Core tools are always loaded — never deferred
|
||||
if (CORE_TOOLS.has(tool.name)) return false
|
||||
|
||||
// Everything else (non-core built-in + all MCP tools) is deferred
|
||||
return true
|
||||
}
|
||||
```
|
||||
- 清理 isDeferredTool 不再需要的代码:
|
||||
- 文件顶部的 `import { feature } from 'bun:bundle'`(仅被 isDeferredTool 使用的 feature flag 逻辑)
|
||||
- 文件顶部的 `import { isReplBridgeActive } from 'src/bootstrap/state.js'`(仅被 KAIROS 逻辑使用)
|
||||
- 保留 `import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'`(仍被 `getToolLocationHint()` 使用,不删除)
|
||||
- 文件顶部的 `import { AGENT_TOOL_NAME } from '../AgentTool/constants.js'`(不再被 isDeferredTool 使用)
|
||||
- L8-L21 的 `BRIEF_TOOL_NAME` 和 `SEND_USER_FILE_TOOL_NAME` 条件 import 块(`isDeferredTool` 不再需要 feature flag 特判)
|
||||
- 注意: 保留 `getToolLocationHint()` 函数及其对 `getFeatureValue_CACHED_MAY_BE_STALE` 的 import(仍被 `getPrompt()` 使用)
|
||||
- 原因: 白名单制替代分散的特判规则,逻辑从"排除例外"变为"包含准入",更易维护和扩展
|
||||
|
||||
- [x] 为 `CORE_TOOLS` 常量和 `isDeferredTool` 重构编写单元测试
|
||||
- 测试文件: `src/constants/__tests__/tools.test.ts`(新建)
|
||||
- 测试场景:
|
||||
- `CORE_TOOLS` 包含预期数量的工具(约 29 个: 7 SHELL_TOOL_NAMES + 22 独立工具名)
|
||||
- `CORE_TOOLS` 包含所有设计文档中列出的核心工具名(抽查: 'Bash', 'Read', 'Edit', 'Write', 'Glob', 'Grep', 'Agent', 'AskUserQuestion', 'ToolSearch', 'WebSearch', 'WebFetch', 'Sleep', 'LSP', 'Skill', 'TeamCreate', 'TeamDelete', 'TaskCreate', 'TaskGet', 'TaskUpdate', 'TaskList', 'TaskOutput', 'TaskStop', 'TodoWrite', 'EnterPlanMode', 'ExitPlanMode', 'VerifyPlanExecution', 'NotebookEdit', 'SyntheticOutput')
|
||||
- `CORE_TOOLS` 是 ReadonlySet,不可外部修改
|
||||
- `isDeferredTool` 对 `CORE_TOOLS` 中的工具名返回 `false`(构造 `{ name: 'Read', alwaysLoad: undefined, isMcp: false, shouldDefer: undefined }` 形式的 mock Tool)
|
||||
- `isDeferredTool` 对 `alwaysLoad: true` 的工具返回 `false`(即使工具名不在 CORE_TOOLS 中)
|
||||
- `isDeferredTool` 对非核心内置工具返回 `true`(工具名 'ConfigTool',无 alwaysLoad,无 isMcp)
|
||||
- `isDeferredTool` 对 MCP 工具返回 `true`(`isMcp: true`,即使 alwaysLoad 为 undefined)
|
||||
- `isDeferredTool` 对 `alwaysLoad: true` 的 MCP 工具返回 `false`(alwaysLoad 优先级最高)
|
||||
- 运行命令: `bun test src/constants/__tests__/tools.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
|
||||
- [x] 验证 `CORE_TOOLS` 常量已导出且包含预期工具
|
||||
- `grep -c "CORE_TOOLS" src/constants/tools.ts`
|
||||
- 预期: 至少 2 行(export 定义 + 注释)
|
||||
|
||||
- [x] 验证 `isDeferredTool` 函数已简化为白名单制
|
||||
- `grep -A 8 "export function isDeferredTool" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts`
|
||||
- 预期: 函数体仅包含 `alwaysLoad`、`CORE_TOOLS.has`、`return true` 三个分支,不包含 `isMcp`、`feature(`、`shouldDefer` 等旧逻辑
|
||||
|
||||
- [x] 验证 `isDeferredTool` 不再依赖已删除的 import
|
||||
- `grep "feature(" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts`
|
||||
- 预期: 无输出(feature flag 依赖已从 isDeferredTool 中移除)
|
||||
|
||||
- [x] 验证类型检查通过
|
||||
- `bunx tsc --noEmit --pretty 2>&1 | head -30`
|
||||
- 预期: 无新增类型错误
|
||||
|
||||
- [x] 运行新增单元测试
|
||||
- `bun test src/constants/__tests__/tools.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
---
|
||||
|
||||
### Task 2: TF-IDF 工具索引
|
||||
|
||||
**背景:**
|
||||
[业务语境] — 本 Task 构建工具索引模块,为 TF-IDF 搜索提供索引构建和查询能力。ToolSearchTool(Task 4)和预取管道依赖此索引来按任务描述发现延迟工具。
|
||||
[修改原因] — 当前项目只有 skill 搜索的 TF-IDF 实现(`localSearch.ts`),缺少工具维度的索引。`localSearch.ts` 中的 `computeWeightedTf`、`computeIdf`、`cosineSimilarity` 三个核心函数未导出,需要先导出才能复用。
|
||||
[上下游影响] — 本 Task 输出 `toolIndex.ts` 被 Task 4(ToolSearchTool 搜索增强)和 Task 3(ExecuteTool 工具查找)依赖。本 Task 依赖 Task 1(`CORE_TOOLS` 常量和 `isDeferredTool` 判定)。
|
||||
|
||||
**涉及文件:**
|
||||
- 修改: `src/services/skillSearch/localSearch.ts`(导出三个私有函数)
|
||||
- 新建: `src/services/toolSearch/toolIndex.ts`
|
||||
- 新建: `src/services/toolSearch/__tests__/toolIndex.test.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 导出 `localSearch.ts` 中三个私有 TF-IDF 函数 — `toolIndex.ts` 需要复用这些算法函数
|
||||
- 位置: `src/services/skillSearch/localSearch.ts` L212, L230, L249
|
||||
- 在 `computeWeightedTf`、`computeIdf`、`cosineSimilarity` 三个函数声明前各加 `export` 关键字
|
||||
- 保持函数签名不变,仅增加导出修饰符
|
||||
- 原因: 这三个函数是 TF-IDF 核心算法,与索引结构无关,导出后 skill 和 tool 两个索引模块均可复用
|
||||
|
||||
- [x] 新建 `src/services/toolSearch/toolIndex.ts`,定义 `ToolIndexEntry` 接口和工具字段权重常量
|
||||
- 位置: 文件开头
|
||||
- 定义 `ToolIndexEntry` 接口,包含以下字段:
|
||||
```typescript
|
||||
export interface ToolIndexEntry {
|
||||
name: string
|
||||
normalizedName: string
|
||||
description: string
|
||||
searchHint: string | undefined
|
||||
isMcp: boolean
|
||||
isDeferred: boolean
|
||||
inputSchema: object | undefined
|
||||
tokens: string[]
|
||||
tfVector: Map<string, number>
|
||||
}
|
||||
```
|
||||
- 定义字段权重常量(参照 `localSearch.ts` 的 `FIELD_WEIGHT` 模式):
|
||||
```typescript
|
||||
const TOOL_FIELD_WEIGHT = {
|
||||
name: 3.0,
|
||||
searchHint: 2.5,
|
||||
description: 1.0,
|
||||
} as const
|
||||
```
|
||||
- 定义最小显示分数常量:`const TOOL_SEARCH_DISPLAY_MIN_SCORE = Number(process.env.TOOL_SEARCH_DISPLAY_MIN_SCORE ?? '0.10')`
|
||||
- 原因: 工具索引结构与 skill 索引不同(无 `whenToUse`/`allowedTools`,增加 `searchHint`/`isMcp`/`isDeferred`/`inputSchema`),需独立定义
|
||||
|
||||
- [x] 实现 `parseToolName` 工具名解析函数 — 将工具名拆分为可搜索的 token 列表
|
||||
- 位置: `src/services/toolSearch/toolIndex.ts`,在接口定义之后
|
||||
- 从 `ToolSearchTool.ts:132-161` 的 `parseToolName` 逻辑提取并适配为独立函数:
|
||||
```typescript
|
||||
export function parseToolName(name: string): { parts: string[]; full: string; isMcp: boolean }
|
||||
```
|
||||
- MCP 工具(`mcp__` 前缀): 去掉前缀后按 `__` 和 `_` 拆分,结果示例 `mcp__github__create_issue` → `["github", "create", "issue"]`
|
||||
- 内置工具: CamelCase 拆分 + 下划线拆分,结果示例 `NotebookEditTool` → `["notebook", "edit", "tool"]`
|
||||
- 原因: 工具名是搜索的高权重信号,需要拆分为有意义的关键词 token
|
||||
|
||||
- [x] 实现 `buildToolIndex` 索引构建函数 — 从 `Tool[]` 数组构建完整的 TF-IDF 索引
|
||||
- 位置: `src/services/toolSearch/toolIndex.ts`,在 `parseToolName` 之后
|
||||
- 函数签名:`export async function buildToolIndex(tools: Tool[]): Promise<ToolIndexEntry[]>`
|
||||
- 导入依赖:从 `localSearch.ts` 导入 `tokenizeAndStem`、`computeWeightedTf`、`computeIdf`、`cosineSimilarity`
|
||||
- 核心逻辑:
|
||||
1. 过滤出延迟工具(调用 `isDeferredTool`,从 `@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js` 导入)
|
||||
2. 对每个延迟工具,调用 `tool.prompt()` 获取描述文本(构造一个 mock 的 `getToolPermissionContext` 返回空权限上下文,`tools` 传原始工具列表,`agents` 传空数组)
|
||||
3. 调用 `parseToolName(tool.name)` 获取工具名 token
|
||||
4. 调用 `tokenizeAndStem` 对 `name parts`、`searchHint`、`description` 分别分词
|
||||
5. 调用 `computeWeightedTf` 按权重计算 TF 向量
|
||||
6. 读取 `tool.inputJSONSchema ?? (tool.inputSchema ? zodToJsonSchema(tool.inputSchema) : undefined)` 作为 `inputSchema`
|
||||
7. 组装 `ToolIndexEntry` 条目
|
||||
8. 对全部条目调用 `computeIdf` 计算 IDF,将 TF 向量乘以 IDF 得到最终 TF-IDF 向量
|
||||
- 返回构建好的索引数组
|
||||
- 原因: 索引构建是搜索的前提,需要从 Tool 对象提取文本并计算 TF-IDF 向量
|
||||
|
||||
- [x] 实现 `searchTools` 搜索函数 — 按任务描述查询最匹配的工具
|
||||
- 位置: `src/services/toolSearch/toolIndex.ts`,在 `buildToolIndex` 之后
|
||||
- 函数签名:`export function searchTools(query: string, index: ToolIndexEntry[], limit?: number): ToolSearchResult[]`
|
||||
- 定义返回类型:
|
||||
```typescript
|
||||
export interface ToolSearchResult {
|
||||
name: string
|
||||
description: string
|
||||
searchHint: string | undefined
|
||||
score: number
|
||||
isMcp: boolean
|
||||
isDeferred: boolean
|
||||
inputSchema: object | undefined
|
||||
}
|
||||
```
|
||||
- 核心逻辑(参照 `localSearch.ts:searchSkills` L383-443 的模式):
|
||||
1. 对 query 调用 `tokenizeAndStem` 分词
|
||||
2. 计算 query 的 TF-IDF 向量(TF 归一化 + IDF 乘法)
|
||||
3. 对索引中每个条目计算 `cosineSimilarity(queryTfIdf, entry.tfVector)`
|
||||
4. CJK bigram 过滤:若 query 包含 CJK token 且匹配数 < 2 且无 ASCII 匹配,则分数置零(复用 `CJK_MIN_BIGRAM_MATCHES = 2` 常量)
|
||||
5. 工具名完全包含加分:若 query 小写化后包含工具的 `normalizedName`,分数取 `Math.max(score, 0.75)`
|
||||
6. 过滤 `score >= TOOL_SEARCH_DISPLAY_MIN_SCORE` 的结果
|
||||
7. 按分数降序排列,截取前 `limit` 条(默认 5)
|
||||
- 原因: 搜索函数是工具发现的核心入口,提供给 ToolSearchTool 和预取管道调用
|
||||
|
||||
- [x] 实现模块级索引缓存和增量更新 — 避免每次搜索都全量重建索引
|
||||
- 位置: `src/services/toolSearch/toolIndex.ts`,在 `searchTools` 之后
|
||||
- 定义模块级缓存变量:
|
||||
```typescript
|
||||
let cachedIndex: ToolIndexEntry[] | null = null
|
||||
let cachedToolNames: string | null = null
|
||||
```
|
||||
- 实现 `getToolIndex` 缓存包装函数:签名 `export async function getToolIndex(tools: Tool[]): Promise<ToolIndexEntry[]>`
|
||||
- 缓存 key 为延迟工具名排序后的字符串
|
||||
- 当工具名集合变化时(MCP 连接/断开),自动重建索引
|
||||
- 缓存未命中时调用 `buildToolIndex`
|
||||
- 实现 `clearToolIndexCache` 清除函数:签名 `export function clearToolIndexCache(): void`
|
||||
- 原因: 索引构建涉及异步 `tool.prompt()` 调用,缓存避免重复计算;增量更新通过比较工具名集合实现
|
||||
|
||||
- [x] 为 `toolIndex.ts` 核心逻辑编写单元测试
|
||||
- 测试文件: `src/services/toolSearch/__tests__/toolIndex.test.ts`
|
||||
- 测试框架: `bun:test`(与 `localSearch.test.ts` 一致)
|
||||
- 测试场景:
|
||||
- `parseToolName` — MCP 工具名 `mcp__github__create_issue` 拆分为 `["github", "create", "issue"]`,`isMcp: true`
|
||||
- `parseToolName` — 内置工具名 `NotebookEditTool` 拆分为 `["notebook", "edit", "tool"]`,`isMcp: false`
|
||||
- `buildToolIndex` — 传入包含延迟工具的 mock Tool 数组,返回正确数量的 `ToolIndexEntry`,每个条目的 `tokens` 非空、`tfVector` 非空
|
||||
- `searchTools` — 英文查询 `"schedule cron job"` 能匹配含 `searchHint: "schedule a recurring or one-shot prompt"` 的工具,返回分数 > 0 且排名第一
|
||||
- `searchTools` — CJK 查询能匹配含中文描述的工具(参照 `localSearch.test.ts` 的 CJK 测试模式)
|
||||
- `searchTools` — 空查询返回空数组
|
||||
- `searchTools` — 无匹配结果返回空数组
|
||||
- `getToolIndex` — 相同工具列表两次调用返回同一缓存引用
|
||||
- `clearToolIndexCache` — 调用后 `getToolIndex` 重新构建索引
|
||||
- Mock 构造: 创建 `Partial<Tool>` 类型的 mock 工具,设置 `name`、`searchHint`、`prompt()`(返回固定描述字符串)、`inputSchema`(mock Zod schema 或 undefined)、`isMcp`、`shouldDefer`、`alwaysLoad` 等字段
|
||||
- 运行命令: `bun test src/services/toolSearch/__tests__/toolIndex.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
- [x] 验证 `localSearch.ts` 三个函数已导出
|
||||
- `grep -c "export function computeWeightedTf\|export function computeIdf\|export function cosineSimilarity" src/services/skillSearch/localSearch.ts`
|
||||
- 预期: 输出 3
|
||||
|
||||
- [x] 验证 `toolIndex.ts` 文件存在且导出正确
|
||||
- `grep -c "export function\|export interface\|export type" src/services/toolSearch/toolIndex.ts`
|
||||
- 预期: 至少 6(ToolIndexEntry, ToolSearchResult, parseToolName, buildToolIndex, searchTools, getToolIndex, clearToolIndexCache)
|
||||
|
||||
- [x] 验证 TypeScript 编译无错误
|
||||
- `bunx tsc --noEmit src/services/toolSearch/toolIndex.ts 2>&1 | head -20`
|
||||
- 预期: 无错误输出
|
||||
|
||||
- [x] 验证单元测试通过
|
||||
- `bun test src/services/toolSearch/__tests__/toolIndex.test.ts 2>&1 | tail -10`
|
||||
- 预期: 输出包含 "pass" 且无 "fail"
|
||||
|
||||
- [x] 验证 `localSearch.ts` 原有测试未回归
|
||||
- `bun test src/services/skillSearch/__tests__/localSearch.test.ts 2>&1 | tail -10`
|
||||
- 预期: 所有测试通过,无回归
|
||||
|
||||
**认知变更:**
|
||||
- [x] [CLAUDE.md] `src/services/skillSearch/localSearch.ts` 中的 `computeWeightedTf`、`computeIdf`、`cosineSimilarity` 已导出,供 `toolIndex.ts` 复用。修改这些函数时需同步检查工具索引的测试
|
||||
---
|
||||
### Task 3: ExecuteTool 执行工具
|
||||
|
||||
**背景:**
|
||||
[业务语境] — 新建 ExecuteTool 作为跨 API provider 的统一工具执行入口。当模型通过 ToolSearchTool 发现延迟工具后,使用 ExecuteTool 以 `tool_name` + `params` 的方式调用该工具,替代仅 Anthropic 支持的 `tool_reference` 机制。
|
||||
[修改原因] — 当前项目无 ExecuteTool,延迟工具无法在非 Anthropic provider(OpenAI/Gemini/Grok)下被模型调用。
|
||||
[上下游影响] — 本 Task 依赖 Task 1(`EXECUTE_TOOL_NAME` 常量、`CORE_TOOLS` 集合、`isDeferredTool` 判定)。本 Task 的输出(ExecuteTool 工具实例)被 Task 4(ToolSearchTool 搜索增强)和 `src/tools.ts`(工具注册)依赖。
|
||||
|
||||
**涉及文件:**
|
||||
- 新建: `packages/builtin-tools/src/tools/ExecuteTool/constants.ts`
|
||||
- 新建: `packages/builtin-tools/src/tools/ExecuteTool/prompt.ts`
|
||||
- 新建: `packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts`
|
||||
- 修改: `packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts`(导入 `EXECUTE_TOOL_NAME`,在 `isDeferredTool` 中排除 ExecuteTool)
|
||||
- 新建: `packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 创建 ExecuteTool 常量文件
|
||||
- 位置: 新建 `packages/builtin-tools/src/tools/ExecuteTool/constants.ts`
|
||||
- 内容:
|
||||
```typescript
|
||||
export const EXECUTE_TOOL_NAME = 'ExecuteTool'
|
||||
```
|
||||
- 原因: 与 `ToolSearchTool/constants.ts` 中的 `TOOL_SEARCH_TOOL_NAME` 保持一致的模式,供 `isDeferredTool`、工具注册等处引用
|
||||
|
||||
- [x] 创建 ExecuteTool prompt 文件
|
||||
- 位置: 新建 `packages/builtin-tools/src/tools/ExecuteTool/prompt.ts`
|
||||
- 从 `./constants.js` 导入 `EXECUTE_TOOL_NAME`
|
||||
- 导出 `DESCRIPTION` 常量(一句话描述)和 `getPrompt()` 函数
|
||||
- `getPrompt()` 返回完整 prompt 文本,包含:
|
||||
- 功能说明:接受 `tool_name` + `params`,在全局工具注册表中查找目标工具并委托执行
|
||||
- 使用场景:当通过 ToolSearch 发现延迟工具后,使用此工具调用该工具
|
||||
- 输入说明:`tool_name` 是目标工具名称(如 "CronCreate"、"mcp__server__action"),`params` 是传递给目标工具的参数对象
|
||||
- 错误处理:工具不存在或参数无效时返回清晰的错误信息
|
||||
- 原因: 与 `ToolSearchTool/prompt.ts` 的 `getPrompt()` 模式保持一致,将 prompt 逻辑与工具实现分离
|
||||
|
||||
- [x] 创建 ExecuteTool 主实现文件
|
||||
- 位置: 新建 `packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts`
|
||||
- 依赖导入:
|
||||
- `z` from `zod/v4`
|
||||
- `buildTool`, `findToolByName`, `type Tool`, `type ToolDef`, `type ToolUseContext`, `type ToolResult` from `src/Tool.js`
|
||||
- `lazySchema` from `src/utils/lazySchema.js`
|
||||
- `DESCRIPTION`, `getPrompt`, `EXECUTE_TOOL_NAME` from `./prompt.js`
|
||||
- `EXECUTE_TOOL_NAME` from `./constants.js`
|
||||
- `isToolSearchEnabledOptimistic` from `src/utils/toolSearch.js`
|
||||
- 定义 `inputSchema`: `z.object({ tool_name: z.string().describe('...'), params: z.record(z.unknown()).describe('...') })`
|
||||
- 定义 `outputSchema`: `z.object({ result: z.unknown(), tool_name: z.string() })`
|
||||
- 使用 `buildTool` 构建 `ExecuteTool`,`satisfies ToolDef<InputSchema, OutputSchema>`
|
||||
- 关键属性:
|
||||
- `name: EXECUTE_TOOL_NAME`
|
||||
- `searchHint: 'execute run invoke call a deferred tool by name with parameters'`
|
||||
- `isConcurrencySafe() { return false }`(委托执行的工具是否并发安全取决于目标工具,保守设为 false)
|
||||
- `maxResultSizeChars: 100_000`(与 ToolSearchTool 和 MCPTool 一致)
|
||||
- `description()` 返回 `DESCRIPTION`
|
||||
- `prompt()` 返回 `getPrompt()`
|
||||
- `call(input, context)` 核心逻辑:
|
||||
1. 从 `context.options.tools` 中通过 `findToolByName(tools, input.tool_name)` 查找目标工具
|
||||
2. 目标工具不存在时,返回 `{ data: { result: null, tool_name: input.tool_name }, newMessages: [错误提示 user message] }`,错误信息格式:`Tool "${input.tool_name}" not found. Use ToolSearch to discover available tools.`
|
||||
3. 目标工具存在时,调用 `targetTool.checkPermissions(input.params as any, context)` 获取权限结果
|
||||
4. 权限检查结果为 `behavior: 'deny'` 时,返回权限拒绝信息
|
||||
5. 权限检查通过后,调用 `targetTool.call(input.params as any, context, ...)` 委托执行,透传 context、canUseTool、parentMessage、onProgress 参数(`call` 签名为 `call(args, context, canUseTool, parentMessage, onProgress?)`,从 ExecuteTool 自身的 `call` 参数中获取后三个参数并传递给目标工具)
|
||||
6. 返回目标工具的执行结果,附加 `tool_name` 字段用于追踪
|
||||
- `checkPermissions()` 返回 `{ behavior: 'passthrough', message: 'ExecuteTool delegates permission to the target tool.' }`,与 MCPTool 的权限透传模式一致
|
||||
- `renderToolUseMessage(input)` 返回格式化字符串:`Executing ${input.tool_name}...`,用于 UI 展示
|
||||
- `userFacingName()` 返回 `'ExecuteTool'`
|
||||
- `mapToolResultToToolResultBlockParam(content, toolUseID)` 返回标准 tool_result 格式
|
||||
- `isEnabled()` 返回 `isToolSearchEnabledOptimistic()`,与 ToolSearchTool 联动启用
|
||||
- `isReadOnly()` 返回 `false`(执行的工具可能执行写操作)
|
||||
- 原因: 采用与 MCPTool 相同的 `buildTool` + `satisfies ToolDef` 模式,确保类型安全和框架一致性。权限透传采用 `passthrough` 策略,由目标工具自行决定权限逻辑
|
||||
|
||||
- [x] 在 `isDeferredTool` 中排除 ExecuteTool
|
||||
- 位置: `packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` 的 `isDeferredTool` 函数内,在 `if (tool.name === TOOL_SEARCH_TOOL_NAME) return false` 之后(~L71)
|
||||
- 新增导入: `import { EXECUTE_TOOL_NAME } from '../ExecuteTool/constants.js'`
|
||||
- 插入: `if (tool.name === EXECUTE_TOOL_NAME) return false`
|
||||
- 原因: ExecuteTool 是核心入口工具,必须在初始化时可用,不能被延迟加载
|
||||
|
||||
- [x] 为 ExecuteTool 编写单元测试
|
||||
- 测试文件: `packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts`
|
||||
- 测试场景:
|
||||
- 正常执行: 构造一个 mock 工具注册到 tools 列表中,调用 ExecuteTool 传入该工具名和合法参数,预期目标工具的 `call` 被调用且返回结果包含 `tool_name`
|
||||
- 工具不存在: 传入不存在的 `tool_name`,预期返回错误信息且 `result` 为 null
|
||||
- 权限拒绝: mock 目标工具的 `checkPermissions` 返回 `{ behavior: 'deny', message: 'denied' }`,预期 ExecuteTool 返回权限拒绝信息
|
||||
- isEnabled 联动: 验证 `ExecuteTool.isEnabled()` 依赖 `isToolSearchEnabledOptimistic()` 的返回值
|
||||
- searchHint 存在: 验证 `ExecuteTool.searchHint` 包含关键词 "execute" 和 "tool"
|
||||
- 运行命令: `bun test packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
- [x] 验证常量文件正确导出 EXECUTE_TOOL_NAME
|
||||
- `grep -n 'EXECUTE_TOOL_NAME' packages/builtin-tools/src/tools/ExecuteTool/constants.ts`
|
||||
- 预期: 输出包含 `export const EXECUTE_TOOL_NAME = 'ExecuteTool'`
|
||||
- [x] 验证 prompt 文件正确导出 DESCRIPTION 和 getPrompt
|
||||
- `grep -n 'export' packages/builtin-tools/src/tools/ExecuteTool/prompt.ts`
|
||||
- 预期: 输出包含 `DESCRIPTION` 和 `getPrompt` 的导出
|
||||
- [x] 验证 ExecuteTool 主文件使用 buildTool 构建且 satisfies ToolDef
|
||||
- `grep -n 'buildTool\|satisfies ToolDef' packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts`
|
||||
- 预期: 输出同时包含 `buildTool` 和 `satisfies ToolDef`
|
||||
- [x] 验证 isDeferredTool 正确排除 ExecuteTool
|
||||
- `grep -n 'EXECUTE_TOOL_NAME' packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts`
|
||||
- 预期: 输出包含 EXECUTE_TOOL_NAME 的导入和 `isDeferredTool` 中的排除逻辑
|
||||
- [x] 验证单元测试通过
|
||||
- `bun test packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts`
|
||||
- 预期: 所有测试用例通过,无错误
|
||||
|
||||
---
|
||||
|
||||
### Task 4: ToolSearchTool 搜索增强
|
||||
|
||||
**背景:**
|
||||
[业务语境] — 本 Task 在现有 ToolSearchTool 上叠加 TF-IDF 搜索路径、`discover:` 查询模式和文本模式输出,使模型能通过自然语言描述发现延迟工具,并在 `tool_reference` 不可用时仍能获取工具信息。
|
||||
[修改原因] — 当前 ToolSearchTool 仅支持关键词搜索(`searchToolsWithKeywords`),缺少语义匹配能力;`mapToolResultToToolResultBlockParam` 仅返回 `tool_reference` 块,不支持非 Anthropic provider;缺少纯发现模式供模型了解工具能力。
|
||||
[上下游影响] — 本 Task 依赖 Task 1(`isDeferredTool` 白名单制判定)和 Task 2(`buildToolIndex`、`searchTools`、`getToolIndex`)。本 Task 的输出(增强后的 ToolSearchTool)被 Task 5(预取管道)和 Task 6(UI 推荐)间接依赖。
|
||||
|
||||
**涉及文件:**
|
||||
- 修改: `packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts`
|
||||
- 修改: `packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts`
|
||||
- 新建: `packages/builtin-tools/src/tools/ToolSearchTool/__tests__/ToolSearchTool.test.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 在 `ToolSearchTool.ts` 中新增 TF-IDF 搜索相关 import — 为并行搜索和结果合并做准备
|
||||
- 位置: `packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts` 文件顶部 import 区域(L18 之前,现有 import 块之后)
|
||||
- 新增 import:
|
||||
```typescript
|
||||
import { getToolIndex, searchTools } from 'src/services/toolSearch/toolIndex.js'
|
||||
import type { ToolSearchResult } from 'src/services/toolSearch/toolIndex.js'
|
||||
import { modelSupportsToolReference } from 'src/utils/toolSearch.js'
|
||||
```
|
||||
- 新增权重常量(import 区域之后、`inputSchema` 定义之前):
|
||||
```typescript
|
||||
const KEYWORD_WEIGHT = Number(process.env.TOOL_SEARCH_WEIGHT_KEYWORD ?? '0.4')
|
||||
const TFIDF_WEIGHT = Number(process.env.TOOL_SEARCH_WEIGHT_TFIDF ?? '0.6')
|
||||
```
|
||||
- 原因: TF-IDF 搜索函数和模型能力判断函数分别定义在 `src/` 下,需显式 import。权重常量支持环境变量调优。
|
||||
|
||||
- [x] 在 `ToolSearchTool.ts` 的 `call` 方法中增加 `discover:` 查询模式分支 — 纯发现搜索,不触发延迟加载
|
||||
- 位置: `ToolSearchTool.ts` 的 `call` 方法内,在 `selectMatch` 正则匹配之后(~L363)、关键词搜索之前(~L408)
|
||||
- 在 `selectMatch` 分支之后插入 `discover:` 分支:
|
||||
```typescript
|
||||
// Check for discover: prefix — pure discovery search.
|
||||
// Returns tool info (name + description + schema) as text,
|
||||
// does NOT trigger deferred tool loading.
|
||||
const discoverMatch = query.match(/^discover:(.+)$/i)
|
||||
if (discoverMatch) {
|
||||
const discoverQuery = discoverMatch[1]!.trim()
|
||||
const index = await getToolIndex(deferredTools)
|
||||
const tfIdfResults = searchTools(discoverQuery, index, max_results)
|
||||
// discover 模式返回文本格式的工具信息
|
||||
const textResults = tfIdfResults.map(r => {
|
||||
let line = `**${r.name}** (score: ${r.score.toFixed(2)})\n${r.description}`
|
||||
if (r.inputSchema) {
|
||||
line += `\nSchema: ${JSON.stringify(r.inputSchema)}`
|
||||
}
|
||||
return line
|
||||
})
|
||||
const text = textResults.length > 0
|
||||
? `Found ${textResults.length} tools:\n${textResults.join('\n\n')}`
|
||||
: 'No matching deferred tools found'
|
||||
logSearchOutcome(tfIdfResults.map(r => r.name), 'keyword')
|
||||
return buildSearchResult(tfIdfResults.map(r => r.name), query, deferredTools.length)
|
||||
}
|
||||
```
|
||||
- 更新 `logSearchOutcome` 的 `queryType` 参数: `discover` 模式使用 `'keyword'` 类型(与关键词搜索共用类型,避免修改分析事件的枚举)
|
||||
- 原因: `discover:` 模式让模型能了解延迟工具的能力(名称 + 描述 + schema),而不触发 schema 注入,适用于规划阶段或信息收集场景
|
||||
|
||||
- [x] 在 `ToolSearchTool.ts` 的 `call` 方法中实现关键词搜索与 TF-IDF 搜索的并行执行和结果合并
|
||||
- 位置: `ToolSearchTool.ts` 的 `call` 方法内,替换当前关键词搜索逻辑(L408-L433)
|
||||
- 替换原有关键词搜索段为并行搜索 + 合并逻辑:
|
||||
```typescript
|
||||
// Keyword search + TF-IDF search in parallel
|
||||
const [keywordMatches, index] = await Promise.all([
|
||||
searchToolsWithKeywords(query, deferredTools, tools, max_results),
|
||||
getToolIndex(deferredTools),
|
||||
])
|
||||
const tfIdfResults = searchTools(query, index, max_results)
|
||||
|
||||
// Merge results: keyword score * 0.4 + TF-IDF score * 0.6
|
||||
const mergedScores = new Map<string, number>()
|
||||
// Add keyword results (assign scores inversely proportional to rank)
|
||||
keywordMatches.forEach((name, rank) => {
|
||||
const score = (keywordMatches.length - rank) / keywordMatches.length
|
||||
mergedScores.set(name, (mergedScores.get(name) ?? 0) + score * KEYWORD_WEIGHT)
|
||||
})
|
||||
// Add TF-IDF results
|
||||
tfIdfResults.forEach(result => {
|
||||
mergedScores.set(result.name, (mergedScores.get(result.name) ?? 0) + result.score * TFIDF_WEIGHT)
|
||||
})
|
||||
|
||||
// Sort by merged score, take top-N
|
||||
const matches = [...mergedScores.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, max_results)
|
||||
.map(([name]) => name)
|
||||
```
|
||||
- 保留后续的 `logForDebugging`、`logSearchOutcome`、空结果 pending servers 逻辑和 `buildSearchResult` 调用不变
|
||||
- 原因: 并行执行避免串行延迟;加权合并综合关键词精确匹配和 TF-IDF 语义匹配的优势(TF-IDF 权重更高,因为其语义能力更强)
|
||||
|
||||
- [x] 修改 `mapToolResultToToolResultBlockParam` 方法,增加文本模式输出 — 当 `tool_reference` 不可用时返回文本格式工具信息
|
||||
- 位置: `ToolSearchTool.ts` 的 `mapToolResultToToolResultBlockParam` 方法(L444-L469)
|
||||
- 新增方法参数 `context` 用于获取当前模型信息: 将 `mapToolResultToToolResultBlockParam(content, toolUseID)` 签名改为 `mapToolResultToToolResultBlockParam(content, toolUseID, context?)`,其中 `context` 类型为 `{ mainLoopModel?: string } | undefined`
|
||||
- 在方法体中,`content.matches.length === 0` 分支保持不变
|
||||
- 在返回 `tool_reference` 块之前,插入 `tool_reference` 支持检查:
|
||||
```typescript
|
||||
const supportsToolRef = context?.mainLoopModel
|
||||
? modelSupportsToolReference(context.mainLoopModel)
|
||||
: true // 默认假设支持(向后兼容)
|
||||
if (!supportsToolRef) {
|
||||
// 文本模式: 返回工具名称列表
|
||||
return {
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseID,
|
||||
content: `Found ${content.matches.length} tool(s): ${content.matches.join(', ')}. Use ExecuteTool with tool_name and params to invoke.`,
|
||||
}
|
||||
}
|
||||
```
|
||||
- 保留原有 `tool_reference` 返回逻辑作为默认路径
|
||||
- 原因: 非 Anthropic provider(OpenAI/Gemini/Grok)不支持 `tool_reference` beta 特性,需要回退到文本模式输出,引导模型使用 ExecuteTool
|
||||
|
||||
- [x] 更新 `ToolSearchTool/prompt.ts` 的 PROMPT 文本,增加 `discover:` 模式和 TF-IDF 搜索说明
|
||||
- 位置: `packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts` 的 `PROMPT_TAIL` 常量(L44-L51)
|
||||
- 在 `Query forms:` 部分追加 `discover:` 模式说明:
|
||||
```typescript
|
||||
const PROMPT_TAIL = ` ... (保留现有内容) ...
|
||||
|
||||
Query forms:
|
||||
- "select:Read,Edit,Grep" — fetch these exact tools by name
|
||||
- "discover:schedule cron job" — pure discovery, returns tool info (name, description, schema) without loading. Use when you want to understand available tools before deciding which to invoke.
|
||||
- "notebook jupyter" — keyword search, up to max_results best matches
|
||||
- "+slack send" — require "slack" in the name, rank by remaining terms`
|
||||
- 原因: 模型需要知道 `discover:` 模式的存在和语义,才能正确使用该功能
|
||||
|
||||
- [x] 为 ToolSearchTool 搜索增强编写单元测试
|
||||
- 测试文件: `packages/builtin-tools/src/tools/ToolSearchTool/__tests__/ToolSearchTool.test.ts`(新建)
|
||||
- 测试框架: `bun:test`(与 `DiscoverSkillsTool.test.ts` 一致)
|
||||
- 测试场景:
|
||||
- `discover:` 前缀解析: 传入 `query: "discover:send notification"` 调用 `ToolSearchTool.call()`,验证返回结果中 `matches` 非空且包含预期工具名(通过 mock `getToolIndex` 和 `searchTools`)
|
||||
- `select:` 前缀保持不变: 传入 `query: "select:SomeTool"` 调用 `ToolSearchTool.call()`,验证返回结果中 `matches` 包含 `"SomeTool"`(mock `findToolByName` 返回对应工具)
|
||||
- 关键词搜索 + TF-IDF 合并: mock `searchToolsWithKeywords` 返回 `["ToolA", "ToolB"]`,mock `searchTools` 返回 `[{name: "ToolB", score: 0.9}, {name: "ToolC", score: 0.8}]`,验证合并后 `matches` 包含 `"ToolB"`(两路均有)、`"ToolA"`(仅关键词)、`"ToolC"`(仅 TF-IDF),且 `"ToolB"` 排名靠前
|
||||
- 文本模式输出: 调用 `mapToolResultToToolResultBlockParam` 传入 `context: { mainLoopModel: 'claude-3-haiku-20240307' }`,验证返回内容为文本格式(包含 "Found" 和 "ExecuteTool"),而非 `tool_reference` 块
|
||||
- tool_reference 模式输出: 调用 `mapToolResultToToolResultBlockParam` 传入 `context: { mainLoopModel: 'claude-sonnet-4-20250514' }`,验证返回内容包含 `type: 'tool_reference'` 块
|
||||
- 向后兼容: 调用 `mapToolResultToToolResultBlockParam` 不传 `context` 参数,验证默认返回 `tool_reference` 块(向后兼容)
|
||||
- 空结果处理: 传入不匹配的查询,验证返回结果中 `matches` 为空数组
|
||||
- Mock 策略: 使用 `bun:test` 的 `mock` 函数 mock `src/services/toolSearch/toolIndex.js` 的 `getToolIndex` 和 `searchTools`,mock `src/utils/toolSearch.js` 的 `modelSupportsToolReference`
|
||||
- 运行命令: `bun test packages/builtin-tools/src/tools/ToolSearchTool/__tests__/ToolSearchTool.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
|
||||
- [x] 验证 TF-IDF 搜索 import 已添加
|
||||
- `grep -n "getToolIndex\|searchTools\|modelSupportsToolReference" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts`
|
||||
- 预期: 输出包含 `getToolIndex`、`searchTools`、`modelSupportsToolReference` 的 import 行
|
||||
|
||||
- [x] 验证 `discover:` 模式分支已添加到 `call` 方法
|
||||
- `grep -n "discoverMatch\|discover:" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts`
|
||||
- 预期: 输出包含 `discoverMatch` 正则匹配和 `discover:` 分支逻辑
|
||||
|
||||
- [x] 验证关键词搜索与 TF-IDF 搜索并行执行
|
||||
- `grep -n "Promise.all" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts`
|
||||
- 预期: 输出包含 `Promise.all` 调用,参数包含 `searchToolsWithKeywords` 和 `getToolIndex`
|
||||
|
||||
- [x] 验证结果合并逻辑使用加权求和
|
||||
- `grep -n "KEYWORD_WEIGHT\|TFIDF_WEIGHT" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts`
|
||||
- 预期: 输出包含权重常量定义和在合并逻辑中的使用
|
||||
|
||||
- [x] 验证 `mapToolResultToToolResultBlockParam` 增加了文本模式分支
|
||||
- `grep -n "supportsToolRef\|ExecuteTool" packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts`
|
||||
- 预期: 输出包含 `modelSupportsToolReference` 调用和 "ExecuteTool" 文本回退
|
||||
|
||||
- [x] 验证 prompt.ts 包含 `discover:` 模式说明
|
||||
- `grep -n "discover:" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts`
|
||||
- 预期: 输出包含 `discover:` 模式的文档说明
|
||||
|
||||
- [x] 验证 TypeScript 编译无错误
|
||||
- `bunx tsc --noEmit --pretty 2>&1 | head -30`
|
||||
- 预期: 无新增类型错误
|
||||
|
||||
- [x] 运行新增单元测试
|
||||
- `bun test packages/builtin-tools/src/tools/ToolSearchTool/__tests__/ToolSearchTool.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**认知变更:**
|
||||
- [x] [CLAUDE.md] `ToolSearchTool.mapToolResultToToolResultBlockParam` 新增可选第三个参数 `context?: { mainLoopModel?: string }`,用于判断当前模型是否支持 `tool_reference`。不支持时回退到文本输出,引导模型使用 ExecuteTool。调用方(`src/services/api/claude.ts` 的 tool_result 处理逻辑)需传入 context 参数。
|
||||
|
||||
### Task 5: 基础设施层验收
|
||||
|
||||
**前置条件:**
|
||||
- Task 1-4 全部完成
|
||||
- 构建环境: `bun run build` 可用
|
||||
|
||||
**端到端验证:**
|
||||
|
||||
1. ✅ 运行完整测试套件确保无回归
|
||||
- `bun test src/constants/__tests__/tools.test.ts src/services/toolSearch/__tests__/toolIndex.test.ts packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.test.ts packages/builtin-tools/src/tools/ToolSearchTool/__tests__/DiscoverSearch.test.ts 2>&1`
|
||||
- 预期: 全部测试通过
|
||||
- 失败排查: 检查各 Task 的测试步骤,确认 import 路径和 mock 配置正确
|
||||
|
||||
2. ✅ 验证 TypeScript 类型检查通过
|
||||
- `bunx tsc --noEmit --pretty 2>&1 | grep -i "error" | head -20`
|
||||
- 预期: 无新增类型错误
|
||||
- 失败排查: 检查 Task 1-4 中新增/修改文件的 import 路径和类型签名
|
||||
|
||||
3. ✅ 验证 CORE_TOOLS 常量被正确使用
|
||||
- `grep -rn "CORE_TOOLS" src/ packages/builtin-tools/src/ --include="*.ts" 2>/dev/null`
|
||||
- 预期: 在 `tools.ts`、`prompt.ts`(isDeferredTool)、`toolIndex.ts` 中被引用
|
||||
- 失败排查: 检查 Task 1 和 Task 2 的 import 步骤
|
||||
|
||||
4. ✅ 验证 isDeferredTool 白名单制生效
|
||||
- `grep -A5 "export function isDeferredTool" packages/builtin-tools/src/tools/ToolSearchTool/prompt.ts`
|
||||
- 预期: 函数体包含 `CORE_TOOLS.has(tool.name)`,不包含旧的 `shouldDefer`、`feature(` 逻辑
|
||||
- 失败排查: 检查 Task 1 的重构步骤
|
||||
|
||||
5. ✅ 验证构建产物正确
|
||||
- `bun run build 2>&1 | tail -5`
|
||||
- 预期: 构建成功,输出 dist/cli.js
|
||||
- 失败排查: 检查新增文件的 import 路径是否兼容 Bun.build splitting
|
||||
587
spec/feature_20260508_F001_tool-search/spec-plan-2.md
Normal file
587
spec/feature_20260508_F001_tool-search/spec-plan-2.md
Normal file
@@ -0,0 +1,587 @@
|
||||
# Tool Search 执行计划(二)— 集成层
|
||||
|
||||
**目标:** 将基础设施层的组件集成到系统中——系统提示词增强、工具注册、预取管道、用户推荐 UI
|
||||
|
||||
**技术栈:** TypeScript, React (Ink), Bun, Zod
|
||||
|
||||
**设计文档:** spec/feature_20260508_F001_tool-search/spec-design.md
|
||||
|
||||
**前置:** spec-plan-1.md(Task 1-4)已完成
|
||||
|
||||
## 改动总览
|
||||
|
||||
- 在系统提示词添加 ToolSearch + ExecuteTool 引导指令,tools.ts 注册 ExecuteTool,toolSearch.ts 更新过时注释;新建预取管道 prefetch.ts 集成到 attachments.ts 和 query.ts(复用 skill prefetch 模式);新建 ToolSearchHint.tsx Ink 组件集成到 REPL
|
||||
- Task 5(系统提示词与注册)是 Task 6/7 的前置;Task 6(预取管道)被 Task 7(UI)依赖
|
||||
- 关键决策:预取管道完全复用 skill prefetch 的触发/消费模式;UI 组件参考 PluginHintMenu 模式
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
### Task 0: 环境准备(轻量)
|
||||
|
||||
**背景:**
|
||||
Plan 1 的环境验证已完成,此处仅需确认 Plan 1 的产出文件可用。
|
||||
|
||||
**执行步骤:**
|
||||
- [x] 确认 Plan 1 产出文件存在
|
||||
- `ls src/constants/tools.ts src/services/toolSearch/toolIndex.ts packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts 2>&1`
|
||||
- 预期: 所有文件存在
|
||||
|
||||
**检查步骤:**
|
||||
- [x] Plan 1 核心常量可被引用
|
||||
- `grep "CORE_TOOLS" src/constants/tools.ts | head -3`
|
||||
- 预期: 输出包含 CORE_TOOLS 定义
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 系统提示词与工具注册
|
||||
|
||||
**背景:**
|
||||
[业务语境] — 本 Task 将 Task 3 创建的 ExecuteTool 注册到系统工具池中,并在系统提示词中添加 ToolSearch + ExecuteTool 的使用引导,确保模型知道如何发现和调用延迟工具。
|
||||
[修改原因] — 当前系统提示词(L192)仅提到"延迟工具必须通过 ToolSearch 或 DiscoverSkills 加载",缺少 ExecuteTool 的引导。`src/tools.ts` 的 `getAllBaseTools()` 中未注册 ExecuteTool。`src/utils/toolSearch.ts` 的 `isToolSearchEnabled()` 和 `isToolSearchEnabledOptimistic()` 内部已通过 `isDeferredTool` 间接使用 `CORE_TOOLS`(Task 1 重构后),需确认无遗留的 `shouldDefer` 直接引用。
|
||||
[上下游影响] — 本 Task 依赖 Task 1(`CORE_TOOLS`、`isDeferredTool` 白名单制)和 Task 3(ExecuteTool 工具包创建完成)。本 Task 的输出被 Task 6(预取管道)和 Task 7(用户推荐 UI)依赖。
|
||||
|
||||
**涉及文件:**
|
||||
- 修改: `src/constants/prompts.ts`
|
||||
- 修改: `src/tools.ts`
|
||||
- 修改: `src/utils/toolSearch.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 在 `src/constants/prompts.ts` 中添加 ToolSearch + ExecuteTool 引导指令到系统提示词
|
||||
- 位置: `src/constants/prompts.ts` 的 `getSimpleSystemSection()` 函数内,在 L192 的延迟工具说明条目之后
|
||||
- 当前 L192 内容为:
|
||||
```
|
||||
`Your visible tool list is partial by design — many tools (deferred tools, skills, MCP resources) must be loaded via ToolSearch or DiscoverSkills before you can call them. Before telling the user that a capability is unavailable, search for a tool or skill that covers it. Only state something is unavailable after the search returns no match.`,
|
||||
```
|
||||
- 在此条目之后(L193 之前)插入新条目:
|
||||
```typescript
|
||||
`When you need a capability that isn't in your available tools, use ToolSearch to discover and load it. ToolSearch can find all deferred tools by keyword or task description. After discovering a tool, use ExecuteTool to invoke it with the appropriate parameters. Common deferred tools include: CronTools (scheduling), WorktreeTools (git isolation), SnipTool (context management), DiscoverSkills (skill search), MCP resource tools, and many more. Always search first rather than assuming a capability is unavailable.`,
|
||||
```
|
||||
- 在文件顶部 import 区域新增:
|
||||
```typescript
|
||||
import { EXECUTE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ExecuteTool/constants.js'
|
||||
```
|
||||
- 注意: `TOOL_SEARCH_TOOL_NAME` 已通过 `src/constants/tools.ts` 的 import 链路导入(L25 `import { TOOL_SEARCH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'`),无需重复导入。但需在 `prompts.ts` 中新增 `EXECUTE_TOOL_NAME` 的 import(当前文件中无此 import,经 grep 确认)。
|
||||
- 原因: 模型需要明确知道 ExecuteTool 的存在和用法,否则发现延迟工具后不知道如何调用
|
||||
|
||||
- [x] 在 `src/tools.ts` 的 `getAllBaseTools()` 中注册 ExecuteTool
|
||||
- 位置: `src/tools.ts` 的 `getAllBaseTools()` 函数内,在 L272 `...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : [])` 之后
|
||||
- 在文件顶部 import 区域(L84 附近,ToolSearchTool import 之后)新增:
|
||||
```typescript
|
||||
import { ExecuteTool } from '@claude-code-best/builtin-tools/tools/ExecuteTool/ExecuteTool.js'
|
||||
```
|
||||
- 将 L272:
|
||||
```typescript
|
||||
...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
|
||||
```
|
||||
- 修改为:
|
||||
```typescript
|
||||
...(isToolSearchEnabledOptimistic() ? [ToolSearchTool, ExecuteTool] : []),
|
||||
```
|
||||
- 原因: ExecuteTool 与 ToolSearchTool 联动启用,在相同条件块中注册确保两者同时可用或同时不可用
|
||||
|
||||
- [x] 在 `src/utils/toolSearch.ts` 中更新模块文档注释,移除过时的 `shouldDefer` 引用
|
||||
- 位置: `src/utils/toolSearch.ts` 文件顶部模块文档注释(L1-L7)
|
||||
- 当前 L4 内容为:
|
||||
```
|
||||
`
|
||||
* When enabled, deferred tools (MCP and shouldDefer tools) are sent with
|
||||
* defer_loading: true and discovered via ToolSearchTool rather than being
|
||||
* loaded upfront.
|
||||
```
|
||||
- 修改为:
|
||||
```
|
||||
`
|
||||
* When enabled, deferred tools (all non-core tools) are sent with
|
||||
* defer_loading: true and discovered via ToolSearchTool rather than being
|
||||
* loaded upfront. Core tools are defined in CORE_TOOLS (src/constants/tools.ts).
|
||||
```
|
||||
- 位置: `src/utils/toolSearch.ts` 的 `ToolSearchMode` 类型文档注释(L155-L156)
|
||||
- 当前内容为:
|
||||
```
|
||||
`
|
||||
* Tool search mode. Determines how deferrable tools (MCP + shouldDefer) are
|
||||
* surfaced:
|
||||
```
|
||||
- 修改为:
|
||||
```
|
||||
`
|
||||
* Tool search mode. Determines how deferred tools (all non-core tools)
|
||||
* are surfaced:
|
||||
```
|
||||
- 位置: `src/utils/toolSearch.ts` 的 `getToolSearchMode()` 函数文档注释(L170)
|
||||
- 当前内容为:
|
||||
```
|
||||
`
|
||||
* (unset) tst (default: always defer MCP and shouldDefer tools)
|
||||
```
|
||||
- 修改为:
|
||||
```
|
||||
`
|
||||
* (unset) tst (default: always defer non-core tools)
|
||||
```
|
||||
- 位置: `src/utils/toolSearch.ts` 的 `getToolSearchMode()` 函数末尾 return 注释(L197)
|
||||
- 当前内容为:
|
||||
```typescript
|
||||
return 'tst' // default: always defer MCP and shouldDefer tools
|
||||
```
|
||||
- 修改为:
|
||||
```typescript
|
||||
return 'tst' // default: always defer non-core tools
|
||||
```
|
||||
- 注意: `shouldDefer` 在此文件中仅出现在注释中(L4, L155, L170, L197),无任何运行时引用。`isDeferredTool` 函数从 `@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js` 导入(L24),Task 1 已将其重构为白名单制,此处无需修改函数调用。
|
||||
- 原因: Task 1 将 `isDeferredTool` 重构为白名单制后,`shouldDefer` 概念已过时。更新注释保持文档与实现一致。
|
||||
|
||||
- [x] 为 Task 5 的三个修改点编写单元测试
|
||||
- 测试文件: `src/__tests__/toolSearchIntegration.test.ts`(新建)
|
||||
- 测试场景:
|
||||
- `getSystemPrompt` 包含 ExecuteTool 引导: 调用 `getSystemPrompt(mockTools, model)` 后,结果字符串中包含 "ExecuteTool" 和 "ToolSearch" 关键词
|
||||
- `getAllBaseTools` 包含 ExecuteTool 当 tool search 启用时: mock `isToolSearchEnabledOptimistic` 返回 `true`,验证 `getAllBaseTools()` 返回的工具列表中包含 `name: 'ExecuteTool'` 的工具
|
||||
- `getAllBaseTools` 不包含 ExecuteTool 当 tool search 禁用时: mock `isToolSearchEnabledOptimistic` 返回 `false`,验证 `getAllBaseTools()` 返回的工具列表中不包含 `name: 'ExecuteTool'` 的工具
|
||||
- `getAllBaseTools` 中 ExecuteTool 紧随 ToolSearchTool: 验证在 tool search 启用时,ExecuteTool 在工具列表中的位置紧跟 ToolSearchTool
|
||||
- Mock 策略: 使用 `bun:test` 的 `mock` 函数 mock `src/utils/toolSearch.js` 的 `isToolSearchEnabledOptimistic`
|
||||
- 运行命令: `bun test src/__tests__/toolSearchIntegration.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
|
||||
- [x] 验证系统提示词包含 ExecuteTool 引导
|
||||
- `grep -n "ExecuteTool" src/constants/prompts.ts`
|
||||
- 预期: 至少 2 行(import + 引导文本)
|
||||
|
||||
- [x] 验证 ExecuteTool 已注册到 getAllBaseTools
|
||||
- `grep -n "ExecuteTool" src/tools.ts`
|
||||
- 预期: 至少 2 行(import + 注册)
|
||||
|
||||
- [x] 验证 ExecuteTool 与 ToolSearchTool 在同一条件块中注册
|
||||
- `grep -A1 "isToolSearchEnabledOptimistic" src/tools.ts | grep -c "ExecuteTool"`
|
||||
- 预期: 输出 1(ExecuteTool 在 isToolSearchEnabledOptimistic 条件块中)
|
||||
|
||||
- [x] 验证 toolSearch.ts 中无运行时 shouldDefer 引用(仅注释)
|
||||
- `grep -n "shouldDefer" src/utils/toolSearch.ts`
|
||||
- 预期: 无输出或仅在注释中出现
|
||||
|
||||
- [x] 验证 TypeScript 编译无错误
|
||||
- `bunx tsc --noEmit --pretty 2>&1 | head -30`
|
||||
- 预期: 无新增类型错误
|
||||
|
||||
- [x] 运行新增单元测试
|
||||
- `bun test src/__tests__/toolSearchIntegration.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
- [x] 验证现有 tools.test.ts 未回归
|
||||
- `bun test src/__tests__/tools.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 预取管道
|
||||
|
||||
**背景:**
|
||||
[业务语境] — 本 Task 实现工具搜索预取管道,在用户输入后异步触发 TF-IDF 工具搜索,将推荐结果以 attachment 消息注入 API 请求,使模型在每轮对话中自动获得最相关的延迟工具提示。
|
||||
[修改原因] — 当前项目仅实现了 skill 搜索的预取管道(`skillSearch/prefetch.ts`),缺少工具维度的预取。工具预取需复用 skill prefetch 的集成模式(turn-0 阻塞式 + inter-turn 异步式),但使用独立的 attachment type(`tool_discovery`)和独立的搜索函数(`toolIndex.searchTools`)。
|
||||
[上下游影响] — 本 Task 依赖 Task 2(`toolIndex.ts` 的 `getToolIndex` 和 `searchTools`)。本 Task 的输出(`prefetch.ts` 模块和集成代码)被 Task 7(用户推荐 UI)间接依赖,UI 组件需要消费预取结果来渲染推荐提示条。
|
||||
|
||||
**涉及文件:**
|
||||
- 新建: `src/services/toolSearch/prefetch.ts`
|
||||
- 修改: `src/utils/attachments.ts`
|
||||
- 修改: `src/query.ts`
|
||||
|
||||
**执行步骤:**
|
||||
|
||||
- [x] 新建 `src/services/toolSearch/prefetch.ts`,定义 `ToolDiscoveryResult` 类型和 `tool_discovery` attachment 构建函数
|
||||
- 位置: 新建文件 `src/services/toolSearch/prefetch.ts`,文件开头
|
||||
- 导入依赖:
|
||||
```typescript
|
||||
import type { Attachment } from '../../utils/attachments.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import type { Tool } from '../../Tool.js'
|
||||
import { getToolIndex, searchTools } from './toolIndex.js'
|
||||
import type { ToolSearchResult } from './toolIndex.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
```
|
||||
- 定义 `ToolDiscoveryResult` 类型:
|
||||
```typescript
|
||||
export type ToolDiscoveryResult = {
|
||||
name: string
|
||||
description: string
|
||||
searchHint: string | undefined
|
||||
score: number
|
||||
isMcp: boolean
|
||||
isDeferred: boolean
|
||||
inputSchema: object | undefined
|
||||
}
|
||||
```
|
||||
- 定义 `buildToolDiscoveryAttachment` 函数:
|
||||
```typescript
|
||||
function buildToolDiscoveryAttachment(
|
||||
tools: ToolDiscoveryResult[],
|
||||
trigger: 'assistant_turn' | 'user_input',
|
||||
queryText: string,
|
||||
durationMs: number,
|
||||
indexSize: number,
|
||||
): Attachment {
|
||||
return {
|
||||
type: 'tool_discovery',
|
||||
tools,
|
||||
trigger,
|
||||
queryText: queryText.slice(0, 200),
|
||||
durationMs,
|
||||
indexSize,
|
||||
} as Attachment
|
||||
}
|
||||
```
|
||||
- 原因: `tool_discovery` 作为独立的 attachment type 与 `skill_discovery` 并列,数据结构不同(工具无 `shortId`/`autoLoaded`/`content`/`path`/`gap`,增加 `searchHint`/`isMcp`/`isDeferred`/`inputSchema`),不能复用 `skill_discovery` 类型
|
||||
|
||||
- [x] 实现 `startToolSearchPrefetch` 异步预取函数 — inter-turn 场景,在 query loop 中异步触发
|
||||
- 位置: `src/services/toolSearch/prefetch.ts`,在 `buildToolDiscoveryAttachment` 之后
|
||||
- 函数签名:
|
||||
```typescript
|
||||
export async function startToolSearchPrefetch(
|
||||
tools: Tool[],
|
||||
messages: Message[],
|
||||
): Promise<Attachment[]>
|
||||
```
|
||||
- 核心逻辑(参照 `skillSearch/prefetch.ts:startSkillDiscoveryPrefetch` L249-296 的模式):
|
||||
1. 调用 `extractQueryFromMessages(null, messages)` 提取用户查询文本(复用 `skillSearch/prefetch.ts` 导出的 `extractQueryFromMessages` 函数,该函数已导出且逻辑通用)
|
||||
2. `queryText` 为空时返回 `[]`
|
||||
3. 记录 `startedAt = Date.now()`
|
||||
4. 调用 `getToolIndex(tools)` 获取缓存的工具索引
|
||||
5. 调用 `searchTools(queryText, index, 3)` 搜索 top-3 工具(预取场景限制 3 条,减少 token 开销)
|
||||
6. 过滤会话内已发现的工具(定义模块级 `discoveredToolsThisSession: Set<string>`,与 skill prefetch 的 `discoveredThisSession` 独立)
|
||||
7. 结果为空时返回 `[]`
|
||||
8. 记录 `logForDebugging` 日志
|
||||
9. 返回 `[buildToolDiscoveryAttachment(filteredResults, 'assistant_turn', queryText, durationMs, index.length)]`
|
||||
10. catch 块返回 `[]`(fire-and-forget,不向上传播错误)
|
||||
- 原因: 异步预取不阻塞主流程,与 skill prefetch 保持一致的错误处理策略(静默失败)
|
||||
|
||||
- [x] 实现 `getTurnZeroToolSearchPrefetch` 同步获取函数 — turn-0 场景,用户首次输入时阻塞式获取
|
||||
- 位置: `src/services/toolSearch/prefetch.ts`,在 `startToolSearchPrefetch` 之后
|
||||
- 函数签名:
|
||||
```typescript
|
||||
export async function getTurnZeroToolSearchPrefetch(
|
||||
input: string,
|
||||
tools: Tool[],
|
||||
): Promise<Attachment | null>
|
||||
```
|
||||
- 核心逻辑(参照 `skillSearch/prefetch.ts:getTurnZeroSkillDiscovery` L308-356 的模式):
|
||||
1. `input` 为空时返回 `null`
|
||||
2. 记录 `startedAt = Date.now()`
|
||||
3. 调用 `getToolIndex(tools)` 获取工具索引
|
||||
4. 调用 `searchTools(input, index, 3)` 搜索 top-3 工具
|
||||
5. 结果为空时返回 `null`
|
||||
6. 将结果工具名加入 `discoveredToolsThisSession`
|
||||
7. 记录 `logForDebugging` 日志
|
||||
8. 返回 `buildToolDiscoveryAttachment(results, 'user_input', input, durationMs, index.length)`
|
||||
9. catch 块返回 `null`
|
||||
- 原因: turn-0 是唯一的阻塞式入口,因为此时没有其他计算可以隐藏预取延迟。与 skill prefetch 保持一致的设计
|
||||
|
||||
- [x] 实现 `collectToolSearchPrefetch` 结果收集函数 — 等待异步预取完成并收集结果
|
||||
- 位置: `src/services/toolSearch/prefetch.ts`,在 `getTurnZeroToolSearchPrefetch` 之后
|
||||
- 函数签名:
|
||||
```typescript
|
||||
export async function collectToolSearchPrefetch(
|
||||
pending: Promise<Attachment[]>,
|
||||
): Promise<Attachment[]>
|
||||
```
|
||||
- 核心逻辑(与 `skillSearch/prefetch.ts:collectSkillDiscoveryPrefetch` L298-306 完全一致):
|
||||
```typescript
|
||||
try {
|
||||
return await pending
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
```
|
||||
- 原因: 包装 Promise,确保预取失败时返回空数组而非抛出异常
|
||||
|
||||
- [x] 在 `src/utils/attachments.ts` 中注册 `tool_discovery` attachment type — 扩展 Attachment 联合类型
|
||||
- 位置: `src/utils/attachments.ts` 的 `Attachment` 类型定义中,在 `skill_discovery` 类型分支(L534-L555)之后
|
||||
- 新增 import(文件顶部 import 区域):
|
||||
```typescript
|
||||
import type { ToolDiscoveryResult } from '../services/toolSearch/prefetch.js'
|
||||
```
|
||||
- 在 `skill_discovery` 分支后追加 `tool_discovery` 类型:
|
||||
```typescript
|
||||
| {
|
||||
type: 'tool_discovery'
|
||||
tools: ToolDiscoveryResult[]
|
||||
trigger: 'assistant_turn' | 'user_input'
|
||||
queryText: string
|
||||
durationMs: number
|
||||
indexSize: number
|
||||
}
|
||||
```
|
||||
- 原因: `createAttachmentMessage` 接收 `Attachment` 类型参数,必须将 `tool_discovery` 注册到联合类型中才能通过类型检查
|
||||
|
||||
- [x] 在 `src/utils/attachments.ts` 中集成 turn-0 工具预取 — 在 skill discovery 附件之后添加 tool discovery 附件
|
||||
- 位置: `src/utils/attachments.ts` 的 `getAttachmentMessages` 函数中,在 skill discovery 的 `maybe('skill_discovery', ...)` 调用块(L818-L831)之后
|
||||
- 新增条件 require 模块(与 `skillSearchModules` 模式一致,在文件顶部 ~L92 `skillSearchModules` 定义之后):
|
||||
```typescript
|
||||
const toolSearchModules = feature('EXPERIMENTAL_TOOL_SEARCH')
|
||||
? {
|
||||
prefetch:
|
||||
require('../services/toolSearch/prefetch.js') as typeof import('../services/toolSearch/prefetch.js'),
|
||||
}
|
||||
: null
|
||||
```
|
||||
- 在 skill discovery 的 spread 数组中追加 tool discovery 附件(在 `]` 闭合 `maybe('skill_discovery', ...)` 之后,在外层 spread `...(feature('EXPERIMENTAL_SKILL_SEARCH') &&` 的 `]` 之前):
|
||||
```typescript
|
||||
...(feature('EXPERIMENTAL_TOOL_SEARCH') &&
|
||||
toolSearchModules &&
|
||||
!options?.skipSkillDiscovery
|
||||
? [
|
||||
maybe('tool_discovery', async () => {
|
||||
if (suppressNextDiscovery) {
|
||||
return []
|
||||
}
|
||||
const result =
|
||||
await toolSearchModules.prefetch.getTurnZeroToolSearchPrefetch(
|
||||
input,
|
||||
context.options.tools ?? [],
|
||||
)
|
||||
return result ? [result] : []
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
```
|
||||
- 注意: `suppressNextDiscovery` 与 skill discovery 共用同一个标志(skill expansion 路径不应触发工具发现,语义一致)
|
||||
- 原因: turn-0 预取与 skill discovery 共享同一集成点(`getAttachmentMessages`),两者互不干扰,各自生成独立 attachment
|
||||
|
||||
- [x] 在 `src/query.ts` 中集成 inter-turn 工具预取触发 — 在 skill prefetch 之后异步启动工具预取
|
||||
- 位置: `src/query.ts` 文件顶部 conditional require 区域(~L68-70 `skillPrefetch` 定义之后)
|
||||
- 新增 conditional require:
|
||||
```typescript
|
||||
const toolSearchPrefetch = feature('EXPERIMENTAL_TOOL_SEARCH')
|
||||
? (require('./services/toolSearch/prefetch.js') as typeof import('./services/toolSearch/prefetch.js'))
|
||||
: null
|
||||
```
|
||||
- 位置: `src/query.ts` 的 `queryLoop` 函数中,在 `pendingSkillPrefetch` 定义(L480-484)之后
|
||||
- 新增工具预取触发:
|
||||
```typescript
|
||||
const pendingToolPrefetch = toolSearchPrefetch?.startToolSearchPrefetch(
|
||||
state.tools ?? [],
|
||||
messages,
|
||||
)
|
||||
```
|
||||
- 原因: 与 skill prefetch 保持相同的触发时机(每轮迭代开始时异步启动),两者并行执行互不阻塞
|
||||
|
||||
- [x] 在 `src/query.ts` 中集成工具预取结果消费 — 在 skill prefetch 收集之后收集工具预取结果
|
||||
- 位置: `src/query.ts` 的 `queryLoop` 函数中,在 skill prefetch 结果消费块(L1910-L1918)之后
|
||||
- 新增工具预取结果消费:
|
||||
```typescript
|
||||
if (toolSearchPrefetch && pendingToolPrefetch) {
|
||||
const toolAttachments =
|
||||
await toolSearchPrefetch.collectToolSearchPrefetch(pendingToolPrefetch)
|
||||
for (const att of toolAttachments) {
|
||||
const msg = createAttachmentMessage(att)
|
||||
yield msg
|
||||
toolResults.push(msg)
|
||||
}
|
||||
}
|
||||
```
|
||||
- 原因: 与 skill prefetch 结果消费保持一致的位置和模式(post-tools 阶段注入),确保预取结果在本轮工具执行完成后、下一轮模型调用前注入
|
||||
|
||||
- [x] 为 `prefetch.ts` 核心逻辑编写单元测试
|
||||
- 测试文件: `src/services/toolSearch/__tests__/prefetch.test.ts`(新建)
|
||||
- 测试框架: `bun:test`
|
||||
- 测试场景:
|
||||
- `startToolSearchPrefetch` — 正常调用: 构造 mock Tool 数组和 mock messages,mock `getToolIndex` 返回固定索引,mock `searchTools` 返回匹配结果,验证返回的 `Attachment[]` 包含 `type: 'tool_discovery'` 且 `tools` 非空、`trigger` 为 `'assistant_turn'`
|
||||
- `startToolSearchPrefetch` — 空查询: messages 中无用户文本内容,验证返回空数组
|
||||
- `startToolSearchPrefetch` — 无匹配: `searchTools` 返回空数组,验证返回空数组
|
||||
- `startToolSearchPrefetch` — 异常安全: mock `getToolIndex` 抛出异常,验证返回空数组(不抛出)
|
||||
- `startToolSearchPrefetch` — 会话去重: 连续两次调用传入相同工具名,第二次返回空数组(已被 `discoveredToolsThisSession` 过滤)
|
||||
- `getTurnZeroToolSearchPrefetch` — 正常调用: 传入有效 input 和 mock tools,验证返回非 null 的 `Attachment`,`trigger` 为 `'user_input'`
|
||||
- `getTurnZeroToolSearchPrefetch` — 空输入: 传入空字符串,验证返回 null
|
||||
- `getTurnZeroToolSearchPrefetch` — 无匹配: `searchTools` 返回空数组,验证返回 null
|
||||
- `collectToolSearchPrefetch` — 正常收集: 传入 resolved promise,验证返回对应 attachment 数组
|
||||
- `collectToolSearchPrefetch` — 异常安全: 传入 rejected promise,验证返回空数组
|
||||
- `buildToolDiscoveryAttachment` — 返回的 attachment 对象包含 `type: 'tool_discovery'`、`tools`、`trigger`、`queryText`、`durationMs`、`indexSize` 字段
|
||||
- Mock 策略: 使用 `bun:test` 的 `mock` 函数 mock `./toolIndex.js` 的 `getToolIndex` 和 `searchTools`;构造 `Partial<Tool>` 类型的 mock Tool 对象;构造包含 `{ type: 'user', content: 'test query' }` 的 mock Message 数组
|
||||
- 运行命令: `bun test src/services/toolSearch/__tests__/prefetch.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
|
||||
- [x] 验证 `prefetch.ts` 文件存在且导出正确
|
||||
- `grep -c "export async function\|export type" src/services/toolSearch/prefetch.ts`
|
||||
- 预期: 至少 5(startToolSearchPrefetch, getTurnZeroToolSearchPrefetch, collectToolSearchPrefetch, ToolDiscoveryResult, extractQueryFromMessages import)
|
||||
|
||||
- [x] 验证 `tool_discovery` 类型已注册到 Attachment 联合类型
|
||||
- `grep -n "tool_discovery" src/utils/attachments.ts`
|
||||
- 预期: 至少 2 行(类型定义 + maybe 调用)
|
||||
|
||||
- [x] 验证 `query.ts` 中工具预取触发和消费代码已添加
|
||||
- `grep -n "toolSearchPrefetch\|pendingToolPrefetch\|collectToolSearchPrefetch" src/query.ts`
|
||||
- 预期: 至少 6 行(conditional require + start 调用 + if + collect 调用 + yield)
|
||||
|
||||
- [x] 验证 `attachments.ts` 中 turn-0 工具预取已集成
|
||||
- `grep -n "getTurnZeroToolSearchPrefetch\|toolSearchModules" src/utils/attachments.ts`
|
||||
- 预期: 至少 3 行(conditional require + getTurnZero 调用 + toolSearchModules 使用)
|
||||
|
||||
- [x] 验证 TypeScript 编译无错误
|
||||
- `bunx tsc --noEmit --pretty 2>&1 | head -30`
|
||||
- 预期: 无新增类型错误
|
||||
|
||||
- [x] 验证单元测试通过
|
||||
- `bun test src/services/toolSearch/__tests__/prefetch.test.ts 2>&1 | tail -10`
|
||||
- 预期: 输出包含 "pass" 且无 "fail"
|
||||
|
||||
**认知变更:**
|
||||
- [x] [CLAUDE.md] `src/services/toolSearch/prefetch.ts` 的 `extractQueryFromMessages` 复用了 `src/services/skillSearch/prefetch.ts` 的同名导出函数。修改 `skillSearch/prefetch.ts` 的 `extractQueryFromMessages` 时需同步检查工具预取的行为。工具预取使用独立的 `discoveredToolsThisSession` Set,与 skill prefetch 的去重集合互不影响。
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 用户推荐 UI
|
||||
|
||||
**背景:**
|
||||
[业务语境] — 在 REPL 输入区域上方渲染工具推荐提示条,帮助用户了解哪些工具适合当前任务,提升工具发现体验
|
||||
[修改原因] — 当前缺少面向用户的工具推荐可视化,预取管道(Task 6)产出的匹配结果无法被用户感知
|
||||
[上下游影响] — 本 Task 消费 Task 6 `collectToolSearchPrefetch()` 的预取结果数据;本 Task 的组件挂载到 REPL.tsx 的对话框优先级系统中
|
||||
|
||||
**涉及文件:**
|
||||
- 新建: `src/components/ToolSearchHint.tsx`
|
||||
- 新建: `src/components/__tests__/ToolSearchHint.test.ts`
|
||||
- 修改: `src/screens/REPL.tsx`
|
||||
|
||||
**执行步骤:**
|
||||
- [x] 新建 `src/components/ToolSearchHint.tsx` — Ink 组件,渲染工具推荐提示条
|
||||
- 位置: 新建文件,参照 `src/components/ClaudeCodeHint/PluginHintMenu.tsx` 的结构模式
|
||||
- 组件签名:
|
||||
```typescript
|
||||
type ToolSearchHintItem = {
|
||||
name: string;
|
||||
description: string;
|
||||
score: number;
|
||||
};
|
||||
type Props = {
|
||||
tools: ToolSearchHintItem[];
|
||||
onSelect: (toolName: string) => void;
|
||||
onDismiss: () => void;
|
||||
};
|
||||
export function ToolSearchHint({ tools, onSelect, onDismiss }: Props): React.ReactNode;
|
||||
```
|
||||
- 使用 `PermissionDialog`(从 `src/components/permissions/PermissionDialog.js`)作为外层容器,title 设为 `"Tool Recommendation"`
|
||||
- 使用 `Select`(从 `src/components/CustomSelect/select.js`)渲染可选工具列表,每个选项格式为: `<工具名> — <描述截断至 60 字符> (score: 0.XX)`
|
||||
- 额外增加一个 "Dismiss" 选项(value: `'dismiss'`),排在选项列表末尾
|
||||
- `onSelect` 回调: 当用户选中某个工具时调用 `onDismiss()` 清除推荐,并调用 `onSelect(toolName)` 将工具名传递给 REPL 层追加到用户消息上下文
|
||||
- 30 秒自动 dismiss(复用 `PluginHintMenu` 的 `AUTO_DISMISS_MS = 30_000` 模式),通过 `setTimeout` + `useRef` 实现,超时调用 `onDismiss()`
|
||||
- `useEffect` 清理函数中 `clearTimeout` 防止内存泄漏
|
||||
- 原因: 遵循现有 UI 提示集成模式(PluginHintMenu),保证交互风格一致
|
||||
|
||||
- [x] 新建 `src/hooks/useToolSearchHint.ts` — 自定义 Hook,管理工具推荐状态与生命周期
|
||||
- 位置: 新建文件,参照 `src/hooks/useClaudeCodeHintRecommendation.tsx` 的状态管理模式
|
||||
- Hook 签名:
|
||||
```typescript
|
||||
type ToolSearchHintResult = {
|
||||
tools: ToolSearchHintItem[];
|
||||
visible: boolean;
|
||||
handleSelect: (toolName: string) => void;
|
||||
handleDismiss: () => void;
|
||||
};
|
||||
export function useToolSearchHint(): ToolSearchHintResult;
|
||||
```
|
||||
- 内部使用 `React.useSyncExternalStore` 订阅预取结果(从 Task 6 的 `src/services/toolSearch/prefetch.ts` 中导出的模块级缓存),subscribe 函数和 getSnapshot 函数从 prefetch 模块获取
|
||||
- `tools` 字段: 从预取结果中提取前 3 个工具,每个工具包含 `name`、`description`(截断至 60 字符)、`score`
|
||||
- `visible` 字段: 当 `tools` 非空且最高 score >= 0.15 时为 true
|
||||
- `handleSelect`: 记录用户选择(analytics 事件 `tengu_tool_search_hint_select`),然后清除推荐状态
|
||||
- `handleDismiss`: 记录 dismiss 事件(analytics 事件 `tengu_tool_search_hint_dismiss`),清除推荐状态
|
||||
- 清除推荐状态时调用 prefetch 模块的清除函数(`clearToolSearchPrefetchResults()`,由 Task 6 提供)
|
||||
- 原因: 将状态管理与 UI 渲染解耦,遵循现有 hook 模式(useClaudeCodeHintRecommendation)
|
||||
|
||||
- [x] 修改 `src/screens/REPL.tsx` — 集成 ToolSearchHint 组件到对话框优先级系统
|
||||
- 位置: `getFocusedInputDialog()` 函数(~L2377),在返回类型联合中新增 `'tool-search-hint'`
|
||||
- 在 `getFocusedInputDialog()` 函数体中,在 `plugin-hint` 判断(~L2446)之后、`desktop-upsell` 判断(~L2449)之前,新增一个优先级分支:
|
||||
```typescript
|
||||
if (allowDialogsWithAnimation && toolSearchHint.visible) return 'tool-search-hint';
|
||||
```
|
||||
- 位置: 文件顶部 import 区域(~L448,`PluginHintMenu` import 附近),新增 import:
|
||||
```typescript
|
||||
import { ToolSearchHint } from '../components/ToolSearchHint.js';
|
||||
import { useToolSearchHint } from '../hooks/useToolSearchHint.js';
|
||||
```
|
||||
- 位置: hook 调用区域(~L1038,`useClaudeCodeHintRecommendation` 调用之后),新增:
|
||||
```typescript
|
||||
const toolSearchHint = useToolSearchHint();
|
||||
```
|
||||
- 位置: JSX 渲染区域(~L6174,`PluginHintMenu` 渲染块之后),新增条件渲染块:
|
||||
```tsx
|
||||
{focusedInputDialog === 'tool-search-hint' && toolSearchHint.visible && (
|
||||
<ToolSearchHint
|
||||
tools={toolSearchHint.tools}
|
||||
onSelect={toolSearchHint.handleSelect}
|
||||
onDismiss={toolSearchHint.handleDismiss}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
- 原因: 遵循 REPL 的 focusedInputDialog 优先级系统,确保工具推荐提示在合适的时机显示,不阻塞高优先级对话框
|
||||
|
||||
- [x] 为 `ToolSearchHint` 组件和 `useToolSearchHint` hook 编写单元测试
|
||||
- 测试文件: `src/components/__tests__/ToolSearchHint.test.ts`
|
||||
- 测试场景:
|
||||
- 当 `tools` 数组为空时,`useToolSearchHint` 返回 `visible: false`
|
||||
- 当 `tools` 数组非空且最高 score >= 0.15 时,`useToolSearchHint` 返回 `visible: true` 且 `tools` 包含最多 3 个条目
|
||||
- 当最高 score < 0.15 时,`useToolSearchHint` 返回 `visible: false`
|
||||
- `handleDismiss` 调用后推荐状态被清除
|
||||
- `handleSelect` 调用后推荐状态被清除且回调被触发
|
||||
- 使用 `bun:test` 框架(与项目现有测试一致)
|
||||
- 运行命令: `bun test src/components/__tests__/ToolSearchHint.test.ts`
|
||||
- 预期: 所有测试通过
|
||||
|
||||
**检查步骤:**
|
||||
- [x] 验证新文件已创建且导出正确
|
||||
- `grep -c "export function ToolSearchHint" src/components/ToolSearchHint.tsx && grep -c "export function useToolSearchHint" src/hooks/useToolSearchHint.ts`
|
||||
- 预期: 两个 grep 均返回 1
|
||||
- [x] 验证 REPL.tsx 集成正确
|
||||
- `grep -c "ToolSearchHint" src/screens/REPL.tsx && grep -c "tool-search-hint" src/screens/REPL.tsx`
|
||||
- 预期: 两个 grep 均返回值 >= 2(import + hook + 渲染 + 优先级判断)
|
||||
- [x] 验证 TypeScript 编译无错误
|
||||
- `npx tsc --noEmit --pretty 2>&1 | grep -E "ToolSearchHint|useToolSearchHint" | head -5`
|
||||
- 预期: 无输出(无相关类型错误)
|
||||
- [x] 验证单元测试通过
|
||||
- `bun test src/components/__tests__/ToolSearchHint.test.ts`
|
||||
- 预期: 所有测试通过,无失败
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 全功能验收
|
||||
|
||||
**前置条件:**
|
||||
- Plan 1(Task 1-4)和 Plan 2(Task 5-7)全部完成
|
||||
- `bun run build` 可用
|
||||
|
||||
**端到端验证:**
|
||||
|
||||
1. 运行完整测试套件确保无回归
|
||||
- `bun test 2>&1 | tail -20`
|
||||
- 预期: 全部测试通过(包含 Plan 1 和 Plan 2 新增的所有测试文件)
|
||||
- 失败排查: 检查对应 Task 的测试步骤,确认 mock 配置和 import 路径
|
||||
|
||||
2. 运行 precheck 确保 typecheck + lint + test 全部通过
|
||||
- `bun run precheck 2>&1 | tail -20`
|
||||
- 预期: 零错误通过
|
||||
- 失败排查: 类型错误检查 import 路径;lint 错误检查格式;测试失败检查对应 Task
|
||||
|
||||
3. 验证系统提示词引导文本正确注入
|
||||
- `bun run dev -- --dump-system-prompt 2>&1 | grep -A5 "ToolSearch"`
|
||||
- 预期: 输出包含 "use ToolSearch to discover" 引导文本
|
||||
- 失败排查: 检查 Task 5 的 prompts.ts 修改
|
||||
|
||||
4. 验证 ExecuteTool 在工具列表中可见
|
||||
- `bun run dev -- --dump-system-prompt 2>&1 | grep "ExecuteTool"`
|
||||
- 预期: 输出包含 ExecuteTool 工具定义
|
||||
- 失败排查: 检查 Task 5 的 tools.ts 注册
|
||||
|
||||
5. 验证构建产物正确
|
||||
- `bun run build 2>&1 | tail -5`
|
||||
- 预期: 构建成功,输出 dist/cli.js
|
||||
- 失败排查: 检查新增文件的 import 是否兼容 Bun.build splitting
|
||||
|
||||
6. 验证延迟工具数量正确
|
||||
- `grep -c "isDeferredTool" src/utils/toolSearch.ts src/services/api/claude.ts packages/builtin-tools/src/tools/ToolSearchTool/ToolSearchTool.ts 2>/dev/null`
|
||||
- 预期: 所有调用点仍在使用 isDeferredTool(已被 Task 1 重构为白名单制)
|
||||
- 失败排查: 检查 Task 1 的 isDeferredTool 重构
|
||||
@@ -41,11 +41,7 @@ import { type Tools, type ToolUseContext, toolMatchesName } from './Tool.js'
|
||||
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||
import { SYNTHETIC_OUTPUT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js'
|
||||
import type { APIError } from '@anthropic-ai/sdk'
|
||||
import type {
|
||||
CompactMetadata,
|
||||
Message,
|
||||
SystemCompactBoundaryMessage,
|
||||
} from './types/message.js'
|
||||
import type { Message, SystemCompactBoundaryMessage } from './types/message.js'
|
||||
import type { OrphanedPermission } from './types/textInputTypes.js'
|
||||
import { createAbortController } from './utils/abortController.js'
|
||||
import type { AttributionState } from './utils/commitAttribution.js'
|
||||
@@ -1003,15 +999,6 @@ export class QueryEngine {
|
||||
uuid: msg.uuid,
|
||||
}
|
||||
}
|
||||
// Proactive truncation: prevent unbounded growth when API doesn't
|
||||
// return compact_boundary (e.g. third-party compat layers).
|
||||
if (feature('HISTORY_SNIP') && snipModule) {
|
||||
const truncated = snipModule.proactiveTruncate(this.mutableMessages)
|
||||
if (truncated !== this.mutableMessages) {
|
||||
this.mutableMessages.length = 0
|
||||
this.mutableMessages.push(...truncated)
|
||||
}
|
||||
}
|
||||
// Don't yield other system messages in headless mode
|
||||
break
|
||||
}
|
||||
@@ -1060,7 +1047,9 @@ export class QueryEngine {
|
||||
initialAppState.fastMode,
|
||||
),
|
||||
uuid: randomUUID(),
|
||||
errors: [`Reached maximum budget ($${maxBudgetUsd})`],
|
||||
errors: [
|
||||
`Reached maximum budget ($${maxBudgetUsd}). Increase the limit with --max-budget-usd or start a new session.`,
|
||||
],
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -391,7 +391,7 @@ export type Tool<
|
||||
*/
|
||||
aliases?: string[]
|
||||
/**
|
||||
* One-line capability phrase used by ToolSearch for keyword matching.
|
||||
* One-line capability phrase used by SearchExtraTools for keyword matching.
|
||||
* Helps the model find this tool via keyword search when it's deferred.
|
||||
* 3–10 words, no trailing period.
|
||||
* Prefer terms not already in the tool name (e.g. 'jupyter' for NotebookEdit).
|
||||
@@ -458,14 +458,14 @@ export type Tool<
|
||||
isLsp?: boolean
|
||||
/**
|
||||
* When true, this tool is deferred (sent with defer_loading: true) and requires
|
||||
* ToolSearch to be used before it can be called.
|
||||
* SearchExtraTools to be used before it can be called.
|
||||
*/
|
||||
readonly shouldDefer?: boolean
|
||||
/**
|
||||
* When true, this tool is never deferred — its full schema appears in the
|
||||
* initial prompt even when ToolSearch is enabled. For MCP tools, set via
|
||||
* initial prompt even when SearchExtraTools is enabled. For MCP tools, set via
|
||||
* `_meta['anthropic/alwaysLoad']`. Use for tools the model must see on
|
||||
* turn 1 without a ToolSearch round-trip.
|
||||
* turn 1 without a SearchExtraTools round-trip.
|
||||
*/
|
||||
readonly alwaysLoad?: boolean
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { getKairosActive } from '../bootstrap/state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
logEvent,
|
||||
logEventAsync,
|
||||
} from '../services/analytics/index.js'
|
||||
import { isInBundledMode } from '../utils/bundledMode.js'
|
||||
import { getBootstrapArgs, getScriptPath } from '../utils/cliLaunch.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { rcLog } from './rcDebugLog.js'
|
||||
|
||||
@@ -72,7 +72,6 @@ import type {
|
||||
SDKControlResponse,
|
||||
} from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { StdoutMessage } from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
|
||||
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
||||
import { setSessionMetadataChangedListener } from '../utils/sessionState.js'
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ import type {
|
||||
SDKControlResponse,
|
||||
} from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { StdoutMessage } from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
|
||||
|
||||
/**
|
||||
* StdoutMessage with optional session_id. The transport layer accepts
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* Companion display card — shown by /buddy (no args).
|
||||
* Mirrors official vc8 component: bordered box with sprite, stats, last reaction.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useInput } from '@anthropic/ink';
|
||||
import { renderSprite } from './sprites.js';
|
||||
|
||||
59
src/cli/__tests__/userFacingErrorMessages.test.ts
Normal file
59
src/cli/__tests__/userFacingErrorMessages.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
/**
|
||||
* Verify that user-facing error messages include actionable guidance.
|
||||
* These are pure string-formatting tests — no side effects.
|
||||
*/
|
||||
|
||||
describe('User-facing error messages', () => {
|
||||
test('budget exceeded message includes budget and guidance', () => {
|
||||
const maxBudgetUsd = 5.0
|
||||
const message = `Error: Exceeded USD budget ($${maxBudgetUsd}).\nTip: Increase the limit with --max-budget-usd or start a new session to continue.`
|
||||
|
||||
expect(message).toContain('Exceeded USD budget')
|
||||
expect(message).toContain('$5')
|
||||
expect(message).toContain('--max-budget-usd')
|
||||
expect(message).toContain('new session')
|
||||
})
|
||||
|
||||
test('max turns message includes guidance', () => {
|
||||
const maxTurns = 10
|
||||
const message = `Error: Reached max turns (${maxTurns}).\nTip: Increase the limit with --max-turns or continue in a new session.`
|
||||
|
||||
expect(message).toContain('max turns')
|
||||
expect(message).toContain('--max-turns')
|
||||
expect(message).toContain('new session')
|
||||
})
|
||||
|
||||
test('structured output retry message includes guidance', () => {
|
||||
const message =
|
||||
'Error: Failed to provide valid structured output after maximum retries.\nTip: Simplify your schema or check if the output format matches the expected structure.'
|
||||
|
||||
expect(message).toContain('structured output')
|
||||
expect(message).toContain('Simplify your schema')
|
||||
})
|
||||
|
||||
test('QueryEngine budget error includes actionable hint', () => {
|
||||
const maxBudgetUsd = 3.0
|
||||
const message = `Reached maximum budget ($${maxBudgetUsd}). Increase the limit with --max-budget-usd or start a new session.`
|
||||
|
||||
expect(message).toContain('maximum budget')
|
||||
expect(message).toContain('--max-budget-usd')
|
||||
expect(message).toContain('new session')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Onboarding security copy', () => {
|
||||
test('security heading uses friendly tone', () => {
|
||||
const heading = 'Before you start, keep in mind:'
|
||||
expect(heading).not.toContain('Security')
|
||||
expect(heading).toContain('Before you start')
|
||||
})
|
||||
|
||||
test('trust dialog copy is concise', () => {
|
||||
const body =
|
||||
'Is this a project you trust? (Your own code, a well-known open source project, or work from your team).'
|
||||
expect(body.length).toBeLessThan(120)
|
||||
expect(body).toContain('trust')
|
||||
})
|
||||
})
|
||||
@@ -68,13 +68,3 @@ export class TmuxEngine implements BgEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getTmuxInstallHint(): string {
|
||||
if (process.platform === 'darwin') {
|
||||
return 'Install with: brew install tmux'
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return 'tmux is not natively available on Windows. Consider using WSL.'
|
||||
}
|
||||
return 'Install with: sudo apt install tmux (or your package manager)'
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { stat } from 'fs/promises';
|
||||
import pMap from 'p-map';
|
||||
import { cwd } from 'process';
|
||||
import React from 'react';
|
||||
import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js';
|
||||
import { wrappedRender as render } from '@anthropic/ink';
|
||||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
|
||||
|
||||
@@ -112,7 +112,6 @@ import type {
|
||||
ModelInfo,
|
||||
SDKMessage,
|
||||
SDKUserMessage,
|
||||
SDKUserMessageReplay,
|
||||
PermissionResult,
|
||||
McpServerConfigForProcessTransport,
|
||||
McpServerStatus,
|
||||
@@ -961,14 +960,18 @@ export async function runHeadless(
|
||||
writeToStdout(`Execution error`)
|
||||
break
|
||||
case 'error_max_turns':
|
||||
writeToStdout(`Error: Reached max turns (${options.maxTurns})`)
|
||||
writeToStdout(
|
||||
`Error: Reached max turns (${options.maxTurns}).\nTip: Increase the limit with --max-turns or continue in a new session.`,
|
||||
)
|
||||
break
|
||||
case 'error_max_budget_usd':
|
||||
writeToStdout(`Error: Exceeded USD budget (${options.maxBudgetUsd})`)
|
||||
writeToStdout(
|
||||
`Error: Exceeded USD budget ($${options.maxBudgetUsd}).\nTip: Increase the limit with --max-budget-usd or start a new session to continue.`,
|
||||
)
|
||||
break
|
||||
case 'error_max_structured_output_retries':
|
||||
writeToStdout(
|
||||
`Error: Failed to provide valid structured output after maximum retries`,
|
||||
`Error: Failed to provide valid structured output after maximum retries.\nTip: Simplify your schema or check if the output format matches the expected structure.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5473,7 +5476,7 @@ function getStructuredIO(
|
||||
*/
|
||||
export async function handleOrphanedPermissionResponse({
|
||||
message,
|
||||
setAppState,
|
||||
setAppState: _setAppState,
|
||||
onEnqueued,
|
||||
handledToolUseIds,
|
||||
}: {
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
import chalk from 'chalk'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import {
|
||||
getLatestVersion,
|
||||
type InstallStatus,
|
||||
installGlobalPackage,
|
||||
} from 'src/utils/autoUpdater.js'
|
||||
import { regenerateCompletionCache } from 'src/utils/completionCache.js'
|
||||
import {
|
||||
getGlobalConfig,
|
||||
type InstallMethod,
|
||||
saveGlobalConfig,
|
||||
} from 'src/utils/config.js'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import { getDoctorDiagnostic } from 'src/utils/doctorDiagnostic.js'
|
||||
import { gracefulShutdown } from 'src/utils/gracefulShutdown.js'
|
||||
import {
|
||||
installOrUpdateClaudePackage,
|
||||
localInstallationExists,
|
||||
} from 'src/utils/localInstaller.js'
|
||||
import {
|
||||
installLatest as installLatestNative,
|
||||
removeInstalledSymlink,
|
||||
} from 'src/utils/nativeInstaller/index.js'
|
||||
import { getPackageManager } from 'src/utils/nativeInstaller/packageManagers.js'
|
||||
import { writeToStdout } from 'src/utils/process.js'
|
||||
import { gte } from 'src/utils/semver.js'
|
||||
import { getInitialSettings } from 'src/utils/settings/settings.js'
|
||||
|
||||
export async function update() {
|
||||
logEvent('tengu_update_check', {})
|
||||
writeToStdout(`Current version: ${MACRO.VERSION}\n`)
|
||||
|
||||
const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest'
|
||||
writeToStdout(`Checking for updates to ${channel} version...\n`)
|
||||
|
||||
logForDebugging('update: Starting update check')
|
||||
|
||||
// Run diagnostic to detect potential issues
|
||||
logForDebugging('update: Running diagnostic')
|
||||
const diagnostic = await getDoctorDiagnostic()
|
||||
logForDebugging(`update: Installation type: ${diagnostic.installationType}`)
|
||||
logForDebugging(
|
||||
`update: Config install method: ${diagnostic.configInstallMethod}`,
|
||||
)
|
||||
|
||||
// Check for multiple installations
|
||||
if (diagnostic.multipleInstallations.length > 1) {
|
||||
writeToStdout('\n')
|
||||
writeToStdout(chalk.yellow('Warning: Multiple installations found') + '\n')
|
||||
for (const install of diagnostic.multipleInstallations) {
|
||||
const current =
|
||||
diagnostic.installationType === install.type
|
||||
? ' (currently running)'
|
||||
: ''
|
||||
writeToStdout(`- ${install.type} at ${install.path}${current}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
// Display warnings if any exist
|
||||
if (diagnostic.warnings.length > 0) {
|
||||
writeToStdout('\n')
|
||||
for (const warning of diagnostic.warnings) {
|
||||
logForDebugging(`update: Warning detected: ${warning.issue}`)
|
||||
|
||||
// Don't skip PATH warnings - they're always relevant
|
||||
// The user needs to know that 'which claude' points elsewhere
|
||||
logForDebugging(`update: Showing warning: ${warning.issue}`)
|
||||
|
||||
writeToStdout(chalk.yellow(`Warning: ${warning.issue}\n`))
|
||||
|
||||
writeToStdout(chalk.bold(`Fix: ${warning.fix}\n`))
|
||||
}
|
||||
}
|
||||
|
||||
// Update config if installMethod is not set (but skip for package managers)
|
||||
const config = getGlobalConfig()
|
||||
if (
|
||||
!config.installMethod &&
|
||||
diagnostic.installationType !== 'package-manager'
|
||||
) {
|
||||
writeToStdout('\n')
|
||||
writeToStdout('Updating configuration to track installation method...\n')
|
||||
let detectedMethod: 'local' | 'native' | 'global' | 'unknown' = 'unknown'
|
||||
|
||||
// Map diagnostic installation type to config install method
|
||||
switch (diagnostic.installationType) {
|
||||
case 'npm-local':
|
||||
detectedMethod = 'local'
|
||||
break
|
||||
case 'native':
|
||||
detectedMethod = 'native'
|
||||
break
|
||||
case 'npm-global':
|
||||
detectedMethod = 'global'
|
||||
break
|
||||
default:
|
||||
detectedMethod = 'unknown'
|
||||
}
|
||||
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
installMethod: detectedMethod,
|
||||
}))
|
||||
writeToStdout(`Installation method set to: ${detectedMethod}\n`)
|
||||
}
|
||||
|
||||
// Check if running from development build
|
||||
if (diagnostic.installationType === 'development') {
|
||||
writeToStdout('\n')
|
||||
writeToStdout(
|
||||
chalk.yellow('Warning: Cannot update development build') + '\n',
|
||||
)
|
||||
await gracefulShutdown(1)
|
||||
}
|
||||
|
||||
// Check if running from a package manager
|
||||
if (diagnostic.installationType === 'package-manager') {
|
||||
const packageManager = await getPackageManager()
|
||||
writeToStdout('\n')
|
||||
|
||||
if (packageManager === 'homebrew') {
|
||||
writeToStdout('Claude is managed by Homebrew.\n')
|
||||
const latest = await getLatestVersion(channel)
|
||||
if (latest && !gte(MACRO.VERSION, latest)) {
|
||||
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
|
||||
writeToStdout('\n')
|
||||
writeToStdout('To update, run:\n')
|
||||
writeToStdout(chalk.bold(' brew upgrade claude-code') + '\n')
|
||||
} else {
|
||||
writeToStdout('Claude is up to date!\n')
|
||||
}
|
||||
} else if (packageManager === 'winget') {
|
||||
writeToStdout('Claude is managed by winget.\n')
|
||||
const latest = await getLatestVersion(channel)
|
||||
if (latest && !gte(MACRO.VERSION, latest)) {
|
||||
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
|
||||
writeToStdout('\n')
|
||||
writeToStdout('To update, run:\n')
|
||||
writeToStdout(
|
||||
chalk.bold(' winget upgrade Anthropic.ClaudeCode') + '\n',
|
||||
)
|
||||
} else {
|
||||
writeToStdout('Claude is up to date!\n')
|
||||
}
|
||||
} else if (packageManager === 'apk') {
|
||||
writeToStdout('Claude is managed by apk.\n')
|
||||
const latest = await getLatestVersion(channel)
|
||||
if (latest && !gte(MACRO.VERSION, latest)) {
|
||||
writeToStdout(`Update available: ${MACRO.VERSION} → ${latest}\n`)
|
||||
writeToStdout('\n')
|
||||
writeToStdout('To update, run:\n')
|
||||
writeToStdout(chalk.bold(' apk upgrade claude-code') + '\n')
|
||||
} else {
|
||||
writeToStdout('Claude is up to date!\n')
|
||||
}
|
||||
} else {
|
||||
// pacman, deb, and rpm don't get specific commands because they each have
|
||||
// multiple frontends (pacman: yay/paru/makepkg, deb: apt/apt-get/aptitude/nala,
|
||||
// rpm: dnf/yum/zypper)
|
||||
writeToStdout('Claude is managed by a package manager.\n')
|
||||
writeToStdout('Please use your package manager to update.\n')
|
||||
}
|
||||
|
||||
await gracefulShutdown(0)
|
||||
}
|
||||
|
||||
// Check for config/reality mismatch (skip for package-manager installs)
|
||||
if (
|
||||
config.installMethod &&
|
||||
diagnostic.configInstallMethod !== 'not set' &&
|
||||
diagnostic.installationType !== 'package-manager'
|
||||
) {
|
||||
const runningType = diagnostic.installationType
|
||||
const configExpects = diagnostic.configInstallMethod
|
||||
|
||||
// Map installation types for comparison
|
||||
const typeMapping: Record<string, string> = {
|
||||
'npm-local': 'local',
|
||||
'npm-global': 'global',
|
||||
native: 'native',
|
||||
development: 'development',
|
||||
unknown: 'unknown',
|
||||
}
|
||||
|
||||
const normalizedRunningType = typeMapping[runningType] || runningType
|
||||
|
||||
if (
|
||||
normalizedRunningType !== configExpects &&
|
||||
configExpects !== 'unknown'
|
||||
) {
|
||||
writeToStdout('\n')
|
||||
writeToStdout(chalk.yellow('Warning: Configuration mismatch') + '\n')
|
||||
writeToStdout(`Config expects: ${configExpects} installation\n`)
|
||||
writeToStdout(`Currently running: ${runningType}\n`)
|
||||
writeToStdout(
|
||||
chalk.yellow(
|
||||
`Updating the ${runningType} installation you are currently using`,
|
||||
) + '\n',
|
||||
)
|
||||
|
||||
// Update config to match reality
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
installMethod: normalizedRunningType as InstallMethod,
|
||||
}))
|
||||
writeToStdout(
|
||||
`Config updated to reflect current installation method: ${normalizedRunningType}\n`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle native installation updates first
|
||||
if (diagnostic.installationType === 'native') {
|
||||
logForDebugging(
|
||||
'update: Detected native installation, using native updater',
|
||||
)
|
||||
try {
|
||||
const result = await installLatestNative(channel, true)
|
||||
|
||||
// Handle lock contention gracefully
|
||||
if (result.lockFailed) {
|
||||
const pidInfo = result.lockHolderPid
|
||||
? ` (PID ${result.lockHolderPid})`
|
||||
: ''
|
||||
writeToStdout(
|
||||
chalk.yellow(
|
||||
`Another Claude process${pidInfo} is currently running. Please try again in a moment.`,
|
||||
) + '\n',
|
||||
)
|
||||
await gracefulShutdown(0)
|
||||
}
|
||||
|
||||
if (!result.latestVersion) {
|
||||
process.stderr.write('Failed to check for updates\n')
|
||||
await gracefulShutdown(1)
|
||||
}
|
||||
|
||||
if (result.latestVersion === MACRO.VERSION) {
|
||||
writeToStdout(
|
||||
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
|
||||
)
|
||||
} else {
|
||||
writeToStdout(
|
||||
chalk.green(
|
||||
`Successfully updated from ${MACRO.VERSION} to version ${result.latestVersion}`,
|
||||
) + '\n',
|
||||
)
|
||||
await regenerateCompletionCache()
|
||||
}
|
||||
await gracefulShutdown(0)
|
||||
} catch (error) {
|
||||
process.stderr.write('Error: Failed to install native update\n')
|
||||
process.stderr.write(String(error) + '\n')
|
||||
process.stderr.write('Try running "claude doctor" for diagnostics\n')
|
||||
await gracefulShutdown(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to existing JS/npm-based update logic
|
||||
// Remove native installer symlink since we're not using native installation
|
||||
// But only if user hasn't migrated to native installation
|
||||
if (config.installMethod !== 'native') {
|
||||
await removeInstalledSymlink()
|
||||
}
|
||||
|
||||
logForDebugging('update: Checking npm registry for latest version')
|
||||
logForDebugging(`update: Package URL: ${MACRO.PACKAGE_URL}`)
|
||||
const npmTag = channel === 'stable' ? 'stable' : 'latest'
|
||||
const npmCommand = `npm view ${MACRO.PACKAGE_URL}@${npmTag} version`
|
||||
logForDebugging(`update: Running: ${npmCommand}`)
|
||||
const latestVersion = await getLatestVersion(channel)
|
||||
logForDebugging(
|
||||
`update: Latest version from npm: ${latestVersion || 'FAILED'}`,
|
||||
)
|
||||
|
||||
if (!latestVersion) {
|
||||
logForDebugging('update: Failed to get latest version from npm registry')
|
||||
process.stderr.write(chalk.red('Failed to check for updates') + '\n')
|
||||
process.stderr.write('Unable to fetch latest version from npm registry\n')
|
||||
process.stderr.write('\n')
|
||||
process.stderr.write('Possible causes:\n')
|
||||
process.stderr.write(' • Network connectivity issues\n')
|
||||
process.stderr.write(' • npm registry is unreachable\n')
|
||||
process.stderr.write(' • Corporate proxy/firewall blocking npm\n')
|
||||
if (MACRO.PACKAGE_URL && !MACRO.PACKAGE_URL.startsWith('@anthropic')) {
|
||||
process.stderr.write(
|
||||
' • Internal/development build not published to npm\n',
|
||||
)
|
||||
}
|
||||
process.stderr.write('\n')
|
||||
process.stderr.write('Try:\n')
|
||||
process.stderr.write(' • Check your internet connection\n')
|
||||
process.stderr.write(' • Run with --debug flag for more details\n')
|
||||
const packageName =
|
||||
MACRO.PACKAGE_URL ||
|
||||
(process.env.USER_TYPE === 'ant'
|
||||
? '@anthropic-ai/claude-cli'
|
||||
: '@anthropic-ai/claude-code')
|
||||
process.stderr.write(
|
||||
` • Manually check: npm view ${packageName} version\n`,
|
||||
)
|
||||
|
||||
process.stderr.write(' • Check if you need to login: npm whoami\n')
|
||||
await gracefulShutdown(1)
|
||||
}
|
||||
|
||||
// Check if versions match exactly, including any build metadata (like SHA)
|
||||
if (latestVersion === MACRO.VERSION) {
|
||||
writeToStdout(
|
||||
chalk.green(`Claude Code is up to date (${MACRO.VERSION})`) + '\n',
|
||||
)
|
||||
await gracefulShutdown(0)
|
||||
}
|
||||
|
||||
writeToStdout(
|
||||
`New version available: ${latestVersion} (current: ${MACRO.VERSION})\n`,
|
||||
)
|
||||
writeToStdout('Installing update...\n')
|
||||
|
||||
// Determine update method based on what's actually running
|
||||
let useLocalUpdate = false
|
||||
let updateMethodName = ''
|
||||
|
||||
switch (diagnostic.installationType) {
|
||||
case 'npm-local':
|
||||
useLocalUpdate = true
|
||||
updateMethodName = 'local'
|
||||
break
|
||||
case 'npm-global':
|
||||
useLocalUpdate = false
|
||||
updateMethodName = 'global'
|
||||
break
|
||||
case 'unknown': {
|
||||
// Fallback to detection if we can't determine installation type
|
||||
const isLocal = await localInstallationExists()
|
||||
useLocalUpdate = isLocal
|
||||
updateMethodName = isLocal ? 'local' : 'global'
|
||||
writeToStdout(
|
||||
chalk.yellow('Warning: Could not determine installation type') + '\n',
|
||||
)
|
||||
writeToStdout(
|
||||
`Attempting ${updateMethodName} update based on file detection...\n`,
|
||||
)
|
||||
break
|
||||
}
|
||||
default:
|
||||
process.stderr.write(
|
||||
`Error: Cannot update ${diagnostic.installationType} installation\n`,
|
||||
)
|
||||
await gracefulShutdown(1)
|
||||
}
|
||||
|
||||
writeToStdout(`Using ${updateMethodName} installation update method...\n`)
|
||||
|
||||
logForDebugging(`update: Update method determined: ${updateMethodName}`)
|
||||
logForDebugging(`update: useLocalUpdate: ${useLocalUpdate}`)
|
||||
|
||||
let status: InstallStatus
|
||||
|
||||
if (useLocalUpdate) {
|
||||
logForDebugging(
|
||||
'update: Calling installOrUpdateClaudePackage() for local update',
|
||||
)
|
||||
status = await installOrUpdateClaudePackage(channel)
|
||||
} else {
|
||||
logForDebugging('update: Calling installGlobalPackage() for global update')
|
||||
status = await installGlobalPackage()
|
||||
}
|
||||
|
||||
logForDebugging(`update: Installation status: ${status}`)
|
||||
|
||||
switch (status) {
|
||||
case 'success':
|
||||
writeToStdout(
|
||||
chalk.green(
|
||||
`Successfully updated from ${MACRO.VERSION} to version ${latestVersion}`,
|
||||
) + '\n',
|
||||
)
|
||||
await regenerateCompletionCache()
|
||||
break
|
||||
case 'no_permissions':
|
||||
process.stderr.write(
|
||||
'Error: Insufficient permissions to install update\n',
|
||||
)
|
||||
if (useLocalUpdate) {
|
||||
process.stderr.write('Try manually updating with:\n')
|
||||
process.stderr.write(
|
||||
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
|
||||
)
|
||||
} else {
|
||||
process.stderr.write('Try running with sudo or fix npm permissions\n')
|
||||
process.stderr.write(
|
||||
'Or consider using native installation with: claude install\n',
|
||||
)
|
||||
}
|
||||
await gracefulShutdown(1)
|
||||
break
|
||||
case 'install_failed':
|
||||
process.stderr.write('Error: Failed to install update\n')
|
||||
if (useLocalUpdate) {
|
||||
process.stderr.write('Try manually updating with:\n')
|
||||
process.stderr.write(
|
||||
` cd ~/.claude/local && npm update ${MACRO.PACKAGE_URL}\n`,
|
||||
)
|
||||
} else {
|
||||
process.stderr.write(
|
||||
'Or consider using native installation with: claude install\n',
|
||||
)
|
||||
}
|
||||
await gracefulShutdown(1)
|
||||
break
|
||||
case 'in_progress':
|
||||
process.stderr.write(
|
||||
'Error: Another instance is currently performing an update\n',
|
||||
)
|
||||
process.stderr.write('Please wait and try again later\n')
|
||||
await gracefulShutdown(1)
|
||||
break
|
||||
}
|
||||
await gracefulShutdown(0)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { resolve } from 'path';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { Dialog } from '../../components/design-system/Dialog.js';
|
||||
|
||||
@@ -129,11 +129,11 @@ export function clearSessionCaches(
|
||||
void import(
|
||||
'@claude-code-best/builtin-tools/tools/WebFetchTool/utils.js'
|
||||
).then(({ clearWebFetchCache }) => clearWebFetchCache())
|
||||
// Clear ToolSearch description cache (full tool prompts, ~500KB for 50 MCP tools)
|
||||
// Clear SearchExtraTools description cache (full tool prompts, ~500KB for 50 MCP tools)
|
||||
void import(
|
||||
'@claude-code-best/builtin-tools/tools/ToolSearchTool/ToolSearchTool.js'
|
||||
).then(({ clearToolSearchDescriptionCache }) =>
|
||||
clearToolSearchDescriptionCache(),
|
||||
'@claude-code-best/builtin-tools/tools/SearchExtraToolsTool/SearchExtraToolsTool.js'
|
||||
).then(({ clearSearchExtraToolsDescriptionCache }) =>
|
||||
clearSearchExtraToolsDescriptionCache(),
|
||||
)
|
||||
// Clear agent definitions cache (accumulates per-cwd via EnterWorktreeTool)
|
||||
void import(
|
||||
|
||||
@@ -18,7 +18,7 @@ const ALLOWED_TOOLS = [
|
||||
'Bash(gh pr edit:*)',
|
||||
'Bash(gh pr view:*)',
|
||||
'Bash(gh pr merge:*)',
|
||||
'ToolSearch',
|
||||
'SearchExtraTools',
|
||||
'mcp__slack__send_message',
|
||||
'mcp__claude_ai_Slack__slack_send_message',
|
||||
]
|
||||
@@ -45,7 +45,7 @@ function getPromptContent(
|
||||
<!-- CHANGELOG:END -->`
|
||||
let slackStep = `
|
||||
|
||||
5. After creating/updating the PR, check if the user's CLAUDE.md mentions posting to Slack channels. If it does, use ToolSearch to search for "slack send message" tools. If ToolSearch finds a Slack tool, ask the user if they'd like you to post the PR URL to the relevant Slack channel. Only post if the user confirms. If ToolSearch returns no results or errors, skip this step silently—do not mention the failure, do not attempt workarounds, and do not try alternative approaches.`
|
||||
5. After creating/updating the PR, check if the user's CLAUDE.md mentions posting to Slack channels. If it does, use SearchExtraTools to search for "slack send message" tools. If SearchExtraTools finds a Slack tool, ask the user if they'd like you to post the PR URL to the relevant Slack channel. Only post if the user confirms. If SearchExtraTools returns no results or errors, skip this step silently—do not mention the failure, do not attempt workarounds, and do not try alternative approaches.`
|
||||
if (process.env.USER_TYPE === 'ant' && isUndercover()) {
|
||||
prefix = getUndercoverInstructions() + '\n'
|
||||
reviewerArg = ''
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { Settings } from '../../components/Settings/Settings.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Doctor } from '../../screens/Doctor.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
|
||||
@@ -43,8 +43,12 @@ export async function call(
|
||||
// Omitting subagent_type triggers implicit fork.
|
||||
const input = {
|
||||
prompt: directive,
|
||||
fork: true, // 触发 AgentTool 的 fork 路径:继承父会话上下文 + system prompt + 模型
|
||||
run_in_background: true, // fork always runs async
|
||||
description: `Fork: ${directive.slice(0, 30)}${directive.length > 30 ? '...' : ''}`,
|
||||
// description 只显示在底部 selector / BackgroundTasksDialog,保持简短标签
|
||||
// 即可;用户输入的 prompt 会作为第一条用户消息呈现在主视图里,这里不要
|
||||
// 重复显示。
|
||||
description: 'forked from main',
|
||||
};
|
||||
|
||||
// Call AgentTool with proper parameters:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { HelpV2 } from '../../components/HelpV2/HelpV2.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import { getTools } from '../../tools.js';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, color, Text, useTheme } from '@anthropic/ink';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, color, Text, useTheme } from '@anthropic/ink';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Text } from '@anthropic/ink';
|
||||
|
||||
export function CheckGitHubStep() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import TextInput from '../../components/TextInput.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { Workflow } from './types.js';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Select } from 'src/components/CustomSelect/index.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import figures from 'figures';
|
||||
import React from 'react';
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import figures from 'figures';
|
||||
import React from 'react';
|
||||
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { feature } from 'bun:bundle';
|
||||
import * as React from 'react';
|
||||
import { resetCostState } from '../../bootstrap/state.js';
|
||||
import { clearTrustedDeviceToken, enrollTrustedDevice } from '../../bridge/trustedDevice.js';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { PermissionRuleList } from '../../components/permissions/rules/PermissionRuleList.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
import { createPermissionRetryMessage } from '../../utils/messages.js';
|
||||
|
||||
@@ -36,7 +36,7 @@ function getMergedEnv(): Record<string, string> {
|
||||
return merged
|
||||
}
|
||||
|
||||
const call: LocalCommandCall = async (args, context) => {
|
||||
const call: LocalCommandCall = async (args, _context) => {
|
||||
const arg = args.trim().toLowerCase()
|
||||
|
||||
// No argument: show current provider
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type ChildProcess } from 'child_process';
|
||||
import { resolve } from 'path';
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getBridgeDisabledReason, isBridgeEnabled } from '../../bridge/bridgeEnabled.js';
|
||||
import { getBridgeDisabledReason } from '../../bridge/bridgeEnabled.js';
|
||||
import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js';
|
||||
import { BRIDGE_LOGIN_INSTRUCTION } from '../../bridge/types.js';
|
||||
import { Dialog } from '../../components/design-system/Dialog.js';
|
||||
|
||||
55
src/commands/resume/__tests__/resume.test.ts
Normal file
55
src/commands/resume/__tests__/resume.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
/**
|
||||
* Verify that user-facing guidance in model picker and resume command
|
||||
* is concise and actionable. Pure string tests — no side effects.
|
||||
*/
|
||||
|
||||
describe('ModelPicker subtitle', () => {
|
||||
test('subtitle mentions effort and context controls', () => {
|
||||
const subtitle =
|
||||
'Choose a model for this and future sessions. Use ← → to adjust effort, Space to toggle 1M context.'
|
||||
expect(subtitle).toContain('effort')
|
||||
expect(subtitle).toContain('1M context')
|
||||
expect(subtitle).toContain('sessions')
|
||||
})
|
||||
|
||||
test('subtitle is under 120 characters', () => {
|
||||
const subtitle =
|
||||
'Choose a model for this and future sessions. Use ← → to adjust effort, Space to toggle 1M context.'
|
||||
expect(subtitle.length).toBeLessThan(120)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Resume error messages', () => {
|
||||
test('session not found suggests /resume to browse', () => {
|
||||
const message =
|
||||
'Session my-session was not found. Run /resume without arguments to browse all sessions.'
|
||||
expect(message).toContain('not found')
|
||||
expect(message).toContain('/resume')
|
||||
expect(message).toContain('browse')
|
||||
})
|
||||
|
||||
test('multiple matches suggests /resume to pick', () => {
|
||||
const message =
|
||||
'Found 3 sessions matching test. Run /resume to pick one from the list.'
|
||||
expect(message).toContain('3 sessions')
|
||||
expect(message).toContain('/resume')
|
||||
expect(message).toContain('pick')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cost command subscriber messages', () => {
|
||||
test('overage message mentions the key behavior', () => {
|
||||
const msg =
|
||||
'You are currently using your overages to power your Claude Code usage. We will automatically switch you back to your subscription rate limits when they reset'
|
||||
expect(msg).toContain('overages')
|
||||
expect(msg).toContain('automatically switch')
|
||||
})
|
||||
|
||||
test('subscription message is concise', () => {
|
||||
const msg =
|
||||
'You are currently using your subscription to power your Claude Code usage'
|
||||
expect(msg.length).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
@@ -36,9 +36,9 @@ type ResumeResult =
|
||||
function resumeHelpMessage(result: ResumeResult): string {
|
||||
switch (result.resultType) {
|
||||
case 'sessionNotFound':
|
||||
return `Session ${chalk.bold(result.arg)} was not found.`;
|
||||
return `Session ${chalk.bold(result.arg)} was not found. Run ${chalk.bold('/resume')} without arguments to browse all sessions.`;
|
||||
case 'multipleMatches':
|
||||
return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Please use /resume to pick a specific session.`;
|
||||
return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Run ${chalk.bold('/resume')} to pick one from the list.`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js';
|
||||
import React from 'react';
|
||||
import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { checkOverageGate, confirmOverage, launchRemoteReview } from './reviewRemote.js';
|
||||
import { UltrareviewOverageDialog } from './UltrareviewOverageDialog.js';
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
exportInstincts,
|
||||
findPromotionCandidates,
|
||||
generateSkillCandidates,
|
||||
importInstincts,
|
||||
ingestTranscript,
|
||||
listKnownProjects,
|
||||
loadInstincts,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { Stats } from '../../components/Stats.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { REMOTE_CONTROL_DISCONNECTED_MSG } from '../bridge/types.js';
|
||||
import type { Command } from '../commands.js';
|
||||
import { DIAMOND_OPEN } from '../constants/figures.js';
|
||||
@@ -21,7 +20,6 @@ import { logForDebugging } from '../utils/debug.js';
|
||||
import { errorMessage } from '../utils/errors.js';
|
||||
import { logError } from '../utils/log.js';
|
||||
import { enqueuePendingNotification } from '../utils/messageQueueManager.js';
|
||||
import { ALL_MODEL_CONFIGS } from '../utils/model/configs.js';
|
||||
import { updateTaskState } from '../utils/task/framework.js';
|
||||
import { archiveRemoteSession, teleportToRemote } from '../utils/teleport.js';
|
||||
import { pollForApprovedExitPlanMode, UltraplanPollError } from '../utils/ultraplan/ccrSession.js';
|
||||
@@ -36,13 +34,6 @@ import { registerCleanup } from '../utils/cleanupRegistry.js';
|
||||
// TODO(prod-hardening): OAuth token may go stale over the 30min poll;
|
||||
// consider refresh.
|
||||
|
||||
/**
|
||||
* Multi-agent exploration is slow; 30min timeout.
|
||||
*
|
||||
* @deprecated use getUltraplanTimeoutMs()
|
||||
*/
|
||||
const ULTRAPLAN_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
export const CCR_TERMS_URL = 'https://code.claude.com/docs/en/claude-code-on-the-web';
|
||||
|
||||
export function getUltraplanTimeoutMs(): number {
|
||||
@@ -61,15 +52,6 @@ export function isUltraplanEnabled(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// CCR runs against the first-party API — use the canonical ID, not the
|
||||
// provider-specific string getModelStrings() would return (which may be a
|
||||
// Bedrock ARN or Vertex ID on the local CLI). Read at call time, not module
|
||||
// load: the GrowthBook cache is empty at import and `/config` Gates can flip
|
||||
// it between invocations.
|
||||
function getUltraplanModel(): string {
|
||||
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_ultraplan_model', ALL_MODEL_CONFIGS.opus47.firstParty);
|
||||
}
|
||||
|
||||
// prompt.txt is wrapped in <system-reminder> so the CCR browser hides
|
||||
// scaffolding (CLI_BLOCK_TAGS dropped by stripSystemNotifications)
|
||||
// while the model still sees full text.
|
||||
@@ -84,19 +66,6 @@ const _rawPrompt = require('../utils/ultraplan/prompt.txt');
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
const DEFAULT_INSTRUCTIONS: string = (typeof _rawPrompt === 'string' ? _rawPrompt : _rawPrompt.default).trimEnd();
|
||||
|
||||
// Dev-only prompt override resolved eagerly at module load.
|
||||
// Gated to ant builds (USER_TYPE is a build-time define,
|
||||
// so the override path is DCE'd from external builds).
|
||||
// Shell-set env only, so top-level process.env read is fine
|
||||
// — settings.env never injects this.
|
||||
// @deprecated use buildUltraplanPrompt()
|
||||
/* eslint-disable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs -- ant-only dev override; eager top-level read is the point (crash at startup, not silently inside the slash-command try/catch) */
|
||||
const ULTRAPLAN_INSTRUCTIONS: string =
|
||||
process.env.USER_TYPE === 'ant' && process.env.ULTRAPLAN_PROMPT_FILE
|
||||
? readFileSync(process.env.ULTRAPLAN_PROMPT_FILE, 'utf8').trimEnd()
|
||||
: DEFAULT_INSTRUCTIONS;
|
||||
/* eslint-enable custom-rules/no-process-env-top-level, custom-rules/no-sync-fs */
|
||||
|
||||
/**
|
||||
* Assemble the initial CCR user message. seedPlan and blurb stay outside the
|
||||
* system-reminder so the browser renders them; scaffolding is hidden.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { Settings } from '../../components/Settings/Settings.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { formatCost } from '../cost-tracker.js';
|
||||
import { Box, Text, ProgressBar } from '@anthropic/ink';
|
||||
import { formatTokens } from '../utils/format.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
|
||||
type RateLimitBucket = {
|
||||
utilization: number;
|
||||
resets_at: number;
|
||||
};
|
||||
|
||||
type BuiltinStatusLineProps = {
|
||||
modelName: string;
|
||||
contextUsedPct: number;
|
||||
usedTokens: number;
|
||||
contextWindowSize: number;
|
||||
totalCostUsd: number;
|
||||
rateLimits: {
|
||||
five_hour?: RateLimitBucket;
|
||||
seven_day?: RateLimitBucket;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a countdown from now until the given epoch time (in seconds).
|
||||
* Returns a compact human-readable string like "3h12m", "5d20h", "45m", or "now".
|
||||
*/
|
||||
export function formatCountdown(epochSeconds: number): string {
|
||||
const diff = epochSeconds - Date.now() / 1000;
|
||||
if (diff <= 0) return 'now';
|
||||
|
||||
const days = Math.floor(diff / 86400);
|
||||
const hours = Math.floor((diff % 86400) / 3600);
|
||||
const minutes = Math.floor((diff % 3600) / 60);
|
||||
|
||||
if (days >= 1) return `${days}d${hours}h`;
|
||||
if (hours >= 1) return `${hours}h${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function Separator() {
|
||||
return <Text dimColor>{' \u2502 '}</Text>;
|
||||
}
|
||||
|
||||
function BuiltinStatusLineInner({
|
||||
modelName,
|
||||
contextUsedPct,
|
||||
usedTokens,
|
||||
contextWindowSize,
|
||||
totalCostUsd,
|
||||
rateLimits,
|
||||
}: BuiltinStatusLineProps) {
|
||||
const { columns } = useTerminalSize();
|
||||
|
||||
// Force re-render every 60s so countdowns stay current
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const hasResetTime = (rateLimits.five_hour?.resets_at ?? 0) || (rateLimits.seven_day?.resets_at ?? 0);
|
||||
if (!hasResetTime) return;
|
||||
const id = setInterval(() => setTick(t => t + 1), 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [rateLimits.five_hour?.resets_at, rateLimits.seven_day?.resets_at]);
|
||||
|
||||
// Suppress unused-variable lint for tick (it exists only to trigger re-renders)
|
||||
void tick;
|
||||
|
||||
// Model display: use first two words (e.g. "Opus 4.6") instead of just first word
|
||||
const modelParts = modelName.split(' ');
|
||||
const shortModel = modelParts.length >= 2 ? `${modelParts[0]} ${modelParts[1]}` : modelName;
|
||||
|
||||
const wide = columns >= 100;
|
||||
const narrow = columns < 60;
|
||||
|
||||
const hasFiveHour = rateLimits.five_hour != null;
|
||||
const hasSevenDay = rateLimits.seven_day != null;
|
||||
|
||||
const fiveHourPct = hasFiveHour ? Math.round(rateLimits.five_hour!.utilization * 100) : 0;
|
||||
const sevenDayPct = hasSevenDay ? Math.round(rateLimits.seven_day!.utilization * 100) : 0;
|
||||
|
||||
// Token display: "50k/1M"
|
||||
const tokenDisplay = `${formatTokens(usedTokens)}/${formatTokens(contextWindowSize)}`;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Model name */}
|
||||
<Text>{shortModel}</Text>
|
||||
|
||||
{/* Context usage with token counts */}
|
||||
<Separator />
|
||||
<Text dimColor>Context </Text>
|
||||
<Text>{contextUsedPct}%</Text>
|
||||
{!narrow && <Text dimColor> ({tokenDisplay})</Text>}
|
||||
|
||||
{/* 5-hour session rate limit */}
|
||||
{hasFiveHour && (
|
||||
<>
|
||||
<Separator />
|
||||
<Text dimColor>Session </Text>
|
||||
{wide && (
|
||||
<>
|
||||
<ProgressBar
|
||||
ratio={rateLimits.five_hour!.utilization}
|
||||
width={10}
|
||||
fillColor="rate_limit_fill"
|
||||
emptyColor="rate_limit_empty"
|
||||
/>
|
||||
<Text> </Text>
|
||||
</>
|
||||
)}
|
||||
<Text>{fiveHourPct}%</Text>
|
||||
{!narrow && rateLimits.five_hour!.resets_at > 0 && (
|
||||
<Text dimColor> {formatCountdown(rateLimits.five_hour!.resets_at)}</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 7-day weekly rate limit */}
|
||||
{hasSevenDay && (
|
||||
<>
|
||||
<Separator />
|
||||
<Text dimColor>Weekly </Text>
|
||||
{wide && (
|
||||
<>
|
||||
<ProgressBar
|
||||
ratio={rateLimits.seven_day!.utilization}
|
||||
width={10}
|
||||
fillColor="rate_limit_fill"
|
||||
emptyColor="rate_limit_empty"
|
||||
/>
|
||||
<Text> </Text>
|
||||
</>
|
||||
)}
|
||||
<Text>{sevenDayPct}%</Text>
|
||||
{!narrow && rateLimits.seven_day!.resets_at > 0 && (
|
||||
<Text dimColor> {formatCountdown(rateLimits.seven_day!.resets_at)}</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cost */}
|
||||
{totalCostUsd > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<Text>{formatCost(totalCostUsd)}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export const BuiltinStatusLine = React.memo(BuiltinStatusLineInner);
|
||||
@@ -79,7 +79,7 @@ export function CompactSummary({ message, screen }: Props): React.ReactNode {
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text bold>
|
||||
Compact summary
|
||||
Conversation summarized to free up context
|
||||
{!isTranscriptMode && (
|
||||
<Text dimColor>
|
||||
{' '}
|
||||
@@ -87,7 +87,7 @@ export function CompactSummary({ message, screen }: Props): React.ReactNode {
|
||||
action="app:toggleTranscript"
|
||||
context="Global"
|
||||
fallback="ctrl+o"
|
||||
description="expand"
|
||||
description="view summary"
|
||||
parens
|
||||
/>
|
||||
</Text>
|
||||
|
||||
@@ -11,12 +11,12 @@ import { getSSLErrorHint } from '@ant/model-provider';
|
||||
import { sendNotification } from '../services/notifier.js';
|
||||
import { OAuthService } from '../services/oauth/index.js';
|
||||
import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js';
|
||||
|
||||
import { logError } from '../utils/log.js';
|
||||
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js';
|
||||
import { Select } from './CustomSelect/select.js';
|
||||
import { Spinner } from './Spinner.js';
|
||||
import TextInput from './TextInput.js';
|
||||
import { fi } from 'zod/v4/locales';
|
||||
|
||||
type Props = {
|
||||
onDone(): void;
|
||||
@@ -596,7 +596,7 @@ function OAuthStatusMessage({
|
||||
[activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel],
|
||||
);
|
||||
|
||||
const switchTo = useCallback(
|
||||
const _switchTo = useCallback(
|
||||
(target: Field) => {
|
||||
setOAuthStatus(buildState(activeField, inputValue, target));
|
||||
setInputValue(displayValues[target] ?? '');
|
||||
|
||||
@@ -115,6 +115,8 @@ export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
agents,
|
||||
skills,
|
||||
messageBreakdown,
|
||||
cacheHitRate,
|
||||
cacheThreshold,
|
||||
} = data;
|
||||
|
||||
// Filter out categories with 0 tokens for the legend, and exclude Free space, Autocompact buffer, and deferred
|
||||
@@ -166,6 +168,12 @@ export function ContextVisualization({ data }: Props): React.ReactNode {
|
||||
{model} · {formatTokens(totalTokens)}/{formatTokens(rawMaxTokens)} tokens ({percentage}%)
|
||||
</Text>
|
||||
<CollapseStatus />
|
||||
{cacheHitRate !== undefined && cacheThreshold !== undefined && (
|
||||
<Text color={cacheHitRate < cacheThreshold ? 'warning' : undefined}>
|
||||
Cache hit rate: {cacheHitRate.toFixed(0)}%
|
||||
{cacheHitRate < cacheThreshold ? ` (below ${cacheThreshold}% threshold)` : ''}
|
||||
</Text>
|
||||
)}
|
||||
<Text> </Text>
|
||||
<Text dimColor italic>
|
||||
Estimated usage by category
|
||||
|
||||
@@ -25,24 +25,24 @@ export function useFrustrationDetection(
|
||||
const [state, setState] = useState<FrustrationState>('closed')
|
||||
|
||||
const config = getGlobalConfig() as { transcriptShareDismissed?: boolean }
|
||||
if (config.transcriptShareDismissed) {
|
||||
return { state: 'closed', handleTranscriptSelect: () => {} }
|
||||
}
|
||||
|
||||
if (!isPolicyAllowed('product_feedback' as any)) {
|
||||
return { state: 'closed', handleTranscriptSelect: () => {} }
|
||||
}
|
||||
|
||||
if (isLoading || hasActivePrompt || otherSurveyOpen) {
|
||||
return { state: 'closed', handleTranscriptSelect: () => {} }
|
||||
}
|
||||
const policyAllowed = isPolicyAllowed('product_feedback' as any)
|
||||
const shouldSkip =
|
||||
config.transcriptShareDismissed ||
|
||||
!policyAllowed ||
|
||||
isLoading ||
|
||||
hasActivePrompt ||
|
||||
otherSurveyOpen
|
||||
|
||||
const frustrated = detectFrustration(messages)
|
||||
|
||||
const effectiveState =
|
||||
frustrated && state === 'closed' ? 'transcript_prompt' : state
|
||||
const effectiveState = shouldSkip
|
||||
? 'closed'
|
||||
: frustrated && state === 'closed'
|
||||
? 'transcript_prompt'
|
||||
: state
|
||||
|
||||
function handleTranscriptSelect(choice: string) {
|
||||
const handleTranscriptSelect = (choice: string) => {
|
||||
if (shouldSkip) return
|
||||
if (choice === 'yes') {
|
||||
void submitTranscriptShare(messages, 'frustration', crypto.randomUUID())
|
||||
setState('submitted')
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { StructuredPatchHunk } from 'diff';
|
||||
import * as React from 'react';
|
||||
import { Text } from '@anthropic/ink';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { count } from '../utils/array.js';
|
||||
import { MessageResponse } from './MessageResponse.js';
|
||||
import { StructuredDiffList } from './StructuredDiffList.js';
|
||||
|
||||
type Props = {
|
||||
filePath: string;
|
||||
structuredPatch: { lines: string[] }[];
|
||||
structuredPatch: StructuredPatchHunk[];
|
||||
firstLine: string | null;
|
||||
fileContent?: string;
|
||||
style?: 'condensed';
|
||||
verbose: boolean;
|
||||
previewHint?: string;
|
||||
@@ -14,10 +19,13 @@ type Props = {
|
||||
export function FileEditToolUpdatedMessage({
|
||||
filePath,
|
||||
structuredPatch,
|
||||
firstLine,
|
||||
fileContent,
|
||||
style,
|
||||
verbose,
|
||||
previewHint,
|
||||
}: Props): React.ReactNode {
|
||||
const { columns } = useTerminalSize();
|
||||
const numAdditions = structuredPatch.reduce((acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('+')), 0);
|
||||
const numRemovals = structuredPatch.reduce((acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('-')), 0);
|
||||
|
||||
@@ -39,7 +47,7 @@ export function FileEditToolUpdatedMessage({
|
||||
|
||||
// Plan files: invert condensed behavior
|
||||
// - Regular mode: just show the hint (user can type /plan to see full content)
|
||||
// - Condensed mode (subagent view): show the text
|
||||
// - Condensed mode (subagent view): show the diff
|
||||
if (previewHint) {
|
||||
if (style !== 'condensed' && !verbose) {
|
||||
return (
|
||||
@@ -52,5 +60,19 @@ export function FileEditToolUpdatedMessage({
|
||||
return text;
|
||||
}
|
||||
|
||||
return <MessageResponse>{text}</MessageResponse>;
|
||||
return (
|
||||
<MessageResponse>
|
||||
<Box flexDirection="column">
|
||||
<Text>{text}</Text>
|
||||
<StructuredDiffList
|
||||
hunks={structuredPatch}
|
||||
dim={false}
|
||||
width={columns - 12}
|
||||
filePath={filePath}
|
||||
firstLine={firstLine}
|
||||
fileContent={fileContent}
|
||||
/>
|
||||
</Box>
|
||||
</MessageResponse>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
import type { StructuredPatchHunk } from 'diff';
|
||||
import { relative } from 'path';
|
||||
import * as React from 'react';
|
||||
import { useTerminalSize } from 'src/hooks/useTerminalSize.js';
|
||||
import { getCwd } from 'src/utils/cwd.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { HighlightedCode } from './HighlightedCode.js';
|
||||
import { MessageResponse } from './MessageResponse.js';
|
||||
import { StructuredDiffList } from './StructuredDiffList.js';
|
||||
|
||||
const MAX_LINES_TO_RENDER = 10;
|
||||
|
||||
type Props = {
|
||||
file_path: string;
|
||||
operation: 'write' | 'update';
|
||||
// For updates - show diff
|
||||
patch?: StructuredPatchHunk[];
|
||||
firstLine: string | null;
|
||||
fileContent?: string;
|
||||
// For new file creation - show content preview
|
||||
content?: string;
|
||||
style?: 'condensed';
|
||||
verbose: boolean;
|
||||
};
|
||||
|
||||
export function FileEditToolUseRejectedMessage({ file_path, operation, style, verbose }: Props): React.ReactNode {
|
||||
export function FileEditToolUseRejectedMessage({
|
||||
file_path,
|
||||
operation,
|
||||
patch,
|
||||
firstLine,
|
||||
fileContent,
|
||||
content,
|
||||
style,
|
||||
verbose,
|
||||
}: Props): React.ReactNode {
|
||||
const { columns } = useTerminalSize();
|
||||
const text = (
|
||||
<Box flexDirection="row">
|
||||
<Text color="subtle">User rejected {operation} to </Text>
|
||||
@@ -26,5 +48,42 @@ export function FileEditToolUseRejectedMessage({ file_path, operation, style, ve
|
||||
return <MessageResponse>{text}</MessageResponse>;
|
||||
}
|
||||
|
||||
return <MessageResponse>{text}</MessageResponse>;
|
||||
// For new file creation, show content preview (dimmed)
|
||||
if (operation === 'write' && content !== undefined) {
|
||||
const lines = content.split('\n');
|
||||
const numLines = lines.length;
|
||||
const plusLines = numLines - MAX_LINES_TO_RENDER;
|
||||
const truncatedContent = verbose ? content : lines.slice(0, MAX_LINES_TO_RENDER).join('\n');
|
||||
|
||||
return (
|
||||
<MessageResponse>
|
||||
<Box flexDirection="column">
|
||||
{text}
|
||||
<HighlightedCode code={truncatedContent || '(No content)'} filePath={file_path} width={columns - 12} dim />
|
||||
{!verbose && plusLines > 0 && <Text dimColor>… +{plusLines} lines</Text>}
|
||||
</Box>
|
||||
</MessageResponse>
|
||||
);
|
||||
}
|
||||
|
||||
// For updates, show diff
|
||||
if (!patch || patch.length === 0) {
|
||||
return <MessageResponse>{text}</MessageResponse>;
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageResponse>
|
||||
<Box flexDirection="column">
|
||||
{text}
|
||||
<StructuredDiffList
|
||||
hunks={patch}
|
||||
dim
|
||||
width={columns - 12}
|
||||
filePath={file_path}
|
||||
firstLine={firstLine}
|
||||
fileContent={fileContent}
|
||||
/>
|
||||
</Box>
|
||||
</MessageResponse>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,28 @@ import { PromptInputHelpMenu } from '../PromptInput/PromptInputHelpMenu.js';
|
||||
export function General(): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column" paddingY={1} gap={1}>
|
||||
<Box>
|
||||
<Text>
|
||||
Claude understands your codebase, makes edits with your permission, and executes commands — right from your
|
||||
terminal.
|
||||
</Text>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text bold>Getting started</Text>
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
<Text bold>1. </Text>
|
||||
<Text>Ask a question or describe a task — Claude will explore your code and respond.</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>2. </Text>
|
||||
<Text>When Claude wants to edit files or run commands, you review and approve each action.</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>3. </Text>
|
||||
<Text>Type </Text>
|
||||
<Text bold>/commit</Text>
|
||||
<Text> to commit changes, </Text>
|
||||
<Text bold>/help</Text>
|
||||
<Text> for commands, or </Text>
|
||||
<Text bold>?</Text>
|
||||
<Text> for shortcuts.</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
|
||||
74
src/components/HelpV2/__tests__/General.test.ts
Normal file
74
src/components/HelpV2/__tests__/General.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
/**
|
||||
* Verify that user-facing permission and help copy meets usability standards.
|
||||
* These are pure string tests — no side effects, no React rendering.
|
||||
*/
|
||||
|
||||
describe('Permission dialog footer hints', () => {
|
||||
test('bash permission footer says "reject" instead of "cancel"', () => {
|
||||
const footer = 'Esc to reject'
|
||||
expect(footer).toContain('reject')
|
||||
expect(footer).not.toContain('cancel')
|
||||
})
|
||||
|
||||
test('bash permission footer tab hint says "add feedback"', () => {
|
||||
const tabHint = 'Tab to add feedback'
|
||||
expect(tabHint).toContain('feedback')
|
||||
expect(tabHint).not.toContain('amend')
|
||||
})
|
||||
|
||||
test('file permission footer matches bash footer language', () => {
|
||||
const bashFooter = 'Esc to reject'
|
||||
const fileFooter = 'Esc to reject'
|
||||
expect(bashFooter).toBe(fileFooter)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Permission option labels', () => {
|
||||
test('.claude/ folder option is under 60 chars', () => {
|
||||
const label = 'Yes, allow edits to .claude/ config for this session'
|
||||
expect(label.length).toBeLessThan(60)
|
||||
expect(label).toContain('.claude/')
|
||||
})
|
||||
|
||||
test('accept-once option has simple label', () => {
|
||||
const label = 'Yes'
|
||||
expect(label).toBe('Yes')
|
||||
})
|
||||
|
||||
test('reject option has simple label', () => {
|
||||
const label = 'No'
|
||||
expect(label).toBe('No')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Help General page getting started guide', () => {
|
||||
test('step 1 mentions exploring code', () => {
|
||||
const step1 =
|
||||
'Ask a question or describe a task — Claude will explore your code and respond.'
|
||||
expect(step1).toContain('explore')
|
||||
expect(step1).toContain('question')
|
||||
})
|
||||
|
||||
test('step 2 mentions reviewing actions', () => {
|
||||
const step2 =
|
||||
'When Claude wants to edit files or run commands, you review and approve each action.'
|
||||
expect(step2).toContain('review')
|
||||
expect(step2).toContain('approve')
|
||||
})
|
||||
|
||||
test('step 3 mentions key commands', () => {
|
||||
const step3 = '/commit'
|
||||
const step3b = '/help'
|
||||
const step3c = '?'
|
||||
expect(step3).toBe('/commit')
|
||||
expect(step3b).toBe('/help')
|
||||
expect(step3c).toBe('?')
|
||||
})
|
||||
|
||||
test('heading says "Getting started"', () => {
|
||||
const heading = 'Getting started'
|
||||
expect(heading).toBe('Getting started')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import { type ReactNode, useEffect } from 'react';
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user