Claude Code 是怎么自己改代码的?答案藏在这 4 个工具里

82 阅读21分钟

image.png

🚀 欢迎来到「不用框架,手搓 AI Agent」系列第四篇。

无论是 Claude Code、Cursor,还是 Codex,这些火爆全网的 AI 编程 Agent 看起来无所不能——能看代码、能改 Bug、还能自己跑测试。

但如果把它们的底层彻底拆开,你会发现:一个能真正干活的 AI Agent,核心秘密其实只藏在我们下面要讲的 4 个最基础的工具里。

如果你是第一次看到这个系列,完全可以从这篇开始。你只需要先记住 Agent 的底层公式:

Agent Loop = 模型决定下一步 → 程序执行工具 → 把结果交回模型 → 重复

今天,我们来手搓 Claude Code 同款的“读-改-跑”闭环与工具注册中心!

先来“放个毒”,看看经过本系列的打磨,我们最终会亲手搞出一个怎样的“完全体”

Kapture 2026-07-13 at 10.07.08.gif

这一篇,我们就来补上这套最小工具箱。

前面我们的 Agent 已经能调用 read_file,读取项目中的文件。今天,我们再把创建文件、局部修改和执行命令也交给它,并且解决一个更关键的问题:

当工具从 1 个变成 4 个、10 个,甚至几十个时,怎样让它们用同一种方式定义、注册和执行?

写完以后,Agent 不只会看代码,还能真正完成一个小开发任务:

用户:创建 hello.ts,让它输出 Hello, power-code,然后运行它

第 1 轮:AI 调用 write_file
✓ 已写入 hello.ts

第 2 轮:AI 调用 bash
Hello, power-code

AI:文件已创建,并且运行成功。

从这一刻开始,我们写的就不再只是一个“会读项目的聊天机器人”,而是一个真的能动手干活的 AI 编程助手了。

💡 补充说明:在真实工程中,诸如 Git 提交管理、浏览器交互以及 MCP 扩展等专用能力同样不可或缺。这一篇,我们先专注把“读、改、跑”这套最核心的最小闭环打通!

🚀 本节配套源码:powercode 👈点它

如果中途遇到问题,可以对照源码排查。后续章节的代码也会持续更新,如果这个项目对你有帮助,欢迎点个 Star ⭐


硬编码的困局:当工具从 1 个暴增到 10 个

在继续改造之前,先解释一下这里的“工具”。

工具就是我们开放给模型调用的一项程序能力。模型不能直接读取文件、修改代码或执行终端命令;它只能告诉我们的程序:“我想调用哪个工具,以及参数是什么。”

真正执行操作的,仍然是我们写的程序代码。

目前,这个 Agent 只有一个工具:

  • read_file:读取项目中的文本文件。

今天,我们还要加入三个工具:

  • write_file:创建文件,或用完整内容覆盖已有文件;
  • edit_file:只替换文件中的一小段内容,适合精确修改;
  • bash:在项目目录里执行终端命令。

bash 可以先理解成“让 Agent 使用终端”。Bash 是一种常见的命令行解释器,我们可以通过它运行项目里的命令。

比如,它可以运行:

npm run build  # 编译项目
npm test       # 运行测试
node hello.js  # 运行一个 JavaScript 文件
git status     # 查看当前项目的改动

在这一篇里,我们主要用 bash 做构建、测试和运行验证;读写文件则优先交给专门的文件工具。

不过,bash 的能力范围最广:它理论上可以执行当前用户有权限执行的命令。后面我们会专门处理它的安全边界。

有了这些概念,再回头看上一篇的 agent.ts。当时只有 read_file 一个工具,处理调用的代码大概是这样:

if (toolCall.function.name !== "read_file") {
  throw new Error(`暂时不支持工具:${toolCall.function.name}`);
}

const result = await readFileTool(toolCall.function.arguments);

当我们只有一个工具时,这样写非常直接。

可工具一多,问题就来了:每增加一个工具,我们都得继续往 agent.ts 里加判断。

if (name === "read_file") {
  // 读文件
} else if (name === "write_file") {
  // 写文件
} else if (name === "edit_file") {
  // 改文件
} else if (name === "bash") {
  // 跑命令
}

以后再加搜索、Git、浏览器、数据库,就继续往后追加。

问题还不只是一长串 if...else。每增加一个工具,我们至少要改三个地方:

  1. 写一份给模型看的工具说明;
  2. 写真正执行工具的函数;
  3. 去 Agent 循环里增加一个分支。

也就是说,Agent 既要负责“循环”,又要认识每一个工具的实现细节。

这就像公司每招一个新同事,老板都要亲自记住他的电话、工位和工作流程。人少时还能撑住,人一多肯定会乱。

所以,我们给它加一个工具管理中心:

Agent:我想调用 edit_file,这是参数
  ↓
Registry:我来查谁叫 edit_file
  ↓
EditFileTool:我负责真正修改文件
  ↓
Registry:把结果交还给 Agent

这个管理中心,在代码里就叫 Registry(注册中心)

第一步:先规定所有工具都要长什么样

要统一管理工具,第一步不是写 Registry,而是先定一套规矩。

不管一个工具是读文件、跑命令还是查数据库,它至少都要回答三个问题:

你叫什么?
你要怎样向模型介绍自己?
真正被调用时,你怎样执行?

新建 src/tools/types.ts

import type OpenAI from "openai";

export interface Tool {
  name: string;

  definition: OpenAI.Chat.Completions.ChatCompletionTool;

  execute(argumentsJson: string): Promise<string>;
}

这就是工具的统一接口。

你可以把 interface 理解成一张入职表:以后谁想加入工具中心,都必须交齐这三样东西。

  • name 是工具的唯一名字,例如 read_file
  • definition 是给模型看的说明书,告诉它工具有什么用、参数怎么传;
  • execute 是给程序调用的执行入口,负责真正干活。

这里有个很重要的区分:

definition:给 AI 看,帮助它决定要不要调用
execute:给我们的程序用,真正操作电脑

模型看到说明书,并不代表它自己获得了文件系统权限。真正的权限仍然掌握在我们写的 execute 里。

第二步:写一个 Registry,统一注册和执行

现在新建 src/tools/registry.ts

import type OpenAI from "openai";
import type { Tool } from "./types.js";

export class Registry {
    // 工具注册表
    // 键:工具名称
    // 值:工具实例
  private readonly tools = new Map<string, Tool>();

  /**
   * 注册工具
   * @param tool 工具
   */   
  register(tool: Tool): void {
    if (this.tools.has(tool.name)) {
      throw new Error(`工具重复注册:${tool.name}`);
    }

    this.tools.set(tool.name, tool);
  }

  /**
   * 获取所有工具的定义
   * @returns 所有工具的定义
   */   
  getDefinitions(): OpenAI.Chat.Completions.ChatCompletionTool[] {
    return [...this.tools.values()].map((tool) => tool.definition);
  }

  /**
   * 执行工具
   * @param name 工具名称
   * @param argumentsJson 工具参数 JSON 字符串
   * @returns 工具执行结果
   */   
  async execute(name: string, argumentsJson: string): Promise<string> {
    const tool = this.tools.get(name);

    if (!tool) {
      throw new Error(`找不到工具:${name}`);
    }

    return tool.execute(argumentsJson);
  }
}

Registry 只做三件事:

register       收下一个工具
getDefinitions 把全部工具说明交给模型
execute        根据名字找到工具并执行

这里用 Map,是因为它很适合保存“名字 → 工具”的对应关系:

read_file  → ReadFileTool
write_file → WriteFileTool
edit_file  → EditFileTool
bash       → BashTool

以后 Agent 不再需要知道 ReadFileToolBashTool 分别放在哪个文件里。它只把名字和参数交给 Registry 就行。

第三步:准备一个安全的项目路径工具

读、写、改文件时,都会收到模型生成的路径。

这类参数不能直接拿来用。假如模型传来:

../../some-secret.txt

它的意思是“回到上两级目录,再访问 some-secret.txt”。如果我们不做检查,Agent 就可能读写项目外的文件。

所以,咱们先写一个公共方法,把所有文件工具的活动范围限制在当前项目里。

新建 src/tools/path.ts

import { resolve, sep } from "node:path";

/**
 * 将路径解析为绝对路径,并确保它仍在工作目录内。
 */
export function resolveInWorkDir(
  workDir: string,
  relativePath: string,
): string {
  if (!relativePath.trim()) {
    throw new Error("文件路径不能为空。");
  }

  const root = resolve(workDir);
  const target = resolve(root, relativePath);
  const isInside =
    target === root || target.startsWith(`${root}${sep}`);

  if (!isInside) {
    throw new Error("只能操作当前项目里的文件。");
  }

  return target;
}

这里最关键的是 resolve

resolve 来自 Node.js 的 node:path。它会把路径计算成一个规范化的绝对路径:自动处理 ... 这类路径片段,但不会真的读取文件,也不会检查文件是否存在。

假设项目目录是:

/Users/me/power-code

那么:

resolve("/Users/me/power-code", "src/index.ts");
// /Users/me/power-code/src/index.ts

resolve("/Users/me/power-code", "src/../package.json");
// /Users/me/power-code/package.json

resolve("/Users/me/power-code", "../some-secret.txt");
// /Users/me/some-secret.txt

第二个例子里的 src/../package.json,可以拆成三段看:

  • src:先进入 src 目录;
  • ..:返回 src 的上一级目录,也就是项目根目录;
  • package.json:再访问项目根目录里的 package.json

因此:

src/../package.json

和:

package.json

最终指向的是同一个文件。

resolve 会自动把这种“先进入一个目录、又立刻退回来”的路径整理掉:

resolve("/Users/me/power-code", "src/../package.json");
// 等价于
resolve("/Users/me/power-code", "package.json");
// /Users/me/power-code/package.json

第三个例子里的 .. 表示“上一级目录”:

/Users/me/power-code
→ ../
→ /Users/me
→ /Users/me/some-secret.txt

也就是说,虽然模型传入的是相对路径 ../some-secret.txt,但 resolve 会先把它算成最终的绝对路径。这样我们就能发现:它已经跑到项目目录外面了。

代码里的 isInside 用来判断最终路径是否仍在项目范围内:

const isInside =
  target === root || target.startsWith(`${root}${sep}`);

这里分成两种合法情况:

  • target === root:路径最终就是项目根目录,例如传入 .src/..
  • target.startsWith(root{root}{sep}):路径位于项目根目录之下。

sep 也是从 node:path 导入的路径分隔符:

macOS / Linux:/
Windows:\

假设 root 是:

/Users/me/power-code

那么:

`${root}${sep}`;
// /Users/me/power-code/

为什么不能只判断:

target.startsWith(root);

假设项目根目录是:

/Users/me/power-code

而目标路径是:

/Users/me/power-code-backup/secret.txt

它同样以 /Users/me/power-code 开头,但实际上并不在项目目录里。

加上 ${root}${sep} 后,只有真正位于项目目录之下的路径才能通过检查。

后面的三个文件工具都走这一个入口,就不用各自重复写安全判断了。

这里做的仍然只是最基础的路径越界检查。resolve 不会处理软链接:一个看似位于项目目录内的软链接,仍可能指向项目外的文件。严格的生产环境还要继续处理软链接、文件权限等问题,不能把这一小段代码当成完整的文件系统沙箱。

第四步:把 read_file 改造成标准工具

上一篇已经实现了读文件。现在它的能力不用推倒重写,只要穿上统一的“工作服”,变成一个实现了 Tool 接口的类。

新建 src/tools/read-file.ts

import { readFile } from "node:fs/promises";
import type { Tool } from "./types.js";
import { resolveInWorkDir } from "./path.js";

// 最大读取字节数
const MAX_BYTES = 8_000;

/**
 *  解析路径
 * @description 解析路径,确保它在工作目录内。
 * @param workDir 工作目录
 * @param relativePath 相对路径
 * @returns 解析后的路径
 */
function parsePath(argumentsJson: string): string {
  const input: unknown = JSON.parse(argumentsJson);

  if (typeof input !== "object" || input === null || Array.isArray(input)) {
    throw new Error("工具参数必须是一个对象。");
  }

  const path = (input as Record<string, unknown>).path;

  if (typeof path !== "string") {
    throw new Error("path 必须是字符串。");
  }

  return path;
}

/**
 *  读取文件工具
 */
export class ReadFileTool implements Tool {
  readonly name = "read_file"; // 工具名称

  readonly definition = {
    type: "function" as const, // 工具类型
    // 工具函数定义
    function: { 
      name: this.name,
    // 工具描述
      description: "读取当前项目中的文本文件。",
    // 工具参数定义
      parameters: {
        type: "object",
        properties: {
          path: {
            type: "string", // 文件路径参数类型
            description: "相对于项目根目录的文件路径", // 文件路径参数描述
          },
        },
        required: ["path"], // 必须包含 path 参数
        additionalProperties: false,  // 不允许其他额外属性
      }, 
    },
  };

  constructor(private readonly workDir: string) {}

  /**
   * 执行工具
   * @param argumentsJson 工具参数 JSON 字符串
   * @returns 工具执行结果
   */
  async execute(argumentsJson: string): Promise<string> {
    const path = parsePath(argumentsJson);
    const targetPath = resolveInWorkDir(this.workDir, path);
    const content = await readFile(targetPath);

    if (content.length > MAX_BYTES) {
      return `${content.subarray(0, MAX_BYTES).toString("utf8")}

...[文件太长,只返回前 ${MAX_BYTES} 个字节]...`;
    }

    return content.toString("utf8");
  }
}

和上一篇相比,真正读文件的核心仍然是:

await readFile(targetPath);

变化只是原来的常量和函数被收进了 ReadFileTool,并且统一提供 namedefinitionexecute

第五步:加入 write_file,让 AI 能创建文件

读文件只是观察,写文件才是真正开始改变项目。

新建 src/tools/write-file.ts

import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { Tool } from "./types.js";
import { resolveInWorkDir } from "./path.js";

/**
 * 解析 write_file 的工具参数。
 */
function parseArguments(argumentsJson: string): {
  path: string;
  content: string;
} {
  const input: unknown = JSON.parse(argumentsJson);

  if (typeof input !== "object" || input === null || Array.isArray(input)) {
    throw new Error("工具参数必须是一个对象。");
  }

  const { path, content } = input as Record<string, unknown>;

  if (typeof path !== "string" || typeof content !== "string") {
    throw new Error("path 和 content 必须是字符串。");
  }

  return { path, content };
}

/**
 * 创建或完整覆盖项目中的文件。
 */
export class WriteFileTool implements Tool {
  readonly name = "write_file";

  readonly definition = {
    type: "function" as const,
    function: {
      name: this.name,
      description: "创建或完整覆盖当前项目中的文件,目录不存在时会自动创建。",
      parameters: {
        type: "object",
        properties: {
          path: { type: "string", description: "相对于项目根目录的路径" },
          content: { type: "string", description: "完整文件内容" },
        },
        required: ["path", "content"],
        additionalProperties: false,
      },
    },
  };

  constructor(private readonly workDir: string) {}

  async execute(argumentsJson: string): Promise<string> {
    const { path, content } = parseArguments(argumentsJson);
    const targetPath = resolveInWorkDir(this.workDir, path);

    await mkdir(dirname(targetPath), { recursive: true });

    await writeFile(targetPath, content, {
      encoding: "utf8",
      flag: "w",
    });

    return `已写入文件:${path}`;
  }
}

这里有两个值得注意的地方。

首先是:

await mkdir(dirname(targetPath), { recursive: true });

dirname(targetPath) 会取出目标文件所在的目录。

例如目标路径是:

src/utils/date.ts

那么它所在的目录就是:

src/utils

recursive: true 表示:如果 AI 想创建 src/utils/date.ts,而 src/utils 还不存在,程序会先把缺少的目录补出来。

其次是写文件时的:

flag: "w"

w 表示 write(写入):

  • 文件不存在:创建新文件;
  • 文件已经存在:先清空旧内容,再写入新的完整内容。

其实,即使不写 flag: "w"writeFile 默认也会使用 "w"

这里把它显式写出来,不是因为程序必须这样才能运行,而是为了让 write_file 的“创建或完整覆盖”语义直接体现在代码里。

所以 write_file 适合两种情况:

  • 创建一个新文件;
  • 模型已经掌握完整内容时,重写已有文件。

如果一个文件有 500 行,而模型只想改其中一两行,让它重新生成整个文件不但浪费 Token,还可能误改其他内容。

这就是咱们还需要 edit_file 的原因。

第六步:加入 edit_file,精确修改一小段内容

假设一个文件有 500 行,只想把端口从 3000 改成 8080

如果让模型重新生成完整的 500 行,不但浪费 Token,还可能误改其他内容。

更稳妥的做法是告诉工具:

在这个文件里
找到 old_text
替换成 new_text

新建 src/tools/edit-file.ts

import { readFile, writeFile } from "node:fs/promises";
import type { Tool } from "./types.js";
import { resolveInWorkDir } from "./path.js";

function parseArguments(argumentsJson: string): {
  path: string;
  oldText: string;
  newText: string;
} {
  const input: unknown = JSON.parse(argumentsJson);

  if (typeof input !== "object" || input === null || Array.isArray(input)) {
    throw new Error("工具参数必须是一个对象。");
  }

  const value = input as Record<string, unknown>;
  const path = value.path;
  const oldText = value.old_text;
  const newText = value.new_text;

  if (
    typeof path !== "string" ||
    typeof oldText !== "string" ||
    typeof newText !== "string"
  ) {
    throw new Error("path、old_text 和 new_text 必须是字符串。");
  }

  if (!oldText) {
    throw new Error("old_text 不能为空。");
  }

  return { path, oldText, newText };
}

export class EditFileTool implements Tool {
  readonly name = "edit_file";

  readonly definition = {
    type: "function" as const,
    function: {
      name: this.name,
      description:
        "把文件中唯一出现的一段旧文本替换成新文本。修改前应先读取文件;如果 old_text 出现多次,请带上前后文后重试。",
      parameters: {
        type: "object",
        properties: {
          path: { type: "string", description: "要修改的文件路径" },
          old_text: { type: "string", description: "文件中原有的完整文本" },
          new_text: { type: "string", description: "替换后的文本" },
        },
        required: ["path", "old_text", "new_text"],
        additionalProperties: false,
      },
    },
  };

  constructor(private readonly workDir: string) {}

  async execute(argumentsJson: string): Promise<string> {
    const { path, oldText, newText } = parseArguments(argumentsJson);
    const targetPath = resolveInWorkDir(this.workDir, path);
    const content = await readFile(targetPath, "utf8");
    const matches = content.split(oldText).length - 1;

    if (matches === 0) {
      throw new Error("没有找到 old_text,请重新读取文件后再试。");
    }

    if (matches > 1) {
      throw new Error(
        `old_text 出现了 ${matches} 次,请提供包含前后文的更长文本。`,
      );
    }

    await writeFile(targetPath, content.replace(oldText, newText), {
      encoding: "utf8",
      flag: "w",
    });

    return `已修改文件:${path}`;
  }
}

这里的参数含义很直接:

path      要修改哪个文件
old_text  文件中原来存在的文本
new_text  要替换成的新文本

old_text 不能为空,因为空字符串会匹配文件里的每个位置,工具无法判断你到底想改哪里。

new_text 可以是空字符串。这样一来,edit_file 也能用来删除一段唯一出现的文本。

为什么要计算匹配次数?

这行代码用来计算 old_text 在文件中出现了几次:

const matches = content.split(oldText).length - 1;

split 可以理解成“按照某段文本,把原字符串切开”。

注意:它返回的不是匹配到的 oldText,而是 oldText 前后留下来的内容。

例如:

"hello hello".split("hello");
// ["", " ", ""]

原字符串可以拆成:

"" + "hello" + " " + "hello" + ""

其中:

  • 第一个 "":第一个 hello 前面没有内容;
  • " ":两个 hello 中间有一个空格;
  • 最后一个 "":第二个 hello 后面没有内容。

两个 hello 被当作分隔符移除后,留下三段内容:

["", " ", ""]

所以数组长度是 3

只要 oldText 不是空字符串,某段文本出现 n 次,就会把原字符串切成 n + 1 段:

出现 0 次 → 1 段
出现 1 次 → 2 段
出现 2 次 → 3

因此:

content.split(oldText).length - 1

就能得到 oldText 的出现次数。

接下来,工具会分三种情况处理:

匹配 0 次:模型拿到的内容可能过期了,或者 old_text 写错了
匹配 1 次:安全替换
匹配多次:不知道该改哪一处,拒绝修改

注意,报错发生在 writeFile 之前,所以匹配失败时,原文件不会被修改。

什么情况才算可以安全修改?

edit_file 的成功条件只有一个:

old_text 在文件中恰好出现 1

这里的“一次”,指的是这段完整文本在文件中出现一次,不是只能修改一行代码

例如,模型想修改开发服务器的端口,可以把整个相关代码块作为 old_text

{
  "path": "src/server.ts",
  "old_text": "function startDevServer() {\n  const port = 3000;\n  app.listen(port);\n}",
  "new_text": "function startDevServer() {\n  const port = 8080;\n  app.listen(port);\n}"
}

这次调用会同时改动函数里的多行代码,但这段完整的 old_text 在文件中只出现一次,所以可以安全执行。

可以把规则理解成这样:

想做的事old_text 的匹配次数当前工具的结果
修改某一个唯一位置的一行或多行代码1 次成功替换
删除一个唯一代码块1 次,new_text 为空字符串成功删除
修改两个不同位置,但短 old_text 相同2 次拒绝修改
把所有相同文本都替换掉多次当前工具不支持

content.replace(oldText, newText) 本身只会替换第一个匹配项。我们先确认 matches === 1,就能保证它替换的正是唯一目标,而不是碰巧改了第一处。

如果 old_text 出现多次,Agent 接下来会怎么做?

假设文件里有两处相同内容:

function startDevServer() {
  const port = 3000;
  app.listen(port);
}

function startTestServer() {
  const port = 3000;
  startTestRunner(port);
}

模型如果只传入:

{
  "path": "src/server.ts",
  "old_text": "const port = 3000;",
  "new_text": "const port = 8080;"
}

工具会发现 old_text 出现两次,于是拒绝修改,并返回:

工具执行失败:old_text 出现了 2 次,请提供包含前后文的更长文本。

这个错误会作为工具结果回传给 Agent。下一轮模型能看到自己刚才的调用为什么失败。

但要注意:报错不会自动把“更多文件内容”塞给模型。

“提供更多上下文”的真正意思是:模型要把 old_text 写得更具体,带上目标位置附近的代码。例如,如果它只想修改开发服务器,就可以改为:

{
  "path": "src/server.ts",
  "old_text": "function startDevServer() {\n  const port = 3000;\n  app.listen(port);\n}",
  "new_text": "function startDevServer() {\n  const port = 8080;\n  app.listen(port);\n}"
}

这段完整文本只出现一次,因此修改可以成功。

如果模型没有足够的文件内容来构造唯一的 old_text,它应该先调用 read_file,再重试:

read_file
  ↓
edit_file:匹配多次,失败
  ↓
模型看到错误结果
  ↓
read_file(必要时重新读取)
  ↓
edit_file:带上前后文后重试

如果用户确实希望同时修改两个独立位置,当前这个版本的工具会要求 Agent 分两次调用 edit_file,每次都提供唯一的 old_text

更成熟的 Agent 可以把多个独立修改放进一次工具调用。例如 Pi 的 edit 工具支持 edits 数组;但其中每个 oldText 仍然必须唯一匹配,且多个编辑块不能重叠。也就是说,“一次改多处”和“允许同一段文本匹配多次”是两回事。Pi edit 源码

这是一条很实用的原则:

修改代码时,宁可明确失败,也不要悄悄改错。

得,而且这里除了概念解释外,代码也建议补一个小改动:命令失败时,要把终端输出带回给模型。否则 npm test 失败后,Agent 可能只看到“命令失败”,却看不到具体报错。

第七步:加入 bash,让 AI 能验证结果

代码写完不等于任务完成。

一个编程助手至少还要能运行构建、测试和程序本身,确认刚才的修改真的可用:

npm run build
npm test
node hello.js

这就是 bash 工具的作用:让 Agent 在项目目录里执行终端命令,并把结果交回给模型。

新建 src/tools/bash.ts

import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { Tool } from "./types.js";

const execFileAsync = promisify(execFile);
const TIMEOUT_MS = 30_000;
const MAX_BUFFER = 8_000;

function parseCommand(argumentsJson: string): string {
  const input: unknown = JSON.parse(argumentsJson);

  if (typeof input !== "object" || input === null || Array.isArray(input)) {
    throw new Error("工具参数必须是一个对象。");
  }

  const command = (input as Record<string, unknown>).command;

  if (typeof command !== "string" || !command.trim()) {
    throw new Error("command 必须是非空字符串。");
  }

  return command;
}

function formatOutput(stdout: string, stderr: string): string {
  return [stdout, stderr].filter(Boolean).join("\n").trim();
}

export class BashTool implements Tool {
  readonly name = "bash";

  readonly definition = {
    type: "function" as const,
    function: {
      name: this.name,
      description:
        "在当前项目目录中执行终端命令,用于构建、测试、运行代码和查看命令结果。",
      parameters: {
        type: "object",
        properties: {
          command: { type: "string", description: "要执行的命令" },
        },
        required: ["command"],
        additionalProperties: false,
      },
    },
  };

  constructor(private readonly workDir: string) {}

  async execute(argumentsJson: string): Promise<string> {
    const command = parseCommand(argumentsJson);

    try {
      const { stdout, stderr } = await execFileAsync(
        "bash",
        ["-c", command],
        {
          cwd: this.workDir,
          timeout: TIMEOUT_MS,
          maxBuffer: MAX_BUFFER,
        },
      );

      const output = formatOutput(stdout, stderr);

      return output || "命令执行成功,没有终端输出。";
    } catch (error) {
      const details = error as {
        stdout?: string;
        stderr?: string;
      };

      const reason = error instanceof Error ? error.message : String(error);
      const output = formatOutput(
        details.stdout ?? "",
        details.stderr ?? "",
      );

      throw new Error(
        output
          ? `命令执行失败:${reason}\n\n${output}`
          : `命令执行失败:${reason}`,
      );
    }
  }
}

先看这两行:

import { execFile } from "node:child_process";
import { promisify } from "node:util";

execFile 是 Node.js 用来启动外部程序的函数。

例如,我们想运行 npm test,本质上就是让 Node.js 启动一个终端程序,再把 npm test 交给它执行。

不过,execFile 原本的写法使用回调函数接收结果:

execFile("bash", ["-c", "npm test"], (error, stdout, stderr) => {
  if (error) {
    console.error(error);
    return;
  }

  console.log(stdout);
});

这里的回调函数可以理解成:“命令还在执行时,先不要往下处理;等命令结束后,再调用我。”

但我们的 execute 方法已经在使用 asyncawait。如果继续使用回调写法,代码风格会不一致,也更容易一层一层嵌套。

所以,我们用 promisifyexecFile 转换成 Promise 版本:

const execFileAsync = promisify(execFile);

转换后,就可以使用 await 等待命令执行完成:

const { stdout, stderr } = await execFileAsync(
  "bash",
  ["-c", "npm test"],
);

命令成功时,stdoutstderr 里会拿到终端输出;命令失败时,await 会抛出错误,代码会进入后面的 catch。Node.js 官方也说明,经过 promisify 后,失败时的错误对象会额外带上 stdoutstderr,这正是我们在 catch 中读取它们的原因。Node.js child_process 文档

bash -c 到底是什么意思?

真正执行命令的是这一段:

execFileAsync("bash", ["-c", command], ...)

可以拆开理解:

bash      启动 Bash 终端
-c        执行后面传入的命令字符串
command   模型实际想运行的命令,例如 npm test

所以如果模型传入:

{
  "command": "npm test"
}

从使用效果看,就相当于人在项目目录的终端里输入:

npm test

程序内部实际执行的是:

bash -c "npm test"

也就是 Node.js 先启动 Bash,再把 npm test 作为命令交给 Bash 执行。 command 不一定只能是一条简单命令。

除了执行:

npm test

Agent 还可以让多条命令配合完成一个任务。

例如:

npm run build && npm test

它的意思是:

先执行 npm run build
只有构建成功后,再执行 npm test

这里的 && 可以理解成:

前一条命令成功后,再执行下一条命令

如果 npm run build 失败,后面的 npm test 就不会执行。

再看这个例子:

node hello.js | grep Hello

grep 是一个在文本中查找内容的终端命令。

grep Hello

可以理解成:

只保留包含 Hello 的文本行

因此,整条命令的执行过程是:

node hello.js 运行 hello.js
  ↓
hello.js 通过 console.log 产生正常输出
  ↓
这些输出通过 | 交给 grep Hello
  ↓
grep 只保留包含 Hello 的行,再打印到终端

这里的 | 可以理解成一根“输出管道”:

前一条命令持续产生的正常输出,会交给后一条命令继续处理。

它和 && 的作用不同:

&&:前一条命令成功后,再执行后一条命令
| :把前一条命令的正常输出,交给后一条命令处理

使用 | 时,两边通常会同时运行。左边不需要完全结束;它一有新的输出,右边就可以开始接收和处理。

Bash 就是认识 &&| 这类终端语法的程序。我们把模型传来的 command 交给 Bash 执行后,Agent 才能像人在终端里一样,把多条命令组合起来使用。

这也意味着 bash 的能力很强:它不只可以运行 npm test,还可以执行各种终端命令。后面讲安全边界时,我们会专门限制这种能力。

stdout 和 stderr 是什么?

终端输出通常分为两类:

stdout:正常输出
stderr:错误输出或警告输出

例如,假设项目里有一个 hello.js

console.log("Hello, power-code");

当 Agent 执行:

node hello.js

node 会运行这个 JavaScript 文件。

文件里的:

console.log("Hello, power-code");

会把文本打印到终端。这个正常的打印结果会进入 stdout

Hello, power-code

所以,stdout 可以先简单理解成:“命令正常运行时,打印到终端上的内容”。

而 当npm test 失败的时候,测试框架通常会把错误信息输出到 stderr

所以我们把两者合并:

const output = [stdout, stderr].filter(Boolean).join("\n").trim();

无论命令是成功输出、警告输出,还是失败输出,模型都能拿到结果。

为什么还要处理 catch?

catch 的作用不是把错误藏起来,而是保留失败命令的终端日志,交给外层的 Agent Loop 处理。

终端命令结束时会返回一个退出码:

0:命令成功
非 0:命令失败

例如,npm test 只要有测试失败,通常就会返回非零退出码。此时 execFileAsync 会抛出错误,直接进入 catch,不会执行成功分支:

return output || "命令执行成功,没有终端输出。";

命令超时或输出超过 maxBuffer 限制时,也会进入 catch

失败时,终端通常已经输出了测试报错或构建日志。Node.js 会把这些 stdoutstderr 放在错误对象上,我们先把它们补进错误信息:

throw new Error(
  output
    ? `命令执行失败:${reason}\n\n${output}`
    : `命令执行失败:${reason}`,
);

注意:这里的 throw 还不会直接把内容交给模型。

它会把包含终端日志的错误抛回 Agent Loop。后面我们再改造 agent.ts 的时候,Agent 会统一捕获所有工具错误,并把错误信息作为工具结果放回对话记录。

整个过程是:

模型:运行 npm test
  ↓
BashTool:执行命令
  ↓
测试失败,BashTool 抛出带日志的错误
  ↓
Agent:捕获错误,并把它作为工具结果交回模型
  ↓
模型:看到具体报错,决定下一步怎么修复

三个基础限制

执行命令时,我们加了三个限制:

{
  cwd: this.workDir,
  timeout: TIMEOUT_MS,
  maxBuffer: MAX_BUFFER,
}

cwd 表示 current working directory,也就是“命令从哪个目录开始执行”。

cwd: this.workDir

意味着:

npm test

会在当前项目目录里运行,而不是在任意目录里运行。

timeout 是最长执行时间:

const TIMEOUT_MS = 30_000;

这里的 30_000 是 30 秒。如果命令超过这个时间还没结束,Node.js 会向子进程发送终止信号,随后让工具返回失败。

maxBuffer 是允许收集的最大输出量:

const MAX_BUFFER = 8_000;

这里大约是 8 KB。

如果 stdoutstderr 超过这个限制,Node.js 会终止子进程,并让这次执行失败;错误对象中可能只保留已经收集到的部分输出。它的目的,是避免一次把巨量日志占满内存和 Agent 的上下文,而不是“悄悄截断后当作成功”。Node.js child_process 文档

这些限制不等于沙箱

一定要说清楚:

cwdtimeoutmaxBuffer 都只是基础限制,不等于安全沙箱。

cwd 只规定命令从哪里开始执行,并不能阻止它访问其他位置。

例如,命令仍然可以写成:

cat /etc/hosts
cd ..

更危险的命令也仍然可能被执行。

bash 拿到的是当前用户拥有的命令执行权限。因此,这一版只适合在自己的练习项目里、本地盯着运行。

另外,这段代码假设系统里有 Bash:

macOS / Linux:通常可以直接使用
Windows:建议通过 WSL 或 Git Bash 使用

后面我们会继续给这个版本加上失败恢复、重复调用检测和危险操作限制,让 Agent 在出错或遇到高风险命令时,知道该停下来、重试,还是请求确认。

真正上线时,通常还会进一步加入命令白名单、容器隔离、权限审批和更严格的资源限制。这些不是几行代码就能解决的问题,但现在先记住一件事就够了:

能执行命令,不等于可以放心地执行任意命令。

第八步:让 ChatClient 接收工具列表

上一篇的 chat.ts 还把工具写死了:

tools: [READ_FILE_TOOL];

当时只有 read_file 一个工具,这样写没有问题。

但现在工具都交给 Registry 管理,ChatClient 不应该再导入或认识任何具体工具。它只负责一件事:接收工具说明,并把它们发送给模型。

src/chat.ts 改成:

import OpenAI from "openai";
import type { ProviderConfig } from "./config.js";

export class ChatClient {
  private readonly client: OpenAI;

  constructor(private readonly config: ProviderConfig) {
    this.client = new OpenAI({
      apiKey: config.apiKey,
      baseURL: config.baseURL,
    });
  }

  async complete(
    messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[],
    tools: OpenAI.Chat.Completions.ChatCompletionTool[],
  ) {
    const response = await this.client.chat.completions.create({
      model: this.config.model,
      messages,
      tools,
    });

    return response.choices[0]?.message;
  }
}

最关键的变化是 complete 多了一个 tools 参数:

async complete(
  messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[],
  tools: OpenAI.Chat.Completions.ChatCompletionTool[],
)

这里的 tools 就是一组“给模型看的工具说明”。

例如,Registry 之后会提供这样的内容:

read_file:可以读取文件
write_file:可以创建或覆盖文件
edit_file:可以精确替换一段文本
bash:可以执行终端命令

ChatClient 不需要知道这些工具具体怎么执行,也不需要导入 ReadFileToolBashTool

它只负责把工具说明放进请求:

const response = await this.client.chat.completions.create({
  model: this.config.model,
  messages,
  tools,
});

模型看到这些说明后,才能决定自己是否需要调用工具、调用哪个工具,以及传什么参数。

第九步:让 Agent 接收 Registry

现在 Agent 不只需要 ChatClient,还需要通过 Registry 获取工具说明、执行工具。

上一篇的 Agent 构造函数只有一个参数:

constructor(private readonly client: ChatClient) {}

现在要多接收一个 registry 参数:

constructor(
  private readonly client: ChatClient,
  private readonly registry: Registry,
) {}

最后改造 src/agent.ts

删除:

import { readFileTool } from "./readFile.js";

改成引入 Registry:

import type OpenAI from "openai";
import { ChatClient } from "./chat.js";
import { Registry } from "./tools/registry.js";

const MAX_STEPS = 8;

export class Agent {
  constructor(
    private readonly client: ChatClient,
    private readonly registry: Registry,
  ) {}

  async run(prompt: string): Promise<string> {
    const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
      {
        role: "system",
        content:
          "你是 power-code,一个研发助手。请优先读取真实文件;修改后主动运行构建或测试验证结果。请使用中文回答。",
      },
      { role: "user", content: prompt },
    ];

    for (let step = 1; step <= MAX_STEPS; step += 1) {
      const message = await this.client.complete(
        messages,
        this.registry.getDefinitions(),
      );

      if (!message) {
        throw new Error("模型没有返回消息。");
      }

      messages.push(message);
      const toolCalls = message.tool_calls ?? [];

      if (toolCalls.length === 0) {
        return message.content ?? "模型没有返回文本内容。";
      }

      for (const toolCall of toolCalls) {
        if (toolCall.type !== "function") {
          throw new Error(`暂时不支持工具类型:${toolCall.type}`);
        }

        const name = toolCall.function.name;
        console.log(`第 ${step} 轮:AI 调用 ${name}`);

        let result: string;

        try {
          result = await this.registry.execute(
            name,
            toolCall.function.arguments,
          );
          console.log(`✓ ${result}\n`);
        } catch (error) {
          const reason = error instanceof Error ? error.message : String(error);
          result = `工具执行失败:${reason}`;
          console.log(`✗ ${result}\n`);
        }

        messages.push({
          role: "tool",
          tool_call_id: toolCall.id,
          content: result,
        });
      }
    }

    throw new Error(`执行超过 ${MAX_STEPS} 轮,已停止。`);
  }
}

这里有三个关键变化。

首先,Agent 不再把某个具体工具写死在代码里:

this.registry.getDefinitions()

会从 Registry 取出全部工具说明,再交给 ChatClient 发给模型。

其次,模型发起工具调用后,Agent 不再写一长串 if...else,而是统一交给 Registry:

await this.registry.execute(name, toolCall.function.arguments);

无论模型调用的是 read_fileedit_file,还是以后新增的工具,Agent 都只走这一行。

最后,所有工具错误也会在这里统一处理:

try {
  result = await this.registry.execute(
    name,
    toolCall.function.arguments,
  );
} catch (error) {
  const reason = error instanceof Error ? error.message : String(error);
  result = `工具执行失败:${reason}`;
}

例如,bash 工具执行测试失败时,会把测试日志放进错误信息;这里捕获错误后,再把它变成工具结果:

messages.push({
  role: "tool",
  tool_call_id: toolCall.id,
  content: result,
});

下一轮模型就能看到失败原因,决定重新读取文件、修改代码、再次运行测试,或者停止任务。

这就是 Agent Loop 中的失败恢复:

模型调用工具
  ↓
工具成功:返回结果
工具失败:抛出错误
  ↓
Agent 统一捕获
  ↓
把成功结果或错误信息交回模型
  ↓
模型决定下一步

第十步:注册全部工具并创建 Agent

现在回到 src/main.ts,创建 Registry、注册全部工具,再把 Registry 交给 Agent。

import { resolve } from "node:path";
import { Agent } from "./agent.js";
import { ChatClient } from "./chat.js";
import { loadConfig } from "./config.js";
import { BashTool } from "./tools/bash.js";
import { EditFileTool } from "./tools/edit-file.js";
import { ReadFileTool } from "./tools/read-file.js";
import { Registry } from "./tools/registry.js";
import { WriteFileTool } from "./tools/write-file.js";

function getPrompt(args: string[]): string {
  const promptIndex = args.indexOf('-prompt');

  if (promptIndex === -1) {
    throw new Error('请通过 -prompt 传入问题,例如:-prompt "你好"');
  }

  const prompt = args[promptIndex + 1];

  if (!prompt) {
    throw new Error('-prompt 后面不能是空内容。');
  }

  return prompt;
}

async function main() {
  const prompt = getPrompt(process.argv.slice(2));
  const config = await loadConfig();
  const client = new ChatClient(config);
  const workDir = resolve(process.cwd());
  const registry = new Registry();

  registry.register(new ReadFileTool(workDir));
  registry.register(new WriteFileTool(workDir));
  registry.register(new EditFileTool(workDir));
  registry.register(new BashTool(workDir));

  const agent = new Agent(client, registry);

  console.log("AI 正在思考...\n");
  const answer = await agent.run(prompt);
  console.log(`AI:${answer}`);
}

main().catch((error: unknown) => {
  const message = error instanceof Error ? error.message : String(error);
  console.error(`启动失败:${message}`);
  process.exit(1);
});

这里的:

const agent = new Agent(client, registry);

对应的就是上一步新增的构造函数参数:

constructor(
  private readonly client: ChatClient,
  private readonly registry: Registry,
) {}

以后增加新工具时,入口只需要再注册一行:

registry.register(new SearchTool(workDir));

Agent Loop 不需要再增加新的分支。

到这里,职责就分清楚了:

Tool:描述并执行一种能力
Registry:保存、查找和分发全部工具
ChatClient:把工具说明发给模型
Agent:维持“想 → 做 → 看结果”的循环

跑起来试试

先编译:

npm run build

然后交给它一个包含“创建 + 运行”的任务:

npm run start -- -prompt "创建 hello.js,让它输出 Hello, power-code,然后运行它验证结果"

你可能会看到:

image.png

再试一次局部修改:

npm run start -- -prompt "把 hello.js 的 power-code 改成 AI Agent,先读取文件,只修改必要内容,然后运行验证"

理想情况下,它会自己完成:

read_file
→ edit_file
→ bash
→ 最终回答

效果如下:

image.png

注意,具体分成几轮由模型决定。它也可能在一轮里申请多个互不依赖的工具。只要工具结果都正确回传,Agent Loop 就能继续往下走。

四个工具为什么不能合成一个?

看到这里,有人可能会问:既然 bash 什么命令都能执行,那读写文件是不是也能用 catsed 完成?为什么还要单独写三个文件工具?

技术上当然可以,但单独提供工具有几个好处:

  • 参数更结构化,模型不需要自己处理复杂的 Shell 引号;
  • 每个工具可以有自己的路径检查和错误提示;
  • 日志更清楚,一眼就知道 AI 在读、写还是改;
  • 以后做权限控制时,可以允许读文件,但禁止写文件和执行命令。

可以把 bash 理解成万能扳手:很强,但不一定每次都最稳。能用专用工具完成的操作,优先交给专用工具。

现在的安全边界到底在哪里?

工具越强,越不能只看“能不能跑”。咱们把这一版已有和没有的边界说透。

文件工具目前做了这些基础限制:

  • 拒绝解析后落到项目目录之外的路径;
  • read_file 会截断过长内容;
  • write_file 可以创建或完整覆盖文件,但还没有人工确认;
  • edit_file 要求旧文本唯一匹配;
  • 工具参数会检查类型,不会盲信模型生成的 JSON。

命令工具目前只有基础刹车:

  • 固定从项目目录启动;
  • 最长执行 30 秒;
  • 限制 stdoutstderr 的大小;超出限制时,命令会失败。

但它还没有真正的隔离。当前用户能做的事,bash 理论上也可能做到。

所以请把这篇的实现当作一个本地教学版 Harness。不要把 API Key 写进项目,不要在存有重要未提交修改的目录里随便测试,更不要未经隔离就把它暴露给陌生用户。

后面我们还会继续加入失败恢复、重复调用检测和危险操作限制。真正的生产系统通常还会把命令放进容器或受限沙箱,并为写入、删除、发布等动作增加人工确认。

到这里,我们得到了什么?

这一篇表面上加了四个工具,真正重要的变化却不是“工具数量变多了”,而是工具终于有了统一结构:

                        ┌─ read_file
模型 → Agent → Registry  ├─ write_file
                        ├─ edit_file
                        └─ bash

每个工具都提供同样的三样东西:

name       我叫什么
definition 怎样向模型介绍我
execute    我怎样真正执行

以后要增加一个新能力,不需要再拆开 Agent Loop。只要:

  1. 实现 Tool 接口;
  2. 注册到 Registry;
  3. 让模型从工具说明中学会何时使用它。

这也是很多 Agent 框架底层都会有的一层。框架可能把名字包装得更复杂,但核心思路并不神秘:统一协议,加一个按名字分发的工具表。

下一篇预告

现在 AI 已经能读、写、改、运行,短任务基本像模像样了。

但任务一长,新的问题马上出现:聊天记录越来越多,它可能忘记最初目标,也可能做着做着不知道还有哪些步骤没完成。

下一篇,咱们会给它加入 Session、工作记忆和任务清单,让 AI 在长任务里记得住目标、看得见进度,也能在中断后继续干活。