第七章:工具系统 -- 7 大内置工具
"工具是手的延伸,也是思维的延伸。"
7.1 工具的哲学
Pi 的设计哲学是最小化核心,最大化可扩展。它只给 LLM 提供 7 个工具,但这 7 个工具足以完成几乎所有编码任务。
| 工具 | 职责 | 类比 |
|---|---|---|
read | 读取文件 | cat |
write | 写入文件 | echo > file |
edit | 编辑文件(差量修改) | sed |
bash | 执行 shell 命令 | bash |
grep | 搜索文件内容 | grep |
find | 查找文件 | find |
ls | 列出目录 | ls |
★ Insight ─────────────────────────────────────为什么只有 7 个工具? 因为 LLM 的上下文窗口是有限的。每个工具的 Schema 都会占用上下文空间。如果工具太多,Schema 本身就会消耗大量 token,留给实际对话的空间就少了。7 个工具是功能覆盖和 token 消耗之间的平衡点。而且,扩展系统允许用户注册自定义工具,所以核心不需要面面俱到。 ─────────────────────────────────────────────────
7.2 工具定义的统一模式
所有 7 个工具都遵循相同的定义模式:
packages/coding-agent/src/core/tools/
├── index.ts # 工厂函数和导出
├── read.ts # Read 工具
├── write.ts # Write 工具
├── edit.ts # Edit 工具
├── bash.ts # Bash 工具
├── grep.ts # Grep 工具
├── find.ts # Find 工具
├── ls.ts # Ls 工具
├── truncate.ts # 截断工具(辅助)
├── edit-diff.ts # Diff 渲染(辅助)
├── path-utils.ts # 路径工具(辅助)
├── render-utils.ts # 渲染工具(辅助)
├── tool-definition-wrapper.ts # 适配器
└── file-mutation-queue.ts # 文件修改队列适配器模式:ToolDefinition → AgentTool
Pi 有两套工具接口:
ToolDefinition(coding-agent 层)-- 完整功能,包含 UI 渲染AgentTool(agent 层)-- 最小接口,只有执行逻辑
tool-definition-wrapper.ts 负责将前者适配为后者:
// packages/coding-agent/src/core/tools/tool-definition-wrapper.ts
export function wrapToolDefinition(definition: ToolDefinition): AgentTool {
return {
name: definition.name,
label: definition.label,
description: definition.description,
parameters: definition.parameters,
executionMode: definition.executionMode,
prepareArguments: definition.prepareArguments,
execute: definition.execute,
};
}⚡ Java 对照 ───────────────────────────────────── 这就是经典的适配器模式(Adapter Pattern)。ToolDefinition 是适配者(Adaptee),AgentTool 是目标接口(Target),wrapToolDefinition 是适配器(Adapter)。在 Java 中你可能用 class AgentToolAdapter implements AgentTool { private ToolDefinition def; } 来实现。 ─────────────────────────────────────────────────
7.3 依赖注入:Operations 接口
每个工具都定义了一个 Operations 接口,用于隔离文件系统操作:
// Read 工具的操作接口
interface ReadOperations {
readFile: (absolutePath: string) => Promise<Buffer>;
access: (absolutePath: string) => Promise<void>;
detectImageMimeType?: (absolutePath: string) => Promise<string | null>;
}
// Bash 工具的操作接口
interface BashOperations {
execute: (command: string, options: BashOptions) => Promise<BashResult>;
abort: () => void;
}
// Edit 工具的操作接口
interface EditOperations {
readFile: (absolutePath: string) => Promise<Buffer>;
writeFile: (absolutePath: string, content: string) => Promise<void>;
access: (absolutePath: string) => Promise<void>;
}★ Insight ─────────────────────────────────────为什么每个工具都要定义自己的 Operations 接口? 为了可测试性和可替换性。在测试中,你可以注入 mock 的 Operations 来模拟文件系统错误、磁盘满等情况。在远程模式下(如 RPC),你可以注入一个通过 SSH 或 HTTP 操作远程文件系统的实现。这和 Java 中用 Repository 接口隔离数据库访问是完全一样的思路。 ─────────────────────────────────────────────────
7.4 工具工厂函数
index.ts 提供了一组工厂函数,用于创建工具集合:
// 创建所有工具
function createAllTools(cwd: string, options?: ToolsOptions): Record<ToolName, AgentTool> {
return {
read: createReadTool(cwd, options?.read),
bash: createBashTool(cwd, options?.bash),
edit: createEditTool(cwd, options?.edit),
write: createWriteTool(cwd, options?.write),
grep: createGrepTool(cwd, options?.grep),
find: createFindTool(cwd, options?.find),
ls: createLsTool(cwd, options?.ls),
};
}
// 创建只读工具(不含 bash、write、edit)
function createReadOnlyTools(cwd: string, options?: ToolsOptions): AgentTool[] {
return [
createReadTool(cwd, options?.read),
createGrepTool(cwd, options?.grep),
createFindTool(cwd, options?.find),
createLsTool(cwd, options?.ls),
];
}★ Insight ─────────────────────────────────────createReadOnlyTools 的存在说明 Pi 支持"受限模式"。 在某些场景下(如代码审查、安全审计),你不希望 Agent 能修改文件或执行命令,只允许它读取和搜索。这和 Java 中的"最小权限原则"一致——只授予完成任务所需的最小权限。 ─────────────────────────────────────────────────
7.5 文件修改队列
当多个工具并行执行时,如果它们都要修改同一个文件,就会产生竞争条件。Pi 用 withFileMutationQueue 解决这个问题:
// packages/coding-agent/src/core/tools/file-mutation-queue.ts
export function withFileMutationQueue(tool: AgentTool): AgentTool {
const queue = new Map<string, Promise<void>>();
return {
...tool,
async execute(toolCallId, params, signal, onUpdate) {
const filePath = getFilePath(params); // 提取文件路径
const existing = queue.get(filePath);
// 等待同一文件的前一个操作完成
const promise = existing?.then(() => tool.execute(toolCallId, params, signal, onUpdate))
?? tool.execute(toolCallId, params, signal, onUpdate);
queue.set(filePath, promise.finally(() => {
if (queue.get(filePath) === promise) queue.delete(filePath);
}));
return promise;
}
};
}⚡ Java 对照 ───────────────────────────────────── 这就是 Java 的 ConcurrentHashMap.computeIfAbsent() 或 synchronized 块的异步版本。它确保对同一文件的修改操作串行执行,但不同文件之间仍然可以并行。类似于数据库的行级锁——锁定粒度是单个文件。 ─────────────────────────────────────────────────
7.6 深入:Read 工具
让我们完整解析一个工具的实现。Read 工具是最简单的,适合入门。
// packages/coding-agent/src/core/tools/read.ts(精简版)
// 1. Schema 定义
const readSchema = Type.Object({
path: Type.String({ description: "Absolute or relative path to the file to read" }),
offset: Type.Optional(Type.Number({ description: "Line number to start reading from", minimum: 0 })),
limit: Type.Optional(Type.Number({ description: "Number of lines to read", exclusiveMinimum: 0 })),
});
// 2. 创建工具定义
function createReadToolDefinition(cwd: string, options?: ReadToolOptions) {
const ops = options?.operations ?? { readFile: fs.readFile, access: fs.access };
return defineTool({
name: "read",
label: "Read",
description: "Read a file from disk. Supports text files and images.",
parameters: readSchema,
// 3. 执行逻辑
async execute(toolCallId, params, signal, onUpdate) {
const absolutePath = path.resolve(cwd, params.path);
// 检查文件是否存在
await ops.access(absolutePath);
// 读取文件
const buffer = await ops.readFile(absolutePath);
// 检测是否为图片
const mimeType = await ops.detectImageMimeType?.(absolutePath);
if (mimeType) {
return {
content: [{ type: "image", data: buffer.toString("base64"), mimeType }],
details: undefined,
};
}
// 文本文件处理
const content = buffer.toString("utf-8");
const lines = content.split("\n");
// 截断处理
const offset = params.offset ?? 0;
const limit = params.limit ?? lines.length;
const selectedLines = lines.slice(offset, offset + limit);
// 带行号的输出
const numbered = selectedLines
.map((line, i) => `${offset + i + 1}\t${line}`)
.join("\n");
return {
content: [{ type: "text", text: numbered }],
details: {
truncation: {
startLine: offset + 1,
endLine: offset + selectedLines.length,
totalLines: lines.length,
},
},
};
},
});
}★ Insight ─────────────────────────────────────行号输出是精心设计的。 Read 工具返回的内容带行号(1\t第一行),这样 LLM 在引用代码时可以精确指出"第 42 行"。Edit 工具也依赖行号来定位修改位置。这种设计让 LLM 和工具之间的协作更加精确。 ─────────────────────────────────────────────────
7.7 深入:Bash 工具
Bash 工具是最复杂的,因为它需要管理子进程:
// Bash 工具的关键特性:
// 1. 执行 shell 命令
// 2. 支持超时
// 3. 支持中断
// 4. 捕获 stdout 和 stderr
// 5. 限制输出大小(防止 LLM 上下文爆炸)
interface BashToolOptions {
timeout?: number; // 默认 120 秒
maxOutputLength?: number; // 默认 10000 字符
operations?: BashOperations;
}★ Insight ─────────────────────────────────────Bash 工具的 maxOutputLength 是关键的安全阀。 如果一个命令输出了 1MB 的日志,直接塞进 LLM 的上下文会浪费大量 token 并可能超出窗口限制。所以 Bash 工具会截断过长的输出。这和 Java 中日志框架的 maxMessageSize 是同一个思路。 ─────────────────────────────────────────────────
7.8 深入:Edit 工具
Edit 工具是实现"精确代码修改"的关键。它接受一个"搜索-替换"对:
const editSchema = Type.Object({
path: Type.String({ description: "Path to the file" }),
old_string: Type.String({ description: "The exact text to find and replace" }),
new_string: Type.String({ description: "The replacement text" }),
});工作原理:
原始文件:
function hello() {
console.log("world");
}
old_string: console.log("world");
new_string: console.log("Hello, World!");
修改后:
function hello() {
console.log("Hello, World!");
}★ Insight ─────────────────────────────────────为什么用"搜索-替换"而不是"行号替换"? 因为 LLM 经常搞错行号。如果文件有 100 行,LLM 可能说"修改第 47 行",但实际上第 47 行不是它想改的那一行。用精确的文本匹配可以避免这种错误——只要 old_string 在文件中唯一存在,就能准确定位。这也让 Edit 工具对文件的中间修改更加健壮。 ─────────────────────────────────────────────────
7.9 工具的 UI 渲染
ToolDefinition 比 AgentTool 多了两个可选的渲染方法:
interface ToolDefinition<TParams, TDetails, TState> {
// ... 其他字段 ...
// 渲染工具调用(显示参数)
renderCall?: (args: Static<TParams>, theme: Theme, context: ToolRenderContext) => Component | undefined;
// 渲染工具结果(显示输出)
renderResult?: (
result: AgentToolResult<TDetails>,
options: ToolRenderResultOptions,
theme: Theme,
context: ToolRenderContext,
) => Component | undefined;
}这让每个工具可以在终端 UI 中有自定义的显示效果。例如 Edit 工具会渲染一个彩色 diff,Bash 工具会显示命令和输出的折叠视图。
7.10 本章小结
ToolDefinition (coding-agent 层)
│
│ wrapToolDefinition()
▼
AgentTool (agent 层)
│
│ agent-loop.ts 调用 execute()
▼
┌─────────────────────────────────────┐
│ execute(toolCallId, params, signal) │
│ │
│ 1. 路径解析: path.resolve(cwd, path)│
│ 2. 权限检查: access() │
│ 3. 核心操作: readFile / bash / ... │
│ 4. 结果格式化: 带行号 / 截断 │
│ 5. 返回: { content, details } │
└─────────────────────────────────────┘