第 3 章 · Tool Calling 与工具系统

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 -rfcurl 下载并执行脚本等危险操作。

当前的实现采取了以下安全措施:

超时限制。 防止无限循环或长时间运行的命令阻塞 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.tsadd 函数的实现,找到 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 工具要解决的问题。

登录以继续阅读

解锁完整文档、代码示例及更多高级功能。

立即登录

On this page