工作区工具:读取、搜索与编辑
围绕明确的工作区设计读取、搜索与编辑工具,限制路径范围,并定义可观察的成功、失败与取消结果。
工作区工具:读取、搜索与编辑
内置的 bash 能力太强——它能改任何路径、跑任何命令。对"修一个 bug"这样的仓库任务来说,正确的起点不是更强的命令,而是一组边界清晰的工作区工具:能读、能搜、能改,但改不到工作区之外。
这篇做三件事:先核对 Pi 内置工具的真实契约(工具名、参数、返回、失败语义),再用工具裁剪选项收敛能力面,最后自己实现一组受控的读取、搜索、编辑工具,把"路径必须落在工作区内"写成代码而不是靠模型自觉。
读它需要会用 TypeScript,并且对 Agent 循环有一个基本印象——模型提出结构化调用请求,程序校验参数后执行,再把结果送回上下文。读完你能拿到一份可以照抄的路径边界实现,和一张"什么该由宿主负责"的对照表。
Pi 迭代很快,接口可能变化。动手前先记录 pi --version,并按你安装的版本复核参数。
内置工具的真实契约
内置工具一共八个,名字来自内置工具清单里的 allToolNames:
// 内置工具的全部名字:模型按名字猜错会直接拿到 Tool <name> not found
export const allToolNames: Set<ToolName> = new Set([
// 四个只读:read 读文件,grep/find 搜内容与文件名,ls 列目录
"read",
// 两个任意副作用:bash 和 powershell 能改任何路径、跑任何命令
"bash",
"powershell",
// 两个写入:write 整file覆盖,edit 做局部精确替换
"edit",
"write",
"grep",
"find",
"ls",
]);注意这份清单里没有 search 或 replace——搜索是 grep / find,替换是 edit,列举是 ls。模型按名字猜会直接拿到 Tool <name> not found。
下表是逐个读源码核对出来的契约。"失败语义"一列尤其重要:它决定模型拿到失败信息后能不能自我纠正。
三处细节会直接影响你的工具设计。
第一,read 的失败信息来自 Node 的 access。 源码里是 await ops.access(absolutePath),模型看到的是 ENOENT: no such file or directory, access '/abs/path'——它没说"这个目录下有什么"。模型要自我纠正就得自己再调一次 ls。你自己的工具如果能给一句"同目录下最接近的文件是 X",纠错轮次会明显下降。
第二,read 的路径解析不是简单 path.resolve。 resolveReadPathAsync 在解析失败时还会尝试 macOS 的 AM/PM 窄空格变体、NFD 分解形式和弯引号变体。这是为 macOS 截图文件名准备的兼容逻辑,不是安全边界——它不会阻止 ../../etc/passwd。
第三,write 和 edit 不做路径范围限制。 两者都走 resolveToCwd(path, cwd),把相对路径拼到工作目录下,但绝对路径原样通过。传 /etc/hosts 给 write 就会写到 /etc/hosts。内置工具不替你划工作区边界,这部分责任在宿主。
收敛工具集合
createAgentSession() 提供四个相关选项:
有两个坑必须知道。
坑一:tools 不能混写。 源码里 getToolListError 明确返回 tool names cannot be mixed with +name or -name entries,混写时 createAgentSession 直接抛错。另外 +name/-name 只接受精确名字,带 * 会被拒绝(错误文案是 +name and -name entries take exact tool names, not patterns)。
// 错:普通名和修饰符混在一起,抛错
tools: ["read", "+grep"]
// 对:全是普通名,等于白名单
tools: ["read", "grep", "find", "ls"]
// 对:全是修饰符,在默认集合(read/bash/edit/write)上增减
tools: ["-bash", "-edit", "-write", "+grep"]坑二:tools 白名单会连自定义工具一起过滤掉。 源码里 customTools 与扩展注册的工具走同一个 _isAllowedTool 判定,白名单没写上的名字会被直接剔除。所以想同时用内置只读工具和自己的写工具,要么把自定义工具名也写进白名单,要么改用 excludeTools 黑名单:
// 方案一:自定义工具名写进白名单
tools: ["read", "grep", "find", "ls", "ws_read", "ws_search", "ws_edit"]
// 方案二:用黑名单收敛内置工具,自定义工具不受影响
excludeTools: ["bash", "write", "edit"]想确认实际生效的集合,读 session.getActiveToolNames()。它返回的是"当前声明给模型的工具",比读配置可靠。
自己实现受控的工作区工具
收敛内置工具解决的是"少给能力"。但"能力范围恰好等于工作区"这件事内置工具不提供,得自己写。
下面这组实现三个要点:路径先 resolve 再校验范围,越界抛错而不是静默截断;文件不存在时给出可纠错的信息;取消信号一路传到底层 I/O。
路径边界:resolve 之后必须还在工作区内
核心规则很简单:把模型给的路径解析成绝对路径,再用 path.relative 判断它是否仍在根目录之下。
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
/**
* 把模型给的路径解析到工作区内。越界直接抛错,不做静默截断。
* 相对路径基于工作区根解析;绝对路径原样参与校验。
*/
export function resolveInWorkspace(workspaceRoot: string, requestedPath: string): string {
const root = resolve(workspaceRoot);
const target = isAbsolute(requestedPath) ? resolve(requestedPath) : resolve(root, requestedPath);
const rel = relative(root, target);
// rel 为空表示就是根目录本身;以 ".." 开头说明已经跑出去了
if (rel !== "" && (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))) {
throw new Error(
`路径越界:${requestedPath} 解析为 ${target},超出工作区 ${root}。` +
`请使用工作区内的相对路径,例如 src/${basename(target)}。`,
);
}
return target;
}用 relative 而不是字符串前缀比较,是因为前缀比较在 /repo 和 /repo-old 这种场景下会误判。relative 返回 .. 开头就说明在根之外。
软链接是另一个口子:resolve 只做词法解析,指向外部的软链接能绕过检查。要堵这个口子,解析之后再取一次 realpath:
import { realpath } from "node:fs/promises";
import { isAbsolute, relative, resolve, sep } from "node:path";
/** 软链接目标可能落在工作区外,需要在 realpath 之后再校验一次。 */
export async function resolveRealInWorkspace(workspaceRoot: string, requestedPath: string): Promise<string> {
// 根目录也要 realpath:工作区本身可能是软链接,不解析会拿错基准
const root = await realpath(resolve(workspaceRoot));
// 新建文件时 realpath 会失败,那不是越界,退回词法解析结果
const target = await realpath(resolve(root, requestedPath)).catch(() => resolve(root, requestedPath));
const rel = relative(root, target);
// rel 为空表示就是根目录本身;以 ".." 开头说明已经跑出去了
if (rel !== "" && (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))) {
throw new Error(`路径越界(经软链接解析):${requestedPath} -> ${target},超出工作区 ${root}。`);
}
return target;
}新建文件时 realpath 会失败,所以上面用 catch 退回词法解析结果。写入路径不存在是合法输入,不该被当成越界。
读取工具:让错误信息带上下一步
read 的核心价值不只是"返回内容",还包括"失败时模型知道怎么办"。所以错误信息里带上"这个目录下最接近的文件"。
import { readdir, readFile } from "node:fs/promises";
import { basename, dirname } from "node:path";
import { Type } from "@earendil-works/pi-ai";
import { defineTool, truncateHead } from "@earendil-works/pi-coding-agent";
/** 找出目录下与目标文件名最接近的条目,让模型能直接改对路径。 */
async function closestSiblings(dir: string, wanted: string): Promise<string[]> {
try {
const entries = await readdir(dir);
const target = basename(wanted).toLowerCase();
const stem = target.replace(/\.[^.]+$/, "");
return entries
.filter((name) => {
const lower = name.toLowerCase();
return lower.includes(stem) || stem.includes(lower.replace(/\.[^.]+$/, ""));
})
.slice(0, 5);
} catch {
return [];
}
}
export function createWorkspaceReadTool(workspaceRoot: string) {
return defineTool({
name: "ws_read",
label: "读取文件",
description:
"读取工作区内的文件内容。path 必须是工作区内的相对路径。文件不存在时错误信息会列出同目录下的相近文件。",
parameters: Type.Object({
path: Type.String({ description: "工作区内的相对路径,例如 src/index.ts" }),
offset: Type.Optional(Type.Number({ description: "起始行号(1 起)" })),
limit: Type.Optional(Type.Number({ description: "最多读取的行数" })),
}),
async execute(_toolCallId, params, signal) {
// 先校验路径再碰文件系统:越界的请求连 open 都不会走到
const absolute = await resolveRealInWorkspace(workspaceRoot, params.path);
let content: string;
try {
content = await readFile(absolute, "utf-8");
} catch (error: any) {
if (error?.code === "ENOENT") {
// 错误信息里带上相近文件名,模型能直接改对路径,不用多跑一轮 ls
const siblings = await closestSiblings(dirname(absolute), params.path);
const hint = siblings.length
? `同目录下相近的文件:${siblings.join("、")}`
: `目录 ${dirname(absolute)} 下没有匹配的条目,可用 ls 查看目录内容。`;
throw new Error(`文件不存在:${params.path}。${hint}`);
}
if (error?.code === "EISDIR") {
throw new Error(`${params.path} 是目录,不能读取。请改用 ws_ls 列出其中的文件。`);
}
throw error;
}
if (signal?.aborted) throw new Error("操作已取消");
const lines = content.split("\n");
// offset 是 1 起的行号,切片前要先减一
const start = params.offset ? Math.max(0, params.offset - 1) : 0;
const end = params.limit ? start + params.limit : lines.length;
const slice = lines.slice(start, end).join("\n");
// 用官方截断工具,和内置 read 保持同一套阈值
const truncation = truncateHead(slice);
return {
// content 给模型读,details 给程序读,两者不要混
content: [{ type: "text", text: truncation.content }],
details: {
path: params.path,
lines: end - start,
totalLines: lines.length,
truncation: truncation.truncated ? truncation : undefined,
},
};
},
});
}这里 execute 的签名是 (toolCallId, params, signal, onUpdate, ctx)。上面省掉了后两个,因为读一个文件不需要上报中间进度,也不需要扩展上下文。
搜索工具:范围在参数里就说清楚
搜索工具的越界风险在"路径参数",不在"内容参数"。所以路径走同一套边界校验,模式本身不校验。
import { Type } from "@earendil-works/pi-ai";
import { createGrepTool, defineTool } from "@earendil-works/pi-coding-agent";
export function createWorkspaceSearchTool(workspaceRoot: string) {
return defineTool({
name: "ws_search",
label: "搜索文件内容",
description: "在工作区内按正则搜索文件内容,返回 路径:行号: 内容 的形式。",
parameters: Type.Object({
pattern: Type.String({ description: "正则表达式" }),
path: Type.Optional(Type.String({ description: "限定搜索目录,相对工作区根" })),
limit: Type.Optional(Type.Number({ description: "最多返回多少条匹配,默认 50" })),
}),
async execute(_toolCallId, params, signal) {
// path 参数先过边界校验:模型给的路径在这里就被替换成已验证的绝对路径
const searchRoot = params.path
? await resolveRealInWorkspace(workspaceRoot, params.path)
: await resolveRealInWorkspace(workspaceRoot, ".");
// 复用内置 grep 的后端,但把搜索根和结果上限都钉死在工作区内。
const grep = createGrepTool(searchRoot);
const result = await grep.execute(
"ws_search",
// 上限再夹一层 200:模型可以传更大的 limit,但搜索结果会撑爆上下文
{ pattern: params.pattern, path: searchRoot, limit: Math.min(params.limit ?? 50, 200) },
signal,
);
return {
// content 原样透传给模型,details 里补上这次实际搜的根
content: result.content,
details: { ...(result.details ?? {}), root: params.path ?? "." },
};
},
});
}关键是 path 参数被替换成了已校验的绝对路径——模型没法通过 path 绕开边界。
一个务实的补充:grep 的后端依赖是 ripgrep,源码里 ensureTool("rg") 找不到时会尝试下载,离线模式或下载失败时直接抛 ripgrep (rg) is not available and could not be downloaded。find 的后端是 fd,同理。这意味着搜索能力依赖宿主机器上能装上这两个二进制,部署时要把它们算进环境依赖。
编辑工具:精确替换,不做模糊匹配
内置 edit 的语义是"每个 oldText 必须在原文件里唯一出现",不唯一就报错。照抄这个语义,不要自作聪明地取"第一个匹配"——那会让模型无法判断自己的修改落在了哪里。
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { Type } from "@earendil-works/pi-ai";
import { defineTool } from "@earendil-works/pi-coding-agent";
export function createWorkspaceEditTool(workspaceRoot: string) {
return defineTool({
name: "ws_edit",
label: "精确编辑文件",
description: "在工作区文件内做精确文本替换。oldText 必须在文件中唯一出现,否则报错并给出出现次数。",
parameters: Type.Object({
path: Type.String({ description: "工作区内的相对路径" }),
oldText: Type.String({ description: "要被替换的原文,必须唯一出现" }),
newText: Type.String({ description: "替换后的新文本" }),
}),
async execute(_toolCallId, params, signal) {
// 编辑前同样要过边界校验,否则模型能改到工作区之外
const absolute = await resolveRealInWorkspace(workspaceRoot, params.path);
let content: string;
try {
content = await readFile(absolute, "utf-8");
} catch (error: any) {
if (error?.code === "ENOENT") {
throw new Error(`文件不存在:${params.path},无法编辑。请先用 ws_search 确认路径。`);
}
throw error;
}
// 用出现次数代替「取第一个匹配」:不唯一就报错,让模型自己扩大上下文
const occurrences = content.split(params.oldText).length - 1;
if (occurrences === 0) {
throw new Error(
`在 ${params.path} 中找不到 oldText。请先用 ws_read 读取该文件的准确内容,再构造 oldText。`,
);
}
if (occurrences > 1) {
throw new Error(
`oldText 在 ${params.path} 中出现了 ${occurrences} 次,无法唯一定位。` +
`请扩大 oldText 的上下文范围直到只匹配一次。`,
);
}
if (signal?.aborted) throw new Error("操作已取消");
const updated = content.replace(params.oldText, params.newText);
// 写文件前确保父目录存在:内置 write 会自动 mkdir,自定义工具要自己补上
await mkdir(dirname(absolute), { recursive: true });
await writeFile(absolute, updated, "utf-8");
return {
content: [{ type: "text", text: `已在 ${params.path} 中替换 1 处内容。` }],
details: { path: params.path, bytesBefore: content.length, bytesAfter: updated.length },
};
},
});
}忘记建父目录,模型新建深层文件时就会拿到一个让人困惑的 ENOENT。
把三者组装成一次完整的会话
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
const workspaceRoot = process.cwd();
const { session } = await createAgentSession({
cwd: workspaceRoot,
// 用黑名单收敛内置工具;自定义工具不受黑名单影响
excludeTools: ["bash", "write", "edit"],
customTools: [
createWorkspaceReadTool(workspaceRoot),
createWorkspaceSearchTool(workspaceRoot),
createWorkspaceEditTool(workspaceRoot),
],
sessionManager: SessionManager.inMemory(workspaceRoot),
});
try {
// 先确认实际生效的工具集合:比读配置可靠
console.log("生效工具:", session.getActiveToolNames());
const unsubscribe = session.subscribe((event) => {
if (event.type === "tool_execution_start") {
console.log(`[tool] ${event.toolName}`, event.args);
}
// isError 为 true 说明工具抛了异常,模型收到的是失败结果
if (event.type === "tool_execution_end" && event.isError) {
console.log(`[tool error] ${event.toolName}`);
}
});
await session.prompt("读取 package.json,找出 scripts 里的 test 命令");
console.log(session.getLastAssistantText());
} finally {
// 释放顺序:先退订,再 dispose
unsubscribe?.();
session.dispose();
}session.dispose() 不能省。它会中止在跑的工作、断开 agent 事件订阅、清空监听器,并清理该会话的临时资源。少这一步,进程里会残留订阅闭包。
宿主负责什么
这组工具的边界有一半是宿主定的,不是模型定的。分清楚哪些归你:
最后两行值得展开:官方安全文档写得很直白,Pi 没有内置权限系统,工具用的是启动进程那个用户的操作系统权限。也就是说你的路径边界校验是工具层的第一道防线,不是最后一道。真正要防住"模型被提示注入后去改别处",还得靠容器或沙箱。
三种场景的可观察结果
设计工具时最容易漏的是"怎么算成功"。把三个场景的结果定清楚,验收才有依据:
关于取消有一点要留意:官方文档明确说抛错就是失败,"把错误当普通内容返回"是不推荐的做法。所以取消场景走 throw new Error("Operation aborted") 符合契约,不要改成返回一段"已取消"的文本——那会让模型以为操作成功了。
你现在能做什么
- 打开你安装的 Pi 跑
pi --version,对照本文的八个工具名和参数表核一遍,有出入以你的版本为准。 - 用
session.getActiveToolNames()打印一次真实会话的生效工具集合,确认tools白名单是否把你自己的自定义工具一起过滤掉了。 - 把上面的
resolveInWorkspace和resolveRealInWorkspace抄进项目,注册一个只读探针工具,然后用绝对路径/etc/passwd和一个指向外部的软链接各试一次,两次都应当抛出越界错误而不是读出内容。 - 写一个失败夹具:让工具读一个不存在的文件,断言返回的错误文本里同时包含"文件不存在"和"相近文件"两个片段——这证明错误信息是可自我纠正的。