Skip to content

第七章:工具系统 -- 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 有两套工具接口:

  1. ToolDefinition(coding-agent 层)-- 完整功能,包含 UI 渲染
  2. AgentTool(agent 层)-- 最小接口,只有执行逻辑

tool-definition-wrapper.ts 负责将前者适配为后者:

typescript
// 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 接口,用于隔离文件系统操作:

typescript
// 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&lt;BashResult&gt;;
    abort: () => void;
}

// Edit 工具的操作接口
interface EditOperations {
    readFile: (absolutePath: string) => Promise&lt;Buffer&gt;;
    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 提供了一组工厂函数,用于创建工具集合:

typescript
// 创建所有工具
function createAllTools(cwd: string, options?: ToolsOptions): Record&lt;ToolName, AgentTool&gt; {
    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 解决这个问题:

typescript
// 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 工具是最简单的,适合入门。

typescript
// 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 工具是最复杂的,因为它需要管理子进程:

typescript
// 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 工具是实现"精确代码修改"的关键。它接受一个"搜索-替换"对:

typescript
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 渲染

ToolDefinitionAgentTool 多了两个可选的渲染方法:

typescript
interface ToolDefinition&lt;TParams, TDetails, TState&gt; {
    // ... 其他字段 ...

    // 渲染工具调用(显示参数)
    renderCall?: (args: Static&lt;TParams&gt;, theme: Theme, context: ToolRenderContext) => Component | undefined;

    // 渲染工具结果(显示输出)
    renderResult?: (
        result: AgentToolResult&lt;TDetails&gt;,
        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 }       │
└─────────────────────────────────────┘

第六章:Agent Core | 第八章:会话管理 -- 持久化与分支 →

基于 MIT 许可证发布