# Neta 工具层 Pluggable Operations 架构改造设计

## 背景

Neta 的 Agent 工具系统（`bash` / `read_file` / `write_file` / `edit` / `patch` / `grep` / `find_files` / `list_dir`）当前直接调用 `fs/promises`、`spawn`、`exec` 等底层系统 API。工具的业务逻辑（参数校验、模糊匹配、diff 计算、输出截断、错误格式化）和执行后端（本地文件系统 / 本地子进程）耦合在同一段代码里。

这种耦合在系统能力较小时没有问题，但随着以下场景出现，已经显现出多个限制：

1. **测试困难** —— 工具单元测试必须真实读写本地文件系统，无法 mock 文件操作做行为级测试
2. **无远程/沙箱执行能力** —— 当前没有把工具调用路由到 SSH、Docker 容器、隔离环境的能力，所有命令都在后端进程的宿主机上执行
3. **Crew 多 Agent 文件并发风险** —— `file_mutation_queue.ts` 提供了单进程内的文件写串行化，但 Crew 多 Agent / subagent worker 跨进程时无法共享这个队列
4. **跨切关注点散落** —— ANSI 输出剥离、命令重试、超时处理、操作审计等共性逻辑无法在统一的层做拦截，必须每个工具单独实现
5. **权限控制粗放** —— 当前 `tools/manifest.ts` 的 `worker-local` / `main-process-proxy` 路由是工具粒度的 yes/no，无法表达"允许读但禁止写"或"只能写白名单目录"这类细粒度策略

但 Neta 现在已经有几块"差一步就能升级"的基础设施：

- `tools/builtin/bash.ts:24-42` 已经定义了 `BashOperations` 接口和 `createLocalBashOperations()` 工厂——这是 Pluggable Operations 模式在 Neta 内部最早的实践，但没有推广到其他工具
- `tools/file_mutation_queue.ts` 已经把文件写串行化抽出来，调用方只需 `withFileMutationQueue(filePath, fn)`
- `tools/runtime_context.ts` 通过 `_netaRuntime` 字段把 `sessionCwd` / `workspaceRoots` 注入到工具参数里，是"会话级上下文"的现成传递通道
- `tools/catalog.ts`（schema 元数据）和 `tools/manifest.ts`（worker 路由策略）已经分离——新引入的 Operations 层是第三个独立维度（执行后端），不与它们冲突
- `comm/child_process.ts` 已经统一了子进程创建（自动注入 `windowsHide: true`），Operations 实现层可以直接复用

参考 Pi（`pi-mono-main/packages/coding-agent`）的工具系统设计，本次改造把这个模式系统性地引入 Neta。

## 目标

通过引入 **Operations 接口层**，把"文件 I/O"和"进程执行"这两类系统调用从工具业务代码中抽出来，使工具的业务逻辑不再依赖 `fs/promises` 或 `child_process`。

具体目标：

1. 定义 `FileOperations` / `ProcessOperations` / `SearchOperations` 三个接口，聚合为 `ToolOperations`
2. 提供 `LocalToolOperations` 默认实现，封装现有的本地文件系统和子进程执行逻辑
3. 把所有 builtin 工具迁移到 Operations 接口，不再直接 import `fs` 或 `child_process`
4. 在 `tool_resolver.ts` 注册阶段统一注入 Operations 实例，未来可根据会话策略注入不同实现
5. Subagent worker 端可以注入 `WorkerProxyOperations`，把需要主进程执行的操作通过 IPC 代理回主进程
6. 完成后，**主进程行为零变化**，但解锁工具单元测试、远程执行、沙箱化、跨切拦截等后续能力

## 非目标

本次改造不包含以下内容：

- **不动 Agent 循环** —— `runtime/attempt.ts` 和 `runtime/agent.ts` 完全不改
- **不动 catalog 和 manifest** —— `tools/catalog.ts`、`tools/manifest.ts`、`tools/common.ts` 不改
- **不动 Hook 契约** —— `beforeToolCall` / `afterToolCall` 的接口和语义保持
- **不动 Session Tree** —— 会话树、压缩、subagent projection 不动
- **不动 LLM Provider 适配层** —— `llm-providers` 不涉及
- **不改工具的 schema 和参数定义** —— LLM 看到的工具描述和参数完全一致
- **不改 memory / skill / delegate / escalate / todo / clarify 工具** —— 这些工具不直接做 fs/spawn 操作，本次不需要迁移
- **不重构现有 manifest 的 worker 路由** —— Phase 3 只做"通过 Operations 注入实现路由"，不打散按工具粒度的现有策略
- **不实现远程后端、沙箱后端** —— 本次只做接口和本地实现，远程/沙箱实现作为未来工作
- **不引入服务端权限模型** —— 细粒度权限作为未来工作

## 架构设计

### 三层模型

```
┌─────────────────────────────────────────────────────┐
│  Tool 业务层（不变）                                │
│   schema 定义、参数校验、模糊匹配、diff 计算、       │
│   截断逻辑、错误格式化、路径解析                     │
├─────────────────────────────────────────────────────┤
│  Operations 接口层（新增）                          │
│   FileOperations、ProcessOperations、SearchOperations │
├─────────────────────────────────────────────────────┤
│  Implementation 实现层（替换点）                    │
│   LocalToolOperations（默认）                       │
│   WorkerProxyOperations（subagent worker → main proxy）│
│   未来：DockerOperations / SshOperations / MockOps  │
└─────────────────────────────────────────────────────┘
```

工具业务层只看到 Operations 接口，不知道底层实现。注入点在 `tool_resolver.ts` 构建工具列表时。

### Operations 接口定义

```typescript
// tools/operations/types.ts

export interface FileOperations {
  /** 读文件，返回 Buffer，由上层决定编码 */
  readFile(absolutePath: string): Promise<Buffer>;
  /** 写文件 */
  writeFile(absolutePath: string, content: string | Buffer): Promise<void>;
  /** 追加写入 */
  appendFile(absolutePath: string, content: string | Buffer): Promise<void>;
  /** 可读性 / 可写性检查，失败抛出 */
  access(absolutePath: string, mode: 'read' | 'write' | 'readwrite'): Promise<void>;
  /** 判断是否目录，不存在抛出 */
  isDirectory(absolutePath: string): Promise<boolean>;
  /** 列目录 */
  readDir(absolutePath: string): Promise<Array<{ name: string; isDirectory: boolean; size: number }>>;
  /** 创建目录（递归） */
  mkdir(absolutePath: string): Promise<void>;
  /** 解析 realpath，用于 file_mutation_queue 的 key */
  realpath(absolutePath: string): Promise<string>;
}

export interface ProcessOperations {
  /**
   * 执行 shell 命令并流式回传输出。
   * 行为契约（必须保持与现有 bash 一致）：
   *  - 通过 onData 流式回调输出
   *  - signal 触发时 kill 进程树
   *  - timeout 到时 kill 并 reject
   *  - cwd / env 必须传透
   *  - Windows 下自动隐藏控制台窗口（由实现保证）
   */
  exec(
    command: string,
    cwd: string,
    options: {
      onData: (chunk: Buffer, stream: 'stdout' | 'stderr') => void;
      signal?: AbortSignal;
      timeout?: number;
      env?: NodeJS.ProcessEnv;
    },
  ): Promise<{ exitCode: number | null; signal: NodeJS.Signals | null }>;
}

export interface SearchOperations {
  /** 等价 ripgrep（grep 工具用） */
  ripgrep(args: string[], cwd: string, signal?: AbortSignal): Promise<{ stdout: string; stderr: string; exitCode: number | null }>;
  /** 等价 fd（find_files 工具用） */
  fd(args: string[], cwd: string, signal?: AbortSignal): Promise<{ stdout: string; stderr: string; exitCode: number | null }>;
}

/** 三接口聚合 */
export interface ToolOperations {
  file: FileOperations;
  process: ProcessOperations;
  search: SearchOperations;
}
```

### 设计权衡

#### 为什么聚合成一个 `ToolOperations`，而不是每个工具一个独立接口（Pi 的做法）

Pi 给每个工具定义了独立接口（`ReadOperations` / `EditOperations` / `WriteOperations` / `BashOperations` / `GrepOperations` / `FindOperations`）。Neta 选择聚合的原因：

- Neta 工具数量多于 Pi（含 memory、skill、delegate 等），独立接口意味着注入点要写 N 个 options 字段，调用方负担大
- 同一套 Operations 实现服务于所有工具更自然——主进程的 LocalOperations、worker 的 ProxyOperations 都是一整套
- 失去"一个工具只看到自己需要的方法"的最小权限好处。但 Neta 已经在 manifest 层做工具粒度的策略控制，权限边界已经在更上层覆盖，下沉到接口层是冗余

#### 为什么把 `realpath` 放在 FileOperations

`withFileMutationQueue` 当前用 `fs.realpathSync.native()` 取 key。改造后 queue 也要走 Operations，否则远程后端的"同一文件"判断就错了——远程符号链接和本地的解析结果不一样。

#### 为什么 `ProcessOperations.exec` 用 `onData` 流式回调

- 与现有 bash 工具实现一致（`tools/builtin/bash.ts:46`），减少改动
- 大输出场景下避免内存爆炸——上层决定截断或落盘

#### 为什么单独抽出 `SearchOperations`

- ripgrep / fd 有"二进制可能不存在"的特殊语义和 fallback 逻辑，是高层概念，不适合塞进 ProcessOperations
- 远程后端可以直接调远程的 ripgrep / fd，性能比把整个目录拉回本地再搜要好几个数量级

## 改造步骤

按风险从低到高分四个 Phase，每个 Phase 独立可提交可回滚。

### Phase 1：建立 Operations 基础设施（无业务影响）

**1.1 新建 `tools/operations/types.ts`** —— 定义三个接口和聚合接口，仅类型，无运行时依赖。

**1.2 新建 `tools/operations/local.ts`** —— 实现 `LocalToolOperations`：

- `FileOperations` 用 `fs/promises`
- `ProcessOperations` 用 `comm/child_process.spawn`（已封装 windowsHide）。把现有 `tools/builtin/bash.ts` 里 `createLocalBashOperations` 的实现迁移过来
- `SearchOperations` 用 `comm/child_process.spawn` 调 ripgrep/fd。把现有 `tools/builtin/search.ts` 里 ripgrep/fd 的调用逻辑迁移过来

**1.3 新建 `tools/operations/index.ts`** —— 导出 `getDefaultOperations(): ToolOperations`，使用模块级单例，避免重复创建。

**1.4 给 `tools/file_mutation_queue.ts` 加新签名**：

```typescript
// 保留原签名（兼容现有调用方）
export async function withFileMutationQueue<T>(
  filePath: string,
  fn: () => Promise<T>,
): Promise<T>;

// 新签名：用注入的 file ops 来 realpath
export async function withFileMutationQueueOps<T>(
  filePath: string,
  fileOps: FileOperations,
  fn: () => Promise<T>,
): Promise<T>;
```

老签名保留，避免一次性迁移所有调用方。

**1.5 新增契约测试 `test/tool_operations.test.ts`** —— 验证 `LocalToolOperations` 的每个方法语义正确（读写文件、列目录、exec 流式输出、ripgrep/fd 调用）。

**Phase 1 验证点**：
- `npx tsc --noEmit` 通过
- 现有所有测试通过
- 新增的契约测试通过
- `getDefaultOperations()` 不被任何工具引用（说明业务代码未动）

### Phase 2：迁移工具到 Operations（一次一个，独立提交）

按以下顺序，每个工具一个独立 commit：

| 顺序 | 工具 | 替换点 |
|-----|------|--------|
| 2.1 | `read_file` | `fs.readFile` → `ops.file.readFile`、`fs.access` → `ops.file.access` |
| 2.2 | `list_dir` | `fs.readdir` / `fs.stat` → `ops.file.readDir` |
| 2.3 | `write_file` | `fs.writeFile` / `fs.mkdir` → `ops.file.*`，queue 改用 `withFileMutationQueueOps` |
| 2.4 | `edit` | `fs.readFile` / `fs.writeFile` → `ops.file.*` |
| 2.5 | `patch` | 同 edit |
| 2.6 | `find_files` | fd 子进程 → `ops.search.fd` |
| 2.7 | `grep` | ripgrep 子进程 → `ops.search.ripgrep` |
| 2.8 | `bash` | 自有 `BashOperations` 删除，改用 `ops.process.exec` |

**注入方式**：每个工具的 factory 增加可选 `operations` 参数，默认走 `getDefaultOperations()`。

```typescript
// 当前
export const readFileTool: AgentToolWithMeta<...> = { ... };

// 改造后
export function createReadFileTool(opts?: { operations?: ToolOperations }): AgentToolWithMeta<...> {
  const ops = opts?.operations ?? getDefaultOperations();
  return { ...; execute: async (id, params) => { /* 用 ops.file.readFile */ } };
}
// 保留 catalog 注册兼容：
export const readFileTool = createReadFileTool();
```

**Phase 2 每个工具的验证点**：
- 该工具的 isolation 测试用 Mock Operations 跑通
- `npx tsc --noEmit` 通过
- 启动后端，Agent 实际调用该工具，输出与改造前完全一致（人工验证一次）
- `git diff` 仅涉及该工具自己的文件

**Phase 2 全部完成的验证点**：
- `git grep "from 'fs/promises'" packages/backend/src/modules/netaclaw/tools/builtin/` 应为 0 命中
- `git grep "child_process" packages/backend/src/modules/netaclaw/tools/builtin/` 应为 0 命中
- 跑一次 e2e Agent 会话，覆盖所有工具：read_file → edit → bash → grep → write_file

### Phase 3：在工具注册处统一注入

**3.1 修改 `service/tool_resolver.ts`**

`ResolveToolParams` 增加 `operations?: ToolOperations`：
- 主进程默认 `getDefaultOperations()`
- 后续可以根据 `runtimePolicy.workspaceRoots` 注入 sandboxed/scoped 实现

工具列表构造时把 operations 传给每个工具的 factory。

**3.2 修改 subagent worker 的工具构建逻辑**

Worker 进程构造工具时注入 `WorkerProxyOperations`：
- `file.writeFile` 等需要主进程代理的方法 → 通过 IPC 发 `proxy_tool_call` 给主进程
- `process.exec` 由 worker 端本地执行（已经被 worker policy 限制），无需代理

**注**：这一步**只做接口对接，沿用现有 manifest 路由策略**，不打散按工具粒度的现有路由。等后续真有需求（比如 worker 想读文件但写文件代理）再细化到操作粒度。

**Phase 3 验证点**：
- 主进程 Agent 行为不变
- subagent worker 行为不变
- file_mutation_queue 在跨 worker 写同一文件时行为可被观测到（写一个 e2e 测试）

### Phase 4：清理遗留代码

**4.1 删除 `tools/builtin/bash.ts` 中的 `BashOperations` 接口和 `createLocalBashOperations`** —— 已经被 `ProcessOperations` 完全替代

**4.2 删除 `withFileMutationQueue` 老签名** —— Phase 2 全部完成后，所有调用方都用新签名

**4.3 清理 `search.ts` 里 `spawnAndCollect`、`collectRipgrepMatches` 等已经迁到 Operations 实现的辅助函数**

## 关键文件

### 新增

- `packages/backend/src/modules/netaclaw/tools/operations/types.ts` —— 接口定义
- `packages/backend/src/modules/netaclaw/tools/operations/local.ts` —— 默认本地实现
- `packages/backend/src/modules/netaclaw/tools/operations/index.ts` —— 单例导出
- `packages/backend/test/tool_operations.test.ts` —— 接口契约测试

### 修改

- `packages/backend/src/modules/netaclaw/tools/file_mutation_queue.ts` —— 增加 ops 版签名
- `packages/backend/src/modules/netaclaw/tools/builtin/file.ts` —— read/write/list_dir 接 Operations
- `packages/backend/src/modules/netaclaw/tools/builtin/edit.ts` —— 接 Operations
- `packages/backend/src/modules/netaclaw/tools/builtin/patch.ts` —— 接 Operations
- `packages/backend/src/modules/netaclaw/tools/builtin/search.ts` —— 接 SearchOperations
- `packages/backend/src/modules/netaclaw/tools/builtin/bash.ts` —— 删除自有 BashOperations，接 ProcessOperations
- `packages/backend/src/modules/netaclaw/service/tool_resolver.ts` —— 增加 operations 参数
- `packages/backend/src/modules/netaclaw/subagent/` 下相关文件（Phase 3 时确认）—— 注入 ProxyOperations

### 不动

- `tools/catalog.ts`、`tools/common.ts`、`tools/manifest.ts`
- `runtime/attempt.ts`、`runtime/agent.ts`
- 所有 memory / skill / delegate / escalate / todo / clarify 工具
- `tools/path_utils.ts`、`tools/runtime_context.ts`、`tools/edit_diff.ts`、`tools/truncate.ts`、`tools/process_utils.ts`
- `comm/child_process.ts`（被 LocalOperations 复用）

## 风险与缓解

| 风险 | 等级 | 缓解 |
|------|------|------|
| 大改动一次提交后回归难定位 | 高 | Phase 2 每个工具独立提交，每次仅迁一个工具 |
| Operations 接口设计不当，未来扩展受限 | 中 | Phase 1 落地后先观察 1-2 周再开始 Phase 2，期间允许接口微调 |
| Subagent worker 路由复杂度上升 | 中 | Phase 3 先只做接口对接，不重构现有 manifest 路由；等真实需求出现再细化 |
| 性能下降（多一层接口跳转） | 低 | 接口都是直接函数调用，V8 会内联；本地实现零额外开销 |
| 测试覆盖不足 | 中 | Phase 1 强制为 LocalOperations 写契约测试；Phase 2 每个工具迁移时跑一次完整 Agent 会话 |

## 价值兑现路径

完成 Phase 1+2 后立即可以做：

1. **工具单元测试** —— Mock 一个 Operations 就能跑，不需要真实文件系统
2. **bash 输出清理** —— 在 LocalProcessOperations 的 exec 实现里加 ANSI strip，所有 bash 调用点零修改自动受益
3. **Skill 沙箱化** —— 给 Skill 注入受限的 FileOperations（白名单目录），无需改工具代码

完成 Phase 3 后可以做：

4. **远程工作区** —— 实现 SshToolOperations，Agent 可以操作远程主机上的代码
5. **Crew 隔离** —— 不同子 Agent 注入不同的 workspace-scoped Operations，硬隔离写权限
6. **细粒度审计** —— 在 Operations 层记录所有文件读写、所有命令执行，统一日志/审计

## 时间预估

| Phase | 工作量 | 说明 |
|-------|--------|------|
| Phase 1 | 1 天 | 建立基础设施，零业务影响 |
| Phase 2 | 3-4 天 | 8 个工具，每个 0.5 天 |
| Phase 3 | 1-2 天 | tool_resolver 注入 + worker proxy 对接 |
| Phase 4 | 0.5 天 | 清理 |
| **合计** | **6-8 天** | |

建议节奏：Phase 1 合并后先稳定运行 1 周再开始 Phase 2；Phase 2 每个工具一个 PR，便于 code review 和回滚。

## 进度跟踪

- `[done]` Phase 1：建立 Operations 基础设施（`6eb15f3`）
- `[done]` Phase 2.1-2.3：迁移 read_file / write_file / list_dir（`a383c43`）
- `[done]` Phase 2.4-2.5：迁移 edit / patch（`009945c`）
- `[done]` Phase 2.6-2.8：迁移 find_files / grep / bash（`69487ae`）
- `[done]` Phase 3：tool_resolver 统一注入 + worker_tools 统一注入（`0e92ed9`）
- `[done]` Phase 4：删除 withFileMutationQueue 旧签名并完成接口收口（`7c4626f`）
- `[done]` P1 测试补齐：LocalToolOperations 契约测试 + tool_resolver 注入测试 + worker_tools 注入测试（本次提交）
