设计沙箱接口
你的工具效果很好。他们知道的也太多了。
read知道readFileSync。bash知道execSync。两人都知道自己运行在Node上。一旦你想让它们在别的地方运行,比如沙箱、远程虚拟机、内存文件系统,所有工具都得重写。
在构建任何后端之前,我们先写下接口。一个沙箱工具要调用,抽象上需要做什么?一旦契约存在,工具就会对其进行重构,后端则会补上。
学习成果
一个定义为readFile、exec、stop加单位字段的Sandbox接口。这三种工具(read、grep、bash)都调用接口,而不是直接使用 Node API。
快速路径
- 定义
Sandbox接口,包含type、workingDirectory、readFile、exec、stop,以及可选的expiresAt和snapshot - 重构
read为调用sandbox.readFile(path)而非readFileSync - 重构
grep和bash(或你的localOps.exec)通过sandbox.exec(command)
动手练习 4.1
编写接口并重构三个工具来使用它。
要求:
- 在
src/sandbox.ts中定义Sandbox,包含type、workingDirectory、readFile、exec、stop以及可选的expiresAt和snapshot - 每种方法都是
async的,即使实现在底层是同步的 - 把
Sandbox传到工具工厂。请更新read、grep和bash以调用sandbox.readFile和sandbox.exec - 构建还无法运行(你还没写实现)。没关系。我们下课会讲这个
实现提示:
- 所有方法都
async,因为云后端需要,而不同实现间的签名不一致会带来混乱 - 使用可选方法(
expiresAt?、snapshot?(): Promise<...>)来处理那些不适用于所有后端的功能 type: string用于日志和调试。别太快把它变成工会。如果你愿意,以后可以"local" | "just-bash" | "cloud"
接口
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。本地后端会将同步调用包裹。云后端确实是异步的。两者签名相同,保持工具简单 type和workingDirectory是恒等域。工具有时需要知道它们的位置和对应的是什么expiresAt和snapshot是可选的。本地沙箱不会过期。just-bash沙箱不会快照。界面兼容两者,无需强制存根
每种方法的收益
| 方法 | 作用 | 必须吗? |
|---|---|---|
readFile | 通过路径读取文件 | 是 |
exec | 执行命令 | 是 |
stop | 优雅地关闭 | 是的(无操作也可以) |
type | 在日志中识别后端 | 是 |
workingDirectory | 工具的基础路径 | 是 |
expiresAt | 超时时间戳 | 没有(仅限云端) |
snapshot | 救救状态 | 没有(仅限云端) |
把界面尽可能小。你现在添加的任何东西,都会是每个实现永远支持的。
重构工具
read的重构只有一行:
// Before
execute: async ({ path: filePath }) => {
const content = readFileSync(resolve(cwd, filePath), "utf-8");
// ...
}
// After
execute: async ({ path: filePath }) => {
const content = await sandbox.readFile(filePath);
// ...
}grep和bash都采用了相同的处理方式,通过sandbox.exec(command)路由,而不是通过execSync或我们在模块2中构建的localOps对象。工厂函数现在接受sandbox参数并对其闭包。
工具的输入 schema、描述、行上限和匹配上限都保持不变。模型仍然看到相同的契约。发动机盖下的管道才是移动的。
**注意:胜利在于便携性,而非行为问题**
经过这个重构后,Agent在同一个提示词上表现相同。同样的工具,同样的效果。这就是判断重构是结构性而非行为性的测试。胜利体现在你在第4.3课中加入第二个后端,无需动工具就能实现。
动手试试
你还没写过实现,所以代码不会端到端运行。你可以检查这些类型是否对应:
npx tsc --noEmit如果你持续重构,这种情况会过去。所有关于工具内readFileSync和execSync的提及都应该被删除。现在这些工具期望一个Sandbox参数。
提交
git add src/sandbox.ts src/tools.ts
git commit -m "refactor(tools): route through Sandbox interface"完成标准
- [ ]
src/sandbox.ts导出了Sandbox界面 - [ ]
read、grep和bash接受Sandbox、调用sandbox.readFile和sandbox.exec - [ ] 现在没有工具可以直接导入
readFileSync或execSync了 - [ ]
expiresAt和snapshot被指定为可选类型 - [ ]
npx tsc --noEmit
**注意:再加一个方法而不破坏世界**
假设你也想要写文件的工具。在界面上添加writeFile(path: string, content: string): Promise<void>。现在每个实现都必须支持它,包括那些写不合理(比如只读审查的沙箱)。正确的做法是什么?新的可选方法?有没有一个专门用于写入的接口沙箱?是实现中投出的错误,无法实现?每个项目的费用都不同。 选一个,注意它在其他地方都被强迫了什么。
参考实现
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 }>;
}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)";
},
});
}