跳到正文

补齐工具箱

你的Agent读取文件。它会搜索代码。接下来它会想运行一个命令。也许npm test。也许是git status。也许,在糟糕的日子里,会有rm -rf /

猛击是你能给Agent最有用的工具,也是最危险的。在这节课中,我们会先添加它,然后给它戴上牵引绳。

学习成果

你有一个bash工具,在工作目录中运行命令,并被允许列表限制。安全命令会自动运行。其他的都是返回一个块消息,模型会把它传回给用户,老实说。

快速路径

  1. 添加一个带有SAFE_PREFIXES允许列表(lscatgit status和好友)的bash工具。
  2. 用清晰的错误字符串屏蔽不在允许列表中的内容
  3. 验证模型报告的模块,而不是虚构成功

动手练习 1.3

在Agent上加bash,并在execute层做门控。

要求:

  1. node:child_process进口execSync
  2. 定义一个SAFE_PREFIXES数组,带有只读命令,如lscatpwdgit statusgit loggit diff
  3. 写一个isSafe(command)检定,与允许列表进行对比
  4. execute中,如果命令不安全,返回阻塞消息,否则执行
  5. 用四部分的描述写工具契约

实现提示:

  • 按前缀匹配,不是精确命令。ls -la应该和她相配ls
  • execSync上设置一个timeout,这样挂机的过程不会冻结Agent
  • 阻挡消息应该准确告诉模型阻挡了什么。模型会把这些信息传递给用户
  • AI SDK有个needsApproval选项。我们这里不使用它。原因请见下文

为什么不needsApproval

AI SDK给你一个needsApprovaltool()看起来完全符合我们的需求。事实并非如此。

ts
const bash = tool({
  needsApproval: () => true,
  execute: async ({ command }) => {
    // This never runs, but the model thinks it did
  },
});

needsApproval返回true时,SDK会在响应中创建tool-approval-request并跳过执行。如果你没有接上审批处理器,工具调用就会消失。模型没有得到结果,于是自己编造了一个:“”完成!我删了文件。”

模型认为命令运行了。用户会看到成功提示。命令没有运行。这比运行命令还糟糕,因为用户根本不知道出了什么问题。

**Warning:需要审批是一个信号,而非一个gate**

needsApproval告诉Harness“这需要人类的审批。”Harness的工作就是真正采取行动。没有周围的流,卡住的工具会悄无声息地消失,模型用虚构填补空白。

我们将在模块8中建立适当的审批流程。目前,我们在 execute 内部进行门控,这样模型总能获得真实的字符串。

执行层门

添加允许列表的工具:

ts
import { execSync } from "node:child_process";

const SAFE_PREFIXES = [
  "ls", "cat", "echo", "pwd", "which", "find",
  "head", "tail", "wc", "git log", "git status", "git diff",
];

function isSafe(command: string): boolean {
  return SAFE_PREFIXES.some((p) => command.trim().startsWith(p));
}

const bash = tool({
  description: `Execute a shell command in the working directory.
WHEN TO USE: running build commands, installing packages, running tests,
  git operations, directory listings.
WHEN NOT TO USE: reading file contents (use read instead).
  Searching for patterns (use grep instead).
DO NOT USE FOR: reading files (use read), searching code (use grep).`,
  inputSchema: z.object({
    command: z.string().describe("Shell command to execute"),
  }),
  execute: async ({ command }) => {
    if (!isSafe(command)) {
      return `Blocked: "${command}" requires approval. Only safe commands (${SAFE_PREFIXES.join(", ")}) run automatically.`;
    }
    try {
      const stdout = execSync(command, {
        cwd,
        encoding: "utf-8",
        timeout: 30_000,
      });
      return stdout || "(no output)";
    } catch (e: any) {
      return `Exit ${e.status ?? 1}: ${e.stdout || e.stderr || e.message || ""}`;
    }
  },
});

关键的模式是:当命令被阻塞时,工具会返回字符串。这条线最终作为工具结果出现在对话中。模型会像读取其他结果一样读取,并可以将块真实地传回给用户。

动手试试

运行一个映射到安全命令的提示词:

bash
bun run index.ts . "List all files in this directory"

模型应该按bash,选择安全命令,比如lsfind,然后返回输出。

现在试试危险的办法:

bash
bun run index.ts . "Run the command: rm -rf node_modules"

模型调用 bash``rm -rf。大门挡住了它。块状消息以工具结果的形式返回,模型会将其传递给你:

The command "rm -rf node_modules" requires approval.
Only safe commands run automatically.

这才是重点。没有无声的失败。没有虚构的成功。

**Warning:关注创意rewrites**

如果你说“删除node_modules”,模型可能会尝试find . -name node_modules -exec rm -rf {} +而不是rm -rf。我们的前缀检查抓住了rmfind -exec漏掉了。生产Harness使用正则表达式模式来表示危险命令。我们保留前缀检查是因为它清晰,而不是因为它完整。

bash
npx tsc --noEmit

三利一Agent

你现在拥有了这三种工具:

工具作用安全
read查看文件内容500 行电容
grep跨文件搜索50场比赛上限
bash运行 shell 命令SAFE_PREFIXES允许名单

描述引导着选择。这些大写保护上下文。门保护你的机器。Agent是有用且可控的。

提交

bash
git add index.ts
git commit -m "feat(tools): add bash with execute-level safety gate"

完成标准

  • [ ] 安全命令如lsfindgit status``bash
  • [ ] 自然语言文件读取提示词仍然会路由到read,而不是bash``cat
  • [ ] rm -rfsudo及其他未知命令返回阻断消息
  • [ ] 模型报告显示,用户没有假装成功,而是阻止了指令
  • [ ] npx tsc --noEmit

**注意:勾勒审批流程**

执行层门诚实但直白。真正的Harness会问使用者。试着添加一个needsApproval检查,返回true非安全命令,暂停循环,并向用户展示一个类似*“Agent想运行npm install express”这样的提示词。允许吗?”*审批简历与工具结果。否认继续,并用否认信息让模型适应。 你会在模块8中正确构建,但现在值得先草拟一下,看看为什么当Agent运行50个命令时,交互审批会变得复杂。

参考实现

ts
import { ToolLoopAgent, stepCountIs, tool } from "ai";
import { z } from "zod";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { execSync } from "node:child_process";

const cwd = resolve(process.argv[2] || process.cwd());

const SAFE_PREFIXES = [
  "ls", "cat", "echo", "pwd", "which", "find",
  "head", "tail", "wc", "git log", "git status", "git diff",
];

function isSafe(command: string): boolean {
  return SAFE_PREFIXES.some((p) => command.trim().startsWith(p));
}

const read = 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).
DO NOT USE FOR: running commands, listing directories.`,
  inputSchema: z.object({
    path: z.string().describe("File path relative to working directory"),
    offset: z.number().optional().describe("Start line (1-indexed)"),
    limit: z.number().optional().describe("Max lines to return"),
  }),
  execute: async ({ path: filePath, offset, limit }) => {
    const abs = resolve(cwd, filePath);
    const content = readFileSync(abs, "utf-8");
    let lines = content.split("\n");

    if (offset) lines = lines.slice(offset - 1);
    if (limit) lines = lines.slice(0, limit);

    const MAX_LINES = 500;
    const truncated = lines.length > MAX_LINES;
    if (truncated) lines = lines.slice(0, MAX_LINES);

    const numbered = lines.map((l, i) => `${(offset || 1) + i}: ${l}`);
    return truncated
      ? numbered.join("\n") + `\n... (truncated at ${MAX_LINES} lines)`
      : numbered.join("\n");
  },
});

const grep = tool({
  description: `Search file contents using regex. Returns matching lines with file paths.
WHEN TO USE: finding patterns across multiple files, locating function definitions,
  searching for imports, finding TODOs or error messages.
WHEN NOT TO USE: reading a known file (use read instead).
DO NOT USE FOR: running commands, listing directories.
EXAMPLES:
  - Find all TODO comments: pattern "TODO" glob "*.ts"
  - Find function definitions: pattern "function \\\\w+" glob "*.ts"`,
  inputSchema: z.object({
    pattern: z.string().describe("Regex pattern to search for"),
    path: z.string().optional().describe("Directory to search (default: working dir)"),
    glob: z.string().optional().describe("File glob filter, e.g. '*.ts'"),
  }),
  execute: async ({ pattern, path: searchPath, glob: globFilter }) => {
    const dir = resolve(cwd, searchPath || ".");
    const escapedPattern = pattern.replace(/'/g, `'\\''`);
    const escapedGlob = (globFilter || "*").replace(/'/g, `'\\''`);
    const cmd = `grep -rn --exclude-dir=node_modules --exclude-dir=.git --include='${escapedGlob}' -E '${escapedPattern}' '${dir}' 2>/dev/null`;

    try {
      const stdout = execSync(cmd, { encoding: "utf-8", timeout: 10_000 });
      const lines = stdout.trim().split("\\n").filter(Boolean);

      const MAX_MATCHES = 50;
      const truncated = lines.length > MAX_MATCHES;
      const result = truncated ? lines.slice(0, MAX_MATCHES) : lines;

      return truncated
        ? result.join("\\n") + `\\n... (${lines.length} total, showing first ${MAX_MATCHES})`
        : result.join("\\n") || "No matches found.";
    } catch (error: any) {
      const stdout = String(error?.stdout || "").trim();
      if (stdout) {
        const lines = stdout.split("\\n").filter(Boolean);
        const MAX_MATCHES = 50;
        const truncated = lines.length > MAX_MATCHES;
        const result = truncated ? lines.slice(0, MAX_MATCHES) : lines;
        return truncated
          ? result.join("\\n") + `\\n... (${lines.length} total, showing first ${MAX_MATCHES})`
          : result.join("\\n");
      }
      return "No matches found.";
    }
  },
});

const bash = tool({
  description: `Execute a shell command in the working directory.
WHEN TO USE: running build commands, installing packages, running tests,
  git operations, directory listings.
WHEN NOT TO USE: reading file contents (use read instead).
  Searching for patterns (use grep instead).
DO NOT USE FOR: reading files (use read), searching code (use grep).`,
  inputSchema: z.object({
    command: z.string().describe("Shell command to execute"),
  }),
  execute: async ({ command }) => {
    if (!isSafe(command)) {
      return `Blocked: "${command}" requires approval. Only safe commands (${SAFE_PREFIXES.join(", ")}) run automatically.`;
    }
    try {
      const stdout = execSync(command, {
        cwd,
        encoding: "utf-8",
        timeout: 30_000,
      });
      return stdout || "(no output)";
    } catch (e: any) {
      return `Exit ${e.status ?? 1}: ${e.stdout || e.stderr || e.message || ""}`;
    }
  },
});

const agent = new ToolLoopAgent({
  model: "anthropic/claude-haiku-4-5",
  instructions: `You are a coding agent.\nWorking directory: ${cwd}`,
  tools: { read, grep, bash },
  stopWhen: stepCountIs(10),
});

const prompt = process.argv.slice(3).join(" ") || "Hello!";
const { text, steps } = await agent.generate({ prompt });
console.log(text);
console.log(`\n(${steps.length} steps)`);

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