跳到正文

设计沙箱接口

你的工具效果很好。他们知道的也太多了。

read知道readFileSyncbash知道execSync。两人都知道自己运行在Node上。一旦你想让它们在别的地方运行,比如沙箱、远程虚拟机、内存文件系统,所有工具都得重写。

在构建任何后端之前,我们先写下接口。一个沙箱工具要调用,抽象上需要做什么?一旦契约存在,工具就会对其进行重构,后端则会补上。

学习成果

一个定义为readFileexecstop加单位字段的Sandbox接口。这三种工具(readgrepbash)都调用接口,而不是直接使用 Node API。

快速路径

  1. 定义Sandbox接口,包含typeworkingDirectoryreadFileexecstop,以及可选的expiresAtsnapshot
  2. 重构read为调用 sandbox.readFile(path)而非readFileSync
  3. 重构grepbash(或你的localOps.exec)通过sandbox.exec(command)

动手练习 4.1

编写接口并重构三个工具来使用它。

要求:

  1. src/sandbox.ts 中定义Sandbox,包含 typeworkingDirectoryreadFileexecstop 以及可选的 expiresAtsnapshot
  2. 每种方法都是async的,即使实现在底层是同步的
  3. Sandbox传到工具工厂。请更新readgrepbash以调用 sandbox.readFilesandbox.exec
  4. 构建还无法运行(你还没写实现)。没关系。我们下课会讲这个

实现提示:

  • 所有方法都async,因为云后端需要,而不同实现间的签名不一致会带来混乱
  • 使用可选方法(expiresAt?snapshot?(): Promise<...>)来处理那些不适用于所有后端的功能
  • type: string用于日志和调试。别太快把它变成工会。如果你愿意,以后可以"local" | "just-bash" | "cloud"

接口

ts
export interface Sandbox {
  type: string;
  workingDirectory: string;
  readFile(path: string): Promise<string>;
  exec(command: string): Promise<{ stdout: string; exitCode: number }>;
  stop(): Promise<void>;
  expiresAt?: number;
  snapshot?(): Promise<{ snapshotId: string }>;
}

值得一提的几个选择:

  • 每个方法返回一个Promise。本地后端会将同步调用包裹。云后端确实是异步的。两者签名相同,保持工具简单
  • typeworkingDirectory 是恒等域。工具有时需要知道它们的位置和对应的是什么
  • expiresAtsnapshot是可选的。本地沙箱不会过期。just-bash 沙箱不会快照。界面兼容两者,无需强制存根

每种方法的收益

方法作用必须吗?
readFile通过路径读取文件
exec执行命令
stop优雅地关闭是的(无操作也可以)
type在日志中识别后端
workingDirectory工具的基础路径
expiresAt超时时间戳没有(仅限云端)
snapshot救救状态没有(仅限云端)

把界面尽可能小。你现在添加的任何东西,都会是每个实现永远支持的。

重构工具

read的重构只有一行:

ts
// Before
execute: async ({ path: filePath }) => {
  const content = readFileSync(resolve(cwd, filePath), "utf-8");
  // ...
}

// After
execute: async ({ path: filePath }) => {
  const content = await sandbox.readFile(filePath);
  // ...
}

grepbash都采用了相同的处理方式,通过sandbox.exec(command)路由,而不是通过execSync或我们在模块2中构建的localOps对象。工厂函数现在接受sandbox参数并对其闭包。

工具的输入 schema、描述、行上限和匹配上限都保持不变。模型仍然看到相同的契约。发动机盖下的管道才是移动的。

**注意:胜利在于便携性,而非行为问题**

经过这个重构后,Agent在同一个提示词上表现相同。同样的工具,同样的效果。这就是判断重构是结构性而非行为性的测试。胜利体现在你在第4.3课中加入第二个后端,无需动工具就能实现。

动手试试

你还没写过实现,所以代码不会端到端运行。你可以检查这些类型是否对应:

bash
npx tsc --noEmit

如果你持续重构,这种情况会过去。所有关于工具内readFileSyncexecSync的提及都应该被删除。现在这些工具期望一个Sandbox参数。

提交

bash
git add src/sandbox.ts src/tools.ts
git commit -m "refactor(tools): route through Sandbox interface"

完成标准

  • [ ] src/sandbox.ts导出了Sandbox界面
  • [ ] readgrepbash接受Sandbox、调用 sandbox.readFilesandbox.exec
  • [ ] 现在没有工具可以直接导入readFileSyncexecSync
  • [ ] expiresAtsnapshot被指定为可选类型
  • [ ] npx tsc --noEmit

**注意:再加一个方法而不破坏世界**

假设你也想要写文件的工具。在界面上添加writeFile(path: string, content: string): Promise<void>。现在每个实现都必须支持它,包括那些写不合理(比如只读审查的沙箱)。正确的做法是什么?新的可选方法?有没有一个专门用于写入的接口沙箱?是实现中投出的错误,无法实现?每个项目的费用都不同。 选一个,注意它在其他地方都被强迫了什么。

参考实现

ts
export interface Sandbox {
  type: string;
  workingDirectory: string;
  readFile(path: string): Promise<string>;
  exec(command: string): Promise<{ stdout: string; exitCode: number }>;
  stop(): Promise<void>;
  expiresAt?: number;
  snapshot?(): Promise<{ snapshotId: string }>;
}
ts
import type { Sandbox } from "./sandbox";

export function createReadTool(sandbox: Sandbox) {
  return tool({
    description: `Read a file from the project. Returns numbered lines.
WHEN TO USE: viewing file contents, checking configs, reading source code.
WHEN NOT TO USE: searching across files (use grep instead).`,
    inputSchema: z.object({
      path: z.string(),
      offset: z.number().optional(),
      limit: z.number().optional(),
    }),
    execute: async ({ path: filePath, offset, limit }) => {
      const content = await sandbox.readFile(filePath);
      // ... same line numbering and truncation logic
    },
  });
}

export function createBashTool(
  sandbox: Sandbox,
  needsApproval: (input: { command: string }) => boolean,
) {
  return tool({
    // ... same description and schema
    execute: async ({ command }) => {
      if (needsApproval({ command })) {
        return `Blocked: "${command}" requires approval.`;
      }
      const { stdout } = await sandbox.exec(command);
      return stdout || "(no output)";
    },
  });
}

非官方简体中文翻译 · 原课程来自 Vercel Academy