AGAgent 学习路线
第 2 / 20
CHAPTER 02 · GPT 生成学习页

工具即能力边界

把自然语言意图变成稳定、可测试的专用工具契约。

01 / 路线

先看它怎样跑起来

从输入到验收

这一章不是几个孤立知识点,而是一条会产生结果的因果链。

关键判断

工具即能力边界

亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。

  • 专用工具让模型表达意图,而不是自己拼 PowerShell。
  • schema 负责参数形状,handler 负责副作用。
  • 工具结果必须可观察、可回填、可测试。
1read_file:读取 UTF-8
2write_file:写入 UTF-8
3edit_file:精确替换
4glob:查找工作区文件
点击播放,观察动作怎样传递0 / 4
02 / 正文

顺着原文把边界看清

35 个小节35 组代码80 行表格

按原文顺序阅读。摘要只负责定位,真正的边界、例外和代码都在展开内容里。

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 一共有五个工具:shellread_filewrite_fileedit_fileglob

最关键的是:Agent Loop 不需要改。

图片
图片

从上下文工程的视角看,工具定义不是一份独立注册表,而是模型每次调用时实际看到的上下文。API 请求里的 tools 字段与 system 一起构成了静态前缀:只要系统提示词和工具定义不变,服务端就能复用此前缀的 KV Cache,后续轮次只需处理新增的对话历史。

这带来一条硬约束:工具集合确定后,不应每轮动态重排或改写描述。前缀一变,之前缓存的结果就会作废;模型同时也会看到不一致的能力边界。这一章的固定注册顺序、不可变快照和同源 schema,都是在为这个稳定前缀打基础。


02本章导读:读完这一章,你能做到什么独立给 Agent 加一个新工具,并说清为什么不用改 Agent Loop。
  1. 独立给 Agent 加一个新工具,并说清为什么不用改 Agent Loop。
  2. 用 Zod 写出一份「模型看到的说明」和「运行时校验规则」同源的输入 schema。
  3. 说出为什么 edit_file 不能接受行号,以及模型正确的编辑工作流是什么。
  4. 解释 safePath 的两道检查各拦住什么,并说明为什么第二道必须访问磁盘。
  5. 跑通第 2 章,让 Agent 只用文件工具完成一次「写 → 改 → 读 → 找」的完整链路。

03你需要先具备什么第 1 章是必须的:要理解 ToolRegistry、ToolResult、toolcallid 配对和审批边界。
需要程度
第 1 章必须。要理解 ToolRegistryToolResulttool_call_id 配对和审批边界
TypeScript能看懂 interfaceasync/awaitinstanceof 判断
Zod不需要。本章用到的写法都会解释
文件系统概念知道「相对路径 / 绝对路径」「符号链接」大致是什么

04术语速查(本章第一次出现的词)本章第一次出现的词。先扫一眼,正文里遇到不认识的词回来查即可。
术语一句话解释
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(副作用类别)工具自我声明的影响范围:readwriteexecute。第 2 章只是元数据,第 3 章才参与决策
WorkspaceFileSystemcore 层声明的文件能力接口。feature 只认这个接口,不认 Node
profile(P01/P02)章节能力白名单。P02 = P01 + tool_registry + files

05建议的阅读路线从导读出发,一路读到运行、验证与小结。
本章导读(你在这)
  ↓
① 先定义可观察结果        ← 本章的验收标准,9 条
  ↓
② 一个完整例子            ← 模型怎么用四个工具走完一次任务
  ↓
③ ToolRegistry            ← 「加一行」到底加在哪
  ↓
④ 文件系统边界 + Zod      ← 依赖方向、同源 schema、描述也是上下文
  ↓
⑤ safePath                ← 本章安全性的核心,两道检查
  ↓
⑥ 四个工具逐个看          ← read / write / edit / glob 各自的取舍
  ↓
⑦ 错误映射 + profile      ← 稳定错误码、P01 与 P02 的差别
  ↓
⑧ 配对与权限边界          ← 多工具单轮的硬规则、本章故意留的缺口
  ↓
⑨ 运行、验证与小结        ← 动手 + 排错表 + 自测题

06先定义可观察结果本章不是“写四个函数”就算完成。最终行为必须满足下面这些条件:

本章不是“写四个函数”就算完成。最终行为必须满足下面这些条件:

  1. P01 仍然只向模型暴露 shell
  2. P02 按固定顺序暴露五个工具;
  3. 工具参数必须先经过严格 schema 校验,再产生副作用;
  4. 文件路径只能落在当前 workspace 内;
  5. ..、绝对路径、Windows 设备名和工作区外链接必须失败;
  6. 写入使用 UTF-8,并返回真实字节数;
  7. 编辑只替换第一处精确文本,找不到时不修改文件;
  8. glob 返回稳定排序的 POSIX 风格相对路径;
  9. 一条 assistant 消息里的多个工具调用仍要逐个回填,并保持 tool_call_id 配对。
07配套代码结构本章源码放在 code/chapters/ch02/src/,每个文件职责如下:

本章源码放在 code/chapters/ch02/src/,每个文件职责如下:

文件职责
core/tools.ts工具注册表、ToolDefinition、参数 prepare、不可变 snapshot、OpenAI schema 导出
core/filesystem.tsWorkspaceFileSystem 接口与文件领域错误类型
core/loop.tsAgentRunner 核心循环、消息配对、最大轮次、授权器边界
core/messages.ts消息角色联合类型、不可变工厂函数、tool_call_id 配对校验
core/model.tsModelClient、ModelReply、OpenAIToolSchema 等模型边界
core/commands.tsCommandRunner、CommandResult 命令执行边界
core/profiles.tsP01/P02 固定能力快照与章节入口校验
features/builtin-tools.tsshell 与四个文件工具的 Zod schema、描述、effect、handler、注册函数
adapters/filesystem.tsNodeWorkspaceFileSystem 与 safePath 双重路径边界检查
adapters/powershell.tsPowerShellRunner 子进程执行、超时与输出截断
adapters/openai-chat.tsOpenAI Chat Completions 适配器、响应归一化与 refusal 处理
bootstrap.ts按 profile 选择工具集并组装 AgentRunner
config.ts.env 解析、必需字段校验、baseUrl 归一化
cli.ts通用 CLI 与固定章节入口、终端交互批准、错误码退出
chapters/ch01.ts / chapters/ch02.ts固定 profile 入口,锁定章节能力快照

后面的小节会深入工具层、文件适配器和 Agent Loop;modelcommandsopenai-chatconfigcli 则是运行入口与供应商边界。阅读源码时,可以先从 core/ 的契约看起,再看 adapters/ 如何实现这些契约。


08一个完整例子:四个工具怎么串起来抽象契约不好记,先看一遍实际发生的事。

抽象契约不好记,先看一遍实际发生的事。用户输入:

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

模型会自己拆成四步,一步一轮。下面只列每轮的关键字段(第 1 章那套 tool_call_id 配对规则完全不变):

模型请求的工具arguments回填给模型的结果
1write_file{"path":"demo/note.txt","content":"alpha"}Wrote 5 UTF-8 bytes to demo/note.txt
2edit_file{"path":"demo/note.txt","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(无工具调用)模型给出最终文本,循环退出

注意第 1 轮的返回值是 5alpha 五个 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 utf8PowerShell 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。循环只处理 ToolCallPreparedToolCallToolResult

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));
}

注册表负责四件事:

  1. 按名称查找工具;
  2. 把 OpenAI 返回的 JSON 字符串解析成 object;
  3. 用 Zod 严格校验参数;
  4. 调用与 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 外字段;
  • pathold_textpattern 不能为空;
  • limit 必须是大于 0 的整数;
  • true"2"0-1 都不是合法 limit;
  • contentnew_text 允许空字符串,因为清空文件或删除一段文本是有效操作。

Zod schema 同时用于运行时校验和 OpenAI tool schema 生成,不需要手写第二份 JSON Schema:

parameters: z.toJSONSchema(definition.inputSchema)

因此,模型看到的契约和 handler 真正接受的契约来自同一个源头。参数上的 .describe() 也会随 z.toJSONSchema 进入 OpenAI tool schema,模型无需额外文档,就能在 JSON Schema 中看到每个参数的作用。

工具输入effect成功结果
read_filepath, limit?read文件文本
write_filepath, contentwrite写入字节数
edit_filepath, old_text, new_textwrite编辑确认
globpatternread匹配路径列表

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.

NULCONCOM1LPT9 等名称在 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 内时,操作立即失败。

这不是完整沙箱。它只保护四个文件工具。shellcwd 虽然是 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"));

这带来三个明确行为:

  1. 旧文本必须精确存在;
  2. 只替换第一处;
  3. 找不到旧文本时不写回文件。

它读入整段原文本并只替换匹配片段,其余字符原样写回,因此未修改部分的 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 适配器先把 ENOENTEISDIRENOTDIR 等操作系统错误翻译成 core 定义的 FileNotFoundErrorInvalidFilePathErrorFileSystemOperationError。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 章的完整策略系统。本章保持以下固定行为:

  • shellexecute,真实 CLI 每次调用前要求明确输入 yyes
  • 四个文件工具依靠 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_fileglob,不产生任何文件改动,适合反复跑着观察。

当前 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-Locationcode/ 再跑

30验证与实验npm run test:ch02 会执行 8 个测试文件、47 个测试。它们分工如下:

npm run test:ch02 会执行 8 个测试文件、47 个测试。它们分工如下:

测试文件验证内容
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 处理

ch01-loop.test.tsshell-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 .\escape

32本章小结 · 三句话版本加工具的正确姿势是「定义 + 注册」,Agent Loop 一行都不用改。
  1. 加工具的正确姿势是「定义 + 注册」,Agent Loop 一行都不用改。
  2. 一份 Zod schema 同时供模型阅读和运行时校验,从根上消除「文档和实现不一致」。
  3. 文件工具的安全性来自 safePath 的两道检查:词法拦明显越界,磁盘解析拦符号链接。

33一定要记住的六条校验顺序、契约外明确失败、路径拒绝、磁盘解析、精确文本、catch 只翻译已知错误。
#结论出现在哪一节
1校验全在 prepare(),副作用全在 invoke(),顺序不能反ToolRegistry
2契约外的输入明确失败,不做隐式转换、不忽略多余字段用 Zod 定义严格输入
3..、绝对路径、Windows 保留名一律拒绝,不尝试修复safePath
4只看字符串挡不住 junction,必须解析磁盘真实路径为什么必须有第二关
5edit_file 用精确文本而非行号,因为行号编辑一次就失效edit_file
6catch 只翻译能列举出名字的错误,其余继续抛handler 错误映射

34本章代码边界(明确「还没做什么」)已经有什么、还没有什么、分别在哪一章补上。
已经有还没有在哪一章补
五个工具(含四个文件工具)结构化权限规则、审计日志第 3 章
shell 逐次人工审批写工具也进审批流程第 3 章
文件工具的 safePath 边界shell 的路径边界(仍然没有不在本教程范围;shell 始终依赖审批
effect 元数据effect 参与权限决策第 3 章
单轮多工具串行执行并发执行与并发安全契约第 13 章(后台任务)
完整读取 + limit 截断产物落盘、上下文压缩第 8 章
启动时固定的五个工具运行中动态增减工具第 19 章

35检查你是否真的读懂了试着不看文章回答这六个问题。答不上来的,回对应小节再看一遍。
  1. 模型传 {"path":"note.txt","limit":"10"},会发生什么?为什么不自动转成数字?(Zod 那节)
  2. nested/../secret.txt 化简后在工作区内,为什么还是拒绝?(safePath)
  3. 工作区里有个指向外部的 junction,第一关和第二关分别是什么结论?(为什么必须有第二关)
  4. write_file 写入 你好,返回的数字是 2 还是 6?为什么?(write_file)
  5. edit_fileold_text 在文件里出现 3 次,会改几处?(edit_file)
  6. handler 的 catch 最后为什么要 throw error 而不是返回一个兜底错误码?(错误映射)

这一章真正增加的不是四个孤立函数,而是一条稳定扩展路径:

Zod schema
   ↓
ToolDefinition(name + description + effect + handler)
   ↓
ToolRegistry(schema 快照 + 参数准备 + 分发)
   ↓
Agent Loop(保持不变)

以后新增工具,先定义明确输入和副作用,再实现 handler,最后注册一次。循环不需要知道工具细节,模型看到的 schema 和运行时执行的 handler 也不会分家。

03 / 自测

换个场景,你还会判断吗?

答完再看理由

每题只测一个边界。先做决定,再看解释。

SCENARIO CHECK01 / 030 分

准备开始