Разработка пользовательских инструментов Pi Agent

Регистрируйте пользовательские инструменты через расширение, позволяя ИИ вызывать написанные вами функции для выполнения конкретных задач.


Основы регистрации инструментов

Usagepi.registerTool()Зарегистрируйте инструмент, вызываемый ИИ:

Пример

// Путь к файлу: ~/.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: Управление списком задач проекта,
    // Краткое описание, отображаемое в разделе доступных инструментов системной подсказки
    promptSnippet: Управление задачами проекта: list выводит списки, add добавляет, done завершает,
    // Рекомендации по использованию, добавляемые в раздел Guidelines системного промпта
    promptGuidelines: [
      Используйте инструмент todo для планирования задач; не редактируйте файлы 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(Неизвестная операция);
      }
    },
  });
}

После регистрации ИИ автономно решает, когда вызывать инструмент todo, основываясь на description и promptSnippet.

Например, когда пользователь говорит "Добавь написание еженедельного отчёта в мой список дел", ИИ выполнит следующий вызов:

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

После выполнения execute текст, возвращаемый ИИ, выглядит так:

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

Получив этот результат, ИИ подтверждает пользователю на естественном языке, например: "Добавлено 'написание еженедельного отчёта' в список дел; сейчас всего 1 элемент."

Помимо восстановления состояния по данным в toolResult, есть другой способ сохранения между перезапусками:pi.appendEntry()。

Он записывает пользовательские записи напрямую в файл сессии; в сочетании сpi.registerEntryRenderer()они также могут отображаться в истории диалога.

Пользовательские записи не попадают в контекст LLM, поэтому они не расходуют токены.

Но они сохраняются вместе с сессией и остаются в ветке /tree.


Объяснение определения инструмента

Объект конфигурации registerTool содержит следующие поля, из которых name, parameters и execute являются основными.

ПолеTypeОбязательныйОписание
namestringДаУникальный идентификатор инструмента; ИИ вызывает его по этому имени
labelstringДаОтображаемое имя инструмента
descriptionstringДаОписание функции инструмента; ИИ использует его, чтобы решить, когда использовать инструмент
parametersСхема TypeBoxДаОпределение типа параметров инструмента
promptSnippetstringнетОднострочное резюме в разделе «Доступные инструменты»
promptGuidelinesstring[]нетРекомендации, добавляемые в раздел «Рекомендации» системного промпта
prepareArgumentsфункциянетПреобразует параметры перед валидацией схемы, используется для совместимости со старыми форматами
executeасинхронная функцияДаФактическая логика выполнения инструмента

Каждая рекомендация в promptGuidelines должнаявно указывать имя инструмента。

Не пишите «при использовании этого инструмента...»; вместо этого пишите «при использовании my_tool...», потому что ИИ не может определить, к какому инструменту относится «это».


Объяснение функции execute

Функция execute получает следующие параметры:

ПараметрTypeОписание
toolCallIdstringУникальный ID вызова инструмента
paramsОбобщённый тип TОбъект параметров, валидированный Schema
signalAbortSignal | undefinedСигнал отмены, срабатывающий при нажатии пользователем Escape
onUpdateфункцияФункция обратного вызова для отправки обновлений о ходе выполнения
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";

// Следующий фрагмент находится внутри функции execute для registerTool
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: {},
    };
  });
}

Очередь модификации файлов гарантирует, что одновременные изменения одного и того же файла не перезаписывают друг друга — когда встроенный инструмент редактирования и ваш пользовательский инструмент изменяют один и тот же файл одновременно, они выполняются в очереди.


Усечение вывода

Вывод инструмента должен быть усечён, чтобы не заполнить контекстное окно LLM. Встроенные ограничения — 50KB (около 10,000 токенов) и 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";

// Следующий фрагмент находится внутри функции execute для registerTool
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;

  // Добавить строку примечания при усечении, чтобы ИИ знал, что содержимое неполное
  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 }],
      };
    },
  });
}

После загрузки этого расширения ИИ будет выводить в терминал строку лога [read] каждый раз, когда читает файл.

При переопределении рендереры (renderCall/renderResult) наследуются по слотам — если опустить renderCall, встроенный renderCall всё равно будет использоваться.

Это позволяет добавлять логирование или управление разрешениями во встроенные инструменты без переписывания UI.

Но для promptSnippet и promptGuidelines онине будутнаследоваться от встроенных инструментов; их нужно явно определять.

Есть ещё одно более тонкое ограничение: ваша реализация должна точно соответствовать форме результата встроенного инструмента, включая поле details.

Возьмём read в качестве примера: входные параметры должны поддерживать два необязательных параметра, offset и limit, чтобы ИИ мог получать ожидаемое содержимое при чтении больших файлов постранично.

Поле details в возвращаемом значении также должно соответствовать форме ReadToolDetails встроенного read; иначе при рендеринге UI будет не хватать информации, а отслеживание состояния сессии также пострадает.

Пример расширения логирования выше приведён только для демонстрации. При официальном переопределении встроенного read, пожалуйста, дополните параметры и details в соответствии со встроенной реализацией.

Другие расширения