04 · 命令工具:执行与验证
让 agent 能在终端执行命令,捕获 stdout/stderr,设置超时限制。这是 agent 能验证自己修改的关键能力。
为什么 agent 要能跑命令
前三节给 agent 装了搜索、读写、精确修改文件的能力。但改完代码之后,agent 怎么知道自己改得对不对?
答案是:跑一下试试。运行测试、跑 lint、执行 build——这些都是验证代码正确性的标准方式。如果 agent 不能执行命令,它就永远只能"改了就走",没办法验证修改是否真的生效。
run_command 就是给 agent 这个能力。它让 agent 能在终端执行任意命令,拿到 stdout 和 stderr 输出,根据结果决定下一步行动。
run_command 的设计
先看完整代码:
// src/tools/run-command.ts
import { exec } from "node:child_process";
import { promisify } from "node:util";
import type { AgentState, Tool } from "../types";
const execAsync = promisify(exec);
/** 命令执行超时时间(毫秒) */
const DEFAULT_TIMEOUT = 30_000;
/** 单次输出最大长度,避免把海量日志塞给模型 */
const MAX_OUTPUT_LENGTH = 10_000;
/**
* 执行命令工具
*
* 让 agent 能在本地终端执行命令,如运行测试、lint、build 等。
* 这是 coding agent "能跑"的核心——只有能执行命令,才能验证自己的修改是否正确。
*
* 安全措施:
* 1. 限制超时(默认 30 秒),防止长时间运行的命令阻塞 agent
* 2. 限制输出长度,避免海量日志消耗上下文窗口
* 3. 在工作目录下执行,不会意外跑到其他目录
*/
export const runCommandTool: Tool = {
name: "run_command",
description:
"在终端中执行命令并返回输出。" +
"适合运行测试、lint、build、脚本等命令。" +
"命令在工作目录下执行。" +
"超时时间为 30 秒。",
parameters: {
type: "object",
properties: {
command: {
type: "string",
description: "要执行的命令",
},
timeout: {
type: "number",
description: "超时时间(毫秒),默认 30000",
},
},
required: ["command"],
},
async execute(
args: Record<string, unknown>,
state: AgentState,
): Promise<string> {
const command = String(args.command);
const timeout = Number(args.timeout) || DEFAULT_TIMEOUT;
if (!command.trim()) {
return "错误:命令不能为空。";
}
try {
const { stdout, stderr } = await execAsync(command, {
cwd: state.workingDir,
timeout,
maxBuffer: 1024 * 1024,
});
return formatCommandResult(stdout, stderr);
} catch (error: unknown) {
return formatCommandError(error);
}
},
};逐个看关键设计决策。
用 promisify(exec) 执行命令
Node.js 的 child_process.exec 是回调风格的,配合 promisify 转成 Promise:
const execAsync = promisify(exec);选择 exec 而不是 spawn,是因为 agent 工具需要的是"执行完拿到全部输出",而不是流式处理。exec 会等命令执行完毕,一次性返回 stdout 和 stderr。这正好符合工具的返回值模型——execute 方法返回一个字符串,不是流。
选择 exec 而不是 execFile,是因为 agent 可能执行包含管道、重定向的复杂命令(如 npm test 2>&1 | head -20)。exec 通过 shell 执行,天然支持这些写法。
超时限制:30 秒安全阀
/** 命令执行超时时间(毫秒) */
const DEFAULT_TIMEOUT = 30_000;
const timeout = Number(args.timeout) || DEFAULT_TIMEOUT;
const { stdout, stderr } = await execAsync(command, {
cwd: state.workingDir,
timeout,
maxBuffer: 1024 * 1024,
});30 秒的默认超时是一个经验值。大多数测试、lint、build 命令在这个时间内可以完成。如果命令跑了 30 秒还没结束,exec 会自动杀掉进程并抛出错误。
超时是可以被调用方覆盖的——如果 agent 知道某个命令确实需要更长时间(比如完整的 E2E 测试),可以传 timeout: 60000。
为什么一定要有超时?想象一下没有超时的情况:agent 执行了一个有 bug 的命令(比如陷入无限循环的脚本),这个命令永远不会结束,agent 也永远卡在那里等。超时就是防止这种"死等"的安全阀。
输出截断:防止海量日志淹没上下文
命令输出可能非常长。一个大型项目的测试套件可以输出几千行日志,如果全部塞进对话历史,会快速消耗模型的上下文窗口。
/** 单次输出最大长度,避免把海量日志塞给模型 */
const MAX_OUTPUT_LENGTH = 10_000;
function truncateOutput(output: string): string {
if (output.length <= MAX_OUTPUT_LENGTH) return output;
const kept = output.slice(0, MAX_OUTPUT_LENGTH);
const totalLines = output.split("\n").length;
const keptLines = kept.split("\n").length;
return `${kept}\n\n... 输出过长,已截断(显示前 ${keptLines}/${totalLines} 行)`;
}10,000 字符大约是 200-300 行典型终端输出。对于验证测试结果来说,前面的输出通常已经包含了关键信息——编译错误、测试失败、lint 违规等。
截断信息包含了行数统计("显示前 150/847 行"),让模型知道输出被截断了,也知道自己只看到了部分内容。
区分 stdout 和 stderr
function formatCommandResult(stdout: string, stderr: string): string {
const parts: string[] = [];
if (stdout.trim()) {
const truncated = truncateOutput(stdout.trim());
parts.push(`stdout:\n${truncated}`);
}
if (stderr.trim()) {
const truncated = truncateOutput(stderr.trim());
parts.push(`stderr:\n${truncated}`);
}
if (parts.length === 0) {
return "命令执行成功(无输出)。";
}
return parts.join("\n\n");
}stdout 和 stderr 分开呈现,不是混在一起。这很重要:
- 测试框架通常把结果输出到 stdout,把警告和错误输出到 stderr
- 编译器把错误信息输出到 stderr,成功编译时 stdout 可能是空的
分开之后,模型能更准确地判断执行结果。比如看到 stderr 里有内容,就知道有警告或错误需要关注。
空输出也有明确反馈:"命令执行成功(无输出)。"这让模型知道命令确实执行了,只是没有输出。
错误处理:区分退出码和超时
命令执行失败有两种典型情况:退出码非零(命令本身报错)和超时被杀。formatCommandError 分别处理:
function formatCommandError(error: unknown): string {
if (isExecError(error)) {
const parts: string[] = [`exit code: ${error.code}`];
if (error.stdout?.trim()) {
parts.push(`stdout:\n${truncateOutput(error.stdout.trim())}`);
}
if (error.stderr?.trim()) {
parts.push(`stderr:\n${truncateOutput(error.stderr.trim())}`);
}
if (error.killed) {
parts.push("命令因超时被终止。");
}
return parts.join("\n\n");
}
return `执行命令出错:${String(error)}`;
}即使命令执行失败(退出码非零),exec 的 error 对象中仍然会包含 stdout 和 stderr。很多测试框架在测试失败时把失败详情输出到 stdout,把错误堆栈输出到 stderr。把这些信息都返回给模型,模型就能分析失败原因。
超时的判断用的是 error.killed——Node.js 在因为超时杀掉进程时会设置这个字段为 true。模型收到"命令因超时被终止"的提示,就知道这个命令确实需要更长时间,而不是本身出了问题。
isExecError 是一个类型守卫函数,确保我们安全地访问 error 对象的属性:
function isExecError(
error: unknown,
): error is {
code: number | null;
stdout: string;
stderr: string;
killed: boolean;
message: string;
} {
return (
error != null &&
typeof error === "object" &&
"code" in error &&
(typeof (error as { code: unknown }).code === "number" ||
(error as { code: unknown }).code === null)
);
}在工作目录下执行
const { stdout, stderr } = await execAsync(command, {
cwd: state.workingDir,
// ...
});cwd: state.workingDir 确保命令在用户的工作目录下执行。这意味着 agent 跑 npm test 时,是在项目的根目录下跑,和用户自己在终端里执行的效果一样。不需要 cd,不需要传绝对路径。
安全考量
run_command 是整个工具集中能力最强、也是风险最高的工具。它能执行任意命令,包括 rm -rf、curl 下载并执行脚本等危险操作。
当前的实现采取了以下安全措施:
超时限制。 防止无限循环或长时间运行的命令阻塞 agent 循环。
输出截断。 防止恶意或失控的命令通过大量输出消耗资源。
工作目录限制。 命令在指定的工作目录下执行,而非系统根目录。不过这并不是沙箱——命令仍然可以通过绝对路径访问系统其他位置。
没有做的安全措施。 当前没有命令白名单或黑名单。这意味着模型可以执行任何命令。在后面的章节中,我们会引入权限系统来控制哪些命令需要用户确认。
实际执行效果
假设 agent 修改了一个文件后跑测试:
> run_command: npm test
exit code: 1
stdout:
FAIL src/utils/math.test.ts
● add › should sum two numbers
expect(received).toBe(expected)
Expected: 4
Received: 5
6 | test('should sum two numbers', () => {
7 | expect(add(2, 2)).toBe(4);
| ^
8 | });
stderr:
Test Suites: 1 failed, 3 passed, 4 total
Tests: 1 failed, 12 passed, 13 total模型看到这个输出,能分析出:add(2, 2) 返回了 5 而不是 4,测试在第 7 行断言失败。然后它会去查看 src/utils/math.ts 中 add 函数的实现,找到 bug 并修复。
这就是"执行与验证"的完整闭环。没有 run_command,模型改完代码只能猜测结果。有了它,模型能真实地运行代码、看到错误、定位问题、修复后再次验证。
注册命令工具
在工具注册表中加入 runCommandTool:
// src/tools/index.ts
import { runCommandTool } from "./run-command";
export const tools: Tool[] = [
searchTool,
globTool,
readFileTool,
writeFileTool,
patchFileTool,
runCommandTool,
// ... git 工具在下一节
];至此,agent 已经有了搜索、读写文件、执行命令的能力。它还不能查看自己到底改了什么——这就是下一节 Git 工具要解决的问题。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。