第 2 章 给 Agent 加一个工具,只需要加一行 · Agent架构实操二 开始测验

给 Agent 加一个工具,只需要加一行

只有 shell 不够:模型想「读文件」,程序却得把意图翻译成 PowerShell。本章加四个专用工具 —— read / write / edit / glob,而 Agent Loop 一行都不用改
⏱ 约 15 分钟 🔧 +4 工具 → P02 共 5 个 🛡 safePath 双重路径边界 🧪 47 个离线测试

🎯 本章导读

读完这一章,你应该能用一句话回答下面每个问题。
读完能做到
  1. 独立给 Agent 加一个新工具,并说清为什么不用改 Agent Loop。
  2. 用 Zod 写出一份「模型看到的说明」和「运行时校验规则」同源的输入 schema。
  3. 说出为什么 edit_file 不能接受行号,以及模型正确的编辑工作流是什么。
  4. 解释 safePath 的两道检查各拦住什么,并说明为什么第二道必须访问磁盘。
  5. 跑通第 2 章,让 Agent 只用文件工具完成一次「写 → 改 → 读 → 找」的完整链路。
你需要先具备什么
需要程度
第 1 章必须。要理解 ToolRegistry、ToolResult、tool_call_id 配对和审批边界
TypeScript能看懂 interfaceasync/awaitinstanceof 判断
Zod不需要。本章用到的写法都会解释
文件系统概念知道「相对路径 / 绝对路径」「符号链接」大致是什么
建议的阅读路线
本章导读(你在这)
  ↓
① 先定义可观察结果        ← 本章的验收标准,9 条
  ↓
② 一个完整例子            ← 模型怎么用四个工具走完一次任务
  ↓
③ ToolRegistry            ← 「加一行」到底加在哪
  ↓
④ 文件系统边界 + Zod      ← 依赖方向、同源 schema、描述也是上下文
  ↓
⑤ safePath                ← 本章安全性的核心,两道检查
  ↓
⑥ 四个工具逐个看          ← read / write / edit / glob 各自的取舍
  ↓
⑦ 错误映射 + profile      ← 稳定错误码、P01 与 P02 的差别
  ↓
⑧ 配对与权限边界          ← 多工具单轮的硬规则、本章故意留的缺口
  ↓
⑨ 运行、验证与小结        ← 动手 + 排错表 + 自测题
提示 想先跑起来?直接跳到 运行第 2 章,那一节是自包含的步骤清单,跑通后再回头读 核心概念

📇 术语速查

本章第一次出现的词。先扫一眼,正文里遇到不认识的词回来查即可。不用背
术语一句话解释
ToolDefinition一个工具的完整声明:名字、描述、输入 schema、副作用类别、handler。五件事写在一个对象里
ZodTypeScript 的运行时校验库。一份 schema 既能校验数据,又能导出 JSON Schema
z.strictObjectZod 的严格对象: schema 里没声明的字段一律拒绝,不是忽略
.describe()给 Zod 字段加说明文字。它会进入导出的 JSON Schema,被模型读到
静态前缀 / KV Cache请求里每轮不变的开头部分(system + tools)。字节稳定时服务端可复用计算结果,省钱省延迟
safePath把「工作区相对路径」翻译成安全绝对路径的函数。翻译不了就拒绝,绝不猜测修复
junction / 符号链接指向别处的目录「快捷方式」。文本路径看着在工作区内,真实位置可能在外面
NTFS ADSWindows 的 Alternate Data Stream,靠路径里的冒号触发,例如 file:stream
Windows 保留名NULCONCOM1LPT9 等。它们指向设备而不是文件
effect(副作用类别)工具自我声明的影响范围:read / write / execute。第 2 章只是元数据,第 3 章才参与决策
WorkspaceFileSystemcore 层声明的文件能力接口。feature 只认这个接口,不认 Node
profile(P01/P02)章节能力白名单。P02 = P01 + tool_registry + files

🧠 核心概念

点击卡片展开。每个概念都对应正文一段实际逻辑。
1 · 一个完整例子:四个工具怎么串起来

用户输入:「只使用文件工具:写入 demo/note.txt,内容为 alpha;把第一处 alpha 改成 gamma;读取文件并用 glob 列出 demo/*.txt」

工具arguments回填结果
1write_file{path,demo/note.txt,content,alpha}Wrote 5 UTF-8 bytes…
2edit_file{old_text,alpha,new_text,gamma}Edited demo/note.txt
3read_file{path,demo/note.txt}gamma
4glob{pattern,demo/*.txt}demo/note.txt
5—(无工具调用)模型给出最终文本,循环退出
换成只有 shell 有多难 得写 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);

循环只认识「一次调用返回一个结果」这个抽象协议。注册表负责四件事:

  1. 按名称查找工具;
  2. 把 OpenAI 返回的 JSON 字符串解析成 object;
  3. 用 Zod 严格校验参数;
  4. 调用与 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 都非法);contentnew_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_filepath, limit?read文件文本
write_filepath, contentwrite写入字节数
edit_filepath, old_text, new_textwrite编辑确认
globpatternread匹配路径列表

effect 在第 2 章只是结构化元数据,第 3 章会用它参与权限决策

5 · 描述也是模型上下文(不只是文档)

工具 description 和参数描述不是文档,而是模型选工具时实际读到的上下文。在请求结构上,工具定义位于 API 顶层 tools 字段,与 system 共同构成每轮不变的静态前缀

KV Cache 硬约束 缓存依赖前缀的字节稳定性。每轮动态重排工具、改描述、塞时间戳,前缀就变,之前缓存的中间结果作废。ToolRegistry 按注册顺序稳定导出 + snapshot() 禁止中途改,正是为稳定前缀打基础。

「模型不需要猜测」四条检查:做什么 / 什么时候用 / 返回什么 / 失败怎么办。例:edit_file 描述要求「先读取文件,不接收行号」,模型就不会凭记忆拼旧文本。

6 · safePath:先守住 workspace(两关)

公开契约是「工作区相对路径」,运行时不猜测不修复危险输入。直接拒绝:../secret.txtnested/../secret.txtC:\secret.txt/etc/passwdNUL.txtnested/CON.logfile:streamtrailing.

第一关 词法检查(不访问磁盘):拒绝空值、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 适配器先把 ENOENTEISDIR 等系统错误翻译成 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其他受控文件系统失败换路径或换方案,不要原样重试
结尾的 throw 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 的全部行为——这是结构保证。
P01P02
模型看到的工具shellshell + 四个文件工具
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
为什么串行而不是并发 读写同一文件存在顺序依赖。并发可能读到旧内容或写了一半的文件——不确定。没有并发安全契约前不擅自并发;第 13 章引入后台任务时才正面处理。
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 只是起始目录
两套规则并存 文件工具靠代码边界,shell 靠人工审批。不要把前者的保证套到后者头上——这也是第 3 章要做统一权限系统的原因。

▶️ 交互演示 1:工具注册台

点左边按钮注册工具,看 P01→P02 的工具集如何增长,而右边的 Agent Loop 代码始终不变

注册工具(点击)

ToolRegistry 快照(P01)

// AgentRunner.run() 循环片段
for (const call of assistant.toolCalls) {
  const prepared = tools.prepare(call);
  const result = await tools.invoke(prepared, context);
  history.push(toolMessage(result.content, call.id));
}
✓ 循环代码:0 处修改

🛡 交互演示 2:safePath 路径检查器

输入一个相对路径(假装 workspace 是 D:\proj),看 safePath 放行还是拒绝、命中的是哪条规则。点芯片快速试。

🚀 运行第 2 章

前置条件和第 1 章完全一样(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_fileglob,不产生任何文件改动,适合反复跑着观察。你在哪个目录敲命令,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-Locationcode/

🧪 验证与实验

8 个测试文件、47 个测试。第 1 章的测试原样保留——它们在验证累加原则
测试文件验证内容
ch02-files.test.ts四个文件工具的成功路径、每个错误码、多工具单轮配对
filesystem.test.tssafePath 两道检查、Windows 保留名、junction 越界、glob 边界
ch01-loop.test.ts第 1 章循环行为在 P02 下仍然成立
shell-tool.test.tsshell 超时、截断、非零退出码保持不变
messages.test.tstool_call_id 严格配对
profiles.test.tsP01 只暴露 shell;P02 按固定顺序暴露五个工具
config.test.ts.env 校验与 baseUrl 归一化
openai-chat.test.tsSDK 响应收窄、refusal 处理
三个建议动手做的小实验
实验预期学到什么
一·给 read_file 传多余字段
让脚本化模型返回 {"path":"note.txt","encoding":"gbk"}
prepare() 阶段就失败,readFile 一次都没被调用strictObject + 校验前置的组合效果
二·连续两次 edit_file 用同一 old_text
第一次成功,第二次会怎样
第二次返回 text_not_found 且文件不变「只替换第一处 + 失败不写回」的实际后果
三·造一个越界 junction
New-Item -ItemType Junction -Path .\escape -Target C:\Windows\Temp(需管理员权限,用完删)
返回 path_escape,文件读不到safePath 第二关的实际效果——第一关完全放行,是磁盘解析拦住的

🔑 一句话总结

新增工具的稳定扩展路径:
Zod schema → ToolDefinition → ToolRegistry → Agent Loop(不变)

这一章真正增加的不是四个孤立函数,而是一条稳定扩展路径:先定义明确输入和副作用,再实现 handler,最后注册一次。循环不需要知道工具细节,模型看到的 schema 和运行时执行的 handler 也不会分家。

📝 本章小结

三句话版本、一定要记住的六条、以及本章还没做什么
三句话版本
  1. 加工具的正确姿势是「定义 + 注册」,Agent Loop 一行都不用改。
  2. 一份 Zod schema 同时供模型阅读和运行时校验,从根上消除「文档和实现不一致」。
  3. 文件工具的安全性来自 safePath 的两道检查:词法拦明显越界,磁盘解析拦符号链接。
一定要记住的六条
#结论出现在哪一节
1校验全在 prepare(),副作用全在 invoke(),顺序不能反ToolRegistry
2契约外的输入明确失败,不做隐式转换、不忽略多余字段用 Zod 定义严格输入
3..、绝对路径、Windows 保留名一律拒绝,不尝试修复safePath
4只看字符串挡不住 junction,必须解析磁盘真实路径为什么必须有第二关
5edit_file 用精确文本而非行号,因为行号编辑一次就失效edit_file
6catch 只翻译能列举出名字的错误,其余继续抛handler 错误映射
本章代码边界(明确「还没做什么」)
已经有还没有在哪一章补
五个工具(含四个文件工具)结构化权限规则、审计日志第 3 章
shell 逐次人工审批写工具也进审批流程第 3 章
文件工具的 safePath 边界shell 的路径边界(仍然没有)教程范围外;shell 始终依赖审批
effect 元数据effect 参与权限决策第 3 章
单轮多工具串行执行并发执行与并发安全契约第 13 章(后台任务)
完整读取 + limit 截断产物落盘、上下文压缩第 8 章
启动时固定的五个工具运行中动态增减工具第 19 章
检查你是否真的读懂了(不看文章回答)
  1. 模型传 {"path":"note.txt","limit":"10"},会发生什么?为什么不自动转成数字?
  2. nested/../secret.txt 化简后在工作区内,为什么还是拒绝?
  3. 工作区里有个指向外部的 junction,第一关和第二关分别是什么结论?
  4. write_file 写入 你好,返回的数字是 2 还是 6?为什么?
  5. edit_fileold_text 在文件里出现 3 次,会改几处?
  6. handler 的 catch 最后为什么要 throw error 而不是返回兜底错误码?
提示 答不上来的,回 核心概念safePath 检查器 再看一遍,然后做下面的测验。

QA 测验

10 道题,选完即时看解析。全部答完显示得分。