第 03 章:补齐四大工具 read/write/edit

上一章的 agent 只会用 bash——理论上这已经够了(catsed 什么都能干)。但实践中,给模型专用的 read/write/edit 工具效果好得多。这一章把 pi 的默认四件套补齐,并解决一个 agent 的隐形杀手:工具输出把上下文撑爆

为什么是这四个工具

pi 默认只给模型 readwriteeditbash 四个工具(createCodingTools,MIT 许可),理由有三:

  1. 模型在训练数据里已经见过大量同构的工具 schema,拿来就会用,不需要在系统提示词里再教一遍。
  2. edit 的"精确文本替换"比"全量重写"省 token 且更不易错——改一行和重写一个 500 行文件,出错概率天差地别。
  3. 其余一切交给 bash。pi 还有 grep/find/ls 三个只读工具,但默认禁用,只在你显式 --tools read,grep,find,ls(只读模式)时才启用。

四件套的语义分工(pi 系统提示词里的 guideline 也是这么写的):

工具用途关键设计
read看文件offset/limit 翻页,默认只给前 2000 行
write新建或整体重写自动创建父目录
edit外科手术式修改oldText 必须精确唯一匹配,否则报错
bash其他一切同步执行,返回 stdout+stderr

edit 的精确匹配哲学

edit 是四件套里最值得细看的。它的失败模式被刻意设计成可操作的错误文本:

  • oldText 找不到 → "oldText not found … Read the file first" —— 提示模型先去读;
  • oldText 匹配到多处 → "matches N locations … Provide more context" —— 提示模型带上更多上下文。

模型读到这些错误会自我修复:先 read 拿到精确内容,再用更大的上下文块重试。错误信息是写给模型看的指令,这是工具设计里最容易被忽视的一点。pi 的 edit.ts 同样如此,只是多了 diff 生成等给 UI 用的附加信息。

pi 的 edit 已演进为 edits[] 数组

mini-pi 用的是"一次一处替换"的形式(简单、好讲)。pi 当前版本的 edit 工具接受 edits: [{oldText, newText}, …],一次调用完成多处互不重曡的替换(旧的单替换形式仍被兼容、内部归一化为 edits[])——改多处可以少一次往返,又比全量重写省 token。等你的 agent 稳定后,这是值得借鉴的下一步。

上下文防爆:输出截断

cat 一个 10 万行的日志、grep 命中 5000 行……任何一次工具调用都可能把上下文窗口打穿。pi 的对策是 truncate.ts:2000 行 / 50KB 双上限,先到先截,绝不返回半行,并在输出尾部告诉模型"被截了、一共多少、怎么拿剩下的"。

src/main.ts(新增)
/** pi 的默认截断上限:2000 行 / 50KB,谁先到谁生效 */
const MAX_LINES = 2000;
const MAX_BYTES = 50 * 1024;

function truncateHead(content: string): string {
  const lines = content.split("\n");
  let out = lines.slice(0, MAX_LINES).join("\n");
  let truncatedBy: "lines" | "bytes" | null = lines.length > MAX_LINES ? "lines" : null;
  if (Buffer.byteLength(out) > MAX_BYTES) {
    out = Buffer.from(out).subarray(0, MAX_BYTES).toString("utf8");
    out = out.slice(0, out.lastIndexOf("\n")); // 不返回半行
    truncatedBy = "bytes";
  }
  if (!truncatedBy) return content;
  return `${out}\n\n[Output truncated by ${truncatedBy}: total ${lines.length} lines / ${Buffer.byteLength(content)} bytes. Use offset/limit or more specific commands to read more.]`;
}

三个新工具

src/main.ts(新增)
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";

async function toolRead(args: { path: string; offset?: number; limit?: number }): Promise<string> {
  const p = resolve(process.cwd(), args.path);
  const text = await readFile(p, "utf8");
  const lines = text.split("\n");
  const start = Math.max(0, (args.offset ?? 1) - 1);
  const sliced = lines.slice(start, args.limit !== undefined ? start + args.limit : undefined);
  return truncateHead(sliced.join("\n"));
}

async function toolWrite(args: { path: string; content: string }): Promise<string> {
  const p = resolve(process.cwd(), args.path);
  await mkdir(dirname(p), { recursive: true });
  await writeFile(p, args.content, "utf8");
  return `Wrote ${Buffer.byteLength(args.content)} bytes to ${args.path}`;
}

async function toolEdit(args: { path: string; oldText: string; newText: string }): Promise<string> {
  const p = resolve(process.cwd(), args.path);
  const text = await readFile(p, "utf8");
  const count = text.split(args.oldText).length - 1;
  if (count === 0) throw new Error(`oldText not found in ${args.path}. Read the file first to get exact content.`);
  if (count > 1)
    throw new Error(`oldText matches ${count} locations in ${args.path}. Provide more context to make it unique.`);
  await writeFile(p, text.replace(args.oldText, args.newText), "utf8");
  return `Edited ${args.path}: replaced ${args.oldText.length} chars with ${args.newText.length} chars`;
}

TOOL_DEFS 里补上三个定义(描述对照 pi;pi 当前的描述还会带上截断行为说明,mini-pi 从简),并把 executeToolswitch 补全:

src/main.ts(TOOL_DEFS 新增三个成员)
{
  type: "function",
  function: {
    name: "read",
    description:
      "Read the contents of a file. For text files, defaults to first 2000 lines. Use offset/limit for large files.",
    parameters: {
      type: "object",
      properties: {
        path: { type: "string", description: "Path to the file to read (relative or absolute)" },
        offset: { type: "number", description: "Line number to start reading from (1-indexed)" },
        limit: { type: "number", description: "Maximum number of lines to read" },
      },
      required: ["path"],
    },
  },
},
{
  type: "function",
  function: {
    name: "edit",
    description:
      "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
    parameters: {
      type: "object",
      properties: {
        path: { type: "string", description: "Path to the file to edit (relative or absolute)" },
        oldText: { type: "string", description: "Exact text to find and replace (must match exactly)" },
        newText: { type: "string", description: "New text to replace the old text with" },
      },
      required: ["path", "oldText", "newText"],
    },
  },
},
{
  type: "function",
  function: {
    name: "write",
    description:
      "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
    parameters: {
      type: "object",
      properties: {
        path: { type: "string", description: "Path to the file to write (relative or absolute)" },
        content: { type: "string", description: "Content to write to the file" },
      },
      required: ["path", "content"],
    },
  },
},
src/main.ts(executeTool 的 switch 补全)
    switch (name) {
      case "read":
        return { content: await toolRead(args), isError: false };
      case "write":
        return { content: await toolWrite(args), isError: false };
      case "edit":
        return { content: await toolEdit(args), isError: false };
      case "bash":
        return { content: await toolBash(args), isError: false };
      default:
        throw new Error(`Unknown tool: ${name}`);
    }

验收

在空目录里试这个任务,它会驱动 writereadedit 的完整链路:

> 创建 hello.ts 打印 "hello",然后把输出的字符串改成 "hello mini-pi",最后用 node 跑不起来没关系,用 tsx 跑一下确认输出

你会看到模型 write 创建文件,bash 执行 npx tsx hello.ts,如需修改则用 edit 精确替换——四个工具各司其职。

本章要点

  • 四件套的价值不在能力(bash 全能),而在引导模型用更省 token、更少出错的方式工作
  • edit 的多匹配报错、"先读后改"的错误文案,都是写给模型的指令。
  • 2000 行/50KB 截断是上下文预算的保险丝,截断说明要告诉模型"怎么拿到剩下的"。
  • pi 的真实实现还做了更多:给 UI 的 diff(edit-diff.ts)、写操作串行化(file-mutation-queue.ts)、图片读取等,详见源码导读:工具实现

下一章:第 04 章:流式输出与中断