diff --git a/README.md b/README.md index 444b5e4f..ca8c671c 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ # Coding Code -**手写 ReAct Loop · 零框架依赖 · 多端统一 · 深度可配置** +**手写 ReAct Loop · 零框架依赖** [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/) -终端原生的 AI 编程助手。核心引擎纯手写 ReAct 循环,通过 HTTP 服务化对外暴露,Desktop / SDK 等所有端共享同一份编排逻辑。没有黑盒,所有行为都可定制。 +面向终端的 AI 编程助手。核心引擎纯手写 ReAct 循环,不依赖任何 Agent 框架。没有黑盒,所有行为都可定制。 @@ -15,12 +15,14 @@ ## 核心特性 - 🔄 **手写 ReAct 循环** — 不依赖外部 Agent 框架,完全可控的 Agent 引擎 -- 🌐 **HTTP 服务化** — Agent 作为独立 HTTP 服务运行,任何端平等接入 -- 🔧 **深度可配置** — 子智能体、工具、提示词、钩子、模型全部可定制 -- 🛡️ **审批流水线** — 六层决策链 + 预设安全规则,开箱即用 +- 🤖 **多子智能体后台委派** — 父智能体通过 `spawn_agent` 派生子会话,立即返回、继续并行工作;`wait_agent` 只在结果阻塞下一步时等待 +- 📬 **会话级 Mailbox** — 子智能体终态异步回注父会话,在回合边界统一吸收,不打断当前推理 +- 🎛️ **完整回合状态机** — 状态轴(running / complete / interrupt / error)与终态原因分离,出生登记、相位转移、幂等终结、级联停止全部显式管理 +- 🛡️ **审批流水线** — 五层决策链,规则引擎 + 权限模式 + 钩子 + 用户确认 + 审计 - 🧠 **长期记忆** — 跨会话自动提取和加载用户/项目上下文 - 🔌 **MCP 集成** — 通过 Model Context Protocol 扩展工具能力 -- 📡 **实时流式** — SSE 推送,多端同步响应 +- 🪝 **可插拔钩子** — 12 个钩子点,在工具执行、回合生命周期、子智能体派发等关键节点注入自定义逻辑 +- 🎯 **技能系统** — 可复用的 Markdown 技能包,按需加载 - 💾 **Checkpoint** — Shadow Git 变更跟踪与一键回滚 --- @@ -30,7 +32,7 @@ ### 前置要求 - Node.js >= 18 -- 一个 LLM API Key(支持 DeepSeek、OpenAI、Gemini 等厂商) +- 一个 LLM API Key ### 安装与启动 @@ -47,7 +49,7 @@ export DEEPSEEK_API_KEY=sk-xxx pnpm start ``` -启动成功后,HTTP server 开始监听并打印 `CODINGCODE_SERVER_READY:`,可用 Desktop 或 SDK 接入。 +启动成功后,HTTP server 开始监听并打印 `CODINGCODE_SERVER_READY:`。 ### SDK 调用示例 @@ -71,53 +73,56 @@ for await (const frame of clients.agent.sendMessage('帮我写一个快排', { ``` ┌──────────────────────────────────────────────────────────┐ -│ 客户端层 │ +│ 客户端层 │ │ @codingcode/desktop (Electron) │ -│ @codingcode/sdk(AgentClient 契约 + HTTP/SSE 实现) │ +│ @codingcode/sdk(契约 + HTTP/SSE 实现) │ └──────────────────────────┬───────────────────────────────┘ - │ HTTP / SSE(AgentClient 接口) + │ HTTP / SSE ┌──────────────────────────┴───────────────────────────────┐ │ 核心引擎层 │ │ @codingcode/core │ │ ReAct Loop · 工具 · MCP · 上下文 · 记忆 · Checkpoint │ -│ 钩子 · 子智能体 · 技能 · 审批 · 会话 · 调度 │ +│ 钩子 · 子智能体 · 技能 · 审批 · 会话 · 回合 · 调度 │ │ 模型清单 · 应用配置 · YAML 存取 · 日志 · 共享类型 │ └──────────────────────────────────────────────────────────┘ ``` -**设计原则**:Agent 是纯 ReAct 循环,不持有 Session、不感知传输协议。所有端通过统一的 `AgentClient` 接口接入,共享同一份编排逻辑。Effect TS 托管依赖注入,编译期强制处理错误。 +**设计原则**:Agent 是纯 ReAct 循环,不持有 Session、不感知传输协议。核心作为独立 HTTP 服务运行,帧流经 SSE 推送。Effect TS 托管依赖注入,编译期强制处理错误。 --- ## 配置 -所有行为都可配置。核心配置文件: - -| 配置文件 | 作用 | 详见 | -|---------|------|------| -| `config/models.json` | 模型厂商、模型列表、API 地址 | [→ configuration.md](docs/configuration.md) | -| `codingcode.yaml` | 应用级配置(并发数、超时等) | [→ configuration.md](docs/configuration.md) | -| `~/.codingcode/rules.md` + `./AGENTS.md` | 全局 + 项目级规则,注入 system prompt | [→ configuration.md](docs/configuration.md) | -| `mcp.yaml` (可选) | MCP 服务配置 | [→ mcp.md](docs/mcp.md) | - -快速配置模型(`config/models.json`): - -```json -{ - "providers": [{ - "name": "deepseek", - "driver": "deepseek", - "base_url": "https://api.deepseek.com", - "api_key_env": "DEEPSEEK_API_KEY", - "default_model": "deepseek-v4-flash", - "models": [ - { "id": "deepseek-v4-flash", "name": "DeepSeek V4 Flash", "context_window": 1048576, "max_output_tokens": 384000 } - ] - }] -} -``` +用户可配置项: + +| 配置文件 | 位置 | 作用 | 详见 | +|---------|------|------|------| +| `config.yaml` | `~/.codingcode/config.yaml` | 应用级配置(步数、权限模式、记忆、子智能体并发等) | [→ configuration.md](docs/configuration.md) | +| `rules.md` | `~/.codingcode/rules.md` | 全局规则,注入 system prompt | [→ configuration.md](docs/configuration.md) | +| `AGENTS.md` | `./AGENTS.md` | 项目级规则,注入 system prompt | [→ configuration.md](docs/configuration.md) | +| `mcp.yaml` | `~/.codingcode/mcp.yaml` + `.codingcode/mcp.yaml` | MCP 服务配置 | [→ mcp.md](docs/mcp.md) | +| `hooks.yaml` | `~/.codingcode/hooks.yaml` + `.codingcode/hooks.yaml` | 钩子配置 | [→ hooks.md](docs/hooks.md) | +| `memory.md` | `./.codingcode/memory.md` | 长期记忆(项目级) | [→ memory.md](docs/memory.md) | + +应用级配置示例(`~/.codingcode/config.yaml`): -`driver` 支持 `"deepseek"`(原生 SDK)、`"openai"`(OpenAI 兼容 API)和 `"gemini"`(Google Gemini)。运行时可在客户端中切换模型。 +```yaml +maxSteps: 200 # Agent 最大步数 +maxStopContinuations: 2 # 最大停止续行次数 +activeProfile: build # agent 模式 +permissionMode: askBeforeExec # 权限模式 + +context: + compactionModel: "" # 压缩用模型,空字符串回退主模型 + +memory: + enabled: false # 启用长期记忆 + model: "" # 记忆提取模型,空字符串回退主模型 + promptMaxBytes: 8192 # 注入提示的记忆内容最大字节数 + +subagent: + maxBackground: 4 # 单父会话并发子智能体上限 +``` --- @@ -125,31 +130,18 @@ for await (const frame of clients.agent.sendMessage('帮我写一个快排', { | 功能 | 说明 | 文档 | |------|------|------| -| 🛠️ 工具系统 | 内置文件/命令/网络工具 + 自定义工具注册 + 审批流水线 | [→ tools.md](docs/tools.md) | +| 🛠️ 工具系统 | 内置文件/命令/网络工具 + 审批流水线 | [→ tools.md](docs/tools.md) | +| 🤖 子智能体 | 后台派生独立 ReAct 会话,结果经 mailbox 回注 | [→ subagent.md](docs/subagent.md) | +| 🎛️ 回合状态机 | 会话状态登记、相位转移与生命周期管理 | [→ turn.md](docs/turn.md) | | 🧠 长期记忆 | 跨会话自动提取用户偏好、项目上下文,支持手动编辑 | [→ memory.md](docs/memory.md) | | 🔌 MCP 集成 | 通过 Model Context Protocol 连接外部工具服务 | [→ mcp.md](docs/mcp.md) | | 💾 Checkpoint | Shadow Git 变更跟踪、Diff 视图、一键回滚 | [→ checkpoint.md](docs/checkpoint.md) | -| 🪝 钩子系统 | 18 个可插拔钩子点,在关键节点注入自定义逻辑 | [→ hooks.md](docs/hooks.md) | -| 🤖 子智能体 | 独立 ReAct 实例,受限工具集,可并行委派任务 | [→ subagent.md](docs/subagent.md) | +| 🪝 钩子系统 | 12 个可插拔钩子点,在关键节点注入自定义逻辑 | [→ hooks.md](docs/hooks.md) | | 📦 上下文压缩 | 超预算自动压缩,截断/总结两种策略 | [→ context.md](docs/context.md) | | 🎯 技能系统 | 可插拔的 Markdown 技能包,扩展 Agent 能力 | [→ skills.md](docs/skills.md) | --- -## 工作流 - -``` -用户输入 → System Prompt(角色 + 规则 + 记忆 + 技能)→ LLM 调用 → ReAct Loop - ├── Tool Call → Approval → Execution → Hook - ├── Subagent Delegation(可选,并行) - └── Context Compression(自动触发) -→ SSE 流式响应 → 会话持久化 → Checkpoint → Memory 提取 -``` - -每个环节都可配置,没有黑盒。 - ---- - ## 技术栈 | 关注点 | 选型 | diff --git a/docs/checkpoint.md b/docs/checkpoint.md index 98e4121e..c6222fde 100644 --- a/docs/checkpoint.md +++ b/docs/checkpoint.md @@ -1,6 +1,6 @@ # Checkpoint 系统 -Coding Code 记录所有文件变更,支持查看历史和回滚。本文档介绍 Shadow Git、Ledger、Diff 视图和回滚功能。 +Coding Code 记录所有文件变更,支持查看历史和回滚。本文档介绍 Shadow Git、Diff 视图和回滚功能。 --- @@ -8,24 +8,20 @@ Coding Code 记录所有文件变更,支持查看历史和回滚。本文档 Checkpoint 系统基于 Shadow Git 实现——一个独立于用户 `.git` 的变更日志系统: -- **存储位置**:`~/.codingcode/project/{encodedProjectPath}/checkpoint/repo.git` - **隔离机制**:使用 `--git-dir` 和 `--work-tree` 分离,不影响用户仓库 -- **大小上限**:1024 MB -- **文件锁**:`repo.lock` 防止并发写入 +- **存储位置**:项目的 checkpoint 数据目录下(`projectDataDir` 派生) +- **大小上限**:`SIZE_CAP_MB = 1024`(超过则不继续跟踪) +- **文件锁**:项目锁(`project-lock.ts`)防止并发写入 ### 忽略规则 -以下目录和文件不会被跟踪: +以下目录和文件不会被跟踪(写入 shadow repo 的 `exclude`): -- `node_modules/`、`.venv/`、`dist/`、`build/` -- `*.log`、`.env`、`.DS_Store` - -### 提交格式 - -Shadow Git 的提交消息格式为: - -- `turn-{shortSid}-{turnId}-baseline`:轮次开始前的快照 -- `turn-{shortSid}-{turnId}-final`:轮次结束后的快照 +``` +node_modules/ .venv/ venv/ dist/ build/ +*.log .env .env.* *.tmp *.temp +.DS_Store Thumbs.db +``` --- @@ -71,51 +67,49 @@ interface CheckpointDiff { | `/api/sessions/:id/rollback-context` | POST | `{ cwd, throughTurnId }` | 上下文回退到指定 turn | | `/api/sessions/:id/rollback-both-to-turn` | POST | `{ cwd, throughTurnId }` | 代码 + 上下文同时回滚 | -### 撤销回滚 - -| 路由 | 方法 | Body | 说明 | -|------|------|------|------| -| `/api/sessions/:id/undo-code-rollback` | POST | `{ cwd, force?, files? }` | 撤销上次代码回滚 | -| `/api/sessions/:id/rollback-state` | GET | - | 获取当前回退状态 | +回滚到指定轮次需要一次预览确认:`rollback-preview` 返回 `RollbackPreviewDiff`,其中列出受影响的 turn 与 diff。 --- -## Ledger 数据结构 - -每次回滚操作记录为 `CodeRestoreEntry`: +## 数据结构 ```typescript -interface CodeRestoreEntry { - id: string; // 唯一标识 - sessionId: string; // 会话 ID - action: 'checkpoint-files' | 'rollback-to-turn'; // 操作类型 - throughTurnId: number; // 回退到的轮次 - affectedTurns: number[]; // 受影响的轮次列表 - selectedFiles: string[]; // 受影响的文件列表 - safetyCommit: string; // 安全提交的 SHA - timestamp: string; // 操作时间 +interface CodeRollbackResult { + reverted: boolean; + throughTurnId: number; + affectedTurns: number[]; + selectedFiles: string[]; } -``` - -存储位置:`{gitDir}/../last-restore-{shortSid}.json` ---- +interface RollbackPreviewDiff { + throughTurnId: number; + affectedTurns: number[]; + diff: string; +} -## 回退状态查询 +interface RestorePlan { + throughTurnId: number; + affectedTurns: number[]; + baseline: string; +} +``` -通过 `GET /api/sessions/:id/rollback-state` 获取当前回退状态: +上下文回退在会话 transcript 中记录为 `RollbackEvent`: ```typescript -interface RollbackState { - context: { - active: boolean; - currentThroughTurnId: number | null; - }; - code: { - canUndoLast: boolean; - lastEntry: CodeRestoreEntry | null; - revertedFiles: string[]; - lastEntryId: string | null; - }; +interface RollbackEvent { + type: 'rollback'; + throughTurnId: number; + reason: string; } ``` + +--- + +## Session Fork + +从某个历史 turn 分叉出新会话: + +| 路由 | 方法 | Body | 说明 | +|------|------|------|------| +| `/api/sessions/:id/fork` | POST | `{ cwd, atTurnId? }` | 复制到指定 turn 为止的会话历史,生成新会话 | diff --git a/docs/configuration.md b/docs/configuration.md index 8a1d9f1c..0c69ddd8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ # 配置 -Coding Code 的核心哲学是所有行为都可配置。本文档详细介绍所有配置文件及其选项。 +Coding Code 的用户可配置项集中在 `~/.codingcode/` 下的 YAML / Markdown 文件。本文档介绍应用级配置、规则配置及其选项。 --- @@ -8,16 +8,16 @@ Coding Code 的核心哲学是所有行为都可配置。本文档详细介绍 | 配置文件 | 位置 | 作用 | 详见 | |---------|------|------|------| -| `codingcode.yaml` | `~/.codingcode/config.yaml` | 应用级配置 | 本文档 | -| `models.json` | `config/models.json` | 模型厂商、模型列表、API 地址 | 本文档 | -| `rules.md` | `~/.codingcode/rules.md` + `./AGENTS.md` | 全局 + 项目级规则 | 本文档 | +| `config.yaml` | `~/.codingcode/config.yaml` | 应用级配置 | 本文档 | +| `rules.md` | `~/.codingcode/rules.md` | 全局规则 | 本文档 | +| `AGENTS.md` | `./AGENTS.md` | 项目级规则 | 本文档 | | `mcp.yaml` | `~/.codingcode/mcp.yaml` + `.codingcode/mcp.yaml` | MCP 服务配置 | [→ mcp.md](mcp.md) | | `hooks.yaml` | `~/.codingcode/hooks.yaml` + `.codingcode/hooks.yaml` | 钩子配置 | [→ hooks.md](hooks.md) | | `memory.md` | `./.codingcode/memory.md` | 长期记忆(项目级) | [→ memory.md](memory.md) | --- -## codingcode.yaml +## config.yaml 应用级主配置文件,存放在 `~/.codingcode/config.yaml`。使用 `deepMerge` 合并默认值。 @@ -26,8 +26,10 @@ Coding Code 的核心哲学是所有行为都可配置。本文档详细介绍 ```yaml maxSteps: 200 # Agent 最大步数 maxStopContinuations: 2 # 最大停止续行次数 +activeProfile: build # 默认 profile +permissionMode: askBeforeExec # 权限模式 -# activeModel: # 可选,覆盖 models.json 中的默认模型 +# activeModel: # 可选,覆盖模型清单中的默认模型 # model: "" # 模型 ID # apiKeyEnv: "" # API Key 环境变量名 @@ -38,6 +40,9 @@ memory: enabled: false # 启用长期记忆 model: "" # 记忆提取模型,空字符串回退主模型 promptMaxBytes: 8192 # 注入提示的记忆内容最大字节数 + +subagent: + maxBackground: 4 # 单父会话并发子智能体上限 ``` ### 字段详细说明 @@ -46,75 +51,18 @@ memory: |------|--------|------| | `maxSteps` | `200` | 单次 Agent 执行的最大步数限制 | | `maxStopContinuations` | `2` | Agent 停止后最大续行次数 | -| `activeModel` | 无(可选) | 覆盖 models.json 中的默认模型,不设置则使用 models.json 配置 | +| `activeProfile` | `build` | 新会话默认 profile(`build` / `plan`) | +| `permissionMode` | `askBeforeExec` | 默认权限模式 | +| `activeModel` | 无(可选) | 覆盖模型清单中的默认模型,不设置则使用清单中的 `default_model` | | `context.compactionModel` | `''` | 上下文压缩使用的模型,空字符串回退到主会话 LLM | | `memory.enabled` | `false` | 是否启用长期记忆系统 | | `memory.model` | `''` | 记忆提取使用的模型,空字符串回退到主模型 | | `memory.promptMaxBytes` | `8192` | 注入 system prompt 的记忆内容最大字节数 | +| `subagent.maxBackground` | `4` | 单个父会话同时运行的子智能体上限 | -> **HTTP 端口不可配置**。服务启动时监听端口 `0`,由操作系统原子地分配一个空闲端口,避免多实例或“先探测再绑定”之间的竞态。实际端口通过 stdout 的 `CODINGCODE_SERVER_READY:` 上报给拉起方(Desktop、SDK 等)。 - ---- - -## models.json - -模型配置文件,存放在 `config/models.json`。定义可用的 LLM 厂商和模型。 - -### 完整格式 - -```json -{ - "providers": [ - { - "name": "deepseek", - "driver": "deepseek", - "base_url": "https://api.deepseek.com", - "api_key_env": "DEEPSEEK_API_KEY", - "default_model": "deepseek-v4-flash", - "models": [ - { - "id": "deepseek-v4-flash", - "name": "DeepSeek V4 Flash", - "context_window": 1048576, - "max_output_tokens": 384000, - "capabilities": { "vision": "supported", "reasoning": "supported" } - } - ] - }, - { - "name": "openai", - "driver": "openai", - "base_url": "https://api.openai.com/v1", - "api_key_env": "OPENAI_API_KEY", - "default_model": "gpt-4o", - "models": [ - { - "id": "gpt-4o", - "name": "GPT-4o", - "context_window": 128000, - "max_output_tokens": 16384, - "capabilities": { "vision": "supported", "reasoning": "supported" } - } - ] - } - ] -} -``` - -### 字段说明 - -| 字段 | 说明 | -|------|------| -| `providers[].name` | 厂商名称,用于切换和引用 | -| `providers[].driver` | 驱动类型:`"deepseek"` 使用原生 SDK,`"openai"` 使用 OpenAI 兼容 API,`"gemini"` 使用 Google Gemini | -| `providers[].base_url` | API 基础 URL | -| `providers[].api_key_env` | 从环境变量读取 API Key 的变量名 | -| `providers[].default_model` | 该厂商的默认模型 ID | -| `providers[].models[]` | 可用模型列表,每项包含 `id`、`name`、`context_window`、`max_output_tokens`、`capabilities` | - -### 运行时切换 +> **HTTP 端口不可配置**。服务启动时监听端口 `0`,由操作系统原子地分配一个空闲端口,避免多实例或"先探测再绑定"之间的竞态。实际端口通过 stdout 的 `CODINGCODE_SERVER_READY:` 上报给拉起方。 -- 通过 API `POST /api/models` 切换 +> **模型清单不是用户配置**。可用的厂商、模型与 API 地址由仓库内置的 `config/models.json` 提供,随代码发布,不通过 `~/.codingcode/` 配置。运行时可在客户端切换当前模型。 --- @@ -149,15 +97,3 @@ memory: - 测试文件放在 src/ 同级的 __tests__/ 目录 - 提交信息格式:type(scope): description ``` - ---- - -## 项目级子配置 - -在 `.codingcode/config.yaml` 中可配置项目级子选项: - -```yaml -# .codingcode/config.yaml -subagent: - enabled: true # 是否允许子智能体 -``` diff --git a/docs/context.md b/docs/context.md index 395d0bac..987eff95 100644 --- a/docs/context.md +++ b/docs/context.md @@ -27,11 +27,11 @@ Coding Code 采用两层压缩策略,在不同阈值下自动触发: | 配置项 | 值 | 说明 | |--------|-----|------| -| 触发阈值 | `promptEstimate > modelMaxTokens * 0.9` | prompt 估算超过模型最大 token 90% 时触发 | +| 触发阈值 | `promptEstimate > modelMaxTokens * 0.85` | prompt 估算超过模型最大 token 85% 时触发 | | 保留最近 turn | 1 | 保留最近 1 个 turn 不压缩 | | 压缩方式 | 调用 LLM 生成摘要 | 整段输出即摘要(全量替换,不做标签抽取) | | 增量压缩 | 是 | 找到已有 SummaryEvent,只压缩 `endTurnId` 之后的事件 | -| 失败追踪 | 连续 3 次失败后停止 | 24 小时 TTL 后重置 | +| 自动压缩轮次 | 最多 3 轮 | 单次自动压缩最多连续尝试 `MAX_AUTO_COMPACT_PASSES` 轮,直到不再需要压缩或无可释放为止 | --- @@ -109,15 +109,15 @@ context: ### 硬编码常量 -以下常量当前硬编码在 `context/service.ts` 中: +以下常量当前硬编码在 `context/context.ts` 中: | 常量 | 值 | 说明 | |------|-----|------| | `MICRO_COMPACT_THRESHOLD` | `0.25` | 微压缩触发比例 | | `MICRO_COMPACT_MIN_CHARS` | `120` | 微压缩最小字符数 | -| `COMPACTION_THRESHOLD` | `0.9` | LLM 压缩触发比例 | +| `COMPACTION_THRESHOLD` | `0.85` | LLM 压缩触发比例 | | `KEEP_RECENT_TURNS` | `1` | 保留最近 turn 数 | -| `REACTIVE_COMPACT_MAX_RETRIES` | `3` | 最大重试次数 | +| `MAX_AUTO_COMPACT_PASSES` | `3` | 单次自动压缩的最大轮次 | --- diff --git a/docs/hooks.md b/docs/hooks.md index 3a4f25c2..8f2fd6b8 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -205,7 +205,7 @@ process.stdin.on('end', () => { ### 让 Agent 继续跑 -`agent.turn.stop` 返回 `continue` 且带 `injection` 时,`injection` 会作为 system 消息写入会话并续行(受 `maxStopContinuations` 限制,默认 3): +`agent.turn.stop` 返回 `continue` 且带 `injection` 时,`injection` 会作为 system 消息写入会话并续行(受 `maxStopContinuations` 限制,默认 2): ```javascript process.stdout.write(JSON.stringify({ decision: 'continue', injection: '还没跑测试,继续。' })); diff --git a/docs/mcp.md b/docs/mcp.md index 54f57372..b9263378 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -39,6 +39,7 @@ servers: | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `name` | `string` | 必填 | 服务器名称,用于工具命名空间化和白名单引用 | +| `enabled` | `boolean` | `true` | 开关;`false` 表示禁用,缺省等同启用 | | `command` | `string` | - | stdio 传输:可执行命令 | | `args` | `string[]` | - | stdio 传输:命令参数 | | `env` | `Record` | - | stdio 传输:环境变量,支持 `${VAR}` 插值 | @@ -90,12 +91,13 @@ servers: ### 连接生命周期 -MCP 连接使用 lease 机制管理会话级生命周期: +MCP 连接按**项目**隔离,由 `syncConnections(projectPath)` 在每个回合开始时对齐: -- 每个会话通过 `addLease` 建立与 MCP 服务器的关联 -- 会话结束时通过 `removeLease` 释放关联 -- 当某个服务器没有任何活跃 lease 时,自动断开连接 -- `autoReconnect: true` 时,断线后自动重连 +- 每个项目持有自己的一份 `client` 映射(`projectPath → serverName → client`)。 +- `syncConnections` 对比配置与现有连接:新增的配置项建立连接,已移除的配置项断开并清理,连接的服务器按配置的 `enabled` 过滤(`enabled: false` 不连接)。 +- 服务器连接失败只记日志,不影响回合继续。 +- `autoReconnect: true` 时,断线后自动重连;`concurrency` 控制对该服务器的最大并发工具调用数(`TSemaphore`,默认 3)。 +- 工具是否可见还受该服务器当前是否被禁用约束(`isDisabledFn` 在调用时实时判断)。 --- diff --git a/docs/skills.md b/docs/skills.md index 2ec90cd5..2444bd47 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -35,9 +35,14 @@ Coding Code 支持可插拔的 Markdown 技能包,扩展 Agent 在特定场景 ## SKILL.md 格式 -SKILL.md 使用 YAML front matter 提供发现元数据。正文不会在发现阶段读取: +SKILL.md 使用 YAML front matter 提供发现元数据(`name`、`description`)。正文不会在发现阶段读取,只存元数据与 `SKILL.md` 的绝对路径: ```markdown +--- +name: code-review +description: Review a diff for security and error-handling issues +--- + # Code Review Skill You are now performing a code review. Follow these steps: @@ -53,6 +58,7 @@ You are now performing a code review. Follow these steps: - [ ] Error messages are helpful ``` +没有 front matter 时,`name` 回退为目录名、`description` 为空。 --- ## 技能类型 diff --git a/docs/subagent.md b/docs/subagent.md index a6874f51..008b618c 100644 --- a/docs/subagent.md +++ b/docs/subagent.md @@ -1,5 +1,153 @@ -# 子智能体系统 +# 子智能体 -当前运行时只保留 `build` 和 `plan` 两个内置 profile。子智能体不再从项目或全局配置文件加载,也不再支持用户通过 profile 配置工具、模型、MCP、hooks、步数或禁用状态。 +子智能体是独立运行的 ReAct 会话:父智能体通过 `spawn_agent` 派生一个子会话,子会话在自己的回合里跑完整套循环,完成后把结果回注到父会话。本文档介绍派发模型、帧流消费、结果回注(Mailbox)、并发控制与生命周期。 -`plan` 模式由独立的 plan 工具策略限制可见和可执行工具,并通过 `submit_plan` 提交计划。普通子智能体工具名单机制已移除;后续子智能体配置由运行时机制另行提供。 +--- + +## 设计取向 + +参数由**父智能体在调用 `spawn_agent` 时决定**: + +| 参数 | 说明 | +|------|------| +| `agentName` | 子智能体短名,用于标识与展示 | +| `prompt` | 任务描述 | +| `model` | 可选。仅在用户明确要求某个模型时设置,省略则继承父回合模型 | +| `systemPrompt` | 可选。替换子智能体 system prompt 的中间段,环境块与系统说明保留 | + +工具可见性由 profile 策略决定:子会话沿用父回合的 `activeProfile`,因此 plan profile 的子会话同样受 `PLAN_ALLOWED_TOOLS` 限制;子代理调用点固定 `bypass` 权限模式,不继承父会话的审批链路。 + +--- + +## 委派模型 + +``` +父回合 step N + └─ spawn_agent(agentName, prompt, [model], [systemPrompt]) + ├─ hook: agent.subagent.spawn.before (decision,可否决) + ├─ 并发闸检查:runningChildren(parent) < subagent.maxBackground + ├─ SubagentRunner.runSubagent(...) → 创建子会话,agent.runTurn 启动子回合 + ├─ hook: agent.subagent.spawn.after + ├─ emit subagent_event(spawned) → 父会话出站队列 + └─ forkDaemon(drainRun) 后台消费子帧流,立即返回 { sessionId, agentName } +``` + +要点: + +- **立即返回**:`spawn_agent` 返回子会话 id 和名字,父智能体可以继续自己的工作,不必等待。 +- **后台消费**:`SubagentRunRegistry.spawn` 用 `Effect.forkDaemon` 起一个后台 fiber 消费子会话的帧流;父回合本身不阻塞。 +- **hook 可否决**:`agent.subagent.spawn.before` 是 decision 点,返回 `deny` 会让 spawn 失败并附 `reason`(工具层抛 `TOOL_NOT_ALLOWED`)。 + +--- + +## 帧流消费 + +子会话对父会话而言就是一条帧流。后台 fiber 的 `consume` 只做两件事: + +1. 累加 `text_delta` 帧的文本,得到子智能体的最终输出; +2. 遇到 `isTurnEnd`(`transition.to === 'end'`)时取终态。 + +终态归一到父会话可消费的形状: + +| 子会话终态 | 处理 | +|-----------|------| +| `done` | 采用累加的文本;空输出记为 `(subagent completed without output)` | +| `error` | 取 `end.error.message` | +| `maxSteps` | 归一成 error 帧,message 为固定文案 | +| `aborted` | 归一成 error 帧,message 为固定文案 | +| 流结束但无终态帧 | 归一成 error 帧 | + +非 `done` 的终态统一经由 `failedEnd()` 变成一条 `reason: 'error'` 的 end 帧,保证父会话始终拿到形状一致的终态。 + +--- + +## 结果回注与 Mailbox + +子智能体结果**不通过回调透传**,而是走**会话级 Mailbox**(`session/mailbox.ts`): + +- Mailbox 是一个全局单例的易失入站队列,按收件人 `sessionId` 分区;子代理终态进的是**父会话**的队列(嵌套委派下 B 的终态进 `mailbox[A]`)。 +- `SubagentRunRegistry.drainRun` 消费完子帧流后,把结果渲染成一条 `subagent_result` 条目 `offer` 给父会话。 +- 条目内容 `renderResult()` 组装为: + +``` +Message Type: FINAL_ANSWER +Task name: {父会话 id} +Sender: {子会话 id} +Payload: +{子智能体输出} +``` + +`Payload` 部分按 token 预算(900 tokens)二分收敛截断,超出时追加 `…[truncated]`。 + +### 渲染与终态来源 + +- **终态由子会话自己写**:子回合的 `finish` 通过 `turn.transition` 落终态;父侧只负责派发与投递。 +- **投递信号**:`drainRun` 回注 mailbox 后调用 `turn.markDelivered(childSessionId)`,用于唤醒 `wait_agent`。 + +--- + +## 父回合如何吸收结果 + +父回合在**每个 step 的边界**统一 drain,避免打断流式推理: + +``` +每个 step: + 1. absorbPendingInputs() // steer 输入先吸收 + 2. if step > 0: mailbox.drain() // 子代理结果后吸收 + 3. 调 LLM、执行工具 +``` + +顺序上 steer(用户中途输入)优先于子代理汇报,避免"用户指令"排在"子代理汇报"之后。每条 mailbox 条目都经 `session.recordSubagentResult` 落盘为 `subagent_result` 事件,再 `context.absorb` 进入上下文。首轮(`step === 0`)不 drain mailbox——此时子代理刚派发,尚无结果。 + +--- + +## wait_agent + +`wait_agent` 等待子智能体到达终态: + +| 参数 | 说明 | +|------|------| +| `sessionId` | `spawn_agent` 返回的子会话 id | +| `timeoutMs` | 可选。夹在 `[10000, 3600000]`,默认 `30000` | + +返回 `completed` / `failed` / `timeout`: + +- `completed`:子会话 `state === 'complete'` +- `failed`:其余终态(error / maxSteps / aborted) +- `timeout`:超时 + +语义要点: + +- **不要在不需要结果时等待**:子智能体最终输出会自动追加到父会话,`wait_agent` 不用于取文本。 +- **只在结果阻塞下一步时等**:且给一个宽松的超时,不要用短超时轮询。 +- **等待到"已投递"为止**:委派记录的 wait 会先等终态(`ended`),再等结果进入父会话 mailbox(`delivered`),所以返回 `completed` 时结果已可被父回合 drain 到。 + +--- + +## 并发与停止 + +| 机制 | 说明 | +|------|------| +| 并发闸 | `spawn` 前检查 `turn.runningChildren(parentSessionId) >= subagent.maxBackground`(默认 4),超限报 `Concurrent subagent limit reached` | +| 停止句柄 | 子回合启动后经 `turn.arm(childSessionId, stop)` 登记中断句柄(runTurn 内部 `Fiber.interrupt`) | +| 级联停止 | `stopChildren(parentSessionId)` 停掉该父会话下所有仍在运行的派生子会话;HTTP 路由 `POST /api/sessions/:id/subagents/stop` | +| 终结清理 | `SubagentRunRegistry` 的 layer finalizer 对本层派发过的父会话请求 `stopChildren` | + +--- + +## 钩子点 + +| 钩子点 | 触发时机 | 类型 | +|--------|---------|------| +| `agent.subagent.spawn.before` | 子智能体创建前 | decision(可 deny) | +| `agent.subagent.spawn.after` | 子智能体创建后 | observer | +| `agent.subagent.complete` | 子智能体终态投递后 | observer | + +--- + +## 状态与 Mailbox 的关系 + +完整的状态轴、原因轴与生命周期见 [→ turn.md](turn.md)。子智能体相关的状态要点: + +- 派生关系(`parentSessionId`、`agentName`)随 `TurnClaim` 登记进回合记录,`runningChildren` / `stopChildren` 按它过滤。 +- 子会话终态条目在父会话 settle 时被回收(父会话结束时删除所有非 running 的派生子条目)。 diff --git a/docs/tools.md b/docs/tools.md index 0939f24c..ce70c034 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -1,6 +1,6 @@ # 工具系统 -Coding Code 的工具系统是 Agent 与外部世界交互的核心机制。本文档介绍内置工具、加载机制、自定义工具开发和沙箱隔离。 +Coding Code 的工具系统是 Agent 与外部世界交互的核心机制。本文档介绍内置工具、加载机制、自定义工具开发和审批流水线。 --- @@ -51,7 +51,7 @@ Coding Code 的工具系统是 Agent 与外部世界交互的核心机制。本 - **Core 工具**:始终可用,在启动时注册。包括上述所有内置工具。 - **MCP 工具**:从 MCP 服务自动导入和注册。名称空间化为 `serverName:toolName` 格式,避免不同服务间的工具名冲突。 -Agent 在一次运行开始时注册内置工具、项目 MCP 工具和 `spawn_agent` / `wait_agent`。plan 模式通过独立的 `PLAN_PROFILE_ALLOWED_TOOLS` 策略过滤工具。 +Agent 在一次运行开始时按 profile 组装工具目录:`approval/tool-policy.ts` 的 `getToolNames(profile)` 返回该 profile 的工具名单,`build` 为 `BUILD_TOOL_NAMES`,`plan` 为 `PLAN_TOOL_NAMES`(`read_file` / `search_files` / `search_code` / `fetch_url` / `submit_plan`)。随后注册项目 MCP 工具。 --- @@ -81,48 +81,54 @@ interface ToolExecCtx { ### 工具可见性策略 -通过 `ToolVisibilityPolicy` 控制工具的可见性: +工具可见性由 profile 工具名单控制。`getToolNames(profile)` 给出名单,`ToolRegistry.describe(allowedTools?)` / `get(name, allowedTools?)` 按该名单过滤。名单定义在 `approval/tool-policy.ts`: ```typescript -interface ToolVisibilityPolicy { - allowedTools?: Set; // 允许的工具白名单 - allowedMcpServers?: Set; // 允许的 MCP 服务白名单 -} +export const PLAN_TOOL_NAMES: readonly string[] = [ + 'read_file', 'search_files', 'search_code', 'fetch_url', 'submit_plan', +]; + +export const BUILD_TOOL_NAMES: readonly string[] = [ + 'read_file', 'write_file', 'edit_file', 'execute_command', + 'search_code', 'search_files', 'fetch_url', 'web_search', + 'todo_write', 'spawn_agent', 'wait_agent', +]; ``` --- -## 沙箱隔离 +## 审批流水线 -所有工具执行经过两层安全保护: +所有工具执行经过五层审批保护。 -### 审批流水线(始终生效) +### 决策链(始终生效) 五层决策链,按顺序执行,任一层返回 deny/allow 即终止: | 层级 | 名称 | 逻辑 | |------|------|------| -| 1 | **RuleEngine** | 规则引擎匹配,支持 glob 模式匹配工具名和参数,按优先级排序 | -| 2 | **PermissionMode** | 权限模式驱动的自动放行:`bypass`(展示名「完全放行」)、`askBeforeExec`(展示名「执行前询问」:非破坏性工具放行,仅 `execute_command` 这类破坏性工具继续下一层)。只读工具不再有无条件的独立白名单层;`plan` Profile 由 `agent/profile.ts` 中的 `planProfileGateHook` 在下一层强制,不在此层处理 | +| 1 | **RuleEngine** | 规则引擎匹配,支持 glob / regex 匹配工具名和参数,按优先级降序取首个命中 | +| 2 | **PermissionMode** | 权限模式驱动的自动放行:`bypass`(展示名「完全放行」)全部放行;`askBeforeExec`(展示名「执行前询问」)非破坏性工具放行,仅 `execute_command` 这类破坏性工具继续下一层。plan profile 在此层强制:`PLAN_ALLOWED_TOOLS` 内的工具放行,其余直接 deny(提示用 `submit_plan`) | | 3 | **HookPreToolUse** | 钩子决策,可返回 allow/deny/ask/continue,支持 `modifiedInput` 修改参数 | | 4 | **UserConfirmation** | 异步用户确认,支持 allow/deny/always/never 四种响应,always/never 会持久化为规则 | | 5 | **AuditLog** | 每一层决策后记录审计日志,通过 `tool.approval.post` 钩子发出 | -### 预设安全规则 +### 权限规则 -系统内置 9 条默认规则(不可删除): +**当前没有内置默认规则**:`createRuleEngine()` 以空集合启动,规则只来自两条途径: -| 规则 | 动作 | 说明 | -|------|------|------| -| `rm -rf /` | deny | 禁止递归删除根目录 | -| `sudo` | deny | 禁止提权执行 | -| `curl \| sh` | deny | 禁止管道执行远程脚本 | -| `chmod u+s` | deny | 禁止设置 SUID 位 | -| `shutdown` | deny | 禁止系统关机 | -| `/etc/shadow` | deny | 禁止读取影子密码文件 | -| `/etc/passwd` | deny | 禁止读取系统密码文件 | -| `.ssh` | ask | 访问 SSH 目录需确认 | -| `.env` | ask | 访问环境变量文件需确认 | +- 用户在确认弹窗里选择 `always` / `never` 时,由 `approval.ts` 的 `onAlways` / `onNever` 注册为持久规则(`addRule`)。 +- 钩子(`tool.approval.pre`)返回 `ask` 无法直接落成规则,需经上面的用户确认流程。 + +规则支持三种动作与两种匹配: + +| 字段 | 说明 | +|------|------| +| `action` | `deny` / `allow` / `ask` | +| `toolPattern` | 工具名的 glob 模式 | +| `argPattern` | 参数(字符串值拼接后)的 glob 模式 | +| `argRegex` | 参数的 regex 模式(与 `argPattern` 二选一) | +| `priority` | 数值大的先匹配 | ### 权限模式 @@ -132,7 +138,3 @@ type PermissionMode = 'askBeforeExec' | 'bypass'; - `askBeforeExec`(展示名「执行前询问」):非破坏性工具自动放行(涵盖只读工具与编辑类工具),破坏性工具(`execute_command`)仍需确认 - `bypass`(展示名「完全放行」):全部放行,跳过所有审批(慎用) - -### OS 级沙箱(预留) - -`packages/codingcode/src/sandbox/` 目前是 stub 实现(`SandboxService` 为空类),尚未集成实际的沙箱运行时。审批流水线已提供基本安全保障,OS 级沙箱将在未来版本中实现。 diff --git a/docs/turn.md b/docs/turn.md new file mode 100644 index 00000000..d7542357 --- /dev/null +++ b/docs/turn.md @@ -0,0 +1,168 @@ +# 回合状态机 + +回合(turn)是"一个会话正在进行的一次执行",由独立的状态登记表统一管理。本文档介绍状态轴与原因轴、转移集、生命周期 API,以及相位帧的投递方式。 + +--- + +## 设计取向 + +状态登记表不属于 Agent 循环本身,它要回答四类问题: + +- **判活**:HTTP 提交同一会话的并发请求时,需要一个权威地方回答"这个会话现在是否正在跑"。 +- **可停**:用户点停止、父会话结束、会话删除时,要能一次性停掉还在跑的回合与它派生的子会话。 +- **可等**:`wait_agent` 要知道子会话何时到达终态。 +- **可观测**:客户端要知道回合走到哪个相位(开始 / 执行中 / 压缩 / 结束)。 + +因此它落在 `turn/registry.ts`,Agent 只依赖 `turn/port.ts` 的契约。 + +--- + +## 状态轴与原因轴 + +状态被拆成两个正交的轴: + +```typescript +// 状态轴:判活只看这一轴 +type TurnState = 'running' | 'complete' | 'interrupt' | 'error'; + +// 原因轴:终态上的属性,不参与判活 +type EndReason = + | { kind: 'done' } + | { kind: 'maxSteps' } + | { kind: 'aborted' } + | { kind: 'error'; error: FrameError }; +``` + +- **状态轴**回答"还在跑吗":只有 `running` 算活跃,终态记录可被新回合覆盖。 +- **原因轴**只挂在终态上,回答"为什么结束",不参与判活。 + +这样拆的好处是:并发判定、`wait` 投影、派生条目回收都只看状态轴;"具体因什么结束"对它们无意义,只在展示与诊断时读取。 + +--- + +## 转移集 + +```typescript +type TurnCommand = + | { kind: 'start' } + | { kind: 'running'; responded?: ResponseMeta } + | { kind: 'compressing' } + | { kind: 'complete'; reason: 'done' | 'maxSteps' } + | { kind: 'interrupt' } + | { kind: 'fail'; error: FrameError }; +``` + +所有状态转移都经过唯一的 `transition(sessionId, cmd)`,一个 `switch` 处理全部出口(新增 kind 会在 `default` 处编译报错)。命令到帧与状态的映射: + +| `TurnCommand` | 状态轴结果 | 投递的帧 | +|---------------|-----------|---------| +| `start` | 保持 `running` | `transition: start`(带 `turnId`) | +| `running` | 保持 `running` | `transition: executing`(可带 `responded`) | +| `compressing` | 保持 `running` | `transition: compress` | +| `complete` | → `complete` | `transition: end`(reason `done` / `maxSteps`) | +| `interrupt` | → `interrupt` | `transition: end`(reason `aborted`) | +| `fail` | → `error` | `transition: end`(reason `error`,带 `error`) | + +`complete` / `interrupt` / `fail` 都走 `settle()`:写状态、写原因、投 end 帧、唤醒 `wait`、回收本回合派生的终态条目。`settle` 是**幂等**的——只有当前记录处于 `running` 时才生效,所以重复终结不会重复投帧。 + +--- + +## 生命周期 + +### 出生登记 claim + +```typescript +claim(sessionId, { turnId, parentSessionId?, agentName? }): boolean +``` + +- 已有 `running` 记录 → 返回 `false`,调用方抛 `TURN_CONFLICT`(HTTP 侧表现为并发冲突)。 +- 成功则建记录,初始化两个 `Deferred`:`ended`(终态信号)与 `delivered`(投递完成信号)。 +- 登记发生在**任何落盘之前**(不写 transcript、不建 checkpoint),避免并发 loser 产生副作用。 +- `parentSessionId` / `agentName` 记录派生关系,供并发闸与级联停止按它过滤。 + +### 相位转移 transition + +见上表。`start` 须在 `sink.attach` 之后调用——出生帧必须排在出站队列已挂载之后,否则帧会丢。 + +### steer 输入槽 submit / drain + +```typescript +submit(sessionId, { id, parts }): { kind: 'attached', turnId } | { kind: 'no-active-turn' } +drain(sessionId): PendingUserInput[] +``` + +- `submit` 判定一条新输入应并入当前回合还是开新回合:命中 `running` 则入槽并返回 `attached`,否则返回 `no-active-turn`。 +- 回合在每个 step 边界 `drain` 取走全部待投递输入(steer),吸收进上下文并在帧流上回显 `user_input`。 +- `id` 由前端在入队时生成,drain 吸收时回显在 `user_input` 帧上,便于前端对齐。 + +### 等待 wait / markDelivered + +```typescript +wait(sessionId, timeoutMs): 'completed' | 'failed' | 'timeout' | undefined +markDelivered(sessionId): void +``` + +- 未知 id → `undefined`;超时 → `timeout`。 +- 委派记录(有 `parentSessionId`)会先 `await ended`,再 `await delivered`——即等到结果确实进了父会话 mailbox 才返回。`markDelivered` 由子代理投递方调用,完成 `delivered` 这个 `Deferred`。 +- `wait` 的对外投影是**有损的**:`complete` → `completed`,其余终态 → `failed`。 + +### 停止 arm / runningChildren / stopChildren + +```typescript +arm(sessionId, stop): void // 登记中断句柄(runFork 之后紧邻调用) +runningChildren(parentSessionId): number // 该父会话下仍在跑的派生会话数 +stopChildren(parentSessionId): number // 停掉该父会话下所有派生子会话 +``` + +- `arm` 登记的是 `runTurn` 内部的 `Fiber.interrupt` 回调。 +- `stopChildren` 只停 `running` 且已 `arm`、未 `stopping` 的记录,避免重复计数。 +- 并发闸用 `runningChildren` 实现(见 [→ subagent.md](subagent.md) 的 `maxBackground`)。 + +### 清理 dispose + +```typescript +dispose(sessionId): void +``` + +会话删除时清空该会话记录,并连带删除以它为父会话的派生条目。 + +--- + +## 相位帧与 Sink + +状态机的所有帧都通过 `EventSink` 投递(`turn/registry.ts` 的 `frame()` 是相位帧的唯一投递口): + +```typescript +interface EventSinkShape { + attach(sessionId): Queue; // 建队列并挂载;调用者即唯一读者 + detach(sessionId): void; // 摘挂载;此后 emit 静默丢弃 + emit(sessionId, body): void; // 任何模块投帧,插在同一队尾 +} +``` + +`FrameBody` 分三族: + +```typescript +type FrameBody = + | { family: 'transition'; transition: Transition } + | { family: 'event'; event: RuntimeEvent } + | { family: 'fatal'; fatal: Fatal }; +``` + +- **全序保证**:任何模块(回合、审批、子代理、状态机)都往同一会话队列 `emit`,插在同一队尾,与回合自身的帧严格全序。 +- **队列即帧流**:`sink.attach` 返回的队列被搬帧器(`Stream.fromQueue` → `AsyncIterable`)消费,直到 `isTurnEnd` 才结束,结束时 `detach`。 +- **SSE 出口**:这条帧流最终经 HTTP/SSE 推送给客户端(见 SDK)。 + +`Transition` 的帧定义在 `sink/types.ts`,与上文转移集一一对应;`isTurnEnd` 用于判定 end 帧,也是子代理消费帧流的终止条件。 + +--- + +## 与其他模块的关系 + +| 模块 | 关系 | +|------|------| +| `agent/agent.ts` | `runTurn` 是唯一的回合驱动方:claim → start → 循环(transition running / compressing / complete / fail / interrupt) | +| `sink/` | 状态机的相位帧出口,见上 | +| `subagent/registry.ts` | 用 `runningChildren` 做并发闸,用 `markDelivered` 唤醒 `wait`,用 `stopChildren` 级联停止 | +| `session/mailbox.ts` | 子代理结果入站队列,与回合的 drain 节奏配合 | +| `server/` | 并发提交经 `submit` 判定;`/api/sessions/:id/subagents/stop` 走 `stopChildren` | diff --git a/packages/desktop/src/agent/AgentWorkspace.tsx b/packages/desktop/src/agent/AgentWorkspace.tsx index dcc9eab7..faec6e40 100644 --- a/packages/desktop/src/agent/AgentWorkspace.tsx +++ b/packages/desktop/src/agent/AgentWorkspace.tsx @@ -291,9 +291,6 @@ function InputBox({ const queueInput = useAgentStore((s) => s.queueInput); const removeQueuedInput = useAgentStore((s) => s.removeQueuedInput); const reorderQueuedInputs = useAgentStore((s) => s.reorderQueuedInputs); - // useShallow:selector 在无队列 / 无会话时会返回新的 `[]` 字面量, - // 默认的 Object.is 比较会让每次 render 都判定「值变了」→ 自激更新循环。 - // 浅比较按元素引用判等,彻底消除「新数组 identity」问题。 const queuedInputs = useAgentStore( useShallow((s) => s.currentThreadId ? (s.queuedInputsByThreadId[s.currentThreadId] ?? []) : [] diff --git a/packages/desktop/test/selector-stable-reference.test.ts b/packages/desktop/test/selector-stable-reference.test.ts index 9dd6adac..5f40e2c2 100644 --- a/packages/desktop/test/selector-stable-reference.test.ts +++ b/packages/desktop/test/selector-stable-reference.test.ts @@ -1,17 +1,5 @@ /** * @vitest-environment jsdom - * - * 回归:React「Maximum update depth exceeded」自激更新循环。 - * - * 根因:zustand selector 里出现 `?? []` / `: []` 这类字面量 fallback 时, - * 每次 render 都会返回一个【新数组引用】。zustand 默认用 Object.is 比较 - * selector 的返回值,于是每次 render 都判定「值变了」→ 触发订阅更新 → - * 又 render → 又新引用 …… 形成无限循环。 - * - * 修法:用 `useShallow` 包住 selector,按【元素引用】浅比较。 - * 本文件从两个层面锁住: - * 1. 源码层面 —— 相关 selector 确实被 useShallow 包裹(防回退); - * 2. 语义层面 —— 空数组 fallback 在浅比较下判为「相等」,不再触发更新。 */ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'fs';