| 你需要先具备 | 程度 |
|---|---|
| 第 1 章 | 必须。要理解 ToolRegistry、ToolResult、tool_call_id 配对和审批边界 |
| TypeScript | 能看懂 interface、async/await、instanceof 判断 |
| Zod | 不需要。本章用到的写法都会解释 |
| 文件系统概念 | 知道「相对路径 / 绝对路径」「符号链接」大致是什么 |
shell;P02 按固定顺序暴露五个工具;参数必须先经过严格 schema 校验再产生副作用;路径只能落在当前 workspace 内;..、绝对路径、Windows 设备名和工作区外链接必须失败;写入使用 UTF-8 并返回真实字节数;编辑只替换第一处精确文本、找不到时不修改文件;glob 返回稳定排序的 POSIX 风格相对路径;一条 assistant 消息里的多个工具调用仍要逐个回填并保持 tool_call_id 配对。本章导读(你在这)
↓
① 先定义可观察结果 ← 本章的验收标准,9 条
↓
② 一个完整例子 ← 模型怎么用四个工具走完一次任务
↓
③ ToolRegistry ← 「加一行」到底加在哪
↓
④ 文件系统边界 + Zod ← 依赖方向、同源 schema、描述也是上下文
↓
⑤ safePath ← 本章安全性的核心,两道检查
↓
⑥ 四个工具逐个看 ← read / write / edit / glob 各自的取舍
↓
⑦ 错误映射 + profile ← 稳定错误码、P01 与 P02 的差别
↓
⑧ 配对与权限边界 ← 多工具单轮的硬规则、本章故意留的缺口
↓
⑨ 运行、验证与小结 ← 动手 + 排错表 + 自测题如果把工具写死在循环里,每加一个工具都要改循环。用 ToolRegistry 让循环只处理 ToolCall、PreparedToolCall、ToolResult 三种对象。
for (const call of assistant.toolCalls) {
const prepared = tools.prepare(call);
const result = prepared.error === undefined
? await tools.invoke(prepared, context)
: prepared.error;
this.#history.push(toolMessage(result.content, call.id));
}
feature 不直接创建 Node 适配器,只依赖 WorkspaceFileSystem 接口;bootstrap 注入具体实现。测试注入替身、生产注入 Node 适配器。
export interface WorkspaceFileSystem {
readFile(workspace, relativePath, limit?): Promise<string>;
writeFile(workspace, relativePath, content): Promise<number>;
editFile(...): Promise<void>;
globFiles(...): Promise<readonly string[]>;
}
第一关词法检查(拒绝空值、NUL、绝对路径、盘符、..、Windows 保留组件);第二关解析磁盘上已存在的父路径,防止 junction / 符号链接越界。
拒绝示例:
../secret.txt
C:\secret.txt
/etc/passwd
NUL.txt
nested/CON.log
file:stream
trailing.
不接受行号。indexOf 精确匹配 oldText,替换第一处后原样写回,未修改部分的 CRLF 不会被改成 LF。找不到旧文本时不写回文件。
const index = current.indexOf(oldText);
if (index === -1) throw new TextNotFoundError(...);
const updated = current.slice(0, index) + newText
+ current.slice(index + oldText.length);
await writeFileBytes(target, Buffer.from(updated, "utf8"));
Zod schema 同时用于运行时校验和 OpenAI tool schema 生成:parameters: z.toJSONSchema(definition.inputSchema)。参数上的 .describe() 也会进入 JSON Schema。
const readFileInputSchema = z.strictObject({
path: z.string().min(1).describe("Workspace-relative path"),
limit: z.number().int().positive().optional()
.describe("Optional maximum lines"),
});
demo/note.txt,内容为 alpha;把第一处 alpha 改成 gamma;读取文件并用 glob 列出 demo/*.txt抽象契约不好记,先看一遍实际发生的事。模型会自己把任务拆成四步,一步一轮。下面只列每轮的关键字段,第 1 章那套 tool_call_id 配对规则完全不变。
| 轮 | 模型请求的工具 | arguments | 回填给模型的结果 |
|---|---|---|---|
| 1 | write_file | {"path":"demo/note.txt","content":"alpha"} | Wrote 5 UTF-8 bytes to demo/note.txt |
| 2 | edit_file | {"path":"demo/note.txt","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 | (无工具调用) | — | 模型给出最终文本,循环退出 |
5:alpha 五个 ASCII 字符正好 5 字节。换成 你好 就会返回 6——这正是「字节数以磁盘内容为准」要讲的事。同样一件事,只有 shell 时模型得自己拼一串 PowerShell:
Set-Content -Path .\demo\note.txt -Value 'alpha' -Encoding utf8
(Get-Content .\demo\note.txt -Raw) -replace 'alpha','gamma' |
Set-Content .\demo\note.txt -Encoding utf8 -NoNewline
Get-Content .\demo\note.txt
Get-ChildItem .\demo\*.txt | Select-Object -ExpandProperty Name
问题不在于「长」,而在于每一处都可能悄悄出错:
| 隐患 | 后果 |
|---|---|
demo 目录不存在 | Set-Content 直接失败,模型得先想到 New-Item |
忘了 -Encoding utf8 | PowerShell 5 默认写 UTF-16,中文变乱码 |
用 -replace | 那是正则替换,alpha.beta 里的 . 会当成任意字符 |
用 -replace | 它替换所有匹配,不是第一处 |
忘了 -NoNewline | 文件末尾多出一个换行,下次精确匹配就失败 |
| 内容里有单引号 | 引号转义写错,命令语法直接崩 |
write_file 自动建父目录、固定 UTF-8;edit_file 是精确文本匹配而非正则,且只改第一处。模型的推理预算可以省下来用在任务本身。模型可能试图访问工作区外的文件。这时不会抛异常打断循环,而是回填一条错误结果:
assistant tool_calls=[ { id:"call_9", name:"read_file",
arguments:'{"path":"../secret.txt"}' } ]
tool tool_call_id="call_9"
content="Error [path_escape]: Path escapes workspace: ../secret.txt"
path_escape 就知道换个路径,而不是卡死。工具失败是数据,不是异常。z.strictObject() 拒绝多余字段;path/old_text/pattern 不能为空;limit 必须为正整数。
name + description + effect(read/write) + handler 绑定在同一个定义里。
registry.register(createReadFileTool(fileSystem)),不用维护第二张 handler 表。
每轮仍从同一注册表快照生成 tools schema,调用、历史、配对都不改。
code/ 里已经 npm ci,.env 里三个变量都填好了。没做过的回第 1 章「运行第 1 章智能体」照做一遍。npm run test:ch02 预期 8 个文件 47 个测试全过,不需要密钥。
写 → 改 → 读 → 找,会真实创建 code/demo/note.txt。
只用 read_file 和 glob,不产生文件改动。
typecheck / test / lint / format:check / build。
Set-Location 'F:\笔记\Agent实操\code'
npm run test:ch02
Test Files 8 passed (8)
Tests 47 passed (47)
比第 1 章多了 2 个文件、17 个测试——新增的正是文件工具和路径边界。同样不需要 API Key,不发网络请求。
npm run ch02 -- --prompt '只使用文件工具:写入 demo/note.txt,内容为 alpha;把第一处 alpha 改成 gamma;读取文件并用 glob 列出 demo/*.txt'
Get-Content .\demo\note.txt
gamma
这条命令会真实创建 code/demo/note.txt。整个过程模型会调用四次工具,对应第 4 节那张表。
shell 的逐次审批。npm run agent-tutorial -- run --chapter 2 --prompt '读取 package.json,并列出 chapters/ch02/src/**/*.ts'
npm run typecheck
npm test
npm run lint
npm run format:check
npm run build
第 3 步只用 read_file 和 glob,不产生任何文件改动,适合反复跑着观察。第 4 步这些测试不需要 API Key,也不会访问网络;真实 OpenAI 运行只是额外验证,不替代离线契约测试。
.env 查找位置,也是 Agent workspace。你在哪个目录敲命令,Agent 的工作区边界就在哪里。在 code/ 里跑,safePath 守的就是 code/。第 1 章那张表仍然适用(配置、参数、审批相关的报错)。下面是本章新增的、和文件工具有关的情况:
| 现象 | 原因 | 怎么处理 |
|---|---|---|
Error [path_escape]: ... | 模型给的路径含 ..、绝对路径或 Windows 保留名 | 正常行为。看 prompt 是否让模型误以为要访问工作区外的文件 |
Error [text_not_found]: ... | 模型凭猜测拼了 old_text | 正常行为。模型下一轮应该先 read_file;反复失败说明 prompt 里的文本描述不够精确 |
Error [invalid_utf8]: ... | 读到了二进制文件(图片、.exe、锁文件等) | 正常行为。用 glob 限定 **/*.ts 之类的文本后缀 |
Error [file_not_found]: ... | 路径写错,或文件确实不存在 | 让模型先 glob 确认路径 |
模型仍然去调 shell | prompt 没有明确限定 | 在 prompt 里写「只使用文件工具」 |
| 文件写出来是乱码 | 不是本章代码的问题 | write_file 固定 UTF-8。检查你用什么打开的——记事本旧版可能按 ANSI 解读 |
demo/note.txt 出现在意外的位置 | 你不在 code/ 目录下运行 | Set-Location 到 code/ 再跑 |
npm run test:ch02 会执行 8 个测试文件、47 个测试。它们分工如下:
| 测试文件 | 验证内容 |
|---|---|
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 处理 |
ch01-loop.test.ts 和 shell-tool.test.ts 出现在这里不是复制粘贴凑数——第 2 章加了四个工具,第 1 章的行为必须一个字都没变。每一章都保留前章的测试,就是为了让「不小心改坏了旧行为」立刻暴露。| 实验 | 预期 | 学到什么 |
|---|---|---|
| 一 · 给 read_file 传多余字段 脚本化模型返回 {"path":"note.txt","encoding":"gbk"} | prepare() 阶段就失败,返回 schema 错误结果,readFile 一次都没被调用 | strictObject + 校验前置的组合效果;可在替身文件系统里加计数器确认 |
| 二 · 连续两次 edit_file 用同一个 old_text | 第二次返回 text_not_found,且文件内容不变 | 「只替换第一处 + 失败不写回」这两条行为的实际后果 |
三 · 手工造一个越界 junctionNew-Item -ItemType Junction -Path .\escape -Target C:\Windows\Temp(需管理员权限,做完 Remove-Item .\escape) | 返回 path_escape,文件读不到 | 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 章 |
shell 靠人工审批,本章的安全模型是两套并存的。不要把前者的路径保证套到后者头上。