首页 / pi-agent 入门教程 / 自定义工具开发

pi-agent 入门教程

自定义工具开发

本教程共 30 篇 · 第 17 篇 · 更新于 2026-08-10 · 约 10 分钟阅读

pi-agent工具自定义工具Tool开发

本节目标:掌握 registerTool() 的完整用法——参数定义、返回值格式、错误处理、进度更新和文件安全队列。写出一个能实际运行的天气查询工具。

上一章的 greet 工具只是一个 hello world。真正的生产工具要考虑的事多得多:参数怎么定义才让 AI 理解准确?返回值什么格式?工具执行出错了怎么通知 AI?文件操作怎么防止并发冲突?

这一章把自定义工具开发的每个环节拆开讲清楚。

工具是怎么工作的

先理解流程。当 AI 决定调用一个工具时,pi 的调用链是这样的:

LLM 输出 tool_call
  → prepareArguments() 转换参数(可选)
  → TypeBox Schema 校验参数
  → execute() 执行工具逻辑
  → 返回值(content + details)发给 LLM

你的工作是把后三步写好:定义 Schema 让参数不出错,实现 execute 让逻辑正确,组织返回值让 AI 能理解结果。

工具定义的完整字段

pi.registerTool() 接收一个对象,以下是所有可用字段:

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

promptGuidelines 里的每条规则必须点明工具名称。写”使用 weather_query 时……”而不是”使用此工具时……”。AI 并不知道”此”指的是谁。

execute 函数的签名

async execute(
  toolCallId: string,           // 本次调用的唯一 ID
  params: T,                    // 经过 Schema 校验的参数
  signal?: AbortSignal,         // 用户按 Escape 时的取消信号
  onUpdate?: (update: ToolUpdate) => void,  // 发送进度更新
  ctx?: ExtensionContext,       // 扩展上下文
): Promise<ToolResult>

signal 在用户取消操作时会变为 aborted,工具应该检查这个状态尽早退出。onUpdate 用于在长时间操作中发送进度通知,让用户看到工具在干活。

返回值格式

execute 必须返回一个 ToolResult 对象:

return {
  // 发送给 LLM 的内容(必填)
  content: [
    { type: "text", text: "查询结果:北京今天晴,25°C" }
  ],
  // 自定义详情数据,用于状态恢复和渲染(可选)
  details: {
    city: "北京",
    temperature: 25,
    condition: "晴",
  },
  // 嵌套 LLM 调用的用量统计(可选)
  usage: nestedModelUsage,
  // 终止标记:同一批次所有工具都返回 terminate 时跳过后续 LLM 调用(可选)
  terminate: true,
};

content 数组里每个元素是 { type: "text", text: string } 格式。details 里的数据会持久化到会话文件里,下次启动可以从中恢复状态。

Note

工具执行失败不要返回特殊标记,直接 throw new Error("原因")。只有抛出异常才会把 isError 设为 true,AI 才能感知到出错了。

参数定义:TypeBox + StringEnum

typeboxType.Object() 定义参数结构,用 @earendil-works/pi-aiStringEnum 定义枚举值。

为什么用 StringEnum 而不是 Type.Union?因为 Google 的 API 不兼容 TypeBox 的 Union/Literal 类型。StringEnum 生成的是标准 JSON Schema enum,所有模型都能识别。

import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";

// ✅ 正确:所有模型兼容
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")]),
})

输出截断

工具返回给 LLM 的内容必须在 50KB(约 1 万 token)和 2000 行以内。超过这个量的输出会撑爆上下文窗口,导致压缩失败或模型性能下降。

pi 提供了内置截断工具:

import {
  truncateHead,
  truncateTail,
  formatSize,
  DEFAULT_MAX_BYTES,
  DEFAULT_MAX_LINES,
} from "@earendil-works/pi-coding-agent";

async execute(_toolCallId, _params) {
  const output = await someLargeOperation();
  const truncation = truncateHead(output, {
    maxLines: DEFAULT_MAX_LINES,
    maxBytes: DEFAULT_MAX_BYTES,
  });

  let result = truncation.content;
  if (truncation.truncated) {
    result += `\n\n[输出已截断:${truncation.outputLines}/${truncation.totalLines} 行`
      + `(${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)})]`;
  }

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

truncateHead 保留开头部分,适合文件读取和搜索结果。需要保留尾部时用 truncateTail(比如日志文件的最新片段)。

文件修改安全队列

如果你的工具要修改文件,用 withFileMutationQueue() 包一下。它确保同一个文件的并发修改排队执行,不会互相覆盖:

import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";

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 工具同时改同一个文件时特别重要——不然可能互相踩。

工具权限控制

pi 支持 allowedTools 配置,限制哪些工具可以被 AI 调用。在 settings.json 中:

{
  "allowedTools": ["read", "bash", "edit", "write", "grep", "weather"]
}

列表里没写的工具不会被 AI 调用。这对自定义工具也适用——想让它可用,名字必须出现在白名单里。

覆盖内置工具

注册一个和内置工具同名的工具,就能覆盖它。

pi -e ./my-read-override.ts   # 注册一个自定义 "read" 工具

覆盖时,渲染器(renderCall / renderResult)会继承内置版本。你只改执行逻辑、不改 UI 也完全行。但 promptSnippetpromptGuidelines 不会从内置工具继承,需要显式定义。

完整示例:天气查询工具

下面写一个能跑起来的天气查询工具。它会调用一个公开 API,处理网络错误、超时和取消信号,返回结构化的结果。

// ~/.pi/agent/extensions/weather-tool.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";

interface WeatherResult {
  city: string;
  temperature: number;
  condition: string;
  humidity: number;
  windSpeed: number;
}

export default function (pi: ExtensionAPI) {
  pi.registerTool({
    name: "weather",
    label: "天气查询",
    description: "查询指定城市的当前天气信息,返回温度、天气状况、湿度和风速",
    promptSnippet: "查询城市天气:城市名 + 单位(metric/imperial)",
    promptGuidelines: [
      "使用 weather 工具查询天气时,城市名请用英文(如 Beijing, Tokyo)",
      "weather 工具返回的温度单位由 unit 参数控制,默认 metric(摄氏度)",
    ],
    parameters: Type.Object({
      city: Type.String({ description: "城市英文名,如 Beijing, Tokyo, London" }),
      unit: Type.Optional(
        // buildEnum 在新版中推荐,StringEnum 也兼容
        StringEnum(["metric", "imperial"] as const, {
          default: "metric",
          description: "温度单位:metric 摄氏度,imperial 华氏度",
        })
      ),
    }),
    async execute(_toolCallId, params, signal) {
      const { city, unit = "metric" } = params;

      // 检查取消信号
      if (signal?.aborted) {
        throw new Error("操作已取消");
      }

      // 使用 Open-Meteo 免费天气 API(无需 API Key)
      // 先通过 geocoding API 获取城市坐标
      const geoUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=zh`;

      try {
        const geoRes = await fetch(geoUrl, { signal });
        if (!geoRes.ok) {
          throw new Error(`地理编码请求失败:HTTP ${geoRes.status}`);
        }

        const geoData = await geoRes.json() as {
          results?: { latitude: number; longitude: number; name: string; country: string }[];
        };

        if (!geoData.results || geoData.results.length === 0) {
          throw new Error(`未找到城市:${city}`);
        }

        const { latitude, longitude, name, country } = geoData.results[0];

        // 获取天气数据
        const weatherUrl =
          `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}`
          + `&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m`
          + `&temperature_unit=${unit === "imperial" ? "fahrenheit" : "celsius"}`;

        const weatherRes = await fetch(weatherUrl, { signal });
        if (!weatherRes.ok) {
          throw new Error(`天气请求失败:HTTP ${weatherRes.status}`);
        }

        const weatherData = await weatherRes.json() as {
          current: {
            temperature_2m: number;
            relative_humidity_2m: number;
            weather_code: number;
            wind_speed_10m: number;
          };
        };

        const current = weatherData.current;
        const condition = weatherCodeToText(current.weather_code);
        const unitLabel = unit === "metric" ? "°C" : "°F";

        const result: WeatherResult = {
          city: `${name}, ${country}`,
          temperature: current.temperature_2m,
          condition,
          humidity: current.relative_humidity_2m,
          windSpeed: current.wind_speed_10m,
        };

        return {
          content: [{
            type: "text",
            text:
              `📍 ${result.city}\n`
              + `🌡 温度:${result.temperature}${unitLabel}\n`
              + `☁️ 天气:${result.condition}\n`
              + `💧 湿度:${result.humidity}%\n`
              + `🌬 风速:${result.windSpeed} km/h`,
          }],
          details: result,
        };
      } catch (err) {
        if (err instanceof Error) {
          if (err.name === "AbortError") {
            throw new Error("天气查询已被取消");
          }
          throw err;
        }
        throw new Error("天气查询发生未知错误");
      }
    },
  });
}

// WMO 天气代码转中文描述
function weatherCodeToText(code: number): string {
  const map: Record<number, string> = {
    0: "晴天", 1: "少云", 2: "多云", 3: "阴天",
    45: "雾", 48: "沉积雾凇",
    51: "小毛毛雨", 53: "毛毛雨", 55: "大毛毛雨",
    61: "小雨", 63: "中雨", 65: "大雨",
    71: "小雪", 73: "中雪", 75: "大雪",
    80: "阵雨", 81: "中等阵雨", 82: "大阵雨",
    95: "雷暴", 96: "雷暴伴小冰雹", 99: "雷暴伴大冰雹",
  };
  return map[code] ?? `未知(code: ${code})`;
}

测试一下:

pi -e ~/.pi/agent/extensions/weather-tool.ts

然后对 AI 说”查一下北京的天气”,AI 应该会调用你的 weather 工具并返回结果。

这个示例涵盖了哪些要点

  • 参数校验:城市名是必填的 string,单位是可选的 enum
  • 错误处理:网络异常、城市找不到、请求超时都抛 Error
  • 取消信号:检查 signal.aborted,fetch 也传入了 signal
  • 结构化返回content 给人/LLM 看,details 存结构化数据
  • promptGuidelines:明确告诉 AI 城市名用英文、单位默认摄氏度

这一章把工具开发的每个关键环节都走了一遍。下一章进入事件系统——教你监听 pi 的生命周期,在恰当时机插入自定义逻辑。