给 Agent 加一个工具,只需要加一行
🎯 本章导读
- 独立给 Agent 加一个新工具,并说清为什么不用改 Agent Loop。
- 用 Zod 写出一份「模型看到的说明」和「运行时校验规则」同源的输入 schema。
- 说出为什么
edit_file不能接受行号,以及模型正确的编辑工作流是什么。 - 解释
safePath的两道检查各拦住什么,并说明为什么第二道必须访问磁盘。 - 跑通第 2 章,让 Agent 只用文件工具完成一次「写 → 改 → 读 → 找」的完整链路。
你需要先具备什么 ▸
| 需要 | 程度 |
|---|---|
| 第 1 章 | 必须。要理解 ToolRegistry、ToolResult、tool_call_id 配对和审批边界 |
| TypeScript | 能看懂 interface、async/await、instanceof 判断 |
| Zod | 不需要。本章用到的写法都会解释 |
| 文件系统概念 | 知道「相对路径 / 绝对路径」「符号链接」大致是什么 |
建议的阅读路线 ▸
本章导读(你在这)
↓
① 先定义可观察结果 ← 本章的验收标准,9 条
↓
② 一个完整例子 ← 模型怎么用四个工具走完一次任务
↓
③ ToolRegistry ← 「加一行」到底加在哪
↓
④ 文件系统边界 + Zod ← 依赖方向、同源 schema、描述也是上下文
↓
⑤ safePath ← 本章安全性的核心,两道检查
↓
⑥ 四个工具逐个看 ← read / write / edit / glob 各自的取舍
↓
⑦ 错误映射 + profile ← 稳定错误码、P01 与 P02 的差别
↓
⑧ 配对与权限边界 ← 多工具单轮的硬规则、本章故意留的缺口
↓
⑨ 运行、验证与小结 ← 动手 + 排错表 + 自测题
📇 术语速查
| 术语 | 一句话解释 |
|---|---|
| ToolDefinition | 一个工具的完整声明:名字、描述、输入 schema、副作用类别、handler。五件事写在一个对象里 |
| Zod | TypeScript 的运行时校验库。一份 schema 既能校验数据,又能导出 JSON Schema |
z.strictObject | Zod 的严格对象: schema 里没声明的字段一律拒绝,不是忽略 |
.describe() | 给 Zod 字段加说明文字。它会进入导出的 JSON Schema,被模型读到 |
| 静态前缀 / KV Cache | 请求里每轮不变的开头部分(system + tools)。字节稳定时服务端可复用计算结果,省钱省延迟 |
| safePath | 把「工作区相对路径」翻译成安全绝对路径的函数。翻译不了就拒绝,绝不猜测修复 |
| junction / 符号链接 | 指向别处的目录「快捷方式」。文本路径看着在工作区内,真实位置可能在外面 |
| NTFS ADS | Windows 的 Alternate Data Stream,靠路径里的冒号触发,例如 file:stream |
| Windows 保留名 | NUL、CON、COM1、LPT9 等。它们指向设备而不是文件 |
| effect(副作用类别) | 工具自我声明的影响范围:read / write / execute。第 2 章只是元数据,第 3 章才参与决策 |
| WorkspaceFileSystem | core 层声明的文件能力接口。feature 只认这个接口,不认 Node |
| profile(P01/P02) | 章节能力白名单。P02 = P01 + tool_registry + files |
🧠 核心概念
1 · 一个完整例子:四个工具怎么串起来 ▸
用户输入:「只使用文件工具:写入 demo/note.txt,内容为 alpha;把第一处 alpha 改成 gamma;读取文件并用 glob 列出 demo/*.txt」
| 轮 | 工具 | arguments | 回填结果 |
|---|---|---|---|
| 1 | write_file | {path,demo/note.txt,content,alpha} | Wrote 5 UTF-8 bytes… |
| 2 | edit_file | {old_text,alpha,new_text,gamma} | Edited demo/note.txt |
| 3 | read_file | {path,demo/note.txt} | gamma |
| 4 | glob | {pattern,demo/*.txt} | demo/note.txt |
| 5 | —(无工具调用) | — | 模型给出最终文本,循环退出 |
Set-Content + -Encoding utf8 + -replace(正则!)、处理目录不存在、忘了 -NoNewline、引号转义……每一处都可能悄悄出错。加专用工具不是为了少打字,是把隐患从「模型每次都要想起来」变成「工具默认就对」。失败也是完整链路的一部分:模型试图读 ../secret.txt,不会抛异常,而是回填 Error [path_escape]: …。模型看到 path_escape 就知道换个路径——工具失败是数据,不是异常。
2 · ToolRegistry:循环只负责分发 ▸
如果把工具写死在循环里,第二个工具出现就开始堆 if (call.name === "shell") 分支——每加一个工具都要改循环。反例:
// 反例:不要让 Agent Loop 认识具体工具
if (call.name === "shell") return runShell(call.arguments);
if (call.name === "read_file") return readFile(call.arguments);
循环只认识「一次调用返回一个结果」这个抽象协议。注册表负责四件事:
- 按名称查找工具;
- 把 OpenAI 返回的 JSON 字符串解析成 object;
- 用 Zod 严格校验参数;
- 调用与 schema 绑定在同一个
ToolDefinition里的 handler。
未知工具、坏 JSON、错误类型、多余字段都会在 handler 执行前变成结构化错误。校验在 prepare(),副作用在 invoke(),顺序不能反——否则先写文件再校验就来不及了。
registry.register(createReadFileTool(fileSystem)),不必改 Loop,也不必维护另一张 handler 表。对比:写死在循环里要改 3~4 处,用注册表只动 1 处(register)。3 · 文件系统做成边界(依赖方向) ▸
文件操作是外部边界。feature 不应直接创建 Node 文件系统适配器,否则业务层反向依赖具体实现——大白话:工具 handler 里不要出现 import { readFile } from "node:fs/promises"。
bootstrap
├─ 创建 NodeWorkspaceFileSystem
└─ 注入 features
features ──依赖──> core/WorkspaceFileSystem (接口)
adapters ──实现──> core/WorkspaceFileSystem
直接 import Node fs 的代价:测「路径越界」要在磁盘上真造一个 junction;测「非法 UTF-8」要造坏字节文件;测试真会写文件、跑完得清理。通过接口注入:替身直接抛领域错误,只在内存里记一笔。测试注入替身,生产注入 Node 适配器,feature 无需知道区别。
4 · Zod 严格输入:schema 同源 ▸
z.strictObject 拒绝 schema 外字段。约束都是实际契约:path/old_text/pattern 不能为空;limit 必须是大于 0 的整数(true、"2"、0、-1 都非法);content 和 new_text 允许空串(清空/删除是有效操作)。
| 模型传来的 arguments | 结果 | 原因 |
|---|---|---|
{"path":"note.txt"} | 通过 | limit 可选 |
{"path":"note.txt","limit":"50"} | 拒绝 | 字符串不是 number,不做隐式转换 |
{"path":"note.txt","limit":0} | 拒绝 | .positive(),读 0 行无意义 |
{"path":"note.txt","encoding":"gbk"} | 拒绝 | strictObject:多余字段,不是忽略 |
{} | 拒绝 | 缺必填字段 path |
"50" 拒绝而不是转成 50——隐式转换看着「宽容」,实际掩盖模型的错误,它下次还会传字符串。多余字段拒绝而不是忽略——静默忽略等于骗它「我处理了」。契约之外的输入一律明确失败。parameters: z.toJSONSchema(definition.inputSchema) 直接生成 OpenAI tool schema。.describe() 也随它进入 schema,模型无需额外文档。模型看到的契约和 handler 接受的契约来自同一源头。| 工具 | 输入 | effect | 成功结果 |
|---|---|---|---|
read_file | path, limit? | read | 文件文本 |
write_file | path, content | write | 写入字节数 |
edit_file | path, old_text, new_text | write | 编辑确认 |
glob | pattern | read | 匹配路径列表 |
effect 在第 2 章只是结构化元数据,第 3 章会用它参与权限决策。
5 · 描述也是模型上下文(不只是文档) ▸
工具 description 和参数描述不是文档,而是模型选工具时实际读到的上下文。在请求结构上,工具定义位于 API 顶层 tools 字段,与 system 共同构成每轮不变的静态前缀。
ToolRegistry 按注册顺序稳定导出 + snapshot() 禁止中途改,正是为稳定前缀打基础。「模型不需要猜测」四条检查:做什么 / 什么时候用 / 返回什么 / 失败怎么办。例:edit_file 描述要求「先读取文件,不接收行号」,模型就不会凭记忆拼旧文本。
6 · safePath:先守住 workspace(两关) ▸
公开契约是「工作区相对路径」,运行时不猜测不修复危险输入。直接拒绝:../secret.txt、nested/../secret.txt、C:\secret.txt、/etc/passwd、NUL.txt、nested/CON.log、file:stream、trailing.。
第一关 词法检查(不访问磁盘):拒绝空值、NUL、绝对路径、盘符、..、Windows 保留组件(CON/NUL/COMx/LPTx)、冒号(ADS)、尾随点。
第二关 解析已存在父路径:若 workspace/escape 是指向工作区外的 junction/符号链接,escape/secret.txt 文本看似在内、真实路径却在外——只解析已存在的父路径(因为 write_file 允许创建还不存在的文件)重新拼出真实路径,不在 root 内就立即失败。
"escape/id_rsa" 每项都合法、拼出来也在 workspace 内,会放行。只有真正解析磁盘才发现 workspace\escape 指向 C:\Users\你\.ssh——呼应变量名:existing.lexical(文本父路径) / existing.physical(真实位置) / resolved(最终路径)。shell 的 cwd 虽是 workspace,PowerShell 仍能用绝对路径、访问网络、启动子进程。审批 ≠ 隔离。7 · 三个工具的精确语义 ▸
read_file:严格 UTF-8 解码,非法字节返回 invalid_utf8(不悄悄替换 �,因为 � 一旦进入上下文模型就看到了错误的文件内容)。执行顺序每步都是先否决再前进:校验 limit → safePath → 读字节 → 严格解码 → 按 limit 截断。limit 截断时尾部标注 ... (N more lines),避免模型猜内容是否被截。
write_file:自动建父目录(mkdir recursive),返回 bytes.byteLength 而非字符串长度。你好 的 length=2,但 UTF-8 实际 6 字节——结果与磁盘一致。
edit_file:不接受行号——行号随每次编辑整体偏移,模型很容易拿旧行号改错位(删掉第 2 行后,原第 4 行变成第 3 行)。要求旧文本+新文本,只替换第一处精确匹配,找不到不写回文件(所以重试是安全的)。读入整段只替换匹配片段,未改部分的 CRLF 不会被顺带改成 LF。
✅ read_file → 拿到精确片段 → edit_file
❌ 凭记忆/猜测拼 old_text → text_not_found第二种失败率很高,因为模型记忆里的代码往往「大概长这样」。这也是为什么 .describe() 里明确写了「先读取文件,再提供精确文本」。glob:返回 / 风格稳定排序的相对路径,无匹配返回 (no matches)(而不是空串——空串和「工具坏了」没法区分)。模式本身也必须相对;通配符前的固定前缀先过 safePath,遍历不进符号链接目录,每个匹配结果再查真实路径。三道防线,因为 glob 要遍历一整片目录树,检查点从一个变成成百上千个。
8 · handler:只在工具边界映射预期错误 ▸
Node 适配器先把 ENOENT、EISDIR 等系统错误翻译成 core 定义的领域错误。handler 在工具边界把这些预期失败转成稳定错误码,不把本机绝对路径或堆栈泄漏给模型。
| error_code | 含义 | 模型看到后应该做什么 |
|---|---|---|
path_escape | 路径违反 workspace 边界 | 换成工作区内的相对路径,不要重试同一路径 |
file_not_found | 目标文件不存在 | 先 glob 找真实路径,或改用 write_file 创建 |
text_not_found | 精确旧文本不存在 | 重新 read_file,用读到的原文再编辑 |
invalid_path | 文件与目录类型不符 | 目标是目录,换一个具体文件 |
invalid_utf8 | 文件不是合法 UTF-8 | 这是二进制文件,不要再尝试文本读取 |
filesystem_error | 其他受控文件系统失败 | 换路径或换方案,不要原样重试 |
catch 只翻译能列举出名字的、预期会发生的错误。冒出预料之外的 bug,把它转成 filesystem_error 等于把真实故障伪装成「正常失败」,模型会以为换路径重试就行,真 bug 被永久掩盖。catch 只处理你能列出名字的错误;列不出名字的,你没资格决定怎么处理它。9 · P01 与 P02 只差构建期工具集 ▸
第 1 章和第 2 章的智能体,代码上只差一个工具集合。P02 构建是「先复用,再追加」:
export const P01 = Object.freeze({
chapter: 1,
capabilities: new CapabilitySet(["loop", "powershell"]),
});
export const P02 = Object.freeze({
chapter: 2,
capabilities: new CapabilitySet(["loop", "powershell", "tool_registry", "files"]),
});
// createChapterTwoTools 第一行就调用 createChapterOneTools(…),
// 所以 P02 一定包含 P01 的全部行为——这是结构保证。
| P01 | P02 | |
|---|---|---|
| 模型看到的工具 | shell | shell + 四个文件工具 |
| Agent Loop 代码 | 同一份 | 同一份 |
| 消息历史处理 | 同一份 | 同一份 |
profile.chapter 选好注册表,之后这一轮运行里工具集合不再变化——这样每轮发的 tools 数组才稳定(回到静态前缀),测试也才能断言「P01 只暴露 shell」。运行中动态增减工具是第 19 章 MCP 的事。10 · 一轮多个工具调用仍然必须配对 ▸
工具从 1 个变成 5 个,模型可能在一轮里同时请求好几个工具。当前实现按顺序执行,结果历史必须严格配对:
assistant(tool_calls: write-1, read-1)
tool(tool_call_id: write-1, 写入结果)
tool(tool_call_id: read-1, 读取结果)
不能漏掉失败调用的结果,也不能在两个 tool 结果之间插入新的 user / assistant 消息。违反配对直接崩:
| 错法 | 结果 |
|---|---|
| 少返回一个结果 | missing tool results for ids: [write-1] |
| 配对中间插了别的消息 | 结构被破坏,OpenAI 侧也会拒绝 |
| 返回没人要的结果 | unexpected tool result id: read-1 |
11 · 第 2 章的权限边界(故意留的缺口) ▸
本章有一个刻意的不对称:
shell "Remove-Item note.txt" → 弹审批,不敲 y 就不执行
write_file "note.txt" "..." → 直接执行,你什么都看不到
两者都能改文件,但只有一个要审批。因为第 2 章还没有权限系统,只有一个硬编码的「shell 需要审批」;effect: "write" 这个标签已经贴上了,但没有任何代码去读它。
| 工具 | 有 safePath 保护吗 |
|---|---|
| read / write / edit / glob | 有,路径必须落在 workspace 内 |
shell | 没有,PowerShell 写 C:\Windows\… 完全可以,cwd 只是起始目录 |
▶️ 交互演示 1:工具注册台
注册工具(点击)
ToolRegistry 快照(P01)
const prepared = tools.prepare(call);
const result = await tools.invoke(prepared, context);
history.push(toolMessage(result.content, call.id));
}
✓ 循环代码:0 处修改
🛡 交互演示 2:safePath 路径检查器
D:\proj),看 safePath 放行还是拒绝、命中的是哪条规则。点芯片快速试。🚀 运行第 2 章
npm ci + 三个 .env 变量)。下面四步,每步都给出预期结果。第 1 步 · 先跑离线测试 ▸
Set-Location 'F:\笔记\Agent实操\code'
npm run test:ch02
Test Files 8 passed (8)
Tests 47 passed (47)
比第 1 章多了 2 个文件、17 个测试——新增的正是文件工具和路径边界。不需要 API Key,不发网络请求。
第 2 步 · 跑一个完整的四工具链路 ▸
npm run ch02 -- --prompt '只使用文件工具:写入 demo/note.txt,内容为 alpha;把第一处 alpha 改成 gamma;读取文件并用 glob 列出 demo/*.txt'
code/demo/note.txt。跑完用 Get-Content .\demo\note.txt 确认,预期输出 gamma。prompt 里「只使用文件工具」是有意加的,否则模型可能直接拼 PowerShell,走回第 1 章的逐次审批。第 3 步 · 换一个只读任务 ▸
npm run agent-tutorial -- run --chapter 2 --prompt '读取 package.json,并列出 chapters/ch02/src/**/*.ts'
这条只用 read_file 和 glob,不产生任何文件改动,适合反复跑着观察。你在哪个目录敲命令,Agent 的工作区边界就在哪里——在 code/ 里跑,safePath 守的就是 code/。
第 4 步(可选) · 完整离线验证 ▸
npm run typecheck
npm test
npm run lint
npm run format:check
npm run build
这些测试不需要 API Key,也不访问网络。真实 OpenAI 运行只是额外验证,不替代离线契约测试。
常见报错排查表 ▸
第 1 章那张表仍然适用(配置、参数、审批相关)。下面是本章新增、和文件工具有关的情况:
| 现象 | 原因 | 怎么处理 |
|---|---|---|
Error [path_escape]: … | 路径含 ..、绝对路径或 Windows 保留名 | 正常行为;看 prompt 是否让模型误以为要访问工作区外 |
Error [text_not_found]: … | 模型凭猜测拼了 old_text | 正常行为;模型下一轮应先 read_file |
Error [invalid_utf8]: … | 读到二进制文件(图片、.exe、锁文件) | 正常行为;用 glob 限定文本后缀 |
Error [file_not_found]: … | 路径写错或文件不存在 | 让模型先 glob 确认路径 |
模型仍然调 shell 不用文件工具 | prompt 没明确限定 | 写「只使用文件工具」 |
| 文件写出来是乱码 | 不是本章代码问题 | write_file 固定 UTF-8;检查打开方式 |
demo/note.txt 出现在意外位置 | 不在 code/ 下运行 | Set-Location 到 code/ |
🧪 验证与实验
| 测试文件 | 验证内容 |
|---|---|
ch02-files.test.ts | 四个文件工具的成功路径、每个错误码、多工具单轮配对 |
filesystem.test.ts | safePath 两道检查、Windows 保留名、junction 越界、glob 边界 |
ch01-loop.test.ts | 第 1 章循环行为在 P02 下仍然成立 |
shell-tool.test.ts | shell 超时、截断、非零退出码保持不变 |
messages.test.ts | tool_call_id 严格配对 |
profiles.test.ts | P01 只暴露 shell;P02 按固定顺序暴露五个工具 |
config.test.ts | .env 校验与 baseUrl 归一化 |
openai-chat.test.ts | SDK 响应收窄、refusal 处理 |
三个建议动手做的小实验 ▸
| 实验 | 预期 | 学到什么 |
|---|---|---|
| 一·给 read_file 传多余字段 让脚本化模型返回 {"path":"note.txt","encoding":"gbk"} | prepare() 阶段就失败,readFile 一次都没被调用 | strictObject + 校验前置的组合效果 |
| 二·连续两次 edit_file 用同一 old_text 第一次成功,第二次会怎样 | 第二次返回 text_not_found 且文件不变 | 「只替换第一处 + 失败不写回」的实际后果 |
三·造一个越界 junctionNew-Item -ItemType Junction -Path .\escape -Target C:\Windows\Temp(需管理员权限,用完删) | 返回 path_escape,文件读不到 | safePath 第二关的实际效果——第一关完全放行,是磁盘解析拦住的 |
🔑 一句话总结
Zod schema → ToolDefinition → ToolRegistry → Agent Loop(不变)
这一章真正增加的不是四个孤立函数,而是一条稳定扩展路径:先定义明确输入和副作用,再实现 handler,最后注册一次。循环不需要知道工具细节,模型看到的 schema 和运行时执行的 handler 也不会分家。
📝 本章小结
- 加工具的正确姿势是「定义 + 注册」,Agent Loop 一行都不用改。
- 一份 Zod schema 同时供模型阅读和运行时校验,从根上消除「文档和实现不一致」。
- 文件工具的安全性来自
safePath的两道检查:词法拦明显越界,磁盘解析拦符号链接。
一定要记住的六条 ▸
| # | 结论 | 出现在哪一节 |
|---|---|---|
| 1 | 校验全在 prepare(),副作用全在 invoke(),顺序不能反 | ToolRegistry |
| 2 | 契约外的输入明确失败,不做隐式转换、不忽略多余字段 | 用 Zod 定义严格输入 |
| 3 | ..、绝对路径、Windows 保留名一律拒绝,不尝试修复 | safePath |
| 4 | 只看字符串挡不住 junction,必须解析磁盘真实路径 | 为什么必须有第二关 |
| 5 | edit_file 用精确文本而非行号,因为行号编辑一次就失效 | edit_file |
| 6 | catch 只翻译能列举出名字的错误,其余继续抛 | handler 错误映射 |
本章代码边界(明确「还没做什么」) ▸
| 已经有 | 还没有 | 在哪一章补 |
|---|---|---|
| 五个工具(含四个文件工具) | 结构化权限规则、审计日志 | 第 3 章 |
shell 逐次人工审批 | 写工具也进审批流程 | 第 3 章 |
文件工具的 safePath 边界 | shell 的路径边界(仍然没有) | 教程范围外;shell 始终依赖审批 |
effect 元数据 | effect 参与权限决策 | 第 3 章 |
| 单轮多工具串行执行 | 并发执行与并发安全契约 | 第 13 章(后台任务) |
完整读取 + limit 截断 | 产物落盘、上下文压缩 | 第 8 章 |
| 启动时固定的五个工具 | 运行中动态增减工具 | 第 19 章 |
检查你是否真的读懂了(不看文章回答) ▸
- 模型传
{"path":"note.txt","limit":"10"},会发生什么?为什么不自动转成数字? nested/../secret.txt化简后在工作区内,为什么还是拒绝?- 工作区里有个指向外部的 junction,第一关和第二关分别是什么结论?
write_file写入你好,返回的数字是 2 还是 6?为什么?edit_file的old_text在文件里出现 3 次,会改几处?- handler 的
catch最后为什么要throw error而不是返回兜底错误码?