GPU_GUARD_MONOREPO/docs/superpowers/specs/2026-05-01-tool-pluggable-operations-design.md

348 lines
19 KiB
Markdown
Raw Permalink Normal View History

2026-05-20 21:39:12 +08:00
# 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默认
│ WorkerProxyOperationssubagent 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 {
/** 等价 ripgrepgrep 工具用) */
ripgrep(args: string[], cwd: string, signal?: AbortSignal): Promise<{ stdout: string; stderr: string; exitCode: number | null }>;
/** 等价 fdfind_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. **远程工作区** —— 实现 SshToolOperationsAgent 可以操作远程主机上的代码
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 2Phase 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 3tool_resolver 统一注入 + worker_tools 统一注入(`0e92ed9`
- `[done]` Phase 4删除 withFileMutationQueue 旧签名并完成接口收口(`7c4626f`
- `[done]` P1 测试补齐LocalToolOperations 契约测试 + tool_resolver 注入测试 + worker_tools 注入测试(本次提交)