CLI 入口
你从第一模块开始就一直在bun run index.ts . "prompt"。那是个CLI。只是这不是一种礼貌的选择。
立场论证工作量太大了。沙箱后端没有标记,所以你一直在忙process.env.SANDBOX。模型已硬编码在Agent中。如果你在跑动中按Ctrl-C,沙箱也不会干净地关闭。本地的话没问题。对于云端沙箱来说,就是让虚拟机运行在别人的信用卡上。
本课正式化了切入点。通过parseArgs进行争论。沙箱通过工厂读取标志。通过带有默认值的标志来建模。通过始终运行的信号处理器关闭sandbox.stop()。
学习成果
index.ts解析--sandbox、--model、位置工作目录和位置提示词。沙箱在正常退出和SIGINT时都能干净地关机。
快速路径
- 用
node:util的parseArgs``--sandbox和--model - 从旗帜到一个小工厂构建沙箱
- 布线一个
SIGINT处理器,调用sandbox.stop()和退出 - Agent跑后一定要调用
sandbox.stop()在finally
动手练习 10.1
用parseArgs加上干净的关机替换临时CLI。
要求:
- 用
node:util的parseArgs,--sandbox(默认local)和--model(默认anthropic/claude-haiku-4-5) - 允许位置战士:第一个是
cwd,其余的则合并成提示词 - 通过
sandboxFromFlag(name, cwd)助手从旗帜上构建沙箱 - 把Agent跑包在
try/finally里,这样sandbox.stop()总是运行 - 注册一个
SIGINT处理程序,停止沙箱并以代码0退出
实现提示:
parseArgs在node:util。设置allowPositionals: true以混合旗帜和位置sandboxFromFlag是一句话的切换,而不是"local"和"just-bash"finally对云端沙箱比本地更重要,但无论如何你都希望代码路径相同
CLI
import { parseArgs } from "node:util";
import { ToolLoopAgent, stepCountIs, pruneMessages } from "ai";
import { resolve } from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { createLocalSandbox } from "./src/sandbox-local";
import { createJustBashSandbox } from "./src/sandbox-just-bash";
import { buildSystemPrompt } from "./src/system";
import {
createReadTool,
createGrepTool,
createBashTool,
createTaskTool,
createAskUserTool,
createTodoTool,
} from "./src/tools";
import { createApproval } from "./src/approval";
import { addCacheControl } from "./src/cache";
import { discoverGates } from "./src/verification";
import type { Sandbox } from "./src/sandbox";
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
options: {
sandbox: { type: "string", default: "local" },
model: { type: "string", default: "anthropic/claude-haiku-4-5" },
},
allowPositionals: true,
});
const cwd = resolve(positionals[0] || process.cwd());
const prompt = positionals.slice(1).join(" ") || "Hello!";
async function sandboxFromFlag(name: string, dir: string): Promise<Sandbox> {
if (name === "just-bash") return createJustBashSandbox(dir);
return createLocalSandbox(dir);
}
const sandbox = await sandboxFromFlag(values.sandbox!, cwd);
console.error(`Sandbox: ${sandbox.type}`);
const projectContext = existsSync(join(cwd, "AGENTS.md"))
? readFileSync(join(cwd, "AGENTS.md"), "utf-8")
: undefined;
const verificationCommands = await discoverGates(sandbox);
const baseTools = {
read: createReadTool(sandbox),
grep: createGrepTool(sandbox),
bash: createBashTool(sandbox, createApproval({ mode: "interactive" })),
};
const tools = {
...baseTools,
task: createTaskTool(sandbox, { read: baseTools.read, grep: baseTools.grep }),
askUser: createAskUserTool(),
todo: createTodoTool(),
};
const agent = new ToolLoopAgent({
model: values.model!,
instructions: buildSystemPrompt({
workingDirectory: cwd,
sandboxType: sandbox.type,
toolNames: Object.keys(tools),
projectContext,
verificationCommands,
}),
tools,
stopWhen: stepCountIs(15),
prepareCall: async (options) => {
const pruned = options.messages
? pruneMessages({
messages: options.messages,
toolCalls: "before-last-3-messages",
})
: undefined;
return {
...options,
messages: pruned ? addCacheControl(pruned) : undefined,
};
},
onStepFinish: ({ usage, stepNumber }) => {
console.error(
`Step ${stepNumber}: ${usage.inputTokens} input, ${usage.outputTokens} output`,
);
},
});
process.on("SIGINT", async () => {
console.error("\nShutting down...");
await sandbox.stop();
process.exit(0);
});
try {
const { text, steps } = await agent.generate({ prompt });
console.log(text);
console.log(`\n(${steps.length} steps)`);
} finally {
await sandbox.stop();
}这就是完整档案。大部分内容是前九个模块已经编写好的汇编。CLI变化包括:parseArgs、sandboxFromFlag助手、SIGINT处理员和try/finally。
为什么这finally重要
如果Agent在中途投掷,finally仍然会继续。作为本地沙箱,你没清理什么重要的东西。对于云端沙箱,你避免了让虚拟机持续运行。你释放了一些记忆,暂时有just-bash 沙箱。代码相同,成本不同,但都处理得很干净。
SIGINT handler 是重复的。按下Ctrl-C的用户是一条路径。未被发现的例外是另一条路径。finally覆盖正常退出,处理器覆盖显式中断。
**注意:CLI是薄包装**
这份文件里几乎没有关于CLI担忧的内容。Agent、工具、提示词用具、沙箱:这些都是可重复使用的。CLI部分包括五到六条parseArgs线和一个信号处理员。如果你构建了不同的表面(比如网页服务器、Slack机器人、VS Code扩展),唯一会变的代码就是那五六行。下面的所有东西都保持原地。
动手试试
用新旗帜跑:
bun run index.ts --sandbox=just-bash --model=anthropic/claude-haiku-4-5 . "Read the package.json"你应该在stderr看到Sandbox: just-bash,然后是模型的响应stdout,最后是步数。
通过在运行中途发送 SIGINT 来测试干净的关机:
bun run index.ts . "Run a long task"按下Ctrl-C。你应该看看《关闭中。.....》干净利落地退出,而不是绞刑。
npx tsc --noEmit提交
git add index.ts
git commit -m "feat(cli): parseArgs and SIGINT-aware shutdown"完成标准
- [ ]
parseArgs显示--sandbox和--model旗帜 - [ ] 位置型提供
cwd和提示词 - [ ]
sandbox.stop()正常出口(通过finally)运行 - [ ]
sandbox.stop()在SIGINT上运行(通过Handler) - [ ]
npx tsc --noEmit
**注意:添加会话标志**
加上--session=<id>可以从磁盘加载之前运行的消息,并作为messages重放到agent.generate({ prompt, messages })。退出时,把新消息保存回去。现在你明天可以继续聊天。文件存放在哪里?文件损坏时会发生什么?你应该给它做版本印章,这样旧会话在Harness更换时不会坏掉吗?
参考实现
详见上文完整index.ts。