Skip to content

Pi Agent 自定义工具开发

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


工具注册基础

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

实例

bash

// 文件路径:~/.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 会发起下面这次调用:

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

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

bash
待办已新增:写周报(共 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 验证的参数对象
signalAbortSignalundefined
onUpdatefunction发送进度更新的回调
ctxExtensionContext扩展上下文,提供 UI、会话等能力

返回值格式:

实例

bash

// 文件路径:~/.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 来定义枚举参数:

实例

typescript

// 文件路径:~/.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() 参与文件修改队列:

实例

javascript

// 文件路径:~/.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 行:

实例

javascript

// 文件路径:~/.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 }],

  };

}

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

bash
[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)。

bash
$ pi -e ./tool-override.ts

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

实例

bash

// 文件路径:~/.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。

AI 思考中...

Pi Agent 扩展入门

Pi Agent 事件系统

基于 VitePress 构建,部署于 GitHub Pages