Pi Agent カスタムツール開発
Extension を通じてカスタムツールを登録し、AI があなたが作成した関数を呼び出して特定のタスクを実行できるようにします。
ツール登録の基本
使用法pi.registerTool()AI が呼び出し可能なツールを登録します:
例
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 コンテキストに入らないため、トークンを消費しません。
ただし、セッションとともに保存され、/tree ブランチにも保持されます。
ツール定義の詳細
registerTool の設定オブジェクトには以下のフィールドが含まれ、その中でも name、parameters、execute が中心です。
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| name | string | である | ツールの一意の識別子。AI はこの名前で呼び出します |
| label | string | である | ツールの表示名 |
| description | string | である | ツールの機能説明。AI はこれに基づいて使用するタイミングを判断します |
| parameters | TypeBox Schema | である | ツールパラメータの型定義 |
| promptSnippet | string | 否 | Available tools 領域の1行の概要 |
| promptGuidelines | string[] | 否 | システムプロンプトの Guidelines 領域に追加されるガイド |
| prepareArguments | function | 否 | Schema 検証の前にパラメータを変換し、旧形式との互換性を実現します |
| execute | async function | である | ツールの実際の実行ロジック |
promptGuidelines の各ガイドは必ずツール名を明示しなければなりません。
「このツールを使用するとき…」ではなく、「my_tool を使用するとき…」と書いてください。AI は「この」だけではどのツールを指しているのか判断できないためです。
execute 関数の詳細
execute 関数は以下のパラメータを受け取ります:
| パラメータ | 型 | 説明 |
|---|---|---|
| toolCallId | string | ツール呼び出しの一意の ID |
| params | ジェネリック型 T | Schema 検証済みのパラメータオブジェクト |
| signal | AbortSignal | undefined | キャンセル信号。ユーザーが Escape キーを押すと発火します |
| onUpdate | function | 進捗状況の更新を送信するコールバック |
| 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 と型安全性
を使用しStringEnumType.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";
// 以下の断片は 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万トークン)と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";
// 以下の断片は 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;
// 切り詰めた場合は説明を1行追加し、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 ツールをオーバーライドし、ファイルを読み取るたびにログを1件追加します:
例
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");
// 追加機能:アクセスログを1回記録します
console.log(`[read] ${params.path}(${text.length}文字)`);
// 戻り値の構造は、組み込みの read と一致させます
return {
content: [{ type: "text", text }],
};
},
});
}
この拡張機能を読み込むと、AI がファイルを読み取るたびにターミナルに [read] ログが1行出力されます。
上書きする際、レンダラー(renderCall/renderResult)はスロット単位で継承されます——renderCall を省略した場合、組み込みの renderCall が引き続き使用されます。
これにより、UI を書き直すことなく、組み込みツールにログや権限制御を追加できます。
ただし、promptSnippet と promptGuidelines については、それらは組み込みツールからは継承されず明示的な定義が必要です。
もう一つ、より見えにくい制約があります:あなたの実装は、details フィールドを含め、組み込みツールの結果の形状と完全に一致する必要があります。
read を例にすると、入力パラメータは offset と limit の2つのオプション引数をサポートしている必要があります。これにより、AI が大きなファイルをページング方式で読み取るときに、期待どおりの内容を取得できます。
戻り値の details も、組み込みの read の ReadToolDetails の形状と一致させる必要があります。そうしないと、UI レンダリングで情報が欠落し、セッションの状態追跡にも影響が出ます。
上記のログ拡張の例はデモ用です。正式に組み込みの read を上書きする場合は、組み込みの実装に合わせてパラメータと details を補完してください。
その他の拡張機能