补齐工具箱
你的Agent读取文件。它会搜索代码。接下来它会想运行一个命令。也许npm test。也许是git status。也许,在糟糕的日子里,会有rm -rf /。
猛击是你能给Agent最有用的工具,也是最危险的。在这节课中,我们会先添加它,然后给它戴上牵引绳。
学习成果
你有一个bash工具,在工作目录中运行命令,并被允许列表限制。安全命令会自动运行。其他的都是返回一个块消息,模型会把它传回给用户,老实说。
快速路径
- 添加一个带有
SAFE_PREFIXES允许列表(ls、cat、git status和好友)的bash工具。 - 用清晰的错误字符串屏蔽不在允许列表中的内容
- 验证模型报告的模块,而不是虚构成功
动手练习 1.3
在Agent上加bash,并在execute层做门控。
要求:
- 从
node:child_process进口execSync - 定义一个
SAFE_PREFIXES数组,带有只读命令,如ls、cat、pwd、git status、git log、git diff - 写一个
isSafe(command)检定,与允许列表进行对比 execute中,如果命令不安全,返回阻塞消息,否则执行- 用四部分的描述写工具契约
实现提示:
- 按前缀匹配,不是精确命令。
ls -la应该和她相配ls - 在
execSync上设置一个timeout,这样挂机的过程不会冻结Agent - 阻挡消息应该准确告诉模型阻挡了什么。模型会把这些信息传递给用户
- AI SDK有个
needsApproval选项。我们这里不使用它。原因请见下文
为什么不needsApproval?
AI SDK给你一个needsApproval场tool()看起来完全符合我们的需求。事实并非如此。
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 内部进行门控,这样模型总能获得真实的字符串。
执行层门
添加允许列表的工具:
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 || ""}`;
}
},
});关键的模式是:当命令被阻塞时,工具会返回字符串。这条线最终作为工具结果出现在对话中。模型会像读取其他结果一样读取,并可以将块真实地传回给用户。
动手试试
运行一个映射到安全命令的提示词:
bun run index.ts . "List all files in this directory"模型应该按bash,选择安全命令,比如ls或find,然后返回输出。
现在试试危险的办法:
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。我们的前缀检查抓住了rm但find -exec漏掉了。生产Harness使用正则表达式模式来表示危险命令。我们保留前缀检查是因为它清晰,而不是因为它完整。
npx tsc --noEmit三利一Agent
你现在拥有了这三种工具:
| 工具 | 作用 | 安全 |
|---|---|---|
read | 查看文件内容 | 500 行电容 |
grep | 跨文件搜索 | 50场比赛上限 |
bash | 运行 shell 命令 | SAFE_PREFIXES允许名单 |
描述引导着选择。这些大写保护上下文。门保护你的机器。Agent是有用且可控的。
提交
git add index.ts
git commit -m "feat(tools): add bash with execute-level safety gate"完成标准
- [ ] 安全命令如
ls、find和git status``bash - [ ] 自然语言文件读取提示词仍然会路由到
read,而不是bash``cat - [ ]
rm -rf、sudo及其他未知命令返回阻断消息 - [ ] 模型报告显示,用户没有假装成功,而是阻止了指令
- [ ]
npx tsc --noEmit
**注意:勾勒审批流程**
执行层门诚实但直白。真正的Harness会问使用者。试着添加一个needsApproval检查,返回true非安全命令,暂停循环,并向用户展示一个类似*“Agent想运行npm install express”这样的提示词。允许吗?”*审批简历与工具结果。否认继续,并用否认信息让模型适应。 你会在模块8中正确构建,但现在值得先草拟一下,看看为什么当Agent运行50个命令时,交互审批会变得复杂。
参考实现
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)`);