工具即能力边界
把自然语言意图变成稳定、可测试的专用工具契约。
先看它怎样跑起来
这一章不是几个孤立知识点,而是一条会产生结果的因果链。
工具即能力边界
亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。
- 专用工具让模型表达意图,而不是自己拼 PowerShell。
- schema 负责参数形状,handler 负责副作用。
- 工具结果必须可观察、可回填、可测试。
顺着原文把边界看清
01导读:问题背景与本章目标上一篇,我们跑通了最小 Agent Loop:模型可以请求 shell,运行时执行 PowerShell,再把结果按 toolcallid 回填给模型。⌄
上一篇,我们跑通了最小 Agent Loop:模型可以请求 shell,运行时执行 PowerShell,再把结果按 tool_call_id 回填给模型。
但只有 shell 还不够。
模型想读文件时,必须自己拼 Get-Content;想写文件时,还要处理路径、引号、换行和编码。也就是说,模型的真实意图是“读取这个文件”,程序却把这份意图翻译成一条 PowerShell 命令。翻译越复杂,越容易出错,也会让模型在无关细节上耗费推理。
这一章给 Agent 增加四个专用工具:
read_file:读取 UTF-8 文本;write_file:写入 UTF-8 文本;edit_file:精确替换第一处文本;glob:按模式查找工作区文件。
加完后,P02 一共有五个工具:shell、read_file、write_file、edit_file、glob。
最关键的是:Agent Loop 不需要改。
从上下文工程的视角看,工具定义不是一份独立注册表,而是模型每次调用时实际看到的上下文。API 请求里的 tools 字段与 system 一起构成了静态前缀:只要系统提示词和工具定义不变,服务端就能复用此前缀的 KV Cache,后续轮次只需处理新增的对话历史。
这带来一条硬约束:工具集合确定后,不应每轮动态重排或改写描述。前缀一变,之前缓存的结果就会作废;模型同时也会看到不一致的能力边界。这一章的固定注册顺序、不可变快照和同源 schema,都是在为这个稳定前缀打基础。
02本章导读:读完这一章,你能做到什么独立给 Agent 加一个新工具,并说清为什么不用改 Agent Loop。⌄
- 独立给 Agent 加一个新工具,并说清为什么不用改 Agent Loop。
- 用 Zod 写出一份「模型看到的说明」和「运行时校验规则」同源的输入 schema。
- 说出为什么
edit_file不能接受行号,以及模型正确的编辑工作流是什么。 - 解释
safePath的两道检查各拦住什么,并说明为什么第二道必须访问磁盘。 - 跑通第 2 章,让 Agent 只用文件工具完成一次「写 → 改 → 读 → 找」的完整链路。
03你需要先具备什么第 1 章是必须的:要理解 ToolRegistry、ToolResult、toolcallid 配对和审批边界。⌄
| 需要 | 程度 |
|---|---|
| 第 1 章 | 必须。要理解 ToolRegistry、ToolResult、tool_call_id 配对和审批边界 |
| TypeScript | 能看懂 interface、async/await、instanceof 判断 |
| Zod | 不需要。本章用到的写法都会解释 |
| 文件系统概念 | 知道「相对路径 / 绝对路径」「符号链接」大致是什么 |
04术语速查(本章第一次出现的词)本章第一次出现的词。先扫一眼,正文里遇到不认识的词回来查即可。⌄
| 术语 | 一句话解释 |
|---|---|
| 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 |
05建议的阅读路线从导读出发,一路读到运行、验证与小结。⌄
本章导读(你在这)
↓
① 先定义可观察结果 ← 本章的验收标准,9 条
↓
② 一个完整例子 ← 模型怎么用四个工具走完一次任务
↓
③ ToolRegistry ← 「加一行」到底加在哪
↓
④ 文件系统边界 + Zod ← 依赖方向、同源 schema、描述也是上下文
↓
⑤ safePath ← 本章安全性的核心,两道检查
↓
⑥ 四个工具逐个看 ← read / write / edit / glob 各自的取舍
↓
⑦ 错误映射 + profile ← 稳定错误码、P01 与 P02 的差别
↓
⑧ 配对与权限边界 ← 多工具单轮的硬规则、本章故意留的缺口
↓
⑨ 运行、验证与小结 ← 动手 + 排错表 + 自测题06先定义可观察结果本章不是“写四个函数”就算完成。最终行为必须满足下面这些条件:⌄
本章不是“写四个函数”就算完成。最终行为必须满足下面这些条件:
- P01 仍然只向模型暴露
shell; - P02 按固定顺序暴露五个工具;
- 工具参数必须先经过严格 schema 校验,再产生副作用;
- 文件路径只能落在当前 workspace 内;
..、绝对路径、Windows 设备名和工作区外链接必须失败;- 写入使用 UTF-8,并返回真实字节数;
- 编辑只替换第一处精确文本,找不到时不修改文件;
- glob 返回稳定排序的 POSIX 风格相对路径;
- 一条 assistant 消息里的多个工具调用仍要逐个回填,并保持
tool_call_id配对。
07配套代码结构本章源码放在 code/chapters/ch02/src/,每个文件职责如下:⌄
本章源码放在 code/chapters/ch02/src/,每个文件职责如下:
| 文件 | 职责 |
|---|---|
core/tools.ts | 工具注册表、ToolDefinition、参数 prepare、不可变 snapshot、OpenAI schema 导出 |
core/filesystem.ts | WorkspaceFileSystem 接口与文件领域错误类型 |
core/loop.ts | AgentRunner 核心循环、消息配对、最大轮次、授权器边界 |
core/messages.ts | 消息角色联合类型、不可变工厂函数、tool_call_id 配对校验 |
core/model.ts | ModelClient、ModelReply、OpenAIToolSchema 等模型边界 |
core/commands.ts | CommandRunner、CommandResult 命令执行边界 |
core/profiles.ts | P01/P02 固定能力快照与章节入口校验 |
features/builtin-tools.ts | shell 与四个文件工具的 Zod schema、描述、effect、handler、注册函数 |
adapters/filesystem.ts | NodeWorkspaceFileSystem 与 safePath 双重路径边界检查 |
adapters/powershell.ts | PowerShellRunner 子进程执行、超时与输出截断 |
adapters/openai-chat.ts | OpenAI Chat Completions 适配器、响应归一化与 refusal 处理 |
bootstrap.ts | 按 profile 选择工具集并组装 AgentRunner |
config.ts | .env 解析、必需字段校验、baseUrl 归一化 |
cli.ts | 通用 CLI 与固定章节入口、终端交互批准、错误码退出 |
chapters/ch01.ts / chapters/ch02.ts | 固定 profile 入口,锁定章节能力快照 |
后面的小节会深入工具层、文件适配器和 Agent Loop;model、commands、openai-chat、config 和 cli 则是运行入口与供应商边界。阅读源码时,可以先从 core/ 的契约看起,再看 adapters/ 如何实现这些契约。
08一个完整例子:四个工具怎么串起来抽象契约不好记,先看一遍实际发生的事。⌄
抽象契约不好记,先看一遍实际发生的事。用户输入:
只使用文件工具:写入 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 | (无工具调用) | — | 模型给出最终文本,循环退出 |
注意第 1 轮的返回值是 5:alpha 五个 ASCII 字符正好 5 字节。换成 你好 就会返回 6——这是本章「字节数以磁盘内容为准」那一节要讲的事。
09换成上一章的做法有多难同样一件事,只有 shell 时模型得自己拼一串 PowerShell,每一处都可能悄悄出错。⌄
同样一件事,只有 shell 时模型得这么写:
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 是精确文本匹配而非正则,且只改第一处。模型的推理预算可以省下来用在任务本身。
10失败也是完整链路的一部分模型可能试图访问工作区外的文件。这时不会抛异常打断循环,而是回填一条错误结果。⌄
模型可能试图访问工作区外的文件。这时不会抛异常打断循环,而是回填一条错误结果:
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 就知道换个路径,而不是卡死。这继承的正是第 1 章那条原则:工具失败是数据,不是异常。
11ToolRegistry:循环只负责分发如果把工具写死在循环里,第二个工具出现时就会开始堆条件分支:⌄
如果把工具写死在循环里,第二个工具出现时就会开始堆条件分支:
// 反例:不要让 Agent Loop 认识具体工具。
if (call.name === "shell") {
return runShell(call.arguments);
}
if (call.name === "read_file") {
return readFile(call.arguments);
}这种结构的问题不只是代码变长。每增加一个工具,都要修改循环;工具参数解析、错误处理和调用协议也会散落在不同分支里。更好的做法是让循环只认识“一次调用返回一个结果”这种抽象协议,不关心工具具体做什么。
配套实现继续使用第 1 章的 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));
}注册表负责四件事:
- 按名称查找工具;
- 把 OpenAI 返回的 JSON 字符串解析成 object;
- 用 Zod 严格校验参数;
- 调用与 schema 绑定在同一个
ToolDefinition里的 handler。
未知工具、坏 JSON、错误类型和多余字段都会在 handler 执行前变成结构化错误。因此,非法参数不会先写文件再报错。
所谓“加一个工具,只需要加一行”,准确含义是:工具定义完成后,只需注册这个定义,不必修改 Agent Loop,也不必维护另一张 handler 表。
registry.register(createReadFileTool(fileSystem));12先把文件系统做成边界文件操作属于外部边界。features 不应该直接创建 Node 文件系统适配器,否则业务层会反向依赖具体实现。⌄
文件操作属于外部边界。features 不应该直接创建 Node 文件系统适配器,否则业务层会反向依赖具体实现。
因此,code/chapters/ch02/src/core/filesystem.ts 只声明运行时需要的能力:
export interface WorkspaceFileSystem {
readFile(workspace: string, relativePath: string, limit?: number): Promise<string>;
writeFile(workspace: string, relativePath: string, content: string): Promise<number>;
editFile(
workspace: string,
relativePath: string,
oldText: string,
newText: string,
): Promise<void>;
globFiles(workspace: string, pattern: string): Promise<readonly string[]>;
}具体 Node 实现位于 code/chapters/ch02/src/adapters/filesystem.ts。工具定义只依赖 WorkspaceFileSystem 接口,bootstrap.ts 才负责创建 NodeWorkspaceFileSystem 并注入。
这条依赖方向很重要:
bootstrap
├─ 创建 NodeWorkspaceFileSystem
└─ 注入 features
features ──依赖──> core/WorkspaceFileSystem
adapters ──实现──> core/WorkspaceFileSystem测试可以注入替身,生产入口可以注入 Node 适配器,feature 不需要知道二者的区别。
13用 Zod 定义严格输入四个文件工具的输入 schema 位于 code/chapters/ch02/src/features/builtin-tools.ts:⌄
四个文件工具的输入 schema 位于 code/chapters/ch02/src/features/builtin-tools.ts:
const readFileInputSchema = z.strictObject({
path: z
.string()
.min(1)
.describe("Workspace-relative file path. Escapes and device names are rejected."),
limit: z
.number()
.int()
.positive()
.optional()
.describe("Optional maximum number of lines to return."),
});
const writeFileInputSchema = z.strictObject({
path: z
.string()
.min(1)
.describe("Workspace-relative destination path; parent directories are created automatically."),
content: z.string().describe("UTF-8 text content to write; an empty string clears the file."),
});
const editFileInputSchema = z.strictObject({
path: z.string().min(1).describe("Workspace-relative target file path."),
old_text: z
.string()
.min(1)
.describe("Exact existing text to replace; only the first occurrence is changed."),
new_text: z.string().describe("Replacement text; an empty string deletes the matched text."),
});
const globInputSchema = z.strictObject({
pattern: z
.string()
.min(1)
.describe("Workspace-relative glob pattern, for example **/*.ts."),
});这里的约束都是实际契约:
strictObject拒绝 schema 外字段;path、old_text和pattern不能为空;limit必须是大于 0 的整数;true、"2"、0和-1都不是合法 limit;content和new_text允许空字符串,因为清空文件或删除一段文本是有效操作。
Zod schema 同时用于运行时校验和 OpenAI tool schema 生成,不需要手写第二份 JSON Schema:
parameters: z.toJSONSchema(definition.inputSchema)因此,模型看到的契约和 handler 真正接受的契约来自同一个源头。参数上的 .describe() 也会随 z.toJSONSchema 进入 OpenAI tool schema,模型无需额外文档,就能在 JSON Schema 中看到每个参数的作用。
| 工具 | 输入 | 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 章会用它参与权限决策。
14描述也是模型上下文工具的 description 和参数描述不是文档,而是模型选择工具时实际读到的上下文。OpenAI 的 function calling 指南和 Anthropic 的 tool use 文档都建议:描述要说明工具做什么、什么时候用、返回什么,以及边界和失败语义,而不是只写一句话。⌄
工具的 description 和参数描述不是文档,而是模型选择工具时实际读到的上下文。OpenAI 的 function calling 指南和 Anthropic 的 tool use 文档都建议:描述要说明工具做什么、什么时候用、返回什么,以及边界和失败语义,而不是只写一句话。
在请求结构上,工具定义位于 API 顶层 tools 字段,与 system 消息共同构成每轮不变的静态前缀。KV Cache 依赖前缀的字节稳定性:如果每轮动态重排工具、修改描述,或者把时间戳之类的变化内容塞进工具说明,前缀就会变化,之前缓存的中间结果无法复用。ToolRegistry 按注册顺序稳定导出工具,snapshot() 又禁止中途修改注册表,正是为了在运行时保持这个前缀。
描述缺失的代价也有实验依据:保留函数签名和参数定义、只移除描述性文本时,工具调用错误率会明显增加。类似消融实验来自上下文工程教程,不是本章离线测试数据,但它说明同一件事:模型对工具的语义理解越完整,越不容易传错参数。
P02 的文件工具描述因此没有写成"读取文件"这种最小文案,而是把关键决策信息直接放进 schema:
read_file说明返回行数、limit语义和 UTF-8 失败行为;edit_file说明只替换第一处精确文本、不接收行号、找不到时保持文件不变;glob说明返回稳定排序的/相对路径;shell说明需要交互批准,避免模型在文件操作可用时仍去拼 PowerShell 字符串。
参数层同样用 Zod 的 .describe() 补充说明。path 描述 workspace 相对路径和拒绝规则,old_text 描述"先读取文件,再提供精确文本",limit 描述省略时的完整读取语义。模型看到的 JSON Schema 会保留这些描述,运行时校验器也来自同一个 schema,因此不会出现"文档说可以、校验拒绝"的漂移。
官方实践没有规定一成不变的模板,但可以把"模型不需要猜测"拆成四条检查:
- 做什么:工具接受什么输入,产生什么副作用;
- 什么时候用:与同类工具的分工是什么,例如文件操作优先于 shell;
- 返回什么:成功结果的形式是什么,例如字节数、行数、路径列表;
- 失败怎么办:稳定错误码是什么,以及模型下一步可以怎么恢复,例如先读取再编辑。
跨工具协作也值得写进描述:edit_file 明确要求先读取文件、不接收行号,模型就不会凭记忆拼一段旧文本。描述不是越长越好,而是让模型在决策点不需要猜测。
15safePath:先守住 workspace四个工具共享同一条路径边界。公开契约是“工作区相对路径”,所以运行时不会猜测或修复危险输入。⌄
四个工具共享同一条路径边界。公开契约是“工作区相对路径”,所以运行时不会猜测或修复危险输入。
下面这些路径直接拒绝:
../secret.txt
nested/../secret.txt
C:\secret.txt
/etc/passwd
NUL.txt
nested/CON.log
file:stream
trailing.NUL、CON、COM1 和 LPT9 等名称在 Windows 中可能指向设备,而不是普通文件。冒号可能形成 NTFS Alternate Data Stream,尾随点或空格也会被 Win32 特殊处理。因此,即使某个名字在别的平台看起来普通,本项目仍按 Windows 11 的运行环境拒绝它。
safePath 分两步检查:
export async function safePath(
workspace: string,
relativePath: string,
): Promise<string> {
const root = await workspaceRoot(workspace);
const parts = relativeParts(relativePath, "path", false);
const target = resolve(root, ...parts);
if (!isInside(root, target)) {
throw new WorkspacePathError(`Path escapes workspace: ${relativePath}`);
}
const existing = await resolvedExistingParent(root, target);
const resolved = resolve(
existing.physical,
relative(existing.lexical, target),
);
if (!isInside(root, resolved)) {
throw new WorkspacePathError(`Path escapes workspace: ${relativePath}`);
}
return resolved;
}第一关是词法检查,不需要访问磁盘:先拒绝空值、NUL、绝对路径、盘符、.. 和 Windows 保留组件。
第二关解析磁盘上已经存在的父路径。如果 workspace/escape 是一个指向工作区外的 junction 或符号链接,那么 escape/secret.txt 的文本看似在 workspace 下,真实路径却在外部。解析后的路径不在 root 内时,操作立即失败。
这不是完整沙箱。它只保护四个文件工具。shell 的 cwd 虽然是 workspace,但 PowerShell 仍能使用绝对路径、访问网络或启动子进程。
16readfile:限制上下文体积readfile 使用严格 UTF-8 解码。非法字节不会被悄悄替换成 �,而会返回 invalidutf8。⌄
read_file 使用严格 UTF-8 解码。非法字节不会被悄悄替换成 �,而会返回 invalid_utf8。
async readFile(
workspace: string,
relativePath: string,
limit?: number,
): Promise<string> {
if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) {
throw new RangeError("limit must be a positive integer");
}
const target = await safePath(workspace, relativePath);
const text = decodeUtf8(await readFileBytes(target), relativePath);
const lines = splitLines(text);
if (limit !== undefined && limit < lines.length) {
return [
...lines.slice(0, limit),
`... (${lines.length - limit} more lines)`,
].join("\n");
}
return lines.join("\n");
}例如文件有三行,limit 为 2,结果是:
one
two
... (1 more lines)限制读取行数可以避免一个大文件占满模型上下文。limit 生效时,尾部会明确标注还剩多少行;不传 limit 时则返回完整文件,模型不需要猜测内容是否被截断。
17writefile:字节数以磁盘内容为准const target = await safePath(workspace, relativePath);⌄
async writeFile(
workspace: string,
relativePath: string,
content: string,
): Promise<number> {
const target = await safePath(workspace, relativePath);
const bytes = Buffer.from(content, "utf8");
await mkdir(dirname(target), { recursive: true });
await writeFileBytes(target, bytes);
return bytes.byteLength;
}它会自动创建父目录,并直接写 UTF-8 字节。返回的是 bytes.byteLength,不是 JavaScript 字符串长度。
例如 你好 的 length 是 2,但 UTF-8 实际占 6 字节。工具成功时返回:
Wrote 6 UTF-8 bytes to note.txt这样,结果与真正写入磁盘的数据一致。
18editfile:只改第一处精确文本editfile 不接受行号。行号会随着前面每次编辑而整体偏移,模型很容易拿着旧行号改错位置。⌄
edit_file 不接受行号。行号会随着前面每次编辑而整体偏移,模型很容易拿着旧行号改错位置。
它要求模型提供旧文本和新文本:
const index = current.indexOf(oldText);
if (index === -1) {
throw new TextNotFoundError(`Exact text not found in ${relativePath}`);
}
const updated =
`${current.slice(0, index)}${newText}` +
current.slice(index + oldText.length);
await writeFileBytes(target, Buffer.from(updated, "utf8"));这带来三个明确行为:
- 旧文本必须精确存在;
- 只替换第一处;
- 找不到旧文本时不写回文件。
它读入整段原文本并只替换匹配片段,其余字符原样写回,因此未修改部分的 CRLF 不会被顺带改成 LF。测试会直接比较编辑前后的字节。
模型正确的工作流应当是:先读文件,再用读到的精确片段调用 edit_file。如果文件已经变化,工具会失败,模型需要重新读取,而不是盲目覆盖。
19glob:先看见项目,再决定读什么模型通常不知道 workspace 里有哪些文件。glob 让它先查找,再选择要读取的目标:⌄
模型通常不知道 workspace 里有哪些文件。glob 让它先查找,再选择要读取的目标:
{"pattern":"chapters/ch02/src/**/*.ts"}返回值统一使用 /,并稳定排序:
chapters/ch02/src/bootstrap.ts
chapters/ch02/src/core/loop.ts
chapters/ch02/src/core/messages.ts没有匹配时返回 (no matches)。
glob 模式本身也必须是相对路径,不能包含 ..。通配符出现前的固定前缀会先经过 safePath,所以 escape/*.txt 不能借已有 junction 跳出工作区。遍历时也不会递归进入符号链接目录,每个匹配结果还会再次检查真实路径。
20handler:只在工具边界映射预期错误Node 适配器先把 ENOENT、EISDIR、ENOTDIR 等操作系统错误翻译成 core 定义的 FileNotFoundError、InvalidFilePathError 和 FileSystemOperationError。feature 不读取 Node 错误码,因此替换文件系统适配器时,工具层的稳定语义不会变化。⌄
Node 适配器先把 ENOENT、EISDIR、ENOTDIR 等操作系统错误翻译成 core 定义的 FileNotFoundError、InvalidFilePathError 和 FileSystemOperationError。feature 不读取 Node 错误码,因此替换文件系统适配器时,工具层的稳定语义不会变化。
handler 位于工具边界,负责把这些预期失败转换成模型能理解的稳定错误码。
下面是 edit_file 的完整分发逻辑:
handler: async ({ path, old_text, new_text }, context) => {
try {
await fileSystem.editFile(
context.workspace,
path,
old_text,
new_text,
);
return toolSuccess(`Edited ${path}`);
} catch (error) {
if (error instanceof WorkspacePathError) {
return toolError("path_escape", error.message);
}
if (error instanceof TextNotFoundError) {
return toolError("text_not_found", `Exact text not found in ${path}`);
}
if (error instanceof InvalidUtf8Error) {
return toolError("invalid_utf8", `File is not valid UTF-8: ${path}`);
}
if (error instanceof FileNotFoundError) {
return toolError("file_not_found", `File not found: ${path}`);
}
if (error instanceof InvalidFilePathError) {
return toolError("invalid_path", `Path is a directory: ${path}`);
}
if (error instanceof FileSystemOperationError) {
return toolError("filesystem_error", `Could not edit file: ${path}`);
}
throw error;
}
}这里的 catch 位于进程与模型之间的边界,能控制对外输出。它只公开稳定信息,不把本机绝对路径或内部堆栈泄漏给模型。
四个工具使用的主要错误码如下:
| error_code | 含义 |
|---|---|
path_escape | 路径违反 workspace 边界 |
file_not_found | 目标文件不存在 |
text_not_found | 精确旧文本不存在 |
invalid_path | 文件与目录类型不符合操作要求 |
invalid_utf8 | 文件不是合法 UTF-8 |
filesystem_error | 其他受控文件系统失败 |
21P01 与 P02 只差构建期工具集code/chapters/ch02/src/core/profiles.ts 定义固定能力快照:⌄
code/chapters/ch02/src/core/profiles.ts 定义固定能力快照:
export const P01: ChapterProfile = Object.freeze({
chapter: 1,
capabilities: new CapabilitySet(["loop", "powershell"]),
});
export const P02: ChapterProfile = Object.freeze({
chapter: 2,
capabilities: new CapabilitySet([
"loop",
"powershell",
"tool_registry",
"files",
]),
});bootstrap 根据固定 profile 选择注册表:
const tools =
profile.chapter === 1
? createChapterOneTools(commandRunner)
: createChapterTwoTools(
commandRunner,
dependencies.fileSystem === undefined
? new NodeWorkspaceFileSystem()
: dependencies.fileSystem,
);P02 的工具构建也很直接:
export function createChapterTwoTools(
commandRunner: CommandRunner,
fileSystem: WorkspaceFileSystem,
): ToolRegistry {
const registry = createChapterOneTools(commandRunner);
registry.register(createReadFileTool(fileSystem));
registry.register(createWriteFileTool(fileSystem));
registry.register(createEditFileTool(fileSystem));
registry.register(createGlobTool(fileSystem));
return registry;
}Agent Loop 每轮仍然从同一个注册表快照生成 tools schema。模型调用、消息历史、最大轮次和结果配对都不需要复制或修改。
22一轮多个工具调用仍然必须配对模型可以在同一条 assistant 消息里请求多个工具:⌄
模型可以在同一条 assistant 消息里请求多个工具:
const assistant = assistantMessage(null, [
toolCall(
"write-1",
"write_file",
'{"path":"note.txt","content":"value"}',
),
toolCall("read-1", "read_file", '{"path":"note.txt"}'),
]);当前实现按顺序执行。上面的结果历史必须是:
assistant(tool_calls: write-1, read-1)
tool(tool_call_id: write-1, 写入结果)
tool(tool_call_id: read-1, 读取结果)不能漏掉失败调用的结果,也不能在两个 tool 结果之间插入新的 user 或 assistant 消息。即使参数无效,也要生成一个与原 ID 配对的错误结果。
串行执行是刻意选择。读写同一文件存在顺序依赖;在没有并发安全契约前,不应为了速度擅自并发。
23第 2 章的权限边界P02 还没有第 3 章的完整策略系统。本章保持以下固定行为:⌄
P02 还没有第 3 章的完整策略系统。本章保持以下固定行为:
shell是execute,真实 CLI 每次调用前要求明确输入y或yes;- 四个文件工具依靠
safePath边界直接执行; - 无交互输入、默认回车或审批异常都会拒绝
shell; cwd不是沙箱,不能把文件工具的路径保证外推给 PowerShell。
第 3 章会增加结构化权限规则,让写工具也能进入审批和审计流程,并保证强拒绝不会被弱允许覆盖。
24运行第 2 章前置条件和第 1 章完全一样:code/ 里已经 npm ci,.env 里三个变量都填好了。⌄
前置条件和第 1 章完全一样:code/ 里已经 npm ci,.env 里三个变量都填好了。没做过的回第 1 章「运行第 1 章智能体」照做一遍。
25第 1 步:先跑离线测试npm run test:ch02 会跑 8 个测试文件、47 个测试,不需要 API Key。⌄
Set-Location 'F:\笔记\Agent实操\code'
npm run test:ch02预期最后两行:
Test Files 8 passed (8)
Tests 47 passed (47)比第 1 章多了 2 个文件、17 个测试——新增的正是文件工具和路径边界。同样不需要 API Key,不发网络请求。
26第 2 步:跑一个完整的四工具链路这条命令会真实创建 code/demo/note.txt,跑完可以自己确认。⌄
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 章了,也会触发 shell 的逐次审批。
27第 3 步:换一个只读任务只用 read_file 和 glob,不产生任何文件改动,适合反复跑着观察。⌄
npm run agent-tutorial -- run --chapter 2 --prompt '读取 package.json,并列出 chapters/ch02/src/**/*.ts'这条只用 read_file 和 glob,不产生任何文件改动,适合反复跑着观察。
当前 PowerShell 目录既是 .env 查找位置,也是 Agent workspace。换句话说,你在哪个目录敲命令,Agent 的工作区边界就在哪里。 在 code/ 里跑,safePath 守的就是 code/。
28第 4 步(可选):运行完整离线验证typecheck、test、lint、format:check、build 五条命令,全部离线。⌄
npm run typecheck
npm test
npm run lint
npm run format:check
npm run build这些测试不需要 API Key,也不会访问网络。真实 OpenAI 运行只是额外验证,不替代离线契约测试。
29常见报错排查第 1 章那张表仍然适用。下面是本章新增的、和文件工具有关的情况。⌄
第 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/ 再跑 |
30验证与实验npm run test:ch02 会执行 8 个测试文件、47 个测试。它们分工如下:⌄
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 章的行为必须一个字都没变。这套教程每一章都保留前章的测试,就是为了让「不小心改坏了旧行为」立刻暴露。
31三个建议动手做的小实验多余字段、连续两次 editfile、手工造一个越界 junction。⌄
实验一:给 read_file 传一个多余字段
在测试里让脚本化模型返回 arguments: '{"path":"note.txt","encoding":"gbk"}'。
预期:prepare() 阶段就失败,返回 schema 错误结果,readFile 一次都没被调用。学到:strictObject + 校验前置的组合效果。可以在替身文件系统里加个计数器确认它真的没被调。
实验二:连续两次 edit_file 用同一个 old_text
第一次成功替换,第二次会怎样?
预期:第二次返回 text_not_found(第一处已经被改掉了),且文件内容不变。学到「只替换第一处 + 失败不写回」这两条行为的实际后果。
实验三:在工作区里手工造一个越界 junction
# 需要管理员权限;实验完记得删掉
New-Item -ItemType Junction -Path .\escape -Target C:\Windows\Temp
npm run ch02 -- --prompt '读取 escape 目录里的任意一个文件'预期:返回 path_escape,文件读不到。学到:safePath 第二关的实际效果——第一关看字符串完全放行,是磁盘解析拦住的。
# 清理
Remove-Item .\escape32本章小结 · 三句话版本加工具的正确姿势是「定义 + 注册」,Agent Loop 一行都不用改。⌄
- 加工具的正确姿势是「定义 + 注册」,Agent Loop 一行都不用改。
- 一份 Zod schema 同时供模型阅读和运行时校验,从根上消除「文档和实现不一致」。
- 文件工具的安全性来自
safePath的两道检查:词法拦明显越界,磁盘解析拦符号链接。
33一定要记住的六条校验顺序、契约外明确失败、路径拒绝、磁盘解析、精确文本、catch 只翻译已知错误。⌄
| # | 结论 | 出现在哪一节 |
|---|---|---|
| 1 | 校验全在 prepare(),副作用全在 invoke(),顺序不能反 | ToolRegistry |
| 2 | 契约外的输入明确失败,不做隐式转换、不忽略多余字段 | 用 Zod 定义严格输入 |
| 3 | ..、绝对路径、Windows 保留名一律拒绝,不尝试修复 | safePath |
| 4 | 只看字符串挡不住 junction,必须解析磁盘真实路径 | 为什么必须有第二关 |
| 5 | edit_file 用精确文本而非行号,因为行号编辑一次就失效 | edit_file |
| 6 | catch 只翻译能列举出名字的错误,其余继续抛 | handler 错误映射 |
34本章代码边界(明确「还没做什么」)已经有什么、还没有什么、分别在哪一章补上。⌄
| 已经有 | 还没有 | 在哪一章补 |
|---|---|---|
| 五个工具(含四个文件工具) | 结构化权限规则、审计日志 | 第 3 章 |
shell 逐次人工审批 | 写工具也进审批流程 | 第 3 章 |
文件工具的 safePath 边界 | shell 的路径边界(仍然没有) | 不在本教程范围;shell 始终依赖审批 |
effect 元数据 | effect 参与权限决策 | 第 3 章 |
| 单轮多工具串行执行 | 并发执行与并发安全契约 | 第 13 章(后台任务) |
完整读取 + limit 截断 | 产物落盘、上下文压缩 | 第 8 章 |
| 启动时固定的五个工具 | 运行中动态增减工具 | 第 19 章 |
35检查你是否真的读懂了试着不看文章回答这六个问题。答不上来的,回对应小节再看一遍。⌄
- 模型传
{"path":"note.txt","limit":"10"},会发生什么?为什么不自动转成数字?(Zod 那节) nested/../secret.txt化简后在工作区内,为什么还是拒绝?(safePath)- 工作区里有个指向外部的 junction,第一关和第二关分别是什么结论?(为什么必须有第二关)
write_file写入你好,返回的数字是 2 还是 6?为什么?(write_file)edit_file的old_text在文件里出现 3 次,会改几处?(edit_file)- handler 的
catch最后为什么要throw error而不是返回一个兜底错误码?(错误映射)
这一章真正增加的不是四个孤立函数,而是一条稳定扩展路径:
Zod schema
↓
ToolDefinition(name + description + effect + handler)
↓
ToolRegistry(schema 快照 + 参数准备 + 分发)
↓
Agent Loop(保持不变)以后新增工具,先定义明确输入和副作用,再实现 handler,最后注册一次。循环不需要知道工具细节,模型看到的 schema 和运行时执行的 handler 也不会分家。
换个场景,你还会判断吗?
每题只测一个边界。先做决定,再看解释。
准备开始