
🚀 欢迎来到「不用框架,手搓 AI Agent」系列第四篇。
无论是 Claude Code、Cursor ,还是 Codex,这些火爆全网的 AI 编程 Agent 看起来无所不能------能看代码、能改 Bug、还能自己跑测试。
但如果把它们的底层彻底拆开,你会发现:一个能真正干活的 AI Agent,核心秘密其实只藏在我们下面要讲的 4 个最基础的工具里。
如果你是第一次看到这个系列,完全可以从这篇开始。你只需要先记住 Agent 的底层公式:
Agent Loop = 模型决定下一步 → 程序执行工具 → 把结果交回模型 → 重复今天,我们来手搓 Claude Code 同款的"读-改-跑"闭环与工具注册中心!
先来"放个毒",看看经过本系列的打磨,我们最终会亲手搞出一个怎样的"完全体"

这一篇,我们就来补上这套最小工具箱。
前面我们的 Agent 已经能调用 read_file,读取项目中的文件。今天,我们再把创建文件、局部修改和执行命令也交给它,并且解决一个更关键的问题:
当工具从 1 个变成 4 个、10 个,甚至几十个时,怎样让它们用同一种方式定义、注册和执行?
写完以后,Agent 不只会看代码,还能真正完成一个小开发任务:
text
用户:创建 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 是一种常见的命令行解释器,我们可以通过它运行项目里的命令。
比如,它可以运行:
bash
npm run build # 编译项目
npm test # 运行测试
node hello.js # 运行一个 JavaScript 文件
git status # 查看当前项目的改动
在这一篇里,我们主要用 bash 做构建、测试和运行验证;读写文件则优先交给专门的文件工具。
不过,bash 的能力范围最广:它理论上可以执行当前用户有权限执行的命令。后面我们会专门处理它的安全边界。
有了这些概念,再回头看上一篇的 agent.ts。当时只有 read_file 一个工具,处理调用的代码大概是这样:
bash
if (toolCall.function.name !== "read_file") {
throw new Error(`暂时不支持工具:${toolCall.function.name}`);
}
const result = await readFileTool(toolCall.function.arguments);
当我们只有一个工具时,这样写非常直接。
可工具一多,问题就来了:每增加一个工具,我们都得继续往 agent.ts 里加判断。
bash
if (name === "read_file") {
// 读文件
} else if (name === "write_file") {
// 写文件
} else if (name === "edit_file") {
// 改文件
} else if (name === "bash") {
// 跑命令
}
以后再加搜索、Git、浏览器、数据库,就继续往后追加。
问题还不只是一长串 if...else。每增加一个工具,我们至少要改三个地方:
- 写一份给模型看的工具说明;
- 写真正执行工具的函数;
- 去 Agent 循环里增加一个分支。
也就是说,Agent 既要负责"循环",又要认识每一个工具的实现细节。
这就像公司每招一个新同事,老板都要亲自记住他的电话、工位和工作流程。人少时还能撑住,人一多肯定会乱。
所以,我们给它加一个工具管理中心:
bash
Agent:我想调用 edit_file,这是参数
↓
Registry:我来查谁叫 edit_file
↓
EditFileTool:我负责真正修改文件
↓
Registry:把结果交还给 Agent
这个管理中心,在代码里就叫 Registry(注册中心) 。
第一步:先规定所有工具都要长什么样
要统一管理工具,第一步不是写 Registry,而是先定一套规矩。
不管一个工具是读文件、跑命令还是查数据库,它至少都要回答三个问题:
text
你叫什么?
你要怎样向模型介绍自己?
真正被调用时,你怎样执行?
新建 src/tools/types.ts:
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是给程序调用的执行入口,负责真正干活。
这里有个很重要的区分:
text
definition:给 AI 看,帮助它决定要不要调用
execute:给我们的程序用,真正操作电脑
模型看到说明书,并不代表它自己获得了文件系统权限。真正的权限仍然掌握在我们写的 execute 里。
第二步:写一个 Registry,统一注册和执行
现在新建 src/tools/registry.ts:
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 只做三件事:
text
register 收下一个工具
getDefinitions 把全部工具说明交给模型
execute 根据名字找到工具并执行
这里用 Map,是因为它很适合保存"名字 → 工具"的对应关系:
text
read_file → ReadFileTool
write_file → WriteFileTool
edit_file → EditFileTool
bash → BashTool
以后 Agent 不再需要知道 ReadFileTool、BashTool 分别放在哪个文件里。它只把名字和参数交给 Registry 就行。
第三步:准备一个安全的项目路径工具
读、写、改文件时,都会收到模型生成的路径。
这类参数不能直接拿来用。假如模型传来:
ts
../../some-secret.txt
它的意思是"回到上两级目录,再访问 some-secret.txt"。如果我们不做检查,Agent 就可能读写项目外的文件。
所以,咱们先写一个公共方法,把所有文件工具的活动范围限制在当前项目里。
新建 src/tools/path.ts:
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。它会把路径计算成一个规范化的绝对路径 :自动处理 .、.. 这类路径片段,但不会真的读取文件,也不会检查文件是否存在。
假设项目目录是:
ts
/Users/me/power-code
那么:
ts
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。
因此:
ts
src/../package.json
和:
ts
package.json
最终指向的是同一个文件。
resolve 会自动把这种"先进入一个目录、又立刻退回来"的路径整理掉:
ts
resolve("/Users/me/power-code", "src/../package.json");
// 等价于
resolve("/Users/me/power-code", "package.json");
// /Users/me/power-code/package.json
第三个例子里的 .. 表示"上一级目录":
ts
/Users/me/power-code
→ ../
→ /Users/me
→ /Users/me/some-secret.txt
也就是说,虽然模型传入的是相对路径 ../some-secret.txt,但 resolve 会先把它算成最终的绝对路径。这样我们就能发现:它已经跑到项目目录外面了。
代码里的 isInside 用来判断最终路径是否仍在项目范围内:
ts
const isInside =
target === root || target.startsWith(`${root}${sep}`);
这里分成两种合法情况:
target === root:路径最终就是项目根目录,例如传入.或src/..;target.startsWith(root{sep}):路径位于项目根目录之下。
sep 也是从 node:path 导入的路径分隔符:
ts
macOS / Linux:/
Windows:\
假设 root 是:
ts
/Users/me/power-code
那么:
ts
`${root}${sep}`;
// /Users/me/power-code/
为什么不能只判断:
ts
target.startsWith(root);
假设项目根目录是:
ts
/Users/me/power-code
而目标路径是:
ts
/Users/me/power-code-backup/secret.txt
它同样以 /Users/me/power-code 开头,但实际上并不在项目目录里。
加上 ${root}${sep} 后,只有真正位于项目目录之下的路径才能通过检查。
后面的三个文件工具都走这一个入口,就不用各自重复写安全判断了。
这里做的仍然只是最基础的路径越界检查。resolve 不会处理软链接:一个看似位于项目目录内的软链接,仍可能指向项目外的文件。严格的生产环境还要继续处理软链接、文件权限等问题,不能把这一小段代码当成完整的文件系统沙箱。
第四步:把 read_file 改造成标准工具
上一篇已经实现了读文件。现在它的能力不用推倒重写,只要穿上统一的"工作服",变成一个实现了 Tool 接口的类。
新建 src/tools/read-file.ts:
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");
}
}
和上一篇相比,真正读文件的核心仍然是:
ts
await readFile(targetPath);
变化只是原来的常量和函数被收进了 ReadFileTool,并且统一提供 name、definition 和 execute。
第五步:加入 write_file,让 AI 能创建文件
读文件只是观察,写文件才是真正开始改变项目。
新建 src/tools/write-file.ts:
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}`;
}
}
这里有两个值得注意的地方。
首先是:
ts
await mkdir(dirname(targetPath), { recursive: true });
dirname(targetPath) 会取出目标文件所在的目录。
例如目标路径是:
ts
src/utils/date.ts
那么它所在的目录就是:
ts
src/utils
recursive: true 表示:如果 AI 想创建 src/utils/date.ts,而 src/utils 还不存在,程序会先把缺少的目录补出来。
其次是写文件时的:
ts
flag: "w"
w 表示 write(写入):
- 文件不存在:创建新文件;
- 文件已经存在:先清空旧内容,再写入新的完整内容。
其实,即使不写 flag: "w",writeFile 默认也会使用 "w"。
这里把它显式写出来,不是因为程序必须这样才能运行,而是为了让 write_file 的"创建或完整覆盖"语义直接体现在代码里。
所以 write_file 适合两种情况:
- 创建一个新文件;
- 模型已经掌握完整内容时,重写已有文件。
如果一个文件有 500 行,而模型只想改其中一两行,让它重新生成整个文件不但浪费 Token,还可能误改其他内容。
这就是咱们还需要 edit_file 的原因。
第六步:加入 edit_file,精确修改一小段内容
假设一个文件有 500 行,只想把端口从 3000 改成 8080。
如果让模型重新生成完整的 500 行,不但浪费 Token,还可能误改其他内容。
更稳妥的做法是告诉工具:
ts
在这个文件里
找到 old_text
替换成 new_text
新建 src/tools/edit-file.ts:
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}`;
}
}
这里的参数含义很直接:
ts
path 要修改哪个文件
old_text 文件中原来存在的文本
new_text 要替换成的新文本
old_text 不能为空,因为空字符串会匹配文件里的每个位置,工具无法判断你到底想改哪里。
而 new_text 可以是空字符串。这样一来,edit_file 也能用来删除一段唯一出现的文本。
为什么要计算匹配次数?
这行代码用来计算 old_text 在文件中出现了几次:
ts
const matches = content.split(oldText).length - 1;
split 可以理解成"按照某段文本,把原字符串切开"。
注意:它返回的不是匹配到的 oldText,而是 oldText 前后留下来的内容。
例如:
ts
"hello hello".split("hello");
// ["", " ", ""]
原字符串可以拆成:
ts
"" + "hello" + " " + "hello" + ""
其中:
- 第一个
"":第一个hello前面没有内容; " ":两个hello中间有一个空格;- 最后一个
"":第二个hello后面没有内容。
两个 hello 被当作分隔符移除后,留下三段内容:
ts
["", " ", ""]
所以数组长度是 3。
只要 oldText 不是空字符串,某段文本出现 n 次,就会把原字符串切成 n + 1 段:
ts
出现 0 次 → 1 段
出现 1 次 → 2 段
出现 2 次 → 3 段
因此:
ts
content.split(oldText).length - 1
就能得到 oldText 的出现次数。
接下来,工具会分三种情况处理:
ts
匹配 0 次:模型拿到的内容可能过期了,或者 old_text 写错了
匹配 1 次:安全替换
匹配多次:不知道该改哪一处,拒绝修改
注意,报错发生在 writeFile 之前,所以匹配失败时,原文件不会被修改。
什么情况才算可以安全修改?
edit_file 的成功条件只有一个:
ts
old_text 在文件中恰好出现 1 次
这里的"一次",指的是这段完整文本在文件中出现一次,不是只能修改一行代码。
例如,模型想修改开发服务器的端口,可以把整个相关代码块作为 old_text:
ts
{
"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 接下来会怎么做?
假设文件里有两处相同内容:
ts
function startDevServer() {
const port = 3000;
app.listen(port);
}
function startTestServer() {
const port = 3000;
startTestRunner(port);
}
模型如果只传入:
ts
{
"path": "src/server.ts",
"old_text": "const port = 3000;",
"new_text": "const port = 8080;"
}
工具会发现 old_text 出现两次,于是拒绝修改,并返回:
ts
工具执行失败:old_text 出现了 2 次,请提供包含前后文的更长文本。
这个错误会作为工具结果回传给 Agent。下一轮模型能看到自己刚才的调用为什么失败。
但要注意:报错不会自动把"更多文件内容"塞给模型。
"提供更多上下文"的真正意思是:模型要把 old_text 写得更具体,带上目标位置附近的代码。例如,如果它只想修改开发服务器,就可以改为:
ts
{
"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,再重试:
ts
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 能验证结果
代码写完不等于任务完成。
一个编程助手至少还要能运行构建、测试和程序本身,确认刚才的修改真的可用:
bash
npm run build
npm test
node hello.js
这就是 bash 工具的作用:让 Agent 在项目目录里执行终端命令,并把结果交回给模型。
新建 src/tools/bash.ts:
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}`,
);
}
}
}
先看这两行:
bash
import { execFile } from "node:child_process";
import { promisify } from "node:util";
execFile 是 Node.js 用来启动外部程序的函数。
例如,我们想运行 npm test,本质上就是让 Node.js 启动一个终端程序,再把 npm test 交给它执行。
不过,execFile 原本的写法使用回调函数接收结果:
ts
execFile("bash", ["-c", "npm test"], (error, stdout, stderr) => {
if (error) {
console.error(error);
return;
}
console.log(stdout);
});
这里的回调函数可以理解成:"命令还在执行时,先不要往下处理;等命令结束后,再调用我。"
但我们的 execute 方法已经在使用 async 和 await。如果继续使用回调写法,代码风格会不一致,也更容易一层一层嵌套。
所以,我们用 promisify 把 execFile 转换成 Promise 版本:
ts
const execFileAsync = promisify(execFile);
转换后,就可以使用 await 等待命令执行完成:
ts
const { stdout, stderr } = await execFileAsync(
"bash",
["-c", "npm test"],
);
命令成功时,stdout 和 stderr 里会拿到终端输出;命令失败时,await 会抛出错误,代码会进入后面的 catch。Node.js 官方也说明,经过 promisify 后,失败时的错误对象会额外带上 stdout 和 stderr,这正是我们在 catch 中读取它们的原因。Node.js child_process 文档
bash -c 到底是什么意思?
真正执行命令的是这一段:
ts
execFileAsync("bash", ["-c", command], ...)
可以拆开理解:
ts
bash 启动 Bash 终端
-c 执行后面传入的命令字符串
command 模型实际想运行的命令,例如 npm test
所以如果模型传入:
bash
{
"command": "npm test"
}
从使用效果看,就相当于人在项目目录的终端里输入:
bash
npm test
程序内部实际执行的是:
bash
bash -c "npm test"
也就是 Node.js 先启动 Bash,再把 npm test 作为命令交给 Bash 执行。 command 不一定只能是一条简单命令。
除了执行:
bash
npm test
Agent 还可以让多条命令配合完成一个任务。
例如:
bash
npm run build && npm test
它的意思是:
bash
先执行 npm run build
只有构建成功后,再执行 npm test
这里的 && 可以理解成:
bash
前一条命令成功后,再执行下一条命令
如果 npm run build 失败,后面的 npm test 就不会执行。
再看这个例子:
bash
node hello.js | grep Hello
grep 是一个在文本中查找内容的终端命令。
bash
grep Hello
可以理解成:
bash
只保留包含 Hello 的文本行
因此,整条命令的执行过程是:
bahs
node hello.js 运行 hello.js
↓
hello.js 通过 console.log 产生正常输出
↓
这些输出通过 | 交给 grep Hello
↓
grep 只保留包含 Hello 的行,再打印到终端
这里的 | 可以理解成一根"输出管道":
前一条命令持续产生的正常输出,会交给后一条命令继续处理。
它和 && 的作用不同:
bash
&&:前一条命令成功后,再执行后一条命令
| :把前一条命令的正常输出,交给后一条命令处理
使用 | 时,两边通常会同时运行。左边不需要完全结束;它一有新的输出,右边就可以开始接收和处理。
Bash 就是认识 &&、| 这类终端语法的程序。我们把模型传来的 command 交给 Bash 执行后,Agent 才能像人在终端里一样,把多条命令组合起来使用。
这也意味着 bash 的能力很强:它不只可以运行 npm test,还可以执行各种终端命令。后面讲安全边界时,我们会专门限制这种能力。
stdout 和 stderr 是什么?
终端输出通常分为两类:
bash
stdout:正常输出
stderr:错误输出或警告输出
例如,假设项目里有一个 hello.js:
hello.js
console.log("Hello, power-code");
当 Agent 执行:
bash
node hello.js
node 会运行这个 JavaScript 文件。
文件里的:
js
console.log("Hello, power-code");
会把文本打印到终端。这个正常的打印结果会进入 stdout:
bash
Hello, power-code
所以,stdout 可以先简单理解成:"命令正常运行时,打印到终端上的内容"。
而 当npm test 失败的时候,测试框架通常会把错误信息输出到 stderr。
所以我们把两者合并:
ts
const output = [stdout, stderr].filter(Boolean).join("\n").trim();
无论命令是成功输出、警告输出,还是失败输出,模型都能拿到结果。
为什么还要处理 catch?
catch 的作用不是把错误藏起来,而是保留失败命令的终端日志,交给外层的 Agent Loop 处理。
终端命令结束时会返回一个退出码:
bash
0:命令成功
非 0:命令失败
例如,npm test 只要有测试失败,通常就会返回非零退出码。此时 execFileAsync 会抛出错误,直接进入 catch,不会执行成功分支:
ts
return output || "命令执行成功,没有终端输出。";
命令超时或输出超过 maxBuffer 限制时,也会进入 catch。
失败时,终端通常已经输出了测试报错或构建日志。Node.js 会把这些 stdout 和 stderr 放在错误对象上,我们先把它们补进错误信息:
ts
throw new Error(
output
? `命令执行失败:${reason}\n\n${output}`
: `命令执行失败:${reason}`,
);
注意:这里的 throw 还不会直接把内容交给模型。
它会把包含终端日志的错误抛回 Agent Loop。后面我们再改造 agent.ts 的时候,Agent 会统一捕获所有工具错误,并把错误信息作为工具结果放回对话记录。
整个过程是:
bash
模型:运行 npm test
↓
BashTool:执行命令
↓
测试失败,BashTool 抛出带日志的错误
↓
Agent:捕获错误,并把它作为工具结果交回模型
↓
模型:看到具体报错,决定下一步怎么修复
三个基础限制
执行命令时,我们加了三个限制:
ts
{
cwd: this.workDir,
timeout: TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
}
cwd 表示 current working directory,也就是"命令从哪个目录开始执行"。
ts
cwd: this.workDir
意味着:
bash
npm test
会在当前项目目录里运行,而不是在任意目录里运行。
timeout 是最长执行时间:
ts
const TIMEOUT_MS = 30_000;
这里的 30_000 是 30 秒。如果命令超过这个时间还没结束,Node.js 会向子进程发送终止信号,随后让工具返回失败。
maxBuffer 是允许收集的最大输出量:
ts
const MAX_BUFFER = 8_000;
这里大约是 8 KB。
如果 stdout 或 stderr 超过这个限制,Node.js 会终止子进程,并让这次执行失败;错误对象中可能只保留已经收集到的部分输出。它的目的,是避免一次把巨量日志占满内存和 Agent 的上下文,而不是"悄悄截断后当作成功"。Node.js child_process 文档
这些限制不等于沙箱
一定要说清楚:
cwd、timeout和maxBuffer都只是基础限制,不等于安全沙箱。
cwd 只规定命令从哪里开始执行,并不能阻止它访问其他位置。
例如,命令仍然可以写成:
bash
cat /etc/hosts
cd ..
更危险的命令也仍然可能被执行。
bash 拿到的是当前用户拥有的命令执行权限。因此,这一版只适合在自己的练习项目里、本地盯着运行。
另外,这段代码假设系统里有 Bash:
bash
macOS / Linux:通常可以直接使用
Windows:建议通过 WSL 或 Git Bash 使用
后面我们会继续给这个版本加上失败恢复、重复调用检测和危险操作限制,让 Agent 在出错或遇到高风险命令时,知道该停下来、重试,还是请求确认。
真正上线时,通常还会进一步加入命令白名单、容器隔离、权限审批和更严格的资源限制。这些不是几行代码就能解决的问题,但现在先记住一件事就够了:
能执行命令,不等于可以放心地执行任意命令。
第八步:让 ChatClient 接收工具列表
上一篇的 chat.ts 还把工具写死了:
ts
tools: [READ_FILE_TOOL];
当时只有 read_file 一个工具,这样写没有问题。
但现在工具都交给 Registry 管理,ChatClient 不应该再导入或认识任何具体工具。它只负责一件事:接收工具说明,并把它们发送给模型。
把 src/chat.ts 改成:
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 参数:
ts
async complete(
messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[],
tools: OpenAI.Chat.Completions.ChatCompletionTool[],
)
这里的 tools 就是一组"给模型看的工具说明"。
例如,Registry 之后会提供这样的内容:
bash
read_file:可以读取文件
write_file:可以创建或覆盖文件
edit_file:可以精确替换一段文本
bash:可以执行终端命令
ChatClient 不需要知道这些工具具体怎么执行,也不需要导入 ReadFileTool 或 BashTool。
它只负责把工具说明放进请求:
ts
const response = await this.client.chat.completions.create({
model: this.config.model,
messages,
tools,
});
模型看到这些说明后,才能决定自己是否需要调用工具、调用哪个工具,以及传什么参数。
第九步:让 Agent 接收 Registry
现在 Agent 不只需要 ChatClient,还需要通过 Registry 获取工具说明、执行工具。
上一篇的 Agent 构造函数只有一个参数:
ts
constructor(private readonly client: ChatClient) {}
现在要多接收一个 registry 参数:
ts
constructor(
private readonly client: ChatClient,
private readonly registry: Registry,
) {}
最后改造 src/agent.ts。
删除:
ts
import { readFileTool } from "./readFile.js";
改成引入 Registry:
ts
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 不再把某个具体工具写死在代码里:
ts
this.registry.getDefinitions()
会从 Registry 取出全部工具说明,再交给 ChatClient 发给模型。
其次,模型发起工具调用后,Agent 不再写一长串 if...else,而是统一交给 Registry:
ts
await this.registry.execute(name, toolCall.function.arguments);
无论模型调用的是 read_file、edit_file,还是以后新增的工具,Agent 都只走这一行。
最后,所有工具错误也会在这里统一处理:
ts
try {
result = await this.registry.execute(
name,
toolCall.function.arguments,
);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
result = `工具执行失败:${reason}`;
}
例如,bash 工具执行测试失败时,会把测试日志放进错误信息;这里捕获错误后,再把它变成工具结果:
ts
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: result,
});
下一轮模型就能看到失败原因,决定重新读取文件、修改代码、再次运行测试,或者停止任务。
这就是 Agent Loop 中的失败恢复:
bash
模型调用工具
↓
工具成功:返回结果
工具失败:抛出错误
↓
Agent 统一捕获
↓
把成功结果或错误信息交回模型
↓
模型决定下一步
第十步:注册全部工具并创建 Agent
现在回到 src/main.ts,创建 Registry、注册全部工具,再把 Registry 交给 Agent。
ts
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);
});
这里的:
ts
const agent = new Agent(client, registry);
对应的就是上一步新增的构造函数参数:
ts
constructor(
private readonly client: ChatClient,
private readonly registry: Registry,
) {}
以后增加新工具时,入口只需要再注册一行:
ts
registry.register(new SearchTool(workDir));
Agent Loop 不需要再增加新的分支。
到这里,职责就分清楚了:
bash
Tool:描述并执行一种能力
Registry:保存、查找和分发全部工具
ChatClient:把工具说明发给模型
Agent:维持"想 → 做 → 看结果"的循环
跑起来试试
先编译:
bash
npm run build
然后交给它一个包含"创建 + 运行"的任务:
bash
npm run start -- -prompt "创建 hello.js,让它输出 Hello, power-code,然后运行它验证结果"
你可能会看到:

再试一次局部修改:
bash
npm run start -- -prompt "把 hello.js 的 power-code 改成 AI Agent,先读取文件,只修改必要内容,然后运行验证"
理想情况下,它会自己完成:
bash
read_file
→ edit_file
→ bash
→ 最终回答
效果如下:

注意,具体分成几轮由模型决定。它也可能在一轮里申请多个互不依赖的工具。只要工具结果都正确回传,Agent Loop 就能继续往下走。
四个工具为什么不能合成一个?
看到这里,有人可能会问:既然 bash 什么命令都能执行,那读写文件是不是也能用 cat、sed 完成?为什么还要单独写三个文件工具?
技术上当然可以,但单独提供工具有几个好处:
- 参数更结构化,模型不需要自己处理复杂的 Shell 引号;
- 每个工具可以有自己的路径检查和错误提示;
- 日志更清楚,一眼就知道 AI 在读、写还是改;
- 以后做权限控制时,可以允许读文件,但禁止写文件和执行命令。
可以把 bash 理解成万能扳手:很强,但不一定每次都最稳。能用专用工具完成的操作,优先交给专用工具。
现在的安全边界到底在哪里?
工具越强,越不能只看"能不能跑"。咱们把这一版已有和没有的边界说透。
文件工具目前做了这些基础限制:
- 拒绝解析后落到项目目录之外的路径;
read_file会截断过长内容;write_file可以创建或完整覆盖文件,但还没有人工确认;edit_file要求旧文本唯一匹配;- 工具参数会检查类型,不会盲信模型生成的 JSON。
命令工具目前只有基础刹车:
- 固定从项目目录启动;
- 最长执行 30 秒;
- 限制
stdout和stderr的大小;超出限制时,命令会失败。
但它还没有真正的隔离。当前用户能做的事,bash 理论上也可能做到。
所以请把这篇的实现当作一个本地教学版 Harness。不要把 API Key 写进项目,不要在存有重要未提交修改的目录里随便测试,更不要未经隔离就把它暴露给陌生用户。
后面我们还会继续加入失败恢复、重复调用检测和危险操作限制。真正的生产系统通常还会把命令放进容器或受限沙箱,并为写入、删除、发布等动作增加人工确认。
到这里,我们得到了什么?
这一篇表面上加了四个工具,真正重要的变化却不是"工具数量变多了",而是工具终于有了统一结构:
bash
┌─ read_file
模型 → Agent → Registry ├─ write_file
├─ edit_file
└─ bash
每个工具都提供同样的三样东西:
bash
name 我叫什么
definition 怎样向模型介绍我
execute 我怎样真正执行
以后要增加一个新能力,不需要再拆开 Agent Loop。只要:
- 实现
Tool接口; - 注册到 Registry;
- 让模型从工具说明中学会何时使用它。
这也是很多 Agent 框架底层都会有的一层。框架可能把名字包装得更复杂,但核心思路并不神秘:统一协议,加一个按名字分发的工具表。
下一篇预告
现在 AI 已经能读、写、改、运行,短任务基本像模像样了。
但任务一长,新的问题马上出现:聊天记录越来越多,它可能忘记最初目标,也可能做着做着不知道还有哪些步骤没完成。
下一篇,咱们会给它加入 Session、工作记忆和任务清单,让 AI 在长任务里记得住目标、看得见进度,也能在中断后继续干活。