mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
Compare commits
90 Commits
feature/do
...
v1.10.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cb4828de6 | ||
|
|
f5c3ee5b5d | ||
|
|
c2ac9a74c1 | ||
|
|
fc438bd222 | ||
|
|
4591432a1d | ||
|
|
901628b4d9 | ||
|
|
cf33c06021 | ||
|
|
e0ca1d054c | ||
|
|
6585d0f67c | ||
|
|
e4403ff010 | ||
|
|
9e61e7a90d | ||
|
|
d03af7bd4e | ||
|
|
e8ef955ff9 | ||
|
|
a8ed0cdce5 | ||
|
|
1c3b280c6a | ||
|
|
7a3cc24a00 | ||
|
|
2e7fc428cd | ||
|
|
ad09f38fd1 | ||
|
|
b0a3ef90dc | ||
|
|
c07ad4c738 | ||
|
|
e38d45460e | ||
|
|
e0c8e9dafc | ||
|
|
047c85fcbf | ||
|
|
da6d06365d | ||
|
|
8613d558a8 | ||
|
|
017c251f78 | ||
|
|
d4223abc34 | ||
|
|
5125a159d2 | ||
|
|
d09f363414 | ||
|
|
9d35f98ec7 | ||
|
|
eb833da33b | ||
|
|
eadd32ae47 | ||
|
|
3c55a8c83f | ||
|
|
5582bb47ef | ||
|
|
95bb191977 | ||
|
|
03811f973b | ||
|
|
02ab1a0307 | ||
|
|
2a5b263641 | ||
|
|
f2dd5142b3 | ||
|
|
4dcbaf1e66 | ||
|
|
0b304730d8 | ||
|
|
7a0dd3057e | ||
|
|
ca1c87f460 | ||
|
|
fc7a85f5c7 | ||
|
|
5bc12b00b2 | ||
|
|
792777d68c | ||
|
|
047634afe6 | ||
|
|
a92af99448 | ||
|
|
cfe1552ec9 | ||
|
|
9624f880e0 | ||
|
|
85e5a8cffb | ||
|
|
299953b0ee | ||
|
|
7a3fdf6e67 | ||
|
|
b642977afe | ||
|
|
781188862e | ||
|
|
b966eef5a9 | ||
|
|
c3d63c8fe2 | ||
|
|
7d4c4278c0 | ||
|
|
93bfdabff1 | ||
|
|
1173a62301 | ||
|
|
7ea69ca279 | ||
|
|
4e82fb5974 | ||
|
|
f43350e600 | ||
|
|
23fcbf9004 | ||
|
|
23bb09d240 | ||
|
|
d208855f07 | ||
|
|
7881cc617c | ||
|
|
c7e1c50b86 | ||
|
|
2247026bd5 | ||
|
|
eec961352b | ||
|
|
fb41513b32 | ||
|
|
94c4b37eed | ||
|
|
6c5df395c3 | ||
|
|
be97a0b010 | ||
|
|
59f8675fa3 | ||
|
|
c4775fff58 | ||
|
|
31b2fdd97a | ||
|
|
1837df5f88 | ||
|
|
04c7ed4250 | ||
|
|
711927f01b | ||
|
|
956e98a445 | ||
|
|
cee62bc654 | ||
|
|
5fc7c8e13d | ||
|
|
300faa18d0 | ||
|
|
96ec96c720 | ||
|
|
13a0bfc479 | ||
|
|
84f0271813 | ||
|
|
ed4bdb9338 | ||
|
|
e4ce08fe39 | ||
|
|
92f8a92fbb |
@@ -41,7 +41,8 @@ All teach-me data is stored under `.claude/skills/teach-me/records/`:
|
||||
.claude/skills/teach-me/records/
|
||||
├── learner-profile.md # Cross-topic notes (created on first session)
|
||||
└── {topic-slug}/
|
||||
└── session.md # Learning state: concepts, status, notes
|
||||
├── session.md # Learning state: concepts, status, notes
|
||||
└── {topic-slug}-notes.md # Learner-facing summary notes (generated at session end)
|
||||
```
|
||||
|
||||
**Slug**: Topic in kebab-case, 2-5 words. Example: "Python decorators" → `python-decorators`
|
||||
@@ -275,7 +276,8 @@ Update `session.md` after each round:
|
||||
When all concepts mastered or user ends session:
|
||||
|
||||
1. Update `session.md` with final state.
|
||||
2. Update `.claude/skills/teach-me/records/learner-profile.md` (keep under 30 lines):
|
||||
2. **Generate learner-facing notes** — write `{topic-slug}-notes.md` in the topic directory. This is a standalone reference document the learner can review later. See "Notes Generation" below for format.
|
||||
3. Update `.claude/skills/teach-me/records/learner-profile.md` (keep under 30 lines):
|
||||
|
||||
```markdown
|
||||
# Learner Profile
|
||||
@@ -293,7 +295,48 @@ Updated: {timestamp}
|
||||
- Python decorators (8/10 concepts, 2025-01-15)
|
||||
```
|
||||
|
||||
3. Give a brief text summary of what was covered, key insights, and areas for further study.
|
||||
4. Give a brief text summary of what was covered, key insights, and areas for further study.
|
||||
|
||||
## Notes Generation
|
||||
|
||||
At session end, generate a learner-facing notes file at `{topic-slug}/{topic-slug}-notes.md`. This file is **written for the learner to review later**, not for the tutor. It should be self-contained and organized as a quick-reference.
|
||||
|
||||
### Notes Structure
|
||||
|
||||
```markdown
|
||||
# {Topic} 核心笔记
|
||||
|
||||
## 1. {Section Name}
|
||||
{Key concept, mechanism, or principle}
|
||||
* **One-line summary**: {what it does / why it matters}
|
||||
* **Detail**: {brief explanation, 2-4 sentences max}
|
||||
* **Example** (if applicable): {code snippet, command, or concrete scenario}
|
||||
|
||||
---
|
||||
|
||||
## 2. {Section Name}
|
||||
...
|
||||
|
||||
---
|
||||
|
||||
## n. 实战参数 / Cheat Sheet (if applicable)
|
||||
{Practical commands, config, or quick-reference table}
|
||||
|
||||
| Parameter / Concept | What it does | Tuning tip |
|
||||
|---------------------|-------------|------------|
|
||||
| ... | ... | ... |
|
||||
```
|
||||
|
||||
### Notes Writing Rules
|
||||
|
||||
1. **Start with "what & why"** before "how". Each section should answer: what is this, why does it exist, what problem does it solve.
|
||||
2. **Use analogies sparingly but effectively**. Only include an analogy if it clarifies a non-obvious mechanism (e.g., "PagedAttention is like OS virtual memory paging").
|
||||
3. **Include trade-offs**. Every optimization or design choice has a cost. Always state it (e.g., "TP improves throughput but increases communication latency").
|
||||
4. **Code / command examples should be minimal**. Under 10 lines, self-contained, with comments explaining the key flags.
|
||||
5. **Organize by concept dependency**, not by chronological teaching order. Foundation concepts first, advanced ones last.
|
||||
6. **No quiz questions, no misconceptions, no tutor-side notes**. This is a clean reference document.
|
||||
7. **Language matches the session**. If the session was in Chinese, notes are in Chinese (technical terms can stay in English).
|
||||
8. **Keep it under 150 lines**. If it gets too long, the learner won't review it. Be ruthless about cutting fluff.
|
||||
|
||||
## Resuming Sessions
|
||||
|
||||
|
||||
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@@ -6,32 +6,48 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
env:
|
||||
GIT_CONFIG_COUNT: 2
|
||||
GIT_CONFIG_KEY_0: init.defaultBranch
|
||||
GIT_CONFIG_VALUE_0: main
|
||||
GIT_CONFIG_KEY_1: advice.defaultBranchName
|
||||
GIT_CONFIG_VALUE_1: "false"
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2, 2026-04-25
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CLAUDE_CODE_SKIP_CHROME_MCP_SETUP: "1"
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: bunx tsc --noEmit
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Test with Coverage
|
||||
run: |
|
||||
set -o pipefail
|
||||
bun test --coverage --coverage-reporter=lcov 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
|
||||
bun test --coverage --coverage-reporter lcov --coverage-dir coverage 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
|
||||
test -s coverage/lcov.info
|
||||
grep -q '^SF:' coverage/lcov.info
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5, 2026-04-25
|
||||
with:
|
||||
file: ./coverage/lcov.info
|
||||
fail_ci_if_error: true
|
||||
files: ./coverage/lcov.info
|
||||
disable_search: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
- name: Build
|
||||
|
||||
79
.github/workflows/publish-npm.yml
vendored
Normal file
79
.github/workflows/publish-npm.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
name: Publish to npm
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: '版本号 (例如: v1.9.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
with:
|
||||
ref: ${{ github.event.inputs.version || github.ref }}
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6, 2026-04-25
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2, 2026-04-25
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Type check
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: bun test
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version || github.ref_name }}"
|
||||
PREV_TAG=$(git tag --sort=-version:refname | grep -v "^${VERSION#v}$" | head -1)
|
||||
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
COMMITS=$(git log "${PREV_TAG}..${VERSION}" --pretty=format:"- %s (%h)" --no-merges)
|
||||
else
|
||||
COMMITS=$(git log --pretty=format:"- %s (%h)" --no-merges -20)
|
||||
fi
|
||||
|
||||
{
|
||||
echo "commits<<EOF"
|
||||
echo "$COMMITS"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2, 2026-04-25
|
||||
with:
|
||||
name: ${{ github.event.inputs.version || github.ref_name }}
|
||||
body: |
|
||||
## What's Changed
|
||||
|
||||
${{ steps.changelog.outputs.commits }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ github.event.inputs.version || github.ref_name }}^...${{ github.event.inputs.version || github.ref_name }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.event.inputs.version || github.ref_name, 'rc') || contains(github.event.inputs.version || github.ref_name, 'beta') || contains(github.event.inputs.version || github.ref_name, 'alpha') }}
|
||||
8
.github/workflows/release-rcs.yml
vendored
8
.github/workflows/release-rcs.yml
vendored
@@ -17,17 +17,17 @@ jobs:
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3, 2026-04-25
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3, 2026-04-25
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5, 2026-04-25
|
||||
with:
|
||||
context: .
|
||||
file: packages/remote-control-server/Dockerfile
|
||||
|
||||
11
.github/workflows/update-contributors.yml
vendored
11
.github/workflows/update-contributors.yml
vendored
@@ -1,11 +1,8 @@
|
||||
name: Update Contributors
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # 每天更新一次
|
||||
- cron: '0 0 * * 1' # 每周一更新一次
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -14,17 +11,17 @@ jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: jaywcjlove/github-action-contributors@main
|
||||
- uses: jaywcjlove/github-action-contributors@86707f6d4c2469ce6b46bc3367253ebd41ee242c # main, 2026-04-25
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
output: "contributors.svg"
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
- uses: stefanzweifel/git-auto-commit-action@v5
|
||||
- uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5, 2026-04-25
|
||||
with:
|
||||
commit_message: "docs: update contributors"
|
||||
file_pattern: "contributors.svg"
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -19,6 +19,11 @@ src/utils/vendor/
|
||||
/*.png
|
||||
*.bmp
|
||||
|
||||
# Internal system prompt documents
|
||||
Claude-Opus-*.txt
|
||||
Claude-Sonnet-*.txt
|
||||
Claude-Haiku-*.txt
|
||||
|
||||
# Agent / tool state dirs
|
||||
.swarm/
|
||||
.agents/__pycache__/
|
||||
@@ -38,3 +43,5 @@ data
|
||||
.codex/skills/.system/**
|
||||
!.codex/prompts/
|
||||
!.codex/prompts/**
|
||||
teach-me
|
||||
credentials.json
|
||||
|
||||
357
AGENTS.md
Normal file
357
AGENTS.md
Normal file
@@ -0,0 +1,357 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) and other AI coding agents when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
|
||||
|
||||
## Git Commit Message Convention
|
||||
|
||||
使用 **Conventional Commits** 规范:
|
||||
|
||||
```
|
||||
<type>: <描述>
|
||||
```
|
||||
|
||||
常见 type:`feat`、`fix`、`docs`、`chore`、`refactor`
|
||||
|
||||
示例:
|
||||
- `feat: 添加模型 1M 上下文切换`
|
||||
- `fix: 修复初次登陆的校验问题`
|
||||
- `chore: remove prefetchOfficialMcpUrls call on startup`
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Dev mode (runs cli.tsx with MACRO defines injected via -d flags)
|
||||
bun run dev
|
||||
|
||||
# Dev mode with debugger (set BUN_INSPECT=9229 to pick port)
|
||||
bun run dev:inspect
|
||||
|
||||
# Pipe mode
|
||||
echo "say hello" | bun run src/entrypoints/cli.tsx -p
|
||||
|
||||
# Build (code splitting, outputs dist/cli.js + chunk files)
|
||||
bun run build
|
||||
|
||||
# Build with Vite (alternative build pipeline)
|
||||
bun run build:vite
|
||||
|
||||
# Test
|
||||
bun test # run all tests
|
||||
bun test src/utils/__tests__/hash.test.ts # run single file
|
||||
bun test --coverage # with coverage report
|
||||
|
||||
# Lint & Format (Biome)
|
||||
bun run lint # check only
|
||||
bun run lint:fix # auto-fix
|
||||
bun run format # format all src/
|
||||
|
||||
# Health check
|
||||
bun run health
|
||||
|
||||
# Check unused exports
|
||||
bun run check:unused
|
||||
|
||||
# Full check (typecheck + lint + test) — run after completing any task
|
||||
bun run test:all
|
||||
bun run typecheck
|
||||
|
||||
# Remote Control Server
|
||||
bun run rcs
|
||||
|
||||
# Docs dev server (Mintlify)
|
||||
bun run docs:dev
|
||||
```
|
||||
|
||||
详细的测试规范、覆盖状态和改进计划见 `docs/testing-spec.md`。
|
||||
|
||||
## Architecture
|
||||
|
||||
### Runtime & Build
|
||||
|
||||
- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs.
|
||||
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature(见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
|
||||
- **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:*`。
|
||||
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`。
|
||||
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`。
|
||||
- **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
|
||||
|
||||
### Entry & Bootstrap
|
||||
|
||||
1. **`src/entrypoints/cli.tsx`** — True entrypoint。`main()` 函数按优先级处理多条快速路径:
|
||||
- `--version` / `-v` — 零模块加载
|
||||
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
|
||||
- `--claude-in-chrome-mcp` / `--chrome-native-host`
|
||||
- `--computer-use-mcp` — 独立 MCP server 模式
|
||||
- `--daemon-worker=<kind>` — feature-gated (DAEMON)
|
||||
- `remote-control` / `rc` / `remote` / `sync` / `bridge` — feature-gated (BRIDGE_MODE)
|
||||
- `daemon` [subcommand] — feature-gated (DAEMON)
|
||||
- `ps` / `logs` / `attach` / `kill` / `--bg` — feature-gated (BG_SESSIONS)
|
||||
- `new` / `list` / `reply` — Template job commands
|
||||
- `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 模式分发。
|
||||
3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。
|
||||
|
||||
### Core Loop
|
||||
|
||||
- **`src/query.ts`** — The main API query function. Sends messages to Claude API, handles streaming responses, processes tool calls, and manages the conversation turn loop.
|
||||
- **`src/QueryEngine.ts`** — Higher-level orchestrator wrapping `query()`. Manages conversation state, compaction, file history snapshots, attribution, and turn-level bookkeeping. Used by the REPL screen.
|
||||
- **`src/screens/REPL.tsx`** — The interactive REPL screen (React/Ink component). Handles user input, message display, tool permission prompts, and keyboard shortcuts.
|
||||
|
||||
### API Layer
|
||||
|
||||
- **`src/services/api/claude.ts`** — Core API client. Builds request params (system prompt, messages, tools, betas), calls the Anthropic SDK streaming endpoint, and processes `BetaRawMessageStreamEvent` events.
|
||||
- **7 providers**: `firstParty` (Anthropic direct), `bedrock` (AWS), `vertex` (Google Cloud), `foundry`, `openai`, `gemini`, `grok` (xAI)。
|
||||
- Provider selection in `src/utils/model/providers.ts`。优先级:modelType 参数 > 环境变量 > 默认 firstParty。
|
||||
|
||||
### Tool System
|
||||
|
||||
- **`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` 包导出。主要分类:
|
||||
- **文件操作**: 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
|
||||
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
|
||||
- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。
|
||||
|
||||
### UI Layer (Ink)
|
||||
|
||||
- **`src/ink.ts`** — Ink render wrapper with ThemeProvider injection.
|
||||
- **`packages/@ant/ink/`** — Custom Ink framework(forked/internal),包含 components、core、hooks、keybindings、theme、utils。注意:不是 `src/ink/`。
|
||||
- **`src/components/`** — 149 个组件目录/文件,渲染于终端 Ink 环境中。关键组件:
|
||||
- `App.tsx` — Root provider (AppState, Stats, FpsMetrics)
|
||||
- `Messages.tsx` / `MessageRow.tsx` — Conversation message rendering
|
||||
- `PromptInput/` — User input handling
|
||||
- `permissions/` — Tool permission approval UI
|
||||
- `design-system/` — 复用 UI 组件(Dialog, FuzzyPicker, ProgressBar, ThemeProvider 等)
|
||||
- Components use React Compiler runtime (`react/compiler-runtime`) — decompiled output has `_c()` memoization calls throughout.
|
||||
|
||||
### State Management
|
||||
|
||||
- **`src/state/AppState.tsx`** — Central app state type and context provider. Contains messages, tools, permissions, MCP connections, etc.
|
||||
- **`src/state/AppStateStore.ts`** — Default state and store factory.
|
||||
- **`src/state/store.ts`** — Zustand-style store for AppState (`createStore`).
|
||||
- **`src/state/selectors.ts`** — State selectors.
|
||||
- **`src/bootstrap/state.ts`** — Module-level singletons for session-global state (session ID, CWD, project root, token counts, model overrides, client type, permission mode).
|
||||
|
||||
### Workspace Packages
|
||||
|
||||
| Package | 说明 |
|
||||
|---------|------|
|
||||
| `packages/@ant/ink/` | Forked Ink 框架(components、hooks、keybindings、theme) |
|
||||
| `packages/@ant/computer-use-mcp/` | Computer Use MCP server(截图/键鼠/剪贴板/应用管理) |
|
||||
| `packages/@ant/computer-use-input/` | 键鼠模拟(dispatcher + darwin/win32/linux backend) |
|
||||
| `packages/@ant/computer-use-swift/` | 截图 + 应用管理(dispatcher + per-platform backend) |
|
||||
| `packages/@ant/claude-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) |
|
||||
| `packages/@ant/model-provider/` | Model provider 抽象层 |
|
||||
| `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 参数读取) |
|
||||
|
||||
### Bridge / Remote Control
|
||||
|
||||
- **`src/bridge/`** — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
||||
- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板(React 19 + Vite + Radix UI)。支持 ACP agent 通过 acp-link 接入(ACP WebSocket handler、relay handler、SSE event stream)。通过 `bun run rcs` 启动。
|
||||
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。
|
||||
- 详见 `docs/features/remote-control-self-hosting.md`。
|
||||
|
||||
### ACP Protocol (Agent Client Protocol)
|
||||
|
||||
- **`src/services/acp/`** — ACP agent 实现,包含 `agent.ts`(AcpAgent 类)、`bridge.ts`(Claude Code ↔ ACP 桥接)、`permissions.ts`(权限处理)、`entry.ts`(入口)。
|
||||
- **`packages/acp-link/`** — ACP 代理服务器,将 WebSocket 客户端桥接到 ACP agent。提供 `acp-link` CLI 命令,支持自定义端口/HTTPS/认证/会话管理、RCS 集成(REST 注册 + WS identify 两步流程)、权限模式透传(fallback: 客户端传值 > config > `ACP_PERMISSION_MODE` 环境变量)。
|
||||
- ACP 权限管道改进:`createAcpCanUseTool` 统一权限流水线,`applySessionMode` 模式同步,`bypassPermissions` 可用性检测(非 root/sandbox 环境)。
|
||||
- ACP Plan 可视化已支持 `session/update plan` 类型的消息展示(PlanView 组件,含进度条/状态图标/优先级标签)。
|
||||
|
||||
### Daemon Mode
|
||||
|
||||
- **`src/daemon/`** — Daemon 模式(长驻 supervisor)。feature-gated by `DAEMON`。包含 `main.ts`(entry)和 `workerRegistry.ts`(worker 管理)。
|
||||
|
||||
### Context & System Prompt
|
||||
|
||||
- **`src/context.ts`** — Builds system/user context for the API call (git status, date, CLAUDE.md contents, memory files).
|
||||
- **`src/utils/claudemd.ts`** — Discovers and loads CLAUDE.md files from project hierarchy.
|
||||
|
||||
### Feature Flag System
|
||||
|
||||
Feature flags control which functionality is enabled at runtime. 代码中统一通过 `import { feature } from 'bun:bundle'` 导入,调用 `feature('FLAG_NAME')` 返回 `boolean`。
|
||||
|
||||
**启用方式**: 环境变量 `FEATURE_<FLAG_NAME>=1`。例如 `FEATURE_BUDDY=1 bun run dev`。
|
||||
|
||||
**Build 默认 features**(19 个,见 `build.ts`):
|
||||
- 基础: `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`
|
||||
|
||||
**Dev mode 默认**: 全部启用(见 `scripts/dev.ts`)。
|
||||
|
||||
**类型声明**: `src/types/internal-modules.d.ts` 中声明了 `bun:bundle` 模块的 `feature` 函数签名。
|
||||
|
||||
**新增功能的正确做法**: 保留 `import { feature } from 'bun:bundle'` + `feature('FLAG_NAME')` 的标准模式,在运行时通过环境变量或配置控制,不要绕过 feature flag 直接 import。
|
||||
|
||||
### Multi-API 兼容层
|
||||
|
||||
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。通过 `/login` 命令配置。
|
||||
|
||||
#### OpenAI 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI Chat Completions 协议端点。含 DeepSeek thinking mode 支持。
|
||||
|
||||
- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
|
||||
- 关键环境变量:`CLAUDE_CODE_USE_OPENAI`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`
|
||||
|
||||
#### Gemini 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用。独立环境变量体系。
|
||||
|
||||
- **`src/services/api/gemini/`** — client、模型映射、类型定义
|
||||
- 关键环境变量:`GEMINI_API_KEY`(必填)、`GEMINI_MODEL`(直接指定)、`GEMINI_DEFAULT_SONNET_MODEL`/`GEMINI_DEFAULT_OPUS_MODEL`(按能力映射)
|
||||
- 模型映射优先级:`GEMINI_MODEL` > `GEMINI_DEFAULT_*_MODEL` > `ANTHROPIC_DEFAULT_*_MODEL`(已废弃) > 原样返回
|
||||
|
||||
#### Grok 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GROK=1` 启用。自定义模型映射支持 xAI Grok API。
|
||||
|
||||
- **`src/services/api/grok/`** — client、模型映射
|
||||
|
||||
详见各兼容层的 docs 文档。
|
||||
|
||||
### 穷鬼模式(Budget Mode)
|
||||
|
||||
- 通过 `/poor` 命令切换,持久化到 `settings.json`。
|
||||
- 启用后跳过 `extract_memories`、`prompt_suggestion` 和 `verification_agent`,显著减少 token 消耗。
|
||||
- 实现在 `src/commands/poor/poorMode.ts`。
|
||||
|
||||
### Stubbed/Deleted Modules
|
||||
|
||||
| Module | Status |
|
||||
|--------|--------|
|
||||
| Computer Use (`@ant/*`) | Restored — macOS + Windows + Linux(后端完整度不一) |
|
||||
| `*-napi` packages | 全部已恢复/实现:`audio-capture-napi`、`image-processor-napi` 已恢复;`color-diff-napi` 完整;`modifiers-napi`(macOS FFI);`url-handler-napi`(环境变量+CLI) |
|
||||
| Voice Mode | Restored — Push-to-Talk 语音输入(需 Anthropic OAuth) |
|
||||
| OpenAI/Gemini/Grok 兼容层 | Restored |
|
||||
| Remote Control Server | Restored — 自托管 RCS + Web UI |
|
||||
| Analytics / GrowthBook / Sentry | Empty implementations |
|
||||
| Magic Docs / LSP Server | Restored — Magic Docs 自动更新 + LSP 服务器管理器 |
|
||||
| Plugins / Marketplace | Restored — 插件安装/卸载/启用/禁用 + Marketplace 浏览 |
|
||||
| MCP OAuth | Simplified |
|
||||
|
||||
### Key Type Files
|
||||
|
||||
- **`src/types/global.d.ts`** — Declares `MACRO`, `BUILD_TARGET`, `BUILD_ENV` and internal Anthropic-only identifiers.
|
||||
- **`src/types/internal-modules.d.ts`** — Type declarations for `bun:bundle`, `bun:ffi`, `@anthropic-ai/mcpb`.
|
||||
- **`src/types/message.ts`** — Message type hierarchy (UserMessage, AssistantMessage, SystemMessage, etc.).
|
||||
- **`src/types/permissions.ts`** — Permission mode and result types.
|
||||
|
||||
## Testing
|
||||
|
||||
- **框架**: `bun:test`(内置断言 + mock)
|
||||
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
|
||||
- **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain)
|
||||
- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/)
|
||||
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
|
||||
- **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests)
|
||||
|
||||
### Mock 使用规范
|
||||
|
||||
**只 mock 有副作用的依赖链,不 mock 纯函数/纯数据模块。**
|
||||
|
||||
被迫 mock 的根源:`log.ts` / `debug.ts` → `bootstrap/state.ts`(模块级 `realpathSync` / `randomUUID` 副作用)。必须 mock 的模块:`log.ts`、`debug.ts`、`bun:bundle`、`settings/settings.js`、`config.ts`、`auth.ts`、第三方网络库。
|
||||
|
||||
**`log.ts` 和 `debug.ts` 使用共享 mock**(`tests/mocks/log.ts` / `tests/mocks/debug.ts`),不要在测试文件中内联 mock 定义。使用方式:
|
||||
|
||||
```ts
|
||||
import { logMock } from "../../../tests/mocks/log";
|
||||
mock.module("src/utils/log.ts", logMock);
|
||||
|
||||
import { debugMock } from "../../../../tests/mocks/debug";
|
||||
mock.module("src/utils/debug.ts", debugMock);
|
||||
```
|
||||
|
||||
源文件导出变更时只需更新 `tests/mocks/` 下的对应文件,不需要逐个修改测试。
|
||||
|
||||
不要 mock:纯函数模块(`errors.ts`、`stringUtils.js`)、mock 值与真实实现相同的模块、mock 路径与实际 import 不匹配的模块。
|
||||
|
||||
路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。
|
||||
|
||||
### 类型检查
|
||||
|
||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
||||
|
||||
```bash
|
||||
bun run typecheck
|
||||
```
|
||||
|
||||
**类型规范**:
|
||||
- 生产代码禁止 `as any`;测试文件中 mock 数据可用 `as any`
|
||||
- 类型不匹配优先用 `as unknown as SpecificType` 双重断言,或补充 interface
|
||||
- 未知结构对象用 `Record<string, unknown>` 替代 `any`
|
||||
- 联合类型用类型守卫(type guard)收窄,不要强转
|
||||
- `msg.request` 属性访问:`const req = msg.request as Record<string, unknown>`
|
||||
- Ink `color` prop:用 `as keyof Theme` 而非 `as any`
|
||||
|
||||
## Working with This Codebase
|
||||
|
||||
- **tsc must pass** — `bun run typecheck` 必须零错误,任何修改都不能引入新的类型错误。
|
||||
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`)。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
|
||||
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
|
||||
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**(Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}` 或 `feature('X') ? a : b`。
|
||||
- **`src/` path alias** — tsconfig maps `src/*` to `./src/*`. Imports like `import { ... } from 'src/utils/...'` are valid.
|
||||
- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入,build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。
|
||||
- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。
|
||||
- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。
|
||||
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。
|
||||
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
|
||||
|
||||
## Design Context
|
||||
|
||||
Impeccable 设计上下文保存在 `.impeccable.md` 中。设计 Web UI(RCS 控制面板、文档站、着陆页)时必须参考该文件。
|
||||
|
||||
### 核心设计原则
|
||||
|
||||
1. **Considered over clever** — 每个设计选择都应感觉有意为之,而非追逐潮流
|
||||
2. **Warmth through subtlety** — 通过橙色色调的中性色、留白布局、有温度的文案来传达温暖
|
||||
3. **Density with clarity** — 技术用户需要信息密度,但不能混乱
|
||||
4. **Community voice** — 设计应感觉是由使用者创造的,而非遥远的设计团队
|
||||
5. **Anthropic's shadow** — 遵循 Anthropic 的设计直觉:干净的布局、充足的间距、温暖的色温
|
||||
|
||||
### 品牌色
|
||||
|
||||
- 主色:Claude Orange `#D77757`(terra cotta)
|
||||
- 辅色:Claude Blue `#5769F7`
|
||||
- 暗色模式使用温暖的深色表面(非冷蓝黑色)
|
||||
|
||||
### 目标用户
|
||||
|
||||
技术团队/企业,在专业工作流中使用 AI 辅助编程。友好的开源社区氛围,非企业 SaaS 风格。
|
||||
|
||||
### 视觉参考
|
||||
|
||||
Anthropic 公司的设计风格 — 干净、考究、温暖的底色。大量留白,以排版为核心。避免 AI 产品常见的设计套路(渐变文字、玻璃态、霓虹色)。
|
||||
71
CLAUDE.md
71
CLAUDE.md
@@ -1,10 +1,10 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
This file provides guidance to Claude Code (claude.ai/code) and other AI coding agents when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced(见 Working with This Codebase 段的 tsc 要求)。
|
||||
This is a **reverse-engineered / decompiled** version of Anthropic's official Claude Code CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
|
||||
|
||||
## Git Commit Message Convention
|
||||
|
||||
@@ -43,9 +43,9 @@ bun run build
|
||||
bun run build:vite
|
||||
|
||||
# Test
|
||||
bun test # run all tests (3175 tests / 207 files / 0 fail)
|
||||
bun test # run all tests
|
||||
bun test src/utils/__tests__/hash.test.ts # run single file
|
||||
bun test --coverage # with coverage report
|
||||
bun test --coverage # with coverage report
|
||||
|
||||
# Lint & Format (Biome)
|
||||
bun run lint # check only
|
||||
@@ -58,6 +58,8 @@ bun run health
|
||||
# Check unused exports
|
||||
bun run check:unused
|
||||
|
||||
# Full check (typecheck + lint + test) — run after completing any task
|
||||
bun run test:all
|
||||
bun run typecheck
|
||||
|
||||
# Remote Control Server
|
||||
@@ -74,7 +76,9 @@ bun run docs:dev
|
||||
### Runtime & Build
|
||||
|
||||
- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs.
|
||||
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature(见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
|
||||
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature(见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。构建时会将 `vendor/audio-capture/` 和 `src/utils/vendor/ripgrep/` 复制到 `dist/vendor/` 下。
|
||||
- **Build (Vite)**: `vite.config.ts` + `scripts/post-build.ts`,chunk 输出到 `dist/chunks/`。post-build 同样复制 vendor 文件到 `dist/vendor/`。
|
||||
- **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:*`。
|
||||
@@ -84,7 +88,7 @@ bun run docs:dev
|
||||
|
||||
### Entry & Bootstrap
|
||||
|
||||
1. **`src/entrypoints/cli.tsx`** (373 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
|
||||
1. **`src/entrypoints/cli.tsx`** — True entrypoint。`main()` 函数按优先级处理多条快速路径:
|
||||
- `--version` / `-v` — 零模块加载
|
||||
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
|
||||
- `--claude-in-chrome-mcp` / `--chrome-native-host`
|
||||
@@ -115,7 +119,7 @@ bun run docs:dev
|
||||
### Tool System
|
||||
|
||||
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
|
||||
- **`src/tools.ts`** (392 行) — 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`.
|
||||
- **`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` 包导出。主要分类:
|
||||
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
|
||||
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
|
||||
@@ -124,6 +128,7 @@ bun run docs:dev
|
||||
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
|
||||
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
|
||||
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
|
||||
- **`src/tools/shared/`** / **`packages/builtin-tools/src/tools/shared/`** — Tool 共享工具函数。
|
||||
|
||||
### UI Layer (Ink)
|
||||
|
||||
@@ -168,12 +173,12 @@ bun run docs:dev
|
||||
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
|
||||
| `packages/color-diff-napi/` | 颜色差异计算(完整实现,11 tests) |
|
||||
| `packages/image-processor-napi/` | 图像处理(已恢复) |
|
||||
| `packages/modifiers-napi/` | 键盘修饰键检测(stub) |
|
||||
| `packages/url-handler-napi/` | URL scheme 处理(stub) |
|
||||
| `packages/modifiers-napi/` | 键盘修饰键检测(macOS FFI 实现) |
|
||||
| `packages/url-handler-napi/` | URL scheme 处理(环境变量 + CLI 参数读取) |
|
||||
|
||||
### Bridge / Remote Control
|
||||
|
||||
- **`src/bridge/`** (~38 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
||||
- **`src/bridge/`** — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
||||
- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板(React 19 + Vite + Radix UI)。支持 ACP agent 通过 acp-link 接入(ACP WebSocket handler、relay handler、SSE event stream)。通过 `bun run rcs` 启动。
|
||||
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。
|
||||
- 详见 `docs/features/remote-control-self-hosting.md`。
|
||||
@@ -215,7 +220,30 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
|
||||
### Multi-API 兼容层
|
||||
|
||||
支持 OpenAI、Gemini、Grok 三种第三方 API,通过 `/login` 命令配置,均采用流适配器模式转为 Anthropic 内部格式。详见各兼容层的 docs 文档。
|
||||
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。通过 `/login` 命令配置。
|
||||
|
||||
#### OpenAI 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI Chat Completions 协议端点。含 DeepSeek thinking mode 支持。
|
||||
|
||||
- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
|
||||
- 关键环境变量:`CLAUDE_CODE_USE_OPENAI`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`
|
||||
|
||||
#### Gemini 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用。独立环境变量体系。
|
||||
|
||||
- **`src/services/api/gemini/`** — client、模型映射、类型定义
|
||||
- 关键环境变量:`GEMINI_API_KEY`(必填)、`GEMINI_MODEL`(直接指定)、`GEMINI_DEFAULT_SONNET_MODEL`/`GEMINI_DEFAULT_OPUS_MODEL`(按能力映射)
|
||||
- 模型映射优先级:`GEMINI_MODEL` > `GEMINI_DEFAULT_*_MODEL` > `ANTHROPIC_DEFAULT_*_MODEL`(已废弃) > 原样返回
|
||||
|
||||
#### Grok 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GROK=1` 启用。自定义模型映射支持 xAI Grok API。
|
||||
|
||||
- **`src/services/api/grok/`** — client、模型映射
|
||||
|
||||
详见各兼容层的 docs 文档。
|
||||
|
||||
### 穷鬼模式(Budget Mode)
|
||||
|
||||
@@ -228,13 +256,13 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
| Module | Status |
|
||||
|--------|--------|
|
||||
| Computer Use (`@ant/*`) | Restored — macOS + Windows + Linux(后端完整度不一) |
|
||||
| `*-napi` packages | `audio-capture-napi`、`image-processor-napi` 已恢复;`color-diff-napi` 完整;`modifiers-napi`、`url-handler-napi` 仍为 stub |
|
||||
| `*-napi` packages | 全部已恢复/实现:`audio-capture-napi`、`image-processor-napi` 已恢复;`color-diff-napi` 完整;`modifiers-napi`(macOS FFI);`url-handler-napi`(环境变量+CLI) |
|
||||
| Voice Mode | Restored — Push-to-Talk 语音输入(需 Anthropic OAuth) |
|
||||
| OpenAI/Gemini/Grok 兼容层 | Restored |
|
||||
| Remote Control Server | Restored — 自托管 RCS + Web UI |
|
||||
| Analytics / GrowthBook / Sentry | Empty implementations |
|
||||
| Magic Docs / LSP Server | Removed |
|
||||
| Plugins / Marketplace | Removed |
|
||||
| Magic Docs / LSP Server | Restored — Magic Docs 自动更新 + LSP 服务器管理器 |
|
||||
| Plugins / Marketplace | Restored — 插件安装/卸载/启用/禁用 + Marketplace 浏览 |
|
||||
| MCP OAuth | Simplified |
|
||||
|
||||
### Key Type Files
|
||||
@@ -247,7 +275,6 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
## Testing
|
||||
|
||||
- **框架**: `bun:test`(内置断言 + mock)
|
||||
- **当前状态**: 3175 tests / 207 files / 0 fail
|
||||
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
|
||||
- **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain)
|
||||
- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/)
|
||||
@@ -260,6 +287,18 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
|
||||
被迫 mock 的根源:`log.ts` / `debug.ts` → `bootstrap/state.ts`(模块级 `realpathSync` / `randomUUID` 副作用)。必须 mock 的模块:`log.ts`、`debug.ts`、`bun:bundle`、`settings/settings.js`、`config.ts`、`auth.ts`、第三方网络库。
|
||||
|
||||
**`log.ts` 和 `debug.ts` 使用共享 mock**(`tests/mocks/log.ts` / `tests/mocks/debug.ts`),不要在测试文件中内联 mock 定义。使用方式:
|
||||
|
||||
```ts
|
||||
import { logMock } from "../../../tests/mocks/log";
|
||||
mock.module("src/utils/log.ts", logMock);
|
||||
|
||||
import { debugMock } from "../../../../tests/mocks/debug";
|
||||
mock.module("src/utils/debug.ts", debugMock);
|
||||
```
|
||||
|
||||
源文件导出变更时只需更新 `tests/mocks/` 下的对应文件,不需要逐个修改测试。
|
||||
|
||||
不要 mock:纯函数模块(`errors.ts`、`stringUtils.js`)、mock 值与真实实现相同的模块、mock 路径与实际 import 不匹配的模块。
|
||||
|
||||
路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。
|
||||
@@ -269,7 +308,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
||||
|
||||
```bash
|
||||
bun run typecheck # equivalent to bun run typecheck
|
||||
bun run typecheck
|
||||
```
|
||||
|
||||
**类型规范**:
|
||||
|
||||
121
README.md
121
README.md
@@ -12,30 +12,32 @@
|
||||
|
||||
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠
|
||||
|
||||
> 我们将会在五一期间进行整个代码仓库的 lint 规范化, 这个期间提交的 PR 可能会有非常多的冲突, 所以大的功能请尽量在这之前提交哈
|
||||
|
||||
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/uApuzJWGKX)
|
||||
|
||||
| 特性 | 说明 | 文档 |
|
||||
|------|------|------|
|
||||
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/pipes-and-lan) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
|
||||
| **ACP 协议一等一支持** | 支持接入 Zed、Cursor 等 IDE,支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) |
|
||||
| **Remote Control 私有部署** | Docker 自托管远程界面, 可以手机上看 CC | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
|
||||
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
|
||||
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
|
||||
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
|
||||
| **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord/微信等),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) |
|
||||
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
|
||||
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
|
||||
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
|
||||
| Chrome Use | 浏览器自动化、表单填写、数据抓取 | [自托管](https://ccb.agent-aura.top/docs/features/chrome-use-mcp) [原生版](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) |
|
||||
| Sentry | 企业级错误追踪 | [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) |
|
||||
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
|
||||
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
|
||||
|
||||
| 特性 | 说明 | 文档 |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/uds-inbox) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
|
||||
| **ACP 协议一等一支持** | 支持接入 Zed、Cursor 等 IDE,支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) |
|
||||
| **Remote Control 私有部署** | Docker 自托管远程界面, 可以手机上看 CC | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
|
||||
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
|
||||
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
|
||||
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
|
||||
| **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord/微信等),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) |
|
||||
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 (`/login`) | [文档](https://ccb.agent-aura.top/docs/features/all-features-guide) |
|
||||
| Voice Mode | 语音输入,支持豆包语言输入(`/voice doubao`) | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
|
||||
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
|
||||
| Chrome Use | 浏览器自动化、表单填写、数据抓取 | [自托管](https://ccb.agent-aura.top/docs/features/chrome-use-mcp) [原生版](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) |
|
||||
| Sentry | 企业级错误追踪 | [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) |
|
||||
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
|
||||
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
|
||||
|
||||
- 🚀 [想要启动项目](#快速开始源码版)
|
||||
- 🐛 [想要调试项目](#vs-code-调试)
|
||||
- 📖 [想要学习项目](#teach-me-学习项目)
|
||||
|
||||
|
||||
## ⚡ 快速开始(安装版)
|
||||
|
||||
不用克隆仓库, 从 NPM 下载后, 直接使用
|
||||
@@ -45,10 +47,11 @@ npm i -g claude-code-best
|
||||
|
||||
# bun 安装比较多问题, 推荐 npm 装
|
||||
# bun i -g claude-code-best
|
||||
# bun pm -g trust claude-code-best
|
||||
# bun pm -g trust claude-code-best @claude-code-best/mcp-chrome-bridge
|
||||
|
||||
ccb # 以 nodejs 打开 claude code
|
||||
ccb-bun # 以 bun 形态打开
|
||||
ccb update # 更新到最新版本
|
||||
CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDGE_OAUTH_TOKEN=test-my-key ccb --remote-control # 我们有自部署的远程控制
|
||||
```
|
||||
|
||||
@@ -59,11 +62,66 @@ CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDG
|
||||
一定要最新版本的 bun 啊, 不然一堆奇奇怪怪的 BUG!!! bun upgrade!!!
|
||||
|
||||
- 📦 [Bun](https://bun.sh/) >= 1.3.11
|
||||
|
||||
**安装 Bun:**
|
||||
|
||||
```bash
|
||||
# Linux 和 macOS
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# Windows (PowerShell)
|
||||
powershell -c "irm bun.sh/install.ps1 | iex"
|
||||
```
|
||||
|
||||
**安装后的操作:**
|
||||
|
||||
1. **让当前终端识别 `bun` 命令**
|
||||
|
||||
安装脚本会把 `~/.bun/bin` 写入对应的 shell 配置文件。macOS 默认 zsh 环境通常会看到:
|
||||
|
||||
```text
|
||||
Added "~/.bun/bin" to $PATH in "~/.zshrc"
|
||||
```
|
||||
|
||||
可以按安装脚本提示重启当前 shell:
|
||||
|
||||
```bash
|
||||
exec /bin/zsh
|
||||
```
|
||||
|
||||
如果你使用 bash,重新加载 bash 配置:
|
||||
|
||||
```bash
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
Windows PowerShell 用户关闭并重新打开 PowerShell 即可。
|
||||
|
||||
2. **验证 Bun 是否可用**
|
||||
|
||||
```bash
|
||||
bun --help
|
||||
bun --version
|
||||
```
|
||||
|
||||
3. **如果已经安装过 Bun,更新到最新版本**
|
||||
|
||||
```bash
|
||||
bun upgrade
|
||||
```
|
||||
|
||||
- ⚙️ 常规的配置 CC 的方式, 各大提供商都有自己的配置方式
|
||||
|
||||
### 📍 命令执行位置
|
||||
|
||||
- 安装或检查 Bun 的命令可以在任意目录执行:
|
||||
`curl -fsSL https://bun.sh/install | bash`、`bun --help`、`bun --version`、`bun upgrade`
|
||||
- 安装本项目依赖、启动开发模式、构建项目时,必须先进入本仓库根目录,也就是包含 `package.json` 的目录。
|
||||
|
||||
### 📥 安装
|
||||
|
||||
```bash
|
||||
cd /path/to/claude-code
|
||||
bun install
|
||||
```
|
||||
|
||||
@@ -90,17 +148,17 @@ bun run build
|
||||
|
||||
需要填写的字段:
|
||||
|
||||
| 📌 字段 | 📝 说明 | 💡 示例 |
|
||||
|------|------|------|
|
||||
| Base URL | API 服务地址 | `https://api.example.com/v1` |
|
||||
| API Key | 认证密钥 | `sk-xxx` |
|
||||
| Haiku Model | 快速模型 ID | `claude-haiku-4-5-20251001` |
|
||||
| Sonnet Model | 均衡模型 ID | `claude-sonnet-4-6` |
|
||||
| Opus Model | 高性能模型 ID | `claude-opus-4-6` |
|
||||
|
||||
| 📌 字段 | 📝 说明 | 💡 示例 |
|
||||
| ------------ | ------------- | ---------------------------- |
|
||||
| Base URL | API 服务地址 | `https://api.example.com/v1` |
|
||||
| API Key | 认证密钥 | `sk-xxx` |
|
||||
| Haiku Model | 快速模型 ID | `claude-haiku-4-5-20251001` |
|
||||
| Sonnet Model | 均衡模型 ID | `claude-sonnet-4-6` |
|
||||
| Opus Model | 高性能模型 ID | `claude-opus-4-6` |
|
||||
|
||||
- ⌨️ **Tab / Shift+Tab** 切换字段,**Enter** 确认并跳到下一个,最后一个字段按 Enter 保存
|
||||
|
||||
|
||||
> ℹ️ 支持所有 Anthropic API 兼容服务(如 OpenRouter、AWS Bedrock 代理等),只要接口兼容 Messages API 即可。
|
||||
|
||||
## Feature Flags
|
||||
@@ -120,16 +178,17 @@ TUI (REPL) 模式需要真实终端,无法直接通过 VS Code launch 启动
|
||||
### 步骤
|
||||
|
||||
1. **终端启动 inspect 服务**:
|
||||
|
||||
```bash
|
||||
bun run dev:inspect
|
||||
```
|
||||
会输出类似 `ws://localhost:8888/xxxxxxxx` 的地址。
|
||||
|
||||
会输出类似 `ws://localhost:8888/xxxxxxxx` 的地址。
|
||||
2. **VS Code 附着调试器**:
|
||||
|
||||
- 在 `src/` 文件中打断点
|
||||
- F5 → 选择 **"Attach to Bun (TUI debug)"**
|
||||
|
||||
|
||||
## Teach Me 学习项目
|
||||
|
||||
我们新加了一个 teach-me skills, 通过问答式引导帮你理解这个项目的任何模块。(调整 [sigma skill 而来](https://github.com/sanyuan0704/sanyuan-skills))
|
||||
@@ -156,7 +215,7 @@ TUI (REPL) 模式需要真实终端,无法直接通过 VS Code launch 启动
|
||||
## 相关文档及网站
|
||||
|
||||
- **在线文档(Mintlify)**: [ccb.agent-aura.top](https://ccb.agent-aura.top/) — 文档源码位于 [`docs/`](docs/) 目录,欢迎投稿 PR
|
||||
- **DeepWiki**: <https://deepwiki.com/claude-code-best/claude-code>
|
||||
- **DeepWiki**: [https://deepwiki.com/claude-code-best/claude-code](https://deepwiki.com/claude-code-best/claude-code)
|
||||
|
||||
## Contributors
|
||||
|
||||
@@ -174,6 +233,10 @@ TUI (REPL) 模式需要真实终端,无法直接通过 VS Code launch 启动
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 致谢
|
||||
|
||||
- [doubaoime-asr](https://github.com/starccy/doubaoime-asr) — 豆包 ASR 语音识别 SDK,为 Voice Mode 提供无需 Anthropic OAuth 的语音输入方案
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目仅供学习研究用途。Claude Code 的所有权利归 [Anthropic](https://www.anthropic.com/) 所有。
|
||||
|
||||
55
README_EN.md
55
README_EN.md
@@ -48,11 +48,64 @@ Sponsor placeholder.
|
||||
Make sure you're on the latest version of Bun, otherwise you'll run into all sorts of weird bugs. Run `bun upgrade`!
|
||||
|
||||
- [Bun](https://bun.sh/) >= 1.3.11
|
||||
|
||||
**Install Bun:**
|
||||
|
||||
```bash
|
||||
# Linux and macOS
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# Windows (PowerShell)
|
||||
powershell -c "irm bun.sh/install.ps1 | iex"
|
||||
```
|
||||
|
||||
**Post-installation steps:**
|
||||
|
||||
1. **Make `bun` available in the current terminal**
|
||||
|
||||
The installer adds `~/.bun/bin` to the matching shell configuration file. On macOS with the default zsh shell, you may see:
|
||||
|
||||
```text
|
||||
Added "~/.bun/bin" to $PATH in "~/.zshrc"
|
||||
```
|
||||
|
||||
Restart the current shell as the installer suggests:
|
||||
|
||||
```bash
|
||||
exec /bin/zsh
|
||||
```
|
||||
|
||||
If you use bash, reload the bash configuration:
|
||||
|
||||
```bash
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
Windows PowerShell users can close and reopen PowerShell.
|
||||
|
||||
2. **Verify that Bun is available:**
|
||||
```bash
|
||||
bun --help
|
||||
bun --version
|
||||
```
|
||||
|
||||
3. **Update to latest version (if already installed):**
|
||||
```bash
|
||||
bun upgrade
|
||||
```
|
||||
|
||||
- Standard Claude Code configuration — each provider has its own setup method
|
||||
|
||||
### Command Execution Location
|
||||
|
||||
- Bun installation and checking commands can be run from any directory:
|
||||
`curl -fsSL https://bun.sh/install | bash`, `bun --help`, `bun --version`, `bun upgrade`
|
||||
- Project dependency installation, development mode, and builds must be run from this repository root, the directory containing `package.json`.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
cd /path/to/claude-code
|
||||
bun install
|
||||
```
|
||||
|
||||
@@ -135,7 +188,7 @@ The TUI (REPL) mode requires a real terminal and cannot be launched directly via
|
||||
## Documentation & Links
|
||||
|
||||
- **Online docs (Mintlify)**: [ccb.agent-aura.top](https://ccb.agent-aura.top/) — source in [`docs/`](docs/), PR contributions welcome
|
||||
- **DeepWiki**: <https://deepwiki.com/claude-code-best/claude-code>
|
||||
- **DeepWiki**: https://deepwiki.com/claude-code-best/claude-code
|
||||
|
||||
## Contributors
|
||||
|
||||
|
||||
55
build.ts
55
build.ts
@@ -1,6 +1,7 @@
|
||||
import { readdir, readFile, writeFile, cp } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { getMacroDefines } from './scripts/defines.ts'
|
||||
import { DEFAULT_BUILD_FEATURES } from './scripts/defines.ts'
|
||||
|
||||
const outdir = 'dist'
|
||||
|
||||
@@ -8,48 +9,6 @@ const outdir = 'dist'
|
||||
const { rmSync } = await import('fs')
|
||||
rmSync(outdir, { recursive: true, force: true })
|
||||
|
||||
// Default features that match the official CLI build.
|
||||
// Additional features can be enabled via FEATURE_<NAME>=1 env vars.
|
||||
const DEFAULT_BUILD_FEATURES = [
|
||||
'BUDDY', 'TRANSCRIPT_CLASSIFIER', 'BRIDGE_MODE',
|
||||
'AGENT_TRIGGERS_REMOTE',
|
||||
'CHICAGO_MCP',
|
||||
'VOICE_MODE',
|
||||
'SHOT_STATS',
|
||||
'PROMPT_CACHE_BREAK_DETECTION',
|
||||
'TOKEN_BUDGET',
|
||||
// P0: local features
|
||||
'AGENT_TRIGGERS',
|
||||
'ULTRATHINK',
|
||||
'BUILTIN_EXPLORE_PLAN_AGENTS',
|
||||
'LODESTONE',
|
||||
// P1: API-dependent features
|
||||
'EXTRACT_MEMORIES',
|
||||
'VERIFICATION_AGENT',
|
||||
'KAIROS_BRIEF',
|
||||
'AWAY_SUMMARY',
|
||||
'ULTRAPLAN',
|
||||
// P2: daemon + remote control server
|
||||
'DAEMON',
|
||||
// ACP (Agent Client Protocol) agent mode
|
||||
'ACP',
|
||||
// PR-package restored features
|
||||
'WORKFLOW_SCRIPTS',
|
||||
'HISTORY_SNIP',
|
||||
'CONTEXT_COLLAPSE',
|
||||
'MONITOR_TOOL',
|
||||
'FORK_SUBAGENT',
|
||||
// 'UDS_INBOX',
|
||||
'KAIROS',
|
||||
'COORDINATOR_MODE',
|
||||
'LAN_PIPES',
|
||||
'BG_SESSIONS',
|
||||
'TEMPLATES',
|
||||
// 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性
|
||||
// P3: poor mode (disable extract_memories + prompt_suggestion)
|
||||
'POOR',
|
||||
]
|
||||
|
||||
// Collect FEATURE_* env vars → Bun.build features
|
||||
const envFeatures = Object.keys(process.env)
|
||||
.filter(k => k.startsWith('FEATURE_'))
|
||||
@@ -116,10 +75,14 @@ console.log(
|
||||
`Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for import.meta.require, ${bunPatched} for Bun destructure)`,
|
||||
)
|
||||
|
||||
// Step 4: Copy native .node addon files (audio-capture)
|
||||
const vendorDir = join(outdir, 'vendor', 'audio-capture')
|
||||
await cp('vendor/audio-capture', vendorDir, { recursive: true })
|
||||
console.log(`Copied vendor/audio-capture/ → ${vendorDir}/`)
|
||||
// Step 4: Copy native .node addon files (audio-capture) and vendored binaries (ripgrep)
|
||||
const audioCaptureDir = join(outdir, 'vendor', 'audio-capture')
|
||||
await cp('vendor/audio-capture', audioCaptureDir, { recursive: true })
|
||||
console.log(`Copied vendor/audio-capture/ → ${audioCaptureDir}/`)
|
||||
|
||||
const ripgrepDir = join(outdir, 'vendor', 'ripgrep')
|
||||
await cp('src/utils/vendor/ripgrep', ripgrepDir, { recursive: true })
|
||||
console.log(`Copied src/utils/vendor/ripgrep/ → ${ripgrepDir}/`)
|
||||
|
||||
// Step 5: Generate cli-bun and cli-node executable entry points
|
||||
const cliBun = join(outdir, 'cli-bun.js')
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
# Claude Code 文档站章节重构方案
|
||||
|
||||
> 按功能域重新组织,专有名词保留英文,其余简化为中文。
|
||||
|
||||
---
|
||||
|
||||
## 一、导航结构
|
||||
|
||||
```
|
||||
开始使用
|
||||
├── 项目介绍
|
||||
├── 项目动机
|
||||
└── 架构总览
|
||||
|
||||
核心机制
|
||||
├── Agent Loop
|
||||
├── 流式响应
|
||||
├── 多轮对话
|
||||
├── 系统提示词
|
||||
└── 深度规划
|
||||
|
||||
上下文管理
|
||||
├── 令牌预算
|
||||
├── 上下文压缩
|
||||
├── 项目记忆
|
||||
├── 自动记忆整理
|
||||
└── 穷鬼模式
|
||||
|
||||
工具系统
|
||||
├── 工具总览
|
||||
├── 文件操作
|
||||
├── 命令执行
|
||||
├── 搜索导航
|
||||
├── 任务管理
|
||||
├── 网页搜索
|
||||
├── 桌面自动化
|
||||
└── 浏览器控制
|
||||
|
||||
多智能体
|
||||
├── 子智能体
|
||||
├── 自定义智能体
|
||||
├── 工作树隔离
|
||||
├── 协调模式
|
||||
├── 团队记忆
|
||||
└── 后台会话
|
||||
|
||||
MCP
|
||||
└── MCP 详解
|
||||
|
||||
Hook 与 Plugin
|
||||
├── Hook 钩子
|
||||
├── 插件市场
|
||||
└── LSP 集成
|
||||
|
||||
Skills
|
||||
├── Skill 技能系统
|
||||
├── Workflow 脚本
|
||||
└── Skill 搜索
|
||||
|
||||
远程控制
|
||||
├── 远程控制
|
||||
├── ACP 接入
|
||||
├── 消息通道
|
||||
├── 本地通信
|
||||
└── 常驻助手
|
||||
|
||||
安全机制
|
||||
├── 安全概述
|
||||
├── 权限模型
|
||||
├── 沙箱
|
||||
├── Bash 检查
|
||||
├── 规划模式
|
||||
└── 自动模式
|
||||
|
||||
额外功能
|
||||
├── 特性开关
|
||||
├── A/B 测试与配置
|
||||
├── 错误追踪
|
||||
├── 隐藏功能
|
||||
├── Buddy 助手
|
||||
├── 命令分类
|
||||
└── 语音模式
|
||||
|
||||
开发人员
|
||||
├── 可观测性
|
||||
├── 调试模式
|
||||
└── 专属特性
|
||||
|
||||
基础设施
|
||||
├── 守护进程
|
||||
└── 自动更新
|
||||
|
||||
附录
|
||||
├── 任务追踪
|
||||
├── 测试计划
|
||||
└── 设计文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、标题映射
|
||||
|
||||
| 导航标题 | 新文件路径 | 合并来源 |
|
||||
|---------|-----------|---------|
|
||||
| 项目介绍 | docs/introduction/what-is-claude-code | (不变) |
|
||||
| 项目动机 | docs/introduction/why-this-whitepaper | (不变) |
|
||||
| 架构总览 | docs/introduction/architecture-overview | (不变) |
|
||||
| Agent Loop | docs/conversation/the-loop | (不变) |
|
||||
| 流式响应 | docs/conversation/streaming | (不变) |
|
||||
| 多轮对话 | docs/conversation/multi-turn | (不变) |
|
||||
| 系统提示词 | docs/context/system-prompt | (不变) |
|
||||
| 语音模式 | docs/features/voice-mode | (不变) |
|
||||
| 深度规划 | docs/features/ultraplan | (不变) |
|
||||
| 令牌预算 | docs/context/token-budget | (不变) |
|
||||
| 上下文压缩 | docs/context/compaction | (不变) |
|
||||
| 项目记忆 | docs/context/project-memory | (不变) |
|
||||
| 自动记忆整理 | docs/features/auto-dream | (不变) |
|
||||
| 穷鬼模式 | (新写) | — |
|
||||
| 工具总览 | docs/tools/what-are-tools | (不变) |
|
||||
| 文件操作 | docs/tools/file-operations | (不变) |
|
||||
| 命令执行 | docs/tools/shell-execution | (不变) |
|
||||
| 搜索导航 | docs/tools/search-and-navigation | (不变) |
|
||||
| 任务管理 | docs/tools/task-management | (不变) |
|
||||
| 网页搜索 | docs/features/web-search-tool | (不变) |
|
||||
| 桌面自动化 | docs/features/computer-use | (不变) |
|
||||
| 浏览器控制 | docs/features/claude-in-chrome-mcp | (不变) |
|
||||
| 子智能体 | docs/agent/sub-agents | ← sub-agents + features/fork-subagent |
|
||||
| 自定义智能体 | docs/extensibility/custom-agents | (不变) |
|
||||
| 工作树隔离 | docs/agent/worktree-isolation | (不变) |
|
||||
| 协调模式 | docs/agent/coordinator-and-swarm | (不变) |
|
||||
| 团队记忆 | docs/features/teammem | (不变) |
|
||||
| 后台会话 | docs/features/pipes-and-lan | (不变) |
|
||||
| MCP 详解 | docs/extensibility/mcp | ← extensibility/mcp-protocol + extensibility/mcp-configuration + features/mcp-skills |
|
||||
| 插件市场 | (新写) | — |
|
||||
| Hook 钩子 | docs/extensibility/hooks | (不变) |
|
||||
| Skill 技能系统 | docs/extensibility/skills | (不变) |
|
||||
| Workflow 脚本 | docs/features/workflow-scripts | (不变) |
|
||||
| Skill 搜索 | docs/features/experimental-skill-search | (不变) |
|
||||
| 远程控制 | docs/features/remote-control | ← features/bridge-mode + features/remote-control-self-hosting |
|
||||
| ACP 接入 | docs/features/acp-link | (不变) |
|
||||
| 消息通道 | docs/features/channels | (不变) |
|
||||
| 本地通信 | docs/features/proactive | (不变) |
|
||||
| 常驻助手 | docs/features/kairos | ← features/proactive + features/kairos |
|
||||
| 安全概述 | docs/safety/why-safety-matters | (不变) |
|
||||
| 权限模型 | docs/safety/permission-model | (不变) |
|
||||
| 沙箱 | docs/safety/sandbox | (不变) |
|
||||
| Bash 检查 | docs/features/tree-sitter-bash | ← features/tree-sitter-bash + features/bash-classifier |
|
||||
| 规划模式 | docs/safety/plan-mode | (不变) |
|
||||
| 自动模式 | docs/safety/auto-mode | (不变) |
|
||||
| 特性开关 | docs/internals/feature-flags | (不变) |
|
||||
| A/B 测试与配置 | docs/internals/growthbook | ← internals/growthbook-ab-testing + internals/growthbook-adapter |
|
||||
| 错误追踪 | docs/internals/sentry-setup | (不变) |
|
||||
| 隐藏功能 | docs/internals/hidden-features | (不变) |
|
||||
| 专属特性 | docs/internals/ant-only-world | (不变,移至开发人员) |
|
||||
| 调试模式 | docs/features/debug-mode | (不变,移至开发人员) |
|
||||
| Buddy 助手 | docs/features/buddy | (不变) |
|
||||
| 命令分类 | docs/features/bash-classifier | (不变) |
|
||||
| 可观测性 | docs/features/langfuse-monitoring | (不变) |
|
||||
| 守护进程 | docs/features/daemon | (不变) |
|
||||
| 自动更新 | docs/auto-updater | (不变) |
|
||||
| LSP 集成 | docs/lsp-integration | (不变) |
|
||||
@@ -1,101 +1,251 @@
|
||||
---
|
||||
title: "协调者与蜂群"
|
||||
description: "两种多 Agent 协作模式:Coordinator 的星型编排和 Swarm 的竞争认领。理解各自的设计权衡和适用场景。"
|
||||
title: "协调者与蜂群模式 - 多 Agent 高级编排"
|
||||
description: "从源码角度解析 Claude Code 多 Agent 协作:Coordinator Mode 的 System Prompt 设计、Worker 生命周期、Task 通信协议和 Swarm 蜂群的任务分配机制。"
|
||||
keywords: ["协调者模式", "蜂群模式", "Agent Swarm", "多 Agent 协作", "任务编排"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:从源码角度揭示 Coordinator Mode 和 Agent Swarms 的架构设计 */}
|
||||
|
||||
单个 AI Agent 的上下文窗口有限。当任务复杂到需要同时理解多个模块、执行多个并行操作时,单个 Agent 会力不从心。
|
||||
|
||||
多 Agent 协作的目标:让多个 AI Agent 分工合作,各自拥有独立的上下文窗口,协同完成复杂任务。
|
||||
|
||||
## 两种协作模式
|
||||
## 两种协作模式的架构差异
|
||||
|
||||
| 维度 | Coordinator Mode | Agent Swarms |
|
||||
|------|-----------------|--------------|
|
||||
| **拓扑** | 星型:Coordinator 居中编排 | 星型 + P2P:Teammate 间可直接通信 |
|
||||
| **角色** | Coordinator 理解决策,Worker 执行 | Team Lead 协调,Teammate 自主认领 |
|
||||
| **适用** | 需要集中决策的复杂任务 | 并行度高、任务独立的场景 |
|
||||
| **门控** | `feature('COORDINATOR_MODE')` + `CLAUDE_CODE_COORDINATOR_MODE=1` | `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` 环境变量 |
|
||||
| **拓扑** | 星型:Coordinator 居中,Worker 外围 | 星型+P2P 混合:Team Lead 协调,Teammate 间可直接通信 |
|
||||
| **角色** | 明确分工:Coordinator 编排、Worker 执行 | Team Lead 协调 + Teammate 自主认领任务 |
|
||||
| **通信** | `SendMessage` 定向通信 + `<task-notification>` | Mailbox 消息系统(message / broadcast) |
|
||||
| **适用** | 需要集中决策的复杂任务 | 并行度高、需要 Teammate 间直接协作的任务 |
|
||||
|
||||
两者不是互斥的——可以组合使用,但那属于高级用法。
|
||||
两者不是互斥的——理论上 Coordinator Mode 可以在 Agent Teams 架构之上运行(概念层叠加,非嵌套团队),将 Coordinator 作为特殊的 Team Lead,但这部分集成(`workerAgent.ts` 中的 `getCoordinatorAgents`)目前为 stub 实现,尚未完整落地。
|
||||
|
||||
## Coordinator Mode:先理解,再分配
|
||||
## Coordinator Mode:星型编排架构
|
||||
|
||||
### 设计哲学
|
||||
### 激活机制
|
||||
|
||||
Coordinator Mode 最核心的约束:**Coordinator 必须先理解问题,再分配任务。**
|
||||
```typescript
|
||||
// src/coordinator/coordinatorMode.ts:36
|
||||
export function isCoordinatorMode(): boolean {
|
||||
if (feature('COORDINATOR_MODE')) {
|
||||
return isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)
|
||||
}
|
||||
return false // 外部构建始终 false
|
||||
}
|
||||
```
|
||||
|
||||
Coordinator Mode 需要双重门控:构建时 `feature('COORDINATOR_MODE')` 和运行时环境变量。`matchSessionMode()` 在会话恢复时自动同步模式状态——如果恢复的会话是 coordinator 模式,它会翻转环境变量以确保一致性。
|
||||
|
||||
### Coordinator 的工具集
|
||||
|
||||
Coordinator 被剥夺了所有"动手"工具,只保留编排能力:
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| **Agent** | 启动新 Worker(`subagent_type: "worker"`) |
|
||||
| **SendMessage** | 向已有 Worker 发送后续指令 |
|
||||
| **TaskStop** | 中途停止走错方向的 Worker |
|
||||
| **subscribe_pr_activity** | 订阅 GitHub PR 事件(review comments、CI 结果) |
|
||||
|
||||
Coordinator **不写代码、不读文件、不执行命令**——它的核心职责是:理解需求、分配任务、综合结果,以及在无需工具时直接回答用户问题。
|
||||
|
||||
### Worker 的工具权限
|
||||
|
||||
Worker 的可用工具由 `getCoordinatorUserContext()`(`coordinatorMode.ts:80`)动态注入到 System Prompt:
|
||||
|
||||
```typescript
|
||||
// 简化模式下:只有 Bash + Read + Edit
|
||||
const workerTools = isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
|
||||
? [BASH_TOOL_NAME, FILE_READ_TOOL_NAME, FILE_EDIT_TOOL_NAME]
|
||||
: Array.from(ASYNC_AGENT_ALLOWED_TOOLS)
|
||||
.filter(name => !INTERNAL_WORKER_TOOLS.has(name))
|
||||
```
|
||||
|
||||
`INTERNAL_WORKER_TOOLS`(TeamCreate、TeamDelete、SendMessage、SyntheticOutput)被显式排除——Worker 不能嵌套创建团队或发送消息,防止不可控的递归。
|
||||
|
||||
### Scratchpad:跨 Worker 的共享知识库
|
||||
|
||||
当 `isScratchpadGateEnabled()`(内部检查 `tengu_scratch` feature gate)启用时,Workers 获得一个 Scratchpad 目录,Coordinator 通过其系统上下文知晓该目录的存在:
|
||||
|
||||
```
|
||||
Scratchpad 目录:
|
||||
- Workers 可自由读写,无需权限审批
|
||||
- 用于持久化的跨 Worker 知识
|
||||
- 结构由 Coordinator 决定(无固定格式)
|
||||
```
|
||||
|
||||
这是一个关键的协作原语——Worker A 的研究结果可以写入 Scratchpad,Worker B 直接读取,无需通过 Coordinator 中转。
|
||||
|
||||
### `<task-notification>` 通信协议
|
||||
|
||||
Worker 完成后,Coordinator 收到 XML 格式的通知:
|
||||
|
||||
```xml
|
||||
<task-notification>
|
||||
<task-id>agent-a1b</task-id> ← Worker 的 agentId
|
||||
<status>completed|failed|killed</status>
|
||||
<summary>Agent "Investigate auth bug" completed</summary>
|
||||
<result>Found null pointer in src/auth/validate.ts:42...</result>
|
||||
<usage>
|
||||
<total_tokens>N</total_tokens>
|
||||
<tool_uses>N</tool_uses>
|
||||
<duration_ms>N</duration_ms>
|
||||
</usage>
|
||||
</task-notification>
|
||||
```
|
||||
|
||||
通知以 `user-role message` 形式送达,Coordinator 通过 `<task-notification>` 标签区分它和用户消息。`<task-id>` 用于 `SendMessage` 的 `to` 参数,实现定向续传。
|
||||
|
||||
### Coordinator 的核心职责:综合(Synthesis)
|
||||
|
||||
Coordinator System Prompt(`coordinatorMode.ts:111-369`,约 260 行)明确要求 Coordinator **不能懒惰地委派理解**:
|
||||
|
||||
```
|
||||
反模式(禁止):
|
||||
> "Based on your findings, fix the auth bug" — 把理解的责任推给了 Worker
|
||||
"Based on your findings, fix the auth bug"
|
||||
→ 把理解的责任推给了 Worker
|
||||
|
||||
正确做法:
|
||||
> "Fix the null pointer in src/auth/validate.ts:42. The user field is undefined when sessions expire but the token remains cached." — Coordinator 自己理解了问题,给出精确指令
|
||||
"Fix the null pointer in src/auth/validate.ts:42.
|
||||
The user field on Session (src/auth/types.ts:15) is
|
||||
undefined when sessions expire but the token remains cached.
|
||||
Add a null check before user.id access."
|
||||
→ Coordinator 自己理解了问题,给出精确指令
|
||||
```
|
||||
|
||||
**设计考量**:如果 Coordinator 不理解问题就分配任务,Worker 会因为缺乏上下文而做出错误的决策。Coordinator 的理解质量直接决定了 Worker 的执行质量。
|
||||
这是 Coordinator Mode 最核心的设计约束:Coordinator 必须先理解,再分配。
|
||||
|
||||
### Coordinator 的工具限制
|
||||
## Agent Teams (Swarm):蜂群式协作
|
||||
|
||||
Coordinator 被剥夺了所有"动手"工具——不写代码、不读文件、不执行命令。只保留编排能力:启动 Worker、发送指令、停止走错方向的 Worker。
|
||||
Swarm 模式基于任务系统 V2(详见[任务管理](../tools/task-management.mdx)),核心机制是**共享任务列表 + 竞争认领 + Mailbox 消息系统**:
|
||||
|
||||
**设计洞察**:限制工具不是能力的削弱,而是职责的清晰化。Coordinator 专注于"想",Worker 专注于"做"。如果 Coordinator 也能动手,它很容易跳过编排直接自己干,失去了多 Agent 的意义。
|
||||
### 团队初始化
|
||||
|
||||
### Scratchpad:跨 Worker 共享知识
|
||||
```
|
||||
Team Lead 创建团队(TeamCreateTool)
|
||||
↓
|
||||
设置 teamName → setLeaderTeamName()
|
||||
↓
|
||||
所有 Teammate 自动获得相同的 taskListId
|
||||
↓
|
||||
Teammate 启动时:
|
||||
1. CLAUDE_CODE_TASK_LIST_ID 环境变量(显式覆盖)
|
||||
2. Teammate 上下文的 teamName(共享 Lead 的任务列表)
|
||||
3. CLAUDE_CODE_TEAM_NAME 环境变量
|
||||
4. Lead 设置的 teamName
|
||||
5. getSessionId()(兜底)
|
||||
```
|
||||
|
||||
Workers 共享一个 Scratchpad 目录,可以自由读写。Worker A 的研究结果可以写入 Scratchpad,Worker B 直接读取,无需通过 Coordinator 中转。
|
||||
多级优先级确保了 Team Lead 和所有 Teammate 指向同一个任务列表,无需额外协调。
|
||||
|
||||
**设计考量**:不是所有知识都需要通过 Coordinator 中转。中间结果(如搜索结果、分析报告)适合 Worker 间直接共享,只有最终结论和决策才需要 Coordinator 参与。
|
||||
### 架构组件
|
||||
|
||||
### 通信协议
|
||||
官方 Agent Teams 架构定义了四个核心组件:
|
||||
|
||||
Worker 完成后,Coordinator 收到结构化通知,包含状态、结果摘要和 token 使用统计。Coordinator 可以向任何 Worker 发送后续指令,实现"分配 → 执行 → 反馈 → 追加"的循环。
|
||||
|
||||
## Agent Teams (Swarm):竞争认领
|
||||
|
||||
### 设计哲学
|
||||
|
||||
Swarm 基于一个简单的假设:**如果任务列表是共享的、可见的,多个 Agent 会自然地分工合作。**
|
||||
|
||||
不需要一个中央调度器——每个 Teammate 看到任务列表,自己决定认领哪个任务。这和人类团队的工作方式一致:站会上大家看到看板,自己选择下一步做什么。
|
||||
|
||||
### 核心机制
|
||||
|
||||
| 机制 | 说明 |
|
||||
| 组件 | 角色 |
|
||||
|------|------|
|
||||
| **共享任务列表** | 所有 Teammate 看到同一个任务池 |
|
||||
| **竞争认领** | 第一个锁定任务的 Teammate 获得执行权 |
|
||||
| **Mailbox 消息** | Teammate 间可直接通信,无需经过 Lead |
|
||||
| **异常恢复** | Teammate 崩溃后,其未完成任务被自动重置 |
|
||||
| **Team Lead** | 创建团队、分配任务、综合结果的主 Claude Code 会话 |
|
||||
| **Teammate** | 独立的 Claude Code 实例,各自拥有独立的上下文窗口 |
|
||||
| **Task List** | 共享的任务列表,Teammate 竞争认领和完成 |
|
||||
| **Mailbox** | 消息系统,支持 Teammate 间直接通信 |
|
||||
|
||||
### 竞争认领的并发安全
|
||||
### Mailbox 消息系统
|
||||
|
||||
两个 Teammate 可能同时想认领同一个任务。文件锁保证原子性——第一个写入者获得锁定,第二个收到 `already_claimed` 错误后选择下一个任务。
|
||||
官方架构中的 Mailbox 是 Teammate 间通信的核心原语,支持两种消息模式(`broadcast` 模式来自源码推断,官方文档未明确细分):
|
||||
|
||||
**设计考量**:竞争是特性而非缺陷。它确保任务自然地分配给最先空闲的 Agent,不需要中央调度。但需要防止"饿死"——如果某个 Agent 总是慢半拍,它可能永远抢不到任务。实践中这个问题不严重,因为 AI Agent 的执行速度差异不大。
|
||||
| 模式 | 作用 | 场景 |
|
||||
|------|------|------|
|
||||
| **message** | 定向发送给指定 Teammate | 传递具体指令、请求协作 |
|
||||
| **broadcast** | 广播给所有 Teammate | 全局通知、状态同步 |
|
||||
|
||||
### Teammate 的生命周期
|
||||
Mailbox 的关键特性:
|
||||
- **自动投递**:消息自动送达目标 Teammate 的对话上下文
|
||||
- **空闲通知**(TeammateIdle):Teammate 完成当前任务进入空闲时,自动通过 Mailbox 通知 Team Lead
|
||||
- **直接通信**:与 Coordinator Mode 不同,Teammate 之间可以直接通信,无需经过 Lead 中转
|
||||
|
||||
Teammate 异常退出时,其未完成任务被自动重置为无主状态。Team Lead 通过共享任务列表或空闲通知感知到变化,可以重新分配或创建新 Teammate。
|
||||
### Hook 事件
|
||||
|
||||
**设计哲学**:单个 Agent 的崩溃不应该阻塞整个团队。任务系统保证工作不会因为个别 Agent 的失败而丢失。
|
||||
Agent Teams 提供三个关键 Hook 事件,用于在团队生命周期中注入自定义逻辑:
|
||||
|
||||
### 当前限制
|
||||
| Hook | 触发时机 | 典型用途 |
|
||||
|------|---------|---------|
|
||||
| **TaskCreated** | 新任务添加到任务列表时 | 自动分配、优先级排序 |
|
||||
| **TaskCompleted** | 任务标记为完成时 | 结果通知、依赖解锁 |
|
||||
| **TeammateIdle** | Teammate 完成所有任务进入空闲时 | Lead 重新分配、动态扩缩容 |
|
||||
|
||||
- 不支持嵌套团队(Teammate 不能创建子团队)
|
||||
- 每会话一个团队
|
||||
- Lead 固定不可更换
|
||||
### 限制
|
||||
|
||||
## 模式选择指南
|
||||
当前 Agent Teams 实现的限制:
|
||||
- **不支持嵌套团队**:Teammate 不能再创建子团队
|
||||
- **每 session 一个团队**:一个会话只能属于一个团队
|
||||
- **Lead 固定**:Team Lead 创建后不可更换
|
||||
- **不支持 in-process Teammate 的会话恢复**:进程重启后 in-process 类型 Teammate 的状态丢失
|
||||
|
||||
### 持久化存储
|
||||
|
||||
团队状态通过文件系统持久化,确保进程重启后可恢复:
|
||||
|
||||
```
|
||||
~/.claude/teams/{team-name}/config.json ← 团队配置
|
||||
~/.claude/tasks/{team-name}/ ← 共享任务列表(文件锁保护)
|
||||
```
|
||||
|
||||
### 任务认领与竞争
|
||||
|
||||
`claimTask()` 是 Agent Teams 的核心并发原语:
|
||||
|
||||
```
|
||||
Teammate A 调用 TaskList → 发现 task #3 是 pending
|
||||
Teammate B 同时发现 task #3 是 pending
|
||||
↓
|
||||
两者同时尝试 TaskUpdate(task #3, {status: "in_progress"})
|
||||
↓
|
||||
文件锁保证原子性:
|
||||
- 第一个写入者获得 owner 锁定
|
||||
- 第二个写入者收到 already_claimed 错误
|
||||
↓
|
||||
获得任务的 teammate 执行工作
|
||||
↓
|
||||
完成后 TaskUpdate(task #3, {status: "completed"})
|
||||
→ 依赖此任务的其他任务自动解锁
|
||||
→ tool_result 提示 "Call TaskList to find your next task"
|
||||
```
|
||||
|
||||
### Teammate 的生命周期管理
|
||||
|
||||
```
|
||||
Teammate 异常退出
|
||||
↓
|
||||
unassignTeammateTasks()
|
||||
→ 扫描任务列表,找到 owner === teammateName 的未完成任务
|
||||
→ 重置为 pending + owner=undefined
|
||||
↓
|
||||
Team Lead 感知途径:
|
||||
1. 任务状态变化(pending 重置)—— 通过共享任务列表
|
||||
2. Mailbox 空闲通知(TeammateIdle hook)—— Teammate 停止时自动通知 Lead
|
||||
↓
|
||||
Team Lead 重新分配任务或创建新 Teammate
|
||||
```
|
||||
|
||||
## 任务类型全景
|
||||
|
||||
支撑多 Agent 协作的是 7 种任务类型(`src/tasks/types.ts`):
|
||||
|
||||
| 任务类型 | 运行位置 | 状态管理 | 适用场景 |
|
||||
|----------|---------|---------|---------|
|
||||
| **LocalAgentTask** | 本地子进程 | `LocalAgentTaskState` | 标准子 Agent 任务 |
|
||||
| **LocalShellTask** | 本地 shell | `LocalShellTaskState` | 后台 shell 命令 |
|
||||
| **InProcessTeammateTask** | 同进程内 | `InProcessTeammateTaskState` | 轻量级进程内队友 |
|
||||
| **RemoteAgentTask** | 远程服务器 | `RemoteAgentTaskState` | 分布式 Agent(CCR) |
|
||||
| **DreamTask** | 后台静默 | `DreamTaskState` | 后台自主整理记忆 |
|
||||
| **LocalWorkflowTask** | 本地 | `LocalWorkflowTaskState` | 工作流编排 |
|
||||
| **MonitorMcpTask** | 本地 | `MonitorMcpTaskState` | MCP 监控任务 |
|
||||
|
||||
`InProcessTeammateTask` 与 `LocalAgentTask` 的关键差异:前者共享进程的内存空间和基础设施状态(如 MCP 连接池),但有独立的对话上下文和工具权限;后者是完全隔离的子进程,启动开销更大但更安全。
|
||||
|
||||
## Coordinator vs Agent Teams 的选择
|
||||
|
||||
| 场景 | 推荐模式 | 原因 |
|
||||
|------|---------|------|
|
||||
| "重构认证系统,需要多模块协调" | Coordinator | 需要集中决策,Worker 间有依赖 |
|
||||
| "修复 10 个独立的 lint 警告" | Swarm | 任务独立,Teammate 可完全并行 |
|
||||
| "修复 10 个独立的 lint 警告" | Agent Teams | 任务独立,Teammate 可完全并行 |
|
||||
| "研究方案 A 和方案 B,然后选一个实现" | Coordinator | 先并行研究,再集中决策 |
|
||||
| "在大仓库中搜索所有 TODO 并分类" | Swarm | 无依赖,各自领任务即可 |
|
||||
|
||||
## 接下来
|
||||
|
||||
- **子 Agent** — 理解单个子 Agent 的创建和生命周期
|
||||
- **Worktree 隔离** — 理解多 Agent 的文件系统隔离
|
||||
- **任务管理** — 理解支撑协作的任务系统
|
||||
| "在大仓库中搜索所有 TODO 并分类" | Agent Teams | 无依赖,各自领任务即可 |
|
||||
|
||||
@@ -1,110 +1,245 @@
|
||||
---
|
||||
title: "子 Agent"
|
||||
description: "主 Agent 可以启动子 Agent 来并行工作或委派专业任务。理解三种子 Agent 路径、工具池隔离、Fork 的缓存优化和异步生命周期管理。"
|
||||
keywords: ["子 Agent", "AgentTool", "任务委派", "子进程隔离"]
|
||||
title: "子 Agent 机制 - AgentTool 的执行链路与隔离架构"
|
||||
description: "从源码角度解析 Claude Code 子 Agent:AgentTool.call() 的完整执行链路、Fork 子进程的 Prompt Cache 共享、Worktree 隔离、工具池独立组装、以及结果回传的数据格式。"
|
||||
keywords: ["子 Agent", "AgentTool", "任务委派", "forkSubagent", "子进程隔离"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:从源码角度揭示子 Agent 的完整执行链路、工具隔离、通信协议和生命周期管理 */}
|
||||
|
||||
单个 Agent 的上下文窗口是有限的。当需要同时研究多个方向、并行执行多个操作时,一个 Agent 做不过来。
|
||||
## 执行链路总览
|
||||
|
||||
子 Agent 让主 Agent 可以启动独立的 AI 实例来并行工作——每个子 Agent 有自己的上下文窗口和工具集。
|
||||
一条 `Agent(prompt="修复 bug")` 调用的完整路径:
|
||||
|
||||
## 三种子 Agent 路径
|
||||
```
|
||||
AI 生成 tool_use: { prompt: "修复 bug", subagent_type: "Explore" }
|
||||
↓
|
||||
AgentTool.call() ← 入口(AgentTool.tsx:387)
|
||||
├── 解析 effectiveType(fork vs 命名 agent vs GP 回退)
|
||||
├── filterDeniedAgents() ← 仅命名 Agent 路径执行:权限过滤
|
||||
├── 检查 requiredMcpServers ← MCP 依赖验证(最长等 30s)
|
||||
├── assembleToolPool(workerPermissionContext) ← 独立组装工具池
|
||||
├── createAgentWorktree() ← 可选 worktree 隔离
|
||||
↓
|
||||
runAgent() ← 核心执行(runAgent.ts)
|
||||
├── getAgentSystemPrompt() ← 构建 agent 专属 system prompt
|
||||
├── initializeAgentMcpServers() ← agent 级 MCP 服务器
|
||||
├── executeSubagentStartHooks() ← Hook 注入
|
||||
├── query() ← 进入标准 agentic loop
|
||||
│ ├── 消息流逐条 yield
|
||||
│ └── recordSidechainTranscript() ← JSONL 持久化(~/.claude/projects/{project}/{session}/subagents/)
|
||||
↓
|
||||
finalizeAgentTool() ← 结果汇总
|
||||
├── 提取文本内容 + usage 统计
|
||||
└── mapToolResultToToolResultBlockParam() ← 格式化为 tool_result
|
||||
```
|
||||
|
||||
系统根据参数和配置走三条不同的路径:
|
||||
## 子 Agent 的三种路径
|
||||
|
||||
| 路径 | 触发条件 | 上下文 | 设计目的 |
|
||||
|------|---------|--------|---------|
|
||||
| **命名 Agent** | 指定 `subagent_type` | 仅任务描述 | 专业任务委派 |
|
||||
| **Fork 子进程** | 启用 Fork + 未指定类型 | 继承父 Agent 完整对话 | Prompt Cache 优化 |
|
||||
| **通用回退** | 未启用 Fork + 未指定类型 | 仅任务描述 | 通用任务处理 |
|
||||
`AgentTool.call()` 根据 `subagent_type` 参数和 Fork 实验开关,走三条不同的路径:
|
||||
|
||||
### 命名 Agent:专业委派
|
||||
| 维度 | 命名 Agent(`subagent_type` 指定) | Fork 子进程(Fork 启用 + 类型省略) | General-purpose 回退(Fork 关闭 + 类型省略) |
|
||||
|------|-------------------------------------|--------------------------------------|---------------------------------------------|
|
||||
| **触发条件** | `subagent_type` 有值 | `isForkSubagentEnabled() === true` 且未指定类型 | `isForkSubagentEnabled() === false` 且未指定类型 |
|
||||
| **System Prompt** | Agent 自身的 `getSystemPrompt()` | 继承父 Agent 的完整 System Prompt | General-purpose Agent 的 `getSystemPrompt()` |
|
||||
| **工具池** | `assembleToolPool()` 独立组装 | 父 Agent 的原始工具池(`useExactTools: true`) | `assembleToolPool()` 独立组装 |
|
||||
| **上下文** | 仅任务描述 | 父 Agent 的完整对话历史(`forkContextMessages`) | 仅任务描述 |
|
||||
| **模型** | 可独立指定 | 继承父模型(`model: 'inherit'`) | 可独立指定 |
|
||||
| **权限模式** | Agent 定义的 `permissionMode` | `'bubble'`(上浮到父终端) | Agent 定义的 `permissionMode` |
|
||||
| **目的** | 专业任务委派 | Prompt Cache 命中率优化 | 通用任务处理 |
|
||||
|
||||
系统预定义了几个内置 Agent,各有明确的职责:
|
||||
<Note>
|
||||
Fork 实验的门控函数 `isForkSubagentEnabled()` 需要同时满足三个前提:`FORK_SUBAGENT` feature flag 已启用、当前不在 Coordinator 模式中、且不是非交互式会话。任一条件不满足时,省略 `subagent_type` 会静默降级为 General-purpose Agent,而非触发 Fork。
|
||||
</Note>
|
||||
|
||||
| Agent | 模型 | 工具 | 用途 |
|
||||
|-------|------|------|------|
|
||||
| **Explore** | Haiku(轻量快速) | 只读(Read/Grep/Glob) | 代码库搜索与探索 |
|
||||
| **Plan** | 继承父模型 | 只读 | 为 Plan Mode 收集信息 |
|
||||
| **General-purpose** | 继承父模型 | 全部工具 | 复杂通用任务 |
|
||||
Fork 路径的设计核心是 **Prompt Cache 共享**:所有 fork 子进程共享父 Agent 的完整 `assistant` 消息(所有 `tool_use` 块),用相同的占位符 `tool_result` 填充,只有最后一个 `text` 块包含各自的指令。这使得 API 请求前缀字节完全一致,最大化缓存命中。
|
||||
|
||||
**设计考量**:Explore Agent 使用 Haiku 模型(更便宜、更快),因为搜索任务不需要 Opus 的推理能力。模型选择是成本和能力的权衡——不是每个子任务都需要最强的模型。
|
||||
```typescript
|
||||
// forkSubagent.ts:93 — 所有 fork 子进程的占位结果
|
||||
const FORK_PLACEHOLDER_RESULT = 'Fork started — processing in background'
|
||||
|
||||
### Fork 子进程:缓存优化
|
||||
// buildForkedMessages() 构建:
|
||||
// [assistant(全量 tool_use), user(placeholder_results..., 子进程指令)]
|
||||
```
|
||||
|
||||
Fork 路径的核心设计是 **Prompt Cache 共享**:所有 fork 子进程共享父 Agent 的完整对话历史,只有最后的指令不同。这使得 API 请求的前缀完全一致,最大化缓存命中。
|
||||
### Fork 递归防护
|
||||
|
||||
**为什么不用命名 Agent**?命名 Agent 只拿到任务描述,缺乏父 Agent 的完整上下文。Fork 让子进程"看到"父 Agent 看到的一切,代价是更高的 token 消耗——但通过 Prompt Cache,这部分消耗被大幅降低。
|
||||
Fork 子进程保留 Agent 工具(为了 cache-identical tool defs),但通过两道防线防止递归 fork:
|
||||
|
||||
Fork 有两道防线防止递归创建子 Agent:检查查询来源标记和扫描消息中的特殊标签。
|
||||
1. **`querySource` 检查**(压缩安全):`context.options.querySource === 'agent:builtin:fork'`
|
||||
2. **消息扫描**(降级兜底):检测 `<fork-boilerplate>` 标签
|
||||
|
||||
### 模型解析优先级
|
||||
|
||||
子 Agent 的模型选择遵循优先级链:
|
||||
子 Agent 的模型选择遵循严格的优先级链(`src/utils/model/agent.ts`):
|
||||
|
||||
```
|
||||
环境变量覆盖 > 每次调用参数 > Agent 定义的模型 > 继承父对话模型
|
||||
1. CLAUDE_CODE_SUBAGENT_MODEL 环境变量 ← 全局覆盖
|
||||
↓(未设置时)
|
||||
2. 每次调用的 model 参数 ← AgentTool 入参
|
||||
↓(未指定时)
|
||||
3. Agent 定义的 model frontmatter ← 如 "sonnet", "haiku", "inherit"
|
||||
↓(未定义时)
|
||||
4. 继承父对话模型(conversation model) ← getDefaultSubagentModel() 返回 "inherit"
|
||||
```
|
||||
|
||||
`inherit` 不是简单的模型传递——它经过运行时解析,确保 plan mode 下的模型映射正确生效。
|
||||
其中 `inherit` 不是简单的模型传递——它经过 `getRuntimeMainLoopModel()` 解析,确保 plan mode 下的 `opusplan→Opus` 等运行时映射正确生效。当 Agent 指定的模型族(如 `haiku`)与父模型同族时,直接复用父模型的精确 ID,避免跨 provider 降级。
|
||||
|
||||
## 工具池隔离
|
||||
## 命名 Agent 的工具池独立组装
|
||||
|
||||
子 Agent 不继承父 Agent 的工具限制——它的工具池完全独立组装。
|
||||
### 内置 Agent
|
||||
|
||||
**设计考量**:父 Agent 可能处于受限模式(如 Plan Mode),但子 Agent 可能需要完整的工具集来执行任务。独立的工具池让子 Agent 的权限不受父 Agent 限制。
|
||||
系统预定义了几个内置 Agent(`packages/builtin-tools/src/tools/AgentTool/builtInAgents.ts`),各有明确的职责和模型配置:
|
||||
|
||||
Fork 子进程例外——它直接继承父 Agent 的工具池,以保持 Prompt Cache 中工具定义的字节一致性。
|
||||
| Agent | 模型 | 权限 | 用途 |
|
||||
|-------|------|------|------|
|
||||
| **Explore** | Haiku(轻量快速) | 只读(Read/Grep/Glob) | 代码库搜索与探索 |
|
||||
| **Plan** | 继承父模型 | 只读 | 为 Plan Mode 收集研究信息 |
|
||||
| **General-purpose** | 继承父模型 | 全部工具 | 复杂的通用任务处理 |
|
||||
| **statusline-setup** | 继承父模型 | 受限 | 配置状态栏设置 |
|
||||
| **claude-code-guide** | 继承父模型 | 受限 | 解答 Claude Code 使用问题 |
|
||||
|
||||
### Agent 级 MCP 服务器
|
||||
用户还可通过 `.claude/agents/` 目录或 settings 定义自定义 Agent,作用域优先级为:managed settings > CLI `--agents` > 项目级 `.claude/agents/` > 用户级 `~/.claude/agents/` > plugin。
|
||||
|
||||
子 Agent 可以额外连接专属的 MCP 服务器。如果 Agent 声明了 `requiredMcpServers`,系统会等待这些服务器连接完成(最长 30 秒),确保工具可用后才启动。
|
||||
命名 Agent(包括 General-purpose 回退)不继承父 Agent 的工具限制——它的工具池完全独立组装。Fork 子进程则通过 `useExactTools: true` 直接继承父 Agent 的原始工具池,以保持 Prompt Cache 中工具定义的字节一致性。
|
||||
|
||||
## Worktree 隔离
|
||||
命名 Agent 的工具池组装逻辑:
|
||||
|
||||
`isolation: "worktree"` 参数让子 Agent 在独立的 git worktree 中工作:
|
||||
|
||||
```
|
||||
创建 worktree → 子 Agent 在独立副本中工作 → 完成
|
||||
→ 有变更:保留 worktree,返回路径
|
||||
→ 无变更:自动删除
|
||||
```typescript
|
||||
const workerPermissionContext = {
|
||||
...appState.toolPermissionContext,
|
||||
mode: selectedAgent.permissionMode ?? 'acceptEdits'
|
||||
}
|
||||
const workerTools = assembleToolPool(workerPermissionContext, appState.mcp.tools)
|
||||
```
|
||||
|
||||
**设计目的**:子 Agent 的实验性修改不应该影响主分支。Worktree 提供了文件系统级别的隔离,同时保持对完整代码库的访问。
|
||||
关键设计决策:
|
||||
- **权限模式独立**:子 Agent 使用 `selectedAgent.permissionMode`(默认 `acceptEdits`),不受父 Agent 当前模式的限制
|
||||
- **MCP 工具继承**:`appState.mcp.tools` 包含所有已连接的 MCP 工具,子 Agent 自动获得
|
||||
- **Agent 级 MCP 服务器**:`runAgent()` 中的 `initializeAgentMcpServers()` 可以为特定 Agent 额外连接专属 MCP 服务器
|
||||
|
||||
## 生命周期:同步 vs 异步
|
||||
### 工具过滤的 resolveAgentTools
|
||||
|
||||
`runAgent.ts:508` 在工具组装后进一步过滤:
|
||||
|
||||
```typescript
|
||||
const resolvedTools = useExactTools
|
||||
? availableTools // Fork: 直接使用父工具
|
||||
: resolveAgentTools(agentDefinition, availableTools, isAsync).resolvedTools
|
||||
```
|
||||
|
||||
`resolveAgentTools()` 会根据 Agent 定义中的 `tools` 字段过滤可用工具,将 `['*']` 映射为全量工具。
|
||||
|
||||
### Hook 事件
|
||||
|
||||
子 Agent 支持 Agent 定义 frontmatter 和全局 settings.json 两种级别的 Hook:
|
||||
|
||||
| 来源 | 事件 | 说明 |
|
||||
|------|------|------|
|
||||
| Agent frontmatter `hooks` | `PreToolUse` / `PostToolUse` | 工具调用前后拦截 |
|
||||
| Agent frontmatter `hooks` | `Stop` | 自动转换为 `SubagentStop`(`registerFrontmatterHooks` 传入 `isAgent=true`) |
|
||||
| settings.json | `SubagentStart` | 子 Agent 启动时触发(`executeSubagentStartHooks()`) |
|
||||
| settings.json | `SubagentStop` | 子 Agent 停止时触发 |
|
||||
|
||||
## Worktree 隔离机制
|
||||
|
||||
`isolation: "worktree"` 参数让子 Agent 在独立的 git worktree 中工作(`AgentTool.tsx:863`):
|
||||
|
||||
```typescript
|
||||
const slug = `agent-${earlyAgentId.slice(0, 8)}`
|
||||
worktreeInfo = await createAgentWorktree(slug)
|
||||
```
|
||||
|
||||
Worktree 生命周期:
|
||||
1. **创建**:在 `.git/worktrees/` 下创建独立工作副本
|
||||
2. **CWD 覆盖**:`runWithCwdOverride(worktreePath, fn)` 让所有文件操作在 worktree 中执行
|
||||
3. **路径翻译**:Fork + worktree 时注入路径翻译通知(`buildWorktreeNotice`)
|
||||
4. **清理**(`cleanupWorktreeIfNeeded`):
|
||||
- Hook-based worktree → 始终保留
|
||||
- 有变更 → 保留,返回 `worktreePath`
|
||||
- 无变更 → 自动删除
|
||||
|
||||
## 生命周期管理:同步 vs 异步
|
||||
|
||||
### 异步 Agent(后台运行)
|
||||
|
||||
异步 Agent 立即返回 `async_launched` 状态,主 Agent 可以继续工作。后台 Agent 完成后通过通知机制汇报结果。
|
||||
当 `run_in_background=true`、`selectedAgent.background=true`、或系统判定应强制异步(如 `assistantForceAsync`、`proactiveModule` 激活)时,Agent 立即返回 `async_launched` 状态:
|
||||
|
||||
异步 Agent 获得独立的 AbortController——用户取消主线程不会杀掉后台 Agent。这确保长时间运行的构建/测试任务不会被意外中断。
|
||||
```
|
||||
registerAsyncAgent(agentId, ...) ← 注册到 AppState.tasks
|
||||
↓ (void — 火后不管)
|
||||
runAsyncAgentLifecycle() ← 后台执行(agentToolUtils.ts)
|
||||
├── runAgent().onCacheSafeParams ← 进度摘要初始化
|
||||
├── 消息流迭代
|
||||
├── finalizeAgentTool() ← 结果汇总(提取文本 + usage 统计)
|
||||
├── completeAsyncAgent() ← 标记完成(先于通知,确保 TaskOutput 尽快解除阻塞)
|
||||
├── classifyHandoffIfNeeded() ← 安全分类(需 TRANSCRIPT_CLASSIFIER feature)
|
||||
├── getWorktreeResult() ← Worktree 清理(如有隔离)
|
||||
└── enqueueAgentNotification() ← 通知主 Agent
|
||||
```
|
||||
|
||||
如果异步 Agent 提供了 `name` 参数,还会注册到 `agentNameRegistry`,支撑 `SendMessage` 工具通过名称路由到该 Agent。
|
||||
|
||||
异步 Agent 获得独立的 `AbortController`,不与父 Agent 共享——用户按 ESC 取消主线程不会杀掉后台 Agent。
|
||||
|
||||
### 同步 Agent(前台运行)
|
||||
|
||||
同步 Agent 有一个"可后台化"机制:如果执行超过阈值(默认 120 秒),系统自动将其转为后台运行。这防止了一个慢子 Agent 阻塞整个工作循环。
|
||||
同步 Agent 的关键特性是 **可后台化**(`AgentTool.tsx:1107`):
|
||||
|
||||
```typescript
|
||||
const registration = registerAgentForeground({
|
||||
autoBackgroundMs: getAutoBackgroundMs() || undefined // 默认 120s
|
||||
})
|
||||
backgroundPromise = registration.backgroundSignal.then(...)
|
||||
```
|
||||
|
||||
在 agentic loop 的每次迭代中,系统用 `Promise.race` 竞争下一条消息和后台化信号:
|
||||
|
||||
```typescript
|
||||
const raceResult = await Promise.race([
|
||||
nextMessagePromise.then(r => ({ type: 'message', result: r })),
|
||||
backgroundPromise // 超过 autoBackgroundMs 触发
|
||||
])
|
||||
```
|
||||
|
||||
后台化后,前台迭代器被终止(`agentIterator.return()`),新的 `runAgent()` 以 `isAsync: true` 重新启动,当前台的输出文件继续写入。
|
||||
|
||||
## 结果回传格式
|
||||
|
||||
`mapToolResultToToolResultBlockParam()` 根据状态返回不同格式:
|
||||
|
||||
| 状态 | 返回内容 |
|
||||
|------|---------|
|
||||
| `completed` | 内容 + `<usage>` 块(token/tool_calls/duration);无内容时插入占位文本 `"(Subagent completed but returned no output.)"` 防止模型误判为空 |
|
||||
| `async_launched` | agentId + outputFile 路径 + 操作指引(指引内容取决于 `canReadOutputFile`:有读取权限时提示通过 Read/Bash 查看进度,否则仅告知已启动) |
|
||||
| `teammate_spawned` | agent_id + name + team_name |
|
||||
| `remote_launched` | taskId + sessionUrl + outputFile |
|
||||
|
||||
对于一次性内置 Agent(Explore、Plan),当**不存在** worktree 隔离时,`<usage>` 块和 agentId 尾部被省略——每周节省约 1-2 Gtok 的上下文窗口。存在 worktree 时仍需返回 `worktreePath` 和 `worktreeBranch` 信息。
|
||||
|
||||
## MCP 依赖的等待机制
|
||||
|
||||
如果 Agent 声明了 `requiredMcpServers`,`call()` 会等待这些服务器连接完成(`AgentTool.tsx:576`):
|
||||
|
||||
```typescript
|
||||
const MAX_WAIT_MS = 30_000 // 最长等 30 秒
|
||||
const POLL_INTERVAL_MS = 500 // 每 500ms 轮询
|
||||
```
|
||||
|
||||
早期退出条件:任何必需服务器进入 `failed` 状态时立即停止等待。工具可用性通过 `mcp__` 前缀工具名解析(`mcp__serverName__toolName`)判断。等待结束后如果仍有必需服务器未就绪,`call()` 会抛出错误并明确列出缺失的服务器名称。
|
||||
|
||||
## 适用场景
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="并行研究" icon="magnifying-glass">
|
||||
多个 fork 子进程并行搜索不同方向,共享 Prompt Cache 前缀
|
||||
多个 fork 子进程并行搜索不同方向,共享 Prompt Cache 前缀,只有指令不同
|
||||
</Card>
|
||||
<Card title="专业委派" icon="code-branch">
|
||||
使用命名 Agent 执行专业任务,受限工具集 + 独立权限
|
||||
使用命名 Agent(Explore/Plan/verification)执行专业任务,受限工具集 + 独立权限
|
||||
</Card>
|
||||
<Card title="隔离实验" icon="flask">
|
||||
worktree 隔离中尝试方案,不影响主分支
|
||||
`isolation: "worktree"` 在独立工作副本中尝试方案,不影响主分支
|
||||
</Card>
|
||||
<Card title="后台构建" icon="layer-group">
|
||||
异步启动长时间构建/测试,主 Agent 继续工作
|
||||
`run_in_background: true` 启动长时间构建/测试任务,主 Agent 继续工作
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## 接下来
|
||||
|
||||
- **协调者与蜂群** — 理解多 Agent 的高级编排模式
|
||||
- **Worktree 隔离** — 深入理解文件系统隔离机制
|
||||
- **任务管理** — 理解支撑多 Agent 的任务追踪
|
||||
|
||||
@@ -1,99 +1,185 @@
|
||||
---
|
||||
title: "Worktree 隔离"
|
||||
description: "多 Agent 并行工作时,共享同一目录会导致冲突。Git worktree 提供文件系统级隔离,让每个 Agent 拥有独立的工作空间。"
|
||||
title: "Worktree 隔离 - Git Worktree 实现文件级隔离"
|
||||
description: "揭秘 Claude Code 的 git worktree 隔离机制:子 Agent 如何获得独立工作空间,worktree 创建/销毁生命周期、路径命名规则和安全防护。"
|
||||
keywords: ["Worktree", "git worktree", "文件隔离", "多 Agent 隔离", "并行安全"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:揭示 worktree 的创建/销毁生命周期、路径命名规则、hook 机制和退出时的安全防护 */}
|
||||
|
||||
## 为什么需要文件级隔离
|
||||
|
||||
多 Agent 并行工作时,共享同一工作目录会导致三类冲突:
|
||||
|
||||
1. **写入冲突**:两个 Agent 同时编辑同一个文件,后写的覆盖前写的
|
||||
1. **写入冲突**:两个 Agent 同时编辑 `config.ts`,后写的覆盖前写的
|
||||
2. **状态干扰**:Agent A 的测试依赖某个环境状态,Agent B 的修改破坏了它
|
||||
3. **不可区分**:半完成的修改混在一起,无法分辨哪些是哪个 Agent 的
|
||||
|
||||
Git worktree 是 git 原生的解决方案——在同一个仓库中创建多个独立工作目录,每个在自己的分支上。
|
||||
|
||||
## 目录结构
|
||||
## 目录结构与命名规则
|
||||
|
||||
Worktree 文件统一存放在仓库根目录下的 `.claude/worktrees/`:
|
||||
|
||||
```
|
||||
<repo-root>/
|
||||
├── .claude/
|
||||
│ └── worktrees/
|
||||
│ ├── fix-auth-bug/ ← Agent A 的独立工作空间
|
||||
│ │ └── .git ← 指向主仓库的链接
|
||||
│ └── add-dark-mode/ ← Agent B 的独立工作空间
|
||||
│ ├── fix-auth-bug/ # worktree 工作目录
|
||||
│ │ ├── .git # 指向主仓库的链接文件
|
||||
│ │ └── src/... # 独立的文件系统视图
|
||||
│ └── add-dark-mode/ # 另一个 worktree
|
||||
│ └── ...
|
||||
├── src/ ← 主工作目录(不受影响)
|
||||
└── .git/ ← 主仓库
|
||||
├── src/ # 主工作目录(不受影响)
|
||||
└── .git/ # 主仓库
|
||||
```
|
||||
|
||||
每个 worktree 是一个完整的文件系统视图,但共享同一个 git 历史。Agent 在自己的 worktree 中修改文件,不会影响主分支或其他 Agent。
|
||||
分支命名规则为 `worktree/<slug>`,其中 slug 由 `validateWorktreeSlug()` 校验:每个 `/` 分隔的段只允许字母、数字、`.`、`_`、`-`,总长 ≤64 字符。未指定时使用 plan slug 自动生成。
|
||||
|
||||
## 创建与恢复
|
||||
## 创建流程:EnterWorktreeTool
|
||||
|
||||
### 创建流程
|
||||
`EnterWorktreeTool`(`packages/builtin-tools/src/tools/EnterWorktreeTool/EnterWorktreeTool.ts`)的执行链路:
|
||||
|
||||
```
|
||||
检查是否已在 worktree 中(防嵌套) → 生成 slug → 创建 worktree → 切换进程工作目录
|
||||
EnterWorktreeTool.call({ name? })
|
||||
↓
|
||||
1. 检查是否已在 worktree 中(防嵌套)
|
||||
↓
|
||||
2. 解析到主仓库根目录(findCanonicalGitRoot)
|
||||
如果当前已在 worktree 内,chdir 到主仓库
|
||||
↓
|
||||
3. 生成 slug(用户提供或 plan slug)
|
||||
↓
|
||||
4. createWorktreeForSession(sessionId, slug)
|
||||
├── 有 WorktreeCreate hook?
|
||||
│ └── 执行 hook,返回 hook 指定的路径(支持非 git VCS)
|
||||
└── 无 hook → git 原生路径:
|
||||
a. getOrCreateWorktree(repoRoot, slug)
|
||||
├── 快速恢复:检查 worktree 目录是否已存在
|
||||
│ └── 读取 .git 指针文件的 HEAD SHA(无子进程)
|
||||
└── 新建:
|
||||
i. mkdir .claude/worktrees/(recursive)
|
||||
ii. fetch origin/<default-branch>(有缓存则跳过)
|
||||
iii. git worktree add -b worktree/<slug> <path> <base>
|
||||
iv. performPostCreationSetup()(sparse checkout 等)
|
||||
↓
|
||||
5. 更新进程状态:
|
||||
- process.chdir(worktreePath)
|
||||
- setCwd(worktreePath)
|
||||
- setOriginalCwd(worktreePath)
|
||||
- saveWorktreeState(session) → 持久化到项目配置
|
||||
- clearSystemPromptSections() → 重新计算系统提示中的 cwd 信息
|
||||
- clearMemoryFileCaches() → 重新加载 worktree 中的 CLAUDE.md
|
||||
↓
|
||||
6. 返回 worktreePath 和 worktreeBranch
|
||||
```
|
||||
|
||||
**设计细节**:
|
||||
- 分支名遵循 `worktree/<slug>` 格式,slug 有严格的字符限制
|
||||
- 创建后自动更新进程状态——所有后续文件操作自动在 worktree 中执行
|
||||
- 系统提示和 CLAUDE.md 被重新加载,确保 AI 看到 worktree 中的上下文
|
||||
### Hook 优先的架构
|
||||
|
||||
`createWorktreeForSession()` 首先检查 `hasWorktreeCreateHook()`——如果用户在 settings.json 中配置了 `WorktreeCreate` hook,系统完全不调用 git,而是执行 hook 命令并将返回的路径作为 worktree 路径。这允许非 git 版本控制系统(如 Pijul、Mercurial)通过 hook 接入。
|
||||
|
||||
### 快速恢复路径
|
||||
|
||||
如果目标 worktree 已存在,直接读取指针文件获取 HEAD SHA——纯文件 I/O,无子进程。在大仓库中 `git fetch` 可能需要 6-8 秒,这个优化将恢复延迟降到接近 0。
|
||||
`getOrCreateWorktree()` 有一个关键优化:如果目标路径已存在,直接读取 `.git` 指针文件获取 HEAD SHA(纯文件 I/O,无子进程),跳过整个 `fetch` + `worktree add` 流程。在大仓库中 `fetch` 需要 6-8 秒,这个优化将恢复场景的延迟降到接近 0。
|
||||
|
||||
**设计洞察**:恢复(resume)是比创建更频繁的操作。用户可能中断后重新开始,或者 Agent 在之前的 worktree 上继续工作。优化恢复路径比优化创建路径更有价值。
|
||||
## 退出流程:ExitWorktreeTool
|
||||
|
||||
### Hook 优先架构
|
||||
`ExitWorktreeTool`(`packages/builtin-tools/src/tools/ExitWorktreeTool/ExitWorktreeTool.ts`)支持两种退出策略:
|
||||
|
||||
如果用户配置了 `WorktreeCreate` hook,系统完全不调用 git,而是执行 hook 命令获取路径。这允许非 git 版本控制系统(如 Pijul、Mercurial)通过 hook 接入隔离机制。
|
||||
### keep:保留 worktree
|
||||
|
||||
## 退出与清理
|
||||
```
|
||||
keepWorktree()
|
||||
↓
|
||||
1. chdir 回 originalCwd
|
||||
2. 清空 currentWorktreeSession
|
||||
3. 更新项目配置(activeWorktreeSession = undefined)
|
||||
4. worktree 目录和分支保留在磁盘上
|
||||
```
|
||||
|
||||
退出支持两种策略:
|
||||
用户可以通过 `cd <worktreePath>` 继续工作,或稍后手动合并。
|
||||
|
||||
| 策略 | 行为 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| **keep** | 保留 worktree 和分支 | 后续需要合并或审查 |
|
||||
| **remove** | 删除 worktree 和分支 | 确认不再需要 |
|
||||
### remove:删除 worktree
|
||||
|
||||
### Fail-closed 安全防护
|
||||
有严格的**安全防护**:
|
||||
|
||||
删除操作有多层安全防护:
|
||||
```
|
||||
validateInput() — 第一道防线
|
||||
↓
|
||||
1. 检查是否在 EnterWorktree 创建的会话中
|
||||
(手动创建的 worktree 不会被删除)
|
||||
↓
|
||||
2. countWorktreeChanges(worktreePath, originalHeadCommit)
|
||||
├── git status --porcelain → 统计未提交文件数
|
||||
├── git rev-list --count <originalHead>..HEAD → 统计新提交数
|
||||
└── 返回 null(git 失败时)→ fail-closed(拒绝删除)
|
||||
↓
|
||||
3. 有未提交文件或新提交?
|
||||
→ 拒绝,要求 discard_changes: true 确认
|
||||
```
|
||||
|
||||
1. 只允许删除通过 EnterWorktree 创建的 worktree(手动 `git worktree add` 的不会被删)
|
||||
2. 有未提交文件或新提交时,拒绝删除(除非显式 `discard_changes: true`)
|
||||
3. git 命令失败时,返回"未知"状态,拒绝删除
|
||||
```
|
||||
call() — 实际执行
|
||||
↓
|
||||
1. 重新计数变更(validateInput 和 call 之间可能有新修改)
|
||||
2. 如果有 tmux session → killTmuxSession()
|
||||
3. cleanupWorktree()
|
||||
├── hook-based → 执行 WorktreeRemove hook
|
||||
└── git-based → git worktree remove --force + git branch -D
|
||||
4. restoreSessionToOriginalCwd()
|
||||
- setCwd(originalCwd)
|
||||
- setOriginalCwd(originalCwd)
|
||||
- 如果 projectRoot 是 worktree 时才恢复(防误触)
|
||||
- 更新 hooks config snapshot
|
||||
- 清空系统提示和 memory 缓存
|
||||
```
|
||||
|
||||
**设计哲学**:宁可让用户手动处理废弃的 worktree,也不冒险丢失工作。磁盘空间比丢失代码便宜得多。
|
||||
### fail-closed 设计
|
||||
|
||||
### 重新计数
|
||||
`countWorktreeChanges()` 在以下情况返回 `null`("未知,假设不安全"):
|
||||
- `git status` 或 `git rev-list` 退出非零(锁文件、损坏的索引)
|
||||
- `originalHeadCommit` 未定义(hook-based worktree 没有设置基线 commit)
|
||||
|
||||
validateInput 和实际执行之间可能有新的修改。删除前重新计数变更,确保不会误删。
|
||||
返回 `null` 时,`validateInput` 拒绝删除——宁可让用户手动处理,也不冒险丢失工作。
|
||||
|
||||
## 与子 Agent 的联动
|
||||
## 与 Agent 工具的联动
|
||||
|
||||
子 Agent 的 `isolation: "worktree"` 参数自动在独立 worktree 中运行。子 Agent 的 worktree 管理与用户会话略有不同:
|
||||
Agent 工具(`AgentTool`)的 `isolation` 参数决定子 Agent 是否在 worktree 中运行。注意 Agent 工具使用**专用的** `createAgentWorktree()`(`src/utils/worktree.ts`),而非用户会话用的 `createWorktreeForSession()`,两者有关键差异:
|
||||
|
||||
| 维度 | 用户会话 | 子 Agent |
|
||||
|------|---------|---------|
|
||||
| Session 状态 | 完整持久化 | 无 session 状态 |
|
||||
| 清理策略 | 手动退出 | 自动清理(无变更则删除) |
|
||||
| 有变更时 | 用户决定 | 保留 worktree,返回路径 |
|
||||
| 维度 | `createWorktreeForSession`(用户会话) | `createAgentWorktree`(子 Agent) |
|
||||
|------|---------------------------------------|----------------------------------|
|
||||
| 调用者 | EnterWorktreeTool | AgentTool |
|
||||
| Session 管理 | 设置 `currentWorktreeSession` | **不设置** `currentWorktreeSession` |
|
||||
| 恢复已有 worktree | 直接复用 | 复用并 bump mtime(防止被周期性清理误删) |
|
||||
|
||||
**设计考量**:子 Agent 的 worktree 不需要用户手动管理——如果 Agent 没有产生任何修改,worktree 被自动清理;如果有修改,保留下来供主 Agent 后续合并。
|
||||
子 Agent 结束时的处理由 `cleanupWorktreeIfNeeded()` 自动完成——它不走 `ExitWorktreeTool`(因为 Agent worktree 没有会话状态,`ExitWorktreeTool` 的 `validateInput` 会拒绝):
|
||||
- **有变更** → 保留 worktree,返回 `worktreePath` 供主 Agent 后续合并
|
||||
- **无变更** → 自动删除
|
||||
- **Hook-based** → 始终保留
|
||||
|
||||
## Session 状态持久化
|
||||
|
||||
`WorktreeSession` 对象通过 `saveCurrentProjectConfig()` 持久化到磁盘,包含:
|
||||
|
||||
```typescript
|
||||
{
|
||||
originalCwd: string, // 进入 worktree 前的工作目录
|
||||
worktreePath: string, // worktree 的绝对路径
|
||||
worktreeName: string, // slug
|
||||
worktreeBranch?: string, // 分支名(如 worktree/fix-auth)
|
||||
originalBranch?: string, // 进入前的分支
|
||||
originalHeadCommit?: string, // 进入前的 HEAD commit(用于变更统计)
|
||||
sessionId: string, // 创建此 worktree 的会话 ID
|
||||
tmuxSessionName?: string, // 关联的 tmux session
|
||||
hookBased?: boolean, // 是否由 hook 创建
|
||||
creationDurationMs?: number, // 创建耗时(分析用)
|
||||
usedSparsePaths?: boolean, // 是否使用了 sparse checkout
|
||||
}
|
||||
```
|
||||
|
||||
这使得 session 恢复(`--resume`)时能正确还原 worktree 上下文——即使进程重启,`getCurrentWorktreeSession()` 从项目配置中读取状态。
|
||||
|
||||
## Sparse Checkout 优化
|
||||
|
||||
大型 monorepo 中,完整检出可能需要数十秒。Sparse checkout 只检出特定目录,将创建时间降到几秒。
|
||||
对于大型 monorepo,worktree 支持 `sparsePaths` 配置——只检出特定目录而非整个仓库。这在 210K 文件的仓库中将 worktree 创建时间从数十秒降到几秒。
|
||||
|
||||
## 接下来
|
||||
|
||||
- **子 Agent** — 理解使用 worktree 隔离的子 Agent 创建
|
||||
- **协调者与蜂群** — 理解多 Agent 的高级编排
|
||||
- **权限模型** — 理解工具权限的安全体系
|
||||
配置位于 `getInitialSettings().worktree?.sparsePaths`,在 `performPostCreationSetup()` 中应用。
|
||||
|
||||
@@ -1,131 +1,265 @@
|
||||
---
|
||||
title: "上下文压缩"
|
||||
description: "对话历史不断增长,token 窗口有限。理解 Claude Code 的三层压缩策略、边界标记机制和紧急降级路径。"
|
||||
keywords: ["上下文压缩", "Compaction", "token 管理", "对话压缩"]
|
||||
title: "上下文压缩 - Compaction 三层策略与边界机制"
|
||||
description: "深度解析 Claude Code 上下文压缩的完整实现:Session Memory 压缩、传统 API 摘要压缩、MicroCompact 局部压缩三层策略,以及 CompactBoundary 消息、工具对保持、PTL 紧急降级等关键机制。"
|
||||
keywords: ["上下文压缩", "Compaction", "token 管理", "对话压缩", "上下文窗口", "MicroCompact"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:从源码层面剖析压缩的三层策略、边界机制和关键常量 */}
|
||||
|
||||
AI 没有真正的长期记忆——它只能看到当前 API 请求中的内容。随着对话增长,token 消耗持续上升,最终会触及模型的上下文窗口限制。
|
||||
## 压缩的触发时机
|
||||
|
||||
压缩就是在"保留足够的语义信息"和"减少 token 占用"之间找平衡。
|
||||
上下文压缩不是单一操作,而是**三层递进**的策略系统,对应不同的触发条件和严重程度:
|
||||
|
||||
## 三层递进策略
|
||||
| 层级 | 触发条件 | 实现位置 | 是否需要 API 调用 |
|
||||
|------|---------|---------|:---:|
|
||||
| **MicroCompact** | 单个工具输出过长 | `microCompact.ts` | 否 |
|
||||
| **Session Memory Compact** | 自动压缩触发(需 feature flag) | `sessionMemoryCompact.ts` | 否 |
|
||||
| **传统 API 摘要** | 手动 `/compact` 或 SM 不可用时的自动回退 | `compact.ts` | 是 |
|
||||
|
||||
系统不是用一种方法解决所有压缩需求,而是根据严重程度递进使用三种策略:
|
||||
### 压缩入口的优先级链
|
||||
|
||||
| 层级 | 触发条件 | 是否需要 API 调用 | 成本 |
|
||||
|------|----------|:---:|------|
|
||||
| **微压缩** | 单个工具输出过长 | 否 | 几乎为零 |
|
||||
| **Session Memory 压缩** | 自动压缩触发时优先尝试 | 否 | 低(使用已提取的记忆) |
|
||||
| **AI 摘要压缩** | 上述方法不可用或用户手动触发 | 是 | 高(需要额外 API 调用) |
|
||||
源码路径:`src/commands/compact/compact.ts`
|
||||
|
||||
### 设计哲学:从廉价到昂贵
|
||||
当用户执行 `/compact` 或系统触发自动压缩时,压缩命令按以下优先级尝试:
|
||||
|
||||
为什么不直接用 AI 摘要?因为 AI 摘要需要额外的 API 调用——既花钱又花时间。系统的策略是**先用确定性操作(廉价),不行再用 AI 生成(昂贵)**。
|
||||
```typescript
|
||||
// compact.ts:55-99 — 简化后的优先级链
|
||||
if (!customInstructions) {
|
||||
const sessionMemoryResult = await trySessionMemoryCompaction(messages, ...)
|
||||
if (sessionMemoryResult) return sessionMemoryResult // 优先:SM 压缩
|
||||
}
|
||||
|
||||
这种分层设计意味着大部分情况下,系统可以在用户无感知的情况下释放足够的 token。
|
||||
if (reactiveCompact?.isReactiveOnlyMode()) {
|
||||
return await compactViaReactive(messages, ...) // 次选:Reactive 压缩
|
||||
}
|
||||
|
||||
## 第一层:微压缩(Micro-Compact)
|
||||
// 兜底:传统 API 摘要
|
||||
const microcompactResult = await microcompactMessages(messages, context)
|
||||
const messagesForCompact = microcompactResult.messages
|
||||
// → 调用 AI 模型生成摘要
|
||||
```
|
||||
|
||||
微压缩不生成摘要,只是清除旧的工具调用结果。
|
||||
注意:SM 压缩不支持自定义指令(`/compact 聚焦在认证模块`),有自定义指令时直接走传统路径。
|
||||
|
||||
### 工作原理
|
||||
## 第一层:MicroCompact — 局部压缩
|
||||
|
||||
AI 在工作过程中会产生大量工具输出:文件内容、命令结果、搜索匹配等。这些信息在产生时很重要,但随着对话推进,它们的价值迅速降低。
|
||||
源码路径:`src/services/compact/microCompact.ts`
|
||||
|
||||
微压缩识别这些"过期"的工具输出,将其替换为简短的占位符文本。原始内容仍保留在磁盘上的 transcript 文件中,只是不再发送给 API。
|
||||
MicroCompact 不压缩整个对话,而是**清除旧工具输出的内容**。它维护一个白名单:
|
||||
|
||||
### 时间衰减
|
||||
```typescript
|
||||
// src/services/compact/microCompact.ts:41-50
|
||||
const COMPACTABLE_TOOLS = new Set([
|
||||
FILE_READ_TOOL_NAME, // 'Read' - 文件读取
|
||||
...SHELL_TOOL_NAMES, // 'Bash' - 命令输出
|
||||
GREP_TOOL_NAME, // 'Grep' - 搜索结果
|
||||
GLOB_TOOL_NAME, // 'Glob' - 文件列表
|
||||
WEB_SEARCH_TOOL_NAME, // 'WebSearch' - 搜索结果
|
||||
WEB_FETCH_TOOL_NAME, // 'WebFetch' - 网页内容
|
||||
FILE_EDIT_TOOL_NAME, // 'Edit' - 编辑输出
|
||||
FILE_WRITE_TOOL_NAME, // 'Write' - 写入输出
|
||||
])
|
||||
```
|
||||
|
||||
越旧的工具输出越容易被清除,最近操作的优先保留。这个策略基于一个经验观察:AI 通常只需要最近几步操作的上下文,更早的工具结果很少被再次引用。
|
||||
替换策略:将超过时间窗口的工具输出内容替换为 `[Old tool result content cleared]`。这不是简单的截断——原始内容仍保留在 JSONL transcript 中,只是不再发送给 API。
|
||||
|
||||
MicroCompact 还有一个**时间衰减配置**(`timeBasedMCConfig.ts`):越旧的工具输出越容易被清除,最近的优先保留。
|
||||
|
||||
### 图片和文档的特殊处理
|
||||
|
||||
图片和 PDF 文档的 token 占用远高于文本。微压缩会将大型图片和文档替换为文本标记(如 `[image]`),在保留"这里曾有一张图片"的语义的同时释放大量 token。
|
||||
```typescript
|
||||
const IMAGE_MAX_TOKEN_SIZE = 2000
|
||||
```
|
||||
|
||||
## 第二层:Session Memory 压缩
|
||||
图片 block 如果超过 2000 token 估算值,也会被 MicroCompact 清除。PDF document block 同理。
|
||||
|
||||
当微压缩释放的 token 不够时,系统优先尝试 Session Memory 压缩。
|
||||
## 第二层:Session Memory Compact — 无 API 调用的压缩
|
||||
|
||||
### 设计思路
|
||||
源码路径:`src/services/compact/sessionMemoryCompact.ts`
|
||||
|
||||
系统在对话过程中会持续提取关键信息形成"Session Memory"。压缩时,直接使用这些已提取的记忆作为对话摘要,而不需要额外调用 AI 生成摘要。
|
||||
当 `tengu_session_memory` + `tengu_sm_compact` 两个 feature flag 启用时,系统优先使用 Session Memory 进行压缩——**不需要调用摘要模型**,直接使用已经提取好的 Session Memory 作为对话摘要。
|
||||
|
||||
### 保留窗口
|
||||
### 保留窗口的计算
|
||||
|
||||
压缩后保留的消息需要满足三个约束:
|
||||
```typescript
|
||||
// sessionMemoryCompact.ts:324-397
|
||||
export function calculateMessagesToKeepIndex(messages, lastSummarizedIndex) {
|
||||
const config = getSessionMemoryCompactConfig()
|
||||
// 默认: minTokens=10K, minTextBlockMessages=5, maxTokens=40K
|
||||
|
||||
- **最小深度**:至少保留 10K token 的最近对话(确保 AI 有足够的上下文)
|
||||
- **最小连续性**:至少保留 5 条包含文本的消息(确保对话连贯)
|
||||
- **最大上限**:不超过 40K token(避免保留太多又很快触发下一次压缩)
|
||||
let startIndex = lastSummarizedIndex + 1
|
||||
// 从 lastSummarizedIndex 向前扩展,直到满足两个下限或命中上限
|
||||
for (let i = startIndex - 1; i >= floor; i--) {
|
||||
totalTokens += estimateMessageTokens([msg])
|
||||
if (hasTextBlocks(msg)) textBlockMessageCount++
|
||||
startIndex = i
|
||||
if (totalTokens >= config.maxTokens) break
|
||||
if (totalTokens >= config.minTokens && textBlockMessageCount >= config.minTextBlockMessages) break
|
||||
}
|
||||
return adjustIndexToPreserveAPIInvariants(messages, startIndex)
|
||||
}
|
||||
```
|
||||
|
||||
这三个约束形成了"不要压缩太少(AI 会迷失),也不要保留太多(很快又需要压缩)"的平衡。
|
||||
这个算法确保压缩后保留的消息窗口满足:
|
||||
- 至少 10,000 token(有上下文深度)
|
||||
- 至少 5 条包含文本的消息(有对话连续性)
|
||||
- 最多 40,000 token(不会太大又触发下一次压缩)
|
||||
|
||||
### 工具对完整性
|
||||
### 工具对完整性保护
|
||||
|
||||
这是一个关键的**正确性保证**:API 要求每个工具调用都有对应的工具结果,反之亦然。如果压缩恰好切在一个工具调用和它的结果之间,API 会报错。
|
||||
`adjustIndexToPreserveAPIInvariants()` 是压缩中一个**关键的正确性保证**:
|
||||
|
||||
系统在计算压缩边界时,会自动向前或向后调整切分点,确保所有工具调用-结果对要么完整保留,要么一起被压缩。
|
||||
API 要求每个 `tool_result` 都有对应的 `tool_use`,反之亦然。如果压缩恰好切在一条 `tool_result` 消息处,会导致 API 报错。
|
||||
|
||||
## 第三层:AI 摘要压缩
|
||||
```typescript
|
||||
// sessionMemoryCompact.ts:232-314
|
||||
// Step 1: 向前扫描,找到所有被保留消息中 tool_result 引用的 tool_use
|
||||
// Step 2: 向前扫描,找到与被保留 assistant 消息共享 message.id 的 thinking block
|
||||
// 两种情况都需要将 startIndex 向前移动
|
||||
```
|
||||
|
||||
当上述方法都不可用时(或用户手动触发 `/compact` 时),系统调用 AI 生成对话摘要。
|
||||
流式传输会将一个 assistant 消息拆分为多条存储记录(thinking、tool_use 等各有独立 uuid 但共享 `message.id`),这增加了边界情况的复杂度。
|
||||
|
||||
## 第三层:传统 API 摘要压缩
|
||||
|
||||
源码路径:`src/services/compact/compact.ts`
|
||||
|
||||
当 SM 压缩不可用时,系统回退到传统方式:调用 AI 模型生成对话摘要。
|
||||
|
||||
### 压缩前处理
|
||||
|
||||
发送给摘要 AI 的消息会经过预处理:
|
||||
- 图片被替换为文本标记(防止摘要请求本身也超出 token 限制)
|
||||
- 会被重新注入的附件被剥离(避免重复)
|
||||
发送给摘要模型之前,消息会经过多层预处理:
|
||||
|
||||
```typescript
|
||||
// compact.ts:147-202
|
||||
const stripped = stripImagesFromMessages(messages) // 图片→[image] 文字标记
|
||||
const stripped2 = stripReinjectedAttachments(stripped) // 移除会被重新注入的附件
|
||||
```
|
||||
|
||||
图片被替换为 `[image]` 标记,防止摘要 API 调用本身也触发 prompt-too-long 错误。
|
||||
|
||||
### 压缩后的重新注入
|
||||
|
||||
压缩不是"砍掉旧对话就完了"。AI 在压缩后立即需要知道"现在正在做什么"。系统会在摘要后重新注入关键上下文:
|
||||
压缩后,系统会从摘要中**重新注入关键上下文**:
|
||||
|
||||
| 重新注入内容 | 预算 | 设计理由 |
|
||||
|-------------|------|----------|
|
||||
| 最近读取的文件(最多 5 个) | 每文件 5K token | AI 最可能需要的就是刚操作过的文件 |
|
||||
| 已激活的技能指令 | 每技能 5K,总计 25K | 保持 AI 的能力集不变 |
|
||||
| CLAUDE.md 内容 | 不限 | 用户的项目指令必须始终存在 |
|
||||
| MCP 工具发现结果 | 不限 | 保持工具可用性 |
|
||||
```typescript
|
||||
// compact.ts:126-134
|
||||
export const POST_COMPACT_TOKEN_BUDGET = 50_000 // 总预算
|
||||
export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5 // 最多恢复 5 个文件
|
||||
export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000 // 每文件 5K token
|
||||
export const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000 // 每技能 5K token
|
||||
export const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000 // 技能总预算 25K
|
||||
```
|
||||
|
||||
**设计洞察**:总共 50K token 的重新注入预算看起来是浪费——刚压缩释放的 token 立刻又被用掉了一部分。但这是必要的:没有这些重新注入,AI 在压缩后会"忘记"当前任务状态,导致糟糕的用户体验。
|
||||
这 50K token 的重新注入预算用于:
|
||||
1. 恢复最近读取的文件内容(最多 5 个文件,每个截断到 5K token)
|
||||
2. 恢复已激活的技能指令(每个技能截断到 5K token,总计 25K)
|
||||
3. 重新注入 CLAUDE.md 内容
|
||||
4. 恢复 MCP 工具发现结果
|
||||
|
||||
## 边界标记:压缩的"墓碑"
|
||||
## CompactBoundary:压缩的边界标记
|
||||
|
||||
每次压缩后,系统在消息流中插入一条边界标记消息。这条消息不展示给用户,但记录了:
|
||||
- 压缩类型(自动/手动/微压缩)
|
||||
- 压缩前的 token 数
|
||||
- 哪些消息被保留
|
||||
源码路径:`src/utils/messages.ts`(`createCompactBoundaryMessage`)
|
||||
|
||||
后续所有操作只处理最后一条边界标记之后的消息。这防止了"重复压缩已经压缩过的内容"——每次压缩都是相对于上一次边界的新压缩。
|
||||
每次压缩后,系统在消息流中插入一条 `SystemCompactBoundaryMessage`:
|
||||
|
||||
### 微压缩 vs 全量压缩的边界区别
|
||||
```typescript
|
||||
type SystemCompactBoundaryMessage = {
|
||||
type: 'system'
|
||||
message: {
|
||||
type: 'compact_boundary'
|
||||
compactMetadata: {
|
||||
compactType: 'auto' | 'manual' | 'micro'
|
||||
preCompactTokenCount: number
|
||||
lastUserMessageUuid: string
|
||||
preCompactDiscoveredTools?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
微压缩的边界标记更轻量——它只记录哪些工具结果被清除了,不生成摘要。原始消息仍然存在(只是内容被替换为占位符),而非被摘要替代。
|
||||
后续所有操作只处理**最后一条 boundary 之后**的消息:
|
||||
|
||||
## 紧急降级:Prompt Too Long
|
||||
```typescript
|
||||
// messages.ts
|
||||
export function getMessagesAfterCompactBoundary(messages: Message[]): Message[] {
|
||||
const lastBoundary = messages.findLastIndex(m => isCompactBoundaryMessage(m))
|
||||
return lastBoundary >= 0 ? messages.slice(lastBoundary + 1) : messages
|
||||
}
|
||||
```
|
||||
|
||||
当压缩后仍然超出 token 限制时,系统进入紧急路径:
|
||||
### Preserved Segment 注解
|
||||
|
||||
1. **更激进的压缩**:尝试比正常压缩更激进的策略
|
||||
2. **直接截断**:从最早的对话开始删除,直到能塞进上下文窗口
|
||||
3. **放弃并报错**:如果以上都失败,向用户报告错误
|
||||
boundary 消息上还附加了 `preservedSegment` 注解,记录哪些消息被保留而非压缩:
|
||||
|
||||
**设计哲学**:系统宁可丢失部分对话历史,也不让整个会话卡死。用户可以通过文件快照和 transcript 记录恢复丢失的信息。
|
||||
```typescript
|
||||
// compact.ts — annotateBoundaryWithPreservedSegment
|
||||
boundaryMarker.compactMetadata.preservedSegment = {
|
||||
summaryMessageUuid: string
|
||||
preservedMessageUuids: string[]
|
||||
}
|
||||
```
|
||||
|
||||
## Hook 机制
|
||||
这在会话恢复时帮助加载器正确重建消息链,避免重复压缩已保留的消息。
|
||||
|
||||
压缩前后支持自定义 Hook:
|
||||
### Microcompact Boundary
|
||||
|
||||
- **Pre-compact Hook**:压缩前执行,可以标记"必须保留"的内容
|
||||
- **Post-compact Hook**:压缩后执行,可以验证关键信息是否被保留
|
||||
- **Session Start Hook**:压缩后恢复 CLAUDE.md 等上下文
|
||||
Microcompact 操作使用单独的 boundary 类型,与全量压缩的 `compact_boundary` 不同:
|
||||
|
||||
这确保了用户的自定义逻辑在压缩过程中被尊重——例如,用户可以通过 Hook 确保某个关键文件的内容永远不会被压缩掉。
|
||||
```typescript
|
||||
// src/utils/messages.ts:4599-4614
|
||||
type SystemMicrocompactBoundaryMessage = {
|
||||
type: 'system'
|
||||
subtype: 'microcompact_boundary'
|
||||
content: 'Context microcompacted'
|
||||
compactMetadata: {
|
||||
trigger: 'auto' // Microcompact 只有自动触发
|
||||
preTokens: number // 压缩前 token 数
|
||||
tokensSaved: number // 节省的 token 数
|
||||
compactedToolIds: string[] // 被压缩的工具 ID 列表
|
||||
clearedAttachmentUUIDs: string[] // 被清除的附件 UUID
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 接下来
|
||||
与 `compact_boundary` 的区别:
|
||||
- **保留原始消息**:Microcompact 仅清除工具输出内容,不删除消息本身
|
||||
- **可追溯性**:`compactedToolIds` 记录了哪些工具结果被清除
|
||||
- **轻量级**:不生成摘要,不调用 API
|
||||
|
||||
- **项目记忆** — 理解跨会话的记忆持久化设计
|
||||
- **令牌预算** — 了解 token 窗口的动态计算和阈值管理
|
||||
- **系统提示词** — 理解上下文组装的缓存优化策略
|
||||
## PTL 紧急降级:Prompt Too Long
|
||||
|
||||
当压缩后仍然超出 token 限制(`PROMPT_TOO_LONG` 错误),系统会进入紧急降级路径:
|
||||
|
||||
1. **Reactive Compact**:`reactiveCompactOnPromptTooLong()` 尝试更激进的压缩
|
||||
2. **截断重试**:如果 reactive 也失败,`truncateHeadForPTLRetry()` 直接截断最早的消息
|
||||
3. 放弃并报错
|
||||
|
||||
Reactive Compact 目前在反编译版本中是 stub(`isReactiveOnlyMode() → false`),表明这是 Anthropic 内部的实验性功能。
|
||||
|
||||
## 压缩的 Hook 机制
|
||||
|
||||
压缩前后可以执行自定义 Hook:
|
||||
|
||||
- **Pre-compact Hook**(`executePreCompactHooks`):在压缩前执行,可以注入"必须保留"的标记
|
||||
- **Post-compact Hook**(`executePostCompactHooks`):在压缩后执行,可以验证关键信息是否保留
|
||||
- **Session Start Hook**(`processSessionStartHooks('compact')`):SM 压缩使用此 Hook 恢复 CLAUDE.md 等上下文
|
||||
|
||||
Hook 结果以 `HookResultMessage` 的形式附加到压缩结果中,确保用户的自定义逻辑在压缩过程中被尊重。
|
||||
|
||||
## Snip Compact(实验性)
|
||||
|
||||
源码路径:`src/services/compact/snipCompact.ts`(stub)
|
||||
|
||||
Snip Compact 是另一种实验性压缩策略,在反编译版本中为空壳实现。从 stub 的类型签名推断:
|
||||
|
||||
```typescript
|
||||
snipCompactIfNeeded(messages, options?: { force?: boolean }) → {
|
||||
messages: Message[]
|
||||
executed: boolean
|
||||
tokensFreed: number
|
||||
boundaryMessage?: Message
|
||||
}
|
||||
```
|
||||
|
||||
它似乎是一种**更细粒度的消息级裁剪**(snip = 剪切),可能是对单条消息的进一步压缩,而非整个对话。`shouldNudgeForSnips()` 和 `SNIP_NUDGE_TEXT` 暗示它可能会提示用户触发。
|
||||
|
||||
@@ -1,137 +1,226 @@
|
||||
---
|
||||
title: "项目记忆"
|
||||
description: "AI 没有真正的记忆。Claude Code 如何通过文件系统构建跨会话的记忆?理解存储架构、四类型分类法、智能召回和漂移防御设计。"
|
||||
keywords: ["项目记忆", "MEMORY.md", "AI 记忆", "跨对话", "自动记忆"]
|
||||
title: "项目记忆系统 - 文件级跨对话记忆架构"
|
||||
description: "深度解析 Claude Code 记忆系统:基于文件的持久化存储、MEMORY.md 索引结构、四类型分类法、Sonnet 智能召回、Session Memory 压缩集成。"
|
||||
keywords: ["项目记忆", "MEMORY.md", "AI 记忆", "跨对话", "自动记忆", "memdir"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:从源码层面剖析记忆系统的存储架构、召回机制和注入链路 */}
|
||||
|
||||
AI 的每次 API 调用都是无状态的——模型只看到当前请求中的内容。这意味着每次新会话,AI 都从零开始,不知道你之前讨论过什么、你喜欢什么风格、项目有什么特殊约束。
|
||||
## 记忆系统的存储架构
|
||||
|
||||
记忆系统的目标:**让 AI 在跨会话中保持连贯性**。
|
||||
|
||||
## 存储架构:纯文件
|
||||
源码路径:`src/memdir/paths.ts`、`src/memdir/memdir.ts`
|
||||
|
||||
Claude Code 的记忆系统是**纯文件**的——没有数据库、没有向量存储,只有 Markdown 文件和目录结构。
|
||||
|
||||
### 目录布局
|
||||
|
||||
```
|
||||
~/.claude/projects/<项目目录>/memory/
|
||||
~/.claude/projects/<sanitized-git-root>/memory/
|
||||
├── MEMORY.md ← 入口索引(每次对话加载)
|
||||
├── user_role.md ← 用户记忆
|
||||
├── feedback_testing.md ← 反馈记忆
|
||||
├── project_mobile_release.md ← 项目记忆
|
||||
└── reference_linear_ingest.md ← 参考记忆
|
||||
├── reference_linear_ingest.md ← 参考记忆
|
||||
└── logs/ ← KAIROS 模式:每日日志
|
||||
└── 2026/
|
||||
└── 04/
|
||||
└── 2026-04-01.md
|
||||
```
|
||||
|
||||
### 为什么选择文件而非数据库
|
||||
路径解析链路(`getAutoMemPath()`):
|
||||
1. `CLAUDE_COWORK_MEMORY_PATH_OVERRIDE` 环境变量(Cowork SDK 全路径覆盖)
|
||||
2. `autoMemoryDirectory` 设置(仅限 `policySettings`/`localSettings`/`userSettings`——**故意排除** `projectSettings`,防止恶意仓库将记忆路径指向 `~/.ssh`)
|
||||
3. 默认:`<memoryBase>/projects/<sanitized-git-root>/memory/`
|
||||
|
||||
| 方案 | 优势 | 劣势 |
|
||||
|------|------|------|
|
||||
| **Markdown 文件** | 用户可直接编辑、git 友好、零依赖 | 查询需要扫描文件 |
|
||||
| SQLite 数据库 | 查询高效 | 用户无法直接编辑、需要额外依赖 |
|
||||
| 向量数据库 | 语义搜索强大 | 过度工程、引入复杂依赖 |
|
||||
|
||||
选择文件的核心理由:**记忆应该是用户可以审查、编辑和删除的**。Markdown 文件让用户完全掌控 AI 记住了什么。如果 AI 记住了错误的信息,用户可以直接打开文件删除它。
|
||||
同一个 Git 仓库的所有 worktree 共享一个记忆目录(通过 `findCanonicalGitRoot()` 找到真正的 `.git` 根)。
|
||||
|
||||
### MEMORY.md 索引
|
||||
|
||||
`MEMORY.md` 是记忆系统的入口。每次对话开始时完整加载到上下文中。它不是记忆内容本身,而是一个链接索引:
|
||||
`MEMORY.md` 是记忆的入口索引,每次对话都完整加载到上下文中:
|
||||
|
||||
```markdown
|
||||
- [用户角色](user_role.md) — 深度 Go 开发者,React 新手
|
||||
- [测试反馈](feedback_testing.md) — 集成测试必须使用真实数据库
|
||||
```typescript
|
||||
// memdir.ts:34-38
|
||||
export const ENTRYPOINT_NAME = 'MEMORY.md'
|
||||
export const MAX_ENTRYPOINT_LINES = 200
|
||||
export const MAX_ENTRYPOINT_BYTES = 25_000
|
||||
```
|
||||
|
||||
索引有双重上限(行数和字节数),防止索引本身占用过多 token。超过上限时自动截断——这确保记忆系统不会成为新的 token 负担。
|
||||
索引有**双重上限**:200 行 AND 25KB。超过任何一条都会被 `truncateEntrypointContent()` 截断并追加警告。设计原因:p97 的索引文件用 200 行就能覆盖,但有些索引条目特别长(p100 观测到 197KB/200 行),字节上限捕捉这种长行异常。
|
||||
|
||||
索引条目格式:
|
||||
```markdown
|
||||
- [Title](file.md) — one-line hook
|
||||
```
|
||||
|
||||
每条一行,~150 字符以内。`MEMORY.md` 本身没有 frontmatter——它只是一个链接列表,不是记忆内容。
|
||||
|
||||
## 四类型分类法
|
||||
|
||||
记忆被约束为一个封闭的四类型系统,每种类型有明确的用途和保存时机:
|
||||
源码路径:`src/memdir/memoryTypes.ts`
|
||||
|
||||
| 类型 | 存储内容 | 设计目的 |
|
||||
|------|---------|----------|
|
||||
| **user** | 用户角色、偏好、技术背景 | 让 AI 理解"用户是谁" |
|
||||
| **feedback** | 用户对 AI 行为的纠正和确认 | 防止 AI 重复犯错或偏离已验证的工作方式 |
|
||||
| **project** | 无法从代码推导的项目上下文 | 记录"为什么这样决定"而非"代码长什么样" |
|
||||
| **reference** | 外部系统的指针 | 告诉 AI 去哪里找信息 |
|
||||
记忆被约束为一个**封闭的四类型系统**,每种类型有明确的 `<when_to_save>`、`<how_to_use>` 和 `<body_structure>` 规范:
|
||||
|
||||
### 关键设计约束
|
||||
| 类型 | 存储内容 | 典型触发 |
|
||||
|------|---------|---------|
|
||||
| **user** | 用户角色、偏好、技术背景 | "我是数据科学家"、"我写了十年 Go" |
|
||||
| **feedback** | 用户对 AI 行为的纠正和确认 | "别 mock 数据库"、"单 PR 更好" |
|
||||
| **project** | 非代码可推导的项目上下文 | "合并冻结从周四开始"、"auth 重写是合规要求" |
|
||||
| **reference** | 外部系统指针 | "pipeline bugs 在 Linear INGEST 项目" |
|
||||
|
||||
**只存储无法从当前项目状态推导的信息。** 代码架构、文件路径、git 历史都可以实时获取,不需要记忆。
|
||||
关键设计约束:**只存储无法从当前项目状态推导的信息**。代码架构、文件路径、git 历史都可以实时获取,不需要记忆。
|
||||
|
||||
这条约束防止记忆系统变成冗余缓存。如果某个信息可以通过读代码获得,那就不应该记下来——因为代码是最新的,而记忆可能已经过时。
|
||||
### 反馈类型的双通道捕获
|
||||
|
||||
### 反馈的双通道捕获
|
||||
`feedback` 类型的 `when_to_save` 指令特别强调:
|
||||
|
||||
反馈类型特别强调不仅要在用户纠正时保存("不要这样做"),也要在用户确认时保存("对,就是这样")。
|
||||
> Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious.
|
||||
|
||||
**设计考量**:如果只记录纠正,AI 会避免过去的错误,但也会偏离用户已经验证过的工作方式,变得越来越保守。记录"成功路径"和记录"失败路径"同等重要。
|
||||
这意味着 AI 不仅在用户说"不要这样做"时保存,也在用户说"对,就是这样"时保存。后一种更难捕捉,但同等重要——它防止 AI 的行为随时间漂移。
|
||||
|
||||
### 每条记忆的结构
|
||||
|
||||
每条记忆文件都有 frontmatter 元数据:
|
||||
### 每条记忆的 Frontmatter 格式
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: 测试反馈
|
||||
description: 集成测试的数据库使用偏好
|
||||
type: feedback
|
||||
name: {{memory name}}
|
||||
description: {{one-line description — 用于未来判断相关性}}
|
||||
type: {{user, feedback, project, reference}}
|
||||
---
|
||||
|
||||
集成测试必须使用真实数据库,不能 mock。
|
||||
**Why:** 上季度发生过 mock 通过但生产迁移失败的事故。
|
||||
**How to apply:** 所有涉及数据库的测试用真实实例。
|
||||
{{memory content — feedback/project 类型建议包含 **Why:** 和 **How to apply:** 行}}
|
||||
```
|
||||
|
||||
`description` 字段不是给人读的摘要——它是给 AI 召回系统做相关性判断的搜索关键词。`Why` 和 `How to apply` 行帮助 AI 理解"为什么有这条规则"和"什么时候该应用它"。
|
||||
`description` 字段是关键:它不是给人读的摘要,而是给 AI 召回系统做相关性判断的搜索关键词。
|
||||
|
||||
## 智能召回
|
||||
## 智能召回机制
|
||||
|
||||
不是所有记忆都适合每次对话。用户可能在 50 个记忆文件中积累了大量信息,但一次对话通常只需要其中 3-5 条。
|
||||
源码路径:`src/memdir/findRelevantMemories.ts`、`src/memdir/memoryScan.ts`
|
||||
|
||||
### 召回架构
|
||||
不是所有记忆都适合每次对话。系统使用一个**轻量级 Sonnet 侧查询**来筛选最相关的记忆。
|
||||
|
||||
系统使用一个轻量级的独立 AI 查询来筛选最相关的记忆:
|
||||
### 召回流程
|
||||
|
||||
```
|
||||
用户消息
|
||||
→ 扫描所有记忆文件的元数据
|
||||
→ 独立 AI 查询:从所有记忆中选出最相关的 ≤5 条
|
||||
→ 只加载选中的记忆文件内容
|
||||
用户消息 → findRelevantMemories(query, memoryDir)
|
||||
├── scanMemoryFiles() — 扫描所有记忆文件的 frontmatter
|
||||
├── selectRelevantMemories() — Sonnet 侧查询,从清单中选出 ≤5 条
|
||||
└── 返回 [{path, mtimeMs}, ...]
|
||||
```
|
||||
|
||||
**设计考量**:为什么不直接加载所有记忆?因为记忆文件会随时间增长,全部加载可能占用大量 token。使用独立查询筛选虽然多花一次 API 调用,但可以显著减少主对话的 token 消耗。
|
||||
核心是 `selectRelevantMemories()` 函数,它调用 `sideQuery()`(一个独立的轻量 API 调用):
|
||||
|
||||
### 去噪设计
|
||||
```typescript
|
||||
// findRelevantMemories.ts:98-121
|
||||
const result = await sideQuery({
|
||||
model: getDefaultSonnetModel(), // 用 Sonnet 做筛选(非主模型)
|
||||
system: SELECT_MEMORIES_SYSTEM_PROMPT,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `Query: ${query}\n\nAvailable memories:\n${manifest}${toolsSection}`
|
||||
}],
|
||||
max_tokens: 256,
|
||||
output_format: { type: 'json_schema', schema: { ... } },
|
||||
})
|
||||
```
|
||||
|
||||
- **近期工具去噪**:当 AI 正在使用某个工具时,不召回该工具的使用文档(对话中已有工作上下文)。但仍召回关于这些工具的**警告和已知问题**——这正是使用时最关键的信息。
|
||||
- **已展示去重**:之前轮次已展示过的记忆不再重复召回,让有限的召回预算花在新的候选上。
|
||||
### 近期工具去噪
|
||||
|
||||
当 AI 正在使用某个工具时,召回该工具的使用文档是噪音(对话中已有工作上下文)。`recentTools` 参数让召回系统跳过这些记忆:
|
||||
|
||||
```typescript
|
||||
// findRelevantMemories.ts:92-95
|
||||
const toolsSection = recentTools.length > 0
|
||||
? `\n\nRecently used tools: ${recentTools.join(', ')}`
|
||||
: ''
|
||||
```
|
||||
|
||||
System Prompt 明确指示:"如果已提供最近使用的工具列表,不要选择该工具的使用参考或 API 文档。**仍然要选择**关于这些工具的警告、陷阱或已知问题——这正是使用时最关键的信息。"
|
||||
|
||||
### 已展示去重
|
||||
|
||||
`alreadySurfaced` 参数过滤之前轮次已展示过的文件路径,让 Sonnet 的 5 槽预算花在新的候选上,而不是重复召回同一文件。
|
||||
|
||||
## 记忆注入 System Prompt 的链路
|
||||
|
||||
源码路径:`src/memdir/memdir.ts` → `src/context.ts`
|
||||
|
||||
`loadMemoryPrompt()` 是记忆注入的入口,每会话调用一次(通过 `systemPromptSection('memory', ...)` 缓存):
|
||||
|
||||
```typescript
|
||||
// memdir.ts:419-507
|
||||
export async function loadMemoryPrompt(): Promise<string | null> {
|
||||
// 优先级:KAIROS 日志模式 → TEAMMEM 组合模式 → 纯自动记忆
|
||||
if (feature('KAIROS') && autoEnabled && getKairosActive()) {
|
||||
return buildAssistantDailyLogPrompt(skipIndex)
|
||||
}
|
||||
if (feature('TEAMMEM') && teamMemPaths!.isTeamMemoryEnabled()) {
|
||||
return teamMemPrompts!.buildCombinedMemoryPrompt(...)
|
||||
}
|
||||
if (autoEnabled) {
|
||||
return buildMemoryLines('auto memory', autoDir, ...).join('\n')
|
||||
}
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
注入时机:`context.ts` 中 `getSystemContext()` 调用时,记忆 Prompt 作为 system prompt 的一个 section 被组装。`MEMORY.md` 的内容作为 **user context message** 注入(而非 system prompt),这样可以利用 Prompt Cache 的 prefix 共享。
|
||||
|
||||
## KAIROS 模式:每日日志
|
||||
|
||||
源码路径:`src/memdir/memdir.ts`(`buildAssistantDailyLogPrompt`)
|
||||
|
||||
长期运行的 assistant 会话使用不同的记忆策略:
|
||||
|
||||
- **标准模式**:AI 维护 `MEMORY.md` 作为实时索引 + 独立记忆文件
|
||||
- **KAIROS 模式**:AI 只往日期文件追加日志(`logs/YYYY/MM/YYYY-MM-DD.md`),不做重组
|
||||
|
||||
```typescript
|
||||
// 日志路径模式(非字面路径——因为 Prompt 被缓存)
|
||||
const logPathPattern = join(memoryDir, 'logs', 'YYYY', 'MM', 'YYYY-MM-DD.md')
|
||||
```
|
||||
|
||||
一个独立的夜间 `/dream` 技能负责将日志蒸馏为主题文件 + `MEMORY.md` 索引。
|
||||
|
||||
## 记忆漂移防御
|
||||
|
||||
记忆可能过时。系统在 AI 的行为指令中设置了专门的防御:
|
||||
源码路径:`src/memdir/memoryTypes.ts`(`TRUSTING_RECALL_SECTION`)
|
||||
|
||||
> 一条记忆提到某个函数或文件,只是声明它**在记忆被写入时**存在。它可能已被重命名、删除或从未合并。在推荐之前,先验证它是否还存在。
|
||||
记忆可能过期。系统在 Prompt 中设置了一个专门的 section "Before recommending from memory":
|
||||
|
||||
这个指令从"行动导向"的角度设计——不是告诉 AI"记忆可能不准确"(太抽象),而是直接告诉它"在推荐之前先检查"(可操作)。
|
||||
```
|
||||
A memory that names a specific function, file, or flag is a claim
|
||||
that it existed *when the memory was written*. It may have been
|
||||
renamed, removed, or never merged. Before recommending it:
|
||||
|
||||
- If the memory names a file path: check the file exists.
|
||||
- If the memory names a function or flag: grep for it.
|
||||
```
|
||||
|
||||
这个 section 的标题经过 A/B 测试验证:"Before recommending from memory"(行动导向)比 "Trusting what you recall"(抽象描述)效果好(3/3 vs 0/3)。
|
||||
|
||||
### 忽略记忆的严格语义
|
||||
|
||||
当用户说"忽略记忆"时,AI 必须做到真正的忽略——不应用、不引用、不比较、甚至不提及记忆内容。
|
||||
```
|
||||
If the user says to *ignore* or *not use* memory:
|
||||
proceed as if MEMORY.md were empty.
|
||||
Do not apply remembered facts, cite, compare against,
|
||||
or mention memory content.
|
||||
```
|
||||
|
||||
这解决了一个常见的 AI 反模式:用户说"忽略关于 X 的记忆",AI 虽然正确识别了代码,但仍加上"不像记忆中说的 Y"——这不是"忽略",而是"承认然后覆盖"。
|
||||
这解决了 AI 的一个常见反模式:用户说"忽略关于 X 的记忆",AI 虽然正确识别了代码但仍然加上"不像记忆中说的 Y"——这不是"忽略",而是"承认然后覆盖"。
|
||||
|
||||
## 与上下文压缩的联动
|
||||
## Session Memory 与压缩的联动
|
||||
|
||||
记忆系统与上下文压缩深度集成。当 Session Memory 功能启用时,压缩优先使用已提取的记忆作为摘要——不需要额外的 AI 调用生成摘要,更快、更便宜、且不会丢失信息。
|
||||
源码路径:`src/services/compact/sessionMemoryCompact.ts`
|
||||
|
||||
这形成了一个正向循环:
|
||||
1. 对话中积累信息 → 提取为记忆
|
||||
2. 上下文需要压缩时 → 使用记忆作为摘要
|
||||
3. 下次对话开始 → 通过智能召回加载相关记忆
|
||||
记忆系统与上下文压缩有深度集成。当 `tengu_session_memory` 和 `tengu_sm_compact` 两个 feature flag 同时开启时,压缩优先使用 Session Memory 而非传统摘要:
|
||||
|
||||
## 接下来
|
||||
```typescript
|
||||
// sessionMemoryCompact.ts:57-61
|
||||
const DEFAULT_SM_COMPACT_CONFIG = {
|
||||
minTokens: 10_000, // 压缩后至少保留 10K token
|
||||
minTextBlockMessages: 5, // 至少保留 5 条文本消息
|
||||
maxTokens: 40_000, // 最多保留 40K token
|
||||
}
|
||||
```
|
||||
|
||||
- **自动记忆整理** — 了解 KAIROS 模式下的每日日志和蒸馏机制
|
||||
- **上下文压缩** — 理解记忆如何与压缩策略联动
|
||||
- **令牌预算** — 了解记忆加载的 token 开销管理
|
||||
SM-compact 不调用压缩 API(没有摘要模型),而是直接使用已有的 Session Memory 作为摘要——更快、更便宜、且不会丢失信息。
|
||||
|
||||
@@ -1,116 +1,290 @@
|
||||
---
|
||||
title: "系统提示词"
|
||||
description: "系统提示词不是一段写死的文本,而是一个动态组装、分块缓存的数组。理解三阶段管道、缓存分界标记和多级优先级选择的设计。"
|
||||
keywords: ["系统提示词", "System Prompt", "动态组装", "Prompt Cache", "缓存策略"]
|
||||
title: "System Prompt 动态组装 - AI 工作记忆构建"
|
||||
description: "深入解析 Claude Code 的 System Prompt 动态组装过程:缓存策略、分界标记、Section 注册表、CLAUDE.md 多级合并,以及如何将零散上下文拼装为 API 可消费的缓存友好结构。"
|
||||
keywords: ["System Prompt", "系统提示词", "动态组装", "CLAUDE.md", "Prompt Cache", "缓存策略"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
## 从数组到 API 调用:System Prompt 的完整链路
|
||||
|
||||
AI 的行为由系统提示词(System Prompt)决定。但一个编程助手的系统提示词远不止一句"你是一个有用的助手"——它需要包含工具使用规则、安全策略、项目上下文、用户偏好等大量动态内容。
|
||||
System Prompt 在 Claude Code 中不是一段写死的文本,而是一个 **`string[]` 数组**(品牌类型 `SystemPrompt`,定义于 `src/utils/systemPromptType.ts:8`),经过组装、分块、缓存标记后发送给 API。
|
||||
|
||||
挑战在于:**这些内容每次请求都要发送,而且大部分是不变的。** 如何在保持动态性的同时最小化 token 成本?
|
||||
|
||||
## 从文本到数组:缓存驱动的架构选择
|
||||
|
||||
系统提示词不是单个字符串,而是一个 **字符串数组**。
|
||||
|
||||
### 为什么是数组?
|
||||
|
||||
Anthropic 的 Prompt Cache 以**内容块**为单位缓存。将提示词拆为多个块,不变的部分可以获得独立的缓存命中。如果是一个单字符串,任何一个字符变化(如日期更新)都会导致整个提示词的缓存失效。
|
||||
|
||||
一个典型的系统提示词约 20K+ tokens,通过缓存分块可以节省 30-50% 的输入 token 费用。
|
||||
|
||||
### 品牌类型保证
|
||||
|
||||
系统使用品牌类型(branded type)防止普通字符串数组被意外传入 API 调用——只有通过显式转换才能获得系统提示词类型。这是零开销的编译时安全保证。
|
||||
|
||||
## 三阶段组装管道
|
||||
### 三阶段管道
|
||||
|
||||
```
|
||||
收集内容 → 选择优先级 → 分块 + 缓存标记
|
||||
getSystemPrompt() → string[] (组装内容)
|
||||
↓
|
||||
buildEffectiveSystemPrompt() → SystemPrompt (选择优先级路径)
|
||||
↓
|
||||
buildSystemPromptBlocks() → TextBlockParam[] (分块 + cache_control 标记)
|
||||
```
|
||||
|
||||
### 第一阶段:内容收集
|
||||
1. **`getSystemPrompt()`**(`src/constants/prompts.ts:444`)—— 收集静态段 + 动态段,插入 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分界标记
|
||||
2. **`buildEffectiveSystemPrompt()`**(`src/utils/systemPrompt.ts:41`)—— 按 Override > Coordinator > Agent > Custom > Default 优先级选择
|
||||
3. **`buildSystemPromptBlocks()`**(`src/services/api/claude.ts:3279`)—— 调用 `splitSysPromptPrefix()` 分块,为每个块附加 `cache_control`
|
||||
|
||||
系统提示词的内容分为两个区域:
|
||||
## SystemPrompt 品牌类型
|
||||
|
||||
| 区域 | 内容 | 特点 |
|
||||
|------|------|------|
|
||||
| **静态区** | 工具使用规则、安全策略、输出格式规范 | 所有用户相同 |
|
||||
| **动态区** | 记忆、MCP 指令、模型覆盖、语言偏好 | 因用户/会话而异 |
|
||||
```typescript
|
||||
// packages/@ant/model-provider/src/types/systemPrompt.ts:4
|
||||
export type SystemPrompt = readonly string[] & {
|
||||
readonly __brand: 'SystemPrompt'
|
||||
}
|
||||
export function asSystemPrompt(value: readonly string[]): SystemPrompt {
|
||||
return value as SystemPrompt // 零开销类型断言
|
||||
}
|
||||
```
|
||||
|
||||
两个区域之间用一个特殊的**分界标记**分隔。这个标记永远不会发送给 AI——它只是告诉缓存系统:"到这里为止是静态的,从这里开始是动态的"。
|
||||
品牌类型(branded type)防止普通 `string[]` 被意外传入 API 调用——只有通过 `asSystemPrompt()` 显式转换才能获得 `SystemPrompt` 类型。
|
||||
|
||||
### 第二阶段:优先级选择
|
||||
## getSystemPrompt():内容组装的全景
|
||||
|
||||
最终使用哪个提示词取决于五级优先级:
|
||||
`src/constants/prompts.ts:444` 是 System Prompt 的核心工厂函数,返回一个有序数组:
|
||||
|
||||
| 优先级 | 来源 | 场景 |
|
||||
| 阶段 | 内容 | 缓存策略 |
|
||||
|------|------|----------|
|
||||
| **静态区** | Intro Section、System Rules、Doing Tasks、Actions、Using Tools、Tone & Style、Output Efficiency | 可跨组织缓存(`scope: 'global'`) |
|
||||
| **BOUNDARY** | `SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'` | 分界标记(不发送给 API,仅用于分割静态区与动态区以实现全局缓存) |
|
||||
| **动态区** | Session Guidance、Memory、Model Override、Env Info、Language、Output Style、MCP Instructions、Scratchpad、FRC、Summarize Tool Results、Token Budget、Brief | 每次会话不同(`scope: 'org'` 或无缓存) |
|
||||
|
||||
> **Boundary 是什么**:它把 System Prompt 分成"不变的静态区"和"因用户/会话而异的动态区"。静态区对所有用户相同,可获得 `scope: 'global'` 跨组织缓存;动态区每次不同,只能 `scope: 'org'` 或不缓存。它本身是一个特殊字符串,在发送给 API 前被移除,AI 永远看不到。
|
||||
|
||||
### 动态区的 Section 注册表
|
||||
|
||||
动态区通过 `systemPromptSection()` / `DANGEROUS_uncachedSystemPromptSection()` 注册,这两个工厂函数定义于 `src/constants/systemPromptSections.ts`:
|
||||
|
||||
```typescript
|
||||
// 缓存式 Section:计算一次,/clear 或 /compact 后才重新计算
|
||||
systemPromptSection('memory', () => loadMemoryPrompt())
|
||||
|
||||
// 危险:每轮重新计算,会破坏 Prompt Cache
|
||||
DANGEROUS_uncachedSystemPromptSection(
|
||||
'mcp_instructions',
|
||||
() => isMcpInstructionsDeltaEnabled() ? null : getMcpInstructionsSection(mcpClients),
|
||||
'MCP servers connect/disconnect between turns' // 必须给出破坏缓存的理由
|
||||
)
|
||||
```
|
||||
|
||||
`resolveSystemPromptSections()` 在每轮查询时解析所有 Section,对于 `cacheBreak: false` 的 Section,优先使用 `getSystemPromptSectionCache()` 中的缓存值。只有 MCP 指令等真正动态的内容使用 `DANGEROUS_uncachedSystemPromptSection`。
|
||||
|
||||
### `CLAUDE_CODE_SIMPLE` 快速路径
|
||||
|
||||
当环境变量 `CLAUDE_CODE_SIMPLE` 为真时,整个 System Prompt 缩减为一行:
|
||||
|
||||
```typescript
|
||||
`You are Claude Code, Anthropic's official CLI for Claude.\n\nCWD: ${getCwd()}\nDate: ${getSessionStartDate()}`
|
||||
```
|
||||
|
||||
跳过所有 Section 注册、缓存分块、动态组装——用于最小化 token 消耗的测试场景。
|
||||
|
||||
## buildEffectiveSystemPrompt():五级优先级
|
||||
|
||||
`src/utils/systemPrompt.ts:41` 决定最终使用哪个 System Prompt:
|
||||
|
||||
| 优先级 | 条件 | 行为 |
|
||||
|--------|------|------|
|
||||
| **Override** | 外部覆盖 | SDK 集成、自动化测试 |
|
||||
| **Coordinator** | 协调者模式 | 多 Agent 编排 |
|
||||
| **Agent** | Agent 定义 | 自定义 Agent |
|
||||
| **Custom** | 命令行参数 | 用户的自定义提示词 |
|
||||
| **Default** | 完整组装 | 正常使用 |
|
||||
| **0. Override** | `overrideSystemPrompt` 非空 | 完全替换,返回 `[override]` |
|
||||
| **1. Coordinator** | `COORDINATOR_MODE` feature + 环境变量 | 使用协调者专用提示词 |
|
||||
| **2. Agent** | `mainThreadAgentDefinition` 存在 | Proactive 模式:追加到默认提示词尾部;否则:替换默认提示词 |
|
||||
| **3. Custom** | `--system-prompt` 参数指定 | 替换默认提示词 |
|
||||
| **4. Default** | 无特殊条件 | 使用 `getSystemPrompt()` 完整输出 |
|
||||
|
||||
**设计考量**:Override 级别完全替换默认提示词(包括安全规则),这是危险的但必要的——自动化测试和 SDK 集成需要完全控制。其他级别则在默认提示词基础上追加或替换。
|
||||
`appendSystemPrompt` 始终追加到末尾(Override 除外)。
|
||||
|
||||
### 第三阶段:分块与缓存标记
|
||||
## Provider 系统概述
|
||||
|
||||
分块策略根据条件选择三种模式:
|
||||
Claude Code 支持多种 API 提供商,分为两大类:
|
||||
|
||||
**模式 1:全局缓存(1P 用户默认)**
|
||||
```
|
||||
[归属头] → 不缓存
|
||||
[提示词前缀] → 不缓存
|
||||
[静态内容] → 全局缓存(跨组织共享)
|
||||
[动态内容] → 不缓存
|
||||
| 类别 | Provider | 环境变量 | 说明 |
|
||||
|------|----------|---------|------|
|
||||
| **1P (First Party)** | `firstParty` | 默认 | Anthropic 官方 API 直连 |
|
||||
| **3P (Third Party)** | `bedrock` | `CLAUDE_CODE_USE_BEDROCK=1` | AWS Bedrock 托管服务 |
|
||||
| **3P** | `vertex` | `CLAUDE_CODE_USE_VERTEX=1` | Google Vertex AI |
|
||||
| **3P** | `openai` | `CLAUDE_CODE_USE_OPENAI=1` | OpenAI 兼容层(Ollama/DeepSeek/vLLM) |
|
||||
| **3P** | `gemini` | `CLAUDE_CODE_USE_GEMINI=1` | Google Gemini API |
|
||||
| **3P** | `grok` | `CLAUDE_CODE_USE_GROK=1` | xAI Grok |
|
||||
|
||||
Provider 决定了:
|
||||
- **可用的 beta headers**:部分 beta 功能仅限 1P 用户
|
||||
- **缓存策略**:全局缓存 `scope: 'global'` 仅 1P 可用
|
||||
- **Token 计数方式**:Bedrock 有独立的 countTokens 端点,OpenAI/Gemini 依赖估算
|
||||
|
||||
```typescript
|
||||
// src/utils/model/providers.ts:5-13
|
||||
export type APIProvider =
|
||||
| 'firstParty' // 1P - Anthropic 直连
|
||||
| 'bedrock' // 3P - AWS Bedrock
|
||||
| 'vertex' // 3P - Google Vertex
|
||||
| 'foundry' // 3P - Anthropic Foundry
|
||||
| 'openai' // 3P - OpenAI 兼容层
|
||||
| 'gemini' // 3P - Google Gemini
|
||||
| 'grok' // 3P - xAI Grok
|
||||
```
|
||||
|
||||
**模式 2:组织缓存(MCP 工具存在时)**
|
||||
## 缓存策略:分块、标记、命中
|
||||
|
||||
这是 System Prompt 设计中最精密的部分。
|
||||
|
||||
### Anthropic Prompt Cache 基础
|
||||
|
||||
Anthropic API 的 Prompt Cache 允许跨请求复用相同的 System Prompt 前缀,按缓存命中量计费(远低于完整输入价格)。缓存键由内容的 Blake2b 哈希决定——任何字符变化都会导致缓存失效。
|
||||
|
||||
### `splitSysPromptPrefix()`:三种分块模式
|
||||
|
||||
`src/utils/api.ts:321` 是缓存策略的核心,根据条件选择三种分块模式:
|
||||
|
||||
#### 模式 1:MCP 工具存在时(`skipGlobalCacheForSystemPrompt=true`)
|
||||
|
||||
```
|
||||
[归属头] → 不缓存
|
||||
[提示词前缀] → 组织缓存
|
||||
[其余内容] → 组织缓存
|
||||
[attribution header] → cacheScope: null (不缓存)
|
||||
[system prompt prefix] → cacheScope: 'org' (组织级缓存)
|
||||
[everything else] → cacheScope: 'org' (组织级缓存)
|
||||
```
|
||||
|
||||
**模式 3:组织缓存(3P 用户)**
|
||||
MCP 工具列表在会话中可能变化(连接/断开),破坏了跨组织缓存的基础,因此降级为组织级。
|
||||
|
||||
#### 模式 2:Global Cache + Boundary 存在(1P 专用)
|
||||
|
||||
```
|
||||
[归属头] → 不缓存
|
||||
[提示词前缀] → 组织缓存
|
||||
[其余内容] → 组织缓存
|
||||
[attribution header] → cacheScope: null (不缓存)
|
||||
[system prompt prefix] → cacheScope: null (不缓存)
|
||||
[static content] → cacheScope: 'global' (全局缓存!跨组织共享)
|
||||
[dynamic content] → cacheScope: null (不缓存)
|
||||
```
|
||||
|
||||
**为什么 MCP 工具会降级缓存**?MCP 工具列表在会话中可能变化(连接/断开),这会改变提示词内容,破坏跨组织缓存的基础。因此当 MCP 工具存在时,只能使用组织级缓存。
|
||||
这是缓存效率最高的模式。`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 之前的静态内容(Intro、Rules、Tone & Style 等)对所有用户相同,可跨组织缓存。
|
||||
|
||||
### 缓存破坏的防御
|
||||
> **Boundary 插入条件**:`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 标记**仅在特定条件**下插入:
|
||||
|
||||
动态区的某些内容(如会话特定的工具集)必须严格放在分界标记之后。如果错误地放在静态区,每个运行时条件的组合会产生 2^N 种不同的哈希值(N = 条件数量),完全破坏缓存命中率。
|
||||
```typescript
|
||||
// src/utils/betas.ts:226-229
|
||||
export function shouldUseGlobalCacheScope(): boolean {
|
||||
return (
|
||||
getAPIProvider() === 'firstParty' &&
|
||||
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
系统对"每轮都重新计算"的 Section 使用专门的危险标记——开发者必须给出破坏缓存的理由才能使用它。
|
||||
```typescript
|
||||
// src/constants/prompts.ts:574
|
||||
...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
|
||||
```
|
||||
|
||||
## 上下文注入:两条独立管道
|
||||
这意味着:
|
||||
- **3P 用户(Bedrock/Vertex/OpenAI/Gemini)**:Boundary 永远不存在,始终使用模式 3
|
||||
- **1P 用户禁用实验性功能**:设置 `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1`,Boundary 不插入
|
||||
- **1P 用户默认**:Boundary 存在,使用模式 2(最高缓存效率)
|
||||
|
||||
系统提示词本身不包含运行时上下文。上下文通过两条独立管道注入:
|
||||
#### 模式 3:默认(3P 提供商 或 Boundary 缺失)
|
||||
|
||||
### System Context(会话级不变)
|
||||
```
|
||||
[attribution header] → cacheScope: null (不缓存)
|
||||
[system prompt prefix] → cacheScope: 'org' (组织级缓存)
|
||||
[everything else] → cacheScope: 'org' (组织级缓存)
|
||||
```
|
||||
|
||||
- Git 分支和状态
|
||||
- 最近的提交记录
|
||||
- 计算一次,整个会话期间缓存
|
||||
### `getCacheControl()`:TTL 决策
|
||||
|
||||
### User Context(动态变化)
|
||||
`src/services/api/claude.ts:348` 生成的 `cache_control` 对象:
|
||||
|
||||
- 合并后的 CLAUDE.md 内容
|
||||
- 当前日期
|
||||
```typescript
|
||||
{
|
||||
type: 'ephemeral',
|
||||
ttl?: '1h', // 仅特定 querySource 符合条件时
|
||||
scope?: 'global', // 仅静态区
|
||||
}
|
||||
```
|
||||
|
||||
**为什么 User Context 不放在 System Prompt 中**?因为 User Context 作为用户消息发送,可以利用 Prompt Cache 的前缀共享——系统提示词是所有用户共享的前缀,User Context 是每个用户的变化部分。这种分层让缓存命中率最大化。
|
||||
1 小时 TTL 的判定逻辑(`should1hCacheTTL()`,第 383 行):
|
||||
- **Bedrock 用户**:通过环境变量 `ENABLE_PROMPT_CACHING_1H_BEDROCK` 启用
|
||||
- **1P 用户**:通过 GrowthBook 配置的 `allowlist` 数组匹配 `querySource`,支持前缀通配符(如 `"repl_main_thread*"`)
|
||||
- **会话级锁定**:资格判定结果在 bootstrap state 中缓存,防止 GrowthBook 配置中途变化导致同一会话内 TTL 不一致
|
||||
|
||||
### 缓存破坏:Session-Specific Guidance 的放置
|
||||
|
||||
`getSessionSpecificGuidanceSection()`(`src/constants/prompts.ts:354`)的内容必须放在 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` **之后**。因为它包含:
|
||||
- 当前会话的 enabledTools 集合
|
||||
- `isForkSubagentEnabled()` 的运行时判定
|
||||
- `getIsNonInteractiveSession()` 的结果
|
||||
|
||||
这些运行时 bit 如果放在静态区,会产生 2^N 种 Blake2b 哈希变体(N = 运行时条件数),完全破坏缓存命中率。源码注释明确警告:
|
||||
|
||||
> Each conditional here is a runtime bit that would otherwise multiply the Blake2b prefix hash variants (2^N). See PR #24490, #24171 for the same bug class.
|
||||
|
||||
### `CLAUDE_CODE_SIMPLE` 模式
|
||||
|
||||
当设置了 `CLAUDE_CODE_SIMPLE` 环境变量时,整个系统提示词会大幅缩减:
|
||||
|
||||
```typescript
|
||||
return [`You are Claude Code, Anthropic's official CLI for Claude.\n\nCWD: ${getCwd()}\nDate: ${getSessionStartDate()}`]
|
||||
```
|
||||
|
||||
## 上下文注入:System Context 与 User Context
|
||||
|
||||
System Prompt 数组本身不包含运行时上下文(git 状态、CLAUDE.md 内容)。上下文通过两个独立的管道注入:
|
||||
|
||||
### System Context(`src/context.ts:116`)
|
||||
|
||||
```typescript
|
||||
export const getSystemContext = memoize(async () => {
|
||||
return {
|
||||
gitStatus, // git 分支、状态、最近提交(截断至 MAX_STATUS_CHARS=2000)
|
||||
cacheBreaker, // 仅 ant 用户的缓存破坏器
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- 使用 `lodash.memoize` 缓存——**整个会话期间只计算一次**
|
||||
- Git 状态快照包含 5 个并行 `git` 命令(branch、defaultBranch、status、log、userName)
|
||||
- `status` 超过 2000 字符时截断并附加提示使用 BashTool 获取更多信息
|
||||
- `systemPromptInjection` 变更时,通过 `getUserContext.cache.clear?.()` 清除所有上下文缓存
|
||||
|
||||
### User Context(`src/context.ts:155`)
|
||||
|
||||
```typescript
|
||||
export const getUserContext = memoize(async () => {
|
||||
return {
|
||||
claudeMd, // 合并后的 CLAUDE.md 内容
|
||||
currentDate, // "Today's date is YYYY-MM-DD."
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- **CLAUDE.md 禁用条件**:`CLAUDE_CODE_DISABLE_CLAUDE_MDS` 环境变量,或 `--bare` 模式(除非通过 `--add-dir` 显式指定目录)
|
||||
- `--bare` 模式的语义是"跳过我没要求的东西"而非"忽略所有"
|
||||
|
||||
### 注入位置
|
||||
|
||||
在 `src/query.ts:449`:
|
||||
|
||||
```typescript
|
||||
// System Context 追加到 System Prompt 尾部
|
||||
const fullSystemPrompt = asSystemPrompt(
|
||||
appendSystemContext(systemPrompt, systemContext) // 简单拼接
|
||||
)
|
||||
```
|
||||
|
||||
User Context 通过 `prependUserContext()`(`src/utils/api.ts:449`)注入为 `<system-reminder>` 标签包裹的首条用户消息,放在所有对话消息之前。
|
||||
|
||||
## Attribution Header:计费与安全
|
||||
|
||||
每个 API 请求的 System Prompt 首块是 Attribution Header(`src/constants/system.ts:30`),包含:
|
||||
- **`cc_version`**:Claude Code 版本 + 指纹
|
||||
- **`cc_entrypoint`**:入口点标识(REPL / SDK / pipe 等)
|
||||
- **`cch=00000`**(NATIVE_CLIENT_ATTESTATION 启用时):Bun 原生 HTTP 层在发送前将零替换为计算出的哈希值,服务器验证此 token 确认请求来自真实 Claude Code 客户端
|
||||
|
||||
Header 始终 `cacheScope: null`——它因版本和指纹不同而变化,不适合缓存。
|
||||
|
||||
## CLAUDE.md:项目级知识注入
|
||||
|
||||
这是 Claude Code 最巧妙的设计之一。在项目目录中放一个 `CLAUDE.md` 文件,就能让 AI "理解"项目。
|
||||
这是 Claude Code 最巧妙的设计之一。在项目根目录放一个 `CLAUDE.md` 文件,就能让 AI "理解" 你的项目:
|
||||
|
||||
### 多级合并
|
||||
- **项目概述**:这个项目做什么、用了什么技术栈
|
||||
- **开发约定**:代码风格、命名规范、分支策略
|
||||
- **常用命令**:怎么构建、怎么测试、怎么部署
|
||||
- **注意事项**:已知的坑、特殊的配置
|
||||
|
||||
系统会自动发现并合并多级 CLAUDE.md:
|
||||
|
||||
```
|
||||
~/.claude/CLAUDE.md ← 用户全局(个人偏好)
|
||||
@@ -118,34 +292,77 @@ Anthropic 的 Prompt Cache 以**内容块**为单位缓存。将提示词拆为
|
||||
└── /project/src/CLAUDE.md ← 子目录(模块特定)
|
||||
```
|
||||
|
||||
系统从当前工作目录向上遍历,合并所有匹配的 CLAUDE.md 文件。子目录的 CLAUDE.md 可以覆盖或补充父目录的规则。
|
||||
加载逻辑在 `src/utils/claudemd.ts` 中的 `getClaudeMds()` 和 `getMemoryFiles()` 实现——从 CWD 向上遍历目录树,合并所有匹配的 CLAUDE.md 文件内容。
|
||||
|
||||
**设计哲学**:项目知识应该由团队成员维护、随代码演进、可通过 git 追踪。CLAUDE.md 本质上是"给人读的项目文档,恰好 AI 也能读"。
|
||||
## 设计洞察:为什么是 `string[]` 而非单个 `string`
|
||||
|
||||
### 安全考量
|
||||
将 System Prompt 设计为数组而非单段文本,是为了 **缓存分块**:
|
||||
|
||||
项目级 CLAUDE.md 可以被仓库中的任何人修改(包括恶意贡献者)。系统因此限制了项目级 CLAUDE.md 的影响范围——它们不能覆盖安全关键设置,也不能修改权限模型。
|
||||
1. Anthropic Prompt Cache 以 **内容块**(TextBlock)为缓存单位
|
||||
2. 将 System Prompt 拆为多个块,可以让不变的部分(Intro、Rules)获得独立的缓存命中
|
||||
3. 如果是单个 `string`,任何一个字符变化(如日期更新)都会导致整个 System Prompt 的缓存失效
|
||||
4. `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 标记允许 `splitSysPromptPrefix()` 精确地将静态区标记为 `scope: 'global'`,动态区不标记或标记为 `scope: 'org'`
|
||||
|
||||
## Provider 差异与缓存
|
||||
这是 Claude Code 在 token 成本优化上的核心设计——一次典型的 System Prompt 约 20K+ tokens,通过缓存分块可以节省 30-50% 的输入 token 费用。
|
||||
|
||||
不同的 API Provider 有不同的缓存能力:
|
||||
## 兼容层:OpenAI 与 Gemini
|
||||
|
||||
| Provider | 全局缓存 | 精确 Token 计数 | 特殊 Beta 功能 |
|
||||
|----------|:--------:|:---------------:|:--------------:|
|
||||
| Anthropic 直连 | ✓ | ✓ | ✓ |
|
||||
| AWS Bedrock | ✗ | ✓(独立端点) | 部分 |
|
||||
| Google Vertex | ✗ | ✗ | 部分 |
|
||||
| OpenAI 兼容 | ✗ | ✗ | ✗ |
|
||||
| Gemini | ✗ | ✗ | ✗ |
|
||||
Claude Code 提供了 OpenAI 和 Gemini 协议的兼容层,允许使用非 Anthropic 端点。
|
||||
|
||||
3P 用户的系统提示词始终使用组织级缓存,因为没有全局缓存的 API 支持。这也意味着 token 计数依赖估算,影响自动压缩的触发时机。
|
||||
### OpenAI 兼容层
|
||||
|
||||
## 最小化模式
|
||||
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持任意 OpenAI Chat Completions 协议端点(Ollama、DeepSeek、vLLM 等)。
|
||||
|
||||
环境变量 `CLAUDE_CODE_SIMPLE` 可以将整个系统提示词缩减为一行——跳过所有 Section 注册、缓存分块和动态组装。用于最小化 token 消耗的测试场景。
|
||||
实现采用**流适配器模式**:
|
||||
1. 将 Anthropic 格式请求转换为 OpenAI 格式
|
||||
2. 调用 OpenAI 兼容端点
|
||||
3. 将 SSE 流转换回 `BetaRawMessageStreamEvent`
|
||||
4. 下游代码完全无感知
|
||||
|
||||
## 接下来
|
||||
```
|
||||
src/services/api/openai/
|
||||
├── client.ts # OpenAI 客户端配置
|
||||
├── convertMessages.ts # 消息格式转换(Anthropic → OpenAI)
|
||||
├── convertTools.ts # 工具定义转换
|
||||
├── streamAdapter.ts # SSE 流适配(OpenAI → Anthropic)
|
||||
├── modelMapping.ts # 模型名称映射
|
||||
└── index.ts # 入口函数 queryModelOpenAI()
|
||||
```
|
||||
|
||||
关键环境变量:
|
||||
- `CLAUDE_CODE_USE_OPENAI=1` — 启用 OpenAI provider
|
||||
- `OPENAI_API_KEY` — API 密钥
|
||||
- `OPENAI_BASE_URL` — API 端点(默认 `https://api.openai.com/v1`)
|
||||
- `OPENAI_MODEL` — 直接指定模型名
|
||||
|
||||
### Gemini 兼容层
|
||||
|
||||
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用,支持 Google Gemini API。
|
||||
|
||||
```
|
||||
src/services/api/gemini/
|
||||
├── client.ts # Gemini 客户端配置
|
||||
├── convertMessages.ts # 消息格式转换
|
||||
├── convertTools.ts # 工具定义转换
|
||||
├── streamAdapter.ts # 流适配
|
||||
├── modelMapping.ts # 模型名称映射
|
||||
├── types.ts # 类型定义
|
||||
└── index.ts # 入口函数
|
||||
```
|
||||
|
||||
关键环境变量:
|
||||
- `CLAUDE_CODE_USE_GEMINI=1` — 启用 Gemini provider
|
||||
- `GEMINI_API_KEY` — API 密钥
|
||||
- `GEMINI_BASE_URL` — API 端点(默认 `https://generativelanguage.googleapis.com/v1beta`)
|
||||
- `GEMINI_MODEL` — 直接指定模型名
|
||||
- `GEMINI_DEFAULT_SONNET_MODEL` / `GEMINI_DEFAULT_OPUS_MODEL` — 按能力级别映射
|
||||
|
||||
### 兼容层的限制
|
||||
|
||||
使用 3P 兼容层时,部分功能受限:
|
||||
- **无精确 token 计数**:系统退回到近似估算,影响自动压缩触发时机
|
||||
- **无全局缓存**:只能使用组织级缓存 `scope: 'org'`
|
||||
- **部分 beta 功能不可用**:依赖 Anthropic 特有 beta headers 的功能受限
|
||||
|
||||
详见 `docs/plans/openai-compatibility.md` 和 `CLAUDE.md` 中的相关章节。
|
||||
|
||||
- **上下文压缩** — 理解当上下文增长超出限制时的压缩策略
|
||||
- **令牌预算** — 了解 token 窗口的动态计算
|
||||
- **Provider 系统** — 了解多 Provider 支持的架构设计
|
||||
|
||||
@@ -1,128 +1,195 @@
|
||||
---
|
||||
title: "令牌预算"
|
||||
description: "200K 上下文窗口不是全部。理解 Claude Code 如何管理 token 预算:动态计算、近似 vs 精确计数、分层压缩策略和缓存优化。"
|
||||
keywords: ["Token 预算", "上下文窗口", "token 计算", "压缩策略"]
|
||||
title: "Token 预算管理 - 上下文窗口动态计算"
|
||||
description: "从源码角度揭示 Claude Code token 预算管理:200K 上下文窗口的动态计算、截断机制、缓存优化和自动压缩的完整链路。"
|
||||
keywords: ["Token 预算", "上下文窗口", "token 计算", "截断机制", "缓存优化"]
|
||||
---
|
||||
|
||||
## 核心约束:200K 不是全部
|
||||
{/* 本章目标:从源码角度揭示 token 预算的动态计算、截断机制、缓存优化和自动压缩的完整链路 */}
|
||||
|
||||
Claude 的上下文窗口为 200K tokens(部分模型支持 1M),但实际可用于对话的空间远小于此:
|
||||
## 上下文窗口:200K 不是全部
|
||||
|
||||
Claude Code 的默认上下文窗口为 200K tokens(`MODEL_CONTEXT_WINDOW_DEFAULT = 200_000`),但实际可用于对话的空间远小于此:
|
||||
|
||||
```
|
||||
上下文窗口(200K)
|
||||
├── 系统提示词(~15-25K)
|
||||
├── 系统提示词(~15-25K,缓存后成本低)
|
||||
├── 工具定义(~10-20K,含 MCP 工具)
|
||||
├── 用户上下文(CLAUDE.md、git status 等)
|
||||
├── 输出预留(AI 响应的空间)
|
||||
└── 剩余:对话历史空间(随对话增长而缩小)
|
||||
├── 输出预留(maxOutputTokens)
|
||||
│ ├── 默认上限:64K
|
||||
│ ├── 实际默认:8K(slot-reservation 优化)
|
||||
│ └── 触顶自动升级:一次 64K 重试
|
||||
└── 剩余:对话历史空间(随对话增长)
|
||||
```
|
||||
|
||||
**设计挑战**:对话历史不断增长,可用空间持续缩小。系统必须在"保留足够的上下文让 AI 理解对话"和"不超出 token 限制"之间持续平衡。
|
||||
`getContextWindowForModel()`(`src/utils/context.ts:51`)按 5 级优先级解析窗口大小:
|
||||
|
||||
### 上下文窗口的动态解析
|
||||
1. `CLAUDE_CODE_MAX_CONTEXT_TOKENS` 环境变量覆盖
|
||||
2. 模型名含 `[1m]` 后缀 → 1M tokens
|
||||
3. `getModelCapability(model).max_input_tokens`
|
||||
4. 1M beta header + 支持的模型(claude-sonnet-4, opus-4-6)
|
||||
5. 兜底:200K
|
||||
|
||||
上下文窗口大小不是硬编码的。系统按优先级从多个来源解析:
|
||||
|
||||
1. 用户环境变量覆盖(强制指定)
|
||||
2. 模型名后缀标记(如 `[1m]` 表示 1M 窗口)
|
||||
3. 模型自身的能力声明
|
||||
4. 特定 beta 功能的支持情况
|
||||
5. 兜底值:200K
|
||||
|
||||
这种分层解析意味着同一个系统可以适配不同模型和不同配置,而不需要为每种情况写特殊逻辑。
|
||||
**有效上下文** = 窗口大小 - min(maxOutputTokens, 20K),因为压缩摘要需要预留输出空间。
|
||||
|
||||
## Token 计数:近似 vs 精确
|
||||
|
||||
Token 计数是所有预算决策的基础。系统采用两级策略:
|
||||
系统使用两级 token 计数策略:
|
||||
|
||||
### 近似估算(毫秒级)
|
||||
|
||||
基于一个简单的经验公式:大约每 4 个字节 ≈ 1 个 token。对不同内容类型有调整:
|
||||
- **JSON/JSONL**:更密集,每 2 字节 ≈ 1 token
|
||||
- **图片/文档**:固定估算值(基于尺寸上限的保守估计)
|
||||
- **普通文本**:每 4 字节 ≈ 1 token
|
||||
```typescript
|
||||
// src/services/tokenEstimation.ts
|
||||
function roughTokenCountEstimation(content: string, bytesPerToken = 4): number {
|
||||
return Math.round(content.length / bytesPerToken)
|
||||
}
|
||||
```
|
||||
|
||||
### 精确计数(需要 API 调用)
|
||||
对不同内容类型有特殊处理:
|
||||
- **JSON/JSONL**:`bytesPerToken = 2`(密集的 `{`, `:`, `,` 符号,每个仅 1-2 token)
|
||||
- **图片/文档**:固定 2000 tokens(基于 2000×2000px 上限的保守估计)
|
||||
- **thinking block**:按实际文本长度 / 4
|
||||
- **tool_use**:序列化 `name + JSON.stringify(input)` 后 / 4
|
||||
|
||||
使用 Anthropic 的 token 计数端点。不同 Provider 的支持程度不同:
|
||||
### 精确计数(API 调用)
|
||||
|
||||
| Provider 类别 | 精确计数支持 | 注意事项 |
|
||||
|---------------|-------------|----------|
|
||||
| Anthropic 直连 | 原生支持 | 最准确 |
|
||||
| 云平台(Bedrock/Vertex) | 各自的 SDK 接口 | 需要额外依赖 |
|
||||
| 第三方兼容(OpenAI/Gemini/Grok) | 不支持 | 退回近似估算 |
|
||||
使用 Anthropic 的 `beta.messages.countTokens` 端点。在不同 provider 上有不同路径:
|
||||
|
||||
### 为什么需要两级策略
|
||||
| Provider | 方法 |
|
||||
|----------|------|
|
||||
| Anthropic 直连 | `anthropic.beta.messages.countTokens()` |
|
||||
| AWS Bedrock | `@aws-sdk/client-bedrock-runtime` 的 `CountTokensCommand` |
|
||||
| Google Vertex | Anthropic SDK + beta 过滤 |
|
||||
| 兜底(Bedrock 不支持) | 用 Haiku 发送 `max_tokens=1` 的请求,读取 `usage.input_tokens` |
|
||||
|
||||
近似估算用于**热路径**——每轮 agentic loop 都需要判断"是否需要压缩",这个检查必须足够快。精确计数用于**关键决策点**——压缩前后对比、费用计算等需要准确数字的场景。
|
||||
精确计数在关键决策点使用(压缩前后对比、warning 判断),近似估算在热路径使用(每轮循环的 shouldAutoCompact 检查)。
|
||||
|
||||
**设计权衡**:近似估算可能偏差 10-20%,这意味着自动压缩的触发时机可能略有提前或延后。但这个偏差是可以接受的——提前压缩只多花一点 token,延后压缩最多触发一次 API 错误然后紧急压缩。
|
||||
### 3P Provider 的 Token 计数差异
|
||||
|
||||
## 分层压缩策略
|
||||
不同 Provider 的精确 token 计数实现方式不同,部分 provider 甚至不支持精确计数:
|
||||
|
||||
系统不是等到"满了才压缩",而是采用了由轻到重的分层策略:
|
||||
| Provider | 计数方式 | 注意事项 |
|
||||
|----------|---------|---------|
|
||||
| **Anthropic 直连** | `anthropic.beta.messages.countTokens()` | 标准 API,最准确 |
|
||||
| **AWS Bedrock** | `CountTokensCommand` | 需要动态加载 279KB AWS SDK |
|
||||
| **Google Vertex** | Anthropic SDK + beta 过滤 | 需要特定 beta headers |
|
||||
| **OpenAI 兼容层** | 无精确计数 | **退回到近似估算** |
|
||||
| **Gemini 兼容层** | 无精确计数 | **退回到近似估算** |
|
||||
| **Bedrock 不支持时** | 用 Haiku 发送 `max_tokens=1` 请求 | 读取 `usage.input_tokens` |
|
||||
|
||||
### 第一层:工具结果截断
|
||||
OpenAI 和 Gemini 兼容层**不支持精确 token 计数**,系统会退回到近似估算。这会影响:
|
||||
- **自动压缩触发时机**:可能略有偏差
|
||||
- **压缩前后 token 对比**:仅为估算值,非精确
|
||||
- **Warning/Error 阈值判断**:基于估算而非精确计数
|
||||
|
||||
单个工具的输出有硬性上限(通常 100K 字符)。超长的命令输出、文件内容在写入消息前就被截断。
|
||||
```typescript
|
||||
// src/services/tokenEstimation.ts - 近似估算函数
|
||||
function roughTokenCountEstimation(content: string, bytesPerToken = 4): number {
|
||||
return Math.round(content.length / bytesPerToken)
|
||||
}
|
||||
```
|
||||
|
||||
这是最轻量的"压缩"——只是防止单个工具结果占用过多空间。
|
||||
源码路径:`src/services/tokenEstimation.ts`
|
||||
|
||||
### 第二层:微压缩(Micro-Compact)
|
||||
## 自动压缩的触发阈值
|
||||
|
||||
在触发全量压缩之前,系统先尝试只压缩旧的工具调用结果:
|
||||
```
|
||||
src/services/compact/autoCompact.ts — 核心阈值
|
||||
```
|
||||
|
||||
- 超过一定时间的工具结果被替换为简短占位符
|
||||
| 常量 | 值 | 含义 |
|
||||
|------|----|------|
|
||||
| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | 窗口减去此值 = 自动压缩触发点 |
|
||||
| `WARNING_THRESHOLD_BUFFER_TOKENS` | 20,000 | 在触发点 + 20K 处显示警告 |
|
||||
| `ERROR_THRESHOLD_BUFFER_TOKENS` | 20,000 | 在触发点 + 20K 处显示错误 |
|
||||
| `MANUAL_COMPACT_BUFFER_TOKENS` | 3,000 | 手动 /compact 的阻塞上限 |
|
||||
| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | 连续失败 3 次后停止尝试 |
|
||||
|
||||
以 200K 窗口为例:
|
||||
- **~167K**:warning 闪烁,用户看到建议压缩的提示
|
||||
- **~180K**:自动压缩触发(200K - 20K 输出预留 = 180K 有效,再 - 13K buffer)
|
||||
- **~197K**:达到 blocking limit,新消息被阻止
|
||||
|
||||
`shouldAutoCompact()` 有多个逃逸条件:
|
||||
- `compact` / `session_memory` 来源的查询永不触发(防递归死锁)
|
||||
- `DISABLE_COMPACT` / `DISABLE_AUTO_COMPACT` 环境变量
|
||||
- 用户配置 `autoCompactEnabled = false`
|
||||
- Context Collapse 模式激活时抑制(collapse 自己管理上下文)
|
||||
- Reactive Compact 实验模式下抑制主动压缩
|
||||
- 超过连续失败上限(circuit breaker)
|
||||
|
||||
## Micro-Compact:工具结果的渐进式压缩
|
||||
|
||||
在触发全量压缩之前,系统先尝试 **micro-compact**——只压缩旧的工具调用结果:
|
||||
|
||||
```
|
||||
可压缩工具列表(COMPACTABLE_TOOLS):
|
||||
FileRead, Bash, Grep, Glob, WebSearch, WebFetch, FileEdit, FileWrite
|
||||
```
|
||||
|
||||
策略基于时间:
|
||||
- 超过一定时间(由 `timeBasedMCConfig` 控制)的工具结果被替换为简短占位符
|
||||
- 图片/文档结果替换为 `[image]` / `[document]` 文本
|
||||
- 每次替换释放 token,可能推迟全量压缩的触发
|
||||
- 每次替换释放 tokens,可能推迟全量压缩
|
||||
|
||||
**设计考量**:为什么不直接全量压缩?因为全量压缩需要调用 AI 生成摘要,成本高且耗时。微压缩是确定性操作(简单替换),几乎零成本,可以频繁执行。
|
||||
工具本身也有 `maxResultSizeChars`(通常 100K)硬限制,超长结果在写入消息前就被截断。
|
||||
|
||||
### 第三层:自动压缩(Auto-Compact)
|
||||
## 全量压缩的完整流程
|
||||
|
||||
当对话接近 token 上限时,系统用 AI 自身来总结之前的对话:
|
||||
```
|
||||
autoCompactIfNeeded() / compactConversation()
|
||||
↓
|
||||
1. 执行 PreCompact hooks(外部可注入自定义指令)
|
||||
↓
|
||||
2. 尝试 Session Memory 压缩(更轻量,优先尝试)
|
||||
↓
|
||||
3. Session Memory 失败 → 全量压缩
|
||||
a. 图片/文档从消息中剥离(替换为 [image]/[document])
|
||||
b. skill_discovery/skill_listing 附件剥离(压缩后会重新注入)
|
||||
c. 通过 forked agent 发送摘要请求(复用主线程的 prompt cache)
|
||||
d. 如果摘要请求本身触发 prompt-too-long → truncateHeadForPTLRetry()
|
||||
从最老的 API 轮次开始删除,重试最多 3 次
|
||||
↓
|
||||
4. 压缩成功后重建上下文:
|
||||
- compactBoundaryMarker(记录压缩类型、前 token 数等)
|
||||
- 摘要消息(不可见的 user 消息)
|
||||
- 最近 5 个文件的重新读取(POST_COMPACT_TOKEN_BUDGET = 50K)
|
||||
- plan 文件附件(如果有)
|
||||
- plan mode 指令(如果在计划模式中)
|
||||
- 已调用的 skill 内容(每 skill ≤5K,总计 ≤25K)
|
||||
- deferred tools / agent listing / MCP 指令的增量重新注入
|
||||
- SessionStart hooks 重新执行
|
||||
- PostCompact hooks 执行
|
||||
↓
|
||||
5. 更新缓存基线,防止被误判为 cache break
|
||||
```
|
||||
|
||||
1. **剥离非必要内容**:图片、文档附件被替换为文本标记
|
||||
2. **生成摘要**:通过一个独立的 agent 调用生成对话摘要
|
||||
3. **重建上下文**:用摘要替代原始对话,同时重新注入关键信息(最近操作的文件、活跃的计划等)
|
||||
### Prompt Cache Sharing
|
||||
|
||||
**设计考量**:压缩后会重新读取最近操作的 5 个文件。这是因为在实际使用中,AI 最可能需要的就是刚刚操作过的文件——重新读取它们比让 AI 再次搜索更高效。
|
||||
|
||||
### 压缩的安全阀
|
||||
|
||||
- **连续失败上限**:连续 3 次压缩失败后停止尝试(断路器模式)
|
||||
- **压缩来源的查询不触发压缩**:防止压缩本身触发无限递归
|
||||
- **手动压缩**:用户可以随时通过 `/compact` 主动触发
|
||||
|
||||
## 缓存感知的压缩设计
|
||||
|
||||
压缩操作本身需要调用 API 生成摘要——这是整个会话中最昂贵的操作之一。系统通过**复用主线程的缓存前缀**来优化:
|
||||
|
||||
系统 prompt + 工具定义 + 上下文消息通常不变,这部分可以通过 API 的 prompt cache 机制缓存。压缩时的摘要请求复用了这些缓存,使得缓存命中率从接近 0% 提升到接近 100%。
|
||||
|
||||
**设计洞察**:这不是一个独立的优化——它是整个缓存策略的一部分。系统 prompt 的组装策略("不变内容在前")和压缩时的缓存复用,都是为了最大化 prompt cache 的命中率。
|
||||
压缩 API 调用是整个会话中最昂贵的操作之一。系统通过 `runForkedAgent` 复用主线程的缓存前缀(system prompt + tools + context messages),将缓存命中率从 2% 提升到接近 100%。这个优化单独节省了舰队级约 0.76% 的 `cache_creation` tokens。
|
||||
|
||||
## 输出 Token 的 Slot 优化
|
||||
|
||||
一个容易被忽视但影响深远的优化:AI 输出的 token 上限默认只设为 8K,而不是模型支持的最大值(32K 或 64K)。
|
||||
一个经常被忽视的优化:**maxOutputTokens 的动态调整**。
|
||||
|
||||
**为什么**?因为 API 服务端按 `max_tokens` 参数预留推理容量(slot)。99% 的请求实际输出不到 5K tokens,但如果所有请求都预留 32K 的 slot,会导致严重的容量浪费。
|
||||
```typescript
|
||||
// src/services/api/claude.ts — getMaxOutputTokensForModel()
|
||||
const defaultTokens = isMaxTokensCapEnabled()
|
||||
? Math.min(maxOutputTokens.default, 8_000) // 默认降到 8K
|
||||
: maxOutputTokens.default // 原始默认 32K/64K
|
||||
```
|
||||
|
||||
降到 8K 后,不到 1% 的请求会被截断——这些请求自动获得一次高上限的干净重试。
|
||||
为什么?因为 API 的 slot 机制按 `max_tokens` 预留推理容量。BQ p99 输出仅 4,911 tokens,32K 默认值浪费了 8-16 倍的 slot 容量。降到 8K 后,不到 1% 的请求被截断——这些请求会自动获得一次 64K 的 clean retry。
|
||||
|
||||
**设计哲学**:用 1% 的请求多一次重试的代价,换取 99% 请求的更快响应。这是一个典型的"优化常见路径,用重试处理边缘情况"的设计模式。
|
||||
这个优化对 token 预算的影响是间接的:更多的 slot 容量意味着更少的排队延迟,间接减少了超时和重试。
|
||||
|
||||
## 选择性压缩
|
||||
## Partial Compact:选择性地压缩
|
||||
|
||||
除了全量压缩,用户还可以选择只压缩对话的某一部分:
|
||||
除了全量压缩,用户还可以在消息历史中选择某个位置,只压缩该位置之前或之后的内容:
|
||||
|
||||
- **压缩早期内容**(保留最近对话):适用于"开头说了很多背景,但后面只需要关注最近操作"的场景
|
||||
- **压缩近期内容**(保留早期对话):适用于"早期的架构决策很重要,但最近的工具调用结果不需要"的场景
|
||||
- **`up_to` 方向**:压缩选中消息之前的内容,保留最近的对话
|
||||
- **`from` 方向**:压缩选中消息之后的内容,保留早期的对话
|
||||
|
||||
两种方向对缓存有不同的影响——保留前缀(早期内容)可以维持 prompt cache,修改前缀则破坏缓存。
|
||||
`from` 方向保留 prompt cache(前缀不变),`up_to` 方向则破坏 cache(摘要插在保留内容之前)。
|
||||
|
||||
## 接下来
|
||||
|
||||
- **上下文压缩** — 深入了解压缩的触发条件和摘要生成机制
|
||||
- **项目记忆** — 理解跨会话的记忆持久化设计
|
||||
- **穷鬼模式** — 了解如何减少 token 消耗
|
||||
两种方向的 PTL(prompt-too-long)重试策略相同:从最老的 API 轮次开始删除,确保至少保留一组消息供摘要。
|
||||
|
||||
@@ -1,130 +1,203 @@
|
||||
---
|
||||
title: "多轮对话"
|
||||
description: "理解 Claude Code 的会话编排设计:为什么需要 QueryEngine?会话如何持久化?成本如何追踪?模型如何热切换?"
|
||||
title: "多轮对话管理 - QueryEngine 会话编排与持久化"
|
||||
description: "从源码角度解析 Claude Code 多轮对话管理:QueryEngine 的会话状态机、JSONL transcript 持久化、成本追踪模型和模型热切换机制。"
|
||||
keywords: ["多轮对话", "会话管理", "QueryEngine", "transcript", "成本追踪"]
|
||||
sourceRef: "3ec5675 (2026-04-08)"
|
||||
---
|
||||
|
||||
## 单轮 vs 多轮
|
||||
{/* 本章目标:从源码角度揭示会话编排、持久化存储、成本追踪和模型切换的完整链路 */}
|
||||
|
||||
- **单轮**(一次 Agentic Loop):用户说一句话,AI 可能执行多步工具调用后返回结果
|
||||
- **多轮**(一个会话):用户和 AI 反复对话,持续数分钟到数小时
|
||||
## 单轮 vs 多轮:架构层面的差异
|
||||
|
||||
单轮关注"AI 如何自主完成任务",多轮关注"如何管理一个持续演进的会话"——这是完全不同的工程问题。
|
||||
- **单轮**(一次 Agentic Loop):`query()` 函数的一次完整执行——组装上下文 → 调 API → 处理工具调用 → 循环直到结束
|
||||
- **多轮**(一个 Session):`QueryEngine` 类管理的一次会话——跨越数十轮 `submitMessage()` 调用,持续数小时
|
||||
|
||||
## 为什么需要会话编排器
|
||||
|
||||
单轮的 Agentic Loop 已经很复杂了(错误恢复、上下文压缩、流式处理)。多轮在此基础上还需要管理:
|
||||
|
||||
- **对话历史的累积**:消息不断增长,需要压缩策略
|
||||
- **成本的持续追踪**:跨轮次的 token 用量累计
|
||||
- **模型的热切换**:用户可能中途换模型
|
||||
- **会话的持久化与恢复**:意外中断后能继续
|
||||
- **文件的快照与回滚**:AI 改了文件,用户想撤回
|
||||
|
||||
这些职责与"执行一次 agentic loop"无关,所以系统引入了 **QueryEngine** 作为会话编排器——它在 Agentic Loop 之上管理会话级别的状态。
|
||||
|
||||
### 设计原则:编排器不参与循环
|
||||
|
||||
QueryEngine 只负责"准备好上下文,然后调用 agentic loop"。它不干预单轮循环的内部逻辑——循环中的错误恢复、工具执行、上下文压缩都是自治的。
|
||||
|
||||
这种分层使得 agentic loop 可以独立测试和优化,而不受会话状态管理的影响。
|
||||
|
||||
## 会话持久化
|
||||
|
||||
### 为什么持久化很重要
|
||||
|
||||
终端会话是脆弱的——网络断开、进程崩溃、意外关闭都可能丢失对话。持久化确保用户的工作不丢失。
|
||||
|
||||
### 设计选择:JSONL 追加写入
|
||||
|
||||
对话事件以 JSONL(每行一条 JSON)格式追加写入磁盘。
|
||||
|
||||
为什么选择 JSONL 而不是数据库或 JSON 文件?
|
||||
|
||||
| 方案 | 优势 | 劣势 |
|
||||
|------|------|------|
|
||||
| **JSONL 追加写入** | 写入快速、不需要读-改-写、崩溃安全 | 查询需要扫描整个文件 |
|
||||
| JSON 文件 | 结构清晰 | 每次写入需要读取整个文件、修改、写回——大文件很慢 |
|
||||
| SQLite 数据库 | 查询高效 | 引入额外依赖、事务管理复杂 |
|
||||
|
||||
追加写入是关键设计:每次新消息只需要在文件末尾追加一行,不需要读取和修改已有内容。即使写入过程中崩溃,也只会丢失最后一条记录,不会损坏整个文件。
|
||||
|
||||
### 存储结构
|
||||
`QueryEngine`(`src/QueryEngine.ts`,类定义)是单轮 Agentic Loop 之上的**会话编排器**,它管理的状态远不止消息列表:
|
||||
|
||||
```
|
||||
~/.claude/projects/<项目目录>/
|
||||
├── <session-1>.jsonl
|
||||
├── <session-2>.jsonl
|
||||
└── ...
|
||||
QueryEngine 内部状态(src/QueryEngine.ts 构造函数)
|
||||
├── mutableMessages: Message[] ← 完整对话历史,跨 turn 累积
|
||||
├── readFileState: FileStateCache ← 已读文件内容缓存,避免重复读取
|
||||
├── totalUsage: NonNullableUsage ← 累计 token 消耗(input/output/cache)
|
||||
├── permissionDenials: SDKPermissionDenial[] ← 权限拒绝记录
|
||||
├── discoveredSkillNames: Set<string> ← 当前 turn 已发现的 skill
|
||||
├── loadedNestedMemoryPaths: Set<string> ← 已加载的嵌套 memory 路径(防重复)
|
||||
├── hasHandledOrphanedPermission: boolean ← 是否已处理孤立权限请求
|
||||
└── abortController: AbortController ← 会话级中断控制
|
||||
```
|
||||
|
||||
每个项目的会话归入同一目录。同一项目的对话可以跨会话积累上下文。
|
||||
## QueryEngine 的核心方法:submitMessage()
|
||||
|
||||
### 大小防护
|
||||
每次用户输入一条消息,REPL 或 SDK 调用 `submitMessage()`,它会执行完整的 turn 初始化链路:
|
||||
|
||||
读取上限为 50MB。超过这个大小的会话文件不会被完整加载——这是防止超大会话导致内存溢出的安全措施。
|
||||
```typescript
|
||||
// src/QueryEngine.ts — QueryEngine.submitMessage() 简化流程
|
||||
async *submitMessage(
|
||||
prompt: string | ContentBlockParam[],
|
||||
options?: { uuid?: string; isMeta?: boolean },
|
||||
): AsyncGenerator<SDKMessage> {
|
||||
// 1. 清除 turn 级追踪状态
|
||||
this.discoveredSkillNames.clear()
|
||||
|
||||
### 会话恢复
|
||||
// 2. 解析模型(用户可能中途通过 setModel() 切换了模型)
|
||||
const mainLoopModel = this.config.userSpecifiedModel
|
||||
? parseUserSpecifiedModel(this.config.userSpecifiedModel)
|
||||
: getMainLoopModel()
|
||||
|
||||
当用户使用 `--resume` 恢复会话时,系统:
|
||||
// 3. 动态组装 System Prompt(每次 turn 都重新构建)
|
||||
const { defaultSystemPrompt, userContext, systemContext } =
|
||||
await fetchSystemPromptParts({ tools, mainLoopModel, mcpClients })
|
||||
|
||||
1. 从 JSONL 文件重建消息数组
|
||||
2. 恢复累计费用状态
|
||||
3. 恢复用户选择的模型和配置
|
||||
4. 从中断点继续对话
|
||||
// 4. 包装权限检查(追踪每次拒绝)
|
||||
const wrappedCanUseTool = async (tool, input, ...) => {
|
||||
const result = await canUseTool(tool, input, ...)
|
||||
if (result.behavior !== 'allow') {
|
||||
this.permissionDenials.push({
|
||||
type: 'permission_denial',
|
||||
tool_name: sdkCompatToolName(tool.name),
|
||||
tool_use_id: toolUseID,
|
||||
tool_input: input,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
整个过程对用户是透明的——恢复后的对话就像从来没有中断过。
|
||||
// 5. 调用核心 query() 函数执行 agentic loop
|
||||
yield* query({
|
||||
systemPrompt, messages: this.mutableMessages,
|
||||
tools, model: mainLoopModel, ...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 成本追踪
|
||||
关键设计:`submitMessage()` 是 `async *Generator`——它逐步 yield `SDKMessage`,让调用方(REPL/SDK)能实时展示进度,而不是等整个 turn 结束。
|
||||
|
||||
### 为什么需要成本追踪
|
||||
## 会话持久化:JSONL Transcript
|
||||
|
||||
AI API 按 token 计费,一次复杂任务可能消耗大量 token。没有成本追踪,用户无法判断"这次对话花了多少钱",也无法在费用过高时及时终止。
|
||||
每次对话事件都被追加写入 transcript 文件(`src/utils/sessionStorage.ts`):
|
||||
|
||||
### 三层追踪架构
|
||||
### 存储路径
|
||||
|
||||
| 层 | 职责 | 持久性 |
|
||||
|----|------|--------|
|
||||
| **记录层** | 从每个 API 响应中提取 token 用量 | 实时 |
|
||||
| **累计层** | 按模型汇总累计费用(切换模型时分别统计) | 会话内 |
|
||||
| **持久化层** | 会话结束时保存到项目配置 | 跨重启 |
|
||||
```
|
||||
~/.claude/projects/<sanitized-cwd>/<session-uuid>.jsonl
|
||||
```
|
||||
|
||||
### 预算提醒
|
||||
- 路径由 `getProjectDir(originalCwd)` 生成,使用 `sanitizePath()` 将项目目录路径转换为安全的目录名(非 hash),同一项目目录的会话归入同一子目录
|
||||
- 每条记录是一行 JSON(JSONL 格式),支持追加写入而不需要读取-修改-写入整个文件
|
||||
- 读取上限为 50MB(`MAX_TRANSCRIPT_READ_BYTES` 常量,`src/utils/sessionStorage.ts`),防止超大会话导致 OOM
|
||||
|
||||
系统提供会话级的预算上限。当累计费用超过阈值时弹出提醒——这不是硬性阻断,而是"软提醒"。设计上选择了提醒而非强制终止,因为用户可能正在进行关键操作(如修复生产 bug),强制终止可能造成更大损失。
|
||||
### Transcript 写入器
|
||||
|
||||
`Project` 类(`src/utils/sessionStorage.ts`,私有类)管理 transcript 的写入。它通过 `writeQueues`(按文件分组的写队列)和 `drainWriteQueue()`(定时批量刷写)确保并发消息追加不会互相覆盖:
|
||||
|
||||
```
|
||||
写入流程(异步排队路径):
|
||||
recordTranscript(sessionId, entry)
|
||||
↓
|
||||
project.enqueueWrite(filePath, entry) ← 入列到 writeQueues
|
||||
↓
|
||||
scheduleDrain() ← 设置定时器(FLUSH_INTERVAL_MS)
|
||||
↓
|
||||
drainWriteQueue() ← 按 MAX_CHUNK_BYTES 分批
|
||||
↓ 写入每批
|
||||
appendToFile(path, batchContent) ← 批量追加
|
||||
↓
|
||||
如果配置了远程持久化:
|
||||
persistToRemote(sessionId, entry)
|
||||
├── CCR v2: internalEventWriter('transcript', entry)
|
||||
└── v1 Ingress: sessionIngress.appendSessionLog(...)
|
||||
|
||||
同步直写路径(用于元数据重写等场景):
|
||||
appendEntryToFile(fullPath, entry) ← 同步 appendFileSync
|
||||
↓
|
||||
失败时 mkdir + 重试
|
||||
```
|
||||
|
||||
### 会话恢复链路
|
||||
|
||||
`--resume` 参数触发的恢复流程(`src/main.tsx` 中 `--resume` 分支):
|
||||
|
||||
```
|
||||
1. 解析 resume 参数:
|
||||
├── UUID 格式 → getTranscriptPathForSession(uuid)
|
||||
├── .jsonl 文件路径 → 直接使用
|
||||
└── boolean → 最近一次会话的 picker
|
||||
|
||||
2. loadTranscriptFromFile(path)
|
||||
├── 按 JSONL 行解析
|
||||
├── 过滤出消息类型记录
|
||||
└── 重建 Message[] 数组
|
||||
|
||||
3. 恢复上下文状态:
|
||||
├── restoreCostStateForSession(sessionId) ← 恢复累计费用
|
||||
├── 恢复 agentSetting(用户选择的 Agent 类型)
|
||||
└── 如果有 --rewind-files,恢复文件到指定消息时的快照
|
||||
|
||||
4. 创建 QueryEngine({ initialMessages: restoredMessages })
|
||||
└── 从恢复的消息继续对话
|
||||
```
|
||||
|
||||
## 成本追踪:从 API Usage 到美元
|
||||
|
||||
成本追踪贯穿三个模块,形成完整的记录→累计→展示链路:
|
||||
|
||||
### 记录层:API 响应中的 Usage
|
||||
|
||||
每个 `message_delta` 事件携带 `usage` 字段(`input_tokens`、`output_tokens`、`cache_creation_input_tokens`、`cache_read_input_tokens`)。`accumulateUsage()` 将增量 usage 累加到会话总量。
|
||||
|
||||
### 累计层:cost-tracker.ts
|
||||
|
||||
```typescript
|
||||
// src/cost-tracker.ts — StoredCostState 类型定义
|
||||
type StoredCostState = {
|
||||
totalCostUSD: number // 累计美元花费
|
||||
totalAPIDuration: number // API 调用总时长(含重试)
|
||||
totalAPIDurationWithoutRetries: number // 不含重试的纯推理时间
|
||||
totalToolDuration: number // 工具执行总时长
|
||||
totalLinesAdded: number // 代码增加行数
|
||||
totalLinesRemoved: number // 代码删除行数
|
||||
lastDuration: number | undefined // 最近一次会话时长
|
||||
modelUsage: { [modelName: string]: ModelUsage } | undefined // 按模型分拆的用量
|
||||
}
|
||||
```
|
||||
|
||||
`addToTotalSessionCost()` 根据模型定价计算每次 API 调用的费用,累计到 `totalCostUSD`。按模型的 `ModelUsage` 支持在同一会话中切换模型后分别统计。
|
||||
|
||||
### 持久化:跨重启保留
|
||||
|
||||
```typescript
|
||||
// 每次会话结束时保存到项目配置
|
||||
saveCurrentSessionCosts(sessionId)
|
||||
→ projectConfig.lastCost = totalCostUSD
|
||||
→ projectConfig.lastSessionId = sessionId
|
||||
→ projectConfig.lastModelUsage = modelUsage
|
||||
```
|
||||
|
||||
### 预算熔断
|
||||
|
||||
`QueryEngineConfig.maxBudgetUsd` 提供了会话级的硬性预算上限。在 REPL 中,当累计费用超过 $5 时(`src/screens/REPL.tsx` 中费用阈值 `useEffect`),弹出费用提醒对话框——这不是硬性阻断,而是"软提醒",且仅在 `hasConsoleBillingAccess()` 为 true 时显示。
|
||||
|
||||
## 模型热切换
|
||||
|
||||
### 设计挑战
|
||||
在一个会话中切换模型不会丢失对话历史——因为 `mutableMessages` 与模型选择是解耦的:
|
||||
|
||||
在一个持续对话中切换模型看似简单,实际上需要解决几个问题:
|
||||
```
|
||||
/model sonnet → QueryEngine.setModel('claude-sonnet-4-20250514')
|
||||
↓ 实际操作:this.config.userSpecifiedModel = model(QueryEngine.setModel() 方法)
|
||||
下一次 submitMessage() 开始时:
|
||||
↓
|
||||
parseUserSpecifiedModel(this.config.userSpecifiedModel)
|
||||
→ 返回新的模型配置
|
||||
↓
|
||||
fetchSystemPromptParts({ mainLoopModel: newModel })
|
||||
→ System Prompt 根据新模型能力重新组装
|
||||
↓
|
||||
query({ model: newModel, messages: this.mutableMessages })
|
||||
→ 使用完整历史 + 新模型继续对话
|
||||
```
|
||||
|
||||
1. **上下文窗口不同**:Sonnet 的 200K 和 Opus 的 1M 需要不同的压缩策略
|
||||
2. **对话历史兼容**:旧模型生成的内容新模型需要能理解
|
||||
3. **费用计算**:不同模型定价不同,需要分别统计
|
||||
|
||||
### 设计决策:消息与模型解耦
|
||||
|
||||
对话历史(消息数组)和模型选择是独立的。切换模型只改变"下一次 API 调用用什么模型",不修改已有消息。系统会在下次调用前根据新模型的规格重新计算上下文窗口和输出限制。
|
||||
|
||||
这意味着用户可以在对话中随时切换模型——例如用便宜的 Sonnet 做简单操作,遇到复杂问题时切换到 Opus——而不需要开始新的会话。
|
||||
切换模型时,`contextWindowTokens` 和 `maxOutputTokens` 也会根据新模型的规格重新计算——例如从 Sonnet 切换到 Opus 时,上下文窗口可能从 200K 变为 1M。
|
||||
|
||||
## 文件快照与回滚
|
||||
|
||||
### 比 git 更细粒度的追踪
|
||||
|
||||
AI 每次修改文件前,系统自动保存当前文件内容的快照。快照绑定到具体的消息 ID,使得用户可以精确恢复到对话中任意时间点的文件状态。
|
||||
|
||||
这比 `git checkout` 更细粒度——git 只追踪已提交的内容,而文件快照追踪的是 AI 每一次修改前的状态。
|
||||
|
||||
### 使用场景
|
||||
|
||||
- AI 改了代码但用户不满意 → 回滚到修改前的状态
|
||||
- AI 进行了多轮修改 → 选择性回滚到某个中间状态
|
||||
- 用户关闭终端后想撤销 → 通过会话恢复 + 文件回滚实现
|
||||
|
||||
## 接下来
|
||||
|
||||
- **系统提示词** — 理解每轮对话前的上下文组装策略
|
||||
- **上下文压缩** — 深入了解对话过长时的自动压缩机制
|
||||
- **令牌预算** — 理解 token 预算管理和成本控制
|
||||
`fileHistoryMakeSnapshot()`(`src/utils/fileHistory.ts`)在 AI 每次修改文件前自动保存当前内容。快照绑定到具体的 `message.id`,使得 `--rewind-files <user-message-id>` 可以精确恢复到对话中任意时间点的文件状态——这比 git 更细粒度(git 只追踪已提交的内容)。
|
||||
|
||||
@@ -1,111 +1,192 @@
|
||||
---
|
||||
title: "流式响应"
|
||||
description: "为什么流式是 Claude Code 的核心设计选择?理解流式传输的设计考量、错误处理策略和多 Provider 适配。"
|
||||
keywords: ["流式响应", "SSE", "streaming", "API streaming"]
|
||||
title: "流式响应机制 - Claude Code 打字机效果原理"
|
||||
description: "解析 Claude Code 流式响应实现:如何通过 SSE 逐 token 接收 AI 输出,实现实时打字机效果,提升用户等待体验。"
|
||||
keywords: ["流式响应", "SSE", "streaming", "实时输出", "API streaming"]
|
||||
sourceRef: "3ec5675 (2026-04-08)"
|
||||
---
|
||||
|
||||
## 为什么流式是核心设计
|
||||
## 为什么需要流式
|
||||
|
||||
想象 AI 需要 30 秒才能生成完整回答——如果等 30 秒后才一次性显示,用户体验是灾难性的。
|
||||
|
||||
但流式不仅仅是为了"打字机效果"。在 Claude Code 中,流式是整个系统架构的基础假设:
|
||||
流式响应让用户**实时看到 AI 的思考过程**:
|
||||
- 文字逐字出现,用户能提前判断方向是否正确
|
||||
- 工具调用的参数在生成过程中就能预览
|
||||
- 长时间任务不会让用户觉得"卡死了"
|
||||
|
||||
- **工具并行执行**:AI 在流式输出过程中就可能发出工具调用,系统可以立即开始执行,不必等整个响应结束。这使得"AI 边想边做"成为可能
|
||||
- **实时反馈**:用户看到 AI 的思考方向后可以提前判断是否正确,必要时提前中断
|
||||
- **可取消性**:流式架构天然支持用户中断——随时可以终止正在进行的流
|
||||
- **工具执行反馈**:不仅是 AI 输出是流式的,工具执行(如 shell 命令)的输出也是流式的——用户实时看到命令输出
|
||||
## `BetaRawMessageStreamEvent` 核心事件类型
|
||||
|
||||
**代价**:流式架构比一次性响应复杂得多——需要处理连接中断、部分数据、乱序事件等边界情况。
|
||||
|
||||
## 流式响应的概念模型
|
||||
|
||||
一次流式响应不是"一个完整的回答",而是一系列按顺序到达的事件流:
|
||||
流式 API 返回的是一系列 `BetaRawMessageStreamEvent`,每种事件类型对应流式响应的不同阶段(`src/services/api/claude.ts`):
|
||||
|
||||
```
|
||||
消息开始
|
||||
├── 内容块 1:文本 "我来帮你修复这个 bug。"
|
||||
├── 内容块 2:工具调用 { name: "Read", input: "..." }
|
||||
├── 内容块 3:文本 "我看到了问题..."
|
||||
└── ...
|
||||
消息结束(包含停止原因和 token 用量)
|
||||
message_start ← 消息开始,包含 model、usage 初始值
|
||||
├── content_block_start ← 内容块开始(text / tool_use / thinking)
|
||||
│ ├── content_block_delta ← 增量数据(text_delta / input_json_delta / thinking_delta)
|
||||
│ ├── content_block_delta ← ... 持续到达
|
||||
│ └── content_block_stop ← 内容块结束,yield AssistantMessage
|
||||
├── content_block_start ← 下一个内容块...
|
||||
│ └── ...
|
||||
└── message_delta ← stop_reason + 最终 usage
|
||||
message_stop ← 消息结束
|
||||
```
|
||||
|
||||
关键设计点:
|
||||
### 事件处理状态机
|
||||
|
||||
### 内容块的增量累加
|
||||
`src/services/api/claude.ts` 中 `queryModelWithStreaming()` 函数的事件处理循环实现了一个基于 `switch(part.type)` 的状态机:
|
||||
|
||||
每个内容块(文本、思考、工具调用)都是通过增量数据逐步构建的。文本逐字到达,工具调用的 JSON 参数逐段到达。系统需要在内存中持续累加这些片段,直到一个内容块完整后才传递给消费者。
|
||||
| 事件类型 | 处理逻辑 | 状态变更 |
|
||||
|----------|----------|----------|
|
||||
| `message_start` | 初始化 `partialMessage`,记录 TTFT(首字节延迟) | `usage` 初始化 |
|
||||
| `content_block_start` | 按 `part.index` 创建对应类型的内容块 | `contentBlocks[index]` 初始化 |
|
||||
| `content_block_delta` | 按子类型增量追加数据 | text / thinking / input 累加 |
|
||||
| `content_block_stop` | 构建完整 `AssistantMessage` 并 yield | 消息推入 `newMessages` |
|
||||
| `message_delta` | 更新 stop_reason 和最终 usage | 写回最后一条消息 |
|
||||
| `message_stop` | 无操作(流结束标记) | — |
|
||||
|
||||
### 多消息产出
|
||||
### 内容块类型及其增量数据
|
||||
|
||||
因为一次 AI 响应可能包含多个内容块(文本和工具调用交替出现),每个完整的内容块都会触发一次消息传递。这意味着一个 API 响应会产生**多条消息**——文本消息和工具调用消息交替产出。
|
||||
`content_block_start` 中的 `content_block.type` 决定了如何处理后续 delta:
|
||||
|
||||
### 停止原因的回写
|
||||
| 内容块类型 | Delta 类型 | 累加逻辑 |
|
||||
|-----------|-----------|----------|
|
||||
| `text` | `text_delta` | `text += delta.text` |
|
||||
| `thinking` | `thinking_delta` + `signature_delta` | `thinking += delta.thinking`,`signature = delta.signature` |
|
||||
| `tool_use` | `input_json_delta` | `input += delta.partial_json`(JSON 字符串增量拼接) |
|
||||
| `server_tool_use` | `input_json_delta` | 同 tool_use |
|
||||
| `connector_text` | `connector_text_delta` | 特殊连接器文本(feature flag 控制) |
|
||||
|
||||
AI 最终是"回答完毕"还是"需要调用工具",这个信息要到最后才知道。所以停止原因是在消息结束时**回写**到最后一条消息上的——消费者在收到中间消息时还不知道整轮对话是否结束。
|
||||
关键设计:`content_block_start` 时所有文本字段初始化为空字符串,只通过 `content_block_delta` 累加。这是因为 SDK 有时在 start 和 delta 中重复发送相同文本。
|
||||
|
||||
## 错误处理:三层防护
|
||||
## 文本 chunk 和 tool_use block 的交织
|
||||
|
||||
流式连接比一次性请求脆弱得多——网络波动、服务器过载、连接超时都可能导致中断。系统设计了三层防护:
|
||||
一次 AI 响应可能包含多个内容块,交替出现:
|
||||
|
||||
### 第一层:被动停滞检测
|
||||
```
|
||||
content_block_start (text, index=0) "我来帮你修复这个 bug。"
|
||||
content_block_delta (text_delta) "首先..."
|
||||
content_block_stop (index=0)
|
||||
content_block_start (tool_use, index=1) { name: "Read", input: "..." }
|
||||
content_block_delta (input_json_delta) '{"file_p' → 'ath":' → '"src/foo.ts"}'
|
||||
content_block_stop (index=1)
|
||||
content_block_start (text, index=2) "我已经看到了问题所在..."
|
||||
content_block_stop (index=2)
|
||||
```
|
||||
|
||||
系统记录每个事件到达的时间间隔。当间隔超过 30 秒时,记录为一次"停滞"并写入遥测。这是被动检测——只在下一个事件到达时才能发现之前的停滞,不会主动中断流。
|
||||
每个 `content_block_stop` 触发一次 `yield`,将完整的 AssistantMessage 推送给消费者。这意味着一个 AI 响应会产生**多条** `AssistantMessage`——文本消息和工具调用消息交替产出。
|
||||
|
||||
**设计考量**:为什么不立即中断?因为 API 可能在做长时间的计算(如复杂推理),短暂的无响应不一定意味着故障。被动检测提供了观测能力,而不影响正常流程。
|
||||
`stop_reason` 要等到 `message_delta` 才确定(可能是 `end_turn`、`tool_use`、`max_tokens` 等),所以最后一条消息的 `stop_reason` 是**回写**的:
|
||||
|
||||
### 第二层:主动空闲超时
|
||||
```typescript
|
||||
// claude.ts — stop_reason 回写逻辑(直接属性修改,不用对象替换)
|
||||
// 因为 transcript 写队列持有 message.message 的引用
|
||||
const lastMsg = newMessages.at(-1)
|
||||
if (lastMsg) {
|
||||
lastMsg.message.usage = usage
|
||||
lastMsg.message.stop_reason = stopReason
|
||||
}
|
||||
```
|
||||
|
||||
如果 90 秒内没有收到任何事件(可通过环境变量配置),系统主动终止流并进入重试流程。
|
||||
## 流式中的错误处理
|
||||
|
||||
**设计考量**:这是兜底机制。真正的故障不能靠被动检测发现——因为被动检测依赖于"下一个事件到达",而如果连接已经死了,下一个事件永远不会到达。
|
||||
### 网络断开
|
||||
|
||||
### 第三层:非流式降级
|
||||
流式连接依赖 SSE(Server-Sent Events)。当连接中断时,系统有两层检测机制:
|
||||
|
||||
作为最后手段,系统可以回退到非流式请求——一次性获取完整响应。失去了实时性,但保证了功能可用性。
|
||||
1. **被动停滞检测**(`src/services/api/claude.ts` 中 stall 检测逻辑):当下一个事件到达时,计算与上一个事件的时间间隔。超过阈值(30 秒,`STALL_THRESHOLD_MS = 30_000`)记录为一次 stall,累积计数并写入遥测日志。这是被动检测——仅在下一个 chunk 到达时才触发,不会主动中断流。
|
||||
2. **主动空闲超时看门狗**(`src/services/api/claude.ts` 中 `STREAM_IDLE_TIMEOUT_MS` 看门狗逻辑):使用 `setTimeout` 设置 90 秒(可通过 `CLAUDE_STREAM_IDLE_TIMEOUT_MS` 环境变量覆盖)的硬性超时。如果在此期间没有收到任何事件,主动终止流并抛出错误进入重试流程。
|
||||
3. **非流式降级**:作为最后手段,设置 `didFallBackToNonStreaming` 标志,通过 `executeNonStreamingRequest()` 回退到非流式请求(一次性获取完整响应)。
|
||||
|
||||
### 降级策略对比
|
||||
```typescript
|
||||
// claude.ts — 被动停滞检测
|
||||
const STALL_THRESHOLD_MS = 30_000 // 30 秒无事件视为停滞
|
||||
let totalStallTime = 0
|
||||
let stallCount = 0
|
||||
|
||||
| 策略 | 检测方式 | 响应时间 | 用户体验 |
|
||||
|------|----------|----------|----------|
|
||||
| 正常流式 | — | 最低延迟 | 逐字显示 |
|
||||
| 被动停滞检测 | 下一个事件到达时 | 不变 | 无感知 |
|
||||
| 主动超时中断 | 定时器触发 | 中断后重试延迟 | 短暂停顿后恢复 |
|
||||
| 非流式降级 | 重试失败后 | 等待完整响应 | 等待后一次性显示 |
|
||||
// claude.ts — 主动空闲超时
|
||||
const STREAM_IDLE_TIMEOUT_MS =
|
||||
parseInt(process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS || '', 10) || 90_000
|
||||
```
|
||||
|
||||
## Token 超限的两种场景
|
||||
### API 限流
|
||||
|
||||
两种不同的 token 超限需要不同的处理策略:
|
||||
当 API 返回限流错误时,系统使用 `withRetry` 包装器进行指数退避重试。重试逻辑考虑了:
|
||||
- 错误类型(429 限流 vs 500 服务器错误)
|
||||
- 重试次数上限
|
||||
- 退避间隔
|
||||
|
||||
| 场景 | 含义 | 处理方式 |
|
||||
|------|------|----------|
|
||||
| **输出超限** | AI 话说了一半被切断 | 提升输出上限重试,或提示 AI "接着说" |
|
||||
| **上下文窗口超限** | 整个对话历史太长,塞不进 API | 触发自动压缩,用 AI 摘要替代原始对话 |
|
||||
### Token 超限
|
||||
|
||||
关键区别:输出超限是"AI 话太多",可以通过调整上限解决;上下文超限是"给 AI 看的东西太多",必须通过压缩或删除来减少。
|
||||
两种 token 超限场景有不同的处理:
|
||||
|
||||
| 场景 | stop_reason | 处理方式 |
|
||||
|------|------------|----------|
|
||||
| **输出超限** | `max_tokens` | 生成错误消息,建议设置 `CLAUDE_CODE_MAX_OUTPUT_TOKENS` |
|
||||
| **上下文窗口超限** | `model_context_window_exceeded` | 触发 compaction 压缩对话历史后重试 |
|
||||
|
||||
```typescript
|
||||
// claude.ts — stop_reason 处理
|
||||
if (stopReason === 'max_tokens') {
|
||||
yield createAssistantAPIErrorMessage({ error: 'max_output_tokens', ... })
|
||||
}
|
||||
if (stopReason === 'model_context_window_exceeded') {
|
||||
// 复用 max_output_tokens 的恢复路径
|
||||
yield createAssistantAPIErrorMessage({ error: 'max_output_tokens', ... })
|
||||
}
|
||||
```
|
||||
|
||||
### 流式停滞检测
|
||||
|
||||
系统持续监控事件到达间隔,检测"停滞"(stall):
|
||||
|
||||
```typescript
|
||||
// claude.ts — stall 检测逻辑
|
||||
const STALL_THRESHOLD_MS = 30_000 // 30 秒无事件视为停滞
|
||||
if (timeSinceLastEvent > STALL_THRESHOLD_MS) {
|
||||
stallCount++
|
||||
totalStallTime += timeSinceLastEvent
|
||||
logEvent('tengu_streaming_stall', { stall_duration_ms, stall_count, ... })
|
||||
}
|
||||
```
|
||||
|
||||
这是**被动检测**——仅在下一个 chunk 到达时才触发比较。与之互补的是 90 秒主动空闲超时看门狗(`STREAM_IDLE_TIMEOUT_MS`),会直接中断长时间无响应的流。
|
||||
|
||||
## 工具执行的流式反馈
|
||||
|
||||
不仅是 API 响应是流式的,工具执行本身也是流式的。例如 BashTool 执行 shell 命令时,命令的标准输出会实时推送给 UI——用户不需要等命令完全结束才能看到结果。
|
||||
BashTool 的命令执行也是流式的——通过 `onProgress` 回调逐行推送输出:
|
||||
|
||||
长时间运行的命令还支持**自动后台化**:如果命令执行超过一定时间,系统自动将其移到后台,AI 可以继续处理其他任务,命令完成后再回调结果。
|
||||
```
|
||||
BashTool.call() → runShellCommand() → AsyncGenerator
|
||||
├── 每秒轮询输出文件 → onProgress(lastLines, allLines, ...)
|
||||
├── yield { type: 'progress', output, fullOutput, elapsedTimeSeconds }
|
||||
└── return { code, stdout, interrupted, ... }
|
||||
```
|
||||
|
||||
UI 层通过 `useToolCallProgress` hook 实时展示命令输出,而不是等命令完全结束。长时间运行的命令还支持自动后台化(`shouldAutoBackground`)。
|
||||
|
||||
## 多 Provider 适配
|
||||
|
||||
系统支持 7 种 API Provider,每种有不同的流式协议和认证方式:
|
||||
| Provider | 流式协议 | 特殊处理 |
|
||||
|----------|----------|----------|
|
||||
| **firstParty** (Anthropic Direct) | 原生 SSE | 延迟最低,TTFT 最快 |
|
||||
| **AWS Bedrock** | AWS SDK 流式接口 | 需要额外的 beta header 和认证 |
|
||||
| **Google Vertex** | gRPC → 事件流 | 通过 `getMergedBetas()` 适配 |
|
||||
| **foundry** | Anthropic 兼容 API | 内部部署 |
|
||||
| **openai** | OpenAI 流式适配器 | 转换为 Anthropic 内部格式 |
|
||||
| **gemini** | Gemini 流式适配器 | 转换为 Anthropic 内部格式 |
|
||||
| **grok** (xAI) | Grok 流式适配器 | 转换为 Anthropic 内部格式 |
|
||||
|
||||
| Provider 类别 | 流式方式 | 设计挑战 |
|
||||
|---------------|----------|----------|
|
||||
| Anthropic 直连 | 原生 SSE | 基准实现,其他 Provider 对齐它 |
|
||||
| 云平台(Bedrock/Vertex) | SDK 封装的流式接口 | 需要适配认证、beta header、参数格式 |
|
||||
| 第三方兼容(OpenAI/Gemini/Grok) | 各自的流式协议 | 需要转换为 Anthropic 内部格式 |
|
||||
所有 Provider 通过统一的 `Stream<BetaRawMessageStreamEvent>` 抽象层屏蔽差异。上层代码(QueryEngine、REPL)不需要关心底层用的是哪个 Provider。
|
||||
|
||||
**设计策略**:所有 Provider 通过统一的流式抽象层屏蔽差异。上层代码(编排层、交互层)不需要关心底层用的是哪个 Provider——它们只看到统一的事件流。
|
||||
### Provider 选择
|
||||
|
||||
这意味着切换 Provider 不需要修改任何业务逻辑,只需要在通信层适配新的协议。这也是为什么"通信层"是独立的一层。
|
||||
`src/utils/model/providers.ts` 中的 `getAPIProvider()` 根据配置决定使用哪个 Provider:
|
||||
|
||||
## 接下来
|
||||
```typescript
|
||||
// 根据 api_provider 配置选择:
|
||||
// "anthropic" → 直连
|
||||
// "bedrock" → AWS SDK
|
||||
// "vertex" → Google SDK
|
||||
// 第三方 base URL → 自动检测
|
||||
```
|
||||
|
||||
- **多轮对话** — 理解跨迭代的上下文管理
|
||||
- **上下文压缩** — 深入了解 token 超限时的自动压缩机制
|
||||
- **工具系统** — 了解工具执行的并行策略
|
||||
每个 Provider 需要适配的细节包括:认证方式、beta header、请求参数格式、错误码映射——但这些差异在 `claude.ts` 的 `queryStream()` 函数中被统一处理。
|
||||
|
||||
@@ -1,166 +1,197 @@
|
||||
---
|
||||
title: "Agent Loop"
|
||||
description: "理解 Claude Code 的核心循环机制——AI 如何自主决定工具调用、处理错误、管理上下文,直到任务完成。"
|
||||
keywords: ["Agentic Loop", "tool_use", "状态机", "auto-compact", "streaming"]
|
||||
title: "Agentic Loop:AI 自主循环的核心机制"
|
||||
description: "深入解析 Claude Code 的 query() 异步生成器循环——从流式 API 调用、工具并行执行、上下文压缩、错误恢复到终止条件的完整状态机,基于 src/query.ts 的源码级分析。"
|
||||
keywords: ["Agentic Loop", "query loop", "tool_use", "状态机", "auto-compact", "streaming", "recovery"]
|
||||
sourceRef: "3ec5675 (2026-04-08)"
|
||||
---
|
||||
|
||||
{/* 本章目标:基于 src/query.ts 揭示 Agentic Loop 的完整状态机 */}
|
||||
|
||||
## 什么是 Agentic Loop
|
||||
|
||||
传统聊天机器人:你问一句,它答一句。
|
||||
传统聊天机器人:你问一句,它答一句。
|
||||
Claude Code 不一样:你说一个需求,它可能连续执行十几步操作才给你最终结果。
|
||||
|
||||
这背后的机制叫做 **Agentic Loop**(智能体循环)。它是一个"思考→行动→观察"的不断循环,直到任务完成或遇到终止条件。
|
||||
这背后的机制叫做 **Agentic Loop**(智能体循环),核心实现在 `src/query.ts` 的 `queryLoop()` 异步生成器函数。它是一个 `while(true)` 无限循环,每次迭代代表一次"思考→行动→观察"周期。
|
||||
|
||||
<Frame caption="Agentic Loop 循环示意">
|
||||
<img src="/docs/images/agentic-loop.png" alt="Agentic Loop 循环图" />
|
||||
</Frame>
|
||||
|
||||
### 为什么需要循环而非一次回答
|
||||
## 循环的完整结构
|
||||
|
||||
因为软件工程任务本质上是**探索性**的。AI 不可能在第一步就知道所有信息:
|
||||
`queryLoop()` 的每次迭代(`src/query.ts` 中 `while(true)` 主循环)包含以下阶段:
|
||||
|
||||
- 它需要先读代码才能知道怎么改
|
||||
- 它需要先运行命令才能知道结果
|
||||
- 它需要先搜索才能找到相关文件
|
||||
- 它需要先修改才能验证是否正确
|
||||
### 阶段 1:上下文预处理(Pre-Processing Pipeline)
|
||||
|
||||
每一步工具执行都产生**真实信息**——命令输出、文件内容、错误信息——这些是 AI 在执行前不可能预知的。因此,AI 必须在每一步后根据新信息重新决策。
|
||||
|
||||
## 循环的四个阶段
|
||||
|
||||
每次循环迭代包含四个阶段,形成一个完整的"感知→决策→执行→反馈"周期。
|
||||
|
||||
### 阶段一:上下文预处理
|
||||
|
||||
在调用 API 之前,系统会依次检查和处理上下文。这是一个串行管道,每一步的输出是下一步的输入:
|
||||
在调用 API 之前,依次执行 5 个压缩/优化步骤:
|
||||
|
||||
```
|
||||
原始消息
|
||||
→ 工具结果截断(单条输出过长时截断)
|
||||
→ 历史压缩(Snip 压缩旧消息)
|
||||
→ 微压缩(工具结果摘要化)
|
||||
→ 自动压缩(对话接近 token 上限时触发 AI 摘要)
|
||||
处理后的消息 → 发往 API
|
||||
messagesForQuery(原始消息)
|
||||
↓ applyToolResultBudget() — 工具结果预算截断(按 maxResultSizeChars)
|
||||
↓ snipCompactIfNeeded() — 历史 Snip 压缩(HISTORY_SNIP feature)
|
||||
↓ microcompact() — 微压缩(工具结果摘要)
|
||||
↓ applyCollapsesIfNeeded() — 上下文折叠(CONTEXT_COLLAPSE feature)
|
||||
↓ autocompact() — 自动压缩(超出阈值时触发)
|
||||
messagesForQuery(处理后的消息)→ 发往 API
|
||||
```
|
||||
|
||||
**设计考量**:为什么是串行管道而非一次性处理?因为每个步骤释放的 token 数会影响下一步的决策。例如,如果 Snip 压缩已经释放了足够的 token,自动压缩就不需要触发了。
|
||||
每个步骤的输出是下一步的输入,形成串行管道。Snip 和 Microcompact 的释放 token 数会传递给 autocompact 的阈值计算(`snipTokensFreed`),避免重复压缩。
|
||||
|
||||
### 阶段二:流式 API 调用
|
||||
### 阶段 2:流式 API 调用(Streaming Loop)
|
||||
|
||||
系统以流式方式调用 Claude API。流式传输不是"锦上添花"——它是核心设计决策:
|
||||
`deps.callModel()` 发起流式请求(`src/query.ts` 中 `attemptWithFallback` 循环内),返回一个 AsyncGenerator。在流式过程中:
|
||||
|
||||
- **用户体验**:用户看到 AI 逐字输出,而非等待数秒后一次性显示
|
||||
- **工具并行执行**:AI 在流式输出过程中就可能发出工具调用,系统可以立即开始执行,不必等流结束
|
||||
- **可取消性**:用户随时可以中断正在进行的流式响应
|
||||
- **AssistantMessage** 被收集到 `assistantMessages[]` 数组
|
||||
- **tool_use 块** 被提取到 `toolUseBlocks[]`,设置 `needsFollowUp = true`
|
||||
- **StreamingToolExecutor** 在流式过程中就开始并行执行工具(不等流结束)
|
||||
- 可恢复的错误(prompt-too-long、max-output-tokens)被**暂扣**(withheld),先尝试恢复
|
||||
|
||||
### 阶段三:工具执行
|
||||
流式回调中的关键守卫:
|
||||
- `backfillObservableInput()` —— 为 tool_use 块回填可观察字段(如文件路径展开),但只在添加了新字段时才克隆消息,避免破坏 prompt cache 的字节一致性
|
||||
- 流式降级检测——如果 `streamingFallbackOccured`,已收集的消息被标记为 tombstone,清空后重试
|
||||
|
||||
如果 AI 请求了工具调用,系统执行工具并将结果回传。这里有两个关键设计:
|
||||
### 阶段 3:工具执行(Tool Execution)
|
||||
|
||||
**并行执行**:当 AI 在一次响应中请求多个独立工具调用时(如同时读两个文件),系统并行执行它们。这直接减少了用户等待时间。
|
||||
如果 `needsFollowUp` 为 true,循环不会终止,而是执行工具:
|
||||
|
||||
**权限检查**:每个工具执行前都经过权限验证。危险操作(如执行 shell 命令)需要用户确认,安全操作(如读文件)可以自动放行。
|
||||
```typescript
|
||||
// 两种工具执行器(互斥)
|
||||
const toolUpdates = streamingToolExecutor
|
||||
? streamingToolExecutor.getRemainingResults() // 流式:获取已完成的+等待中的
|
||||
: runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)
|
||||
```
|
||||
|
||||
### 阶段四:终止或继续
|
||||
工具结果通过 `normalizeMessagesForAPI()` 标准化后,与原始消息合并,进入**下一轮循环迭代**。
|
||||
|
||||
每次迭代结束时,系统判断是否需要继续:
|
||||
### 阶段 4:终止或继续
|
||||
|
||||
| 条件 | 结果 |
|
||||
|------|------|
|
||||
| AI 请求了工具调用 | 继续(下一轮迭代) |
|
||||
| AI 只返回文本,没有工具调用 | 终止(任务完成) |
|
||||
| 用户中断 | 终止(用户取消) |
|
||||
| 达到最大 turn 数 | 终止(安全限制) |
|
||||
| Token 预算耗尽 | 终止(成本控制) |
|
||||
每次迭代结束时,根据条件决定 `return`(终止)或 `continue`(继续):
|
||||
|
||||
## 错误恢复:自愈的状态机
|
||||
## 终止条件(源码级)
|
||||
|
||||
Agentic Loop 不是"正常路径走完就结束"的简单循环。它包含了多层错误恢复机制,使系统在各种异常情况下都能优雅处理。
|
||||
循环有多种终止路径,按触发时机排列:
|
||||
|
||||
### 输出截断恢复
|
||||
| 终止原因 | 触发位置 | 机制 |
|
||||
|----------|---------|------|
|
||||
| **blocking_limit** | 第 686 行 | Token 计数超过硬限制(非 autocompact 模式)→ 生成 PTL 错误消息 → 返回 |
|
||||
| **image_error** | 第 1021 行 | `ImageSizeError` / `ImageResizeError` 异常 → 直接返回 |
|
||||
| **model_error** | 第 1040 行 | `callModel()` 抛出不可恢复异常 → 生成错误消息 → 返回 |
|
||||
| **aborted_streaming** | 第 1095 行 | `abortController.signal.aborted`(流式阶段)→ 为未完成的 tool_use 生成合成 tool_result → 返回 |
|
||||
| **prompt_too_long** | 第 1219/1226 行 | 413 错误且 reactive compact 无法恢复 → 暂扣的错误消息被释放 → 返回 |
|
||||
| **completed** | 第 1308 行 | API 错误(限流、认证失败等)导致无法继续 → 返回 |
|
||||
| **stop_hook_prevented** | 第 1323 行 | Stop hook 返回 `preventContinuation: true` → 返回 |
|
||||
| **completed** | 第 1401 行 | 正常完成:AI 未发出 tool_use → `needsFollowUp = false` → 经过 stop hooks → 返回 |
|
||||
| **aborted_tools** | 第 1559 行 | `abortController.signal.aborted`(工具执行阶段)→ 返回 |
|
||||
| **hook_stopped** | 第 1564 行 | 工具执行期间 hook 返回 `shouldPreventContinuation` → 返回 |
|
||||
| **max_turns** | 第 1755 行 | 轮次计数超过 `maxTurns` 限制 → 返回 |
|
||||
|
||||
当 AI 的响应被 token 上限截断时(AI 话说了一半被切断):
|
||||
## 继续条件(恢复路径)
|
||||
|
||||
1. **首次截断**:静默提升输出 token 上限,重试
|
||||
2. **仍然截断**:注入提示消息让 AI "接着说",最多重试 3 次
|
||||
3. **恢复耗尽**:将截断的响应作为最终结果返回
|
||||
循环不仅是一个简单的"有 tool_use 就继续",它还包含多种恢复/重试路径:
|
||||
|
||||
### 上下文过长恢复
|
||||
### 1. 正常工具循环(`next_turn`)
|
||||
`needsFollowUp = true` → 执行工具 → 新消息追加到 `messagesForQuery` → state 重新赋值 → `continue`
|
||||
|
||||
当对话历史超过 API 的 token 限制时(413 错误):
|
||||
### 2. max_output_tokens 恢复(`max_output_tokens_escalate` / `max_output_tokens_recovery`)
|
||||
当 AI 输出被截断时(`apiError === 'max_output_tokens'`),分两阶段恢复:
|
||||
- **提升阶段**(`max_output_tokens_escalate`):首次截断时,将 `maxOutputTokens` 从默认值提升到 `ESCALATED_MAX_TOKENS`(64K)。静默重试,不注入 meta 消息。
|
||||
- **恢复阶段**(`max_output_tokens_recovery`):提升后仍然截断时,注入恢复消息"Output token limit hit. Resume directly...",最多重试 `MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3` 次。恢复耗尽后,暂扣的错误消息被释放。
|
||||
|
||||
1. **压缩重试**:即时压缩对话历史,生成摘要后重试
|
||||
2. **压缩后仍过长**:返回错误信息,让用户决定如何处理
|
||||
### 3. Prompt-Too-Long 恢复(`collapse_drain_retry` / `reactive_compact_retry`)
|
||||
当遇到 413 错误时,按优先级尝试两种压缩策略:
|
||||
- **Context Collapse Drain**(`collapse_drain_retry`):提交所有已暂存的折叠(collapse),释放空间后重试。如果上一轮已经是 `collapse_drain_retry` 则跳过,避免无限循环。
|
||||
- **Reactive Compact**(`reactive_compact_retry`):如果 collapse drain 无法恢复,触发即时压缩(reactive compact),生成摘要后重试。`hasAttemptedReactiveCompact` 标志防止无限循环。
|
||||
|
||||
关键设计:系统通过标志位防止无限循环——每种恢复路径只尝试一次,不会在"压缩→失败→压缩"之间死循环。
|
||||
### 4. Stop Hook 阻塞重试(`stop_hook_blocking`)
|
||||
Stop hook 可以注入阻塞错误消息,强制 AI 重新思考。新的消息(包含阻塞错误)被追加到对话中,`stopHookActive = true`,进入下一轮迭代。
|
||||
|
||||
### 模型降级
|
||||
### 5. Token Budget 继续提示(`token_budget_continuation`)
|
||||
当 `TOKEN_BUDGET` feature 启用时,如果 token 消耗达到阈值但未超出预算,注入 nudge 消息让 AI 加速收尾,然后继续。
|
||||
|
||||
当主模型不可用时(过载、维护等):
|
||||
## 模型降级(Fallback)
|
||||
|
||||
1. 已收集的响应被保留为历史记录
|
||||
2. 自动切换到备用模型
|
||||
3. 通知用户发生了降级
|
||||
4. 从中断点继续,而不是从头开始
|
||||
当主模型不可用时(`FallbackTriggeredError`,`src/query.ts` 中 `attemptWithFallback` 循环的 catch 分支):
|
||||
|
||||
## 状态管理
|
||||
1. 已收集的 `assistantMessages` 被清空,tool_use 块收到合成 tool_result:"Model fallback triggered"
|
||||
2. 思维签名块被移除(`stripSignatureBlocks`)—— 因为思维签名与模型绑定,跨模型回放会 400
|
||||
3. 切换到 `fallbackModel`,更新 `toolUseContext.options.mainLoopModel`
|
||||
4. 生成系统消息:"Switched to {fallback} due to high demand for {original}"
|
||||
5. 重新发起流式请求
|
||||
|
||||
每次迭代的状态是不可变更新的——系统创建新的状态对象而非就地修改。状态中包含:
|
||||
## 状态机:State 对象
|
||||
|
||||
- **对话消息**:当前所有消息的数组
|
||||
- **压缩跟踪**:压缩操作的累计状态
|
||||
- **恢复计数**:各种错误恢复已尝试的次数
|
||||
- **继续原因**:上一轮为什么继续(用于检测和避免循环)
|
||||
每次迭代的状态通过 `State` 类型(`src/query.ts`,类型定义)传递:
|
||||
|
||||
**设计考量**:状态中记录"继续原因"是一个关键的防循环机制。系统可以在后续迭代中检查"上一轮是因为压缩重试而继续的",从而避免在同一个恢复路径上反复尝试。
|
||||
```typescript
|
||||
// src/query.ts — State 类型定义
|
||||
type State = {
|
||||
messages: Message[] // 当前对话消息
|
||||
toolUseContext: ToolUseContext // 工具上下文(含权限)
|
||||
autoCompactTracking: AutoCompactTrackingState | undefined // 压缩跟踪
|
||||
maxOutputTokensRecoveryCount: number // 输出截断恢复计数
|
||||
hasAttemptedReactiveCompact: boolean // 是否已尝试即时压缩
|
||||
maxOutputTokensOverride: number | undefined // 输出 token 上限覆盖
|
||||
pendingToolUseSummary: Promise<...> | undefined // 异步工具摘要
|
||||
stopHookActive: boolean | undefined // Stop hook 是否激活
|
||||
turnCount: number // 轮次计数
|
||||
transition: Continue | undefined // 上一次继续的原因
|
||||
}
|
||||
```
|
||||
|
||||
每次 `continue` 都创建新的 State 对象(不可变更新),而非就地修改。`transition` 字段记录了为什么继续——让后续迭代能检测特定恢复路径(如 `collapse_drain_retry`)避免循环。
|
||||
|
||||
## Token Budget(实验性)
|
||||
|
||||
当 `TOKEN_BUDGET` feature 启用时(`src/query.ts` 中 `!needsFollowUp` 分支内的预算检查逻辑),循环在终止前会检查 token 消耗:
|
||||
|
||||
- **continuation**:未达到预算但超过阈值 → 注入 nudge 消息,让 AI 加速收尾
|
||||
- **diminishing_returns**:检测到收益递减 → 提前终止
|
||||
- 预算数据来自 `createBudgetTracker()`,跨迭代累计
|
||||
|
||||
## 为什么不是"一次规划,批量执行"
|
||||
|
||||
一个自然的疑问是:为什么不先让 AI 规划好所有步骤,然后一次性批量执行?
|
||||
<Note>
|
||||
源码揭示了为什么 Claude Code 选择逐步循环:
|
||||
</Note>
|
||||
|
||||
答案在于软件工程的**不确定性**:
|
||||
|
||||
- **每步结果影响下一步**:搜索结果决定了要改哪些文件,修改后的编译结果决定了是否需要进一步调整
|
||||
- **错误需要即时修正**:如果某步失败,AI 需要立即调整策略,而非继续执行无效计划
|
||||
- **用户可能中途干预**:循环架构允许用户随时打断和修正方向
|
||||
|
||||
这不是说 AI 不做规划——事实上系统内置了规划模式(Plan Mode)用于复杂任务。但规划的结果仍然是逐步执行的,每一步都有机会根据新信息调整。
|
||||
- **每一步都产生真实信息**:`runTools()` 返回的 `toolResults` 是 API 不可能预知的——命令输出、文件内容、错误信息
|
||||
- **动态上下文管理**:每轮迭代前都重新评估压缩需求(autocompact → microcompact → snip),基于最新的 token 计数
|
||||
- **错误即时恢复**:工具失败不需要推倒重来——stop hook 可以注入阻塞错误让 AI 修正策略
|
||||
- **用户可控**:`abortController.signal` 在循环的多个检查点被检测(第 1059、1095、1529 行),用户按 ESC 可以优雅中断
|
||||
- **成本控制**:Token Budget 在每轮终止前检查,防止 AI 无效循环
|
||||
|
||||
## 一个完整的迭代示例
|
||||
|
||||
> 用户:"帮我找到项目里所有未使用的导入语句,然后删掉它们"
|
||||
|
||||
```
|
||||
迭代 1: 探索
|
||||
AI: 先找到所有 TypeScript 文件
|
||||
工具: Glob("**/*.ts") → 返回 42 个文件
|
||||
决策: 需要进一步分析 → 继续
|
||||
迭代 1: 思考→行动
|
||||
预处理管道: applyToolResultBudget → snipCompact(HISTORY_SNIP feature) → microcompact → applyCollapses(CONTEXT_COLLAPSE feature) → autocompact
|
||||
→ 上下文很短,无需压缩
|
||||
API 调用: 返回 tool_use(Glob, "**/*.ts")
|
||||
工具执行: 返回 42 个文件路径
|
||||
→ needsFollowUp = true
|
||||
→ transition: { reason: 'next_turn' }, continue
|
||||
|
||||
迭代 2: 分析
|
||||
AI: 搜索这些文件中的 import 语句
|
||||
工具: Grep("import.*from") → 在 15 个文件中找到 120 条 import
|
||||
决策: 结果太多,需要进一步筛选 → 继续
|
||||
迭代 2: 思考→行动
|
||||
预处理管道: 42 个文件结果仍在预算内
|
||||
API 调用: 返回 tool_use(Grep, "import.*from")
|
||||
工具执行: 在 15 个文件中找到 120 条 import
|
||||
→ needsFollowUp = true
|
||||
→ transition: { reason: 'next_turn' }, continue
|
||||
|
||||
迭代 3: 精确修改
|
||||
AI: 分析哪些 import 未被使用,删除它们
|
||||
上下文预处理: 120 条结果被微压缩为摘要
|
||||
工具: FileEdit × 3 → 删除 5 条未使用导入
|
||||
决策: 需要验证 → 继续
|
||||
迭代 3: 思考→行动(多轮)
|
||||
预处理管道: 120 条 Grep 结果触发 microcompact → 摘要化
|
||||
API 调用: 返回 3 个 tool_use(FileEdit, ...)
|
||||
工具执行: 删除 5 条未使用导入
|
||||
→ needsFollowUp = true
|
||||
→ transition: { reason: 'next_turn' }, continue
|
||||
|
||||
迭代 4: 验证与总结
|
||||
AI: 验证修改后编译通过
|
||||
工具: Bash("tsc --noEmit") → 编译通过
|
||||
决策: 任务完成 → 终止
|
||||
迭代 4: 总结
|
||||
API 调用: 返回纯文本"已清理 3 个文件中的 5 条未使用导入"
|
||||
→ needsFollowUp = false
|
||||
→ Stop hooks 通过
|
||||
→ Token Budget 检查通过(如果启用)
|
||||
→ return { reason: 'completed' }
|
||||
```
|
||||
|
||||
注意这个过程中的关键特征:
|
||||
- AI 在每一步后根据结果自主决定下一步
|
||||
- 上下文在迭代过程中动态调整(微压缩被触发)
|
||||
- 用户全程无需介入
|
||||
|
||||
## 接下来
|
||||
|
||||
- **流式响应** — 理解流式传输的设计细节和用户体验考量
|
||||
- **多轮对话** — 跨迭代的上下文管理和会话持久化
|
||||
- **上下文压缩** — 深入理解自动压缩的触发条件和策略
|
||||
- **工具系统** — 了解 AI 可以调用哪些工具及其设计
|
||||
|
||||
@@ -1,107 +1,211 @@
|
||||
---
|
||||
title: "自定义 Agent"
|
||||
description: "用 Markdown 文件定义自己的 Agent。理解 Agent 定义的数据模型、工具过滤策略和与 AgentTool 的联动。"
|
||||
keywords: ["自定义 Agent", "Agent 定义", "Markdown Agent", "角色定制"]
|
||||
title: "自定义 Agent - 从 Markdown 到运行时的完整链路"
|
||||
description: "揭秘 Claude Code 自定义 Agent 完整链路:Agent 定义的 Markdown 数据模型、三种加载来源、工具过滤策略和与 AgentTool 的联动机制。"
|
||||
keywords: ["自定义 Agent", "Agent 定义", "Markdown Agent", "Agent 配置", "角色定制"]
|
||||
---
|
||||
|
||||
## 核心概念
|
||||
{/* 本章目标:揭示 Agent 定义的完整数据模型、加载发现机制、工具过滤和与 AgentTool 的联动 */}
|
||||
|
||||
自定义 Agent 是一个 Markdown 文件——frontmatter 定义配置,正文是 system prompt。不需要写代码。
|
||||
## Agent 定义的三种来源
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: "reviewer"
|
||||
description: "Code review specialist"
|
||||
tools: "Read,Glob,Grep"
|
||||
model: "haiku"
|
||||
---
|
||||
|
||||
你是代码审查专家。你的职责是...
|
||||
```
|
||||
|
||||
这个 Markdown 文件就定义了一个完整的 Agent:它使用什么工具、什么模型、什么行为规则。
|
||||
|
||||
## Agent 的三种来源
|
||||
Claude Code 的 Agent 不仅仅来自用户自定义——系统有三类来源,按优先级合并:
|
||||
|
||||
| 来源 | 位置 | 优先级 |
|
||||
|------|------|--------|
|
||||
| **Built-in** | 硬编码 | 最低(可被覆盖) |
|
||||
| **Plugin** | 插件系统注册 | 中 |
|
||||
| **User/Project** | `.claude/agents/*.md` | 最高 |
|
||||
| **Built-in** | `packages/builtin-tools/src/tools/AgentTool/built-in/` 硬编码 | 最低(可被覆盖) |
|
||||
| **Plugin** | 通过插件系统注册 | 中 |
|
||||
| **User/Project/Policy** | `.claude/agents/*.md` 或 settings.json | 最高 |
|
||||
|
||||
合并时按 `agentType` 去重,后者覆盖前者。这意味着你可以在 `.claude/agents/` 中放一个 `Explore.md` 来完全替换内置的 Explore Agent。
|
||||
合并逻辑在 `getActiveAgentsFromList()` 中:按 `agentType` 去重,后者覆盖前者。这意味着你可以在 `.claude/agents/` 中放一个 `Explore.md` 来完全替换内置的 Explore Agent。
|
||||
|
||||
## 配置字段
|
||||
## Markdown Agent 文件的完整格式
|
||||
|
||||
### 工具控制
|
||||
```markdown
|
||||
---
|
||||
# === 必需字段 ===
|
||||
name: "reviewer" # Agent 标识(agentType)
|
||||
description: "Code review specialist, read-only analysis"
|
||||
|
||||
- `tools` — 允许的工具白名单(未指定 = 全部工具)
|
||||
- `disallowedTools` — 显式禁止的工具(即使 `tools` 未指定也生效)
|
||||
# === 工具控制 ===
|
||||
tools: "Read,Glob,Grep,Bash" # 允许的工具列表(逗号分隔)
|
||||
disallowedTools: "Write,Edit" # 显式禁止的工具
|
||||
|
||||
**设计考量**:`disallowedTools` 是比 `tools` 更安全的控制方式。如果只指定 `tools` 白名单,新增工具时需要更新白名单。`disallowedTools` 是黑名单思维——默认允许,只禁止危险的。
|
||||
# === 模型配置 ===
|
||||
model: "haiku" # 指定模型(或 "inherit" 继承主线程)
|
||||
effort: "high" # 推理努力程度:low/medium/high 或整数
|
||||
|
||||
### 模型配置
|
||||
# === 行为控制 ===
|
||||
maxTurns: 10 # 最大 agentic 轮次
|
||||
permissionMode: "plan" # 权限模式:plan/bypassPermissions 等
|
||||
background: true # 始终作为后台任务运行
|
||||
initialPrompt: "/search TODO" # 首轮用户消息前缀(支持斜杠命令)
|
||||
|
||||
- `model` — 指定模型(`haiku`/`sonnet`/`opus`/`inherit`)
|
||||
- `effort` — 推理努力程度(`low`/`medium`/`high`)
|
||||
# === 隔离与持久化 ===
|
||||
isolation: "worktree" # 在独立 git worktree 中运行
|
||||
memory: "project" # 持久记忆范围:user/project/local
|
||||
|
||||
`inherit` 使用主线程的模型——适合需要完整推理能力的任务。`haiku` 适合轻量任务(如搜索),更便宜更快。
|
||||
# === MCP 服务器 ===
|
||||
mcpServers:
|
||||
- "slack" # 引用已配置的 MCP 服务器
|
||||
- database: # 内联定义
|
||||
command: "npx"
|
||||
args: ["mcp-db"]
|
||||
|
||||
### 行为控制
|
||||
# === Hooks ===
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- command: "audit-log.sh"
|
||||
timeout: 5000
|
||||
|
||||
- `maxTurns` — 最大 agentic 轮次(防止无限循环)
|
||||
- `permissionMode` — 权限模式(`plan`/`bypassPermissions` 等)
|
||||
- `background` — 始终作为后台任务运行
|
||||
- `isolation` — 在独立 worktree 中运行
|
||||
# === Skills ===
|
||||
skills: "code-review,security-review" # 预加载的 skills(逗号分隔)
|
||||
|
||||
### 持久化
|
||||
# === 显示 ===
|
||||
color: "blue" # 终端中的 Agent 颜色标识
|
||||
---
|
||||
|
||||
- `memory` — 启用跨会话的持久记忆(`user`/`project`/`local`)
|
||||
- `mcpServers` — 引用或内联定义 MCP 服务器
|
||||
- `hooks` — Agent 专属的 Hook 配置
|
||||
- `skills` — 预加载的技能
|
||||
你是代码审查专家。你的职责是...
|
||||
|
||||
(正文内容 = system prompt)
|
||||
```
|
||||
|
||||
### 字段解析细节
|
||||
|
||||
- **`tools`**:通过 `parseAgentToolsFromFrontmatter()` 解析,支持逗号分隔字符串或数组
|
||||
- **`model: "inherit"`**:使用主线程的模型(区分大小写,只有小写 "inherit" 有效)
|
||||
- **`memory`**:启用后自动注入 `Write`/`Edit`/`Read` 工具(即使 `tools` 未包含),并在 system prompt 末尾追加 memory 指令
|
||||
- **`isolation: "remote"`**:仅在 Anthropic 内部可用(`USER_TYPE === 'ant'`),外部构建只支持 `worktree`
|
||||
- **`background`**:`true` 使 Agent 始终在后台运行,主线程不等待结果
|
||||
|
||||
## 加载与发现机制
|
||||
|
||||
`getAgentDefinitionsWithOverrides()`(被 `memoize` 缓存)执行完整的发现流程:
|
||||
|
||||
```
|
||||
1. 加载 Markdown 文件
|
||||
├── loadMarkdownFilesForSubdir('agents', cwd)
|
||||
│ ├── ~/.claude/agents/*.md (用户级,source = 'userSettings')
|
||||
│ ├── .claude/agents/*.md (项目级,source = 'projectSettings')
|
||||
│ └── managed/policy sources (策略级,source = 'policySettings')
|
||||
│
|
||||
└── 每个 .md 文件:
|
||||
├── 解析 YAML frontmatter
|
||||
├── 正文作为 system prompt
|
||||
├── 校验必需字段(name, description)
|
||||
├── 静默跳过无 frontmatter 的 .md 文件(可能是参考文档)
|
||||
└── 解析失败 → 记录到 failedFiles,不阻塞其他 Agent
|
||||
|
||||
2. 并行加载 Plugin Agents
|
||||
└── loadPluginAgents() → memoized
|
||||
|
||||
3. 初始化 Memory Snapshots(如果 AGENT_MEMORY_SNAPSHOT 启用)
|
||||
└── initializeAgentMemorySnapshots()
|
||||
|
||||
4. 合并 Built-in + Plugin + Custom
|
||||
└── getActiveAgentsFromList() → 按 agentType 去重,后者覆盖前者
|
||||
|
||||
5. 分配颜色
|
||||
└── setAgentColor(agentType, color) → 终端 UI 中区分不同 Agent
|
||||
```
|
||||
|
||||
## 工具过滤的实现
|
||||
|
||||
当 Agent 被派生时,`AgentTool` 根据定义中的 `tools` / `disallowedTools` 过滤可用工具列表:
|
||||
|
||||
```
|
||||
全部工具
|
||||
↓ disallowedTools 移除
|
||||
↓ tools 白名单过滤(如果指定)
|
||||
可用工具
|
||||
```
|
||||
|
||||
- **`tools` 未指定**:Agent 可以使用所有工具(默认全能)
|
||||
- **`tools` 指定**:只能使用列出的工具
|
||||
- **`disallowedTools`**:即使 `tools` 未指定,这些工具也被禁止
|
||||
- **自动注入**:`memory` 启用时自动添加 `Write`/`Edit`/`Read`
|
||||
|
||||
以内置 Explore Agent 为例:
|
||||
|
||||
```typescript
|
||||
// packages/builtin-tools/src/tools/AgentTool/built-in/exploreAgent.ts
|
||||
disallowedTools: [
|
||||
'Agent', // 不能嵌套调用 Agent
|
||||
'ExitPlanMode', // 不需要 plan mode
|
||||
'FileEdit', // 只读
|
||||
'FileWrite', // 只读
|
||||
'NotebookEdit', // 只读
|
||||
]
|
||||
```
|
||||
|
||||
## System Prompt 的注入方式
|
||||
|
||||
Agent 的 Markdown 正文**完全替换**默认的 system prompt,而非追加。这意味着自定义 Agent 的行为完全由你定义——不受默认 prompt 的约束。
|
||||
Agent 的 system prompt 通过 `getSystemPrompt()` 闭包延迟生成:
|
||||
|
||||
如果启用了 `memory`,记忆指令自动追加到 system prompt 末尾。
|
||||
|
||||
## 工具过滤流程
|
||||
|
||||
```
|
||||
全部工具 → disallowedTools 移除 → tools 白名单过滤 → 可用工具
|
||||
```typescript
|
||||
// Markdown Agent
|
||||
getSystemPrompt: () => {
|
||||
if (isAutoMemoryEnabled() && memory) {
|
||||
return systemPrompt + '\n\n' + loadAgentMemoryPrompt(agentType, memory)
|
||||
}
|
||||
return systemPrompt
|
||||
}
|
||||
```
|
||||
|
||||
内置 Explore Agent 的工具限制是很好的例子:
|
||||
- 禁止 `Agent`(不能嵌套调用子 Agent)
|
||||
- 禁止 `FileEdit`/`FileWrite`(只读)
|
||||
- 禁止 `ExitPlanMode`(不需要 plan mode)
|
||||
这意味着:
|
||||
1. **Markdown 正文 = 完整的 system prompt**——不是追加,而是替换默认 prompt
|
||||
2. **Memory 指令**在 memory 启用时自动追加到末尾
|
||||
3. **闭包延迟计算**——memory 状态可能在文件加载后才变化
|
||||
|
||||
这些限制让 Explore Agent 成为一个纯粹的搜索工具——它只能看,不能改。
|
||||
对于 Built-in Agent,`getSystemPrompt` 接受 `toolUseContext` 参数,可以根据运行时状态(如是否使用嵌入式搜索工具)动态调整 prompt 内容。
|
||||
|
||||
## 与 AgentTool 的联动
|
||||
|
||||
当主 Agent 需要派生子 Agent 时:
|
||||
|
||||
```
|
||||
查找 Agent 定义 → 检查 MCP 依赖 → 过滤工具 → 解析模型 → 构建隔离环境 → 注入 system prompt → 启动子 Agent
|
||||
AgentTool.call({ subagent_type: "reviewer", ... })
|
||||
↓
|
||||
1. 从 agentDefinitions.activeAgents 查找 agentType === "reviewer"
|
||||
↓
|
||||
2. 检查 requiredMcpServers(如果 Agent 要求特定 MCP 服务器)
|
||||
↓
|
||||
3. 过滤工具列表(tools / disallowedTools)
|
||||
↓
|
||||
4. 解析模型:
|
||||
- "inherit" → 使用主线程模型
|
||||
- 具体模型名 → 直接使用
|
||||
- 未指定 → 主线程模型
|
||||
↓
|
||||
5. 解析权限模式(permissionMode)
|
||||
↓
|
||||
6. 构建隔离环境(如果 isolation === "worktree")
|
||||
↓
|
||||
7. 注入 system prompt(getSystemPrompt())
|
||||
↓
|
||||
8. 注入 initialPrompt(如果定义了)
|
||||
↓
|
||||
9. 启动子 Agent 循环(forkSubagent / runAgent)
|
||||
```
|
||||
|
||||
每一步都使用 Agent 定义中的配置——工具列表、模型选择、权限模式、隔离环境等。
|
||||
|
||||
## 内置 Agent 参考
|
||||
|
||||
| Agent | 角色 | 工具限制 | 模型 |
|
||||
|-------|------|---------|------|
|
||||
| General Purpose | 通用任务 | 全部工具 | 继承主线程 |
|
||||
| Explore | 代码搜索 | 只读 | haiku |
|
||||
| Plan | 规划研究 | 只读 + ExitPlanMode | 继承主线程 |
|
||||
| Verification | 结果验证 | 由 feature flag 控制 | — |
|
||||
| Code Guide | 使用指南 | 只读 | — |
|
||||
| Agent | agentType | 角色 | 工具限制 | 模型 |
|
||||
|-------|-----------|------|---------|------|
|
||||
| **General Purpose** | `general-purpose` | 默认子 Agent | 全部工具 | 主线程模型 |
|
||||
| **Explore** | `Explore` | 代码搜索专家 | 只读(无 Write/Edit) | haiku(外部) |
|
||||
| **Plan** | `Plan` | 规划专家 | 只读 + ExitPlanMode | inherit |
|
||||
| **Verification** | `verification` | 结果验证 | 由 feature flag 控制 | — |
|
||||
| **Code Guide** | `claude-code-guide` | Claude Code 使用指南 | 只读 | — |
|
||||
| **Statusline Setup** | `statusline-setup` | 终端状态栏配置 | 有限 | — |
|
||||
|
||||
## 接下来
|
||||
SDK 入口(`sdk-ts`/`sdk-py`/`sdk-cli`)不加载 Code Guide Agent。环境变量 `CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS` 可以完全禁用内置 Agent,给 SDK 用户提供空白画布。
|
||||
|
||||
- **子 Agent** — 理解子 Agent 的完整执行链路
|
||||
- **Skills** — 理解 Skill 中指定 Agent 定义
|
||||
- **MCP 配置** — 理解 Agent 中引用 MCP 服务器
|
||||
## Agent Memory:持久化的 Agent 状态
|
||||
|
||||
当 `memory` 字段启用时,Agent 获得跨会话的持久记忆:
|
||||
|
||||
- **`local`**:当前项目、当前用户有效
|
||||
- **`project`**:当前项目所有用户共享
|
||||
- **`user`**:所有项目共享
|
||||
|
||||
Memory 通过 `loadAgentMemoryPrompt()` 注入到 system prompt 末尾,包含读写记忆的指令。Agent Memory Snapshot 机制在项目间同步 `user` 级记忆。
|
||||
|
||||
@@ -1,148 +1,253 @@
|
||||
---
|
||||
title: "Hooks"
|
||||
description: "Hooks 是 Claude Code 的扩展机制——在工具调用前后注入自定义逻辑。理解四种 Hook 能力、匹配机制和安全防护。"
|
||||
title: "Hooks 生命周期钩子 - 执行引擎与拦截协议"
|
||||
description: "从源码角度解析 Claude Code Hooks 系统:27 种 Hook 事件、6 种 Hook 类型、同步/异步执行协议、JSON 输出 schema、if 条件匹配、以及 Hook 如何注入上下文和拦截工具调用。"
|
||||
keywords: ["Hooks", "生命周期钩子", "拦截器", "PreToolUse", "Hook 协议"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:从源码角度揭示 Hook 的执行引擎、匹配机制、返回值协议和生命周期管理 */}
|
||||
|
||||
Claude Code 提供了强大的内置功能,但每个团队和工作流都不同。Hooks 让你可以在关键节点注入自定义逻辑——不需要修改 Claude Code 本身。
|
||||
## 27 种 Hook 事件
|
||||
|
||||
## Hook 事件
|
||||
Claude Code 定义了 27 种 Hook 事件(`HOOK_EVENTS` 数组,`src/entrypoints/sdk/coreTypes.ts`),覆盖完整的 Agent 生命周期:
|
||||
|
||||
Hooks 覆盖 Agent 生命周期的所有关键节点:
|
||||
| 阶段 | 事件 | 触发时机 | 匹配字段 |
|
||||
|------|------|---------|---------|
|
||||
| **会话** | `SessionStart` | 会话启动 | `source` |
|
||||
| | `SessionEnd` | 会话结束 | `reason` |
|
||||
| | `Setup` | 初始化完成 | `trigger` |
|
||||
| **用户交互** | `UserPromptSubmit` | 用户提交消息 | — |
|
||||
| | `Stop` | Agent 停止响应 | — |
|
||||
| | `StopFailure` | Agent 停止失败 | `error` |
|
||||
| **工具执行** | `PreToolUse` | 工具调用前 | `tool_name` |
|
||||
| | `PostToolUse` | 工具调用后(成功) | `tool_name` |
|
||||
| | `PostToolUseFailure` | 工具调用后(失败) | `tool_name` |
|
||||
| **权限** | `PermissionRequest` | 权限请求 | `tool_name` |
|
||||
| | `PermissionDenied` | 权限被拒 | `tool_name` |
|
||||
| **子 Agent** | `SubagentStart` | 子 Agent 启动 | `agent_type` |
|
||||
| | `SubagentStop` | 子 Agent 停止 | `agent_type` |
|
||||
| **压缩** | `PreCompact` | 上下文压缩前 | `trigger` |
|
||||
| | `PostCompact` | 上下文压缩后 | `trigger` |
|
||||
| **协作** | `TeammateIdle` | Teammate 空闲 | — |
|
||||
| | `TaskCreated` | 任务创建 | — |
|
||||
| | `TaskCompleted` | 任务完成 | — |
|
||||
| **MCP** | `Elicitation` | MCP 服务器请求用户输入 | `mcp_server_name` |
|
||||
| | `ElicitationResult` | Elicitation 结果返回 | `mcp_server_name` |
|
||||
| **通知** | `Notification` | 系统通知事件 | `notification_type` |
|
||||
| **环境** | `ConfigChange` | 配置变更 | `source` |
|
||||
| | `CwdChanged` | 工作目录变更 | — |
|
||||
| | `FileChanged` | 文件变更 | `file_path` |
|
||||
| | `InstructionsLoaded` | 指令加载 | `load_reason` |
|
||||
| | `WorktreeCreate` / `WorktreeRemove` | Worktree 操作 | — |
|
||||
|
||||
| 阶段 | 典型事件 |
|
||||
|------|---------|
|
||||
| **会话** | 启动、结束、初始化 |
|
||||
| **用户交互** | 提交消息、停止响应 |
|
||||
| **工具执行** | 工具调用前、工具调用后(成功/失败) |
|
||||
| **权限** | 权限请求、权限被拒 |
|
||||
| **子 Agent** | 启动、停止 |
|
||||
| **压缩** | 压缩前、压缩后 |
|
||||
| **协作** | Teammate 空闲、任务创建/完成 |
|
||||
## 6 种 Hook 类型
|
||||
|
||||
## 四种 Hook 能力
|
||||
Hooks 配置支持 6 种执行方式,类型定义分布在 3 个文件中:
|
||||
|
||||
Hook 不仅是"执行一个脚本"——它有四种不同的能力:
|
||||
- **可持久化类型**(`command`、`prompt`、`agent`、`http`)— Zod schema 定义在 `src/schemas/hooks.ts`,通过 `z.discriminatedUnion('type', [...])` 声明
|
||||
- **callback 类型** — TypeScript 接口定义在 `src/types/hooks.ts`,用于 SDK 注册的内部 JS 函数
|
||||
- **function 类型** — 定义在 `src/utils/hooks/sessionHooks.ts`,用于运行时动态注册的函数 Hook
|
||||
|
||||
### 1. 拦截操作(PreToolUse)
|
||||
| 类型 | 执行方式 | 适用场景 |
|
||||
|------|---------|---------|
|
||||
| `command` | Shell 命令(bash/PowerShell) | 通用脚本、CI 检查 |
|
||||
| `prompt` | 注入到 AI 上下文 | 代码规范提醒 |
|
||||
| `agent` | 启动子 Agent 执行 | 复杂分析任务 |
|
||||
| `http` | HTTP 请求 | 远程服务、Webhook |
|
||||
| `callback` | 内部 JS 函数 | 系统内置 Hook |
|
||||
| `function` | 运行时注册的函数 Hook | Agent/Skill 内部使用 |
|
||||
|
||||
在工具执行前拦截,可以阻止危险操作:
|
||||
## 执行引擎:execCommandHook
|
||||
|
||||
`execCommandHook()`(`src/utils/hooks.ts`,`execCommandHook` 函数)是命令型 Hook 的执行核心:
|
||||
|
||||
```
|
||||
execCommandHook(hook, hookEvent, hookName, jsonInput, signal)
|
||||
├── Shell 选择: hook.shell ?? DEFAULT_HOOK_SHELL
|
||||
│ ├── bash: spawn(cmd, [], { shell: gitBashPath | true })
|
||||
│ └── powershell: spawn(pwsh, ['-NoProfile', '-NonInteractive', '-Command', cmd])
|
||||
├── 变量替换
|
||||
│ ├── ${CLAUDE_PLUGIN_ROOT} → pluginRoot 路径
|
||||
│ ├── ${CLAUDE_PLUGIN_DATA} → plugin 数据目录
|
||||
│ └── ${user_config.X} → 用户配置值
|
||||
├── 环境变量注入
|
||||
│ ├── CLAUDE_PROJECT_DIR
|
||||
│ ├── CLAUDE_ENV_FILE(SessionStart/Setup/CwdChanged/FileChanged)
|
||||
│ └── CLAUDE_PLUGIN_OPTION_*(plugin options)
|
||||
├── stdin 写入: jsonInput + '\n'
|
||||
├── 超时: hook.timeout * 1000 ?? 600000ms(10分钟)
|
||||
└── 异步检测: 检查 stdout 首行是否为 {"async":true}
|
||||
```
|
||||
|
||||
### 异步 Hook 的检测协议
|
||||
|
||||
Hook 进程的 stdout 第一行如果是 `{"async":true}`,系统将其转为后台任务(`isAsyncHookJSONOutput` 检测 + `executeInBackground` 调用):
|
||||
|
||||
```typescript
|
||||
const firstLine = firstLineOf(stdout).trim()
|
||||
if (isAsyncHookJSONOutput(parsed)) {
|
||||
executeInBackground({
|
||||
processId: `async_hook_${child.pid}`,
|
||||
asyncResponse: parsed,
|
||||
...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
后台 Hook 通过 `registerPendingAsyncHook()` 注册到 `AsyncHookRegistry`,完成后通过 `enqueuePendingNotification()` 通知主线程。
|
||||
|
||||
### asyncRewake:Hook 唤醒模型
|
||||
|
||||
`asyncRewake` 模式的 Hook 绕过 `AsyncHookRegistry`。当 Hook 退出码为 2 时,通过 `enqueuePendingNotification()` 以 `task-notification` 模式注入消息,唤醒空闲的模型(通过 `useQueueProcessor`)或在忙碌时注入 `queued_command` 附件。
|
||||
|
||||
## Hook 输出的 JSON Schema
|
||||
|
||||
同步 Hook 的输出遵循严格的 Zod schema(`syncHookResponseSchema`,定义在 `src/types/hooks.ts`,`hookJSONOutputSchema` 定义在 `src/schemas/hooks.ts`):
|
||||
|
||||
```json
|
||||
{
|
||||
"continue": false, // 是否继续执行
|
||||
"suppressOutput": true, // 隐藏 stdout
|
||||
"stopReason": "安全检查失败", // continue=false 时的原因
|
||||
"decision": "approve" | "block", // 全局决策
|
||||
"reason": "原因说明", // 决策原因
|
||||
"systemMessage": "警告内容", // 注入到上下文的系统消息
|
||||
"hookSpecificOutput": {
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": "不允许在生产分支上强制推送"
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "allow" | "deny" | "ask",
|
||||
"permissionDecisionReason": "匹配了安全规则",
|
||||
"updatedInput": { ... }, // 修改后的工具输入
|
||||
"additionalContext": "额外上下文" // 注入到对话
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 修改行为
|
||||
### 各事件的 hookSpecificOutput
|
||||
|
||||
修改工具的输入或输出:
|
||||
| 事件 | 专有字段 | 作用 |
|
||||
|------|---------|------|
|
||||
| `PreToolUse` | `permissionDecision`, `permissionDecisionReason`, `updatedInput`, `additionalContext` | 拦截/修改工具输入 |
|
||||
| `PostToolUse` | `additionalContext`, `updatedMCPToolOutput` | 修改 MCP 工具输出 |
|
||||
| `PostToolUseFailure` | `additionalContext` | 失败后注入上下文 |
|
||||
| `UserPromptSubmit` | `additionalContext` | 注入额外上下文 |
|
||||
| `SessionStart` | `additionalContext`, `initialUserMessage`, `watchPaths` | 设置初始消息和文件监控 |
|
||||
| `PermissionRequest` | `decision`(含 `allow`/`deny` 子字段) | 权限请求的 Hook 决策 |
|
||||
| `PermissionDenied` | `retry` | 指示是否重试 |
|
||||
| `SubagentStart` | `additionalContext` | 子 Agent 启动时注入上下文 |
|
||||
| `Elicitation` | `action`, `content` | 控制用户输入对话框 |
|
||||
| `ElicitationResult` | `action`, `content` | Elicitation 结果处理 |
|
||||
| `Notification` | `additionalContext` | 通知事件注入上下文 |
|
||||
| `Setup` | `additionalContext` | 初始化时注入上下文 |
|
||||
| `CwdChanged` | `watchPaths` | 目录变更后更新监控路径 |
|
||||
| `FileChanged` | `watchPaths` | 文件变更后更新监控路径 |
|
||||
| `WorktreeCreate` | `worktreePath` | Worktree 创建通知 |
|
||||
|
||||
## Hook 匹配机制:getMatchingHooks
|
||||
|
||||
`getMatchingHooks()`(`src/utils/hooks.ts`,`getMatchingHooks` 函数)负责从所有来源中查找匹配的 Hook:
|
||||
|
||||
### 多来源合并
|
||||
|
||||
```
|
||||
getHooksConfig()
|
||||
├── getHooksConfigFromSnapshot() ← settings.json 中的 Hook(user/project/local)
|
||||
├── getRegisteredHooks() ← SDK 注册的 callback Hook
|
||||
├── getSessionHooks() ← Agent/Skill 前置注册的 session Hook
|
||||
└── getSessionFunctionHooks() ← 运行时 function Hook
|
||||
```
|
||||
|
||||
### 匹配规则
|
||||
|
||||
`matcher` 字段支持三种模式(`matchesPattern()` 函数,`src/utils/hooks.ts`):
|
||||
|
||||
```
|
||||
"Write" → 精确匹配
|
||||
"Write|Edit" → 管道分隔的多值匹配
|
||||
"^Bash(git.*)" → 正则匹配
|
||||
"*" 或 "" → 通配(匹配所有)
|
||||
```
|
||||
|
||||
### if 条件过滤
|
||||
|
||||
Hook 可以指定 `if` 条件,只在特定输入时触发。`prepareIfConditionMatcher()`(`src/utils/hooks.ts`,`prepareIfConditionMatcher` 函数)预编译匹配器:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": [{
|
||||
"command": "check-git-branch.sh",
|
||||
"if": "Bash(git push*)"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
`if` 条件使用 `permissionRuleValueFromString` 解析,支持与权限规则相同的语法(工具名 + 参数模式)。Bash 工具还会使用 tree-sitter 进行 AST 级别的命令解析。
|
||||
|
||||
### Hook 去重
|
||||
|
||||
同一个 Hook 命令在不同配置层级(user/project/local)可能重复。系统按四部分复合键做 Map 去重:`${pluginRoot}\0${shell}\0${command}\0${ifCondition}`(由 `hookDedupKey()` 函数构建),保留**最后合并的层级**。
|
||||
|
||||
## 工作区信任检查
|
||||
|
||||
**所有 Hook 都要求工作区信任**(`shouldSkipHookDueToTrust()` 函数,`src/utils/hooks.ts`)。这是纵深防御措施——防止恶意仓库的 `.claude/settings.json` 在未信任的情况下执行任意命令。
|
||||
|
||||
```typescript
|
||||
// 交互模式下,所有 Hook 要求信任
|
||||
const hasTrust = checkHasTrustDialogAccepted()
|
||||
return !hasTrust
|
||||
```
|
||||
|
||||
SDK 非交互模式下信任是隐式的(`getIsNonInteractiveSession()` 为 true 时跳过检查)。
|
||||
|
||||
## 四种 Hook 能力的源码映射
|
||||
|
||||
### 1. 拦截操作(PreToolUse)
|
||||
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`processHookJSONOutput()` 将 `permissionDecision` 映射为 `result.permissionBehavior = 'deny'`,并设置 `blockingError`,阻止工具执行。
|
||||
|
||||
### 2. 修改行为(updatedInput / updatedMCPToolOutput)
|
||||
|
||||
```json
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"updatedInput": { "command": "npm test -- --bail" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
PostToolUse 的 `updatedMCPToolOutput` 可以替换 MCP 工具的返回值——用于过滤敏感数据。
|
||||
`updatedInput` 替换原始工具输入;`updatedMCPToolOutput`(PostToolUse 事件)替换 MCP 工具的返回值——可用于过滤敏感数据。
|
||||
|
||||
### 3. 注入上下文
|
||||
### 3. 注入上下文(additionalContext / systemMessage)
|
||||
|
||||
向 AI 的对话中注入额外信息:
|
||||
- `additionalContext` — 注入为用户消息,AI 可以参考
|
||||
- `systemMessage` — 显示为系统警告
|
||||
- `additionalContext` → 通过 `createAttachmentMessage({ type: 'hook_additional_context' })` 注入为用户消息
|
||||
- `systemMessage` → 注入为系统警告,直接显示给用户
|
||||
|
||||
### 4. 控制流程
|
||||
|
||||
阻止 Agent 继续执行:
|
||||
### 4. 控制流程(continue / stopReason)
|
||||
|
||||
```json
|
||||
{
|
||||
"continue": false,
|
||||
"stopReason": "构建失败,停止执行"
|
||||
}
|
||||
{ "continue": false, "stopReason": "构建失败,停止执行" }
|
||||
```
|
||||
|
||||
**设计洞察**:四种能力从"被动观察"到"主动干预"递进。最简单的 Hook 只是记录日志,最强大的 Hook 可以阻止操作、修改输入、控制流程。
|
||||
|
||||
## 六种 Hook 类型
|
||||
|
||||
| 类型 | 执行方式 | 适用场景 |
|
||||
|------|---------|---------|
|
||||
| `command` | Shell 命令 | 通用脚本、CI 检查 |
|
||||
| `prompt` | 注入到 AI 上下文 | 代码规范提醒 |
|
||||
| `agent` | 启动子 Agent | 复杂分析任务 |
|
||||
| `http` | HTTP 请求 | 远程服务、Webhook |
|
||||
| `callback` | 内部 JS 函数 | 系统内置 Hook |
|
||||
| `function` | 运行时函数 | Agent/Skill 内部使用 |
|
||||
|
||||
### 异步 Hook
|
||||
|
||||
Hook 进程的 stdout 第一行如果是 `{"async":true}`,系统将其转为后台任务。异步 Hook 完成后通过通知机制汇报结果。
|
||||
|
||||
**设计考量**:有些 Hook 需要长时间运行(如"等待 CI 结果"),不应该阻塞 Agent 的执行。异步 Hook 让这些操作在后台运行,完成后再通知 Agent。
|
||||
|
||||
## 匹配机制
|
||||
|
||||
### Matcher 模式
|
||||
|
||||
```
|
||||
"Write" → 精确匹配
|
||||
"Write|Edit" → 多值匹配
|
||||
"^Bash(git.*)" → 正则匹配
|
||||
"*" 或 "" → 通配所有
|
||||
```
|
||||
|
||||
### if 条件
|
||||
|
||||
Hook 可以指定 `if` 条件,只在特定输入时触发:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "check-branch.sh",
|
||||
"if": "Bash(git push*)"
|
||||
}
|
||||
```
|
||||
|
||||
条件使用与权限规则相同的语法——工具名 + 参数模式。Bash 工具还会进行 AST 级别的命令解析。
|
||||
|
||||
### 多来源合并
|
||||
|
||||
Hook 从多个来源汇聚:
|
||||
- settings.json 中的配置(user/project/local)
|
||||
- SDK 注册的回调
|
||||
- Agent/Skill 的 frontmatter
|
||||
- 运行时动态注册
|
||||
|
||||
同一命令可能在不同层级重复出现。系统按复合键去重,保留最后合并的层级。
|
||||
|
||||
## 安全防护
|
||||
|
||||
### 工作区信任
|
||||
|
||||
**所有 Hook 都要求工作区信任**。这是纵深防御——防止恶意仓库的 `.claude/settings.json` 在未信任的情况下执行任意命令。
|
||||
|
||||
**设计哲学**:Hook 有执行任意命令的能力,这个能力不应该被不可信的来源获取。项目级配置是团队共享的,任何人都可以修改——信任检查确保只有用户明确信任的项目才能运行 Hook。
|
||||
|
||||
### 超时控制
|
||||
|
||||
Hook 有默认 10 分钟的超时限制,可以通过配置调整。超时后 Hook 进程被终止,Agent 继续执行。
|
||||
`continue: false` 设置 `preventContinuation = true`,阻止 Agent 继续执行后续操作。
|
||||
|
||||
## Session Hook 的生命周期
|
||||
|
||||
Agent 和 Skill 可以注册 session Hook,绑定到特定的 session ID。Agent 结束时自动清理——Agent A 的 Hook 不会泄漏到 Agent B 的执行中。
|
||||
Agent 和 Skill 的前置 Hook 通过 `registerFrontmatterHooks()` 注册(调用位置:`packages/builtin-tools/src/tools/AgentTool/runAgent.ts`;定义位置:`src/utils/hooks/registerFrontmatterHooks.ts`),绑定到 agent 的 session ID。Agent 结束时通过 `clearSessionHooks()`(定义位置:`src/utils/hooks/sessionHooks.ts`)清理。
|
||||
|
||||
**设计考量**:如果不自动清理,Agent A 的 PreToolUse Hook 可能意外拦截 Agent B 的工具调用,导致难以调试的问题。
|
||||
```typescript
|
||||
// runAgent.ts — 注册 agent 的前置 Hook
|
||||
registerFrontmatterHooks(rootSetAppState, agentId, agentDefinition.hooks, ...)
|
||||
|
||||
## 接下来
|
||||
// runAgent.ts — finally 块清理
|
||||
clearSessionHooks(rootSetAppState, agentId)
|
||||
```
|
||||
|
||||
- **Skills** — 理解基于 Hook 的技能系统
|
||||
- **MCP 配置** — 理解外部工具的注册
|
||||
- **权限模型** — 理解 PreToolUse Hook 与权限系统的协作
|
||||
这确保 Agent A 的 Hook 不会泄漏到 Agent B 的执行中。
|
||||
|
||||
@@ -1,84 +1,346 @@
|
||||
---
|
||||
title: "MCP 配置"
|
||||
description: "MCP 服务器从多个来源汇聚配置。理解多来源合并、企业排他模式、项目配置审批和保留名称机制。"
|
||||
keywords: ["MCP", "配置", "settings.json", ".mcp.json", "企业策略"]
|
||||
title: "MCP 配置 - 多来源合并、作用域与策略管控"
|
||||
description: "详细说明 Claude Code MCP 配置的来源层次、合并优先级、传输类型、企业策略管控、插件集成和保留名称机制。"
|
||||
keywords: ["MCP", "配置", "settings.json", ".mcp.json", "企业策略", "插件"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
## 配置来源与作用域
|
||||
|
||||
MCP(Model Context Protocol)让 Claude Code 可以使用外部工具——数据库查询、浏览器控制、API 调用等。但 MCP 配置来自多个来源:用户全局、项目级、插件、企业策略。如何合并?谁优先?
|
||||
Claude Code 的 MCP 配置来自多个来源,每个来源对应一个 `scope`(作用域)。配置按优先级合并,高优先级来源的同名配置覆盖低优先级。
|
||||
|
||||
## 配置来源与合并优先级
|
||||
### 来源列表
|
||||
|
||||
配置按优先级从低到高合并,高优先级覆盖低优先级:
|
||||
| 来源 | Scope | 文件/接口 | 说明 |
|
||||
|------|-------|----------|------|
|
||||
| 企业管控 | `enterprise` | 系统管理路径 `managed-mcp.json` | **排他模式**:存在时忽略所有其他来源 |
|
||||
| 本地项目 | `local` | `<project>/.claude/settings.local.json` | 项目级私有配置(不提交到 VCS) |
|
||||
| 项目配置 | `project` | `<project>/.mcp.json` | 项目级共享配置(可提交到 VCS) |
|
||||
| 用户全局 | `user` | `~/.claude/settings.json` | 用户级配置,所有项目共享 |
|
||||
| 插件 | `dynamic` | 插件 manifest 中 `.mcp.json` / `.mcpb` | 插件提供的 MCP 服务器 |
|
||||
| claude.ai | `claudeai` | 通过 API 获取 | claude.ai 网页端配置的连接器 |
|
||||
| 内置动态 | `dynamic` | 代码中注册 | Computer Use / Chrome 等内置服务器 |
|
||||
| IDE SDK | `sdk` | IDE 传入 | VS Code / JetBrains 嵌入模式 |
|
||||
|
||||
### 合并优先级(从低到高)
|
||||
|
||||
```
|
||||
claude.ai 连接器(最低) → 插件 → 用户全局 → 项目配置 → 本地项目 → 内置动态(最高)
|
||||
claude.ai 连接器 ← 最低优先级
|
||||
↓ 去重
|
||||
插件服务器
|
||||
↓ 去重
|
||||
用户全局配置
|
||||
↓
|
||||
项目配置(.mcp.json) ← 需要用户审批
|
||||
↓
|
||||
本地项目配置
|
||||
↓
|
||||
动态配置(内置 MCP) ← 最高优先级
|
||||
```
|
||||
|
||||
| 来源 | Scope | 说明 |
|
||||
|------|-------|------|
|
||||
| claude.ai 连接器 | `claudeai` | 网页端配置的远程连接器 |
|
||||
| 插件 | `dynamic` | 插件 manifest 中声明 |
|
||||
| 用户全局 | `user` | `~/.claude/settings.json` |
|
||||
| 项目配置 | `project` | `.mcp.json`(需审批) |
|
||||
| 本地项目 | `local` | `settings.local.json`(不提交 VCS) |
|
||||
| 内置动态 | `dynamic` | Computer Use 等内置服务器 |
|
||||
`Object.assign({}, dedupedPluginServers, userServers, approvedProjectServers, localServers)` 实现合并——后出现的同名键覆盖前者。
|
||||
|
||||
## 企业排他模式
|
||||
## 企业管控模式
|
||||
|
||||
当企业管理员部署 `managed-mcp.json` 时,进入**排他模式**:只使用企业配置,忽略所有用户、项目、插件和 claude.ai 配置。
|
||||
当 `managed-mcp.json` 文件存在时,进入 **排他模式**:
|
||||
|
||||
**设计考量**:企业环境需要严格控制 AI 可以访问哪些外部工具。排他模式确保用户不能绕过企业策略添加自己的 MCP 服务器。
|
||||
```typescript
|
||||
// config.ts:1084
|
||||
if (doesEnterpriseMcpConfigExist()) {
|
||||
// 只返回企业配置,忽略所有用户/项目/插件/claude.ai 配置
|
||||
return { servers: filtered, errors: [] }
|
||||
}
|
||||
```
|
||||
|
||||
特性:
|
||||
- 路径由系统管理决定(`getManagedFilePath()` + `managed-mcp.json`)
|
||||
- 覆盖所有用户级、项目级、插件和 claude.ai 配置
|
||||
- 仍然应用策略过滤(allowlist/denylist)
|
||||
- 无法通过 CLI 添加新服务器(`addMcpConfig` 会拒绝)
|
||||
|
||||
## 传输类型与配置 Schema
|
||||
|
||||
### stdio(默认)
|
||||
|
||||
启动子进程,通过 stdin/stdout JSON-RPC 通信。
|
||||
|
||||
```json
|
||||
{
|
||||
"my-server": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@my-org/mcp-server"],
|
||||
"env": { "API_KEY": "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`type` 字段可省略(默认为 `stdio`)。环境变量通过 `env` 传递给子进程,会与当前进程环境合并。
|
||||
|
||||
**Windows 注意**:使用 `npx` 需要包装为 `cmd /c npx`,否则会报错。
|
||||
|
||||
### SSE(Server-Sent Events)
|
||||
|
||||
通过 HTTP SSE 连接远程 MCP 服务器。
|
||||
|
||||
```json
|
||||
{
|
||||
"my-remote": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"headers": { "Authorization": "Bearer ..." },
|
||||
"oauth": {
|
||||
"clientId": "...",
|
||||
"authServerMetadataUrl": "https://auth.example.com/.well-known/oauth-authorization-server"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
支持 OAuth 认证流程。认证失败时进入 `needs-auth` 状态,15 分钟 TTL 缓存避免重复提示。
|
||||
|
||||
### HTTP(Streamable HTTP)
|
||||
|
||||
HTTP 流式传输。
|
||||
|
||||
```json
|
||||
{
|
||||
"my-http": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.example.com/mcp",
|
||||
"headers": { "X-API-Key": "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
支持与 SSE 相同的 OAuth 配置。
|
||||
|
||||
### WebSocket
|
||||
|
||||
```json
|
||||
{
|
||||
"my-ws": {
|
||||
"type": "ws",
|
||||
"url": "wss://mcp.example.com/ws"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### IDE 专用类型(内部)
|
||||
|
||||
`sse-ide` 和 `ws-ide` 是 IDE 扩展专用类型,不由用户直接配置。
|
||||
|
||||
- `sse-ide`:使用 lockfile token 认证
|
||||
- `ws-ide`:使用 `X-Claude-Code-Ide-Authorization` header
|
||||
|
||||
### SDK 类型(内部)
|
||||
|
||||
`type: "sdk"` 由 IDE 嵌入模式传入,不经过保留名称检查和企业管控排他限制。
|
||||
|
||||
### claude.ai 代理类型(内部)
|
||||
|
||||
`type: "claudeai-proxy"` 由 claude.ai 网页端配置的连接器使用,通过 OAuth bearer token 认证并支持 401 重试。
|
||||
|
||||
## 配置操作
|
||||
|
||||
### 添加 MCP 服务器
|
||||
|
||||
通过 CLI 命令 `claude mcp add` 或 API 调用 `addMcpConfig()`:
|
||||
|
||||
```bash
|
||||
# 添加到用户配置
|
||||
claude mcp add my-server -s user -- npx @my-org/mcp-server
|
||||
|
||||
# 添加到项目配置
|
||||
claude mcp add my-server -s project -- npx @my-org/mcp-server
|
||||
|
||||
# 添加 HTTP 类型
|
||||
claude mcp add my-remote -s user -t http -u https://mcp.example.com/mcp
|
||||
```
|
||||
|
||||
添加时的验证流程:
|
||||
|
||||
1. **名称校验**:只允许字母、数字、连字符和下划线
|
||||
2. **保留名检查**:`claude-in-chrome` 和 `computer-use` 被保留
|
||||
3. **企业管控检查**:企业模式下拒绝添加
|
||||
4. **Schema 验证**:Zod 校验配置格式
|
||||
5. **策略检查**:denylist 拒绝、allowlist 验证
|
||||
|
||||
### 移除 MCP 服务器
|
||||
|
||||
```bash
|
||||
claude mcp remove my-server -s user
|
||||
```
|
||||
|
||||
### 列出 MCP 服务器
|
||||
|
||||
```bash
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
## 项目配置审批
|
||||
|
||||
`.mcp.json` 是项目级共享配置(可提交到 git),但需要用户显式审批才能生效。
|
||||
`.mcp.json` 中的项目配置需要用户显式审批才能生效:
|
||||
|
||||
**为什么需要审批**?项目配置可能由任何人修改(包括恶意贡献者)。审批机制确保用户知情并同意项目提供的 MCP 服务器连接到他们的环境。
|
||||
```typescript
|
||||
// config.ts:1166
|
||||
const approvedProjectServers: Record<string, ScopedMcpServerConfig> = {}
|
||||
for (const [name, config] of Object.entries(projectServers)) {
|
||||
if (getProjectMcpServerStatus(name) === 'approved') {
|
||||
approvedProjectServers[name] = config
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
审批状态持久化在本地配置中,不需要每次重新审批。
|
||||
首次打开项目时,Claude Code 会提示用户审批 `.mcp.json` 中的每个服务器。审批状态持久化在本地配置中。
|
||||
|
||||
## 传输类型
|
||||
## 插件 MCP 集成
|
||||
|
||||
MCP 服务器通过不同的传输方式连接:
|
||||
插件通过 manifest 中的 `.mcp.json` 或 `.mcpb` 文件声明 MCP 服务器:
|
||||
|
||||
| 类型 | 适用场景 | 配置方式 |
|
||||
|------|---------|---------|
|
||||
| **stdio** | 本地工具(启动子进程) | `command` + `args` |
|
||||
| **SSE** | 远程 Server-Sent Events | `url` + 可选 `headers` |
|
||||
| **HTTP** | HTTP 流式传输 | `url` + 可选 `headers` |
|
||||
| **WebSocket** | 双向实时通信 | `wss://` URL |
|
||||
```typescript
|
||||
// 插件 MCP 加载流程
|
||||
const pluginResult = await loadAllPluginsCacheOnly()
|
||||
const pluginServerResults = await Promise.all(
|
||||
pluginResult.enabled.map(plugin => getPluginMcpServers(plugin, mcpErrors))
|
||||
)
|
||||
```
|
||||
|
||||
stdio 类型最常见——它启动一个本地子进程,通过 stdin/stdout 通信。远程类型(SSE/HTTP/WS)用于连接远程服务。
|
||||
### 插件命名空间
|
||||
|
||||
### 认证
|
||||
插件 MCP 服务器名格式为 `plugin:<pluginName>:<serverName>`,不会与手动配置的名称冲突。
|
||||
|
||||
远程 MCP 服务器支持 OAuth 认证。认证失败时进入 `needs-auth` 状态,15 分钟内不重复提示。
|
||||
### 去重机制
|
||||
|
||||
## 插件集成
|
||||
插件服务器通过内容签名去重(`dedupPluginMcpServers`):
|
||||
|
||||
插件通过 manifest 声明 MCP 服务器,命名空间为 `plugin:<pluginName>:<serverName>`,不会与手动配置冲突。
|
||||
- **stdio 类型**:签名 = `stdio:` + JSON.stringify([command, ...args])
|
||||
- **URL 类型**:签名 = `url:` + 原始 URL(unwrap CCR proxy URL)
|
||||
- **sdk 类型**:签名为 null,不去重
|
||||
|
||||
插件服务器通过内容签名去重:
|
||||
- stdio 类型:基于 command + args
|
||||
- URL 类型:基于 URL
|
||||
- 手动配置优先于插件配置
|
||||
去重规则:
|
||||
1. 手动配置优先于插件配置
|
||||
2. 先加载的插件优先于后加载的
|
||||
3. 被抑制的插件服务器在 `/plugin` UI 中显示提示
|
||||
|
||||
### claude.ai 连接器去重
|
||||
|
||||
claude.ai 连接器使用相同的内容签名机制去重(`dedupClaudeAiMcpServers`):
|
||||
- 仅启用的手动配置参与去重(禁用的手动配置不应抑制连接器)
|
||||
- 连接器名格式为 `claude.ai <DisplayName>`
|
||||
|
||||
## 策略管控
|
||||
|
||||
企业策略通过 allowlist 和 denylist 控制可用的 MCP 服务器。策略检查不仅匹配服务器名称,还匹配 command/args(stdio)和 URL 模式(远程)。
|
||||
### Allowlist / Denylist
|
||||
|
||||
企业策略通过 allowlist 和 denylist 控制可用的 MCP 服务器:
|
||||
|
||||
```typescript
|
||||
// config.ts:1243 - 最终策略过滤
|
||||
for (const [name, serverConfig] of Object.entries(configs)) {
|
||||
if (!isMcpServerAllowedByPolicy(name, serverConfig)) {
|
||||
continue // 跳过策略禁止的服务器
|
||||
}
|
||||
filtered[name] = serverConfig
|
||||
}
|
||||
```
|
||||
|
||||
策略检查考虑:
|
||||
- 服务器名称匹配
|
||||
- stdio 类型的 command + args 匹配
|
||||
- URL 类型的 URL 模式匹配(支持通配符)
|
||||
|
||||
### 插件专用模式
|
||||
|
||||
`isRestrictedToPluginOnly('mcp')` 启用时,只允许插件提供的 MCP 服务器——用户/项目级配置被忽略。
|
||||
|
||||
## 环境变量展开
|
||||
|
||||
MCP 配置中的环境变量支持 `$VAR` 和 `${VAR}` 语法展开:
|
||||
|
||||
```json
|
||||
{
|
||||
"my-server": {
|
||||
"command": "npx",
|
||||
"args": ["@my-org/mcp-server"],
|
||||
"env": {
|
||||
"API_KEY": "$MY_API_KEY",
|
||||
"DB_URL": "${DATABASE_URL}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
展开时缺失的变量会生成警告信息,但不阻止配置加载。
|
||||
|
||||
## 内置 MCP 动态注册
|
||||
|
||||
内置 MCP 服务器在 `main.tsx` 启动流程中动态注入配置:
|
||||
|
||||
### Computer Use MCP
|
||||
|
||||
```typescript
|
||||
// src/utils/computerUse/setup.ts
|
||||
export function setupComputerUseMCP(): {
|
||||
mcpConfig: Record<string, ScopedMcpServerConfig>
|
||||
allowedTools: string[]
|
||||
} {
|
||||
return {
|
||||
mcpConfig: {
|
||||
"computer-use": {
|
||||
type: "stdio",
|
||||
command: process.execPath,
|
||||
args: ["--computer-use-mcp"],
|
||||
scope: "dynamic",
|
||||
}
|
||||
},
|
||||
allowedTools: ["mcp__computer-use__screenshot", ...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
启用条件:
|
||||
- Feature flag `CHICAGO_MCP` 开启
|
||||
- `getPlatform() !== "unknown"`(macOS/Windows/Linux)
|
||||
- 非非交互式会话
|
||||
- GrowthBook gate `getChicagoEnabled()` 返回 true
|
||||
|
||||
### Claude in Chrome MCP
|
||||
|
||||
```typescript
|
||||
// 类似 Computer Use,在 main.tsx 中注册
|
||||
const { mcpConfig, allowedTools, systemPrompt } = setupClaudeInChrome()
|
||||
dynamicMcpConfig = { ...dynamicMcpConfig, ...mcpConfig }
|
||||
```
|
||||
|
||||
启用条件:
|
||||
- `--chrome` 参数或 `claudeInChromeDefaultEnabled` 配置
|
||||
- Chrome 扩展已安装
|
||||
|
||||
### VSCode SDK MCP
|
||||
|
||||
IDE 嵌入模式通过初始化消息传入 `type:'sdk'` 的配置,由 `setupVscodeSdkMcp()` 设置双向通知。
|
||||
|
||||
## 保留名称
|
||||
|
||||
以下名称被保留,用户无法手动配置:
|
||||
- `claude-in-chrome` — Chrome 浏览器控制
|
||||
- `computer-use` — 桌面自动化
|
||||
以下 MCP 服务器名称被保留,用户无法手动配置同名服务器:
|
||||
|
||||
这防止用户意外覆盖内置服务器的配置。
|
||||
| 名称 | 用途 | 检查条件 |
|
||||
|------|------|---------|
|
||||
| `claude-in-chrome` | Chrome 浏览器控制 | 始终检查 |
|
||||
| `computer-use` | 桌面自动化 | `CHICAGO_MCP` feature flag 开启时检查 |
|
||||
| `claude-vscode` | VSCode IDE 集成 | 由 SDK 传入,不经过名称检查 |
|
||||
|
||||
## 接下来
|
||||
保留名检查在两个位置:
|
||||
1. `addMcpConfig()`(`config.ts:636-648`)— 运行时拒绝
|
||||
2. `main.tsx` 启动检查(`main.tsx:2351-2368`)— 启动时退出
|
||||
|
||||
- **MCP 协议** — 理解连接管理、工具发现和执行链路
|
||||
- **Hooks** — 理解 MCP 生命周期中的 Hook 集成
|
||||
- **自定义 Agent** — 理解 Agent 中引用 MCP 服务器
|
||||
## 关键源文件索引
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/services/mcp/config.ts` | 配置管理核心:合并、去重、策略、添加/删除 |
|
||||
| `src/services/mcp/types.ts` | Zod Schema 定义、类型声明 |
|
||||
| `src/services/mcp/client.ts` | 连接管理、传输层选择 |
|
||||
| `src/utils/plugins/mcpPluginIntegration.ts` | 插件 MCP 配置加载 |
|
||||
| `src/utils/computerUse/setup.ts` | Computer Use 动态注册 |
|
||||
| `src/utils/claudeInChrome/common.ts` | Chrome MCP 保留名与工具名 |
|
||||
| `src/services/mcp/vscodeSdkMcp.ts` | VSCode SDK 双向通知 |
|
||||
|
||||
@@ -1,109 +1,407 @@
|
||||
---
|
||||
title: "MCP 协议"
|
||||
description: "从配置到可用工具:MCP 连接管理、内置 vs 外部两种模式、工具发现和执行链路的设计。"
|
||||
keywords: ["MCP", "Model Context Protocol", "工具扩展", "MCP 客户端", "工具发现"]
|
||||
title: "MCP 协议 - 连接管理、工具发现与执行链路"
|
||||
description: "从源码角度解析 Claude Code 的 MCP 集成:内置 MCP 与外部 MCP 的区别、7 种传输层实现、connectToServer 的 memoize 缓存、工具发现的 LRU 策略、认证状态机、以及 MCP 工具如何进入权限检查链路。"
|
||||
keywords: ["MCP", "Model Context Protocol", "工具扩展", "MCP 客户端", "工具发现", "内置 MCP", "外部 MCP"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:从源码角度揭示 MCP 客户端的两种运行模式(内置/外部)、连接管理、工具发现协议和执行链路 */}
|
||||
|
||||
MCP 让 Claude Code 使用外部工具。但连接外部服务有延迟、可能失败、工具列表可能变化。如何管理这些不确定性?
|
||||
|
||||
## 架构总览
|
||||
## 架构总览:从配置到可用工具
|
||||
|
||||
```
|
||||
配置(多来源合并) → 连接管理(缓存) → 工具发现(MCP 协议) → 工具执行
|
||||
配置层(多来源合并)
|
||||
├── settings.json: { mcpServers: { "my-db": { command: "npx", args: [...] } } } ← 外部
|
||||
├── .mcp.json: 项目级 MCP 配置 ← 外部
|
||||
├── 插件 manifest (.mcp.json / .mcpb) ← 外部(插件)
|
||||
├── claude.ai connectors ← 外部(远程)
|
||||
├── enterprise managed-mcp.json ← 外部(企业管控)
|
||||
├── setupComputerUseMCP() / setupClaudeInChrome() ← 内置(动态注册)
|
||||
└── SDK 传入 (type:'sdk') ← 内置(IDE 嵌入)
|
||||
↓
|
||||
getAllMcpConfigs() ← enterprise 独占 或 合并 user/project/local + plugin + claude.ai
|
||||
↓
|
||||
useManageMCPConnections() ← React Hook 管理连接生命周期
|
||||
↓
|
||||
connectToServer(name, config) ← memoize 缓存(lodash memoize)
|
||||
├── 判断:内置 MCP → InProcessTransport(同进程)
|
||||
├── 判断:外部 stdio → StdioClientTransport(子进程)
|
||||
├── 判断:远程 SSE/HTTP/WS → 网络传输
|
||||
└── 返回 MCPServerConnection ← { connected | failed | needs-auth | pending | disabled }
|
||||
↓
|
||||
fetchToolsForClient(client) ← LRU(20) 缓存
|
||||
├── client.request({ method: 'tools/list' })
|
||||
└── 每个工具包装为 MCPTool ← 统一 Tool 接口
|
||||
↓
|
||||
assembleToolPool() ← 合并内置工具 + MCP 工具
|
||||
↓
|
||||
工具名格式: mcp__<serverName>__<toolName> ← buildMcpToolName()
|
||||
```
|
||||
|
||||
## 两种 MCP 模式
|
||||
## 两种 MCP 模式:内置 vs 外部
|
||||
|
||||
Claude Code 的 MCP 实现区分 **内置 MCP 服务器** 和 **外部 MCP 服务器**。两者使用相同的客户端协议和工具发现机制,但在连接方式、生命周期管理和配置来源上完全不同。
|
||||
|
||||
### 内置 MCP 服务器
|
||||
|
||||
Computer Use、Chrome 控制等内置功能通过 MCP 协议暴露,但运行在**同进程内**——不启动子进程,无网络开销,无 IPC 序列化。
|
||||
内置 MCP 服务器由 Claude Code 自身提供,无需用户手动配置。它们在启动时自动注册为 `dynamic` scope 的配置,并在同进程内运行。
|
||||
|
||||
**设计洞察**:内置服务器使用与外部服务器完全相同的 MCP 协议(`tools/list`、`tools/call`),但通过 `InProcessTransport` 在进程内通信。这意味着所有工具发现、权限检查、执行逻辑都是同一套代码——内置和外部工具的唯一区别是传输方式。
|
||||
| 服务器 | 名称 | 包路径 | Feature Flag | 启用方式 |
|
||||
|--------|------|--------|-------------|---------|
|
||||
| Computer Use | `computer-use` | `@ant/computer-use-mcp` | `CHICAGO_MCP` | GrowthBook gate + macOS + interactive |
|
||||
| Claude in Chrome | `claude-in-chrome` | `@ant/claude-for-chrome-mcp` | — | `--chrome` 参数或 `claudeInChromeDefaultEnabled` 配置 |
|
||||
| VSCode SDK | `claude-vscode` | — | — | IDE 嵌入模式 (type:`sdk`) |
|
||||
|
||||
#### InProcessTransport:零开销同进程通信
|
||||
|
||||
内置服务器通过 `InProcessTransport`(`src/services/mcp/InProcessTransport.ts`)运行,**不启动子进程**:
|
||||
|
||||
```typescript
|
||||
// 创建一对 linked transport —— 消息在两端之间直接传递
|
||||
const [clientTransport, serverTransport] = createLinkedTransportPair()
|
||||
|
||||
// server 端连接到 serverTransport
|
||||
inProcessServer = createComputerUseMcpServerForCli()
|
||||
await inProcessServer.connect(serverTransport)
|
||||
|
||||
// client 端使用 clientTransport(与外部 MCP 的 Client 相同接口)
|
||||
transport = clientTransport
|
||||
```
|
||||
|
||||
`InProcessTransport` 的核心设计:
|
||||
- `send()` 通过 `queueMicrotask()` 异步投递消息到对端,避免同步请求/响应的栈深度问题
|
||||
- `close()` 双向关闭,任一端关闭都会触发两端的 `onclose` 回调
|
||||
- 无网络开销、无 IPC 序列化、无进程启动时间
|
||||
|
||||
#### 动态注册流程
|
||||
|
||||
内置服务器在 `main.tsx` 的启动流程中注册,注入 `dynamicMcpConfig`:
|
||||
|
||||
```typescript
|
||||
// main.tsx: Computer Use MCP 动态注册
|
||||
if (feature("CHICAGO_MCP") && getPlatform() !== "unknown" && !getIsNonInteractiveSession()) {
|
||||
const { getChicagoEnabled } = await import("src/utils/computerUse/gates.js")
|
||||
if (getChicagoEnabled()) {
|
||||
const { setupComputerUseMCP } = await import("src/utils/computerUse/setup.js")
|
||||
const { mcpConfig, allowedTools } = setupComputerUseMCP()
|
||||
dynamicMcpConfig = { ...dynamicMcpConfig, ...mcpConfig }
|
||||
allowedTools.push(...cuTools)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`setupComputerUseMCP()` 返回的配置(`src/utils/computerUse/setup.ts`):
|
||||
|
||||
```typescript
|
||||
{
|
||||
"computer-use": {
|
||||
type: "stdio", // 类型标记为 stdio(但 client.ts 会拦截为 InProcessTransport)
|
||||
command: process.execPath,
|
||||
args: ["--computer-use-mcp"],
|
||||
scope: "dynamic", // 动态作用域,不持久化
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 连接时拦截
|
||||
|
||||
`connectToServer()` 在 `client.ts:906-944` 中根据服务器名拦截内置服务器:
|
||||
|
||||
```typescript
|
||||
// Chrome MCP — 在 process 内运行,避免 ~325MB 子进程
|
||||
if (isClaudeInChromeMCPServer(name)) {
|
||||
const { createChromeContext } = await import('../../utils/claudeInChrome/mcpServer.js')
|
||||
const { createClaudeForChromeMcpServer } = await import('@ant/claude-for-chrome-mcp')
|
||||
const { createLinkedTransportPair } = await import('./InProcessTransport.js')
|
||||
const context = createChromeContext(config.env)
|
||||
inProcessServer = createClaudeForChromeMcpServer(context)
|
||||
const [clientTransport, serverTransport] = createLinkedTransportPair()
|
||||
await inProcessServer.connect(serverTransport)
|
||||
transport = clientTransport
|
||||
}
|
||||
|
||||
// Computer Use MCP — 同理
|
||||
if (feature('CHICAGO_MCP') && isComputerUseMCPServer(name)) {
|
||||
const { createComputerUseMcpServerForCli } = await import('../../utils/computerUse/mcpServer.js')
|
||||
const { createLinkedTransportPair } = await import('./InProcessTransport.js')
|
||||
inProcessServer = await createComputerUseMcpServerForCli()
|
||||
const [clientTransport, serverTransport] = createLinkedTransportPair()
|
||||
await inProcessServer.connect(serverTransport)
|
||||
transport = clientTransport
|
||||
}
|
||||
```
|
||||
|
||||
#### 保留名称保护
|
||||
|
||||
内置服务器的名称被保留,用户无法手动添加同名配置(`config.ts:636-648`):
|
||||
|
||||
```typescript
|
||||
// 添加 MCP 配置时检查保留名
|
||||
if (isClaudeInChromeMCPServer(name)) {
|
||||
throw new Error(`Cannot add MCP server "${name}": this name is reserved.`)
|
||||
}
|
||||
if (feature('CHICAGO_MCP') && isComputerUseMCPServer(name)) {
|
||||
throw new Error(`Cannot add MCP server "${name}": this name is reserved.`)
|
||||
}
|
||||
```
|
||||
|
||||
启动时也有全局检查(`main.tsx:2351-2368`):如果用户配置中包含保留名(非 `type:'sdk'`),直接 `process.exit(1)`。
|
||||
|
||||
#### VSCode SDK MCP
|
||||
|
||||
VSCode SDK MCP 是特殊的内置模式。IDE(如 VS Code、JetBrains)通过嵌入方式启动 Claude Code,并传入 `type:'sdk'` 的 MCP 配置。这类配置:
|
||||
- 不经过保留名称检查(IDE 可以使用任意名称)
|
||||
- 不参与 enterprise MCP 的排他控制
|
||||
- 通过 VSCode SDK transport 连接
|
||||
- 支持双向通知(如 `file_updated`、`experiment_gates`)
|
||||
|
||||
```typescript
|
||||
// src/services/mcp/vscodeSdkMcp.ts
|
||||
export function setupVscodeSdkMcp(sdkClients: MCPServerConnection[]): void {
|
||||
const client = sdkClients.find(client => client.name === 'claude-vscode')
|
||||
if (client && client.type === 'connected') {
|
||||
// 注册 log_event 通知处理器
|
||||
client.client.setNotificationHandler(LogEventNotificationSchema(), ...)
|
||||
// 发送实验门控到 VSCode
|
||||
client.client.notification({ method: 'experiment_gates', params: { gates } })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 外部 MCP 服务器
|
||||
|
||||
用户配置的外部工具,通过子进程(stdio)或网络连接(SSE/HTTP/WS)运行。
|
||||
外部 MCP 服务器由用户在配置文件中声明,通过子进程或网络连接运行。
|
||||
|
||||
| 维度 | 内置 | 外部 |
|
||||
|------|------|------|
|
||||
| 进程模型 | 同进程 | 子进程或网络 |
|
||||
| 启动开销 | 零 | 子进程启动或网络握手 |
|
||||
| 权限 | 自动授权 | 需要用户确认 |
|
||||
| 配置来源 | 动态注册 | settings.json / .mcp.json |
|
||||
| 名称保护 | 保留名,不可覆盖 | 自由命名 |
|
||||
#### 配置来源
|
||||
|
||||
## 连接管理
|
||||
| 来源 | Scope | 文件位置 | 优先级 |
|
||||
|------|-------|---------|--------|
|
||||
| 项目配置 | `project` | `<project>/.mcp.json` | 最高(同名覆盖) |
|
||||
| 本地配置 | `local` | `<project>/.claude/settings.local.json` | 高 |
|
||||
| 用户配置 | `user` | `~/.claude/settings.json` | 中 |
|
||||
| 插件 | `dynamic` | 插件 manifest 中 `.mcp.json` | 中 |
|
||||
| claude.ai | `claudeai` | 通过 API 获取 | 低 |
|
||||
| 企业管控 | `enterprise` | 系统管理路径 `managed-mcp.json` | 排他(存在时覆盖全部) |
|
||||
|
||||
### 缓存机制
|
||||
#### 配置示例
|
||||
|
||||
连接使用 memoize 缓存——相同的配置不会重复建立连接。缓存 key 包含服务器名和配置内容,配置变化时自动失效。
|
||||
```json
|
||||
// settings.json / .mcp.json 中的 MCP 配置
|
||||
{
|
||||
"mcpServers": {
|
||||
// stdio 类型 — 启动子进程
|
||||
"my-database": {
|
||||
"command": "npx",
|
||||
"args": ["@my-org/db-mcp-server"],
|
||||
"env": { "DB_URL": "postgres://..." }
|
||||
},
|
||||
|
||||
### 重连机制
|
||||
// HTTP 流类型 — 远程服务器
|
||||
"remote-api": {
|
||||
"type": "http",
|
||||
"url": "https://api.example.com/mcp"
|
||||
},
|
||||
|
||||
远程连接有连续错误计数器。遇到网络错误(ECONNRESET、ETIMEDOUT 等)连续 3 次后,主动关闭连接触发重连。
|
||||
// SSE 类型 — Server-Sent Events
|
||||
"realtime-feed": {
|
||||
"type": "sse",
|
||||
"url": "https://feed.example.com/sse"
|
||||
},
|
||||
|
||||
**设计考量**:网络连接是脆弱的——临时故障不应该永久禁用一个 MCP 服务器。自动重连确保服务恢复后工具能继续使用。
|
||||
// WebSocket 类型
|
||||
"ws-service": {
|
||||
"type": "ws",
|
||||
"url": "wss://ws.example.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 清理策略
|
||||
#### 配置合并与去重
|
||||
|
||||
stdio 类型的子进程清理使用信号升级策略:
|
||||
`getAllMcpConfigs()`(`config.ts`)按优先级合并多个来源的配置:
|
||||
|
||||
1. 企业管控配置存在时,**独占返回**(忽略所有其他来源)
|
||||
2. 否则合并:user → project → local → plugin → claude.ai
|
||||
3. 插件与手动配置去重:通过 `getMcpServerSignature()` 生成内容签名(基于 command/args/url),插件配置被同名手动配置抑制
|
||||
4. `addScopeToServers()` 为每个配置项标注来源 scope
|
||||
|
||||
## 7 种传输层实现
|
||||
|
||||
`connectToServer()`(`client.ts:596-1643`)根据 `config.type` 分发到不同的 Transport 实现:
|
||||
|
||||
| 传输类型 | Transport 类 | 适用场景 | 认证方式 |
|
||||
|----------|-------------|---------|---------|
|
||||
| `stdio`(默认) | `StdioClientTransport` | 外部本地子进程 | 无 |
|
||||
| `sse` | `SSEClientTransport` | 远程 SSE 服务 | `ClaudeAuthProvider` + OAuth |
|
||||
| `http` | `StreamableHTTPClientTransport` | HTTP 流 | `ClaudeAuthProvider` + OAuth |
|
||||
| `sse-ide` | `SSEClientTransport` | IDE 集成 | lockfile token |
|
||||
| `ws-ide` | `WebSocketTransport` | IDE WebSocket | `X-Claude-Code-Ide-Authorization` |
|
||||
| `ws` | `WebSocketTransport` | WebSocket 服务 | session ingress token |
|
||||
| `claudeai-proxy` | `StreamableHTTPClientTransport` | claude.ai 代理 | OAuth bearer + 401 重试 |
|
||||
| InProcess(内置) | `InProcessTransport` | Computer Use / Chrome | 无(同进程) |
|
||||
|
||||
### stdio 传输的进程管理
|
||||
|
||||
stdio 类型的 MCP 服务器作为子进程运行,cleanup 时采用 **信号升级策略**(`client.ts:1431-1564`):
|
||||
|
||||
```
|
||||
SIGINT (100ms) → SIGTERM (400ms) → SIGKILL
|
||||
```
|
||||
|
||||
总清理时间上限 600ms。防止 MCP 服务器关闭阻塞 CLI 退出。
|
||||
总清理时间上限 600ms,防止 MCP 服务器关闭阻塞 CLI 退出。
|
||||
|
||||
### 并发控制
|
||||
### 远程传输的认证状态机
|
||||
|
||||
| 类型 | 并发上限 | 原因 |
|
||||
|------|---------|------|
|
||||
| 本地(stdio) | 3 | 每个子进程是重量级资源 |
|
||||
| 远程(HTTP) | 20 | 轻量级 HTTP 请求 |
|
||||
SSE/HTTP 类型使用 `ClaudeAuthProvider` 实现 OAuth 认证流程。认证失败时进入 `needs-auth` 状态,并写入 15 分钟 TTL 的缓存文件(`mcp-needs-auth-cache.json`),避免重复弹出认证提示。
|
||||
|
||||
## 工具发现
|
||||
```
|
||||
连接尝试 → 401 Unauthorized
|
||||
↓
|
||||
handleRemoteAuthFailure()
|
||||
├── logEvent('tengu_mcp_server_needs_auth')
|
||||
├── setMcpAuthCacheEntry(name) ← 写入 15min TTL 缓存
|
||||
└── return { type: 'needs-auth' } ← UI 显示认证提示
|
||||
```
|
||||
|
||||
### 从 MCP 到 Tool 接口
|
||||
## 连接缓存与重连机制
|
||||
|
||||
MCP 服务器通过 `tools/list` 方法暴露工具列表。每个工具被包装为 Claude Code 统一的 Tool 接口,工具名格式为 `mcp__<serverName>__<toolName>`。
|
||||
`connectToServer` 使用 lodash `memoize` 缓存连接对象,缓存 key 为 `${name}-${JSON.stringify(config)}`。
|
||||
|
||||
### 缓存失效触发
|
||||
|
||||
当连接关闭时(`client.onclose`),清除所有相关缓存(`client.ts:1376-1404`):
|
||||
|
||||
```typescript
|
||||
client.onclose = () => {
|
||||
const key = getServerCacheKey(name, serverRef)
|
||||
fetchToolsForClient.cache.delete(name) // 工具缓存
|
||||
fetchResourcesForClient.cache.delete(name) // 资源缓存
|
||||
fetchCommandsForClient.cache.delete(name) // 命令缓存
|
||||
connectToServer.cache.delete(key) // 连接缓存
|
||||
}
|
||||
```
|
||||
|
||||
### 连接降级检测
|
||||
|
||||
远程传输有 **连续错误计数器**(`client.ts:1229`):
|
||||
|
||||
```typescript
|
||||
let consecutiveConnectionErrors = 0
|
||||
const MAX_ERRORS_BEFORE_RECONNECT = 3
|
||||
```
|
||||
|
||||
遇到终端错误(ECONNRESET、ETIMEDOUT、EPIPE 等)连续 3 次后,主动关闭 transport 触发重连。对于 HTTP 传输,还检测 session 过期(404 + JSON-RPC code -32001)。
|
||||
|
||||
### 请求级超时保护
|
||||
|
||||
每个 HTTP 请求使用独立的 `setTimeout` 超时(`wrapFetchWithTimeout`,`client.ts:493`),而非共享 `AbortSignal.timeout()`。原因是 Bun 对 AbortSignal.timeout 的 GC 是惰性的——每个请求约 2.4KB 原生内存,即使请求毫秒级完成也要等 60s 才回收。
|
||||
|
||||
```typescript
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(c => c.abort(...), MCP_REQUEST_TIMEOUT_MS, controller)
|
||||
timer.unref?.() // 不阻止进程退出
|
||||
```
|
||||
|
||||
## 工具发现:从 MCP 到 Tool 接口
|
||||
|
||||
`fetchToolsForClient()`(`client.ts:1744-2000`)使用 `memoizeWithLRU` 缓存(上限 100),将 MCP 工具转换为 Claude Code 的统一 Tool 接口:
|
||||
|
||||
```typescript
|
||||
const fullyQualifiedName = buildMcpToolName(client.name, tool.name)
|
||||
// 结果: "mcp__my-database__query"
|
||||
```
|
||||
|
||||
### 内置 MCP 的工具发现
|
||||
|
||||
内置 MCP 服务器虽然使用 InProcessTransport,但工具发现流程与外部服务器完全一致:
|
||||
|
||||
- **Computer Use**:`createComputerUseMcpServerForCli()` 在 `src/utils/computerUse/mcpServer.ts` 中构建 MCP Server 对象,注册 `ListToolsRequestSchema` handler。工具描述包含平台特定的已安装应用列表(1s 超时枚举)。
|
||||
- **Claude in Chrome**:`createClaudeForChromeMcpServer()` 在 `@ant/claude-for-chrome-mcp` 包中构建 Server,提供 17+ 个浏览器控制工具。
|
||||
- **VSCode SDK**:由 IDE 端提供工具列表,通过 SDK transport 传递。
|
||||
|
||||
### 工具描述截断
|
||||
|
||||
MCP 工具描述上限 2048 字符。OpenAPI 生成的 MCP 服务器曾观察到 15-60KB 的描述文档——截断防止这些巨大描述占满 System Prompt。
|
||||
MCP 工具描述上限 2048 字符(`MAX_MCP_DESCRIPTION_LENGTH`)。OpenAPI 生成的 MCP 服务器曾观察到 15-60KB 的描述文档。
|
||||
|
||||
### 工具能力标注
|
||||
|
||||
MCP 工具通过 `annotations` 声明自身特性:
|
||||
每个 MCP 工具根据 `tool.annotations` 自动标注:
|
||||
|
||||
| 注解 | 含义 |
|
||||
| 注解 | 映射到 | 含义 |
|
||||
|------|--------|------|
|
||||
| `readOnlyHint` | `isReadOnly()` + `isConcurrencySafe()` | 只读,可并行 |
|
||||
| `destructiveHint` | `isDestructive()` | 破坏性操作 |
|
||||
| `openWorldHint` | `isOpenWorld()` | 开放世界(不可枚举) |
|
||||
| `title` | `userFacingName()` | 显示名称 |
|
||||
|
||||
### MCP 工具的权限检查
|
||||
|
||||
MCP 工具默认返回 `{ behavior: 'passthrough' }`(`client.ts:1816-1834`),意味着它们始终进入权限确认流程。工具名使用 `mcp__` 前缀精确匹配权限规则。
|
||||
|
||||
内置 MCP 服务器的工具通过 `allowedTools` 列表自动授权——在 `main.tsx` 启动时加入,绕过普通权限提示。例如 Computer Use 工具的 `request_access` 自行处理会话级审批。
|
||||
|
||||
## MCP 工具的执行链路
|
||||
|
||||
```
|
||||
AI 生成 tool_use: { name: "mcp__my-db__query", input: { sql: "..." } }
|
||||
↓
|
||||
MCPTool.call() ← client.ts:1835
|
||||
├── ensureConnectedClient() ← 确保连接有效(重连)
|
||||
├── callMCPToolWithUrlElicitationRetry() ← 带 Elicitation 重试
|
||||
│ ├── client.request({ method: 'tools/call' })
|
||||
│ ├── 处理图片结果(resize + persist)
|
||||
│ └── 内容截断(mcpContentNeedsTruncation)
|
||||
├── McpSessionExpiredError → 重试一次
|
||||
└── 返回 { data: content, mcpMeta }
|
||||
```
|
||||
|
||||
### Session 过期自动重试
|
||||
|
||||
HTTP 传输的 MCP session 可能过期。检测到 `McpSessionExpiredError` 后自动重试一次(`client.ts:1862`),因为 `ensureConnectedClient()` 已经清除了缓存并建立了新连接。
|
||||
|
||||
### 内容截断与持久化
|
||||
|
||||
大型 MCP 工具输出通过 `truncateMcpContentIfNeeded` 截断,二进制内容(图片)通过 `persistBinaryContent` 写入文件并返回文件路径。图片自动 resize(`maybeResizeAndDownsampleImageBuffer`)。
|
||||
|
||||
## MCP 连接的并发控制
|
||||
|
||||
```typescript
|
||||
// 本地服务器并发连接数
|
||||
getMcpServerConnectionBatchSize() // 默认 3
|
||||
|
||||
// 远程服务器并发连接数
|
||||
getRemoteMcpServerConnectionBatchSize() // 默认 20
|
||||
```
|
||||
|
||||
本地 MCP 服务器(stdio)是重量级的子进程,默认限制 3 个并发连接。远程服务器是轻量级 HTTP 请求,允许 20 个并发。
|
||||
|
||||
## 内置 vs 外部 MCP 对比总结
|
||||
|
||||
| 维度 | 内置 MCP | 外部 MCP |
|
||||
|------|---------|---------|
|
||||
| **Transport** | `InProcessTransport`(同进程) | stdio / SSE / HTTP / WebSocket |
|
||||
| **配置来源** | `setupComputerUseMCP()` / `setupClaudeInChrome()` 等动态注册 | settings.json / .mcp.json / 插件 / claude.ai |
|
||||
| **Scope** | `dynamic` | `user` / `project` / `local` / `enterprise` / `claudeai` |
|
||||
| **进程模型** | 同进程,零开销 | 子进程(stdio)或网络连接 |
|
||||
| **名称保护** | 保留名,用户不可添加同名 | 自由命名(字母数字 + `-_`) |
|
||||
| **生命周期** | 随 CLI 启停 | 连接缓存 + 按需重连 |
|
||||
| **权限** | `allowedTools` 自动授权 | `passthrough` 进入权限确认 |
|
||||
| **Feature Flag** | `CHICAGO_MCP`(Computer Use)等 | 无(始终可用) |
|
||||
| **工具发现** | 与外部相同(MCP 协议) | 标准 MCP `tools/list` |
|
||||
| **清理** | `inProcessServer.close()` | 信号升级策略 SIGINT→SIGTERM→SIGKILL |
|
||||
|
||||
## 关键源文件索引
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `readOnlyHint` | 只读操作,可并行 |
|
||||
| `destructiveHint` | 破坏性操作 |
|
||||
| `openWorldHint` | 开放世界(不可枚举所有行为) |
|
||||
|
||||
这些标注影响权限检查——只读工具在更多权限模式下被自动放行。
|
||||
|
||||
### 权限检查
|
||||
|
||||
MCP 工具默认进入权限确认流程(`passthrough`),通过 `mcp__` 前缀匹配权限规则。用户可以为特定 MCP 服务器配置 allow/deny 规则。
|
||||
|
||||
内置 MCP 工具通过白名单自动授权,不触发权限提示。
|
||||
|
||||
## 执行链路
|
||||
|
||||
```
|
||||
AI 调用 MCP 工具 → 确保连接有效 → 通过 MCP 协议执行 → 处理结果
|
||||
→ Session 过期?自动重试一次
|
||||
→ 结果过大?截断 + 持久化到磁盘
|
||||
→ 包含图片?自动 resize + 持久化
|
||||
```
|
||||
|
||||
**设计考量**:MCP 工具的执行结果可能很大(数据库查询结果、API 响应)。截断 + 持久化确保大型结果不会耗尽上下文窗口,同时 AI 仍然知道结果在哪里可以找到。
|
||||
|
||||
## 接下来
|
||||
|
||||
- **MCP 配置** — 理解多来源合并和企业管控
|
||||
- **工具系统** — 理解所有工具的统一接口
|
||||
- **权限模型** — 理解 MCP 工具的权限检查
|
||||
| `src/services/mcp/client.ts` | 核心客户端:connectToServer、fetchToolsForClient、MCPTool.call |
|
||||
| `src/services/mcp/config.ts` | 配置管理:getAllMcpConfigs、addMcpConfig、removeMcpConfig |
|
||||
| `src/services/mcp/types.ts` | 类型定义:配置 Schema、连接状态类型 |
|
||||
| `src/services/mcp/InProcessTransport.ts` | 内置 MCP 传输层:linked transport pair |
|
||||
| `src/services/mcp/vscodeSdkMcp.ts` | VSCode SDK MCP:双向通知、实验门控 |
|
||||
| `src/services/mcp/useManageMCPConnections.ts` | React Hook:连接生命周期、重连 |
|
||||
| `src/utils/computerUse/mcpServer.ts` | Computer Use MCP Server 构建 |
|
||||
| `src/utils/computerUse/setup.ts` | Computer Use 动态注册 |
|
||||
| `src/utils/claudeInChrome/mcpServer.ts` | Chrome MCP Server 构建 + Bridge 配置 |
|
||||
| `src/tools/MCPTool/MCPTool.ts` | MCP 工具包装:统一 Tool 接口 |
|
||||
| `src/entrypoints/mcp.ts` | MCP server 入口(Claude Code 作为 MCP server) |
|
||||
|
||||
@@ -1,123 +1,221 @@
|
||||
---
|
||||
title: "Skills 技能系统"
|
||||
description: "Prompt 即能力。Skill 不是代码,而是高质量的 Prompt + 权限配置的声明式封装。理解加载链路、两条执行路径和条件激活机制。"
|
||||
keywords: ["Skills", "技能加载", "Prompt 即能力", "条件激活"]
|
||||
title: "Skills 技能系统 - Prompt 即能力的架构哲学"
|
||||
description: "深入剖析 Claude Code Skills 系统的完整实现:从磁盘加载、Frontmatter 解析、预算感知描述截断、双模式执行(inline/fork)、权限白名单、条件激活、动态发现到远程技能加载,揭示一条完整的 Skill 生命周期链路。"
|
||||
keywords: ["Skills", "SkillTool", "技能加载", "Frontmatter", "whenToUse", "allowedTools", "fork执行", "动态发现"]
|
||||
---
|
||||
|
||||
## 核心洞见:Prompt 即能力
|
||||
{/* 本章目标:揭示 Skill 系统从文件到执行的全链路实现 */}
|
||||
|
||||
Skill 的核心设计哲学:**复杂任务的关键不在代码逻辑,而在 Prompt 质量**。
|
||||
|
||||
一个代码审查 Skill 不需要审查引擎,只需告诉 AI "审查什么、按什么顺序、输出什么格式"。Skill 把这种"经验"封装为可复用的 Markdown 文件。
|
||||
## Tool vs Skill:本质差异
|
||||
|
||||
| | Tool | Skill |
|
||||
|---|---|---|
|
||||
| 粒度 | 单个原子操作(读文件、执行命令) | 完整工作流(代码审查、创建 PR) |
|
||||
| 本质 | TypeScript 执行逻辑 | Prompt + 权限配置的声明式封装 |
|
||||
| 创建 | 需要写代码 | 写 Markdown 文件即可 |
|
||||
| 粒度 | 单个原子操作(读文件、执行命令) | 一套完整的工作流(代码审查、创建 PR) |
|
||||
| 触发方式 | AI 自主选择 | 用户 `/skill-name` 或 AI 通过 `SkillTool` 自动匹配 |
|
||||
| 本质 | TypeScript 执行逻辑 | **Prompt + 权限配置**的声明式封装 |
|
||||
| 注册位置 | `src/tools.ts` → `getTools()` | `src/commands.ts` → `getCommands()` |
|
||||
| 执行器 | 各 Tool 的 `call()` 方法 | `SkillTool.call()` → 两条分支(inline / fork) |
|
||||
|
||||
## Skill 的来源
|
||||
Skill 的核心洞见:**复杂任务的关键不在代码逻辑,而在 Prompt 质量**。一个代码审查 Skill 不需要审查引擎,只需告诉 AI "审查什么、按什么顺序、输出什么格式"——Skill 把这种"经验"封装为可复用的 Markdown。
|
||||
|
||||
| 来源 | 路径 | 特点 |
|
||||
|------|------|------|
|
||||
| **内置命令** | 硬编码 | `/commit`、`/compact` 等 70+ 命令 |
|
||||
| **Bundled Skills** | 编译时打包 | 延迟解压,享有不可截断特权 |
|
||||
| **磁盘 Skills** | `.claude/skills/` | 最重要的来源,支持多层级 |
|
||||
| **MCP Skills** | MCP Server 提供 | 远程内容,禁止内联 shell 命令 |
|
||||
| **Legacy Commands** | `.claude/commands/` | 向后兼容旧格式 |
|
||||
## Skill 的五个来源与加载链路
|
||||
|
||||
### 多层级磁盘加载
|
||||
### 1. 内置命令(Built-in Commands)
|
||||
|
||||
硬编码在 `src/commands.ts:299` 的 `COMMANDS` memoize 数组中,包含 70+ 条命令(`/commit`、`/review`、`/compact` 等)。这些是 TypeScript 模块而非 Markdown,但实现了相同的 `Command` 接口(`src/types/command.ts`)。
|
||||
|
||||
### 2. Bundled Skills(编译时打包)
|
||||
|
||||
通过 `registerBundledSkill()`(`src/skills/bundledSkills.ts:53`)在模块初始化时注册。关键特性:
|
||||
|
||||
- **延迟文件提取**:如果 Skill 声明了 `files`(参考文件),首次调用时才解压到临时目录(`getBundledSkillExtractDir()`),使用 `O_NOFOLLOW | O_EXCL` 防止符号链接攻击(`safeWriteFile`,第 186 行)
|
||||
- **闭包级 memoize**:并发调用共享同一个 extraction promise,避免竞态写入
|
||||
- 来源标记为 `source: 'bundled'`,在 Prompt 预算中享有**不可截断**的特权
|
||||
|
||||
### 3. 磁盘 Skills(`.claude/skills/`)
|
||||
|
||||
由 `loadSkillsFromSkillsDir()`(`src/skills/loadSkillsDir.ts:407`)加载,这是最重要的加载路径:
|
||||
|
||||
```
|
||||
管理策略: $MANAGED_DIR/.claude/skills/ (企业管理)
|
||||
用户全局: ~/.claude/skills/ (个人偏好)
|
||||
项目级: .claude/skills/ (团队共享)
|
||||
附加目录: --add-dir 指定的路径 (额外来源)
|
||||
管理策略: $MANAGED_DIR/.claude/skills/ (policySettings)
|
||||
用户全局: ~/.claude/skills/ (userSettings)
|
||||
项目级: .claude/skills/ (projectSettings, 向上遍历至 home)
|
||||
附加目录: --add-dir 指定的路径下 .claude/skills/
|
||||
```
|
||||
|
||||
每个 Skill 是一个 `skill-name/SKILL.md` 目录。加载时解析 YAML frontmatter 提取配置。
|
||||
**加载协议**:只识别 `skill-name/SKILL.md` 目录格式,不再支持单文件 `.md`。加载流程:
|
||||
|
||||
### 安全边界
|
||||
1. `readdir` 扫描目录 → 仅保留 `isDirectory()` 或 `isSymbolicLink()` 的条目
|
||||
2. 在每个子目录中查找 `SKILL.md`,未找到则跳过
|
||||
3. `parseFrontmatter()` 解析 YAML 头部,提取 `whenToUse`、`allowedTools`、`context` 等字段
|
||||
4. `parseSkillFrontmatterFields()`(第 185 行)统一解析 16 个 frontmatter 字段
|
||||
5. `createSkillCommand()`(第 270 行)构造 `Command` 对象
|
||||
|
||||
MCP Skills 的 Prompt 内容**禁止执行内联 shell 命令**。因为远程内容不可信——如果允许,恶意 MCP Server 就可以通过 Skill 注入执行任意命令。
|
||||
**去重机制**:使用 `realpath()` 解析符号链接获得规范路径(`getFileIdentity`,第 118 行),避免通过符号链接或重叠父目录导致的重复加载。
|
||||
|
||||
## Frontmatter 配置
|
||||
### 4. MCP Skills(动态发现)
|
||||
|
||||
一个 SKILL.md 的完整配置:
|
||||
通过 `registerMCPSkillBuilders()` 注册构建器,MCP Server 的 prompt 被 `mcpSkillBuilders.ts` 转换为 `Command` 对象。标记为 `loadedFrom: 'mcp'`。
|
||||
|
||||
**安全边界**:MCP Skills 的 Prompt 内容**禁止执行内联 shell 命令**(`loadSkillsDir.ts:374` 的 `loadedFrom !== 'mcp'` 守卫),因为远程内容不可信。
|
||||
|
||||
### 5. Legacy Commands(`/commands/` 目录)
|
||||
|
||||
向后兼容的旧格式,由 `loadSkillsFromCommandsDir()`(第 566 行)加载。同时支持 `SKILL.md` 目录格式和单 `.md` 文件格式。
|
||||
|
||||
## Frontmatter 字段全景
|
||||
|
||||
一个 `SKILL.md` 的完整 frontmatter(`parseSkillFrontmatterFields`,第 185 行):
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: code-review
|
||||
description: 系统性代码审查
|
||||
when_to_use: "用户说审查代码、找 bug"
|
||||
allowed-tools:
|
||||
name: code-review # 显示名称(覆盖目录名)
|
||||
description: 系统性代码审查 # 描述(或从 Markdown 首段提取)
|
||||
when_to_use: "用户说审查代码、找 bug" # AI 自动匹配依据
|
||||
allowed-tools: # 工具白名单
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
context: fork # 执行模式:inline | fork
|
||||
model: opus # 模型覆盖
|
||||
effort: high # 努力级别
|
||||
paths: # 条件激活
|
||||
argument-hint: "<file-or-directory>" # 参数提示
|
||||
arguments: [path] # 声明式参数名(用于 $ARGUMENTS 替换)
|
||||
model: opus # 模型覆盖
|
||||
effort: high # 努力级别
|
||||
context: fork # 执行模式:inline(默认)| fork
|
||||
agent: code-reviewer # 指定 Agent 定义文件
|
||||
user-invocable: true # 用户是否可 /调用
|
||||
disable-model-invocation: false # 禁止 AI 自主调用
|
||||
version: "1.0" # 版本号
|
||||
paths: # 条件激活的文件路径模式
|
||||
- "src/**/*.ts"
|
||||
hooks: # Hook 配置
|
||||
PreToolUse:
|
||||
- command: ["echo", "checking"]
|
||||
shell: ["bash"] # Shell 执行环境
|
||||
---
|
||||
```
|
||||
|
||||
- `when_to_use` — AI 根据此描述自动匹配用户意图
|
||||
- `allowed-tools` — 限制 Skill 可用的工具白名单
|
||||
- `context` — 控制执行模式(见下文)
|
||||
- `paths` — 条件激活,只在操作匹配文件时出现
|
||||
解析后有 16 个字段被提取,其中 `allowedTools`、`model`、`effort` 在执行时动态修改 `toolPermissionContext`。
|
||||
|
||||
## 两条执行路径
|
||||
## 两条执行路径:Inline vs Fork
|
||||
|
||||
SkillTool(`packages/builtin-tools/src/tools/SkillTool/SkillTool.ts:332`)在 `call()` 中根据 `command.context` 分流:
|
||||
|
||||
### Inline 模式(默认)
|
||||
|
||||
Skill 的 Prompt 内容被注入为用户消息,在主对话流中继续执行。AI "穿上"了 Skill 的经验,但仍在同一个对话中。
|
||||
Skill 的 Prompt 内容被注入为 **UserMessage**,在主对话流中继续执行:
|
||||
|
||||
**优点**:共享主对话的完整上下文,可以引用之前的讨论。
|
||||
**缺点**:Skill 的中间过程会污染主对话的上下文。
|
||||
1. `processPromptSlashCommand()` 处理参数替换(`$ARGUMENTS`)和 shell 命令展开(`` !`...` ``)
|
||||
2. `${CLAUDE_SKILL_DIR}` 被替换为 Skill 所在目录的绝对路径
|
||||
3. `${CLAUDE_SESSION_ID}` 被替换为当前会话 ID
|
||||
4. 返回 `newMessages`(注入到对话流)+ `contextModifier`(修改权限上下文)
|
||||
|
||||
`contextModifier`(第 776 行)做了三件事:
|
||||
- **工具白名单注入**:将 `allowedTools` 合并到 `alwaysAllowRules.command`
|
||||
- **模型切换**:`resolveSkillModelOverride()` 处理模型覆盖,保留 `[1m]` 后缀以避免 200K 窗口截断
|
||||
- **努力级别覆盖**:修改 `effortValue`
|
||||
|
||||
### Fork 模式(`context: fork`)
|
||||
|
||||
Skill 在独立子 Agent 中执行,拥有独立的 token 预算和工具权限。执行完成后只返回最终结果,中间过程不保留。
|
||||
Skill 在**独立子 Agent** 中执行(`executeForkedSkill`,第 122 行):
|
||||
|
||||
**优点**:不污染主对话,适合长时间运行的任务。
|
||||
**缺点**:子 Agent 看不到主对话的完整上下文。
|
||||
1. `prepareForkedCommandContext()` 构建隔离的 Agent 定义和 Prompt
|
||||
2. `runAgent()` 启动子 Agent 循环,拥有独立的 token 预算
|
||||
3. 通过 `onProgress` 回调报告工具使用进度
|
||||
4. 结果通过 `extractResultText()` 提取,子 Agent 的全部消息在提取后被释放(`agentMessages.length = 0`)
|
||||
5. 最终通过 `clearInvokedSkillsForAgent()` 清理状态
|
||||
|
||||
**设计考量**:大多数 Skill 使用 inline 模式就够了——它们需要主对话的上下文。Fork 模式适合"重型"任务(如完整的代码审查),这些任务的中间步骤很多,留在主对话中会浪费大量 token。
|
||||
Fork 模式适用于需要强隔离的场景(如长时间运行的审查任务),避免污染主对话的上下文。
|
||||
|
||||
## 权限模型
|
||||
## 权限模型:Safe Properties 白名单
|
||||
|
||||
Skill 有五层权限检查:
|
||||
`checkPermissions()`(第 433 行)实现了一个五层权限检查:
|
||||
|
||||
```
|
||||
Deny 规则 → 远程 Skill 自动放行 → Allow 规则 → Safe Properties 白名单 → Ask 用户确认
|
||||
1. Deny 规则匹配(支持精确匹配和 prefix:* 通配符)
|
||||
↓ 未命中
|
||||
2. 远程 canonical Skill 自动放行(EXPERIMENTAL_SKILL_SEARCH + USER_TYPE === 'ant')
|
||||
↓ 未命中
|
||||
3. Allow 规则匹配
|
||||
↓ 未命中
|
||||
4. Safe Properties 白名单检查(skillHasOnlySafeProperties,第 911 行)
|
||||
↓ 有非安全属性
|
||||
5. Ask 用户确认(附带精确匹配和前缀匹配两条建议规则)
|
||||
```
|
||||
|
||||
**Safe Properties 白名单**是一个包含 30 个安全属性名的列表。任何不在白名单中的属性都会触发权限请求。这是**正向安全**设计——未来新增的属性默认需要权限,而非默认允许。
|
||||
**Safe Properties**(`SAFE_SKILL_PROPERTIES`,第 876 行)是一个包含 30 个属性名的白名单(覆盖 `PromptCommand` 和 `CommandBase` 两个类型的所有安全属性)。任何不在白名单中的**有意义的属性值**(排除 `undefined`、`null`、空数组、空对象)都会触发权限请求。这是**正向安全**设计——未来新增的属性默认需要权限。
|
||||
|
||||
## Prompt 预算
|
||||
## Prompt 预算:1% 上下文窗口的截断策略
|
||||
|
||||
Skill 列表注入 System Prompt 时有严格预算(约上下文窗口的 1%):
|
||||
1. 优先保留 bundled Skills 的完整描述
|
||||
2. 非 bundled Skills 按剩余预算均分
|
||||
3. 预算不足时只保留名称
|
||||
Skill 列表注入 System Prompt 时有严格的字符预算(`prompt.ts`):
|
||||
|
||||
**设计考量**:Skill 列表只是让 AI "知道有什么可用"。完整的 Skill Prompt 在 AI 选择后才加载,不需要全部塞进 System Prompt。
|
||||
- **预算计算**:`contextWindowTokens × 4 chars/token × 1%`(约 8000 字符)
|
||||
- **单条上限**:`MAX_LISTING_DESC_CHARS = 250` 字符(超出截断为 `…`)
|
||||
- **Bundled Skills 不可截断**:它们始终保留完整描述,预算不足时只截断非 bundled 的
|
||||
- **降级策略**:
|
||||
1. 尝试完整描述 → 超预算?
|
||||
2. Bundled 保留完整,非 bundled 均分剩余预算 → 每条描述低于 20 字符?
|
||||
3. 非 bundled 仅保留名称
|
||||
|
||||
## 条件激活
|
||||
`formatCommandsWithinBudget()`(`prompt.ts:70`)实现了这个三级降级。
|
||||
|
||||
带有 `paths` 模式的 Skill 在加载时不会立即可用。只有当被操作的文件路径匹配模式时,该 Skill 才被激活。
|
||||
## 动态发现与条件激活
|
||||
|
||||
一个只在 `*.test.ts` 上激活的测试 Skill,平时完全不可见,只有当 AI 读取或编辑测试文件时才会出现。
|
||||
### 基于文件路径的动态发现
|
||||
|
||||
**设计洞察**:这解决了"Skill 泛滥"问题——项目可能定义了几十个 Skill,但一次对话通常只需要其中几个。条件激活让 Skill 按需出现,而不是全部堆在 AI 面前让它选择。
|
||||
`discoverSkillDirsForPaths()`(`loadSkillsDir.ts:861`)在文件操作时触发:
|
||||
|
||||
1. 从被操作的文件路径开始,**向上遍历**至 CWD(不包含 CWD 本身)
|
||||
2. 在每层查找 `.claude/skills/` 目录
|
||||
3. 使用 `realpath` 去重,`git check-ignore` 过滤 gitignored 目录
|
||||
4. 按路径深度排序(**深层优先**),更接近文件的 Skill 优先级更高
|
||||
|
||||
### 条件激活(paths frontmatter)
|
||||
|
||||
带有 `paths` 模式的 Skill 在加载时不会立即可用,而是存入 `conditionalSkills` Map。当被操作的文件路径匹配某个 Skill 的 paths 模式时(使用 `ignore` 库做 gitignore 风格匹配),该 Skill 才被**激活**——从 `conditionalSkills` 移入 `dynamicSkills`。
|
||||
|
||||
这意味着一个只在 `*.test.ts` 上激活的测试 Skill,平时完全不可见,只有当 AI 读取或编辑测试文件时才会出现。
|
||||
|
||||
## 使用频率排名
|
||||
|
||||
Skill 的排序使用指数衰减算法:一周前的使用权重减半。这确保常用的 Skill 排在前面,但偶尔用的老 Skill 也不会完全沉底。
|
||||
`recordSkillUsage()`(`skillUsageTracking.ts`)使用指数衰减算法计算 Skill 排名分数:
|
||||
|
||||
## 接下来
|
||||
```
|
||||
score = usageCount × max(0.5^(daysSinceUse / 7), 0.1)
|
||||
```
|
||||
|
||||
- **Hooks** — 理解 Skill 中可以使用的 Hook 机制
|
||||
- **MCP 配置** — 理解 MCP Skills 的来源
|
||||
- **自定义 Agent** — 理解 Skill 中指定的 Agent 定义
|
||||
- **7 天半衰期**:一周前的使用权重减半
|
||||
- **最低 0.1 保底**:避免老但高频使用的 Skill 完全沉底
|
||||
- **60 秒去抖**:同一 Skill 在 1 分钟内的多次调用只计一次,减少文件 I/O
|
||||
|
||||
排名数据持久化在全局配置的 `skillUsage` 字段中。
|
||||
|
||||
## 远程技能加载(Experimental)
|
||||
|
||||
通过 `EXPERIMENTAL_SKILL_SEARCH` feature flag 控制,支持从远程(AKI/GCS/S3)加载 `_canonical_<slug>` 格式的 Skill:
|
||||
|
||||
1. `validateInput()` 中 `stripCanonicalPrefix()` 拦截 canonical 名称
|
||||
2. `executeRemoteSkill()`(第 970 行)从远程 URL 加载 SKILL.md
|
||||
3. 支持 `gs://`、`https://`、`s3://` 等 URL 协议
|
||||
4. 内容经过 frontmatter 剥离、`${CLAUDE_SKILL_DIR}` 替换后直接注入
|
||||
5. 通过 `addInvokedSkill()` 注册到 compaction 保留状态,确保压缩后仍可恢复
|
||||
6. 远程 Skill 不经过 `processPromptSlashCommand`——无 `!command` 替换、无 `$ARGUMENTS` 展开
|
||||
|
||||
## 完整生命周期总结
|
||||
|
||||
```
|
||||
磁盘 SKILL.md
|
||||
↓ parseFrontmatter()
|
||||
↓ parseSkillFrontmatterFields() → 16 个字段
|
||||
↓ createSkillCommand() → Command 对象
|
||||
↓ 去重(realpath + seenFileIds)
|
||||
↓ 条件 Skill → conditionalSkills Map(等待路径匹配激活)
|
||||
↓ getSkillDirCommands() memoize 缓存
|
||||
↓ getAllCommands() 合并 local + MCP
|
||||
↓ formatCommandsWithinBudget() → 截断后的 Skill 列表注入 System Prompt
|
||||
↓ AI 选择匹配的 Skill
|
||||
↓ SkillTool.validateInput() → 名称校验 + 存在性检查
|
||||
↓ SkillTool.checkPermissions() → 五层权限检查
|
||||
↓ SkillTool.call() → inline 或 fork 执行
|
||||
↓ contextModifier() → 注入 allowedTools + model + effort
|
||||
↓ recordSkillUsage() → 更新使用频率排名
|
||||
```
|
||||
|
||||
@@ -99,12 +99,15 @@ ARGUMENTS
|
||||
|
||||
## 四、认证
|
||||
|
||||
默认启动时自动生成随机 token。客户端连接时需通过 query 参数传递:
|
||||
默认启动时自动生成随机 token。客户端连接时不要把 token 放在 URL 中:
|
||||
|
||||
```
|
||||
ws://localhost:9315/ws?token=<your-token>
|
||||
ws://localhost:9315/ws
|
||||
```
|
||||
|
||||
无法发送 `Authorization` header 的 WebSocket 客户端需要使用
|
||||
`rcs.auth.<base64url-token>` 子协议传递 token。
|
||||
|
||||
配置固定 token:
|
||||
|
||||
```bash
|
||||
@@ -135,6 +138,9 @@ acp-link ccb-bun -- --acp
|
||||
1. **REST 注册**:通过 `POST /v1/environments/bridge` 向 RCS 注册环境
|
||||
2. **WS identify**:建立 WebSocket 连接后发送 `identify` 消息(携带 agentId),替代完整 `register`
|
||||
|
||||
RCS 的 ACP WebSocket 连接不接受 URL query token。acp-link 会通过
|
||||
`rcs.auth.<base64url-token>` WebSocket 子协议发送 `ACP_RCS_TOKEN`。
|
||||
|
||||
```
|
||||
acp-link RCS
|
||||
│ │
|
||||
|
||||
140
docs/features/context-collapse.md
Normal file
140
docs/features/context-collapse.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# CONTEXT_COLLAPSE — 上下文折叠
|
||||
|
||||
> Feature Flag: `FEATURE_CONTEXT_COLLAPSE=1`
|
||||
> 子 Feature: `FEATURE_HISTORY_SNIP=1`
|
||||
> 实现状态:核心逻辑全部 Stub,布线完整
|
||||
> 引用数:CONTEXT_COLLAPSE 20 + HISTORY_SNIP 16 = 36
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
CONTEXT_COLLAPSE 让模型内省上下文窗口使用情况,并智能压缩旧消息。当对话接近上下文限制时,自动将旧消息折叠为压缩摘要,保留关键信息的同时释放 token 空间。
|
||||
|
||||
### 子 Feature
|
||||
|
||||
| Feature | 功能 |
|
||||
|---------|------|
|
||||
| `CONTEXT_COLLAPSE` | 上下文折叠引擎(后台 LLM 调用压缩旧消息) |
|
||||
| `HISTORY_SNIP` | SnipTool — 标记消息进行折叠/修剪 |
|
||||
|
||||
## 二、实现架构
|
||||
|
||||
### 2.1 模块状态
|
||||
|
||||
| 模块 | 文件 | 状态 |
|
||||
|------|------|------|
|
||||
| 折叠核心 | `src/services/contextCollapse/index.ts` | **Stub** — 接口完整(`ContextCollapseStats`、`CollapseResult`、`DrainResult`),函数全部空操作 |
|
||||
| 折叠操作 | `src/services/contextCollapse/operations.ts` | **Stub** — `projectView` 为恒等函数 |
|
||||
| 折叠持久化 | `src/services/contextCollapse/persist.ts` | **Stub** — `restoreFromEntries` 为空操作 |
|
||||
| CtxInspectTool | `packages/builtin-tools/src/tools/CtxInspectTool/CtxInspectTool.ts` | **实现** — 上下文内省工具 |
|
||||
| SnipTool 提示 | `src/tools/SnipTool/prompt.ts` | **Stub** — 空工具名 |
|
||||
| SnipTool 实现 | `src/tools/SnipTool/SnipTool.ts` | **缺失** |
|
||||
| force-snip 命令 | `src/commands/force-snip.js` | **缺失** |
|
||||
| 折叠读取搜索 | `src/utils/collapseReadSearch.ts` | **完整** — Snip 作为静默吸收操作 |
|
||||
| QueryEngine 集成 | `src/QueryEngine.ts` | **布线** — 导入并使用 snip 投影 |
|
||||
| Token 警告 UI | `src/components/TokenWarning.tsx` | **布线** — 折叠进度标签 |
|
||||
|
||||
### 2.2 核心接口(已定义,待实现)
|
||||
|
||||
```ts
|
||||
// contextCollapse/index.ts
|
||||
interface ContextCollapseStats {
|
||||
// 上下文使用统计
|
||||
}
|
||||
interface CollapseResult {
|
||||
// 折叠操作结果
|
||||
}
|
||||
interface DrainResult {
|
||||
// 紧急释放结果
|
||||
}
|
||||
|
||||
// 关键函数(全部 stub):
|
||||
isContextCollapseEnabled() // → false
|
||||
applyCollapsesIfNeeded(messages) // 透传
|
||||
recoverFromOverflow(messages) // 透传(413 恢复)
|
||||
initContextCollapse() // 空操作
|
||||
```
|
||||
|
||||
### 2.3 预期数据流
|
||||
|
||||
```
|
||||
对话持续增长
|
||||
│
|
||||
▼
|
||||
上下文接近限制(由 query.ts 检测)
|
||||
│
|
||||
├── 溢出检测 (query.ts:440,616,802)
|
||||
│
|
||||
▼
|
||||
applyCollapsesIfNeeded(messages) [需要实现]
|
||||
│
|
||||
├── 后台 LLM 调用压缩旧消息
|
||||
├── 保留关键信息(决策、文件路径、错误)
|
||||
└── 替换旧消息为压缩摘要
|
||||
│
|
||||
├── 413 恢复 (query.ts:1093,1179)
|
||||
│ └── recoverFromOverflow() 紧急折叠
|
||||
│
|
||||
▼
|
||||
projectView() 过滤折叠后的消息视图
|
||||
│
|
||||
▼
|
||||
模型继续工作(在压缩后的上下文中)
|
||||
```
|
||||
|
||||
### 2.4 HISTORY_SNIP 子功能
|
||||
|
||||
SnipTool 提供手动折叠能力:
|
||||
|
||||
- `/force-snip` 命令 — 强制执行折叠
|
||||
- SnipTool — 标记特定消息进行折叠/修剪
|
||||
- `collapseReadSearch.ts` 已完整实现,将 Snip 作为静默吸收操作处理
|
||||
|
||||
### 2.5 集成点
|
||||
|
||||
| 文件 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| `src/query.ts` | 18,440,616,802,1093,1179 | 溢出检测、413 恢复、折叠应用 |
|
||||
| `src/QueryEngine.ts` | 124,127,1301 | Snip 投影使用 |
|
||||
| `src/utils/analyzeContext.ts` | 1122 | 跳过保留缓冲区显示 |
|
||||
| `src/utils/sessionRestore.ts` | 127,494 | 恢复折叠状态 |
|
||||
| `src/services/compact/autoCompact.ts` | 179,215 | 自动压缩时考虑折叠 |
|
||||
|
||||
## 三、需要补全的内容
|
||||
|
||||
| 优先级 | 模块 | 工作量 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| 1 | `services/contextCollapse/index.ts` | 大 | 折叠状态机、LLM 调用、消息压缩 |
|
||||
| 2 | `services/contextCollapse/operations.ts` | 中 | `projectView()` 消息过滤 |
|
||||
| 3 | `services/contextCollapse/persist.ts` | 小 | `restoreFromEntries()` 磁盘持久化 |
|
||||
| 4 | `tools/CtxInspectTool/` | 已完成 | 上下文内省工具已实现(`packages/builtin-tools/src/tools/CtxInspectTool/`) |
|
||||
| 5 | `tools/SnipTool/SnipTool.ts` | 中 | Snip 工具实现 |
|
||||
| 6 | `commands/force-snip.js` | 小 | `/force-snip` 命令 |
|
||||
|
||||
## 四、关键设计决策
|
||||
|
||||
1. **后台 LLM 压缩**:折叠不是简单截断,而是用 LLM 生成压缩摘要保留关键信息
|
||||
2. **413 恢复**:当 API 返回 413(请求过大)时,紧急折叠是最重要的恢复手段
|
||||
3. **与 autoCompact 协作**:折叠和自动压缩(compact)是不同的机制,折叠在消息级别,压缩在对话级别
|
||||
4. **持久化**:折叠状态持久化到磁盘,会话恢复时重载
|
||||
|
||||
## 五、使用方式
|
||||
|
||||
```bash
|
||||
# 启用 context collapse
|
||||
FEATURE_CONTEXT_COLLAPSE=1 bun run dev
|
||||
|
||||
# 启用 snip 子功能
|
||||
FEATURE_CONTEXT_COLLAPSE=1 FEATURE_HISTORY_SNIP=1 bun run dev
|
||||
```
|
||||
|
||||
## 六、文件索引
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/services/contextCollapse/index.ts` | 折叠核心(stub,接口已定义) |
|
||||
| `src/services/contextCollapse/operations.ts` | 投影操作(stub) |
|
||||
| `src/services/contextCollapse/persist.ts` | 持久化(stub) |
|
||||
| `src/utils/collapseReadSearch.ts` | Snip 吸收操作(完整) |
|
||||
| `src/query.ts` | 溢出检测和 413 恢复集成 |
|
||||
| `src/QueryEngine.ts` | Snip 投影使用 |
|
||||
| `src/components/TokenWarning.tsx` | 折叠进度 UI |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,160 +0,0 @@
|
||||
# Feature Flags 审查报告 — Codex 复核
|
||||
|
||||
> 审查日期: 2026-04-05
|
||||
> 审查工具: Codex CLI v0.118.0 (本地, full-auto mode)
|
||||
> 消耗 tokens: 240,306
|
||||
> 审查范围: docs/feature-flags-audit-complete.md 中标记为 COMPLETE 的 22 个编译时 feature flag
|
||||
|
||||
---
|
||||
|
||||
## 审查背景
|
||||
|
||||
原始审计报告 (`docs/feature-flags-audit-complete.md`) 声称 22 个 feature flag 被标记为 "COMPLETE",只需在 `build.ts` / `scripts/dev.ts` 中启用即可工作。
|
||||
|
||||
Claude Code 团队通过 6 个并行子代理实际读取源码后初步发现大量误判,随后将分析结果传递给 Codex CLI 进行独立二次验证。
|
||||
|
||||
---
|
||||
|
||||
## Codex 发现摘要
|
||||
|
||||
### High 级发现
|
||||
|
||||
1. **`CONTEXT_COLLAPSE` 不是 COMPLETE**
|
||||
- `src/services/contextCollapse/index.ts:43` — `isContextCollapseEnabled()` 硬编码为 `false`
|
||||
- `src/services/contextCollapse/index.ts:47` — `applyCollapsesIfNeeded()` 只是原样返回消息
|
||||
- `src/services/contextCollapse/index.ts:59` — `recoverFromOverflow()` 也是 no-op
|
||||
- `src/services/contextCollapse/operations.ts:3` 和 `persist.ts:3` 同样是 stub
|
||||
- 审计报告把 UI/命令文件算进去了,但真正被查询循环消费的是 stub 后端
|
||||
|
||||
2. **原分类"真正只需编译开关"的 7 个 flag,只有 3 个准确**
|
||||
- ✅ `SHOT_STATS` — 零额外门控,compile-only
|
||||
- ✅ `PROMPT_CACHE_BREAK_DETECTION` — 有 try-catch 兜底,compile-only
|
||||
- ✅ `TOKEN_BUDGET` — 纯本地计算,compile-only
|
||||
- ❌ `TEAMMEM` — 还要求 AutoMem + GrowthBook `tengu_herring_clock` + GitHub repo (`teamMemPaths.ts:73`, `watcher.ts:256`, `watcher.ts:259`)
|
||||
- ❌ `AGENT_TRIGGERS` — 受 `isKairosCronEnabled()` GrowthBook 控制 (`useScheduledTasks.ts:61`, `useScheduledTasks.ts:119`)
|
||||
- ❌ `EXTRACT_MEMORIES` — 受 `tengu_passport_quail` + AutoMem + 非 remote 限制 (`extractMemories.ts:536`, `:545`, `:550`)
|
||||
- ❌ `KAIROS_BRIEF` — 受 `tengu_kairos_brief` + opt-in/kairosActive 限制 (`BriefTool.ts:95`, `:126`, `:132`)
|
||||
|
||||
### Medium 级发现
|
||||
|
||||
3. **`BG_SESSIONS` 和 `BASH_CLASSIFIER` 不适合简单归为"全 stub"**
|
||||
- `BG_SESSIONS` — 会话注册/清理是真实现 (`concurrentSessions.ts:44`, `:55`),但任务摘要核心是 stub (`taskSummary.ts:2`)
|
||||
- `BASH_CLASSIFIER` — 权限编排很大一块是真实现 (`bashPermissions.ts` 2621行),但分类后端 `bashClassifier.ts:24` 永远返回 disabled
|
||||
|
||||
4. **审计口径问题**
|
||||
- 把"代码量/周边 UI 很多"误当成"可独立启用"
|
||||
- `PROACTIVE` — `index.ts:3` 只有 state stub,`commands.ts:64` 和 `REPL.tsx:415` 引用缺失文件
|
||||
- `REACTIVE_COMPACT` — `reactiveCompact.ts:13` 整块是 stub
|
||||
- `CACHED_MICROCOMPACT` — `cachedMicrocompact.ts:22` 全部 stub
|
||||
|
||||
---
|
||||
|
||||
## Codex 修正后的分类
|
||||
|
||||
### 第一类:真正 compile-only(3 个)
|
||||
|
||||
| Flag | 说明 | Crash 风险 |
|
||||
|------|------|-----------|
|
||||
| **SHOT_STATS** | 纯本地 shot 分布统计,ant-only 数据路径 | 低 |
|
||||
| **PROMPT_CACHE_BREAK_DETECTION** | 本地 cache key 变化检测,写 diff 有兜底 | 低 |
|
||||
| **TOKEN_BUDGET** | 本地 token 预算追踪,纯计算逻辑 | 低 |
|
||||
|
||||
### 第二类:compile + 运行时条件(7 个)
|
||||
|
||||
| Flag | 条件 | Crash 风险 |
|
||||
|------|------|-----------|
|
||||
| **TEAMMEM** | AutoMem + GrowthBook `tengu_herring_clock` + GitHub repo | 低 (clean no-op) |
|
||||
| **AGENT_TRIGGERS** | GrowthBook `isKairosCronEnabled()` | 低 (clean no-op) |
|
||||
| **EXTRACT_MEMORIES** | `tengu_passport_quail` + AutoMem + 非 remote | 低 (clean no-op) |
|
||||
| **KAIROS_BRIEF** | `tengu_kairos_brief` + opt-in/kairosActive,可用 `CLAUDE_CODE_BRIEF=1` 绕过 | 低 |
|
||||
| **COORDINATOR_MODE** | 需 `CLAUDE_CODE_COORDINATOR_MODE=1`,`workerAgent.ts` 是 stub 但不阻塞 | 低 |
|
||||
| **COMMIT_ATTRIBUTION** | 仅对 `isInternal=true` 的 repo 生效 | 低 |
|
||||
| **VERIFICATION_AGENT** | 受 GrowthBook `tengu_hive_evidence` 双重门控 | 低 |
|
||||
|
||||
### 第三类:混合型 — 部分实现 + stub 核心(5 个)
|
||||
|
||||
| Flag | 真实现部分 | Stub 核心 |
|
||||
|------|-----------|----------|
|
||||
| **BG_SESSIONS** | 会话注册/清理 (`concurrentSessions.ts`) | `bg.ts`/`taskSummary.ts`/`udsClient.ts` 全 stub + 依赖 tmux |
|
||||
| **BASH_CLASSIFIER** | 权限编排 (`bashPermissions.ts` 2621行) | `bashClassifier.ts` 分类后端 stub + 需 API beta |
|
||||
| **PROACTIVE** | REPL/命令注册框架 | `index.ts` stub + 3 文件缺失 |
|
||||
| **REACTIVE_COMPACT** | 调用点已在主查询环路 | `reactiveCompact.ts` 22行全 no-op |
|
||||
| **CACHED_MICROCOMPACT** | 调用点已布线 | `cachedMicrocompact.ts` 全 stub + 需未公开 API |
|
||||
|
||||
### 第四类:纯 stub(1 个)
|
||||
|
||||
| Flag | 问题 |
|
||||
|------|------|
|
||||
| **CONTEXT_COLLAPSE** | 3 核心文件全 stub + CtxInspectTool 目录不存在 |
|
||||
|
||||
### 第五类:依赖远程服务(3 个)
|
||||
|
||||
| Flag | 依赖 |
|
||||
|------|------|
|
||||
| **ULTRAPLAN** | CCR 远程 agent 基础设施 + OAuth |
|
||||
| **CCR_REMOTE_SETUP** | claude.ai OAuth + GitHub CLI + CCR 后端 |
|
||||
| **BRIDGE_MODE** (build端) | claude.ai 订阅 + GrowthBook + WebSocket 后端 |
|
||||
|
||||
---
|
||||
|
||||
## 第三类恢复优先级建议
|
||||
|
||||
Codex 推荐的恢复顺序:
|
||||
|
||||
1. **REACTIVE_COMPACT** — 收益最直接,调用点在主查询环路,改完最容易立刻见效
|
||||
2. **BG_SESSIONS** — 已有会话注册基础,补齐摘要和后台运行链路的 ROI 高
|
||||
3. **PROACTIVE** — 产品面大,但缺文件比 stub 更严重,范围比前两项大
|
||||
4. **CONTEXT_COLLAPSE** — collapse engine 全 stub,恢复成本和设计不确定性都高
|
||||
5. **BASH_CLASSIFIER** — 若无 API beta 能力不值得优先;若有则升到第 2
|
||||
6. **CACHED_MICROCOMPACT** — 受未公开 API 约束,最后做
|
||||
|
||||
---
|
||||
|
||||
## 审计报告分类标准修正建议
|
||||
|
||||
Codex 建议将原来的单轴分类(COMPLETE/PARTIAL/STUB)改为**三轴**:
|
||||
|
||||
| 轴 | 取值 | 说明 |
|
||||
|----|------|------|
|
||||
| **实现完整度** | `full` / `mixed` / `stub` | 活跃调用链上的核心模块是否有真实现 |
|
||||
| **激活条件** | `compile-only` / `compile+env` / `compile+GrowthBook` / `compile+remote` / `compile+private API` | 启用需要什么 |
|
||||
| **运行风险** | `safe no-op` / `background IO` / `startup critical` | 启用后条件不满足时的行为 |
|
||||
|
||||
**COMPLETE 的最低标准应满足:**
|
||||
1. 活跃调用链上的核心模块不能是 stub
|
||||
2. "可启用"不能只看编译 flag,还要单列运行时 gate
|
||||
|
||||
按此标准,`CONTEXT_COLLAPSE`、`BG_SESSIONS`、`BASH_CLASSIFIER`、`PROACTIVE`、`REACTIVE_COMPACT`、`CACHED_MICROCOMPACT` 都应从 COMPLETE 降级。
|
||||
|
||||
---
|
||||
|
||||
## 已采取的行动
|
||||
|
||||
基于审查结果,已将以下 3 个确认安全的 flag 加入默认构建:
|
||||
|
||||
**build.ts:**
|
||||
```typescript
|
||||
const DEFAULT_BUILD_FEATURES = [
|
||||
"AGENT_TRIGGERS_REMOTE", "CHICAGO_MCP", "VOICE_MODE",
|
||||
"SHOT_STATS", "PROMPT_CACHE_BREAK_DETECTION", "TOKEN_BUDGET"
|
||||
];
|
||||
```
|
||||
|
||||
**scripts/dev.ts:**
|
||||
```typescript
|
||||
const DEFAULT_FEATURES = [
|
||||
"BUDDY", "TRANSCRIPT_CLASSIFIER", "BRIDGE_MODE",
|
||||
"AGENT_TRIGGERS_REMOTE", "CHICAGO_MCP", "VOICE_MODE",
|
||||
"SHOT_STATS", "PROMPT_CACHE_BREAK_DETECTION", "TOKEN_BUDGET"
|
||||
];
|
||||
```
|
||||
|
||||
### 验证结果
|
||||
|
||||
| 项目 | 结果 |
|
||||
|------|------|
|
||||
| `bun run build` | ✅ 成功 (475 files) |
|
||||
| `bun test` | ✅ 无新增失败 (23 fail 为已有问题) |
|
||||
| SHOT_STATS 代码路径 | ✅ 完整 — stats 面板显示 shot 分布 |
|
||||
| TOKEN_BUDGET 代码路径 | ✅ 完整 — 支持 `+500k` 语法,带进度条 |
|
||||
| PROMPT_CACHE_BREAK_DETECTION 代码路径 | ✅ 完整 — 内部诊断,debug 模式可见 |
|
||||
@@ -145,8 +145,8 @@ M 键(或 ← / →)用于在两种路由模式之间切换,**无需展开
|
||||
|
||||
```
|
||||
/pipes — 显示所有实例 + 切换选择面板
|
||||
/pipes select <name> — 选中某实例(消息会广播到它)
|
||||
/pipes deselect <name> — 取消选中
|
||||
/pipes select <name> — 选中某实例(消息会广播到它)
|
||||
/pipes deselect <name> — 取消选中
|
||||
/pipes all — 全选
|
||||
/pipes none — 全部取消
|
||||
```
|
||||
@@ -169,7 +169,7 @@ LAN Peers:
|
||||
Selected: cli-da029538
|
||||
```
|
||||
|
||||
### /attach <name>
|
||||
### /attach <name>
|
||||
|
||||
手动 attach 到一个实例,使其成为你的 slave。
|
||||
|
||||
@@ -179,7 +179,7 @@ Selected: cli-da029538
|
||||
|
||||
attach 后,对方变为 slave,你变为 master。可以向它发送 prompt。通常不需要手动 attach——heartbeat 会自动发现并连接。
|
||||
|
||||
### /detach <name>
|
||||
### /detach <name>
|
||||
|
||||
断开与某个 slave 的连接。
|
||||
|
||||
@@ -187,7 +187,7 @@ attach 后,对方变为 slave,你变为 master。可以向它发送 prompt
|
||||
/detach cli-04d67950
|
||||
```
|
||||
|
||||
### /send <name> <message>
|
||||
### /send <name> <message>
|
||||
|
||||
向指定 pipe 发送消息(不依赖选择状态,直接指定目标)。
|
||||
|
||||
|
||||
@@ -225,6 +225,11 @@ acp-link ◄──ACP relay──► RCS ◄──Web UI WS──► 浏览器
|
||||
| `src/transport/acp-relay-handler.ts` | 前端 WS → acp-link 透传 + EventBus inbound 转发 |
|
||||
| `src/transport/acp-sse-writer.ts` | SSE event stream 供外部消费者订阅 |
|
||||
|
||||
ACP 的 agents、channel groups、relay 和 channel-group SSE 端点都要求有效
|
||||
API key。浏览器 `EventSource` 不能发送 `Authorization` header,外部订阅
|
||||
`/acp/channel-groups/:id/events` 时需要使用 `fetch` + `ReadableStream` 并带
|
||||
`Authorization: Bearer <api-key>`。
|
||||
|
||||
### acp-link 连接
|
||||
|
||||
详见 [acp-link 文档](./acp-link.md)。
|
||||
|
||||
426
docs/features/ssh-remote.md
Normal file
426
docs/features/ssh-remote.md
Normal file
@@ -0,0 +1,426 @@
|
||||
# SSH Remote — 远程主机运行 Claude Code
|
||||
|
||||
## 概述
|
||||
|
||||
SSH Remote 提供两种方式在远程 Linux 主机上运行 Claude Code:
|
||||
|
||||
1. **SSH Remote 模块**(`ccb ssh <host>`)— 本地 REPL + 远程工具执行,自动部署二进制 + 认证隧道
|
||||
2. **直接 SSH 运行**(`ssh <host> -t ccb`)— 远程已安装 ccb,直接启动交互式会话
|
||||
|
||||
## 架构
|
||||
|
||||
### 方式一:SSH Remote 模块(完整模式)
|
||||
|
||||
适用场景:远端没有 API 凭据或没有安装 ccb。
|
||||
|
||||
```
|
||||
┌──────────────── 本地 Windows/Mac/Linux ───────────┐
|
||||
│ │
|
||||
│ ccb ssh <host> [dir] │
|
||||
│ │ │
|
||||
│ ├── 1. SSHProbe: 探测远端平台/架构/已有二进制 │
|
||||
│ ├── 2. SSHDeploy: 部署 dist/ 到远端 │
|
||||
│ ├── 3. SSHAuthProxy: 启动本地认证代理 │
|
||||
│ │ ├─ Unix Socket (Linux/Mac) │
|
||||
│ │ └─ TCP 127.0.0.1:<port> (Windows) │
|
||||
│ │ │
|
||||
│ └── 4. SSH -R 反向隧道 + 启动远端 CLI │
|
||||
│ ssh -R <remote>:<local> <host> \ │
|
||||
│ ANTHROPIC_BASE_URL=... \ │
|
||||
│ ANTHROPIC_AUTH_NONCE=... \ │
|
||||
│ ccb --output-format stream-json │
|
||||
│ │
|
||||
│ ┌─────── 本地 REPL (Ink TUI) ───────┐ │
|
||||
│ │ 用户输入 → NDJSON → SSH stdin │ │
|
||||
│ │ SSH stdout → NDJSON → 渲染消息 │ │
|
||||
│ │ 工具权限请求 → 本地审批 → 回传 │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ SSH 连接 (加密通道)
|
||||
│
|
||||
┌───────────────── 远端 Linux ──────────────────────┐
|
||||
│ │
|
||||
│ ccb (自动部署或已存在) │
|
||||
│ ├── --output-format stream-json │
|
||||
│ ├── --input-format stream-json │
|
||||
│ ├── --verbose -p │
|
||||
│ │ │
|
||||
│ ├── API 请求 → ANTHROPIC_BASE_URL │
|
||||
│ │ → SSH 反向隧道 → 本地 AuthProxy │
|
||||
│ │ → 注入真实凭据 → api.anthropic.com │
|
||||
│ │ │
|
||||
│ └── 工具执行 (Bash/Read/Write/...) │
|
||||
│ 直接在远端文件系统上操作 │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 方式二:直接 SSH 运行(简单模式)
|
||||
|
||||
适用场景:远端已安装 ccb 且已有 API 凭据(订阅或 API Key)。
|
||||
|
||||
```
|
||||
┌─────── 本地终端 ───────┐ ┌──────── 远端 Linux ────────┐
|
||||
│ │ SSH │ │
|
||||
│ ssh <host> -t ccb │ ──────→ │ ccb (全局安装) │
|
||||
│ │ │ ├── 使用远端自身凭据 │
|
||||
│ 终端直接显示远端 TUI │ ←────── │ ├── 远端文件系统操作 │
|
||||
│ │ TTY │ └── API 直连 Anthropic │
|
||||
└─────────────────────────┘ └─────────────────────────────┘
|
||||
```
|
||||
|
||||
### 适用场景对比
|
||||
|
||||
| | SSH Remote 模块 | 直接 SSH 运行 |
|
||||
|---|---|---|
|
||||
| 远端需要安装 ccb | 不需要(自动部署) | 需要 |
|
||||
| 远端需要 API 凭据 | 不需要(本地隧道) | 需要 |
|
||||
| 本地需要安装 ccb | 需要 | 不需要(任何终端) |
|
||||
| 斜杠命令 | 本地处理 | 远端处理 |
|
||||
| 网络延迟敏感 | 高(NDJSON 双向) | 低(仅 TTY) |
|
||||
| 推荐场景 | 远端无凭据/无安装 | 远端已配置完整 |
|
||||
|
||||
---
|
||||
|
||||
## 前置准备:SSH 密钥配置
|
||||
|
||||
两种方式都依赖 SSH 免密连接。以下是完整的密钥配置步骤。
|
||||
|
||||
### 1. 生成 SSH 密钥对(本地)
|
||||
|
||||
```bash
|
||||
# 生成 Ed25519 密钥(推荐)
|
||||
ssh-keygen -t ed25519 -C "your-email@example.com" -f ~/.ssh/id_remote
|
||||
|
||||
# 或 RSA 4096 位
|
||||
ssh-keygen -t rsa -b 4096 -C "your-email@example.com" -f ~/.ssh/id_remote
|
||||
```
|
||||
|
||||
生成两个文件:
|
||||
- `~/.ssh/id_remote` — 私钥(不可泄露)
|
||||
- `~/.ssh/id_remote.pub` — 公钥(部署到远端)
|
||||
|
||||
### 2. 将公钥部署到远端
|
||||
|
||||
```bash
|
||||
# 方式 A:ssh-copy-id(推荐)
|
||||
ssh-copy-id -i ~/.ssh/id_remote.pub user@remote-host
|
||||
|
||||
# 方式 B:手动复制
|
||||
cat ~/.ssh/id_remote.pub | ssh user@remote-host "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
|
||||
```
|
||||
|
||||
### 3. 配置 SSH Config(本地)
|
||||
|
||||
编辑 `~/.ssh/config`(不存在则创建):
|
||||
|
||||
```
|
||||
Host my-server
|
||||
HostName 192.168.1.100 # 远端 IP 或域名
|
||||
User root # 远端用户名
|
||||
IdentityFile ~/.ssh/id_remote # 私钥路径
|
||||
ServerAliveInterval 60 # 防止连接超时断开
|
||||
ServerAliveCountMax 3
|
||||
```
|
||||
|
||||
配置后可直接用别名连接:
|
||||
|
||||
```bash
|
||||
ssh my-server # 等同于 ssh -i ~/.ssh/id_remote root@192.168.1.100
|
||||
```
|
||||
|
||||
### 4. 文件权限设置
|
||||
|
||||
#### Linux / macOS
|
||||
|
||||
```bash
|
||||
chmod 700 ~/.ssh
|
||||
chmod 600 ~/.ssh/config
|
||||
chmod 600 ~/.ssh/id_remote
|
||||
chmod 644 ~/.ssh/id_remote.pub
|
||||
```
|
||||
|
||||
#### Windows(OpenSSH 强制 ACL 检查)
|
||||
|
||||
```powershell
|
||||
# 重置 .ssh 目录权限:仅允许当前用户 + SYSTEM
|
||||
icacls "$env:USERPROFILE\.ssh" /inheritance:r /grant:r "$($env:USERNAME):(OI)(CI)F" /grant "SYSTEM:(OI)(CI)F"
|
||||
|
||||
# 修复 config 文件权限
|
||||
icacls "$env:USERPROFILE\.ssh\config" /inheritance:r /grant:r "$($env:USERNAME):F" /grant "SYSTEM:F"
|
||||
|
||||
# 修复私钥权限
|
||||
icacls "$env:USERPROFILE\.ssh\id_remote" /inheritance:r /grant:r "$($env:USERNAME):F" /grant "SYSTEM:F"
|
||||
```
|
||||
|
||||
> **Windows 常见错误**:如果 `icacls` 显示 `UNKNOWN\UNKNOWN` ACL 条目,需要先移除再重新授权。权限错误会导致 SSH 拒绝使用密钥。
|
||||
|
||||
### 5. 验证免密连接
|
||||
|
||||
```bash
|
||||
ssh my-server "echo 'SSH connection OK'"
|
||||
# 应直接输出 "SSH connection OK",不要求输入密码
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 方式一:SSH Remote 模块
|
||||
|
||||
```bash
|
||||
# 基本用法 — 自动探测、部署、启动
|
||||
ccb ssh user@remote-host
|
||||
|
||||
# 使用 SSH Config 别名
|
||||
ccb ssh my-server
|
||||
|
||||
# 指定远端工作目录
|
||||
ccb ssh my-server /home/user/project
|
||||
|
||||
# 使用自定义远端二进制(跳过探测/部署)
|
||||
ccb ssh my-server --remote-bin "bun /opt/ccb/dist/cli.js"
|
||||
|
||||
# 权限控制
|
||||
ccb ssh my-server --permission-mode auto
|
||||
ccb ssh my-server --dangerously-skip-permissions
|
||||
|
||||
# 恢复远端会话
|
||||
ccb ssh my-server --continue
|
||||
ccb ssh my-server --resume <session-uuid>
|
||||
|
||||
# 选择模型
|
||||
ccb ssh my-server --model claude-sonnet-4-6-20250514
|
||||
|
||||
# 本地测试模式(不连接远端,测试 auth proxy 管道)
|
||||
ccb ssh localhost --local
|
||||
```
|
||||
|
||||
### 方式二:直接 SSH 运行
|
||||
|
||||
```bash
|
||||
# 启动交互式会话
|
||||
ssh my-server -t ccb
|
||||
|
||||
# 指定工作目录
|
||||
ssh my-server -t "ccb --cwd /home/user/project"
|
||||
|
||||
# 使用特定模型
|
||||
ssh my-server -t "ccb --model claude-sonnet-4-6-20250514"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 构建与部署
|
||||
|
||||
### 构建产物
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
bun install
|
||||
|
||||
# 构建(输出到 dist/)
|
||||
bun run build
|
||||
```
|
||||
|
||||
产物说明:
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `dist/cli.js` | Bun 入口(`#!/usr/bin/env bun`) |
|
||||
| `dist/cli-node.js` | Node.js 入口(`#!/usr/bin/env node` → `import ./cli.js`) |
|
||||
| `dist/cli-bun.js` | Bun 专用入口 |
|
||||
| `dist/chunk-*.js` | 代码分割 chunk 文件(约 668 个) |
|
||||
|
||||
### 运行方式
|
||||
|
||||
```bash
|
||||
# 方式 A:通过 bun 直接运行(开发/调试)
|
||||
bun run dev
|
||||
|
||||
# 方式 B:运行构建产物(bun 运行时)
|
||||
bun dist/cli.js
|
||||
|
||||
# 方式 C:运行构建产物(node 运行时)
|
||||
node dist/cli-node.js
|
||||
|
||||
# 方式 D:全局安装后使用命令名
|
||||
ccb
|
||||
```
|
||||
|
||||
### 全局安装
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```bash
|
||||
# bun 全局安装(推荐)
|
||||
bun install -g .
|
||||
|
||||
# 创建的命令:
|
||||
# ccb → dist/cli-node.js
|
||||
# ccb-bun → dist/cli-bun.js
|
||||
# claude-code-best → dist/cli-node.js
|
||||
|
||||
# 安装位置:~/.bun/bin/ccb
|
||||
```
|
||||
|
||||
或使用 npm:
|
||||
|
||||
```bash
|
||||
npm install -g .
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
ccb --version
|
||||
# → x.x.x (Claude Code)
|
||||
```
|
||||
|
||||
### 远端部署(全流程)
|
||||
|
||||
```bash
|
||||
# 1. 登录远端
|
||||
ssh my-server
|
||||
|
||||
# 2. 克隆或同步项目代码
|
||||
git clone <repo-url> ~/ccb-project
|
||||
cd ~/ccb-project
|
||||
|
||||
# 3. 安装运行时(如果没有 bun)
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
source ~/.bashrc
|
||||
|
||||
# 4. 安装依赖 + 构建
|
||||
bun install
|
||||
bun run build
|
||||
|
||||
# 5. 全局安装
|
||||
bun install -g .
|
||||
|
||||
# 6. 确保非交互式 SSH 可访问 ccb 命令
|
||||
# bun install -g 安装到 ~/.bun/bin/,但非交互式 SSH 不加载 .bashrc,
|
||||
# 所以 PATH 中不包含 ~/.bun/bin/
|
||||
# 解决方式(任选其一):
|
||||
|
||||
# 方式 A:符号链接到系统 PATH(推荐)
|
||||
ln -sf ~/.bun/bin/ccb /usr/local/bin/ccb
|
||||
|
||||
# 方式 B:添加到 /etc/profile.d/(所有用户生效)
|
||||
echo 'export PATH="$HOME/.bun/bin:$PATH"' > /etc/profile.d/bun-path.sh
|
||||
|
||||
# 方式 C:添加到 ~/.bash_profile(当前用户,ssh -t 时生效)
|
||||
echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.bash_profile
|
||||
|
||||
# 7. 验证
|
||||
ccb --version
|
||||
|
||||
# 8. 从本地测试
|
||||
# (在本地终端)
|
||||
ssh my-server -t ccb
|
||||
```
|
||||
|
||||
### SSH Remote 自动部署
|
||||
|
||||
使用 `ccb ssh <host>` 时,模块自动处理:
|
||||
|
||||
1. **SSHProbe** 探测远端 `~/.local/bin/claude` 或 `command -v claude`
|
||||
2. 若二进制不存在或版本不匹配,**SSHDeploy** 通过 `scp` 传输 `dist/` 目录
|
||||
3. 在远端创建 wrapper 脚本(`~/.local/bin/claude`)
|
||||
4. 无需手动安装
|
||||
|
||||
---
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
src/ssh/
|
||||
├── createSSHSession.ts — 会话工厂:编排 probe → deploy → proxy → spawn
|
||||
├── SSHSessionManager.ts — 双向 NDJSON 通信管理 + 权限转发 + 重连
|
||||
├── SSHAuthProxy.ts — 本地认证代理(API 凭据隧道)
|
||||
├── SSHProbe.ts — 远端主机探测(平台/架构/已有二进制)
|
||||
├── SSHDeploy.ts — 远端二进制部署(scp + wrapper 脚本)
|
||||
└── __tests__/
|
||||
└── SSHSessionManager.test.ts — 17 个单元测试
|
||||
```
|
||||
|
||||
## 关键技术细节
|
||||
|
||||
### 认证隧道
|
||||
|
||||
- **AuthProxy** 在本地监听(Unix socket 或 TCP),接收远端 CLI 的 API 请求
|
||||
- 通过 SSH `-R` 反向端口转发隧道到远端
|
||||
- AuthProxy 注入本地真实凭据(API key 或 OAuth token),转发到 `api.anthropic.com`
|
||||
- `ANTHROPIC_AUTH_NONCE` header 防止未授权访问(nonce 通过环境变量传递给远端 CLI,远端 CLI 在每个 API 请求中携带此 header)
|
||||
|
||||
### waitForInit vs 存活检查
|
||||
|
||||
- **标准模式**:`waitForInit` 等待远端 CLI 发送 `{type:'system', subtype:'init'}` JSON 消息
|
||||
- **`--remote-bin` 模式**:跳过 `waitForInit`(print+stream-json 模式下 init 只在首次查询后发送),改用 3 秒进程存活检查
|
||||
|
||||
### 重连机制
|
||||
|
||||
- `SSHSessionManager` 检测 SSH 连接断开后自动重连
|
||||
- 重连时在远端 CLI 命令中追加 `--continue` 恢复会话
|
||||
- 指数退避重试(最多 5 次,间隔 1s → 2s → 4s → 8s → 16s)
|
||||
|
||||
## Feature Flag
|
||||
|
||||
SSH Remote 功能受 `SSH_REMOTE` feature flag 控制:
|
||||
|
||||
- **Dev 模式**:默认启用
|
||||
- **Build 模式**:需在 `build.ts` 的 `DEFAULT_BUILD_FEATURES` 中添加 `'SSH_REMOTE'`
|
||||
- **运行时**:`FEATURE_SSH_REMOTE=1` 环境变量
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### `ccb: command not found`(SSH 远程执行时)
|
||||
|
||||
非交互式 SSH 不加载 `.bashrc`,`~/.bun/bin` 不在 PATH 中。
|
||||
|
||||
```bash
|
||||
# 解决:创建符号链接
|
||||
ln -sf ~/.bun/bin/ccb /usr/local/bin/ccb
|
||||
```
|
||||
|
||||
### SSH 密钥被拒绝
|
||||
|
||||
```
|
||||
Permission denied (publickey)
|
||||
```
|
||||
|
||||
1. 确认公钥已添加到远端 `~/.ssh/authorized_keys`
|
||||
2. 确认本地私钥文件权限正确(`chmod 600`)
|
||||
3. 确认 `~/.ssh/config` 中 `IdentityFile` 路径正确
|
||||
4. Windows 用户检查 ACL 权限(见上方 Windows 权限设置)
|
||||
|
||||
### SSH 连接超时
|
||||
|
||||
```
|
||||
ssh: connect to host x.x.x.x port 22: Connection timed out
|
||||
```
|
||||
|
||||
1. 确认远端 SSH 服务正在运行:`systemctl status sshd`
|
||||
2. 确认防火墙允许 22 端口
|
||||
3. 确认 IP 地址/域名正确
|
||||
4. 在 `~/.ssh/config` 中添加 `ConnectTimeout 10`
|
||||
|
||||
### 403 Forbidden(SSH Remote 模块)
|
||||
|
||||
AuthProxy 的 nonce 验证失败。确认:
|
||||
1. 远端 CLI 版本包含 nonce header 注入修复
|
||||
2. `ANTHROPIC_AUTH_NONCE` 环境变量正确传递到远端
|
||||
3. `src/services/api/client.ts` 中 `x-auth-nonce` header 已启用
|
||||
|
||||
### 远端 CLI 启动后立即退出
|
||||
|
||||
```
|
||||
Remote process exited immediately (code 1)
|
||||
```
|
||||
|
||||
1. 确认远端 `bun` / `node` 运行时可用
|
||||
2. 手动在远端执行 `ccb --version` 验证安装
|
||||
3. 检查 `--remote-bin` 路径是否正确
|
||||
4. 查看 stderr 输出获取详细错误信息
|
||||
@@ -1,27 +1,32 @@
|
||||
# VOICE_MODE — 语音输入
|
||||
|
||||
> Feature Flag: `FEATURE_VOICE_MODE=1`
|
||||
> 实现状态:完整可用(需要 Anthropic OAuth)
|
||||
> 实现状态:完整可用(双后端:Anthropic OAuth / 豆包 ASR)
|
||||
> 引用数:46
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
VOICE_MODE 实现"按键说话"(Push-to-Talk)语音输入。用户按住空格键录音,音频通过 WebSocket 流式传输到 Anthropic STT 端点(Nova 3),实时转录显示在终端中。
|
||||
VOICE_MODE 实现"按键说话"(Push-to-Talk)语音输入。用户按住空格键录音,音频流式传输到 STT 后端,实时转录显示在终端中。支持两个后端:
|
||||
|
||||
- **Anthropic STT(默认)**:通过 WebSocket 流式传输到 Nova 3 端点,需要 Anthropic OAuth
|
||||
- **豆包 ASR(Doubao)**:通过 `doubaoime-asr` 包的 AsyncGenerator 协议流式识别,使用独立凭证文件,无需 Anthropic OAuth
|
||||
|
||||
### 核心特性
|
||||
|
||||
- **Push-to-Talk**:长按空格键录音,释放后自动发送
|
||||
- **流式转录**:录音过程中实时显示中间转录结果
|
||||
- **无缝集成**:转录文本直接作为用户消息提交到对话
|
||||
- **双后端切换**:通过 `/voice` 命令参数选择 STT 后端,持久化到 settings.json
|
||||
|
||||
## 二、用户交互
|
||||
|
||||
| 操作 | 行为 |
|
||||
|------|------|
|
||||
| 长按空格 | 开始录音,显示录音状态 |
|
||||
| 释放空格 | 停止录音,等待最终转录 |
|
||||
| 转录完成 | 自动插入到输入框并提交 |
|
||||
| `/voice` 命令 | 切换语音模式开关 |
|
||||
| 释放空格 | 停止录音,转录结果自动提交 |
|
||||
| `/voice` | 切换语音模式开关(默认使用 Anthropic 后端) |
|
||||
| `/voice doubao` | 启用语音模式并使用豆包 ASR 后端 |
|
||||
| `/voice anthropic` | 切换回 Anthropic STT 后端 |
|
||||
|
||||
### UI 反馈
|
||||
|
||||
@@ -35,26 +40,37 @@ VOICE_MODE 实现"按键说话"(Push-to-Talk)语音输入。用户按住空
|
||||
|
||||
文件:`src/voice/voiceModeEnabled.ts`
|
||||
|
||||
三层检查:
|
||||
两层检查函数:
|
||||
|
||||
```ts
|
||||
// Anthropic 后端(需要 OAuth)
|
||||
isVoiceModeEnabled() = hasVoiceAuth() && isVoiceGrowthBookEnabled()
|
||||
|
||||
// 豆包后端 / 通用可用性检查(不需要 OAuth)
|
||||
isVoiceAvailable() = isVoiceGrowthBookEnabled()
|
||||
```
|
||||
|
||||
1. **Feature Flag**:`feature('VOICE_MODE')` — 编译时/运行时开关
|
||||
2. **GrowthBook Kill-Switch**:`!getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_quartz_disabled', false)` — 紧急关闭开关(默认 false = 未禁用)
|
||||
3. **Auth 检查**:`hasVoiceAuth()` — 需要 Anthropic OAuth token(非 API key)
|
||||
3. **Auth 检查(仅 Anthropic)**:`hasVoiceAuth()` — 需要 Anthropic OAuth token(非 API key)
|
||||
4. **Provider 检查**:`voiceProvider` 设置决定使用哪个后端,豆包后端跳过 OAuth 检查
|
||||
|
||||
### 3.2 核心模块
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| `src/voice/voiceModeEnabled.ts` | Feature flag + GrowthBook + Auth 三层门控 |
|
||||
| `src/hooks/useVoice.ts` | React hook 管理录音状态和 WebSocket 连接 |
|
||||
| `src/services/voiceStreamSTT.ts` | WebSocket 流式传输到 Anthropic STT |
|
||||
| `src/hooks/useVoice.ts` | React hook 管理录音状态和后端连接 |
|
||||
| `src/services/voiceStreamSTT.ts` | Anthropic WebSocket 流式 STT |
|
||||
| `src/services/doubaoSTT.ts` | 豆包 ASR 适配器(AsyncGenerator → VoiceStreamConnection) |
|
||||
| `src/commands/voice/voice.ts` | `/voice` 命令实现,处理后端选择和持久化 |
|
||||
| `src/hooks/useVoiceEnabled.ts` | 语音启用状态 hook,根据 provider 决定是否跳过 OAuth |
|
||||
| `src/utils/settings/types.ts` | `voiceProvider: 'anthropic' | 'doubao'` 设置类型定义 |
|
||||
|
||||
### 3.3 数据流
|
||||
|
||||
#### Anthropic 后端
|
||||
|
||||
```
|
||||
用户按下空格键
|
||||
│
|
||||
@@ -79,20 +95,108 @@ WebSocket 连接到 Anthropic STT 端点
|
||||
转录文本 → 插入输入框 → 自动提交
|
||||
```
|
||||
|
||||
#### 豆包 ASR 后端
|
||||
|
||||
```
|
||||
用户按下空格键
|
||||
│
|
||||
▼
|
||||
useVoice hook 激活(检测到 voiceProvider === 'doubao')
|
||||
│
|
||||
▼
|
||||
macOS 原生音频 / SoX 开始录音
|
||||
│
|
||||
▼
|
||||
connectDoubaoStream() 创建 AudioChunkQueue + VoiceStreamConnection
|
||||
│
|
||||
├──→ onReady 立即触发(无需等待握手)
|
||||
│
|
||||
▼
|
||||
音频数据通过 AudioChunkQueue 传入 transcribeRealtime()
|
||||
│
|
||||
├──→ INTERIM_RESULT → 实时显示中间转录
|
||||
├──→ FINAL_RESULT → 显示最终转录
|
||||
│
|
||||
▼
|
||||
用户释放空格键
|
||||
│
|
||||
▼
|
||||
finalize() 立即返回(豆包在录音过程中已返回结果,无需等待)
|
||||
│
|
||||
▼
|
||||
转录文本 → 插入输入框 → 自动提交
|
||||
```
|
||||
|
||||
### 3.4 音频录制
|
||||
|
||||
支持两种音频后端:
|
||||
支持两种音频后端(两个 STT 后端共享):
|
||||
- **macOS 原生音频**:优先使用,低延迟
|
||||
- **SoX(Sound eXchange)**:回退方案,跨平台
|
||||
|
||||
音频流通过 WebSocket 发送到 Anthropic 的 Nova 3 STT 模型。
|
||||
### 3.5 豆包 ASR 适配器设计
|
||||
|
||||
文件:`src/services/doubaoSTT.ts`
|
||||
|
||||
豆包后端使用适配器模式,将 `doubaoime-asr` 的 AsyncGenerator 协议桥接到 `VoiceStreamConnection` 接口:
|
||||
|
||||
**AudioChunkQueue** — push 式异步队列:
|
||||
- 实现 `AsyncIterable<Uint8Array>` 接口
|
||||
- `push(chunk)` 将音频数据入队,`push(null)` 发送结束信号
|
||||
- 内部维护等待者(waiting)和缓冲队列(chunks)两个状态
|
||||
|
||||
**connectDoubaoStream()** — 连接入口:
|
||||
- 动态导入 `doubaoime-asr`(optionalDependencies)
|
||||
- 从 `~/.claude/tts/doubao/credentials.json` 加载凭证
|
||||
- 创建 AudioChunkQueue 和 VoiceStreamConnection
|
||||
- 立即触发 `onReady`(避免与 useVoice 的音频缓冲死锁)
|
||||
- `finalize()` 立即返回(豆包在录音过程中已返回结果)
|
||||
- 后台 async IIFE 消费 `transcribeRealtime` generator,映射响应类型到回调
|
||||
|
||||
**响应类型映射**:
|
||||
|
||||
| doubaoime-asr ResponseType | 回调映射 |
|
||||
|----------------------------|----------|
|
||||
| SESSION_STARTED | 日志记录 |
|
||||
| VAD_START | 日志记录 |
|
||||
| INTERIM_RESULT | `onTranscript(text, false)` |
|
||||
| FINAL_RESULT | `onTranscript(text, true)` |
|
||||
| ERROR | `onError(errorMsg)` |
|
||||
| SESSION_FINISHED | 日志记录 |
|
||||
|
||||
### 3.6 后端选择逻辑
|
||||
|
||||
文件:`src/hooks/useVoice.ts`
|
||||
|
||||
```ts
|
||||
// 判断当前 provider
|
||||
isDoubaoProvider() → 读取 settings.voiceProvider
|
||||
|
||||
// handleKeyEvent 中的可用性检查
|
||||
const sttAvailable = isDoubaoProvider()
|
||||
? isDoubaoAvailableSync() // 乐观检查(首次返回 true)
|
||||
: isVoiceStreamAvailable() // Anthropic WebSocket 检查
|
||||
|
||||
// attemptConnect 中的连接函数选择
|
||||
const connectFn = isDoubaoProvider()
|
||||
? connectDoubaoStream
|
||||
: connectVoiceStream
|
||||
```
|
||||
|
||||
豆包后端的特殊处理:
|
||||
- 跳过 `getVoiceKeyterms()` 调用(豆包无需关键词提示)
|
||||
- 跳过 Focus Mode(`if (!enabled || !focusMode || isDoubaoProvider())`)
|
||||
|
||||
## 四、关键设计决策
|
||||
|
||||
1. **OAuth 独占**:语音模式使用 `voice_stream` 端点(claude.ai),仅 Anthropic OAuth 用户可用。API key、Bedrock、Vertex 用户无法使用
|
||||
2. **GrowthBook 负向门控**:`tengu_amber_quartz_disabled` 默认 `false`,新安装自动可用(无需等 GrowthBook 初始化)
|
||||
3. **Keychain 缓存**:`getClaudeAIOAuthTokens()` 首次调用访问 macOS keychain(~20-50ms),后续缓存命中
|
||||
4. **独立于主 feature flag**:`isVoiceGrowthBookEnabled()` 在 feature flag 关闭时短路返回 `false`,不触发任何模块加载
|
||||
1. **双后端共存**:豆包后端作为独立适配器与 Anthropic 后端并存,不替换原有流程,通过 `voiceProvider` 设置切换
|
||||
2. **设置持久化**:`voiceProvider` 存储在 `settings.json`,通过 `/voice` 命令修改,跨会话生效
|
||||
3. **OAuth 独占(Anthropic)**:Anthropic 后端使用 `voice_stream` 端点(claude.ai),仅 OAuth 用户可用
|
||||
4. **豆包无需 OAuth**:豆包后端使用独立凭证文件,不依赖 Anthropic 认证,通过 `isVoiceAvailable()` 放宽门控
|
||||
5. **GrowthBook 负向门控**:`tengu_amber_quartz_disabled` 默认 `false`,新安装自动可用
|
||||
6. **onReady 立即触发**:豆包后端在连接建立后立即触发 `onReady`,避免与 useVoice 音频缓冲的时序死锁(Anthropic 需要等待 WebSocket 握手)
|
||||
7. **finalize() 立即返回**:豆包在录音过程中已返回所有结果,用户抬手时无需等待处理
|
||||
8. **乐观可用性检查**:`isDoubaoAvailableSync()` 在首次调用时返回 `true`,实际导入错误在 `connectDoubaoStream` 中处理
|
||||
9. **optionalDependencies**:`doubaoime-asr` 作为可选依赖,安装失败不影响 Anthropic 后端
|
||||
|
||||
## 五、使用方式
|
||||
|
||||
@@ -100,26 +204,60 @@ WebSocket 连接到 Anthropic STT 端点
|
||||
# 启用 feature
|
||||
FEATURE_VOICE_MODE=1 bun run dev
|
||||
|
||||
# 在 REPL 中使用
|
||||
# 在 REPL 中使用 Anthropic 后端
|
||||
# 1. 确保已通过 OAuth 登录(claude.ai 订阅)
|
||||
# 2. 按住空格键说话
|
||||
# 3. 释放空格键等待转录
|
||||
# 4. 或使用 /voice 命令切换开关
|
||||
# 2. 输入 /voice 启用
|
||||
# 3. 按住空格键说话
|
||||
# 4. 释放空格键等待转录
|
||||
|
||||
# 在 REPL 中使用豆包 ASR 后端
|
||||
# 1. 确保 doubaoime-asr 已安装(bun add doubaoime-asr)
|
||||
# 2. 配置凭证文件:~/.claude/tts/doubao/credentials.json
|
||||
# 3. 输入 /voice doubao 启用
|
||||
# 4. 按住空格键说话
|
||||
# 5. 释放空格键,转录结果即刻显示
|
||||
|
||||
# 切换后端
|
||||
/voice doubao # 切换到豆包 ASR
|
||||
/voice anthropic # 切换回 Anthropic STT
|
||||
/voice # 关闭语音模式
|
||||
```
|
||||
|
||||
### 豆包凭证配置
|
||||
|
||||
凭证文件路径:`~/.claude/tts/doubao/credentials.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"deviceId": "...",
|
||||
"installId": "...",
|
||||
"cdid": "...",
|
||||
"openudid": "...",
|
||||
"clientudid": "...",
|
||||
"token": "..."
|
||||
}
|
||||
```
|
||||
|
||||
## 六、外部依赖
|
||||
|
||||
| 依赖 | 说明 |
|
||||
|------|------|
|
||||
| Anthropic OAuth | claude.ai 订阅登录,非 API key |
|
||||
| GrowthBook | `tengu_amber_quartz_disabled` 紧急关闭 |
|
||||
| macOS 原生音频 或 SoX | 音频录制 |
|
||||
| Nova 3 STT | 语音转文本模型 |
|
||||
| 依赖 | 说明 | 适用后端 |
|
||||
|------|------|----------|
|
||||
| Anthropic OAuth | claude.ai 订阅登录,非 API key | Anthropic |
|
||||
| GrowthBook | `tengu_amber_quartz_disabled` 紧急关闭 | 通用 |
|
||||
| macOS 原生音频 或 SoX | 音频录制 | 通用 |
|
||||
| Nova 3 STT | Anthropic 语音转文本模型 | Anthropic |
|
||||
| doubaoime-asr | 豆包 ASR SDK(optionalDependencies) | 豆包 |
|
||||
| 凭证文件 | `~/.claude/tts/doubao/credentials.json` | 豆包 |
|
||||
|
||||
## 七、文件索引
|
||||
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `src/voice/voiceModeEnabled.ts` | 54 | 三层门控逻辑 |
|
||||
| `src/hooks/useVoice.ts` | — | React hook(录音状态 + WebSocket) |
|
||||
| `src/services/voiceStreamSTT.ts` | — | STT WebSocket 流式传输 |
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/voice/voiceModeEnabled.ts` | 三层门控逻辑 + `isVoiceAvailable()` |
|
||||
| `src/hooks/useVoice.ts` | React hook(录音状态 + 后端选择 + 连接管理) |
|
||||
| `src/hooks/useVoiceEnabled.ts` | 语音启用状态 hook(按 provider 决定 OAuth 检查) |
|
||||
| `src/services/voiceStreamSTT.ts` | Anthropic STT WebSocket 流式传输 |
|
||||
| `src/services/doubaoSTT.ts` | 豆包 ASR 适配器(AudioChunkQueue + connectDoubaoStream) |
|
||||
| `src/commands/voice/voice.ts` | `/voice` 命令(开关 + 后端选择) |
|
||||
| `src/commands/voice/index.ts` | 命令注册(去除 availability 限制) |
|
||||
| `src/utils/settings/types.ts` | `voiceProvider` 类型定义 |
|
||||
|
||||
564
docs/internals/agent-comm-fix-jira-tasks.md
Normal file
564
docs/internals/agent-comm-fix-jira-tasks.md
Normal file
@@ -0,0 +1,564 @@
|
||||
# Agent 通讯修复 Jira Task
|
||||
|
||||
- 版本:v1.0
|
||||
- 生成日期:2026-04-25
|
||||
- 来源:由按文件执行清单、Claude 交叉验证意见整理合并
|
||||
- 范围:ACP Agent / Bridge / Remote Control Server / REPL Hook 生命周期
|
||||
- 使用方式:这是唯一执行任务文档;每个 `JIRA-*` 小节可直接拆成一个 Jira issue,字段保持统一,便于复制或二次导入。
|
||||
|
||||
---
|
||||
|
||||
## 方案性质
|
||||
|
||||
本文档是目标状态式执行方案,不是临时补丁清单。每张 ticket 必须交付明确的代码终态、测试覆盖和回归边界;不得只用局部 workaround 掩盖问题。
|
||||
|
||||
---
|
||||
|
||||
## 执行总则
|
||||
|
||||
1. 先边界安全,后内部优化:先修 WS 入站大小与输入校验,避免线上风险扩大。
|
||||
2. 单文件可回滚:每个文件内修改保持内聚,便于回滚与 bisect。
|
||||
3. 不改协议语义,只修实现缺陷:除 `resource_link` 表达形式统一外,不改变主流程契约。
|
||||
4. 每个文件必须有验收输出:要么测试用例,要么日志/指标验证。
|
||||
5. 发布前必须确认协议层行为无回归:`stopReason` 决策与 `sessionUpdate` 发送顺序保持稳定。
|
||||
|
||||
---
|
||||
|
||||
## Epic
|
||||
|
||||
### JIRA-EPIC-001:提升 Agent 通讯链路稳定性与边界安全
|
||||
|
||||
- Issue Type:Epic
|
||||
- Priority:P0
|
||||
- Owner:核心通讯 / 后端网关 / QA
|
||||
- Scope:ACP Agent、ACP Bridge、Remote Control Server、REPL 初始化生命周期
|
||||
- Goal:修复长会话资源泄漏、补齐 WebSocket 入站边界、统一 prompt 转换、收敛类型风险,并补充关键回归测试。
|
||||
|
||||
#### Epic 验收标准
|
||||
|
||||
- `bun run typecheck` 0 error。
|
||||
- P0 WebSocket 超大消息拒绝逻辑已实现并覆盖测试。
|
||||
- ACP bridge abort listener 生命周期无累积。
|
||||
- prompt 转换实现单源化。
|
||||
- settings/defaultMode 能真实影响 ACP permission mode,且 `_meta.permissionMode` 保持最高优先级。
|
||||
- REPL 目标 hook suppress 清理完成,timer cleanup 完整。
|
||||
|
||||
---
|
||||
|
||||
## P0 Tickets
|
||||
|
||||
### JIRA-001:为 session ingress WebSocket 补齐消息大小限制
|
||||
|
||||
- Issue Type:Bug
|
||||
- Priority:P0
|
||||
- Story Points:3
|
||||
- Owner:后端/网关
|
||||
- Files:
|
||||
- `packages/remote-control-server/src/routes/v1/session-ingress.ts`
|
||||
- 后续票:JIRA-008(同文件 P1 类型与 decode path 收尾)
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `packages/remote-control-server/src/routes/v1/session-ingress.ts:100-106`
|
||||
|
||||
#### 背景
|
||||
|
||||
`session-ingress` 当前缺少 WebSocket message size limit。ACP 路由已有类似限制,两个入口边界不一致,可能导致大包占用内存或绕过入口保护。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 新增 `MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024`,与 ACP 路由的 10MB 上限保持一致。
|
||||
- 在 `onMessage` decode 后优先检查 payload size。
|
||||
- 超限时执行 `ws.close(1009, "message too large")`。
|
||||
- 日志记录 `sessionId`、payload size、limit。
|
||||
- 对 `string`、`ArrayBuffer`、`Uint8Array` 进行统一 decode 分流。
|
||||
- 非支持类型直接拒绝并记录,不进入业务 handler。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 11MB payload 被 1009 close。
|
||||
- 1KB 合法 payload 仍正常进入 handler。
|
||||
- 非支持类型 payload 不进入 handler。
|
||||
- 不改变 URL、auth、session 解析逻辑。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- Remote Control Server session ingress WebSocket。
|
||||
- 正常会话消息转发。
|
||||
- WebSocket close code 行为。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。入口逻辑变更可能影响特殊客户端 payload 类型。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 在 `packages/remote-control-server/src/__tests__/routes.test.ts` 增加 session-ingress WebSocket 大包、小包、坏类型 payload 用例。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-002:修复 ACP bridge abort listener 生命周期泄漏
|
||||
|
||||
- Issue Type:Bug
|
||||
- Priority:P0
|
||||
- Story Points:3
|
||||
- Owner:核心通讯
|
||||
- Files:
|
||||
- `src/services/acp/bridge.ts`
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `src/services/acp/bridge.ts:576-585`
|
||||
|
||||
#### 背景
|
||||
|
||||
ACP bridge 的 `Promise.race` abort 分支注册 listener 后缺少完整 cleanup。长会话或高频 next 场景可能出现 listener 累积。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 将 abort race 改为可清理监听器写法。
|
||||
- 注册 listener 后保留 handler 引用。
|
||||
- `sdkMessages.next()` 先返回时必须 `removeEventListener`。
|
||||
- abort、throw、return 等路径都在 `finally` 中清理。
|
||||
- 不改变 `stopReason` 决策逻辑。
|
||||
- 不改变 `sessionUpdate` 发送顺序。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 模拟 10k 次 next 且不 abort,listener 不增长。
|
||||
- abort 场景仍返回 `cancelled`。
|
||||
- 原有 streaming/session update 行为无回归。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP bridge streaming loop。
|
||||
- 用户取消请求。
|
||||
- SDK generator 异常路径。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。异步控制流变更需要覆盖取消与异常路径。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 新增 listener cleanup 单元测试。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
## P1 Tickets
|
||||
|
||||
### JIRA-003:优化 ACP agent pending prompt 队列为 O(1) 出队
|
||||
|
||||
- Issue Type:Task
|
||||
- Priority:P1
|
||||
- Story Points:5
|
||||
- Owner:核心通讯
|
||||
- Files:
|
||||
- `src/services/acp/agent.ts`
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `src/services/acp/agent.ts:332-339`
|
||||
|
||||
#### 背景
|
||||
|
||||
当前 pending prompt 队列使用 `Map + sort` 获取下一项,排队量上升时会带来不必要的排序成本。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 改为 `queue: string[]` + `pendingMap: Map<string, PendingPrompt>` 组合。
|
||||
- 入队执行 `queue.push(id)` 与 `pendingMap.set(id, prompt)`。
|
||||
- 出队从队首惰性跳过已取消项。
|
||||
- 取消只从 `pendingMap` 删除,不做数组中间删除。
|
||||
- 保持现有取消语义和出队顺序。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 1000 pending prompt 场景下出队顺序正确。
|
||||
- 已取消 prompt 不会被 resolve。
|
||||
- 出队不再依赖全量 sort。
|
||||
- 1000 排队场景下出队耗时低于旧实现;测试记录旧实现复杂度风险和新实现 O(1) 出队路径。
|
||||
- 行为与旧实现兼容。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP prompt queue。
|
||||
- 并发 prompt 请求。
|
||||
- prompt cancel / resolve 边界。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。队列结构变更可能引入取消边界问题。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 新增 queue 顺序与取消测试。
|
||||
- 对 1000 prompt 场景做性能断言或日志记录。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-004:接入真实 settings 读取并校验 ACP permission mode
|
||||
|
||||
- Issue Type:Bug
|
||||
- Priority:P1
|
||||
- Story Points:3
|
||||
- Owner:核心通讯
|
||||
- Files:
|
||||
- `src/services/acp/agent.ts`
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `src/services/acp/agent.ts:465-467`
|
||||
|
||||
#### 背景
|
||||
|
||||
`getSetting()` 当前未真正接入项目配置,导致默认 permission mode 配置无法按预期生效。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 接入项目现有 settings/config 读取逻辑。
|
||||
- 仅接受合法 permission mode 枚举值。
|
||||
- 非法值 fallback 到 `default`。
|
||||
- `_meta.permissionMode` 继续保持最高优先级。
|
||||
- 不改变外部协议字段。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- settings/defaultMode 能影响默认 permission mode。
|
||||
- `_meta.permissionMode` 能覆盖 settings。
|
||||
- 非法 settings 值不会传播到运行时。
|
||||
- 类型检查通过。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP agent session 初始化。
|
||||
- 权限模式同步。
|
||||
- 客户端 `_meta` 覆盖逻辑。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。配置优先级错误会影响权限行为。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 新增 defaultMode / `_meta.permissionMode` 优先级测试。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-005:单源化 ACP prompt 转换逻辑
|
||||
|
||||
- Issue Type:Refactor
|
||||
- Priority:P1
|
||||
- Story Points:5
|
||||
- Owner:核心通讯
|
||||
- Files:
|
||||
- `src/services/acp/agent.ts`
|
||||
- `src/services/acp/bridge.ts`
|
||||
- `src/services/acp/promptConversion.ts`(新增)
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `src/services/acp/agent.ts:754-758`
|
||||
- `src/services/acp/agent.ts:764-785`
|
||||
- `src/services/acp/bridge.ts:522-537`
|
||||
|
||||
#### 背景
|
||||
|
||||
ACP agent 与 bridge 存在重复 prompt 转换逻辑,`resource_link` 等 block 的输出策略容易分叉。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 新增共享转换模块 `src/services/acp/promptConversion.ts`。
|
||||
- `agent.ts` 与 `bridge.ts` 改为调用共享转换函数。
|
||||
- 删除 `bridge.ts` 中 `promptToQueryContent` 的真实实现;如导出仍需保留,则只允许保留调用共享函数的 wrapper。
|
||||
- `resource_link` 输出改为稳定纯文本元信息,禁止 markdown link。
|
||||
- 保持其他 block 转换语义不变。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 全仓库仅保留一个真实 prompt 转换实现。
|
||||
- 相同 input block 在 agent/bridge 输出一致。
|
||||
- `resource_link` 不再输出 `[name](uri)` 形式。
|
||||
- 相关测试覆盖转换一致性。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP prompt input。
|
||||
- bridge query content。
|
||||
- resource link prompt 表达。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。文本格式变化可能影响下游 prompt 快照或断言。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 新增 shared conversion 单元测试。
|
||||
- 全仓库搜索重复转换函数。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-006:治理 REPL onInit effect 依赖并补齐 timer cleanup
|
||||
|
||||
- Issue Type:Task
|
||||
- Priority:P1
|
||||
- Story Points:3
|
||||
- Owner:终端 UI
|
||||
- Files:
|
||||
- `src/screens/REPL.tsx`
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `src/screens/REPL.tsx:654-662`
|
||||
- `src/screens/REPL.tsx:4996-5005`
|
||||
|
||||
#### 背景
|
||||
|
||||
REPL 中目标初始化 effect 存在 hook dependency suppress,warm-up timer 也需要显式 cleanup,避免频繁挂载/卸载时留下悬挂任务。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 整理 `onInit` 生命周期,使用稳定引用或 effect 内联。
|
||||
- 移除目标段 `exhaustive-deps` suppress。
|
||||
- 保持 unmount cleanup 行为不变。
|
||||
- warm-up effect 中记录 timeout id。
|
||||
- cleanup 中执行 `clearTimeout(timeoutId)`。
|
||||
- 保留 `alive` 判定作为并发保护。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 目标段不再需要 hooks lint suppress。
|
||||
- 高频打开/关闭搜索栏无悬挂 timer 增长。
|
||||
- REPL 初始化行为无回归。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- REPL 初始化。
|
||||
- 搜索栏 warm-up。
|
||||
- 组件卸载 cleanup。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。React effect 依赖治理可能改变初始化时机。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 运行 lint/typecheck。
|
||||
- 手动或测试覆盖 REPL mount/unmount。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-007:收敛 ACP route WebSocket 事件 any 类型
|
||||
|
||||
- Issue Type:Task
|
||||
- Priority:P1
|
||||
- Story Points:2
|
||||
- Owner:后端/网关
|
||||
- Files:
|
||||
- `packages/remote-control-server/src/routes/acp/index.ts`
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `packages/remote-control-server/src/routes/acp/index.ts:108-146`
|
||||
|
||||
#### 背景
|
||||
|
||||
ACP route 中 WebSocket 事件和 socket 参数存在 `any`,降低编译期保护。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 定义最小 WebSocket 事件类型:open/message/close/error。
|
||||
- 将 `_evt: any`、`evt: any`、`ws: any` 替换为窄类型。
|
||||
- 不改变 payload decode 与大小检查策略。
|
||||
- 不改变现有 handler 行为。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 编译期能捕获错误事件字段访问。
|
||||
- 现有 WebSocket 行为不变。
|
||||
- `bun run typecheck` 通过。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP WebSocket route。
|
||||
- message decode。
|
||||
- close/error handler。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 低。类型收敛为主。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 运行 `bun run typecheck`。
|
||||
- 保留现有测试通过。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-008:收敛 session ingress WebSocket 事件类型与 decode path
|
||||
|
||||
- Issue Type:Task
|
||||
- Priority:P1
|
||||
- Story Points:3
|
||||
- Owner:后端/网关
|
||||
- Files:
|
||||
- `packages/remote-control-server/src/routes/v1/session-ingress.ts`
|
||||
- 前置依赖:JIRA-001 已合并
|
||||
|
||||
#### 参考代码位置
|
||||
|
||||
- `packages/remote-control-server/src/routes/v1/session-ingress.ts:100-106`
|
||||
|
||||
#### 背景
|
||||
|
||||
在完成 P0 size guard 后,session ingress 仍需要进一步收敛事件类型与 decode path,减少隐式类型风险。
|
||||
|
||||
#### 实施要求
|
||||
|
||||
- 定义或复用最小 WebSocket message event 类型。
|
||||
- 将 message decode 分支集中到一个小函数。
|
||||
- 保持 P0 size guard 与 close code 语义。
|
||||
- 不改变 auth/session 解析。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- decode path 单一清晰。
|
||||
- 不支持 payload 类型有明确拒绝路径。
|
||||
- `bun run typecheck` 通过。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- Session ingress WebSocket message handling。
|
||||
- P0 大包拒绝逻辑。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 低到中。与 P0 同文件,注意避免重复改动冲突。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 与 JIRA-001 同批测试。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
## QA Tickets
|
||||
|
||||
### JIRA-009:补充 ACP 通讯回归测试
|
||||
|
||||
- Issue Type:Test
|
||||
- Priority:P1
|
||||
- Story Points:5
|
||||
- Owner:QA/核心通讯
|
||||
- Files:
|
||||
- `src/services/acp/agent.ts`
|
||||
- `src/services/acp/bridge.ts`
|
||||
- `src/services/acp/promptConversion.ts`
|
||||
- `src/services/acp/__tests__/agent.test.ts`
|
||||
- `src/services/acp/__tests__/bridge.test.ts`
|
||||
- `src/services/acp/__tests__/promptConversion.test.ts`
|
||||
|
||||
#### 覆盖场景
|
||||
|
||||
- 长会话 10k turn,无 abort listener 累积。
|
||||
- prompt queue 1000 并发排队,取消/出队顺序正确。
|
||||
- settings/defaultMode 与 `_meta.permissionMode` 优先级正确。
|
||||
- `resource_link` 转换在 agent 与 bridge 输出一致。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 新增测试在本地稳定通过。
|
||||
- 不依赖真实网络或外部服务。
|
||||
- 测试 mock 遵守仓库规范,只 mock 有副作用链路。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- ACP bridge。
|
||||
- ACP agent。
|
||||
- prompt conversion。
|
||||
- permission mode resolution。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。异步测试可能有稳定性问题,需要避免时间敏感断言。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 运行相关 `bun test`。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
### JIRA-010:补充 Remote Control Server WebSocket 入站回归测试
|
||||
|
||||
- Issue Type:Test
|
||||
- Priority:P1
|
||||
- Story Points:3
|
||||
- Owner:QA/后端
|
||||
- Files:
|
||||
- `packages/remote-control-server/src/__tests__/routes.test.ts`
|
||||
- `packages/remote-control-server/src/routes/v1/session-ingress.ts`
|
||||
|
||||
#### 覆盖场景
|
||||
|
||||
- 11MB session ingress payload 被 1009 close(与 10MB 上限对齐)。
|
||||
- 合法小 payload 正常进入 handler。
|
||||
- 非支持 payload 类型被拒绝。
|
||||
- 日志或可观测输出包含 sessionId、payload size、limit。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
- 11MB payload 被 1009 close(与 10MB 上限对齐)。
|
||||
- 新增测试稳定通过。
|
||||
- 不启动真实外部服务。
|
||||
- 不改变现有 route public contract。
|
||||
|
||||
#### 回归范围
|
||||
|
||||
- RCS session ingress route。
|
||||
- WebSocket message handling。
|
||||
- close code 行为。
|
||||
|
||||
#### 风险等级
|
||||
|
||||
- 中。测试需要适配现有 WebSocket/mock 基础设施。
|
||||
|
||||
#### 必须验证
|
||||
|
||||
- 运行 RCS package 相关测试。
|
||||
- 运行 `bun run typecheck`。
|
||||
|
||||
---
|
||||
|
||||
## 推荐执行顺序
|
||||
|
||||
执行节奏与原计划保持一致:先完成 P0 全部改动和冒烟验证,再启动 P1 改造;测试票可穿插执行,但不得绕过 P0 gate。
|
||||
|
||||
1. JIRA-001:先封入口大包风险。
|
||||
2. JIRA-002:修长会话 listener 生命周期。
|
||||
3. JIRA-010:补 RCS 入站测试,锁住 P0 行为。
|
||||
4. JIRA-003:优化 pending prompt queue。
|
||||
5. JIRA-004:接入 settings/defaultMode。
|
||||
6. JIRA-005:单源化 prompt 转换。
|
||||
7. JIRA-009:补 ACP 回归测试。
|
||||
8. JIRA-006:治理 REPL effect/timer。
|
||||
9. JIRA-007:收敛 ACP route 类型。
|
||||
10. JIRA-008:收敛 session ingress 类型与 decode path。
|
||||
|
||||
---
|
||||
|
||||
## Release Checklist
|
||||
|
||||
- [ ] `bun run typecheck` 0 error
|
||||
- [ ] P0 tickets 已合并并测试通过
|
||||
- [ ] ACP 回归测试通过
|
||||
- [ ] RCS WebSocket 入站测试通过
|
||||
- [ ] prompt conversion 单源化已通过代码搜索确认
|
||||
- [ ] permission mode 优先级测试通过
|
||||
- [ ] 协议层行为无回归(stopReason 决策、sessionUpdate 发送顺序)
|
||||
- [ ] REPL hook/timer 改动通过 lint/typecheck
|
||||
- [ ] 最终变更说明包含风险与未覆盖项
|
||||
74
docs/internals/agent-comm-fix-questions.md
Normal file
74
docs/internals/agent-comm-fix-questions.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Agent 通讯修复问题文档
|
||||
|
||||
- 版本:v1.0
|
||||
- 生成日期:2026-04-25
|
||||
- 范围:ACP Agent / Bridge / Remote Control Server / REPL Hook 生命周期
|
||||
- 配套执行文档:`docs/internals/agent-comm-fix-jira-tasks.md`
|
||||
- 目的:保留决策前要问的问题、交叉验证提示词和已确认结论;不要在这里写 Jira 执行步骤。
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前已确认结论
|
||||
|
||||
- 只保留两份交付文档:本问题文档 + Jira Task 文档。
|
||||
- Jira Task 文档是唯一执行入口,包含 Owner、优先级、文件范围、验收标准、风险和验证建议。
|
||||
- Claude 交叉验证结论:整体通过,无 blocking findings;建议补充协议回归 gate、JIRA-001/008 依赖、代码参考位置和阈值一致性,这些建议已合并到 Jira Task 文档。
|
||||
- 本次已进入业务代码修复阶段,必须运行 `bun run typecheck` 和相关回归测试。
|
||||
|
||||
---
|
||||
|
||||
## 2. 执行前必须问清的问题
|
||||
|
||||
1. `session-ingress` 的 WebSocket 上限是否固定为 10MB,并与 ACP route 保持一致?
|
||||
2. 超限 close code 是否统一使用 `1009`,close reason 是否固定为 `message too large`?
|
||||
3. `resource_link` 的纯文本格式是否已有下游依赖,能否替代当前 markdown link 表达?
|
||||
4. ACP permission mode 的真实 settings key 是哪个,非法值 fallback 是否统一为 `default`?
|
||||
5. `_meta.permissionMode` 是否必须始终覆盖 settings/defaultMode?
|
||||
6. abort listener 测试中,是否能通过 mock signal 或计数器稳定证明 10k next 后无 listener 累积?
|
||||
7. pending prompt queue 的取消语义是否允许惰性清理,而不是立刻从数组中删除?
|
||||
8. REPL hook suppress 的清理范围是否只限目标段,不顺手改其他 decompiled React Compiler 结构?
|
||||
9. RCS WebSocket 测试应放在现有哪个 `__tests__` 布局下,是否已有 route/mock 基础设施可复用?
|
||||
10. 发布 gate 是否必须包含 `stopReason` 决策与 `sessionUpdate` 发送顺序不回归?
|
||||
|
||||
---
|
||||
|
||||
## 3. 给 Claude 或 Reviewer 的复核问题
|
||||
|
||||
```text
|
||||
请作为外部审查者,复核 docs/internals/agent-comm-fix-jira-tasks.md。
|
||||
|
||||
请检查:
|
||||
1. 是否仍满足“按文件分工的执行清单”和“Jira task 文档”要求。
|
||||
2. 是否存在遗漏的文件、验收标准、风险或前置依赖。
|
||||
3. 是否有重复、误导执行者、优先级不合理或测试不可落地的问题。
|
||||
4. 是否还有必须阻断实施的 finding。
|
||||
|
||||
请用中文输出:
|
||||
- Verdict
|
||||
- Blocking Findings
|
||||
- Non-blocking Findings
|
||||
- Suggested Edits
|
||||
- Final Recommendation
|
||||
|
||||
不要修改文件,只输出审查意见。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 已处理的复核建议
|
||||
|
||||
- Release Checklist 已补充协议层行为无回归 gate。
|
||||
- JIRA-001 与 JIRA-008 已明确同文件前后置关系。
|
||||
- JIRA-001 到 JIRA-008 已补充参考代码位置。
|
||||
- JIRA-003 已补回 1000 排队场景下的出队耗时验收。
|
||||
- JIRA-008 story points 已从 2 调整为 3。
|
||||
- JIRA-010 已明确 11MB payload 对齐 10MB 上限并触发 1009 close。
|
||||
- 推荐执行顺序已明确 P0 gate:P0 全部改动和冒烟验证完成后,再启动 P1 改造。
|
||||
|
||||
---
|
||||
|
||||
## 5. 不在本文档维护的内容
|
||||
|
||||
- 不维护 Jira ticket 正文;统一在 `docs/internals/agent-comm-fix-jira-tasks.md` 修改。
|
||||
- 不维护业务代码实现方案;实现时按具体 ticket 读取对应文件。
|
||||
- 不维护历史中间稿;旧执行清单已合并进 Jira Task 文档。
|
||||
@@ -1,31 +1,53 @@
|
||||
---
|
||||
title: "Ant 特权世界"
|
||||
description: "Anthropic 内部构建与公开发布版本的差异。理解身份门控机制、Ant-Only 工具/命令和 Beta Header 的分层设计。"
|
||||
keywords: ["Ant 特权", "USER_TYPE", "身份门控", "内部功能"]
|
||||
title: "Ant 特权世界 - Anthropic 员工专属功能"
|
||||
description: "完整记录 Claude Code 身份门控层:USER_TYPE === 'ant' 时解锁的专属工具、命令、API 和代号体系,揭示内外部构建的差异。"
|
||||
keywords: ["Ant 特权", "USER_TYPE", "身份门控", "内部功能", "Anthropic 员工"]
|
||||
---
|
||||
|
||||
{/* 本章目标:完整记录身份门控层——ant 构建独享的一切 */}
|
||||
|
||||
## 什么是 Ant
|
||||
|
||||
Claude Code 有两种构建:Anthropic 员工使用的内部构建(`USER_TYPE === 'ant'`)和公开发布的外部构建(`USER_TYPE === 'external'`)。
|
||||
`USER_TYPE` 是一个构建时常量,通过 Bun 打包器的 `--define` 注入。在 Anthropic 的内部构建中它被设为 `'ant'`,在公开发布的版本中是 `'external'`:
|
||||
|
||||
`USER_TYPE` 是构建时常量,通过 Bun 的 `--define` 注入。外部构建中,所有 `process.env.USER_TYPE === 'ant'` 判断被编译器折叠为 `false`,后续代码被死代码消除(DCE)移除。
|
||||
```typescript
|
||||
// 反编译版本(src/types/global.d.ts 第 63 行)
|
||||
// Build-time constants BUILD_TARGET/BUILD_ENV/INTERFACE_TYPE — removed (zero runtime usage)
|
||||
```
|
||||
|
||||
这个门控在代码库中出现 410+ 处,控制着工具、命令、API、UI 等方方面面。
|
||||
`BUILD_TARGET` 等构建时常量在反编译版本中已被移除。`USER_TYPE` 通过 Bun 的 `--define` 或环境变量注入,Bun 会进行**常量折叠**——所有 `process.env.USER_TYPE === 'ant'` 在外部构建中直接变为 `false`,后续代码被 DCE 移除。但在反编译版本中,这些代码保留完整。
|
||||
|
||||
`USER_TYPE === 'ant'` 在代码库中出现 **351+ 次**(跨 163 个文件),另有 `!== 'ant'` 59 次(跨 38 个文件),总计 **410+ 处引用**,控制着工具、命令、API、UI 等方方面面。
|
||||
|
||||
## Ant-Only 工具
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| **REPLTool** | 高级 REPL 模式——在 VM 中包装其他工具 |
|
||||
| **ConfigTool** | 交互式配置编辑器,包含 Gates 标签页覆盖 feature flags |
|
||||
| **SuggestBackgroundPRTool** | 建议在后台创建 PR |
|
||||
| **TungstenTool** | 基于 tmux 的终端面板工具 |
|
||||
以下工具仅在内部构建中被加载到工具注册表:
|
||||
|
||||
**设计考量**:这些工具要么涉及内部基础设施(如 GrowthBook flag 覆盖),要么需要 Anthropic 特有的 API 支持。对外部用户暴露它们没有意义——甚至可能引起混淆。
|
||||
| 工具 | 代码位置 | 用途 |
|
||||
|------|---------|------|
|
||||
| **REPLTool** | `packages/builtin-tools/src/tools/REPLTool/` | 高级 REPL 模式——在 VM 中包装 Bash/Read/Edit/Glob/Grep/Agent 等工具 |
|
||||
| **SuggestBackgroundPRTool** | `packages/builtin-tools/src/tools/SuggestBackgroundPRTool/` | 建议在后台创建 PR |
|
||||
| **ConfigTool** | `packages/builtin-tools/src/tools/ConfigTool/` | 交互式配置编辑器,包含 Gates 标签页用于覆盖 GrowthBook flags |
|
||||
| **TungstenTool** | `packages/builtin-tools/src/tools/TungstenTool/` | 基于 tmux 的终端面板工具(反编译版中已 stub) |
|
||||
|
||||
```typescript
|
||||
// src/tools.ts 第 14-24 行——条件导入 + Dead Code Elimination 标记
|
||||
// Dead code elimination: conditional import for ant-only tools
|
||||
/* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
|
||||
const REPLTool =
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? require('@claude-code-best/builtin-tools/tools/REPLTool/REPLTool.js').REPLTool
|
||||
: null
|
||||
const SuggestBackgroundPRTool =
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? require('@claude-code-best/builtin-tools/tools/SuggestBackgroundPRTool/SuggestBackgroundPRTool.js')
|
||||
.SuggestBackgroundPRTool
|
||||
: null
|
||||
```
|
||||
|
||||
## Ant-Only 命令
|
||||
|
||||
内部构建注册了 24+ 个额外的斜杠命令,覆盖调试、实验、工作流和基础设施:
|
||||
`src/commands.ts` 注册了 **24+** 个仅在内部构建中可用的斜杠命令(`INTERNAL_ONLY_COMMANDS`,lines 267-295),在 `USER_TYPE === 'ant' && !IS_DEMO` 时才加载(line 400-401):
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="调试类">
|
||||
@@ -34,6 +56,14 @@ Claude Code 有两种构建:Anthropic 员工使用的内部构建(`USER_TYPE
|
||||
- `debugToolCall` — 调试工具调用
|
||||
- `env` — 显示环境变量
|
||||
- `mockLimits` — 模拟速率限制
|
||||
- `resetLimits` — 重置速率限制
|
||||
- `resetLimitsNonInteractive` — 重置速率限制(非交互式)
|
||||
</Accordion>
|
||||
<Accordion title="实验类">
|
||||
- `bughunter` — Bug 猎人模式
|
||||
- `goodClaude` — 质量评估工具
|
||||
- `antTrace` — 追踪分析
|
||||
- `perfIssue` — 性能问题诊断
|
||||
</Accordion>
|
||||
<Accordion title="工作流类">
|
||||
- `commit` — 快速提交
|
||||
@@ -42,75 +72,139 @@ Claude Code 有两种构建:Anthropic 员工使用的内部构建(`USER_TYPE
|
||||
- `autofixPr` — 自动修复 PR 中的问题
|
||||
- `share` — 分享会话
|
||||
- `summary` — 生成摘要
|
||||
- `subscribePr` — 订阅 PR(需要 `KAIROS_GITHUB_WEBHOOKS` feature flag)
|
||||
- `forceSnip` — 强制截断历史(需要 `HISTORY_SNIP` feature flag)
|
||||
- `ultraplan` — 超级规划(需要 `ULTRAPLAN` feature flag,单独注册于 `commands.ts:396`)
|
||||
</Accordion>
|
||||
<Accordion title="基础设施类">
|
||||
- `backfillSessions` — 回填会话数据
|
||||
- `bridgeKick` — 重启 Bridge 连接
|
||||
- `oauthRefresh` — 刷新 OAuth Token
|
||||
- `teleport` — 传送到指定上下文
|
||||
- `onboarding` — 新手引导
|
||||
- `agentsPlatform` — Agents 平台管理
|
||||
- `version` — 内部版本详情
|
||||
- `initVerifiers` — 初始化验证器
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
这些命令在演示模式(`IS_DEMO`)下也被隐藏,防止在公开演示中暴露内部功能。
|
||||
<Note>
|
||||
这些命令在 `IS_DEMO` 模式下也会被隐藏,防止在演示环境中暴露内部功能。
|
||||
</Note>
|
||||
|
||||
## Beta API Headers 的分层
|
||||
## Beta API Headers
|
||||
|
||||
Claude Code 向 API 发送的 beta headers 按可见性分为多层:
|
||||
Claude Code 向 API 发送的 beta headers 分布在 `src/constants/betas.ts`(主注册表)和其他文件中,按可见性分为以下几类:
|
||||
|
||||
### 公开 Headers(所有构建)
|
||||
### 公开 Headers(所有构建均发送)
|
||||
|
||||
| Header | 功能 |
|
||||
|--------|------|
|
||||
| `claude-code-20250219` | Claude Code 标识 |
|
||||
| `effort-2025-11-24` | 推理强度控制 |
|
||||
| `interleaved-thinking-2025-05-14` | 交错思考模式 |
|
||||
| `context-1m-2025-08-07` | 1M 上下文窗口 |
|
||||
| Header | 功能 | 额外条件 |
|
||||
|--------|------|----------|
|
||||
| `claude-code-20250219` | Claude Code 标识 | 非 Haiku 时始终发送;Haiku 在 agentic 模式下也发送 |
|
||||
| `effort-2025-11-24` | 推理强度控制 | 动态注入 |
|
||||
| `task-budgets-2026-03-13` | 任务预算 | 始终通过 `addAgenticBetas()` 注入 |
|
||||
| `fast-mode-2026-02-01` | 快速模式 | 通过 sticky-on latch 动态注入 |
|
||||
| `advisor-tool-2026-03-01` | 顾问工具 | 启用 advisor 时动态注入 |
|
||||
| `advanced-tool-use-2025-11-20` | 工具搜索(1P) | Claude API / Foundry |
|
||||
| `tool-search-tool-2025-10-19` | 工具搜索(3P) | Vertex / Bedrock |
|
||||
|
||||
### 模型能力相关(有条件发送)
|
||||
|
||||
| Header | 功能 | 条件 |
|
||||
|--------|------|------|
|
||||
| `interleaved-thinking-2025-05-14` | 交错思考模式 | 模型支持 ISP 且未禁用 |
|
||||
| `context-1m-2025-08-07` | 1M 上下文窗口 | 模型支持 1M context |
|
||||
| `context-management-2025-06-27` | 上下文管理 | Claude 4+ 或 ant 手动启用 |
|
||||
| `structured-outputs-2025-12-15` | 结构化输出 | Claude 4.5/4.6 + GrowthBook `tengu_tool_pear` |
|
||||
| `web-search-2025-03-05` | 网页搜索 | Vertex (Claude 4+) / Foundry |
|
||||
| `redact-thinking-2026-02-12` | 思维摘要/脱敏 | ISP 模型 + 非交互 + 未强制显示思维 |
|
||||
| `prompt-caching-scope-2026-01-05` | 提示缓存作用域 | firstParty/foundry + 全局缓存 |
|
||||
|
||||
### Ant-Only Headers
|
||||
|
||||
| Header | 功能 |
|
||||
|--------|------|
|
||||
| `cli-internal-2026-02-09` | 内部 CLI 功能 |
|
||||
| `token-efficient-tools-2026-03-28` | Token 高效工具 |
|
||||
| Header | 功能 | 条件 |
|
||||
|--------|------|------|
|
||||
| **`cli-internal-2026-02-09`** | 内部 CLI 功能 | `USER_TYPE === 'ant'` + CLI 入口 |
|
||||
| **`token-efficient-tools-2026-03-28`** | Token 高效工具 | `USER_TYPE === 'ant'` + GrowthBook `tengu_amber_json_tools` |
|
||||
|
||||
**设计洞察**:`cli-internal` header 说明 Anthropic 的 API 服务端也维护着 ant-only 的行为——这不只是客户端门控,而是端到端的功能隔离。
|
||||
### Feature Flag Gated
|
||||
|
||||
| Header | 功能 | 条件 |
|
||||
|--------|------|------|
|
||||
| **`afk-mode-2026-01-31`** | AFK 模式(离开键盘自动审批) | `feature('TRANSCRIPT_CLASSIFIER')` |
|
||||
|
||||
### 其他特殊 Headers
|
||||
|
||||
| Header | 功能 | 来源 |
|
||||
|--------|------|------|
|
||||
| `oauth-2025-04-20` | OAuth 订阅者标识 | `src/constants/oauth.ts`,Pro/Max/Team/Enterprise |
|
||||
| `environments-2025-11-01` | Bridge 环境 API | `src/bridge/bridgeApi.ts`,仅 Bridge 模式 |
|
||||
|
||||
```typescript
|
||||
// src/constants/betas.ts — 常量定义
|
||||
export const TOKEN_EFFICIENT_TOOLS_BETA_HEADER =
|
||||
'token-efficient-tools-2026-03-28'
|
||||
export const CLI_INTERNAL_BETA_HEADER =
|
||||
process.env.USER_TYPE === 'ant' ? 'cli-internal-2026-02-09' : ''
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/utils/betas.ts 第 315-321 行——TOKEN_EFFICIENT_TOOLS 的实际门控逻辑
|
||||
if (
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
includeFirstPartyOnlyBetas &&
|
||||
tokenEfficientToolsEnabled // GrowthBook 'tengu_amber_json_tools' flag
|
||||
) {
|
||||
betaHeaders.push(TOKEN_EFFICIENT_TOOLS_BETA_HEADER)
|
||||
}
|
||||
```
|
||||
|
||||
`cli-internal` header 意味着 Anthropic 的 API 服务端也维护着一套 ant-only 的服务端行为——这不仅仅是客户端的门控。`token-efficient-tools` 进一步需要 GrowthBook flag 开启,说明 Ant 员工内部也有分层灰度。
|
||||
|
||||
## 内部代号体系
|
||||
|
||||
Anthropic 有"动物命名"文化:
|
||||
Anthropic 有浓厚的"动物命名"文化:
|
||||
|
||||
| 代号 | 身份 |
|
||||
|------|------|
|
||||
| **Tengu**(天狗) | Claude Code 项目代号(所有内部 flag 的 `tengu_` 前缀) |
|
||||
| **Capybara**(水豚) | 模型代号 |
|
||||
| **Fennec**(耳廓狐) | 已退役模型别名(已迁移到 Opus) |
|
||||
| 代号 | 身份 | 出处 |
|
||||
|------|------|------|
|
||||
| **Tengu**(天狗) | Claude Code 项目代号 | 所有 GrowthBook flags 的 `tengu_` 前缀、分析事件名称 |
|
||||
| **Capybara**(水豚) | 模型代号 | `src/constants/prompts.ts` 中被 Undercover Mode 屏蔽的名称 |
|
||||
| **Fennec**(耳廓狐) | 已退役模型别名 | `src/migrations/migrateFennecToOpus.ts`——曾用名已迁移到 Opus |
|
||||
|
||||
这些代号通过 Undercover Mode 在公开仓库的 commit 中被过滤。
|
||||
这些代号通过 Undercover Mode 在公开仓库的 commit 中被严格过滤。
|
||||
|
||||
## 环境变量开关
|
||||
|
||||
除了 `USER_TYPE`,还有一系列精细的环境变量控制各项功能:
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="功能禁用开关">
|
||||
- `CLAUDE_CODE_SIMPLE` — 简化模式(禁用高级功能)
|
||||
- `CLAUDE_CODE_DISABLE_THINKING` — 禁用 thinking
|
||||
- `DISABLE_INTERLEAVED_THINKING` — 禁用交错思考
|
||||
- `DISABLE_COMPACT` — 禁用消息压缩
|
||||
- `DISABLE_AUTO_COMPACT` — 禁用自动压缩
|
||||
- `CLAUDE_CODE_DISABLE_AUTO_MEMORY` — 禁用自动记忆
|
||||
- `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS` — 禁用后台任务
|
||||
- `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` — 禁用实验性 beta headers
|
||||
- `USE_API_CONTEXT_MANAGEMENT` — 上下文管理工具清除(需 ant)
|
||||
</Accordion>
|
||||
<Accordion title="功能启用开关">
|
||||
- `CLAUDE_CODE_VERIFY_PLAN` — 启用 VerifyPlanExecutionTool
|
||||
- `ENABLE_LSP_TOOL` — 启用 LSP 工具
|
||||
- `ENABLE_LSP_TOOL` — 启用 LSP 语言服务器工具
|
||||
- `CLAUDE_CODE_UNDERCOVER` — 强制启用 Undercover Mode
|
||||
- `CLAUDE_CODE_TERMINAL_RECORDING` — 启用终端录制(asciicast)
|
||||
- `CLAUDE_CODE_ABLATION_BASELINE` — 启用基线对照模式
|
||||
</Accordion>
|
||||
<Accordion title="环境配置">
|
||||
- `CLAUDE_CODE_REMOTE` — 远程执行模式
|
||||
- `CLAUDE_CODE_REMOTE` — 远程执行模式(自动增加堆内存限制)
|
||||
- `CLAUDE_CODE_COORDINATOR_MODE` — 启用 Coordinator 模式
|
||||
- `CLAUDE_INTERNAL_FC_OVERRIDES` — GrowthBook flag 覆盖(ant-only)
|
||||
- `IS_DEMO` — 演示模式(隐藏内部命令和敏感信息)
|
||||
- `CLAUDE_CODE_ENTRYPOINT` — 入口类型标识(`cli` | 其他)
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
`CLAUDE_CODE_ABLATION_BASELINE` 特别有趣——它同时关闭 thinking、compaction、auto-memory 和 background tasks,用于测量这些高级功能对 AI 表现的**因果影响**。这是一个严肃的"科学对照实验"工具。
|
||||
|
||||
## 接下来
|
||||
|
||||
- **Feature Flags** — 理解功能开关的设计
|
||||
- **权限模型** — 理解身份门控与权限的协作
|
||||
<Note>
|
||||
`ABLATION_BASELINE` 特别有趣——它同时关闭 thinking、compaction、auto-memory 和 background tasks,用于测量这些高级功能对 AI 表现的**因果影响**。这是一个严肃的"科学对照实验"工具。
|
||||
</Note>
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
---
|
||||
title: "Feature Flags"
|
||||
description: "88+ 个构建时特性门控:理解 feature() 的编译时求值机制、死代码消除和 flags 的分类全景。"
|
||||
keywords: ["feature flags", "特性标志", "构建时门控", "条件编译"]
|
||||
title: "88 个 Feature Flags - 构建时特性门控全解"
|
||||
description: "深入剖析 Claude Code 的 88+ 个构建时 feature flags:bun:bundle 编译时门控机制,揭示被编译器删除的隐藏功能模块。"
|
||||
keywords: ["feature flags", "特性标志", "构建时门控", "bun:bundle", "条件编译"]
|
||||
---
|
||||
|
||||
## 核心机制
|
||||
{/* 本章目标:完整梳理构建时 feature flag 系统的机制和所有 flag 的分类 */}
|
||||
|
||||
Claude Code 使用 Bun 打包器的编译时特性门控。代码中通过 `import { feature } from 'bun:bundle'` 导入 `feature()` 函数,在构建时被求值——返回 `true` 的代码保留,返回 `false` 的代码被**死代码消除(DCE)**彻底移除。
|
||||
## feature() 是什么
|
||||
|
||||
**设计洞察**:这不是运行时的 if-else——feature flag 在构建时就已经决定了哪些代码存在。外部构建中不存在的功能不是"被禁用",而是"从未编译进去"。这是一种零运行时开销的特性门控。
|
||||
Claude Code 使用 Bun 打包器的 `bun:bundle` 模块提供编译时特性门控:
|
||||
|
||||
## 三种使用模式
|
||||
```typescript
|
||||
// 源码中的用法(src/tools.ts 等)
|
||||
import { feature } from 'bun:bundle'
|
||||
|
||||
### 1. 条件加载工具
|
||||
const SleepTool = feature('PROACTIVE') || feature('KAIROS')
|
||||
? require('@claude-code-best/builtin-tools/tools/SleepTool/SleepTool.js').SleepTool
|
||||
: null
|
||||
```
|
||||
|
||||
当 flag 为 `false` 时,`require()` 调用被 DCE 移除,工具不会出现在可用工具列表中。不增加打包体积。
|
||||
在 Anthropic 的内部构建中,`feature()` 在打包时被求值——返回 `true` 的代码会被保留,返回 `false` 的代码会被 **Dead Code Elimination (DCE)** 彻底移除。
|
||||
|
||||
### 2. 条件注册命令
|
||||
在我们的反编译版本中,`feature` 从 `bun:bundle` 导入(声明在 `src/types/internal-modules.d.ts`),在运行时始终返回 `false`:
|
||||
|
||||
斜杠命令只在对应 flag 启用时注册。用户不会看到不可用的命令。
|
||||
```typescript
|
||||
// src/types/internal-modules.d.ts
|
||||
declare module 'bun:bundle' {
|
||||
export function feature(name: string): boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 条件启用 API 特性
|
||||
|
||||
控制发送给 API 的 beta header。未启用的功能不会向服务端声明能力。
|
||||
这意味着所有 88+ 个 feature flag 后的代码**在运行时永远不会执行**,但代码本身完整保留,可以阅读和分析。
|
||||
|
||||
## Flags 分类全景
|
||||
|
||||
@@ -64,14 +72,46 @@ Claude Code 使用 Bun 打包器的编译时特性门控。代码中通过 `impo
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## 代码中的典型模式
|
||||
|
||||
Feature flags 在代码中主要有三种使用模式:
|
||||
|
||||
### 模式一:条件加载工具
|
||||
|
||||
```typescript
|
||||
// src/tools.ts — 最常见的模式
|
||||
const MonitorTool = feature('MONITOR_TOOL')
|
||||
? require('@claude-code-best/builtin-tools/tools/MonitorTool/MonitorTool.js').MonitorTool
|
||||
: null
|
||||
```
|
||||
|
||||
当 flag 为 `false` 时,`require()` 调用被 DCE 移除,工具不会出现在可用工具列表中。
|
||||
|
||||
### 模式二:条件注册命令
|
||||
|
||||
```typescript
|
||||
// src/commands.ts — 注册斜杠命令
|
||||
if (feature('VOICE_MODE')) {
|
||||
commands.push({ name: 'voice', description: '...' })
|
||||
}
|
||||
```
|
||||
|
||||
### 模式三:条件启用 API 特性
|
||||
|
||||
```typescript
|
||||
// src/constants/betas.ts — 控制发送给 API 的 beta header
|
||||
export const AFK_MODE_BETA_HEADER = feature('TRANSCRIPT_CLASSIFIER')
|
||||
? 'afk-mode-2026-01-31'
|
||||
: ''
|
||||
```
|
||||
|
||||
<Note>
|
||||
由于 `feature()` 在构建时求值,被 DCE 移除的代码不会增加最终打包体积。但在反编译版本中,这些代码全部保留——这正是我们能够进行完整分析的原因。
|
||||
</Note>
|
||||
|
||||
## 有趣的发现
|
||||
|
||||
- **KAIROS 家族**最庞大——6 个相关 flag 控制从核心功能到推送通知的方方面面
|
||||
- **ABLATION_BASELINE** 是用于"科学对照实验"的——关闭 thinking、compaction、auto-memory 等高级功能,测量裸 API 调用的基线性能
|
||||
- **BUDDY** 是一个 AI 吉祥物/精灵系统
|
||||
- **ABLATION_BASELINE** 是用于"科学对照实验"的——它会关闭 thinking、compaction、auto-memory 等高级功能,测量裸 API 调用的基线性能
|
||||
- **BUDDY** 是一个 AI 吉祥物/精灵系统——在 `src/buddy/` 目录下有完整实现
|
||||
- **ULTRAPLAN** 和 **ULTRATHINK** 暗示着比当前 extended thinking 更高级的推理模式
|
||||
|
||||
## 接下来
|
||||
|
||||
- **Ant 特权世界** — 理解 USER_TYPE 门控与 feature flags 的关系
|
||||
- **Auto Mode** — 理解 TRANSCRIPT_CLASSIFIER flag 控制的自动权限分类
|
||||
|
||||
@@ -1,131 +1,115 @@
|
||||
---
|
||||
title: "架构总览"
|
||||
description: "从交互层到通信层,理解 Claude Code 的五层架构设计。每层的职责、边界和设计考量。"
|
||||
keywords: ["Claude Code 架构", "五层架构", "Agentic Loop", "数据流"]
|
||||
og:image: "https://ccb.agent-aura.top/docs/images/og-cover.png"
|
||||
title: "架构全景 - Claude Code 五层架构详解"
|
||||
description: "从交互层到基础设施层,详解 Claude Code 的五层架构设计。基于 src/main.tsx、src/QueryEngine.ts、src/query.ts、src/tools.ts、src/services/api/claude.ts 的源码级数据流分析。"
|
||||
keywords: ["Claude Code 架构", "五层架构", "QueryEngine", "Agentic Loop", "数据流"]
|
||||
---
|
||||
|
||||
{/* 本章目标:一张图讲清楚整体架构,为后续章节建立坐标系 */}
|
||||
|
||||
## 五层架构
|
||||
|
||||
Claude Code 从上到下分为五个层次。每一层职责清晰、边界分明,层与层之间通过明确的接口通信。
|
||||
Claude Code 从上到下分为五个层次,每一层职责清晰、边界分明:
|
||||
|
||||
<Frame caption="Claude Code 五层架构">
|
||||
<img src="/docs/images/architecture-layers.png" alt="Claude Code 五层架构图" />
|
||||
</Frame>
|
||||
|
||||
| 层次 | 职责 | 设计考量 |
|
||||
|------|------|----------|
|
||||
| **交互层** | 终端 UI、用户输入、消息展示 | 为什么用 React/Ink 而不是 readline?因为需要组件化渲染工具权限确认、进度条等复杂 UI |
|
||||
| **编排层** | 多轮对话管理、会话持久化、成本追踪 | 为什么需要独立的编排层?因为 agentic loop 本身不应该关心"会话保存"和"token 计费" |
|
||||
| **核心循环层** | 单轮:发请求 → 拿响应 → 执行工具 → 循环 | 这是整个系统的心脏。为什么用 AsyncGenerator?因为流式输出需要逐步 yield,工具执行需要可取消 |
|
||||
| **工具层** | AI 的"双手"——读写文件、执行命令等 50+ 工具 | 为什么工具是独立层?因为工具可以动态增减(MCP 扩展),不应该硬编码在核心循环中 |
|
||||
| **通信层** | 与 Claude API 的流式通信 | 为什么支持 7 种 Provider?因为不同用户使用不同的 API 端点(直连、AWS Bedrock、Google Vertex 等) |
|
||||
| 层次 | 职责 | 入口源码 | 关键词 |
|
||||
|------|------|---------|--------|
|
||||
| **交互层** | 终端 UI、用户输入、消息展示 | `src/screens/REPL.tsx` | React/Ink、PromptInput |
|
||||
| **编排层** | 多轮对话、会话持久化、成本追踪 | `src/QueryEngine.ts` | QueryEngine、transcript |
|
||||
| **核心循环层** | 单轮:发请求 → 拿响应 → 执行工具 → 循环 | `src/query.ts` | Agentic Loop、State |
|
||||
| **工具层** | AI 的"双手"——读写文件、执行命令 | `src/tools.ts` → `src/Tool.ts` | Tool 接口、MCP |
|
||||
| **通信层** | 与 Claude API 的流式通信 | `src/services/api/claude.ts` | Streaming、Provider |
|
||||
|
||||
### 层间通信原则
|
||||
|
||||
- **交互层 → 编排层**:用户消息和指令(如斜杠命令)
|
||||
- **编排层 → 核心循环层**:上下文参数(消息历史、工具列表、权限上下文)
|
||||
- **核心循环层 → 工具层**:工具调用请求(工具名 + 参数)
|
||||
- **工具层 → 核心循环层**:工具执行结果
|
||||
- **核心循环层 → 通信层**:API 请求(消息数组 + 系统提示 + 工具定义)
|
||||
- **通信层 → 核心循环层**:流式响应事件
|
||||
|
||||
每层只依赖下一层的接口,不跨层访问。这种约束使得:
|
||||
- 通信层可以替换 Provider 而不影响核心循环
|
||||
- 工具层可以新增工具而不影响编排逻辑
|
||||
- 交互层可以替换为 Web UI 而不影响底层
|
||||
|
||||
## 核心数据流
|
||||
## 一条主数据流的源码追踪
|
||||
|
||||
<Frame caption="核心数据流">
|
||||
<img src="/docs/images/data-flow.png" alt="Claude Code 核心数据流" />
|
||||
</Frame>
|
||||
|
||||
整个系统的运转可以浓缩为一条循环数据流。以下是每一步的设计意图:
|
||||
整个系统的运转可以浓缩为一条核心数据流,以下是每一步对应的源码路径:
|
||||
|
||||
### 1. 用户输入 → 交互层
|
||||
### 1. 用户输入 → REPL
|
||||
|
||||
用户输入经过预处理管道:斜杠命令解析、文件附件处理、图片编码等。设计上,输入处理是可扩展的——新的输入类型(如语音)只需要在预处理管道中添加一个处理器。
|
||||
`src/screens/REPL.tsx` 是基于 React/Ink 的终端 UI 组件。用户输入经 `processUserInput()`(`src/utils/processUserInput/processUserInput.ts`)处理,支持斜杠命令、文件附件、图片等。
|
||||
|
||||
### 2. 交互层 → 编排层
|
||||
### 2. QueryEngine 编排
|
||||
|
||||
编排层是会话的"大脑"。它管理三类关键状态:
|
||||
- **对话状态**:消息数组、工具权限上下文——决定 AI 看到什么
|
||||
- **成本状态**:token 用量累计——决定何时触发压缩或警告用户
|
||||
- **持久化状态**:对话序列化到磁盘——支持会话恢复和 undo
|
||||
`src/QueryEngine.ts` 是 REPL 与 `query()` 之间的中间层,管理:
|
||||
- **会话状态**:消息数组、工具权限上下文(`ToolPermissionContext`)、文件历史快照
|
||||
- **成本追踪**:`accumulateUsage()` / `getTotalCost()` 累计 token 用量
|
||||
- **Transcript 持久化**:`recordTranscript()` 将对话序列化到磁盘,支持 `--resume`
|
||||
- **文件历史**:`fileHistoryMakeSnapshot()` 在修改前创建快照,支持 undo
|
||||
|
||||
### 3. 编排层 → 核心循环(Agentic Loop)
|
||||
关键方法:`queryEngine.query()` 构造 `QueryParams`,调用 `query()` 异步生成器。
|
||||
|
||||
核心循环是一个 `while(true)` 的迭代:
|
||||
### 3. Agentic Loop(`src/query.ts`)
|
||||
|
||||
`query()` 是一个 `AsyncGenerator`,`while(true)` 循环的每次迭代包含:
|
||||
|
||||
```
|
||||
上下文预处理 → API 调用 → 解析响应 → 工具执行 → 结果回传 → 再次调用 → 循环
|
||||
① 上下文预处理管道:
|
||||
applyToolResultBudget → snipCompact → microcompact → contextCollapse → autocompact
|
||||
|
||||
② 流式 API 调用:
|
||||
deps.callModel() → AsyncGenerator<StreamEvent | Message>
|
||||
收集 assistantMessages[]、toolUseBlocks[]
|
||||
|
||||
③ 工具执行:
|
||||
StreamingToolExecutor(并行) 或 runTools(串行)
|
||||
→ toolResults[]
|
||||
|
||||
④ 终止/继续判定:
|
||||
needsFollowUp ? continue : return { reason }
|
||||
```
|
||||
|
||||
**上下文预处理管道**是循环中最精巧的部分。在每次 API 调用前,系统会依次检查:
|
||||
- 单条工具输出是否过长 → 截断
|
||||
- 对话历史是否接近 token 上限 → 自动压缩
|
||||
- 是否需要紧急压缩 → API 返回 token 超限错误时触发
|
||||
完整的状态机通过 `State` 类型(`src/query.ts:207`)在迭代间传递,包含 10 个字段(messages、autoCompactTracking、maxOutputTokensRecoveryCount 等)。
|
||||
|
||||
这套管道确保 AI 始终在有效的上下文窗口内工作。
|
||||
### 4. 工具层(`src/tools.ts` → `src/Tool.ts`)
|
||||
|
||||
### 4. 核心循环 → 工具层
|
||||
`getAllBaseTools()`(`src/tools.ts:195`)组装 50+ 工具列表,经过 `filterToolsByDenyRules()` 权限过滤后传给 API。
|
||||
|
||||
工具执行支持**并行和串行两种模式**。多个独立的工具调用可以并行执行(如同时读两个文件),有依赖关系的调用则串行执行。这个设计直接影响用户体验——并行意味着更快的响应速度。
|
||||
每个工具实现 `Tool<Input, Output, Progress>` 接口(`src/Tool.ts:368`),核心方法链:
|
||||
```
|
||||
validateInput() → canUseTool()(UI 层)→ checkPermissions() → call() → ToolResult
|
||||
```
|
||||
|
||||
### 5. 工具层 → 核心循环 → 通信层
|
||||
### 5. 通信层(`src/services/api/claude.ts`)
|
||||
|
||||
工具执行结果回传到核心循环,拼入消息数组,再次调用 API。AI 根据工具结果决定:
|
||||
- 继续调用工具(任务未完成)
|
||||
- 返回最终回答(任务完成)
|
||||
- 请求用户输入(需要决策)
|
||||
API 客户端支持 7 种 Provider:
|
||||
- **Anthropic Direct (firstParty)**:默认
|
||||
- **AWS Bedrock**:`ANTHROPIC_BEDROCK_BASE_URL`
|
||||
- **Google Vertex**:`ANTHROPIC_VERTEX_PROJECT_ID`
|
||||
- **Foundry**:`ANTHROPIC_CODE_USE_FOUNDRY`
|
||||
- **OpenAI**:兼容层
|
||||
- **Gemini**:兼容层
|
||||
- **Grok (xAI)**:兼容层
|
||||
|
||||
### 6. 终止条件
|
||||
|
||||
循环不是无限运行的。终止条件包括:
|
||||
- AI 不再请求工具调用(任务完成)
|
||||
- 用户主动中断
|
||||
- 达到最大 turn 数限制
|
||||
- Token 预算耗尽
|
||||
`deps.callModel()` 发起流式请求,返回 `BetaRawMessageStreamEvent` 事件流。支持 Prompt Cache(`cache_control`)、thinking blocks、multi-turn tool use。
|
||||
|
||||
## 四个核心设计原则
|
||||
|
||||
### 流式优先(Streaming-first)
|
||||
<AccordionGroup>
|
||||
<Accordion title="流式优先 (Streaming-first)">
|
||||
所有 API 通信都是流式的——`deps.callModel()` 返回 AsyncGenerator,用户看到 AI "逐字打出"回答。StreamingToolExecutor 在流式过程中就开始并行执行工具,不等流结束。模型降级(Fallback)时,已收集的 assistantMessages 被标记为 tombstone 并清空,重试整个流式请求。
|
||||
</Accordion>
|
||||
<Accordion title="工具即能力 (Tool as Capability)">
|
||||
每个工具是 `Tool<Input, Output, Progress>` 结构化类型,通过 `buildTool()` 工厂创建。`getTools()` 在每次 API 调用时组装(非全局缓存),因为 `isEnabled()` 可能随运行时状态变化。MCP 工具通过 `mcpInfo` 字段标记来源,支持 server 级别的 blanket deny。
|
||||
</Accordion>
|
||||
<Accordion title="权限即边界 (Permission as Boundary)">
|
||||
每次工具调用经过 `validateInput() → checkPermissions()` 双重检查。权限规则从 5 个来源汇聚(session → project → user → managed → default),支持工具名、命令模式、路径前缀等匹配方式。Plan Mode 通过 `prepareContextForPlanMode()` 切换为只读模式,退出时自动恢复。
|
||||
</Accordion>
|
||||
<Accordion title="上下文即记忆 (Context as Memory)">
|
||||
System Prompt 由 `fetchSystemPromptParts()` 动态组装,包含 CLAUDE.md、git 状态、日期、MCP 服务器列表。Auto-compact 在每轮迭代前评估 token 阈值,超出时触发压缩。压缩后的摘要通过 `buildPostCompactMessages()` 替换原始消息,`taskBudgetRemaining` 跨压缩边界累计。
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
所有 API 通信都是流式的。用户看到 AI "逐字打出"回答,工具执行在流式过程中就开始并行执行,不需要等流结束。
|
||||
## 入口与引导
|
||||
|
||||
当模型需要降级(如从 Opus 降到 Sonnet)时,已收集的响应被标记为历史记录,整个流式请求重新发起。
|
||||
|
||||
### 工具即能力(Tool as Capability)
|
||||
|
||||
每个工具是一个结构化的类型定义,包含输入验证、权限检查和执行逻辑三个阶段。工具列表在每次 API 调用时动态组装(不是全局缓存),因为工具的可用性可能随运行时状态变化(如 MCP 服务器连接状态)。
|
||||
|
||||
### 权限即边界(Permission as Boundary)
|
||||
|
||||
每次工具调用经过"输入验证 → 权限检查"双重检查。权限规则从五个来源汇聚(会话级 → 项目级 → 用户级 → 管理级 → 默认级),优先级从高到低。
|
||||
|
||||
这个分层设计意味着:团队可以为整个项目设置统一的权限策略(项目级),同时允许个人覆盖(用户级),管理员还可以强制执行安全策略(管理级)。
|
||||
|
||||
### 上下文即记忆(Context as Memory)
|
||||
|
||||
System Prompt 在每轮调用时动态组装,包含项目结构、git 状态、用户指令(CLAUDE.md)等信息。组装策略是"不变内容在前、变化内容在后",利用 API 的前缀缓存机制减少重复计算。
|
||||
|
||||
自动压缩在每轮迭代前评估 token 阈值,超出时用 AI 自身来总结之前的对话,保留语义信息的同时减少 token 占用。
|
||||
|
||||
## 入口概览
|
||||
|
||||
| 入口 | 说明 | 使用场景 |
|
||||
|------|------|----------|
|
||||
| CLI 启动 | 加载配置、认证、启动 REPL | 日常交互式使用 |
|
||||
| 管道模式 | 从 stdin 读取输入,输出到 stdout | 嵌入 CI/CD 和脚本 |
|
||||
| 远程控制 | 通过 Bridge API 远程控制 CLI | 从 claude.ai 或手机发送指令 |
|
||||
| Daemon 模式 | 长驻后台的 supervisor 进程 | 持续运行的自动化任务 |
|
||||
|
||||
## 接下来
|
||||
|
||||
现在你已经理解了整体架构。以下是推荐的深入路径:
|
||||
|
||||
- **Agent Loop** — 深入核心循环的每一轮迭代细节
|
||||
- **工具系统** — 了解 50+ 工具的设计和分类
|
||||
- **上下文工程** — 理解 token 预算和自动压缩机制
|
||||
- **安全机制** — 了解权限模型和沙箱设计
|
||||
| 入口 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| CLI 启动 | `src/entrypoints/cli.tsx` | 注入 `feature()` polyfill(始终返回 false)、MACRO 全局变量 |
|
||||
| 命令定义 | `src/main.tsx` | Commander.js 解析参数,初始化 auth/analytics/policy |
|
||||
| 一次性初始化 | `src/entrypoints/init.ts` | 遥测配置、信任对话框 |
|
||||
| 管道模式 | `src/main.tsx` `-p` flag | `echo "say hello" \| bun run dev -p` |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "项目介绍"
|
||||
description: "Claude Code 是运行在终端中的 agentic coding system。理解它的设计定位、架构选择和核心能力。"
|
||||
keywords: ["Claude Code", "AI 编程助手", "Agentic Coding", "终端 AI"]
|
||||
title: "什么是 Claude Code - Terminal Native Agentic Coding System"
|
||||
description: "Claude Code 是运行在终端中的 agentic coding system,直接在你的项目目录中读代码、改文件、跑命令、调试程序。了解它的技术定位、架构差异和核心能力。"
|
||||
keywords: ["Claude Code", "AI 编程助手", "Agentic Coding", "终端 AI", "CLI AI"]
|
||||
og:image: "https://ccb.agent-aura.top/docs/images/og-cover.png"
|
||||
---
|
||||
|
||||
@@ -9,104 +9,103 @@ og:image: "https://ccb.agent-aura.top/docs/images/og-cover.png"
|
||||
|
||||
Claude Code 是一个**运行在本地终端中的 agentic coding system**。它不是给建议的聊天机器人——它直接在你的项目目录中读代码、改文件、跑命令、调试程序,拥有完整的 shell 能力。
|
||||
|
||||
## 设计定位
|
||||
## 技术定位:terminal-native agentic system
|
||||
|
||||
理解 Claude Code 的关键在于三个词:
|
||||
|
||||
| 定位 | 含义 | 设计影响 |
|
||||
|------|------|----------|
|
||||
| **Terminal-native** | 原生 CLI 应用,不是 IDE 插件也不是 Web 界面 | 拥有完整 shell 访问权,但需要自建权限模型和安全沙箱 |
|
||||
| **Agentic** | AI 自主决定调用什么工具、传什么参数、何时停止 | 不是"一问一答",而是多轮自主决策循环 |
|
||||
| **Coding system** | 面向软件工程全流程设计 | 工具集覆盖文件操作、命令执行、代码搜索、任务管理 |
|
||||
| 定位关键词 | 含义 |
|
||||
|-----------|------|
|
||||
| **Terminal-native** | 原生 CLI 应用,不是 IDE 插件、不是 Web 界面、不是 API wrapper |
|
||||
| **Agentic** | AI 自主决策工具调用链,不是"一问一答"的聊天模式 |
|
||||
| **Coding system** | 面向软件工程全流程,不是通用问答工具 |
|
||||
|
||||
### 为什么选择终端
|
||||
与同类工具的**架构层面**差异(不是功能清单):
|
||||
|
||||
终端不是限制,而是一个有意为之的架构选择。它带来了独特的能力,也带来了对应的代价:
|
||||
| 工具 | 架构模式 | 运行位置 | 工具执行 |
|
||||
|------|----------|----------|----------|
|
||||
| **Claude Code** | Terminal-native agentic loop | 本地进程 | 直接 shell 执行 |
|
||||
| Cursor / Copilot | IDE-integrated autocomplete + chat | IDE 进程内 | LSP / IDE API |
|
||||
| Aider | CLI chat → git patch | 本地进程 | 文件操作为主 |
|
||||
| ChatGPT / Claude.ai | Cloud chat + artifacts | 浏览器/云端 | 沙箱容器 |
|
||||
|
||||
**优势:**
|
||||
- **完整的 shell 访问** — 可以运行任何命令行工具,无需为每个能力写插件
|
||||
- **项目原生** — 直接在项目目录工作,天然理解文件系统结构和 git 状态
|
||||
- **可组合性** — 管道模式(`echo "..." | claude -p`)允许嵌入 CI/CD 和自动化流程
|
||||
- **低延迟** — 没有 Electron 开销,React/Ink 渲染的 TUI 响应极快
|
||||
核心差异:Claude Code 拥有**完整的 shell 访问权**——这意味着它可以做任何你在终端里能做的事,但也需要对应的安全机制来约束这个能力。
|
||||
|
||||
**代价:**
|
||||
- 用户需要适应命令行界面
|
||||
- 没有 GUI 意味着无法直接展示图片、图表等富内容
|
||||
- 权限管理的复杂度远高于 IDE 插件(因为能力边界更大)
|
||||
## 端到端示例:从输入到输出
|
||||
|
||||
### 与同类工具的架构差异
|
||||
|
||||
| 工具 | 架构模式 | 工具执行方式 | 安全边界 |
|
||||
|------|----------|-------------|----------|
|
||||
| **Claude Code** | Terminal-native agentic loop | 直接 shell 执行 | 自建权限模型 + 沙箱 |
|
||||
| Cursor / Copilot | IDE-integrated autocomplete + chat | LSP / IDE API | IDE 沙箱天然隔离 |
|
||||
| Aider | CLI chat → git patch | 文件操作为主 | 通过 git diff 约束变更范围 |
|
||||
| ChatGPT / Claude.ai | Cloud chat + artifacts | 沙箱容器 | 云端容器隔离 |
|
||||
|
||||
核心区别:Claude Code 把 AI 放在了**与用户相同的权限层级**上。这既是它最强大的地方,也是安全设计最复杂的挑战。
|
||||
|
||||
## 端到端:一次任务的生命周期
|
||||
|
||||
当你在终端中输入 `bun run dev 有个 TypeScript 报错,帮我修一下` 时,系统经历了什么?
|
||||
当你在终端中输入 `bun run dev 有个 TypeScript 报错,帮我修一下` 时,系统发生了什么?
|
||||
|
||||
```
|
||||
用户输入 → REPL 捕获 → 上下文组装 → API 调用 → 流式响应
|
||||
↓
|
||||
解析工具调用
|
||||
↓
|
||||
权限检查 → 执行工具
|
||||
↓
|
||||
结果回传 → 再次调 API
|
||||
↓
|
||||
循环直到完成
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 1. 入口层 (cli.tsx → main.tsx) │
|
||||
│ feature() = false, MACRO 注入, 启动 Commander.js CLI │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 2. 交互层 (REPL.tsx — React/Ink) │
|
||||
│ PromptInput 捕获用户输入 → UserMessage 加入会话 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 3. 编排层 (QueryEngine.ts) │
|
||||
│ 管理 turn 生命周期、token 预算、compaction 触发 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 4. 核心循环 (query.ts — Agentic Loop) │
|
||||
│ 组装上下文 → 调 API → 收流式响应 → 解析工具调用 │
|
||||
│ → 权限检查 → 执行工具 → 结果回传 → 再次调 API → 循环 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 5. 工具执行 (BashTool.call / FileEditTool.call / ...) │
|
||||
│ 实际执行: 读文件、运行命令、搜索代码... │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 6. 通信层 (claude.ts → Anthropic API) │
|
||||
│ 流式 HTTP, 支持 Bedrock/Vertex/Foundry 等 7 种 provider │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
具体到这个报错修复场景,一次典型的 agentic loop:
|
||||
具体到这个报错修复场景,一次典型的 agentic loop 可能包含多轮工具调用:
|
||||
|
||||
| 步骤 | AI 自主决策 | 工具调用 | 设计考量 |
|
||||
|------|------------|----------|----------|
|
||||
| 1 | 先复现报错 | `Bash("bun run dev 2>&1 | head -30")` | AI 自行决定先诊断再修复,而非直接改代码 |
|
||||
| 2 | 定位到问题文件 | `Read("src/utils/foo.ts")` | 读取而非猜测,保证修改基于最新代码 |
|
||||
| 3 | 搜索相关类型定义 | `Grep("interface Foo", "src/")` | 跨文件理解依赖关系 |
|
||||
| 4 | 修改代码 | `FileEdit(old, new)` | 精确替换而非全文重写,保留 git 历史可追溯 |
|
||||
| 5 | 验证修复 | `Bash("bun run dev 2>&1 | head -10")` | 自主验证是 agentic 系统的关键闭环 |
|
||||
| Turn | AI 决策 | 工具调用 | 结果 |
|
||||
|------|---------|----------|------|
|
||||
| 1 | 先看报错信息 | `Bash("bun run dev 2>&1 | head -30")` | TypeScript 错误输出 |
|
||||
| 2 | 定位到文件 | `Read("src/utils/foo.ts")` | 源代码内容 |
|
||||
| 3 | 搜索相关类型定义 | `Grep("interface Foo", "src/")` | 类型定义位置 |
|
||||
| 4 | 修复代码 | `FileEdit(old, new)` | 代码已修改 |
|
||||
| 5 | 验证修复 | `Bash("bun run dev 2>&1 | head -10")` | 编译通过 |
|
||||
|
||||
每一步都是 AI 自主决策的。它决定用哪个工具、传什么参数、何时停止——这就是 "agentic" 的含义。
|
||||
|
||||
## 核心设计原则
|
||||
|
||||
这些原则贯穿整个系统的设计:
|
||||
|
||||
**1. 能力优先,安全兜底**
|
||||
|
||||
系统设计的第一步是"让 AI 能做什么",然后才是"如何限制它"。这导致工具系统非常强大(完整 shell),但需要配套的权限模型(每次敏感操作可配置为需用户确认)。
|
||||
|
||||
**2. 本地优先**
|
||||
|
||||
所有代码执行都在本地机器上,不经过云端沙箱。这意味着:
|
||||
- 用户对数据有完全控制权
|
||||
- 可以访问本地文件系统、网络、硬件
|
||||
- 但也需要自己承担安全风险
|
||||
|
||||
**3. 流式一切**
|
||||
|
||||
从 API 响应到工具执行结果,所有数据都以流的方式处理。这让用户可以在 AI 思考的同时看到进展,也使得长时间运行的任务可以中途取消。
|
||||
|
||||
**4. 上下文工程**
|
||||
|
||||
系统花大量精力在"给 AI 看什么"上——自动收集 git 状态、项目结构、CLAUDE.md 指令、历史记忆等。上下文的质量直接决定 AI 的表现。
|
||||
每一步都是 AI 自主决策的——它决定用哪个工具、传什么参数、何时停止。这就是 "agentic" 的含义。
|
||||
|
||||
## 它不是什么
|
||||
|
||||
理解边界和了解能力一样重要:
|
||||
- **不是 IDE 插件**:没有图形界面,不依赖 VS Code 或任何 IDE
|
||||
- **不是 API wrapper**:它有自己的工具系统、权限模型、上下文工程、会话管理
|
||||
- **不是聊天机器人**:输出不是纯文本,而是实际的文件修改、命令执行
|
||||
- **不是无脑执行器**:每个敏感操作都有权限检查和用户确认环节
|
||||
|
||||
- **不是 IDE 插件** — 没有图形界面,不依赖任何 IDE
|
||||
- **不是 API wrapper** — 它有自己的工具系统、权限模型、上下文工程、会话管理
|
||||
- **不是聊天机器人** — 输出不是纯文本建议,而是实际的文件修改和命令执行
|
||||
- **不是无脑执行器** — 每个敏感操作都有权限检查,系统内置规划模式用于复杂任务
|
||||
## 启动入口解剖
|
||||
|
||||
## 接下来
|
||||
真正的代码入口是 `src/entrypoints/cli.tsx`,它做了三件关键的事:
|
||||
|
||||
- **架构总览** — 了解系统的模块划分和数据流
|
||||
- **Agent Loop** — 深入理解核心循环的工作机制
|
||||
- **工具系统** — 了解 AI 拥有哪些工具及其设计考量
|
||||
```typescript
|
||||
// 1. 注入运行时 polyfill —— feature() 永远返回 false
|
||||
const feature = (_name: string) => false;
|
||||
|
||||
// 2. 注入构建时宏
|
||||
globalThis.MACRO = { VERSION: "2.1.888", BUILD_TIME: ..., };
|
||||
|
||||
// 3. 声明构建目标
|
||||
globalThis.BUILD_TARGET = "external"; // 外部构建(非 Anthropic 内部)
|
||||
globalThis.BUILD_ENV = "production";
|
||||
globalThis.INTERFACE_TYPE = "stdio"; // 标准 I/O 交互
|
||||
```
|
||||
|
||||
然后控制流传递到 `src/main.tsx`:
|
||||
1. Commander.js 解析命令行参数
|
||||
2. 初始化认证、遥测、策略限制
|
||||
3. 加载工具列表(`getTools()`)
|
||||
4. 启动 REPL(`launchRepl()`)或管道模式(`-p`)
|
||||
|
||||
## 为什么选择终端
|
||||
|
||||
终端不是限制,而是选择。它带来了独特的能力:
|
||||
|
||||
- **完整的 shell 访问**:可以运行任何命令行工具,无需为每个能力写插件
|
||||
- **项目原生**:直接在项目目录工作,理解文件系统结构、git 状态
|
||||
- **可组合性**:管道模式(`echo "..." | claude -p`)允许嵌入 CI/CD 和自动化流程
|
||||
- **低延迟**:没有 Electron 开销,React/Ink 渲染的 TUI 响应极快
|
||||
|
||||
代价是用户需要适应命令行界面——但也正因如此,它吸引的是需要**真正掌控开发环境**的开发者。
|
||||
|
||||
@@ -1,155 +1,121 @@
|
||||
---
|
||||
title: "项目动机"
|
||||
description: "Claude Code 解决了什么工程问题?为什么 agentic coding 需要一整套独立的设计体系?理解这些设计决策背后的动机。"
|
||||
keywords: ["Claude Code", "设计动机", "Agentic Coding", "工程决策"]
|
||||
og:image: "https://ccb.agent-aura.top/docs/images/og-cover.png"
|
||||
title: "为什么写这份白皮书 - Claude Code 逆向工程分析"
|
||||
description: "对 Anthropic 官方 Claude Code CLI 的逆向工程分析白皮书。通过反编译 TypeScript 单文件 bundle,深入解析运行时行为与源码结构。"
|
||||
keywords: ["Claude Code", "逆向工程", "白皮书", "反编译", "TypeScript"]
|
||||
---
|
||||
|
||||
## 为什么需要这篇文章
|
||||
## 这份白皮书是什么
|
||||
|
||||
理解一个系统的"是什么"只是第一步。真正有用的是理解**为什么这样设计**——每个架构选择的动机、权衡和代价。
|
||||
这是对 Anthropic 官方发布的 **Claude Code CLI** 的**逆向工程分析**。
|
||||
|
||||
本文梳理 Claude Code 最核心的五个设计决策,解释它们解决了什么问题、放弃了什么、以及这些决策如何相互影响。
|
||||
源码经过反编译处理(TypeScript 单文件 bundle 逆向),保留了核心功能模块,但包含大量 `unknown`/`never`/`{}` 类型错误——这些不影响 Bun 运行时执行,但意味着我们的分析基于运行时行为 + 残留源码结构,而非原始源码。
|
||||
|
||||
## 决策一:Agentic Loop 而非 Chat
|
||||
**这不是:**
|
||||
- 官方文档或使用教程
|
||||
- API 参考手册
|
||||
- Claude Code 的功能推销
|
||||
|
||||
### 问题
|
||||
**这是:**
|
||||
- 一个生产级 agentic system 的架构解构
|
||||
- 每个设计决策背后的"为什么"
|
||||
- 可复用的工程模式:agentic loop、工具抽象、上下文工程、安全纵深防御
|
||||
|
||||
大多数 AI 编程工具采用"一问一答"模式:用户描述问题,AI 给出建议,用户手动执行。这个模式的瓶颈在于——人类成为了 AI 和代码之间的中转站。
|
||||
## 逆向过程中最精妙的设计决策
|
||||
|
||||
### 决策
|
||||
### 1. Agentic Loop 的自愈能力
|
||||
|
||||
Claude Code 采用 **agentic loop**:AI 自主决定调用什么工具、传什么参数、何时停止。用户只需要描述目标,AI 自己规划执行路径。
|
||||
`src/query.ts` 实现的核心循环不是简单的"发请求→收响应"。它是一个**自愈的状态机**:
|
||||
|
||||
### 关键设计
|
||||
- API 返回错误(限流、token 超限)→ 自动重试/降级
|
||||
- 工具执行超时 → 后台化 + 通知机制
|
||||
- 对话过长触发 compaction → 压缩历史后无缝继续
|
||||
- 用户中断 → 生成 `UserInterruptionMessage` 让 AI 理解发生了什么
|
||||
|
||||
这个循环不是简单的"发请求→收响应",而是一个**自愈的状态机**:
|
||||
这不是"if-else 堆叠",而是让 AI 自己根据上下文决定下一步——即使发生了意外。
|
||||
|
||||
- API 返回错误(限流、token 超限)→ 自动重试或降级处理
|
||||
- 工具执行超时 → 后台化运行,通过通知机制回馈结果
|
||||
- 对话过长触发 compaction → 自动压缩历史后无缝继续
|
||||
- 用户中途打断 → 系统生成中断消息让 AI 理解发生了什么
|
||||
### 2. 上下文工程的分层策略
|
||||
|
||||
核心思想:**让 AI 根据上下文自己决定下一步**,即使是意外情况也不需要人类介入。
|
||||
AI 没有真正的"记忆",Claude Code 通过精心分层营造了这个幻觉:
|
||||
|
||||
### 代价
|
||||
| 层 | 机制 | 持久性 |
|
||||
|----|------|--------|
|
||||
| **System Prompt** | 项目结构、git 状态、CLAUDE.md | 每轮重建 |
|
||||
| **对话历史** | 完整的 User/Assistant/Tool 消息 | 会话内 |
|
||||
| **Compaction** | 自动压缩过长对话为摘要 | 压缩后替代原始消息 |
|
||||
| **Memory 文件** | 跨会话持久化的笔记 | 永久(用户可控) |
|
||||
| **File History** | 文件修改时间戳快照 | 会话内 |
|
||||
|
||||
- 更高的 token 消耗(AI 需要多轮工具调用才能完成一个任务)
|
||||
- 需要精心设计的权限模型(自主性越高,失控风险越大)
|
||||
- 调试困难(AI 的决策链不总是可预测的)
|
||||
`src/context.ts` 组装 System Prompt 时的策略是:**不变内容在前、变化内容在后**——这利用了 API 的缓存机制,前缀不变时可以复用缓存 token。
|
||||
|
||||
## 决策二:终端原生而非 IDE 插件
|
||||
### 3. 工具系统的权限双轨制
|
||||
|
||||
### 问题
|
||||
`packages/builtin-tools/src/tools/BashTool/shouldUseSandbox.ts` 展示了一个精巧的双重安全模型:
|
||||
|
||||
IDE 插件可以复用 IDE 的 UI 框架、权限沙箱和用户习惯。为什么还要做一个独立的终端应用?
|
||||
- **应用层**:权限规则决定"能不能执行"(白名单/黑名单/用户确认)
|
||||
- **OS 层**:沙箱决定"执行时能做什么"(文件系统/网络/进程隔离)
|
||||
|
||||
### 决策
|
||||
两层的信任假设不同:应用层信任用户配置,OS 层不信任任何东西。即使 AI 绕过了应用层权限(理论上不可能,但纵深防御),OS 层沙箱仍然限制实际危害。
|
||||
|
||||
选择终端原生(Terminal-native)架构。CLI 拥有与用户相同的 shell 权限,可以运行任何命令行工具。
|
||||
### 4. Feature Flag 的全局开关
|
||||
|
||||
### 动机
|
||||
`src/entrypoints/cli.tsx` 中一行代码决定了整个系统的行为:
|
||||
|
||||
1. **能力边界最大化** — IDE 插件受限于 IDE 提供的 API;终端应用可以做任何用户能在终端里做的事
|
||||
2. **可组合性** — 管道模式让 AI 能力可以嵌入 CI/CD、脚本和自动化流程
|
||||
3. **零依赖** — 不需要安装特定 IDE,任何有终端的环境都能运行
|
||||
|
||||
### 代价
|
||||
|
||||
- 必须自建整个权限模型和安全沙箱(IDE 天然提供这些)
|
||||
- 用户门槛更高(需要适应命令行)
|
||||
- 没有 GUI 意味着无法直接展示富内容
|
||||
|
||||
## 决策三:上下文工程的分层策略
|
||||
|
||||
### 问题
|
||||
|
||||
AI 没有真正的"记忆"。每次 API 调用都是无状态的——模型只看到当前请求中的内容。如何在一个 200K token 的窗口内让 AI "记住"足够多的上下文?
|
||||
|
||||
### 决策
|
||||
|
||||
通过精心分层来营造"记忆"的幻觉:
|
||||
|
||||
| 层 | 机制 | 生命周期 | 设计目的 |
|
||||
|----|------|----------|----------|
|
||||
| **System Prompt** | 项目结构、git 状态、用户指令 | 每轮重建 | 让 AI 理解当前环境 |
|
||||
| **对话历史** | 完整的用户/AI/工具消息流 | 会话内 | 保持任务连贯性 |
|
||||
| **Compaction** | AI 自主压缩过长对话为摘要 | 自动触发 | 在有限窗口内保留语义 |
|
||||
| **Memory 文件** | 跨会话持久化的笔记文件 | 永久 | 跨会话保留关键信息 |
|
||||
|
||||
### 关键洞察
|
||||
|
||||
System Prompt 的组装策略是**不变内容在前、变化内容在后**。这不是随意排列——它利用了 API 的前缀缓存机制:不变的系统指令部分可以复用缓存的 token,只有变化的部分需要重新计算。
|
||||
|
||||
### 代价
|
||||
|
||||
- Compaction 会丢失细节(压缩毕竟是信息损失)
|
||||
- Memory 文件需要用户主动维护才有用
|
||||
- 整个策略的复杂度很高,任何一环出问题都会影响 AI 表现
|
||||
|
||||
## 决策四:权限的双轨制
|
||||
|
||||
### 问题
|
||||
|
||||
AI 拥有完整 shell 权限意味着它可以做任何事——包括删除文件、发送网络请求、安装软件包。如何在保持能力的同时控制风险?
|
||||
|
||||
### 决策
|
||||
|
||||
采用**纵深防御**的双轨权限模型:
|
||||
|
||||
- **应用层**(权限规则)— 决定"能不能执行"。通过白名单、黑名单和用户确认三级控制
|
||||
- **OS 层**(沙箱)— 决定"执行时能做什么"。通过文件系统、网络和进程隔离限制实际行为
|
||||
|
||||
### 设计哲学
|
||||
|
||||
两层的**信任假设不同**:应用层信任用户配置和 AI 的判断力;OS 层不信任任何东西。即使应用层被绕过(纵深防御的思路),OS 层仍然限制实际危害。
|
||||
|
||||
### 代价
|
||||
|
||||
- 双轨制增加了系统复杂度
|
||||
- 频繁的权限确认会打断工作流(因此需要"自动模式"等快捷方案)
|
||||
- 沙箱不是所有平台都支持(目前主要是 macOS)
|
||||
|
||||
## 决策五:Feature Flag 驱动的渐进发布
|
||||
|
||||
### 问题
|
||||
|
||||
一个大型系统需要同时服务不同用户群体:内部测试者、早期用户、正式用户。如何在不维护多套代码的情况下控制功能可见性?
|
||||
|
||||
### 决策
|
||||
|
||||
所有实验性功能通过 feature flag 控制。默认情况下所有 flag 关闭,不同的运行模式(开发/构建/发布)启用不同的 flag 子集。
|
||||
|
||||
### 设计效果
|
||||
|
||||
- **同一个代码库** — 不需要维护功能分支
|
||||
- **运行时切换** — 通过环境变量即时启用或关闭功能
|
||||
- **渐进式发布** — 可以先对内部用户开放,再逐步扩大范围
|
||||
- **安全回退** — 发现问题时关闭 flag 即可,不需要回滚代码
|
||||
|
||||
### 代价
|
||||
|
||||
- 代码中到处都是 `if (feature('X'))` 分支,增加阅读复杂度
|
||||
- flag 之间的依赖关系需要手动管理
|
||||
- 测试组合爆炸(每个 flag 的开/关都需要考虑)
|
||||
|
||||
## 这些决策如何相互影响
|
||||
|
||||
这五个决策不是孤立的——它们形成了一个互相支撑的系统:
|
||||
|
||||
```
|
||||
Agentic Loop(自主决策)
|
||||
└── 需要强大的工具系统 → 终端原生架构
|
||||
└── 需要安全约束 → 双轨权限模型
|
||||
└── 需要在有限窗口内工作 → 上下文分层策略
|
||||
└── 需要渐进式发布 → Feature Flag 体系
|
||||
```typescript
|
||||
const feature = (_name: string) => false;
|
||||
```
|
||||
|
||||
每个决策都为其他决策创造了前提条件。这也是为什么理解"动机"比理解"实现"更重要——知道了为什么,才能判断在什么情况下应该偏离这些选择。
|
||||
所有 `feature('FLAG_NAME')` 调用返回 `false`——这意味着 Anthropic 内部的实验功能(COORDINATOR_MODE、KAIROS、PROACTIVE 等)全部禁用。在官方构建中,这些 flag 通过 Bun 的 `bun:bundle` 在编译时注入,不同用户群体看到不同功能。
|
||||
|
||||
## 适合谁读这套文档
|
||||
这是一个**渐进式发布架构**:同一个代码库,通过 feature flag 控制功能可见性,而不需要维护多个分支。
|
||||
|
||||
- **AI Agent 开发者** — 想理解生产级 agentic system 的架构模式
|
||||
- **安全工程师** — 对 AI 操作真实环境时的信任模型感兴趣
|
||||
- **工具构建者** — 正在构建类似的 coding assistant 或 CLI 工具
|
||||
- **好奇心驱动的开发者** — 想知道"AI 编程助手到底怎么工作的"
|
||||
### 5. Compaction 的分档策略
|
||||
|
||||
`src/services/compact/` 实现了三种压缩策略:
|
||||
|
||||
- **Micro-compact**:单次工具输出过长时,截断结果
|
||||
- **Auto-compact**:对话 token 接近上限时,自动压缩历史
|
||||
- **Reactive-compact**:API 返回 token 超限错误时,紧急压缩后重试
|
||||
|
||||
这不是简单的"砍掉旧消息"——而是用 AI 自身来总结之前的对话,保留语义信息。压缩后插入一条 `TombstoneMessage` 标记边界。
|
||||
|
||||
## 阅读路线图
|
||||
|
||||
推荐的阅读顺序,每章解决一个核心问题:
|
||||
|
||||
```
|
||||
什么是 Claude Code (你在读的) ← 建立直觉
|
||||
│
|
||||
├── 架构全景 ← 五层架构 + 数据流
|
||||
│
|
||||
├── 安全体系 ← 信任与控制
|
||||
│ ├── 权限模型 ← 应用层安全
|
||||
│ ├── 沙箱机制 ← OS 层安全
|
||||
│ └── Plan Mode ← 用户主导模式
|
||||
│
|
||||
├── 对话引擎 ← AI 如何思考
|
||||
│ ├── Agentic Loop ← 核心循环
|
||||
│ ├── 流式响应 ← 实时通信
|
||||
│ └── 多轮对话 ← 上下文管理
|
||||
│
|
||||
├── 上下文工程 ← 记忆与预算
|
||||
│ ├── System Prompt ← 上下文组装
|
||||
│ ├── Token 预算 ← 预算管理
|
||||
│ └── 项目记忆 ← 跨会话持久化
|
||||
│
|
||||
├── 工具系统 ← AI 的双手
|
||||
│ ├── 工具概览 ← 统一接口
|
||||
│ ├── Shell 执行 ← Bash 工具
|
||||
│ └── 搜索与导航 ← Glob/Grep
|
||||
│
|
||||
└── Agent 与扩展 ← 能力扩展
|
||||
├── 子 Agent ← 并行任务
|
||||
├── 自定义 Agent ← 用户定义
|
||||
└── MCP 协议 ← 外部工具接入
|
||||
```
|
||||
|
||||
## 适合谁读
|
||||
|
||||
- **AI Agent 开发者**:想理解生产级 agentic system 的架构模式
|
||||
- **安全工程师**:对 AI 操作真实环境时的信任模型感兴趣
|
||||
- **工具构建者**:正在构建类似的 coding assistant 或 CLI 工具
|
||||
- **好奇心驱动的开发者**:想知道"AI 编程助手到底怎么工作的"
|
||||
|
||||
@@ -200,9 +200,9 @@ LSP 服务器通过插件提供。插件的 `manifest.json` 中可以声明 LSP
|
||||
|------|------|------|------|
|
||||
| `command` | string | 是 | LSP 服务器可执行命令(不含空格) |
|
||||
| `args` | string[] | 否 | 命令行参数 |
|
||||
| `extensionToLanguage` | Record<string, string> | 是 | 文件扩展名到语言 ID 的映射(至少一个) |
|
||||
| `extensionToLanguage` | `Record<string, string>` | 是 | 文件扩展名到语言 ID 的映射(至少一个) |
|
||||
| `transport` | `"stdio"` \| `"socket"` | 否 | 通信方式,默认 `stdio` |
|
||||
| `env` | Record<string, string> | 否 | 启动服务器时设置的环境变量 |
|
||||
| `env` | `Record<string, string>` | 否 | 启动服务器时设置的环境变量 |
|
||||
| `initializationOptions` | unknown | 否 | 传给服务器的初始化选项 |
|
||||
| `settings` | unknown | 否 | 通过 `workspace/didChangeConfiguration` 传递的设置 |
|
||||
| `workspaceFolder` | string | 否 | 工作区目录路径 |
|
||||
|
||||
@@ -1,102 +1,263 @@
|
||||
---
|
||||
title: "Auto Mode"
|
||||
description: "AI 分类器驱动的自主执行模式。理解两阶段分类流水线、危险权限剥离和分类器不可用时的降级策略。"
|
||||
keywords: ["auto mode", "自动执行", "AI 分类器", "权限分类"]
|
||||
title: "Auto Mode - AI 分类器驱动的自主执行模式"
|
||||
description: "详解 Claude Code 的 auto mode:基于 transcript classifier 的自动权限决策、两阶段分类流水线、危险权限剥离机制、模式切换状态管理、以及与 plan mode 的协作方式。"
|
||||
keywords: ["auto mode", "yoloClassifier", "transcript classifier", "权限分类", "自动执行", "两阶段分类"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
## 概述
|
||||
|
||||
默认模式下,AI 执行每个敏感操作都需要用户确认。这在处理复杂任务时产生大量打断——一次重构可能需要确认 20 次文件编辑和 10 次命令执行。
|
||||
|
||||
Auto mode 的目标:**让 AI 连续自主执行,只在真正危险时才停下来。**
|
||||
|
||||
## 权限模式的层级
|
||||
Auto mode 是 Claude Code 的一种权限模式,让 AI 进入**连续自主执行**状态。与传统模式(每个敏感操作都弹出权限对话框等待用户审批)不同,auto mode 使用 AI 分类器(transcript classifier)自动判断每个工具调用是否安全,从而实现无中断的执行体验。
|
||||
|
||||
```
|
||||
default → auto → bypass
|
||||
权限模式层级:
|
||||
|
||||
default → auto → bypassPermissions
|
||||
(逐项确认) (AI 分类器审批) (全部放行)
|
||||
```
|
||||
|
||||
Auto mode 不是 bypass——它不是"什么都允许",而是"让 AI 判断什么安全、什么危险"。
|
||||
## 核心架构
|
||||
|
||||
## 核心架构:AI 分类器
|
||||
### 1. AI 分类器(yoloClassifier)
|
||||
|
||||
Auto mode 的核心是一个 AI 分类器。每个工具调用经过分类器评估,返回三种裁决:
|
||||
分类器是 auto mode 的核心,位于 `src/utils/permissions/yoloClassifier.ts`。
|
||||
|
||||
| 裁决 | 含义 | 处理 |
|
||||
|------|------|------|
|
||||
| **allow** | 操作安全 | 直接执行 |
|
||||
| **deny** | 操作危险 | 拒绝并告知 AI |
|
||||
| **ask** | 无法确定 | 回退到用户确认 |
|
||||
每个工具调用经过分类器评估,返回三种裁决之一:
|
||||
|
||||
**设计洞察**:分类器基于完整的对话上下文判断,而非只看单条命令。它能理解操作的意图——同样是 `rm` 命令,删除构建产物是安全的,删除源代码是危险的。
|
||||
| 裁决 | 含义 | 处理方式 |
|
||||
|------|------|---------|
|
||||
| **allow** | 操作安全 | 直接执行,用户无感知 |
|
||||
| **deny** | 操作危险 | 拒绝执行,向 AI 返回拒绝消息 |
|
||||
| **ask** | 无法确定 | 回退到传统权限对话框 |
|
||||
|
||||
## 两阶段分类流水线
|
||||
分类器的判断基于完整的对话上下文(transcript),而不仅仅看单条命令——它能理解操作的意图和上下文。
|
||||
|
||||
分类器采用两阶段设计:
|
||||
### 2. 两阶段分类流水线
|
||||
|
||||
### Stage 1:快速判断
|
||||
分类器支持两阶段 XML 分类(`classifyYoloActionXml`):
|
||||
|
||||
使用短输出(最多 64 token)快速给出 allow/block 判断。大多数安全操作在这一阶段就被放行,延迟极低。
|
||||
```
|
||||
Stage 1 — "fast"(快速判断)
|
||||
├── max_tokens=64,stop_sequences 触发即时 yes/no
|
||||
├── 如果 allow → 直接放行(最快路径)
|
||||
└── 如果 block → 进入 Stage 2
|
||||
|
||||
### Stage 2:深度思考
|
||||
Stage 2 — "thinking"(深度思考)
|
||||
├── chain-of-thought 推理
|
||||
├── 减少误报(false positives)
|
||||
└── 最终决定 allow / deny / ask
|
||||
```
|
||||
|
||||
Stage 1 判断为 block 的操作进入深度推理阶段,通过 chain-of-thought 减少误报。
|
||||
两个阶段共享相同的 system prompt 和 user content,利用 API 的 prompt caching(1 小时 TTL)优化性能。
|
||||
|
||||
**设计考量**:两阶段设计在速度和准确性之间取得平衡。99% 的操作在 Stage 1 就能正确判断,只有少数模糊操作需要 Stage 2 的深度分析。这避免了每个操作都跑完整推理的性能开销。
|
||||
可通过配置选择模式:
|
||||
- `'both'`(默认)— 两阶段都跑
|
||||
- `'fast'` — 只跑 Stage 1
|
||||
- `'thinking'` — 只跑 Stage 2
|
||||
|
||||
### 3. 分类器结果类型
|
||||
|
||||
```typescript
|
||||
// src/types/permissions.ts
|
||||
type YoloClassifierResult = {
|
||||
thinking?: string // 分类器的推理过程
|
||||
shouldBlock: boolean // 是否阻止
|
||||
reason: string // 决策原因
|
||||
unavailable?: boolean // 分类器是否不可用
|
||||
transcriptTooLong?: boolean // 对话是否超出上下文窗口
|
||||
model: string // 使用的分类器模型
|
||||
stage?: 'fast' | 'thinking' // 哪个阶段做出的决定
|
||||
// ... token 使用量、耗时等监控字段
|
||||
}
|
||||
```
|
||||
|
||||
## 安全机制
|
||||
|
||||
### 危险权限剥离
|
||||
|
||||
进入 auto mode 时,系统自动剥离所有可能绕过分类器的 allow 规则:
|
||||
进入 auto mode 时,系统调用 `stripDangerousPermissionsForAutoMode()`(`permissionSetup.ts:510`),移除所有可能绕过分类器的 allow 规则。
|
||||
|
||||
| 被剥离的规则类型 | 原因 |
|
||||
|----------------|------|
|
||||
| Bash 解释器规则(python/node/bash) | 可执行任意代码 |
|
||||
| Agent allow 规则 | 会绕过分类器审批子 Agent |
|
||||
| 权限提升规则(sudo/eval) | 可执行任意命令 |
|
||||
被剥离的规则类型(`dangerousPatterns.ts`):
|
||||
|
||||
剥离的规则在退出 auto mode 时恢复。
|
||||
| 规则类型 | 示例 | 剥离原因 |
|
||||
|---------|------|---------|
|
||||
| **Bash 代码执行** | `Bash(python:*)`, `Bash(node:*)` | 解释器可执行任意代码,绕过分类器审查 |
|
||||
| **Shell 入口** | `Bash(bash:*)`, `Bash(sh:*)` | 直接 shell 访问等同无限制 |
|
||||
| **Agent 规则** | `Agent(*)` | 任何 Agent allow 规则会绕过分类器审批子代理 |
|
||||
| **PowerShell 代码执行** | `PowerShell(node:*)` | 同 Bash 逻辑 |
|
||||
| **权限提升** | `Bash(sudo:*)`, `Bash(eval:*)` | 可执行任意命令 |
|
||||
|
||||
**设计哲学**:auto mode 的安全性依赖于分类器的判断。如果用户之前设置了"Bash: always allow",分类器就被绕过了。剥离这些规则确保分类器是唯一的安全决策者。
|
||||
|
||||
### Circuit Breaker
|
||||
|
||||
远程配置可以在紧急情况下全局禁用 auto mode。这为 Anthropic 提供了远程紧急关停能力——如果发现分类器存在系统性漏洞,可以在不发布新版本的情况下立即禁用。
|
||||
剥离的规则被暂存在 `strippedDangerousRules` 中,退出 auto mode 时通过 `restoreDangerousPermissions()` 恢复。
|
||||
|
||||
### 模型支持检测
|
||||
|
||||
不是所有模型都支持 auto mode。分类器需要特定的能力(如理解安全语义),不支持该能力的模型无法进入 auto mode。
|
||||
不是所有模型都支持 auto mode。`modelSupportsAutoMode()`(`src/utils/betas.ts`)检查当前模型是否具备安全分类能力。不支持的模型无法进入 auto mode。
|
||||
|
||||
### Circuit Breaker 机制
|
||||
|
||||
`autoModeState.ts` 维护一个 circuit breaker 标志:
|
||||
|
||||
```typescript
|
||||
let autoModeCircuitBroken = false // 由远程配置控制
|
||||
```
|
||||
|
||||
当远程配置(GrowthBook `tengu_auto_mode_config.enabled`)设为 `'disabled'` 时,circuit breaker 触发,阻止 auto mode 的进入和继续使用。这为 Anthropic 提供了远程紧急关停能力。
|
||||
|
||||
## 模式切换状态管理
|
||||
|
||||
### 进入 Auto Mode
|
||||
|
||||
`transitionPermissionMode()`(`permissionSetup.ts:597`)处理所有模式切换:
|
||||
|
||||
```
|
||||
1. 检查 auto mode gate 是否开启(isAutoModeGateEnabled)
|
||||
2. 设置 autoModeActive = true
|
||||
3. 调用 stripDangerousPermissionsForAutoMode() 剥离危险规则
|
||||
4. 向对话注入 Auto Mode 系统提示
|
||||
```
|
||||
|
||||
### 退出 Auto Mode
|
||||
|
||||
```
|
||||
1. 设置 autoModeActive = false
|
||||
2. 设置 needsAutoModeExitAttachment = true(触发退出通知)
|
||||
3. 调用 restoreDangerousPermissions() 恢复被剥离的规则
|
||||
4. 向对话注入 "Exited Auto Mode" 提示
|
||||
```
|
||||
|
||||
### 触发路径
|
||||
|
||||
Auto mode 可通过以下方式激活:
|
||||
- CLI 参数 `--enable-auto-mode`
|
||||
- settings.json 中的 `autoMode` 配置
|
||||
- Plan mode 默认使用 auto mode 语义(`useAutoModeDuringPlan`,默认 true)
|
||||
- SDK 控制消息
|
||||
- REPL 中 Shift+Tab 切换
|
||||
|
||||
## 系统提示词
|
||||
|
||||
### 进入时
|
||||
### 进入时(Full Instructions)
|
||||
|
||||
注入到对话中的指令要求 AI:
|
||||
1. **直接执行** — 做合理假设,减少提问
|
||||
2. **偏好行动** — 默认直接编码,不进 plan mode
|
||||
3. **避免破坏性操作** — 删除数据、修改生产系统仍需确认
|
||||
注入到对话中的指令(`messages.ts:3481`):
|
||||
|
||||
### 退出时
|
||||
> Auto mode is active. The user chose continuous, autonomous execution. You should:
|
||||
>
|
||||
> 1. **Execute immediately** — 直接实现,做合理假设
|
||||
> 2. **Minimize interruptions** — 常规决策自行判断,减少提问
|
||||
> 3. **Prefer action over planning** — 默认直接编码,不进 plan mode
|
||||
> 4. **Expect course corrections** — 用户可随时纠正
|
||||
> 5. **Do not take overly destructive actions** — 删除数据/修改生产系统仍需确认
|
||||
> 6. **Avoid data exfiltration** — 不主动分享密钥/内部文档
|
||||
|
||||
注入"退出 auto mode"提示,要求 AI 回到谨慎模式——方案不明确时提问而非假设。
|
||||
### 持续运行时(Sparse Instructions)
|
||||
|
||||
## 降级策略
|
||||
后续轮次注入简短提醒:
|
||||
|
||||
当分类器 API 不可用时:
|
||||
- **不直接 allow** — 回退到传统权限对话框
|
||||
- 告知 AI 分类器暂时不可用
|
||||
- 确定性错误(如对话过长)不重试
|
||||
> Auto mode still active. Execute autonomously, minimize interruptions, prefer action over planning.
|
||||
|
||||
**设计哲学**:降级到更安全的行为。宁可多确认一次,也不要在没有分类器保护的情况下自动放行。
|
||||
### 退出时(Exit Instructions)
|
||||
|
||||
> You have exited auto mode. Ask clarifying questions when the approach is ambiguous rather than making assumptions.
|
||||
|
||||
## 与 Plan Mode 的协作
|
||||
|
||||
Plan mode 默认使用 auto mode 语义——在只读探索阶段,分类器自动判断哪些只读操作是安全的,进一步减少打断。
|
||||
Plan mode 默认使用 auto mode 语义(`getUseAutoModeDuringPlan()`,默认 true)。这意味着:
|
||||
|
||||
## 接下来
|
||||
- Plan mode 进入时,如果 auto mode 可用,也会激活分类器
|
||||
- `isAutoModeActive()` 是权威信号(`prePlanMode`/`strippedDangerousRules` 不可靠)
|
||||
- 退出 plan mode 时会同时退出 auto mode
|
||||
|
||||
- **权限模型** — 理解 auto mode 在权限体系中的位置
|
||||
- **Plan Mode** — 理解"先规划再执行"的安全工作流
|
||||
- **为什么安全很重要** — 理解安全体系的设计动机
|
||||
## 分类器不可用的降级策略
|
||||
|
||||
当分类器 API 不可用时(`unavailable: true` 或 `transcriptTooLong: true`):
|
||||
|
||||
- 不会直接 allow — 回退到传统的权限对话框(ask)
|
||||
- 向 AI 发送消息:"{model} is temporarily unavailable, so auto mode cannot determine the safety of {toolName} right now."
|
||||
- 确定性错误(如对话过长)不重试,直接降级
|
||||
|
||||
## 分类器 Prompt 模板
|
||||
|
||||
分类器的行为由三个 prompt 文件控制,位于 `src/utils/permissions/yolo-classifier-prompts/`。这些文件在构建时通过 `require()` 内联为字符串常量,运行时不可修改。
|
||||
|
||||
### auto_mode_system_prompt.txt
|
||||
|
||||
主系统提示词,定义分类器的角色、分类流程和决策类别。包含:
|
||||
|
||||
- **分类流程**:理解操作 → 检查用户意图 → 评估风险
|
||||
- **BLOCK 始终阻止**:外部代码执行、不可逆删除、未授权持久化、安全削弱、提权、网络服务
|
||||
- **BLOCK 除非明确意图**:CWD 外写入、系统包管理、git push、大规模变更
|
||||
- **ALLOW 安全操作**:读文件、搜索、git 只读命令、测试/lint/构建、CWD 内编辑
|
||||
- `<permissions_template>` 占位符 — 运行时替换为具体权限模板(external 或 anthropic)
|
||||
- 末尾 `Use the classify_result tool to report your classification.` — XML 模式下被替换为 `<block>` 标签格式说明
|
||||
|
||||
### permissions_external.txt
|
||||
|
||||
外部用户版本的权限模板。三个 `<user_*_to_replace>` 标签内包裹默认规则(bullet 格式),用户自定义规则**整体替换**默认值:
|
||||
|
||||
```
|
||||
<user_allow_rules_to_replace>
|
||||
- 默认 allow 规则 1
|
||||
- 默认 allow 规则 2
|
||||
</user_allow_rules_to_replace>
|
||||
```
|
||||
|
||||
- **allow**:9 条默认规则(只读命令、版本检查、git 只读、测试/lint/构建、包安装、CWD 内文件操作等)
|
||||
- **soft_deny**:10 条默认规则(外部代码执行、递归删除、shell 配置修改、提权、网络服务等)
|
||||
- **environment**:4 条环境描述(终端环境、auto mode 上下文、开发工具可用、语言/框架不限)
|
||||
|
||||
`getDefaultExternalAutoModeRules()` 从此文件提取 bullet 项,供 `claude auto-mode defaults` 命令输出。
|
||||
|
||||
### permissions_anthropic.txt
|
||||
|
||||
Anthropic 内部版本的权限模板。默认规则在标签**外部**,标签内为空,用户自定义规则以**追加**方式叠加:
|
||||
|
||||
```
|
||||
- 默认规则(在标签外,始终生效)
|
||||
<user_allow_rules_to_replace>
|
||||
</user_allow_rules_to_replace>
|
||||
```
|
||||
|
||||
相比 external 版本,额外包含:
|
||||
- 云 CLI 只读命令(aws describe, gcloud describe, kubectl get 等)
|
||||
- 基础设施即代码 plan 命令(terraform plan, pulumi preview 等)
|
||||
- 对应的 deny 规则(云资源创建/修改/删除、IaC apply、生产环境访问等)
|
||||
|
||||
### 模板替换流程
|
||||
|
||||
```
|
||||
buildYoloSystemPrompt()
|
||||
├── BASE_PROMPT.replace('<permissions_template>', EXTERNAL/ANTHROPIC_TEMPLATE)
|
||||
├── .replace(<user_allow_rules_to_replace>, userAllow ?? defaults)
|
||||
├── .replace(<user_deny_rules_to_replace>, userDeny ?? defaults)
|
||||
└── .replace(<user_environment_to_replace>, userEnvironment ?? defaults)
|
||||
```
|
||||
|
||||
- 外部模板:用户设置非空时**替换**对应标签内容,否则保留默认值
|
||||
- 内部模板:用户设置**追加**到默认值之后(标签在末尾为空)
|
||||
|
||||
## 当前状态说明
|
||||
|
||||
> **注意**:auto mode 的完整代码逻辑已存在于代码库中,但依赖 `feature('TRANSCRIPT_CLASSIFIER')` feature flag。
|
||||
> 在当前反编译版本中,`feature()` 始终返回 `false`,因此 auto mode 不可用。
|
||||
> 要启用需将 `feature('TRANSCRIPT_CLASSIFIER')` 改为 `true`,并确保 GrowthBook 配置源有合理的 fallback 默认值。
|
||||
|
||||
Prompt 模板文件为**重建产物**——原始文件在反编译过程中丢失,已根据代码逻辑和 `yoloClassifier.ts` 中的替换模式重新编写。
|
||||
|
||||
## 相关源码索引
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/utils/permissions/yoloClassifier.ts` | 分类器核心实现 |
|
||||
| `src/utils/permissions/autoModeState.ts` | Auto mode 状态管理 |
|
||||
| `src/utils/permissions/permissionSetup.ts` | 模式切换、危险权限剥离 |
|
||||
| `src/utils/permissions/dangerousPatterns.ts` | 危险命令模式列表 |
|
||||
| `src/utils/permissions/classifierDecision.ts` | 分类器决策处理 |
|
||||
| `src/utils/permissions/classifierShared.ts` | 分类器共享逻辑 |
|
||||
| `src/utils/permissions/bashClassifier.ts` | Bash 命令分类规则 |
|
||||
| `src/utils/permissions/bypassPermissionsKillswitch.ts` | bypass 权限熔断器 |
|
||||
| `src/utils/permissions/yolo-classifier-prompts/auto_mode_system_prompt.txt` | 分类器主系统提示词 |
|
||||
| `src/utils/permissions/yolo-classifier-prompts/permissions_external.txt` | 外部权限模板 |
|
||||
| `src/utils/permissions/yolo-classifier-prompts/permissions_anthropic.txt` | 内部权限模板 |
|
||||
| `src/cli/handlers/autoMode.ts` | CLI `auto-mode` 子命令处理 |
|
||||
| `src/utils/messages.ts` | Auto mode 系统提示词注入 |
|
||||
| `src/types/permissions.ts` | 权限类型定义 |
|
||||
| `src/utils/betas.ts` | 模型 auto mode 支持检测 |
|
||||
|
||||
@@ -1,106 +1,177 @@
|
||||
---
|
||||
title: "权限模型"
|
||||
description: "AI 执行命令是最危险的能力。Allow/Ask/Deny 三级权限体系如何在安全与效率间取得平衡?理解规则来源、匹配引擎和死循环防护。"
|
||||
keywords: ["权限模型", "Allow Ask Deny", "权限规则", "权限模式"]
|
||||
title: "权限模型 - Allow/Ask/Deny 三级权限体系"
|
||||
description: "详解 Claude Code 的三级权限模型实现:基于 src/utils/permissions/permissions.ts 的规则匹配引擎、五层规则来源优先级、工具名/命令/路径三维度匹配、Denial Tracking 死循环防护、权限模式切换机制。"
|
||||
keywords: ["权限模型", "Allow Ask Deny", "PermissionRule", "checkPermissions", "Denial Tracking", "权限规则"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
|
||||
AI 可以读取文件、执行命令、修改代码——这些操作在带来效率的同时也带来风险。权限系统的问题是:**什么时候需要人类确认,什么时候可以自动放行?**
|
||||
{/* 本章目标:基于源码揭示权限系统的完整实现 */}
|
||||
|
||||
## 三种权限行为
|
||||
|
||||
| 行为 | 含义 | 用户感知 |
|
||||
|------|------|---------|
|
||||
| **Allow** | 自动放行 | 无感知 |
|
||||
| **Ask** | 弹出确认 | 需要批准或拒绝 |
|
||||
| **Deny** | 直接拒绝 | 被告知原因 |
|
||||
每一次工具调用,系统都会做出三种裁决之一:
|
||||
|
||||
## 规则来源的八层优先级
|
||||
| 行为 | 含义 | 返回类型 | 典型场景 |
|
||||
|------|------|---------|---------|
|
||||
| **Allow** | 自动放行,用户无感知 | `{ behavior: 'allow', updatedInput, decisionReason }` | Read 读取项目内文件 |
|
||||
| **Ask** | 弹出确认对话框 | `{ behavior: 'ask', message, suggestions, metadata }` | Bash 执行未知命令 |
|
||||
| **Deny** | 直接拒绝 | `{ behavior: 'deny', message, decisionReason }` | 尝试执行被禁止的命令 |
|
||||
|
||||
权限规则从 8 个来源汇聚,后者覆盖前者:
|
||||
这些行为由 `PermissionResult` 类型定义(`src/utils/permissions/PermissionResult.ts`)。
|
||||
|
||||
## 权限规则的来源
|
||||
|
||||
规则从 8 个来源汇聚(`PERMISSION_RULE_SOURCES`,`permissions.ts:109`),优先级从低到高(后者覆盖前者):
|
||||
|
||||
```
|
||||
用户全局设置 < 项目设置 < 本地覆盖 < 命令行参数 < 企业策略 < CLI 参数 < 技能白名单 < 本次会话授权
|
||||
1. userSettings — ~/.claude/settings.json(跨项目)
|
||||
2. projectSettings — .claude/settings.json(团队共享)
|
||||
3. localSettings — .claude/settings.local.json(gitignored,个人覆盖)
|
||||
4. flagSettings — --settings 命令行参数
|
||||
5. policySettings — 企业管理员下发的策略(用户不可覆盖)
|
||||
6. cliArg — 命令行 --allow/--deny 参数
|
||||
7. command — Skill 工具的 allowedTools 白名单
|
||||
8. session — 用户在当前对话中手动授权("Always allow")
|
||||
```
|
||||
|
||||
**设计考量**:
|
||||
- **企业策略不可被用户覆盖**——企业管理员可以通过策略强制禁止某些操作
|
||||
- **本次会话授权优先级最高**——用户在对话中选择"Always allow"立即生效
|
||||
- **项目设置可被 git 追踪**——团队可以通过 `.claude/settings.json` 共享权限规则
|
||||
每个来源维护三个数组:`alwaysAllowRules[source]`、`alwaysAskRules[source]`、`alwaysDenyRules[source]`。
|
||||
|
||||
### 规则结构
|
||||
|
||||
每条规则指定三个要素:
|
||||
- **工具名**:如 `Bash`、`Edit`、`mcp__server1`
|
||||
- **匹配内容**:如 `git *`(命令模式)、`src/**`(路径模式)
|
||||
- **行为**:allow / ask / deny
|
||||
|
||||
## 三维度匹配引擎
|
||||
|
||||
### 1. 工具名匹配
|
||||
|
||||
最简单的匹配——规则只指定工具名,没有额外内容:
|
||||
- `"Bash"` → 匹配所有 Bash 调用
|
||||
- `"mcp__server1"` → 匹配该 MCP Server 的所有工具
|
||||
|
||||
### 2. 命令模式匹配(Bash 专用)
|
||||
|
||||
Bash 工具的规则可以指定命令模式:
|
||||
- `{"tool": "Bash", "content": "git *"}` → 匹配 `git commit -m 'fix'`
|
||||
|
||||
命令通过 AST 解析提取第一个子命令进行匹配,不受管道或条件表达式干扰。
|
||||
|
||||
### 3. 路径匹配(文件工具专用)
|
||||
|
||||
Read/Edit/Write 工具的规则可以指定文件路径 glob:
|
||||
- `{"tool": "Edit", "content": "src/**"}` → 匹配 `src/utils/foo.ts`
|
||||
|
||||
**设计洞察**:三维度匹配对应三种不同的安全关注点:
|
||||
- 工具名 → "这个工具允不允许用"
|
||||
- 命令模式 → "这条命令安不安全"
|
||||
- 路径 → "这个文件能不能碰"
|
||||
|
||||
## 权限检查流程
|
||||
|
||||
```
|
||||
Blanket deny → 工具名完全匹配 deny 规则? → 直接拒绝
|
||||
Blanket allow → 工具名完全匹配 allow 规则? → 直接放行
|
||||
工具自身检查 → 各工具有自定义逻辑
|
||||
Hook 系统 → PreToolUse hook 可以覆盖结果
|
||||
Ask 规则 → 匹配则弹出确认
|
||||
默认行为 → 由当前权限模式决定
|
||||
规则数据结构为 `PermissionRule`:
|
||||
```typescript
|
||||
{
|
||||
source: PermissionRuleSource // 来自哪个层级
|
||||
ruleBehavior: 'allow' | 'ask' | 'deny'
|
||||
ruleValue: {
|
||||
toolName: string // 如 "Bash"、"mcp__server1"
|
||||
ruleContent?: string // 如 "git *"、"src/**"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**设计哲学**:deny 优先于 allow。如果某条规则说"禁止 Bash",即使另一条规则说"允许 Bash",最终结果也是禁止。
|
||||
## 规则匹配引擎
|
||||
|
||||
### 三维度匹配
|
||||
|
||||
`permissions.ts` 实现了三种匹配维度:
|
||||
|
||||
**1. 工具名匹配**(`toolMatchesRule()`,第 238 行)
|
||||
|
||||
匹配整个工具,仅当规则没有 `ruleContent`:
|
||||
```typescript
|
||||
// 精确匹配
|
||||
rule "Bash" → 匹配 BashTool
|
||||
rule "mcp__server1" → 匹配该 MCP Server 的所有工具(server 级别)
|
||||
rule "mcp__server1__*" → 通配符匹配(同上)
|
||||
```
|
||||
|
||||
MCP 工具使用 `getToolNameForPermissionCheck()` 获取匹配名称,支持有前缀(`mcp__server__tool`)和无前缀模式。
|
||||
|
||||
**2. 命令模式匹配**(BashTool 的 `checkPermissions()`)
|
||||
|
||||
BashTool 通过 `preparePermissionMatcher()`(`Tool.ts:520`)解析命令模式:
|
||||
```json
|
||||
{"tool": "Bash", "ruleContent": "git *"} → 匹配 "git commit -m 'fix'"
|
||||
```
|
||||
|
||||
命令通过 AST 解析(`readOnlyValidation.ts` 使用 tree-sitter bash),提取第一个子命令进行匹配。
|
||||
|
||||
**3. 路径匹配**(文件工具的 `checkPermissions()`)
|
||||
|
||||
Read/Edit/Write 工具通过 `getPath()` 提取文件路径,与 `ruleContent` 中的 glob 模式匹配:
|
||||
```json
|
||||
{"tool": "Edit", "ruleContent": "src/**"} → 匹配 "src/utils/foo.ts"
|
||||
```
|
||||
|
||||
### 权限检查的完整流程
|
||||
|
||||
每次工具调用的权限检查(`canUseTool()` → `checkPermissions()`)经过以下步骤:
|
||||
|
||||
```
|
||||
1a. Blanket deny 检查
|
||||
getDenyRuleForTool() → 工具名完全匹配 deny 规则?
|
||||
↓ 命中 → deny(工具在 getTools() 阶段就被过滤掉)
|
||||
|
||||
1b. Blanket allow 检查
|
||||
toolAlwaysAllowedRule() → 工具名完全匹配 allow 规则?
|
||||
↓ 命中 → allow
|
||||
|
||||
2. 工具自身 checkPermissions()
|
||||
各工具有自定义逻辑:
|
||||
- BashTool: readOnlyValidation → sandbox 判定 → AST 解析 → 模式匹配
|
||||
- FileEditTool: 路径白名单检查
|
||||
- SkillTool: safe properties 白名单 + 精确/前缀匹配
|
||||
↓ 返回 PermissionResult
|
||||
|
||||
3. Hook 系统
|
||||
executePermissionRequestHooks() → PreToolUse hook 可以 override
|
||||
↓ hook 返回 deny → deny
|
||||
↓ hook 返回 ask → 升级为 ask
|
||||
|
||||
4. Ask 规则检查
|
||||
getAskRules() → 命中 → ask
|
||||
|
||||
5. 默认行为
|
||||
根据当前 permissionMode 决定默认行为
|
||||
- 'default': 大部分工具 ask
|
||||
- 'plan': 写操作 deny,读操作 allow
|
||||
- 'bypass': 全部 allow
|
||||
```
|
||||
|
||||
## 权限模式
|
||||
|
||||
| 模式 | 适用场景 | 行为 |
|
||||
|------|---------|------|
|
||||
| **Default** | 日常使用 | 敏感操作逐一确认 |
|
||||
| **Plan Mode** | 探索阶段 | 只能读不能写 |
|
||||
| **Accept Edits** | 快速迭代 | 文件编辑自动放行 |
|
||||
| **Don't Ask** | 减少打断 | 尽量自动决策 |
|
||||
| **Auto** | 信任 AI | 自动分类决策 |
|
||||
| **Bypass** | 完全信任 | 所有操作自动放行 |
|
||||
| 模式 | `PermissionMode` 值 | 适用场景 | 行为 |
|
||||
|------|---------------------|---------|------|
|
||||
| **Default** | `'default'` | 日常使用 | 敏感操作逐一确认 |
|
||||
| **Plan Mode** | `'plan'` | 探索阶段 | 只能读不能写(`isReadOnly()` 检查) |
|
||||
| **Accept Edits** | `'acceptEdits'` | 快速迭代 | 工作区内文件编辑自动放行,其他操作仍需确认 |
|
||||
| **Don't Ask** | `'dontAsk'` | 减少打断 | 尽量自动决策,减少确认弹窗 |
|
||||
| **Auto** | `'auto'` | 信任 AI | 通过 transcript classifier 自动决策(需 `TRANSCRIPT_CLASSIFIER` feature flag) |
|
||||
| **Bypass** | `'bypassPermissions'` | 完全信任 | 所有操作自动放行(需显式 `--dangerously-skip-permissions`) |
|
||||
|
||||
**设计考量**:权限模式不是"越宽松越好"。Default 模式下每次确认看似烦人,但它是防止 AI 误操作的最后防线。Bypass 模式需要显式的危险标志才能启用——这不是给日常使用的。
|
||||
Plan Mode 切换由 `EnterPlanModeTool.call()` 触发:
|
||||
```typescript
|
||||
// EnterPlanModeTool.ts:88
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
toolPermissionContext: applyPermissionUpdate(
|
||||
prepareContextForPlanMode(prev.toolPermissionContext),
|
||||
{ type: 'setMode', mode: 'plan', destination: 'session' },
|
||||
),
|
||||
}))
|
||||
```
|
||||
|
||||
退出时由 `ExitPlanModeV2Tool` 恢复为之前的模式。
|
||||
|
||||
## Denial Tracking:死循环防护
|
||||
|
||||
当 AI 被连续拒绝同一类操作达到 3 次时,系统注入消息迫使其改变策略。
|
||||
`src/utils/permissions/denialTracking.ts` 实现了拒绝追踪机制:
|
||||
|
||||
**为什么需要这个**?AI 有时会陷入"请求 → 被拒 → 用略有不同的方式请求同一个操作 → 再被拒"的死循环。Denial tracking 检测到这种模式后强制 AI 换思路。
|
||||
```typescript
|
||||
const DENIAL_LIMITS = {
|
||||
maxConsecutive: 3, // 同一工具连续拒绝上限
|
||||
maxTotal: 20, // 总拒绝上限
|
||||
}
|
||||
```
|
||||
|
||||
这是一个经典的"AI 行为修正"设计——不是通过硬约束阻止特定行为,而是通过反馈引导 AI 自行调整。
|
||||
当 AI 被连续拒绝同一类操作达到上限时:
|
||||
1. `recordDenial()` 记录拒绝,增加计数
|
||||
2. `shouldFallbackToPrompting()` 检测到连续拒绝,返回 true
|
||||
3. 系统向 AI 注入消息:"Your previous tool call was rejected..."
|
||||
4. AI 被迫改变策略,避免"反复请求同一个被拒操作"的死循环
|
||||
|
||||
## 运行时更新
|
||||
操作成功时调用 `recordSuccess()` 重置计数。
|
||||
|
||||
权限规则可以在运行时动态更新。当用户在确认对话框中选择"Always allow",规则被同时写入 settings 文件和内存中的权限上下文,立即生效。
|
||||
## 规则的运行时更新
|
||||
|
||||
## 接下来
|
||||
权限规则可以在运行时动态更新(`applyPermissionUpdate()`,`PermissionUpdate.ts`):
|
||||
|
||||
- **为什么安全很重要** — 理解权限系统的设计动机
|
||||
- **Plan Mode** — 理解"先规划再执行"的安全工作流
|
||||
- **沙箱** — 理解 Bash 命令的隔离执行环境
|
||||
```typescript
|
||||
type PermissionUpdate =
|
||||
| { type: 'addRules', destination, rules, behavior }
|
||||
| { type: 'replaceRules', destination, rules, behavior }
|
||||
| { type: 'removeRules', destination, rules, behavior }
|
||||
| { type: 'setMode', destination, mode }
|
||||
| { type: 'addDirectories', destination, directories }
|
||||
| { type: 'removeDirectories', destination, directories }
|
||||
```
|
||||
|
||||
当用户在 Ask 对话框中选择 "Always allow",系统调用 `persistPermissionUpdates()` 将规则写入对应层级的 settings 文件(project/user/managed),同时更新内存中的 `toolPermissionContext`。
|
||||
|
||||
@@ -1,82 +1,151 @@
|
||||
---
|
||||
title: "Plan Mode"
|
||||
description: "先看后做的安全机制。Plan Mode 让 AI 在探索阶段只能读不能写,形成方案后再提交用户审批。理解权限收窄、Prompt-based 权限和计划持久化的设计。"
|
||||
keywords: ["Plan Mode", "计划模式", "先规划再执行", "安全工作流"]
|
||||
title: "计划模式 - Plan Mode 先看后做的安全机制"
|
||||
description: "基于源码解析 Claude Code Plan Mode 的完整实现:EnterPlanModeTool/ExitPlanModeV2Tool 的工具设计、权限上下文切换机制、Prompt-based 权限请求、计划文件持久化、Teammate 审批流程。"
|
||||
keywords: ["Plan Mode", "计划模式", "EnterPlanMode", "ExitPlanMode", "prepareContextForPlanMode", "allowedPrompts"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:基于源码揭示 Plan Mode 的完整实现 */}
|
||||
|
||||
## 问题场景
|
||||
|
||||
你说"重构这个模块",AI 立刻开始改代码——但你还没搞清楚它打算怎么改。等改了一半发现方向不对,已经来不及了。
|
||||
|
||||
## 解决方案:先看后做
|
||||
## Plan Mode 的解决方案
|
||||
|
||||
Plan Mode 给对话加了一个"只读阶段":
|
||||
计划模式给对话加了一个"只读阶段",通过两个工具实现闭环:
|
||||
|
||||
<Steps>
|
||||
<Step title="进入计划模式">
|
||||
AI 判断任务需要规划,请求进入 Plan Mode。需要**用户审批**。
|
||||
<Step title="EnterPlanMode — 进入计划模式">
|
||||
AI 自主判断(或用户触发)任务需要规划,调用 `EnterPlanModeTool`(`packages/builtin-tools/src/tools/EnterPlanModeTool/EnterPlanModeTool.ts:36`)。该工具需要**用户审批**(`checkPermissions` 返回 `ask`)。
|
||||
</Step>
|
||||
<Step title="探索阶段">
|
||||
AI 只能使用只读工具(Read、Grep、Glob)。写操作被自动拒绝。
|
||||
<Step title="探索阶段 — 只读工具集">
|
||||
权限模式切换为 `'plan'`,AI 只能使用 `isReadOnly()` 为 true 的工具(Read、Grep、Glob、Agent 等)。写操作被自动拒绝。
|
||||
</Step>
|
||||
<Step title="提交方案审批">
|
||||
AI 完成探索后,将计划文件提交给用户审阅。这是第二个**需要审批**的节点。
|
||||
<Step title="ExitPlanMode — 提交方案审批">
|
||||
AI 完成探索后,调用 `ExitPlanModeV2Tool`(`packages/builtin-tools/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts:147`),将计划文件提交给用户审阅。这是第二个**需要用户审批**的节点。
|
||||
</Step>
|
||||
<Step title="恢复执行">
|
||||
用户批准后,权限恢复,AI 按计划执行。
|
||||
<Step title="恢复执行 — 全部工具权限">
|
||||
用户批准后,权限模式恢复为进入前的状态,AI 按计划执行。
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
**两个审批节点的设计**:进入时审批("我可以探索吗?")和退出时审批("这个方案可以吗?")。两次审批确保用户对过程和结果都有控制权。
|
||||
|
||||
## 权限的自动收窄与恢复
|
||||
|
||||
### 进入:只读锁定
|
||||
### 进入:`prepareContextForPlanMode()`
|
||||
|
||||
权限模式切换为 `plan`,所有工具的 `isReadOnly()` 检查成为唯一准入条件。不是"某些工具被禁用",而是"只有标记为只读的工具才能执行"。
|
||||
`EnterPlanModeTool.call()`(第 77 行)的核心逻辑:
|
||||
|
||||
**设计考量**:基于属性而非名单的权限控制更安全。如果新增了一个工具但忘记把它加入"plan 模式禁用名单",基于名单的系统会漏过它;但基于 `isReadOnly()` 的系统不会——新工具必须显式声明自己只读才能在 Plan Mode 中使用。
|
||||
```typescript
|
||||
// 1. 记录转换状态(保存之前的模式)
|
||||
handlePlanModeTransition(currentMode, 'plan')
|
||||
|
||||
### 退出:Prompt-based 权限
|
||||
// 2. 切换权限上下文为 plan 模式
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
toolPermissionContext: applyPermissionUpdate(
|
||||
prepareContextForPlanMode(prev.toolPermissionContext),
|
||||
{ type: 'setMode', mode: 'plan', destination: 'session' },
|
||||
),
|
||||
}))
|
||||
```
|
||||
|
||||
AI 可以在计划中声明它需要执行的命令类别。用户批准计划后,这些命令自动放行。
|
||||
`prepareContextForPlanMode()`(`src/utils/permissions/permissionSetup.ts`)做了什么:
|
||||
- 创建新的 `ToolPermissionContext`,`mode` 设为 `'plan'`
|
||||
- 在 plan 模式下,工具的 `isReadOnly()` 检查成为唯一准入条件
|
||||
- 如果用户的默认模式是 `'auto'`,还会激活 classifier 的副作用
|
||||
|
||||
**设计洞察**:这是 Plan Mode 最精妙的设计。传统做法是:计划完成后,AI 每执行一步都要用户确认。Prompt-based 权限让用户在审批计划时"一揽子"授权了后续操作——既减少了打断,又保持了控制。
|
||||
### 退出:权限恢复 + Prompt-based 权限
|
||||
|
||||
当然,AI 只能获得计划中声明的权限。如果计划说"运行测试",AI 不能突然执行 `rm -rf /`。
|
||||
`ExitPlanModeV2Tool` 的退出逻辑做了两件关键的事:
|
||||
|
||||
## 计划文件:可编辑的方案
|
||||
**1. 恢复权限模式**
|
||||
|
||||
计划内容被写入磁盘文件,用户可以在审批前修改 AI 的方案。
|
||||
通过 `handlePlanModeTransition()` 和 `applyPermissionUpdate()` 恢复到进入前的模式。
|
||||
|
||||
**为什么不直接在对话中展示计划**?
|
||||
1. 对话中的计划是"只读"的,用户无法直接修改
|
||||
2. 磁盘文件可以被用户用任何编辑器修改
|
||||
3. 修改后的计划成为 AI 执行的"合约"——AI 必须执行用户修改后的版本
|
||||
**2. 注入 Prompt-based 权限**
|
||||
|
||||
## 什么时候该用 Plan Mode
|
||||
这是 Plan Mode 最精妙的设计——AI 可以在计划中声明它需要执行的命令类别:
|
||||
|
||||
| 场景 | 应该用吗 | 原因 |
|
||||
|------|:--------:|------|
|
||||
| 修复 typo | 跳过 | 改动明确,无需规划 |
|
||||
| 添加删除按钮 | 通常跳过 | 路径明确 |
|
||||
| 重构认证系统 | **使用** | 高影响,需要理解全局 |
|
||||
| 架构决策(Redis vs 内存缓存) | **使用** | 需要权衡多种方案 |
|
||||
| 用户说"开始做 X" | 通常跳过 | 用户已明确意图 |
|
||||
```typescript
|
||||
// ExitPlanModeV2Tool 的 inputSchema
|
||||
allowedPrompts: z.array(z.object({
|
||||
tool: z.enum(['Bash']),
|
||||
prompt: z.string().describe('Semantic description, e.g. "run tests"'),
|
||||
})).optional()
|
||||
```
|
||||
|
||||
**设计哲学**:Plan Mode 是工具而非默认行为。AI 应该在"不确定性高"时使用它,而不是在每次修改时都进入。过度使用 Plan Mode 会降低效率,如同人类在每次改代码前都写设计文档一样低效。
|
||||
当 AI 提交计划时,如果声明了 `allowedPrompts: [{ tool: 'Bash', prompt: 'run tests' }]`,用户批准后,"run tests" 这类 Bash 命令会被自动放行——不再需要逐个确认。
|
||||
|
||||
## 与任务系统的配合
|
||||
## 计划文件的持久化
|
||||
|
||||
Plan Mode 通常与任务系统配合:
|
||||
1. AI 在探索阶段创建任务列表
|
||||
计划内容被写入磁盘文件(由 `getPlanFilePath()` 确定路径),这与简单的"AI 说一段话然后开始执行"有本质区别:
|
||||
|
||||
1. `ExitPlanModeV2Tool` 的 `normalizeToolInput` 从磁盘读取计划内容,注入到 `input.plan` 和 `input.planFilePath`
|
||||
2. 计划文件是用户**可编辑**的——用户可以在审批前修改 AI 的方案
|
||||
3. `planWasEdited` 字段标记用户是否修改了计划,影响后续的 tool_result 回显
|
||||
4. `persistFileSnapshotIfRemote()` 在远程场景下保存文件快照
|
||||
|
||||
## Teammate 场景下的计划审批
|
||||
|
||||
在 Agent Swarms(`isAgentSwarmsEnabled()`)模式下,计划审批有额外的协作流程:
|
||||
|
||||
```typescript
|
||||
// 如果是 Teammate 角色
|
||||
if (isTeammate()) {
|
||||
// 发送计划到 Team Leader 的 mailbox 等待审批
|
||||
const requestId = generateRequestId()
|
||||
writeToMailbox(getTeamName(), {
|
||||
type: 'plan_approval_request',
|
||||
plan, requestId, ...
|
||||
})
|
||||
// 返回 awaitingLeaderApproval: true
|
||||
// Team Leader 审批后通过 mailbox 通知 Teammate
|
||||
}
|
||||
```
|
||||
|
||||
这意味着在蜂群模式下,计划可能不是由直接用户审批,而是由 Team Leader 审批。
|
||||
|
||||
## 什么时候该用计划模式
|
||||
|
||||
`EnterPlanModeTool` 的 Prompt(`packages/builtin-tools/src/tools/EnterPlanModeTool/prompt.ts`)定义了两套触发标准——外部版本更积极(鼓励规划),内部版本更克制(仅在真正模糊时使用):
|
||||
|
||||
| 场景 | 外部版本 | 内部版本 |
|
||||
|------|---------|---------|
|
||||
| 修复 typo | 跳过 | 跳过 |
|
||||
| 添加删除按钮 | **进入**(涉及多个文件) | **跳过**(路径明确) |
|
||||
| 重构认证系统 | **进入** | **进入**(高影响重构) |
|
||||
| "开始做 X" | — | **跳过**(直接开始) |
|
||||
| 架构决策(Redis vs 内存缓存) | **进入** | **进入**(真正模糊) |
|
||||
|
||||
## 计划模式 + 任务系统
|
||||
|
||||
计划模式通常与任务系统配合使用:
|
||||
|
||||
1. 在计划模式中,AI 把实施步骤创建为任务列表(`TodoWrite`)
|
||||
2. 用户审批计划(包含任务列表)
|
||||
3. 退出 Plan Mode 后,AI 按任务列表逐项执行
|
||||
3. 退出计划模式后,AI 按任务列表逐项执行
|
||||
4. 用户可以通过任务列表追踪进度
|
||||
|
||||
这把"理解"和"执行"在时间上分开了——先花时间理解问题,再高效执行方案。
|
||||
## 完整生命周期
|
||||
|
||||
## 接下来
|
||||
|
||||
- **权限模型** — 理解支撑 Plan Mode 的完整权限体系
|
||||
- **任务管理** — 理解 Plan Mode 中创建的任务追踪
|
||||
- **子 Agent** — 理解 Plan Mode 中使用的 Explore Agent
|
||||
```
|
||||
用户: "重构这个模块"
|
||||
↓
|
||||
AI 判断需要规划 → 调用 EnterPlanModeTool
|
||||
↓ 用户审批(Ask 对话框)
|
||||
handlePlanModeTransition(default, 'plan') // 保存 default
|
||||
prepareContextForPlanMode() // 创建只读上下文
|
||||
↓
|
||||
AI 使用 Read/Grep/Glob/Agent 探索代码库
|
||||
↓ (可能 10+ 轮只读工具调用)
|
||||
AI 形成方案 → 调用 ExitPlanModeV2Tool({
|
||||
allowedPrompts: [
|
||||
{ tool: 'Bash', prompt: 'run tests' },
|
||||
{ tool: 'Bash', prompt: 'install dependencies' }
|
||||
]
|
||||
})
|
||||
↓ 用户审批计划(可编辑计划文件)
|
||||
恢复权限模式 → 注入 prompt-based 权限
|
||||
↓
|
||||
AI 使用全部工具执行计划,"run tests" 等命令自动放行
|
||||
```
|
||||
|
||||
@@ -20,7 +20,7 @@ keywords: ["沙箱", "sandbox", "权限", "Bash", "PowerShell", "bubblewrap", "s
|
||||
- 这一层仓库自己负责:策略、配置转换、启停判断、命令包裹、清理和权限联动
|
||||
- 真正做 OS 级隔离的是外部运行时 `@anthropic-ai/sandbox-runtime`
|
||||
|
||||
适配层负责导入底层运行时对象(`SandboxManager`、`SandboxViolationStore` 等),然后在它们外面再包一层符合 Claude Code 自身权限模型的接口。
|
||||
在 `src/utils/sandbox/sandbox-adapter.ts` 里,可以很清楚地看到这条边界:项目导入 `SandboxManager as BaseSandboxManager`、`SandboxViolationStore` 等运行时对象,然后在外面再包一层符合 Claude Code 自身权限模型的适配器。
|
||||
|
||||
底层隔离在不同平台上的落地也不是同一套实现:
|
||||
|
||||
@@ -64,7 +64,7 @@ keywords: ["沙箱", "sandbox", "权限", "Bash", "PowerShell", "bubblewrap", "s
|
||||
|
||||
### 1. 给 shell 一个 OS 级兜底
|
||||
|
||||
Bash AST 分析明确声明自己不是沙箱——它只判断"能不能可靠地理解命令结构",不能阻止危险命令真正运行。
|
||||
`src/utils/bash/ast.ts` 开头就写得很明确:Bash AST 分析不是沙箱,它只是在判断我们能不能可靠地理解命令结构,不能阻止危险命令真的运行。
|
||||
|
||||
这就是为什么应用层再聪明,也很难仅靠“执行前推断”覆盖完整风险面。像下面这些命令,真实副作用都要到运行时才完全展开:
|
||||
|
||||
@@ -106,7 +106,7 @@ Bash AST 分析明确声明自己不是沙箱——它只判断"能不能可靠
|
||||
|
||||
### 4. 拦截运行时绕过和逃逸路径
|
||||
|
||||
适配层专门把一些高风险路径额外加入 `denyWrite`,例如:
|
||||
这个仓库在 `src/utils/sandbox/sandbox-adapter.ts` 里专门把一些高风险路径额外加入 `denyWrite`,例如:
|
||||
|
||||
- `settings.json`
|
||||
- `.claude/skills`
|
||||
@@ -138,7 +138,7 @@ Bash AST 分析明确声明自己不是沙箱——它只判断"能不能可靠
|
||||
- 纯应用层的权限弹窗和规则匹配
|
||||
- Bash AST 解析本身
|
||||
|
||||
尤其要注意一点:Bash AST 分析不是沙箱。它只回答”我们能不能可信地提取 argv 结构”,并不负责阻止危险命令真正运行。
|
||||
尤其要注意一点:Bash AST 分析不是沙箱。源码自己写得很明确,它只回答“我们能不能可信地提取 argv 结构”,并不负责阻止危险命令真正运行。
|
||||
|
||||
## 哪些场景会走沙箱
|
||||
|
||||
@@ -166,7 +166,7 @@ Bash AST 分析明确声明自己不是沙箱——它只判断"能不能可靠
|
||||
5. 这条命令没有被显式排除
|
||||
6. 这次调用没有被允许以 `dangerouslyDisableSandbox` 绕过
|
||||
|
||||
对应入口在 `shouldUseSandbox()` 和沙箱适配层中。
|
||||
对应入口在 `packages/builtin-tools/src/tools/BashTool/shouldUseSandbox.ts` 和 `src/utils/sandbox/sandbox-adapter.ts`。
|
||||
|
||||
### 3. PowerShell 只在支持平台上走
|
||||
|
||||
@@ -514,6 +514,20 @@ REPL / CLI 启动
|
||||
|
||||
而沙箱负责的是最终兜底。
|
||||
|
||||
## 推荐的阅读路径
|
||||
|
||||
如果你想继续顺着源码深入,推荐按下面顺序看:
|
||||
|
||||
1. `packages/builtin-tools/src/tools/BashTool/shouldUseSandbox.ts`
|
||||
2. `src/utils/Shell.ts`
|
||||
3. `src/utils/sandbox/sandbox-adapter.ts`
|
||||
4. `src/utils/permissions/permissions.ts`
|
||||
5. `packages/builtin-tools/src/tools/BashTool/bashPermissions.ts`
|
||||
6. `src/utils/permissions/pathValidation.ts`
|
||||
7. `src/utils/permissions/filesystem.ts`
|
||||
|
||||
按这条线读,会更容易把“权限系统”和“沙箱系统”在脑中拆开。
|
||||
|
||||
## FAQ
|
||||
|
||||
### Q1:Linux 下 `echo hi > /etc/hosts` 会怎样?
|
||||
|
||||
@@ -1,121 +1,220 @@
|
||||
---
|
||||
title: "文件操作"
|
||||
description: "Read/Edit/Write 三个工具不是功能划分,而是风险分级。理解读取去重、原子性编辑、文件历史快照和安全防线的设计。"
|
||||
title: "文件操作工具 - 三大工具的源码级解剖"
|
||||
description: "逆向分析 FileRead、FileEdit、FileWrite 三大工具的完整执行链路:去重缓存、AST 安全编辑、原子性读写、文件历史快照的实现细节。"
|
||||
keywords: ["文件操作", "FileRead", "FileEdit", "FileWrite", "代码编辑", "原子写入"]
|
||||
---
|
||||
|
||||
## 核心设计:风险分级
|
||||
{/* 本章目标:从源码层面解剖三大文件工具的完整执行链路 */}
|
||||
|
||||
## 三大工具的职责分化
|
||||
|
||||
Claude Code 将文件操作拆分为三个独立工具——这不是功能划分,而是**风险分级**:
|
||||
|
||||
| 工具 | 风险级别 | 典型场景 |
|
||||
|------|---------|----------|
|
||||
| **Read** | 只读(免审批) | 查看代码、搜索内容 |
|
||||
| **Edit** | 写入(需确认) | 修改已有代码 |
|
||||
| **Write** | 写入/创建(需确认) | 创建新文件、全量重写 |
|
||||
| 工具 | 权限级别 | 核心方法 | 关键属性 |
|
||||
|------|---------|---------|---------|
|
||||
| **Read** | 只读(免审批) | `isReadOnly() → true` | `maxResultSizeChars: Infinity` |
|
||||
| **Edit** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
|
||||
| **Write** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
|
||||
|
||||
拆成三个工具让权限系统可以精确控制:只读模式只禁用 Edit/Write,允许 AI 自由探索代码;而全禁用模式则连 Read 都受限。
|
||||
<Tip>
|
||||
Read 的 `maxResultSizeChars` 是 `Infinity`,但这并不意味着无限制输出——真正的截断发生在 `validateContentTokens()` 中基于 token 预算的动态判定,而非字符数硬限制。
|
||||
</Tip>
|
||||
|
||||
## Read:多模态读取引擎
|
||||
## FileRead:多模态文件读取引擎
|
||||
|
||||
Read 工具不只是一个 `cat` 命令。它是一个多格式分发器:
|
||||
源码路径:`packages/builtin-tools/src/tools/FileReadTool/FileReadTool.ts`
|
||||
|
||||
| 文件类型 | 处理路径 | 特殊处理 |
|
||||
|---------|---------|---------|
|
||||
| 文本文件 | 分页读取 | 支持行号范围 |
|
||||
| 图片 | 压缩 + 降采样 | 自动调整到 token 预算内 |
|
||||
| PDF | 页面级提取 | 超大 PDF 强制分页读取 |
|
||||
| Notebook | JSON cell 解析 | 保留 cell 结构 |
|
||||
### 读取去重机制
|
||||
|
||||
### 读取去重
|
||||
Read 工具有一个常被忽视但至关重要的**去重层**。当 AI 重复读取同一个文件的同一范围时,系统不会浪费 token 发送两份完整内容:
|
||||
|
||||
当 AI 重复读取同一个文件时,系统通过文件修改时间(mtime)比对避免重复发送相同内容。约 18% 的 Read 调用是重复读取——去重机制直接节省了这部分 token 开销。
|
||||
```typescript
|
||||
// FileReadTool.ts — 去重逻辑
|
||||
const existingState = readFileState.get(fullFilePath)
|
||||
if (existingState && !existingState.isPartialView && existingState.offset !== undefined) {
|
||||
const rangeMatch = existingState.offset === offset && existingState.limit === limit
|
||||
if (rangeMatch) {
|
||||
const mtimeMs = await getFileModificationTimeAsync(fullFilePath)
|
||||
if (mtimeMs === existingState.timestamp) {
|
||||
return { data: { type: 'file_unchanged', file: { filePath: file_path } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**设计细节**:去重只对 Read 工具自身的读取生效。Edit/Write 也会更新内部状态,但不会误触发去重——通过 offset 字段区分读取来源。
|
||||
关键设计点:
|
||||
- 去重仅对 **Read 工具自身的读取**生效(通过 `offset !== undefined` 判定)
|
||||
- Edit/Write 也会写入 `readFileState`,但它们的 `offset` 为 `undefined`,所以不会误命中去重
|
||||
- 通过 mtime 比对确保文件未被外部修改
|
||||
- 有 GrowthBook killswitch(`tengu_read_dedup_killswitch`)可紧急关闭
|
||||
|
||||
实测数据:BQ proxy 显示约 18% 的 Read 调用是同文件碰撞,占 fleet `cache_creation` 的 2.64%。
|
||||
|
||||
### 多格式分发:文本、图片、PDF、Notebook 四条路径
|
||||
|
||||
Read 工具的 `callInner()` 按 `ext` 分发到四条完全不同的处理路径:
|
||||
|
||||
```
|
||||
.ipynb → readNotebook() → JSON cell 解析 → token 校验
|
||||
.png/.jpg/.gif/.webp → readImageWithTokenBudget() → 压缩+降采样
|
||||
.pdf → extractPDFPages() / readPDF() → 页面级提取
|
||||
其他 → readFileInRange() → 分页读取
|
||||
```
|
||||
|
||||
**图片路径的压缩策略**特别精细:
|
||||
1. 先用 `maybeResizeAndDownsampleImageBuffer()` 标准缩放
|
||||
2. 用 `base64.length * 0.125` 估算 token 数
|
||||
3. 超出预算时调用 `compressImageBufferWithTokenLimit()` 激进压缩
|
||||
4. 仍然超限时用 sharp 做最后兜底:`resize(400,400).jpeg({quality:20})`
|
||||
|
||||
**PDF 路径**有页数阈值:超过 `PDF_AT_MENTION_INLINE_THRESHOLD`(默认值在 `apiLimits.ts`)时强制分页读取,每请求最多 `PDF_MAX_PAGES_PER_READ` 页。
|
||||
|
||||
### 安全防线
|
||||
|
||||
Read 工具有多层安全门:
|
||||
Read 工具在 `validateInput()` 中设置了多层安全门:
|
||||
|
||||
- **设备文件屏蔽**:`/dev/zero`、`/dev/random` 等被直接拒绝——它们会产生无限输出或阻塞
|
||||
- **二进制文件拒绝**:排除图片/PDF 后,`.exe`、`.so` 等二进制文件被阻止
|
||||
- **UNC 路径跳过**:Windows 下 `\\server\share` 路径跳过操作,防止 SMB 凭据泄露
|
||||
1. **设备文件屏蔽**(`BLOCKED_DEVICE_PATHS`):`/dev/zero`、`/dev/random`、`/dev/tty` 等——防止无限输出或阻塞挂起
|
||||
2. **二进制文件拒绝**(`hasBinaryExtension`):排除 PDF 和图片扩展名后,阻止读取 `.exe`、`.so` 等二进制文件
|
||||
3. **UNC 路径跳过**:Windows 下 `\\server\share` 路径跳过文件系统操作,防止 SMB NTLM 凭据泄露
|
||||
4. **权限拒绝规则**(`matchingRuleForInput`):匹配 `deny` 规则后直接拒绝
|
||||
|
||||
### 智能错误提示
|
||||
### 文件未找到时的智能建议
|
||||
|
||||
文件不存在时,Read 不只是报错——它会尝试提供修复建议:
|
||||
- 相似文件名的推荐
|
||||
- 基于 CWD 的相对路径建议
|
||||
- macOS 截图文件名中特殊空格字符的纠正
|
||||
当文件不存在时,Read 不会只报一个 "file not found":
|
||||
|
||||
## Edit:精确字符串替换
|
||||
```typescript
|
||||
// FileReadTool.ts
|
||||
const similarFilename = findSimilarFile(fullFilePath) // 相似扩展名
|
||||
const cwdSuggestion = await suggestPathUnderCwd(fullFilePath) // cwd 相对路径建议
|
||||
// macOS 截图特殊处理:薄空格(U+202F) vs 普通空格
|
||||
const altPath = getAlternateScreenshotPath(fullFilePath)
|
||||
```
|
||||
|
||||
Edit 工具的核心操作是"找到旧字符串,替换为新字符串"。听起来简单,实际充满了边缘情况。
|
||||
对 macOS 截图文件名中 AM/PM 前的薄空格(U+202F)做了特殊处理——这是实测中发现的跨 macOS 版本兼容性问题。
|
||||
|
||||
### 引号标准化
|
||||
## FileEdit:精确字符串替换引擎
|
||||
|
||||
AI 模型只能输出直引号(`'` `"`),但源码中可能使用弯引号(`'` `'` `"` `"`)。Edit 工具在匹配时自动标准化引号,但写入时保持文件原有的引号风格——如果文件用弯引号,替换后的新内容也用弯引号。
|
||||
源码路径:`packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts` + `utils.ts`
|
||||
|
||||
**设计洞察**:这是一个典型的"AI 能力边界补偿"设计。AI 的输出限制(只能用直引号)不应该成为文件修改的问题。系统在 AI 和文件系统之间做了透明翻译。
|
||||
### 引号标准化:AI 无法输出的字符怎么办
|
||||
|
||||
AI 模型只能输出直引号(`'` `"`),但源码中可能使用弯引号(`'` `'` `"` `"`)。`findActualString()` 函数处理了这个不对齐:
|
||||
|
||||
```typescript
|
||||
// utils.ts:73-93
|
||||
export function findActualString(fileContent: string, searchString: string): string | null {
|
||||
if (fileContent.includes(searchString)) return searchString // 精确匹配
|
||||
const normalizedSearch = normalizeQuotes(searchString) // 弯引号→直引号
|
||||
const normalizedFile = normalizeQuotes(fileContent)
|
||||
const idx = normalizedFile.indexOf(normalizedSearch)
|
||||
if (idx !== -1) return fileContent.substring(idx, idx + searchString.length)
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
匹配后还有**反向引号保持**(`preserveQuoteStyle`):如果文件用弯引号,替换后的新字符串也自动转换为弯引号,包括缩写中的撇号(如 "don't")。
|
||||
|
||||
### 原子性读-改-写
|
||||
|
||||
Edit 的执行过程是一个无锁原子更新协议:
|
||||
Edit 工具的 `call()` 方法实现了一个**无锁原子更新**协议:
|
||||
|
||||
```
|
||||
备份旧内容 → 同步读取 → mtime 校验 → 查找匹配 → 计算 diff → 写入磁盘 → 更新缓存
|
||||
1. await fs.mkdir(dir) ← 确保目录存在(异步,在临界区外)
|
||||
2. await fileHistoryTrackEdit() ← 备份旧内容(异步,在临界区外)
|
||||
3. readFileSyncWithMetadata() ← 同步读取当前文件内容(临界区开始)
|
||||
4. getFileModificationTime() ← mtime 校验
|
||||
5. findActualString() ← 引号标准化匹配
|
||||
6. getPatchForEdit() ← 计算 diff
|
||||
7. writeTextContent() ← 写入磁盘
|
||||
8. readFileState.set() ← 更新缓存(临界区结束)
|
||||
```
|
||||
|
||||
**关键约束**:从"同步读取"到"写入磁盘"之间不允许任何异步操作。这确保在 mtime 校验和实际写入之间不会有其他进程修改文件——否则就会出现"读到的内容和写入时的内容不一致"的竞态条件。
|
||||
步骤 3-8 之间**不允许任何异步操作**(源码注释明确写道:"Please avoid async operations between here and writing to disk to preserve atomicity")。这确保了在 mtime 校验和实际写入之间不会有其他进程修改文件。
|
||||
|
||||
### 防覆写校验
|
||||
|
||||
Edit 前置条件:
|
||||
1. **必须先读取**文件(AI 不能编辑没看过的文件)
|
||||
2. **文件未被外部修改**(mtime 未变)
|
||||
Edit 工具在 `validateInput()` 中检查两个条件:
|
||||
1. **必须先读取**(`readFileState` 中有记录且不是局部视图)
|
||||
2. **文件未被外部修改**(`mtime` 未变,或全量读取时内容完全一致)
|
||||
|
||||
Windows 上的 mtime 可能因云同步或杀毒软件被修改而不改变内容,因此对全量读取做了内容级比对作为兜底。
|
||||
```typescript
|
||||
// FileEditTool.ts — Windows 特殊处理
|
||||
const isFullRead = readTimestamp.offset === undefined && readTimestamp.limit === undefined
|
||||
if (isFullRead && fileContent === readTimestamp.content) {
|
||||
// 内容不变,安全继续(Windows 云同步/杀毒可能改 mtime)
|
||||
}
|
||||
```
|
||||
|
||||
## Write:全量写入与创建
|
||||
Windows 上的 mtime 可能因云同步、杀毒软件等被修改而不改变内容,因此对全量读取做了内容级比对作为兜底。
|
||||
|
||||
Write 与 Edit 共享大部分基础设施(权限检查、mtime 校验、历史备份),但有两个关键差异。
|
||||
### 编辑大小限制
|
||||
|
||||
```typescript
|
||||
const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024 // 1 GiB
|
||||
```
|
||||
|
||||
超过 1 GiB 的文件直接拒绝编辑——这是 V8 字符串长度限制(~2^30 字符)的安全边界。
|
||||
|
||||
## FileWrite:全量写入与创建
|
||||
|
||||
源码路径:`packages/builtin-tools/src/tools/FileWriteTool/FileWriteTool.ts`
|
||||
|
||||
Write 工具与 Edit 共享大部分基础设施(权限检查、mtime 校验、fileHistory 备份),但有两个关键差异:
|
||||
|
||||
### 行尾处理
|
||||
|
||||
Write 始终使用 LF 行尾。早期版本会保留旧文件的行尾风格,但这导致 Linux 上 bash 脚本被注入 `\r`——现在 AI 发什么行尾就用什么行尾,不再尝试"智能"转换。
|
||||
```typescript
|
||||
// FileWriteTool.ts:300-305 — 关键注释
|
||||
// Write is a full content replacement — the model sent explicit line endings
|
||||
// in `content` and meant them. Do not rewrite them.
|
||||
writeTextContent(fullFilePath, content, enc, 'LF')
|
||||
```
|
||||
|
||||
**设计教训**:有时"不做智能处理"比"做智能处理"更安全。
|
||||
Write 工具始终使用 `LF` 行尾。早期版本会保留旧文件的行尾或采样仓库行尾风格,但这导致 Linux 上 bash 脚本被注入 `\r`——现在 AI 发什么行尾就用什么行尾。
|
||||
|
||||
### 创建 vs 更新
|
||||
### 输出区分
|
||||
|
||||
Write 返回操作类型:
|
||||
- **create**:文件不存在,全新创建
|
||||
- **update**:文件存在且被覆盖,包含完整 diff
|
||||
Write 工具返回 `type: 'create' | 'update'`:
|
||||
- `create`:文件不存在,`originalFile: null`
|
||||
- `update`:文件存在且被覆盖,`structuredPatch` 包含完整 diff
|
||||
|
||||
这让用户和 AI 都能清楚知道操作的实际影响。
|
||||
## 文件历史快照系统
|
||||
|
||||
## 文件历史快照
|
||||
源码路径:`src/utils/fileHistory.ts`
|
||||
|
||||
每次 Edit/Write 前都会备份旧内容。快照系统最多保留 100 个版本,使用内容哈希去重(同一文件多次未变只存一份)。
|
||||
每次 Edit/Write 前都会调用 `fileHistoryTrackEdit()`,快照存储在 `FileHistoryState` 中:
|
||||
|
||||
**设计目的**:不是版本控制(那是 git 的工作),而是"撤销"功能——用户可以在会话中回退 AI 的任何文件修改。
|
||||
```typescript
|
||||
type FileHistorySnapshot = {
|
||||
messageId: UUID // 关联的助手消息 ID
|
||||
trackedFileBackups: Record<string, FileHistoryBackup> // 文件路径 → 备份版本
|
||||
timestamp: Date
|
||||
}
|
||||
```
|
||||
|
||||
## LSP 通知链路
|
||||
- 最多保留 `MAX_SNAPSHOTS = 100` 个快照
|
||||
- 备份使用**内容哈希**去重(同一文件多次未变只存一份)
|
||||
- 支持差异统计(`DiffStats`:`insertions` / `deletions` / `filesChanged`)
|
||||
- 快照通过 `recordFileHistorySnapshot()` 持久化到会话存储
|
||||
|
||||
Edit 和 Write 完成后会通知 LSP 服务器和 IDE 扩展:
|
||||
1. 清除旧的诊断信息
|
||||
2. 通知 LSP 文件已变更
|
||||
3. 触发 LSP 重新计算诊断(如 TypeScript 类型检查)
|
||||
4. 通知 IDE 更新 diff 视图
|
||||
### LSP 通知链路
|
||||
|
||||
这确保文件修改后 IDE 端的实时反馈是同步的——AI 改了一个文件,TypeScript 的类型错误立刻出现在编辑器中。
|
||||
Edit 和 Write 完成写入后都会:
|
||||
1. `clearDeliveredDiagnosticsForFile()` — 清除旧诊断
|
||||
2. `lspManager.changeFile()` — 通知 LSP 文件已变更
|
||||
3. `lspManager.saveFile()` — 触发 LSP 保存事件(TypeScript server 会重新计算诊断)
|
||||
4. `notifyVscodeFileUpdated()` — 通知 VSCode 扩展更新 diff 视图
|
||||
|
||||
## 安全提醒
|
||||
这条链路确保文件修改后 IDE 端的实时反馈是同步的。
|
||||
|
||||
Read 工具在读取文件内容后追加安全提醒:如果文件看起来像恶意代码,AI 应该分析但拒绝改进。这是在"帮助用户"和"防止滥用"之间的平衡。
|
||||
## Cyber Risk 防御
|
||||
|
||||
## 接下来
|
||||
Read 工具在文本内容后追加一个 `<system-reminder>` 提示:
|
||||
|
||||
- **搜索与导航** — Glob/Grep 的搜索策略
|
||||
- **Shell 执行** — Bash 的沙箱和超时控制
|
||||
- **权限模型** — 理解工具权限的完整设计
|
||||
```
|
||||
Whenever you read a file, you should consider whether it would be
|
||||
considered malware. You CAN and SHOULD provide analysis of malware,
|
||||
what it is doing. But you MUST refuse to improve or augment the code.
|
||||
```
|
||||
|
||||
这个提示只在非豁免模型上生效(`MITIGATION_EXEMPT_MODELS` 目前包含 `claude-opus-4-6`)。模型级别的豁免表明:防恶意代码的判断力在不同模型间有差异,这是一个精巧的分级策略。
|
||||
|
||||
@@ -1,116 +1,283 @@
|
||||
---
|
||||
title: "搜索与导航"
|
||||
description: "Glob 按名称找文件,Grep 按内容搜代码——两个维度覆盖代码库定位需求。理解搜索结果排序、token 预算控制和多后端 Web 搜索的设计。"
|
||||
title: "搜索与导航工具 - 代码库精准定位"
|
||||
description: "解析 Claude Code 的搜索导航工具:Glob 文件匹配、Grep 内容搜索,基于 ripgrep 的高性能代码检索,帮助 AI 在百万行代码中精准定位。"
|
||||
keywords: ["代码搜索", "Glob", "Grep", "ripgrep", "文件搜索"]
|
||||
---
|
||||
|
||||
## 两个搜索维度
|
||||
## 两种搜索维度
|
||||
|
||||
| 维度 | 工具 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| **按名称找文件** | Glob | "找到所有测试文件"、"找 config 开头的文件" |
|
||||
| **按内容找代码** | Grep | "哪里定义了这个函数"、"谁在调用这个 API" |
|
||||
| 维度 | 工具 | 底层实现 | 适用场景 |
|
||||
|------|------|----------|---------|
|
||||
| **按名称找文件** | Glob | ripgrep `--files` + glob 过滤 | "找到所有测试文件"、"找 config 开头的文件" |
|
||||
| **按内容找代码** | Grep | ripgrep 正则搜索 | "哪里定义了这个函数"、"谁在调用这个 API" |
|
||||
|
||||
两者共享同一个 ripgrep 引擎,通过不同的参数组合实现不同搜索模式。
|
||||
|
||||
## ripgrep 的内嵌策略
|
||||
## ripgrep 的内嵌方式
|
||||
|
||||
Claude Code 不依赖系统安装的 ripgrep。它使用三级降级策略确保在任何环境下都能工作:
|
||||
Claude Code 不依赖系统安装的 ripgrep——它在 `src/utils/ripgrep.ts` 中实现了三级降级策略:
|
||||
|
||||
| 优先级 | 方式 | 场景 |
|
||||
|--------|------|------|
|
||||
| 1 | 系统 PATH 中的 rg | 用户自定义安装 |
|
||||
| 2 | 编译进二进制的 rg | Bun 静态编译模式 |
|
||||
| 3 | vendor 目录中的预编译二进制 | npm 安装模式 |
|
||||
```
|
||||
优先级 1: 系统 ripgrep (USE_BUILTIN_RIPGREP=false)
|
||||
→ 使用 PATH 中的 rg 二进制
|
||||
→ 安全考虑:只用命令名 'rg',不用完整路径,防止 PATH 劫持
|
||||
|
||||
**设计考量**:搜索是 AI 最频繁使用的操作之一,不能因为用户没装 ripgrep 就降级到低效方案。但也不能假设系统 ripgrep 总是可用——不同环境的安装差异太大了。
|
||||
优先级 2: 内嵌模式 (bundled/native build)
|
||||
→ process.execPath 自身,argv0='rg'
|
||||
→ Bun 将 rg 静态编译进二进制,通过 argv0 分发
|
||||
|
||||
优先级 3: vendor 目录 (npm build)
|
||||
→ vendor/ripgrep/{arch}-{platform}/rg
|
||||
→ macOS 需要 codesign 签名 + 移除 quarantine xattr
|
||||
```
|
||||
|
||||
平台适配示例:
|
||||
```
|
||||
vendor/ripgrep/
|
||||
├── x86_64-darwin/rg # macOS Intel
|
||||
├── arm64-darwin/rg # macOS Apple Silicon
|
||||
├── x86_64-linux/rg # Linux Intel
|
||||
├── arm64-linux/rg # Linux ARM
|
||||
└── x86_64-win32/rg.exe # Windows
|
||||
```
|
||||
|
||||
### macOS 代码签名
|
||||
|
||||
vendor 模式下的 rg 二进制需要 ad-hoc 签名才能通过 Gatekeeper(`codesignRipgrepIfNecessary()`):
|
||||
|
||||
```typescript
|
||||
// 首次使用时执行:
|
||||
// 1. 检查是否已是有效签名
|
||||
codesign -vv -d <rg-path>
|
||||
// 2. 如果只是 linker-signed,重新签名
|
||||
codesign --sign - --force --preserve-metadata=entitlements,requirements,flags,runtime <rg-path>
|
||||
// 3. 移除隔离属性
|
||||
xattr -d com.apple.quarantine <rg-path>
|
||||
```
|
||||
|
||||
## 搜索结果的设计考量
|
||||
|
||||
### 结果数量与 Token 预算
|
||||
### head_limit 与 Token 预算
|
||||
|
||||
默认最多返回 250 条匹配。这不是随意选择的数字:
|
||||
大型项目的搜索结果可能有数十万条。默认最多返回 250 条匹配——这不是随意选择,而是**token 预算**的约束:
|
||||
|
||||
- 每条匹配行约 50-100 token
|
||||
- 250 条 ≈ 12,500-25,000 token
|
||||
- 这大约占 200K 上下文窗口的 6-12%
|
||||
- 这大约占 200k 上下文窗口的 6-12%
|
||||
- 超过这个比例,AI 的推理质量会下降
|
||||
|
||||
AI 可以按需调整 `head_limit` 参数——搜索小项目时可以用更大的值。
|
||||
Grep 工具的 `head_limit` 参数让 AI 可以按需调整——搜索小项目时可以用更大的值。
|
||||
|
||||
### 按修改时间排序
|
||||
|
||||
Glob 默认把**最近修改的文件排在前面**。这不是文件系统的默认排序,而是刻意的设计决策:
|
||||
Glob 默认把**最近修改的文件排在前面**。这不是默认的文件系统排序,而是刻意的设计决策:
|
||||
|
||||
**设计假设**:最近修改的文件最可能与当前任务相关。
|
||||
```
|
||||
设计假设:最近修改的文件最可能与当前任务相关
|
||||
实际效果:AI 优先看到"活"的代码,而不是沉寂的历史文件
|
||||
```
|
||||
|
||||
**实际效果**:AI 优先看到"活"的代码,而不是沉寂的历史文件。当用户说"帮我修这个 bug"时,最近改过的文件通常就是 bug 所在。
|
||||
在 `packages/builtin-tools/src/tools/GlobTool/` 中,ripgrep 的输出在返回给 AI 前按 mtime 排序。
|
||||
|
||||
### 错误恢复
|
||||
### ripgrep 的错误处理
|
||||
|
||||
ripgrep 执行有专门的错误恢复策略:
|
||||
ripgrep 执行有专门的错误恢复链(`src/utils/ripgrep.ts`):
|
||||
|
||||
| 错误 | 处理 |
|
||||
|------|------|
|
||||
| 资源不足 | 自动切换到单线程模式重试 |
|
||||
| 超时 | 返回已有部分结果,丢弃可能不完整的最后一行 |
|
||||
| 进程无响应 | 5 秒后强制终止 |
|
||||
|
||||
**设计哲学**:部分结果比没有结果好。即使搜索没完成,AI 也能基于已有信息做出判断。
|
||||
| **EAGAIN**(资源不足) | 自动以单线程模式 `-j 1` 重试 |
|
||||
| **超时**(默认 20s,WSL 60s) | 返回已有部分结果,丢弃可能不完整的最后一行 |
|
||||
| **缓冲区溢出** | 截断到 20MB,返回已收集的结果 |
|
||||
| **SIGTERM 失效** | 5 秒后升级为 SIGKILL |
|
||||
|
||||
## ToolSearch:在 50+ 工具中发现目标
|
||||
|
||||
当可用工具超过 50 个时(含 MCP 提供的外部工具),AI 可能不知道该用哪个。ToolSearch 提供了工具发现机制。
|
||||
当可用工具超过 50 个时(含 MCP 提供的外部工具),AI 可能不知道该用哪个。**ToolSearch**(`packages/builtin-tools/src/tools/ToolSearchTool/`)提供了工具发现机制。
|
||||
|
||||
### 搜索算法
|
||||
|
||||
ToolSearch 实现了基于关键词的加权搜索(`searchToolsWithKeywords()`):
|
||||
|
||||
```
|
||||
输入: "database connection"
|
||||
→ 精确匹配工具名(快速路径)
|
||||
→ MCP 前缀匹配("mcp__postgres" 匹配所有 postgres 工具)
|
||||
→ 关键词拆分 + 加权评分
|
||||
→ 按分数排序,返回 top-N
|
||||
输入: query = "database connection"
|
||||
↓
|
||||
1. 精确匹配: 检查是否有工具名完全匹配(快速路径)
|
||||
2. MCP 前缀匹配: "mcp__postgres" → 匹配所有 postgres 相关工具
|
||||
3. 关键词拆分: ["database", "connection"]
|
||||
4. 工具名解析:
|
||||
- MCP 工具: "mcp__server__action" → ["server", "action"]
|
||||
- 普通工具: "FileEditTool" → ["file", "edit", "tool"]
|
||||
5. 加权评分:
|
||||
- 工具名精确匹配: 10 分(MCP: 12 分)
|
||||
- 工具名部分匹配: 5 分(MCP: 6 分)
|
||||
- searchHint 匹配: 4 分
|
||||
- 描述匹配: 2 分
|
||||
6. 必选词过滤: "+database" 前缀表示必须包含
|
||||
7. 按分数排序,返回 top-N
|
||||
```
|
||||
|
||||
评分权重:工具名精确匹配(10分)> 工具名部分匹配(5分)> 搜索提示匹配(4分)> 描述匹配(2分)。MCP 工具额外加分,因为它们通常按功能组织。
|
||||
### `select:` 直接选择
|
||||
|
||||
### 延迟加载
|
||||
AI 也可以用 `select:ToolName` 精确选择已知工具。这比搜索更快,且支持逗号分隔的批量选择(`select:A,B,C`)。
|
||||
|
||||
不是所有工具都常驻内存。MCP 工具和低频工具被标记为延迟加载——只有在 ToolSearch 选中后才真正加载。这减少了每次 API 调用的 token 开销(工具描述占用大量 token)。
|
||||
### 延迟加载(Deferred Tools)
|
||||
|
||||
不是所有工具都常驻内存。MCP 工具和低频工具被标记为 `isDeferredTool`,只有在 ToolSearch 选中后才真正加载。这减少了每次 API 调用的 token 开销(工具描述占用大量 token)。
|
||||
|
||||
### 缓存策略
|
||||
|
||||
工具描述的获取是 memoized 的——只在延迟工具集合变化时清除缓存:
|
||||
|
||||
```typescript
|
||||
// 工具名排序后拼接作为缓存 key
|
||||
function getDeferredToolsCacheKey(deferredTools: Tools): string {
|
||||
return deferredTools.map(t => t.name).sort().join(',')
|
||||
}
|
||||
```
|
||||
|
||||
## Web 搜索与抓取
|
||||
|
||||
AI 的信息获取不局限于本地代码。WebSearch 搜索互联网,WebFetch 抓取特定网页内容——和人类开发者的工作方式一致。
|
||||
AI 的信息获取不局限于本地代码:
|
||||
|
||||
### WebSearch 的多后端设计
|
||||
- **WebSearch**(`packages/builtin-tools/src/tools/WebSearchTool/`):调用 Anthropic API 的 `web_search_20250305` server tool 搜索互联网
|
||||
- **WebFetch**(`packages/builtin-tools/src/tools/WebFetchTool/`):抓取特定 URL 内容,转换为 Markdown 供 AI 阅读
|
||||
|
||||
WebSearch 通过适配器模式支持三种搜索后端:
|
||||
这让 AI 可以查阅文档、搜索 Stack Overflow、阅读 GitHub issue——和人类开发者的工作方式一致。
|
||||
|
||||
| 后端 | 适用场景 | 需要 API 密钥 |
|
||||
|------|---------|:------------:|
|
||||
| **Anthropic API** | 使用官方 API 的用户 | 是 |
|
||||
| **Bing** | 第三方代理/非官方端点 | 否 |
|
||||
| **Brave** | 需要 Brave 搜索 | 是 |
|
||||
### WebSearch 实现机制
|
||||
|
||||
**设计考量**:不是所有用户都使用 Anthropic 官方 API。第三方代理(OpenAI 兼容、Bedrock 等)无法使用 Anthropic 的服务端搜索工具,因此需要独立的搜索后端。
|
||||
WebSearch 通过适配器模式支持三种搜索后端,由 `packages/builtin-tools/src/tools/WebSearchTool/adapters/` 中的工厂函数 `createAdapter()` 选择:
|
||||
|
||||
Bing 适配器直接抓取搜索页面并解析结果。它使用完整的浏览器请求头绕过反爬机制,并解码 Bing 的重定向 URL 获取真实链接。
|
||||
```
|
||||
适配器架构:
|
||||
WebSearchTool.call()
|
||||
→ createAdapter() 选择后端
|
||||
├─ ApiSearchAdapter — Anthropic API 服务端搜索(需官方 API 密钥)
|
||||
├─ BingSearchAdapter — 直接抓取 Bing 搜索页面解析(无需 API 密钥)
|
||||
└─ BraveSearchAdapter — 调用 Brave LLM Context API 解析(需 Brave API 密钥)
|
||||
→ adapter.search(query, options)
|
||||
→ 转换为统一 SearchResult[] 格式返回
|
||||
```
|
||||
|
||||
### WebFetch 的安全防护
|
||||
#### 适配器选择逻辑
|
||||
|
||||
WebFetch 不只是"抓取 URL"——它有完整的安全防护层:
|
||||
`adapters/index.ts` 中的工厂函数按以下优先级选择后端:
|
||||
|
||||
| 防护 | 说明 |
|
||||
| 优先级 | 条件 | 适配器 |
|
||||
|--------|------|--------|
|
||||
| 1 | 环境变量 `WEB_SEARCH_ADAPTER=api` | `ApiSearchAdapter` |
|
||||
| 2 | 环境变量 `WEB_SEARCH_ADAPTER=bing` | `BingSearchAdapter` |
|
||||
| 3 | 环境变量 `WEB_SEARCH_ADAPTER=brave` | `BraveSearchAdapter` |
|
||||
| 4 | API Base URL 指向 Anthropic 官方 | `ApiSearchAdapter` |
|
||||
| 5 | 第三方代理 / 非官方端点 | `BingSearchAdapter` |
|
||||
|
||||
适配器是无状态的,同一会话内缓存复用。
|
||||
|
||||
#### ApiSearchAdapter — API 服务端搜索
|
||||
|
||||
将搜索请求委托给 Anthropic API 的 `web_search_20250305` server tool:
|
||||
|
||||
```
|
||||
调用链:
|
||||
ApiSearchAdapter.search(query, options)
|
||||
→ queryModelWithStreaming() 发起独立的 API 调用
|
||||
→ 携带 extraToolSchemas: [BetaWebSearchTool20250305]
|
||||
→ API 服务端执行搜索,返回流式事件
|
||||
→ server_tool_use / web_search_tool_result / text 交替返回
|
||||
→ extractSearchResults() 从 content blocks 提取 SearchResult[]
|
||||
```
|
||||
|
||||
| 特性 | 实现 |
|
||||
|------|------|
|
||||
| 域名预检 | 调用 Anthropic API 检查域名是否在黑名单中 |
|
||||
| 重定向控制 | 仅允许同域重定向,跨域重定向需要 AI 重新调用 |
|
||||
| 内容大小限制 | 单次响应上限 10MB |
|
||||
| URL 验证 | 长度、协议、公网域名检查 |
|
||||
| **模型选择** | Feature flag `tengu_plum_vx3` 控制用 Haiku(强制 tool_choice)还是主模型 |
|
||||
| **搜索上限** | 每次调用最多 8 次搜索(`max_uses: 8`) |
|
||||
| **域过滤** | 支持 `allowedDomains` / `blockedDomains` |
|
||||
| **进度追踪** | 流式解析 `input_json_delta` 提取 query,实时回调 `onProgress` |
|
||||
|
||||
**预批准域名**:约 90 个主流技术文档站点(MDN、Python docs、React docs 等)无需手动授权即可抓取。对预批准域名,WebFetch 跳过摘要步骤直接返回原文——因为技术文档本身的结构化程度已经足够好。
|
||||
#### BingSearchAdapter — Bing 搜索页面解析
|
||||
|
||||
## 接下来
|
||||
直接抓取 Bing 搜索 HTML 并用正则提取结果,无需 API 密钥:
|
||||
|
||||
- **Shell 执行** — Bash 的沙箱和超时控制
|
||||
- **任务管理** — TaskCreate/TaskUpdate 的追踪系统
|
||||
- **工具系统** — 理解所有工具的统一接口设计
|
||||
```
|
||||
调用链:
|
||||
BingSearchAdapter.search(query, options)
|
||||
→ axios.get(bing.com/search?q=...) — 使用浏览器级别 headers 绕过反爬
|
||||
→ extractBingResults(html)
|
||||
→ 正则匹配 <li class="b_algo"> 块
|
||||
→ 提取 <h2><a> 标题和 URL
|
||||
→ resolveBingUrl() 解码 Bing 重定向链接
|
||||
→ extractSnippet() 三级降级提取摘要
|
||||
→ 客户端域过滤 (allowedDomains / blockedDomains)
|
||||
→ 返回 SearchResult[]
|
||||
```
|
||||
|
||||
**反爬策略**:Bing 对非浏览器 UA 返回需要 JS 渲染的空页面。适配器使用完整的 Edge 浏览器请求头(包含 `Sec-Ch-Ua`、`Sec-Fetch-*` 等现代浏览器标头)确保获得完整 HTML。同时使用 `setmkt=en-US` 参数统一市场定位,避免 Bing 基于用户 IP 做区域化定向(如跳转到德语/新加坡市场导致结果不相关)。
|
||||
|
||||
**URL 解码**:Bing 搜索结果中的 URL 为重定向格式(`bing.com/ck/a?...&u=a1aHR0cHM6Ly9...`),`resolveBingUrl()` 从 `u` 参数中 base64 解码出真实目标 URL(`a1` 前缀 = https,`a0` = http)。
|
||||
|
||||
**摘要提取**(`extractSnippet()`)按优先级尝试三个来源:
|
||||
1. `<p class="b_lineclamp...">` — 带行截断的摘要段落
|
||||
2. `<div class="b_caption">` 内的 `<p>` — 普通摘要段落
|
||||
3. `<div class="b_caption">` 的直接文本内容 — 兜底方案
|
||||
|
||||
| 特性 | 实现 |
|
||||
|------|------|
|
||||
| **超时** | 30 秒(`FETCH_TIMEOUT_MS`) |
|
||||
| **域过滤** | 支持 `allowedDomains` / `blockedDomains`,含子域名匹配 |
|
||||
| **进度追踪** | 发送 query_update 和 search_results_received 回调 |
|
||||
| **中止支持** | 外部 AbortSignal 传播到 axios 请求 |
|
||||
|
||||
### WebSearchTool 统一接口
|
||||
|
||||
`WebSearchTool`(`packages/builtin-tools/src/tools/WebSearchTool/WebSearchTool.ts`)是面向主循环的工具定义,所有 provider 均可使用(`isEnabled()` 始终返回 true)。它将适配器返回的 `SearchResult[]` 转换为内部 `Output` 格式,`mapToolResultToToolResultBlockParam` 将搜索结果格式化为带 markdown 超链接的文本,并附加 "REMINDER" 要求主模型在回复中包含 Sources。
|
||||
|
||||
### WebFetch 实现机制
|
||||
|
||||
WebFetch 是一个完整的 HTTP 客户端 + 内容处理管线:
|
||||
|
||||
```
|
||||
调用链:
|
||||
WebFetchTool.call({ url, prompt })
|
||||
→ getURLMarkdownContent(url)
|
||||
→ validateURL() — 长度≤2000、无用户名密码、公网域名
|
||||
→ URL_CACHE 命中检查(15 分钟 TTL LRU,50MB 上限)
|
||||
→ checkDomainBlocklist() — 调用 api.anthropic.com/api/web/domain_info 预检
|
||||
→ getWithPermittedRedirects() — axios 请求,自定义重定向处理
|
||||
→ HTML → Turndown 转 Markdown(懒加载单例,~1.4MB)
|
||||
→ 非 HTML → 原始文本
|
||||
→ 二进制(PDF 等)→ persistBinaryContent() 保存到磁盘
|
||||
→ applyPromptToMarkdown()
|
||||
→ 截断到 100K 字符
|
||||
→ queryHaiku() 用小模型按 prompt 提取信息
|
||||
→ 返回处理后的结果
|
||||
```
|
||||
|
||||
安全防护多层设计:
|
||||
|
||||
| 层级 | 机制 | 说明 |
|
||||
|------|------|------|
|
||||
| **域名预检** | `checkDomainBlocklist()` | 调用 `api.anthropic.com/api/web/domain_info?domain=…`,5 分钟缓存 |
|
||||
| **重定向控制** | `isPermittedRedirect()` | 仅允许同 host(±www)重定向,跨域重定向返回提示让 AI 重新调用 |
|
||||
| **重定向深度** | `MAX_REDIRECTS = 10` | 防止重定向循环无限挂起 |
|
||||
| **内容大小** | `MAX_HTTP_CONTENT_LENGTH = 10MB` | 单次响应上限 |
|
||||
| **请求超时** | `FETCH_TIMEOUT_MS = 60s` | 主请求超时;域名预检 10s |
|
||||
| **URL 验证** | `validateURL()` | 长度、协议、用户名密码、公网域名检查 |
|
||||
| **egress 检测** | `X-Proxy-Error: blocked-by-allowlist` | 检测企业代理拦截 |
|
||||
|
||||
预批准域名(`packages/builtin-tools/src/tools/WebFetchTool/preapproved.ts`):
|
||||
|
||||
用户无需手动授权即可抓取的域名列表,包含 ~90 个主流技术文档站点(MDN、Python docs、React docs、AWS docs 等)。列表分为 hostname-only 和 path-prefix 两类,查找复杂度 O(1)。
|
||||
|
||||
对预批准域名,WebFetch 跳过 Haiku 摘要步骤(如果内容是 Markdown 且 < 100K 字符),直接返回原文——因为技术文档本身的结构化程度已经足够好。
|
||||
|
||||
权限模型方面,WebFetch 按 hostname 生成 `domain:xxx` 规则匹配用户的 allow/deny/ask 规则,支持用户对特定域名配置永久允许或拒绝。
|
||||
|
||||
### ripgrep 的流式输出
|
||||
|
||||
对于交互式场景(如 QuickOpen),ripgrep 支持**流式输出**(`ripGrepStream()`):
|
||||
|
||||
```
|
||||
rg --files → 逐 chunk 到达 → 按行分割 → onLines(lines) 回调
|
||||
```
|
||||
|
||||
不需要等 ripgrep 完成整个搜索——第一批结果在 rg 仍在遍历目录树时就已展示。调用者可以通过 AbortSignal 提前终止搜索(例如找到足够多的结果后)。
|
||||
|
||||
@@ -1,84 +1,168 @@
|
||||
---
|
||||
title: "Shell 执行"
|
||||
description: "AI 执行命令是最危险的能力。BashTool 如何通过只读判定、AST 解析、自动后台化和输出截断在安全与效率间取得平衡。"
|
||||
title: "命令执行工具 - BashTool 安全设计与实现"
|
||||
description: "从源码角度解析 Claude Code BashTool:只读命令判定、AST 安全解析、自动后台化、输出截断和专用工具 vs shell 命令的设计权衡。"
|
||||
keywords: ["Bash 工具", "命令执行", "Shell 执行", "安全命令", "AI 执行命令"]
|
||||
---
|
||||
|
||||
## 核心挑战
|
||||
{/* 本章目标:从源码角度揭示 BashTool 的安全设计、执行链路和关键工程决策 */}
|
||||
|
||||
AI 能执行任意 shell 命令是最强大也最危险的能力。一个 `rm -rf /` 就能造成不可逆的破坏。
|
||||
## 执行链路总览
|
||||
|
||||
BashTool 的设计核心是在**安全**和**效率**之间取得平衡——让 AI 能自由执行只读操作,但对有副作用的命令严格把关。
|
||||
一条 Bash 命令从 AI 决策到实际执行的完整路径:
|
||||
|
||||
## 只读命令的判定
|
||||
```
|
||||
AI 生成 tool_use: { command: "npm test" }
|
||||
↓
|
||||
BashTool.validateInput() ← 基础输入校验
|
||||
↓
|
||||
BashTool.checkPermissions() ← 权限检查(详见安全体系章节)
|
||||
├── isReadOnly()? → 自动 allow(只读命令免审批)
|
||||
├── bashToolHasPermission() ← AST 解析 + 语义检查 + 规则匹配
|
||||
└── 未匹配 → 弹窗确认
|
||||
↓
|
||||
BashTool.call() → runShellCommand()
|
||||
↓
|
||||
shouldUseSandbox(input) ← 是否需要沙箱包裹
|
||||
↓
|
||||
Shell.exec(command, { shouldUseSandbox, shouldAutoBackground })
|
||||
↓
|
||||
spawn(wrapped_command) ← 实际进程创建
|
||||
```
|
||||
|
||||
BashTool 的 `isReadOnly()` 决定一条命令是否需要用户确认:
|
||||
## 只读命令的判定:为什么 Read 免审批而 Bash 不一定
|
||||
|
||||
| 命令类别 | 示例 | 是否只读 |
|
||||
|---------|------|:--------:|
|
||||
| 搜索类 | `find`、`grep`、`rg`、`which` | ✓ |
|
||||
| 读取类 | `cat`、`head`、`wc`、`jq`、`sort` | ✓ |
|
||||
| 列表类 | `ls`、`tree`、`du` | ✓ |
|
||||
| 中性命令 | `echo`、`true`、`false` | 不影响判定 |
|
||||
BashTool 的 `isReadOnly()` 方法(`packages/builtin-tools/src/tools/BashTool/BashTool.tsx:655`)决定一条命令是否被视为"只读":
|
||||
|
||||
### 复合命令的处理
|
||||
```typescript
|
||||
isReadOnly(input) {
|
||||
const compoundCommandHasCd = commandHasAnyCd(input.command)
|
||||
const result = checkReadOnlyConstraints(input, compoundCommandHasCd)
|
||||
return result.behavior === 'allow'
|
||||
}
|
||||
```
|
||||
|
||||
对于复合命令(`ls dir && echo "---" && ls dir2`),系统拆分后逐段检查——**所有非中性段都必须属于只读集合**,整条命令才被视为只读。
|
||||
判定逻辑基于 4 个命令集合(`BashTool.tsx:120-166`):
|
||||
|
||||
**设计考量**:`echo` 等中性命令不影响判定,因为 `ls && echo "done"` 和单纯的 `ls` 在副作用上没有区别。但如果 `ls && git push`,`git push` 有副作用,整条命令就不能免审批。
|
||||
| 集合 | 命令 | 性质 |
|
||||
|------|------|------|
|
||||
| `BASH_SEARCH_COMMANDS` | find, grep, rg, ag, ack, locate, which, whereis | 搜索类 |
|
||||
| `BASH_READ_COMMANDS` | cat, head, tail, wc, stat, file, jq, awk, sort, uniq... | 读取/分析类 |
|
||||
| `BASH_LIST_COMMANDS` | ls, tree, du | 列表类 |
|
||||
| `BASH_SEMANTIC_NEUTRAL_COMMANDS` | echo, printf, true, false, : | 语义中性(不影响判定) |
|
||||
|
||||
## AST 安全解析
|
||||
对于复合命令(`ls dir && echo "---" && ls dir2`),系统拆分后逐段检查——**所有非中性段都必须属于上述集合**,整条命令才被视为只读。
|
||||
|
||||
权限检查不是基于简单的字符串匹配——系统使用 tree-sitter bash 解析器分析命令的抽象语法树。
|
||||
```typescript
|
||||
// BashTool.tsx — 简化的判定逻辑
|
||||
for (const part of partsWithOperators) {
|
||||
if (BASH_SEMANTIC_NEUTRAL_COMMANDS.has(baseCommand)) continue // 跳过中性段
|
||||
if (!isPartSearch && !isPartRead && !isPartList) {
|
||||
return { isSearch: false, isRead: false, isList: false } // 有任何一段不通过 → 非只读
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**为什么需要 AST 解析**?字符串匹配无法处理 `git push` 被嵌在管道、子 shell 或条件表达式中的情况。AST 解析可以准确提取每个子命令,确保 `git push` 不会因为被嵌在看似无害的上下文中而绕过检查。
|
||||
## AST 安全解析:tree-sitter bash 解析
|
||||
|
||||
**Fail-safe 策略**:解析失败时,系统假设命令不安全,触发所有安全检查。宁可多确认一次,也不要漏过一个危险命令。
|
||||
`preparePermissionMatcher()`(`BashTool.tsx:663`)在权限检查前用 `parseForSecurity()` 解析命令结构:
|
||||
|
||||
## 超时与自动后台化
|
||||
```typescript
|
||||
async preparePermissionMatcher({ command }) {
|
||||
const parsed = await parseForSecurity(command)
|
||||
if (parsed.kind !== 'simple') {
|
||||
return () => true // 解析失败 → fail-safe,触发所有 hook
|
||||
}
|
||||
// 提取子命令列表,剥离 VAR=val 前缀
|
||||
const subcommands = parsed.commands.map(c => c.argv.join(' '))
|
||||
return pattern => {
|
||||
return subcommands.some(cmd => matchWildcardPattern(pattern, cmd))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
长时间运行的命令不应该阻塞 AI 的整个工作循环。
|
||||
关键安全点:对于复合命令 `ls && git push`,解析后拆分为 `["ls", "git push"]`,确保 `git push` 不会因为前半段是只读命令而绕过权限检查。解析失败时采用 fail-safe 策略——假设不安全,触发所有安全 hook。
|
||||
|
||||
### 分级超时
|
||||
## 超时控制:分级策略
|
||||
|
||||
| 场景 | 超时 |
|
||||
|------|------|
|
||||
| 默认 | 2 分钟 |
|
||||
| 用户显式设置 | 最长 10 分钟 |
|
||||
```
|
||||
用户指定 timeout → 直接使用
|
||||
↓ 未指定
|
||||
getDefaultTimeoutMs()
|
||||
├── 默认上限:120,000ms(2 分钟)
|
||||
└── 最大上限:600,000ms(10 分钟,用户显式设置时)
|
||||
```
|
||||
|
||||
### 自动后台化
|
||||
超时后系统不会直接杀进程——`ShellCommand`(`src/utils/ShellCommand.ts:144`)通过 `onTimeout` 回调通知调用方,由调用方决定是终止还是后台化。
|
||||
|
||||
主线程 Agent 有 15 秒的"阻塞预算"——超过这个时间,系统自动将命令转为后台任务,AI 可以继续做其他事。后台任务完成后通过通知机制汇报结果。
|
||||
## 自动后台化
|
||||
|
||||
**设计考量**:一个 `npm install` 可能需要几分钟,不应该让 AI 在此期间完全停滞。自动后台化让 AI 可以在等待安装的同时继续做其他工作——和人类开发者一样。
|
||||
长时间运行的命令可以自动转为后台任务,不阻塞 AI 的 agentic loop:
|
||||
|
||||
## 输出截断
|
||||
```typescript
|
||||
// BashTool.tsx:1158
|
||||
const shouldAutoBackground = !isBackgroundTasksDisabled
|
||||
&& isAutobackgroundingAllowed(command)
|
||||
```
|
||||
|
||||
命令输出过长时会触发截断,防止海量日志塞进 AI 的上下文窗口。
|
||||
自动后台化的完整链路:
|
||||
|
||||
截断不是简单砍尾——系统通过 `isIncomplete` 标记告知 AI 输出不完整。AI 可以决定是否需要用更精确的命令(如 `grep` 管道、`head` 限制)重新获取。
|
||||
```
|
||||
命令开始执行
|
||||
↓ 进度轮询
|
||||
15 秒内未完成(ASSISTANT_BLOCKING_BUDGET_MS)
|
||||
↓
|
||||
检查 isAutobackgroundingAllowed(command)
|
||||
↓ 允许
|
||||
将前台任务转为后台任务(backgroundExistingForegroundTask)
|
||||
↓
|
||||
shellCommand.onTimeout → spawnBackgroundTask()
|
||||
↓
|
||||
返回 taskId 给 AI,AI 可以继续做其他事
|
||||
↓
|
||||
后台任务完成后通过通知机制汇报结果
|
||||
```
|
||||
|
||||
**设计哲学**:让 AI 知道"信息不完整"比给它一堆截断的垃圾更有用。AI 会根据不完整的信息自行调整策略。
|
||||
主线程 Agent 有 15 秒的阻塞预算——超过这个时间,系统自动将命令后台化。这防止了一个 `npm install` 阻塞整个 agentic loop 数分钟。
|
||||
|
||||
## 专用工具 vs Shell 命令
|
||||
## 输出截断策略
|
||||
|
||||
Claude Code 为文件读写、搜索等操作提供了专用工具(Read、Grep、Glob),而不是让 AI 用 `cat`、`grep` 等 shell 命令:
|
||||
命令输出过长时会触发截断,防止把海量日志塞进 AI 的上下文窗口:
|
||||
|
||||
| 截断点 | 位置 | 行为 |
|
||||
|--------|------|------|
|
||||
| `maxResultSizeChars` | 工具级(通常 100K 字符) | 超长输出在写入消息前截断 |
|
||||
| 进度轮询截断 | `onProgress` 回调 | 只传递最后几行作为进度显示 |
|
||||
| `totalBytes` 标记 | `isIncomplete` 参数 | 告知 AI 输出被截断 |
|
||||
|
||||
截断不是简单砍尾——`isIncomplete` 标记确保 AI 知道输出不完整,可以决定是否需要用更精确的命令重新获取。
|
||||
|
||||
## 为什么用专用工具而不是直接调 shell
|
||||
|
||||
Claude Code 为文件读写、代码搜索等操作提供了专用工具(Read、Grep、Glob),而不是让 AI 用 `cat`、`grep` 等 shell 命令。这不仅是用户体验的选择,更是架构层面的设计决策:
|
||||
|
||||
| 维度 | 专用工具 | Bash 命令 |
|
||||
|------|---------|----------|
|
||||
| **权限** | 只读操作自动放行 | 需要整条命令的权限检查 |
|
||||
| **输出** | 结构化数据,支持 diff 高亮 | 纯文本,无渲染优化 |
|
||||
| **性能** | 文件缓存、分页、token 预算 | 每次新进程,无缓存 |
|
||||
| **并发** | 只读操作可并行执行 | 有副作用的命令必须串行 |
|
||||
| **权限粒度** | `Read` 是只读操作 → 自动放行 | `Bash: cat file` 需要审批整条命令(cat 在只读集合中但走不同路径) |
|
||||
| **输出结构化** | 返回结构化数据,UI 可渲染 diff、高亮 | 纯文本输出,无渲染优化 |
|
||||
| **性能优化** | 文件缓存、分页、token 预算控制 | 每次都是新进程,无缓存 |
|
||||
| **并发安全** | `isConcurrencySafe()` 返回 `true` → 可并行执行 | Bash 命令可能有副作用,串行执行 |
|
||||
| **安全审计** | 工具名精确匹配权限规则 | 需 AST 解析命令结构后匹配 |
|
||||
|
||||
**设计洞察**:专用工具在安全性和效率上都优于等效的 shell 命令。`Read` 工具知道自己是只读的,所以可以自动放行;而 `cat` 走 BashTool 的权限路径,需要更多检查。
|
||||
`isConcurrencySafe()`(`BashTool.tsx:652`)是一个常被忽视但重要的设计——只有只读命令可以在 agentic loop 中并行执行,有副作用的命令必须串行,防止竞态条件。
|
||||
|
||||
## 进度反馈
|
||||
## 进度反馈的流式设计
|
||||
|
||||
BashTool 的命令执行是流式的——输出逐行推送,用户可以实时看到 AI 正在执行什么。这比"命令执行中...请等待"的黑盒体验好得多。
|
||||
BashTool 的命令执行是流式的,通过 `onProgress` 回调逐行推送输出:
|
||||
|
||||
## 接下来
|
||||
```
|
||||
runShellCommand()
|
||||
├── Shell.exec() 启动子进程
|
||||
├── 每秒轮询输出文件
|
||||
├── onProgress(lastLines, allLines, totalLines, totalBytes, isIncomplete)
|
||||
│ ├── 更新 lastProgressOutput / fullOutput
|
||||
│ └── resolveProgress() → 唤醒 generator yield
|
||||
├── yield { type: 'progress', output, fullOutput, elapsedTimeSeconds }
|
||||
└── return { code, stdout, interrupted, ... }
|
||||
```
|
||||
|
||||
- **任务管理** — TaskCreate/TaskUpdate 的追踪系统
|
||||
- **权限模型** — 理解只读判定的完整安全体系
|
||||
- **沙箱** — Bash 命令的隔离执行环境
|
||||
UI 层通过 `useToolCallProgress` hook 实时展示命令输出。`resolveProgress()` 信号机制让 generator 在有新数据时才 yield,避免了忙等待。
|
||||
|
||||
@@ -1,113 +1,212 @@
|
||||
---
|
||||
title: "任务管理"
|
||||
description: "任务追踪是 AI 自我管理的关键。理解双轨架构(V1 内存 / V2 文件系统)、依赖管理、认领竞争和验证推动的设计。"
|
||||
title: "任务管理系统 - TodoWrite 与 Tasks 双轨架构"
|
||||
description: "揭秘 Claude Code 任务管理系统的双轨架构:V1 内存 TodoWrite 与 V2 文件系统 Tasks,包含依赖管理、认领竞争和验证推动机制。"
|
||||
keywords: ["任务管理", "TodoWrite", "任务队列", "依赖管理", "多任务"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:揭示任务系统 V1(内存 TodoWrite)和 V2(文件系统 Task*)的双轨架构,以及依赖管理、认领竞争、验证推动的工程细节 */}
|
||||
|
||||
复杂任务需要分解为多个步骤。没有任务追踪,AI 容易"迷失"——做了第三步忘了第二步,或者跳过关键验证直接宣布完成。
|
||||
## 双轨架构:TodoWrite V1 与 Tasks V2
|
||||
|
||||
任务系统让 AI 能规划、追踪和汇报自己的工作进度。
|
||||
Claude Code 的任务管理并非单一系统,而是两个并存、按运行模式切换的实现:
|
||||
|
||||
## 双轨架构
|
||||
| 维度 | V1: TodoWrite | V2: TaskCreate / TaskUpdate / TaskList / TaskGet |
|
||||
|------|--------------|--------------------------------------------------|
|
||||
| **启用条件** | 非交互式(pipe/SDK)或 `isTodoV2Enabled()` 返回 `false` | 交互式 REPL(默认)或 `CLAUDE_CODE_ENABLE_TASKS=1` |
|
||||
| **存储** | 内存中 `AppState.todos[sessionId]`(Zustand store) | 文件系统 `~/.claude/tasks/<taskListId>/<id>.json` |
|
||||
| **数据模型** | `{content, status, activeForm}` — 扁平三元组 | `{id, subject, description, activeForm, owner, status, blocks[], blockedBy[], metadata}` — 完整实体 |
|
||||
| **持久化** | 进程退出即丢失 | 跨进程存活,支持多 Agent 并发访问 |
|
||||
| **并发安全** | 无(单会话单写者) | 文件锁 + 高水位标记 + TOCTOU 防护 |
|
||||
|
||||
Claude Code 有两个并存的任务系统,按运行模式自动切换:
|
||||
|
||||
| 维度 | V1: TodoWrite | V2: TaskCreate/TaskUpdate |
|
||||
|------|:------------:|:------------------------:|
|
||||
| **存储** | 内存(进程退出即丢失) | 文件系统(跨进程持久化) |
|
||||
| **适用** | 非交互式(pipe/SDK) | 交互式 REPL(默认) |
|
||||
| **数据** | 扁平三元组 | 完整实体(含依赖、认领) |
|
||||
| **并发** | 无(单会话) | 文件锁 + 高水位标记 |
|
||||
|
||||
**设计考量**:V1 追求极简——pipe 模式是一次性执行,不需要持久化。V2 追求可靠——交互式会话和多 Agent 团队需要任务在进程崩溃后仍能恢复。
|
||||
切换逻辑位于 `isTodoV2Enabled()`(`src/utils/tasks.ts:133`):交互式会话默认启用 V2,SDK/pipe 模式回落 V1。两者互斥——`TodoWriteTool.isEnabled` 返回 `!isTodoV2Enabled()`,而 `TaskCreateTool.isEnabled` 返回 `isTodoV2Enabled()`。
|
||||
|
||||
## V1:TodoWrite 的极简设计
|
||||
|
||||
V1 本质是一个全量替换操作——每次调用传入完整的任务列表,完全覆盖之前的状态。
|
||||
TodoWrite 本质是一个**全量替换**操作——每次调用传入完整的 `todos[]` 数组,完全覆盖之前的状态:
|
||||
|
||||
### 智能清空
|
||||
```typescript
|
||||
// packages/builtin-tools/src/tools/TodoWriteTool/TodoWriteTool.ts — call() 核心逻辑
|
||||
async call({ todos }, context) {
|
||||
const todoKey = context.agentId ?? getSessionId()
|
||||
const oldTodos = appState.todos[todoKey] ?? []
|
||||
const allDone = todos.every(_ => _.status === 'completed')
|
||||
const newTodos = allDone ? [] : todos // 全部完成则清空列表
|
||||
// ... 写入 AppState
|
||||
}
|
||||
```
|
||||
|
||||
当所有任务都完成时,列表被自动清空。这确保 UI 上不会有"已完成"的视觉噪音。
|
||||
### 智能清空与验证推动
|
||||
|
||||
### 验证推动(Verification Nudge)
|
||||
一个微妙的设计:当所有任务都 `completed` 时,`newTodos` 被设为空数组(而非保留 `completed` 列表)。这确保 UI 上不会有"已完成"的视觉噪音。
|
||||
|
||||
当 AI 完成了 3+ 个任务且没有任何一个是验证步骤时,系统追加提示催促 AI 派生验证子 Agent。
|
||||
此外,V1 包含一个**验证推动**(verification nudge)机制:当主线程 Agent 完成 3+ 个任务且没有任何一个是验证步骤时,系统在 tool_result 中追加提示,催促 Agent 派生验证子 Agent:
|
||||
|
||||
**设计洞察**:这是防止 AI "自说自话地宣布完成"的防御性设计。它不是硬约束(AI 可以忽略),而是结构性推动——通过在合适的时机插入提醒,让 AI 自己决定是否验证。
|
||||
```typescript
|
||||
// 条件:主线程 + 全部完成 + ≥3 项 + 无验证任务
|
||||
if (allDone && todos.length >= 3 && !todos.some(t => /verif/i.test(t.content))) {
|
||||
verificationNudgeNeeded = true
|
||||
}
|
||||
// tool_result 中追加:
|
||||
// "NOTE: You just closed out 3+ tasks and none was a verification step..."
|
||||
```
|
||||
|
||||
为什么是 3 个任务?太少的阈值会产生过多噪音,太多则会让 AI 在完成大量工作后才被提醒验证,错过了早期发现问题的机会。
|
||||
这是防止 Agent "自说自话地宣布完成"的防御性设计——通过结构性推动而非硬约束。
|
||||
|
||||
## V2:文件系统持久化
|
||||
## V2:文件系统持久化的任务系统
|
||||
|
||||
### 数据模型
|
||||
|
||||
每个任务是一个独立的 JSON 文件,包含:
|
||||
- **subject**:祈使句标题("Fix auth bug")
|
||||
- **activeForm**:进行时形式("Fixing auth bug"),用于 spinner
|
||||
- **status**:pending → in_progress → completed
|
||||
- **owner**:认领该任务的 Agent
|
||||
- **blocks / blockedBy**:任务间依赖
|
||||
每个任务是一个独立 JSON 文件,路径为 `~/.claude/tasks/<taskListId>/<id>.json`:
|
||||
|
||||
### ID 分配的安全保证
|
||||
```typescript
|
||||
// src/utils/tasks.ts — TaskSchema
|
||||
{
|
||||
id: string, // 自增整数(1, 2, 3...)
|
||||
subject: string, // 祈使句标题(如 "Fix auth bug")
|
||||
description: string, // 详细描述
|
||||
activeForm?: string, // 进行时形式(如 "Fixing auth bug"),用于 spinner
|
||||
owner?: string, // 认领该任务的 Agent ID/名称
|
||||
status: "pending" | "in_progress" | "completed",
|
||||
blocks: string[], // 此任务阻塞哪些任务 ID
|
||||
blockedBy: string[], // 哪些任务 ID 阻塞此任务
|
||||
metadata?: Record<string, unknown> // 任意附加数据
|
||||
}
|
||||
```
|
||||
|
||||
任务 ID 是递增整数,但在并发场景下需要防止竞争:
|
||||
- 使用排他锁防止两个 Agent 同时创建相同 ID
|
||||
- 高水位标记确保删除任务后 ID 不会被重用
|
||||
### 任务列表 ID 的解析优先级
|
||||
|
||||
**设计考量**:为什么不使用 UUID?整数 ID 更直观("任务 3 阻塞任务 5"比"任务 a3f2 阻塞任务 b7c1"更易读)。但在并发环境下需要额外的工作来保证唯一性。
|
||||
`getTaskListId()` 按 5 级优先级解析任务归属:
|
||||
|
||||
1. `CLAUDE_CODE_TASK_LIST_ID` 环境变量(显式覆盖)
|
||||
2. 进程内 teammate 上下文的 teamName(共享 leader 的任务列表)
|
||||
3. `CLAUDE_CODE_TEAM_NAME` 环境变量(进程级 teammate)
|
||||
4. Leader 通过 `setLeaderTeamName()` 设置的 teamName
|
||||
5. `getSessionId()`(独立会话的兜底)
|
||||
|
||||
这意味着多 Agent 团队模式下,所有 teammate 自动共享同一个任务列表,无需额外协调。
|
||||
|
||||
### ID 分配与高水位标记
|
||||
|
||||
任务 ID 是简单的递增整数,但在并发场景下需要防止竞争:
|
||||
|
||||
```typescript
|
||||
// src/utils/tasks.ts — createTask() 简化
|
||||
async function createTask(taskListId, taskData) {
|
||||
release = await lockfile.lock(lockPath, LOCK_OPTIONS) // 获取排他锁
|
||||
const highestId = await findHighestTaskId(taskListId) // 读取当前最大 ID
|
||||
const id = String(highestId + 1) // 递增
|
||||
await writeFile(path, JSON.stringify({ id, ...taskData }))
|
||||
return id
|
||||
}
|
||||
```
|
||||
|
||||
锁配置使用指数退避重试 30 次(总计约 2.6 秒),适配 10+ 并发 Agent 的 swarm 场景。
|
||||
|
||||
高水位标记文件 `.highwatermark` 确保删除任务后 ID 不会被重用——即使任务 #5 被删除,下一个新建任务仍然是 #6。
|
||||
|
||||
## 依赖管理:blocks / blockedBy
|
||||
|
||||
任务间的依赖通过双向字段实现:
|
||||
- `taskA.blocks = ["3"]` — 任务 A 完成前,任务 3 不能开始
|
||||
- `task3.blockedBy = ["A"]` — 任务 3 必须等任务 A 完成
|
||||
任务间的依赖通过双向链表式的 `blocks` / `blockedBy` 字段实现:
|
||||
|
||||
两端同时维护,删除任务时自动清理所有引用。
|
||||
- `taskA.blocks = ["3"]` 表示 "任务 A 完成前,任务 3 不能开始"
|
||||
- `task3.blockedBy = ["A"]` 表示 "任务 3 必须等任务 A 完成"
|
||||
|
||||
**为什么是双向而非单向**?因为两个方向的查询都很常见:
|
||||
- "任务 A 阻塞了谁?" → 读 `blocks`
|
||||
- "任务 3 在等谁?" → 读 `blockedBy`
|
||||
`blockTask()` 函数同时维护两端:
|
||||
|
||||
单向存储需要遍历所有任务来回答其中一个问题。
|
||||
```typescript
|
||||
// src/utils/tasks.ts — blockTask()
|
||||
// A blocks B → 更新 A.blocks 加入 B,同时更新 B.blockedBy 加入 A
|
||||
if (!fromTask.blocks.includes(toTaskId)) {
|
||||
await updateTask(taskListId, fromTaskId, { blocks: [...fromTask.blocks, toTaskId] })
|
||||
}
|
||||
if (!toTask.blockedBy.includes(fromTaskId)) {
|
||||
await updateTask(taskListId, toTaskId, { blockedBy: [...toTask.blockedBy, fromTaskId] })
|
||||
}
|
||||
```
|
||||
|
||||
删除任务时,系统自动清理所有指向它的依赖引用(`deleteTask()` 遍历全部任务移除 `blocks` 和 `blockedBy` 中的引用)。
|
||||
|
||||
## 任务认领与并发控制
|
||||
|
||||
多个 Agent 可能同时想认领同一个任务。系统提供两种锁定粒度:
|
||||
`claimTask()` 是 V2 的核心并发原语,支持两种锁定粒度:
|
||||
|
||||
| 模式 | 锁定范围 | 适用场景 |
|
||||
|------|---------|---------|
|
||||
| 任务级锁 | 只锁定目标任务 | 单 Agent |
|
||||
| 列表级锁 + Agent 忙碌检查 | 锁定整个任务目录 | 多 Agent 团队 |
|
||||
### 1. 任务级锁(默认)
|
||||
|
||||
认领失败有多种原因:任务已被认领、任务已完成、依赖未满足、Agent 已有其他未完成任务。
|
||||
|
||||
**设计考量**:列表级锁的 `checkAgentBusy` 防止一个 Agent 一次认领太多任务。在 swarm 模式下,每个 Agent 应该专注于一件事——认领新任务前必须完成或放弃当前任务。
|
||||
|
||||
## 多 Agent 团队的生命周期
|
||||
仅锁定目标任务文件,适合单 Agent 场景:
|
||||
|
||||
```
|
||||
Leader 创建任务 → 设置依赖 → Teammate 认领 → 执行 → 完成 → 解锁下游任务
|
||||
↓
|
||||
Teammate 异常退出 → 未完成任务被重置
|
||||
getTask → 检查 owner → 检查 status → 检查 blockedBy → 写入 owner
|
||||
```
|
||||
|
||||
Teammate 异常退出时,其未完成任务被自动重置为无主状态,Leader 可以重新分配。这确保了单个 Agent 的崩溃不会永远阻塞整个团队。
|
||||
### 2. 列表级锁 + Agent 忙碌检查
|
||||
|
||||
## 与 Plan Mode 的配合
|
||||
当 `checkAgentBusy: true` 时,锁定整个任务列表目录(`.lock` 文件),原子化地完成:
|
||||
|
||||
Plan Mode 和任务系统是互补但独立的机制:
|
||||
```
|
||||
listTasks → 检查任务状态 → 检查依赖 → 检查 Agent 是否已拥有其他未完成任务 → 写入 owner
|
||||
```
|
||||
|
||||
1. Plan Mode 限制工具集为只读,迫使 AI 先理解再行动
|
||||
2. AI 在 Plan Mode 中创建任务列表
|
||||
认领失败有 4 种原因:
|
||||
|
||||
| `reason` | 含义 |
|
||||
|----------|------|
|
||||
| `task_not_found` | 任务 ID 不存在 |
|
||||
| `already_claimed` | 已被其他 Agent 认领 |
|
||||
| `already_resolved` | 任务已标记 completed |
|
||||
| `blocked` | blockedBy 列表中有未完成的任务 |
|
||||
| `agent_busy` | 该 Agent 已拥有其他未完成任务(仅 `checkAgentBusy` 模式) |
|
||||
|
||||
## Agent 团队的任务生命周期
|
||||
|
||||
在 swarms 模式下,任务系统的生命周期是这样的:
|
||||
|
||||
```
|
||||
Leader 创建团队
|
||||
↓
|
||||
Leader 用 TaskCreate 创建任务(status=pending, owner=undefined)
|
||||
↓
|
||||
Leader 用 TaskUpdate 设置依赖关系(addBlocks/addBlockedBy)
|
||||
↓
|
||||
Teammate 调用 TaskList → 发现可认领的任务
|
||||
↓
|
||||
Teammate 调用 TaskUpdate(taskId, {status: "in_progress"})
|
||||
→ 自动设置 owner 为 teammate 名称
|
||||
→ Leader 通过 mailbox 收到 task_assignment 通知
|
||||
↓
|
||||
Teammate 完成工作 → TaskUpdate(taskId, {status: "completed"})
|
||||
→ tool_result 提示 "Call TaskList to find your next available task"
|
||||
→ 依赖此任务的其他任务自动解锁
|
||||
↓
|
||||
Teammate 异常退出 → unassignTeammateTasks()
|
||||
→ 未完成任务被重置为 pending + owner=undefined
|
||||
→ Leader 收到通知并重新分配
|
||||
```
|
||||
|
||||
### Hooks 集成
|
||||
|
||||
TaskCreate 和 TaskUpdate 都集成了 hooks 系统:
|
||||
|
||||
- **创建时**:`executeTaskCreatedHooks` — 外部钩子可以阻断任务创建(blockingError 导致任务被立即删除)
|
||||
- **完成时**:`executeTaskCompletedHooks` — 外部钩子可以阻断任务标记为完成
|
||||
|
||||
这允许外部系统(CI、审批流)参与任务状态机。
|
||||
|
||||
## activeForm:终端 UX 的细节
|
||||
|
||||
每个任务有两个文案字段:
|
||||
|
||||
- `subject`:祈使句,用于任务列表展示("Fix auth bug")
|
||||
- `activeForm`:进行时形式,用于 spinner 动画("Fixing auth bug...")
|
||||
|
||||
当 `activeForm` 缺省时,spinner 回退显示 `subject`。这个看似微小的设计确保了用户在等待时看到的是"正在做什么"而非"要做什么"。
|
||||
|
||||
## Plan Mode 与任务系统的配合
|
||||
|
||||
Plan Mode(计划模式)和任务系统是互补但独立的机制:
|
||||
|
||||
1. Plan Mode 限制工具集为只读(搜索、阅读),迫使 AI 先理解再行动
|
||||
2. AI 在 Plan Mode 中用 TaskCreate 建立任务列表
|
||||
3. 用户审批后退出 Plan Mode
|
||||
4. AI 按依赖拓扑序逐项执行
|
||||
4. AI 按 `blockedBy` 拓扑序逐项执行,每项用 TaskUpdate 标记进度
|
||||
|
||||
任务管理操作始终自动批准——它们不产生副作用(不修改代码、不执行命令),只是追踪"要做什么"。
|
||||
|
||||
## 接下来
|
||||
|
||||
- **Agent 系统** — 理解子 Agent 的创建和协调
|
||||
- **Plan Mode** — 理解"先规划再执行"的工作流
|
||||
- **Swarm 模式** — 理解多 Agent 团队的任务分配
|
||||
`shouldDefer: true` 属性确保这些工具调用不会触发权限确认弹窗——任务管理操作始终自动批准,因为它们不产生副作用。
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
---
|
||||
title: "工具系统"
|
||||
description: "AI 本质上只能生成文本。工具是 AI 的双手——让它能读文件、跑命令、搜代码。理解统一接口、分层注册和调用链路的设计。"
|
||||
keywords: ["工具系统", "Tool 抽象", "AI 工具", "function calling"]
|
||||
title: "工具系统设计 - AI 如何从说到做"
|
||||
description: "深入理解 Claude Code 的 Tool 抽象设计:从类型定义、注册机制、调用链路到渲染系统,揭示 50+ 内置工具如何通过统一的 Tool 接口协同工作。"
|
||||
keywords: ["工具系统", "Tool 抽象", "AI 工具", "function calling", "buildTool", "getTools"]
|
||||
---
|
||||
|
||||
## 核心问题
|
||||
{/* 本章目标:基于 src/Tool.ts 和 src/tools.ts 揭示工具系统的完整架构 */}
|
||||
|
||||
## AI 为什么需要工具
|
||||
|
||||
大语言模型本质上只能做一件事:**根据输入文本,生成输出文本**。
|
||||
|
||||
@@ -12,93 +14,159 @@ keywords: ["工具系统", "Tool 抽象", "AI 工具", "function calling"]
|
||||
|
||||
工具是 AI 的双手。AI 说"我想读这个文件",工具系统替它真正去读;AI 说"我想执行这条命令",工具系统替它真正去跑。
|
||||
|
||||
## 统一接口:一个工具长什么样
|
||||
## Tool 类型:35 个字段的统一接口
|
||||
|
||||
所有工具——无论是读文件、执行命令还是启动子 Agent——都实现同一个接口。这不是一个类,而是一个结构化类型:任何满足该接口的对象就是一个工具。
|
||||
所有工具都实现 `src/Tool.ts:368` 的 `Tool<Input, Output, Progress>` 类型。这不是一个 class,而是一个包含 35+ 字段的**结构化类型**(structural typing),任何满足该接口的对象就是一个工具:
|
||||
|
||||
### 核心四要素
|
||||
|
||||
| 要素 | 作用 |
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `name` | `string` | 唯一标识(如 `Read`、`Bash`、`Agent`) |
|
||||
| `description()` | `(input) => Promise<string>` | **动态描述**——根据输入参数返回不同描述(如 `Execute skill: ${skill}`) |
|
||||
| `inputSchema` | `z.ZodType` | Zod schema,定义参数类型和校验规则 |
|
||||
| `call()` | `(args, context, canUseTool, parentMessage, onProgress?) => Promise<ToolResult<Output>>` | 执行函数 |
|
||||
|
||||
### 注册与发现
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| **name** | 唯一标识(如 `Read`、`Bash`、`Agent`) |
|
||||
| **description()** | 动态描述——根据输入参数返回不同描述 |
|
||||
| **inputSchema** | 参数类型和校验规则(Zod schema) |
|
||||
| **call()** | 执行函数——真正做事的地方 |
|
||||
| `aliases` | 别名数组(向后兼容重命名) |
|
||||
| `searchHint` | 3-10 词的短语,供 ToolSearch 关键词匹配(如 `"jupyter"` for NotebookEdit) |
|
||||
| `shouldDefer` | 是否延迟加载(配合 ToolSearch 按需加载) |
|
||||
| `alwaysLoad` | 永不延迟加载(如 SkillTool 必须在 turn 1 可见) |
|
||||
| `isEnabled()` | 运行时开关(如 PowerShellTool 检查平台) |
|
||||
|
||||
**设计洞察**:`description()` 是动态的而非静态字符串。这意味着同一个工具在不同参数下可以展示不同的描述——例如 Agent 工具会显示它正在执行的具体子任务。
|
||||
### 安全与权限
|
||||
|
||||
### 安全层字段
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `validateInput()` | 输入校验(在权限检查之前),返回 `ValidationResult` |
|
||||
| `checkPermissions()` | 权限检查(在校验之后),返回 `PermissionResult` |
|
||||
| `isReadOnly()` | 是否只读操作(影响权限模式) |
|
||||
| `isDestructive()` | 是否不可逆操作(删除、覆盖、发送) |
|
||||
| `isConcurrencySafe()` | 相同输入是否可以并行执行 |
|
||||
| `preparePermissionMatcher()` | 为 Hook 的 `if` 条件准备模式匹配器 |
|
||||
| `interruptBehavior()` | 用户中断时的行为:`'cancel'` 或 `'block'` |
|
||||
|
||||
工具接口包含了完整的安全控制:
|
||||
### 输出与渲染
|
||||
|
||||
- **validateInput()** — 输入校验(在权限检查之前)
|
||||
- **checkPermissions()** — 权限检查(在校验之后)
|
||||
- **isReadOnly()** — 是否只读(影响权限模式的自动审批)
|
||||
- **isDestructive()** — 是否不可逆(触发更严格的确认)
|
||||
- **interruptBehavior()** — 用户中断时的行为(取消 vs 阻塞)
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `maxResultSizeChars` | 结果字符上限(超出则持久化到磁盘,如 `100_000`) |
|
||||
| `mapToolResultToToolResultBlockParam()` | 将 Output 映射为 API 格式的 `ToolResultBlockParam` |
|
||||
| `renderToolResultMessage()` | React 组件渲染工具结果到终端 |
|
||||
| `renderToolUseMessage()` | React 组件渲染工具调用过程 |
|
||||
| `backfillObservableInput()` | 在不破坏 prompt cache 的前提下回填可观察字段 |
|
||||
|
||||
**设计考量**:校验和权限是两个独立步骤,有明确的执行顺序。校验先于权限——如果输入本身不合法,就不需要检查权限。这避免了在无效输入上触发权限 UI 的尴尬体验。
|
||||
### 上下文与 Prompt
|
||||
|
||||
### 渲染字段
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `prompt()` | 返回该工具的详细使用说明,注入到 System Prompt |
|
||||
| `outputSchema` | 输出 Zod schema(用于类型安全的结果处理) |
|
||||
| `getPath()` | 提取操作的文件路径(用于权限匹配和 UI 显示) |
|
||||
|
||||
每个工具不仅有执行逻辑,还有 UI 渲染:
|
||||
- 工具调用时的进度展示
|
||||
- 工具结果的内容渲染
|
||||
- 活动描述(为 spinner 提供文字,如 "Reading src/foo.ts")
|
||||
## 工具注册:`getTools()` 的分层组装
|
||||
|
||||
**设计哲学**:工具不是黑盒。用户应该实时看到 AI 正在做什么、结果是什么。渲染与执行是同一接口的一等公民。
|
||||
|
||||
## 工具注册:分层组装
|
||||
|
||||
工具不是一次性全部加载的。系统使用分层注册策略:
|
||||
|
||||
### 固定工具(始终可用)
|
||||
|
||||
文件操作(Read/Write/Edit)、命令执行(Bash)、搜索(Glob/Grep)、Web(Fetch/Search)等基础工具始终在工具列表中。
|
||||
|
||||
### 条件工具(运行时检查)
|
||||
|
||||
| 条件 | 加载的工具 |
|
||||
|------|-----------|
|
||||
| 需要搜索能力 | GlobTool、GrepTool |
|
||||
| 平台是 Windows | PowerShellTool |
|
||||
| Worktree 模式 | EnterWorktree/ExitWorktree |
|
||||
| Agent Swarms 启用 | Teams 相关工具 |
|
||||
|
||||
### Feature-flag 工具
|
||||
|
||||
通过 feature flag 控制的实验性工具——协调者模式工具、KAIROS 工具等。
|
||||
|
||||
**关键设计**:工具列表在每次 API 调用时重新组装,而非全局缓存。因为 `isEnabled()` 的结果可能随运行时状态变化——例如 MCP 服务器在会话中途连接或断开。
|
||||
|
||||
## 工具调用链路
|
||||
|
||||
从 AI 发出调用到结果回传,经过一条严格的管道:
|
||||
`src/tools.ts` 的 `getAllBaseTools()`(第 195 行)是工具注册的核心:
|
||||
|
||||
```
|
||||
API 返回 tool_use → 输入校验 → 权限检查 → 执行 → 结果格式化 → 回传 API
|
||||
固定工具(始终可用):
|
||||
AgentTool, BashTool, FileReadTool, FileEditTool, FileWriteTool,
|
||||
NotebookEditTool, WebFetchTool, WebSearchTool, TodoWriteTool,
|
||||
AskUserQuestionTool, SkillTool, EnterPlanModeTool, ExitPlanModeV2Tool,
|
||||
TaskOutputTool, BriefTool, ListMcpResourcesTool, ReadMcpResourceTool
|
||||
|
||||
条件工具(运行时检查):
|
||||
← hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]
|
||||
← isTodoV2Enabled() ? V2 Tasks : []
|
||||
← isWorktreeModeEnabled() ? Worktree : []
|
||||
← isAgentSwarmsEnabled() ? Teams : []
|
||||
← isToolSearchEnabled() ? ToolSearch: []
|
||||
← isPowerShellToolEnabled() ? PowerShell: []
|
||||
|
||||
Feature-flag 工具:
|
||||
← feature('COORDINATOR_MODE') ? [coordinatorMode tools]
|
||||
← feature('KAIROS') ? [SleepTool, SendUserFileTool, ...]
|
||||
← feature('WEB_BROWSER_TOOL') ? [WebBrowserTool]
|
||||
← feature('HISTORY_SNIP') ? [SnipTool]
|
||||
|
||||
Ant-only 工具:
|
||||
← process.env.USER_TYPE === 'ant' ? [REPLTool, ConfigTool, TungstenTool]
|
||||
```
|
||||
|
||||
每一步都有明确的失败路径:
|
||||
- 校验失败 → 返回错误,AI 可以修正参数重试
|
||||
- 权限拒绝 → 返回拒绝原因,AI 可以调整方案
|
||||
- 执行出错 → 返回错误信息,AI 可以诊断重试
|
||||
`getTools()`(第 274 行)在 `getAllBaseTools()` 基础上应用权限过滤:
|
||||
|
||||
**设计考量**:错误不是异常——它们是正常的对话流程。AI 看到错误信息后会自行调整,不需要人类介入。这把"AI 犯错 → 人类纠正"的循环转变为"AI 试错 → 自我纠正"的循环。
|
||||
```typescript
|
||||
export const getTools = (permissionContext): Tools => {
|
||||
const base = getAllBaseTools()
|
||||
// 过滤 blanket deny 规则命中的工具
|
||||
return filterToolsByDenyRules(base, permissionContext)
|
||||
}
|
||||
```
|
||||
|
||||
**关键设计**:工具列表在每次 API 调用时组装(而非全局缓存),因为 `isEnabled()` 的结果可能随运行时状态变化。
|
||||
|
||||
## `buildTool()` 工厂函数
|
||||
|
||||
大多数工具通过 `buildTool()` 创建(`src/Tool.ts:789`),它是一个类型安全的构造器:
|
||||
|
||||
```typescript
|
||||
export const BashTool: Tool<...> = buildTool({
|
||||
name: 'Bash',
|
||||
inputSchema: lazySchema(() => z.object({command: z.string(), ...})),
|
||||
// ...其他字段
|
||||
}) satisfies ToolDef<Input, Output, Progress>
|
||||
```
|
||||
|
||||
`satisfies ToolDef` 确保编译时类型检查,`lazySchema` 延迟 Zod schema 解析(避免循环依赖)。
|
||||
|
||||
## 工具调用的完整链路
|
||||
|
||||
从 AI 发出 `tool_use` 到结果回传,经过以下步骤:
|
||||
|
||||
```
|
||||
1. API 返回 tool_use block(包含 name + input)
|
||||
↓
|
||||
2. StreamingToolExecutor.addTool() / runTools()
|
||||
↓
|
||||
3. findToolByName() 查找工具
|
||||
↓
|
||||
4. validateInput() — 输入校验
|
||||
↓ 失败 → 返回错误 tool_result
|
||||
5. canUseTool() — 权限 UI(Ask 模式下弹确认)
|
||||
↓ 拒绝 → 返回拒绝 tool_result
|
||||
6. checkPermissions() — 规则匹配
|
||||
↓
|
||||
7. call() — 执行实际操作
|
||||
↓ onProgress() 回调实时更新 UI
|
||||
8. 返回 ToolResult<Output>
|
||||
↓
|
||||
9. mapToolResultToToolResultBlockParam() — 转为 API 格式
|
||||
↓
|
||||
10. 新消息追加到对话 → 进入下一轮迭代
|
||||
```
|
||||
|
||||
## 工具结果的预算控制
|
||||
|
||||
每个工具声明自己的输出上限(BashTool: 30K 字符,SkillTool: 100K 等)。超出上限的结果被持久化到磁盘,AI 只收到预览 + 文件路径。
|
||||
每个工具通过 `maxResultSizeChars` 声明输出上限:
|
||||
|
||||
**为什么不无限返回**?因为工具输出的 token 会累积到上下文中。一个 `find /` 命令可能产生几十万字符的输出——如果不限制,几次工具调用就能耗尽整个上下文窗口。
|
||||
- **BashTool**:`30_000`(命令输出)
|
||||
- **SkillTool**:`100_000`(技能执行结果)
|
||||
- **FileReadTool**:`Infinity`(文件内容不走持久化,避免 Read→file→Read 循环)
|
||||
|
||||
FileReadTool 故意设为无上限——文件内容需要完整呈现给 AI,截断可能导致错误的代码修改。
|
||||
超出上限的结果被 `applyToolResultBudget()`(`src/utils/toolResultStorage.ts`)持久化到磁盘,AI 只收到预览 + 文件路径。
|
||||
|
||||
## MCP 工具的扩展
|
||||
|
||||
MCP(Model Context Protocol)允许外部工具注册到系统中。MCP 工具的 schema 使用 JSON Schema 而非 Zod,因为 schema 来自远程协议而非本地定义。
|
||||
MCP Server 提供的工具通过 `mcpInfo` 字段标记来源:
|
||||
|
||||
MCP 工具支持 `mcp__server` 前缀的 deny 规则——用户可以精确控制哪些 MCP 服务器的哪些工具被禁止使用。
|
||||
```typescript
|
||||
mcpInfo?: { serverName: string; toolName: string }
|
||||
```
|
||||
|
||||
MCP 工具的 `inputJSONSchema` 直接使用 JSON Schema(而非 Zod),因为 schema 来自远程协议。它们通过 `filterToolsByDenyRules()` 支持 `mcp__server` 前缀的 blanket deny 规则。
|
||||
|
||||
## 50+ 内置工具全景
|
||||
|
||||
@@ -123,8 +191,16 @@ MCP 工具支持 `mcp__server` 前缀的 deny 规则——用户可以精确控
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## 接下来
|
||||
## 工具的可视化渲染
|
||||
|
||||
- **文件操作** — Read/Write/Edit 的设计和安全机制
|
||||
- **搜索与导航** — Glob/Grep 的搜索策略
|
||||
- **Shell 执行** — Bash 的沙箱和超时控制
|
||||
工具不仅能"做事",还能"展示"。每个工具通过 React 组件定义 UI 渲染:
|
||||
|
||||
- **FileEdit** → `renderToolResultMessage` 展示语法高亮的 diff 视图
|
||||
- **Bash** → 实时显示命令输出(通过 `onProgress` 回调),带进度指示
|
||||
- **Grep** → 高亮匹配结果,显示文件路径和行号链接
|
||||
- **Agent** → 显示子 Agent 的进度条和状态
|
||||
- **SkillTool** → 渲染技能执行进度
|
||||
|
||||
`isSearchOrReadCommand()` 允许工具声明自己是搜索/读取操作,触发 UI 的折叠显示模式(避免大量搜索结果占满屏幕)。
|
||||
|
||||
`getActivityDescription()` 为 spinner 提供活动描述(如 "Reading src/foo.ts"、"Running bun test"),替代默认的工具名显示。
|
||||
|
||||
@@ -175,7 +175,7 @@ F. getCompletedResults() → 空
|
||||
|
||||
---
|
||||
|
||||
#### #8 stream_event (input_json_delta: '{"file_path":')
|
||||
#### #8 stream_event (input_json_delta: `'{"file_path":'`)
|
||||
|
||||
```
|
||||
D. yield message ✅ → REPL 追加工具输入 JSON 碎片
|
||||
|
||||
68
package.json
68
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "1.6.0",
|
||||
"version": "1.10.4",
|
||||
"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>",
|
||||
@@ -47,22 +47,28 @@
|
||||
"build:bun": "bun run build.ts",
|
||||
"dev": "bun run scripts/dev.ts",
|
||||
"dev:inspect": "bun run scripts/dev-debug.ts",
|
||||
"prepublishOnly": "bun run build",
|
||||
"prepublishOnly": "bun run build:vite",
|
||||
"lint": "biome lint src/",
|
||||
"lint:fix": "biome lint --fix src/",
|
||||
"format": "biome format --write src/",
|
||||
"prepare": "git config core.hooksPath .githooks",
|
||||
"test": "bun test",
|
||||
"test:production": "bun run scripts/production-test.ts",
|
||||
"test:production:offline": "bun run scripts/production-test.ts --offline",
|
||||
"test:production:verbose": "bun run scripts/production-test.ts --verbose",
|
||||
"test:production:bun": "bun run scripts/production-test.ts --bun",
|
||||
"check:bundle": "bun run scripts/check-bundle-integrity.ts",
|
||||
"check:unused": "knip-bun",
|
||||
"health": "bun run scripts/health-check.ts",
|
||||
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs",
|
||||
"docs:dev": "npx mintlify dev",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:all": "bun run typecheck && bun test",
|
||||
"rcs": "bun run scripts/rcs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.19.0",
|
||||
"@claude-code-best/mcp-chrome-bridge": "^2.0.8",
|
||||
"@claude-code-best/mcp-chrome-bridge": "^3.0.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -72,19 +78,19 @@
|
||||
"@ant/computer-use-input": "workspace:*",
|
||||
"@ant/computer-use-mcp": "workspace:*",
|
||||
"@ant/computer-use-swift": "workspace:*",
|
||||
"@anthropic-ai/bedrock-sdk": "^0.26.4",
|
||||
"@anthropic-ai/bedrock-sdk": "^0.29.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
|
||||
"@anthropic-ai/foundry-sdk": "^0.2.3",
|
||||
"@anthropic-ai/mcpb": "^2.1.2",
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.44",
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
||||
"@anthropic-ai/sdk": "^0.81.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
||||
"@anthropic/ink": "workspace:*",
|
||||
"@aws-sdk/client-bedrock": "^3.1032.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1032.0",
|
||||
"@aws-sdk/client-sts": "^3.1032.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.32",
|
||||
"@aws-sdk/credential-providers": "^3.1032.0",
|
||||
"@aws-sdk/client-bedrock": "^3.1037.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1037.0",
|
||||
"@aws-sdk/client-sts": "^3.1037.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.36",
|
||||
"@aws-sdk/credential-providers": "^3.1037.0",
|
||||
"@azure/identity": "^4.13.1",
|
||||
"@biomejs/biome": "^2.4.12",
|
||||
"@claude-code-best/agent-tools": "workspace:*",
|
||||
@@ -97,20 +103,20 @@
|
||||
"@langfuse/tracing": "^5.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/api-logs": "^0.214.0",
|
||||
"@opentelemetry/api-logs": "^0.215.0",
|
||||
"@opentelemetry/core": "^2.7.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.214.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-proto": "^0.214.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.215.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.215.0",
|
||||
"@opentelemetry/exporter-logs-otlp-proto": "^0.215.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.215.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.215.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-proto": "^0.215.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.215.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.215.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.215.0",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.215.0",
|
||||
"@opentelemetry/resources": "^2.7.0",
|
||||
"@opentelemetry/sdk-logs": "^0.214.0",
|
||||
"@opentelemetry/sdk-logs": "^0.215.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.7.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.7.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
@@ -138,7 +144,7 @@
|
||||
"asciichart": "^1.5.25",
|
||||
"audio-capture-napi": "workspace:*",
|
||||
"auto-bind": "^5.0.1",
|
||||
"axios": "^1.15.0",
|
||||
"axios": "^1.15.2",
|
||||
"bidi-js": "^1.0.3",
|
||||
"cacache": "^20.0.4",
|
||||
"chalk": "^5.6.2",
|
||||
@@ -157,7 +163,6 @@
|
||||
"get-east-asian-width": "^1.5.0",
|
||||
"google-auth-library": "^10.6.2",
|
||||
"he": "^1.2.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
"ignore": "^7.0.5",
|
||||
"image-processor-napi": "workspace:*",
|
||||
@@ -200,5 +205,16 @@
|
||||
"xss": "^1.0.15",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"doubaoime-asr": "^0.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@inquirer/prompts": "8.4.2",
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"follow-redirects": "1.16.0",
|
||||
"hono": "4.12.15",
|
||||
"postcss": "8.5.10",
|
||||
"uuid": "14.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +286,15 @@ export default class App extends PureComponent<Props, State> {
|
||||
// ignore calling setRawMode on an handle stdin it cannot be called
|
||||
if (this.isRawModeSupported()) {
|
||||
this.handleSetRawMode(false)
|
||||
} else {
|
||||
// Even when raw mode was never enabled (e.g. non-TTY stdin on
|
||||
// Windows Node.js), ensure stdin is unref'd so the process can
|
||||
// exit. earlyInput may have called ref() before Ink mounted.
|
||||
try {
|
||||
this.props.stdin.unref()
|
||||
} catch {
|
||||
// stdin may already be destroyed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"./client": "./src/client/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@anthropic-ai/sdk": "^0.81.0",
|
||||
"openai": "^6.33.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,26 +21,22 @@ function makeAssistantMsg(content: string | any[]): AssistantMessage {
|
||||
|
||||
describe('anthropicMessagesToOpenAI', () => {
|
||||
test('converts system prompt to system message', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('hello')],
|
||||
['You are helpful.'] as any,
|
||||
)
|
||||
const result = anthropicMessagesToOpenAI([makeUserMsg('hello')], [
|
||||
'You are helpful.',
|
||||
] as any)
|
||||
expect(result[0]).toEqual({ role: 'system', content: 'You are helpful.' })
|
||||
})
|
||||
|
||||
test('joins multiple system prompt strings', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('hi')],
|
||||
['Part 1', 'Part 2'] as any,
|
||||
)
|
||||
const result = anthropicMessagesToOpenAI([makeUserMsg('hi')], [
|
||||
'Part 1',
|
||||
'Part 2',
|
||||
] as any)
|
||||
expect(result[0]).toEqual({ role: 'system', content: 'Part 1\n\nPart 2' })
|
||||
})
|
||||
|
||||
test('skips empty system prompt', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('hi')],
|
||||
[] as any,
|
||||
)
|
||||
const result = anthropicMessagesToOpenAI([makeUserMsg('hi')], [] as any)
|
||||
expect(result[0].role).toBe('user')
|
||||
})
|
||||
|
||||
@@ -54,10 +50,12 @@ describe('anthropicMessagesToOpenAI', () => {
|
||||
|
||||
test('converts user message with content array', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{ type: 'text', text: 'line 1' },
|
||||
{ type: 'text', text: 'line 2' },
|
||||
])],
|
||||
[
|
||||
makeUserMsg([
|
||||
{ type: 'text', text: 'line 1' },
|
||||
{ type: 'text', text: 'line 2' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{ role: 'user', content: 'line 1\nline 2' }])
|
||||
@@ -73,55 +71,67 @@ describe('anthropicMessagesToOpenAI', () => {
|
||||
|
||||
test('converts assistant message with tool_use', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeAssistantMsg([
|
||||
{ type: 'text', text: 'Let me help.' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_123',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
])],
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{ type: 'text', text: 'Let me help.' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_123',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{
|
||||
role: 'assistant',
|
||||
content: 'Let me help.',
|
||||
tool_calls: [{
|
||||
id: 'toolu_123',
|
||||
type: 'function',
|
||||
function: { name: 'bash', arguments: '{"command":"ls"}' },
|
||||
}],
|
||||
}])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Let me help.',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'toolu_123',
|
||||
type: 'function',
|
||||
function: { name: 'bash', arguments: '{"command":"ls"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts tool_result to tool message', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: 'toolu_123',
|
||||
content: 'file1.txt\nfile2.txt',
|
||||
},
|
||||
])],
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: 'toolu_123',
|
||||
content: 'file1.txt\nfile2.txt',
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{
|
||||
role: 'tool',
|
||||
tool_call_id: 'toolu_123',
|
||||
content: 'file1.txt\nfile2.txt',
|
||||
}])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'toolu_123',
|
||||
content: 'file1.txt\nfile2.txt',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('strips thinking blocks', () => {
|
||||
test('preserves thinking blocks as reasoning_content', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
||||
{ type: 'text', text: 'visible response' },
|
||||
])],
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
||||
{ type: 'text', text: 'visible response' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{ role: 'assistant', content: 'visible response' }])
|
||||
expect(result).toEqual([{ role: 'assistant', content: 'visible response', reasoning_content: 'internal thoughts...' }] as any)
|
||||
})
|
||||
|
||||
test('handles full conversation with tools', () => {
|
||||
@@ -157,91 +167,105 @@ describe('anthropicMessagesToOpenAI', () => {
|
||||
|
||||
test('converts base64 image to image_url', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{ type: 'text', text: 'what is this?' },
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'iVBORw0KGgo=',
|
||||
[
|
||||
makeUserMsg([
|
||||
{ type: 'text', text: 'what is this?' },
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'iVBORw0KGgo=',
|
||||
},
|
||||
},
|
||||
},
|
||||
])],
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'what is this?' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
|
||||
},
|
||||
],
|
||||
}])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'what is this?' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts url image to image_url', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com/img.png',
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com/img.png',
|
||||
},
|
||||
},
|
||||
},
|
||||
])],
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/img.png' },
|
||||
},
|
||||
],
|
||||
}])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/img.png' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts image-only message without text', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/jpeg',
|
||||
data: '/9j/4AAQ',
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/jpeg',
|
||||
data: '/9j/4AAQ',
|
||||
},
|
||||
},
|
||||
},
|
||||
])],
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ' },
|
||||
},
|
||||
],
|
||||
}])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('defaults to image/png when media_type is missing', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
data: 'ABC123',
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
data: 'ABC123',
|
||||
},
|
||||
},
|
||||
},
|
||||
])],
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect((result[0].content as any[])[0].image_url.url).toBe(
|
||||
@@ -253,10 +277,16 @@ describe('anthropicMessagesToOpenAI', () => {
|
||||
describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
test('preserves thinking block as reasoning_content when enabled', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('question'), makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'Let me reason about this...' },
|
||||
{ type: 'text', text: 'The answer is 42.' },
|
||||
])],
|
||||
[
|
||||
makeUserMsg('question'),
|
||||
makeAssistantMsg([
|
||||
{
|
||||
type: 'thinking' as const,
|
||||
thinking: 'Let me reason about this...',
|
||||
},
|
||||
{ type: 'text', text: 'The answer is 42.' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
@@ -269,17 +299,19 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
expect(assistant.reasoning_content).toBe('Let me reason about this...')
|
||||
})
|
||||
|
||||
test('drops thinking block when enableThinking is false (default)', () => {
|
||||
test('preserves thinking block as reasoning_content even without enableThinking', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
||||
{ type: 'text', text: 'visible response' },
|
||||
])],
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
||||
{ type: 'text', text: 'visible response' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
const assistant = result[0] as any
|
||||
expect(assistant.content).toBe('visible response')
|
||||
expect(assistant.reasoning_content).toBeUndefined()
|
||||
expect(assistant.reasoning_content).toBe('internal thoughts...')
|
||||
})
|
||||
|
||||
test('preserves reasoning_content with tool_calls in same turn', () => {
|
||||
@@ -287,7 +319,10 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
[
|
||||
makeUserMsg('what is the weather?'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'I need to call the weather tool.' },
|
||||
{
|
||||
type: 'thinking' as const,
|
||||
thinking: 'I need to call the weather tool.',
|
||||
},
|
||||
{ type: 'text', text: '' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
@@ -317,7 +352,7 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
expect(assistant.tool_calls[0].function.name).toBe('get_weather')
|
||||
})
|
||||
|
||||
test('strips reasoning_content from previous turns', () => {
|
||||
test('always preserves reasoning_content from all turns', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
// Turn 1: user → assistant (with thinking)
|
||||
@@ -326,7 +361,8 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
{ type: 'thinking' as const, thinking: 'Turn 1 reasoning...' },
|
||||
{ type: 'text', text: 'Turn 1 answer' },
|
||||
]),
|
||||
// Turn 2: new user message → previous reasoning should be stripped
|
||||
// Turn 2: new user message → reasoning should still be preserved
|
||||
// (DeepSeek requires reasoning_content to be passed back when tool calls are involved)
|
||||
makeUserMsg('question 2'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'Turn 2 reasoning...' },
|
||||
@@ -338,10 +374,9 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
)
|
||||
|
||||
const assistants = result.filter(m => m.role === 'assistant')
|
||||
// Turn 1 assistant: reasoning should be stripped (previous turn)
|
||||
expect((assistants[0] as any).reasoning_content).toBeUndefined()
|
||||
// Both turns preserve reasoning_content (DeepSeek API requires it for tool calls)
|
||||
expect((assistants[0] as any).reasoning_content).toBe('Turn 1 reasoning...')
|
||||
expect((assistants[0] as any).content).toBe('Turn 1 answer')
|
||||
// Turn 2 assistant: reasoning should be preserved (current turn)
|
||||
expect((assistants[1] as any).reasoning_content).toBe('Turn 2 reasoning...')
|
||||
expect((assistants[1] as any).content).toBe('Turn 2 answer')
|
||||
})
|
||||
@@ -399,18 +434,27 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
const assistants = result.filter(m => m.role === 'assistant')
|
||||
expect(assistants.length).toBe(3)
|
||||
// All iterations within the same turn preserve reasoning
|
||||
expect((assistants[0] as any).reasoning_content).toBe('I need the date first.')
|
||||
expect((assistants[1] as any).reasoning_content).toBe('Now I can get the weather.')
|
||||
expect((assistants[2] as any).reasoning_content).toBe('I have the info now.')
|
||||
expect((assistants[0] as any).reasoning_content).toBe(
|
||||
'I need the date first.',
|
||||
)
|
||||
expect((assistants[1] as any).reasoning_content).toBe(
|
||||
'Now I can get the weather.',
|
||||
)
|
||||
expect((assistants[2] as any).reasoning_content).toBe(
|
||||
'I have the info now.',
|
||||
)
|
||||
})
|
||||
|
||||
test('handles multiple thinking blocks in single assistant message', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('question'), makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'First thought.' },
|
||||
{ type: 'thinking' as const, thinking: 'Second thought.' },
|
||||
{ type: 'text', text: 'Final answer.' },
|
||||
])],
|
||||
[
|
||||
makeUserMsg('question'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'First thought.' },
|
||||
{ type: 'thinking' as const, thinking: 'Second thought.' },
|
||||
{ type: 'text', text: 'Final answer.' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
@@ -420,10 +464,13 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
|
||||
test('skips empty thinking blocks', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('question'), makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: '' },
|
||||
{ type: 'text', text: 'Answer.' },
|
||||
])],
|
||||
[
|
||||
makeUserMsg('question'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: '' },
|
||||
{ type: 'text', text: 'Answer.' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
@@ -481,15 +528,18 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
|
||||
test('sets content to null when only thinking and tool_calls present', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('question'), makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'Reasoning only.' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_001',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
])],
|
||||
[
|
||||
makeUserMsg('question'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'Reasoning only.' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_001',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
|
||||
@@ -18,25 +18,29 @@ describe('anthropicToolsToOpenAI', () => {
|
||||
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
|
||||
expect(result).toEqual([{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'bash',
|
||||
description: 'Run a bash command',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { command: { type: 'string' } },
|
||||
required: ['command'],
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'bash',
|
||||
description: 'Run a bash command',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { command: { type: 'string' } },
|
||||
required: ['command'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}])
|
||||
])
|
||||
})
|
||||
|
||||
test('uses empty schema when input_schema missing', () => {
|
||||
const tools = [{ type: 'custom', name: 'noop', description: 'no-op' }]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
|
||||
expect((result[0] as { function: { parameters: unknown } }).function.parameters).toEqual({ type: 'object', properties: {} })
|
||||
expect(
|
||||
(result[0] as { function: { parameters: unknown } }).function.parameters,
|
||||
).toEqual({ type: 'object', properties: {} })
|
||||
})
|
||||
|
||||
test('strips Anthropic-specific fields', () => {
|
||||
@@ -76,7 +80,8 @@ describe('anthropicToolsToOpenAI', () => {
|
||||
},
|
||||
]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
const props = (result[0] as { function: { parameters: any } }).function.parameters as any
|
||||
const props = (result[0] as { function: { parameters: any } }).function
|
||||
.parameters as any
|
||||
expect(props.properties.mode).toEqual({ enum: ['read'] })
|
||||
expect(props.properties.mode.const).toBeUndefined()
|
||||
expect(props.properties.name).toEqual({ type: 'string' })
|
||||
@@ -110,8 +115,11 @@ describe('anthropicToolsToOpenAI', () => {
|
||||
},
|
||||
]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
const params = (result[0] as { function: { parameters: any } }).function.parameters as any
|
||||
expect(params.properties.outer.properties.inner).toEqual({ enum: ['fixed'] })
|
||||
const params = (result[0] as { function: { parameters: any } }).function
|
||||
.parameters as any
|
||||
expect(params.properties.outer.properties.inner).toEqual({
|
||||
enum: ['fixed'],
|
||||
})
|
||||
expect(params.definitions.MyType.properties.field).toEqual({ enum: [42] })
|
||||
})
|
||||
|
||||
@@ -125,18 +133,17 @@ describe('anthropicToolsToOpenAI', () => {
|
||||
type: 'object',
|
||||
properties: {
|
||||
val: {
|
||||
anyOf: [
|
||||
{ const: 'a' },
|
||||
{ const: 'b' },
|
||||
{ type: 'string' },
|
||||
],
|
||||
anyOf: [{ const: 'a' }, { const: 'b' }, { type: 'string' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
const anyOf = ((result[0] as { function: { parameters: any } }).function.parameters as any).properties.val.anyOf
|
||||
const anyOf = (
|
||||
(result[0] as { function: { parameters: any } }).function
|
||||
.parameters as any
|
||||
).properties.val.anyOf
|
||||
expect(anyOf[0]).toEqual({ enum: ['a'] })
|
||||
expect(anyOf[1]).toEqual({ enum: ['b'] })
|
||||
expect(anyOf[2]).toEqual({ type: 'string' })
|
||||
|
||||
@@ -26,16 +26,16 @@ export interface ConvertMessagesOptions {
|
||||
* - system prompt → role: "system" message prepended
|
||||
* - tool_use blocks → tool_calls[] on assistant message
|
||||
* - tool_result blocks → role: "tool" messages
|
||||
* - thinking blocks → silently dropped (or preserved as reasoning_content when enableThinking=true)
|
||||
* - thinking blocks → preserved as reasoning_content (DeepSeek requires passing it back)
|
||||
* - cache_control → stripped
|
||||
*/
|
||||
export function anthropicMessagesToOpenAI(
|
||||
messages: (UserMessage | AssistantMessage)[],
|
||||
systemPrompt: SystemPrompt,
|
||||
options?: ConvertMessagesOptions,
|
||||
// options retained for API compatibility; thinking blocks are now always preserved
|
||||
_options?: ConvertMessagesOptions,
|
||||
): ChatCompletionMessageParam[] {
|
||||
const result: ChatCompletionMessageParam[] = []
|
||||
const enableThinking = options?.enableThinking ?? false
|
||||
|
||||
// Prepend system prompt as system message
|
||||
const systemText = systemPromptToText(systemPrompt)
|
||||
@@ -46,50 +46,13 @@ export function anthropicMessagesToOpenAI(
|
||||
} satisfies ChatCompletionSystemMessageParam)
|
||||
}
|
||||
|
||||
// When thinking mode is on, detect turn boundaries so that reasoning_content
|
||||
// from *previous* user turns is stripped (saves bandwidth; DeepSeek ignores it).
|
||||
// A "new turn" starts when a user text message appears after at least one assistant response.
|
||||
const turnBoundaries = new Set<number>()
|
||||
if (enableThinking) {
|
||||
let hasSeenAssistant = false
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
if (msg.type === 'assistant') {
|
||||
hasSeenAssistant = true
|
||||
}
|
||||
if (msg.type === 'user' && hasSeenAssistant) {
|
||||
const content = msg.message.content
|
||||
// A user message starts a new turn if it contains any non-tool_result content
|
||||
// (text, image, or other media). Tool results alone do NOT start a new turn
|
||||
// because they are continuations of the previous assistant tool call.
|
||||
const startsNewUserTurn = typeof content === 'string'
|
||||
? content.length > 0
|
||||
: Array.isArray(content) && content.some(
|
||||
(b: any) =>
|
||||
typeof b === 'string' ||
|
||||
(b &&
|
||||
typeof b === 'object' &&
|
||||
'type' in b &&
|
||||
b.type !== 'tool_result'),
|
||||
)
|
||||
if (startsNewUserTurn) {
|
||||
turnBoundaries.add(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
for (const msg of messages) {
|
||||
switch (msg.type) {
|
||||
case 'user':
|
||||
result.push(...convertInternalUserMessage(msg))
|
||||
break
|
||||
case 'assistant':
|
||||
// Preserve reasoning_content unless we're before a turn boundary
|
||||
// (i.e., from a previous user Q&A round)
|
||||
const preserveReasoning = enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries)
|
||||
result.push(...convertInternalAssistantMessage(msg, preserveReasoning))
|
||||
result.push(...convertInternalAssistantMessage(msg))
|
||||
break
|
||||
default:
|
||||
break
|
||||
@@ -101,20 +64,7 @@ export function anthropicMessagesToOpenAI(
|
||||
|
||||
function systemPromptToText(systemPrompt: SystemPrompt): string {
|
||||
if (!systemPrompt || systemPrompt.length === 0) return ''
|
||||
return systemPrompt
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if index `i` falls before any turn boundary (i.e. it belongs to a previous turn).
|
||||
* A message at index i is "before" a boundary if there exists a boundary j where i < j.
|
||||
*/
|
||||
function isBeforeAnyTurnBoundary(i: number, boundaries: Set<number>): boolean {
|
||||
for (const b of boundaries) {
|
||||
if (i < b) return true
|
||||
}
|
||||
return false
|
||||
return systemPrompt.filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
function convertInternalUserMessage(
|
||||
@@ -131,7 +81,8 @@ function convertInternalUserMessage(
|
||||
} else if (Array.isArray(content)) {
|
||||
const textParts: string[] = []
|
||||
const toolResults: BetaToolResultBlockParam[] = []
|
||||
const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> = []
|
||||
const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> =
|
||||
[]
|
||||
|
||||
for (const block of content) {
|
||||
if (typeof block === 'string') {
|
||||
@@ -141,7 +92,9 @@ function convertInternalUserMessage(
|
||||
} else if (block.type === 'tool_result') {
|
||||
toolResults.push(block as BetaToolResultBlockParam)
|
||||
} else if (block.type === 'image') {
|
||||
const imagePart = convertImageBlockToOpenAI(block as unknown as Record<string, unknown>)
|
||||
const imagePart = convertImageBlockToOpenAI(
|
||||
block as unknown as Record<string, unknown>,
|
||||
)
|
||||
if (imagePart) {
|
||||
imageParts.push(imagePart)
|
||||
}
|
||||
@@ -158,7 +111,10 @@ function convertInternalUserMessage(
|
||||
|
||||
// 如果有图片,构建多模态 content 数组
|
||||
if (imageParts.length > 0) {
|
||||
const multiContent: Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }> = []
|
||||
const multiContent: Array<
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image_url'; image_url: { url: string } }
|
||||
> = []
|
||||
if (textParts.length > 0) {
|
||||
multiContent.push({ type: 'text', text: textParts.join('\n') })
|
||||
}
|
||||
@@ -206,7 +162,6 @@ function convertToolResult(
|
||||
|
||||
function convertInternalAssistantMessage(
|
||||
msg: AssistantMessage,
|
||||
preserveReasoning = false,
|
||||
): ChatCompletionMessageParam[] {
|
||||
const content = msg.message.content
|
||||
|
||||
@@ -229,7 +184,9 @@ function convertInternalAssistantMessage(
|
||||
}
|
||||
|
||||
const textParts: string[] = []
|
||||
const toolCalls: NonNullable<ChatCompletionAssistantMessageParam['tool_calls']> = []
|
||||
const toolCalls: NonNullable<
|
||||
ChatCompletionAssistantMessageParam['tool_calls']
|
||||
> = []
|
||||
const reasoningParts: string[] = []
|
||||
|
||||
for (const block of content) {
|
||||
@@ -248,9 +205,12 @@ function convertInternalAssistantMessage(
|
||||
typeof tu.input === 'string' ? tu.input : JSON.stringify(tu.input),
|
||||
},
|
||||
})
|
||||
} else if (block.type === 'thinking' && preserveReasoning) {
|
||||
// DeepSeek thinking mode: preserve reasoning_content for tool call iterations
|
||||
const thinkingText = (block as unknown as Record<string, unknown>).thinking
|
||||
} else if (block.type === 'thinking') {
|
||||
// DeepSeek thinking mode: always preserve reasoning_content.
|
||||
// DeepSeek requires reasoning_content to be passed back in subsequent requests,
|
||||
// especially when tool calls are involved (returns 400 if missing).
|
||||
const thinkingText = (block as unknown as Record<string, unknown>)
|
||||
.thinking
|
||||
if (typeof thinkingText === 'string' && thinkingText) {
|
||||
reasoningParts.push(thinkingText)
|
||||
}
|
||||
@@ -262,7 +222,9 @@ function convertInternalAssistantMessage(
|
||||
role: 'assistant',
|
||||
content: textParts.length > 0 ? textParts.join('\n') : null,
|
||||
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
|
||||
...(reasoningParts.length > 0 && { reasoning_content: reasoningParts.join('\n') }),
|
||||
...(reasoningParts.length > 0 && {
|
||||
reasoning_content: reasoningParts.join('\n'),
|
||||
}),
|
||||
}
|
||||
|
||||
return [result]
|
||||
|
||||
@@ -16,21 +16,27 @@ export function anthropicToolsToOpenAI(
|
||||
.filter(tool => {
|
||||
// Only convert standard tools (skip server tools like computer_use, etc.)
|
||||
const toolType = (tool as unknown as { type?: string }).type
|
||||
return tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
|
||||
return (
|
||||
tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
|
||||
)
|
||||
})
|
||||
.map(tool => {
|
||||
// Handle the various tool shapes from Anthropic SDK
|
||||
const anyTool = tool as unknown as Record<string, unknown>
|
||||
const name = (anyTool.name as string) || ''
|
||||
const description = (anyTool.description as string) || ''
|
||||
const inputSchema = anyTool.input_schema as Record<string, unknown> | undefined
|
||||
const inputSchema = anyTool.input_schema as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
|
||||
return {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name,
|
||||
description,
|
||||
parameters: sanitizeJsonSchema(inputSchema || { type: 'object', properties: {} }),
|
||||
parameters: sanitizeJsonSchema(
|
||||
inputSchema || { type: 'object', properties: {} },
|
||||
),
|
||||
},
|
||||
} satisfies ChatCompletionTool
|
||||
})
|
||||
@@ -43,7 +49,9 @@ export function anthropicToolsToOpenAI(
|
||||
* support the `const` keyword in JSON Schema. Convert it to `enum` with a
|
||||
* single-element array, which is semantically equivalent.
|
||||
*/
|
||||
function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
|
||||
function sanitizeJsonSchema(
|
||||
schema: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (!schema || typeof schema !== 'object') return schema
|
||||
|
||||
const result = { ...schema }
|
||||
@@ -55,20 +63,37 @@ function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unk
|
||||
}
|
||||
|
||||
// Recursively process nested schemas
|
||||
const objectKeys = ['properties', 'definitions', '$defs', 'patternProperties'] as const
|
||||
const objectKeys = [
|
||||
'properties',
|
||||
'definitions',
|
||||
'$defs',
|
||||
'patternProperties',
|
||||
] as const
|
||||
for (const key of objectKeys) {
|
||||
const nested = result[key]
|
||||
if (nested && typeof nested === 'object') {
|
||||
const sanitized: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(nested as Record<string, unknown>)) {
|
||||
sanitized[k] = v && typeof v === 'object' ? sanitizeJsonSchema(v as Record<string, unknown>) : v
|
||||
sanitized[k] =
|
||||
v && typeof v === 'object'
|
||||
? sanitizeJsonSchema(v as Record<string, unknown>)
|
||||
: v
|
||||
}
|
||||
result[key] = sanitized
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively process single-schema keys
|
||||
const singleKeys = ['items', 'additionalProperties', 'not', 'if', 'then', 'else', 'contains', 'propertyNames'] as const
|
||||
const singleKeys = [
|
||||
'items',
|
||||
'additionalProperties',
|
||||
'not',
|
||||
'if',
|
||||
'then',
|
||||
'else',
|
||||
'contains',
|
||||
'propertyNames',
|
||||
] as const
|
||||
for (const key of singleKeys) {
|
||||
const nested = result[key]
|
||||
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
|
||||
@@ -82,7 +107,9 @@ function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unk
|
||||
const nested = result[key]
|
||||
if (Array.isArray(nested)) {
|
||||
result[key] = nested.map(item =>
|
||||
item && typeof item === 'object' ? sanitizeJsonSchema(item as Record<string, unknown>) : item
|
||||
item && typeof item === 'object'
|
||||
? sanitizeJsonSchema(item as Record<string, unknown>)
|
||||
: item,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
let currentContentIndex = -1
|
||||
|
||||
// Track tool_use blocks: tool_calls index → { contentIndex, id, name, arguments }
|
||||
const toolBlocks = new Map<number, { contentIndex: number; id: string; name: string; arguments: string }>()
|
||||
const toolBlocks = new Map<
|
||||
number,
|
||||
{ contentIndex: number; id: string; name: string; arguments: string }
|
||||
>()
|
||||
|
||||
// Track thinking block state
|
||||
let thinkingBlockOpen = false
|
||||
@@ -197,7 +200,8 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
|
||||
// Start new tool_use block
|
||||
currentContentIndex++
|
||||
const toolId = tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||
const toolId =
|
||||
tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||
const toolName = tc.function?.name || ''
|
||||
|
||||
toolBlocks.set(tcIndex, {
|
||||
|
||||
@@ -80,13 +80,17 @@ ARGUMENTS
|
||||
|
||||
## Authentication
|
||||
|
||||
By default, a random token is auto-generated on startup. Pass it as a query parameter:
|
||||
By default, a random token is auto-generated on startup. Connect to the
|
||||
WebSocket endpoint without putting the token in the URL:
|
||||
|
||||
```
|
||||
ws://localhost:9315/ws?token=<your-token>
|
||||
ws://localhost:9315/ws
|
||||
```
|
||||
|
||||
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
|
||||
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to
|
||||
disable (not recommended). Clients that cannot send an `Authorization` header
|
||||
must send the token in a WebSocket subprotocol named
|
||||
`rcs.auth.<base64url-token>`.
|
||||
|
||||
## RCS Upstream
|
||||
|
||||
|
||||
@@ -26,11 +26,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.19.0",
|
||||
"@hono/node-server": "^1.13.8",
|
||||
"@hono/node-server": "^2.0.0",
|
||||
"@hono/node-ws": "^1.0.5",
|
||||
"@stricli/auto-complete": "^1.2.4",
|
||||
"@stricli/core": "^1.2.4",
|
||||
"hono": "^4.7.0",
|
||||
"hono": "^4.12.15",
|
||||
"pino": "^10.3.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"selfsigned": "^5.5.0"
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import type { ServerConfig } from "../server.js";
|
||||
import { describe, test, expect, mock } from "bun:test";
|
||||
import {
|
||||
__testing,
|
||||
decodeClientWsMessage,
|
||||
MAX_CLIENT_WS_PAYLOAD_BYTES,
|
||||
resolveNewSessionPermissionMode,
|
||||
type ServerConfig,
|
||||
} from "../server.js";
|
||||
import {
|
||||
authTokensEqual,
|
||||
decodeWebSocketAuthProtocol,
|
||||
encodeWebSocketAuthProtocol,
|
||||
extractWebSocketAuthToken,
|
||||
} from "../ws-auth.js";
|
||||
import { buildRcsWsUrl } from "../rcs-upstream.js";
|
||||
|
||||
function makeTestWs(sent: unknown[]) {
|
||||
type TestWs = Parameters<typeof __testing.dispatchClientMessage>[0];
|
||||
|
||||
return {
|
||||
readyState: 1,
|
||||
send: mock((message: string) => {
|
||||
sent.push(JSON.parse(message));
|
||||
}),
|
||||
close: mock(() => {}),
|
||||
raw: null,
|
||||
isInner: false,
|
||||
url: "",
|
||||
origin: "",
|
||||
protocol: "",
|
||||
} as unknown as TestWs;
|
||||
}
|
||||
|
||||
describe("Server HTTP endpoints", () => {
|
||||
test("package.json has correct bin and main entries", async () => {
|
||||
@@ -60,6 +90,188 @@ describe("WebSocket message types", () => {
|
||||
expect(clientMessageTypes).toContain("connect");
|
||||
expect(clientMessageTypes).toContain("cancel");
|
||||
});
|
||||
|
||||
test("decodes supported client message payloads", () => {
|
||||
expect(decodeClientWsMessage('{"type":"ping"}')).toEqual({ type: "ping" });
|
||||
expect(
|
||||
decodeClientWsMessage(Buffer.from('{"type":"prompt","payload":{"content":[]}}')),
|
||||
).toEqual({ type: "prompt", payload: { content: [] } });
|
||||
expect(
|
||||
decodeClientWsMessage(new TextEncoder().encode('{"type":"cancel"}').buffer),
|
||||
).toEqual({ type: "cancel" });
|
||||
expect(
|
||||
decodeClientWsMessage([
|
||||
Buffer.from('{"type":"list_sessions","payload":{"cursor":"'),
|
||||
Buffer.from('next"}}'),
|
||||
]),
|
||||
).toEqual({ type: "list_sessions", payload: { cwd: undefined, cursor: "next" } });
|
||||
});
|
||||
|
||||
test("rejects malformed typed client payloads", () => {
|
||||
expect(() => decodeClientWsMessage('{"type":"prompt"}')).toThrow(
|
||||
"Invalid prompt payload",
|
||||
);
|
||||
expect(() =>
|
||||
decodeClientWsMessage('{"type":"load_session","payload":{}}'),
|
||||
).toThrow("Invalid load_session payload");
|
||||
expect(() => decodeClientWsMessage('{"type":"unknown"}')).toThrow(
|
||||
"Unknown message type",
|
||||
);
|
||||
expect(() =>
|
||||
decodeClientWsMessage(
|
||||
'{"type":"new_session","payload":{"permissionMode":123}}',
|
||||
),
|
||||
).toThrow("Invalid new_session.permissionMode");
|
||||
expect(() =>
|
||||
decodeClientWsMessage(
|
||||
'{"type":"new_session","payload":{"permissionMode":{}}}',
|
||||
),
|
||||
).toThrow("Invalid new_session.permissionMode");
|
||||
expect(() =>
|
||||
decodeClientWsMessage(
|
||||
'{"type":"new_session","payload":{"permissionMode":null}}',
|
||||
),
|
||||
).toThrow("Invalid new_session.permissionMode");
|
||||
});
|
||||
|
||||
test("rejects oversized client message payloads before decoding", () => {
|
||||
const payload = "x".repeat(MAX_CLIENT_WS_PAYLOAD_BYTES + 1);
|
||||
expect(() => decodeClientWsMessage(payload)).toThrow("WebSocket message too large");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket auth protocol", () => {
|
||||
test("round-trips tokens through a WebSocket subprotocol token", () => {
|
||||
const protocol = encodeWebSocketAuthProtocol("secret/token+with=symbols");
|
||||
expect(protocol).toStartWith("rcs.auth.");
|
||||
expect(protocol).not.toContain("secret/token");
|
||||
expect(decodeWebSocketAuthProtocol(protocol)).toBe("secret/token+with=symbols");
|
||||
});
|
||||
|
||||
test("ignores query-token style inputs", () => {
|
||||
expect(decodeWebSocketAuthProtocol(undefined)).toBeUndefined();
|
||||
expect(decodeWebSocketAuthProtocol("token=secret")).toBeUndefined();
|
||||
expect(decodeWebSocketAuthProtocol("other, rcs.auth.")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("prefers Authorization headers and supports protocol auth", () => {
|
||||
expect(
|
||||
extractWebSocketAuthToken({
|
||||
authorization: "Bearer header-token",
|
||||
protocol: encodeWebSocketAuthProtocol("protocol-token"),
|
||||
}),
|
||||
).toBe("header-token");
|
||||
expect(
|
||||
extractWebSocketAuthToken({
|
||||
protocol: encodeWebSocketAuthProtocol("protocol-token"),
|
||||
}),
|
||||
).toBe("protocol-token");
|
||||
});
|
||||
|
||||
test("compares auth tokens through the shared constant-time path", () => {
|
||||
expect(authTokensEqual("secret-token", "secret-token")).toBe(true);
|
||||
expect(authTokensEqual("secret-token", "wrong-token")).toBe(false);
|
||||
expect(authTokensEqual(undefined, "secret-token")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RCS upstream URL normalization", () => {
|
||||
test("removes legacy token query params from WebSocket URLs", () => {
|
||||
expect(
|
||||
buildRcsWsUrl("http://example.test/acp/ws?token=old-secret&x=1"),
|
||||
).toBe("ws://example.test/acp/ws?x=1");
|
||||
});
|
||||
|
||||
test("adds /acp/ws for base URLs", () => {
|
||||
expect(buildRcsWsUrl("https://example.test/")).toBe(
|
||||
"wss://example.test/acp/ws",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permission mode resolution", () => {
|
||||
test("uses client requested non-bypass modes", () => {
|
||||
expect(resolveNewSessionPermissionMode("plan", "acceptEdits")).toBe("plan");
|
||||
});
|
||||
|
||||
test("uses local default when client does not request a mode", () => {
|
||||
expect(resolveNewSessionPermissionMode(undefined, "acceptEdits")).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
test("rejects client requested bypassPermissions without local default", () => {
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("bypassPermissions", "acceptEdits"),
|
||||
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("bypass", "acceptEdits"),
|
||||
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("bypasspermissions", "acceptEdits"),
|
||||
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("bypassPermissions", undefined),
|
||||
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
|
||||
});
|
||||
|
||||
test("rejects unknown client permission modes before forwarding", () => {
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("unknown-mode", "acceptEdits"),
|
||||
).toThrow("Invalid permissionMode: unknown-mode");
|
||||
});
|
||||
|
||||
test("allows bypassPermissions when local default already enables it", () => {
|
||||
expect(resolveNewSessionPermissionMode("bypassPermissions", "bypassPermissions")).toBe("bypassPermissions");
|
||||
expect(resolveNewSessionPermissionMode("bypass", "bypassPermissions")).toBe("bypassPermissions");
|
||||
expect(resolveNewSessionPermissionMode("bypassPermissions", "bypass")).toBe("bypassPermissions");
|
||||
});
|
||||
|
||||
test("new_session rejects client bypass before forwarding to the agent", async () => {
|
||||
const sent: unknown[] = [];
|
||||
const ws = makeTestWs(sent);
|
||||
const originalTestInternals = process.env.ACP_LINK_TEST_INTERNALS;
|
||||
process.env.ACP_LINK_TEST_INTERNALS = "1";
|
||||
let unregisterClient = () => {};
|
||||
let restoreMode = () => {};
|
||||
|
||||
try {
|
||||
const newSession = mock(async () => ({
|
||||
sessionId: "should-not-be-created",
|
||||
}));
|
||||
unregisterClient = __testing.registerClient(ws, {
|
||||
connection: { newSession },
|
||||
});
|
||||
restoreMode = __testing.setDefaultPermissionMode("acceptEdits");
|
||||
|
||||
await __testing.dispatchClientMessage(ws, {
|
||||
type: "new_session",
|
||||
payload: {
|
||||
cwd: "/tmp",
|
||||
permissionMode: "bypass",
|
||||
},
|
||||
});
|
||||
|
||||
expect(newSession).not.toHaveBeenCalled();
|
||||
expect(__testing.getClientSessionId(ws)).toBeNull();
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "error",
|
||||
payload: {
|
||||
message: expect.stringContaining(
|
||||
"bypassPermissions requires local ACP_PERMISSION_MODE",
|
||||
),
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
restoreMode();
|
||||
unregisterClient();
|
||||
if (originalTestInternals === undefined) {
|
||||
delete process.env.ACP_LINK_TEST_INTERNALS;
|
||||
} else {
|
||||
process.env.ACP_LINK_TEST_INTERNALS = originalTestInternals;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Heartbeat constants", () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createLogger } from "./logger.js";
|
||||
import { decodeJsonWsMessage, WsPayloadTooLargeError } from "./ws-message.js";
|
||||
import { encodeWebSocketAuthProtocol } from "./ws-auth.js";
|
||||
|
||||
export interface RcsUpstreamConfig {
|
||||
rcsUrl: string; // e.g. "http://localhost:3000"
|
||||
@@ -9,6 +11,18 @@ export interface RcsUpstreamConfig {
|
||||
maxSessions?: number;
|
||||
}
|
||||
|
||||
export function buildRcsWsUrl(rcsUrl: string): string {
|
||||
let raw = rcsUrl;
|
||||
raw = raw.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
|
||||
const url = new URL(raw);
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
if (!path || path === "/") {
|
||||
url.pathname = "/acp/ws";
|
||||
}
|
||||
url.searchParams.delete("token");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* RCS upstream client — connects acp-link to a Remote Control Server.
|
||||
*
|
||||
@@ -87,17 +101,7 @@ export class RcsUpstreamClient {
|
||||
|
||||
/** Normalize RCS URL: accept http(s) base URL and convert to ws(s) + /acp/ws path */
|
||||
private buildWsUrl(): string {
|
||||
let raw = this.config.rcsUrl;
|
||||
raw = raw.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
|
||||
const url = new URL(raw);
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
if (!path || path === "/") {
|
||||
url.pathname = "/acp/ws";
|
||||
}
|
||||
if (this.config.apiToken) {
|
||||
url.searchParams.set("token", this.config.apiToken);
|
||||
}
|
||||
return url.toString();
|
||||
return buildRcsWsUrl(this.config.rcsUrl);
|
||||
}
|
||||
|
||||
/** Open connection to RCS: REST register → WS identify */
|
||||
@@ -121,7 +125,9 @@ export class RcsUpstreamClient {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
this.ws = new WebSocket(wsUrl, [
|
||||
encodeWebSocketAuthProtocol(this.config.apiToken),
|
||||
]);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
RcsUpstreamClient.log.debug("ws open — sending identify");
|
||||
@@ -136,8 +142,13 @@ export class RcsUpstreamClient {
|
||||
this.ws.onmessage = (event) => {
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = JSON.parse(event.data as string);
|
||||
} catch {
|
||||
data = decodeJsonWsMessage(event.data);
|
||||
} catch (err) {
|
||||
if (err instanceof WsPayloadTooLargeError) {
|
||||
RcsUpstreamClient.log.warn({ error: err.message }, "server message too large");
|
||||
this.ws?.close(1009, "message too large");
|
||||
return;
|
||||
}
|
||||
RcsUpstreamClient.log.warn({ raw: String(event.data).slice(0, 200) }, "invalid JSON from server");
|
||||
return;
|
||||
}
|
||||
@@ -152,11 +163,7 @@ export class RcsUpstreamClient {
|
||||
.replace(/\/acp\/ws.*$/, "")
|
||||
.replace(/\/$/, "");
|
||||
console.log();
|
||||
if (this.sessionId) {
|
||||
console.log(` 🔗 Dashboard: ${webBase}/code/?sid=${this.sessionId}`);
|
||||
} else {
|
||||
console.log(` 🔗 Dashboard: ${webBase}/code/`);
|
||||
}
|
||||
console.log(` 🔗 Dashboard: ${webBase}/code/`);
|
||||
if (this.agentId) {
|
||||
console.log(` Agent ID: ${this.agentId}`);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ import type { WebSocket as RawWebSocket } from "ws";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { getOrCreateCertificate, getLanIPs } from "./cert.js";
|
||||
import { RcsUpstreamClient, type RcsUpstreamConfig } from "./rcs-upstream.js";
|
||||
import {
|
||||
decodeJsonWsMessage,
|
||||
WsPayloadTooLargeError,
|
||||
} from "./ws-message.js";
|
||||
import { authTokensEqual, extractWebSocketAuthToken } from "./ws-auth.js";
|
||||
|
||||
export { MAX_CLIENT_WS_PAYLOAD_BYTES } from "./ws-message.js";
|
||||
|
||||
export interface ServerConfig {
|
||||
port: number;
|
||||
@@ -251,6 +258,7 @@ async function handleConnect(ws: WSContext): Promise<void> {
|
||||
const agentProcess = spawn(AGENT_COMMAND, AGENT_ARGS, {
|
||||
cwd: AGENT_CWD,
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: buildAgentEnv(),
|
||||
});
|
||||
|
||||
state.process = agentProcess;
|
||||
@@ -334,7 +342,16 @@ async function handleNewSession(
|
||||
|
||||
try {
|
||||
const sessionCwd = params.cwd || AGENT_CWD;
|
||||
const permissionMode = params.permissionMode || DEFAULT_PERMISSION_MODE;
|
||||
let permissionMode: string | undefined;
|
||||
try {
|
||||
permissionMode = resolveNewSessionPermissionMode(
|
||||
params.permissionMode,
|
||||
DEFAULT_PERMISSION_MODE,
|
||||
);
|
||||
} catch (error) {
|
||||
send(ws, "error", { message: (error as Error).message });
|
||||
return;
|
||||
}
|
||||
const result = await state.connection.newSession({
|
||||
cwd: sessionCwd,
|
||||
mcpServers: [],
|
||||
@@ -590,9 +607,326 @@ interface ContentBlock {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface ProxyMessage {
|
||||
type: "connect" | "disconnect" | "new_session" | "prompt" | "cancel" | "set_session_model";
|
||||
payload?: { cwd?: string } | { content: ContentBlock[] } | { modelId: string };
|
||||
type PermissionResponsePayload = {
|
||||
requestId: string;
|
||||
outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string };
|
||||
};
|
||||
|
||||
type ProxyMessage =
|
||||
| { type: "connect" }
|
||||
| { type: "disconnect" }
|
||||
| { type: "new_session"; payload: { cwd?: string; permissionMode?: string } }
|
||||
| { type: "prompt"; payload: { content: ContentBlock[] } }
|
||||
| { type: "permission_response"; payload: PermissionResponsePayload }
|
||||
| { type: "cancel" }
|
||||
| { type: "set_session_model"; payload: { modelId: string } }
|
||||
| { type: "list_sessions"; payload: { cwd?: string; cursor?: string } }
|
||||
| { type: "load_session"; payload: { sessionId: string; cwd?: string } }
|
||||
| { type: "resume_session"; payload: { sessionId: string; cwd?: string } }
|
||||
| { type: "ping" };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function optionalStringField(
|
||||
payload: Record<string, unknown>,
|
||||
key: string,
|
||||
source: string,
|
||||
): string | undefined {
|
||||
if (!Object.hasOwn(payload, key)) return undefined;
|
||||
const value = payload[key];
|
||||
if (typeof value === "string") return value;
|
||||
throw new Error(`Invalid ${source}: expected a string`);
|
||||
}
|
||||
|
||||
function payloadRecord(value: unknown, type: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(`Invalid ${type} payload`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalPayloadRecord(value: unknown, type: string): Record<string, unknown> {
|
||||
if (value === undefined) return {};
|
||||
return payloadRecord(value, type);
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function decodeContentBlocks(value: unknown): ContentBlock[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
!value.every(block => isRecord(block) && typeof block.type === "string")
|
||||
) {
|
||||
throw new Error("Invalid prompt payload");
|
||||
}
|
||||
return value as ContentBlock[];
|
||||
}
|
||||
|
||||
function decodePermissionResponsePayload(value: unknown): PermissionResponsePayload {
|
||||
const payload = payloadRecord(value, "permission_response");
|
||||
if (typeof payload.requestId !== "string" || !isRecord(payload.outcome)) {
|
||||
throw new Error("Invalid permission_response payload");
|
||||
}
|
||||
if (payload.outcome.outcome === "cancelled") {
|
||||
return { requestId: payload.requestId, outcome: { outcome: "cancelled" } };
|
||||
}
|
||||
if (
|
||||
payload.outcome.outcome === "selected" &&
|
||||
typeof payload.outcome.optionId === "string"
|
||||
) {
|
||||
return {
|
||||
requestId: payload.requestId,
|
||||
outcome: { outcome: "selected", optionId: payload.outcome.optionId },
|
||||
};
|
||||
}
|
||||
throw new Error("Invalid permission_response payload");
|
||||
}
|
||||
|
||||
function decodeClientMessage(message: Record<string, unknown>): ProxyMessage {
|
||||
if (typeof message.type !== "string") {
|
||||
throw new Error("Invalid WebSocket message payload");
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "connect":
|
||||
case "disconnect":
|
||||
case "cancel":
|
||||
case "ping":
|
||||
return { type: message.type };
|
||||
case "new_session": {
|
||||
const payload = optionalPayloadRecord(message.payload, "new_session");
|
||||
return {
|
||||
type: "new_session",
|
||||
payload: {
|
||||
cwd: optionalStringField(payload, "cwd", "new_session.cwd"),
|
||||
permissionMode: optionalStringField(
|
||||
payload,
|
||||
"permissionMode",
|
||||
"new_session.permissionMode",
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
case "prompt": {
|
||||
const payload = payloadRecord(message.payload, "prompt");
|
||||
return {
|
||||
type: "prompt",
|
||||
payload: { content: decodeContentBlocks(payload.content) },
|
||||
};
|
||||
}
|
||||
case "permission_response":
|
||||
return {
|
||||
type: "permission_response",
|
||||
payload: decodePermissionResponsePayload(message.payload),
|
||||
};
|
||||
case "set_session_model": {
|
||||
const payload = payloadRecord(message.payload, "set_session_model");
|
||||
if (typeof payload.modelId !== "string") {
|
||||
throw new Error("Invalid set_session_model payload");
|
||||
}
|
||||
return { type: "set_session_model", payload: { modelId: payload.modelId } };
|
||||
}
|
||||
case "list_sessions": {
|
||||
const payload = optionalRecord(message.payload);
|
||||
return {
|
||||
type: "list_sessions",
|
||||
payload: {
|
||||
cwd: optionalString(payload.cwd),
|
||||
cursor: optionalString(payload.cursor),
|
||||
},
|
||||
};
|
||||
}
|
||||
case "load_session":
|
||||
case "resume_session": {
|
||||
const payload = payloadRecord(message.payload, message.type);
|
||||
if (typeof payload.sessionId !== "string") {
|
||||
throw new Error(`Invalid ${message.type} payload`);
|
||||
}
|
||||
return {
|
||||
type: message.type,
|
||||
payload: {
|
||||
sessionId: payload.sessionId,
|
||||
cwd: optionalString(payload.cwd),
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown message type: ${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeClientWsMessage(data: unknown): ProxyMessage {
|
||||
return decodeClientMessage(decodeJsonWsMessage(data));
|
||||
}
|
||||
|
||||
async function dispatchClientMessage(ws: WSContext, data: ProxyMessage): Promise<void> {
|
||||
switch (data.type) {
|
||||
case "connect":
|
||||
await handleConnect(ws);
|
||||
break;
|
||||
case "disconnect":
|
||||
handleDisconnect(ws);
|
||||
break;
|
||||
case "new_session":
|
||||
await handleNewSession(ws, data.payload);
|
||||
break;
|
||||
case "prompt":
|
||||
await handlePrompt(ws, data.payload);
|
||||
break;
|
||||
case "permission_response":
|
||||
handlePermissionResponse(ws, data.payload);
|
||||
break;
|
||||
case "cancel":
|
||||
await handleCancel(ws);
|
||||
break;
|
||||
case "set_session_model":
|
||||
await handleSetSessionModel(ws, data.payload);
|
||||
break;
|
||||
case "list_sessions":
|
||||
await handleListSessions(ws, data.payload);
|
||||
break;
|
||||
case "load_session":
|
||||
await handleLoadSession(ws, data.payload);
|
||||
break;
|
||||
case "resume_session":
|
||||
await handleResumeSession(ws, data.payload);
|
||||
break;
|
||||
case "ping":
|
||||
send(ws, "pong");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
dispatchClientMessage(
|
||||
ws: WSContext,
|
||||
data: unknown,
|
||||
): Promise<void> {
|
||||
assertTestingInternalsEnabled();
|
||||
return dispatchClientMessage(ws, data as ProxyMessage);
|
||||
},
|
||||
registerClient(
|
||||
ws: WSContext,
|
||||
state: {
|
||||
connection?: unknown;
|
||||
process?: ChildProcess | null;
|
||||
sessionId?: string | null;
|
||||
},
|
||||
): () => void {
|
||||
assertTestingInternalsEnabled();
|
||||
clients.set(ws, {
|
||||
process: state.process ?? null,
|
||||
connection: (state.connection ?? null) as acp.ClientSideConnection | null,
|
||||
sessionId: state.sessionId ?? null,
|
||||
pendingPermissions: new Map(),
|
||||
agentCapabilities: null,
|
||||
promptCapabilities: null,
|
||||
modelState: null,
|
||||
isAlive: true,
|
||||
});
|
||||
return () => {
|
||||
clients.delete(ws);
|
||||
};
|
||||
},
|
||||
getClientSessionId(ws: WSContext): string | null | undefined {
|
||||
assertTestingInternalsEnabled();
|
||||
return clients.get(ws)?.sessionId;
|
||||
},
|
||||
setDefaultPermissionMode(mode: string | undefined): () => void {
|
||||
assertTestingInternalsEnabled();
|
||||
const previous = DEFAULT_PERMISSION_MODE;
|
||||
DEFAULT_PERMISSION_MODE = mode;
|
||||
return () => {
|
||||
DEFAULT_PERMISSION_MODE = previous;
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function assertTestingInternalsEnabled(): void {
|
||||
if (process.env.ACP_LINK_TEST_INTERNALS === "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"acp-link test internals are disabled outside test execution.",
|
||||
);
|
||||
}
|
||||
|
||||
const ACP_LINK_PERMISSION_MODE_ALIASES = {
|
||||
auto: "auto",
|
||||
default: "default",
|
||||
acceptedits: "acceptEdits",
|
||||
dontask: "dontAsk",
|
||||
plan: "plan",
|
||||
bypasspermissions: "bypassPermissions",
|
||||
bypass: "bypassPermissions",
|
||||
} as const;
|
||||
|
||||
type AcpLinkPermissionMode =
|
||||
(typeof ACP_LINK_PERMISSION_MODE_ALIASES)[keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES];
|
||||
|
||||
export function resolveNewSessionPermissionMode(
|
||||
requestedMode: string | undefined,
|
||||
defaultMode: string | undefined,
|
||||
): string | undefined {
|
||||
const requested = resolveAcpLinkPermissionMode(requestedMode);
|
||||
const localDefault = resolveAcpLinkPermissionMode(defaultMode);
|
||||
|
||||
if (!requested) {
|
||||
return localDefault;
|
||||
}
|
||||
|
||||
if (requested !== "bypassPermissions") {
|
||||
return requested;
|
||||
}
|
||||
|
||||
if (localDefault === "bypassPermissions") {
|
||||
return "bypassPermissions";
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"bypassPermissions requires local ACP_PERMISSION_MODE=bypassPermissions before a client can request it.",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAcpLinkPermissionMode(
|
||||
mode: string | undefined,
|
||||
): AcpLinkPermissionMode | undefined {
|
||||
if (mode === undefined) return undefined;
|
||||
|
||||
const normalized = mode?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
throw new Error("Invalid permissionMode: expected a non-empty string.");
|
||||
}
|
||||
|
||||
const resolved =
|
||||
ACP_LINK_PERMISSION_MODE_ALIASES[
|
||||
normalized as keyof typeof ACP_LINK_PERMISSION_MODE_ALIASES
|
||||
];
|
||||
if (!resolved) {
|
||||
throw new Error(`Invalid permissionMode: ${mode}.`);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function buildAgentEnv(): NodeJS.ProcessEnv {
|
||||
if (!DEFAULT_PERMISSION_MODE) {
|
||||
return process.env;
|
||||
}
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
ACP_PERMISSION_MODE: DEFAULT_PERMISSION_MODE,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startServer(config: ServerConfig): Promise<void> {
|
||||
@@ -638,44 +972,9 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
|
||||
rcsUpstream.setMessageHandler(async (msg) => {
|
||||
try {
|
||||
logRelay.debug({ type: msg.type }, "processing");
|
||||
switch (msg.type) {
|
||||
case "connect":
|
||||
await handleConnect(relayWs);
|
||||
break;
|
||||
case "disconnect":
|
||||
handleDisconnect(relayWs);
|
||||
break;
|
||||
case "new_session":
|
||||
await handleNewSession(relayWs, (msg.payload as { cwd?: string; permissionMode?: string }) || {});
|
||||
break;
|
||||
case "prompt":
|
||||
await handlePrompt(relayWs, msg.payload as { content: ContentBlock[] });
|
||||
break;
|
||||
case "permission_response":
|
||||
handlePermissionResponse(relayWs, msg.payload as { requestId: string; outcome: { outcome: "cancelled" } | { outcome: "selected"; optionId: string } });
|
||||
break;
|
||||
case "cancel":
|
||||
await handleCancel(relayWs);
|
||||
break;
|
||||
case "set_session_model":
|
||||
await handleSetSessionModel(relayWs, msg.payload as { modelId: string });
|
||||
break;
|
||||
case "list_sessions":
|
||||
await handleListSessions(relayWs, (msg.payload as { cwd?: string; cursor?: string }) || {});
|
||||
break;
|
||||
case "load_session":
|
||||
await handleLoadSession(relayWs, msg.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "resume_session":
|
||||
await handleResumeSession(relayWs, msg.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "ping":
|
||||
send(relayWs, "pong");
|
||||
break;
|
||||
default:
|
||||
logRelay.warn({ type: msg.type }, "unknown message type");
|
||||
}
|
||||
const data = decodeClientMessage(msg);
|
||||
logRelay.debug({ type: data.type }, "processing");
|
||||
await dispatchClientMessage(relayWs, data);
|
||||
} catch (error) {
|
||||
logRelay.error({ error: (error as Error).message }, "handler error");
|
||||
}
|
||||
@@ -700,9 +999,11 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
"/ws",
|
||||
upgradeWebSocket((c) => {
|
||||
if (AUTH_TOKEN) {
|
||||
const url = new URL(c.req.url);
|
||||
const providedToken = url.searchParams.get("token");
|
||||
if (providedToken !== AUTH_TOKEN) {
|
||||
const providedToken = extractWebSocketAuthToken({
|
||||
authorization: c.req.header("Authorization"),
|
||||
protocol: c.req.header("Sec-WebSocket-Protocol"),
|
||||
});
|
||||
if (!authTokensEqual(providedToken, AUTH_TOKEN)) {
|
||||
logWs.warn("connection rejected: invalid token");
|
||||
return {
|
||||
onOpen(_event, ws) {
|
||||
@@ -734,63 +1035,31 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
state.isAlive = true;
|
||||
});
|
||||
},
|
||||
async onMessage(event, ws) {
|
||||
try {
|
||||
const data = JSON.parse(event.data.toString());
|
||||
logWs.debug({ type: data.type }, "received");
|
||||
|
||||
switch (data.type) {
|
||||
case "connect":
|
||||
await handleConnect(ws);
|
||||
break;
|
||||
case "disconnect":
|
||||
handleDisconnect(ws);
|
||||
break;
|
||||
case "new_session":
|
||||
await handleNewSession(ws, (data.payload as { cwd?: string; permissionMode?: string }) || {});
|
||||
break;
|
||||
case "prompt":
|
||||
await handlePrompt(ws, data.payload as { content: ContentBlock[] });
|
||||
break;
|
||||
case "permission_response":
|
||||
handlePermissionResponse(ws, data.payload);
|
||||
break;
|
||||
case "cancel":
|
||||
await handleCancel(ws);
|
||||
break;
|
||||
case "set_session_model":
|
||||
await handleSetSessionModel(ws, data.payload as { modelId: string });
|
||||
break;
|
||||
case "list_sessions":
|
||||
await handleListSessions(ws, (data.payload as { cwd?: string; cursor?: string }) || {});
|
||||
break;
|
||||
case "load_session":
|
||||
await handleLoadSession(ws, data.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "resume_session":
|
||||
await handleResumeSession(ws, data.payload as { sessionId: string; cwd?: string });
|
||||
break;
|
||||
case "ping":
|
||||
send(ws, "pong");
|
||||
break;
|
||||
default:
|
||||
send(ws, "error", { message: `Unknown message type: ${data.type}` });
|
||||
async onMessage(event, ws) {
|
||||
try {
|
||||
const data = decodeClientWsMessage(event.data);
|
||||
logWs.debug({ type: data.type }, "received");
|
||||
await dispatchClientMessage(ws, data);
|
||||
} catch (error) {
|
||||
if (error instanceof WsPayloadTooLargeError) {
|
||||
logWs.warn({ error: error.message }, "message too large");
|
||||
ws.close(1009, "message too large");
|
||||
return;
|
||||
}
|
||||
logWs.error({ error: (error as Error).message }, "message error");
|
||||
send(ws, "error", { message: `Error: ${(error as Error).message}` });
|
||||
}
|
||||
} catch (error) {
|
||||
logWs.error({ error: (error as Error).message }, "message error");
|
||||
send(ws, "error", { message: `Error: ${(error as Error).message}` });
|
||||
}
|
||||
},
|
||||
onClose(_event, ws) {
|
||||
logWs.info("client disconnected");
|
||||
const state = clients.get(ws);
|
||||
if (state) {
|
||||
cancelPendingPermissions(state);
|
||||
}
|
||||
handleDisconnect(ws);
|
||||
clients.delete(ws);
|
||||
},
|
||||
};
|
||||
},
|
||||
onClose(_event, ws) {
|
||||
logWs.info("client disconnected");
|
||||
const state = clients.get(ws);
|
||||
if (state) {
|
||||
cancelPendingPermissions(state);
|
||||
}
|
||||
handleDisconnect(ws);
|
||||
clients.delete(ws);
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -855,7 +1124,7 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
console.log(` URL: ${localWsUrl}`);
|
||||
}
|
||||
if (AUTH_TOKEN) {
|
||||
console.log(` Token: ${AUTH_TOKEN}`);
|
||||
console.log(` Token: configured`);
|
||||
}
|
||||
console.log();
|
||||
if (!AUTH_TOKEN) {
|
||||
|
||||
62
packages/acp-link/src/ws-auth.ts
Normal file
62
packages/acp-link/src/ws-auth.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const WS_AUTH_PROTOCOL_PREFIX = "rcs.auth.";
|
||||
|
||||
function sha256(value: string): Buffer {
|
||||
return createHash("sha256").update(value).digest();
|
||||
}
|
||||
|
||||
export function encodeWebSocketAuthProtocol(token: string): string {
|
||||
return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, "utf8").toString("base64url")}`;
|
||||
}
|
||||
|
||||
export function decodeWebSocketAuthProtocol(protocolHeader: string | undefined): string | undefined {
|
||||
if (!protocolHeader) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const protocol of protocolHeader.split(",")) {
|
||||
const trimmed = protocol.trim();
|
||||
if (!trimmed.startsWith(WS_AUTH_PROTOCOL_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length);
|
||||
if (!encoded) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = Buffer.from(encoded, "base64url").toString("utf8");
|
||||
return token.length > 0 ? token : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractBearerToken(authorizationHeader: string | undefined): string | undefined {
|
||||
return authorizationHeader?.startsWith("Bearer ")
|
||||
? authorizationHeader.slice("Bearer ".length)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function extractWebSocketAuthToken(headers: {
|
||||
authorization?: string;
|
||||
protocol?: string;
|
||||
}): string | undefined {
|
||||
return extractBearerToken(headers.authorization) ??
|
||||
decodeWebSocketAuthProtocol(headers.protocol);
|
||||
}
|
||||
|
||||
export function authTokensEqual(
|
||||
providedToken: string | undefined,
|
||||
expectedToken: string | undefined,
|
||||
): boolean {
|
||||
if (!providedToken || !expectedToken) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(sha256(providedToken), sha256(expectedToken));
|
||||
}
|
||||
60
packages/acp-link/src/ws-message.ts
Normal file
60
packages/acp-link/src/ws-message.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export const MAX_CLIENT_WS_PAYLOAD_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export class WsPayloadTooLargeError extends Error {
|
||||
constructor(byteLength: number) {
|
||||
super(`WebSocket message too large: ${byteLength} bytes`);
|
||||
this.name = "WsPayloadTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface JsonWsMessage {
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function assertPayloadSize(byteLength: number): void {
|
||||
if (byteLength > MAX_CLIENT_WS_PAYLOAD_BYTES) {
|
||||
throw new WsPayloadTooLargeError(byteLength);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeWsText(data: unknown): string {
|
||||
if (typeof data === "string") {
|
||||
assertPayloadSize(Buffer.byteLength(data, "utf8"));
|
||||
return data;
|
||||
}
|
||||
|
||||
if (data instanceof ArrayBuffer) {
|
||||
assertPayloadSize(data.byteLength);
|
||||
return new TextDecoder().decode(new Uint8Array(data));
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
assertPayloadSize(data.byteLength);
|
||||
return new TextDecoder().decode(
|
||||
new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(data) && data.every(Buffer.isBuffer)) {
|
||||
const byteLength = data.reduce((total, chunk) => total + chunk.byteLength, 0);
|
||||
assertPayloadSize(byteLength);
|
||||
return Buffer.concat(data, byteLength).toString("utf8");
|
||||
}
|
||||
|
||||
throw new Error("Unsupported WebSocket message payload");
|
||||
}
|
||||
|
||||
export function decodeJsonWsMessage(data: unknown): JsonWsMessage {
|
||||
const parsed = JSON.parse(decodeWsText(data)) as unknown;
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
!("type" in parsed) ||
|
||||
typeof parsed.type !== "string"
|
||||
) {
|
||||
throw new Error("Invalid WebSocket message payload");
|
||||
}
|
||||
return parsed as JsonWsMessage;
|
||||
}
|
||||
@@ -1,3 +1,32 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
// createRequire works in both Bun and Node.js ESM contexts.
|
||||
// Needed because this package is "type": "module" but uses require() for
|
||||
// loading native .node addons — bare require is not available in Node.js ESM.
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
/**
|
||||
* Resolve the "vendor root" directory where native .node binaries live.
|
||||
*
|
||||
* - Dev mode: import.meta.url → packages/audio-capture-napi/src/index.ts
|
||||
* → vendor root = <project>/vendor/
|
||||
* - Bun build: import.meta.url → dist/chunk-xxx.js
|
||||
* → vendor root = <project>/dist/vendor/
|
||||
* - Vite build: import.meta.url → dist/chunks/chunk-xxx.js
|
||||
* → vendor root = <project>/dist/vendor/
|
||||
*/
|
||||
function getVendorRoot(): string {
|
||||
const filePath = fileURLToPath(import.meta.url)
|
||||
const dir = dirname(filePath)
|
||||
const parts = dir.split(sep)
|
||||
const distIdx = parts.lastIndexOf('dist')
|
||||
if (distIdx !== -1) {
|
||||
return parts.slice(0, distIdx + 1).join(sep) + sep + 'vendor'
|
||||
}
|
||||
// Dev mode — go up from packages/audio-capture-napi/src/ to project root
|
||||
return resolve(dir, '..', '..', '..', 'vendor')
|
||||
}
|
||||
|
||||
type AudioCaptureNapi = {
|
||||
startRecording(
|
||||
@@ -41,7 +70,7 @@ function loadModule(): AudioCaptureNapi | null {
|
||||
if (process.env.AUDIO_CAPTURE_NODE_PATH) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
cachedModule = require(
|
||||
cachedModule = nodeRequire(
|
||||
process.env.AUDIO_CAPTURE_NODE_PATH,
|
||||
) as AudioCaptureNapi
|
||||
return cachedModule
|
||||
@@ -50,20 +79,23 @@ function loadModule(): AudioCaptureNapi | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Candidates 2-4: npm-install, dev/source, and workspace layouts.
|
||||
// In bundled output, require() resolves relative to cli.js at the package root.
|
||||
// In dev, it resolves relative to this file. When loaded from a workspace
|
||||
// package (packages/audio-capture-napi/src/), we need an absolute path fallback.
|
||||
// Candidates 2-5: resolved vendor path + relative fallbacks.
|
||||
// The primary candidate uses getVendorRoot() to find the correct dist root
|
||||
// regardless of chunk nesting depth. Relative fallbacks cover edge cases.
|
||||
const platformDir = `${process.arch}-${platform}`
|
||||
const binaryRel = `audio-capture/${platformDir}/audio-capture.node`
|
||||
const vendorRoot = getVendorRoot()
|
||||
const fallbacks = [
|
||||
`./vendor/audio-capture/${platformDir}/audio-capture.node`,
|
||||
`../audio-capture/${platformDir}/audio-capture.node`,
|
||||
`${process.cwd()}/vendor/audio-capture/${platformDir}/audio-capture.node`,
|
||||
resolve(vendorRoot, binaryRel),
|
||||
`./vendor/${binaryRel}`,
|
||||
`../vendor/${binaryRel}`,
|
||||
`../../vendor/${binaryRel}`,
|
||||
`${process.cwd()}/vendor/${binaryRel}`,
|
||||
]
|
||||
for (const p of fallbacks) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
cachedModule = require(p) as AudioCaptureNapi
|
||||
cachedModule = nodeRequire(p) as AudioCaptureNapi
|
||||
return cachedModule
|
||||
} catch {
|
||||
// try next
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
import { debugMock } from "../../../../../../tests/mocks/debug";
|
||||
|
||||
// ─── Mocks for agentToolUtils.ts dependencies ───
|
||||
// Only mock modules that are truly unavailable or cause side effects.
|
||||
@@ -87,20 +88,7 @@ mock.module("src/tasks/LocalAgentTask/LocalAgentTask.js", () => ({
|
||||
updateProgressFromMessage: noop,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/debug.ts", () => ({
|
||||
getMinDebugLogLevel: () => "warn",
|
||||
isDebugMode: () => false,
|
||||
enableDebugLogging: () => false,
|
||||
getDebugFilter: () => null,
|
||||
isDebugToStdErr: () => false,
|
||||
getDebugFilePath: () => null,
|
||||
setHasFormattedOutput: noop,
|
||||
getHasFormattedOutput: () => false,
|
||||
flushDebugLogs: async () => {},
|
||||
logForDebugging: noop,
|
||||
getDebugLogPath: () => "",
|
||||
logAntError: noop,
|
||||
}));
|
||||
mock.module("src/utils/debug.ts", debugMock);
|
||||
|
||||
mock.module("src/utils/errors.js", () => ({
|
||||
ClaudeError: class extends Error {},
|
||||
|
||||
@@ -394,6 +394,7 @@ export const getAgentDefinitionsWithOverrides = memoize(
|
||||
|
||||
export function clearAgentDefinitionsCache(): void {
|
||||
getAgentDefinitionsWithOverrides.cache.clear?.()
|
||||
loadMarkdownFilesForSubdir.cache?.clear?.()
|
||||
clearPluginAgentCache()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ import { z } from 'zod/v4'
|
||||
import type { ToolResultBlockParam } from 'src/Tool.js'
|
||||
import { buildTool } from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { tokenCountWithEstimation } from 'src/utils/tokens.js'
|
||||
import {
|
||||
getStats,
|
||||
isContextCollapseEnabled,
|
||||
} from 'src/services/contextCollapse/index.js'
|
||||
import { isSessionMemoryInitialized } from 'src/services/SessionMemory/sessionMemoryUtils.js'
|
||||
|
||||
const CTX_INSPECT_TOOL_NAME = 'CtxInspect'
|
||||
|
||||
@@ -19,6 +25,10 @@ type CtxInput = z.infer<InputSchema>
|
||||
type CtxOutput = {
|
||||
total_tokens: number
|
||||
message_count: number
|
||||
context_window_model: string
|
||||
prompt_caching_enabled: boolean
|
||||
session_memory_enabled: boolean
|
||||
context_collapse_enabled: boolean
|
||||
summary: string
|
||||
}
|
||||
|
||||
@@ -67,13 +77,45 @@ Use this to understand your context budget before deciding whether to snip old m
|
||||
}
|
||||
},
|
||||
|
||||
async call() {
|
||||
// Context inspection is wired into the context collapse system.
|
||||
async call(input: CtxInput, context) {
|
||||
const messages = context.messages ?? []
|
||||
const model = context.options?.mainLoopModel ?? 'unknown'
|
||||
const totalTokens = tokenCountWithEstimation(messages)
|
||||
const collapseEnabled = isContextCollapseEnabled()
|
||||
const collapseStats = getStats()
|
||||
const focused = input.query?.trim()
|
||||
|
||||
const sessionMemoryEnabled = isSessionMemoryInitialized()
|
||||
// Prompt caching is an API-level feature controlled by the provider, not
|
||||
// a user-facing toggle. Report as enabled only for providers known to
|
||||
// support Anthropic-style prompt caching (first-party, Bedrock, Vertex).
|
||||
const promptCachingEnabled = !model.startsWith('openai/') &&
|
||||
!model.startsWith('grok/') &&
|
||||
!model.startsWith('gemini/')
|
||||
|
||||
const summaryParts = [
|
||||
focused ? `Focus: ${focused}` : 'Overall context summary',
|
||||
`Model context: ${model}`,
|
||||
`Prompt caching: ${promptCachingEnabled ? 'enabled' : 'disabled'}`,
|
||||
`Session memory: ${sessionMemoryEnabled ? 'enabled' : 'disabled'}`,
|
||||
`Context collapse: ${collapseEnabled ? 'enabled' : 'disabled'}`,
|
||||
]
|
||||
|
||||
if (collapseEnabled) {
|
||||
summaryParts.push(
|
||||
`Collapse spans: ${collapseStats.collapsedSpans} committed, ${collapseStats.stagedSpans} staged, ${collapseStats.collapsedMessages} messages summarized`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
total_tokens: 0,
|
||||
message_count: 0,
|
||||
summary: 'Context inspection requires the CONTEXT_COLLAPSE runtime.',
|
||||
total_tokens: totalTokens,
|
||||
message_count: messages.length,
|
||||
context_window_model: model,
|
||||
prompt_caching_enabled: promptCachingEnabled,
|
||||
session_memory_enabled: sessionMemoryEnabled,
|
||||
context_collapse_enabled: collapseEnabled,
|
||||
summary: summaryParts.join('\n'),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { logMock } from '../../../../../../tests/mocks/log'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
|
||||
mock.module('src/services/tokenEstimation.ts', () => ({
|
||||
roughTokenCountEstimation: (text: string) => Math.ceil(text.length / 4),
|
||||
roughTokenCountEstimationForMessages: (msgs: unknown[]) => msgs.length * 64,
|
||||
roughTokenCountEstimationForMessage: () => 64,
|
||||
roughTokenCountEstimationForFileType: () => 64,
|
||||
bytesPerTokenForFileType: () => 4,
|
||||
countTokensWithAPI: async () => 0,
|
||||
countMessagesTokensWithAPI: async () => 0,
|
||||
countTokensViaHaikuFallback: async () => 0,
|
||||
}))
|
||||
|
||||
let sessionMemoryInitialized = false
|
||||
mock.module('src/services/SessionMemory/sessionMemoryUtils.ts', () => ({
|
||||
isSessionMemoryInitialized: () => sessionMemoryInitialized,
|
||||
waitForSessionMemoryExtraction: async () => {},
|
||||
getLastSummarizedMessageId: () => undefined,
|
||||
getSessionMemoryContent: async () => null,
|
||||
setLastSummarizedMessageId: () => {},
|
||||
markExtractionStarted: () => {},
|
||||
markExtractionCompleted: () => {},
|
||||
setSessionMemoryConfig: () => {},
|
||||
getSessionMemoryConfig: () => ({}),
|
||||
recordExtractionTokenCount: () => {},
|
||||
markSessionMemoryInitialized: () => {},
|
||||
hasMetInitializationThreshold: () => false,
|
||||
hasMetUpdateThreshold: () => false,
|
||||
getToolCallsBetweenUpdates: () => 0,
|
||||
resetSessionMemoryState: () => {},
|
||||
DEFAULT_SESSION_MEMORY_CONFIG: {},
|
||||
}))
|
||||
|
||||
mock.module('src/utils/slowOperations.ts', () => ({
|
||||
jsonStringify: JSON.stringify,
|
||||
jsonParse: JSON.parse,
|
||||
slowLogging: { enabled: false },
|
||||
clone: (value: unknown) => structuredClone(value),
|
||||
cloneDeep: (value: unknown) => structuredClone(value),
|
||||
callerFrame: () => '',
|
||||
SLOW_OPERATION_THRESHOLD_MS: 100,
|
||||
writeFileSync_DEPRECATED: () => {},
|
||||
}))
|
||||
|
||||
const { initContextCollapse, resetContextCollapse } = await import(
|
||||
'src/services/contextCollapse/index.js'
|
||||
)
|
||||
const { tokenCountWithEstimation } = await import('src/utils/tokens.js')
|
||||
const { CtxInspectTool } = await import('../CtxInspectTool.js')
|
||||
|
||||
function makeUserMessage(text: string) {
|
||||
return {
|
||||
type: 'user' as const,
|
||||
uuid: `user-${text}`,
|
||||
message: { role: 'user' as const, content: text },
|
||||
}
|
||||
}
|
||||
|
||||
function makeAssistantMessage(text: string) {
|
||||
return {
|
||||
type: 'assistant' as const,
|
||||
uuid: `assistant-${text}`,
|
||||
message: {
|
||||
role: 'assistant' as const,
|
||||
content: [{ type: 'text' as const, text }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function makeContext(messages: unknown[], mainLoopModel = 'claude-sonnet-4-6') {
|
||||
return {
|
||||
messages,
|
||||
options: {
|
||||
mainLoopModel,
|
||||
},
|
||||
getAppState: () => ({}),
|
||||
} as any
|
||||
}
|
||||
|
||||
const allowTool = async (input: Record<string, unknown>) => ({
|
||||
behavior: 'allow' as const,
|
||||
updatedInput: input,
|
||||
})
|
||||
|
||||
const parentMessage = makeAssistantMessage('Parent tool call')
|
||||
|
||||
beforeEach(() => {
|
||||
resetContextCollapse()
|
||||
sessionMemoryInitialized = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetContextCollapse()
|
||||
sessionMemoryInitialized = false
|
||||
})
|
||||
|
||||
describe('CtxInspectTool', () => {
|
||||
test('tool exports and metadata remain stable', async () => {
|
||||
expect(CtxInspectTool).toBeDefined()
|
||||
expect(CtxInspectTool.name).toBe('CtxInspect')
|
||||
expect(typeof CtxInspectTool.call).toBe('function')
|
||||
expect(await CtxInspectTool.description()).toContain('context')
|
||||
expect(CtxInspectTool.userFacingName()).toBe('CtxInspect')
|
||||
expect(CtxInspectTool.isReadOnly()).toBe(true)
|
||||
expect(CtxInspectTool.isConcurrencySafe()).toBe(true)
|
||||
})
|
||||
|
||||
test('formats tool results for transcript rendering', () => {
|
||||
const block = CtxInspectTool.mapToolResultToToolResultBlockParam(
|
||||
{
|
||||
total_tokens: 192,
|
||||
message_count: 3,
|
||||
context_window_model: 'claude-sonnet-4-6',
|
||||
prompt_caching_enabled: true,
|
||||
session_memory_enabled: true,
|
||||
context_collapse_enabled: false,
|
||||
summary: 'Context collapse: disabled',
|
||||
},
|
||||
'tool-use-id',
|
||||
)
|
||||
|
||||
expect(block.tool_use_id).toBe('tool-use-id')
|
||||
expect(block.content).toContain('192 tokens')
|
||||
expect(block.content).toContain('3 messages')
|
||||
expect(block.content).toContain('Context collapse: disabled')
|
||||
})
|
||||
|
||||
test('returns live context counts and mechanism state', async () => {
|
||||
const messages = [
|
||||
makeUserMessage('Inspect the current context budget.'),
|
||||
makeAssistantMessage('Looking at the current conversation state.'),
|
||||
]
|
||||
const context = makeContext(messages, 'claude-sonnet-4-6')
|
||||
|
||||
const result = await (CtxInspectTool as any).call(
|
||||
{},
|
||||
context,
|
||||
allowTool,
|
||||
parentMessage,
|
||||
)
|
||||
|
||||
expect(Object.keys(result.data).sort()).toEqual([
|
||||
'context_collapse_enabled',
|
||||
'context_window_model',
|
||||
'message_count',
|
||||
'prompt_caching_enabled',
|
||||
'session_memory_enabled',
|
||||
'summary',
|
||||
'total_tokens',
|
||||
])
|
||||
expect(result.data.message_count).toBe(messages.length)
|
||||
expect(result.data.total_tokens).toBe(tokenCountWithEstimation(messages as any))
|
||||
expect(result.data.context_window_model).toBe('claude-sonnet-4-6')
|
||||
expect(result.data.prompt_caching_enabled).toBe(true)
|
||||
expect(result.data.session_memory_enabled).toBe(false)
|
||||
expect(result.data.context_collapse_enabled).toBe(false)
|
||||
expect(result.data.summary).toContain('Overall context summary')
|
||||
expect(result.data.summary).toContain('Session memory: disabled')
|
||||
expect(result.data.summary).toContain('Context collapse: disabled')
|
||||
})
|
||||
|
||||
test('query input focuses summary and collapse runtime changes the reported state', async () => {
|
||||
const messages = [
|
||||
makeUserMessage('Show me tool usage pressure in this thread.'),
|
||||
makeAssistantMessage('Summarizing tool-heavy context now.'),
|
||||
]
|
||||
const context = makeContext(messages, 'claude-sonnet-4-6')
|
||||
|
||||
const disabledResult = await (CtxInspectTool as any).call(
|
||||
{ query: 'tool usage' },
|
||||
context,
|
||||
allowTool,
|
||||
parentMessage,
|
||||
)
|
||||
|
||||
initContextCollapse()
|
||||
|
||||
const enabledResult = await (CtxInspectTool as any).call(
|
||||
{ query: 'tool usage' },
|
||||
context,
|
||||
allowTool,
|
||||
parentMessage,
|
||||
)
|
||||
|
||||
expect(disabledResult.data.message_count).toBe(messages.length)
|
||||
expect(enabledResult.data.message_count).toBe(messages.length)
|
||||
expect(disabledResult.data.total_tokens).toBe(
|
||||
tokenCountWithEstimation(messages as any),
|
||||
)
|
||||
expect(enabledResult.data.total_tokens).toBe(
|
||||
tokenCountWithEstimation(messages as any),
|
||||
)
|
||||
expect(disabledResult.data.summary).toContain('Focus: tool usage')
|
||||
expect(disabledResult.data.context_collapse_enabled).toBe(false)
|
||||
expect(enabledResult.data.context_collapse_enabled).toBe(true)
|
||||
expect(enabledResult.data.summary).toContain('Context collapse: enabled')
|
||||
expect(enabledResult.data.summary).toContain('Collapse spans:')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { z } from 'zod/v4'
|
||||
import type { ToolResultBlockParam } from 'src/Tool.js'
|
||||
import { buildTool } from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import {
|
||||
DISCOVER_SKILLS_TOOL_NAME,
|
||||
DESCRIPTION,
|
||||
DISCOVER_SKILLS_PROMPT,
|
||||
} from './prompt.js'
|
||||
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
description: z
|
||||
.string()
|
||||
.describe(
|
||||
'Description of what you want to do. Be specific — e.g. "deploy a Next.js app to Cloudflare Workers" rather than just "deploy".',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Maximum number of results to return (default: 5)'),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
type DiscoverInput = z.infer<InputSchema>
|
||||
|
||||
type DiscoverOutput = {
|
||||
results: Array<{ name: string; description: string; score: number }>
|
||||
count: number
|
||||
}
|
||||
|
||||
export const DiscoverSkillsTool = buildTool({
|
||||
name: DISCOVER_SKILLS_TOOL_NAME,
|
||||
searchHint: 'find search discover skills commands tools capabilities',
|
||||
maxResultSizeChars: 10_000,
|
||||
strict: true,
|
||||
|
||||
get inputSchema(): InputSchema {
|
||||
return inputSchema()
|
||||
},
|
||||
|
||||
async description() {
|
||||
return DESCRIPTION
|
||||
},
|
||||
async prompt() {
|
||||
return DISCOVER_SKILLS_PROMPT
|
||||
},
|
||||
|
||||
isConcurrencySafe() {
|
||||
return true
|
||||
},
|
||||
isReadOnly() {
|
||||
return true
|
||||
},
|
||||
|
||||
userFacingName() {
|
||||
return 'Discover Skills'
|
||||
},
|
||||
|
||||
renderToolUseMessage(input: Partial<DiscoverInput>) {
|
||||
return `Searching skills: ${input.description?.slice(0, 80) ?? '...'}`
|
||||
},
|
||||
|
||||
mapToolResultToToolResultBlockParam(
|
||||
content: DiscoverOutput,
|
||||
toolUseID: string,
|
||||
): ToolResultBlockParam {
|
||||
if (content.count === 0) {
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: 'No matching skills found for that description.',
|
||||
}
|
||||
}
|
||||
const lines = content.results.map(
|
||||
(r, i) =>
|
||||
`${i + 1}. **${r.name}** (score: ${r.score.toFixed(2)})\n ${r.description}`,
|
||||
)
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: `Found ${content.count} relevant skill(s):\n\n${lines.join('\n\n')}`,
|
||||
}
|
||||
},
|
||||
|
||||
async call(input: DiscoverInput, context) {
|
||||
const { getSkillIndex, searchSkills } = await import(
|
||||
'src/services/skillSearch/localSearch.js'
|
||||
)
|
||||
const { getCwd } = await import('src/utils/cwd.js')
|
||||
const cwd = getCwd()
|
||||
|
||||
const index = await getSkillIndex(cwd)
|
||||
const results = searchSkills(input.description, index, input.limit ?? 5)
|
||||
|
||||
return {
|
||||
data: {
|
||||
results: results.map(r => ({
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
score: r.score,
|
||||
})),
|
||||
count: results.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { DISCOVER_SKILLS_TOOL_NAME } from '../prompt.js'
|
||||
|
||||
describe('DiscoverSkillsTool', () => {
|
||||
test('DISCOVER_SKILLS_TOOL_NAME is not empty', () => {
|
||||
expect(DISCOVER_SKILLS_TOOL_NAME).toBe('DiscoverSkills')
|
||||
expect(DISCOVER_SKILLS_TOOL_NAME.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('tool exports are functions', async () => {
|
||||
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
|
||||
expect(DiscoverSkillsTool).toBeDefined()
|
||||
expect(DiscoverSkillsTool.name).toBe('DiscoverSkills')
|
||||
expect(typeof DiscoverSkillsTool.call).toBe('function')
|
||||
})
|
||||
|
||||
test('tool has correct metadata', async () => {
|
||||
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
|
||||
expect(await DiscoverSkillsTool.description()).toContain('skill')
|
||||
expect(DiscoverSkillsTool.userFacingName()).toBe('Discover Skills')
|
||||
expect(DiscoverSkillsTool.isReadOnly()).toBe(true)
|
||||
expect(DiscoverSkillsTool.isConcurrencySafe()).toBe(true)
|
||||
})
|
||||
|
||||
test('renderToolUseMessage formats input', async () => {
|
||||
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
|
||||
const msg = DiscoverSkillsTool.renderToolUseMessage({
|
||||
description: 'deploy to cloudflare',
|
||||
})
|
||||
expect(msg).toContain('deploy to cloudflare')
|
||||
})
|
||||
|
||||
test('mapToolResultToToolResultBlockParam formats empty results', async () => {
|
||||
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
|
||||
const result = DiscoverSkillsTool.mapToolResultToToolResultBlockParam(
|
||||
{ results: [], count: 0 },
|
||||
'test-id',
|
||||
)
|
||||
expect(result.content).toContain('No matching skills')
|
||||
})
|
||||
|
||||
test('mapToolResultToToolResultBlockParam formats results', async () => {
|
||||
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
|
||||
const result = DiscoverSkillsTool.mapToolResultToToolResultBlockParam(
|
||||
{
|
||||
results: [{ name: 'test-skill', description: 'A test skill', score: 0.85 }],
|
||||
count: 1,
|
||||
},
|
||||
'test-id',
|
||||
)
|
||||
expect(result.content).toContain('test-skill')
|
||||
expect(result.content).toContain('0.85')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,13 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const DISCOVER_SKILLS_TOOL_NAME: string = '';
|
||||
export const DISCOVER_SKILLS_TOOL_NAME = 'DiscoverSkills'
|
||||
|
||||
export const DESCRIPTION =
|
||||
'Search for relevant skills by describing what you want to do'
|
||||
|
||||
export const DISCOVER_SKILLS_PROMPT = `Search for skills relevant to a task description. Returns matching skills ranked by relevance.
|
||||
|
||||
Use this when:
|
||||
- The auto-surfaced skills don't cover your current task
|
||||
- You're pivoting to a different kind of work mid-conversation
|
||||
- You want to find specialized skills for an unusual workflow
|
||||
|
||||
The search uses TF-IDF keyword matching against all registered skills (bundled, user-defined, and MCP-provided). Results include skill name, description, and relevance score.`
|
||||
|
||||
@@ -273,18 +273,6 @@ export const FileEditTool = buildTool({
|
||||
}
|
||||
|
||||
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
|
||||
if (!readTimestamp || readTimestamp.isPartialView) {
|
||||
return {
|
||||
result: false,
|
||||
behavior: 'ask',
|
||||
message:
|
||||
'File has not been read yet. Read it first before writing to it.',
|
||||
meta: {
|
||||
isFilePathAbsolute: String(isAbsolute(file_path)),
|
||||
},
|
||||
errorCode: 6,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if file exists and get its last modified time
|
||||
if (readTimestamp) {
|
||||
|
||||
@@ -186,14 +186,6 @@ export function renderToolUseErrorMessage(
|
||||
extractTag(result, 'tool_use_error')
|
||||
) {
|
||||
const errorMessage = extractTag(result, 'tool_use_error')
|
||||
// Show a less scary message for intended behavior
|
||||
if (errorMessage?.includes('File has not been read yet')) {
|
||||
return (
|
||||
<MessageResponse>
|
||||
<Text dimColor>File must be read first</Text>
|
||||
</MessageResponse>
|
||||
)
|
||||
}
|
||||
if (errorMessage?.includes(FILE_NOT_FOUND_CWD_NOTE)) {
|
||||
return (
|
||||
<MessageResponse>
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
import { logMock } from "../../../../../../tests/mocks/log";
|
||||
|
||||
// Mock log.ts to cut the heavy dependency chain
|
||||
mock.module("src/utils/log.ts", () => ({
|
||||
logError: () => {},
|
||||
logToFile: () => {},
|
||||
getLogDisplayTitle: () => "",
|
||||
logEvent: () => {},
|
||||
logMCPError: () => {},
|
||||
logMCPDebug: () => {},
|
||||
dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"),
|
||||
getLogFilePath: () => "/tmp/mock-log",
|
||||
attachErrorLogSink: () => {},
|
||||
getInMemoryErrors: () => [],
|
||||
loadErrorLogs: async () => [],
|
||||
getErrorLogByIndex: async () => null,
|
||||
captureAPIRequest: () => {},
|
||||
_resetErrorLogForTesting: () => {},
|
||||
}));
|
||||
mock.module("src/utils/log.ts", logMock);
|
||||
|
||||
const {
|
||||
normalizeQuotes,
|
||||
|
||||
@@ -196,25 +196,18 @@ export const FileWriteTool = buildTool({
|
||||
}
|
||||
|
||||
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
|
||||
if (!readTimestamp || readTimestamp.isPartialView) {
|
||||
return {
|
||||
result: false,
|
||||
message:
|
||||
'File has not been read yet. Read it first before writing to it.',
|
||||
errorCode: 2,
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse mtime from the stat above — avoids a redundant statSync via
|
||||
// getFileModificationTime. The readTimestamp guard above ensures this
|
||||
// block is always reached when the file exists.
|
||||
const lastWriteTime = Math.floor(fileMtimeMs)
|
||||
if (lastWriteTime > readTimestamp.timestamp) {
|
||||
return {
|
||||
result: false,
|
||||
message:
|
||||
'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.',
|
||||
errorCode: 3,
|
||||
// getFileModificationTime.
|
||||
if (readTimestamp) {
|
||||
const lastWriteTime = Math.floor(fileMtimeMs)
|
||||
if (lastWriteTime > readTimestamp.timestamp) {
|
||||
return {
|
||||
result: false,
|
||||
message:
|
||||
'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.',
|
||||
errorCode: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
import { debugMock } from "../../../../../../tests/mocks/debug";
|
||||
|
||||
mock.module("src/utils/debug.ts", () => ({
|
||||
logForDebugging: () => {},
|
||||
isDebugMode: () => false,
|
||||
}));
|
||||
mock.module("src/utils/debug.ts", debugMock);
|
||||
|
||||
const {
|
||||
formatGoToDefinitionResult,
|
||||
|
||||
@@ -421,7 +421,7 @@ export const PowerShellTool = buildTool({
|
||||
isSearch: boolean
|
||||
isRead: boolean
|
||||
} {
|
||||
if (!input.command) {
|
||||
if (!input?.command) {
|
||||
return { isSearch: false, isRead: false }
|
||||
}
|
||||
return isSearchOrReadPowerShellCommand(input.command)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getClaudeAIOAuthTokens,
|
||||
} from 'src/utils/auth.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { appendRemoteTriggerAuditRecord } from 'src/utils/remoteTriggerAudit.js'
|
||||
import { jsonStringify } from 'src/utils/slowOperations.js'
|
||||
import { DESCRIPTION, PROMPT, REMOTE_TRIGGER_TOOL_NAME } from './prompt.js'
|
||||
import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
|
||||
@@ -36,6 +37,7 @@ const outputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
status: z.number(),
|
||||
json: z.string(),
|
||||
audit_id: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
type OutputSchema = ReturnType<typeof outputSchema>
|
||||
@@ -76,77 +78,96 @@ export const RemoteTriggerTool = buildTool({
|
||||
return PROMPT
|
||||
},
|
||||
async call(input: Input, context: ToolUseContext) {
|
||||
await checkAndRefreshOAuthTokenIfNeeded()
|
||||
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error(
|
||||
'Not authenticated with a claude.ai account. Run /login and try again.',
|
||||
)
|
||||
}
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
throw new Error('Unable to resolve organization UUID.')
|
||||
const auditBase = {
|
||||
action: input.action,
|
||||
...(input.trigger_id ? { triggerId: input.trigger_id } : {}),
|
||||
}
|
||||
try {
|
||||
await checkAndRefreshOAuthTokenIfNeeded()
|
||||
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error(
|
||||
'Not authenticated with a claude.ai account. Run /login and try again.',
|
||||
)
|
||||
}
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
throw new Error('Unable to resolve organization UUID.')
|
||||
}
|
||||
|
||||
const base = `${getOauthConfig().BASE_API_URL}/v1/code/triggers`
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': TRIGGERS_BETA,
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
const base = `${getOauthConfig().BASE_API_URL}/v1/code/triggers`
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': TRIGGERS_BETA,
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
const { action, trigger_id, body } = input
|
||||
let method: 'GET' | 'POST'
|
||||
let url: string
|
||||
let data: unknown
|
||||
switch (action) {
|
||||
case 'list':
|
||||
method = 'GET'
|
||||
url = base
|
||||
break
|
||||
case 'get':
|
||||
if (!trigger_id) throw new Error('get requires trigger_id')
|
||||
method = 'GET'
|
||||
url = `${base}/${trigger_id}`
|
||||
break
|
||||
case 'create':
|
||||
if (!body) throw new Error('create requires body')
|
||||
method = 'POST'
|
||||
url = base
|
||||
data = body
|
||||
break
|
||||
case 'update':
|
||||
if (!trigger_id) throw new Error('update requires trigger_id')
|
||||
if (!body) throw new Error('update requires body')
|
||||
method = 'POST'
|
||||
url = `${base}/${trigger_id}`
|
||||
data = body
|
||||
break
|
||||
case 'run':
|
||||
if (!trigger_id) throw new Error('run requires trigger_id')
|
||||
method = 'POST'
|
||||
url = `${base}/${trigger_id}/run`
|
||||
data = {}
|
||||
break
|
||||
}
|
||||
const { action, trigger_id, body } = input
|
||||
let method: 'GET' | 'POST'
|
||||
let url: string
|
||||
let data: unknown
|
||||
switch (action) {
|
||||
case 'list':
|
||||
method = 'GET'
|
||||
url = base
|
||||
break
|
||||
case 'get':
|
||||
if (!trigger_id) throw new Error('get requires trigger_id')
|
||||
method = 'GET'
|
||||
url = `${base}/${trigger_id}`
|
||||
break
|
||||
case 'create':
|
||||
if (!body) throw new Error('create requires body')
|
||||
method = 'POST'
|
||||
url = base
|
||||
data = body
|
||||
break
|
||||
case 'update':
|
||||
if (!trigger_id) throw new Error('update requires trigger_id')
|
||||
if (!body) throw new Error('update requires body')
|
||||
method = 'POST'
|
||||
url = `${base}/${trigger_id}`
|
||||
data = body
|
||||
break
|
||||
case 'run':
|
||||
if (!trigger_id) throw new Error('run requires trigger_id')
|
||||
method = 'POST'
|
||||
url = `${base}/${trigger_id}/run`
|
||||
data = {}
|
||||
break
|
||||
}
|
||||
|
||||
const res = await axios.request({
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
data,
|
||||
timeout: 20_000,
|
||||
signal: context.abortController.signal,
|
||||
validateStatus: () => true,
|
||||
})
|
||||
|
||||
return {
|
||||
data: {
|
||||
const res = await axios.request({
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
data,
|
||||
timeout: 20_000,
|
||||
signal: context.abortController.signal,
|
||||
validateStatus: () => true,
|
||||
})
|
||||
const audit = await appendRemoteTriggerAuditRecord({
|
||||
...auditBase,
|
||||
ok: res.status >= 200 && res.status < 300,
|
||||
status: res.status,
|
||||
json: jsonStringify(res.data),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
data: {
|
||||
status: res.status,
|
||||
json: jsonStringify(res.data),
|
||||
audit_id: audit.auditId,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
await appendRemoteTriggerAuditRecord({
|
||||
...auditBase,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
mapToolResultToToolResultBlockParam(output, toolUseID) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { mkdir, readFile, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import {
|
||||
resetStateForTests,
|
||||
setOriginalCwd,
|
||||
setProjectRoot,
|
||||
} from 'src/bootstrap/state.js'
|
||||
|
||||
let requestStatus = 200
|
||||
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
request: async () => ({
|
||||
status: requestStatus,
|
||||
data: { ok: requestStatus >= 200 && requestStatus < 300 },
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module('src/utils/auth.js', () => ({
|
||||
checkAndRefreshOAuthTokenIfNeeded: async () => {},
|
||||
getClaudeAIOAuthTokens: () => ({ accessToken: 'token' }),
|
||||
}))
|
||||
|
||||
mock.module('src/services/oauth/client.js', () => ({
|
||||
getOrganizationUUID: async () => 'org',
|
||||
}))
|
||||
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://example.test' }),
|
||||
}))
|
||||
|
||||
let cwd = ''
|
||||
let previousCwd = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
requestStatus = 200
|
||||
previousCwd = process.cwd()
|
||||
cwd = join(tmpdir(), `remote-trigger-tool-${Date.now()}-${Math.random().toString(16).slice(2)}`)
|
||||
await mkdir(cwd, { recursive: true })
|
||||
process.chdir(cwd)
|
||||
resetStateForTests()
|
||||
setOriginalCwd(cwd)
|
||||
setProjectRoot(cwd)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetStateForTests()
|
||||
process.chdir(previousCwd)
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('RemoteTriggerTool audit', () => {
|
||||
test('writes an audit record for successful remote calls', async () => {
|
||||
const { RemoteTriggerTool } = await import('../RemoteTriggerTool')
|
||||
const result = await RemoteTriggerTool.call(
|
||||
{ action: 'run', trigger_id: 'trigger-1' },
|
||||
{ abortController: new AbortController() } as any,
|
||||
)
|
||||
|
||||
expect(result.data.audit_id).toBeString()
|
||||
const raw = await readFile(
|
||||
join(cwd, '.claude', 'remote-trigger-audit.jsonl'),
|
||||
'utf-8',
|
||||
)
|
||||
expect(raw).toContain('"action":"run"')
|
||||
expect(raw).toContain('"triggerId":"trigger-1"')
|
||||
expect(raw).toContain('"ok":true')
|
||||
})
|
||||
|
||||
test('writes an audit record before rethrowing validation failures', async () => {
|
||||
const { RemoteTriggerTool } = await import('../RemoteTriggerTool')
|
||||
|
||||
await expect(
|
||||
RemoteTriggerTool.call(
|
||||
{ action: 'run' },
|
||||
{ abortController: new AbortController() } as any,
|
||||
),
|
||||
).rejects.toThrow('run requires trigger_id')
|
||||
|
||||
const raw = await readFile(
|
||||
join(cwd, '.claude', 'remote-trigger-audit.jsonl'),
|
||||
'utf-8',
|
||||
)
|
||||
expect(raw).toContain('"action":"run"')
|
||||
expect(raw).toContain('"ok":false')
|
||||
expect(raw).toContain('run requires trigger_id')
|
||||
})
|
||||
})
|
||||
@@ -14,11 +14,26 @@ import {
|
||||
} from 'src/utils/swarm/teamHelpers.js'
|
||||
import { clearTeammateColors } from 'src/utils/swarm/teammateLayoutManager.js'
|
||||
import { clearLeaderTeamName } from 'src/utils/tasks.js'
|
||||
import { ensureBackendsRegistered, getBackendByType, getInProcessBackend } from 'src/utils/swarm/backends/registry.js'
|
||||
import { createPaneBackendExecutor } from 'src/utils/swarm/backends/PaneBackendExecutor.js'
|
||||
import { isPaneBackend } from 'src/utils/swarm/backends/types.js'
|
||||
import { sleep } from 'src/utils/sleep.js'
|
||||
import { TEAM_DELETE_TOOL_NAME } from './constants.js'
|
||||
import { getPrompt } from './prompt.js'
|
||||
import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
|
||||
|
||||
const inputSchema = lazySchema(() => z.strictObject({}))
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
wait_ms: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(30_000)
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional time to wait for active teammates to acknowledge shutdown before cleanup.',
|
||||
),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
|
||||
export type Output = {
|
||||
@@ -68,7 +83,7 @@ export const TeamDeleteTool: Tool<InputSchema, Output> = buildTool({
|
||||
}
|
||||
},
|
||||
|
||||
async call(_input, context) {
|
||||
async call(input, context) {
|
||||
const { setAppState, getAppState } = context
|
||||
const appState = getAppState()
|
||||
const teamName = appState.teamContext?.teamName
|
||||
@@ -87,13 +102,82 @@ export const TeamDeleteTool: Tool<InputSchema, Output> = buildTool({
|
||||
const activeMembers = nonLeadMembers.filter(m => m.isActive !== false)
|
||||
|
||||
if (activeMembers.length > 0) {
|
||||
const memberNames = activeMembers.map(m => m.name).join(', ')
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
message: `Cannot cleanup team with ${activeMembers.length} active member(s): ${memberNames}. Use requestShutdown to gracefully terminate teammates first.`,
|
||||
team_name: teamName,
|
||||
},
|
||||
const requested: string[] = []
|
||||
for (const member of activeMembers) {
|
||||
let sent = false
|
||||
if (member.backendType === 'in-process') {
|
||||
const executor = getInProcessBackend()
|
||||
executor.setContext?.(context)
|
||||
sent = await executor.terminate(
|
||||
member.agentId,
|
||||
'Team cleanup requested by team lead',
|
||||
)
|
||||
} else if (member.backendType && isPaneBackend(member.backendType)) {
|
||||
await ensureBackendsRegistered()
|
||||
const executor = createPaneBackendExecutor(
|
||||
getBackendByType(member.backendType),
|
||||
)
|
||||
executor.setContext?.(context)
|
||||
sent = await executor.terminate(
|
||||
member.agentId,
|
||||
'Team cleanup requested by team lead',
|
||||
)
|
||||
}
|
||||
if (sent) {
|
||||
requested.push(member.name)
|
||||
}
|
||||
}
|
||||
const waitMs = input.wait_ms ?? 0
|
||||
if (waitMs > 0 && requested.length > 0) {
|
||||
const deadline = Date.now() + waitMs
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(Math.min(250, Math.max(0, deadline - Date.now())))
|
||||
const refreshed = readTeamFile(teamName)
|
||||
const stillActive =
|
||||
refreshed?.members.filter(
|
||||
m => m.name !== TEAM_LEAD_NAME && m.isActive !== false,
|
||||
) ?? []
|
||||
if (stillActive.length === 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
const refreshed = readTeamFile(teamName)
|
||||
const stillActive =
|
||||
refreshed?.members.filter(
|
||||
m => m.name !== TEAM_LEAD_NAME && m.isActive !== false,
|
||||
) ?? []
|
||||
if (stillActive.length === 0) {
|
||||
// Fall through to cleanup with the refreshed team file state.
|
||||
} else {
|
||||
const memberNames = stillActive.map(m => m.name).join(', ')
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
message: `Shutdown requested for active teammate(s): ${requested.join(', ')}. Cleanup is still blocked after waiting ${waitMs}ms: ${memberNames}.`,
|
||||
team_name: teamName,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
const latestTeamFile = readTeamFile(teamName)
|
||||
const latestActiveMembers =
|
||||
latestTeamFile?.members.filter(
|
||||
m => m.name !== TEAM_LEAD_NAME && m.isActive !== false,
|
||||
) ?? []
|
||||
if (latestActiveMembers.length === 0) {
|
||||
// Continue to cleanup below.
|
||||
} else {
|
||||
const memberNames = latestActiveMembers.map(m => m.name).join(', ')
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
message:
|
||||
requested.length > 0
|
||||
? `Shutdown requested for active teammate(s): ${requested.join(', ')}. Cleanup is blocked until they exit: ${memberNames}.`
|
||||
: `Cannot cleanup team with ${latestActiveMembers.length} active member(s): ${memberNames}. Use requestShutdown to gracefully terminate teammates first.`,
|
||||
team_name: teamName,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,19 +9,11 @@ const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
url: z
|
||||
.string()
|
||||
.describe('URL to navigate to in the browser.'),
|
||||
.describe('URL to fetch and extract content from.'),
|
||||
action: z
|
||||
.enum(['navigate', 'screenshot', 'click', 'type', 'scroll'])
|
||||
.enum(['navigate', 'screenshot'])
|
||||
.optional()
|
||||
.describe('Browser action to perform. Defaults to "navigate".'),
|
||||
selector: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('CSS selector for click/type actions.'),
|
||||
text: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Text to type when action is "type".'),
|
||||
.describe('Action to perform. "navigate" fetches page content (default). "screenshot" returns a text snapshot of the page.'),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
@@ -45,16 +37,24 @@ export const WebBrowserTool = buildTool({
|
||||
},
|
||||
|
||||
async description() {
|
||||
return 'Browse the web using an embedded browser'
|
||||
return 'Fetch and read web page content via HTTP'
|
||||
},
|
||||
async prompt() {
|
||||
return `Open and interact with web pages in an embedded browser. Supports navigation, screenshots, clicking, typing, and scrolling.
|
||||
return `Fetch web pages via HTTP and extract their text content. This is a lightweight browser tool (HTTP fetch, not a full browser engine).
|
||||
|
||||
Supported actions:
|
||||
- navigate: Fetch a URL and extract page title + text content
|
||||
- screenshot: Same as navigate (returns text snapshot, not a visual screenshot)
|
||||
|
||||
Limitations:
|
||||
- No JavaScript execution — only sees server-rendered HTML
|
||||
- click/type/scroll require a full browser runtime (not available)
|
||||
- For full browser interaction, use the Claude-in-Chrome MCP tools instead
|
||||
|
||||
Use this for:
|
||||
- Viewing web pages and their content
|
||||
- Taking screenshots of UI
|
||||
- Interacting with web applications
|
||||
- Testing web endpoints with full browser rendering`
|
||||
- Reading web page content and documentation
|
||||
- Checking API endpoints that return HTML
|
||||
- Quick page title/content extraction`
|
||||
},
|
||||
|
||||
isConcurrencySafe() {
|
||||
@@ -85,12 +85,84 @@ Use this for:
|
||||
},
|
||||
|
||||
async call(input: BrowserInput) {
|
||||
// Browser integration requires the WEB_BROWSER_TOOL runtime (Bun WebView).
|
||||
const action = input.action ?? 'navigate'
|
||||
|
||||
if (action === 'navigate' || action === 'screenshot') {
|
||||
// Fetch the page content via HTTP
|
||||
try {
|
||||
const response = await fetch(input.url, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
Accept:
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
redirect: 'follow',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
data: {
|
||||
title: `HTTP ${response.status}`,
|
||||
url: input.url,
|
||||
content: `Error: ${response.status} ${response.statusText}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
|
||||
// Extract title
|
||||
const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i)
|
||||
const title = titleMatch?.[1]?.trim() ?? ''
|
||||
|
||||
// Extract text content (strip HTML tags, scripts, styles)
|
||||
let textContent = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
// Truncate to reasonable size
|
||||
if (textContent.length > 50_000) {
|
||||
textContent = textContent.slice(0, 50_000) + '\n[truncated]'
|
||||
}
|
||||
|
||||
if (action === 'screenshot') {
|
||||
return {
|
||||
data: {
|
||||
title,
|
||||
url: response.url,
|
||||
content: `[Text snapshot — visual screenshots require Chrome browser tools]\n\n${textContent}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
title,
|
||||
url: response.url,
|
||||
content: textContent,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
data: {
|
||||
title: 'Error',
|
||||
url: input.url,
|
||||
content: `Failed to fetch: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable — schema only allows navigate/screenshot
|
||||
return {
|
||||
data: {
|
||||
title: '',
|
||||
url: input.url,
|
||||
content: 'Web browser requires the WEB_BROWSER_TOOL runtime.',
|
||||
content: `Unknown action "${action}".`,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
|
||||
|
||||
// Mock fetch directly — avoids flaky dependency on external hosts AND
|
||||
// pollution by other tests that call setGlobalDispatcher (proxy agents make
|
||||
// localhost fetches return 500 in the full-suite run).
|
||||
const realFetch = globalThis.fetch
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.fetch = (async (
|
||||
input: string | URL | Request,
|
||||
_init?: RequestInit,
|
||||
) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
if (url === 'not-a-url' || !url.startsWith('http')) {
|
||||
throw new TypeError('Failed to fetch')
|
||||
}
|
||||
const body =
|
||||
'<!doctype html><html><head><title>Example Domain</title></head>' +
|
||||
'<body><h1>Example Domain</h1><p>Sample content.</p></body></html>'
|
||||
const res = new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/html' },
|
||||
})
|
||||
// Make response.url match the request URL so tests can assert on it.
|
||||
Object.defineProperty(res, 'url', { value: url, configurable: true })
|
||||
return res
|
||||
}) as typeof fetch
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.fetch = realFetch
|
||||
})
|
||||
|
||||
describe('WebBrowserTool', () => {
|
||||
test('tool exports and metadata', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
expect(WebBrowserTool).toBeDefined()
|
||||
expect(WebBrowserTool.name).toBe('WebBrowser')
|
||||
expect(typeof WebBrowserTool.call).toBe('function')
|
||||
expect(WebBrowserTool.userFacingName()).toBe('Browser')
|
||||
expect(WebBrowserTool.isReadOnly()).toBe(true)
|
||||
})
|
||||
|
||||
test('description reflects browser-lite', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const desc = await WebBrowserTool.description()
|
||||
expect(desc).toContain('HTTP')
|
||||
expect(desc).not.toContain('embedded browser')
|
||||
})
|
||||
|
||||
test('prompt mentions limitations', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const prompt = await WebBrowserTool.prompt()
|
||||
expect(prompt).toContain('Limitations')
|
||||
expect(prompt).toContain('No JavaScript')
|
||||
expect(prompt).toContain('Claude-in-Chrome')
|
||||
})
|
||||
|
||||
test('navigate fetches URL', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const result = await WebBrowserTool.call({
|
||||
url: 'https://example.com',
|
||||
} as any)
|
||||
expect(result.data.title).toBe('Example Domain')
|
||||
expect(result.data.url).toContain('example.com')
|
||||
expect(result.data.content).toContain('Example Domain')
|
||||
}, 15000)
|
||||
|
||||
test('screenshot returns text snapshot', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const result = await WebBrowserTool.call({
|
||||
url: 'https://example.com',
|
||||
action: 'screenshot',
|
||||
} as any)
|
||||
expect(result.data.content).toContain('Text snapshot')
|
||||
expect(result.data.content).toContain('Example Domain')
|
||||
}, 15000)
|
||||
|
||||
test('schema only allows navigate and screenshot', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const schema = WebBrowserTool.inputSchema
|
||||
const parseResult = schema.safeParse({
|
||||
url: 'https://example.com',
|
||||
action: 'click',
|
||||
})
|
||||
expect(parseResult.success).toBe(false)
|
||||
})
|
||||
|
||||
test('invalid URL returns error', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const result = await WebBrowserTool.call({ url: 'not-a-url' } as any)
|
||||
expect(result.data.content).toContain('Failed to fetch')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { logMock } from '../../../../../../tests/mocks/log'
|
||||
|
||||
type MockAxiosResponse = {
|
||||
data: ArrayBuffer
|
||||
headers: Record<string, unknown>
|
||||
status: number
|
||||
statusText: string
|
||||
}
|
||||
|
||||
type MockAxiosError = Error & {
|
||||
isAxiosError: true
|
||||
response?: {
|
||||
headers: Record<string, unknown>
|
||||
status: number
|
||||
}
|
||||
}
|
||||
|
||||
let getMock: (url: string) => Promise<MockAxiosResponse>
|
||||
|
||||
mock.module('axios', () => {
|
||||
const axiosMock = {
|
||||
get: (url: string) => getMock(url),
|
||||
isAxiosError: (error: unknown): error is MockAxiosError =>
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
(error as { isAxiosError?: unknown }).isAxiosError === true,
|
||||
}
|
||||
|
||||
return { default: axiosMock }
|
||||
})
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
}))
|
||||
|
||||
mock.module('src/services/api/claude.js', () => ({
|
||||
queryHaiku: async () => ({ message: { content: [] } }),
|
||||
}))
|
||||
|
||||
mock.module('src/utils/http.js', () => ({
|
||||
getWebFetchUserAgent: () => 'TestAgent/1.0',
|
||||
}))
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
|
||||
mock.module('src/utils/mcpOutputStorage.js', () => ({
|
||||
isBinaryContentType: (contentType: string) =>
|
||||
!contentType.toLowerCase().startsWith('text/'),
|
||||
persistBinaryContent: async () => ({
|
||||
filepath: '/tmp/webfetch-test.bin',
|
||||
size: 0,
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module('src/utils/settings/settings.js', () => ({
|
||||
getInitialSettings: () => ({}),
|
||||
getSettings_DEPRECATED: () => ({ skipWebFetchPreflight: true }),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
getMock = async () => ({
|
||||
data: new TextEncoder().encode('hello').buffer,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebFetch response headers', () => {
|
||||
test('reads redirect Location from AxiosHeaders-style get()', async () => {
|
||||
getMock = async () => {
|
||||
const error = new Error('redirect') as MockAxiosError
|
||||
error.isAxiosError = true
|
||||
error.response = {
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === 'location' ? '/next' : undefined,
|
||||
},
|
||||
status: 302,
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const { getWithPermittedRedirects } = await import('../utils')
|
||||
const result = await getWithPermittedRedirects(
|
||||
'https://example.com/old',
|
||||
new AbortController().signal,
|
||||
() => false,
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'redirect',
|
||||
originalUrl: 'https://example.com/old',
|
||||
redirectUrl: 'https://example.com/next',
|
||||
statusCode: 302,
|
||||
})
|
||||
})
|
||||
|
||||
test('reads proxy block markers from normalized headers', async () => {
|
||||
getMock = async () => {
|
||||
const error = new Error('blocked') as MockAxiosError
|
||||
error.isAxiosError = true
|
||||
error.response = {
|
||||
headers: { 'x-proxy-error': 'blocked-by-allowlist' },
|
||||
status: 403,
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const { getWithPermittedRedirects } = await import('../utils')
|
||||
|
||||
await expect(
|
||||
getWithPermittedRedirects(
|
||||
'https://blocked.example/path',
|
||||
new AbortController().signal,
|
||||
() => false,
|
||||
),
|
||||
).rejects.toThrow('EGRESS_BLOCKED')
|
||||
})
|
||||
|
||||
test('normalizes array content-type before cache and parsing', async () => {
|
||||
getMock = async () => ({
|
||||
data: new TextEncoder().encode('plain body').buffer,
|
||||
headers: { 'content-type': ['text/plain', 'charset=utf-8'] },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
})
|
||||
|
||||
const { clearWebFetchCache, getURLMarkdownContent } = await import('../utils')
|
||||
clearWebFetchCache()
|
||||
|
||||
const result = await getURLMarkdownContent(
|
||||
'https://example.com/plain.txt',
|
||||
new AbortController(),
|
||||
)
|
||||
|
||||
expect('type' in result).toBe(false)
|
||||
if ('type' in result) {
|
||||
throw new Error('unexpected redirect result')
|
||||
}
|
||||
expect(result.content).toBe('plain body')
|
||||
expect(result.contentType).toBe('text/plain, charset=utf-8')
|
||||
})
|
||||
})
|
||||
@@ -82,6 +82,34 @@ export function clearWebFetchCache(): void {
|
||||
DOMAIN_CHECK_CACHE.clear()
|
||||
}
|
||||
|
||||
function responseHeaderToString(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value
|
||||
.map(responseHeaderToString)
|
||||
.filter((part): part is string => part !== undefined)
|
||||
return parts.length > 0 ? parts.join(', ') : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getResponseHeader(
|
||||
headers: AxiosResponse<unknown>['headers'],
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const headersWithGet = headers as { get?: (headerName: string) => unknown }
|
||||
if (typeof headersWithGet.get === 'function') {
|
||||
const value = responseHeaderToString(headersWithGet.get(name))
|
||||
if (value !== undefined) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return responseHeaderToString(headers[name.toLowerCase()])
|
||||
}
|
||||
|
||||
// Lazy singleton — defers the turndown → @mixmark-io/domino import (~1.4MB
|
||||
// retained heap) until the first HTML fetch, and reuses one instance across
|
||||
// calls (construction builds 15 rule objects; .turndown() is stateless).
|
||||
@@ -286,7 +314,7 @@ export async function getWithPermittedRedirects(
|
||||
error.response &&
|
||||
[301, 302, 307, 308].includes(error.response.status)
|
||||
) {
|
||||
const redirectLocation = error.response.headers.location
|
||||
const redirectLocation = getResponseHeader(error.response.headers, 'location')
|
||||
if (!redirectLocation) {
|
||||
throw new Error('Redirect missing Location header')
|
||||
}
|
||||
@@ -318,7 +346,8 @@ export async function getWithPermittedRedirects(
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
error.response?.status === 403 &&
|
||||
error.response.headers['x-proxy-error'] === 'blocked-by-allowlist'
|
||||
getResponseHeader(error.response.headers, 'x-proxy-error') ===
|
||||
'blocked-by-allowlist'
|
||||
) {
|
||||
const hostname = new URL(url).hostname
|
||||
throw new EgressBlockedError(hostname)
|
||||
@@ -430,7 +459,7 @@ export async function getURLMarkdownContent(
|
||||
// This lets GC reclaim up to MAX_HTTP_CONTENT_LENGTH (10MB) before Turndown
|
||||
// builds its DOM tree (which can be 3-5x the HTML size).
|
||||
;(response as { data: unknown }).data = null
|
||||
const contentType = response.headers['content-type'] ?? ''
|
||||
const contentType = getResponseHeader(response.headers, 'content-type') ?? ''
|
||||
|
||||
// Binary content: save raw bytes to disk with a proper extension so Claude
|
||||
// can inspect the file later. We still fall through to the utf-8 decode +
|
||||
|
||||
@@ -23,6 +23,26 @@ const inputSchema = lazySchema(() =>
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Never include search results from these domains'),
|
||||
num_results: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Number of search results to return (default: 8)'),
|
||||
livecrawl: z
|
||||
.enum(['fallback', 'preferred'])
|
||||
.optional()
|
||||
.describe(
|
||||
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
|
||||
),
|
||||
search_type: z
|
||||
.enum(['auto', 'fast', 'deep'])
|
||||
.optional()
|
||||
.describe(
|
||||
"Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
|
||||
),
|
||||
context_max_characters: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Maximum characters for context string optimized for LLMs (default: 10000)'),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
@@ -148,6 +168,10 @@ export const WebSearchTool = buildTool({
|
||||
const adapterResults = await adapter.search(query, {
|
||||
allowedDomains: input.allowed_domains,
|
||||
blockedDomains: input.blocked_domains,
|
||||
numResults: input.num_results,
|
||||
livecrawl: input.livecrawl,
|
||||
searchType: input.search_type,
|
||||
contextMaxCharacters: input.context_max_characters,
|
||||
signal: context.abortController.signal,
|
||||
onProgress(progress) {
|
||||
if (onProgress) {
|
||||
|
||||
@@ -52,10 +52,10 @@ describe('createAdapter', () => {
|
||||
expect(createAdapter().constructor.name).toBe('ApiSearchAdapter')
|
||||
})
|
||||
|
||||
test('selects the Bing adapter for third-party Anthropic base URLs', () => {
|
||||
test('selects the Exa adapter for third-party Anthropic base URLs', () => {
|
||||
delete process.env.WEB_SEARCH_ADAPTER
|
||||
isFirstPartyBaseUrl = false
|
||||
|
||||
expect(createAdapter().constructor.name).toBe('BingSearchAdapter')
|
||||
expect(createAdapter().constructor.name).toBe('ExaSearchAdapter')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
const _abortMock = () => ({
|
||||
AbortError: class AbortError extends Error {
|
||||
constructor(message?: string) { super(message); this.name = 'AbortError' }
|
||||
},
|
||||
isAbortError: (e: unknown) => e instanceof Error && (e as Error).name === 'AbortError',
|
||||
})
|
||||
mock.module('src/utils/errors.js', _abortMock)
|
||||
mock.module('src/utils/errors', _abortMock)
|
||||
|
||||
describe('ExaSearchAdapter.search', () => {
|
||||
const createAdapter = async () => {
|
||||
const { ExaSearchAdapter } = await import('../adapters/exaAdapter')
|
||||
return new ExaSearchAdapter()
|
||||
}
|
||||
|
||||
// Exa MCP returns SSE lines like: data: {"result":{"content":[{"type":"text","text":"..."}]}}
|
||||
const buildSseResponse = (text: string) => `data: ${JSON.stringify({ result: { content: [{ type: 'text', text }] } })}\n`
|
||||
|
||||
const STRUCTURED_TEXT = [
|
||||
'Title: Example Result 1',
|
||||
'URL: https://example.com/page1',
|
||||
'Content: This is the content snippet for page 1.',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'Title: Example Result 2',
|
||||
'URL: https://example.com/page2',
|
||||
'Content: This is the content snippet for page 2.',
|
||||
].join('\n')
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
test('parses structured Title/URL/Content blocks from SSE response', async () => {
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const results = await adapter.search('test query', {})
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]).toEqual({
|
||||
title: 'Example Result 1',
|
||||
url: 'https://example.com/page1',
|
||||
snippet: 'This is the content snippet for page 1.',
|
||||
})
|
||||
expect(results[1]).toEqual({
|
||||
title: 'Example Result 2',
|
||||
url: 'https://example.com/page2',
|
||||
snippet: 'This is the content snippet for page 2.',
|
||||
})
|
||||
})
|
||||
|
||||
test('parses markdown link fallback when no structured blocks', async () => {
|
||||
const markdownText = '- [React Docs](https://react.dev/docs)\n- [React Hooks](https://react.dev/hooks)'
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: buildSseResponse(markdownText) })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const results = await adapter.search('react', {})
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]).toEqual({
|
||||
title: 'React Docs',
|
||||
url: 'https://react.dev/docs',
|
||||
snippet: undefined,
|
||||
})
|
||||
expect(results[1].url).toBe('https://react.dev/hooks')
|
||||
})
|
||||
|
||||
test('parses plain URL fallback', async () => {
|
||||
const plainUrlText = 'https://example.com/page1\nhttps://example.com/page2'
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: buildSseResponse(plainUrlText) })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const results = await adapter.search('test', {})
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0].url).toBe('https://example.com/page1')
|
||||
})
|
||||
|
||||
test('returns empty array for empty response', async () => {
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: '' })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const results = await adapter.search('test', {})
|
||||
|
||||
expect(results).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('parses direct JSON response (non-SSE fallback)', async () => {
|
||||
const jsonResponse = JSON.stringify({
|
||||
result: { content: [{ type: 'text', text: STRUCTURED_TEXT }] },
|
||||
})
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: jsonResponse })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const results = await adapter.search('test', {})
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0].url).toBe('https://example.com/page1')
|
||||
})
|
||||
|
||||
test('calls onProgress with query_update and search_results_received', async () => {
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const progressCalls: any[] = []
|
||||
const onProgress = (p: any) => progressCalls.push(p)
|
||||
|
||||
const adapter = await createAdapter()
|
||||
await adapter.search('test', { onProgress })
|
||||
|
||||
expect(progressCalls).toHaveLength(2)
|
||||
expect(progressCalls[0]).toEqual({ type: 'query_update', query: 'test' })
|
||||
expect(progressCalls[1]).toEqual({
|
||||
type: 'search_results_received',
|
||||
resultCount: 2,
|
||||
query: 'test',
|
||||
})
|
||||
})
|
||||
|
||||
test('filters results by allowedDomains', async () => {
|
||||
const mixedText = [
|
||||
'Title: Allowed',
|
||||
'URL: https://allowed.com/a',
|
||||
'---',
|
||||
'Title: Blocked',
|
||||
'URL: https://blocked.com/b',
|
||||
].join('\n')
|
||||
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: buildSseResponse(mixedText) })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const results = await adapter.search('test', { allowedDomains: ['allowed.com'] })
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].url).toBe('https://allowed.com/a')
|
||||
})
|
||||
|
||||
test('filters results by blockedDomains', async () => {
|
||||
const mixedText = [
|
||||
'Title: Good',
|
||||
'URL: https://good.com/a',
|
||||
'---',
|
||||
'Title: Spam',
|
||||
'URL: https://spam.com/b',
|
||||
].join('\n')
|
||||
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: buildSseResponse(mixedText) })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const results = await adapter.search('test', { blockedDomains: ['spam.com'] })
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].url).toBe('https://good.com/a')
|
||||
})
|
||||
|
||||
test('filters subdomains with allowedDomains', async () => {
|
||||
const text = [
|
||||
'Title: Subdomain',
|
||||
'URL: https://docs.example.com/page',
|
||||
'---',
|
||||
'Title: Other',
|
||||
'URL: https://other.com/page',
|
||||
].join('\n')
|
||||
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: buildSseResponse(text) })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const results = await adapter.search('test', { allowedDomains: ['example.com'] })
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].url).toBe('https://docs.example.com/page')
|
||||
})
|
||||
|
||||
test('throws AbortError when signal is already aborted', async () => {
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) })),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
const { AbortError } = await import('src/utils/errors')
|
||||
await expect(
|
||||
adapter.search('test', { signal: controller.signal }),
|
||||
).rejects.toThrow(AbortError)
|
||||
})
|
||||
|
||||
test('re-throws non-abort axios errors', async () => {
|
||||
const networkError = new Error('Network error')
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: mock(() => Promise.reject(networkError)),
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
await expect(adapter.search('test', {})).rejects.toThrow('Network error')
|
||||
})
|
||||
|
||||
test('sends correct MCP request payload to Exa endpoint', async () => {
|
||||
const axiosPost = mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) }))
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: axiosPost,
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
await adapter.search('hello world', {})
|
||||
|
||||
expect(axiosPost.mock.calls).toHaveLength(1)
|
||||
const [url, body, config] = (axiosPost.mock.calls as any[][])[0]
|
||||
expect(url).toBe('https://mcp.exa.ai/mcp')
|
||||
expect(body.jsonrpc).toBe('2.0')
|
||||
expect(body.method).toBe('tools/call')
|
||||
expect(body.params.name).toBe('web_search_exa')
|
||||
expect(body.params.arguments.query).toBe('hello world')
|
||||
expect(body.params.arguments.type).toBe('auto')
|
||||
expect(body.params.arguments.numResults).toBe(8)
|
||||
expect(body.params.arguments.livecrawl).toBe('fallback')
|
||||
expect(body.params.arguments.contextMaxCharacters).toBe(10000)
|
||||
expect(config.headers.Accept).toBe('application/json, text/event-stream')
|
||||
})
|
||||
|
||||
test('passes custom search options to MCP request', async () => {
|
||||
const axiosPost = mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) }))
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
post: axiosPost,
|
||||
isCancel: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
const adapter = await createAdapter()
|
||||
await adapter.search('test', {
|
||||
numResults: 15,
|
||||
livecrawl: 'preferred',
|
||||
searchType: 'deep',
|
||||
contextMaxCharacters: 20000,
|
||||
})
|
||||
|
||||
const [, body] = (axiosPost.mock.calls as any[][])[0]
|
||||
expect(body.params.arguments.numResults).toBe(15)
|
||||
expect(body.params.arguments.livecrawl).toBe('preferred')
|
||||
expect(body.params.arguments.type).toBe('deep')
|
||||
expect(body.params.arguments.contextMaxCharacters).toBe(20000)
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
||||
import { queryModelWithStreaming } from 'src/services/api/claude.js'
|
||||
import { createTrace, endTrace, isLangfuseEnabled } from 'src/services/langfuse/index.js'
|
||||
import { getSessionId } from 'src/bootstrap/state.js'
|
||||
import { getAPIProvider } from 'src/utils/model/providers.js'
|
||||
import { createUserMessage } from 'src/utils/messages.js'
|
||||
import { getMainLoopModel, getSmallFastModel } from 'src/utils/model/model.js'
|
||||
import { jsonParse } from 'src/utils/slowOperations.js'
|
||||
@@ -38,6 +41,15 @@ export class ApiSearchAdapter implements WebSearchAdapter {
|
||||
const toolSchema = makeToolSchema({ allowedDomains, blockedDomains })
|
||||
|
||||
const useHaiku = getFeatureValue_CACHED_MAY_BE_STALE('tengu_plum_vx3', false)
|
||||
const model = useHaiku ? getSmallFastModel() : getMainLoopModel()
|
||||
const langfuseTrace = isLangfuseEnabled()
|
||||
? createTrace({
|
||||
sessionId: getSessionId(),
|
||||
model,
|
||||
provider: getAPIProvider(),
|
||||
name: 'web-search-tool',
|
||||
})
|
||||
: null
|
||||
|
||||
const queryStream = queryModelWithStreaming({
|
||||
messages: [userMessage],
|
||||
@@ -58,7 +70,7 @@ export class ApiSearchAdapter implements WebSearchAdapter {
|
||||
alwaysAskRules: {},
|
||||
isBypassPermissionsModeAvailable: false,
|
||||
}),
|
||||
model: useHaiku ? getSmallFastModel() : getMainLoopModel(),
|
||||
model,
|
||||
toolChoice: useHaiku ? { type: 'tool' as const, name: 'web_search' } : undefined,
|
||||
isNonInteractiveSession: false,
|
||||
hasAppendSystemPrompt: false,
|
||||
@@ -68,6 +80,7 @@ export class ApiSearchAdapter implements WebSearchAdapter {
|
||||
mcpTools: [],
|
||||
agentId: undefined,
|
||||
effortValue: undefined,
|
||||
langfuseTrace,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -148,6 +161,8 @@ export class ApiSearchAdapter implements WebSearchAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
endTrace(langfuseTrace)
|
||||
|
||||
// Extract SearchResult[] from content blocks
|
||||
return extractSearchResults(allContentBlocks)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Exa AI-based search adapter — uses MCP protocol to call Exa's web search API.
|
||||
*
|
||||
* Ported from kilocode's production-validated implementation (mcp-exa.ts + websearch.ts).
|
||||
* Key improvements over previous version:
|
||||
* - Passes through numResults/livecrawl/type/contextMaxCharacters from options
|
||||
* - Cleaner SSE parsing matching kilocode's approach
|
||||
* - Proper content snippet extraction from Exa responses
|
||||
*/
|
||||
|
||||
import axios from 'axios'
|
||||
import { AbortError } from 'src/utils/errors.js'
|
||||
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
|
||||
|
||||
const EXA_MCP_URL = 'https://mcp.exa.ai/mcp'
|
||||
const FETCH_TIMEOUT_MS = 25_000
|
||||
|
||||
export class ExaSearchAdapter implements WebSearchAdapter {
|
||||
async search(
|
||||
query: string,
|
||||
options: SearchOptions,
|
||||
): Promise<SearchResult[]> {
|
||||
const { signal, onProgress, allowedDomains, blockedDomains } = options
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
|
||||
onProgress?.({ type: 'query_update', query })
|
||||
|
||||
const abortController = new AbortController()
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => abortController.abort(), { once: true })
|
||||
}
|
||||
|
||||
// Use options to derive search params — matches kilocode websearch.ts defaults
|
||||
const numResults = options.numResults ?? 8
|
||||
const livecrawl = options.livecrawl ?? 'fallback'
|
||||
const searchType = options.searchType ?? 'auto'
|
||||
const contextMaxCharacters = options.contextMaxCharacters ?? 10000
|
||||
|
||||
let responseText: string
|
||||
try {
|
||||
const response = await axios.post(
|
||||
EXA_MCP_URL,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'web_search_exa',
|
||||
arguments: {
|
||||
query,
|
||||
type: searchType,
|
||||
numResults,
|
||||
livecrawl,
|
||||
contextMaxCharacters,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
signal: abortController.signal,
|
||||
timeout: FETCH_TIMEOUT_MS,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
},
|
||||
responseType: 'text',
|
||||
},
|
||||
)
|
||||
responseText = response.data as string
|
||||
} catch (e) {
|
||||
if (axios.isCancel(e) || abortController.signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
|
||||
const searchText = this.parseSse(responseText)
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
|
||||
// Parse the Exa results from the text response
|
||||
const results = this.parseResults(searchText)
|
||||
|
||||
// Client-side domain filtering
|
||||
const filteredResults = results.filter((r) => {
|
||||
if (!r.url) return false
|
||||
try {
|
||||
const hostname = new URL(r.url).hostname
|
||||
if (allowedDomains?.length && !allowedDomains.some(d => hostname === d || hostname.endsWith('.' + d))) {
|
||||
return false
|
||||
}
|
||||
if (blockedDomains?.length && blockedDomains.some(d => hostname === d || hostname.endsWith('.' + d))) {
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
onProgress?.({
|
||||
type: 'search_results_received',
|
||||
resultCount: filteredResults.length,
|
||||
query,
|
||||
})
|
||||
|
||||
return filteredResults
|
||||
}
|
||||
|
||||
private parseSse(body: string): string | undefined {
|
||||
// SSE format: lines starting with "data: " containing JSON
|
||||
// Matches kilocode mcp-exa.ts parseSse implementation
|
||||
for (const line of body.split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const data = line.substring(6).trim()
|
||||
if (!data || data === '[DONE]' || data === 'null') continue
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
const content = parsed?.result?.content
|
||||
if (Array.isArray(content) && content[0]?.text) {
|
||||
return content[0].text
|
||||
}
|
||||
} catch {
|
||||
// Continue to next line
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try parsing as direct JSON response (non-SSE)
|
||||
try {
|
||||
const parsed = JSON.parse(body)
|
||||
const content = parsed?.result?.content
|
||||
if (Array.isArray(content) && content[0]?.text) {
|
||||
return content[0].text
|
||||
}
|
||||
} catch {
|
||||
// Not JSON
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private parseResults(text: string | undefined): SearchResult[] {
|
||||
if (!text) return []
|
||||
|
||||
const results: SearchResult[] = []
|
||||
|
||||
// Exa returns structured text with "Title:", "URL:", and "Content:" fields
|
||||
// separated by "---" between entries
|
||||
const blocks = text.split(/\n---\n/g)
|
||||
|
||||
for (const block of blocks) {
|
||||
const titleMatch = block.match(/^Title:\s*(.+)$/m)
|
||||
const urlMatch = block.match(/^URL:\s*(https?:\/\/[^\s]+)$/m)
|
||||
const contentMatch = block.match(/^Content:\s*([\s\S]+?)(?=\n(?:Title:|URL:|---)|$)/m)
|
||||
|
||||
if (urlMatch) {
|
||||
results.push({
|
||||
title: titleMatch?.[1]?.trim() ?? urlMatch[1],
|
||||
url: urlMatch[1].trim(),
|
||||
snippet: contentMatch?.[1]?.trim().slice(0, 300),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: markdown links
|
||||
if (results.length === 0) {
|
||||
const markdownLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = markdownLinkRegex.exec(text)) !== null) {
|
||||
results.push({
|
||||
title: match[1].trim(),
|
||||
url: match[2].trim(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: plain URLs
|
||||
if (results.length === 0) {
|
||||
const urlRegex = /^https?:\/\/[^\s<>"\]]+/gm
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = urlRegex.exec(text)) !== null) {
|
||||
results.push({
|
||||
title: match[0],
|
||||
url: match[0],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { isFirstPartyAnthropicBaseUrl } from 'src/utils/model/providers.js'
|
||||
import { ApiSearchAdapter } from './apiAdapter.js'
|
||||
import { BingSearchAdapter } from './bingAdapter.js'
|
||||
import { BraveSearchAdapter } from './braveAdapter.js'
|
||||
import { ExaSearchAdapter } from './exaAdapter.js'
|
||||
import type { WebSearchAdapter } from './types.js'
|
||||
|
||||
export type {
|
||||
@@ -16,17 +17,37 @@ export type {
|
||||
WebSearchAdapter,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Check if the current session uses a third-party (non-Anthropic) API provider.
|
||||
* These providers don't support Anthropic's server_tools (server-side web search),
|
||||
* so they must fall back to the Bing scraper adapter.
|
||||
*/
|
||||
function isThirdPartyProvider(): boolean {
|
||||
return !!(
|
||||
process.env.CLAUDE_CODE_USE_OPENAI ||
|
||||
process.env.CLAUDE_CODE_USE_GEMINI ||
|
||||
process.env.CLAUDE_CODE_USE_GROK
|
||||
)
|
||||
}
|
||||
|
||||
let cachedAdapter: WebSearchAdapter | null = null
|
||||
let cachedAdapterKey: 'api' | 'bing' | 'brave' | null = null
|
||||
let cachedAdapterKey: 'api' | 'bing' | 'brave' | 'exa' | null = null
|
||||
|
||||
export function createAdapter(): WebSearchAdapter {
|
||||
const envAdapter = process.env.WEB_SEARCH_ADAPTER
|
||||
// Priority:
|
||||
// 1. Explicit env override (WEB_SEARCH_ADAPTER=api|bing|brave)
|
||||
// 2. Third-party provider (OpenAI/Gemini/Grok) → bing (no server_tools support)
|
||||
// 3. First-party Anthropic API → api (server-side web search + connector_text)
|
||||
// 4. Fallback → bing
|
||||
const adapterKey =
|
||||
envAdapter === 'api' || envAdapter === 'bing' || envAdapter === 'brave'
|
||||
envAdapter === 'api' || envAdapter === 'bing' || envAdapter === 'brave' || envAdapter === 'exa'
|
||||
? envAdapter
|
||||
: isFirstPartyAnthropicBaseUrl()
|
||||
? 'api'
|
||||
: 'bing'
|
||||
: isThirdPartyProvider()
|
||||
? 'bing'
|
||||
: isFirstPartyAnthropicBaseUrl()
|
||||
? 'api'
|
||||
: 'exa'
|
||||
|
||||
if (cachedAdapter && cachedAdapterKey === adapterKey) return cachedAdapter
|
||||
|
||||
@@ -36,9 +57,14 @@ export function createAdapter(): WebSearchAdapter {
|
||||
return cachedAdapter
|
||||
}
|
||||
if (adapterKey === 'brave') {
|
||||
cachedAdapter = new BraveSearchAdapter()
|
||||
cachedAdapterKey = 'brave'
|
||||
return cachedAdapter
|
||||
cachedAdapter = new BraveSearchAdapter()
|
||||
cachedAdapterKey = 'brave'
|
||||
return cachedAdapter
|
||||
}
|
||||
if (adapterKey === 'exa') {
|
||||
cachedAdapter = new ExaSearchAdapter()
|
||||
cachedAdapterKey = 'exa'
|
||||
return cachedAdapter
|
||||
}
|
||||
|
||||
cachedAdapter = new BingSearchAdapter()
|
||||
|
||||
@@ -9,6 +9,14 @@ export interface SearchOptions {
|
||||
blockedDomains?: string[]
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: SearchProgress) => void
|
||||
/** Number of search results to return (default: 8) */
|
||||
numResults?: number
|
||||
/** Live crawl mode (default: 'fallback') */
|
||||
livecrawl?: 'fallback' | 'preferred'
|
||||
/** Search type (default: 'auto') */
|
||||
searchType?: 'auto' | 'fast' | 'deep'
|
||||
/** Maximum characters for context string (default: 10000) */
|
||||
contextMaxCharacters?: number
|
||||
}
|
||||
|
||||
export interface SearchProgress {
|
||||
|
||||
@@ -1,18 +1,358 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { mkdir, readdir, readFile, writeFile } from 'fs/promises'
|
||||
import { join, parse } from 'path'
|
||||
import { z } from 'zod/v4'
|
||||
import type { ToolResultBlockParam } from 'src/Tool.js'
|
||||
import { buildTool } from 'src/Tool.js'
|
||||
import { truncate } from 'src/utils/format.js'
|
||||
import { WORKFLOW_TOOL_NAME } from './constants.js'
|
||||
import { safeParseJSON } from 'src/utils/json.js'
|
||||
import {
|
||||
WORKFLOW_DIR_NAME,
|
||||
WORKFLOW_FILE_EXTENSIONS,
|
||||
WORKFLOW_TOOL_NAME,
|
||||
} from './constants.js'
|
||||
|
||||
const WORKFLOW_RUNS_DIR = '.claude/workflow-runs'
|
||||
|
||||
const inputSchema = z.object({
|
||||
workflow: z.string().describe('Name of the workflow to execute'),
|
||||
args: z.string().optional().describe('Arguments to pass to the workflow'),
|
||||
action: z
|
||||
.enum(['start', 'status', 'advance', 'cancel', 'list'])
|
||||
.optional()
|
||||
.describe('Workflow action. Defaults to start.'),
|
||||
run_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Workflow run id for status, advance, or cancel.'),
|
||||
})
|
||||
type Input = typeof inputSchema
|
||||
type WorkflowInput = z.infer<Input>
|
||||
|
||||
type WorkflowStepStatus = 'pending' | 'running' | 'completed' | 'cancelled'
|
||||
|
||||
type WorkflowStep = {
|
||||
name: string
|
||||
prompt: string
|
||||
status: WorkflowStepStatus
|
||||
startedAt?: number
|
||||
completedAt?: number
|
||||
}
|
||||
|
||||
type WorkflowRun = {
|
||||
runId: string
|
||||
workflow: string
|
||||
args?: string
|
||||
status: 'running' | 'completed' | 'cancelled'
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
currentStepIndex: number
|
||||
steps: WorkflowStep[]
|
||||
}
|
||||
|
||||
type WorkflowOutput = { output: string }
|
||||
|
||||
async function findWorkflowFile(
|
||||
workflowDir: string,
|
||||
workflow: string,
|
||||
): Promise<{ path: string; content: string } | null> {
|
||||
for (const ext of WORKFLOW_FILE_EXTENSIONS) {
|
||||
const path = join(workflowDir, `${workflow}${ext}`)
|
||||
try {
|
||||
return { path, content: await readFile(path, 'utf-8') }
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function listAvailableWorkflows(workflowDir: string): Promise<string[]> {
|
||||
try {
|
||||
const files = await readdir(workflowDir)
|
||||
return files
|
||||
.filter(f => WORKFLOW_FILE_EXTENSIONS.includes(parse(f).ext.toLowerCase()))
|
||||
.map(f => parse(f).name)
|
||||
.sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function workflowRunPath(cwd: string, runId: string): string {
|
||||
return join(cwd, WORKFLOW_RUNS_DIR, `${runId}.json`)
|
||||
}
|
||||
|
||||
async function readWorkflowRun(
|
||||
cwd: string,
|
||||
runId: string,
|
||||
): Promise<WorkflowRun | null> {
|
||||
try {
|
||||
const parsed = safeParseJSON(
|
||||
await readFile(workflowRunPath(cwd, runId), 'utf-8'),
|
||||
false,
|
||||
) as Partial<WorkflowRun> | null
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed.runId !== 'string' ||
|
||||
typeof parsed.workflow !== 'string' ||
|
||||
!Array.isArray(parsed.steps)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return parsed as WorkflowRun
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function writeWorkflowRun(cwd: string, run: WorkflowRun): Promise<void> {
|
||||
await mkdir(join(cwd, WORKFLOW_RUNS_DIR), { recursive: true })
|
||||
await writeFile(
|
||||
workflowRunPath(cwd, run.runId),
|
||||
JSON.stringify(run, null, 2) + '\n',
|
||||
'utf-8',
|
||||
)
|
||||
}
|
||||
|
||||
async function listWorkflowRuns(cwd: string): Promise<WorkflowRun[]> {
|
||||
let files: string[]
|
||||
try {
|
||||
files = await readdir(join(cwd, WORKFLOW_RUNS_DIR))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const runs = await Promise.all(
|
||||
files
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.map(f => readWorkflowRun(cwd, f.slice(0, -'.json'.length))),
|
||||
)
|
||||
return runs
|
||||
.filter((run): run is WorkflowRun => run !== null)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
}
|
||||
|
||||
function parseMarkdownSteps(content: string): WorkflowStep[] {
|
||||
const steps: WorkflowStep[] = []
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
const taskMatch = line.match(/^[-*]\s+\[[ xX]\]\s+(.+)$/)
|
||||
const bulletMatch = line.match(/^[-*]\s+(.+)$/)
|
||||
const numberedMatch = line.match(/^\d+[.)]\s+(.+)$/)
|
||||
const text = taskMatch?.[1] ?? bulletMatch?.[1] ?? numberedMatch?.[1]
|
||||
if (!text) continue
|
||||
steps.push({ name: text.slice(0, 80), prompt: text, status: 'pending' })
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
function parseYamlSteps(content: string): WorkflowStep[] {
|
||||
const steps: WorkflowStep[] = []
|
||||
let current: Partial<WorkflowStep> | null = null
|
||||
const flush = () => {
|
||||
if (!current) return
|
||||
const prompt = current.prompt ?? current.name
|
||||
if (current.name && prompt) {
|
||||
steps.push({
|
||||
name: current.name,
|
||||
prompt,
|
||||
status: 'pending',
|
||||
})
|
||||
}
|
||||
current = null
|
||||
}
|
||||
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
const stepText = line.match(/^-\s+(.+)$/)?.[1]
|
||||
if (stepText) {
|
||||
flush()
|
||||
const inlineName = stepText.match(/^name:\s*(.+)$/)?.[1]
|
||||
current = {
|
||||
name: inlineName ?? stepText,
|
||||
prompt: inlineName ? undefined : stepText,
|
||||
}
|
||||
continue
|
||||
}
|
||||
const name = line.match(/^name:\s*(.+)$/)?.[1]
|
||||
if (name) {
|
||||
if (!current) current = {}
|
||||
current.name = name
|
||||
continue
|
||||
}
|
||||
const prompt = line.match(/^(prompt|run|command):\s*(.+)$/)?.[2]
|
||||
if (prompt) {
|
||||
if (!current) current = {}
|
||||
current.prompt = prompt
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return steps
|
||||
}
|
||||
|
||||
function parseWorkflowSteps(filePath: string, content: string): WorkflowStep[] {
|
||||
const ext = parse(filePath).ext.toLowerCase()
|
||||
const steps =
|
||||
ext === '.md' ? parseMarkdownSteps(content) : parseYamlSteps(content)
|
||||
if (steps.length > 0) {
|
||||
return steps
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: 'Execute workflow',
|
||||
prompt: content.trim(),
|
||||
status: 'pending',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function formatStep(step: WorkflowStep, index: number): string {
|
||||
return `Step ${index + 1}: ${step.name}\n${step.prompt}`
|
||||
}
|
||||
|
||||
function formatRunStatus(run: WorkflowRun): string {
|
||||
const lines = [
|
||||
`Workflow run: ${run.runId}`,
|
||||
`Workflow: ${run.workflow}`,
|
||||
`Status: ${run.status}`,
|
||||
`Current step: ${run.steps[run.currentStepIndex]?.name ?? 'none'}`,
|
||||
`Steps: ${run.steps.length}`,
|
||||
]
|
||||
for (let i = 0; i < run.steps.length; i += 1) {
|
||||
const step = run.steps[i]!
|
||||
lines.push(` ${i + 1}. [${step.status}] ${step.name}`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
async function startWorkflow(
|
||||
input: WorkflowInput,
|
||||
cwd: string,
|
||||
): Promise<WorkflowOutput> {
|
||||
const workflowDir = join(cwd, WORKFLOW_DIR_NAME)
|
||||
const found = await findWorkflowFile(workflowDir, input.workflow)
|
||||
if (!found) {
|
||||
const available = await listAvailableWorkflows(workflowDir)
|
||||
const hint =
|
||||
available.length > 0
|
||||
? `\nAvailable workflows: ${available.join(', ')}`
|
||||
: `\nNo workflows found in ${WORKFLOW_DIR_NAME}/. Create .md or .yaml files there.`
|
||||
return { output: `Error: Workflow "${input.workflow}" not found.${hint}` }
|
||||
}
|
||||
|
||||
const steps = parseWorkflowSteps(found.path, found.content)
|
||||
const now = Date.now()
|
||||
steps[0] = { ...steps[0]!, status: 'running', startedAt: now }
|
||||
const run: WorkflowRun = {
|
||||
runId: randomUUID(),
|
||||
workflow: input.workflow,
|
||||
...(input.args ? { args: input.args } : {}),
|
||||
status: 'running',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
currentStepIndex: 0,
|
||||
steps,
|
||||
}
|
||||
await writeWorkflowRun(cwd, run)
|
||||
|
||||
const argsSection = input.args ? `\n\nArguments:\n${input.args}` : ''
|
||||
return {
|
||||
output: [
|
||||
`Workflow run started`,
|
||||
`run_id: ${run.runId}`,
|
||||
`workflow: ${run.workflow}`,
|
||||
'',
|
||||
formatStep(steps[0]!, 0),
|
||||
argsSection,
|
||||
'',
|
||||
`When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`,
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
async function getRunOrError(
|
||||
cwd: string,
|
||||
runId: string | undefined,
|
||||
): Promise<{ run?: WorkflowRun; output?: string }> {
|
||||
if (!runId) return { output: 'Error: run_id is required for this action.' }
|
||||
const run = await readWorkflowRun(cwd, runId)
|
||||
if (!run) return { output: `Error: Workflow run "${runId}" not found.` }
|
||||
return { run }
|
||||
}
|
||||
|
||||
async function advanceWorkflow(
|
||||
cwd: string,
|
||||
runId: string | undefined,
|
||||
): Promise<WorkflowOutput> {
|
||||
const found = await getRunOrError(cwd, runId)
|
||||
if (!found.run) return { output: found.output! }
|
||||
const run = found.run
|
||||
const now = Date.now()
|
||||
const current = run.steps[run.currentStepIndex]
|
||||
if (current && current.status === 'running') {
|
||||
current.status = 'completed'
|
||||
current.completedAt = now
|
||||
}
|
||||
const nextIndex = run.currentStepIndex + 1
|
||||
if (nextIndex >= run.steps.length) {
|
||||
run.status = 'completed'
|
||||
run.updatedAt = now
|
||||
await writeWorkflowRun(cwd, run)
|
||||
return { output: `Workflow completed\nrun_id: ${run.runId}` }
|
||||
}
|
||||
run.currentStepIndex = nextIndex
|
||||
run.steps[nextIndex] = {
|
||||
...run.steps[nextIndex]!,
|
||||
status: 'running',
|
||||
startedAt: now,
|
||||
}
|
||||
run.updatedAt = now
|
||||
await writeWorkflowRun(cwd, run)
|
||||
return {
|
||||
output: [
|
||||
`Next workflow step`,
|
||||
`run_id: ${run.runId}`,
|
||||
'',
|
||||
formatStep(run.steps[nextIndex]!, nextIndex),
|
||||
'',
|
||||
`When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`,
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelWorkflow(
|
||||
cwd: string,
|
||||
runId: string | undefined,
|
||||
): Promise<WorkflowOutput> {
|
||||
const found = await getRunOrError(cwd, runId)
|
||||
if (!found.run) return { output: found.output! }
|
||||
const run = found.run
|
||||
const now = Date.now()
|
||||
run.status = 'cancelled'
|
||||
run.updatedAt = now
|
||||
for (const step of run.steps) {
|
||||
if (step.status === 'pending' || step.status === 'running') {
|
||||
step.status = 'cancelled'
|
||||
}
|
||||
}
|
||||
await writeWorkflowRun(cwd, run)
|
||||
return { output: `Workflow cancelled\nrun_id: ${run.runId}` }
|
||||
}
|
||||
|
||||
async function listWorkflowRunsForOutput(cwd: string): Promise<WorkflowOutput> {
|
||||
const runs = await listWorkflowRuns(cwd)
|
||||
if (runs.length === 0) return { output: 'No workflow runs recorded.' }
|
||||
return {
|
||||
output: runs
|
||||
.slice(0, 20)
|
||||
.map(
|
||||
run =>
|
||||
`${run.runId} | ${run.workflow} | ${run.status} | step=${run.steps[run.currentStepIndex]?.name ?? 'none'} | updated=${new Date(run.updatedAt).toLocaleString()}`,
|
||||
)
|
||||
.join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
export const WorkflowTool = buildTool({
|
||||
name: WORKFLOW_TOOL_NAME,
|
||||
searchHint: 'execute user-defined workflow scripts',
|
||||
@@ -22,21 +362,25 @@ export const WorkflowTool = buildTool({
|
||||
inputSchema,
|
||||
|
||||
async description() {
|
||||
return 'Execute a user-defined workflow script from .claude/workflows/'
|
||||
return 'Execute and track a user-defined workflow from .claude/workflows/'
|
||||
},
|
||||
async prompt() {
|
||||
return `Use the Workflow tool to execute user-defined workflow scripts located in .claude/workflows/. Workflows are YAML or Markdown files that define a sequence of steps for common development tasks.
|
||||
return `Use the Workflow tool to run user-defined workflows located in .claude/workflows/. Workflows may be Markdown checklists/lists or YAML files with steps.
|
||||
|
||||
Guidelines:
|
||||
- Specify the workflow name to execute (must match a file in .claude/workflows/)
|
||||
- Optionally pass arguments that the workflow can use
|
||||
- Workflows run in the context of the current project`
|
||||
Actions:
|
||||
- start (default): create a persisted workflow run and return the first step to execute
|
||||
- advance: mark the current step complete and return the next step
|
||||
- status: inspect a workflow run by run_id
|
||||
- cancel: cancel a workflow run
|
||||
- list: list recent workflow runs
|
||||
|
||||
Workflow run state is persisted in .claude/workflow-runs/.`
|
||||
},
|
||||
userFacingName() {
|
||||
return 'Workflow'
|
||||
},
|
||||
isReadOnly() {
|
||||
return false
|
||||
isReadOnly(input) {
|
||||
return input.action === 'status' || input.action === 'list'
|
||||
},
|
||||
isEnabled() {
|
||||
return true
|
||||
@@ -44,10 +388,10 @@ Guidelines:
|
||||
|
||||
renderToolUseMessage(input: Partial<WorkflowInput>) {
|
||||
const name = input.workflow ?? 'unknown'
|
||||
if (input.args) {
|
||||
return `Workflow: ${name} ${input.args}`
|
||||
}
|
||||
return `Workflow: ${name}`
|
||||
const action = input.action ?? 'start'
|
||||
return input.args
|
||||
? `Workflow: ${action} ${name} ${input.args}`
|
||||
: `Workflow: ${action} ${name}`
|
||||
},
|
||||
|
||||
mapToolResultToToolResultBlockParam(
|
||||
@@ -61,14 +405,26 @@ Guidelines:
|
||||
}
|
||||
},
|
||||
|
||||
async call(_input: WorkflowInput, _context, _progress) {
|
||||
// Workflow execution is wired by the WORKFLOW_SCRIPTS feature bootstrap.
|
||||
// Without it, this tool is not functional.
|
||||
return {
|
||||
data: {
|
||||
output:
|
||||
'Error: Workflow execution requires the WORKFLOW_SCRIPTS runtime.',
|
||||
},
|
||||
async call(input: WorkflowInput) {
|
||||
const cwd = process.cwd()
|
||||
const action = input.action ?? 'start'
|
||||
switch (action) {
|
||||
case 'start':
|
||||
return { data: await startWorkflow(input, cwd) }
|
||||
case 'status': {
|
||||
const found = await getRunOrError(cwd, input.run_id)
|
||||
return {
|
||||
data: {
|
||||
output: found.run ? formatRunStatus(found.run) : found.output!,
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'advance':
|
||||
return { data: await advanceWorkflow(cwd, input.run_id) }
|
||||
case 'cancel':
|
||||
return { data: await cancelWorkflow(cwd, input.run_id) }
|
||||
case 'list':
|
||||
return { data: await listWorkflowRunsForOutput(cwd) }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { WorkflowTool } from '../WorkflowTool'
|
||||
|
||||
let cwd: string
|
||||
let previousCwd: string
|
||||
|
||||
beforeEach(async () => {
|
||||
previousCwd = process.cwd()
|
||||
cwd = join(tmpdir(), `workflow-tool-${Date.now()}-${Math.random().toString(16).slice(2)}`)
|
||||
await mkdir(join(cwd, '.claude', 'workflows'), { recursive: true })
|
||||
process.chdir(cwd)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
process.chdir(previousCwd)
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('WorkflowTool', () => {
|
||||
test('starts a workflow run and persists step state', async () => {
|
||||
await writeFile(
|
||||
join(cwd, '.claude', 'workflows', 'release.md'),
|
||||
[
|
||||
'# Release',
|
||||
'',
|
||||
'- [ ] Run tests',
|
||||
'- [ ] Build package',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const result = await WorkflowTool.call({ workflow: 'release' })
|
||||
|
||||
expect(result.data.output).toContain('Workflow run started')
|
||||
expect(result.data.output).toContain('Run tests')
|
||||
const match = result.data.output.match(/run_id: ([a-f0-9-]+)/)
|
||||
expect(match?.[1]).toBeString()
|
||||
|
||||
const raw = await readFile(
|
||||
join(cwd, '.claude', 'workflow-runs', `${match![1]}.json`),
|
||||
'utf-8',
|
||||
)
|
||||
const run = JSON.parse(raw)
|
||||
expect(run.workflow).toBe('release')
|
||||
expect(run.status).toBe('running')
|
||||
expect(run.steps).toHaveLength(2)
|
||||
expect(run.steps[0].status).toBe('running')
|
||||
expect(run.steps[1].status).toBe('pending')
|
||||
})
|
||||
|
||||
test('advances a workflow run through completion', async () => {
|
||||
await writeFile(
|
||||
join(cwd, '.claude', 'workflows', 'audit.yaml'),
|
||||
[
|
||||
'steps:',
|
||||
' - name: Inspect',
|
||||
' prompt: Inspect the code',
|
||||
' - name: Verify',
|
||||
' prompt: Run focused tests',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const started = await WorkflowTool.call({ workflow: 'audit' })
|
||||
const runId = started.data.output.match(/run_id: ([a-f0-9-]+)/)![1]!
|
||||
|
||||
const next = await WorkflowTool.call(
|
||||
{ workflow: 'audit', action: 'advance', run_id: runId },
|
||||
)
|
||||
expect(next.data.output).toContain('Next workflow step')
|
||||
expect(next.data.output).toContain('Run focused tests')
|
||||
|
||||
const done = await WorkflowTool.call(
|
||||
{ workflow: 'audit', action: 'advance', run_id: runId },
|
||||
)
|
||||
expect(done.data.output).toContain('Workflow completed')
|
||||
})
|
||||
|
||||
test('lists and cancels workflow runs', async () => {
|
||||
await writeFile(
|
||||
join(cwd, '.claude', 'workflows', 'cleanup.md'),
|
||||
'- Remove stale files',
|
||||
)
|
||||
|
||||
const started = await WorkflowTool.call({ workflow: 'cleanup' })
|
||||
const runId = started.data.output.match(/run_id: ([a-f0-9-]+)/)![1]!
|
||||
|
||||
const listed = await WorkflowTool.call(
|
||||
{ workflow: 'cleanup', action: 'list' },
|
||||
)
|
||||
expect(listed.data.output).toContain(runId)
|
||||
|
||||
const cancelled = await WorkflowTool.call(
|
||||
{ workflow: 'cleanup', action: 'cancel', run_id: runId },
|
||||
)
|
||||
expect(cancelled.data.output).toContain('Workflow cancelled')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnTeammate } from '../spawnMultiAgent'
|
||||
|
||||
let tempHome: string
|
||||
let previousConfigDir: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
tempHome = join(tmpdir(), `spawn-multi-agent-${Date.now()}-${Math.random().toString(16).slice(2)}`)
|
||||
process.env.CLAUDE_CONFIG_DIR = tempHome
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (previousConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
||||
}
|
||||
rmSync(tempHome, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('spawnTeammate', () => {
|
||||
test('fails before spawn side effects when the team file is missing', async () => {
|
||||
let setAppStateCalled = false
|
||||
const context = {
|
||||
getAppState: () => ({
|
||||
teamContext: undefined,
|
||||
}),
|
||||
setAppState: () => {
|
||||
setAppStateCalled = true
|
||||
},
|
||||
options: {
|
||||
agentDefinitions: {
|
||||
activeAgents: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
await expect(
|
||||
spawnTeammate(
|
||||
{
|
||||
name: 'worker',
|
||||
prompt: 'do work',
|
||||
team_name: 'missing-team',
|
||||
},
|
||||
context as any,
|
||||
),
|
||||
).rejects.toThrow('Team "missing-team" does not exist')
|
||||
expect(setAppStateCalled).toBe(false)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user