diff --git a/.gitignore b/.gitignore index 3c554e5..9eeb3ea 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ browser_state/ .tasks/ .transcripts/ workspace/ +sessions/ +sessions.json.bak # Vue node_modules/ frontend/node_modules/ diff --git a/RAG_KNOWLEDGE_DEV_PLAN.md b/RAG_KNOWLEDGE_DEV_PLAN.md new file mode 100644 index 0000000..f2d74f0 --- /dev/null +++ b/RAG_KNOWLEDGE_DEV_PLAN.md @@ -0,0 +1,308 @@ +# NeoFish 知识库(文件夹粒度)RAG 开发方案 + +## 1. 目标与范围 + +本方案用于 NeoFish 的“知识库”能力建设,目标是: + +- 以**文件夹**为最小管理粒度(不做文件级勾选); +- 支持文件夹创建、文件上传、文件浏览、文件删除; +- 支持勾选文件夹进入检索范围(selected folders); +- 保证“向量索引中的有效内容”始终与用户勾选状态一致; +- 前端采用“进入文件夹工作区”的交互(类似文件管理器)。 + +非目标: + +- 暂不做文件级权限系统; +- 暂不做复杂增量索引(首版采用文件夹全量重建); +- 暂不接入多租户。 + +--- + +## 2. 核心设计原则 + +### 2.1 单一真相源 + +`selected_folders` 是唯一真相(source of truth)。 +向量库只是副本,必须被“同步器/对账器”收敛到该真相。 + +### 2.2 声明式同步 + +用户操作只改变“期望状态”,后台任务负责把实际状态对齐。 + +- `to_add = selected - indexed` +- `to_remove = indexed - selected` +- `to_rebuild = selected ∩ dirty` + +执行顺序建议: + +1. 先 `rebuild/add`; +2. 再 `remove`; +3. 更新 `index_manifest` 与 `dirty` 标记。 + +### 2.3 文件夹粒度索引 + +- 选中文件夹:对该文件夹全量扫描、切块、嵌入、写索引; +- 取消勾选:按 `folder_id` 清空索引分区; +- 文件增删:若文件夹已选中,则标记 `dirty` 并触发该文件夹全量重建。 + +--- + +## 3. 代码结构建议 + +## 3.1 后端新增模块 + +- `knowledge_service.py` + 负责文件夹/文件 CRUD、元数据统计、路径安全校验。 + +- `knowledge_state.py` + 负责状态持久化(selected/indexed/dirty/index_status)。 + +- `knowledge_indexer.py` + 负责切块、embedding、索引构建、按 folder 检索与清理。 + +- `embedder.py` + 抽象 embedding 提供方(首版可固定 MiniMax,后续可替换)。 + +## 3.2 现有文件改造点 + +- `main.py` + 增加知识库 REST API 路由。 + +- `agent.py`(第二阶段) + 增加 `knowledge_*` 工具(列表、勾选、取消、检索)。 + +--- + +## 4. 存储与目录规范 + +建议落盘结构: + +- `WORKDIR/knowledge//`:原始知识文件 +- `WORKDIR/.knowledge/state.json`:selected / indexed / dirty / status +- `WORKDIR/.knowledge/file_index.json`:文件元数据(hash、mtime、size) +- `WORKDIR/.knowledge/vector_snapshot/`:本地索引快照(可选) + +关键字段建议: + +- `folder_id`:安全 slug(显示名和 id 分离) +- `file_id`:建议 `sha1(folder_id + relative_path)` +- `index_status`:`ready | indexing | failed` +- `generation`:索引代次(可选,用于原子切换) + +--- + +## 5. API 设计(首版) + +### 5.1 文件夹管理 + +- `GET /knowledge/folders` + 返回: + - `id` + - `name` + - `path` + - `file_count` + - `size_label` + - `updated_at` + +- `POST /knowledge/folders` + 请求:`{ "name": "xxx" }` + 行为:创建文件夹,返回新对象。 + +### 5.2 勾选状态管理 + +- `GET /knowledge/selected` + 返回:`{ "selected_folder_ids": ["..."] }` + +- `POST /knowledge/select` + 请求:`{ "folder_id": "xxx" }` + 行为:加入 selected,触发该 folder 重建任务(幂等)。 + +- `POST /knowledge/deselect` + 请求:`{ "folder_id": "xxx" }` + 行为:从 selected 移除,删除该 folder 索引(幂等)。 + +### 5.3 文件管理 + +- `POST /knowledge/upload`(multipart) + - 字段:`folder_id` + `files[]` + - 行为:保存文件;若 folder 已选中,标记 dirty 并触发重建。 + +- `GET /knowledge/folders/{folder_id}/files` + 返回: + - `id` + - `name` + - `mime_type` + - `size_label` + - `updated_at` + - `preview_url`(可选) + +- `DELETE /knowledge/files/{file_id}` + 行为:删除文件;若所属 folder 已选中,标记 dirty 并触发重建。 + +### 5.4 可选状态接口 + +- `GET /knowledge/status` + 返回每个 folder 的索引状态,用于前端提示 `indexing/failed/ready`。 + +--- + +## 6. 并发、幂等与容错 + +### 6.1 并发控制 + +- 每个 `folder_id` 一把 `asyncio.Lock`; +- 防止上传与重建并发导致元数据不一致。 + +### 6.2 幂等语义 + +- 已选再选:返回成功(不重复入库); +- 未选取消:返回成功(不报错); +- 重复删除文件:返回“已不存在”可视为成功。 + +### 6.3 失败恢复 + +- 重建失败:`index_status=failed`,保留 dirty 标志; +- 定时对账任务再次尝试; +- API 层不因后台失败阻塞用户主流程。 + +--- + +## 7. 索引与检索策略(首版) + +### 7.1 构建 + +1. 扫描 `folder_id` 下所有可索引文件; +2. 文本提取与切块; +3. embedding; +4. 写入索引分区(按 `folder_id` 标记); +5. 更新 `index_manifest` 和状态。 + +### 7.1.1 切片(Chunking)策略 + +首版采用“**段落优先 + 长度兜底 + 重叠补边界**”策略,不做整文件向量化。 + +- 入库单位:`chunk`(不是整个文件) +- 推荐 chunk 大小:`500 ~ 900 tokens` +- 推荐 overlap:`10% ~ 20%`(建议 `80 ~ 150 tokens`) +- 分片优先级: + 1. 先按标题/段落边界切(保证语义完整); + 2. 超长段落再按长度强制切分; + 3. 切分后在相邻 chunk 之间加入 overlap。 + +建议的 chunk 元数据字段: + +- `folder_id` +- `file_id` +- `chunk_id` +- `source_path` +- `chunk_index` +- `start_offset` / `end_offset`(可选) + +为什么不用“整文件直接入库”: + +- 语义粒度过粗,召回精度低; +- 长文容易超 embedding 上下文限制或被截断; +- 召回后单条上下文过大,导致成本升高且噪音增加。 + +--- + +### 7.1.2 切片参数调优方法(上线前) + +建议用离线评测集做 A/B: + +- 方案 A:`chunk=512, overlap=64` +- 方案 B:`chunk=768, overlap=96` +- 方案 C:`chunk=900, overlap=128` + +评测指标建议: + +- `Recall@k`(是否召回正确片段) +- `MRR`(正确片段排序) +- 单次查询上下文 token 成本 +- 最终回答正确率(人工抽样) + +首版默认推荐: + +- `chunk=768 tokens` +- `overlap=96 tokens` + +若文档短小且结构化明显,可适当减小 chunk。 +若文档偏长且跨段依赖强,可适当增大 overlap。 + +### 7.2 查询 + +- 查询时只在 `selected_folders` 对应分区检索; +- 返回 top-k 片段给 Agent; +- 返回项附带 `folder_id/file_id/chunk_id` 便于追踪。 + +### 7.3 清理 + +- 取消勾选时按 `folder_id` 批量清理索引; +- 定时“垃圾回收”删除非 selected 的残留分区。 + +--- + +## 8. 前后端联调约定 + +前端目前已具备以下交互路径: + +- 左侧“知识库”入口; +- 文件夹列表; +- 点击文件夹进入工作区(文件网格); +- 工作区内上传、删除; +- 勾选文件夹是否参与检索。 + +后端接口实现时需保证字段名与前端一致: + +- `selected_folder_ids` +- `file_count` +- `size_label` +- `updated_at` +- `mime_type` +- `preview_url`(可空) + +--- + +## 9. 落地顺序(建议) + +### 阶段 1:接口与文件管理打通 + +1. 实现 `folders/selected/upload/files/delete` API; +2. 本地状态落盘(state + file_index); +3. 前端从 demo 切真实接口。 + +### 阶段 2:索引与同步器 + +1. 实现文件夹全量重建; +2. 接入 select/deselect 流程; +3. 增加 dirty 对账任务。 + +### 阶段 3:Agent 集成 + +1. 在 `agent.py` 增加 `knowledge_search` 工具; +2. 仅查询 selected folders; +3. 在回答中引用来源片段(可选)。 + +--- + +## 10. 纠错清单(上线前必查) + +- [ ] `folder_id` 是否安全规范化(防路径穿越) +- [ ] 文件上传是否限制类型/大小 +- [ ] 幂等行为是否一致(select/deselect/delete) +- [ ] dirty 标志是否在成功重建后清理 +- [ ] 重建失败是否可重试 +- [ ] 查询是否严格限定 selected folders +- [ ] 前端是否正确处理 `loading/indexing/error` 状态 + +--- + +## 11. 首版验收标准 + +- 新建文件夹后可见; +- 上传文件后进入文件夹可见; +- 删除文件后列表即时更新; +- 勾选文件夹后可检索命中; +- 取消勾选后不可命中; +- 重启服务后 selected 与索引状态不丢失; +- 异常中断后可通过对账任务恢复一致性。 diff --git "a/SessionMemory\345\217\257\350\241\214\346\200\247\345\210\206\346\236\220\344\270\216\346\200\235\350\200\203.md" "b/SessionMemory\345\217\257\350\241\214\346\200\247\345\210\206\346\236\220\344\270\216\346\200\235\350\200\203.md" new file mode 100644 index 0000000..3f78875 --- /dev/null +++ "b/SessionMemory\345\217\257\350\241\214\346\200\247\345\210\206\346\236\220\344\270\216\346\200\235\350\200\203.md" @@ -0,0 +1,46 @@ +# 原始对话 + +``` +我:帮我搭用户注册接口 +Agent:创建了 user.py,用 bcrypt 加密 +我:报错 +Agent:bcrypt 参数不对,改成 bcrypt.hash(password, 12) +我:还是报错 +Agent:换 argon2,成功了 +Agent:开始写测试用例 test_user.py +... + +``` + +# 压缩之后 + +``` +对话摘要:用户要求搭建 FastAPI 用户注册接口。 +已完成:user.py(含 argon2 密码加密)、test_user.py。 +待完成:API 文档、参数校验。 + +``` + +# 但是sessionmemory会存什么呢 + +``` +Current State: 用户注册接口主体完成,已通过 argon2 加密,测试用例写了一半 +Task Specification: FastAPI REST 用户注册,含注册/登录/修改密码 +Important Files: + - src/api/user.py # 主接口,argon2 加密 + - tests/test_user.py # 测试用例,50% 完成度 +Errors & Corrections: + - bcrypt 失败(参数不兼容),已改用 argon2 +Pending Tasks: + - [ ] 完成测试用例 + - [ ] 添加参数校验(pydantic) + - [ ] 写 API 文档 + +``` + + +# 设计哲学 + +- 敢遗忘的 AI 反而更聪明。只存储"不可推导"的知识。 + +bcrypt 报错的具体日志是不可推导的吗?不是,可以重新跑出来。 bcrypt 方案已经放弃、换 argon2 了 ,这是不可推导的,因为代码里看不出来你为什么选了 argon2。 \ No newline at end of file diff --git a/agent.py b/agent.py index 502d65f..b4067d0 100644 --- a/agent.py +++ b/agent.py @@ -4,6 +4,7 @@ import logging import time import re +from datetime import datetime from pathlib import Path from dotenv import load_dotenv from anthropic import AsyncAnthropic @@ -11,6 +12,8 @@ from workspace_manager import WorkspaceManager from task_manager import task_manager from background_manager import background_manager +from memory.session_memory import SessionMemory +from knowledge_service import KnowledgeService from message_center import MessageCenter from tool_registry import ToolExecutionResult, ToolRegistry @@ -21,7 +24,7 @@ client = AsyncAnthropic( api_key=os.getenv("ANTHROPIC_API_KEY"), base_url=os.getenv("ANTHROPIC_BASE_URL") ) -model_name = os.getenv("MODEL_NAME", "claude-3-7-sonnet-20250219") +model_name = os.getenv("MODEL_NAME", "MiniMax-M2.7") # Configuration WORKDIR = Path(os.getenv("WORKDIR", "./workspace")).resolve() @@ -32,64 +35,138 @@ # Initialize managers workspace = WorkspaceManager(WORKDIR, strict=False) - -SYSTEM_PROMPT = """You are NeoFish, an autonomous agent that can: -1. **Browse the web** - Navigate, click, type, extract information -2. **Manage files** - Read, write, edit files in the workspace -3. **Execute commands** - Run shell commands (blocking or background) -4. **Track tasks** - Create, update, and manage persistent tasks -5. **Send files** - Send files to the user - -## CRITICAL: Working Directory -Your workspace is located at: {workdir} -- ALL file operations MUST be relative to this directory -- When reading/writing files, use relative paths like `src/main.py` or `data/config.json` -- The system will automatically resolve them to the correct absolute path -- NEVER use absolute paths like `/Users/...` or `C:\\...` unless specifically required -- If you need to check the current directory, use `run_bash` with `pwd` - -## Observing the page -You have two complementary ways to observe the current state of the page: -1. **Screenshots** – visual snapshots that arrive automatically each step. -2. **snapshot** tool – returns an ARIA accessibility snapshot of the page, listing - every interactive element with a stable ref ID, e.g.: - - button "提交" [ref=e1] - - textbox "用户名" [ref=e2] - - link "忘记密码" [ref=e3] - -## Interacting with elements -**Always prefer ref-based interaction** over CSS / XPath selectors: -- Call `snapshot` to get the current element list with refs. -- Pass `ref=e1` (or whichever ref) to `click` or `type_text` – the engine - will locate the element by its ARIA role and accessible name, which is far - more reliable than brittle CSS selectors. -- Only fall back to a CSS/XPath `selector` when no suitable ref is available. - -## File Operations -- Use `read_file` to read file contents -- Use `write_file` to create or overwrite files -- Use `edit_file` to make precise changes to existing files -- Use `send_file` to send a file to the user (images, documents, etc.) -- Use `run_bash` to execute shell commands (blocking, with timeout) -- Use `background_run` for long-running commands (non-blocking) +knowledge_service = KnowledgeService(WORKDIR) + +SYSTEM_PROMPT = """# Identity +You are NeoFish, a browser-first general-purpose digital labor agent. Your job is to help ordinary users get real work done across websites, web apps, and connected platforms by carrying out actions, extracting information, and coordinating multi-step tasks on their behalf. + +You can: +1. Browse the web and operate pages +2. Click, type, navigate, scroll, inspect, and extract information +3. Pause for human takeover when the task truly needs human intervention +4. Manage persistent tasks across long conversations +5. Read, write, and edit files when the task needs file handling +6. Run commands or search knowledge folders when they genuinely help complete the task +7. Send files or screenshots back to the user + +## Primary Goal +- Be useful, accurate, and persistent. +- Prefer completing the user's real-world task over giving abstract advice. +- Default to acting as an execution agent, not just a conversational assistant. +- Treat browser interaction, information retrieval, and digital task completion as the primary workflow. +- When blocked, identify the real blocker and choose the next best action instead of looping blindly. + +## Safety Boundaries +- Operate inside the current browser session, connected platform session, and workspace boundaries. +- Do not perform destructive or high-risk actions unless they are clearly necessary and appropriately justified. +- Do not assume one approval implies future approval for unrelated risky actions. +- If you hit a login wall, CAPTCHA, QR-code scan, or user-only verification step, call `request_human_assistance`. +- Do not fabricate having run tools, changed files, or verified results when you have not. + +## Behavior Guidelines +- First understand what the user is actually trying to get done in the digital world: browse, search, compare, summarize, submit, collect, monitor, or produce. +- Prefer completing tasks directly in the browser or platform when that is the natural path. +- Use files, shell commands, and workspace editing as support capabilities, not as the default center of the task. +- Prefer fixing the cause of a problem over repeatedly retrying the same failing action. +- If something is ambiguous, gather evidence with tools before making assumptions. +- For multi-step work, maintain task state proactively and keep the root task accurate. +- Do not restart solved work after context compression; continue from the latest known state. + +## Tool Use Guidelines +- Prefer dedicated tools over generic shell commands whenever a dedicated tool exists. +- Prefer browser tools before workspace tools when the task is fundamentally web-based. +- Use `read_file`, `write_file`, and `edit_file` for file operations instead of shell-based file manipulation. +- Use `snapshot` before `click` or `type_text` whenever you need reliable page references. +- Prefer ref-based interaction (`ref=e1`) over CSS/XPath selectors. +- Use `background_run` only for genuinely long-running commands. Use `run_bash` for short blocking commands. +- Use `knowledge_search` only when relevant knowledge folders may contain useful information for the task. + +## Browser Workflow +The browser is your main working surface. + +You observe the page in two ways: +1. Screenshots attached automatically during the loop +2. `snapshot`, which returns an ARIA accessibility tree with stable refs + +When interacting with the page: +- Prefer `snapshot` + `ref` +- Fall back to selectors only when refs are unavailable +- Re-observe the page after meaningful navigation or interaction if the state may have changed +- When collecting information, extract what matters to the user's goal instead of copying noise +- When a user asks for a web task, keep moving the task forward until the result is delivered or human help is required + +## Human Collaboration +- NeoFish is allowed to pause and ask the user to take over when the task requires human identity, verification, or judgment that cannot be automated safely. +- When takeover is needed, explain the blocker clearly and preserve continuity so execution can resume smoothly afterward. + +## File And Command Safety +- Files and commands are auxiliary capabilities. Use them when they help the user's end goal, not just because they are available. +- Read before editing when the file content matters. +- Make precise edits when possible instead of rewriting large files unnecessarily. +- Use relative workspace paths like `src/main.py` or `data/config.json`. +- Avoid absolute paths unless they are explicitly required. +- If you need to confirm location or inspect the workspace, use safe shell commands such as `pwd`, `ls`, or similar read-only inspection. +- Treat shell as a power tool: use it deliberately, keep commands scoped, and avoid risky patterns unless the user explicitly wants them. ## Task Management -Tasks persist across context compression. Use them to track progress on complex tasks: -- `task_create` - Create a new task with subject and description -- `task_list` - List all tasks with their status -- `task_get` - Get full details of a specific task -- `task_update` - Update task status or dependencies -- For non-trivial multi-step requests, maintain persistent task state proactively. -- If the system tells you a root task was auto-created, do not create a duplicate root task. -- When such a root task exists, keep it updated and mark it completed before `finish_task`. - -## Background Tasks -For commands that take a long time: -- `background_run` - Start a background command, returns task_id immediately -- `check_background` - Check status of background tasks - -If you ever encounter a strict login wall, CAPTCHA, or require the user to scan a QR code, you must call the `request_human_assistance` tool. Do NOT give up easily; only ask for help when absolutely necessary. -When the task is completely finished, call `finish_task`. +Tasks persist across context compression. Use them for non-trivial work. +- If the system says a root task was auto-created, do not create a duplicate. +- Update relevant tasks as the work moves from planning to execution to completion. +- Mark the root task completed before calling `finish_task`. +- Use task tracking to keep long-running browsing or multi-platform workflows coherent. + +## Plan Mode +You support a dedicated planning workflow: +- Use `enter_plan_mode` when the task needs investigation, scoping, design, workflow planning, or user approval before execution. +- While planning, do not modify project files or browser state outside the dedicated plan file. +- `enter_plan_mode` may be called again while planning to overwrite the plan file with a refined version. +- The plan should be concrete and execution-ready, not vague brainstorming. +- Use `exit_plan_mode` only when the plan is ready for review. +- After calling `exit_plan_mode`, stop execution and wait for approval or revision feedback. + +## Completion And Communication +- When the task is fully completed, call `finish_task`. +- If the user asked for a deliverable such as a summary, screenshot, structured result, or file, make sure you actually provide it. +- If you need more user input, ask for the minimum missing information clearly. +- If you are waiting for approval, do not continue implementation. + +## Output Style +- Be concise between tool calls. +- Do not narrate obvious actions with unnecessary filler. +- Prefer short, factual updates while working. +- Give fuller explanations only when presenting results, blockers, or decisions. + +__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__ + +## Environment +Workspace root: {workdir} +- All file operations must stay within this workspace unless explicitly instructed otherwise. +- The system resolves relative paths against this workspace automatically. + +## Session Memory +You will be given structured Session Memory. Treat it as the canonical summary of this conversation's current state. +- Use it to avoid forgetting task progress, important files, errors, and pending work. +- Respect it when context has been compressed. +- Update it when meaningful progress happens. + +Whenever you complete a meaningful step, make progress, encounter an error, or the user's request changes direction, +output a Memory Update block at the END of your response (after all tool calls and text). + +Format: +``` +[Memory Update] +current_state: +task_spec: +important_files: +errors_corrections: +pending_tasks: +[/Memory Update] +``` + +- Only output this block when there is something meaningful to record. +- `current_state` is the most important field. +- Keep each field concise. +- Do not output the block if nothing meaningful changed. """.format(workdir=WORKDIR) TOOLS = [ @@ -332,7 +409,13 @@ "task_id": {"type": "integer"}, "status": { "type": "string", - "enum": ["pending", "in_progress", "completed"], + "enum": [ + "pending", + "planning", + "awaiting_approval", + "in_progress", + "completed", + ], }, "addBlockedBy": { "type": "array", @@ -386,6 +469,62 @@ "required": [], }, }, + # Knowledge tools + { + "name": "knowledge_search", + "description": "Semantic search in selected knowledge folders. Use this when user asks questions about uploaded knowledge files.", + "input_schema": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "top_k": { + "type": "integer", + "description": "Number of results to return (default 5)", + }, + }, + "required": ["query"], + }, + }, + { + "name": "enter_plan_mode", + "description": ( + "Enter planning mode and create or overwrite the session plan file. " + "Use this before implementation when you need to investigate first or " + "present a plan for approval. You may call it again while planning to " + "replace the plan file with refined markdown." + ), + "input_schema": { + "type": "object", + "properties": { + "goal": { + "type": "string", + "description": "Planning goal or scope summary (optional)", + }, + "plan_markdown": { + "type": "string", + "description": "Full markdown content to write into the plan file (optional)", + }, + }, + "required": [], + }, + }, + { + "name": "exit_plan_mode", + "description": ( + "Submit the current plan for user approval and pause execution. " + "Only use this after the plan file is ready." + ), + "input_schema": { + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": "Short summary of the proposed plan (optional)", + } + }, + "required": [], + }, + }, # Context management { "name": "compact", @@ -403,6 +542,165 @@ }, ] +_PLAN_MODES = {"execution", "planning", "awaiting_approval"} +_PLAN_ALLOWED_TOOLS = { + "snapshot", + "read_file", + "extract_info", + "knowledge_search", + "task_list", + "task_get", + "run_bash", + "compact", + "request_human_assistance", + "send_screenshot", + "enter_plan_mode", + "exit_plan_mode", +} +_PLAN_READ_ONLY_COMMAND_PREFIXES = ( + "pwd", + "ls", + "dir", + "get-location", + "get-childitem", + "rg ", + "rg.exe ", + "type ", + "cat ", + "git status", + "git diff --stat", + "git diff --name-only", +) +_PLAN_DISALLOWED_COMMAND_SNIPPETS = ( + ">>", + ">", + "set-content", + "add-content", + "out-file", + "new-item", + "remove-item", + "rename-item", + "move-item", + "copy-item", + "del ", + "rm ", + "mv ", + "cp ", + "mkdir ", + "touch ", +) + + +def _default_plan_file(session_id: str | None) -> str: + suffix = session_id or "current" + return f".plans/plan-{suffix}.md" + + +def _normalize_plan_state( + plan_state: dict | None, session_id: str | None +) -> dict[str, str | bool]: + state = dict(plan_state or {}) + mode = str(state.get("mode") or "execution").strip().lower() + if mode not in _PLAN_MODES: + mode = "execution" + state["mode"] = mode + state["awaiting_approval"] = mode == "awaiting_approval" + state["plan_file"] = str(state.get("plan_file") or _default_plan_file(session_id)) + return state + + +def _get_tools_for_mode(mode: str) -> list[dict]: + if mode == "planning": + return [tool for tool in TOOLS if tool["name"] in _PLAN_ALLOWED_TOOLS] + if mode == "awaiting_approval": + return [tool for tool in TOOLS if tool["name"] in {"enter_plan_mode", "exit_plan_mode"}] + return TOOLS + + +def _is_read_only_bash_command(command: str) -> bool: + normalized = (command or "").strip().lower() + if not normalized: + return False + if any(snippet in normalized for snippet in _PLAN_DISALLOWED_COMMAND_SNIPPETS): + return False + return any(normalized.startswith(prefix) for prefix in _PLAN_READ_ONLY_COMMAND_PREFIXES) + + +def _merge_memory_line(existing: str, line: str) -> str: + clean_line = (line or "").strip() + if not clean_line: + return existing + lines = [item.strip() for item in existing.splitlines() if item.strip()] + if clean_line not in lines: + lines.append(clean_line) + return "\n".join(lines) + + +def _build_plan_template(goal: str, plan_file: str) -> str: + safe_goal = goal.strip() or "Plan the requested work" + return ( + f"# Execution Plan\n\n" + f"Plan file: `{plan_file}`\n\n" + f"## Goal\n{safe_goal}\n\n" + f"## Current Understanding\n- ...\n\n" + f"## Risks\n- ...\n\n" + f"## Implementation Steps\n1. ...\n\n" + f"## Validation\n- ...\n\n" + f"## User Confirmation Needed\n- ...\n" + ) + + +def _build_mode_system_prompt(plan_state: dict) -> str: + mode = str(plan_state.get("mode") or "execution") + plan_file = str(plan_state.get("plan_file") or "") + lines = [ + "## Runtime Plan State", + f"- Current mode: {mode}", + f"- Plan file: {plan_file or '(none)'}", + ] + if mode == "planning": + lines.extend( + [ + "- You are in planning mode.", + "- Research and analyze only; do not implement changes yet.", + "- Use `enter_plan_mode` to create or overwrite the plan markdown.", + "- When the plan is ready for review, call `exit_plan_mode`.", + ] + ) + elif mode == "awaiting_approval": + lines.extend( + [ + "- A plan has already been submitted for approval.", + "- Do not continue work until the user approves it or requests changes.", + ] + ) + elif plan_file: + lines.append( + f"- If an approved plan exists in `{plan_file}`, follow it during execution unless the user changes direction." + ) + return "\n".join(lines) + + +def _load_existing_root_task(task_id: int | None) -> dict | None: + if not task_id: + return None + raw = task_manager.get(int(task_id)) + try: + data = json.loads(raw) + except Exception: + return None + if isinstance(data, dict) and "id" in data: + return data + return None + + +def _task_status_for_plan_mode(mode: str) -> str: + if mode == "planning": + return "planning" + if mode == "awaiting_approval": + return "awaiting_approval" + return "in_progress" + def _get_block_type(block) -> str: if isinstance(block, dict): @@ -489,6 +787,39 @@ def microcompact(messages: list) -> list: return messages +_MEMORY_UPDATE_RE = re.compile( + r"\[Memory Update\]\s*\n(.*?)\n\[/Memory Update\]", + re.DOTALL | re.IGNORECASE, +) + + +def _parse_memory_update(text: str) -> dict | None: + """Extract [Memory Update] block from AI response text. Returns dict of fields or None.""" + m = _MEMORY_UPDATE_RE.search(text) + if not m: + return None + block = m.group(1) + result: dict = {} + for line in block.split("\n"): + line = line.strip() + if not line or line.startswith("```"): + continue + if ": " in line: + key, _, val = line.partition(": ") + key = key.strip().lower().replace(" ", "_") + if key in ( + "current_state", + "task_spec", + "important_files", + "workflow", + "errors_corrections", + "learnings", + "pending_tasks", + ): + result[key] = val.strip() + return result if result else None + + def _process_queued_message( messages: list, user_content: list, qtext: str, qimages: list ) -> None: @@ -719,8 +1050,38 @@ def _create_tool_registry( emit_action_required, emit_image, emit_file, + session_memory: SessionMemory, + save_session_memory_fn=None, + plan_state: dict | None = None, + save_plan_state_fn=None, + emit_task_status=None, ) -> ToolRegistry: registry = ToolRegistry() + if plan_state is None: + current_plan_state = _normalize_plan_state(None, effective_session_id) + else: + previous_state = dict(plan_state) + current_plan_state = plan_state + current_plan_state.clear() + current_plan_state.update( + _normalize_plan_state(previous_state, effective_session_id) + ) + + def _save_plan_state() -> None: + if save_plan_state_fn: + save_plan_state_fn(current_plan_state) + + def _save_session_memory() -> None: + if save_session_memory_fn: + save_session_memory_fn() + + async def _set_runtime_plan_mode(mode: str) -> None: + current_plan_state["mode"] = mode + current_plan_state["awaiting_approval"] = mode == "awaiting_approval" + current_plan_state["updated_at"] = datetime.now().isoformat() + if emit_task_status: + await emit_task_status("planning" if mode == "planning" else mode) + _save_plan_state() async def _snapshot(args: dict) -> ToolExecutionResult: snapshot_text = await pm.get_aria_snapshot(effective_session_id) @@ -836,8 +1197,18 @@ async def _send_file(args: dict) -> ToolExecutionResult: return ToolExecutionResult(output=f"File sent: {file_path}") async def _run_bash(args: dict) -> ToolExecutionResult: + command = args["command"] + if current_plan_state.get("mode") == "planning" and not _is_read_only_bash_command( + command + ): + return ToolExecutionResult( + output=( + "Error: run_bash is restricted in planning mode. " + "Only read-only commands like pwd, ls/dir, rg, type/cat, or git status/diff are allowed." + ) + ) return ToolExecutionResult( - output=await workspace.run_bash(args["command"], args.get("timeout", 120)) + output=await workspace.run_bash(command, args.get("timeout", 120)) ) async def _task_create(args: dict) -> ToolExecutionResult: @@ -873,6 +1244,114 @@ async def _check_background(args: dict) -> ToolExecutionResult: output=await background_manager.check(args.get("task_id")) ) + async def _knowledge_search(args: dict) -> ToolExecutionResult: + query = str(args.get("query", "")).strip() + if not query: + return ToolExecutionResult(output="Error: query is required") + top_k = int(args.get("top_k", 5) or 5) + top_k = max(1, min(20, top_k)) + results = knowledge_service.search(query=query, top_k=top_k) + if not results: + return ToolExecutionResult(output="No relevant knowledge found in selected folders.") + return ToolExecutionResult( + output=json.dumps({"results": results}, ensure_ascii=False, indent=2) + ) + + async def _enter_plan_mode(args: dict) -> ToolExecutionResult: + goal = str(args.get("goal") or session_memory.get("task_spec") or "").strip() + plan_file = str(current_plan_state.get("plan_file") or _default_plan_file(effective_session_id)) + current_plan_state["plan_file"] = plan_file + await _set_runtime_plan_mode("planning") + + plan_markdown = str(args.get("plan_markdown") or "").strip() + plan_abs = (WORKDIR / plan_file).resolve() + should_seed_template = not plan_abs.exists() and not plan_markdown + if should_seed_template: + plan_markdown = _build_plan_template(goal, plan_file) + if plan_markdown: + await workspace.write_file(plan_file, plan_markdown) + + session_memory.update("current_state", "In planning mode") + session_memory.update( + "important_files", + _merge_memory_line(session_memory.get("important_files"), plan_file), + ) + session_memory.update( + "pending_tasks", + "Draft or refine the plan, then submit it for approval.", + ) + session_memory.update( + "workflow", + f"Planning mode active. Maintain the proposal in {plan_file} and wait for approval before implementation.", + ) + if auto_root_task: + task_manager.update(auto_root_task["id"], status="planning") + _save_session_memory() + + if plan_markdown: + return ToolExecutionResult( + output=( + f"Planning mode enabled. Plan file `{plan_file}` has been written. " + "Continue researching and call `enter_plan_mode` again with refined plan_markdown whenever needed." + ) + ) + return ToolExecutionResult( + output=( + f"Planning mode enabled. Use the plan file `{plan_file}` for the proposal. " + "Call `enter_plan_mode` again with `plan_markdown` to overwrite it." + ) + ) + + async def _exit_plan_mode(args: dict) -> ToolExecutionResult: + plan_file = str(current_plan_state.get("plan_file") or _default_plan_file(effective_session_id)) + plan_abs = (WORKDIR / plan_file).resolve() + if not plan_abs.exists(): + return ToolExecutionResult( + output=( + f"Error: plan file `{plan_file}` does not exist yet. " + "Call `enter_plan_mode` with `plan_markdown` first." + ) + ) + + plan_text = await workspace.read_file(plan_file) + summary = str(args.get("summary") or "").strip() + await _set_runtime_plan_mode("awaiting_approval") + if summary: + current_plan_state["last_summary"] = summary + + session_memory.update("current_state", "Awaiting plan approval") + session_memory.update( + "important_files", + _merge_memory_line(session_memory.get("important_files"), plan_file), + ) + session_memory.update( + "pending_tasks", + f"Await user approval for the plan in {plan_file}.", + ) + session_memory.update( + "workflow", + f"Plan submitted for approval. Wait for user confirmation before implementing {plan_file}.", + ) + if auto_root_task: + task_manager.update(auto_root_task["id"], status="awaiting_approval") + _save_session_memory() + + approval_message = ( + "Plan ready for approval.\n\n" + f"Plan file: `{plan_file}`\n" + ) + if summary: + approval_message += f"\nSummary: {summary}\n" + approval_message += f"\n```md\n{plan_text[:12000]}\n```" + await emit_info({"message": approval_message}) + return ToolExecutionResult( + output=( + f"Submitted the plan in `{plan_file}` for approval. " + "Pause execution until the user approves it or requests revisions." + ), + stop_loop=True, + ) + async def _compact(args: dict) -> ToolExecutionResult: focus = args.get("focus") return ToolExecutionResult( @@ -901,6 +1380,9 @@ async def _compact(args: dict) -> ToolExecutionResult: registry.register("task_list", _task_list) registry.register("background_run", _background_run) registry.register("check_background", _check_background) + registry.register("knowledge_search", _knowledge_search) + registry.register("enter_plan_mode", _enter_plan_mode) + registry.register("exit_plan_mode", _exit_plan_mode) registry.register("compact", _compact) return registry @@ -925,6 +1407,11 @@ async def run_agent_loop( web_queue_getter=None, web_session_id: str = None, cancel_event: asyncio.Event = None, + session_memory: SessionMemory | None = None, + save_session_memory_fn=None, + plan_state: dict | None = None, + save_plan_state_fn=None, + set_runtime_status_fn=None, ): effective_session_id = web_session_id or session_id @@ -962,12 +1449,36 @@ async def emit_file(file_path: str, description: str) -> None: if ws_send_file: await ws_send_file(file_path, description) + async def emit_task_status(status: str) -> None: + if set_runtime_status_fn: + set_runtime_status_fn(status) + if message_center: + await message_center.publish("task_status", {"status": status}) + if not effective_session_id: await emit_info( {"message": "Error: No session ID provided", "message_key": "common.error"} ) return + if session_memory is None: + session_memory = SessionMemory(session_id=effective_session_id) + plan_state = _normalize_plan_state(plan_state, effective_session_id) + existing_root_task = _load_existing_root_task(plan_state.get("root_task_id")) + if not session_memory.get("task_spec"): + session_memory.update("task_spec", user_instruction) + if not session_memory.get("current_state"): + if plan_state["mode"] == "planning": + session_memory.update("current_state", "In planning mode") + elif plan_state["mode"] == "awaiting_approval": + session_memory.update("current_state", "Awaiting plan approval") + else: + session_memory.update("current_state", "Task started") + if save_session_memory_fn: + save_session_memory_fn() + if save_plan_state_fn: + save_plan_state_fn(plan_state) + try: page = await pm.get_or_create_page(effective_session_id) except Exception as e: @@ -979,7 +1490,23 @@ async def emit_file(file_path: str, description: str) -> None: ) return - auto_root_task = _auto_create_root_task(user_instruction, images, uploaded_files) + auto_root_task = existing_root_task or _auto_create_root_task( + user_instruction, images, uploaded_files + ) + if auto_root_task and not plan_state.get("root_task_id"): + plan_state["root_task_id"] = auto_root_task["id"] + if save_plan_state_fn: + save_plan_state_fn(plan_state) + if auto_root_task: + task_manager.update( + auto_root_task["id"], status=_task_status_for_plan_mode(plan_state["mode"]) + ) + + await emit_task_status( + "planning" if plan_state["mode"] == "planning" else ( + "awaiting_approval" if plan_state["mode"] == "awaiting_approval" else "running" + ) + ) await emit_info( { @@ -992,6 +1519,7 @@ async def emit_file(file_path: str, description: str) -> None: messages = history_messages.copy() max_steps = 9999999 is_finished = False + stopped_for_plan_approval = False # Build first user message with context about uploaded files context_parts = [] @@ -1029,12 +1557,28 @@ async def emit_file(file_path: str, description: str) -> None: "\n\nA persistent root task has already been auto-created for this request:\n" f"- task_id: {auto_root_task['id']}\n" f"- subject: {auto_root_task['subject']}\n" - "- Its status is already `in_progress`.\n" + f"- Its current status is `{_task_status_for_plan_mode(plan_state['mode'])}`.\n" "- Do not create a duplicate root task for the same request.\n" "- Update this root task when needed and mark it `completed` before calling finish_task.\n" "- You may create additional sub-tasks only if they are genuinely useful." ) + if plan_state["mode"] == "planning": + user_content[0]["text"] += ( + "\n\nYou are currently in planning mode." + f"\nUse `{plan_state['plan_file']}` as the dedicated plan file." + "\nDo not implement changes yet." + ) + elif plan_state["mode"] == "awaiting_approval": + user_content[0]["text"] += ( + "\n\nA plan has already been submitted and is waiting for user approval." + "\nDo not continue until the user confirms or requests revisions." + ) + elif plan_state.get("plan_file"): + user_content[0]["text"] += ( + f"\n\nIf there is an approved plan, follow `{plan_state['plan_file']}` during execution." + ) + # Add images as base64 for vision if images: for data_url in images: @@ -1058,6 +1602,7 @@ async def emit_file(file_path: str, description: str) -> None: if cancel_event and cancel_event.is_set(): if auto_root_task: task_manager.update(auto_root_task["id"], status="pending") + await emit_task_status("cancelled") await emit_info( { "message": "Task cancelled by user.", @@ -1133,6 +1678,27 @@ async def emit_file(file_path: str, description: str) -> None: # Reset user_content after compression to avoid appending old data user_content = [] + if plan_state["mode"] == "planning": + user_content.append( + { + "type": "text", + "text": ( + f"Planning reminder: stay in planning mode and keep the proposal in `{plan_state['plan_file']}`. " + "Do not implement changes yet. When the proposal is ready, call `exit_plan_mode`." + ), + } + ) + elif plan_state["mode"] == "awaiting_approval": + user_content.append( + { + "type": "text", + "text": ( + f"Approval reminder: the submitted plan in `{plan_state['plan_file']}` is still waiting for user approval. " + "Do not continue implementation until the user approves it or asks for revisions." + ), + } + ) + # 1. Observe - append observation to user_content if page and not page.is_closed(): try: @@ -1172,12 +1738,16 @@ async def emit_file(file_path: str, description: str) -> None: ) try: + current_tools = _get_tools_for_mode(str(plan_state.get("mode") or "execution")) + current_system_prompt = ( + f"{SYSTEM_PROMPT}\n\n{_build_mode_system_prompt(plan_state)}\n\n{session_memory.get_all()}" + ) response = await client.messages.create( model=model_name, max_tokens=4096, - system=SYSTEM_PROMPT, + system=current_system_prompt, messages=messages, - tools=TOOLS, + tools=current_tools, ) assistant_blocks = response.content except Exception as e: @@ -1198,9 +1768,9 @@ async def emit_file(file_path: str, description: str) -> None: response = await client.messages.create( model=model_name, max_tokens=4096, - system=SYSTEM_PROMPT, + system=current_system_prompt, messages=messages, - tools=TOOLS, + tools=current_tools, ) assistant_blocks = response.content except Exception as retry_error: @@ -1212,6 +1782,14 @@ async def emit_file(file_path: str, description: str) -> None: messages.append({"role": "assistant", "content": assistant_blocks}) + assistant_text = "\n".join(_extract_text_parts(assistant_blocks)) + memory_update = _parse_memory_update(assistant_text) + if memory_update: + for key, value in memory_update.items(): + session_memory.update(key, value) + if save_session_memory_fn: + save_session_memory_fn() + # 3. Act tool_uses = [block for block in assistant_blocks if _get_block_type(block) == "tool_use"] user_content = [] @@ -1226,6 +1804,7 @@ async def emit_file(file_path: str, description: str) -> None: manual_compact = False manual_compact_focus = None + stop_loop = False tool_registry = _create_tool_registry( pm=pm, page=page, @@ -1235,6 +1814,11 @@ async def emit_file(file_path: str, description: str) -> None: emit_action_required=emit_action_required, emit_image=emit_image, emit_file=emit_file, + session_memory=session_memory, + save_session_memory_fn=save_session_memory_fn, + plan_state=plan_state, + save_plan_state_fn=save_plan_state_fn, + emit_task_status=emit_task_status, ) for tool in tool_uses: @@ -1260,6 +1844,10 @@ async def emit_file(file_path: str, description: str) -> None: if execution.manual_compact: manual_compact = True manual_compact_focus = execution.compact_focus + if execution.stop_loop: + stop_loop = True + if str(plan_state.get("mode")) == "awaiting_approval": + stopped_for_plan_approval = True except Exception as e: result_str = f"Error executing {tool_name}: {str(e)}" @@ -1282,8 +1870,10 @@ async def emit_file(file_path: str, description: str) -> None: if is_finished: break + if stop_loop: + break - if not is_finished: + if not is_finished and not stopped_for_plan_approval: if auto_root_task: task_manager.update(auto_root_task["id"], status="pending") await emit_info( @@ -1293,4 +1883,23 @@ async def emit_file(file_path: str, description: str) -> None: } ) + if stopped_for_plan_approval: + session_memory.update("current_state", "Awaiting plan approval") + if auto_root_task: + task_manager.update(auto_root_task["id"], status="awaiting_approval") + elif is_finished: + session_memory.update("current_state", "Task completed") + if auto_root_task: + task_manager.update(auto_root_task["id"], status="completed") + else: + session_memory.update("current_state", "Task ended (max steps or cancelled)") + + if save_session_memory_fn: + save_session_memory_fn() + pm.deactivate_tab(effective_session_id) + if stopped_for_plan_approval: + return "awaiting_approval" + if is_finished: + return "completed" + return "cancelled" if cancel_event and cancel_event.is_set() else "completed" diff --git a/agent_task_manager.py b/agent_task_manager.py index e7172d8..9c3faf7 100644 --- a/agent_task_manager.py +++ b/agent_task_manager.py @@ -16,6 +16,8 @@ class TaskStatus(Enum): PENDING = "pending" RUNNING = "running" + PLANNING = "planning" + AWAITING_APPROVAL = "awaiting_approval" COMPLETED = "completed" CANCELLED = "cancelled" FAILED = "failed" @@ -61,13 +63,26 @@ def has_running_task(self, session_id: str) -> bool: if session_id not in self._tasks: return False task = self._tasks[session_id] - return task.status == TaskStatus.RUNNING and not task.task.done() + return task.status in (TaskStatus.RUNNING, TaskStatus.PLANNING) and not task.task.done() def get_task_status(self, session_id: str) -> Optional[TaskStatus]: if session_id not in self._tasks: return None return self._tasks[session_id].status + def set_task_status(self, session_id: str, status: TaskStatus | str) -> bool: + if session_id not in self._tasks: + return False + if isinstance(status, str): + try: + status = TaskStatus(status) + except ValueError: + return False + self._tasks[session_id].status = status + if status in (TaskStatus.COMPLETED, TaskStatus.CANCELLED, TaskStatus.FAILED): + self._tasks[session_id].completed_at = datetime.now() + return True + async def start_task( self, session_id: str, agent_fn: Callable, *args, **kwargs ) -> asyncio.Task: @@ -83,8 +98,17 @@ async def wrapped_fn(): task_ref = self._tasks[session_id] try: task_ref.status = TaskStatus.RUNNING - await agent_fn(*args, cancel_event=cancel_event, **kwargs) - task_ref.status = TaskStatus.COMPLETED + result = await agent_fn(*args, cancel_event=cancel_event, **kwargs) + if task_ref.status != TaskStatus.AWAITING_APPROVAL: + if isinstance(result, TaskStatus): + task_ref.status = result + elif isinstance(result, str): + try: + task_ref.status = TaskStatus(result) + except ValueError: + task_ref.status = TaskStatus.COMPLETED + else: + task_ref.status = TaskStatus.COMPLETED except asyncio.CancelledError: task_ref.status = TaskStatus.CANCELLED except Exception as e: diff --git "a/docs/readme_img/claude\346\272\220\347\240\201\347\240\224\347\251\266\346\226\207\346\241\243/claude\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/docs/readme_img/claude\346\272\220\347\240\201\347\240\224\347\251\266\346\226\207\346\241\243/claude\346\236\266\346\236\204\350\256\276\350\256\241.md" new file mode 100644 index 0000000..a236beb --- /dev/null +++ "b/docs/readme_img/claude\346\272\220\347\240\201\347\240\224\347\251\266\346\226\207\346\241\243/claude\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -0,0 +1,342 @@ +# Claude 架构设计(整理版,保留全部信息) + +> 说明:本文件仅整理格式,不删减原始信息。 +> +> 原文备份:claude架构设计_原文备份.md + +## 二、架构设计 + +一个能自主编程的 Agent 要处理的事情非常多:调大模型 API、执行 40 多种工具、管理权限、压缩上下文、维护记忆、支持多 Agent 协作……如果这些东西全部塞在一个文件里,代码会立刻变成一团乱麻。那 Claude Code 是怎么组织这些子系统的?它采用了一个四层分层架构:我们从上往下,一层一层来理解。引擎层是 Agent 的「大脑」,负责思考和调度。它的关键设计原则是不包含任何业务逻辑,它不知道怎么读文件、怎么改代码、怎么搜索,这些全是工具层的事。引擎层只做三件事:第一,协调,把用户输入、系统指令、历史对话拼在一起,发给大模型;第二,分发,大模型说「我要用某个工具」时,找到对应的工具并执行;第三,决策,根据大模型的返回决定是继续循环还是结束对话。这种设计的好处是:新增能力只需要新增一个工具,引擎层完全不用改。工具层是 Agent 的全部「能力」,40 多个工具都在这一层。每个工具就是 Agent 的一个能力:执行 Shell 命令、读写文件、搜索代码、生成子 Agent……这些工具不是随便写的,它们遵循一个统一的规范。这个规范不仅定义了「工具能做什么」,还强制定义了三个安全属性:这个工具是只读的还是会改东西的?它是否具有破坏性需要额外确认?它能不能和其他工具同时执行?这三个属性不是「建议加上」的,而是类型系统强制要求的,漏了任何一个,代码就编译不过。这意味着每一把刀都有刀鞘,从出厂就配好了安全机制。服务层是所有层共享的「基础设施」。这一层包括三样东西:调大模型 API(不管是谁要调,主循环也好、子 Agent 也好,都走这一层)、上下文压缩(后面会详细讲的五步压缩策略)、MCP 协议(和外部工具服务器通信的标准接口)。你可以把它类比成大楼的水电煤,所有楼层都需要,但谁也不会自己去铺设管道。安全与治理层有点特殊,它不像其他三层那样各管一块,而是像一张安全网罩在所有层上面。权限系统决定哪些操作需要用户确认、哪些可以自动执行;Hook 系统允许在工具执行前后插入自定义行为(比如「每次 git push 前自动跑 lint」);Bash 安全模块会对 Shell 命令做语法级别的分析,检测命令注入、路径逃逸等危险模式,而不是简单地用正则匹配关键词。 + +## 三、Agent 工作模式 + +搞清楚了四层架构的宏观布局之后,一个自然的问题来了:引擎层那个主循环里,到底发生了什么?Agent 是怎么「思考」和「行动」的?它用的是什么 Agent 框架?是大家常说的 ReAct 模式吗?这个问题值得深入聊聊,因为 Agent 的工作模式决定了整个系统的架构走向。Claude Code 的答案可能出乎你的意料,它没有用 ReAct,而是用了一个更简洁、更高效的模式。### 什么是 ReAct + +如果你接触过 Agent 开发,大概率听说过 ReAct(Reasoning + Acting)。它是 2022 年提出的一种 Agent 范式,核心思路是把 Agent 的每一步拆成三个阶段:具体来说,模型在每一轮都会先输出一段「思考」(Thought),比如「我需要先读取 config.ts 文件来了解数据库连接配置」;然后选择一个工具调用(Action);最后拿到工具结果(Observation)。这三步不断循环,直到模型认为任务完成。这个模式在 2022 年非常流行,因为当时的大模型(GPT-3.5 时代)推理能力有限,需要用显式的「Thought」步骤来引导模型一步步思考。但 ReAct 有几个问题: +- 第一个问题:Token 浪费。 每一轮都要输出一段 Thought 文本,这些文本要作为上下文的一部分发给 API,占用了宝贵的 Token 预算。对于编程 Agent 来说,一次任务可能循环 50 轮,每轮都写一段「我打算先读取……然后分析……」的思考过程,加起来就是好几万 Token 的浪费。 +- 第二个问题:应用层代码太复杂。 你需要解析模型的输出,区分「哪部分是 Thought、哪部分是 Action」,然后提取 Action 调用工具,再把 Observation 拼回去。这个解析过程写起来很麻烦,而且很容易出 bug,因为模型输出的格式不一定标准,一崩就全崩了。 +- 第三个问题:ReAct 是为「弱模型」设计的。 当大模型的推理能力不够强时,用显式的 Thought 来「强迫」它一步步思考是有意义的。但 Claude Opus 这种级别的模型,推理能力已经足够强了,它完全可以在内部完成推理,不需要在输出里显式写出每一步的思考过程。### Tool-Use Loop + +Claude Code 没有采用 ReAct 的 Thought-Action-Observation 三步循环,而是用了一个更简洁的模式,我把它叫做 Tool-Use Loop。核心思路非常简单,就一个 while(true) 循环:看到区别了吗?没有 Thought 步骤。模型在内部完成推理(通过 Extended Thinking,这是 Claude Opus 的一个能力,模型在生成回复前会在内部进行一段不可见的深度推理,不占用上下文空间),然后直接返回两种结果之一:**tool_use**:「我要用某个工具」,应用层执行工具,把结果拼入消息列表,继续循环**end_turn**:「我说完了」,跳出循环,把最终结果返回给用户这个设计的核心哲学是:信任模型的推理能力,保持应用层框架尽可能简单。来看 query.ts 中的核心循环,它的实际代码长这样(这是一段 TypeScript 代码,其中 yield 的作用是流式输出,你可以理解为一边接收 API 的响应,一边把每个 token 实时传给 UI 显示):async function* queryLoop( +  params: QueryParams, +  consumedCommandUuids: string[], +): AsyncGenerator { +let state: State = { messages, toolUseContext, turnCount: 1, ... } + +while (true) { +    // 步骤 1:压缩上下文(五步从轻到重) +    // 步骤 2:调用大模型 API,流式接收 +    forawait (const event of streamAPI(params)) { +      yield event  // 流式输出每个 token +    } +    // 步骤 3:分析模型返回 +    if (response.stopReason === 'end_turn') break// 完成了,跳出循环 + +    // 步骤 4:执行工具调用(并发/串行编排) +    const toolResults = await executeToolCalls(toolUseMessages) + +    // 步骤 5:更新 state,继续循环 +    state = { ...state, messages: updatedMessages, turnCount: turnCount + 1 } +    continue +  } +} +注意 break 和 continue,模型说 end_turn 就 break 跳出循环,说 tool_use 就 continue 回到循环开头。整个决策逻辑就这么简单。### 为什么比 ReAct 更好 + +你可能会问:不就是把 Thought 去掉了吗,有什么了不起的?区别其实很大,我列了三个关键原因:第一,Extended Thinking 让推理在「模型内部」完成。 Claude Opus 支持 Extended Thinking,模型在生成最终回复之前,会在内部进行一段不可见的深度推理。这段推理发生在模型内部,不占用应用的上下文空间,也不需要应用层去解析。所以 ReAct 的 Thought 步骤在 Claude 的架构里是多余的,模型已经在内部「想好了」,不需要在外部输出中再写一遍。第二,API 原生支持 tool_use。 Claude 的 API 原生支持工具调用,模型可以直接返回 tool_use 类型的响应,不需要用正则表达式从文本中提取「Action」。这消除了 ReAct 的格式解析问题,应用层代码变得极其简洁。第三,end_turn 作为天然的终止信号。 ReAct 需要一套额外的规则来判断「Agent 是否完成了」,比如检测输出中是否包含「Final Answer」。而 Tool-Use Loop 用模型的 end_turn 信号作为终止条件,这是 API 层面的原语,语义清晰,不需要任何解析。用一个表格来总结两者的区别:维度ReActTool-Use Loop推理方式显式 Thought 文本模型内部 Extended Thinking工具调用解析文本提取 ActionAPI 原生 tool_use终止判断检测 「Final Answer」API 原生 end_turnToken 开销每轮要输出 Thought无额外开销编排复杂度高(需要解析 Thought/Action)低(只需要 if/else)适合场景弱模型 + 简单工具强模型 + 复杂工具集Claude Code 的 Agent 工作模式可以总结为一句话:信任模型的推理能力,把应用层框架做得尽可能简单。ReAct 的设计哲学是「帮模型思考」,用显式的 Thought 步骤引导模型一步步推理。这在弱模型时代是必要的。但 Claude Code 面对的是 Opus 级别的强模型,它的推理能力完全可以在内部完成,不需要应用层去「教」它怎么想。所以 Claude Code 的 Tool-Use Loop 只做最简单的事:调 API、执行工具、再调 API。推理交给模型,执行交给工具,编排交给最简单的 while(true) 循环。这种「大道至简」的设计,反而是最高效的。### Plan Mode + +Claude Code 不仅有 Tool-Use Loop 这种「边想边做」模式,还有 Plan Mode,一个更精细的两阶段工作流:先规划、再执行。Plan Mode 的核心思想是:复杂任务应该先规划再执行,避免方向跑偏、浪费精力。它并不是一个独立的框架,而是在同一个 Tool-Use Loop 中通过 EnterPlanMode 和 ExitPlanMode 两个工具实现的:整个流程分三步: +- 第一步:模型自主进入或用户手动触发。 当模型判断「这是一个复杂任务」时,它会调用 EnterPlanMode 工具。对于简单任务(修 typo、加 console.log),则明确不进入。用户也可以通过 Shift+Tab 手动切换。 +- 第二步:只读探索 + 设计方案。 进入 Plan Mode 后,权限降为只读,模型只能用 Read、Grep、Glob 这些工具去探索代码库,不能写文件、不能改代码、不能跑命令。探索完后,把计划写入 .claude/plans/ 目录。每 5 轮对话,系统会偷偷给模型塞一张「小纸条」,提醒它「你现在还在 Plan Mode,别手痒改代码」,防止模型在长对话中「走神」。 +- 第三步:用户审批后实施。 模型调用 ExitPlanMode,此时需要用户确认。用户批准后,权限恢复为之前的模式,模型开始自由执行读写操作,按计划实施。Plan Mode 最值得学习的设计是「工具即能力」。对模型来说,Plan Mode 不是一种特殊的「模式切换」,而只是调用了 EnterPlanMode 和 ExitPlanMode 这两个工具。就像调用 Read 工具读文件一样自然。整个过程不需要引擎层做任何特殊处理,query() 仍然只是一个简单的 while(true) 循环。 + +## 四、System Prompt 的构造 + +System Prompt 就是 Claude Code 的灵魂,它定义了 Agent 的身份、行为规范、可用工具、安全约束……一切。但 Claude Code 的 System Prompt 不是一个静态的文本文件。它是动态组装的,由十几个 Section 拼接而成,而且在组装过程中做了非常精巧的缓存优化。我们先来看一下,Claude Code 的 System Prompt 到底长什么样,它是怎么「调教」大模型变成一个靠谱的编程 Agent 的。注:Claude Code 源码中所有 Prompt 原文均为英文。为了让大家更好地理解设计思路,下面展示的 Prompt 内容我翻译成了中文,并保留了关键术语的英文原文。### 角色定义与安全红线 + +每个 Agent 的 System Prompt 都要回答一个根本问题:你是谁?Claude Code 的开场是这样的:你是一个交互式代理(interactive agent),帮助用户完成软件工程任务。 +请使用下面的指令和可用的工具来协助用户。 + +重要:你绝对不能为用户生成或猜测 URL,除非你确信这些 URL +是为了帮助用户完成编程任务。你可以使用用户在消息或本地文件中 +提供的 URL。 +注意两个关键点。第一,它把自己定位为「interactive agent」,而不是「assistant」或「chatbot」,这从一开始就暗示了模型应该主动采取行动,而不是被动回答。第二,立刻划了安全红线:不能乱编 URL。这看起来是个小事,但对编程 Agent 非常重要,如果模型瞎编一个 npm 包的 URL,用户执行了就可能中招。紧接着是一段安全约束指令,这段话非常值得每个 Agent 开发者抄作业:重要:允许协助已授权的安全测试、防御性安全研究、CTF 挑战赛 +和教育场景。拒绝涉及破坏性技术、DoS 攻击、大规模目标扫描、 +供应链攻击或用于恶意目的的检测规避请求。 +这段 Prompt 没有用「绝对不能做 X」的口吻,而是先说「可以做什么」(授权的安全测试、CTF 挑战),再划定「不能做什么」(DoS、供应链攻击)。这种「先肯定再约束」的写法,比纯禁止清单效果好得多,它给了模型清晰的判断依据,而不是一堆模糊的红线。### 行为准则 + +接下来是一大段关于「怎么做事」的行为指南,这部分是 Claude Code System Prompt 的精华。我挑几条最值得学习的:关于修改代码前先阅读:一般来说,不要对你没有阅读过的代码提出修改建议。如果用户 +要求你查看或修改某个文件,先读一遍它。在提出修改建议之前, +先理解现有代码。 +这条看起来简单,但解决了 Agent 的一个常见问题:很多 Agent 会根据用户描述直接生成代码,而不先看看现有代码是什么样的,结果经常和项目风格不一致或者引入重复实现。关于代码风格:「少即是多」:不要在用户要求之外添加功能、重构代码或进行"改进"。修一个 bug +不需要顺手清理周围的代码。一个简单功能不需要额外的可配置性。 + +不要为一次性操作创建辅助函数、工具类或抽象层。 +三行相似的代码比一个过早的抽象更好。 +这个设计思路太重要了。如果你用过 Agent 写代码,你一定遇到过这种情况:你让它修一个 bug,它顺手把整个文件重构了,加了一堆你没要求的类型标注和错误处理。Claude Code 在 Prompt 里明确禁止了这种行为。关于失败处理:「先诊断再换方案」:如果某个方案失败了,先诊断原因再决定是否换方案——读报错信息、 +检查你的假设、尝试有针对性的修复。不要盲目重试完全相同的操作, +但也不要因为一次失败就放弃一个可行的方案。 +这条解决了 Agent 的另一个常见问题,「摆烂式重试」或「草率放弃」。Claude Code 要求模型先搞清楚为什么失败了,再决定是修复还是换方案,而不是两个极端。### 操作安全 + +Claude Code 对「什么操作需要用户确认」做了非常详细的规定。我建议每个 Agent 开发者都研读这段 Prompt:仔细考虑操作的可逆性(reversibility)和影响范围(blast radius)。 +一般来说,你可以自由执行本地的、可逆的操作,比如编辑文件或 +运行测试。但对于难以撤销、影响共享系统或有风险的操作, +请先和用户确认后再执行。 + +需要用户确认的高风险操作示例: +- 破坏性操作:删除文件/分支、删表、rm -rf +- 难以逆转的操作:force-push、git reset --hard、修改已发布的 commit +- 对他人可见的操作:推送代码、创建/关闭 PR、发送消息 +- 上传到第三方工具:内容可能被缓存或索引,即使删除也无法撤回 +这段的核心思想是用可逆性和影响范围两个维度来判断风险。读文件、改本地代码是低风险的(可逆、只影响本地),直接放行。git push、发 Slack 消息是高风险的(不可逆、影响他人),必须确认。然后还有一句非常精妙的补充:用户批准了某个操作(比如 git push)一次,并不意味着他在所有 +场景下都批准这个操作。授权仅对指定的范围有效,不能超出范围。 +这解决了「权限蔓延」的问题,用户同意了一次 push 不代表以后都自动 push,授权是一次性的、有范围的。这个原则在 Agent 权限设计中非常重要。### 工具使用指南 + +当有专用工具可用时,不要用 Bash 来执行命令。使用专用工具可以 +让用户更好地理解和审查你的工作。这一点至关重要: + +- 读取文件用 Read 工具,而不是 cat、head、tail 或 sed +- 编辑文件用 Edit 工具,而不是 sed 或 awk +- 创建文件用 Write 工具,而不是 echo 重定向 +- 搜索文件用 Glob 工具,而不是 find 或 ls +- 搜索内容用 Grep 工具,而不是 grep 或 rg +这条规则的设计动机值得深思。为什么不让模型直接用 cat 读文件、用 sed 改代码?技术上完全可以。原因是可审查性。当模型调用 Read 工具读文件时,UI 会清晰地展示「Agent 正在读取 src/index.ts」。但如果模型执行 cat src/index.ts,用户看到的只是一条 Bash 命令和一大坨输出,完全不知道 Agent 在干什么。而且,专用工具有专用的权限检查,Read 工具会检查文件路径是否在允许范围内,而 cat 命令就没有这层保护了。所以「用专用工具而不是 Bash」不仅是体验问题,更是安全问题。### Git 安全协议 + +Claude Code 对 Git 操作有一套非常严格的安全协议,这段 Prompt 写得极其细致:Git 安全协议: +- 绝不修改 git config +- 绝不执行破坏性 git 命令(push --force、reset --hard、 +  checkout .、clean -f),除非用户明确要求 +- 绝不跳过 hooks(--no-verify),除非用户明确要求 +- 绝不 force push 到 main/master 分支,如果用户要求则发出警告 + +关键:始终创建新的 commit(NEW commit),而不是用 --amend 修改。 +当 pre-commit hook 失败时,commit 实际上并没有发生——所以 +--amend 会修改上一个(不相关的)commit,可能导致代码丢失。 +正确做法是:修复问题后创建一个新的 commit。 +最后一条关于 --amend 的警告特别值得注意。很多人(包括一些 Agent 实现)在 commit 失败后会习惯性地 git commit --amend。但如果失败原因是 pre-commit hook 拒绝了,那么 commit 实际上没发生!这时候 --amend 会修改上一个(不相关的)commit,可能导致代码丢失。这种微妙的 bug 很难被发现,Claude Code 直接在 Prompt 里防住了。### 输出风格约束 + +Claude Code 对模型的输出风格也有严格规定:# 输出效率 +直奔重点。先尝试最简单的方案。要极度简洁。 +工具调用之间的文字不超过 25 个词。最终回复不超过 100 个词。 + +先给出答案或行动,而不是推理过程。跳过填充词、开场白和 +不必要的过渡句。不要复述用户说过的话——直接做就行。 +25 个词的限制非常苛刻,这意味着模型在两次工具调用之间,基本只能说一句话。这个设计的目的是避免 Agent 「话痨」,没人想看 Agent 在每次读文件前先写一段「让我来看看这个文件的内容……」的废话。### 环境信息注入 + +每次对话开始时,Claude Code 会把当前环境信息注入 System Prompt:# 环境信息 +- 主工作目录:/Users/you/my-project +- 是否为 Git 仓库:是 +- 操作系统平台:darwin (macOS) +- Shell 类型:zsh +- 当前模型:Claude Opus 4.6 (1M context) +- 知识截止日期:2025 年 5 月 +这些信息让模型知道自己「在哪里」,是什么操作系统、什么 Shell、什么项目目录。没有这些信息,模型可能会在 macOS 上执行 apt-get install,或者在 zsh 环境里用 bash 语法。### 分割线与三级缓存 + +了解了各个 Section 的内容,我们回到一个很实际的问题:这些 Section 是怎么组装到一起的?为什么组装方式会影响费用?先看一段组装后的 System Prompt 长什么样(简化版):┌─────────────────────────────────────────────────┐ +│  [角色定义] 你是一个交互式代理,帮助用户完成...    │  ← 所有用户完全一样 +│  [安全红线] 重要:允许协助已授权的安全测试...       │  ← 所有用户完全一样 +│  [行为准则] 一般来说,不要对你没有阅读过的代码...   │  ← 所有用户完全一样 +│  [操作安全] 仔细考虑操作的可逆性...               │  ← 所有用户完全一样 +│  [工具使用] 当有专用工具可用时...                  │  ← 所有用户完全一样 +│  [Git 安全] 绝不修改 git config...               │  ← 所有用户完全一样 +│  [输出风格] 直奔重点,要极度简洁...               │  ← 所有用户完全一样 +├────── __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__ ────────┤ +│  [环境信息] 主工作目录: /Users/you/my-project    │  ← 每个用户不一样 +│  [CLAUDE.md] 本项目使用 TypeScript + Jest...      │  ← 每个项目不一样 +│  [记忆指令] 你有一个持久记忆系统...               │  ← 每次对话可能不一样 +│  [MCP 指令] 你已连接 GitHub MCP server...         │  ← 每个用户不一样 +└─────────────────────────────────────────────────┘ +看到中间那条粗线了吗?那就是 Claude Code 在 System Prompt 中插入的分割标记 __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__。分割线之上的内容,对所有用户都完全一样。 不管你是北京的 Java 工程师还是纽约的 Python 开发者,你看到的「角色定义」「行为准则」「Git 安全协议」这些内容是一模一样的。分割线之下的内容,每个用户都不同。 你的工作目录、你的 CLAUDE.md、你的记忆文件、你连接的 MCP 服务,这些是因人而异的。为什么要这么分?因为 Claude API 有一个 Prompt Cache 机制:如果两次请求的 Prompt 前缀完全相同,API 会复用上次的计算结果,**费用可以降低 90%**。对于几万 Token 的 System Prompt 来说,缓存命中与否意味着每次请求几美分和几美元的差距。分割线之上的内容对所有用户都一样,所以可以全球所有用户共享同一份缓存——你用的和东京的开发者用的是同一份。而分割线之下的内容因人而异,没法共享,只能实时生成。这就是 Claude Code 的三级缓存体系:全局缓存(分割线之上,跨组织跨用户共享)→ 组织缓存(同一组织内跨会话共享)→ 会话缓存(同一个 Section 在一次会话内只计算一次)。每一级都在帮 API 省钱。### 小结 + +回过头来看 Claude Code 的 System Prompt,你会发现它其实在做一件事:用最小的 Token 成本,给模型划出最清晰的行为边界。怎么划的呢?我总结了三个最值得抄作业的设计。第一个是「先给范围再画红线」。比如安全约束那段,它不是一上来就说「不准做这不准做那」,而是先说「安全测试、CTF 挑战这些可以做」,然后再说「DoS、供应链攻击这些不能做」。这比你写十句「不准 XX」管用得多,因为模型拿到了判断标准,而不是一堆模糊的禁令。第二个是「用两个维度把风险分出层次」。Claude Code 判断一个操作安不安全,不看它「看起来危不危险」,而是看两件事:这操作能撤回吗?会影响别人吗?改本地代码当然能撤回、只影响自己,直接放行。git push 撤不回来、别人能看到,那就得确认。这个思路比笼统的「危险/安全」二分法精细太多了。第三个是「静态内容和动态内容用分割线隔开」。那条分割线不是随便画的,它把所有用户都一样的部分和因人而异的部分切开了。这样做的好处是,分割线之上的内容可以被全球所有用户共享缓存,每次 API 调用能省 90% 的费用。一个看似简单的排版调整,背后是实打实的成本优化。 + +## 五、记忆系统 + +每次启动 Claude Code 都是一个全新的会话,模型不记得上次对话的任何内容。但用户的偏好、项目背景、行为反馈,这些信息需要跨会话保持。这个问题看起来简单,做起来却非常难。业界常见的方案是用向量数据库,把记忆存成 embedding,每次对话时做相似度检索。但 Claude Code 没有这么做。为什么?因为 Agent 需要记住的大部分不是「相似的文档片段」,而是「用户说过'不要 mock 数据库'」这种结构化的行为指令。用向量相似度去检索「不要 mock 数据库」这句话,效果其实很差,它可能匹配到一堆包含「数据库」关键词的无关内容,真正重要的行为反馈却被淹没了。Claude Code 设计了一套完全不同的记忆系统,我们来一层一层拆解。### 记什么:四类型分类 + +Claude Code 把记忆分成了四种明确的类型:export const MEMORY_TYPES = [ +  'user',      // 用户画像:角色、偏好、知识水平 +  'feedback',  // 行为反馈:该做什么、不该做什么 +  'project',   // 项目动态:在做什么、截止日期、协作信息 +  'reference', // 外部指针:哪里能找到什么信息 +] as const +注意,只有这四种,不能随便加新的。为什么不搞一个通用的「any」类型什么都能存?因为无约束的记忆会迅速膨胀成垃圾堆。限定四种类型,就是在逼 Agent 做分类决策。每存一条记忆,它必须想清楚「这到底属于哪一类」,而不是一股脑往里塞。我逐个解释一下这四种类型的设计意图。User(用户画像)是最个人化的一类,记住用户是谁、擅长什么、知识水平如何。比如用户说「我是一个写了十年 Go 的后端工程师,第一次接触 React」,Agent 就应该在解释前端概念时用后端的类比,而不是从零讲起。这类记忆让 Agent 的回答因人而异,而不是千篇一律。Feedback(行为反馈)是最重要的一类,记住用户说过「不要做什么」和「做得好继续保持」。这类记忆的关键在于,它不仅记规则本身,还要求记录 Why(为什么) 和 How to apply(怎么应用):规则本身:集成测试必须使用真实数据库,不能用 mock +Why:上季度 mock 测试全部通过但生产环境迁移失败了 +How to apply:在这个模块写测试时,始终连接真实数据库 +为什么一定要记 Why?因为光记住「不要 mock 数据库」是不够的。如果遇到一个边缘情况,比如一个纯单元测试不涉及数据库迁移,Agent 需要根据 Why 来判断「这条规则在这个场景下是否适用」。没有 Why,Agent 只能盲目遵守,可能在不该用真实数据库的地方也强行连接。Project(项目动态)记的是「正在发生什么」,谁在做什么、截止日期是什么、有什么重要决策。这类记忆有一个特殊要求:必须把相对日期转成绝对日期。用户说「周四之前冻结合并」,Agent 要存成「2026-03-05 之前冻结合并」,因为「周四」过几天就没意义了,但「2026-03-05」永远准确。Reference(外部指针)记的是「去哪找什么信息」,Bug 在 Linear 的哪个项目里追踪、Grafana 看板的地址是什么、Slack 的哪个频道能问到相关的人。这类记忆的价值在于,Agent 不需要知道外部系统的具体内容,只需要知道去哪里找。### 不记什么:排除清单 + +Claude Code 明确规定了什么不应该存到记忆里,这个设计和「记什么」同样重要。首先是代码模式、项目架构和文件结构这些信息,通过 grep、git、CLAUDE.md 就能获取,存在记忆里反而会导致记忆和代码实际状态不一致。然后是 Git 历史和最近的改动,git log 和 git blame 才是权威来源,不需要记忆系统再来存一遍。调试方案和修复方法也不存,因为修复已经在代码里了,commit 消息已经记录了上下文。CLAUDE.md 里已经写了的内容也不存,避免重复。最后是临时任务状态和当前对话上下文,这些是会话级的信息,不需要跨会话保持。这个排除清单背后的核心原则是:可以从当前代码推导出来的信息,一律不存。因为代码是「活的」,它随时在变,但记忆是「死的」,它存下来就定格了。如果记忆说「AuthService 在 src/auth.ts 第 42 行」,但代码已经重构了,那这条记忆就变成了一个「权威的错误」,比没有记忆还糟糕。### 怎么存:索引 + 独立文件 + +搞清楚了「记什么」和「不记什么」,接下来看「怎么存」。每条记忆存为一个独立的 .md 文件,文件开头有一段 YAML 格式的元信息(你可以理解为这条记忆的「身份证」):--- +name: no-mock-database +description: 集成测试必须使用真实数据库,不能用 mock +type: feedback +--- + +集成测试必须使用真实数据库,不能用 mock。 + +**Why:** 上季度 mock 测试全部通过但生产环境迁移失败了。 +**How to apply:** 在这个模块写测试时,始终连接真实数据库。 +文件开头那段 YAML 格式的元信息里,三个字段各有用途:name 是人类可读的标识;description 是一句话摘要,专门用于检索时的相关性匹配(后面会讲到);type 标记四类型之一。然后有一个 MEMORY.md 文件作为索引,它是一个不超过 200 行(25KB)的轻量目录:- [No Mock Database](feedback_no_mock_db.md) — tests must use real DB +- [User Preferences](user_preferences.md) — prefers terse responses +- [Auth Rewrite](project_auth_rewrite.md) — driven by compliance, not tech debt +注意这个 200 行的硬性上限。为什么要限制?来看源码里的截断逻辑:export const MAX_ENTRYPOINT_LINES = 200 +exportconst MAX_ENTRYPOINT_BYTES = 25_000  // 25KB + +exportfunction truncateEntrypointContent(raw: string): EntrypointTruncation { +// 同时检查行数和字节数上限 +const wasLineTruncated = lineCount > MAX_ENTRYPOINT_LINES +const wasByteTruncated = byteCount > MAX_ENTRYPOINT_BYTES + +if (wasLineTruncated || wasByteTruncated) { +    // 截断并附加警告 +    return { +      content: truncated + '\n\n> WARNING: MEMORY.md 太大了...', +      // ... +    } +  } +} +它同时检查行数和字节数两个维度。为什么要两个?因为有人可能写了 199 行,每行 500 字,行数没超但字节数爆了。双重检查堵住了这个漏洞。现在来看整个存储架构的关键设计:MEMORY.md 索引始终被加载到 System Prompt 里,但独立记忆文件按需加载。这解决了一个经典矛盾,如果把所有记忆都塞进 System Prompt,50 条记忆就可能占满上下文;如果完全不塞,Agent 又不知道有哪些记忆可用。索引文件两全其美:Agent 看到索引就知道有哪些记忆,但只加载真正相关的那几条。### 怎么召回:Sonnet 当秘书 + +存好了记忆,关键问题来了:每次对话时,怎么从几十条记忆里挑出最相关的那几条加载进来?Claude Code 的做法非常巧妙,用一个廉价的小模型(Sonnet)来做记忆检索。整个召回流程分为三步: +- 第一步:扫描所有记忆文件的「头部信息」export asyncfunction scanMemoryFiles( +  memoryDir: string, +  signal: AbortSignal, +): Promise { +const entries = await readdir(memoryDir, { recursive: true }) +const mdFiles = entries.filter( +    f => f.endsWith('.md') && basename(f) !== 'MEMORY.md', +  ) +// 只读每个文件的前 30 行(frontmatter 区域),不读全文 +const headers = awaitPromise.allSettled( +    mdFiles.map(async (relativePath) => { +      const { content, mtimeMs } = await readFileInRange( +        filePath, 0, 30,  // 只读前 30 行! +      ) +      const { frontmatter } = parseFrontmatter(content) +      return { +        filename: relativePath, +        description: frontmatter.description || null, +        type: parseMemoryType(frontmatter.type), +        mtimeMs,  // 文件修改时间,用于后续的新旧度判断 +      } +    }), +  ) +// 按修改时间倒序,最多 200 个 +return headers.sort((a, b) => b.mtimeMs - a.mtimeMs) +    .slice(0, 200) +} +注意它只读每个文件的前 30 行,足够提取文件开头那段元信息里的 name、description、type,但不会读取记忆的完整内容。这样即使有 200 个记忆文件,扫描开销也很小。 +- 第二步:拼成清单,发给 Sonnet 做选择。扫描完之后,所有记忆的「头部信息」被拼成一个文本清单:- [feedback] feedback_no_mock.md (2026-03-28): 集成测试必须使用真实数据库 +- [user] user_preferences.md (2026-03-25): 用户是后端工程师,偏好简洁回复 +- [project] project_auth.md (2026-03-20): 认证模块重写由合规需求驱动 +然后把这个清单连同用户当前的输入一起发给 Sonnet:const result = await sideQuery({ +  model: getDefaultSonnetModel(), +  system: '你是一个记忆选择器,从列表中选出最多 5 条与用户问题最相关的记忆...', +  messages: [{ +    role: 'user', +    content: `用户问题: ${query}\n\n可用的记忆:\n${manifest}`, +  }], +  max_tokens: 256,  // 只需要返回文件名列表,非常短 +}) +Sonnet 返回的只是一个文件名列表(比如 ["feedback_no_mock.md", "project_auth.md"]),不是记忆内容本身。 +- 第三步:加载选中记忆的完整内容,注入上下文。拿到文件名列表后,系统才去读取这几条记忆的完整内容,作为  注入当前对话。这里还有一个非常讲究的细节,记忆陈旧度检测。对于超过 1 天的记忆,系统会自动附加一段警告:export function memoryFreshnessText(mtimeMs: number): string { +const d = memoryAgeDays(mtimeMs) +if (d <= 1) return''// 今天或昨天的记忆不加警告 +return ( +    `这条记忆已经有 ${d} 天了。` + +    `记忆是某个时间点的观察,不是实时状态——` + +    `其中关于代码行为或 file:line 引用的断言可能已经过时。` + +    `在当作事实引用之前,请先对照当前代码验证。` +  ) +} +为什么需要这个?因为用户可能 30 天前存了一条记忆说「AuthService 在 src/auth.ts 第 42 行使用了 JWT」,但代码早就改了。如果模型盲目相信这条记忆,就会给出错误的建议。陈旧度警告提醒模型「这个信息可能过时了,先验证再引用」。### 性能优化:并行预取 + +最后一个值得学习的设计:记忆召回的执行时机。Sonnet 侧查询不是在主模型需要时才触发的,而是在用户提交消息后立刻就开始了,和主模型的 API 调用并行执行:// query.ts 中的调用——在进入主循环之前就启动记忆预取 +using pendingMemoryPrefetch = startRelevantMemoryPrefetch( +  state.messages, +  state.toolUseContext, +) +时序大概是这样的:Sonnet 比 Opus 快得多(延迟通常只有几百毫秒),所以等主模型的响应回来时,记忆选择早就完成了。整个记忆召回过程几乎不增加任何额外延迟。还有一个小优化:如果用户当前正在使用某些工具(比如正在调用某个 MCP 工具),Sonnet 选择器会自动过滤掉该工具的使用文档类记忆,因为模型已经在用这个工具了,它的用法文档此刻是噪声,不是信号。但「该工具的已知 bug 和注意事项」类记忆仍然会被选中,正在用的时候,恰恰是最需要知道坑在哪里的时候。### 小结 + +回顾一下 Claude Code 的记忆系统,它的核心设计哲学可以用三句话概括。第一句是「记该记的,不记能推导的」。通过四类型封闭集合加上排除清单,把记忆控制在有价值的范围内,防止它膨胀成一个什么都往里塞的垃圾堆。第二句是「存索引,按需加载详情」。MEMORY.md 作为轻量索引始终常驻在 System Prompt 里,但每条记忆的具体内容是独立文件,用到的时候才加载。这样既让 Agent 知道有哪些记忆可用,又不会撑爆上下文。第三句是「用小模型做秘书,大模型做决策」。Sonnet 负责并行预取和选择记忆,Opus 只管做决策,加上陈旧度检测机制,实现了零延迟、低成本、高可靠。 + +## 六、上下文窗口管理 + +这可能是整个 Claude Code 里最复杂也最精妙的部分。大模型有上下文窗口限制。即使是 200K Token 的窗口,一次复杂的编程任务(读了几十个文件、执行了几十条命令)很容易就塞满了。业界常见的做法是「简单截断」,只保留最近的 N 条消息,旧的扔掉。但这对于编程 Agent 来说是灾难性的:你可能 20 轮前读过一个关键配置文件,现在要改代码时那个文件的信息已经被截掉了,Agent 就会犯低级错误。另一种做法是「全量摘要」,把整段对话总结成一段摘要。但这很贵(摘要本身就是一次 API 调用),而且有信息损失。### 压缩五步走 + +Claude Code 的核心理念是:压缩一定有信息损失,所以能不压就不压,必须压的时候从最轻的手段开始。它设计了五个从轻到重的压缩手段,就像医院的分诊制度一样:先试最温和的,不行再上猛药。在每次 API 调用前依次尝试:为什么要分五步,而不是一步到位做全量摘要?因为每一步的「代价」是递增的。第 1 层几乎没有信息损失,完整内容还在磁盘上,只是不在上下文里了。第 2、3 层有少量信息损失,丢掉了老的工具输出,但模型随时可以重新获取。第 4 层有中等信息损失,对话细节被分段压缩了。第 5 层信息损失最大,整段对话变成一段摘要。所以 Claude Code 的策略是:先用代价最小的手段,实在不行再升级。大部分情况下,前三层就够用了,根本不需要触发昂贵的全量摘要。接下来我们一层一层拆解。### 第 1 步:大结果存磁盘 + +问题是什么? 想象一下,你让 Agent 读一个 10MB 的日志文件。Read 工具忠实地返回了全部内容,一下子就吃掉了几万 Token。更夸张的是,如果模型同时读了 3 个大文件,一条消息就可能占掉大半个上下文窗口。Claude Code 怎么做? 它在工具结果进入消息列表之前,就先做一道「体检」:async function maybePersistLargeToolResult( +  toolResultBlock: ToolResultBlockParam, +  toolName: string, +): Promise { +const size = contentSize(content) +// 单个工具结果超过阈值(默认约 50KB)? +if (size <= threshold) { +    return toolResultBlock  // 没超,原样通过 +  } +// 超了!把完整内容存到磁盘文件 +const result = await persistToolResult(content, toolUseId) +// 用一个 2KB 的预览替换原内容 +const preview = buildLargeToolResultMessage(result) +return { ...toolResultBlock, content: preview } +} +它的逻辑很简单:如果单个工具的结果超过约 50KB,就把完整内容写到磁盘上,在消息里只留一个 2KB 的预览摘要。这样模型还是能看到文件的大概内容(前 2KB),但不会撑爆上下文。除了单个工具的限制,还有一个消息级的总量控制,同一条消息里所有工具结果的总大小不能超过 200KB。如果超了,系统会挑出最大的那几个结果存磁盘,直到总量降到限制以内。这一层的精妙之处在于:完整内容并没有丢,它还在磁盘上。如果模型后面真的需要那个大文件的某个片段,它可以再次调用 Read 工具去读取特定的行范围。### 第 2 步:砍掉远古消息 + +问题是什么? 一次长对话可能有上百轮。对话开头那几轮的内容,比如用户最初的探索性提问、模型早期的试探性回答,到了后面几乎完全没用了。但它们仍然占着宝贵的上下文空间。Claude Code 怎么做? Snip 是最「粗暴」但也最高效的一层,直接把对话开头的一批老消息移除掉,然后插入一个边界标记告诉模型「这之前的内容已经被清理了」。if (feature('HISTORY_SNIP')) { +  const snipResult = snipModule.snipCompactIfNeeded(messagesForQuery) +  messagesForQuery = snipResult.messages +  snipTokensFreed = snipResult.tokensFreed +  if (snipResult.boundaryMessage) { +    yield snipResult.boundaryMessage  // 插入边界标记 +  } +} +它不做任何摘要,不总结「前面聊了什么」,直接砍掉。听起来很暴力,但对于那些确实已经完全过时的消息来说,这是代价最低的做法,因为它不需要额外调用大模型来生成摘要,零 API 开销。还有一个重要的细节:Snip 会把「我释放了多少 Token」这个数字(snipTokensFreed)传给后面的第 5 层 Auto-Compact。为什么?因为 Auto-Compact 是根据「当前上下文占了多少 Token」来决定是否触发的。如果 Snip 已经释放了足够的空间,Auto-Compact 就不需要触发了,避免两层同时做无谓的压缩。### 第 3 步:裁剪老的工具输出 + +问题是什么? 经过前两层之后,上下文里剩下的都是「不太老但也不太新」的消息。这些消息不能直接砍掉(可能还有用),但里面大量的工具输出其实已经过时了,比如 30 分钟前读的一个文件,现在那个文件可能已经被改过了。Claude Code 怎么做? Micro-Compact 的核心思想是时间衰减:越老的工具结果越不重要,可以被裁剪。但是,不是所有工具的结果都能裁剪:const COMPACTABLE_TOOLS = new Set([ +  FILE_READ_TOOL_NAME,    // 读文件 → 可以重新读 +  ...SHELL_TOOL_NAMES,    // 执行命令 → 可以重新执行 +  GREP_TOOL_NAME,         // 搜索 → 可以重新搜 +  GLOB_TOOL_NAME,         // 查找文件 → 可以重新查 +  WEB_SEARCH_TOOL_NAME,   // 搜索网页 → 可以重新搜 +  FILE_EDIT_TOOL_NAME,    // 编辑文件 → 结果可裁剪 +  FILE_WRITE_TOOL_NAME,   // 写文件 → 结果可裁剪 +]) +看到规律了吗?可以被裁剪的,都是「可重新获取」的工具,Read 的结果可以再读一次,Bash 的输出可以再执行一次,搜索结果可以再搜一次。但 AgentTool(子 Agent 的输出)、TaskTool(任务状态)这类工具的结果永远不会被裁剪,因为子 Agent 的推理过程是不可重复的,砍掉就真的丢了。具体裁剪逻辑是「保留最近 N 个,清理其余的」:// 收集所有可裁剪工具的结果 ID +const compactableIds = collectCompactableToolIds(messages) +// 保留最近 5 个,其余全部清理 +const keepRecent = Math.max(1, config.keepRecent)  // 至少保留 1 个 +const keepSet = new Set(compactableIds.slice(-keepRecent)) +const clearSet = compactableIds.filter(id => !keepSet.has(id)) +被裁剪的工具结果会被替换成一个标记:export const TIME_BASED_MC_CLEARED_MESSAGE = +  '[Old tool result content cleared]' +这样模型看到这个标记就知道「这里原来有内容但被清理了」。如果它后面还需要这些信息,它可以自己决定重新读文件或重新执行命令。为什么叫「时间衰减」?因为它的触发条件跟时间有关,当距离上一次 API 调用超过一定时间(默认约 60 分钟),说明大模型 API 端的 Prompt Cache 大概率已经过期了。既然缓存已经没了,那清理旧的工具结果也不会浪费之前的缓存投入。### 第 4 步:读时投影 + +问题是什么? 经过前三层后,如果上下文还是太大,下一步就得做全量摘要了。但全量摘要代价很高(要额外调一次 API),而且会把整段对话的细节全部丢掉。有没有一个「中间态」,比全量摘要轻,但比 Micro-Compact 重?Claude Code 怎么做? Context Collapse 引入了一个非常巧妙的概念,读时投影(Read-Time Projection)。什么意思呢?前面三层都是「写时压缩」,直接修改消息列表,把内容替换掉或删掉。但 Context Collapse 不修改原始消息,它只在调用 API 的那一刻,动态计算一个「压缩视图」给模型看。// 这是 query.ts 中的调用 +// 注意:这是一个"读时投影"——不修改 REPL 的完整历史, +// 只在发送给 API 时计算压缩视图 +if (feature('CONTEXT_COLLAPSE') && contextCollapse) { +  const collapseResult = await contextCollapse.applyCollapsesIfNeeded( +    messagesForQuery, +    toolUseContext, +    querySource, +  ) +  messagesForQuery = collapseResult.messages +} +它的触发有两级阈值:90% 上下文窗口:主动开始分段压缩旧消息(预留缓冲区)95% 上下文窗口:紧急压缩更多内容(留足 API 响应空间)这个设计最精妙的地方是它和第 5 层的配合:Context Collapse 运行在 Auto-Compact 之前。如果 Context Collapse 已经通过「读时投影」把上下文压到了阈值以下,Auto-Compact 就完全不需要触发了。这样模型保留了更多的细节上下文,而不是被一段粗糙的全量摘要替代。### 第 5 步:全量摘要 + +问题是什么? 当前面四层都不够用,上下文实在太大了,必须做一次彻底的压缩。这是代价最高但效果最强的一层。什么时候触发? Claude Code 用一个公式计算触发阈值:function getAutoCompactThreshold(model: string): number { +  const effectiveContextWindow = getEffectiveContextWindowSize(model) +  // 有效窗口 - 13K 缓冲区 = 触发阈值 +  return effectiveContextWindow - 13_000 +} +以 200K Token 的模型为例:有效窗口大约 180K(预留 20K 给输出),减去 13K 缓冲区,当上下文达到 167K Token 时触发。触发后做了什么? 三步走: +- 第一步:生成摘要。调用大模型,把整段对话总结成一段结构化摘要。这个摘要不是随便写的,Claude Code 用一个精心设计的 Prompt 要求模型按多个维度来总结:用户的主要请求和意图、关键技术概念、涉及的文件和代码片段、遇到的错误和修复方案、问题解决过程、用户的所有消息(不能遗漏任何一条)、待完成的任务、当前工作状态、建议的下一步。为什么要这么细?因为压缩后模型要靠这段摘要来「恢复记忆」。如果摘要漏掉了关键信息(比如「用户还有一个待完成的任务」),模型就会忘记这件事。 +- 第二步:替换旧消息。把压缩边界之前的所有消息删掉,替换为刚才生成的摘要。同时插入一条边界标记消息,记录压缩前的 Token 数,方便后续追踪。 +- 第三步:Post-Compact Restoration(压缩后恢复)。这是整个流程中最关键的一步,压缩完不是就完了,还要主动恢复最重要的上下文:export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5 +export const POST_COMPACT_TOKEN_BUDGET = 50_000 +export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000 +export const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000 +系统会从文件状态缓存(fileStateCache)中找出最近访问过的文件,按最后访问时间排序,挑选最多 5 个、总共不超过 50K Token 的文件内容重新注入。同时恢复活跃的 Skill(不超过 25K Token),如果有进行中的 Plan 也会恢复 Plan 文件。为什么要做恢复?因为压缩后模型「失忆」了,它不记得刚才读过的文件内容了。如果不恢复,模型的第一反应就是「让我重新读一下文件」,白白浪费一轮工具调用。主动恢复最近的文件内容,可以让模型无缝继续工作,体验上几乎感觉不到压缩发生过。还有一个兜底机制:如果全量摘要连续失败 3 次(比如 API 超时),系统会自动放弃,不会无限重试,这就是熔断器 模式,防止一个失败的压缩操作拖垮整个 Agent。### 小结 + +回顾一下这五步压缩策略,它们体现了一个核心设计哲学:能轻则轻,逐步加码。层级手段信息损失API 开销触发条件第 1 层大结果存磁盘几乎为零零工具结果超 50KB第 2 层砍掉远古消息低零消息过时第 3 层清理老工具输出中低零缓存过期/数量超限第 4 层读时投影压缩中低上下文达 90%第 5 层全量摘要高高(一次 API 调用)上下文达 ~93%越往下代价越高,但效果也越强。大部分场景下前三层就足够了,它们完全不需要额外的 API 调用,只是「搬运」和「裁剪」数据。只有在极端情况下,才需要触发昂贵的全量摘要。这种设计的另一个好处是各层相互协调。第 2 层 Snip 会告诉第 5 层「我已经释放了多少 Token」,避免重复压缩。第 4 层 Context Collapse 在第 5 层之前运行,如果它够用了,第 5 层就不触发。每一层都在为下一层「减负」。 diff --git "a/docs/readme_img/claude\346\272\220\347\240\201\347\240\224\347\251\266\346\226\207\346\241\243/claude\346\236\266\346\236\204\350\256\276\350\256\241_\345\216\237\346\226\207\345\244\207\344\273\275.md" "b/docs/readme_img/claude\346\272\220\347\240\201\347\240\224\347\251\266\346\226\207\346\241\243/claude\346\236\266\346\236\204\350\256\276\350\256\241_\345\216\237\346\226\207\345\244\207\344\273\275.md" new file mode 100644 index 0000000..ed1cf88 --- /dev/null +++ "b/docs/readme_img/claude\346\272\220\347\240\201\347\240\224\347\251\266\346\226\207\346\241\243/claude\346\236\266\346\236\204\350\256\276\350\256\241_\345\216\237\346\226\207\345\244\207\344\273\275.md" @@ -0,0 +1,254 @@ +二、架构设计一个能自主编程的 Agent 要处理的事情非常多:调大模型 API、执行 40 多种工具、管理权限、压缩上下文、维护记忆、支持多 Agent 协作……如果这些东西全部塞在一个文件里,代码会立刻变成一团乱麻。那 Claude Code 是怎么组织这些子系统的?它采用了一个四层分层架构:我们从上往下,一层一层来理解。引擎层是 Agent 的「大脑」,负责思考和调度。它的关键设计原则是不包含任何业务逻辑,它不知道怎么读文件、怎么改代码、怎么搜索,这些全是工具层的事。引擎层只做三件事:第一,协调,把用户输入、系统指令、历史对话拼在一起,发给大模型;第二,分发,大模型说「我要用某个工具」时,找到对应的工具并执行;第三,决策,根据大模型的返回决定是继续循环还是结束对话。这种设计的好处是:新增能力只需要新增一个工具,引擎层完全不用改。工具层是 Agent 的全部「能力」,40 多个工具都在这一层。每个工具就是 Agent 的一个能力:执行 Shell 命令、读写文件、搜索代码、生成子 Agent……这些工具不是随便写的,它们遵循一个统一的规范。这个规范不仅定义了「工具能做什么」,还强制定义了三个安全属性:这个工具是只读的还是会改东西的?它是否具有破坏性需要额外确认?它能不能和其他工具同时执行?这三个属性不是「建议加上」的,而是类型系统强制要求的,漏了任何一个,代码就编译不过。这意味着每一把刀都有刀鞘,从出厂就配好了安全机制。服务层是所有层共享的「基础设施」。这一层包括三样东西:调大模型 API(不管是谁要调,主循环也好、子 Agent 也好,都走这一层)、上下文压缩(后面会详细讲的五步压缩策略)、MCP 协议(和外部工具服务器通信的标准接口)。你可以把它类比成大楼的水电煤,所有楼层都需要,但谁也不会自己去铺设管道。安全与治理层有点特殊,它不像其他三层那样各管一块,而是像一张安全网罩在所有层上面。权限系统决定哪些操作需要用户确认、哪些可以自动执行;Hook 系统允许在工具执行前后插入自定义行为(比如「每次 git push 前自动跑 lint」);Bash 安全模块会对 Shell 命令做语法级别的分析,检测命令注入、路径逃逸等危险模式,而不是简单地用正则匹配关键词。三、Agent 工作模式搞清楚了四层架构的宏观布局之后,一个自然的问题来了:引擎层那个主循环里,到底发生了什么?Agent 是怎么「思考」和「行动」的?它用的是什么 Agent 框架?是大家常说的 ReAct 模式吗?这个问题值得深入聊聊,因为 Agent 的工作模式决定了整个系统的架构走向。Claude Code 的答案可能出乎你的意料,它没有用 ReAct,而是用了一个更简洁、更高效的模式。什么是 ReAct如果你接触过 Agent 开发,大概率听说过 ReAct(Reasoning + Acting)。它是 2022 年提出的一种 Agent 范式,核心思路是把 Agent 的每一步拆成三个阶段:具体来说,模型在每一轮都会先输出一段「思考」(Thought),比如「我需要先读取 config.ts 文件来了解数据库连接配置」;然后选择一个工具调用(Action);最后拿到工具结果(Observation)。这三步不断循环,直到模型认为任务完成。这个模式在 2022 年非常流行,因为当时的大模型(GPT-3.5 时代)推理能力有限,需要用显式的「Thought」步骤来引导模型一步步思考。但 ReAct 有几个问题:第一个问题:Token 浪费。 每一轮都要输出一段 Thought 文本,这些文本要作为上下文的一部分发给 API,占用了宝贵的 Token 预算。对于编程 Agent 来说,一次任务可能循环 50 轮,每轮都写一段「我打算先读取……然后分析……」的思考过程,加起来就是好几万 Token 的浪费。第二个问题:应用层代码太复杂。 你需要解析模型的输出,区分「哪部分是 Thought、哪部分是 Action」,然后提取 Action 调用工具,再把 Observation 拼回去。这个解析过程写起来很麻烦,而且很容易出 bug,因为模型输出的格式不一定标准,一崩就全崩了。第三个问题:ReAct 是为「弱模型」设计的。 当大模型的推理能力不够强时,用显式的 Thought 来「强迫」它一步步思考是有意义的。但 Claude Opus 这种级别的模型,推理能力已经足够强了,它完全可以在内部完成推理,不需要在输出里显式写出每一步的思考过程。Tool-Use LoopClaude Code 没有采用 ReAct 的 Thought-Action-Observation 三步循环,而是用了一个更简洁的模式,我把它叫做 Tool-Use Loop。核心思路非常简单,就一个 while(true) 循环:看到区别了吗?没有 Thought 步骤。模型在内部完成推理(通过 Extended Thinking,这是 Claude Opus 的一个能力,模型在生成回复前会在内部进行一段不可见的深度推理,不占用上下文空间),然后直接返回两种结果之一:**tool_use**:「我要用某个工具」,应用层执行工具,把结果拼入消息列表,继续循环**end_turn**:「我说完了」,跳出循环,把最终结果返回给用户这个设计的核心哲学是:信任模型的推理能力,保持应用层框架尽可能简单。来看 query.ts 中的核心循环,它的实际代码长这样(这是一段 TypeScript 代码,其中 yield 的作用是流式输出,你可以理解为一边接收 API 的响应,一边把每个 token 实时传给 UI 显示):async function* queryLoop( +  params: QueryParams, +  consumedCommandUuids: string[], +): AsyncGenerator { +let state: State = { messages, toolUseContext, turnCount: 1, ... } + +while (true) { +    // 步骤 1:压缩上下文(五步从轻到重) +    // 步骤 2:调用大模型 API,流式接收 +    forawait (const event of streamAPI(params)) { +      yield event  // 流式输出每个 token +    } +    // 步骤 3:分析模型返回 +    if (response.stopReason === 'end_turn') break// 完成了,跳出循环 + +    // 步骤 4:执行工具调用(并发/串行编排) +    const toolResults = await executeToolCalls(toolUseMessages) + +    // 步骤 5:更新 state,继续循环 +    state = { ...state, messages: updatedMessages, turnCount: turnCount + 1 } +    continue +  } +} +注意 break 和 continue,模型说 end_turn 就 break 跳出循环,说 tool_use 就 continue 回到循环开头。整个决策逻辑就这么简单。为什么比 ReAct 更好你可能会问:不就是把 Thought 去掉了吗,有什么了不起的?区别其实很大,我列了三个关键原因:第一,Extended Thinking 让推理在「模型内部」完成。 Claude Opus 支持 Extended Thinking,模型在生成最终回复之前,会在内部进行一段不可见的深度推理。这段推理发生在模型内部,不占用应用的上下文空间,也不需要应用层去解析。所以 ReAct 的 Thought 步骤在 Claude 的架构里是多余的,模型已经在内部「想好了」,不需要在外部输出中再写一遍。第二,API 原生支持 tool_use。 Claude 的 API 原生支持工具调用,模型可以直接返回 tool_use 类型的响应,不需要用正则表达式从文本中提取「Action」。这消除了 ReAct 的格式解析问题,应用层代码变得极其简洁。第三,end_turn 作为天然的终止信号。 ReAct 需要一套额外的规则来判断「Agent 是否完成了」,比如检测输出中是否包含「Final Answer」。而 Tool-Use Loop 用模型的 end_turn 信号作为终止条件,这是 API 层面的原语,语义清晰,不需要任何解析。用一个表格来总结两者的区别:维度ReActTool-Use Loop推理方式显式 Thought 文本模型内部 Extended Thinking工具调用解析文本提取 ActionAPI 原生 tool_use终止判断检测 「Final Answer」API 原生 end_turnToken 开销每轮要输出 Thought无额外开销编排复杂度高(需要解析 Thought/Action)低(只需要 if/else)适合场景弱模型 + 简单工具强模型 + 复杂工具集Claude Code 的 Agent 工作模式可以总结为一句话:信任模型的推理能力,把应用层框架做得尽可能简单。ReAct 的设计哲学是「帮模型思考」,用显式的 Thought 步骤引导模型一步步推理。这在弱模型时代是必要的。但 Claude Code 面对的是 Opus 级别的强模型,它的推理能力完全可以在内部完成,不需要应用层去「教」它怎么想。所以 Claude Code 的 Tool-Use Loop 只做最简单的事:调 API、执行工具、再调 API。推理交给模型,执行交给工具,编排交给最简单的 while(true) 循环。这种「大道至简」的设计,反而是最高效的。Plan ModeClaude Code 不仅有 Tool-Use Loop 这种「边想边做」模式,还有 Plan Mode,一个更精细的两阶段工作流:先规划、再执行。Plan Mode 的核心思想是:复杂任务应该先规划再执行,避免方向跑偏、浪费精力。它并不是一个独立的框架,而是在同一个 Tool-Use Loop 中通过 EnterPlanMode 和 ExitPlanMode 两个工具实现的:整个流程分三步:第一步:模型自主进入或用户手动触发。 当模型判断「这是一个复杂任务」时,它会调用 EnterPlanMode 工具。对于简单任务(修 typo、加 console.log),则明确不进入。用户也可以通过 Shift+Tab 手动切换。第二步:只读探索 + 设计方案。 进入 Plan Mode 后,权限降为只读,模型只能用 Read、Grep、Glob 这些工具去探索代码库,不能写文件、不能改代码、不能跑命令。探索完后,把计划写入 .claude/plans/ 目录。每 5 轮对话,系统会偷偷给模型塞一张「小纸条」,提醒它「你现在还在 Plan Mode,别手痒改代码」,防止模型在长对话中「走神」。第三步:用户审批后实施。 模型调用 ExitPlanMode,此时需要用户确认。用户批准后,权限恢复为之前的模式,模型开始自由执行读写操作,按计划实施。Plan Mode 最值得学习的设计是「工具即能力」。对模型来说,Plan Mode 不是一种特殊的「模式切换」,而只是调用了 EnterPlanMode 和 ExitPlanMode 这两个工具。就像调用 Read 工具读文件一样自然。整个过程不需要引擎层做任何特殊处理,query() 仍然只是一个简单的 while(true) 循环。四、System Prompt 的构造System Prompt 就是 Claude Code 的灵魂,它定义了 Agent 的身份、行为规范、可用工具、安全约束……一切。但 Claude Code 的 System Prompt 不是一个静态的文本文件。它是动态组装的,由十几个 Section 拼接而成,而且在组装过程中做了非常精巧的缓存优化。我们先来看一下,Claude Code 的 System Prompt 到底长什么样,它是怎么「调教」大模型变成一个靠谱的编程 Agent 的。注:Claude Code 源码中所有 Prompt 原文均为英文。为了让大家更好地理解设计思路,下面展示的 Prompt 内容我翻译成了中文,并保留了关键术语的英文原文。角色定义与安全红线每个 Agent 的 System Prompt 都要回答一个根本问题:你是谁?Claude Code 的开场是这样的:你是一个交互式代理(interactive agent),帮助用户完成软件工程任务。 +请使用下面的指令和可用的工具来协助用户。 + +重要:你绝对不能为用户生成或猜测 URL,除非你确信这些 URL +是为了帮助用户完成编程任务。你可以使用用户在消息或本地文件中 +提供的 URL。 +注意两个关键点。第一,它把自己定位为「interactive agent」,而不是「assistant」或「chatbot」,这从一开始就暗示了模型应该主动采取行动,而不是被动回答。第二,立刻划了安全红线:不能乱编 URL。这看起来是个小事,但对编程 Agent 非常重要,如果模型瞎编一个 npm 包的 URL,用户执行了就可能中招。紧接着是一段安全约束指令,这段话非常值得每个 Agent 开发者抄作业:重要:允许协助已授权的安全测试、防御性安全研究、CTF 挑战赛 +和教育场景。拒绝涉及破坏性技术、DoS 攻击、大规模目标扫描、 +供应链攻击或用于恶意目的的检测规避请求。 +这段 Prompt 没有用「绝对不能做 X」的口吻,而是先说「可以做什么」(授权的安全测试、CTF 挑战),再划定「不能做什么」(DoS、供应链攻击)。这种「先肯定再约束」的写法,比纯禁止清单效果好得多,它给了模型清晰的判断依据,而不是一堆模糊的红线。行为准则接下来是一大段关于「怎么做事」的行为指南,这部分是 Claude Code System Prompt 的精华。我挑几条最值得学习的:关于修改代码前先阅读:一般来说,不要对你没有阅读过的代码提出修改建议。如果用户 +要求你查看或修改某个文件,先读一遍它。在提出修改建议之前, +先理解现有代码。 +这条看起来简单,但解决了 Agent 的一个常见问题:很多 Agent 会根据用户描述直接生成代码,而不先看看现有代码是什么样的,结果经常和项目风格不一致或者引入重复实现。关于代码风格:「少即是多」:不要在用户要求之外添加功能、重构代码或进行"改进"。修一个 bug +不需要顺手清理周围的代码。一个简单功能不需要额外的可配置性。 + +不要为一次性操作创建辅助函数、工具类或抽象层。 +三行相似的代码比一个过早的抽象更好。 +这个设计思路太重要了。如果你用过 Agent 写代码,你一定遇到过这种情况:你让它修一个 bug,它顺手把整个文件重构了,加了一堆你没要求的类型标注和错误处理。Claude Code 在 Prompt 里明确禁止了这种行为。关于失败处理:「先诊断再换方案」:如果某个方案失败了,先诊断原因再决定是否换方案——读报错信息、 +检查你的假设、尝试有针对性的修复。不要盲目重试完全相同的操作, +但也不要因为一次失败就放弃一个可行的方案。 +这条解决了 Agent 的另一个常见问题,「摆烂式重试」或「草率放弃」。Claude Code 要求模型先搞清楚为什么失败了,再决定是修复还是换方案,而不是两个极端。操作安全Claude Code 对「什么操作需要用户确认」做了非常详细的规定。我建议每个 Agent 开发者都研读这段 Prompt:仔细考虑操作的可逆性(reversibility)和影响范围(blast radius)。 +一般来说,你可以自由执行本地的、可逆的操作,比如编辑文件或 +运行测试。但对于难以撤销、影响共享系统或有风险的操作, +请先和用户确认后再执行。 + +需要用户确认的高风险操作示例: +- 破坏性操作:删除文件/分支、删表、rm -rf +- 难以逆转的操作:force-push、git reset --hard、修改已发布的 commit +- 对他人可见的操作:推送代码、创建/关闭 PR、发送消息 +- 上传到第三方工具:内容可能被缓存或索引,即使删除也无法撤回 +这段的核心思想是用可逆性和影响范围两个维度来判断风险。读文件、改本地代码是低风险的(可逆、只影响本地),直接放行。git push、发 Slack 消息是高风险的(不可逆、影响他人),必须确认。然后还有一句非常精妙的补充:用户批准了某个操作(比如 git push)一次,并不意味着他在所有 +场景下都批准这个操作。授权仅对指定的范围有效,不能超出范围。 +这解决了「权限蔓延」的问题,用户同意了一次 push 不代表以后都自动 push,授权是一次性的、有范围的。这个原则在 Agent 权限设计中非常重要。工具使用指南当有专用工具可用时,不要用 Bash 来执行命令。使用专用工具可以 +让用户更好地理解和审查你的工作。这一点至关重要: + +- 读取文件用 Read 工具,而不是 cat、head、tail 或 sed +- 编辑文件用 Edit 工具,而不是 sed 或 awk +- 创建文件用 Write 工具,而不是 echo 重定向 +- 搜索文件用 Glob 工具,而不是 find 或 ls +- 搜索内容用 Grep 工具,而不是 grep 或 rg +这条规则的设计动机值得深思。为什么不让模型直接用 cat 读文件、用 sed 改代码?技术上完全可以。原因是可审查性。当模型调用 Read 工具读文件时,UI 会清晰地展示「Agent 正在读取 src/index.ts」。但如果模型执行 cat src/index.ts,用户看到的只是一条 Bash 命令和一大坨输出,完全不知道 Agent 在干什么。而且,专用工具有专用的权限检查,Read 工具会检查文件路径是否在允许范围内,而 cat 命令就没有这层保护了。所以「用专用工具而不是 Bash」不仅是体验问题,更是安全问题。Git 安全协议Claude Code 对 Git 操作有一套非常严格的安全协议,这段 Prompt 写得极其细致:Git 安全协议: +- 绝不修改 git config +- 绝不执行破坏性 git 命令(push --force、reset --hard、 +  checkout .、clean -f),除非用户明确要求 +- 绝不跳过 hooks(--no-verify),除非用户明确要求 +- 绝不 force push 到 main/master 分支,如果用户要求则发出警告 + +关键:始终创建新的 commit(NEW commit),而不是用 --amend 修改。 +当 pre-commit hook 失败时,commit 实际上并没有发生——所以 +--amend 会修改上一个(不相关的)commit,可能导致代码丢失。 +正确做法是:修复问题后创建一个新的 commit。 +最后一条关于 --amend 的警告特别值得注意。很多人(包括一些 Agent 实现)在 commit 失败后会习惯性地 git commit --amend。但如果失败原因是 pre-commit hook 拒绝了,那么 commit 实际上没发生!这时候 --amend 会修改上一个(不相关的)commit,可能导致代码丢失。这种微妙的 bug 很难被发现,Claude Code 直接在 Prompt 里防住了。输出风格约束Claude Code 对模型的输出风格也有严格规定:# 输出效率 +直奔重点。先尝试最简单的方案。要极度简洁。 +工具调用之间的文字不超过 25 个词。最终回复不超过 100 个词。 + +先给出答案或行动,而不是推理过程。跳过填充词、开场白和 +不必要的过渡句。不要复述用户说过的话——直接做就行。 +25 个词的限制非常苛刻,这意味着模型在两次工具调用之间,基本只能说一句话。这个设计的目的是避免 Agent 「话痨」,没人想看 Agent 在每次读文件前先写一段「让我来看看这个文件的内容……」的废话。环境信息注入每次对话开始时,Claude Code 会把当前环境信息注入 System Prompt:# 环境信息 +- 主工作目录:/Users/you/my-project +- 是否为 Git 仓库:是 +- 操作系统平台:darwin (macOS) +- Shell 类型:zsh +- 当前模型:Claude Opus 4.6 (1M context) +- 知识截止日期:2025 年 5 月 +这些信息让模型知道自己「在哪里」,是什么操作系统、什么 Shell、什么项目目录。没有这些信息,模型可能会在 macOS 上执行 apt-get install,或者在 zsh 环境里用 bash 语法。分割线与三级缓存了解了各个 Section 的内容,我们回到一个很实际的问题:这些 Section 是怎么组装到一起的?为什么组装方式会影响费用?先看一段组装后的 System Prompt 长什么样(简化版):┌─────────────────────────────────────────────────┐ +│  [角色定义] 你是一个交互式代理,帮助用户完成...    │  ← 所有用户完全一样 +│  [安全红线] 重要:允许协助已授权的安全测试...       │  ← 所有用户完全一样 +│  [行为准则] 一般来说,不要对你没有阅读过的代码...   │  ← 所有用户完全一样 +│  [操作安全] 仔细考虑操作的可逆性...               │  ← 所有用户完全一样 +│  [工具使用] 当有专用工具可用时...                  │  ← 所有用户完全一样 +│  [Git 安全] 绝不修改 git config...               │  ← 所有用户完全一样 +│  [输出风格] 直奔重点,要极度简洁...               │  ← 所有用户完全一样 +├────── __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__ ────────┤ +│  [环境信息] 主工作目录: /Users/you/my-project    │  ← 每个用户不一样 +│  [CLAUDE.md] 本项目使用 TypeScript + Jest...      │  ← 每个项目不一样 +│  [记忆指令] 你有一个持久记忆系统...               │  ← 每次对话可能不一样 +│  [MCP 指令] 你已连接 GitHub MCP server...         │  ← 每个用户不一样 +└─────────────────────────────────────────────────┘ +看到中间那条粗线了吗?那就是 Claude Code 在 System Prompt 中插入的分割标记 __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__。分割线之上的内容,对所有用户都完全一样。 不管你是北京的 Java 工程师还是纽约的 Python 开发者,你看到的「角色定义」「行为准则」「Git 安全协议」这些内容是一模一样的。分割线之下的内容,每个用户都不同。 你的工作目录、你的 CLAUDE.md、你的记忆文件、你连接的 MCP 服务,这些是因人而异的。为什么要这么分?因为 Claude API 有一个 Prompt Cache 机制:如果两次请求的 Prompt 前缀完全相同,API 会复用上次的计算结果,**费用可以降低 90%**。对于几万 Token 的 System Prompt 来说,缓存命中与否意味着每次请求几美分和几美元的差距。分割线之上的内容对所有用户都一样,所以可以全球所有用户共享同一份缓存——你用的和东京的开发者用的是同一份。而分割线之下的内容因人而异,没法共享,只能实时生成。这就是 Claude Code 的三级缓存体系:全局缓存(分割线之上,跨组织跨用户共享)→ 组织缓存(同一组织内跨会话共享)→ 会话缓存(同一个 Section 在一次会话内只计算一次)。每一级都在帮 API 省钱。小结回过头来看 Claude Code 的 System Prompt,你会发现它其实在做一件事:用最小的 Token 成本,给模型划出最清晰的行为边界。怎么划的呢?我总结了三个最值得抄作业的设计。第一个是「先给范围再画红线」。比如安全约束那段,它不是一上来就说「不准做这不准做那」,而是先说「安全测试、CTF 挑战这些可以做」,然后再说「DoS、供应链攻击这些不能做」。这比你写十句「不准 XX」管用得多,因为模型拿到了判断标准,而不是一堆模糊的禁令。第二个是「用两个维度把风险分出层次」。Claude Code 判断一个操作安不安全,不看它「看起来危不危险」,而是看两件事:这操作能撤回吗?会影响别人吗?改本地代码当然能撤回、只影响自己,直接放行。git push 撤不回来、别人能看到,那就得确认。这个思路比笼统的「危险/安全」二分法精细太多了。第三个是「静态内容和动态内容用分割线隔开」。那条分割线不是随便画的,它把所有用户都一样的部分和因人而异的部分切开了。这样做的好处是,分割线之上的内容可以被全球所有用户共享缓存,每次 API 调用能省 90% 的费用。一个看似简单的排版调整,背后是实打实的成本优化。五、记忆系统每次启动 Claude Code 都是一个全新的会话,模型不记得上次对话的任何内容。但用户的偏好、项目背景、行为反馈,这些信息需要跨会话保持。这个问题看起来简单,做起来却非常难。业界常见的方案是用向量数据库,把记忆存成 embedding,每次对话时做相似度检索。但 Claude Code 没有这么做。为什么?因为 Agent 需要记住的大部分不是「相似的文档片段」,而是「用户说过'不要 mock 数据库'」这种结构化的行为指令。用向量相似度去检索「不要 mock 数据库」这句话,效果其实很差,它可能匹配到一堆包含「数据库」关键词的无关内容,真正重要的行为反馈却被淹没了。Claude Code 设计了一套完全不同的记忆系统,我们来一层一层拆解。记什么:四类型分类Claude Code 把记忆分成了四种明确的类型:export const MEMORY_TYPES = [ +  'user',      // 用户画像:角色、偏好、知识水平 +  'feedback',  // 行为反馈:该做什么、不该做什么 +  'project',   // 项目动态:在做什么、截止日期、协作信息 +  'reference', // 外部指针:哪里能找到什么信息 +] as const +注意,只有这四种,不能随便加新的。为什么不搞一个通用的「any」类型什么都能存?因为无约束的记忆会迅速膨胀成垃圾堆。限定四种类型,就是在逼 Agent 做分类决策。每存一条记忆,它必须想清楚「这到底属于哪一类」,而不是一股脑往里塞。我逐个解释一下这四种类型的设计意图。User(用户画像)是最个人化的一类,记住用户是谁、擅长什么、知识水平如何。比如用户说「我是一个写了十年 Go 的后端工程师,第一次接触 React」,Agent 就应该在解释前端概念时用后端的类比,而不是从零讲起。这类记忆让 Agent 的回答因人而异,而不是千篇一律。Feedback(行为反馈)是最重要的一类,记住用户说过「不要做什么」和「做得好继续保持」。这类记忆的关键在于,它不仅记规则本身,还要求记录 Why(为什么) 和 How to apply(怎么应用):规则本身:集成测试必须使用真实数据库,不能用 mock +Why:上季度 mock 测试全部通过但生产环境迁移失败了 +How to apply:在这个模块写测试时,始终连接真实数据库 +为什么一定要记 Why?因为光记住「不要 mock 数据库」是不够的。如果遇到一个边缘情况,比如一个纯单元测试不涉及数据库迁移,Agent 需要根据 Why 来判断「这条规则在这个场景下是否适用」。没有 Why,Agent 只能盲目遵守,可能在不该用真实数据库的地方也强行连接。Project(项目动态)记的是「正在发生什么」,谁在做什么、截止日期是什么、有什么重要决策。这类记忆有一个特殊要求:必须把相对日期转成绝对日期。用户说「周四之前冻结合并」,Agent 要存成「2026-03-05 之前冻结合并」,因为「周四」过几天就没意义了,但「2026-03-05」永远准确。Reference(外部指针)记的是「去哪找什么信息」,Bug 在 Linear 的哪个项目里追踪、Grafana 看板的地址是什么、Slack 的哪个频道能问到相关的人。这类记忆的价值在于,Agent 不需要知道外部系统的具体内容,只需要知道去哪里找。不记什么:排除清单Claude Code 明确规定了什么不应该存到记忆里,这个设计和「记什么」同样重要。首先是代码模式、项目架构和文件结构这些信息,通过 grep、git、CLAUDE.md 就能获取,存在记忆里反而会导致记忆和代码实际状态不一致。然后是 Git 历史和最近的改动,git log 和 git blame 才是权威来源,不需要记忆系统再来存一遍。调试方案和修复方法也不存,因为修复已经在代码里了,commit 消息已经记录了上下文。CLAUDE.md 里已经写了的内容也不存,避免重复。最后是临时任务状态和当前对话上下文,这些是会话级的信息,不需要跨会话保持。这个排除清单背后的核心原则是:可以从当前代码推导出来的信息,一律不存。因为代码是「活的」,它随时在变,但记忆是「死的」,它存下来就定格了。如果记忆说「AuthService 在 src/auth.ts 第 42 行」,但代码已经重构了,那这条记忆就变成了一个「权威的错误」,比没有记忆还糟糕。怎么存:索引 + 独立文件搞清楚了「记什么」和「不记什么」,接下来看「怎么存」。每条记忆存为一个独立的 .md 文件,文件开头有一段 YAML 格式的元信息(你可以理解为这条记忆的「身份证」):--- +name: no-mock-database +description: 集成测试必须使用真实数据库,不能用 mock +type: feedback +--- + +集成测试必须使用真实数据库,不能用 mock。 + +**Why:** 上季度 mock 测试全部通过但生产环境迁移失败了。 +**How to apply:** 在这个模块写测试时,始终连接真实数据库。 +文件开头那段 YAML 格式的元信息里,三个字段各有用途:name 是人类可读的标识;description 是一句话摘要,专门用于检索时的相关性匹配(后面会讲到);type 标记四类型之一。然后有一个 MEMORY.md 文件作为索引,它是一个不超过 200 行(25KB)的轻量目录:- [No Mock Database](feedback_no_mock_db.md) — tests must use real DB +- [User Preferences](user_preferences.md) — prefers terse responses +- [Auth Rewrite](project_auth_rewrite.md) — driven by compliance, not tech debt +注意这个 200 行的硬性上限。为什么要限制?来看源码里的截断逻辑:export const MAX_ENTRYPOINT_LINES = 200 +exportconst MAX_ENTRYPOINT_BYTES = 25_000  // 25KB + +exportfunction truncateEntrypointContent(raw: string): EntrypointTruncation { +// 同时检查行数和字节数上限 +const wasLineTruncated = lineCount > MAX_ENTRYPOINT_LINES +const wasByteTruncated = byteCount > MAX_ENTRYPOINT_BYTES + +if (wasLineTruncated || wasByteTruncated) { +    // 截断并附加警告 +    return { +      content: truncated + '\n\n> WARNING: MEMORY.md 太大了...', +      // ... +    } +  } +} +它同时检查行数和字节数两个维度。为什么要两个?因为有人可能写了 199 行,每行 500 字,行数没超但字节数爆了。双重检查堵住了这个漏洞。现在来看整个存储架构的关键设计:MEMORY.md 索引始终被加载到 System Prompt 里,但独立记忆文件按需加载。这解决了一个经典矛盾,如果把所有记忆都塞进 System Prompt,50 条记忆就可能占满上下文;如果完全不塞,Agent 又不知道有哪些记忆可用。索引文件两全其美:Agent 看到索引就知道有哪些记忆,但只加载真正相关的那几条。怎么召回:Sonnet 当秘书存好了记忆,关键问题来了:每次对话时,怎么从几十条记忆里挑出最相关的那几条加载进来?Claude Code 的做法非常巧妙,用一个廉价的小模型(Sonnet)来做记忆检索。整个召回流程分为三步:第一步:扫描所有记忆文件的「头部信息」export asyncfunction scanMemoryFiles( +  memoryDir: string, +  signal: AbortSignal, +): Promise { +const entries = await readdir(memoryDir, { recursive: true }) +const mdFiles = entries.filter( +    f => f.endsWith('.md') && basename(f) !== 'MEMORY.md', +  ) +// 只读每个文件的前 30 行(frontmatter 区域),不读全文 +const headers = awaitPromise.allSettled( +    mdFiles.map(async (relativePath) => { +      const { content, mtimeMs } = await readFileInRange( +        filePath, 0, 30,  // 只读前 30 行! +      ) +      const { frontmatter } = parseFrontmatter(content) +      return { +        filename: relativePath, +        description: frontmatter.description || null, +        type: parseMemoryType(frontmatter.type), +        mtimeMs,  // 文件修改时间,用于后续的新旧度判断 +      } +    }), +  ) +// 按修改时间倒序,最多 200 个 +return headers.sort((a, b) => b.mtimeMs - a.mtimeMs) +    .slice(0, 200) +} +注意它只读每个文件的前 30 行,足够提取文件开头那段元信息里的 name、description、type,但不会读取记忆的完整内容。这样即使有 200 个记忆文件,扫描开销也很小。第二步:拼成清单,发给 Sonnet 做选择。扫描完之后,所有记忆的「头部信息」被拼成一个文本清单:- [feedback] feedback_no_mock.md (2026-03-28): 集成测试必须使用真实数据库 +- [user] user_preferences.md (2026-03-25): 用户是后端工程师,偏好简洁回复 +- [project] project_auth.md (2026-03-20): 认证模块重写由合规需求驱动 +然后把这个清单连同用户当前的输入一起发给 Sonnet:const result = await sideQuery({ +  model: getDefaultSonnetModel(), +  system: '你是一个记忆选择器,从列表中选出最多 5 条与用户问题最相关的记忆...', +  messages: [{ +    role: 'user', +    content: `用户问题: ${query}\n\n可用的记忆:\n${manifest}`, +  }], +  max_tokens: 256,  // 只需要返回文件名列表,非常短 +}) +Sonnet 返回的只是一个文件名列表(比如 ["feedback_no_mock.md", "project_auth.md"]),不是记忆内容本身。第三步:加载选中记忆的完整内容,注入上下文。拿到文件名列表后,系统才去读取这几条记忆的完整内容,作为  注入当前对话。这里还有一个非常讲究的细节,记忆陈旧度检测。对于超过 1 天的记忆,系统会自动附加一段警告:export function memoryFreshnessText(mtimeMs: number): string { +const d = memoryAgeDays(mtimeMs) +if (d <= 1) return''// 今天或昨天的记忆不加警告 +return ( +    `这条记忆已经有 ${d} 天了。` + +    `记忆是某个时间点的观察,不是实时状态——` + +    `其中关于代码行为或 file:line 引用的断言可能已经过时。` + +    `在当作事实引用之前,请先对照当前代码验证。` +  ) +} +为什么需要这个?因为用户可能 30 天前存了一条记忆说「AuthService 在 src/auth.ts 第 42 行使用了 JWT」,但代码早就改了。如果模型盲目相信这条记忆,就会给出错误的建议。陈旧度警告提醒模型「这个信息可能过时了,先验证再引用」。性能优化:并行预取最后一个值得学习的设计:记忆召回的执行时机。Sonnet 侧查询不是在主模型需要时才触发的,而是在用户提交消息后立刻就开始了,和主模型的 API 调用并行执行:// query.ts 中的调用——在进入主循环之前就启动记忆预取 +using pendingMemoryPrefetch = startRelevantMemoryPrefetch( +  state.messages, +  state.toolUseContext, +) +时序大概是这样的:Sonnet 比 Opus 快得多(延迟通常只有几百毫秒),所以等主模型的响应回来时,记忆选择早就完成了。整个记忆召回过程几乎不增加任何额外延迟。还有一个小优化:如果用户当前正在使用某些工具(比如正在调用某个 MCP 工具),Sonnet 选择器会自动过滤掉该工具的使用文档类记忆,因为模型已经在用这个工具了,它的用法文档此刻是噪声,不是信号。但「该工具的已知 bug 和注意事项」类记忆仍然会被选中,正在用的时候,恰恰是最需要知道坑在哪里的时候。小结回顾一下 Claude Code 的记忆系统,它的核心设计哲学可以用三句话概括。第一句是「记该记的,不记能推导的」。通过四类型封闭集合加上排除清单,把记忆控制在有价值的范围内,防止它膨胀成一个什么都往里塞的垃圾堆。第二句是「存索引,按需加载详情」。MEMORY.md 作为轻量索引始终常驻在 System Prompt 里,但每条记忆的具体内容是独立文件,用到的时候才加载。这样既让 Agent 知道有哪些记忆可用,又不会撑爆上下文。第三句是「用小模型做秘书,大模型做决策」。Sonnet 负责并行预取和选择记忆,Opus 只管做决策,加上陈旧度检测机制,实现了零延迟、低成本、高可靠。六、上下文窗口管理这可能是整个 Claude Code 里最复杂也最精妙的部分。大模型有上下文窗口限制。即使是 200K Token 的窗口,一次复杂的编程任务(读了几十个文件、执行了几十条命令)很容易就塞满了。业界常见的做法是「简单截断」,只保留最近的 N 条消息,旧的扔掉。但这对于编程 Agent 来说是灾难性的:你可能 20 轮前读过一个关键配置文件,现在要改代码时那个文件的信息已经被截掉了,Agent 就会犯低级错误。另一种做法是「全量摘要」,把整段对话总结成一段摘要。但这很贵(摘要本身就是一次 API 调用),而且有信息损失。压缩五步走Claude Code 的核心理念是:压缩一定有信息损失,所以能不压就不压,必须压的时候从最轻的手段开始。它设计了五个从轻到重的压缩手段,就像医院的分诊制度一样:先试最温和的,不行再上猛药。在每次 API 调用前依次尝试:为什么要分五步,而不是一步到位做全量摘要?因为每一步的「代价」是递增的。第 1 层几乎没有信息损失,完整内容还在磁盘上,只是不在上下文里了。第 2、3 层有少量信息损失,丢掉了老的工具输出,但模型随时可以重新获取。第 4 层有中等信息损失,对话细节被分段压缩了。第 5 层信息损失最大,整段对话变成一段摘要。所以 Claude Code 的策略是:先用代价最小的手段,实在不行再升级。大部分情况下,前三层就够用了,根本不需要触发昂贵的全量摘要。接下来我们一层一层拆解。第 1 步:大结果存磁盘问题是什么? 想象一下,你让 Agent 读一个 10MB 的日志文件。Read 工具忠实地返回了全部内容,一下子就吃掉了几万 Token。更夸张的是,如果模型同时读了 3 个大文件,一条消息就可能占掉大半个上下文窗口。Claude Code 怎么做? 它在工具结果进入消息列表之前,就先做一道「体检」:async function maybePersistLargeToolResult( +  toolResultBlock: ToolResultBlockParam, +  toolName: string, +): Promise { +const size = contentSize(content) +// 单个工具结果超过阈值(默认约 50KB)? +if (size <= threshold) { +    return toolResultBlock  // 没超,原样通过 +  } +// 超了!把完整内容存到磁盘文件 +const result = await persistToolResult(content, toolUseId) +// 用一个 2KB 的预览替换原内容 +const preview = buildLargeToolResultMessage(result) +return { ...toolResultBlock, content: preview } +} +它的逻辑很简单:如果单个工具的结果超过约 50KB,就把完整内容写到磁盘上,在消息里只留一个 2KB 的预览摘要。这样模型还是能看到文件的大概内容(前 2KB),但不会撑爆上下文。除了单个工具的限制,还有一个消息级的总量控制,同一条消息里所有工具结果的总大小不能超过 200KB。如果超了,系统会挑出最大的那几个结果存磁盘,直到总量降到限制以内。这一层的精妙之处在于:完整内容并没有丢,它还在磁盘上。如果模型后面真的需要那个大文件的某个片段,它可以再次调用 Read 工具去读取特定的行范围。第 2 步:砍掉远古消息问题是什么? 一次长对话可能有上百轮。对话开头那几轮的内容,比如用户最初的探索性提问、模型早期的试探性回答,到了后面几乎完全没用了。但它们仍然占着宝贵的上下文空间。Claude Code 怎么做? Snip 是最「粗暴」但也最高效的一层,直接把对话开头的一批老消息移除掉,然后插入一个边界标记告诉模型「这之前的内容已经被清理了」。if (feature('HISTORY_SNIP')) { +  const snipResult = snipModule.snipCompactIfNeeded(messagesForQuery) +  messagesForQuery = snipResult.messages +  snipTokensFreed = snipResult.tokensFreed +  if (snipResult.boundaryMessage) { +    yield snipResult.boundaryMessage  // 插入边界标记 +  } +} +它不做任何摘要,不总结「前面聊了什么」,直接砍掉。听起来很暴力,但对于那些确实已经完全过时的消息来说,这是代价最低的做法,因为它不需要额外调用大模型来生成摘要,零 API 开销。还有一个重要的细节:Snip 会把「我释放了多少 Token」这个数字(snipTokensFreed)传给后面的第 5 层 Auto-Compact。为什么?因为 Auto-Compact 是根据「当前上下文占了多少 Token」来决定是否触发的。如果 Snip 已经释放了足够的空间,Auto-Compact 就不需要触发了,避免两层同时做无谓的压缩。第 3 步:裁剪老的工具输出问题是什么? 经过前两层之后,上下文里剩下的都是「不太老但也不太新」的消息。这些消息不能直接砍掉(可能还有用),但里面大量的工具输出其实已经过时了,比如 30 分钟前读的一个文件,现在那个文件可能已经被改过了。Claude Code 怎么做? Micro-Compact 的核心思想是时间衰减:越老的工具结果越不重要,可以被裁剪。但是,不是所有工具的结果都能裁剪:const COMPACTABLE_TOOLS = new Set([ +  FILE_READ_TOOL_NAME,    // 读文件 → 可以重新读 +  ...SHELL_TOOL_NAMES,    // 执行命令 → 可以重新执行 +  GREP_TOOL_NAME,         // 搜索 → 可以重新搜 +  GLOB_TOOL_NAME,         // 查找文件 → 可以重新查 +  WEB_SEARCH_TOOL_NAME,   // 搜索网页 → 可以重新搜 +  FILE_EDIT_TOOL_NAME,    // 编辑文件 → 结果可裁剪 +  FILE_WRITE_TOOL_NAME,   // 写文件 → 结果可裁剪 +]) +看到规律了吗?可以被裁剪的,都是「可重新获取」的工具,Read 的结果可以再读一次,Bash 的输出可以再执行一次,搜索结果可以再搜一次。但 AgentTool(子 Agent 的输出)、TaskTool(任务状态)这类工具的结果永远不会被裁剪,因为子 Agent 的推理过程是不可重复的,砍掉就真的丢了。具体裁剪逻辑是「保留最近 N 个,清理其余的」:// 收集所有可裁剪工具的结果 ID +const compactableIds = collectCompactableToolIds(messages) +// 保留最近 5 个,其余全部清理 +const keepRecent = Math.max(1, config.keepRecent)  // 至少保留 1 个 +const keepSet = new Set(compactableIds.slice(-keepRecent)) +const clearSet = compactableIds.filter(id => !keepSet.has(id)) +被裁剪的工具结果会被替换成一个标记:export const TIME_BASED_MC_CLEARED_MESSAGE = +  '[Old tool result content cleared]' +这样模型看到这个标记就知道「这里原来有内容但被清理了」。如果它后面还需要这些信息,它可以自己决定重新读文件或重新执行命令。为什么叫「时间衰减」?因为它的触发条件跟时间有关,当距离上一次 API 调用超过一定时间(默认约 60 分钟),说明大模型 API 端的 Prompt Cache 大概率已经过期了。既然缓存已经没了,那清理旧的工具结果也不会浪费之前的缓存投入。第 4 步:读时投影问题是什么? 经过前三层后,如果上下文还是太大,下一步就得做全量摘要了。但全量摘要代价很高(要额外调一次 API),而且会把整段对话的细节全部丢掉。有没有一个「中间态」,比全量摘要轻,但比 Micro-Compact 重?Claude Code 怎么做? Context Collapse 引入了一个非常巧妙的概念,读时投影(Read-Time Projection)。什么意思呢?前面三层都是「写时压缩」,直接修改消息列表,把内容替换掉或删掉。但 Context Collapse 不修改原始消息,它只在调用 API 的那一刻,动态计算一个「压缩视图」给模型看。// 这是 query.ts 中的调用 +// 注意:这是一个"读时投影"——不修改 REPL 的完整历史, +// 只在发送给 API 时计算压缩视图 +if (feature('CONTEXT_COLLAPSE') && contextCollapse) { +  const collapseResult = await contextCollapse.applyCollapsesIfNeeded( +    messagesForQuery, +    toolUseContext, +    querySource, +  ) +  messagesForQuery = collapseResult.messages +} +它的触发有两级阈值:90% 上下文窗口:主动开始分段压缩旧消息(预留缓冲区)95% 上下文窗口:紧急压缩更多内容(留足 API 响应空间)这个设计最精妙的地方是它和第 5 层的配合:Context Collapse 运行在 Auto-Compact 之前。如果 Context Collapse 已经通过「读时投影」把上下文压到了阈值以下,Auto-Compact 就完全不需要触发了。这样模型保留了更多的细节上下文,而不是被一段粗糙的全量摘要替代。第 5 步:全量摘要问题是什么? 当前面四层都不够用,上下文实在太大了,必须做一次彻底的压缩。这是代价最高但效果最强的一层。什么时候触发? Claude Code 用一个公式计算触发阈值:function getAutoCompactThreshold(model: string): number { +  const effectiveContextWindow = getEffectiveContextWindowSize(model) +  // 有效窗口 - 13K 缓冲区 = 触发阈值 +  return effectiveContextWindow - 13_000 +} +以 200K Token 的模型为例:有效窗口大约 180K(预留 20K 给输出),减去 13K 缓冲区,当上下文达到 167K Token 时触发。触发后做了什么? 三步走:第一步:生成摘要。调用大模型,把整段对话总结成一段结构化摘要。这个摘要不是随便写的,Claude Code 用一个精心设计的 Prompt 要求模型按多个维度来总结:用户的主要请求和意图、关键技术概念、涉及的文件和代码片段、遇到的错误和修复方案、问题解决过程、用户的所有消息(不能遗漏任何一条)、待完成的任务、当前工作状态、建议的下一步。为什么要这么细?因为压缩后模型要靠这段摘要来「恢复记忆」。如果摘要漏掉了关键信息(比如「用户还有一个待完成的任务」),模型就会忘记这件事。第二步:替换旧消息。把压缩边界之前的所有消息删掉,替换为刚才生成的摘要。同时插入一条边界标记消息,记录压缩前的 Token 数,方便后续追踪。第三步:Post-Compact Restoration(压缩后恢复)。这是整个流程中最关键的一步,压缩完不是就完了,还要主动恢复最重要的上下文:export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5 +export const POST_COMPACT_TOKEN_BUDGET = 50_000 +export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000 +export const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000 +系统会从文件状态缓存(fileStateCache)中找出最近访问过的文件,按最后访问时间排序,挑选最多 5 个、总共不超过 50K Token 的文件内容重新注入。同时恢复活跃的 Skill(不超过 25K Token),如果有进行中的 Plan 也会恢复 Plan 文件。为什么要做恢复?因为压缩后模型「失忆」了,它不记得刚才读过的文件内容了。如果不恢复,模型的第一反应就是「让我重新读一下文件」,白白浪费一轮工具调用。主动恢复最近的文件内容,可以让模型无缝继续工作,体验上几乎感觉不到压缩发生过。还有一个兜底机制:如果全量摘要连续失败 3 次(比如 API 超时),系统会自动放弃,不会无限重试,这就是熔断器 模式,防止一个失败的压缩操作拖垮整个 Agent。小结回顾一下这五步压缩策略,它们体现了一个核心设计哲学:能轻则轻,逐步加码。层级手段信息损失API 开销触发条件第 1 层大结果存磁盘几乎为零零工具结果超 50KB第 2 层砍掉远古消息低零消息过时第 3 层清理老工具输出中低零缓存过期/数量超限第 4 层读时投影压缩中低上下文达 90%第 5 层全量摘要高高(一次 API 调用)上下文达 ~93%越往下代价越高,但效果也越强。大部分场景下前三层就足够了,它们完全不需要额外的 API 调用,只是「搬运」和「裁剪」数据。只有在极端情况下,才需要触发昂贵的全量摘要。这种设计的另一个好处是各层相互协调。第 2 层 Snip 会告诉第 5 层「我已经释放了多少 Token」,避免重复压缩。第 4 层 Context Collapse 在第 5 层之前运行,如果它够用了,第 5 层就不触发。每一层都在为下一层「减负」。 diff --git "a/docs/readme_img/claude\346\272\220\347\240\201\347\240\224\347\251\266\346\226\207\346\241\243/\345\244\232Agent\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/docs/readme_img/claude\346\272\220\347\240\201\347\240\224\347\251\266\346\226\207\346\241\243/\345\244\232Agent\346\236\266\346\236\204\350\256\276\350\256\241.md" new file mode 100644 index 0000000..0382742 --- /dev/null +++ "b/docs/readme_img/claude\346\272\220\347\240\201\347\240\224\347\251\266\346\226\207\346\241\243/\345\244\232Agent\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -0,0 +1,1381 @@ +# Claude Code 架构研究文档整理版(保留全部信息) + +> 说明:本文件基于两篇原始文档重新整理,目标是提升可读性。 +> +> 处理原则:只整理格式,不做内容删减;原始信息完整保留。 +> +> 原文备份:多Agent架构设计_原文备份.md + +## 第一篇:Claude 架构设计 + +## 二、架构设计 + +一个能自主编程的 Agent 要处理的事情非常多:调大模型 API、执行 40 多种工具、管理权限、压缩上下文、维护记忆、支持多 Agent 协作……如果这些东西全部塞在一个文件里,代码会立刻变成一团乱麻。 +那 Claude Code 是怎么组织这些子系统的? +它采用了一个四层分层架构:我们从上往下,一层一层来理解。 +引擎层是 Agent 的「大脑」,负责思考和调度。 +它的关键设计原则是不包含任何业务逻辑,它不知道怎么读文件、怎么改代码、怎么搜索,这些全是工具层的事。 +引擎层只做三件事:第一,协调,把用户输入、系统指令、历史对话拼在一起,发给大模型;第二,分发,大模型说「我要用某个工具」时,找到对应的工具并执行;第三,决策,根据大模型的返回决定是继续循环还是结束对话。 +这种设计的 + +好处是:新增能力只需要新增一个工具,引擎层完全不用改。 +工具层是 Agent 的全部「能力」,40 多个工具都在这一层。 +每个工具就是 Agent 的一个能力:执行 Shell 命令、读写文件、搜索代码、生成子 Agent……这些工具不是随便写的,它们遵循一个统一的规范。 +这个规范不仅定义了「工具能做什么」,还强制定义了三个安全属性:这个工具是只读的还是会改东西的? +它是否具有破坏性需要额外确认? +它能不能和其他工具同时执行? +这三个属性不是「建议加上」的,而是类型系统强制要求的,漏了任何一个,代码就编译不过。 +这意味着每一把刀都有刀鞘,从出厂就配好了安全机制。 +服务层是所有层共享的「基础设施」。 +这一层包括三样东西:调大模型 API(不管是谁要调,主循环也好、子 Agent 也好,都走这一层)、上下文压缩(后面会详细讲的五步压缩策略)、MCP 协议(和外部工具服务器通信的标准接口)。 +你可以把它类比成大楼的水电煤,所有楼层都需要,但谁也不会自己去铺设管道。 +安全与治理层有点特殊,它不像其他三层那样各管一块,而是像一张安全网罩在所有层上面。 +权限系统决定哪些操作需要用户确认、哪些可以自动执行;Hook 系统允许在工具执行前后插入自定义行为(比如「每次 git push 前自动跑 lint」);Bash 安全模块会对 Shell 命令做语法级别的分析,检测命令注入、路径逃逸等危险模式,而不是简单地用正则匹配关键词。 + +## 三、Agent 工作模式 + +搞清楚了四层架构的宏观布局之后,一个自然的问题来了:引擎层那个主循环里,到底发生了什么? +Agent 是怎么「思考」和「行动」的? +它用的是什么 Agent 框架? +是大家常说的 ReAct 模式吗? +这个问题值得深入聊聊,因为 Agent 的工作模式决定了整个系统的架构走向。 +Claude Code 的答案可能出乎你的意料,它没有用 ReAct,而是用了一个更简洁、更高效的模式。 + +### 什么是 ReAct + +如果你接触过 Agent 开发,大概率听说过 ReAct(Reasoning + Acting)。 +它是 2022 年提出的一种 Agent 范式,核心思路是把 Agent 的每一步拆成三个阶段: + +具体来说,模型在每一轮都会先输出一段「思考」(Thought),比如「我需要先读取 config.ts 文件来了解数据库连接配置」;然后选择一个工具调用(Action);最后拿到工具结果(Observation)。 +这三步不断循环,直到模型认为任务完成。 +这个模式在 2022 年非常流行,因为当时的大模型(GPT-3.5 时代)推理能力有限,需要用显式的「Thought」步骤来引导模型一步步思考。 +但 ReAct 有几个问题: + +- 第一个问题:Token 浪费。 + 每一轮都要输出一段 Thought 文本,这些文本要作为上下文的一部分发给 API,占用了宝贵的 Token 预算。 +对于编程 Agent 来说,一次任务可能循环 50 轮,每轮都写一段「我打算先读取……然后分析……」的思考过程,加起来就是好几万 Token 的浪费。 + +- 第二个问题:应用层代码太复杂。 + 你需要解析模型的输出,区分「哪部分是 Thought、哪部分是 Action」,然后提取 Action 调用工具,再把 Observation 拼回去。 +这个解析过程写起来很麻烦,而且很容易出 bug,因为模型输出的格式不一定标准,一崩就全崩了。 + +- 第三个问题:ReAct 是为「弱模型」设计的。 + 当大模型的推理能力不够强时,用显式的 Thought 来「强迫」它一步步思考是有意义的。 +但 Claude Opus 这种级别的模型,推理能力已经足够强了,它完全可以在内部完成推理,不需要在输出里显式写出每一步的思考过程。 + +### Tool-Use Loop + +Claude Code 没有采用 ReAct 的 Thought-Action-Observation 三步循环,而是用了一个更简洁的模式,我把它叫做  + +### Tool-Use Loop + +。 +核心思路非常简单,就一个 while(true) 循环:看到区别了吗? +没有 Thought 步骤。 +模型在内部完成推理(通过 Extended Thinking,这是 Claude Opus 的一个能力,模型在生成回复前会在内部进行一段不可见的深度推理,不占用上下文空间),然后直接返回两种结果之一:**tool_use**:「我要用某个工具」,应用层执行工具,把结果拼入消息列表,继续循环**end_turn**:「我说完了」,跳出循环,把最终结果返回给用户这个设计的核心哲学是:信任模型的推理能力,保持应用层框架尽可能简单。 +来看 query.ts 中的核心循环,它的实际代码长这样(这是一段 TypeScript 代码,其中 yield 的作用是流式输出,你可以理解为一边接收 API 的响应,一边把每个 token 实时传给 UI 显示):async function* queryLoop( +  params: QueryParams, +  consumedCommandUuids: string[], +): AsyncGenerator { +let state: State = { messages, toolUseContext, turnCount: 1, ... } + +while (true) { +    // 步骤 1:压缩上下文(五步从轻到重) +    // 步骤 2:调用大模型 API,流式接收 +    forawait (const event of streamAPI(params)) { +      yield event  // 流式输出每个 token +    } +    // 步骤 3:分析模型返回 +    if (response.stopReason === 'end_turn') break// 完成了,跳出循环 + +    // 步骤 4:执行工具调用(并发/串行编排) +    const toolResults = await executeToolCalls(toolUseMessages) + +    // 步骤 5:更新 state,继续循环 +    state = { ...state, messages: updatedMessages, turnCount: turnCount + 1 } +    continue +  } +} +注意 break 和 continue,模型说 end_turn 就 break 跳出循环,说 tool_use 就 continue 回到循环开头。 +整个决策逻辑就这么简单。 + +### 为什么比 ReAct 更好 + +你可能会问:不就是把 Thought 去掉了吗,有什么了不起的? +区别其实很大,我列了三个关键原因:第一,Extended Thinking 让推理在「模型内部」完成。 + Claude Opus 支持 Extended Thinking,模型在生成最终回复之前,会在内部进行一段不可见的深度推理。 +这段推理发生在模型内部,不占用应用的上下文空间,也不需要应用层去解析。 +所以 ReAct 的 Thought 步骤在 Claude 的架构里是多余的,模型已经在内部「想好了」,不需要在外部输出中再写一遍。 +第二,API 原生支持 tool_use。 + Claude 的 API 原生支持工具调用,模型可以直接返回 tool_use 类型的响应,不需要用正则表达式从文本中提取「Action」。 +这消除了 ReAct 的格式解析问题,应用层代码变得极其简洁。 +第三,end_turn 作为天然的终止信号。 + ReAct 需要一套额外的规则来判断「Agent 是否完成了」,比如检测输出中是否包含「Final Answer」。 +而 + +### Tool-Use Loop + + 用模型的 end_turn 信号作为终止条件,这是 API 层面的原语,语义清晰,不需要任何解析。 +用一个表格来总结两者的区别:维度ReAct + +### Tool-Use Loop + +推理方式显式 Thought 文本模型内部 Extended Thinking工具调用解析文本提取 ActionAPI 原生 tool_use终止判断检测 「Final Answer」API 原生 end_turnToken 开销每轮要输出 Thought无额外开销编排复杂度高(需要解析 Thought/Action)低(只需要 if/else)适合场景弱模型 + 简单工具强模型 + 复杂工具集Claude Code 的 Agent 工作模式可以总结为一句话:信任模型的推理能力,把应用层框架做得尽可能简单。 +ReAct 的设计哲学是「帮模型思考」,用显式的 Thought 步骤引导模型一步步推理。 +这在弱模型时代是必要的。 +但 Claude Code 面对的是 Opus 级别的强模型,它的推理能力完全可以在内部完成,不需要应用层去「教」它怎么想。 +所以 Claude Code 的 + +### Tool-Use Loop + + 只做最简单的事:调 API、执行工具、再调 API。 +推理交给模型,执行交给工具,编排交给最简单的 while(true) 循环。 +这种「大道至简」的设计,反而是最高效的。 + +### Plan Mode + +Claude Code 不仅有 + +### Tool-Use Loop + + 这种「边想边做」模式,还有  + +### Plan Mode + +,一个更精细的两阶段工作流:先规划、再执行。 + +### Plan Mode + + 的核心思想是:复杂任务应该先规划再执行,避免方向跑偏、浪费精力。 +它并不是一个独立的框架,而是在同一个 + +### Tool-Use Loop + + 中通过 EnterPlanMode 和 ExitPlanMode 两个工具实现的:整个流程分三步: + +#### + +- 第一步:模型自主进入或用户手动触发。 + 当模型判断「这是一个复杂任务」时,它会调用 EnterPlanMode 工具。 +对于简单任务(修 typo、加 console.log),则明确不进入。 +用户也可以通过 Shift+Tab 手动切换。 + +#### + +- 第二步:只读探索 + 设计方案。 + 进入 + +### Plan Mode + + 后,权限降为只读,模型只能用 Read、Grep、Glob 这些工具去探索代码库,不能写文件、不能改代码、不能跑命令。 +探索完后,把计划写入 .claude/plans/ 目录。 +每 5 轮对话,系统会偷偷给模型塞一张「小纸条」,提醒它「你现在还在 + +### Plan Mode + +,别手痒改代码」,防止模型在长对话中「走神」。 + +#### + +- 第三步:用户审批后实施。 + 模型调用 ExitPlanMode,此时需要用户确认。 +用户批准后,权限恢复为之前的模式,模型开始自由执行读写操作,按计划实施。 + +### Plan Mode + + 最值得学习的设计是「工具即能力」。 +对模型来说, + +### Plan Mode + + 不是一种特殊的「模式切换」,而只是调用了 EnterPlanMode 和 ExitPlanMode 这两个工具。 +就像调用 Read 工具读文件一样自然。 +整个过程不需要引擎层做任何特殊处理,query() 仍然只是一个简单的 while(true) 循环。 + +## 四、System Prompt 的构造 + +System Prompt 就是 Claude Code 的灵魂,它定义了 Agent 的身份、行为规范、可用工具、安全约束……一切。 +但 Claude Code 的 System Prompt 不是一个静态的文本文件。 +它是动态组装的,由十几个 Section 拼接而成,而且在组装过程中做了非常精巧的缓存优化。 +我们先来看一下,Claude Code 的 System Prompt 到底长什么样,它是怎么「调教」大模型变成一个靠谱的编程 Agent 的。 +注:Claude Code 源码中所有 Prompt 原文均为英文。 +为了让大家更好地理解设计思路,下面展示的 Prompt 内容我翻译成了中文,并保留了关键术语的英文原文。 +角色定义与安全红线每个 Agent 的 System Prompt 都要回答一个根本问题:你是谁? +Claude Code 的开场是这样的:你是一个交互式代理(interactive agent),帮助用户完成软件工程任务。 + +请使用下面的指令和可用的工具来协助用户。 + +重要:你绝对不能为用户生成或猜测 URL,除非你确信这些 URL +是为了帮助用户完成编程任务。 +你可以使用用户在消息或本地文件中 +提供的 URL。 + +注意两个关键点。 +第一,它把自己定位为「interactive agent」,而不是「assistant」或「chatbot」,这从一开始就暗示了模型应该主动采取行动,而不是被动回答。 +第二,立刻划了安全红线:不能乱编 URL。 +这看起来是个小事,但对编程 Agent 非常重要,如果模型瞎编一个 npm 包的 URL,用户执行了就可能中招。 +紧接着是一段安全约束指令,这段话非常值得每个 Agent 开发者抄作业:重要:允许协助已授权的安全测试、防御性安全研究、CTF 挑战赛 +和教育场景。 +拒绝涉及破坏性技术、DoS 攻击、大规模目标扫描、 +供应链攻击或用于恶意目的的检测规避请求。 + +这段 Prompt 没有用「绝对不能做 X」的口吻,而是先说「可以做什么」(授权的安全测试、CTF 挑战),再划定「不能做什么」(DoS、供应链攻击)。 +这种「先肯定再约束」的写法,比纯禁止清单效果好得多,它给了模型清晰的判断依据,而不是一堆模糊的红线。 + +### 行为准则 + +接下来是一大段关于「怎么做事」的行为指南,这部分是 Claude Code System Prompt 的精华。 +我挑几条最值得学习的:关于修改代码前先阅读:一般来说,不要对你没有阅读过的代码提出修改建议。 +如果用户 +要求你查看或修改某个文件,先读一遍它。 +在提出修改建议之前, +先理解现有代码。 + +这条看起来简单,但解决了 Agent 的一个常见问题:很多 Agent 会根据用户描述直接生成代码,而不先看看现有代码是什么样的,结果经常和项目风格不一致或者引入重复实现。 +关于代码风格:「少即是多」:不要在用户要求之外添加功能、重构代码或进行"改进"。 +修一个 bug +不需要顺手清理周围的代码。 +一个简单功能不需要额外的可配置性。 + +不要为一次性操作创建辅助函数、工具类或抽象层。 + +三行相似的代码比一个过早的抽象更好。 + +这个设计思路太重要了。 +如果你用过 Agent 写代码,你一定遇到过这种情况:你让它修一个 bug,它顺手把整个文件重构了,加了一堆你没要求的类型标注和错误处理。 +Claude Code 在 Prompt 里明确禁止了这种行为。 +关于失败处理:「先诊断再换方案」:如果某个方案失败了,先诊断原因再决定是否换方案——读报错信息、 +检查你的假设、尝试有针对性的修复。 +不要盲目重试完全相同的操作, +但也不要因为一次失败就放弃一个可行的方案。 + +这条解决了 Agent 的另一个常见问题,「摆烂式重试」或「草率放弃」。 +Claude Code 要求模型先搞清楚为什么失败了,再决定是修复还是换方案,而不是两个极端。 + +### 操作安全 + +Claude Code 对「什么操作需要用户确认」做了非常详细的规定。 +我建议每个 Agent 开发者都研读这段 Prompt:仔细考虑操作的可逆性(reversibility)和影响范围(blast radius)。 + +一般来说,你可以自由执行本地的、可逆的操作,比如编辑文件或 +运行测试。 +但对于难以撤销、影响共享系统或有风险的操作, +请先和用户确认后再执行。 + +需要用户确认的高风险操作示例: +- 破坏性操作:删除文件/分支、删表、rm -rf +- 难以逆转的操作:force-push、git reset --hard、修改已发布的 commit +- 对他人可见的操作:推送代码、创建/关闭 PR、发送消息 +- 上传到第三方工具:内容可能被缓存或索引,即使删除也无法撤回 +这段的核心思想是用可逆性和影响范围两个维度来判断风险。 +读文件、改本地代码是低风险的(可逆、只影响本地),直接放行。 +git push、发 Slack 消息是高风险的(不可逆、影响他人),必须确认。 +然后还有一句非常精妙的补充:用户批准了某个操作(比如 git push)一次,并不意味着他在所有 +场景下都批准这个操作。 +授权仅对指定的范围有效,不能超出范围。 + +这解决了「权限蔓延」的问题,用户同意了一次 push 不代表以后都自动 push,授权是一次性的、有范围的。 +这个原则在 Agent 权限设计中非常重要。 + +### 工具使用指南 + +当有专用工具可用时,不要用 Bash 来执行命令。 +使用专用工具可以 +让用户更好地理解和审查你的工作。 +这一点至关重要: + +- 读取文件用 Read 工具,而不是 cat、head、tail 或 sed +- 编辑文件用 Edit 工具,而不是 sed 或 awk +- 创建文件用 Write 工具,而不是 echo 重定向 +- 搜索文件用 Glob 工具,而不是 find 或 ls +- 搜索内容用 Grep 工具,而不是 grep 或 rg +这条规则的设计动机值得深思。 +为什么不让模型直接用 cat 读文件、用 sed 改代码? +技术上完全可以。 +原因是可审查性。 +当模型调用 Read 工具读文件时,UI 会清晰地展示「Agent 正在读取 src/index.ts」。 +但如果模型执行 cat src/index.ts,用户看到的只是一条 Bash 命令和一大坨输出,完全不知道 Agent 在干什么。 +而且,专用工具有专用的权限检查,Read 工具会检查文件路径是否在允许范围内,而 cat 命令就没有这层保护了。 +所以「用专用工具而不是 Bash」不仅是体验问题,更是安全问题。 + +### Git 安全协议 + +Claude Code 对 Git 操作有一套非常严格的安全协议,这段 Prompt 写得极其细致: + +### Git 安全协议 + +: +- 绝不修改 git config +- 绝不执行破坏性 git 命令(push --force、reset --hard、 +  checkout .、clean -f),除非用户明确要求 +- 绝不跳过 hooks(--no-verify),除非用户明确要求 +- 绝不 force push 到 main/master 分支,如果用户要求则发出警告 + +关键:始终创建新的 commit(NEW commit),而不是用 --amend 修改。 + +当 pre-commit hook 失败时,commit 实际上并没有发生——所以 +--amend 会修改上一个(不相关的)commit,可能导致代码丢失。 + +正确做法是:修复问题后创建一个新的 commit。 + +最后一条关于 --amend 的警告特别值得注意。 +很多人(包括一些 Agent 实现)在 commit 失败后会习惯性地 git commit --amend。 +但如果失败原因是 pre-commit hook 拒绝了,那么 commit 实际上没发生! +这时候 --amend 会修改上一个(不相关的)commit,可能导致代码丢失。 +这种微妙的 bug 很难被发现,Claude Code 直接在 Prompt 里防住了。 + +### 输出风格约束 + +Claude Code 对模型的输出风格也有严格规定:# 输出效率 +直奔重点。 +先尝试最简单的方案。 +要极度简洁。 + +工具调用之间的文字不超过 25 个词。 +最终回复不超过 100 个词。 + +先给出答案或行动,而不是推理过程。 +跳过填充词、开场白和 +不必要的过渡句。 +不要复述用户说过的话——直接做就行。 + +25 个词的限制非常苛刻,这意味着模型在两次工具调用之间,基本只能说一句话。 +这个设计的目的是避免 Agent 「话痨」,没人想看 Agent 在每次读文件前先写一段「让我来看看这个文件的内容……」的废话。 + +### 环境信息注入 + +每次对话开始时,Claude Code 会把当前 + +### 环境信息注入 + + System Prompt:# 环境信息 +- 主工作目录:/Users/you/my-project +- 是否为 Git 仓库:是 +- 操作系统平台:darwin (macOS) +- Shell 类型:zsh +- 当前模型:Claude Opus 4.6 (1M context) +- 知识截止日期:2025 年 5 月 +这些信息让模型知道自己「在哪里」,是什么操作系统、什么 Shell、什么项目目录。 +没有这些信息,模型可能会在 macOS 上执行 apt-get install,或者在 zsh 环境里用 bash 语法。 + +### 分割线与三级缓存 + +了解了各个 Section 的内容,我们回到一个很实际的问题:这些 Section 是怎么组装到一起的? +为什么组装方式会影响费用? +先看一段组装后的 System Prompt 长什么样(简化版):┌─────────────────────────────────────────────────┐ +│  [角色定义] 你是一个交互式代理,帮助用户完成...    │  ← 所有用户完全一样 +│  [安全红线] 重要:允许协助已授权的安全测试...       │  ← 所有用户完全一样 +│  [ + +### 行为准则 + +] 一般来说,不要对你没有阅读过的代码...   │  ← 所有用户完全一样 +│  [ + +### 操作安全 + +] 仔细考虑操作的可逆性...               │  ← 所有用户完全一样 +│  [工具使用] 当有专用工具可用时...                  │  ← 所有用户完全一样 +│  [Git 安全] 绝不修改 git config...               │  ← 所有用户完全一样 +│  [输出风格] 直奔重点,要极度简洁...               │  ← 所有用户完全一样 +├────── __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__ ────────┤ +│  [环境信息] 主工作目录: /Users/you/my-project    │  ← 每个用户不一样 +│  [CLAUDE.md] 本项目使用 TypeScript + Jest...      │  ← 每个项目不一样 +│  [记忆指令] 你有一个持久记忆系统...               │  ← 每次对话可能不一样 +│  [MCP 指令] 你已连接 GitHub MCP server...         │  ← 每个用户不一样 +└─────────────────────────────────────────────────┘ +看到中间那条粗线了吗? +那就是 Claude Code 在 System Prompt 中插入的分割标记 __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__。 +分割线之上的内容,对所有用户都完全一样。 + 不管你是北京的 Java 工程师还是纽约的 Python 开发者,你看到的「角色定义」「 + +### 行为准则 + +」「 + +### Git 安全协议 + +」这些内容是一模一样的。 +分割线之下的内容,每个用户都不同。 + 你的工作目录、你的 CLAUDE.md、你的记忆文件、你连接的 MCP 服务,这些是因人而异的。 + +为什么要这么分? +因为 Claude API 有一个 Prompt Cache 机制:如果两次请求的 Prompt 前缀完全相同,API 会复用上次的计算结果,**费用可以降低 90%**。 +对于几万 Token 的 System Prompt 来说,缓存命中与否意味着每次请求几美分和几美元的差距。 +分割线之上的内容对所有用户都一样,所以可以全球所有用户共享同一份缓存——你用的和东京的开发者用的是同一份。 +而分割线之下的内容因人而异,没法共享,只能实时生成。 +这就是 Claude Code 的三级缓存体系:全局缓存(分割线之上,跨组织跨用户共享)→ 组织缓存(同一组织内跨会话共享)→ 会话缓存(同一个 Section 在一次会话内只计算一次)。 +每一级都在帮 API 省钱。 + +### 小结 + +回过头来看 Claude Code 的 System Prompt,你会发现它其实在做一件事:用最小的 Token 成本,给模型划出最清晰的行为边界。 +怎么划的呢? +我总结了三个最值得抄作业的设计。 +第一个是「先给范围再画红线」。 +比如安全约束那段,它不是一上来就说「不准做这不准做那」,而是先说「安全测试、CTF 挑战这些可以做」,然后再说「DoS、供应链攻击这些不能做」。 +这比你写十句「不准 XX」管用得多,因为模型拿到了判断标准,而不是一堆模糊的禁令。 +第二个是「用两个维度把风险分出层次」。 +Claude Code 判断一个操作安不安全,不看它「看起来危不危险」,而是看两件事:这操作能撤回吗? +会影响别人吗? +改本地代码当然能撤回、只影响自己,直接放行。 +git push 撤不回来、别人能看到,那就得确认。 +这个思路比笼统的「危险/安全」二分法精细太多了。 +第三个是「静态内容和动态内容用分割线隔开」。 +那条分割线不是随便画的,它把所有用户都一样的部分和因人而异的部分切开了。 +这样做的好处是,分割线之上的内容可以被全球所有用户共享缓存,每次 API 调用能省 90% 的费用。 +一个看似简单的排版调整,背后是实打实的成本优化。 + +## 五、记忆系统 + +每次启动 Claude Code 都是一个全新的会话,模型不记得上次对话的任何内容。 +但用户的偏好、项目背景、行为反馈,这些信息需要跨会话保持。 +这个问题看起来简单,做起来却非常难。 +业界常见的方案是用向量数据库,把记忆存成 embedding,每次对话时做相似度检索。 +但 Claude Code 没有这么做。 +为什么? +因为 Agent 需要记住的大部分不是「相似的文档片段」,而是「用户说过'不要 mock 数据库'」这种结构化的行为指令。 +用向量相似度去检索「不要 mock 数据库」这句话,效果其实很差,它可能匹配到一堆包含「数据库」关键词的无关内容,真正重要的行为反馈却被淹没了。 +Claude Code 设计了一套完全不同的记忆系统,我们来一层一层拆解。 + +### 记什么:四类型分类 + +Claude Code 把记忆分成了四种明确的类型:export const MEMORY_TYPES = [ +  'user',      // 用户画像:角色、偏好、知识水平 +  'feedback',  // 行为反馈:该做什么、不该做什么 +  'project',   // 项目动态:在做什么、截止日期、协作信息 +  'reference', // 外部指针:哪里能找到什么信息 +] as const +注意,只有这四种,不能随便加新的。 +为什么不搞一个通用的「any」类型什么都能存? +因为无约束的记忆会迅速膨胀成垃圾堆。 +限定四种类型,就是在逼 Agent 做分类决策。 +每存一条记忆,它必须想清楚「这到底属于哪一类」,而不是一股脑往里塞。 +我逐个解释一下这四种类型的设计意图。 +User(用户画像)是最个人化的一类,记住用户是谁、擅长什么、知识水平如何。 +比如用户说「我是一个写了十年 Go 的后端工程师,第一次接触 React」,Agent 就应该在解释前端概念时用后端的类比,而不是从零讲起。 +这类记忆让 Agent 的回答因人而异,而不是千篇一律。 +Feedback(行为反馈)是最重要的一类,记住用户说过「不要做什么」和「做得好继续保持」。 +这类记忆的关键在于,它不仅记规则本身,还要求记录 Why(为什么) 和 How to apply(怎么应用):规则本身:集成测试必须使用真实数据库,不能用 mock +Why:上季度 mock 测试全部通过但生产环境迁移失败了 +How to apply:在这个模块写测试时,始终连接真实数据库 +为什么一定要记 Why? +因为光记住「不要 mock 数据库」是不够的。 +如果遇到一个边缘情况,比如一个纯单元测试不涉及数据库迁移,Agent 需要根据 Why 来判断「这条规则在这个场景下是否适用」。 +没有 Why,Agent 只能盲目遵守,可能在不该用真实数据库的地方也强行连接。 +Project(项目动态)记的是「正在发生什么」,谁在做什么、截止日期是什么、有什么重要决策。 +这类记忆有一个特殊要求:必须把相对日期转成绝对日期。 +用户说「周四之前冻结合并」,Agent 要存成「2026-03-05 之前冻结合并」,因为「周四」过几天就没意义了,但「2026-03-05」永远准确。 +Reference(外部指针)记的是「去哪找什么信息」,Bug 在 Linear 的哪个项目里追踪、Grafana 看板的地址是什么、Slack 的哪个频道能问到相关的人。 +这类记忆的价值在于,Agent 不需要知道外部系统的具体内容,只需要知道去哪里找。 + +### 不记什么:排除清单 + +Claude Code 明确规定了什么不应该存到记忆里,这个设计和「记什么」同样重要。 +首先是代码模式、项目架构和文件结构这些信息,通过 grep、git、CLAUDE.md 就能获取,存在记忆里反而会导致记忆和代码实际状态不一致。 +然后是 Git 历史和最近的改动,git log 和 git blame 才是权威来源,不需要记忆系统再来存一遍。 +调试方案和修复方法也不存,因为修复已经在代码里了,commit 消息已经记录了上下文。 +CLAUDE.md 里已经写了的内容也不存,避免重复。 +最后是临时任务状态和当前对话上下文,这些是会话级的信息,不需要跨会话保持。 +这个排除清单背后的核心原则是:可以从当前代码推导出来的信息,一律不存。 +因为代码是「活的」,它随时在变,但记忆是「死的」,它存下来就定格了。 +如果记忆说「AuthService 在 src/auth.ts 第 42 行」,但代码已经重构了,那这条记忆就变成了一个「权威的错误」,比没有记忆还糟糕。 + +### 怎么存:索引 + 独立文件 + +搞清楚了「记什么」和「不记什么」,接下来看「怎么存」。 +每条记忆存为一个独立的 .md 文件,文件开头有一段 YAML 格式的元信息(你可以理解为这条记忆的「身份证」):--- +name: no-mock-database +description: 集成测试必须使用真实数据库,不能用 mock +type: feedback +--- + +集成测试必须使用真实数据库,不能用 mock。 + +**Why:** 上季度 mock 测试全部通过但生产环境迁移失败了。 + +**How to apply:** 在这个模块写测试时,始终连接真实数据库。 + +文件开头那段 YAML 格式的元信息里,三个字段各有用途:name 是人类可读的标识;description 是一句话摘要,专门用于检索时的相关性匹配(后面会讲到);type 标记四类型之一。 +然后有一个 MEMORY.md 文件作为索引,它是一个不超过 200 行(25KB)的轻量目录:- [No Mock Database](feedback_no_mock_db.md) — tests must use real DB +- [User Preferences](user_preferences.md) — prefers terse responses +- [Auth Rewrite](project_auth_rewrite.md) — driven by compliance, not tech debt +注意这个 200 行的硬性上限。 +为什么要限制? +来看源码里的截断逻辑:export const MAX_ENTRYPOINT_LINES = 200 +exportconst MAX_ENTRYPOINT_BYTES = 25_000  // 25KB + +exportfunction truncateEntrypointContent(raw: string): EntrypointTruncation { +// 同时检查行数和字节数上限 +const wasLineTruncated = lineCount > MAX_ENTRYPOINT_LINES +const wasByteTruncated = byteCount > MAX_ENTRYPOINT_BYTES + +if (wasLineTruncated || wasByteTruncated) { +    // 截断并附加警告 +    return { +      content: truncated + '\n\n> WARNING: MEMORY.md 太大了...', +      // ... +    } +  } +} +它同时检查行数和字节数两个维度。 + +为什么要两个? +因为有人可能写了 199 行,每行 500 字,行数没超但字节数爆了。 +双重检查堵住了这个漏洞。 +现在来看整个存储架构的关键设计:MEMORY.md 索引始终被加载到 System Prompt 里,但独立记忆文件按需加载。 +这解决了一个经典矛盾,如果把所有记忆都塞进 System Prompt,50 条记忆就可能占满上下文;如果完全不塞,Agent 又不知道有哪些记忆可用。 +索引文件两全其美:Agent 看到索引就知道有哪些记忆,但只加载真正相关的那几条。 + +### 怎么召回:Sonnet 当秘书 + +存好了记忆,关键问题来了:每次对话时,怎么从几十条记忆里挑出最相关的那几条加载进来? +Claude Code 的做法非常巧妙,用一个廉价的小模型(Sonnet)来做记忆检索。 +整个召回流程分为三步: + +#### + +- 第一步:扫描所有记忆文件的「头部信息」export asyncfunction scanMemoryFiles( +  memoryDir: string, +  signal: AbortSignal, +): Promise { +const entries = await readdir(memoryDir, { recursive: true }) +const mdFiles = entries.filter( +    f => f.endsWith('.md') && basename(f) !== 'MEMORY.md', +  ) +// 只读每个文件的前 30 行(frontmatter 区域),不读全文 +const headers = awaitPromise.allSettled( +    mdFiles.map(async (relativePath) => { +      const { content, mtimeMs } = await readFileInRange( +        filePath, 0, 30,  // 只读前 30 行! + +      ) +      const { frontmatter } = parseFrontmatter(content) +      return { +        filename: relativePath, +        description: frontmatter.description || null, +        type: parseMemoryType(frontmatter.type), +        mtimeMs,  // 文件修改时间,用于后续的新旧度判断 +      } +    }), +  ) +// 按修改时间倒序,最多 200 个 +return headers.sort((a, b) => b.mtimeMs - a.mtimeMs) +    .slice(0, 200) +} +注意它只读每个文件的前 30 行,足够提取文件开头那段元信息里的 name、description、type,但不会读取记忆的完整内容。 +这样即使有 200 个记忆文件,扫描开销也很小。 + +#### + +- 第二步:拼成清单,发给 Sonnet 做选择。 +扫描完之后,所有记忆的「头部信息」被拼成一个文本清单:- [feedback] feedback_no_mock.md (2026-03-28): 集成测试必须使用真实数据库 +- [user] user_preferences.md (2026-03-25): 用户是后端工程师,偏好简洁回复 +- [project] project_auth.md (2026-03-20): 认证模块重写由合规需求驱动 +然后把这个清单连同用户当前的输入一起发给 Sonnet:const result = await sideQuery({ +  model: getDefaultSonnetModel(), +  system: '你是一个记忆选择器,从列表中选出最多 5 条与用户问题最相关的记忆...', +  messages: [{ +    role: 'user', +    content: `用户问题: ${query}\n\n可用的记忆:\n${manifest}`, +  }], +  max_tokens: 256,  // 只需要返回文件名列表,非常短 +}) +Sonnet 返回的只是一个文件名列表(比如 ["feedback_no_mock.md", "project_auth.md"]),不是记忆内容本身。 + +#### + +- 第三步:加载选中记忆的完整内容,注入上下文。 +拿到文件名列表后,系统才去读取这几条记忆的完整内容,作为  注入当前对话。 +这里还有一个非常讲究的细节,记忆陈旧度检测。 +对于超过 1 天的记忆,系统会自动附加一段警告:export function memoryFreshnessText(mtimeMs: number): string { +const d = memoryAgeDays(mtimeMs) +if (d <= 1) return''// 今天或昨天的记忆不加警告 +return ( +    `这条记忆已经有 ${d} 天了。 +` + +    `记忆是某个时间点的观察,不是实时状态——` + +    `其中关于代码行为或 file:line 引用的断言可能已经过时。 +` + +    `在当作事实引用之前,请先对照当前代码验证。 +` +  ) +} + +为什么需要这个? +因为用户可能 30 天前存了一条记忆说「AuthService 在 src/auth.ts 第 42 行使用了 JWT」,但代码早就改了。 +如果模型盲目相信这条记忆,就会给出错误的建议。 +陈旧度警告提醒模型「这个信息可能过时了,先验证再引用」。 + +### 性能优化:并行预取 + +最后一个值得学习的设计:记忆召回的执行时机。 +Sonnet 侧查询不是在主模型需要时才触发的,而是在用户提交消息后立刻就开始了,和主模型的 API 调用并行执行:// query.ts 中的调用——在进入主循环之前就启动记忆预取 +using pendingMemoryPrefetch = startRelevantMemoryPrefetch( +  state.messages, +  state.toolUseContext, +) +时序大概是这样的:Sonnet 比 Opus 快得多(延迟通常只有几百毫秒),所以等主模型的响应回来时,记忆选择早就完成了。 +整个记忆召回过程几乎不增加任何额外延迟。 +还有一个小优化:如果用户当前正在使用某些工具(比如正在调用某个 MCP 工具),Sonnet 选择器会自动过滤掉该工具的使用文档类记忆,因为模型已经在用这个工具了,它的用法文档此刻是噪声,不是信号。 +但「该工具的已知 bug 和注意事项」类记忆仍然会被选中,正在用的时候,恰恰是最需要知道坑在哪里的时候。 + +### 小结 + +回顾一下 Claude Code 的记忆系统,它的核心设计哲学可以用三句话概括。 +第一句是「记该记的,不记能推导的」。 +通过四类型封闭集合加上排除清单,把记忆控制在有价值的范围内,防止它膨胀成一个什么都往里塞的垃圾堆。 +第二句是「存索引,按需加载详情」。 +MEMORY.md 作为轻量索引始终常驻在 System Prompt 里,但每条记忆的具体内容是独立文件,用到的时候才加载。 +这样既让 Agent 知道有哪些记忆可用,又不会撑爆上下文。 +第三句是「用小模型做秘书,大模型做决策」。 +Sonnet 负责并行预取和选择记忆,Opus 只管做决策,加上陈旧度检测机制,实现了零延迟、低成本、高可靠。 + +## 六、上下文窗口管理 + +这可能是整个 Claude Code 里最复杂也最精妙的部分。 +大模型有上下文窗口限制。 +即使是 200K Token 的窗口,一次复杂的编程任务(读了几十个文件、执行了几十条命令)很容易就塞满了。 +业界常见的做法是「简单截断」,只保留最近的 N 条消息,旧的扔掉。 +但这对于编程 Agent 来说是灾难性的:你可能 20 轮前读过一个关键配置文件,现在要改代码时那个文件的信息已经被截掉了,Agent 就会犯低级错误。 +另一种做法是「全量摘要」,把整段对话总结成一段摘要。 +但这很贵(摘要本身就是一次 API 调用),而且有信息损失。 + +### 压缩五步走 + +Claude Code 的核心理念是:压缩一定有信息损失,所以能不压就不压,必须压的时候从最轻的手段开始。 +它设计了五个从轻到重的压缩手段,就像医院的分诊制度一样:先试最温和的,不行再上猛药。 +在每次 API 调用前依次尝试:为什么要分五步,而不是一步到位做全量摘要? +因为每一步的「代价」是递增的。 +第 1 层几乎没有信息损失,完整内容还在磁盘上,只是不在上下文里了。 +第 2、3 层有少量信息损失,丢掉了老的工具输出,但模型随时可以重新获取。 +第 4 层有中等信息损失,对话细节被分段压缩了。 +第 5 层信息损失最大,整段对话变成一段摘要。 +所以 Claude Code 的策略是:先用代价最小的手段,实在不行再升级。 +大部分情况下,前三层就够用了,根本不需要触发昂贵的全量摘要。 +接下来我们一层一层拆解。 + +### 第 1 步:大结果存磁盘 + +问题是什么? + 想象一下,你让 Agent 读一个 10MB 的日志文件。 +Read 工具忠实地返回了全部内容,一下子就吃掉了几万 Token。 +更夸张的是,如果模型同时读了 3 个大文件,一条消息就可能占掉大半个上下文窗口。 +Claude Code 怎么做? + 它在工具结果进入消息列表之前,就先做一道「体检」:async function maybePersistLargeToolResult( +  toolResultBlock: ToolResultBlockParam, +  toolName: string, +): Promise { +const size = contentSize(content) +// 单个工具结果超过阈值(默认约 50KB)? + +if (size <= threshold) { +    return toolResultBlock  // 没超,原样通过 +  } +// 超了! +把完整内容存到磁盘文件 +const result = await persistToolResult(content, toolUseId) +// 用一个 2KB 的预览替换原内容 +const preview = buildLargeToolResultMessage(result) +return { ...toolResultBlock, content: preview } +} +它的逻辑很简单:如果单个工具的结果超过约 50KB,就把完整内容写到磁盘上,在消息里只留一个 2KB 的预览摘要。 +这样模型还是能看到文件的大概内容(前 2KB),但不会撑爆上下文。 +除了单个工具的限制,还有一个消息级的总量控制,同一条消息里所有工具结果的总大小不能超过 200KB。 +如果超了,系统会挑出最大的那几个结果存磁盘,直到总量降到限制以内。 +这一层的精妙之处在于:完整内容并没有丢,它还在磁盘上。 +如果模型后面真的需要那个大文件的某个片段,它可以再次调用 Read 工具去读取特定的行范围。 + +### 第 2 步:砍掉远古消息 + +问题是什么? + 一次长对话可能有上百轮。 +对话开头那几轮的内容,比如用户最初的探索性提问、模型早期的试探性回答,到了后面几乎完全没用了。 +但它们仍然占着宝贵的上下文空间。 +Claude Code 怎么做? + Snip 是最「粗暴」但也最高效的一层,直接把对话开头的一批老消息移除掉,然后插入一个边界标记告诉模型「这之前的内容已经被清理了」。 +if (feature('HISTORY_SNIP')) { +  const snipResult = snipModule.snipCompactIfNeeded(messagesForQuery) +  messagesForQuery = snipResult.messages +  snipTokensFreed = snipResult.tokensFreed +  if (snipResult.boundaryMessage) { +    yield snipResult.boundaryMessage  // 插入边界标记 +  } +} +它不做任何摘要,不总结「前面聊了什么」,直接砍掉。 +听起来很暴力,但对于那些确实已经完全过时的消息来说,这是代价最低的做法,因为它不需要额外调用大模型来生成摘要,零 API 开销。 +还有一个重要的细节:Snip 会把「我释放了多少 Token」这个数字(snipTokensFreed)传给后面的第 5 层 Auto-Compact。 +为什么? +因为 Auto-Compact 是根据「当前上下文占了多少 Token」来决定是否触发的。 +如果 Snip 已经释放了足够的空间,Auto-Compact 就不需要触发了,避免两层同时做无谓的压缩。 + +### 第 3 步:裁剪老的工具输出 + +问题是什么? + 经过前两层之后,上下文里剩下的都是「不太老但也不太新」的消息。 +这些消息不能直接砍掉(可能还有用),但里面大量的工具输出其实已经过时了,比如 30 分钟前读的一个文件,现在那个文件可能已经被改过了。 +Claude Code 怎么做? + Micro-Compact 的核心思想是时间衰减:越老的工具结果越不重要,可以被裁剪。 +但是,不是所有工具的结果都能裁剪:const COMPACTABLE_TOOLS = new Set([ +  FILE_READ_TOOL_NAME,    // 读文件 → 可以重新读 +  ...SHELL_TOOL_NAMES,    // 执行命令 → 可以重新执行 +  GREP_TOOL_NAME,         // 搜索 → 可以重新搜 +  GLOB_TOOL_NAME,         // 查找文件 → 可以重新查 +  WEB_SEARCH_TOOL_NAME,   // 搜索网页 → 可以重新搜 +  FILE_EDIT_TOOL_NAME,    // 编辑文件 → 结果可裁剪 +  FILE_WRITE_TOOL_NAME,   // 写文件 → 结果可裁剪 +]) +看到规律了吗? +可以被裁剪的,都是「可重新获取」的工具,Read 的结果可以再读一次,Bash 的输出可以再执行一次,搜索结果可以再搜一次。 +但 AgentTool(子 Agent 的输出)、TaskTool(任务状态)这类工具的结果永远不会被裁剪,因为子 Agent 的推理过程是不可重复的,砍掉就真的丢了。 +具体裁剪逻辑是「保留最近 N 个,清理其余的」:// 收集所有可裁剪工具的结果 ID +const compactableIds = collectCompactableToolIds(messages) +// 保留最近 5 个,其余全部清理 +const keepRecent = Math.max(1, config.keepRecent)  // 至少保留 1 个 +const keepSet = new Set(compactableIds.slice(-keepRecent)) +const clearSet = compactableIds.filter(id => !keepSet.has(id)) +被裁剪的工具结果会被替换成一个标记:export const TIME_BASED_MC_CLEARED_MESSAGE = +  '[Old tool result content cleared]' +这样模型看到这个标记就知道「这里原来有内容但被清理了」。 +如果它后面还需要这些信息,它可以自己决定重新读文件或重新执行命令。 +为什么叫「时间衰减」? +因为它的触发条件跟时间有关,当距离上一次 API 调用超过一定时间(默认约 60 分钟),说明大模型 API 端的 Prompt Cache 大概率已经过期了。 +既然缓存已经没了,那清理旧的工具结果也不会浪费之前的缓存投入。 + +### 第 4 步:读时投影 + +问题是什么? + 经过前三层后,如果上下文还是太大,下一步就得做全量摘要了。 +但全量摘要代价很高(要额外调一次 API),而且会把整段对话的细节全部丢掉。 +有没有一个「中间态」,比全量摘要轻,但比 Micro-Compact 重? +Claude Code 怎么做? + Context Collapse 引入了一个非常巧妙的概念,读时投影(Read-Time Projection)。 +什么意思呢? +前面三层都是「写时压缩」,直接修改消息列表,把内容替换掉或删掉。 +但 Context Collapse 不修改原始消息,它只在调用 API 的那一刻,动态计算一个「压缩视图」给模型看。 +// 这是 query.ts 中的调用 +// 注意:这是一个"读时投影"——不修改 REPL 的完整历史, +// 只在发送给 API 时计算压缩视图 +if (feature('CONTEXT_COLLAPSE') && contextCollapse) { +  const collapseResult = await contextCollapse.applyCollapsesIfNeeded( +    messagesForQuery, +    toolUseContext, +    querySource, +  ) +  messagesForQuery = collapseResult.messages +} +它的触发有两级阈值:90% 上下文窗口:主动开始分段压缩旧消息(预留缓冲区)95% 上下文窗口:紧急压缩更多内容(留足 API 响应空间)这个设计最精妙的地方是它和第 5 层的配合:Context Collapse 运行在 Auto-Compact 之前。 +如果 Context Collapse 已经通过「读时投影」把上下文压到了阈值以下,Auto-Compact 就完全不需要触发了。 +这样模型保留了更多的细节上下文,而不是被一段粗糙的全量摘要替代。 + +### 第 5 步:全量摘要 + +问题是什么? + 当前面四层都不够用,上下文实在太大了,必须做一次彻底的压缩。 +这是代价最高但效果最强的一层。 + +什么时候触发? + Claude Code 用一个公式计算触发阈值:function getAutoCompactThreshold(model: string): number { +  const effectiveContextWindow = getEffectiveContextWindowSize(model) +  // 有效窗口 - 13K 缓冲区 = 触发阈值 +  return effectiveContextWindow - 13_000 +} +以 200K Token 的模型为例:有效窗口大约 180K(预留 20K 给输出),减去 13K 缓冲区,当上下文达到 167K Token 时触发。 +触发后做了什么? + 三步走: + +#### + +- 第一步:生成摘要。 +调用大模型,把整段对话总结成一段结构化摘要。 +这个摘要不是随便写的,Claude Code 用一个精心设计的 Prompt 要求模型按多个维度来总结:用户的主要请求和意图、关键技术概念、涉及的文件和代码片段、遇到的错误和修复方案、问题解决过程、用户的所有消息(不能遗漏任何一条)、待完成的任务、当前工作状态、建议的下一步。 +为什么要这么细? +因为压缩后模型要靠这段摘要来「恢复记忆」。 +如果摘要漏掉了关键信息(比如「用户还有一个待完成的任务」),模型就会忘记这件事。 + +#### + +- 第二步:替换旧消息。 +把压缩边界之前的所有消息删掉,替换为刚才生成的摘要。 +同时插入一条边界标记消息,记录压缩前的 Token 数,方便后续追踪。 + +#### + +- 第三步:Post-Compact Restoration(压缩后恢复)。 +这是整个流程中最关键的一步,压缩完不是就完了,还要主动恢复最重要的上下文:export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5 +export const POST_COMPACT_TOKEN_BUDGET = 50_000 +export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000 +export const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000 +系统会从文件状态缓存(fileStateCache)中找出最近访问过的文件,按最后访问时间排序,挑选最多 5 个、总共不超过 50K Token 的文件内容重新注入。 +同时恢复活跃的 Skill(不超过 25K Token),如果有进行中的 Plan 也会恢复 Plan 文件。 +为什么要做恢复? +因为压缩后模型「失忆」了,它不记得刚才读过的文件内容了。 +如果不恢复,模型的第一反应就是「让我重新读一下文件」,白白浪费一轮工具调用。 +主动恢复最近的文件内容,可以让模型无缝继续工作,体验上几乎感觉不到压缩发生过。 +还有一个兜底机制:如果全量摘要连续失败 3 次(比如 API 超时),系统会自动放弃,不会无限重试,这就是熔断器 模式,防止一个失败的压缩操作拖垮整个 Agent。 + +### 小结 + +回顾一下这五步压缩策略,它们体现了一个核心设计哲学:能轻则轻,逐步加码。 +层级手段信息损失API 开销触发条件第 1 层大结果存磁盘几乎为零零工具结果超 50KB第 2 层砍掉远古消息低零消息过时第 3 层清理老工具输出中低零缓存过期/数量超限第 4 层读时投影压缩中低上下文达 90%第 5 层全量摘要高高(一次 API 调用)上下文达 ~93%越往下代价越高,但效果也越强。 +大部分场景下前三层就足够了,它们完全不需要额外的 API 调用,只是「搬运」和「裁剪」数据。 +只有在极端情况下,才需要触发昂贵的全量摘要。 +这种设计的另一个好处是各层相互协调。 +第 2 层 Snip 会告诉第 5 层「我已经释放了多少 Token」,避免重复压缩。 +第 4 层 Context Collapse 在第 5 层之前运行,如果它够用了,第 5 层就不触发。 +每一层都在为下一层「减负」。 + +--- + +## 第二篇:多 Agent 架构设计 + +## 一、先搞明白 Multi-Agent 到底是个啥 + +在扒源码之前,我想先花一点篇幅,把 Multi-Agent 这个词的底层逻辑讲清楚。 +因为我发现很多人连「为啥要有多 agent」都没想明白,光盯着代码看是看不懂的。 + +### 为什么一个 agent 不够用? + +我们先回到最朴素的 agent 模型:一个 LLM + 一堆工具 + 一个循环。 +你给它一个任务,它自己决定调什么工具、调几次,直到做完。 +这就是经典的 agentic loop。 +看起来挺强的是吧? +但一到真实项目里,问题就出来了。 +想象你让一个 agent 去做这么一件事:「调研下 React 18 的新特性,然后在我的项目里实现一个 useTransition 的例子,最后帮我把代码评审一遍」。 +这一套下来有三个麻烦:第一,上下文会爆炸。 +调研阶段要看大量文档和 StackOverflow 链接,实现阶段要读项目代码,评审阶段又要重新读实现。 +三个阶段的内容全塞到一个 agent 的上下文里,token 蹭蹭往上涨,后面直接塞不下。 +第二,职责混乱。 +一个 agent 既当研究员又当程序员又当评审员,它自己都不知道现在是什么角色,容易跑偏。 +比如调研到一半就开始写代码了,代码写到一半又去查文档。 +第三,没法并发。 +一个 agent 一次只能做一件事,它在查文档的时候,项目代码就在那干等着。 +单 agent 硬扛三件事老板派活的思路这时候 Multi-Agent 的思路就来了。 +说白了,就像一个老板带团队:老板不自己一头扎进代码里,而是把任务拆成几块,派给不同的「专家」。 +研究员去调研,工程师去写代码,评审员去挑错。 +老板自己只负责看大方向、收结果、做决策。 + +这样一来:每个专家的上下文是干净的(只装自己领域的信息);职责也清楚(研究员就好好查资料别去写代码);多个专家还能同时开工。 +这就是 Multi-Agent 的核心思想:把一个大任务拆给多个职责清晰的 agent 去做,它们之间通过某种方式通信和协作。 + +### Multi-Agent 的三种常见形态 + +绕开花哨的术语,Multi-Agent 系统在工业界落地时,一般就三种形态。 +第一种,父子型。 +主 agent 处理整个任务,遇到某个子问题时派一个 subagent 出去搞定,拿结果回来接着干。 +这是最常见的,Claude Code 里的 Task 工具就是这种。 +第二种,平级协作型。 +几个 agent 职责对等,通过共享状态或者消息互相协作。 +不过这种在工程上比较难落地,状态同步很麻烦。 +第三种,主从型(Coordinator-Worker)。 +有一个专门的「协调者 agent」,它自己不干活,只负责派 worker、收结果、做合成。 +worker 之间互不通信,全靠协调者调度。 +这种是高并发场景的标配。 +Claude Code 源码里,常规 Subagent 对应父子型,Coordinator 模式对应主从型,Fork Subagent 是父子型的一个特殊优化版本(跟 cache 有关,后面讲)。 + +### subagent 在 Claude Code 里到底长啥样? + +讲到这儿可能还有朋友有点虚:「subagent 听起来挺抽象,它在 Claude Code 里到底长啥样,看得见吗? +」我举个真实能感知的场景你就懂了。 +你跟 Claude Code 说「调研一下这个项目的认证模块」,它自己判断一下:这活得派个「侦察兵」去干,而不是我亲自扎进去。 +于是它在内部调了一个叫 Agent 的工具(对,这个工具的名字就叫 Agent),把任务交给一个叫 Explore 的内置 subagent 去跑。 +Explore 带着一套精简的工具池(只有读文件、搜代码这些只读工具),带着一份独立的上下文,跑完调研把结果打包回来交给主 agent。 +主 agent 收到结果后,该改代码改代码、该回答回答。 +所以 subagent 不是什么玄学,说白了就是「主 agent 通过一个特定工具派出去的另一个独立 agent 实例」。 +每一个 subagent 都是一个真实存在的执行单元,有自己的工具池、上下文、生命周期。 +明白了这些,咱们就可以进入 Claude Code 的源码了。 + +## 二、Subagent 的隔离机制 + +在讲通信、讲并发之前,我想先从 Claude Code 多 agent 设计里最关键的一环讲起:隔离机制。 +为什么隔离最关键? +你想想,多 agent 系统本质就是「一堆 agent 共处一个进程、共享一个底层运行时」。 +如果隔离做得不好,一个 subagent 偷偷污染了父 agent 的状态、或者调了不该调的工具,整个系统就会乱成一锅粥。 +Claude Code 在 subagent 启动时,把隔离做到了两个维度:工具隔离(不给子 agent 它不该有的工具)和 上下文隔离(不让子 agent 搅乱父 agent 的运行时状态)。 +咱们一个一个看。 +第一维度:给子 agent 发一个定制工具箱先说工具隔离。 +这是 Claude Code 多 agent 设计里最容易被忽略,但又很重要的一环。 +什么意思呢? +主 agent 拥有一大堆工具(读文件、写文件、执行命令、派 subagent、问用户问题等等几十个),但你不能把这堆工具原封不动地丢给 subagent。 +为啥? +你想想,如果 subagent 也能调派新 subagent 的工具,那它就能派子子 agent,子子 agent 又派子子子 agent,层层嵌套没完没了,token 消耗直接起飞。 +再比如主 agent 用来管理任务列表的工具,是给主 agent 的大脑用的,subagent 跟着瞎写会污染主 agent 的待办状态。 +所以 Claude Code 给 subagent 发工具的思路是「按 agent 身份走三道准入门」:第一道门是「所有 subagent 通用黑名单」。 +这道门里被禁的工具有几类:能派新 subagent 的工具:防止子再派孙、孙再派重孙的递归嵌套能主动问用户问题的工具:子 agent 不该抢主 agent 的对话权,用户是跟主 agent 说话的能切换规划模式的工具:规划模式是主 agent 用来跟用户对齐方案的,子 agent 没资格切能停止其他任务的工具:任务管理是主线程的专属权力,子 agent 乱停会天下大乱第二道门是「自定义 agent 多套一层黑名单」。 +用户自己写的 agent(比如在项目里自己配的那种 Markdown agent)比内置 agent 要再严一点,因为用户写的没经过官方审核,多防一道更安全。 +第三道门反过来,是「后台异步 agent 走白名单」。 +这类 agent 是完全后台跑的,没法跟用户交互,所以只准用事先圈定好的一小批工具(读文件、搜代码、执行命令、编辑文件这些)。 +白名单的哲学是「默认不准用,明确列出来的才能用」,比黑名单更保险。 +三道门走下来,每个 subagent 拿到的都是一份量身定制的工具池,既够它干活,又不会越权。 +这个机制在源码里其实就是一个过滤函数:// src/tools/AgentTool/agentToolUtils.ts:70 +exportfunction filterToolsForAgent({ tools, isBuiltIn, isAsync, permissionMode }): Tools { +return tools.filter(tool => { +    if (tool.name.startsWith('mcp__')) returntrue// MCP 工具全放行 +    if (ALL_AGENT_DISALLOWED_TOOLS.has(tool.name)) returnfalse +    if (!isBuiltIn && CUSTOM_AGENT_DISALLOWED_TOOLS.has(tool.name)) returnfalse +    if (isAsync && !ASYNC_AGENT_ALLOWED_TOOLS.has(tool.name)) { +      returnfalse +    } +    returntrue +  }) +} +可以看到就是顺着「全局黑名单 → 自定义 agent 加严 → 异步白名单」这三道条件依次判定。 +最后留下来的,才是这个 subagent 能用的工具。 +这个设计看着简单,其实挺有工程智慧的。 +我在设计自己的多 agent 系统时,就学到了一条原则:不要假设所有 agent 都能用所有工具,按 agent 类型做细粒度的权限控制。 + +### 第二维度:搭一个隔离的运行环境 + +说完工具,再来聊第二维度:上下文隔离。 +这块是 Claude Code 多 agent 设计里最精髓的一块,我觉得全篇文章最值得细读的就是这一节。 +先说问题。 +父 agent 跑起来后有一个庞大的运行时上下文,里面装着很多东西:已经读过哪些文件、每个文件读到第几行、全局的 UI 状态、中止信号、权限状态、任务注册表等等。 +现在轮到你做设计。 +要派一个 subagent,这份庞大上下文怎么传给它? +你脑子里很可能蹦出两个直觉方案:A 完全共享(父那份直接给子用)、或者 B 完全新建(给子一份全新空的)。 +先别看下面,自己想想哪个对? +…先说 A 不行,举个具体场景你就懂:父 agent 已经读过 file.ts 的前 100 行,子 agent 拿过去接着读到 200 行。 +这下父 agent 那边「文件读到哪了」的缓存被刷成 200 了,下次它要读这文件就以为自己已经读过 200 行了,直接跳过。 +子的一次操作,把父的视图污染了。 +再说 B 也不行:用户按 Ctrl+C 想中止整个任务,主线程把中止信号广播出去,结果子 agent 因为是全新上下文收不到这个信号,对外面发生啥一无所知,自顾自继续跑。 +子 agent 跟世界完全脱节了。 +发现了吧,两个极端都走不通。 +那 Claude Code 怎么办? +答案是一个很巧妙的折中思路:不按「整体」决策,而是按「字段」决策。 +每一项状态单独判断该克隆、该共享、该屏蔽,还是该新建。 +我把 Claude Code 在这件事上的四个关键决策挑出来,用大白话讲一遍: + +- 决策一:「读文件的缓存」要复制一份给子 agent这个缓存存的是「这个文件读过没、读到第几行」。 +如果父子共享,子 agent 读了某个文件,父 agent 会误以为自己也读过,下次跳过不读,数据就错了。 +所以要复制一份独立的给子 agent,子怎么折腾都不影响父的文件视图。 + +- 决策二:「改全局状态」这件事对子 agent 直接关闭全局 UI 状态是主线程用 React 在管的。 +如果异步 subagent 也能改,就会出现「两边同时改同一份状态、抢起来对不上」的问题,界面就花了。 +所以 Claude Code 干脆把 subagent 的「写全局状态」这个权力完全关闭掉,改成空操作,一了百了。 + +- 决策三:但「注册后台任务」这条通路得保留这里有个小细节值得讲。 +既然子 agent 的写权力关掉了,那它自己起的后台进程(比如在后台跑一条 bash 命令)怎么登记到全局任务表? +Claude Code 专门开了一个小口子:其他写全局的口都堵死,唯独「注册/结束后台任务」这条路留着。 +不然子 agent 起的后台进程就变成「没爹的孤儿进程」,永远在后台跑没人回收。 + +- 决策四:给每个 subagent 发独立 ID、深度代代 +1每派一个 subagent,都给它一个独立的 ID,并且在父 agent 的深度基础上 +1。 +这样系统能随时知道「当前这个 agent 处于嵌套的第几层」。 +深度超过阈值(比如 5 层)就报警甚至强制停止,防止意外嵌套失控。 +这四个决策其实回答了四类问题:信息怎么传、状态怎么写、通路怎么留、身份怎么追踪。 +对应到源码里,就是一个叫 createSubagentContext 的函数,我把最能说明上面四个决策的部分精简出来:// src/utils/forkedAgent.ts:345 +exportfunction createSubagentContext(parentContext, overrides): ToolUseContext { +return { +    // + +- 决策一:文件读缓存克隆一份 +    readFileState: cloneFileStateCache(parentContext.readFileState), +    // + +- 决策二:写全局状态直接设为空操作 +    setAppState: () => {}, +    // + +- 决策三:但任务注册的通路例外保留 +    setAppStateForTasks: parentContext.setAppStateForTasks ?? parentContext.setAppState, +    // + +- 决策四:独立 ID + 深度 +1 +    agentId: overrides?.agentId ?? createAgentId(), +    queryTracking: { +      chainId: randomUUID(), +      depth: (parentContext.queryTracking?.depth ?? -1) + 1, +    }, +    // ...其他字段略 +  } +} +你看这几行代码,一一对应上面讲的四个决策:克隆缓存、关掉写权限、保留任务通路、发独立 ID。 +看完这块,我的感受是:所谓上下文隔离,不是一刀切地「全隔离」或者「不隔离」,而是按每个状态的语义单独决策。 +这个细腻劲儿,正是 Claude Code 这种工业级产品稳定跑的根基。 +走完「工具隔离」和「上下文隔离」这两道门,一个 subagent 就拿到了干净的工具池 + 干净的运行环境,可以独立跑起来了。 +那父 agent 和这个跑起来的 subagent,又是怎么互相说话的呢? +下一章见真章。 + +## 三、父子 Agent 是怎么通信的 + +隔离机制搞定了,但隔离只是开始,真正决定一个多 agent 系统好不好用的,是它们之间怎么通信。 +这一章我来讲 Claude Code 的通信方式。 +先抛一个问题:subagent 跑起来之后,父 agent 怎么给它发新指令? +subagent 又怎么把结果交回去? +为什么不用函数调用? +我建议你先停个 10 秒,自己想想:如果让你来设计这套通信,你会怎么写? +大概率你脑子里第一反应是「父 agent 调个函数,等 subagent 跑完返回」对吧? +这跟我们平时写 RPC 调远程服务的思路一模一样,太自然了。 +但我接着追问你两个问题,你看你能不能答上来:第一个追问:如果 subagent 是个跑 5 分钟的代码评审任务,那这 5 分钟里,父 agent 能干啥? +用户跟父 agent 说话又会发生什么? +第二个追问:如果父 agent 想同时派 5 个 subagent 并行调研 5 个模块,你这个「调函数等返回」的方案要怎么改? +是不是有点卡了? +第一个追问的答案是:父 agent 啥也干不了,被同步阻塞死了。 +用户在这 5 分钟里跟它说话也没反应。 +第二个追问的答案是:要么 5 个 subagent 全在主线程里阻塞排队,要么得手动搓各种并发代码,整体会乱成一锅粥。 +Claude Code 正是看穿了这两个坑,才换了一个完全不一样的路子:消息驱动。 +想象每个 subagent 是公司里一个带「信箱」的独立员工。 +父 agent 要给它布置新活,就往它信箱里扔一张字条走人,不站在那儿等。 +subagent 自己干完活了,通过另一条信道把结果送回主 agent 的案头。 +这个「信箱 + 字条」的模型,本质上就是消息队列 + 异步通知。 +没有直接的函数返回,没有主线程阻塞,所有沟通都是消息。 +subagent 的员工档案为了支持这套模型,Claude Code 给每个 subagent 建了一份「员工档案」:一个对象,里面记着这个 subagent 的 ID、当前状态(等待中/跑步中/已完成/失败/被停了)、它的信箱(待处理消息数组)、已经产生的结果、进度信息等等。 +所有跟 subagent 有关的读写(父要发消息,子要改状态),都通过全局的 task 表里这份档案来进行。 +对应到源码里的类型定义大致长这样:// src/tasks/LocalAgentTask/LocalAgentTask.tsx:116 +export type LocalAgentTaskState = TaskStateBase & { +  type: 'local_agent'; +  agentId: string;               // 子 agent 唯一 ID +  prompt: string;                // 初始任务 +  agentType: string; +  status: TaskStatus;            // pending/running/completed/failed/killed +  result?: AgentToolResult;      // 完成后的结果 +  progress?: AgentProgress;      // 进度 +  isBackgrounded: boolean;       // 是否已转后台 +  pendingMessages: string[];     // 信箱:父 agent 扔进来的待处理消息 +  messages?: Message[]; +}; +重点关注的是 pendingMessages 数组,它就是我们前面说的「信箱」,父 agent 往里扔字条,子 agent 自己来捡。 + +### 父 → 子:扔字条 + 子自己来取 + +父 agent 要给跑着的 subagent 发指令的流程,拆开看就是两步: + +#### + +- 第一步:父往信箱扔字条。 +父 agent 在自己的 agentic loop 里调用一个叫 SendMessage 的工具,工具内部做的事情很简单:往目标 subagent 档案的信箱末尾追加一条消息,然后立刻返回。 +父 agent 扔完走人,不等子 agent 看。 + +#### + +- 第二步:子在循环边界自己捡字条。 +subagent 自己的 agentic loop 在每一轮工具调用结束后,都会去瞄一眼自己的信箱。 +如果有新字条,就把这些字条作为「用户消息」注入自己的对话历史,然后带着新消息进入下一轮 LLM 调用。 +这里有个细节设计特别巧:如果子 agent 已经干完活停下来了(completed 或者被手动停了),父 agent 发 SendMessage 会怎样? +Claude Code 的做法是:自动把它唤醒。 +从磁盘上那份已经保存的对话 transcript 里,把子 agent 的完整对话历史恢复出来,拼上新消息,重新跑起来。 +这个唤醒机制很妙,意味着 subagent 即使完成了也不是「死了」,父 agent 随时可以叫醒它继续干。 +对应到源码,SendMessage 工具里的核心逻辑长这样:// src/tools/SendMessageTool/SendMessageTool.ts:800 +const task = appState.tasks[agentId] +if (isLocalAgentTask(task) && !isMainSessionTask(task)) { +  if (task.status === 'running') { +    queuePendingMessage(agentId, input.message, context.setAppStateForTasks) +    return { data: { success: true, message: 'Message queued...' } } +  } +  // 任务已停止,自动唤醒从 transcript 里恢复 +  const result = await resumeAgentBackground({ agentId, prompt: input.message, ... }) +} +可以看到就是两个分支:正在跑就扔信箱,已经停了就唤醒。 +「扔信箱」这个动作本身的实现就 4 行:// src/tasks/LocalAgentTask/LocalAgentTask.tsx:162 +export function queuePendingMessage(taskId, msg, setAppState): void { +  updateTaskState(taskId, setAppState, task => ({ +    ...task, +    pendingMessages: [...task.pendingMessages, msg] +  })); +} +纯纯的「追加到数组末尾」。 + +### 子 → 父:把通知伪装成用户消息 + +反方向呢? +subagent 跑完一个任务,怎么告诉父 agent「我干完了」? +最直觉的做法是:给主线程发一个「工具返回结果」事件。 +但 Claude Code 玩得更骚气,它的设计是:把完成通知拼成一段 XML,伪装成一条用户消息,塞给父 agent 的对话历史。 +父 agent 那边看到的就像用户发了一条新消息过来,长这样: +agent-a1b +/tmp/xxx.txt +completed +Agent "Investigate auth bug" completed +Found null pointer in src/auth/validate.ts:42... + +  12345 +  8 +  34567 + + +📌 配图建议:task-notification XML 渲染示意,高亮各个 tag 的含义为啥要搞 XML 不用结构化对象? + 这个设计有它的巧妙之处,我特意想明白过。 +第一,LLM 对 XML 非常友好。 +Anthropic 训练 Claude 的时候就强调了 XML 的结构化表达。 +你把 XML 塞到 prompt 里,LLM 能很自然地解析出语义,不用额外教它。 +第二,XML 是纯文本,可以直接塞进对话历史。 +如果是结构化对象,还得额外走个「工具结果」的字段结构,流程更复杂。 +第三,它伪装成用户消息,天然地复用了 agentic loop 的处理逻辑。 +父 agent 不需要额外的状态机去「等通知」,它就像收到一条新的用户输入一样处理。 +这种「把系统事件伪装成对话」的设计思路,在 LLM 应用里是非常值得学的一招。 +对应到源码里,生成这段 XML 的代码就是在拼字符串:// src/tasks/LocalAgentTask/LocalAgentTask.tsx:197 +const message = `<${TASK_NOTIFICATION_TAG}> +<${TASK_ID_TAG}>${taskId} +<${OUTPUT_FILE_TAG}>${outputPath} +<${STATUS_TAG}>${status} +<${SUMMARY_TAG}>${summary}${resultSection}${usageSection} +`; +enqueuePendingNotification({ value: message, mode: 'task-notification' }); +拼完就扔到主 agent 的待处理消息队列里,等主 agent 下一轮循环时当作一条用户消息来处理。 + +### 为什么要自动后台化? + +再讲一个通信体系里的重要设计:auto-background。 +subagent 跑起来之后,父 agent 其实要等一会。 +如果 subagent 很快跑完(比如 30 秒内),父 agent 就在前台阻塞等,像一次普通工具调用,完事就拿结果继续。 +但如果 subagent 跑超过 2 分钟还没完,Claude Code 会自动把它转到后台,让父 agent 可以先继续干别的。 +2 分钟后 subagent 真完成了,通过前面说的 task-notification 把结果送回。 +这个设计本质上是把同步工具调用自动降级成异步通知的优化。 +没有它,长任务会一直占着父 agent 的执行权,用户也没法跟父 agent 继续对话。 +源码里这个「2 分钟阈值」就是一个常量开关:// src/tools/AgentTool/AgentTool.tsx:72 +function getAutoBackgroundMs(): number { +  if (isEnvTruthy(process.env.CLAUDE_AUTO_BACKGROUND_TASKS)  +      || getFeatureValue_CACHED_MAY_BE_STALE('tengu_auto_background_agents', false)) { +    return 120_000;  // 2 分钟 +  } +  return 0; +} + +### 回头看通信设计的全貌 + +到这儿我们把父子通信的两个方向都讲清楚了:父 → 子:调 SendMessage 工具把消息写进子 agent 的信箱 → 子 agent 下一轮循环边界自己读取。 +子 → 父:子 agent 把完成通知拼成 XML 消息 → 伪装成用户消息注入父 agent 对话。 +整个通信体系就两个关键字:异步 + 消息。 +没有直接函数调用,没有锁,没有回调地狱,全靠读写共享的任务状态和消息队列。 +这种设计有个特别大的好处:天然支持多 subagent 并发。 +因为父 agent 从来不阻塞等子,它可以同时派 5 个 subagent,谁先完成谁先给它发通知,父 agent 按到达顺序处理就行。 +下一章,我们再讲一个特别精妙的优化:Fork Subagent。 + +## 四、Fork Subagent:省钱又省延迟的隐藏大招 + +前面讲的常规 subagent 已经是主流玩法了,但 Claude Code 还有一个更精妙的机制,叫 Fork Subagent。 +这个机制有点隐蔽,用起来是透明的,但对成本和延迟的优化非常显著。 +我先抛两个问题让你估算下,先别往下翻看答案:第一,Claude Code 的 system prompt 大概有多长? +是几百 token、几千 token,还是上万 token? + 第二,每派一个 subagent,如果它有自己独立的 system prompt,LLM API 那边对这段 prompt 是从头算一遍,还是有办法复用? +subagent 的隐藏成本公布答案:Claude Code 的 system prompt 长度是上万 token,里面塞了大量的工具说明、规范约定、用户上下文。 +而每派一个 subagent,如果它有独立的 system prompt(内置的 Explore、Plan 这些都有独立的),LLM API 那边就得对这一万多 token 重新从头算一遍,就跟没见过似的。 +这有两个代价:钱(input token 重新算钱)和延迟(首 token 等更久)。 +在生产环境里,subagent 派得越频繁,这个开销线性放大,是个很可怕的成本黑洞。 +Anthropic 有个 prompt 缓存机制可以缓解这事。 +简单说:API 请求里如果前缀跟之前某次请求一样,这段前缀可以不重新算,直接走缓存,价钱只要原来的 10%,延迟也大幅降低。 +到这儿我再问你一个关键的:prompt 缓存命中的条件是「内容大致相同」就行,还是「字符级别相同」,还是「字节级别完全相同」? +再猜一下。 +公布:是最严格的那个,字节级别完全相同。 +系统 prompt 一个字不一样、工具列表顺序不一样、甚至空格位置不一样,都会直接没命中缓存。 +是不是比你想的严格多了? +那既然这么严,能不能设计一种 subagent,它的 system prompt 和工具池跟父 agent 完全一样,这样就能复用父的缓存了? +这就是 Fork Subagent 的起点。 + +### Fork 的核心思路:派一个「字节级相同」的分身 + +Fork Subagent 的直觉是这样的:派一个子 agent 出去干活,但这个子 agent 的 API 请求前缀跟父 agent 一模一样,让 Anthropic 那边一看:「哦这个前缀我认识」,走缓存。 +这里的「一模一样」要做到什么程度? +字节级。 +一个字节不对都不行。 +具体要对齐哪些东西呢? +有五样必须跟父 agent 完全一致:系统 prompt 的内容(最核心的,对齐第一位)用户上下文(拼在消息前的那部分动态内容,比如当前项目的 CLAUDE.md 内容)系统上下文(拼在 system prompt 后的环境信息)工具池的顺序和定义(工具的字段结构会被序列化进 API 请求,顺序都不能变)对话历史的前缀(决定了 user/assistant 消息序列中「从哪里开始分叉」)这五样只要有一样跟父 agent 字节不一致,缓存就直接没了。 +对应到源码里,Claude Code 专门定义了一个类型(CacheSafeParams),把这五项打包:// src/utils/forkedAgent.ts:57 +exporttype CacheSafeParams = { +/** System prompt - 必须跟父完全一致 */ +  systemPrompt: SystemPrompt +/** User context - 拼接在消息前,影响缓存 */ +  userContext: { [k: string]: string } +/** System context - 拼接在 system prompt 后,影响缓存 */ +  systemContext: { [k: string]: string } +/** 工具池、模型等所在的上下文 */ +  toolUseContext: ToolUseContext +/** 父 agent 的消息前缀,用于缓存共享 */ +  forkContextMessages: Message[] +} +你看这个类型的意思很明显:凡是会影响缓存命中的字段,我全列在这儿,你 Fork 的时候严格按这份清单跟父 agent 对齐。 + +### 一个有意思的细节:system prompt 不重新生成 + +Fork Subagent 的合成定义里有个有意思的细节,值得单独说。 +正常一个 subagent 有个生成 system prompt 的函数,跑的时候现生成一段 prompt 文本。 +但 Fork 机制用的那个 subagent 的生成函数直接返回空字符串:// src/tools/AgentTool/forkSubagent.ts:60 +export const FORK_AGENT = { +  agentType: FORK_SUBAGENT_TYPE, +  tools: ['*'],             // 用父的完整工具池 +  maxTurns: 200, +  model: 'inherit',          // 继承父的模型 +  permissionMode: 'bubble',  // 权限弹窗浮到父终端 +  source: 'built-in', +  getSystemPrompt: () => '', // 返回空串! + +} satisfies BuiltInAgentDefinition +这不是偷懒,而是精心设计的。 +为啥要返回空串? +因为 Fork subagent 的 system prompt 根本不走这个函数生成,而是直接用父 agent 已经渲染好的那份字节。 +原因很简单:如果重新调一次生成函数,里面可能有些小差异(比如某个功能开关的缓存状态变了、某个动态字段的值变了),生成出来的 prompt 跟父 agent 就可能差一个字符,缓存就没了。 +最稳的办法是:把父 agent 那边已经渲染出来的 prompt,作为字节原样拿过来用,一个字节都不动。 +这个细节非常工业级,普通人写 agent 系统根本想不到。 + +### 什么时候用 Fork,什么时候用常规 subagent? + +Fork 机制不是万能的,它的适用场景很特定:你希望子 agent 完全继承父 agent 的整个上下文(对话历史、system prompt、工具池),只是「派个分身去试试另一条路」。 +比如「Ctrl+F 生成 PR 描述」「运行 /btw 命令做 post-turn 总结」,这些任务需要父 agent 的完整上下文,但又不希望污染父 agent 的主循环。 +相反,如果你的任务有明确的专业分工(比如派一个专门搜代码的 agent、派一个专门做规划的 agent),那就用常规 subagent,它们的 system prompt 是定制的,Fork 机制反而不适用。 +还有一个关键点:Fork 机制和 Coordinator 模式是互斥的。 +Coordinator 模式下主 agent 已经是个纯协调者了,它派的 worker 本来就是异步的,不需要 Fork 这种「轻量分身」机制。 +两个机制职责重叠,就只留一个:// src/tools/AgentTool/forkSubagent.ts:32 +exportfunction isForkSubagentEnabled(): boolean { +if (feature('FORK_SUBAGENT')) { +    if (isCoordinatorMode()) returnfalse// 互斥! + +    if (getIsNonInteractiveSession()) returnfalse +    returntrue +  } +returnfalse +} + +### Fork 的工程启示 + +Fork 机制我想单独说下它对我们的启示。 +很多人做 agent 系统只关心「能不能跑起来」,不关心「跑起来要花多少钱」。 +但在生产环境,这两个是一回事。 +Claude Code 靠 Fork 机制,在缓存友好的场景下能把 subagent 的成本降到原来的 10% 左右。 +这意味着什么? +意味着你的 subagent 可以调得更频繁。 +原本成本考虑不敢派的活,现在都能派了,这反过来又让整个 agent 系统的能力边界扩大了。 +所以成本优化本身就是能力的一部分。 +这个思路我觉得对自建 agent 系统的朋友特别重要。 +好了,讲完 Fork,下面进入整篇文章最「多 agent」的一章:Coordinator 模式。 + +## 五、Coordinator 模式:真正的多 Agent 并行协作 + +前面讲的 subagent(不管是常规的还是 Fork 的),本质都是父子结构:父 agent 派一个子,自己该干啥干啥,子完成了通知一声。 +但如果你的任务量很大,需要一堆 agent 同时开工呢? +比如一个大的代码迁移,要并行调研 10 个模块。 +这时候父子结构就显得单薄了。 +Claude Code 为此设计了一个专门的模式:Coordinator 模式。 +这是 Claude Code 多 agent 设计里最「多 agent」的部分,也是最能打的地方。 + +### Coordinator 模式的启用 + +这个模式不是默认开的,要显式打开。 +需要同时满足两个条件:编译时的功能开关和**运行时的环境变量 CLAUDE_CODE_COORDINATOR_MODE=1**。 +// src/coordinator/coordinatorMode.ts:36 +export function isCoordinatorMode(): boolean { +  if (feature('COORDINATOR_MODE')) { +    return isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) +  } +  return false +} +开启之后,主 agent 的行为模式会发生根本性变化。 + +### 核心设计:主 agent 退化成「纯协调者」 + +常规模式下,主 agent 是「全能型选手」:它读代码、写代码、跑测试、做规划全都干,只在需要时才派 subagent 帮一把。 +Coordinator 模式下,主 agent 不干实际工作了,它只做三件事:派 worker、收结果、合成答案。 +这个角色转换是通过主 agent 的 system prompt 强制约束出来的。 +打开源码里那段 prompt,开头就写得很明白:You are Claude Code, an AI assistant that orchestrates software engineering  +tasks across multiple workers. + +## 1. Your Role +You are a **coordinator**. Your job is to: +- Help the user achieve their goal +- Direct workers to research, implement and verify code changes +- Synthesize results and communicate with the user +- Answer questions directly when possible, don't delegate work  +  that you can handle without tools +翻译一下:你的身份是协调者,你的工作是指挥 worker 去做研究、实现、验证,然后自己合成结果跟用户交流。 +能自己回答的问题不要派人去做。 + +### 三大内部工具 + +既然主 agent 要协调,就得有专门的协调工具。 +Coordinator 模式下,主 agent 多了一套「团队管理」工具箱:派 worker 的工具:派一个新 worker 出去干某件具体的活,派完立刻返回 worker 的 ID。 +创建/解散团队的工具:批量管理 worker 组。 +给 worker 发消息的工具:给已经派出去的 worker 发后续指令(也就是前面讲的 SendMessage),因为 worker 的上下文还在,续命比重新派一个更省钱。 +合成最终输出的工具:协调者合成完答案后,通过这个工具把最终回复交给用户。 +停止 worker 的工具:当协调者意识到某个 worker 跑错方向时,把它停掉省 token。 +这套工具放在一起,协调者就有了一整套指挥团队的 API。 +📌 配图建议:协调者工具箱图,把五个工具画成五个按钮,标注每个按钮的作用对应到源码里,这组「只有协调者能用」的内部工具是这样定义的:// src/coordinator/coordinatorMode.ts:29 +const INTERNAL_WORKER_TOOLS = new Set([ +  TEAM_CREATE_TOOL_NAME,       // 创建 worker 团队 +  TEAM_DELETE_TOOL_NAME,       // 解散团队 +  SEND_MESSAGE_TOOL_NAME,      // 给 worker 发消息 +  SYNTHETIC_OUTPUT_TOOL_NAME,  // 合成最终输出给用户 +]) + +### 并行才是真本事 + +Coordinator 模式的 prompt 里有一句我特别喜欢:Parallelism is your superpower. Workers are async. Launch independent workers concurrently whenever possible, don't serialize work that can run simultaneously and look for opportunities to fan out.翻译一下:并行是你的超能力,worker 全是异步的,能并行的绝不串行,多找机会一口气派一堆出去。 +这句话背后是一个很关键的工程事实:Claude Code 的派 worker 工具调用可以在同一条 assistant 消息里出现多次,底层会一起并发执行,不是一个跑完再跑下一个。 +所以协调者要做的就是在一次 LLM 回合里,一口气生成多个派 worker 的工具调用:派 worker 调研 auth 模块 +派 worker 调研 session 模块 +派 worker 调研 token 模块 +这三个调用同时启动,三个 worker 同时干活,协调者等通知一条条返回。 + +对比一下:串行:派 worker1 → 等 → 结果 → 派 worker2 → 等 → 结果 → 派 worker3... 用户等十分钟并行:同时派三个 worker → 三份结果陆续到 → 用户等三分钟多一点这就是「并行是超能力」的真正含义。 +工业级多 agent 系统,没有并行就没有可用性。 + +### 协调者的「任务流水线」 + +Coordinator 模式下,一个典型的任务流程被切成四个阶段:阶段谁来做目的调研Workers(并行)调查代码库、找文件、理解问题合成协调者本人读完发现、理解问题、写实现规格实现Workers按规格做具体修改、提交验证Workers测试改动是否真的工作注意中间的「合成」阶段是协调者亲自做,这是协调者存在的意义:理解全局,做决策。 +prompt 里反复强调:不要偷懒让 worker「based on your findings, implement the fix」,而是自己把 findings 读懂、写成具体的规格再派下去。 +这是一个非常重要的 multi-agent 设计哲学:协调者必须「理解」而不能「转发」。 +如果协调者只是转发,它就没有存在价值,worker 直接跟用户对话就行了。 + +### Continue vs Spawn:老 worker 还是新 worker? + +协调者要持续派活,遇到一个新任务,是给老 worker 发消息续命,还是派个新 worker 从头开始? +这是个有经验才能做好的决策。 +Claude Code 的 prompt 里给出了一张决策表,我总结一下核心逻辑:如果新任务跟 worker 现有上下文高度相关(比如刚查的文件现在要改),续命老 worker,因为它已经「知道」那些文件了。 +如果新任务跟 worker 现有上下文没关系,或者之前 worker 的工作走偏了,派新 worker,避免旧上下文干扰判断。 +验证这种需要「新鲜眼光」的工作,永远派新 worker,不能让刚写完代码的 worker 自己验自己。 +这个设计其实也挺反映人类团队合作的直觉:有的活就该让懂上下文的人接着干(沟通成本低),有的活就该换个人做(避免认知偏差)。 + +### Worker 的工具限制 + +Coordinator 模式下,worker 拿到的工具有什么不同? +关键在于:协调者专属的那套内部工具(创建团队、发消息、合成输出等等),不给 worker 用。 +worker 不需要再去协调别人,它的活是干事情。 +这其实是一个递归防护:如果 worker 也能派 worker,整个系统就变成递归树了,没完没了。 +通过工具白名单把 worker 的「派人权」收回,让系统结构保持「一个协调者 + 一堆 worker」的扁平形态。 + +### 跟常规 subagent 对比 + +讲完这些我们对比一下 Coordinator 模式和常规 subagent:维度常规 subagentCoordinator 模式主 agent 角色全能选手纯协调者subagent 执行同步(2 分钟后才转后台)默认异步并发程度偶尔并发最大化并发适合场景单个任务 + 临时帮手大任务 + 高并发拆解系统形态父子树协调者 + worker 扁平层 + +### Coordinator 模式的工程启示 + +讲完 Coordinator,我想提炼几条值得学的设计思想。 +第一,角色分离。 +协调和干活是两件事,不要让同一个 agent 身兼二职。 +角色清晰的系统更稳定。 +第二,并发优先。 +异步 + 消息队列是并发的基础,有了这套基础,多 agent 才能真正发挥威力。 +第三,合成不转发。 +协调者要理解中间结果,不能把它当传话筒。 +这是 Multi-Agent 系统里最容易踩坑的一点。 +第四,扁平不递归。 +通过工具权限把层级限制在两层(协调者 + worker),避免失控的递归嵌套。 + +## 六、5 条 Multi-Agent 设计原则 + +Claude Code 的源码扒得差不多了。 +我把前面讲的所有东西浓缩一下,沉淀成 5 条可以直接用到自己项目、也可以直接用到面试答案里的设计原则。 + +### 原则 1:上下文隔离要按字段粒度做 + +这是我最想强调的一条。 +很多 agent 框架的「隔离」就是粗暴地给 subagent 一个空 context,结果缺这缺那一堆 bug。 +Claude Code 的做法是:每个状态单独决策。 +读文件缓存克隆(避免污染),写全局状态关掉(避免两边抢),任务注册通路保留(不然孤儿进程没人回收),深度计数 +1(可追踪,防失控嵌套)。 +做多 agent 系统时,对着父 agent 的每项状态问一句:「子 agent 拿这个状态干啥? +会不会影响父? +」,就能避开大部分坑。 + +### 原则 2:通信走消息,不走函数调用 + +父 → 子:写入子 agent 的消息队列,子 agent 下一轮循环自己读取。 +子 → 父:把完成通知包装成 XML 消息,伪装成用户消息注入父 agent 对话。 +这套模型的好处:天然异步、天然支持并发、天然兼容 agentic loop、天然持久化(消息都能落盘)。 +如果你问面试官「你们的多 agent 之间怎么通信」,把这套答出来,基本就到位了。 +原则 3:工具权限要分级管控全局黑名单(防递归、防乱问用户),类型黑名单(自定义 agent 更严),异步白名单(后台 agent 只能用子集)。 +每种 agent 按自己的场景配工具,不要一刀切。 +原则 4:缓存友好是一种架构能力API 成本和延迟对生产环境 agent 来说是能力的一部分。 +设计 subagent 的时候,考虑它的 prompt 前缀能不能复用父 agent 的缓存,能省 80-90% 的成本。 +Claude Code 那套「严格锁定缓存前缀 + 复用父 agent 已渲染字节」的思路,是这方面的教科书式实现。 +原则 5:并行优先 + 协调者合成真正的多 agent 系统威力在并发。 +通过异步消息和消息队列做基础,通过协调者做合成,避免「大 agent 大循环什么都自己扛」的窘境。 +并且协调者要亲自合成,不能当传话筒。 +这 5 条原则背后,其实都能看到 Claude Code 源码里的清晰落点。 +我建议你别光记这些原则,下次看到 Multi-Agent 相关的东西,都拿这 5 条去对照,会迅速看出对方系统的深浅。 diff --git "a/docs/readme_img/claude\346\272\220\347\240\201\347\240\224\347\251\266\346\226\207\346\241\243/\345\244\232Agent\346\236\266\346\236\204\350\256\276\350\256\241_\345\216\237\346\226\207\345\244\207\344\273\275.md" "b/docs/readme_img/claude\346\272\220\347\240\201\347\240\224\347\251\266\346\226\207\346\241\243/\345\244\232Agent\346\236\266\346\236\204\350\256\276\350\256\241_\345\216\237\346\226\207\345\244\207\344\273\275.md" new file mode 100644 index 0000000..02e46f5 --- /dev/null +++ "b/docs/readme_img/claude\346\272\220\347\240\201\347\240\224\347\251\266\346\226\207\346\241\243/\345\244\232Agent\346\236\266\346\236\204\350\256\276\350\256\241_\345\216\237\346\226\207\345\244\207\344\273\275.md" @@ -0,0 +1,148 @@ +一、先搞明白 Multi-Agent 到底是个啥在扒源码之前,我想先花一点篇幅,把 Multi-Agent 这个词的底层逻辑讲清楚。因为我发现很多人连「为啥要有多 agent」都没想明白,光盯着代码看是看不懂的。为什么一个 agent 不够用?我们先回到最朴素的 agent 模型:一个 LLM + 一堆工具 + 一个循环。你给它一个任务,它自己决定调什么工具、调几次,直到做完。这就是经典的 agentic loop。看起来挺强的是吧?但一到真实项目里,问题就出来了。想象你让一个 agent 去做这么一件事:「调研下 React 18 的新特性,然后在我的项目里实现一个 useTransition 的例子,最后帮我把代码评审一遍」。这一套下来有三个麻烦:第一,上下文会爆炸。调研阶段要看大量文档和 StackOverflow 链接,实现阶段要读项目代码,评审阶段又要重新读实现。三个阶段的内容全塞到一个 agent 的上下文里,token 蹭蹭往上涨,后面直接塞不下。第二,职责混乱。一个 agent 既当研究员又当程序员又当评审员,它自己都不知道现在是什么角色,容易跑偏。比如调研到一半就开始写代码了,代码写到一半又去查文档。第三,没法并发。一个 agent 一次只能做一件事,它在查文档的时候,项目代码就在那干等着。单 agent 硬扛三件事老板派活的思路这时候 Multi-Agent 的思路就来了。说白了,就像一个老板带团队:老板不自己一头扎进代码里,而是把任务拆成几块,派给不同的「专家」。研究员去调研,工程师去写代码,评审员去挑错。老板自己只负责看大方向、收结果、做决策。这样一来:每个专家的上下文是干净的(只装自己领域的信息);职责也清楚(研究员就好好查资料别去写代码);多个专家还能同时开工。这就是 Multi-Agent 的核心思想:把一个大任务拆给多个职责清晰的 agent 去做,它们之间通过某种方式通信和协作。Multi-Agent 的三种常见形态绕开花哨的术语,Multi-Agent 系统在工业界落地时,一般就三种形态。第一种,父子型。主 agent 处理整个任务,遇到某个子问题时派一个 subagent 出去搞定,拿结果回来接着干。这是最常见的,Claude Code 里的 Task 工具就是这种。第二种,平级协作型。几个 agent 职责对等,通过共享状态或者消息互相协作。不过这种在工程上比较难落地,状态同步很麻烦。第三种,主从型(Coordinator-Worker)。有一个专门的「协调者 agent」,它自己不干活,只负责派 worker、收结果、做合成。worker 之间互不通信,全靠协调者调度。这种是高并发场景的标配。Claude Code 源码里,常规 Subagent 对应父子型,Coordinator 模式对应主从型,Fork Subagent 是父子型的一个特殊优化版本(跟 cache 有关,后面讲)。subagent 在 Claude Code 里到底长啥样?讲到这儿可能还有朋友有点虚:「subagent 听起来挺抽象,它在 Claude Code 里到底长啥样,看得见吗?」我举个真实能感知的场景你就懂了。你跟 Claude Code 说「调研一下这个项目的认证模块」,它自己判断一下:这活得派个「侦察兵」去干,而不是我亲自扎进去。于是它在内部调了一个叫 Agent 的工具(对,这个工具的名字就叫 Agent),把任务交给一个叫 Explore 的内置 subagent 去跑。Explore 带着一套精简的工具池(只有读文件、搜代码这些只读工具),带着一份独立的上下文,跑完调研把结果打包回来交给主 agent。主 agent 收到结果后,该改代码改代码、该回答回答。所以 subagent 不是什么玄学,说白了就是「主 agent 通过一个特定工具派出去的另一个独立 agent 实例」。每一个 subagent 都是一个真实存在的执行单元,有自己的工具池、上下文、生命周期。明白了这些,咱们就可以进入 Claude Code 的源码了。二、Subagent 的隔离机制在讲通信、讲并发之前,我想先从 Claude Code 多 agent 设计里最关键的一环讲起:隔离机制。为什么隔离最关键?你想想,多 agent 系统本质就是「一堆 agent 共处一个进程、共享一个底层运行时」。如果隔离做得不好,一个 subagent 偷偷污染了父 agent 的状态、或者调了不该调的工具,整个系统就会乱成一锅粥。Claude Code 在 subagent 启动时,把隔离做到了两个维度:工具隔离(不给子 agent 它不该有的工具)和 上下文隔离(不让子 agent 搅乱父 agent 的运行时状态)。咱们一个一个看。第一维度:给子 agent 发一个定制工具箱先说工具隔离。这是 Claude Code 多 agent 设计里最容易被忽略,但又很重要的一环。什么意思呢?主 agent 拥有一大堆工具(读文件、写文件、执行命令、派 subagent、问用户问题等等几十个),但你不能把这堆工具原封不动地丢给 subagent。为啥?你想想,如果 subagent 也能调派新 subagent 的工具,那它就能派子子 agent,子子 agent 又派子子子 agent,层层嵌套没完没了,token 消耗直接起飞。再比如主 agent 用来管理任务列表的工具,是给主 agent 的大脑用的,subagent 跟着瞎写会污染主 agent 的待办状态。所以 Claude Code 给 subagent 发工具的思路是「按 agent 身份走三道准入门」:第一道门是「所有 subagent 通用黑名单」。这道门里被禁的工具有几类:能派新 subagent 的工具:防止子再派孙、孙再派重孙的递归嵌套能主动问用户问题的工具:子 agent 不该抢主 agent 的对话权,用户是跟主 agent 说话的能切换规划模式的工具:规划模式是主 agent 用来跟用户对齐方案的,子 agent 没资格切能停止其他任务的工具:任务管理是主线程的专属权力,子 agent 乱停会天下大乱第二道门是「自定义 agent 多套一层黑名单」。用户自己写的 agent(比如在项目里自己配的那种 Markdown agent)比内置 agent 要再严一点,因为用户写的没经过官方审核,多防一道更安全。第三道门反过来,是「后台异步 agent 走白名单」。这类 agent 是完全后台跑的,没法跟用户交互,所以只准用事先圈定好的一小批工具(读文件、搜代码、执行命令、编辑文件这些)。白名单的哲学是「默认不准用,明确列出来的才能用」,比黑名单更保险。三道门走下来,每个 subagent 拿到的都是一份量身定制的工具池,既够它干活,又不会越权。这个机制在源码里其实就是一个过滤函数:// src/tools/AgentTool/agentToolUtils.ts:70 +exportfunction filterToolsForAgent({ tools, isBuiltIn, isAsync, permissionMode }): Tools { +return tools.filter(tool => { +    if (tool.name.startsWith('mcp__')) returntrue// MCP 工具全放行 +    if (ALL_AGENT_DISALLOWED_TOOLS.has(tool.name)) returnfalse +    if (!isBuiltIn && CUSTOM_AGENT_DISALLOWED_TOOLS.has(tool.name)) returnfalse +    if (isAsync && !ASYNC_AGENT_ALLOWED_TOOLS.has(tool.name)) { +      returnfalse +    } +    returntrue +  }) +} +可以看到就是顺着「全局黑名单 → 自定义 agent 加严 → 异步白名单」这三道条件依次判定。最后留下来的,才是这个 subagent 能用的工具。这个设计看着简单,其实挺有工程智慧的。我在设计自己的多 agent 系统时,就学到了一条原则:不要假设所有 agent 都能用所有工具,按 agent 类型做细粒度的权限控制。第二维度:搭一个隔离的运行环境说完工具,再来聊第二维度:上下文隔离。这块是 Claude Code 多 agent 设计里最精髓的一块,我觉得全篇文章最值得细读的就是这一节。先说问题。父 agent 跑起来后有一个庞大的运行时上下文,里面装着很多东西:已经读过哪些文件、每个文件读到第几行、全局的 UI 状态、中止信号、权限状态、任务注册表等等。现在轮到你做设计。要派一个 subagent,这份庞大上下文怎么传给它?你脑子里很可能蹦出两个直觉方案:A 完全共享(父那份直接给子用)、或者 B 完全新建(给子一份全新空的)。先别看下面,自己想想哪个对?…先说 A 不行,举个具体场景你就懂:父 agent 已经读过 file.ts 的前 100 行,子 agent 拿过去接着读到 200 行。这下父 agent 那边「文件读到哪了」的缓存被刷成 200 了,下次它要读这文件就以为自己已经读过 200 行了,直接跳过。子的一次操作,把父的视图污染了。再说 B 也不行:用户按 Ctrl+C 想中止整个任务,主线程把中止信号广播出去,结果子 agent 因为是全新上下文收不到这个信号,对外面发生啥一无所知,自顾自继续跑。子 agent 跟世界完全脱节了。发现了吧,两个极端都走不通。那 Claude Code 怎么办?答案是一个很巧妙的折中思路:不按「整体」决策,而是按「字段」决策。每一项状态单独判断该克隆、该共享、该屏蔽,还是该新建。我把 Claude Code 在这件事上的四个关键决策挑出来,用大白话讲一遍:决策一:「读文件的缓存」要复制一份给子 agent这个缓存存的是「这个文件读过没、读到第几行」。如果父子共享,子 agent 读了某个文件,父 agent 会误以为自己也读过,下次跳过不读,数据就错了。所以要复制一份独立的给子 agent,子怎么折腾都不影响父的文件视图。决策二:「改全局状态」这件事对子 agent 直接关闭全局 UI 状态是主线程用 React 在管的。如果异步 subagent 也能改,就会出现「两边同时改同一份状态、抢起来对不上」的问题,界面就花了。所以 Claude Code 干脆把 subagent 的「写全局状态」这个权力完全关闭掉,改成空操作,一了百了。决策三:但「注册后台任务」这条通路得保留这里有个小细节值得讲。既然子 agent 的写权力关掉了,那它自己起的后台进程(比如在后台跑一条 bash 命令)怎么登记到全局任务表?Claude Code 专门开了一个小口子:其他写全局的口都堵死,唯独「注册/结束后台任务」这条路留着。不然子 agent 起的后台进程就变成「没爹的孤儿进程」,永远在后台跑没人回收。决策四:给每个 subagent 发独立 ID、深度代代 +1每派一个 subagent,都给它一个独立的 ID,并且在父 agent 的深度基础上 +1。这样系统能随时知道「当前这个 agent 处于嵌套的第几层」。深度超过阈值(比如 5 层)就报警甚至强制停止,防止意外嵌套失控。这四个决策其实回答了四类问题:信息怎么传、状态怎么写、通路怎么留、身份怎么追踪。对应到源码里,就是一个叫 createSubagentContext 的函数,我把最能说明上面四个决策的部分精简出来:// src/utils/forkedAgent.ts:345 +exportfunction createSubagentContext(parentContext, overrides): ToolUseContext { +return { +    // 决策一:文件读缓存克隆一份 +    readFileState: cloneFileStateCache(parentContext.readFileState), +    // 决策二:写全局状态直接设为空操作 +    setAppState: () => {}, +    // 决策三:但任务注册的通路例外保留 +    setAppStateForTasks: parentContext.setAppStateForTasks ?? parentContext.setAppState, +    // 决策四:独立 ID + 深度 +1 +    agentId: overrides?.agentId ?? createAgentId(), +    queryTracking: { +      chainId: randomUUID(), +      depth: (parentContext.queryTracking?.depth ?? -1) + 1, +    }, +    // ...其他字段略 +  } +} +你看这几行代码,一一对应上面讲的四个决策:克隆缓存、关掉写权限、保留任务通路、发独立 ID。看完这块,我的感受是:所谓上下文隔离,不是一刀切地「全隔离」或者「不隔离」,而是按每个状态的语义单独决策。这个细腻劲儿,正是 Claude Code 这种工业级产品稳定跑的根基。走完「工具隔离」和「上下文隔离」这两道门,一个 subagent 就拿到了干净的工具池 + 干净的运行环境,可以独立跑起来了。那父 agent 和这个跑起来的 subagent,又是怎么互相说话的呢?下一章见真章。三、父子 Agent 是怎么通信的隔离机制搞定了,但隔离只是开始,真正决定一个多 agent 系统好不好用的,是它们之间怎么通信。这一章我来讲 Claude Code 的通信方式。先抛一个问题:subagent 跑起来之后,父 agent 怎么给它发新指令?subagent 又怎么把结果交回去?为什么不用函数调用?我建议你先停个 10 秒,自己想想:如果让你来设计这套通信,你会怎么写?大概率你脑子里第一反应是「父 agent 调个函数,等 subagent 跑完返回」对吧?这跟我们平时写 RPC 调远程服务的思路一模一样,太自然了。但我接着追问你两个问题,你看你能不能答上来:第一个追问:如果 subagent 是个跑 5 分钟的代码评审任务,那这 5 分钟里,父 agent 能干啥?用户跟父 agent 说话又会发生什么?第二个追问:如果父 agent 想同时派 5 个 subagent 并行调研 5 个模块,你这个「调函数等返回」的方案要怎么改?是不是有点卡了?第一个追问的答案是:父 agent 啥也干不了,被同步阻塞死了。用户在这 5 分钟里跟它说话也没反应。第二个追问的答案是:要么 5 个 subagent 全在主线程里阻塞排队,要么得手动搓各种并发代码,整体会乱成一锅粥。Claude Code 正是看穿了这两个坑,才换了一个完全不一样的路子:消息驱动。想象每个 subagent 是公司里一个带「信箱」的独立员工。父 agent 要给它布置新活,就往它信箱里扔一张字条走人,不站在那儿等。subagent 自己干完活了,通过另一条信道把结果送回主 agent 的案头。这个「信箱 + 字条」的模型,本质上就是消息队列 + 异步通知。没有直接的函数返回,没有主线程阻塞,所有沟通都是消息。subagent 的员工档案为了支持这套模型,Claude Code 给每个 subagent 建了一份「员工档案」:一个对象,里面记着这个 subagent 的 ID、当前状态(等待中/跑步中/已完成/失败/被停了)、它的信箱(待处理消息数组)、已经产生的结果、进度信息等等。所有跟 subagent 有关的读写(父要发消息,子要改状态),都通过全局的 task 表里这份档案来进行。对应到源码里的类型定义大致长这样:// src/tasks/LocalAgentTask/LocalAgentTask.tsx:116 +export type LocalAgentTaskState = TaskStateBase & { +  type: 'local_agent'; +  agentId: string;               // 子 agent 唯一 ID +  prompt: string;                // 初始任务 +  agentType: string; +  status: TaskStatus;            // pending/running/completed/failed/killed +  result?: AgentToolResult;      // 完成后的结果 +  progress?: AgentProgress;      // 进度 +  isBackgrounded: boolean;       // 是否已转后台 +  pendingMessages: string[];     // 信箱:父 agent 扔进来的待处理消息 +  messages?: Message[]; +}; +重点关注的是 pendingMessages 数组,它就是我们前面说的「信箱」,父 agent 往里扔字条,子 agent 自己来捡。父 → 子:扔字条 + 子自己来取父 agent 要给跑着的 subagent 发指令的流程,拆开看就是两步:第一步:父往信箱扔字条。父 agent 在自己的 agentic loop 里调用一个叫 SendMessage 的工具,工具内部做的事情很简单:往目标 subagent 档案的信箱末尾追加一条消息,然后立刻返回。父 agent 扔完走人,不等子 agent 看。第二步:子在循环边界自己捡字条。subagent 自己的 agentic loop 在每一轮工具调用结束后,都会去瞄一眼自己的信箱。如果有新字条,就把这些字条作为「用户消息」注入自己的对话历史,然后带着新消息进入下一轮 LLM 调用。这里有个细节设计特别巧:如果子 agent 已经干完活停下来了(completed 或者被手动停了),父 agent 发 SendMessage 会怎样?Claude Code 的做法是:自动把它唤醒。从磁盘上那份已经保存的对话 transcript 里,把子 agent 的完整对话历史恢复出来,拼上新消息,重新跑起来。这个唤醒机制很妙,意味着 subagent 即使完成了也不是「死了」,父 agent 随时可以叫醒它继续干。对应到源码,SendMessage 工具里的核心逻辑长这样:// src/tools/SendMessageTool/SendMessageTool.ts:800 +const task = appState.tasks[agentId] +if (isLocalAgentTask(task) && !isMainSessionTask(task)) { +  if (task.status === 'running') { +    queuePendingMessage(agentId, input.message, context.setAppStateForTasks) +    return { data: { success: true, message: 'Message queued...' } } +  } +  // 任务已停止,自动唤醒从 transcript 里恢复 +  const result = await resumeAgentBackground({ agentId, prompt: input.message, ... }) +} +可以看到就是两个分支:正在跑就扔信箱,已经停了就唤醒。「扔信箱」这个动作本身的实现就 4 行:// src/tasks/LocalAgentTask/LocalAgentTask.tsx:162 +export function queuePendingMessage(taskId, msg, setAppState): void { +  updateTaskState(taskId, setAppState, task => ({ +    ...task, +    pendingMessages: [...task.pendingMessages, msg] +  })); +} +纯纯的「追加到数组末尾」。子 → 父:把通知伪装成用户消息反方向呢?subagent 跑完一个任务,怎么告诉父 agent「我干完了」?最直觉的做法是:给主线程发一个「工具返回结果」事件。但 Claude Code 玩得更骚气,它的设计是:把完成通知拼成一段 XML,伪装成一条用户消息,塞给父 agent 的对话历史。父 agent 那边看到的就像用户发了一条新消息过来,长这样: +agent-a1b +/tmp/xxx.txt +completed +Agent "Investigate auth bug" completed +Found null pointer in src/auth/validate.ts:42... + +  12345 +  8 +  34567 + + +📌 配图建议:task-notification XML 渲染示意,高亮各个 tag 的含义为啥要搞 XML 不用结构化对象? 这个设计有它的巧妙之处,我特意想明白过。第一,LLM 对 XML 非常友好。Anthropic 训练 Claude 的时候就强调了 XML 的结构化表达。你把 XML 塞到 prompt 里,LLM 能很自然地解析出语义,不用额外教它。第二,XML 是纯文本,可以直接塞进对话历史。如果是结构化对象,还得额外走个「工具结果」的字段结构,流程更复杂。第三,它伪装成用户消息,天然地复用了 agentic loop 的处理逻辑。父 agent 不需要额外的状态机去「等通知」,它就像收到一条新的用户输入一样处理。这种「把系统事件伪装成对话」的设计思路,在 LLM 应用里是非常值得学的一招。对应到源码里,生成这段 XML 的代码就是在拼字符串:// src/tasks/LocalAgentTask/LocalAgentTask.tsx:197 +const message = `<${TASK_NOTIFICATION_TAG}> +<${TASK_ID_TAG}>${taskId} +<${OUTPUT_FILE_TAG}>${outputPath} +<${STATUS_TAG}>${status} +<${SUMMARY_TAG}>${summary}${resultSection}${usageSection} +`; +enqueuePendingNotification({ value: message, mode: 'task-notification' }); +拼完就扔到主 agent 的待处理消息队列里,等主 agent 下一轮循环时当作一条用户消息来处理。为什么要自动后台化?再讲一个通信体系里的重要设计:auto-background。subagent 跑起来之后,父 agent 其实要等一会。如果 subagent 很快跑完(比如 30 秒内),父 agent 就在前台阻塞等,像一次普通工具调用,完事就拿结果继续。但如果 subagent 跑超过 2 分钟还没完,Claude Code 会自动把它转到后台,让父 agent 可以先继续干别的。2 分钟后 subagent 真完成了,通过前面说的 task-notification 把结果送回。这个设计本质上是把同步工具调用自动降级成异步通知的优化。没有它,长任务会一直占着父 agent 的执行权,用户也没法跟父 agent 继续对话。源码里这个「2 分钟阈值」就是一个常量开关:// src/tools/AgentTool/AgentTool.tsx:72 +function getAutoBackgroundMs(): number { +  if (isEnvTruthy(process.env.CLAUDE_AUTO_BACKGROUND_TASKS)  +      || getFeatureValue_CACHED_MAY_BE_STALE('tengu_auto_background_agents', false)) { +    return 120_000;  // 2 分钟 +  } +  return 0; +} +回头看通信设计的全貌到这儿我们把父子通信的两个方向都讲清楚了:父 → 子:调 SendMessage 工具把消息写进子 agent 的信箱 → 子 agent 下一轮循环边界自己读取。子 → 父:子 agent 把完成通知拼成 XML 消息 → 伪装成用户消息注入父 agent 对话。整个通信体系就两个关键字:异步 + 消息。没有直接函数调用,没有锁,没有回调地狱,全靠读写共享的任务状态和消息队列。这种设计有个特别大的好处:天然支持多 subagent 并发。因为父 agent 从来不阻塞等子,它可以同时派 5 个 subagent,谁先完成谁先给它发通知,父 agent 按到达顺序处理就行。下一章,我们再讲一个特别精妙的优化:Fork Subagent。四、Fork Subagent:省钱又省延迟的隐藏大招前面讲的常规 subagent 已经是主流玩法了,但 Claude Code 还有一个更精妙的机制,叫 Fork Subagent。这个机制有点隐蔽,用起来是透明的,但对成本和延迟的优化非常显著。我先抛两个问题让你估算下,先别往下翻看答案:第一,Claude Code 的 system prompt 大概有多长?是几百 token、几千 token,还是上万 token? 第二,每派一个 subagent,如果它有自己独立的 system prompt,LLM API 那边对这段 prompt 是从头算一遍,还是有办法复用?subagent 的隐藏成本公布答案:Claude Code 的 system prompt 长度是上万 token,里面塞了大量的工具说明、规范约定、用户上下文。而每派一个 subagent,如果它有独立的 system prompt(内置的 Explore、Plan 这些都有独立的),LLM API 那边就得对这一万多 token 重新从头算一遍,就跟没见过似的。这有两个代价:钱(input token 重新算钱)和延迟(首 token 等更久)。在生产环境里,subagent 派得越频繁,这个开销线性放大,是个很可怕的成本黑洞。Anthropic 有个 prompt 缓存机制可以缓解这事。简单说:API 请求里如果前缀跟之前某次请求一样,这段前缀可以不重新算,直接走缓存,价钱只要原来的 10%,延迟也大幅降低。到这儿我再问你一个关键的:prompt 缓存命中的条件是「内容大致相同」就行,还是「字符级别相同」,还是「字节级别完全相同」?再猜一下。公布:是最严格的那个,字节级别完全相同。系统 prompt 一个字不一样、工具列表顺序不一样、甚至空格位置不一样,都会直接没命中缓存。是不是比你想的严格多了?那既然这么严,能不能设计一种 subagent,它的 system prompt 和工具池跟父 agent 完全一样,这样就能复用父的缓存了?这就是 Fork Subagent 的起点。Fork 的核心思路:派一个「字节级相同」的分身Fork Subagent 的直觉是这样的:派一个子 agent 出去干活,但这个子 agent 的 API 请求前缀跟父 agent 一模一样,让 Anthropic 那边一看:「哦这个前缀我认识」,走缓存。这里的「一模一样」要做到什么程度?字节级。一个字节不对都不行。具体要对齐哪些东西呢?有五样必须跟父 agent 完全一致:系统 prompt 的内容(最核心的,对齐第一位)用户上下文(拼在消息前的那部分动态内容,比如当前项目的 CLAUDE.md 内容)系统上下文(拼在 system prompt 后的环境信息)工具池的顺序和定义(工具的字段结构会被序列化进 API 请求,顺序都不能变)对话历史的前缀(决定了 user/assistant 消息序列中「从哪里开始分叉」)这五样只要有一样跟父 agent 字节不一致,缓存就直接没了。对应到源码里,Claude Code 专门定义了一个类型(CacheSafeParams),把这五项打包:// src/utils/forkedAgent.ts:57 +exporttype CacheSafeParams = { +/** System prompt - 必须跟父完全一致 */ +  systemPrompt: SystemPrompt +/** User context - 拼接在消息前,影响缓存 */ +  userContext: { [k: string]: string } +/** System context - 拼接在 system prompt 后,影响缓存 */ +  systemContext: { [k: string]: string } +/** 工具池、模型等所在的上下文 */ +  toolUseContext: ToolUseContext +/** 父 agent 的消息前缀,用于缓存共享 */ +  forkContextMessages: Message[] +} +你看这个类型的意思很明显:凡是会影响缓存命中的字段,我全列在这儿,你 Fork 的时候严格按这份清单跟父 agent 对齐。一个有意思的细节:system prompt 不重新生成Fork Subagent 的合成定义里有个有意思的细节,值得单独说。正常一个 subagent 有个生成 system prompt 的函数,跑的时候现生成一段 prompt 文本。但 Fork 机制用的那个 subagent 的生成函数直接返回空字符串:// src/tools/AgentTool/forkSubagent.ts:60 +export const FORK_AGENT = { +  agentType: FORK_SUBAGENT_TYPE, +  tools: ['*'],             // 用父的完整工具池 +  maxTurns: 200, +  model: 'inherit',          // 继承父的模型 +  permissionMode: 'bubble',  // 权限弹窗浮到父终端 +  source: 'built-in', +  getSystemPrompt: () => '', // 返回空串! +} satisfies BuiltInAgentDefinition +这不是偷懒,而是精心设计的。为啥要返回空串?因为 Fork subagent 的 system prompt 根本不走这个函数生成,而是直接用父 agent 已经渲染好的那份字节。原因很简单:如果重新调一次生成函数,里面可能有些小差异(比如某个功能开关的缓存状态变了、某个动态字段的值变了),生成出来的 prompt 跟父 agent 就可能差一个字符,缓存就没了。最稳的办法是:把父 agent 那边已经渲染出来的 prompt,作为字节原样拿过来用,一个字节都不动。这个细节非常工业级,普通人写 agent 系统根本想不到。什么时候用 Fork,什么时候用常规 subagent?Fork 机制不是万能的,它的适用场景很特定:你希望子 agent 完全继承父 agent 的整个上下文(对话历史、system prompt、工具池),只是「派个分身去试试另一条路」。比如「Ctrl+F 生成 PR 描述」「运行 /btw 命令做 post-turn 总结」,这些任务需要父 agent 的完整上下文,但又不希望污染父 agent 的主循环。相反,如果你的任务有明确的专业分工(比如派一个专门搜代码的 agent、派一个专门做规划的 agent),那就用常规 subagent,它们的 system prompt 是定制的,Fork 机制反而不适用。还有一个关键点:Fork 机制和 Coordinator 模式是互斥的。Coordinator 模式下主 agent 已经是个纯协调者了,它派的 worker 本来就是异步的,不需要 Fork 这种「轻量分身」机制。两个机制职责重叠,就只留一个:// src/tools/AgentTool/forkSubagent.ts:32 +exportfunction isForkSubagentEnabled(): boolean { +if (feature('FORK_SUBAGENT')) { +    if (isCoordinatorMode()) returnfalse// 互斥! +    if (getIsNonInteractiveSession()) returnfalse +    returntrue +  } +returnfalse +} +Fork 的工程启示Fork 机制我想单独说下它对我们的启示。很多人做 agent 系统只关心「能不能跑起来」,不关心「跑起来要花多少钱」。但在生产环境,这两个是一回事。Claude Code 靠 Fork 机制,在缓存友好的场景下能把 subagent 的成本降到原来的 10% 左右。这意味着什么?意味着你的 subagent 可以调得更频繁。原本成本考虑不敢派的活,现在都能派了,这反过来又让整个 agent 系统的能力边界扩大了。所以成本优化本身就是能力的一部分。这个思路我觉得对自建 agent 系统的朋友特别重要。好了,讲完 Fork,下面进入整篇文章最「多 agent」的一章:Coordinator 模式。五、Coordinator 模式:真正的多 Agent 并行协作前面讲的 subagent(不管是常规的还是 Fork 的),本质都是父子结构:父 agent 派一个子,自己该干啥干啥,子完成了通知一声。但如果你的任务量很大,需要一堆 agent 同时开工呢?比如一个大的代码迁移,要并行调研 10 个模块。这时候父子结构就显得单薄了。Claude Code 为此设计了一个专门的模式:Coordinator 模式。这是 Claude Code 多 agent 设计里最「多 agent」的部分,也是最能打的地方。Coordinator 模式的启用这个模式不是默认开的,要显式打开。需要同时满足两个条件:编译时的功能开关和**运行时的环境变量 CLAUDE_CODE_COORDINATOR_MODE=1**。// src/coordinator/coordinatorMode.ts:36 +export function isCoordinatorMode(): boolean { +  if (feature('COORDINATOR_MODE')) { +    return isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) +  } +  return false +} +开启之后,主 agent 的行为模式会发生根本性变化。核心设计:主 agent 退化成「纯协调者」常规模式下,主 agent 是「全能型选手」:它读代码、写代码、跑测试、做规划全都干,只在需要时才派 subagent 帮一把。Coordinator 模式下,主 agent 不干实际工作了,它只做三件事:派 worker、收结果、合成答案。这个角色转换是通过主 agent 的 system prompt 强制约束出来的。打开源码里那段 prompt,开头就写得很明白:You are Claude Code, an AI assistant that orchestrates software engineering  +tasks across multiple workers. + +## 1. Your Role +You are a **coordinator**. Your job is to: +- Help the user achieve their goal +- Direct workers to research, implement and verify code changes +- Synthesize results and communicate with the user +- Answer questions directly when possible, don't delegate work  +  that you can handle without tools +翻译一下:你的身份是协调者,你的工作是指挥 worker 去做研究、实现、验证,然后自己合成结果跟用户交流。能自己回答的问题不要派人去做。三大内部工具既然主 agent 要协调,就得有专门的协调工具。Coordinator 模式下,主 agent 多了一套「团队管理」工具箱:派 worker 的工具:派一个新 worker 出去干某件具体的活,派完立刻返回 worker 的 ID。创建/解散团队的工具:批量管理 worker 组。给 worker 发消息的工具:给已经派出去的 worker 发后续指令(也就是前面讲的 SendMessage),因为 worker 的上下文还在,续命比重新派一个更省钱。合成最终输出的工具:协调者合成完答案后,通过这个工具把最终回复交给用户。停止 worker 的工具:当协调者意识到某个 worker 跑错方向时,把它停掉省 token。这套工具放在一起,协调者就有了一整套指挥团队的 API。📌 配图建议:协调者工具箱图,把五个工具画成五个按钮,标注每个按钮的作用对应到源码里,这组「只有协调者能用」的内部工具是这样定义的:// src/coordinator/coordinatorMode.ts:29 +const INTERNAL_WORKER_TOOLS = new Set([ +  TEAM_CREATE_TOOL_NAME,       // 创建 worker 团队 +  TEAM_DELETE_TOOL_NAME,       // 解散团队 +  SEND_MESSAGE_TOOL_NAME,      // 给 worker 发消息 +  SYNTHETIC_OUTPUT_TOOL_NAME,  // 合成最终输出给用户 +]) +并行才是真本事Coordinator 模式的 prompt 里有一句我特别喜欢:Parallelism is your superpower. Workers are async. Launch independent workers concurrently whenever possible, don't serialize work that can run simultaneously and look for opportunities to fan out.翻译一下:并行是你的超能力,worker 全是异步的,能并行的绝不串行,多找机会一口气派一堆出去。这句话背后是一个很关键的工程事实:Claude Code 的派 worker 工具调用可以在同一条 assistant 消息里出现多次,底层会一起并发执行,不是一个跑完再跑下一个。所以协调者要做的就是在一次 LLM 回合里,一口气生成多个派 worker 的工具调用:派 worker 调研 auth 模块 +派 worker 调研 session 模块 +派 worker 调研 token 模块 +这三个调用同时启动,三个 worker 同时干活,协调者等通知一条条返回。对比一下:串行:派 worker1 → 等 → 结果 → 派 worker2 → 等 → 结果 → 派 worker3... 用户等十分钟并行:同时派三个 worker → 三份结果陆续到 → 用户等三分钟多一点这就是「并行是超能力」的真正含义。工业级多 agent 系统,没有并行就没有可用性。协调者的「任务流水线」Coordinator 模式下,一个典型的任务流程被切成四个阶段:阶段谁来做目的调研Workers(并行)调查代码库、找文件、理解问题合成协调者本人读完发现、理解问题、写实现规格实现Workers按规格做具体修改、提交验证Workers测试改动是否真的工作注意中间的「合成」阶段是协调者亲自做,这是协调者存在的意义:理解全局,做决策。prompt 里反复强调:不要偷懒让 worker「based on your findings, implement the fix」,而是自己把 findings 读懂、写成具体的规格再派下去。这是一个非常重要的 multi-agent 设计哲学:协调者必须「理解」而不能「转发」。如果协调者只是转发,它就没有存在价值,worker 直接跟用户对话就行了。Continue vs Spawn:老 worker 还是新 worker?协调者要持续派活,遇到一个新任务,是给老 worker 发消息续命,还是派个新 worker 从头开始?这是个有经验才能做好的决策。Claude Code 的 prompt 里给出了一张决策表,我总结一下核心逻辑:如果新任务跟 worker 现有上下文高度相关(比如刚查的文件现在要改),续命老 worker,因为它已经「知道」那些文件了。如果新任务跟 worker 现有上下文没关系,或者之前 worker 的工作走偏了,派新 worker,避免旧上下文干扰判断。验证这种需要「新鲜眼光」的工作,永远派新 worker,不能让刚写完代码的 worker 自己验自己。这个设计其实也挺反映人类团队合作的直觉:有的活就该让懂上下文的人接着干(沟通成本低),有的活就该换个人做(避免认知偏差)。Worker 的工具限制Coordinator 模式下,worker 拿到的工具有什么不同?关键在于:协调者专属的那套内部工具(创建团队、发消息、合成输出等等),不给 worker 用。worker 不需要再去协调别人,它的活是干事情。这其实是一个递归防护:如果 worker 也能派 worker,整个系统就变成递归树了,没完没了。通过工具白名单把 worker 的「派人权」收回,让系统结构保持「一个协调者 + 一堆 worker」的扁平形态。跟常规 subagent 对比讲完这些我们对比一下 Coordinator 模式和常规 subagent:维度常规 subagentCoordinator 模式主 agent 角色全能选手纯协调者subagent 执行同步(2 分钟后才转后台)默认异步并发程度偶尔并发最大化并发适合场景单个任务 + 临时帮手大任务 + 高并发拆解系统形态父子树协调者 + worker 扁平层Coordinator 模式的工程启示讲完 Coordinator,我想提炼几条值得学的设计思想。第一,角色分离。协调和干活是两件事,不要让同一个 agent 身兼二职。角色清晰的系统更稳定。第二,并发优先。异步 + 消息队列是并发的基础,有了这套基础,多 agent 才能真正发挥威力。第三,合成不转发。协调者要理解中间结果,不能把它当传话筒。这是 Multi-Agent 系统里最容易踩坑的一点。第四,扁平不递归。通过工具权限把层级限制在两层(协调者 + worker),避免失控的递归嵌套。六、5 条 Multi-Agent 设计原则Claude Code 的源码扒得差不多了。我把前面讲的所有东西浓缩一下,沉淀成 5 条可以直接用到自己项目、也可以直接用到面试答案里的设计原则。原则 1:上下文隔离要按字段粒度做这是我最想强调的一条。很多 agent 框架的「隔离」就是粗暴地给 subagent 一个空 context,结果缺这缺那一堆 bug。Claude Code 的做法是:每个状态单独决策。读文件缓存克隆(避免污染),写全局状态关掉(避免两边抢),任务注册通路保留(不然孤儿进程没人回收),深度计数 +1(可追踪,防失控嵌套)。做多 agent 系统时,对着父 agent 的每项状态问一句:「子 agent 拿这个状态干啥?会不会影响父?」,就能避开大部分坑。原则 2:通信走消息,不走函数调用父 → 子:写入子 agent 的消息队列,子 agent 下一轮循环自己读取。子 → 父:把完成通知包装成 XML 消息,伪装成用户消息注入父 agent 对话。这套模型的好处:天然异步、天然支持并发、天然兼容 agentic loop、天然持久化(消息都能落盘)。如果你问面试官「你们的多 agent 之间怎么通信」,把这套答出来,基本就到位了。原则 3:工具权限要分级管控全局黑名单(防递归、防乱问用户),类型黑名单(自定义 agent 更严),异步白名单(后台 agent 只能用子集)。每种 agent 按自己的场景配工具,不要一刀切。原则 4:缓存友好是一种架构能力API 成本和延迟对生产环境 agent 来说是能力的一部分。设计 subagent 的时候,考虑它的 prompt 前缀能不能复用父 agent 的缓存,能省 80-90% 的成本。Claude Code 那套「严格锁定缓存前缀 + 复用父 agent 已渲染字节」的思路,是这方面的教科书式实现。原则 5:并行优先 + 协调者合成真正的多 agent 系统威力在并发。通过异步消息和消息队列做基础,通过协调者做合成,避免「大 agent 大循环什么都自己扛」的窘境。并且协调者要亲自合成,不能当传话筒。这 5 条原则背后,其实都能看到 Claude Code 源码里的清晰落点。我建议你别光记这些原则,下次看到 Multi-Agent 相关的东西,都拿这 5 条去对照,会迅速看出对方系统的深浅。 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index cf5ecb1..808c45e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -28,11 +28,27 @@ const ws = ref(null) const isConnected = ref(false) const isInTakeover = ref(false) const isTaskRunning = ref(false) +const agentRuntimeStatus = ref<'idle' | 'running' | 'planning' | 'awaiting_approval' | 'completed' | 'cancelled' | 'failed'>('idle') const browserFrame = ref('') const browserUrl = ref('') const browserViewport = ref({ width: 1280, height: 800 }) let taskPollTimer: number | null = null +const agentStatusLabel = computed(() => { + if (!isConnected.value) return t('common.connecting') + if (agentRuntimeStatus.value === 'planning') return t('common.agent_planning') + if (agentRuntimeStatus.value === 'awaiting_approval') return t('common.agent_awaiting_approval') + if (agentRuntimeStatus.value === 'running') return t('common.agent_running') + return t('common.agent_ready') +}) + +const agentStatusDotClass = computed(() => { + if (!isConnected.value) return 'bg-red-500' + if (agentRuntimeStatus.value === 'planning') return 'bg-violet-500' + if (agentRuntimeStatus.value === 'awaiting_approval') return 'bg-amber-500' + return 'bg-green-500' +}) + function connectWs(sessionId: string) { if (ws.value) { ws.value.onclose = null // prevent auto-reconnect on intentional close @@ -53,7 +69,8 @@ function connectWs(sessionId: string) { activeChatId.value = data.session_id } if (data.type === 'task_status') { - isTaskRunning.value = data.status === 'running' + agentRuntimeStatus.value = data.status || 'idle' + isTaskRunning.value = data.status === 'running' || data.status === 'planning' return } if (data.message_key === 'common.agent_thinking' && !debugMode.value) { @@ -74,9 +91,16 @@ function connectWs(sessionId: string) { } if (data.message_key === 'common.agent_starting') { isTaskRunning.value = true + if (agentRuntimeStatus.value === 'idle') { + agentRuntimeStatus.value = 'running' + } } if (data.message_key === 'common.task_completed' || data.message_key === 'common.task_cancelled' || data.message_key === 'common.task_stopped') { isTaskRunning.value = false + if (data.message_key === 'common.task_completed') agentRuntimeStatus.value = 'completed' + if (data.message_key === 'common.task_cancelled' || data.message_key === 'common.task_stopped') { + agentRuntimeStatus.value = 'cancelled' + } } pushMessage(data) if (shouldRefreshTasks(data)) { @@ -87,6 +111,7 @@ function connectWs(sessionId: string) { socket.onclose = () => { isConnected.value = false isInTakeover.value = false + agentRuntimeStatus.value = 'idle' // Re-connect after 3s setTimeout(() => { if (activeChatId.value) connectWs(activeChatId.value) @@ -158,7 +183,8 @@ function getToolDisplayName(toolName: string): string { function shouldRefreshTasks(data: any): boolean { const toolName = data?.params?.tool - return data?.message_key === 'common.agent_starting' || + return data?.type === 'task_status' || + data?.message_key === 'common.agent_starting' || data?.message_key === 'common.task_completed' || (typeof toolName === 'string' && toolName.startsWith('task_')) } @@ -497,8 +523,8 @@ onUnmounted(() => {
-
- {{ isConnected ? $t('common.agent_ready') : $t('common.connecting') }} +
+ {{ agentStatusLabel }}
diff --git a/frontend/src/composables/useTasks.ts b/frontend/src/composables/useTasks.ts index 39233c8..f79252a 100644 --- a/frontend/src/composables/useTasks.ts +++ b/frontend/src/composables/useTasks.ts @@ -4,7 +4,7 @@ export interface AgentTask { id: number subject: string description: string - status: 'pending' | 'in_progress' | 'completed' + status: 'pending' | 'planning' | 'awaiting_approval' | 'in_progress' | 'completed' blockedBy: ReadonlyArray blocks: ReadonlyArray owner: string @@ -14,6 +14,8 @@ export interface AgentTask { export interface TaskSummary { total: number pending: number + planning: number + awaiting_approval: number in_progress: number completed: number } @@ -24,15 +26,19 @@ const tasks = ref([]) const summary = ref({ total: 0, pending: 0, + planning: 0, + awaiting_approval: 0, in_progress: 0, completed: 0, }) const isLoading = ref(false) const statusRank: Record = { - in_progress: 0, - pending: 1, - completed: 2, + planning: 0, + awaiting_approval: 1, + in_progress: 2, + pending: 3, + completed: 4, } function sortTasks(items: AgentTask[]): AgentTask[] { @@ -52,6 +58,8 @@ async function loadTasks() { summary.value = { total: data.summary?.total ?? tasks.value.length, pending: data.summary?.pending ?? tasks.value.filter(task => task.status === 'pending').length, + planning: data.summary?.planning ?? tasks.value.filter(task => task.status === 'planning').length, + awaiting_approval: data.summary?.awaiting_approval ?? tasks.value.filter(task => task.status === 'awaiting_approval').length, in_progress: data.summary?.in_progress ?? tasks.value.filter(task => task.status === 'in_progress').length, completed: data.summary?.completed ?? tasks.value.filter(task => task.status === 'completed').length, } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 9cbf833..838ec6f 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -17,6 +17,9 @@ "agent_paused_for_takeover": "Agent paused. Waiting for you to finish…", "agent_thinking": "Agent is thinking...", "agent_resumed": "Agent resumed execution.", + "agent_running": "Agent running", + "agent_planning": "Planning in progress", + "agent_awaiting_approval": "Waiting for plan approval", "agent_starting": "Agent starting task: {task}", "executing_action": "Executing action: `{tool}` with args: {args}", "sent_resume": "Sent resume execution command.", @@ -95,6 +98,8 @@ "background_run": "Starting background task", "check_background": "Checking background task", "send_screenshot": "Taking screenshot", + "enter_plan_mode": "Writing plan", + "exit_plan_mode": "Submitting plan", "compact": "Compressing context", "default": "Executing action" }, @@ -110,7 +115,9 @@ "tasks": { "toggle": "Tasks", "loading_inline": "Syncing tasks", - "empty_inline": "No tasks yet" + "empty_inline": "No tasks yet", + "status_planning": "Planning", + "status_awaiting_approval": "Awaiting approval" }, "knowledge": { "title": "Knowledge Folders", @@ -149,4 +156,4 @@ "title": "Gallery", "placeholder": "Gallery panel is preserved. Asset management can be added here." } -} \ No newline at end of file +} diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 696f4b2..29cfc11 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -17,6 +17,9 @@ "agent_paused_for_takeover": "代理已暂停,等待您完成操作…", "agent_thinking": "代理正在思考...", "agent_resumed": "代理已恢复执行。", + "agent_running": "代理执行中", + "agent_planning": "正在规划方案", + "agent_awaiting_approval": "等待方案审批", "agent_starting": "代理开始任务:{task}", "executing_action": "正在执行动作:`{tool}`,参数:{args}", "sent_resume": "已发送继续执行指令。", @@ -95,6 +98,8 @@ "background_run": "正在启动后台任务", "check_background": "正在检查后台任务", "send_screenshot": "正在截图", + "enter_plan_mode": "正在写入计划", + "exit_plan_mode": "正在提交计划", "compact": "正在压缩上下文", "default": "正在执行操作" }, @@ -110,7 +115,9 @@ "tasks": { "toggle": "任务面板", "loading_inline": "任务同步中", - "empty_inline": "暂无任务" + "empty_inline": "暂无任务", + "status_planning": "规划中", + "status_awaiting_approval": "待审批" }, "knowledge": { "title": "知识库文件夹", @@ -149,4 +156,4 @@ "title": "图库", "placeholder": "图库面板已保留。后续可在这里管理图片素材与引用。" } -} \ No newline at end of file +} diff --git a/knowledge_indexer.py b/knowledge_indexer.py new file mode 100644 index 0000000..c12e839 --- /dev/null +++ b/knowledge_indexer.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import hashlib +import json +import math +import re +from pathlib import Path +from typing import Any + +import numpy as np + +try: + import faiss # type: ignore +except Exception as e: # pragma: no cover + faiss = None + _FAISS_IMPORT_ERROR = e +else: # pragma: no cover + _FAISS_IMPORT_ERROR = None + + +def _tokenize(text: str) -> list[str]: + return re.findall(r"[\w\u4e00-\u9fff]+", text.lower()) + + +class FaissKnowledgeIndexer: + """ + Lightweight FAISS indexer with local deterministic embeddings. + + Notes: + - Uses hash-based embedding to avoid external embedding service in first stage. + - Supports folder-level rebuild/remove and query by selected folders. + """ + + def __init__( + self, + meta_root: Path, + dim: int = 384, + chunk_chars: int = 3200, + overlap_chars: int = 400, + ) -> None: + if faiss is None: + raise RuntimeError( + f"FAISS is required but unavailable: {_FAISS_IMPORT_ERROR}" + ) + + self.dim = dim + self.chunk_chars = max(512, chunk_chars) + self.overlap_chars = max(64, min(overlap_chars, self.chunk_chars // 2)) + + self.snapshot_root = (meta_root / "vector_snapshot").resolve() + self.snapshot_root.mkdir(parents=True, exist_ok=True) + + self.records_file = self.snapshot_root / "records.json" + self.vectors_file = self.snapshot_root / "vectors.npy" + self.index_file = self.snapshot_root / "index.faiss" + + self.records: list[dict[str, Any]] = [] + self.vectors = np.zeros((0, self.dim), dtype=np.float32) + self.index = faiss.IndexFlatIP(self.dim) + + self._load_snapshot() + + def _embed(self, text: str) -> np.ndarray: + """ + Deterministic hash embedding (L2-normalized) for local FAISS search. + """ + vec = np.zeros(self.dim, dtype=np.float32) + tokens = _tokenize(text) + if not tokens: + return vec + + for token in tokens: + h = hashlib.sha1(token.encode("utf-8")).digest() + bucket = int.from_bytes(h[:4], "little") % self.dim + sign = -1.0 if (h[4] & 1) else 1.0 + vec[bucket] += sign + + norm = float(np.linalg.norm(vec)) + if norm > 0: + vec /= norm + return vec + + def _chunk_text(self, text: str) -> list[str]: + clean = text.strip() + if not clean: + return [] + chunks: list[str] = [] + start = 0 + n = len(clean) + while start < n: + end = min(n, start + self.chunk_chars) + chunk = clean[start:end].strip() + if chunk: + chunks.append(chunk) + if end >= n: + break + start = max(0, end - self.overlap_chars) + return chunks + + def _persist_snapshot(self) -> None: + if len(self.vectors) > 0: + np.save(self.vectors_file, self.vectors) + elif self.vectors_file.exists(): + self.vectors_file.unlink(missing_ok=True) + + self.records_file.write_text( + json.dumps(self.records, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + faiss.write_index(self.index, str(self.index_file)) + + def _load_snapshot(self) -> None: + if self.records_file.exists(): + try: + loaded = json.loads(self.records_file.read_text(encoding="utf-8")) + if isinstance(loaded, list): + self.records = loaded + except Exception: + self.records = [] + + if self.vectors_file.exists(): + try: + arr = np.load(self.vectors_file).astype(np.float32) + if arr.ndim == 2 and arr.shape[1] == self.dim: + self.vectors = arr + except Exception: + self.vectors = np.zeros((0, self.dim), dtype=np.float32) + + self._rebuild_faiss_index() + + def reload(self) -> None: + self.records = [] + self.vectors = np.zeros((0, self.dim), dtype=np.float32) + self._load_snapshot() + + def _rebuild_faiss_index(self) -> None: + self.index = faiss.IndexFlatIP(self.dim) + if len(self.vectors) > 0: + self.index.add(self.vectors) + + def _remove_folder_internal(self, folder_id: str) -> None: + if not self.records: + return + keep_indices = [ + i for i, rec in enumerate(self.records) if rec.get("folder_id") != folder_id + ] + if len(keep_indices) == len(self.records): + return + self.records = [self.records[i] for i in keep_indices] + self.vectors = ( + self.vectors[keep_indices] + if len(keep_indices) > 0 + else np.zeros((0, self.dim), dtype=np.float32) + ) + self._rebuild_faiss_index() + + def remove_folder(self, folder_id: str) -> None: + self._remove_folder_internal(folder_id) + self._persist_snapshot() + + def rebuild_folder(self, folder_id: str, chunks: list[dict[str, Any]]) -> int: + # Replace-by-folder strategy. + self._remove_folder_internal(folder_id) + + if not chunks: + self._persist_snapshot() + return 0 + + new_records: list[dict[str, Any]] = [] + new_vectors = np.zeros((len(chunks), self.dim), dtype=np.float32) + for idx, item in enumerate(chunks): + text = str(item.get("text", "")).strip() + if not text: + continue + vec = self._embed(text) + new_vectors[idx] = vec + new_records.append( + { + "folder_id": folder_id, + "file_id": item.get("file_id", ""), + "chunk_id": item.get("chunk_id", ""), + "source_path": item.get("source_path", ""), + "chunk_index": item.get("chunk_index", 0), + "text": text, + } + ) + + if new_records: + valid_count = len(new_records) + self.vectors = np.vstack([self.vectors, new_vectors[:valid_count]]) + self.records.extend(new_records) + + self._rebuild_faiss_index() + self._persist_snapshot() + return len(new_records) + + def build_chunks_from_text( + self, + folder_id: str, + file_id: str, + source_path: str, + text: str, + ) -> list[dict[str, Any]]: + chunks = self._chunk_text(text) + out: list[dict[str, Any]] = [] + for idx, chunk in enumerate(chunks): + out.append( + { + "folder_id": folder_id, + "file_id": file_id, + "chunk_id": f"{file_id}:{idx}", + "source_path": source_path, + "chunk_index": idx, + "text": chunk, + } + ) + return out + + def search( + self, + query: str, + selected_folders: list[str], + top_k: int = 5, + ) -> list[dict[str, Any]]: + if not query.strip(): + return [] + if not selected_folders: + return [] + if len(self.records) == 0 or self.index.ntotal == 0: + return [] + + q = self._embed(query).reshape(1, -1).astype(np.float32) + k = max(1, min(top_k * 4, self.index.ntotal)) + scores, ids = self.index.search(q, k) + selected = set(selected_folders) + + results: list[dict[str, Any]] = [] + for score, idx in zip(scores[0].tolist(), ids[0].tolist()): + if idx < 0 or idx >= len(self.records): + continue + rec = self.records[idx] + if rec.get("folder_id") not in selected: + continue + results.append( + { + "score": float(score), + "folder_id": rec.get("folder_id", ""), + "file_id": rec.get("file_id", ""), + "chunk_id": rec.get("chunk_id", ""), + "source_path": rec.get("source_path", ""), + "chunk_index": rec.get("chunk_index", 0), + "text": rec.get("text", ""), + } + ) + if len(results) >= top_k: + break + return results + + def stats(self) -> dict[str, Any]: + folder_counter: dict[str, int] = {} + for rec in self.records: + fid = str(rec.get("folder_id", "")) + folder_counter[fid] = folder_counter.get(fid, 0) + 1 + return { + "vector_count": int(self.index.ntotal), + "chunk_count": len(self.records), + "folder_chunk_counts": folder_counter, + "dim": self.dim, + "chunk_chars": self.chunk_chars, + "overlap_chars": self.overlap_chars, + } diff --git a/knowledge_service.py b/knowledge_service.py new file mode 100644 index 0000000..2b99ca4 --- /dev/null +++ b/knowledge_service.py @@ -0,0 +1,449 @@ +from __future__ import annotations + +import hashlib +import json +import mimetypes +import re +from datetime import datetime +from pathlib import Path +from threading import RLock +from typing import Any + +from knowledge_indexer import FaissKnowledgeIndexer + +def _now_iso() -> str: + return datetime.now().isoformat() + + +def _human_size(size_bytes: int) -> str: + if size_bytes < 1024: + return f"{size_bytes} B" + if size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + if size_bytes < 1024 * 1024 * 1024: + return f"{size_bytes / (1024 * 1024):.1f} MB" + return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB" + + +def _slugify_folder_id(name: str) -> str: + base = name.strip().lower() + # Keep letters/numbers/underscore/hyphen and CJK; normalize separators to "_". + base = re.sub(r"\s+", "_", base) + base = re.sub(r"[^a-z0-9_\-\u4e00-\u9fff]", "_", base) + base = re.sub(r"_+", "_", base).strip("_") + return base or "folder" + + +class KnowledgeService: + """Folder-level knowledge base management and lightweight state persistence.""" + + def __init__(self, workdir: Path) -> None: + self.workdir = workdir.resolve() + self.knowledge_root = (self.workdir / "knowledge").resolve() + self.meta_root = (self.workdir / ".knowledge").resolve() + self.state_file = self.meta_root / "state.json" + self.file_index_file = self.meta_root / "file_index.json" + self._lock = RLock() + + self.knowledge_root.mkdir(parents=True, exist_ok=True) + self.meta_root.mkdir(parents=True, exist_ok=True) + + self.state = self._load_state() + self.file_index = self._load_file_index() + self.indexer = FaissKnowledgeIndexer(self.meta_root) + self._save_state() + self._save_file_index() + + def _load_state(self) -> dict[str, Any]: + default_state: dict[str, Any] = { + "selected_folder_ids": [], + "indexed_folder_ids": [], + "dirty_folders": [], + "folder_status": {}, + "folder_display_names": {}, + } + if not self.state_file.exists(): + return default_state + try: + loaded = json.loads(self.state_file.read_text(encoding="utf-8")) + if not isinstance(loaded, dict): + return default_state + default_state.update(loaded) + for key in ( + "selected_folder_ids", + "indexed_folder_ids", + "dirty_folders", + ): + if not isinstance(default_state.get(key), list): + default_state[key] = [] + if not isinstance(default_state.get("folder_status"), dict): + default_state["folder_status"] = {} + if not isinstance(default_state.get("folder_display_names"), dict): + default_state["folder_display_names"] = {} + return default_state + except Exception: + return default_state + + def _load_file_index(self) -> dict[str, dict[str, Any]]: + if not self.file_index_file.exists(): + return {} + try: + loaded = json.loads(self.file_index_file.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + return loaded + except Exception: + pass + return {} + + def _save_state(self) -> None: + self.state_file.write_text( + json.dumps(self.state, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + def _save_file_index(self) -> None: + self.file_index_file.write_text( + json.dumps(self.file_index, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + def refresh_from_disk(self) -> None: + with self._lock: + self.state = self._load_state() + self.file_index = self._load_file_index() + self.indexer.reload() + + def _folder_path(self, folder_id: str) -> Path: + path = (self.knowledge_root / folder_id).resolve() + if not str(path).startswith(str(self.knowledge_root)): + raise ValueError("Invalid folder_id") + return path + + def _generate_file_id(self, folder_id: str, rel_path: str) -> str: + seed = f"{folder_id}:{rel_path}" + return hashlib.sha1(seed.encode("utf-8")).hexdigest() + + def _sync_file_index_for_folder(self, folder_id: str) -> None: + folder_path = self._folder_path(folder_id) + if not folder_path.exists(): + return + + # Remove stale entries for the folder first, then repopulate. + stale_ids = [ + fid + for fid, meta in self.file_index.items() + if meta.get("folder_id") == folder_id + ] + for fid in stale_ids: + self.file_index.pop(fid, None) + + for file_path in folder_path.rglob("*"): + if not file_path.is_file(): + continue + rel_path = str(file_path.relative_to(folder_path)).replace("\\", "/") + file_id = self._generate_file_id(folder_id, rel_path) + stat = file_path.stat() + mime_type = ( + mimetypes.guess_type(file_path.name)[0] or "application/octet-stream" + ) + self.file_index[file_id] = { + "folder_id": folder_id, + "rel_path": rel_path, + "name": file_path.name, + "size": int(stat.st_size), + "updated_at": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "mime_type": mime_type, + } + + def _mark_dirty_and_reindex_if_selected(self, folder_id: str) -> None: + if folder_id not in self.state["selected_folder_ids"]: + return + if folder_id not in self.state["dirty_folders"]: + self.state["dirty_folders"].append(folder_id) + self.state["folder_status"][folder_id] = "indexing" + self._sync_file_index_for_folder(folder_id) + self._rebuild_folder_index(folder_id) + + def _extract_text_for_file(self, file_path: Path, mime_type: str) -> str: + # First version: index text-like files directly. + text_like = ( + mime_type.startswith("text/") + or mime_type in { + "application/json", + "application/xml", + "application/javascript", + } + or file_path.suffix.lower() in { + ".md", + ".txt", + ".py", + ".js", + ".ts", + ".tsx", + ".jsx", + ".json", + ".yaml", + ".yml", + ".toml", + ".ini", + ".cfg", + ".csv", + ".html", + ".css", + ".sql", + } + ) + if not text_like: + return "" + try: + return file_path.read_text(encoding="utf-8") + except Exception: + try: + return file_path.read_text(encoding="utf-8", errors="ignore") + except Exception: + return "" + + def _rebuild_folder_index(self, folder_id: str) -> None: + chunks: list[dict[str, Any]] = [] + for file_id, meta in self.file_index.items(): + if meta.get("folder_id") != folder_id: + continue + rel_path = str(meta.get("rel_path", "")) + file_path = self._folder_path(folder_id) / rel_path + if not file_path.exists(): + continue + mime_type = str(meta.get("mime_type", "application/octet-stream")) + text = self._extract_text_for_file(file_path, mime_type) + if not text.strip(): + continue + source_path = str(file_path.relative_to(self.workdir)).replace("\\", "/") + chunks.extend( + self.indexer.build_chunks_from_text( + folder_id=folder_id, + file_id=file_id, + source_path=source_path, + text=text, + ) + ) + self.indexer.rebuild_folder(folder_id, chunks) + self.state["folder_status"][folder_id] = "ready" + if folder_id not in self.state["indexed_folder_ids"]: + self.state["indexed_folder_ids"].append(folder_id) + if folder_id in self.state["dirty_folders"]: + self.state["dirty_folders"].remove(folder_id) + + def list_folders(self) -> list[dict[str, Any]]: + with self._lock: + folder_rows: list[dict[str, Any]] = [] + for folder_path in sorted(self.knowledge_root.iterdir(), key=lambda p: p.name): + if not folder_path.is_dir(): + continue + + file_count = 0 + total_size = 0 + latest_mtime = 0.0 + for fp in folder_path.rglob("*"): + if not fp.is_file(): + continue + file_count += 1 + stat = fp.stat() + total_size += int(stat.st_size) + latest_mtime = max(latest_mtime, stat.st_mtime) + + folder_id = folder_path.name + display_name = self.state["folder_display_names"].get(folder_id, folder_id) + updated_at = ( + datetime.fromtimestamp(latest_mtime).isoformat() + if latest_mtime > 0 + else _now_iso() + ) + folder_rows.append( + { + "id": folder_id, + "name": display_name, + "path": str(folder_path.relative_to(self.workdir)).replace("\\", "/"), + "file_count": file_count, + "updated_at": updated_at, + "size_label": _human_size(total_size), + } + ) + return folder_rows + + def create_folder(self, name: str) -> dict[str, Any]: + with self._lock: + raw_name = name.strip() + if not raw_name: + raise ValueError("Folder name is required") + + base_id = _slugify_folder_id(raw_name) + folder_id = base_id + suffix = 1 + while self._folder_path(folder_id).exists(): + suffix += 1 + folder_id = f"{base_id}_{suffix}" + + folder_path = self._folder_path(folder_id) + folder_path.mkdir(parents=True, exist_ok=False) + + self.state["folder_display_names"][folder_id] = raw_name + self.state["folder_status"].setdefault(folder_id, "ready") + self._save_state() + + return { + "id": folder_id, + "name": raw_name, + "path": str(folder_path.relative_to(self.workdir)).replace("\\", "/"), + "file_count": 0, + "updated_at": _now_iso(), + "size_label": "0 B", + } + + def get_selected(self) -> list[str]: + with self._lock: + return list(self.state["selected_folder_ids"]) + + def select_folder(self, folder_id: str) -> None: + with self._lock: + folder_path = self._folder_path(folder_id) + if not folder_path.exists(): + raise FileNotFoundError("Folder not found") + + if folder_id not in self.state["selected_folder_ids"]: + self.state["selected_folder_ids"].append(folder_id) + self.state["folder_status"][folder_id] = "indexing" + self._sync_file_index_for_folder(folder_id) + self._rebuild_folder_index(folder_id) + self._save_file_index() + self._save_state() + + def deselect_folder(self, folder_id: str) -> None: + with self._lock: + if folder_id in self.state["selected_folder_ids"]: + self.state["selected_folder_ids"].remove(folder_id) + if folder_id in self.state["indexed_folder_ids"]: + self.state["indexed_folder_ids"].remove(folder_id) + if folder_id in self.state["dirty_folders"]: + self.state["dirty_folders"].remove(folder_id) + self.state["folder_status"][folder_id] = "ready" + # Cleanup vector entries for this folder. + self.indexer.remove_folder(folder_id) + stale_ids = [ + fid + for fid, meta in self.file_index.items() + if meta.get("folder_id") == folder_id + ] + for fid in stale_ids: + self.file_index.pop(fid, None) + self._save_file_index() + self._save_state() + + def upload_files(self, folder_id: str, files: list[tuple[str, bytes, str]]) -> dict[str, Any]: + with self._lock: + folder_path = self._folder_path(folder_id) + if not folder_path.exists(): + raise FileNotFoundError("Folder not found") + + saved_files: list[str] = [] + for filename, data, _content_type in files: + safe_name = Path(filename).name.strip() or "unnamed_file" + target_path = folder_path / safe_name + stem = target_path.stem + suffix = target_path.suffix + counter = 1 + while target_path.exists(): + target_path = folder_path / f"{stem}_{counter}{suffix}" + counter += 1 + target_path.write_bytes(data) + saved_files.append(target_path.name) + + self._mark_dirty_and_reindex_if_selected(folder_id) + self._save_file_index() + self._save_state() + return { + "saved_count": len(saved_files), + "saved_files": saved_files, + } + + def list_files(self, folder_id: str) -> list[dict[str, Any]]: + with self._lock: + folder_path = self._folder_path(folder_id) + if not folder_path.exists(): + raise FileNotFoundError("Folder not found") + + self._sync_file_index_for_folder(folder_id) + self._save_file_index() + + items: list[dict[str, Any]] = [] + for file_id, meta in self.file_index.items(): + if meta.get("folder_id") != folder_id: + continue + mime_type = str(meta.get("mime_type", "application/octet-stream")) + preview_url = ( + f"/knowledge/files/{file_id}/preview" + if mime_type.startswith("image/") + else None + ) + items.append( + { + "id": file_id, + "name": meta.get("name", ""), + "mime_type": mime_type, + "size_label": _human_size(int(meta.get("size", 0))), + "updated_at": meta.get("updated_at", _now_iso()), + "preview_url": preview_url, + } + ) + items.sort(key=lambda x: str(x["updated_at"]), reverse=True) + return items + + def _resolve_file_from_index(self, file_id: str) -> tuple[Path, dict[str, Any]]: + meta = self.file_index.get(file_id) + if not meta: + raise FileNotFoundError("File not found") + folder_id = str(meta.get("folder_id", "")) + rel_path = str(meta.get("rel_path", "")) + file_path = self._folder_path(folder_id) / rel_path + if not file_path.exists(): + # stale index; clean and fail. + self.file_index.pop(file_id, None) + self._save_file_index() + raise FileNotFoundError("File not found") + return file_path, meta + + def delete_file(self, file_id: str) -> dict[str, Any]: + with self._lock: + file_path, meta = self._resolve_file_from_index(file_id) + folder_id = str(meta.get("folder_id", "")) + file_path.unlink(missing_ok=True) + self.file_index.pop(file_id, None) + self._mark_dirty_and_reindex_if_selected(folder_id) + self._save_file_index() + self._save_state() + return {"ok": True, "folder_id": folder_id} + + def preview_path(self, file_id: str) -> Path: + with self._lock: + file_path, meta = self._resolve_file_from_index(file_id) + mime_type = str(meta.get("mime_type", "application/octet-stream")) + if not mime_type.startswith("image/"): + raise ValueError("Preview is only supported for image files") + return file_path + + def status(self) -> dict[str, Any]: + with self._lock: + return { + "selected_folder_ids": list(self.state["selected_folder_ids"]), + "indexed_folder_ids": list(self.state["indexed_folder_ids"]), + "dirty_folders": list(self.state["dirty_folders"]), + "folder_status": dict(self.state["folder_status"]), + "index_stats": self.indexer.stats(), + } + + def search(self, query: str, top_k: int = 5) -> list[dict[str, Any]]: + with self._lock: + # Keep multi-instance state consistent (main.py and agent.py each hold a service instance). + self.state = self._load_state() + self.file_index = self._load_file_index() + self.indexer.reload() + selected = list(self.state["selected_folder_ids"]) + return self.indexer.search(query=query, selected_folders=selected, top_k=top_k) diff --git a/main.py b/main.py index 58fcace..f9b09b5 100644 --- a/main.py +++ b/main.py @@ -8,16 +8,19 @@ from datetime import datetime from pathlib import Path from contextlib import asynccontextmanager -from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, UploadFile, File, Form from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse from pydantic import BaseModel from typing import Optional import os +from memory.session_memory import SessionMemory from playwright_manager import PlaywrightManager from agent import run_agent_loop from platforms.web import WebAdapter from task_manager import task_manager +from knowledge_service import KnowledgeService pm = PlaywrightManager() @@ -25,28 +28,166 @@ WORKSPACE_DIR = Path(os.getenv("WORKDIR", "./workspace")).resolve() UPLOADS_DIR = WORKSPACE_DIR / "uploads" UPLOADS_DIR.mkdir(parents=True, exist_ok=True) +knowledge_service = KnowledgeService(WORKSPACE_DIR) # ─── Session Store ──────────────────────────────────────────────────────────── -SESSIONS_FILE = Path("sessions.json") +SESSIONS_DIR = Path("sessions/") +INDEX_FILE = SESSIONS_DIR / "index.json" +_session_index: dict[str, dict] = {} -def _load_sessions() -> dict: - if SESSIONS_FILE.exists(): + +def _ensure_sessions_dir() -> None: + SESSIONS_DIR.mkdir(exist_ok=True) + + +def _load_index() -> dict[str, dict]: + if INDEX_FILE.exists(): try: - return json.loads(SESSIONS_FILE.read_text(encoding="utf-8")) + import orjson + return orjson.loads(INDEX_FILE.read_bytes()) except Exception: pass return {} -def _save_sessions(): - SESSIONS_FILE.write_text( - json.dumps(sessions, ensure_ascii=False, indent=2), encoding="utf-8" - ) +def _save_index() -> None: + import orjson + INDEX_FILE.write_bytes(orjson.dumps(_session_index)) + + +def _session_dir(sid: str) -> Path: + return SESSIONS_DIR / sid + + +def _load_session(sid: str) -> dict | None: + meta_path = _session_dir(sid) / "meta.json" + if not meta_path.exists(): + return None + try: + import orjson + meta = orjson.loads(meta_path.read_bytes()) + except Exception: + return None + msgs = _load_messages_jsonl(sid) + meta["messages"] = msgs + return meta + + +def _load_messages_jsonl(sid: str) -> list: + path = _session_dir(sid) / "messages.jsonl" + if not path.exists(): + return [] + try: + lines = path.read_text(encoding="utf-8").splitlines() + import orjson as _orjson + return [_orjson.loads(line) for line in lines if line.strip()] + except Exception: + return [] + + +def _append_message_jsonl(sid: str, msg: dict) -> None: + import orjson as _orjson + path = _session_dir(sid) / "messages.jsonl" + with open(path, "ab") as f: + f.write(_orjson.dumps(msg)) + f.write(b"\n") + + +def _save_session_meta(sid: str, data: dict) -> None: + import orjson as _orjson + meta = { + "id": data["id"], + "title": data.get("title", ""), + "created_at": data.get("created_at", ""), + "session_memory": data.get("session_memory"), + "plan_state": data.get("plan_state"), + } + meta_path = _session_dir(sid) / "meta.json" + meta_path.write_bytes(_orjson.dumps(meta)) + + +def _new_session(title: str = "") -> dict: + import uuid as _uuid + sid = str(_uuid.uuid4()) + created = datetime.now().isoformat() + data = { + "id": sid, + "title": title, + "created_at": created, + "messages": [], + "session_memory": None, + "plan_state": None, + } + sdir = _session_dir(sid) + sdir.mkdir(parents=True, exist_ok=True) + _save_session_meta(sid, data) + _append_message_jsonl(sid, {"role": "system", "content": "session_start", "timestamp": created}) + _session_index[sid] = {"id": sid, "title": title, "created_at": created} + _save_index() + return data + + +def _delete_session_files(sid: str) -> None: + import shutil + sdir = _session_dir(sid) + if sdir.exists(): + shutil.rmtree(sdir) + + +def _migrate_legacy() -> None: + legacy = Path("sessions.json") + if not legacy.exists(): + return + try: + import json + legacy_data = json.loads(legacy.read_text(encoding="utf-8")) + for sid, data in legacy_data.items(): + sdir = _session_dir(sid) + sdir.mkdir(parents=True, exist_ok=True) + messages = data.get("messages", []) + with open(sdir / "messages.jsonl", "wb") as f: + import orjson as _orjson + for msg in messages: + f.write(_orjson.dumps(msg)) + f.write(b"\n") + _save_session_meta(sid, data) + _session_index[sid] = { + "id": sid, + "title": data.get("title", ""), + "created_at": data.get("created_at", ""), + } + _save_index() + legacy.rename(legacy.with_suffix(".json.bak")) + except Exception as e: + print(f"Migration warning: {e}") -sessions: dict = _load_sessions() # {session_id: {title, created_at, messages: [...]}} +def _migrate_per_session() -> None: + for sid_path in SESSIONS_DIR.iterdir(): + if not sid_path.is_dir(): + continue + sid = sid_path.name + json_file = sid_path.with_suffix(".json") + if json_file.exists(): + try: + import orjson as _orjson + data = _orjson.loads(json_file.read_bytes()) + _save_session_meta(sid, data) + if not (sid_path / "messages.jsonl").exists(): + msgs = data.get("messages", []) + for msg in msgs: + _append_message_jsonl(sid, msg) + json_file.unlink() + except Exception: + pass + + +_ensure_sessions_dir() +_migrate_legacy() +_migrate_per_session() +_session_index = _load_index() _HIDDEN_PREVIEW_KEYS = { "common.connected_ws", @@ -136,18 +277,6 @@ def _extract_session_preview(messages: list[dict]) -> str: return "" -def _new_session(title: str = "") -> dict: - sid = str(uuid.uuid4()) - sessions[sid] = { - "id": sid, - "title": title, - "created_at": datetime.now().isoformat(), - "messages": [], - } - _save_sessions() - return sessions[sid] - - def _session_preview(s: dict) -> dict: msgs = s.get("messages", []) return { @@ -192,7 +321,7 @@ def read_root(): @app.get("/chats") def list_chats(): """Return all sessions sorted by created_at descending.""" - result = [_session_preview(s) for s in sessions.values()] + result = [_session_preview({"id": sid, **info}) for sid, info in _session_index.items()] result.sort(key=lambda x: x["created_at"], reverse=True) return result @@ -210,27 +339,35 @@ class PatchChat(BaseModel): @app.patch("/chats/{session_id}") def rename_chat(session_id: str, body: PatchChat): - if session_id not in sessions: + if session_id not in _session_index: raise HTTPException(status_code=404, detail="Session not found") - sessions[session_id]["title"] = body.title - _save_sessions() - return _session_preview(sessions[session_id]) + _session_index[session_id]["title"] = body.title + data = _load_session(session_id) + if data: + data["title"] = body.title + _save_session_meta(session_id, data) + _save_index() + return _session_preview({"id": session_id, **_session_index[session_id]}) @app.delete("/chats/{session_id}") def delete_chat(session_id: str): - if session_id not in sessions: + if session_id not in _session_index: raise HTTPException(status_code=404, detail="Session not found") - del sessions[session_id] - _save_sessions() + del _session_index[session_id] + _delete_session_files(session_id) + _save_index() return {"ok": True} @app.get("/chats/{session_id}/messages") def get_messages(session_id: str): - if session_id not in sessions: + if session_id not in _session_index: + raise HTTPException(status_code=404, detail="Session not found") + data = _load_session(session_id) + if not data: raise HTTPException(status_code=404, detail="Session not found") - return sessions[session_id]["messages"] + return data.get("messages", []) @app.get("/tasks") @@ -239,12 +376,130 @@ def list_tasks(): summary = { "total": len(tasks), "pending": sum(1 for task in tasks if task.get("status") == "pending"), + "planning": sum(1 for task in tasks if task.get("status") == "planning"), + "awaiting_approval": sum( + 1 for task in tasks if task.get("status") == "awaiting_approval" + ), "in_progress": sum(1 for task in tasks if task.get("status") == "in_progress"), "completed": sum(1 for task in tasks if task.get("status") == "completed"), } return {"tasks": tasks, "summary": summary} +class CreateKnowledgeFolder(BaseModel): + name: str + + +class ToggleKnowledgeFolder(BaseModel): + folder_id: str + + +class KnowledgeSearchBody(BaseModel): + query: str + top_k: int = 5 + + +@app.get("/knowledge/folders") +def list_knowledge_folders(): + folders = knowledge_service.list_folders() + return {"folders": folders} + + +@app.post("/knowledge/folders") +def create_knowledge_folder(body: CreateKnowledgeFolder): + try: + folder = knowledge_service.create_folder(body.name) + return {"folder": folder} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@app.get("/knowledge/selected") +def get_selected_knowledge_folders(): + return {"selected_folder_ids": knowledge_service.get_selected()} + + +@app.post("/knowledge/select") +def select_knowledge_folder(body: ToggleKnowledgeFolder): + try: + knowledge_service.select_folder(body.folder_id) + return {"ok": True, "folder_id": body.folder_id} + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Folder not found") + + +@app.post("/knowledge/deselect") +def deselect_knowledge_folder(body: ToggleKnowledgeFolder): + knowledge_service.deselect_folder(body.folder_id) + return {"ok": True, "folder_id": body.folder_id} + + +@app.post("/knowledge/upload") +async def upload_knowledge_files( + folder_id: str = Form(...), + files: list[UploadFile] = File(...), +): + parsed_files: list[tuple[str, bytes, str]] = [] + for upload in files: + content = await upload.read() + parsed_files.append( + ( + upload.filename or "unnamed_file", + content, + upload.content_type or "application/octet-stream", + ) + ) + try: + result = knowledge_service.upload_files(folder_id, parsed_files) + return {"ok": True, **result} + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Folder not found") + + +@app.get("/knowledge/folders/{folder_id}/files") +def list_knowledge_files(folder_id: str): + try: + files = knowledge_service.list_files(folder_id) + return {"files": files} + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Folder not found") + + +@app.delete("/knowledge/files/{file_id}") +def delete_knowledge_file(file_id: str): + try: + result = knowledge_service.delete_file(file_id) + return result + except FileNotFoundError: + raise HTTPException(status_code=404, detail="File not found") + + +@app.get("/knowledge/files/{file_id}/preview") +def preview_knowledge_file(file_id: str): + try: + file_path = knowledge_service.preview_path(file_id) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="File not found") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return FileResponse(file_path) + + +@app.get("/knowledge/status") +def knowledge_status(): + return knowledge_service.status() + + +@app.post("/knowledge/search") +def knowledge_search(body: KnowledgeSearchBody): + query = body.query.strip() + if not query: + raise HTTPException(status_code=400, detail="query is required") + top_k = max(1, min(20, body.top_k)) + results = knowledge_service.search(query=query, top_k=top_k) + return {"results": results} + + # ─── WebSocket ──────────────────────────────────────────────────────────────── @@ -253,7 +508,7 @@ async def websocket_endpoint(websocket: WebSocket): session_id: Optional[str] = websocket.query_params.get("session_id") # Auto-create session if not provided or not found - if not session_id or session_id not in sessions: + if not session_id or session_id not in _session_index: session = _new_session() session_id = session["id"] @@ -262,11 +517,12 @@ async def websocket_endpoint(websocket: WebSocket): adapter = WebAdapter( websocket=websocket, session_id=session_id, - sessions=sessions, - save_sessions=_save_sessions, uploads_dir=UPLOADS_DIR, playwright_manager=pm, run_agent=run_agent_loop, + append_message_fn=lambda sid, msg: _append_message_jsonl(sid, msg), + update_session_fn=lambda sid, data: _save_session_meta(sid, data), + load_history_fn=_load_messages_jsonl, ) await adapter.start() diff --git a/memory/__init__.py b/memory/__init__.py new file mode 100644 index 0000000..a835eb3 --- /dev/null +++ b/memory/__init__.py @@ -0,0 +1,3 @@ +from memory.session_memory import SessionMemory + +__all__ = ["SessionMemory"] diff --git a/memory/session_memory.py b/memory/session_memory.py new file mode 100644 index 0000000..ae3b885 --- /dev/null +++ b/memory/session_memory.py @@ -0,0 +1,166 @@ +""" +memory/session_memory.py - Structured session memory for agent loops. + +Replaces the need for the agent to track "where am I in the task" by +maintaining a real-time structured snapshot across the session. + +Section limits (per Claude Code's design): + - Each section: 2000 tokens + - Total: 12000 tokens +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import Any + + +SECTION_TEMPLATE = """## Current State +... + +## Task Specification +... + +## Important Files +... + +## Workflow +... + +## Errors & Corrections +... + +## Learnings +... + +## Pending Tasks +... +""" + +SECTION_KEYS = [ + "current_state", + "task_spec", + "important_files", + "workflow", + "errors_corrections", + "learnings", + "pending_tasks", +] + +SECTION_SUMMARIES = { + "current_state": "What is currently being done, progress status", + "task_spec": "What the user asked to build, core requirements", + "important_files": "Key files and their purposes / modification history", + "workflow": "Common build, test, deploy commands", + "errors_corrections": "Errors encountered and how they were fixed", + "learnings": "What worked, what didn't", + "pending_tasks": "Explicit pending tasks", +} + +SECTION_MAX_CHARS = { + "current_state": 1500, + "task_spec": 1500, + "important_files": 2000, + "workflow": 1500, + "errors_corrections": 2000, + "learnings": 1500, + "pending_tasks": 2000, +} + + +@dataclass +class MemoryEntry: + content: str + timestamp: float = field(default_factory=time.time) + + def to_dict(self) -> dict: + return {"content": self.content, "timestamp": self.timestamp} + + @staticmethod + def from_dict(d: dict) -> MemoryEntry: + return MemoryEntry(content=d["content"], timestamp=d.get("timestamp", 0)) + + +class SessionMemory: + def __init__(self, session_id: str | None = None) -> None: + self.session_id = session_id + self._sections: dict[str, list[MemoryEntry]] = {k: [] for k in SECTION_KEYS} + + # ── Public API ────────────────────────────────────────────────────────────── + + def update(self, section: str, content: str) -> None: + if section not in SECTION_KEYS: + raise ValueError(f"Unknown section: {section}. Must be one of {SECTION_KEYS}") + max_chars = SECTION_MAX_CHARS.get(section, 2000) + if len(content) > max_chars: + content = content[:max_chars] + self._sections[section].append(MemoryEntry(content=content)) + + def append(self, section: str, line: str) -> None: + existing = self.get(section) + if existing.strip(): + new_content = existing + "\n" + line + else: + new_content = line + self.update(section, new_content) + + def get(self, section: str) -> str: + if section not in SECTION_KEYS: + raise ValueError(f"Unknown section: {section}") + entries = self._sections.get(section, []) + if not entries: + return "" + return entries[-1].content + + def get_all(self) -> str: + parts = [f"# Session Memory\n"] + for key in SECTION_KEYS: + content = self.get(key) + summary = SECTION_SUMMARIES.get(key, "") + if content: + parts.append(f"## {key.replace('_', ' ').title()} [{summary}]\n{content}\n") + else: + parts.append(f"## {key.replace('_', ' ').title()} [{summary}]\n...\n") + return "\n".join(parts) + + def get_dict(self) -> dict[str, Any]: + return { + "session_id": self.session_id, + "sections": {k: [e.to_dict() for e in v] for k, v in self._sections.items()}, + } + + @classmethod + def from_dict(cls, data: dict | None) -> SessionMemory: + if data is None: + return cls() + instance = cls(session_id=data.get("session_id")) + raw_sections = data.get("sections", {}) + for key in SECTION_KEYS: + entries_data = raw_sections.get(key, []) + instance._sections[key] = [MemoryEntry.from_dict(e) for e in entries_data] + return instance + + def to_compact_dict(self) -> dict[str, Any]: + return { + "session_id": self.session_id, + "sections": {k: self.get(k) for k in SECTION_KEYS}, + } + + @classmethod + def from_compact_dict(cls, data: dict | None) -> SessionMemory: + if data is None: + return cls() + instance = cls(session_id=data.get("session_id")) + raw_sections = data.get("sections", {}) + for key in SECTION_KEYS: + content = raw_sections.get(key, "") or "" + if content: + instance._sections[key] = [MemoryEntry(content=content)] + return instance + + def is_empty(self) -> bool: + return all(not self.get(k) for k in SECTION_KEYS) + + def clear(self) -> None: + self._sections = {k: [] for k in SECTION_KEYS} diff --git a/platforms/web.py b/platforms/web.py index 76b4c08..2e09894 100644 --- a/platforms/web.py +++ b/platforms/web.py @@ -43,6 +43,28 @@ _web_queues: dict[str, asyncio.Queue] = {} _web_running: set[str] = set() +_PLAN_APPROVAL_PHRASES = ( + "同意", + "批准", + "通过", + "可以执行", + "按计划执行", + "开始执行", + "继续执行", + "没问题", + "approved", + "approve", + "go ahead", + "looks good", + "proceed", +) + + +def _classify_plan_response(text: str) -> str: + normalized = (text or "").strip().lower() + if any(phrase in normalized for phrase in _PLAN_APPROVAL_PHRASES): + return "approved" + return "revise" class WebAdapter(PlatformAdapter): @@ -79,21 +101,27 @@ def __init__( self, websocket: WebSocket, session_id: str, - sessions: dict, - save_sessions: Callable, uploads_dir: Path, playwright_manager, run_agent: Callable, + session_memory=None, + save_session_memory_fn=None, + append_message_fn=None, + update_session_fn=None, + load_history_fn=None, ) -> None: super().__init__() self._ws = websocket self._session_id = session_id - self._sessions = sessions - self._save_sessions = save_sessions + self._append_message_fn = append_message_fn + self._update_session_fn = update_session_fn self._uploads_dir = uploads_dir self._workspace_dir = uploads_dir.parent self._pm = playwright_manager self._run_agent = run_agent + self._session_memory = session_memory + self._save_session_memory_fn = save_session_memory_fn + self._load_history_fn = load_history_fn self._message_center = MessageCenter(session_id) self._bus_handler = self._handle_bus_event @@ -113,11 +141,17 @@ async def start(self) -> None: ) task_status = task_manager.get_task_status(self._session_id) + initial_status = task_status.value if task_status else None + if initial_status is None: + meta = self._load_current_meta() + plan_state = meta.get("plan_state") if isinstance(meta, dict) else None + if isinstance(plan_state, dict) and plan_state.get("mode") == "awaiting_approval": + initial_status = "awaiting_approval" await self._ws.send_text( json.dumps( { "type": "task_status", - "status": task_status.value if task_status else None, + "status": initial_status, } ) ) @@ -194,6 +228,11 @@ async def _handle_bus_event(self, event: BusEvent) -> None: packet = {"type": "info", "message": text} await self._send_packet(packet) self._append_message("assistant", text) + return + + if event_type == "task_status": + await self._send_packet({"type": "task_status", "status": payload.get("status")}) + return def _is_ws_connected(self) -> bool: client_state = getattr(self._ws, "client_state", None) @@ -302,10 +341,21 @@ def _append_message( msg["message_key"] = message_key if params: msg["params"] = params - self._sessions[self._session_id]["messages"].append(msg) - if role == "user" and not self._sessions[self._session_id]["title"]: - self._sessions[self._session_id]["title"] = (content or "📷 Image")[:40] - self._save_sessions() + if self._append_message_fn: + self._append_message_fn(self._session_id, msg) + if role == "user" and self._update_session_fn: + pass + + def _load_current_meta(self) -> dict: + meta_path = Path("sessions") / self._session_id / "meta.json" + if meta_path.exists(): + try: + import orjson + + return orjson.loads(meta_path.read_bytes()) + except Exception: + pass + return {} async def _send_image(self, description: str, b64_image: str) -> None: """Send a screenshot / image frame to the frontend.""" @@ -321,9 +371,12 @@ async def _send_image(self, description: str, b64_image: str) -> None: def _build_history(self) -> list: """Build the conversation history list for the agent (excludes last msg).""" + if self._load_history_fn: + all_messages = self._load_history_fn(self._session_id) + else: + return [] history: list = [] - messages = self._sessions[self._session_id]["messages"] - for m in messages[:-1]: + for m in all_messages[:-1]: role = m.get("role", "user") content = m.get("content", "") if role == "user": @@ -654,6 +707,87 @@ async def _handle_user_input(self, payload: dict) -> None: ) await self.on_message(unified) + current_meta = self._load_current_meta() + + def _persist_meta(): + if self._update_session_fn is None: + return + self._update_session_fn(self._session_id, current_meta) + + def _save_sm(): + if self._update_session_fn is None: + return + current_meta["session_memory"] = ( + sm.to_compact_dict() if hasattr(sm, "to_compact_dict") else None + ) + _persist_meta() + + def _save_plan_state(plan_state: dict | None): + if self._update_session_fn is None: + return + current_meta["plan_state"] = plan_state + _persist_meta() + + sm_data = current_meta.get("session_memory") + if sm_data: + from memory.session_memory import SessionMemory + + sm = SessionMemory.from_compact_dict(sm_data) + else: + from memory.session_memory import SessionMemory + + sm = SessionMemory(session_id=self._session_id) + + plan_state = current_meta.get("plan_state") + run_instruction = user_msg + if isinstance(plan_state, dict) and plan_state.get("mode") == "awaiting_approval": + plan_file = plan_state.get("plan_file", ".plans/current-plan.md") + decision = _classify_plan_response(user_msg) + if decision == "approved": + plan_state = dict(plan_state) + plan_state["mode"] = "execution" + plan_state["awaiting_approval"] = False + plan_state["updated_at"] = datetime.now().isoformat() + current_meta["plan_state"] = plan_state + sm.update("current_state", "Plan approved; ready to execute") + sm.update( + "pending_tasks", + f"Execute the approved plan in {plan_file} and validate the result.", + ) + sm.update( + "workflow", + f"Execution resumed from the approved plan in {plan_file}.", + ) + current_meta["session_memory"] = sm.to_compact_dict() + _persist_meta() + run_instruction = ( + "The user approved the current plan. " + f"Switch to execution mode and implement the approved plan in `{plan_file}`.\n" + f"User confirmation: {user_msg or 'Approved.'}" + ) + else: + plan_state = dict(plan_state) + plan_state["mode"] = "planning" + plan_state["awaiting_approval"] = False + plan_state["updated_at"] = datetime.now().isoformat() + current_meta["plan_state"] = plan_state + sm.update("current_state", "In planning mode") + sm.update( + "pending_tasks", + f"Revise the plan in {plan_file} based on the user's feedback.", + ) + sm.update( + "workflow", + f"Planning mode resumed. Update {plan_file} according to the latest user feedback.", + ) + current_meta["session_memory"] = sm.to_compact_dict() + _persist_meta() + run_instruction = ( + "The user reviewed the submitted plan and requested changes. " + f"Stay in planning mode and revise `{plan_file}`.\n" + f"User feedback: {user_msg}" + ) + history = self._build_history() _web_running.add(self._session_id) @@ -662,7 +796,7 @@ async def _run_with_queue(cancel_event: asyncio.Event = None): try: await self._run_agent( self._pm, - user_msg, + run_instruction, None, lambda reason, img: self.request_action( self._session_id, reason, img @@ -676,6 +810,13 @@ async def _run_with_queue(cancel_event: asyncio.Event = None): web_queue_getter=lambda: _web_queues.get(self._session_id), web_session_id=self._session_id, cancel_event=cancel_event, + session_memory=sm, + save_session_memory_fn=_save_sm, + plan_state=plan_state, + save_plan_state_fn=_save_plan_state, + set_runtime_status_fn=lambda status: task_manager.set_task_status( + self._session_id, status + ), ) finally: _web_running.discard(self._session_id) diff --git a/pyproject.toml b/pyproject.toml index ceb44a0..9256f8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,9 +7,13 @@ requires-python = ">=3.11" dependencies = [ "aiohttp>=3.13.3", "anthropic>=0.84.0", + "faiss-cpu>=1.12.0", "fastapi>=0.135.1", + "numpy>=2.3.4", "openai>=2.26.0", + "orjson>=3.10.0", "playwright>=1.58.0", + "python-multipart>=0.0.20", "python-dotenv>=1.2.2", "uvicorn>=0.41.0", "websockets>=16.0", diff --git a/task_manager.py b/task_manager.py index 13c4c16..f108c76 100644 --- a/task_manager.py +++ b/task_manager.py @@ -111,7 +111,7 @@ def update( Args: task_id: Task ID - status: New status (pending, in_progress, completed) + status: New status (pending, planning, awaiting_approval, in_progress, completed) add_blocked_by: Task IDs this task depends on add_blocks: Task IDs that depend on this task owner: Owner identifier @@ -124,7 +124,7 @@ def update( # Update status if status: - valid_statuses = ("pending", "in_progress", "completed") + valid_statuses = ("pending", "planning", "awaiting_approval", "in_progress", "completed") if status not in valid_statuses: return f"Error: Invalid status '{status}'. Must be one of {valid_statuses}" task["status"] = status @@ -190,6 +190,8 @@ def list_all(self) -> str: lines = [] status_markers = { "pending": "[ ]", + "planning": "[plan]", + "awaiting_approval": "[wait]", "in_progress": "[>]", "completed": "[x]" } diff --git a/tool_registry.py b/tool_registry.py index 788305d..a8669d8 100644 --- a/tool_registry.py +++ b/tool_registry.py @@ -14,6 +14,7 @@ class ToolExecutionResult: finished: bool = False manual_compact: bool = False compact_focus: str | None = None + stop_loop: bool = False ToolHandler = Callable[[dict], Awaitable[ToolExecutionResult]] @@ -31,4 +32,3 @@ async def execute(self, name: str, args: dict) -> ToolExecutionResult: if handler is None: return ToolExecutionResult(output=f"Unknown tool: {name}") return await handler(args) - diff --git a/uv.lock b/uv.lock index b77bae6..fa459b8 100644 --- a/uv.lock +++ b/uv.lock @@ -233,6 +233,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] +[[package]] +name = "faiss-cpu" +version = "1.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c9/671f66f6b31ec48e5825d36435f0cb91189fa8bb6b50724029dbff4ca83c/faiss_cpu-1.13.2-cp310-abi3-macosx_14_0_arm64.whl", hash = "sha256:a9064eb34f8f64438dd5b95c8f03a780b1a3f0b99c46eeacb1f0b5d15fc02dc1", size = 3452776, upload-time = "2025-12-24T10:27:01.419Z" }, + { url = "https://files.pythonhosted.org/packages/5a/4a/97150aa1582fb9c2bca95bd8fc37f27d3b470acec6f0a6833844b21e4b40/faiss_cpu-1.13.2-cp310-abi3-macosx_14_0_x86_64.whl", hash = "sha256:c8d097884521e1ecaea6467aeebbf1aa56ee4a36350b48b2ca6b39366565c317", size = 7896434, upload-time = "2025-12-24T10:27:03.592Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d0/0940575f059591ca31b63a881058adb16a387020af1709dcb7669460115c/faiss_cpu-1.13.2-cp310-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ee330a284042c2480f2e90450a10378fd95655d62220159b1408f59ee83ebf1", size = 11485825, upload-time = "2025-12-24T10:27:05.681Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e1/a5acac02aa593809f0123539afe7b4aff61d1db149e7093239888c9053e1/faiss_cpu-1.13.2-cp310-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab88ee287c25a119213153d033f7dd64c3ccec466ace267395872f554b648cd7", size = 23845772, upload-time = "2025-12-24T10:27:08.194Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7b/49dcaf354834ec457e85ca769d50bc9b5f3003fab7c94a9dcf08cf742793/faiss_cpu-1.13.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:85511129b34f890d19c98b82a0cd5ffb27d89d1cec2ee41d2621ee9f9ef8cf3f", size = 13477567, upload-time = "2025-12-24T10:27:10.822Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6b/12bb4037921c38bb2c0b4cfc213ca7e04bbbebbfea89b0b5746248ce446e/faiss_cpu-1.13.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b32eb4065bac352b52a9f5ae07223567fab0a976c7d05017c01c45a1c24264f", size = 25102239, upload-time = "2025-12-24T10:27:13.476Z" }, + { url = "https://files.pythonhosted.org/packages/14/6d/40439a05e4e60a0e889aa68b08ec70f5c8e32901f75f2be25c593a2e050e/faiss_cpu-1.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:7c5944d7807d58fe7244b6aba06be710ee7ed99343365ed92699349efe979f51", size = 18879906, upload-time = "2025-12-24T10:27:19.041Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f9/b97eadbdd9e00f945d1566c7101382344f504596bfb19219465b0fc61e6e/faiss_cpu-1.13.2-cp311-cp311-win_arm64.whl", hash = "sha256:19508a1badfb36e456c1c8664eeb948349f604db5c7545f277a0126b4a84b080", size = 8548280, upload-time = "2025-12-24T10:27:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/35ed875423200c17bdd594ce921abfc1812ddd21e09355290b9a94e170ab/faiss_cpu-1.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:b82c01d30430dd7b1fa442001b9099735d1a82f6bb72033acdc9206d5ac66a64", size = 18890300, upload-time = "2025-12-24T10:27:24.194Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/bbdf5deaf6feb34b46b469c0a0acd40216c3d3c6ecf5aeb71d56b8a650e3/faiss_cpu-1.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2c4f696ae76e7c97cbc12311db83aaf1e7f4f7be06a3ffea7e5b0e8ec1fd805b", size = 8553157, upload-time = "2025-12-24T10:27:26.38Z" }, + { url = "https://files.pythonhosted.org/packages/60/4b/903d85bf3a8264d49964ec799e45c7ffc91098606b8bc9ef2c904c1a56cb/faiss_cpu-1.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:cb4b5ee184816a4b099162ac93c0d7f0033d81a88e7c1291d0a9cc41ec348984", size = 18891330, upload-time = "2025-12-24T10:27:28.806Z" }, + { url = "https://files.pythonhosted.org/packages/b2/52/5d10642da628f63544aab27e48416be4a7ea25c6b81d8bd65016d8538b00/faiss_cpu-1.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:1243967eeb2298791ff7f3683a4abd2100d7e6ec7542ca05c3b75d47a7f621e5", size = 8553088, upload-time = "2025-12-24T10:27:31.325Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b1/daaab8046f56c60079648bd83774f61b283b59a9930a2f60790ee4cdedfe/faiss_cpu-1.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:c8b645e7d56591aa35dc75415bb53a62e4a494dba010e16f4b67daeffd830bd7", size = 18892621, upload-time = "2025-12-24T10:27:33.923Z" }, + { url = "https://files.pythonhosted.org/packages/06/6f/5eaf3e249c636e616ebb52e369a4a2f1d32b1caf9a611b4f917b3dd21423/faiss_cpu-1.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:8113a2a80b59fe5653cf66f5c0f18be0a691825601a52a614c30beb1fca9bc7c", size = 8556374, upload-time = "2025-12-24T10:27:36.653Z" }, +] + [[package]] name = "fastapi" version = "0.135.1" @@ -661,10 +686,14 @@ source = { virtual = "." } dependencies = [ { name = "aiohttp" }, { name = "anthropic" }, + { name = "faiss-cpu" }, { name = "fastapi" }, + { name = "numpy" }, { name = "openai" }, + { name = "orjson" }, { name = "playwright" }, { name = "python-dotenv" }, + { name = "python-multipart" }, { name = "uvicorn" }, { name = "websockets" }, ] @@ -673,14 +702,97 @@ dependencies = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.13.3" }, { name = "anthropic", specifier = ">=0.84.0" }, + { name = "faiss-cpu", specifier = ">=1.12.0" }, { name = "fastapi", specifier = ">=0.135.1" }, + { name = "numpy", specifier = ">=2.3.4" }, { name = "openai", specifier = ">=2.26.0" }, + { name = "orjson", specifier = ">=3.10.0" }, { name = "playwright", specifier = ">=1.58.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "python-multipart", specifier = ">=0.0.20" }, { name = "uvicorn", specifier = ">=0.41.0" }, { name = "websockets", specifier = ">=16.0" }, ] +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, + { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, + { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, + { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, +] + [[package]] name = "openai" version = "2.26.0" @@ -700,6 +812,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/2e/3f73e8ca53718952222cacd0cf7eecc9db439d020f0c1fe7ae717e4e199a/openai-2.26.0-py3-none-any.whl", hash = "sha256:6151bf8f83802f036117f06cc8a57b3a4da60da9926826cc96747888b57f394f", size = 1136409, upload-time = "2026-03-05T23:17:34.072Z" }, ] +[[package]] +name = "orjson" +version = "3.11.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/41/5aa7fa3b0f4dc6b47dcafc3cea909299c37e40e9972feabc8b6a74e2730d/orjson-3.11.8-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:003646067cc48b7fcab2ae0c562491c9b5d2cbd43f1e5f16d98fd118c5522d34", size = 229229, upload-time = "2026-03-31T16:14:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/0a/d7/57e7f2458e0a2c41694f39fc830030a13053a84f837a5b73423dca1f0938/orjson-3.11.8-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:ed193ce51d77a3830cad399a529cd4ef029968761f43ddc549e1bc62b40d88f8", size = 128871, upload-time = "2026-03-31T16:14:51.888Z" }, + { url = "https://files.pythonhosted.org/packages/53/4a/e0fdb9430983e6c46e0299559275025075568aad5d21dd606faee3703924/orjson-3.11.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30491bc4f862aa15744b9738517454f1e46e56c972a2be87d70d727d5b2a8f8", size = 132104, upload-time = "2026-03-31T16:14:53.142Z" }, + { url = "https://files.pythonhosted.org/packages/08/4a/2025a60ff3f5c8522060cda46612d9b1efa653de66ed2908591d8d82f22d/orjson-3.11.8-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eda5b8b6be91d3f26efb7dc6e5e68ee805bc5617f65a328587b35255f138bf4", size = 130483, upload-time = "2026-03-31T16:14:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3c/b9cde05bdc7b2385c66014e0620627da638d3d04e4954416ab48c31196c5/orjson-3.11.8-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee8db7bfb6fe03581bbab54d7c4124a6dd6a7f4273a38f7267197890f094675f", size = 135481, upload-time = "2026-03-31T16:14:55.901Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f2/a8238e7734de7cb589fed319857a8025d509c89dc52fdcc88f39c6d03d5a/orjson-3.11.8-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d8b5231de76c528a46b57010bbd83fb51e056aa0220a372fd5065e978406f1c", size = 146819, upload-time = "2026-03-31T16:14:57.548Z" }, + { url = "https://files.pythonhosted.org/packages/db/10/dbf1e2a3cafea673b1b4350e371877b759060d6018a998643b7040e5de48/orjson-3.11.8-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58a4a208a6fbfdb7a7327b8f201c6014f189f721fd55d047cafc4157af1bc62a", size = 132846, upload-time = "2026-03-31T16:14:58.91Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fc/55e667ec9c85694038fcff00573d221b085d50777368ee3d77f38668bf3c/orjson-3.11.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f8952d6d2505c003e8f0224ff7858d341fa4e33fef82b91c4ff0ef070f2393c", size = 133580, upload-time = "2026-03-31T16:15:00.519Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a6/c08c589a9aad0cb46c4831d17de212a2b6901f9d976814321ff8e69e8785/orjson-3.11.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0022bb50f90da04b009ce32c512dc1885910daa7cb10b7b0cba4505b16db82a8", size = 142042, upload-time = "2026-03-31T16:15:01.906Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/2f78ea241d52b717d2efc38878615fe80425bf2beb6e68c984dde257a766/orjson-3.11.8-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ff51f9d657d1afb6f410cb435792ce4e1fe427aab23d2fcd727a2876e21d4cb6", size = 423845, upload-time = "2026-03-31T16:15:03.703Z" }, + { url = "https://files.pythonhosted.org/packages/70/07/c17dcf05dd8045457538428a983bf1f1127928df5bf328cb24d2b7cddacb/orjson-3.11.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6dbe9a97bdb4d8d9d5367b52a7c32549bba70b2739c58ef74a6964a6d05ae054", size = 147729, upload-time = "2026-03-31T16:15:05.203Z" }, + { url = "https://files.pythonhosted.org/packages/90/6c/0fb6e8a24e682e0958d71711ae6f39110e4b9cd8cab1357e2a89cb8e1951/orjson-3.11.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5c370674ebabe16c6ccac33ff80c62bf8a6e59439f5e9d40c1f5ab8fd2215b7", size = 136425, upload-time = "2026-03-31T16:15:07.052Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/4d3cc3a3d616035beb51b24a09bb872942dc452cf2df0c1d11ab35046d9f/orjson-3.11.8-cp311-cp311-win32.whl", hash = "sha256:0e32f7154299f42ae66f13488963269e5eccb8d588a65bc839ed986919fc9fac", size = 131870, upload-time = "2026-03-31T16:15:08.678Z" }, + { url = "https://files.pythonhosted.org/packages/13/26/9fe70f81d16b702f8c3a775e8731b50ad91d22dacd14c7599b60a0941cd1/orjson-3.11.8-cp311-cp311-win_amd64.whl", hash = "sha256:25e0c672a2e32348d2eb33057b41e754091f2835f87222e4675b796b92264f06", size = 127440, upload-time = "2026-03-31T16:15:09.994Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c6/b038339f4145efd2859c1ca53097a52c0bb9cbdd24f947ebe146da1ad067/orjson-3.11.8-cp311-cp311-win_arm64.whl", hash = "sha256:9185589c1f2a944c17e26c9925dcdbc2df061cc4a145395c57f0c51f9b5dbfcd", size = 127399, upload-time = "2026-03-31T16:15:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/01/f6/8d58b32ab32d9215973a1688aebd098252ee8af1766c0e4e36e7831f0295/orjson-3.11.8-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1cd0b77e77c95758f8e1100139844e99f3ccc87e71e6fc8e1c027e55807c549f", size = 229233, upload-time = "2026-03-31T16:15:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/2ffe35e71f6b92622e8ea4607bf33ecf7dfb51b3619dcfabfd36cbe2d0a5/orjson-3.11.8-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6a3d159d5ffa0e3961f353c4b036540996bf8b9697ccc38261c0eac1fd3347a6", size = 128772, upload-time = "2026-03-31T16:15:14.237Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/1f8682ae50d5c6897a563cb96bc106da8c9cb5b7b6e81a52e4cc086679b9/orjson-3.11.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76070a76e9c5ae661e2d9848f216980d8d533e0f8143e6ed462807b242e3c5e8", size = 131946, upload-time = "2026-03-31T16:15:15.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/4b/5500f76f0eece84226e0689cb48dcde081104c2fa6e2483d17ca13685ffb/orjson-3.11.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:54153d21520a71a4c82a0dbb4523e468941d549d221dc173de0f019678cf3813", size = 130368, upload-time = "2026-03-31T16:15:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/da/4e/58b927e08fbe9840e6c920d9e299b051ea667463b1f39a56e668669f8508/orjson-3.11.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:469ac2125611b7c5741a0b3798cd9e5786cbad6345f9f400c77212be89563bec", size = 135540, upload-time = "2026-03-31T16:15:18.404Z" }, + { url = "https://files.pythonhosted.org/packages/56/7c/ba7cb871cba1bcd5cd02ee34f98d894c6cea96353ad87466e5aef2429c60/orjson-3.11.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14778ffd0f6896aa613951a7fbf4690229aa7a543cb2bfbe9f358e08aafa9546", size = 146877, upload-time = "2026-03-31T16:15:19.833Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/eb9c25fc1386696c6a342cd361c306452c75e0b55e86ad602dd4827a7fd7/orjson-3.11.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea56a955056a6d6c550cf18b3348656a9d9a4f02e2d0c02cabf3c73f1055d506", size = 132837, upload-time = "2026-03-31T16:15:21.282Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/5ddeb7fc1fbd9004aeccab08426f34c81a5b4c25c7061281862b015fce2b/orjson-3.11.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53a0f57e59a530d18a142f4d4ba6dfc708dc5fdedce45e98ff06b44930a2a48f", size = 133624, upload-time = "2026-03-31T16:15:22.641Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/90048793db94ee4b2fcec4ac8e5ddb077367637d6650be896b3494b79bb7/orjson-3.11.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b48e274f8824567d74e2158199e269597edf00823a1b12b63d48462bbf5123e", size = 141904, upload-time = "2026-03-31T16:15:24.435Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cf/eb284847487821a5d415e54149a6449ba9bfc5872ce63ab7be41b8ec401c/orjson-3.11.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3f262401086a3960586af06c054609365e98407151f5ea24a62893a40d80dbbb", size = 423742, upload-time = "2026-03-31T16:15:26.155Z" }, + { url = "https://files.pythonhosted.org/packages/44/09/e12423d327071c851c13e76936f144a96adacfc037394dec35ac3fc8d1e8/orjson-3.11.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e8c6218b614badf8e229b697865df4301afa74b791b6c9ade01d19a9953a942", size = 147806, upload-time = "2026-03-31T16:15:27.909Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6d/37c2589ba864e582ffe7611643314785c6afb1f83c701654ef05daa8fcc7/orjson-3.11.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093d489fa039ddade2db541097dbb484999fcc65fc2b0ff9819141e2ab364f25", size = 136485, upload-time = "2026-03-31T16:15:29.749Z" }, + { url = "https://files.pythonhosted.org/packages/be/c9/135194a02ab76b04ed9a10f68624b7ebd238bbe55548878b11ff15a0f352/orjson-3.11.8-cp312-cp312-win32.whl", hash = "sha256:e0950ed1bcb9893f4293fd5c5a7ee10934fbf82c4101c70be360db23ce24b7d2", size = 131966, upload-time = "2026-03-31T16:15:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9a/9796f8fbe3cf30ce9cb696748dbb535e5c87be4bf4fe2e9ca498ef1fa8cf/orjson-3.11.8-cp312-cp312-win_amd64.whl", hash = "sha256:3cf17c141617b88ced4536b2135c552490f07799f6ad565948ea07bef0dcb9a6", size = 127441, upload-time = "2026-03-31T16:15:33.333Z" }, + { url = "https://files.pythonhosted.org/packages/cc/47/5aaf54524a7a4a0dd09dd778f3fa65dd2108290615b652e23d944152bc8e/orjson-3.11.8-cp312-cp312-win_arm64.whl", hash = "sha256:48854463b0572cc87dac7d981aa72ed8bf6deedc0511853dc76b8bbd5482d36d", size = 127364, upload-time = "2026-03-31T16:15:34.748Z" }, + { url = "https://files.pythonhosted.org/packages/66/7f/95fba509bb2305fab0073558f1e8c3a2ec4b2afe58ed9fcb7d3b8beafe94/orjson-3.11.8-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3f23426851d98478c8970da5991f84784a76682213cd50eb73a1da56b95239dc", size = 229180, upload-time = "2026-03-31T16:15:36.426Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9d/b237215c743ca073697d759b5503abd2cb8a0d7b9c9e21f524bcf176ab66/orjson-3.11.8-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ebaed4cef74a045b83e23537b52ef19a367c7e3f536751e355a2a394f8648559", size = 128754, upload-time = "2026-03-31T16:15:38.049Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/27d65b6d11e63f133781425f132807aef793ed25075fec686fc8e46dd528/orjson-3.11.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97c8f5d3b62380b70c36ffacb2a356b7c6becec86099b177f73851ba095ef623", size = 131877, upload-time = "2026-03-31T16:15:39.484Z" }, + { url = "https://files.pythonhosted.org/packages/dd/cc/faee30cd8f00421999e40ef0eba7332e3a625ce91a58200a2f52c7fef235/orjson-3.11.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:436c4922968a619fb7fef1ccd4b8b3a76c13b67d607073914d675026e911a65c", size = 130361, upload-time = "2026-03-31T16:15:41.274Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bb/a6c55896197f97b6d4b4e7c7fd77e7235517c34f5d6ad5aadd43c54c6d7c/orjson-3.11.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ab359aff0436d80bfe8a23b46b5fea69f1e18aaf1760a709b4787f1318b317f", size = 135521, upload-time = "2026-03-31T16:15:42.758Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ca3a3525aa32ff636ebb1778e77e3587b016ab2edb1b618b36ba96f8f2c0/orjson-3.11.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89b6d0b3a8d81e1929d3ab3d92bbc225688bd80a770c49432543928fe09ac55", size = 146862, upload-time = "2026-03-31T16:15:44.341Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0c/18a9d7f18b5edd37344d1fd5be17e94dc652c67826ab749c6e5948a78112/orjson-3.11.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c009e7a2ca9ad0ed1376ce20dd692146a5d9fe4310848904b6b4fee5c5c137", size = 132847, upload-time = "2026-03-31T16:15:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/23/91/7e722f352ad67ca573cee44de2a58fb810d0f4eb4e33276c6a557979fd8a/orjson-3.11.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b895b781b3e395c067129d8551655642dfe9437273211d5404e87ac752b53", size = 133637, upload-time = "2026-03-31T16:15:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/af/04/32845ce13ac5bd1046ddb02ac9432ba856cc35f6d74dde95864fe0ad5523/orjson-3.11.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88006eda83858a9fdf73985ce3804e885c2befb2f506c9a3723cdeb5a2880e3e", size = 141906, upload-time = "2026-03-31T16:15:49.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/5e/c551387ddf2d7106d9039369862245c85738b828844d13b99ccb8d61fd06/orjson-3.11.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:55120759e61309af7fcf9e961c6f6af3dde5921cdb3ee863ef63fd9db126cae6", size = 423722, upload-time = "2026-03-31T16:15:51.176Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/ecfe62434096f8a794d4976728cb59bcfc4a643977f21c2040545d37eb4c/orjson-3.11.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98bdc6cb889d19bed01de46e67574a2eab61f5cc6b768ed50e8ac68e9d6ffab6", size = 147801, upload-time = "2026-03-31T16:15:52.939Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/0dce10b9f6643fdc59d99333871a38fa5a769d8e2fc34a18e5d2bfdee900/orjson-3.11.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:708c95f925a43ab9f34625e45dcdadf09ec8a6e7b664a938f2f8d5650f6c090b", size = 136460, upload-time = "2026-03-31T16:15:54.431Z" }, + { url = "https://files.pythonhosted.org/packages/01/d6/6dde4f31842d87099238f1f07b459d24edc1a774d20687187443ab044191/orjson-3.11.8-cp313-cp313-win32.whl", hash = "sha256:01c4e5a6695dc09098f2e6468a251bc4671c50922d4d745aff1a0a33a0cf5b8d", size = 131956, upload-time = "2026-03-31T16:15:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f9/4e494a56e013db957fb77186b818b916d4695b8fa2aa612364974160e91b/orjson-3.11.8-cp313-cp313-win_amd64.whl", hash = "sha256:c154a35dd1330707450bb4d4e7dd1f17fa6f42267a40c1e8a1daa5e13719b4b8", size = 127410, upload-time = "2026-03-31T16:15:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/803203d00d6edb6e9e7eef421d4e1adbb5ea973e40b3533f3cfd9aeb374e/orjson-3.11.8-cp313-cp313-win_arm64.whl", hash = "sha256:4861bde57f4d253ab041e374f44023460e60e71efaa121f3c5f0ed457c3a701e", size = 127338, upload-time = "2026-03-31T16:15:59.106Z" }, + { url = "https://files.pythonhosted.org/packages/6d/35/b01910c3d6b85dc882442afe5060cbf719c7d1fc85749294beda23d17873/orjson-3.11.8-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ec795530a73c269a55130498842aaa762e4a939f6ce481a7e986eeaa790e9da4", size = 229171, upload-time = "2026-03-31T16:16:00.651Z" }, + { url = "https://files.pythonhosted.org/packages/c2/56/c9ec97bd11240abef39b9e5d99a15462809c45f677420fd148a6c5e6295e/orjson-3.11.8-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c492a0e011c0f9066e9ceaa896fbc5b068c54d365fea5f3444b697ee01bc8625", size = 128746, upload-time = "2026-03-31T16:16:02.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/66d4f30a90de45e2f0cbd9623588e8ae71eef7679dbe2ae954ed6d66a41f/orjson-3.11.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:883206d55b1bd5f5679ad5e6ddd3d1a5e3cac5190482927fdb8c78fb699193b5", size = 131867, upload-time = "2026-03-31T16:16:04.342Z" }, + { url = "https://files.pythonhosted.org/packages/19/30/2a645fc9286b928675e43fa2a3a16fb7b6764aa78cc719dc82141e00f30b/orjson-3.11.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5774c1fdcc98b2259800b683b19599c133baeb11d60033e2095fd9d4667b82db", size = 124664, upload-time = "2026-03-31T16:16:05.837Z" }, + { url = "https://files.pythonhosted.org/packages/db/44/77b9a86d84a28d52ba3316d77737f6514e17118119ade3f91b639e859029/orjson-3.11.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7381c83dd3d4a6347e6635950aa448f54e7b8406a27c7ecb4a37e9f1ae08b", size = 129701, upload-time = "2026-03-31T16:16:07.407Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/eff3d9bfe47e9bc6969c9181c58d9f71237f923f9c86a2d2f490cd898c82/orjson-3.11.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14439063aebcb92401c11afc68ee4e407258d2752e62d748b6942dad20d2a70d", size = 141202, upload-time = "2026-03-31T16:16:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/52/c8/90d4b4c60c84d62068d0cf9e4d8f0a4e05e76971d133ac0c60d818d4db20/orjson-3.11.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa72e71977bff96567b0f500fc5bfd2fdf915f34052c782a4c6ebbdaa97aa858", size = 127194, upload-time = "2026-03-31T16:16:11.02Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c7/ea9e08d1f0ba981adffb629811148b44774d935171e7b3d780ae43c4c254/orjson-3.11.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7679bc2f01bb0d219758f1a5f87bb7c8a81c0a186824a393b366876b4948e14f", size = 133639, upload-time = "2026-03-31T16:16:13.434Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/ddbbfd6ba59453c8fc7fe1d0e5983895864e264c37481b2a791db635f046/orjson-3.11.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14f7b8fcb35ef403b42fa5ecfa4ed032332a91f3dc7368fbce4184d59e1eae0d", size = 141914, upload-time = "2026-03-31T16:16:14.955Z" }, + { url = "https://files.pythonhosted.org/packages/4e/31/dbfbefec9df060d34ef4962cd0afcb6fa7a9ec65884cb78f04a7859526c3/orjson-3.11.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c2bdf7b2facc80b5e34f48a2d557727d5c5c57a8a450de122ae81fa26a81c1bc", size = 423800, upload-time = "2026-03-31T16:16:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/87/cf/f74e9ae9803d4ab46b163494adba636c6d7ea955af5cc23b8aaa94cfd528/orjson-3.11.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ccd7ba1b0605813a0715171d39ec4c314cb97a9c85893c2c5c0c3a3729df38bf", size = 147837, upload-time = "2026-03-31T16:16:18.585Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/9214f017b5db85e84e68602792f742e5dc5249e963503d1b356bee611e01/orjson-3.11.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbc8c9c02463fef4d3c53a9ba3336d05496ec8e1f1c53326a1e4acc11f5c600", size = 136441, upload-time = "2026-03-31T16:16:20.151Z" }, + { url = "https://files.pythonhosted.org/packages/24/dd/3590348818f58f837a75fb969b04cdf187ae197e14d60b5e5a794a38b79d/orjson-3.11.8-cp314-cp314-win32.whl", hash = "sha256:0b57f67710a8cd459e4e54eb96d5f77f3624eba0c661ba19a525807e42eccade", size = 131983, upload-time = "2026-03-31T16:16:21.823Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/b6cb692116e05d058f31ceee819c70f097fa9167c82f67fabe7516289abc/orjson-3.11.8-cp314-cp314-win_amd64.whl", hash = "sha256:735e2262363dcbe05c35e3a8869898022af78f89dde9e256924dc02e99fe69ca", size = 127396, upload-time = "2026-03-31T16:16:23.685Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d1/facb5b5051fabb0ef9d26c6544d87ef19a939a9a001198655d0d891062dd/orjson-3.11.8-cp314-cp314-win_arm64.whl", hash = "sha256:6ccdea2c213cf9f3d9490cbd5d427693c870753df41e6cb375bd79bcbafc8817", size = 127330, upload-time = "2026-03-31T16:16:25.496Z" }, +] + +[[package]] +name = "packaging" +version = "26.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, +] + [[package]] name = "playwright" version = "1.58.0" @@ -951,6 +1140,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, +] + [[package]] name = "sniffio" version = "1.3.1"