现在位置: 首页 > Pi Agent > 正文

Pi Agent 自定义工具开发

通过 Extension 注册自定义工具,让 AI 能够调用你编写的函数来完成特定任务。


工具注册基础

使用 pi.registerTool() 注册一个 AI 可调用的工具:

实例

// 文件路径:~/.pi/agent/extensions/todo-tool.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";

// 定义待办事项列表(内存存储)
let todos: string[] = [];

export default function (pi: ExtensionAPI) {
  // 启动时从会话中恢复状态
  pi.on("session_start", async (_event, ctx) => {
    todos = [];
    for (const entry of ctx.sessionManager.getBranch()) {
      if (entry.type === "message"
          && entry.message.role === "toolResult"
          && entry.message.toolName === "todo") {
        todos = entry.message.details?.todos ?? [];
      }
    }
  });

  pi.registerTool({
    name: "todo",
    label: "待办事项",
    description: "管理项目的待办事项列表",
    // 简短描述,显示在系统提示词的 Available tools 区域
    promptSnippet: "管理项目待办事项:list 列表、add 添加、done 完成",
    // 使用指南,追加到系统提示词的 Guidelines 区域
    promptGuidelines: [
      "使用 todo 工具进行任务规划,不要直接编辑待办文件",
    ],
    parameters: Type.Object({
      // StringEnum 确保与 Google API 兼容
      action: StringEnum(["list", "add", "done"] as const),
      text: Type.Optional(Type.String({
        description: "添加时:事项描述。完成时:事项编号或描述"
      })),
    }),
    // 兼容旧参数格式
    prepareArguments(args) {
      if (!args || typeof args !== "object") return args;
      const input = args as { action?: string; oldAction?: string };
      // 将旧字段映射到新字段
      if (typeof input.oldAction === "string"
          && input.action === undefined) {
        return { ...input, action: input.oldAction };
      }
      return args;
    },
    async execute(toolCallId, params, signal, onUpdate, ctx) {
      if (signal?.aborted) {
        return {
          content: [{ type: "text", text: "操作已取消" }]
        };
      }

      // 发送进度更新
      onUpdate?.({
        content: [{ type: "text", text: `正在处理: ${params.action}` }],
      });

      switch (params.action) {
        case "list":
          return {
            content: [{
              type: "text",
              text: todos.length === 0
                ? "待办列表为空"
                : todos.map((t, i) => `${i + 1}. ${t}`).join("\n"),
            }],
            details: { todos: [...todos] },
          };
        case "add":
          if (!params.text) {
            throw new Error("add 操作需要 text 参数");
          }
          todos.push(params.text);
          // 第 5 个参数 ctx 是扩展上下文,这里用它向终端弹一条通知
          ctx.ui.notify(`待办已新增:${params.text}`, "info");
          return {
            content: [{
              type: "text",
              text: `已添加: ${params.text}(共 ${todos.length} 项)`,
            }],
            details: { todos: [...todos] },
          };
        case "done":
          if (!params.text) {
            throw new Error("done 操作需要 text 参数");
          }
          const idx = todos.findIndex(
            t => t.includes(params.text!)
          );
          if (idx === -1) {
            return {
              content: [{
                type: "text",
                text: `未找到匹配项: ${params.text}`
              }],
              details: { todos: [...todos] },
            };
          }
          const removed = todos.splice(idx, 1)[0];
          return {
            content: [{
              type: "text",
              text: `已完成: ${removed}(剩余 ${todos.length} 项)`,
            }],
            details: { todos: [...todos] },
          };
        default:
          throw new Error("未知操作");
      }
    },
  });
}

注册完成后,AI 会根据 description 和 promptSnippet 自主决定何时调用 todo 工具。

例如用户说"帮我把写周报加进待办",AI 会发起下面这次调用:

tool_call: todo
{
  "action": "add",
  "text": "写周报"
}

execute 执行后,返回给 AI 的文本如下:

待办已新增:写周报(共 1 项)

AI 拿到这个结果后,会用自然语言向用户确认,例如"已把「写周报」加入待办列表,目前共 1 项"。

除了从 toolResult 的 details 重建状态,跨重启持久化还有另一条路:pi.appendEntry()

它把自定义条目直接写进会话文件,配合 pi.registerEntryRenderer() 还能在对话记录里渲染出来。

自定义条目不会进入 LLM 上下文,因此不占用 token。

但它们会随会话一起保存,并且在 /tree 分支中保留。


工具定义详解

registerTool 的配置对象包含以下字段,其中 name、parameters 和 execute 是核心。

字段类型必填说明
namestring工具的唯一标识,AI 通过此名称调用
labelstring工具显示名
descriptionstring工具功能描述,AI 据此判断何时使用
parametersTypeBox Schema工具参数的类型定义
promptSnippetstring在 Available tools 区域的单行简介
promptGuidelinesstring[]追加到系统提示 Guidelines 区域的指南
prepareArgumentsfunction在 Schema 验证前转换参数,用于兼容旧格式
executeasync function工具的实际执行逻辑

promptGuidelines 中的每条指南都必须明确指出工具名称

不要写「使用此工具时…」,而要写「使用 my_tool 时…」,因为 AI 无法根据「此」来判断指的是哪个工具。


execute 函数详解

execute 函数接收以下参数:

参数类型说明
toolCallIdstring工具调用的唯一 ID
params泛型 T经过 Schema 验证的参数对象
signalAbortSignal | undefined取消信号,用户按 Escape 时触发
onUpdatefunction发送进度更新的回调
ctxExtensionContext扩展上下文,提供 UI、会话等能力

返回值格式:

实例

// 文件路径:~/.pi/agent/extensions/todo-tool.ts(execute 函数的返回值)
return {
  // 发送给 LLM 的内容(必需)
  content: [{ type: "text", text: "操作完成" }],

  // 自定义详情数据,用于状态重建和渲染(可选)
  details: { result: "..." },

  // 嵌套 LLM 调用的用量统计(可选)
  usage: nestedModelUsage,

  // 终止标记:当同一批次所有工具都返回 terminate 时
  // 跳过后续的 LLM 调用(可选)
  terminate: true,
};

要标记工具执行失败,使用 throw new Error(),不要尝试在返回值中设置错误标记。

只有抛出异常才能将 isError 设为 true,并通知 LLM 发生了错误。


StringEnum 与类型安全

使用 StringEnum 而不是 Type.Union/Type.Literal 来定义枚举参数:

实例

// 文件路径:~/.pi/agent/extensions/todo-tool.ts(parameters 字段)
import { StringEnum } from "@earendil-works/pi-ai";

// 正确:Google API 兼容
parameters: Type.Object({
  action: StringEnum(["list", "add", "done"] as const),
})

// 错误:Google API 不兼容
parameters: Type.Object({
  action: Type.Union([
    Type.Literal("list"),
    Type.Literal("add"),
    Type.Literal("done"),
  ]),
})

文件修改安全队列

如果你的工具会修改文件,使用 withFileMutationQueue() 参与文件修改队列:

实例

// 文件路径:~/.pi/agent/extensions/note-edit.ts
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";

// 以下片段位于 registerTool 的 execute 函数内
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
  // 将路径解析为绝对路径
  const absolutePath = resolve(ctx.cwd, params.path);

  return withFileMutationQueue(absolutePath, async () => {
    // 确保目录存在
    await mkdir(dirname(absolutePath), { recursive: true });
    // 读取当前内容
    const current = await readFile(absolutePath, "utf8");
    // 应用修改
    const next = current.replace(params.oldText, params.newText);
    // 写回文件
    await writeFile(absolutePath, next, "utf8");

    return {
      content: [{ type: "text", text: `已更新 ${params.path}` }],
      details: {},
    };
  });
}

文件修改队列确保同一文件的并发修改不会互相覆盖——当内置 edit 工具和你的自定义工具同时修改同一个文件时,它们会排队执行。


输出截断

工具输出必须截断,避免撑满 LLM 上下文窗口。内置限制为 50KB(约 1 万 token)和 2000 行:

实例

// 文件路径:~/.pi/agent/extensions/read-log.ts
import {
  truncateHead,
  formatSize,
  DEFAULT_MAX_BYTES,
  DEFAULT_MAX_LINES,
} from "@earendil-works/pi-coding-agent";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";

// 以下片段位于 registerTool 的 execute 函数内
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
  // 读取一个真实文件,得到待返回的文本
  const output = await readFile(resolve(ctx.cwd, params.file), "utf8");

  // 保留开头部分(适合文件读取、搜索结果)
  const truncation = truncateHead(output, {
    maxLines: DEFAULT_MAX_LINES,
    maxBytes: DEFAULT_MAX_BYTES,
  });

  let result = truncation.content;

  // 被截断时追加一行说明,让 AI 知道内容不完整
  if (truncation.truncated) {
    result += `\n\n[输出已截断:${truncation.outputLines}/`;
    result += `${truncation.totalLines} 行`;
    result += `(${formatSize(truncation.outputBytes)}/`;
    result += `${formatSize(truncation.totalBytes)}]`;
  }

  return {
    content: [{ type: "text", text: result }],
  };
}

对一个超过限制的大日志文件,工具的返回文本末尾会带上截断说明:

[2026-08-31 10:02:11] server started on port 3000
[2026-08-31 10:02:12] GET / 200 12ms
[2026-08-31 10:02:12] GET /static/app.js 200 4ms
...

[输出已截断:2000/8642 行(49.9KB/210.3KB)]

截断不是可选的

过大的工具输出会导致上下文溢出、压缩失败和模型性能下降,始终对工具输出进行截断处理。


覆盖内置工具

你可以通过注册同名工具来覆盖内置工具(read、bash、powershell、edit、write、grep、find、ls)。

$ pi -e ./tool-override.ts

下面用 registerTool 覆盖内置的 read 工具,为每次文件读取加一条日志:

实例

// 文件路径:~/.pi/agent/extensions/read-logger.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";

export default function (pi: ExtensionAPI) {
  // 与内置工具同名即可覆盖,这里只给 read 增加日志
  pi.registerTool({
    name: "read",
    label: "读取文件",
    description: "读取文件内容,并在终端记录访问日志",
    parameters: Type.Object({
      path: Type.String({ description: "要读取的文件路径" }),
    }),
    async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
      const absolutePath = resolve(ctx.cwd, params.path);
      const text = await readFile(absolutePath, "utf8");
      // 额外能力:记录一次访问日志
      console.log(`[read] ${params.path}(${text.length} 字符)`);
      // 返回结构与内置 read 保持一致
      return {
        content: [{ type: "text", text }],
      };
    },
  });
}

加载这个扩展后,AI 每次读文件都会在终端打印一行 [read] 日志。

覆盖时,渲染器(renderCall/renderResult)会按槽位继承——如果你省略了 renderCall,内置的 renderCall 仍会使用。

这让你可以在不重写 UI 的情况下为内置工具添加日志或权限控制。

但对于 promptSnippet 和 promptGuidelines,它们不会从内置工具继承,需要显式定义。

还有一个更隐蔽的约束:你的实现必须与内置工具的结果形状完全一致,包括 details 字段。

以 read 为例,入参需要支持 offset 和 limit 两个可选参数,这样 AI 按分页方式读取大文件时才能拿到预期内容。

返回值中的 details 也必须与内置 read 的 ReadToolDetails 形状一致,否则 UI 渲染会缺信息,会话的状态跟踪也会受影响。

上面这个日志扩展示例只做演示,正式覆盖内置 read 时请按内置实现补全参数与 details。