Разработка пользовательских инструментов Pi Agent
Регистрируйте пользовательские инструменты через расширение, позволяя ИИ вызывать написанные вами функции для выполнения конкретных задач.
Основы регистрации инструментов
Usagepi.registerTool()Зарегистрируйте инструмент, вызываемый ИИ:
Пример
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 | Обязательный | Описание |
|---|---|---|---|
| name | string | Да | Уникальный идентификатор инструмента; ИИ вызывает его по этому имени |
| label | string | Да | Отображаемое имя инструмента |
| description | string | Да | Описание функции инструмента; ИИ использует его, чтобы решить, когда использовать инструмент |
| parameters | Схема TypeBox | Да | Определение типа параметров инструмента |
| promptSnippet | string | нет | Однострочное резюме в разделе «Доступные инструменты» |
| promptGuidelines | string[] | нет | Рекомендации, добавляемые в раздел «Рекомендации» системного промпта |
| prepareArguments | функция | нет | Преобразует параметры перед валидацией схемы, используется для совместимости со старыми форматами |
| execute | асинхронная функция | Да | Фактическая логика выполнения инструмента |
Каждая рекомендация в promptGuidelines должнаявно указывать имя инструмента。
Не пишите «при использовании этого инструмента...»; вместо этого пишите «при использовании my_tool...», потому что ИИ не может определить, к какому инструменту относится «это».
Объяснение функции execute
Функция execute получает следующие параметры:
| Параметр | Type | Описание |
|---|---|---|
| toolCallId | string | Уникальный ID вызова инструмента |
| params | Обобщённый тип T | Объект параметров, валидированный Schema |
| signal | AbortSignal | undefined | Сигнал отмены, срабатывающий при нажатии пользователем Escape |
| onUpdate | функция | Функция обратного вызова для отправки обновлений о ходе выполнения |
| ctx | ExtensionContext | Контекст расширения, предоставляющий UI, сеанс и другие возможности |
Формат возвращаемого значения:
Пример
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 для определения параметров перечисления:
Пример
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()для участия в очереди изменения файлов:
Пример
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 строк:
Пример
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, добавляя запись журнала для каждого чтения файла:
Пример
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 в соответствии со встроенной реализацией.
Другие расширения