GPU_GUARD_MONOREPO/docs/superpowers/specs/2026-05-01-tool-pluggable-operations-design.md
2026-05-20 21:39:12 +08:00

19 KiB
Raw Permalink Blame History

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

背景

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

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

  1. 测试困难 —— 工具单元测试必须真实读写本地文件系统,无法 mock 文件操作做行为级测试
  2. 无远程/沙箱执行能力 —— 当前没有把工具调用路由到 SSH、Docker 容器、隔离环境的能力,所有命令都在后端进程的宿主机上执行
  3. Crew 多 Agent 文件并发风险 —— file_mutation_queue.ts 提供了单进程内的文件写串行化,但 Crew 多 Agent / subagent worker 跨进程时无法共享这个队列
  4. 跨切关注点散落 —— ANSI 输出剥离、命令重试、超时处理、操作审计等共性逻辑无法在统一的层做拦截,必须每个工具单独实现
  5. 权限控制粗放 —— 当前 tools/manifest.tsworker-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.tsschema 元数据)和 tools/manifest.tsworker 路由策略)已经分离——新引入的 Operations 层是第三个独立维度(执行后端),不与它们冲突
  • comm/child_process.ts 已经统一了子进程创建(自动注入 windowsHide: trueOperations 实现层可以直接复用

参考 Pipi-mono-main/packages/coding-agent)的工具系统设计,本次改造把这个模式系统性地引入 Neta。

目标

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

具体目标:

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

非目标

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

  • 不动 Agent 循环 —— runtime/attempt.tsruntime/agent.ts 完全不改
  • 不动 catalog 和 manifest —— tools/catalog.tstools/manifest.tstools/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 接口定义

// 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.execonData 流式回调

  • 与现有 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

  • FileOperationsfs/promises
  • ProcessOperationscomm/child_process.spawn(已封装 windowsHide。把现有 tools/builtin/bash.tscreateLocalBashOperations 的实现迁移过来
  • SearchOperationscomm/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 加新签名

// 保留原签名(兼容现有调用方)
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.readFileops.file.readFilefs.accessops.file.access
2.2 list_dir fs.readdir / fs.statops.file.readDir
2.3 write_file fs.writeFile / fs.mkdirops.file.*queue 改用 withFileMutationQueueOps
2.4 edit fs.readFile / fs.writeFileops.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()

// 当前
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.tsspawnAndCollectcollectRipgrepMatches 等已经迁到 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.tstools/common.tstools/manifest.ts
  • runtime/attempt.tsruntime/agent.ts
  • 所有 memory / skill / delegate / escalate / todo / clarify 工具
  • tools/path_utils.tstools/runtime_context.tstools/edit_diff.tstools/truncate.tstools/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 后可以做:

  1. 远程工作区 —— 实现 SshToolOperationsAgent 可以操作远程主机上的代码
  2. Crew 隔离 —— 不同子 Agent 注入不同的 workspace-scoped Operations硬隔离写权限
  3. 细粒度审计 —— 在 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_dira383c43
  • [done] Phase 2.4-2.5:迁移 edit / patch009945c
  • [done] Phase 2.6-2.8:迁移 find_files / grep / bash69487ae
  • [done] Phase 3tool_resolver 统一注入 + worker_tools 统一注入(0e92ed9
  • [done] Phase 4删除 withFileMutationQueue 旧签名并完成接口收口(7c4626f
  • [done] P1 测试补齐LocalToolOperations 契约测试 + tool_resolver 注入测试 + worker_tools 注入测试(本次提交)