跳到正文

CLI 入口

你从第一模块开始就一直在bun run index.ts . "prompt"。那是个CLI。只是这不是一种礼貌的选择。

立场论证工作量太大了。沙箱后端没有标记,所以你一直在忙process.env.SANDBOX。模型已硬编码在Agent中。如果你在跑动中按Ctrl-C,沙箱也不会干净地关闭。本地的话没问题。对于云端沙箱来说,就是让虚拟机运行在别人的信用卡上。

本课正式化了切入点。通过parseArgs进行争论。沙箱通过工厂读取标志。通过带有默认值的标志来建模。通过始终运行的信号处理器关闭sandbox.stop()

学习成果

index.ts解析--sandbox--model、位置工作目录和位置提示词。沙箱在正常退出和SIGINT时都能干净地关机。

快速路径

  1. node:utilparseArgs``--sandbox--model
  2. 从旗帜到一个小工厂构建沙箱
  3. 布线一个SIGINT处理器,调用 sandbox.stop()和退出
  4. Agent跑后一定要调用 sandbox.stop()finally

动手练习 10.1

parseArgs加上干净的关机替换临时CLI。

要求:

  1. node:utilparseArgs--sandbox(默认local)和--model(默认anthropic/claude-haiku-4-5
  2. 允许位置战士:第一个是cwd,其余的则合并成提示词
  3. 通过sandboxFromFlag(name, cwd)助手从旗帜上构建沙箱
  4. 把Agent跑包在try/finally里,这样sandbox.stop()总是运行
  5. 注册一个SIGINT处理程序,停止沙箱并以代码0退出

实现提示:

  • parseArgsnode:util。设置allowPositionals: true以混合旗帜和位置
  • sandboxFromFlag 是一句话的切换,而不是 "local""just-bash"
  • finally对云端沙箱比本地更重要,但无论如何你都希望代码路径相同

CLI

ts
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变化包括:parseArgssandboxFromFlag助手、SIGINT处理员和try/finally

为什么这finally重要

如果Agent在中途投掷,finally仍然会继续。作为本地沙箱,你没清理什么重要的东西。对于云端沙箱,你避免了让虚拟机持续运行。你释放了一些记忆,暂时有just-bash 沙箱。代码相同,成本不同,但都处理得很干净。

SIGINT handler 是重复的。按下Ctrl-C的用户是一条路径。未被发现的例外是另一条路径。finally覆盖正常退出,处理器覆盖显式中断。

**注意:CLI是薄包装**

这份文件里几乎没有关于CLI担忧的内容。Agent、工具、提示词用具、沙箱:这些都是可重复使用的。CLI部分包括五到六条parseArgs线和一个信号处理员。如果你构建了不同的表面(比如网页服务器、Slack机器人、VS Code扩展),唯一会变的代码就是那五六行。下面的所有东西都保持原地。

动手试试

用新旗帜跑:

bash
bun run index.ts --sandbox=just-bash --model=anthropic/claude-haiku-4-5 . "Read the package.json"

你应该在stderr看到Sandbox: just-bash,然后是模型的响应stdout,最后是步数。

通过在运行中途发送 SIGINT 来测试干净的关机:

bash
bun run index.ts . "Run a long task"

按下Ctrl-C。你应该看看《关闭中。.....》干净利落地退出,而不是绞刑。

bash
npx tsc --noEmit

提交

bash
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

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