跳到主内容
创建时间:最后更新:作者:白川

工作区工具:读取、搜索与编辑

围绕明确的工作区设计读取、搜索与编辑工具,限制路径范围,并定义可观察的成功、失败与取消结果。

工作区工具:读取、搜索与编辑

内置的 bash 能力太强——它能改任何路径、跑任何命令。对"修一个 bug"这样的仓库任务来说,正确的起点不是更强的命令,而是一组边界清晰的工作区工具:能读、能搜、能改,但改不到工作区之外。

这篇做三件事:先核对 Pi 内置工具的真实契约(工具名、参数、返回、失败语义),再用工具裁剪选项收敛能力面,最后自己实现一组受控的读取、搜索、编辑工具,把"路径必须落在工作区内"写成代码而不是靠模型自觉。

读它需要会用 TypeScript,并且对 Agent 循环有一个基本印象——模型提出结构化调用请求,程序校验参数后执行,再把结果送回上下文。读完你能拿到一份可以照抄的路径边界实现,和一张"什么该由宿主负责"的对照表。

Pi 迭代很快,接口可能变化。动手前先记录 pi --version,并按你安装的版本复核参数。

内置工具的真实契约

内置工具一共八个,名字来自内置工具清单里的 allToolNames:

typescript
// 内置工具的全部名字:模型按名字猜错会直接拿到 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
path(必填)、offset、limit
文件文本;图片返回附件块
ops.access() 抛错即失败,错误原文来自 Node 的 ENOENT/EACCES
只读
write
path、content
Successfully wrote to <path>
父目录创建失败时抛错;覆盖已有文件
写入(覆盖)
edit
path、edits[](每项 oldText/newText)
Successfully replaced N block(s) in <path>.
oldText 不唯一或不匹配时抛错;edits 为空数组直接抛错
写入(局部)
grep
pattern、path、glob、ignoreCase、literal、context、limit
命中行;无命中返回 No matches found
路径不存在抛 Path not found;缺 rg 时抛错
只读
find
pattern(glob)、path、limit
匹配文件路径;无命中返回 No files found matching pattern
路径不存在抛 Path not found;缺 fd 时抛错
只读
ls
path、limit
目录条目,目录带 / 后缀;空目录返回 (empty directory)
路径不存在抛 Path not found;非目录抛 Not a directory
只读
bash
command、timeout(秒)
stdout+stderr 合并文本
非 0 退出码返回 isError: true 并附 Command exited with code N;超时抛错
任意
powershell
同 bash
同上
同上
任意

三处细节会直接影响你的工具设计。

第一,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
string[]
普通名字或 * 通配的白名单;只有 +name / -name 形式时改为"在默认集合上增减"
noTools
"all" | "builtin"
不给白名单时的抑制模式:all 关掉全部,builtin 只关内置四个
excludeTools
string[]
黑名单,在 tools 之后生效
customTools
ToolDefinition[]
额外注册的工具,与内置工具并存

有两个坑必须知道。

坑一: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)。

typescript
// 错:普通名和修饰符混在一起,抛错
tools: ["read", "+grep"]

// 对:全是普通名,等于白名单
tools: ["read", "grep", "find", "ls"]

// 对:全是修饰符,在默认集合(read/bash/edit/write)上增减
tools: ["-bash", "-edit", "-write", "+grep"]

坑二:tools 白名单会连自定义工具一起过滤掉。 源码里 customTools 与扩展注册的工具走同一个 _isAllowedTool 判定,白名单没写上的名字会被直接剔除。所以想同时用内置只读工具和自己的写工具,要么把自定义工具名也写进白名单,要么改用 excludeTools 黑名单:

typescript
// 方案一:自定义工具名写进白名单
tools: ["read", "grep", "find", "ls", "ws_read", "ws_search", "ws_edit"]

// 方案二:用黑名单收敛内置工具,自定义工具不受影响
excludeTools: ["bash", "write", "edit"]

想确认实际生效的集合,读 session.getActiveToolNames()。它返回的是"当前声明给模型的工具",比读配置可靠。

自己实现受控的工作区工具

收敛内置工具解决的是"少给能力"。但"能力范围恰好等于工作区"这件事内置工具不提供,得自己写。

下面这组实现三个要点:路径先 resolve 再校验范围,越界抛错而不是静默截断;文件不存在时给出可纠错的信息;取消信号一路传到底层 I/O。

路径边界:resolve 之后必须还在工作区内

核心规则很简单:把模型给的路径解析成绝对路径,再用 path.relative 判断它是否仍在根目录之下。

typescript
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:

typescript
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 的核心价值不只是"返回内容",还包括"失败时模型知道怎么办"。所以错误信息里带上"这个目录下最接近的文件"。

typescript
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)。上面省掉了后两个,因为读一个文件不需要上报中间进度,也不需要扩展上下文。

搜索工具:范围在参数里就说清楚

搜索工具的越界风险在"路径参数",不在"内容参数"。所以路径走同一套边界校验,模式本身不校验。

typescript
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 必须在原文件里唯一出现",不唯一就报错。照抄这个语义,不要自作聪明地取"第一个匹配"——那会让模型无法判断自己的修改落在了哪里。

typescript
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。

把三者组装成一次完整的会话

typescript
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 事件订阅、清空监听器,并清理该会话的临时资源。少这一步,进程里会残留订阅闭包。

宿主负责什么

这组工具的边界有一半是宿主定的,不是模型定的。分清楚哪些归你:

职责由谁承担具体表现
工作区根目录
宿主
传给工具工厂,工具内部所有路径都以此为基准
路径范围校验
宿主(工具实现内)
resolve + relative 判定,越界抛错
软链接逃逸
宿主
realpath 之后再校验一次
允许的工具集合
宿主 + 设置
tools / noTools / excludeTools / defaultTools
工具失败语义
工具实现
抛错由 Agent 捕获并标为 isError: true
输出截断阈值
宿主
默认 2000 行 / 50KB(DEFAULT_MAX_LINES 与 DEFAULT_MAX_BYTES)
文件系统权限
宿主进程
Pi 以启动它的用户身份运行,没有内置沙箱
凭据与密钥
宿主
不放进工作区,不通过工具参数传递

最后两行值得展开:官方安全文档写得很直白,Pi 没有内置权限系统,工具用的是启动进程那个用户的操作系统权限。也就是说你的路径边界校验是工具层的第一道防线,不是最后一道。真正要防住"模型被提示注入后去改别处",还得靠容器或沙箱。

三种场景的可观察结果

设计工具时最容易漏的是"怎么算成功"。把三个场景的结果定清楚,验收才有依据:

场景触发条件模型看到什么宿主观察到什么
成功
路径合法、内容可读
工具结果内容正常返回,isError 为 false
tool_execution_end 且 isError: false,durationMs 有值
失败
文件不存在 / 路径越界 / oldText 不唯一
错误信息作为工具结果回填,含可执行的下一步建议
tool_execution_end 且 isError: true
取消
用户按下取消,signal 触发
工具抛出 Operation aborted,模型看到失败结果
工具执行以 aborted 结束,会话进入 idle

关于取消有一点要留意:官方文档明确说抛错就是失败,"把错误当普通内容返回"是不推荐的做法。所以取消场景走 throw new Error("Operation aborted") 符合契约,不要改成返回一段"已取消"的文本——那会让模型以为操作成功了。

你现在能做什么

  • 打开你安装的 Pi 跑 pi --version,对照本文的八个工具名和参数表核一遍,有出入以你的版本为准。
  • 用 session.getActiveToolNames() 打印一次真实会话的生效工具集合,确认 tools 白名单是否把你自己的自定义工具一起过滤掉了。
  • 把上面的 resolveInWorkspace 和 resolveRealInWorkspace 抄进项目,注册一个只读探针工具,然后用绝对路径 /etc/passwd 和一个指向外部的软链接各试一次,两次都应当抛出越界错误而不是读出内容。
  • 写一个失败夹具:让工具读一个不存在的文件,断言返回的错误文本里同时包含"文件不存在"和"相近文件"两个片段——这证明错误信息是可自我纠正的。

排障速查

现象检查点处理
模型调用了不存在的工具
工具名是否在 allToolNames 里
内置只有 read/bash/powershell/edit/write/grep/find/ls,没有 search 或 replace
tools 选项直接抛错
是否混写了普通名和 +name
拆成两次调用,或全用普通名当白名单
自定义工具没被模型看到
是否设了 tools 白名单
白名单会过滤自定义工具,改用 excludeTools 或把自定义工具名写进白名单
ws_read 报路径越界但路径看着正常
是否用了软链接
词法解析过了但 realpath 越界,检查 resolveRealInWorkspace
grep 报找不到 ripgrep
宿主环境
ensureTool("rg") 下载失败,离线模式会跳过下载
find 报找不到 fd
宿主环境
同上,ensureTool("fd")
工具结果撑爆上下文
截断配置
内置阈值是 2000 行 / 50KB,自定义工具要自己 truncateHead/truncateTail
会话结束后进程不退出
是否调用了 dispose()
session.dispose() 会断开订阅并清理会话资源
模型的编辑落在了意料之外的位置
oldText 是否唯一
edits[].oldText 必须唯一,重复会抛错而不是取第一个

参考资料