Pi Agent desarrollo de herramientas personalizadas

Registra herramientas personalizadas mediante Extension para que la IA pueda llamar a las funciones que escribas y completar tareas específicas.


Fundamentos de registro de herramientas

Usopi.registerTool()Registra una herramienta que la IA pueda invocar:

Ejemplo

// Ruta del archivo: ~/.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";

// Define la lista de tareas pendientes (almacenamiento en memoria)
let todos: string[] = [];

export default function (pi: ExtensionAPI) {
  // Restaura el estado desde la sesión al iniciar
  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: "Tareas pendientes",
    description: "Gestiona la lista de tareas pendientes del proyecto",
    // Descripción breve; se muestra en el área de Available tools del prompt del sistema
    promptSnippet: "Gestiona las tareas pendientes del proyecto: list para listar, add para agregar, done para completar",
    // Guía de uso; se añade al área de Guidelines del prompt del sistema
    promptGuidelines: [
      "Usa la herramienta todo para la planificación de tareas; no edites directamente el archivo de tareas pendientes",
    ],
    parameters: Type.Object({
      // StringEnum garantiza la compatibilidad con la API de Google
      action: StringEnum(["list", "add", "done"] as const),
      text: Type.Optional(Type.String({
        description: "Al agregar: descripción del elemento. Al completar: número o descripción del elemento"
      })),
    }),
    // Compatible con formatos de parámetros antiguos
    prepareArguments(args) {
      if (!args || typeof args !== "object") return args;
      const input = args as { action?: string; oldAction?: string };
      // Asigna los campos antiguos a los nuevos
      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: "Operación cancelada" }]
        };
      }

      // Envía una actualización de progreso
      onUpdate?.({
        content: [{ type: "text", text:`Procesando: ${params.action}` }],
      });

      switch (params.action) {
        case "list":
          return {
            content: [{
              type: "text",
              text: todos.length === 0
                ? "La lista de tareas pendientes está vacía"
                : todos.map((t, i) => `${i + 1}. ${t}`).join("\n"),
            }],
            details: { todos: [...todos] },
          };
        case "add":
          if (!params.text) {
            throw new Error("La operación add requiere el parámetro text");
          }
          todos.push(params.text);
          // El quinto parámetro ctx es el contexto de extensión; aquí se usa para mostrar una notificación en la terminal
          ctx.ui.notify(`Tarea agregada: ${params.text}`, "info");
          return {
            content: [{
              type: "text",
              text:`Agregada: ${params.text}(total ${todos.length}elementos)`,
            }],
            details: { todos: [...todos] },
          };
        case "done":
          if (!params.text) {
            throw new Error("La operación done requiere el parámetro text");
          }
          const idx = todos.findIndex(
            t => t.includes(params.text!)
          );
          if (idx === -1) {
            return {
              content: [{
                type: "text",
                text:`No se encontraron coincidencias: ${params.text}`
              }],
              details: { todos: [...todos] },
            };
          }
          const removed = todos.splice(idx, 1)[0];
          return {
            content: [{
              type: "text",
              text:`Completada: ${removed}(quedan ${todos.length}elementos)`,
            }],
            details: { todos: [...todos] },
          };
        default:
          throw new Error("Operación desconocida");
      }
    },
  });
}

Una vez completado el registro, la IA decidirá de forma autónoma cuándo invocar la herramienta todo según description y promptSnippet.

Por ejemplo, si el usuario dice «ayúdame a poner escribir el informe semanal en las tareas pendientes», la IA realizará la siguiente invocación:

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

Después de ejecutar execute, el texto devuelto a la IA es el siguiente:

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

Cuando la IA recibe este resultado, confirma al usuario en lenguaje natural, por ejemplo: «He agregado “escribir el informe semanal” a la lista de tareas pendientes; actualmente hay 1 elemento».

Además de reconstruir el estado desde los details de toolResult, existe otra vía para la persistencia entre reinicios:pi.appendEntry()。

Escribe las entradas personalizadas directamente en el archivo de sesión; junto conpi.registerEntryRenderer()también puede renderizarlas en el historial de conversación.

Las entradas personalizadas no entran en el contexto del LLM, por lo que no ocupan tokens.

Pero se guardan junto con la sesión y se conservan en la rama /tree.


Explicación detallada de la definición de herramientas

El objeto de configuración de registerTool contiene los siguientes campos; entre ellos, name, parameters y execute son los principales.

CampoTipoObligatorioDescripción
namestringSíIdentificador único de la herramienta; la IA la invoca mediante este nombre
labelstringSíNombre visible de la herramienta
descriptionstringSíDescripción de la funcionalidad de la herramienta; la IA determina cuándo usarla según esto
parametersTypeBox SchemaSíDefinición de tipos de los parámetros de la herramienta
promptSnippetstringnoResumen de una línea en el área de Available tools
promptGuidelinesstring[]noGuía añadida al área de Guidelines del prompt del sistema
prepareArgumentsfunctionnoConvierte los parámetros antes de la validación del Schema, para compatibilidad con formatos antiguos
executeasync functionSíLógica de ejecución real de la herramienta

Cada guía en promptGuidelines debeindicar claramente el nombre de la herramienta。

No escribas «al usar esta herramienta…», sino «al usar my_tool…», porque la IA no puede determinar a qué herramienta te refieres basándose en «esta».


Explicación detallada de la función execute

La función execute recibe los siguientes parámetros:

ParámetroTipoDescripción
toolCallIdstringID único de la invocación de la herramienta
paramsGenérico TObjeto de parámetros validado por Schema
signalAbortSignal | undefinedSeñal de cancelación; se activa cuando el usuario pulsa Escape
onUpdatefunctionDevolución de llamada para enviar actualizaciones de progreso
ctxExtensionContextContexto de extensión; proporciona capacidades como UI, sesión, etc.

Formato del valor de retorno:

Ejemplo

// Ruta del archivo: ~/.pi/agent/extensions/todo-tool.ts (valor de retorno de la función execute)
return {
  // Contenido enviado al LLM (obligatorio)
  content: [{ type: "text", text: "Operación completada" }],

  // Datos de detalle personalizados, para reconstrucción de estado y renderizado (opcional)
  details: { result: "..." },

  // Estadísticas de uso de llamadas LLM anidadas (opcional)
  usage: nestedModelUsage,

  // Marcador de terminación: cuando todas las herramientas del mismo lote devuelven terminate
  // Omite las llamadas LLM posteriores (opcional)
  terminate: true,
};

Para marcar una falla en la ejecución de la herramienta, usathrow new Error()y no intentes establecer un marcador de error en el valor de retorno.

Solo lanzando una excepción se puede establecer isError como true y notificar al LLM que ocurrió un error.


StringEnum y seguridad de tipos

UsaStringEnumen lugar de Type.Union/Type.Literal para definir parámetros de enumeración:

Ejemplo

// Ruta del archivo: ~/.pi/agent/extensions/todo-tool.ts (campo parameters)
import { StringEnum } from "@earendil-works/pi-ai";

// Correcto: compatible con la API de Google
parameters: Type.Object({
  action: StringEnum(["list", "add", "done"] as const),
})

// Incorrecto: no compatible con la API de Google
parameters: Type.Object({
  action: Type.Union([
    Type.Literal("list"),
    Type.Literal("add"),
    Type.Literal("done"),
  ]),
})

Cola segura de modificación de archivos

Si tu herramienta modifica archivos, usawithFileMutationQueue()para participar en la cola de modificación de archivos:

Ejemplo

// Ruta del archivo: ~/.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";

// El siguiente fragmento se encuentra dentro de la función execute de registerTool
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
  // Resuelve la ruta como una ruta absoluta
  const absolutePath = resolve(ctx.cwd, params.path);

  return withFileMutationQueue(absolutePath, async () => {
    // Asegura que el directorio exista
    await mkdir(dirname(absolutePath), { recursive: true });
    // Lee el contenido actual
    const current = await readFile(absolutePath, "utf8");
    // Aplica la modificación
    const next = current.replace(params.oldText, params.newText);
    // Escribe de vuelta en el archivo
    await writeFile(absolutePath, next, "utf8");

    return {
      content: [{ type: "text", text:`Actualizado ${params.path}` }],
      details: {},
    };
  });
}

La cola de modificación de archivos garantiza que las modificaciones concurrentes del mismo archivo no se sobrescriban entre sí: cuando la herramienta integrada edit y tu herramienta personalizada modifican el mismo archivo al mismo tiempo, se ejecutan en cola.


Truncamiento de salida

La salida de la herramienta debe truncarse para evitar llenar la ventana de contexto del LLM. El límite incorporado es de 50KB (aproximadamente 10 000 tokens) y 2000 líneas:

Ejemplo

// Ruta del archivo: ~/.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";

// El siguiente fragmento se encuentra dentro de la función execute de registerTool
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
  // Lee un archivo real para obtener el texto que se devolverá
  const output = await readFile(resolve(ctx.cwd, params.file), "utf8");

  // Conserva la parte inicial (adecuado para lectura de archivos, resultados de búsqueda)
  const truncation = truncateHead(output, {
    maxLines: DEFAULT_MAX_LINES,
    maxBytes: DEFAULT_MAX_BYTES,
  });

  let result = truncation.content;

  // Cuando se trunca, añade una línea de aviso para que la IA sepa que el contenido está incompleto
  if (truncation.truncated) {
    result += `\n\n[Salida truncada: ${truncation.outputLines}/`;
    result += `${truncation.totalLines}líneas`;
    result += `(${formatSize(truncation.outputBytes)}/`;
    result += `${formatSize(truncation.totalBytes)})]`;
  }

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

Para un archivo de registro grande que supera el límite, el texto de retorno de la herramienta incluirá la nota de truncamiento al final:

[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)]

El truncamiento no es opcional。

Una salida de herramienta demasiado grande puede provocar desbordamiento del contexto, fallos de compresión y degradación del rendimiento del modelo; trunca siempre la salida de las herramientas.


Sobrescribir herramientas integradas

Puedes sobrescribir las herramientas integradas (read, bash, powershell, edit, write, grep, find, ls) registrando herramientas con el mismo nombre.

$ pi -e ./tool-override.ts

A continuación, se sobrescribe la herramienta integrada read con registerTool para añadir un registro en cada lectura de archivo:

Ejemplo

// Ruta del archivo: ~/.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) {
  // Sobrescribir es tan simple como usar el mismo nombre que la herramienta integrada; aquí solo añadimos un registro a read.
  pi.registerTool({
    name: "read",
    label: "Leer archivo",
    description: "Lee el contenido del archivo y registra un registro de acceso en la terminal",
    parameters: Type.Object({
      path: Type.String({ description: "Ruta del archivo que se va a leer" }),
    }),
    async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
      const absolutePath = resolve(ctx.cwd, params.path);
      const text = await readFile(absolutePath, "utf8");
      // Capacidad adicional: registrar un registro de acceso
      console.log(`[read] ${params.path}(${text.length}carácter)`);
      // La estructura de retorno es la misma que la del read integrado
      return {
        content: [{ type: "text", text }],
      };
    },
  });
}

Después de cargar esta extensión, cada vez que la IA lea un archivo, imprimirá una línea de registro [read] en la terminal.

Al sobrescribir, los renderizadores (renderCall/renderResult) se heredan por ranura: si omites renderCall, el renderCall integrado se seguirá usando.

Esto te permite añadir registros o control de permisos a las herramientas integradas sin reescribir la interfaz de usuario.

Pero para promptSnippet y promptGuidelines, estosnose heredan de las herramientas integradas; deben definirse explícitamente.

Hay una restricción aún más sutil: tu implementación debe coincidir exactamente con la forma del resultado de la herramienta integrada, incluido el campo details.

Tomando read como ejemplo, los parámetros de entrada deben admitir los dos parámetros opcionales offset y limit, para que la IA pueda obtener el contenido esperado al leer archivos grandes mediante paginación.

El campo details del valor de retorno también debe coincidir en forma con el ReadToolDetails del read integrado; de lo contrario, la renderización de la interfaz de usuario carecerá de información y el seguimiento de estado de la sesión también se verá afectado.

El ejemplo de la extensión de registro anterior es solo una demostración; al sobrescribir oficialmente el read integrado, completa los parámetros y details según la implementación integrada.

Otras extensiones