# 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; /** 写文件 */ writeFile(absolutePath: string, content: string | Buffer): Promise; /** 追加写入 */ appendFile(absolutePath: string, content: string | Buffer): Promise; /** 可读性 / 可写性检查,失败抛出 */ access(absolutePath: string, mode: 'read' | 'write' | 'readwrite'): Promise; /** 判断是否目录,不存在抛出 */ isDirectory(absolutePath: string): Promise; /** 列目录 */ readDir(absolutePath: string): Promise>; /** 创建目录(递归) */ mkdir(absolutePath: string): Promise; /** 解析 realpath,用于 file_mutation_queue 的 key */ realpath(absolutePath: string): Promise; } 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( filePath: string, fn: () => Promise, ): Promise; // 新签名:用注入的 file ops 来 realpath export async function withFileMutationQueueOps( filePath: string, fileOps: FileOperations, fn: () => Promise, ): Promise; ``` 老签名保留,避免一次性迁移所有调用方。 **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 注入测试(本次提交)