Files
mnote/design/07-ai/process/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md
T

866 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 7-15 [process] 页面 AI ACP Agent Runtime 统一抽象层 v1
> 创建时间:2026-05-17
>
> 当前状态:`PROCESS`
>
> 本稿目的:
> 1. 在 mnote-web 中引入 ACPAgent Client Protocol)作为统一 agent runtime 抽象层
> 2. 使 Hermes(当前)与 Reasonix(缓存优先)可互换,前端下拉切换
> 3. 褪去当前 `hermes_client.rs` 中的 Hermes-HTTPS-proxy 硬编码,改为 ACP JSON-RPC 通用连接器
> 4. 复用现有参考代码,最小化重复实现工作
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-vscode-main/`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/DeepSeek-Reasonix-main/`
---
## 1. 问题描述
### 1.1 当前架构
```
浏览器 (页面 AI 面板)
│ SSE
mnote-web (Rust Axum)
├─ hermes_client.rs (3845 行)
│ └─ HTTP proxy → Hermes HTTPS gateway API
│ post /api/hermes/runs
│ get /api/hermes/runs/{id}/events
├─ hermes_tools.rs (mnote.doc.* / mnote.block.*)
│ └─ Rust 工具实现,通过 HTTP/Convex 读写文档
└─ page_ai_workflow.rs (fast-path 块编辑)
└─ local_rule planner, 不经过 Hermes
```
**问题:**
1. `hermes_client.rs` 与 Hermes HTTPS API 的字段、鉴权、错误码硬耦合——换 agent runtime 需重写整个模块
2. Hermes HTTP 协议没有标准化,Reasonix、Claude Code、Cline 各自用不同的 HTTP 接口
3. 前端 SSE 事件格式(`tool.started` / `message.delta` / `run.completed`)也是 mnote 私有定制的
4. 没有「运行时选择器」——切换 agent 需要改环境变量重启 mnote-web
5. `hermes_client.rs` 中大量代码(3845 行)是做 Hermes 专属的 HTTP proxy、session 管理、profile 路由——这些应该在统一抽象层中解决
### 1.2 目标架构
```
浏览器 (页面 AI 面板)
│ SSE (前端不变)
mnote-web (Rust Axum)
├─ ACP Session Manager (统一层,新增 ~800 行)
│ ├─ 运行时选择器 (profile → agent runtime 映射)
│ │ ├─ Hermes: spawn("hermes", ["acp"])
│ │ └─ Reasonix: spawn("node", ["reasonix-acp.mjs"])
│ ├─ ACP JSON-RPC 2.0 client (通用实现)
│ │ ├─ session/new
│ │ ├─ session/prompt
│ │ ├─ session/cancel
│ │ └─ session/update ≫ SSE 转发
│ └─ 代理层:向下游工具通知
├─ hermes_tools.rs (不变)
│ └─ mnote.doc.* / mnote.block.* / mnote.page.*
└─ page_ai_workflow.rs (不变)
└─ fast-path 块编辑
```
ACP 是整个架构的支点——它是一个**开放协议**,不是某个产品的私有接口。
---
## 2. ACP 协议标准
ACPAgent Client Protocol)是一个基于 JSON-RPC 2.0 的、面向 AI agent runtime 的标准通信协议。同时被 Reasonix (`src/acp/`) 和 Hermes (`hermes acp` CLI) 实现。
### 2.1 传输层
NDJSON over stdio(默认),也可用 TCP/Unix socket。每行一个完整的 JSON 对象。
### 2.2 协议方法
| 方向 | 方法 | 用途 |
|------|------|------|
| Client → Server | `session/new` | 创建一个会话线程 |
| Client → Server | `session/prompt` | 发送用户输入,等待 agent 完成 |
| Client → Server (notification) | `session/cancel` | 中断正在运行的 prompt |
| Server → Client (notification) | `session/update` | 推送实时状态变更 |
| Server → Client (request) | `session/request_permission` | 请求用户审批工具调用 |
### 2.3 `session/update` 事件类型
| `sessionUpdate` 值 | 含义 | 字段 |
|---|---|---|
| `agent_message_chunk` | 模型生成文本增量 | `content: { type: "text", text: "…" }` |
| `agent_thought_chunk` | 模型推理/思考过程 | `content: { type: "text", text: "…" }` |
| `tool_call` | 工具调用开始 | `toolCallId`, `title`, `kind: "read"|"edit"|"search"|"execute"|"other"`, `status: "pending"` |
| `tool_call_update` | 工具状态变更 | `toolCallId`, `status: "in_progress"|"completed"|"failed"`, `content` |
| `usage_update` | 上下文用量更新 | `used: number`, `size: number` |
| `session_info_update` | 会话元信息 | `title: string` |
### 2.4 对比 mnote 当前 SSE 格式
| mnote 当前事件 | ACP 对应事件 | 备注 |
|---|---|---|
| `message.delta` | `agent_message_chunk` | 几乎 1:1 |
| `tool.started` | `tool_call` + `kind` | 前者多了 `preview` 字段 |
| `tool.completed` | `tool_call_update` + `status: "completed"` | 前者多了 `duration` |
| `run.completed` | `session/update` 不再发事件,prompt 返回 | 语义等价 |
| `run.failed` | `tool_call_update` + `status: "failed"` | 语义等价 |
| 无 | `agent_thought_chunk` | 当前页面 AI 未显示思考过程,新增能力 |
| 无 | `usage_update` | 可展示 token 用量 |
**结论:** 前端桥接层只需做一个事件名映射 + 字段适配(~50 行),即可对接 ACP。
---
## 3. 参考代码分析 — 可复用部分
### 3.1 Hermes VSCode 扩展 (`hermes-vscode-main/`)
这是**最完整的 ACP 客户端参考实现**,可以直接指导 mnote-web 的 Rust ACP 客户端设计。
| 文件 | 内容 | 可复用方式 |
|---|---|---|
| `src/acpClient.ts` | ACP JSON-RPC 2.0 客户端:spawn 子进程、读写 NDJSON、处理分帧、请求/响应/通知路由 [acpClient.ts:30-220] | **逻辑移植到 Rust**`tokio::process::Command` spawn + `BufReader` 按行读取 + 请求 ID 映射表 |
| `src/sessionManager.ts` | 会话生命周期管理:session/new → session/prompt → session/cancel,去重,事件派发 [sessionManager.ts:37-294] | **逻辑移植到 Rust**`HashMap<String, Session>` 管理活跃会话 |
| `src/protocol.ts` | ACP 事件的类型解析:文本提取、去重、tool call 解析、usage 解析 [protocol.ts:1-120] | **直接指导 Rust struct 设计** |
| `src/chatPanel.ts` | VSCode WebviewView 桥接:ACP 事件 → webview HTML 渲染 [chatPanel.ts:1-524] | **UI 架构参考** — mnote 页面 AI 面板已存在,只需适配事件格式 |
| `src/webview/main.ts` | webview 端事件处理:消息渲染、工具展示、todo 面板 [webview/main.ts:1-531] | **UI 交互参考** — tool call 显示方式、todo overlay |
| `src/webview/renderers.ts` | 工具调用格式化、历史加载 [renderers.ts:1-170] | **前端渲染参考** |
**核心设计复用:**
```typescript
// acpClient.ts 的精髓:请求-响应匹配
class AcpClient {
private pending = new Map<number, PendingRequest>();
private nextId = 1;
async sendRequest(method: string, params: unknown): Promise<unknown> {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.output.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
});
}
}
```
### 3.2 Reasonix ACP server (`DeepSeek-Reasonix-main/`)
| 文件 | 内容 | 可复用方式 |
|---|---|---|
| `src/acp/server.ts` | ACP JSON-RPC 2.0 server 类 [acp/server.ts:1-150] | **理解对端协议** — Reasonix 作为 server 时的行为 |
| `src/acp/protocol.ts` | ACP 类型定义 + 工具函数 [acp/protocol.ts:1-80] | **协议规格参考** |
| `src/acp/dispatch.ts` | 内核事件 → ACP `session/update` 映射 [acp/dispatch.ts:55-112] | **事件映射参考** — Reasonix 的 `kernel event → ACP` 与 mnote 的 `ACP → SSE` 是互逆过程 |
| `src/acp/gates.ts` | 权限审批逻辑 [gates.ts:1-6353] | **可选参考** — 页面 AI 的 tool call 审批 |
| `src/cli/commands/acp.ts` | `reasonix acp` CLI 命令,将 `CacheFirstLoop` + toolset 包装为 ACP server [acp.ts:1-339] | **Reasonix 侧入口** — 启动 Reasonix ACP 的参考实现 |
| `desktop/src/protocol.ts` | 桌面客户端的 UI 事件协议 [desktop/protocol.ts:1-432] | **前端事件架构参考**`ModelDeltaEvent``ToolPreparingEvent``ToolResultEvent` 等 25+ 事件类型的设计 |
### 3.3 复用策略
**不要「移植代码」——要「移植逻辑和接口形状」**
Rust 端参考 `acpClient.ts` 的架构,但用 tokio async 重写。核心接口设计:
```rust
// Rust ACP client — 接口形状参考 acpClient.ts
pub struct AcpClient {
child: tokio::process::Child,
stdin: tokio::io::BufWriter<tokio::process::ChildStdin>,
stdout: tokio::io::BufReader<tokio::process::ChildStdout>,
pending: HashMap<u64, PendingRequest>,
next_id: u64,
}
impl AcpClient {
pub async fn spawn(bin: &str, args: &[&str]) -> Result<Self>;
pub async fn send_request<P, R>(&mut self, method: &str, params: P) -> Result<R>;
pub async fn send_notification(&mut self, method: &str, params: Value);
pub fn on_notification(&mut self, handler: impl Fn(String, Value));
}
```
---
## 4. 架构设计
### 4.1 运行时选择器
```rust
// mnote-web 配置
struct AcpRuntimeConfig {
name: String, // "hermes" | "reasonix"
bin: String, // "hermes" | "node"
args: Vec<String>, // ["acp"] | ["reasonix-acp.mjs"]
default_model: Option<String>,
env: HashMap<String, String>,
}
```
mnote-web 支持多个运行时配置,用户通过 profile 选择:
```
profile "default" → runtime "hermes" (spawn hermes acp)
profile "reasonix" → runtime "reasonix" (spawn node reasonix-acp-wrapper.mjs)
```
前端获取可用运行时列表:`GET /api/hermes/client/profiles`(现有接口,扩展字段)
### 4.2 会话生命周期 (ACP Session Manager)
```
用户发送消息
├─ POST /api/hermes/client/sessions → 创建 session
│ ├─ ACP session/new → 得到 sessionId
│ └─ 返回 { sessionId, ... }
├─ POST /api/hermes/client/runs → 开始 run(现有接口)
│ ├─ ACP session/prompt → 下发用户输入 + 页面上下文
│ └─ 返回 { runId }
├─ GET /api/hermes/client/events/{runId} → SSE 流
│ ├─ ACP session/update 的 6 种事件 → SSE 映射
│ ├─ agent_message_chunk → { event: "message.delta", delta: ... }
│ ├─ tool_call → { event: "tool.started", tool: ..., kind: ... }
│ ├─ tool_call_update → { event: "tool.completed", ... }
│ ├─ agent_thought_chunk → { event: "thought.delta", delta: ... } (新增)
│ ├─ usage_update → { event: "usage.updated", usage: ... } (新增)
│ └─ session/update 转 ACP → prompt 返回 → SSE event "run.completed"
└─ POST /api/hermes/client/runs/{runId}/abort → 取消
└─ ACP session/cancel
```
### 4.3 工具桥接
当前 `hermes_tools.rs` 中注册的 mnote 工具(`mnote.doc.fetch``mnote.doc.markdown_edit``mnote.block.*``mnote.page.*`)对 ACP 来说只是一组 HTTP 端点。
对于 Reasonix 作为 runtime 的场景,需要一个 Reasonix-side 的工具注册包装脚本,将 mnote 工具注册到 `ToolRegistry`
```typescript
// reasonix-acp-wrapper.mjs — ACP 包装层
// 参考: DeepSeek-Reasonix-main/src/cli/commands/acp.ts (整个文件, ~339 行)
import { AcpServer } from 'reasonix/acp/server';
import { DeepSeekClient, CacheFirstLoop, ToolRegistry } from 'reasonix';
// 注册 mnote 工具 — 工具实现调用 mnote-web HTTP API
const tools = new ToolRegistry();
tools.define({
name: "mnote.doc.fetch",
description: "阅读当前文档的 markdown 内容",
parameters: { ... },
call: async (args) => {
// 调 mnote-web Rust HTTP API
return fetch(`${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`, {
method: 'POST', body: JSON.stringify({ toolName: "mnote.doc.fetch", args })
}).then(r => r.json());
},
parallelSafe: false,
});
// 启动 ACP server — 参考 acp.ts 的 acpCommand 函数
const server = new AcpServer();
const client = new DeepSeekClient({ apiKey: process.env.DEEPSEEK_API_KEY });
let sessions = new Map();
server.onRequest("session/new", async (params) => {
const loop = new CacheFirstLoop({ client, tools, ... });
// ... 参考 acp.ts 第 220-330 行
});
```
工具的实际执行路径:
```
agent → tool call → (通过 TCP/localhost HTTP) → mnote-web Rust hermes_tools.rs
→ Convex / 文档系统
```
**不需要在 Rust 侧重新注册工具到 Reasonix。** mnote-web 的工具 HTTP 端点 (`/api/hermes/tools/mnote/call`) 不变,只通过 ACP 换掉了 agent runtime。
### 4.4 前端 SSE 扩展
当前前端 SSE 事件格式 (`HermesRunEvent`)
```typescript
type HermesRunEvent =
| { event: "tool.started"; tool: string; preview?: string | null }
| { event: "tool.completed"; tool: string; duration?: number; error?: boolean }
| { event: "message.delta"; delta: string }
| { event: "run.completed"; output?: string; usage?: Record<string, unknown> }
| { event: "run.failed"; error?: string };
```
新增字段(向后兼容):
```typescript
type HermesRunEvent = /* 原有 5 种 */ | {
event: "thought.delta"; // 新增 — 思考过程
delta: string;
} | {
event: "run.completed"; // 扩展 — 新增缓存指标
output?: string;
usage?: Record<string, unknown>;
cacheHitRate?: number; // 新增:Reasonix 缓存命中率
cacheHitTokens?: number; // 新增
};
```
前端 AiAgentPanel 接到 `thought.delta` 后,可渲染在独立区域(参考 hermes-vscode-main 的 `agent_thought_chunk` 处理)。
### 4.5 Profile 体系扩展
当前 `hermes_client.rs` 的 profile 机制(`configured_upstream_for_profile`)是 Hermes-HTTPS 专有的。需要扩展为通用运行时 profile:
```rust
struct AgentProfile {
name: String,
runtime_type: RuntimeType, // Hermes | Reasonix | ACPGeneric
runtime_config: AcpRuntimeConfig,
api_key: Option<String>,
models: Vec<ModelConfig>,
default_model: Option<String>,
}
```
当前已存在的 `hermes_client.rs` profile 相关端点不需要大改——profile 切换逻辑不变,只是 profile 的数据结构增加了 `runtimeType` 字段。
---
## 5. 褪去历史负担 — 模块重构路线
### 阶段 0:现状(当前)
```
hermes_client.rs (3845 行)
├─ HTTP proxy 逻辑 (proxy_json, proxy_stream)
├─ Hermes API 硬编码 (create_run, stream_events)
├─ Session/profile/Skills/工具管理
├─ Memory 管理
└─ 各种配置和鉴权
```
### 阶段 1:新增 ACP 客户端,并行运行
新增文件:
- `acp_client.rs` — ACP JSON-RPC 2.0 客户端(参考 hermes-vscode-main `acpClient.ts`
- `acp_session_manager.rs` — 会话生命周期管理(参考 hermes-vscode-main `sessionManager.ts`
- `acp_runtime.rs` — 运行时管理(spawn、健康检查、切换)
`hermes_client.rs` 中原有的 HTTP proxy 逻辑标志为 `#[deprecated]`,前端通过 profile 选择使用 ACP 还是旧 HTTP proxy。
### 阶段 2:前端运行时选择器
AiAgentPanel 增加 runtime 切换下拉框,实际只是切换 mnote-web 内部使用的 profile。
### 阶段 3:存量迁移
- `list_sessions` → ACP `session/new` + 本地记录
- `create_session` → ACP `session/new`
- `create_run` + `stream_events` → ACP `session/prompt` + `session/update` 事件映射
- `abort_run` → ACP `session/cancel`
- `list_profiles` → 扩展为包含 runtime_type 字段
- `gateway_health` → 改为 ACP runtime 健康检查(spawn + PING
- `list_tools` → 迁移到 `acp_session_manager.rs`
### 阶段 4:退役旧代码
当所有 profile 都已迁移到 ACP 后,`hermes_client.rs` 中原来的 HTTP proxy 代码可以删掉。Profile 的 `upstream_url` 字段不再需要——运行时由 `bin + args` 定义。
---
## 6. 实现计划
### 6.1 Rust ACP 客户端 (`acp_client.rs`)
**参考:** `hermes-vscode-main/src/acpClient.ts`
核心接口:
```rust
use tokio::process::{Command, Child};
use tokio::io::{BufReader, BufWriter, AsyncBufReadExt, AsyncWriteExt};
use serde_json::Value;
use std::collections::HashMap;
type PendingMap = HashMap<u64, tokio::sync::oneshot::Sender<Result<Value, AcpError>>>;
pub struct AcpClient {
child: Child,
writer: BufWriter<ChildStdin>,
pending: Arc<Mutex<PendingMap>>,
next_id: AtomicU64,
}
impl AcpClient {
/// Spawn ACP subprocess
/// 参考: acpClient.ts line 50-80 (spawn + stdio setup)
pub async fn spawn(bin: &str, args: &[&str]) -> Result<Self> {
let mut child = Command::new(bin)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()?;
let writer = BufWriter::new(child.stdin.take().unwrap());
let reader = BufReader::new(child.stdout.take().unwrap());
let pending = Arc::new(Mutex::new(HashMap::new()));
// 后台读取 stdout 行
let p = pending.clone();
tokio::spawn(async move {
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() { continue; }
if let Ok(msg) = serde_json::from_str::<Value>(&line) {
// 参考 acpClient.ts line 120-180 (onData 解析逻辑)
if let Some(id) = msg.get("id").and_then(|v| v.as_u64()) {
// 响应 → 匹配 pending
if let Some(tx) = p.lock().unwrap().remove(&id) {
let _ = tx.send(Ok(msg));
}
} else if let Some(method) = msg.get("method").and_then(|v| v.as_str()) {
// 通知 → 调用 onNotification
}
}
}
});
Ok(Self { child, writer, pending, next_id: AtomicU64::new(1) })
}
/// Send JSON-RPC request, await response
/// 参考: acpClient.ts line 95-110 (sendRequest)
pub async fn request<P: Serialize, R: DeserializeOwned>(
&self, method: &str, params: P
) -> Result<R> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = tokio::sync::oneshot::channel();
self.pending.lock().unwrap().insert(id, tx);
let req = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
self.writer.write_all(format!("{}\n", serde_json::to_string(&req)?).as_bytes()).await?;
self.writer.flush().await?;
match rx.await {
Ok(Ok(val)) => serde_json::from_value(val).map_err(Into::into),
_ => Err(AcpError::Timeout),
}
}
}
```
**工作量:** ~200 行 Rust。核心逻辑直接映射自 hermes-vscode-main 的 `acpClient.ts`
### 6.2 ACP Session Manager (`acp_session_manager.rs`)
**参考:** `hermes-vscode-main/src/sessionManager.ts`
```rust
struct AcpSession {
id: String,
runtime: AcpRuntimeConfig,
client: AcpClient,
state: SessionState,
run_handle: Option<JoinHandle<()>>,
}
pub struct AcpSessionManager {
runtimes: HashMap<String, AcpRuntimeConfig>,
sessions: HashMap<String, AcpSession>,
active_profile: String,
}
```
接口:
| 方法 | 对应 ACP | 参考 |
|---|---|---|
| `create_session(profile, page_context)` | `session/new` | sessionManager.ts `start()` / `sendPrompt()` |
| `run_prompt(session_id, prompt)` | `session/prompt` | sessionManager.ts `sendPrompt()` |
| `cancel(session_id)` | `session/cancel` | sessionManager.ts `cancel()` |
| `on_update(handler)` | `session/update` | sessionManager.ts `handleUpdate()` |
| `switch_runtime(profile)` | — | 切换 `active_profile`,刷新 runtime |
**工作量:** ~300 行 Rust。
### 6.3 Reasonix ACP wrapper (`reasonix-acp-wrapper.mjs`)
**参考:** `DeepSeek-Reasonix-main/src/cli/commands/acp.ts`
```typescript
// 参考 acp.ts 的 acpCommand() 函数 — ~147 行核心逻辑
// 1. 创建 AcpServer
// 2. 注册 mnote 工具 (调 mnote-web HTTP)
// 3. onRequest("session/new") → 创建 CacheFirstLoop
// 4. onRequest("session/prompt") → loop.run() + dispatchKernelEvent
// 5. onNotification("session/cancel") → aborter.abort()
```
**工作量:** ~150 行 TypeScript。直接改编自 `acp.ts` 已有代码。
### 6.4 桥接层 — ACP → SSE
**参考:** `hermes-vscode-main/src/sessionManager.ts``handleUpdate()` 方法
当前 `stream_events` 输出 SSE。改为 ACP 后:
```rust
// 在 new AcpSessionManager().on_update() 中
fn on_acp_update(update: SessionUpdateParams) -> Option<SseEvent> {
match update.update.sessionUpdate {
"agent_message_chunk" => Some(SseEvent {
event: "message.delta",
data: json!({ "delta": extract_text(&update) }),
}),
"tool_call" => Some(SseEvent {
event: "tool.started",
data: json!({ "tool": update.title, "kind": update.kind }),
}),
// ... 其余事件映射
}
}
```
**工作量:** ~60 行 Rust。
### 6.5 前端运行时选择器
AiAgentPanel 增加下拉框 + 切换逻辑:
```tsx
<select value={activeProfile} onChange={switchProfile}>
<option value="default">Hermes(默认)</option>
<option value="reasonix">Reasonix(缓存优先)</option>
</select>
```
`switchProfile``PUT /api/hermes/client/profiles/active`(已有接口)。
**工作量:** ~50 行 TypeScript。
---
## 7. 与现有设计的关系
### 7.1 对 7-5 (Hermes client proxy 合同) 的影响
7-5 规定的路由路径不变:
| 路由 | 当前实现 | ACP 后 |
|------|----------|--------|
| `GET /client/sessions` | Hermes HTTP proxy | ACP `session/new` 历史 |
| `POST /client/sessions` | 本地生成 sessionId | 本地生成 + ACP `session/new` |
| `POST /client/runs` | Hermes HTTP run | ACP `session/prompt` |
| `GET /client/events/{runId}` | Hermes SSE 流 | ACP `session/update` → SSE |
| `POST /client/runs/{runId}/abort` | Hermes HTTP abort | ACP `session/cancel` |
前端看到的 HTTP 接口不变,后端实现透明切换。
### 7.2 对 7-14 (markdown 编辑收敛) 的影响
7-14 确立的「两层操作模型」(`mnote.doc.markdown_edit` 主 + `mnote.block.*` 辅)不受影响——工具在 Rust 侧 `hermes_tools.rs` 实现不变。ACP 只是换掉了 driver(从 Hermes 换成 Reasonix),不改 driver 调用的工具。
### 7.3 对 `page_ai_workflow.rs` 的影响
不影响。`block_edit_workflow` 作为独立 fast-path 与 ACP 无关。
---
## 8. 风险与缓解
| 风险 | 概率 | 缓解 |
|------|------|------|
| Reasonix ACP server 的 tool call 调用 mnote-web HTTP 有延迟 | 中 | 工具调用走 localhost TCP,延迟 <1ms |
| Reasonix `CacheFirstLoop` 与 mnote 页面上下文的兼容性 | 低 | ACP `session/new``pageContext`system prompt 在 Reasonix wrapper 中注入 |
| 两个运行时并行维护增加心智负担 | 中 | 过渡期后退役旧 Hermes HTTP proxy,只保留 ACP |
| ACP 协议字段差异(Hermes vs Reasonix camelCase/snake_case | 低 | 在桥接层做一次字段映射即可 |
| Task 7-12 说「mnote 不再建设独立 AI agent runtime」 | 不冲突 | ACP 层不是 agent runtime,是 runtime 抽象接口。mnote 仍然不建设 runtime,只是可以选接不同的 runtime |
---
## 9. 开放问题
- Reasonix 的 `DeepSeekClient` 需要的 API key 如何注入?环境变量?mnote-web 配置?
- **已决定**:通过 `reasonix-acp-wrapper.mjs` 的环境变量 `DEEPSEEK_API_KEY` 注入
- 本地 `.md` 文件的 tool 实现(`mnote.doc.fetch` 的本地变体)是否也在同一套 ACP 中?
- Phase C(流式 review/apply)的审批事件(`session/request_permission`)是否需要先加入 ACP 层?当前跳过,等 Phase C 再扩展。
---
## 11. 当前实现状态
### ✅ 已完成(2026-05-17
| Step | 文件 | 状态 | 测试 |
|------|------|------|------|
| 3 | `acp_client.rs` | 编译通过,475 行 | 6 单元测试通过 |
| 4 | `acp_types.rs` | 编译通过,440 行 | 7 单元测试通过 |
| 5 | `acp_session_manager.rs` | 编译通过,~530 行 | 4 测试通过(含事件派发) |
| 6 | `acp_runtime.rs` | 编译通过,~330 行 | 5 测试通过,含真实 Hermes 连接测试 |
| 7 | `acp_bridge.rs` + `hermes_client.rs` 修改 | 编译通过 | 新增 `acp_stream_events()` SSE 端点 |
| 8 | `lib.rs` 模块注册 | 编译通过 | — |
| 9 | `AppState` 集成 | 编译通过 | — |
| 10 | `scripts/reasonix-acp-wrapper.mjs` | 已创建,~250 行 | 需安装 `npm install reasonix` 后测试 |
| 12 | Profile 扩展 `configured_runtime_for_profile()` | 编译通过 | — |
| 13 | HTTP proxy 标 `#[deprecated]` | 编译通过(6 个 warning | — |
### 📋 待完成
| Step | 工作 | 前置 | 估算 |
|------|------|------|------|
| 11 | 前端运行时选择器(AiAgentPanel.tsx | Step 7 | ~半天 |
| 14 | e2e 验证:Hermes ACP + Reasonix ACP | Step 10 | ~半天 |
| 15 | 压力测试:多会话、进程管理 | Step 14 | ~半天 |
| 16 | 退役旧 HTTP proxy 代码 | Step 14 稳定后 | ~1 天 |
| 17 | 基准测试:缓存收益量化 | Step 10 | ~半天 |
---
## 10. 详细执行 Checklist(顺序执行)
以下 checklist 按依赖关系排序,每个 step 标注了**参考文件**(可直接读的代码)、**产出文件**、**验证方法**。执行时从 step-1 开始,完成后由 AI 调用 `todo_write` 标记进度后进入下一步。
### Step 1:读取参考代码,熟悉 ACP 协议细节
| 项 | 内容 |
|---|---|
| **目的** | 确认 ACP JSON-RPC 协议的方法名、字段名、事件类型,确保后续实现与 Hermes/Reasonix 兼容 |
| **参考** | `reference-code/hermes-vscode-main/src/protocol.ts`ACP 事件解析)、`reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`ACP 类型定义)、`reference-code/hermes-vscode-main/src/acpClient.ts`ACP 客户端完整实现) |
| **产出** | 无代码产出,仅阅读确认 |
| **验证** | 能在脑中回答:`session/update` 有几种 `sessionUpdate` 变体?每个变体有哪些必选字段? `session/prompt` 的 params 结构是什么? |
### Step 2:确认 `run_command` 的 cwd 与项目根一致
| 项 | 内容 |
|---|---|
| **目的** | 确保后续所有文件操作路径正确,不再触发 sandbox 偏移 |
| **操作** | `run_command pwd` 确认输出为 `/mnt/Data1T/mnote` |
| **验证** | 输出包含 `/mnt/Data1T/mnote` |
### Step 3:创建 `acp_client.rs` — ACP JSON-RPC 2.0 客户端
| 项 | 内容 |
|---|---|
| **目的** | 实现通用的 ACP 协议传输层:spawn 子进程、读写 NDJSON、请求/响应/通知路由 |
| **参考** | `reference-code/hermes-vscode-main/src/acpClient.ts`(完整参考,~220 行):spawn 逻辑 (L50-80)、onData 解析 (L120-180)、sendRequest (L95-110)、sendNotification (L115-118) |
| **路径** | `rust/crates/mnote-web/src/acp_client.rs`(新建) |
| **结构** | `pub struct AcpClient { child, writer, reader, pending: HashMap<u64, OneshotSender>, next_id }` |
| **方法** | `spawn(bin, args) → Result``request(method, params) → Result<R>``notification(method, params)``on_notification(handler)``close()` |
| **参考字段映射** | JSON-RPC `id``pending` key;响应匹配 `id`;通知匹配 `method` 字段 |
| **细节** | stdin 用 `BufWriter`(行缓冲),stdout 用 `BufReader` + `lines()` 逐行读;后台 tokio task 处理 incoming 行;`request()` 返回 `oneshot::Receiver`;超时处理用 `tokio::time::timeout`(默认 5min |
| **验证** | 单元测试:mock stdin/stdout 子进程,发送 `session/new` 请求,验证收到响应;发送 notification,验证 handler 被调用 |
### Step 4:创建 ACP 协议类型定义 — `acp_types.rs`
| 项 | 内容 |
|---|---|
| **目的** | ACP 协议的 Rust 类型定义,序列化/反序列化用 serde |
| **参考** | `reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`ACP 类型定义,~80 行)、`reference-code/hermes-vscode-main/src/protocol.ts`(解析逻辑,~120 行) |
| **路径** | `rust/crates/mnote-web/src/acp_types.rs`(新建) |
| **结构** | `InitializeParams/Result``SessionNewParams/Result``SessionPromptParams/Result``SessionCancelParams``SessionUpdateParams``ContentBlock`text/resource/image/audio)、`SessionUpdateKind`enum 6 种变体) |
| **字段注意** | Hermes 和 Reasonix 都使用 **camelCase** JSON 字段(`sessionUpdate``toolCallId`),对应 `#[serde(rename_all = "camelCase")]` |
| **验证** | 单元测试:JSON 反序列化 `acp/dispatch.ts` 中的 `session/update` 案例、`session/prompt` 的请求/响应序列化-反序列化往返 |
### Step 5:创建 `acp_session_manager.rs` — 会话生命周期管理
| 项 | 内容 |
|---|---|
| **目的** | 管理 ACP 会话生命周期:session/new → session/prompt → session/cancel,将 `session/update` 事件派发给 mnote-web 各模块 |
| **参考** | `reference-code/hermes-vscode-main/src/sessionManager.ts`(完整参考,~294 行):SessionManager 类 (L37-294)、handleUpdate (L130-280)、sendPrompt (L80-128)、cancel (L280-294) |
| **路径** | `rust/crates/mnote-web/src/acp_session_manager.rs`(新建) |
| **结构** | `pub struct AcpSessionManager { runtimes: HashMap<String, AcpRuntimeConfig>, sessions: HashMap<String, AcpSession>, active_profile: String }``struct AcpSession { id, client, state, run_handle, page_context }` |
| **方法** | `create_session(profile, page_context) → sessionId`(调 ACP `session/new`)、`run_prompt(session_id, prompt_blocks, on_event) → JoinHandle`(调 ACP `session/prompt`,注册 `session/update` handler)、`cancel(session_id)`(调 ACP `session/cancel`)、`switch_runtime(profile_name)`(切换 active_profile,关闭旧 sessions,创建新 runtime)、`SessionUpdateKind``AcpSessionEvent` 的映射 |
| **事件映射** | `agent_message_chunk``AcpSessionEvent::TextDelta { text }``agent_thought_chunk``AcpSessionEvent::ThoughtDelta { text }``tool_call``AcpSessionEvent::ToolCall { id, title, kind, status }``tool_call_update``AcpSessionEvent::ToolCallUpdate { id, status, content? }``usage_update``AcpSessionEvent::UsageUpdate { used, size }``session_info_update``AcpSessionEvent::SessionInfo { title }` |
| **去重逻辑** | 参考 `hermes-vscode-main/src/protocol.ts``deduplicateChunk()` 函数——ACP 会重发完整文本作为可靠性 fallback,需检测并丢弃重复 |
| **验证** | 单元测试:构造 mock `AcpClient``create_session` → 验证发送 `session/new``run_prompt` → 验证发送 `session/prompt`;模拟 `session/update` notification,验证 `on_event` 回调被正确调用 |
### Step 6:创建 `acp_runtime.rs` — 运行时管理
| 项 | 内容 |
|---|---|
| **目的** | 管理 agent runtime 进程的 spawn、健康检查、自动重启 |
| **路径** | `rust/crates/mnote-web/src/acp_runtime.rs`(新建) |
| **结构** | `pub struct AcpRuntimeManager { runtimes: HashMap<String, AcpRuntimeConfig>, active: Mutex<Option<String>> }``pub struct AcpRuntimeConfig { name, bin, args, env }` |
| **方法** | `register_runtime(config)``spawn_runtime(name) → AcpClient``health_check(name) → bool`spawn 进程 + 发送 `initialize` 请求,超时 5s)、`shutdown_runtime(name)``switch_to(name) → Result`(先 shutdown 当前 active,再 spawn 新的) |
| **配置来源** | 从环境变量 / 配置文件读取(`MNOTE_WEB_ACP_RUNTIMES` JSON),当前固定配置:`hermes``{ bin: "hermes", args: ["acp"] }``reasonix``{ bin: "node", args: ["reasonix-acp-wrapper.mjs"] }` |
| **profile 扩展** | 当前 `configured_upstream_for_profile()` 返回 Hermes HTTP URL;改为返回 `AcpRuntimeConfig`。已有 profile 系统(`active_profile_name`, `configured_upstream_for_profile`) 保持接口不变,内部实现切换 |
| **验证** | 运行 `hermes acp`(需本地安装),发送 `session/new` 验证返回 `sessionId`;无 Hermes 环境时 mock 子进程验证健康检查逻辑 |
### Step 7:集成 ACP Session Manager 到 Hermes routes
| 项 | 内容 |
|---|---|
| **目的** | 让 `hermes_client.rs` 的现有端点可以选择使用 ACP 而非 HTTP proxy |
| **参考** | `hermes_client.rs` 中现有 `create_run` / `stream_events` / `abort_run` |
| **操作** | 在 `hermes_client.rs` 中导入 `AcpRuntimeManager``AcpSessionManager`;当当前 profile 的 `runtime_type == "acp"` 时走 ACP 路径,否则走原有 HTTP proxy 路径;`create_run``acp_session_manager.run_prompt()`(替代 Hermes HTTP `POST /api/hermes/runs`);`stream_events` → 从 `AcpSessionManager``on_event` 回调中发出 SSE(替代 Hermes HTTP `GET /api/hermes/runs/{id}/events`);`abort_run``acp_session_manager.cancel()`(替代 Hermes HTTP `POST /api/hermes/runs/{id}/abort` |
| **SSE 桥接函数** | 新增 `fn acp_event_to_sse(event: AcpSessionEvent) → Option<SseEvent>`。映射表:`TextDelta("msg")``{ event: "message.delta", data: { delta: "msg" } }``ThoughtDelta("t")``{ event: "thought.delta", data: { delta: "t" } }`(新增);`ToolCall("id","title","kind","pending")``{ event: "tool.started", data: { tool: "title", preview: null } }``ToolCallUpdate("id","completed",content)``{ event: "tool.completed", data: { tool: "...", duration: null } }``UsageUpdate(used,size)``{ event: "usage.updated", data: { used, size } }`(新增);`SessionInfo(title)` → 忽略(mnote 前端不需要) |
| **验证** | 用 `hermes acp`(本地已安装)做 e2e 测试:前端发消息 → ACP session/prompt → 收到 SSE 流 → 显示工具调用 → 显示最终回复 |
### Step 8:编辑全局 Router 添加 ACP 模块
| 项 | 内容 |
|---|---|
| **目的** | 让 ACP 模块被编译,各模块之间可引用 |
| **参考** | `rust/crates/mnote-web/src/routes/mod.rs` 中当前 Hermes routes 的注册方式 (nest at `hermes_base_path`) |
| **操作** | 在 `mod.rs` 中添加 `mod acp_client;``mod acp_types;``mod acp_session_manager;``mod acp_runtime;`;初始化时创建 `AcpRuntimeManager`,注册 Hermes runtime 和 Reasonix runtime(如果配置存在);将 `AcpRuntimeManager` 放入 `AppState``Extension` |
| **验证** | `cargo build` 通过 |
### Step 9AppState 改造 — 加入 AcpRuntimeManager
| 项 | 内容 |
|---|---|
| **目的** | 让路由 handler 可以访问运行时管理器 |
| **参考** | `rust/crates/mnote-web/src/app.rs` 中的 `AppState` 结构 |
| **操作** | 在 `AppState` 中添加 `acp_runtime: Arc<AcpRuntimeManager>` 字段;`AppState::new()` 中根据配置注册 `hermes` 和/或 `reasonix` runtime |
| **验证** | `cargo build` 通过;health endpoint 返回 ACP runtime 状态 |
### Step 10:创建 Reasonix ACP wrapper 脚本
| 项 | 内容 |
|---|---|
| **目的** | 实现 Reasonix ACP server,让 mnote-web 可以 `spawn("node", ["reasonix-acp-wrapper.mjs"])` 连接 |
| **参考** | `reference-code/DeepSeek-Reasonix-main/src/cli/commands/acp.ts`(完整参考,~339 行):acpCommand() (L195-339)、loadMcpServers() (L88-193) |
| **路径** | `scripts/reasonix-acp-wrapper.mjs`(新建) |
| **结构** | import `AcpServer` from `reasonix/acp/server`、import `DeepSeekClient`, `CacheFirstLoop`, `ToolRegistry` from `reasonix`;从环境变量读取 `DEEPSEEK_API_KEY``MNOTE_WEB_URL`(默认为 `http://127.0.0.1:3000`);创建 `ToolRegistry`,注册 `mnote.doc.fetch``mnote.doc.markdown_edit` 工具(工具实现通过 HTTP 调用 `MNOTE_WEB_URL/api/hermes/tools/mnote/call`);`AcpServer` + `onRequest("session/new")` → 创建 `CacheFirstLoop`(参考 acp.ts L220-260);`onRequest("session/prompt")``loop.run()` + `dispatchKernelEvent()`(参考 acp.ts L260-330);`onNotification("session/cancel")``aborter.abort()`(参考 acp.ts L330-339);启动后 `server.done()` 等待 stdin 关闭 |
| **MNOTE_WEB_URL 寻址** | wrapper 脚本在本地运行,通过 `http://127.0.0.1:3000` 调 mnote-web 的 tool API——因为 `hermes_tools.rs` 的 tool 实现在 Rust 侧,wrapper 不重复实现工具逻辑 |
| **验证** | 手动测试:`node scripts/reasonix-acp-wrapper.mjs` 启动后,用标准 ACP client 发送 `session/new` + `session/prompt`,验证返回正常;工具调用可正确通过 mnote-web 读写文档 |
### Step 11:添加运行时选择器的前端支持
| 项 | 内容 |
|---|---|
| **目的** | 在页面 AI 面板中增加运行时切换能力 |
| **参考** | `hermes-vscode-main/src/chatPanel.ts`ACP 事件 → UI 渲染)、`hermes-vscode-main/src/webview/main.ts`webview 事件处理) |
| **前端路径** | `wolai-frontend/src/components/ai-agent/AiAgentPanel.tsx` |
| **操作** | 扩展 profile 获取接口 `GET /api/hermes/client/profiles`,解析 `runtimeType` 字段;增加 `<select>` 下拉框显示可用 runtime"Hermes / Reasonix");切换时调用 `PUT /api/hermes/client/profiles/active`;切换后自动刷新当前会话 |
| **新增 Thought Delta 渲染** | 在 AiAgentPanel 中处理新增的 `thought.delta` SSE 事件,渲染在对话气泡的独立区域(灰色小字或可折叠的 reasoning 面板,参考 hermes-vscode-main webview 对 `agent_thought_chunk` 的渲染) |
| **验证** | 切换 runtime → 发送消息 → 确认 AI 回复流畅;Reasonix 模式下确认缓存指标显示在 header 中 |
### Step 12profile 扩展 — 从 upstream URL 改为 runtime 配置
| 项 | 内容 |
|---|---|
| **目的** | 让 profile 不再持有 `upstream_url`Hermes HTTPS),而是持有 `runtime_name`ACP 通用) |
| **参考** | `hermes_client.rs``configured_upstream_for_profile()``profile_gateway_status()` |
| **操作** | 扩展 profile 数据结构:添加 `runtime_type: Option<String>`"hermes_http"|"acp")、`runtime_name: Option<String>`RuntimeConfig 的 name);新增 `configured_runtime_for_profile(profile) → Option<&AcpRuntimeConfig>`;向后兼容:profile 如果只有 `upstream_url` 但没有 `runtime_type`,视为 `hermes_http`(旧行为);profile 如果有 `runtime_type: "acp"`,则走 ACP Session Manager |
| **health check 改造** | `gateway_health()` 当前只 probe Hermes HTTP upstream;改为:如果 profile 是 `acp` 类型,则调用 `acp_runtime.health_check()`,否则继续 probe HTTP upstream |
| **profile 默认值** | 新增环境变量 `MNOTE_WEB_ACP_DEFAULT_RUNTIME`:默认 `hermes`;设为 `reasonix` 则默认使用 Reasonix |
| **验证** | 不改变现有 `hermes_http` 行为;新增 `acp` 类型 profile 的 health check 正常返回 |
### Step 13Hermes HTTP proxy 代码标为 deprecated
| 项 | 内容 |
|---|---|
| **目的** | 标记旧代码,避免新开发继续依赖 |
| **操作** | 在 `hermes_client.rs` 中 HTTP proxy 相关函数(`proxy_json``proxy_stream``configured_upstream_for_profile` 等)添加 `#[deprecated(note = "迁移到 ACP Session Manager")]`;不影响编译,只是 IDE 和 CI 提示 |
| **验证** | `cargo build` 无 warningdeprecated 函数被自身使用时默认不 warn) |
### Step 14:前端运行时切换验证 e2e
| 项 | 内容 |
|---|---|
| **目的** | 同时验证 Hermes ACP 和 Reasonix ACP 两条路径都能正常走通 |
| **环境准备** | 启动 mnote-web (`cargo run`)、确保 `hermes` 命令可用、确保 `node``reasonix` npm 包已安装、确保 Reasonix wrapper 脚本就绪 |
| **测试路径** | 在浏览器页面 AI 面板中选择 "Hermes" → 发送编辑请求 → 确认工具调用和回复正常;切换到 "Reasonix" → 发送同样的编辑请求 → 确认工具调用和回复正常(且 header 显示缓存命中率);块编辑 fast-path (`/api/page-ai/block-edit-workflow`) 独立测试,不受 ACP 切换影响 |
| **测试账号** | 使用默认测试账号 `mnote.e2e@example.com`,见项目记忆 |
| **验证** | 两种 runtime 都能正常读写文档;Reasonix 模式下 `message.delta` 流式响应速度不慢于 Hermes |
### Step 15:压力测试 — 确认同时多会话稳定性
| 项 | 内容 |
|---|---|
| **目的** | 确保多个 ACP session 并发时不出现进程冲突、内存泄漏 |
| **操作** | 通过 Playwright 或手动测试:同时打开 3 个页面 AI 面板,分别发送不同的编辑请求;观察所有会话是否独立完成;检查 `AcpSessionManager` 的 sessions map 是否在会话结束后正确清理;检查子进程数量是否失控(每个 runtime 应有进程上限,可在 `AcpRuntimeConfig` 中添加 `max_concurrent_sessions`,默认 10 |
| **验证** | 所有会话都能正常完成;无僵尸子进程残留;`top` 确认 Reasonix Node.js 进程数可控 |
### Step 16:退役旧的 Hermes HTTP proxy 代码
| 项 | 内容 |
|---|---|
| **前提** | 所有 production profile 都已迁移到 ACP;灰度观察期至少 1 周无回退 |
| **操作** | 删除 `hermes_client.rs` 中所有 `#[deprecated]` 的函数(`proxy_json``proxy_stream``configured_upstream_for_profile` 等);删除环境变量 `MNOTE_WEB_HERMES_UPSTREAM_URL` 的解析代码;统一所有 profile 为 `runtime_type: "acp"`;不再依赖 `hermes` 命令的 HTTP gateway 模式 |
| **验证** | `cargo build`、常规 e2e 测试全部通过 |
### Step 17:基准测试 — Reasonix 缓存收益量化
| 项 | 内容 |
|---|---|
| **目的** | 收集 Reasonix prefix cache 的实际收益数据,作为后续切流的决策依据 |
| **操作** | 设计 3 轮测试:**场景 A(同一文档连续编辑 5 次)**——对同一文档连续发 5 次 `mnote.doc.markdown_edit`,记录每次的 `prompt_cache_hit_tokens``prompt_cache_miss_tokens`。**场景 B(不同文档交替编辑 5 次)**——交替编辑 5 个不同文档,统计 cache hit rate。**场景 C(长会话 10 轮对话)**——在同一 session 中连续发 10 条消息,统计 cache hit rate 变化趋势 |
| **指标** | `cache_hit_rate = hit_tokens / (hit + miss)`Reasonix 的 `CacheFirstLoop` 每轮迭代后暴露 `ctx.prefixHash``ctx.stats` |
| **对比基线** | 同样 3 个场景下 Hermes ACP 的 cache hit rate(理论上接近 0% |
| **产出** | 记录到 `benchmarks/reasonix-cache-report.md` |
| **通过标准** | Reasonix 场景 A 的 cache hit rate ≥ 80%Reasonix 自述 ~90%+);场景 B ≥ 50%;场景 C ≥ 60% |
---
## 附录:文件依赖关系图
```
step-3 acp_client.rs
│ depends on: none
step-4 acp_types.rs step-6 acp_runtime.rs
│ depends on: none │ depends on: serde_json
▼ ▼
step-5 acp_session_manager.rs ←───────────┘
│ depends on: acp_client, acp_types, acp_runtime
step-7 hermes_client.rs 改造
│ depends on: acp_session_manager
step-8 mod.rs 添加模块
│ depends on: step-3,4,5,6
step-9 AppState 改造
│ depends on: acp_runtime, acp_session_manager
step-10 reasonix-acp-wrapper.mjs (独立, 可并行)
│ depends on: npm reasonix 包
step-11 AiAgentPanel.tsx 修改 step-12 profile 扩展
│ depends on: step-7 │ depends on: hermes_client.rs
▼ ▼
step-13 标 deprecated (与 step-10 可并行)
step-14 e2e 验证
├── step-15 压力测试
└── step-16 退役旧代码
└── step-17 基准测试
```
### 关键并行路径
```
step-3 ─→ step-5 ─→ step-7 ─→ step-11 ─→ step-14
step-10 (Reasonix wrapper, 可并行)
step-4 ─→ step-5
step-6 ─→ step-5, step-9
```
### 每次 AI 执行前必须确认
1. `run_command pwd``/mnt/Data1T/mnote`(确认 cwd
2. 写文件路径用相对路径 `rust/crates/...` 而非 `/mnt/Data1T/mnote/...`
3.`search_content` / `read_file` 确认目标文件最新内容,避免 edit_file SEARCH 不匹配
4. 每个 step 完成后 `todo_write` 更新进度