Agent 工具系统搭建:4 个内置工具让 Agent 学会写代码
这是 code-artisan 拆解系列第三篇。
前言
上一篇我们把 ReAct loop 的骨架搭起来了:可中断、可流式、多模型。但通篇能"做事"的工具只有一个 get_weather,还是 mock 的。
这一篇我们要把这个通用 Agent 武装成 Coding Agent:能读文件、能改文件、能跑命令、能起后台进程。还会把工具系统的几个常见问题处理掉:
- 工具定义怎么写才能 schema 和 impl 一体、还类型安全
- 同一份工具怎么同时喂给 OpenAI 协议和 Anthropic 协议
- 单个工具炸了怎么不让整个 agent run 跟着崩
npm run dev这种常驻命令,agent 怎么使用
文章每一 Feature 配一个独立可运行的 demo,链接放在每节末尾。读者可以访问进行体验
Part 1:defineTool + Zod · 工具定义的统一形态
回顾上一篇的工具是怎么写的:
ts
const tools = [
{
type: "function" as const,
function: {
name: "get_weather",
description: "查询某个城市的天气",
parameters: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
},
},
},
];
const toolImpls: Record<string, (input: any) => Promise<string>> = {
get_weather: async ({ city }) => `${city} 今天 25°C 晴`,
};
三个让人不舒服的地方:
- schema 和 impl 分两处:加一个工具要改两个地方,删一个工具也要改两个地方
- 没有类型 :
(input: any)是裸的 any,schema 写了{ city: string }但 impl 拿到的还是 any,typo 了不会报错 - 写死 OpenAI 形态 :
type: "function"这层壳是 OpenAI 协议的,换 Anthropic 又要重写一遍
这一节就是把这三件事合到一起去维护。我们先定义一个 defineTool 工厂:
ts
import { z } from "zod";
interface ToolContext {
signal?: AbortSignal;
}
interface FunctionTool<P extends z.ZodObject = z.ZodObject, R = unknown> {
name: string;
description: string;
parameters: P;
invoke: (input: z.infer<P>, ctx: ToolContext) => Promise<R>;
}
function defineTool<P extends z.ZodObject, R>(opts: {
name: string;
description: string;
parameters: P;
invoke: (input: z.infer<P>, ctx: ToolContext) => Promise<R>;
}): FunctionTool<P, R> {
return opts;
}
参数用 Zod 写,invoke 的第一参数自动从 z.infer<P> 推出来:schema 改了类型自动跟着变,TypeScript 会帮我们看着不让 typo。
ctx 这个第二参数现在只放了 signal(沿用上一篇的 abort 通路)。后面要给工具喂运行时上下文(工作目录、沙箱等等)就往 ctx 里加,不污染 input schema。
那么现在一个工具就长这样:
ts
const getWeatherTool = defineTool({
name: "get_weather",
description: "查询某个城市的天气",
parameters: z.object({
city: z.string().describe("城市中文名,例如:北京、上海"),
}),
invoke: async ({ city }, { signal }) => {
await new Promise((r) => setTimeout(r, 300));
if (signal?.aborted) throw signal.reason;
return `${city} 今天 25°C 晴`;
},
});
schema 用 .describe() 给字段加描述。这段描述会被 LLM 看到,是 LLM 决定怎么调工具的关键依据。
Zod schema 给到 LLM 的时候要变 JSON Schema
OpenAI / Anthropic 的 API 要的 tool 参数描述是 JSON Schema,不是 Zod schema。Zod 4 有内置转换:
ts
import { z } from "zod";
const schema = z.object({ city: z.string() });
console.log(z.toJSONSchema(schema));
// → { type: "object", properties: { city: { type: "string" } }, required: ["city"] }
主循环里把 tool 喂给 LLM 的时候,调用 z.toJSONSchema:
ts
const resp = await this.client.chat.completions.create({
model: "deepseek-chat",
messages: this.messages,
tools: tools.map((t) => ({
type: "function" as const,
function: {
name: t.name,
description: t.description,
parameters: z.toJSONSchema(t.parameters) as Record<string, unknown>,
},
})),
});
如果是 Anthropic Provider,也是同一个 t.parameters 调用 z.toJSONSchema 拿 JSON Schema,只是塞到 input_schema 字段里。同一份工具定义,一行不用改就能跨LLM协议。
工具调用时多做一步 parse
注意LLM 返回的 tool_calls[i].function.arguments 是 JSON 字符串。我们之前是直接 JSON.parse 然后丢给工具函数去执行。现在多做一步:用 Zod schema 验证一遍。
ts
const tool = tools.find((t) => t.name === call.function.name);
const input = tool.parameters.parse(JSON.parse(call.function.arguments));
const result = await tool.invoke(input, ctx);
为什么要 parse?模型幻觉可能偶尔会少传字段、传错类型。Zod 的 .parse() 直接抛异常出来,我们后面有错误隔离机制兜底。这样的好处是总比脏数据进了工具的 invoke 函数好排查。
🟢 在线试一下 → Part 1 Playground
Part 2:第一个 builtin 工具 · read_file
工具系统的形态对了,现在加点真东西。Coding Agent 第一个要的能力一定是读文件。
我们直接使用用 node:fs/promises:
ts
import { readFile } from "node:fs/promises";
const readFileTool = defineTool({
name: "read_file",
description: "读取指定路径的文本文件",
parameters: z.object({
path: z.string().describe("文件的绝对路径"),
}),
invoke: async ({ path }) => {
const content = await readFile(path, "utf8");
if (!content) return "(empty)";
return content;
},
});
虽然很短,但已经是个能用的工具。但前提是没有碰到长文件。
长文件会撑爆 LLM 上下文
LLM 的上下文窗口虽然大(DeepSeek 64k,Claude 200k),但一个工具返回 50000 tokens 直接吃掉一半的预算,剩下的 step 就没空间走了。更糟的是,多数情况 LLM 不会通篇浏览整篇文章,往往只会找对应关键词的段落。
所以可以再加一层截断逻辑:超过 12000 字符就保留头部 80% + 尾部 20%,中间用一行说明替代:
ts
const MAX_FILE_CHARS = 12000;
const readFileTool = defineTool({
name: "read_file",
description: "读取指定路径的文本文件。文件超过 12000 字符时会自动截断(保留头部 80% + 尾部 20%)",
parameters: z.object({
path: z.string().describe("文件的绝对路径"),
}),
invoke: async ({ path }) => {
const content = await readFile(path, "utf8");
if (!content) return "(empty)";
if (content.length > MAX_FILE_CHARS) {
const headChars = Math.floor(MAX_FILE_CHARS * 0.8);
const tailChars = Math.floor(MAX_FILE_CHARS * 0.2);
const head = content.slice(0, headChars);
const tail = content.slice(-tailChars);
const omitted = content.length - headChars - tailChars;
return `${head}\n\n[... ${omitted} 字符省略 ...]\n\n${tail}`;
}
return content;
},
});
这里有两个细节:
- 截断信息也写进 description :LLM 看到 "超过 12000 字符会截断" 就知道遇到大文件应该用
start_line/end_line范围读(如果你给工具加了这两个参数)。code-artisan 的 read_file 就有这两个范围参数,篇幅原因这里不展开,只是做了类似于分页的逻辑。 - 截断信息要明显 :
[... N 字符省略 ...]这种夹在中间的标记 LLM 一眼就能看出来。如果只是默默 truncate 掉尾巴,模型可能基于半截内容做错误推断
🟢 在线试一下(agent 读 README.md 然后总结)→ Part 2 Playground
Part 3:write_file / str_replace / bash + 错误隔离
只读是不够的。Coding Agent 至少要包含 4 个 builtin 工具:read_file / write_file / str_replace / bash。
write_file:暴力覆盖
ts
import { writeFile } from "node:fs/promises";
const writeFileTool = defineTool({
name: "write_file",
description: "覆盖写入文件(不存在则创建,存在则整个覆盖)",
parameters: z.object({
path: z.string().describe("文件的绝对路径"),
content: z.string().describe("文件内容"),
}),
invoke: async ({ path, content }) => {
await writeFile(path, content, "utf8");
return `wrote ${content.length} chars to ${path}`;
},
});
简单,但只在新建文件 / 整个文件重写时 用。改一个常量、加一行 import 这种小改动 绝对不要用 write_file,让 LLM 把整个文件重写一遍。既费 token,又容易把它没注意的部分改坏。所以我们还有str_replace 工具。
str_replace:改一处或全改
ts
const strReplaceTool = defineTool({
name: "str_replace",
description: "在文件里把 old_str 替换成 new_str。默认只换第一处出现;传 replace_all=true 才会全部替换",
parameters: z.object({
path: z.string().describe("文件的绝对路径"),
old_str: z.string().describe("要替换的旧字符串"),
new_str: z.string().describe("新字符串"),
replace_all: z.boolean().optional().describe("是否替换所有匹配"),
}),
invoke: async ({ path, old_str, new_str, replace_all }) => {
let content = await readFile(path, "utf8");
if (!content.includes(old_str)) throw new Error(`old_str 在 ${path} 里没找到`);
content = replace_all
? content.replaceAll(old_str, new_str)
: content.replace(old_str, new_str);
await writeFile(path, content, "utf8");
return "OK";
},
});
默认只换第一处 ,LLM 绝大多数小改动就是改单点(改一个常量、调一行赋值)、要批量替换的时候(统一改个变量名、把所有 console.log 换成 logger.info),它自己加 replace_all: true 就行------LLM 知道自己想换几处,不用工具层面替它兜底。
这种
old_str / new_str模式跟 diff 编辑比,优势是 token 成本远低:LLM 只要写新老字符串,不用写行号、不用拼 diff hunks。
bash:让 agent 能跑命令
child_process.exec 套个 promisify:
ts
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
const bashTool = defineTool({
name: "bash",
description: "在工作目录里执行 bash 命令,同步返回 stdout / stderr",
parameters: z.object({
command: z.string().describe("要执行的 bash 命令"),
}),
invoke: async ({ command }, { signal, cwd }) => {
const { stdout, stderr } = await execAsync(command, { cwd, signal, timeout: 10_000 });
const out = (stdout || "") + (stderr ? `\n${stderr}` : "");
return out.trim() || "(no output)";
},
});
timeout: 10_000:前台命令必须有超时限制,防止LLM 偶尔会调一些卡死的命令,影响整体 agent loopsignal透传 :abort()时把 signal 传给 exec,正在跑的命令直接收到 SIGTERM。这是上一篇 abort 功能的实际用处。
bash 同步等待这一点决定了它只适合短命令 。npm run dev、vitest --watch 这种永远不退出的,强行同步会等到 10 秒 timeout 报错。我们下一节解决这个。
错误隔离:单工具异常不能让整个 run 抛出错误
工具会失败:路径写错、命令 exit 非零、网络断了、JSON parse 不过、唯一性校验没过......都正常。关键是单个工具失败不能把整个 agent run 拖下水。需要让 LLM 看到错误,自己决定下一步(重试 / 换方法 / 放弃 / 报告用户)才是健康的循环。
主循环里 dispatch 工具那段,每个 invoke 都包 try/catch,错误统一包成 Error: xxx 文本塞回 tool_result:
ts
const toolMsgs = await Promise.all(
assistant.tool_calls.map(async (call: any) => {
let result: string;
try {
const tool = tools.find((t) => t.name === call.function.name);
if (!tool) throw new Error(`Tool ${call.function.name} not found`);
const input = tool.parameters.parse(JSON.parse(call.function.arguments));
const r = await tool.invoke(input, ctx);
result = typeof r === "string" ? r : JSON.stringify(r);
} catch (err) {
// 单工具错误隔离:把异常包成 "Error: xxx" 的 tool_result,run 继续
// LLM 看到错误自己决定下一步(重试 / 换工具 / 放弃)
result = `Error: ${err instanceof Error ? err.message : String(err)}`;
}
return {
role: "tool" as const,
tool_call_id: call.id,
content: result,
};
})
);
这个改动只有 5 行 try/catch,但是起到非常重要的作用 。回想上一篇我们没有这层兜底:tool.parameters.parse 抛错直接跳到外层 catch、整个 run 中断。生产环境一个工具 typo 就把对话弄死了。
🟢 在线试一下(让 agent 用 str_replace 改 README,再用 bash cat 验证)→ Part 3 Playground
Part 4:长任务 bash · run_in_background
bash 工具同步等命令完成的设计,遇到 npm run dev 这种启动后不退出的命令直接卡死。但 coding agent 经常需要起 dev server。不然怎么验证刚写的代码能跑?
设计目标:bash(run_in_background: true) 立即返回一个 session_id,后台进程的 stdout / stderr 累积到 buffer。再创建两个工具配合:
bash_output(session_id, since_offset?):读后台 buffer。可以传上次的 offset 只读增量kill_shell(session_id):发 SIGKILL 终止它
agent 起完 dev server,过几秒调一次 bash_output 看看 "Listening on" 出来没,就跟我们平常开发的时候一样盯着终端等服务起来一样。
这里目前是在内存中管理,但实际上
ts
import { spawn, type ChildProcess } from "node:child_process";
interface Session {
process: ChildProcess;
command: string;
output: string;
done: boolean;
exitCode: number | null;
}
class SessionManager {
private sessions = new Map<string, Session>();
start(command: string, cwd?: string): { id: string; pid: number | undefined } {
const id = `bg_${Math.random().toString(36).slice(2, 10)}`;
const proc = spawn("bash", ["-c", command], { cwd });
const session: Session = { process: proc, command, output: "", done: false, exitCode: null };
this.sessions.set(id, session);
// stdout / stderr 一起累积到一个 buffer,保持时序
proc.stdout?.on("data", (chunk: Buffer) => { session.output += chunk.toString(); });
proc.stderr?.on("data", (chunk: Buffer) => { session.output += chunk.toString(); });
proc.on("exit", (code) => {
session.done = true;
session.exitCode = code;
});
return { id, pid: proc.pid };
}
read(id: string, since = 0) {
const s = this.sessions.get(id);
if (!s) return null;
return {
output: s.output.slice(since),
done: s.done,
exitCode: s.exitCode,
nextOffset: s.output.length,
};
}
kill(id: string): boolean {
const s = this.sessions.get(id);
if (!s) return false;
s.process.kill("SIGKILL");
return true;
}
}
几个细节:
- stdout 和 stderr 合并到一个 buffer:保持输出的时间顺序。LLM 看到的就是终端里那种穿插的样子,比分两个 buffer 自然
done标志 :bash_output返回的 done 字段告诉 LLM 这个进程是不是还活着。dev server 永远 done=false(正常),构建命令跑完会 done=true(也正常)nextOffset让 agent 做增量轮询:下次只要传上次的 nextOffset 进来就只读新出现的输出,不用重复消费已经看过的
bash 工具升级
增加 background 参数,支持启动常驻命令
ts
const bashTool = defineTool({
name: "bash",
description:
"执行 bash 命令。默认前台同步返回 stdout/stderr。" +
"对长任务(dev server / watch / tail 日志)传 run_in_background:true,会立即返回 session_id,输出累积到后台缓冲。" +
"之后用 bash_output 读取,用 kill_shell 关停。",
parameters: z.object({
command: z.string().describe("要执行的 bash 命令"),
run_in_background: z
.boolean()
.optional()
.default(false)
.describe("true 则后台跑,立即返回 session_id;false(默认)前台同步等待"),
}),
invoke: async ({ command, run_in_background }, { signal, cwd, sessions }) => {
if (run_in_background) {
const { id, pid } = sessions.start(command, cwd);
return `started session=${id} pid=${pid}. 用 bash_output 读输出,用 kill_shell 关停。`;
}
const { stdout, stderr } = await execAsync(command, { cwd, signal, timeout: 10_000 });
const out = (stdout || "") + (stderr ? `\n${stderr}` : "");
return out.trim() || "(no output)";
},
});
description 里要明确告诉 LLM 什么时候用 background、什么时候不用:dev server 这种用 background;ls 这种一定不能用 background(不然就拿不到输出了)。这一点写在 description 里,LLM 大概率能正确选。
bash_output 和 kill_shell 直接复用 SessionManager 上的 read / kill:
ts
const bashOutputTool = defineTool({
name: "bash_output",
description: "读后台 session 累积的输出。可选 since_offset 只读最近增量;不传则读全部。返回 done 标志判断进程是否退出。",
parameters: z.object({
session_id: z.string(),
since_offset: z.number().optional().describe("上次调用返回的 nextOffset,传它可以只读增量输出"),
}),
invoke: async ({ session_id, since_offset }, { sessions }) => {
const r = sessions.read(session_id, since_offset ?? 0);
if (!r) return `session not found: ${session_id}`;
return JSON.stringify(r);
},
});
const killShellTool = defineTool({
name: "kill_shell",
description: "向后台 session 发 SIGKILL 终止它",
parameters: z.object({ session_id: z.string() }),
invoke: async ({ session_id }, { sessions }) => {
const ok = sessions.kill(session_id);
return ok ? `killed ${session_id}` : `session not found: ${session_id}`;
},
});
实际跑起来 agent 是怎么轮询的
让 agent 启动一条 4 秒后才打印 "Listening on :3000" 的命令,然后等到 ready:
ts
await agent.run(
"请用 bash(run_in_background=true) 启动这条命令:'echo starting && sleep 4 && echo \"Listening on :3000\"'," +
"拿到 session_id 后用 bash_output 轮询它(每次间隔大约 1-2 秒)," +
"等到输出里出现 'Listening' 字样就告诉我服务起来了,然后调 kill_shell 关掉它。",
{ signal, sessions }
);
跑下来你会看到 agent:
bash({ command: "echo starting && sleep 4 && ...", run_in_background: true })→ 拿到session=bg_xxxbash_output({ session_id: "bg_xxx" })→ 看到starting、done=false- 隔 2 秒再
bash_output→ 还是只有starting - 再隔 2 秒
bash_output→ 看到Listening on :3000 kill_shell({ session_id: "bg_xxx" })→ 关掉- 报告:"服务已起来,已关闭"
这就是 coding agent 启动 dev server / 等待编译的标准玩法。code-artisan 的真实实现还多了 PTY 终端、preview URL 自动绑定、用户也能挂上同一个 session 看输出。这些是产品层的事,工具核心机制就是上面这套。
🟢 在线试一下 → Part 4 Playground
写在最后
到这一篇结束,我们的 Agent Loop 已经从"会用工具的聊天机器人"变成了一个可读、可写、可运行命令、可起后台进程的 Coding Agent:
- Part 1 :defineTool + Zod · schema/impl 一体、类型从 schema 推导、
z.toJSONSchema跨协议适配 - Part 2:read_file · 长文件头尾截断保护上下文
- Part 3:write_file / str_replace / bash + 错误隔离 · builtin 工具集 + 单工具失败兜底
- Part 4:run_in_background · SessionManager 管理后台进程,agent 用 bash_output 轮询
不过越往生产走,光有工具系统还不够。我们还有几个横切关注点没解决:
- 长对话上下文怎么压缩?token 一直涨终究会爆
- 模型陷入死循环(连续 5 次调同一个 read_file)怎么自动检测并打断 LLM
- 用户额度用完了怎么优雅退出,而不是把请求打爆,浪费多余的token
- 工具调用前后想统一加日志、加 telemetry、加权限检查,怎么统一去做,而不是每个工具自己加。
下一篇会拆 code-artisan 的 middleware system :8 个生命周期钩子(beforeAgentRun / afterModel / beforeToolUse / afterToolUse 等等),怎么把上下文压缩、loop detection、quota 检查这些横切逻辑统一插进 agent 主循环,主循环不动一行代码。
完整代码在 GitHub:github.com/lhz960904/c...(这篇文章拆的核心文件是 packages/agent/tools/)。如果觉得这个项目对你有帮助,欢迎点个 star ⭐。