This commit is contained in:
Mario Zechner
2026-03-29 21:09:04 +02:00
10 changed files with 287 additions and 8 deletions

View File

@@ -2,6 +2,11 @@
## [Unreleased]
### Added
- Added `ToolDefinition.prepareArguments` hook to prepare raw tool call arguments before schema validation, enabling compatibility shims for resumed sessions with outdated tool schemas
- Built-in `edit` tool now uses `prepareArguments` to silently fold legacy top-level `oldText`/`newText` into `edits[]` when resuming old sessions
### Fixed
- Fixed extension-queued user messages to refresh the interactive pending-message list while preserving `input` event source semantics for `pi.sendUserMessage()` ([#2674](https://github.com/badlogic/pi-mono/pull/2674) by [@mrexodia](https://github.com/mrexodia))

View File

@@ -1008,6 +1008,12 @@ pi.registerTool({
action: StringEnum(["list", "add"] as const),
text: Type.Optional(Type.String()),
}),
prepareArguments(args) {
// Optional compatibility shim. Runs before schema validation.
// Return the current schema shape, for example to fold legacy fields
// into the modern parameter object.
return args;
},
async execute(toolCallId, params, signal, onUpdate, ctx) {
// Stream progress
@@ -1472,6 +1478,14 @@ pi.registerTool({
action: StringEnum(["list", "add"] as const), // Use StringEnum for Google compatibility
text: Type.Optional(Type.String()),
}),
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) {
// Check for cancellation
@@ -1515,6 +1529,53 @@ async execute(toolCallId, params) {
**Important:** Use `StringEnum` from `@mariozechner/pi-ai` for string enums. `Type.Union`/`Type.Literal` doesn't work with Google's API.
**Argument preparation:** `prepareArguments(args)` is optional. If defined, it runs before schema validation and before `execute()`. Use it to mimic an older accepted input shape when pi resumes an older session whose stored tool call arguments no longer match the current schema. Return the object you want validated against `parameters`. Keep the public schema strict. Do not add deprecated compatibility fields to `parameters` just to keep old resumed sessions working.
Example: an older session may contain an `edit` tool call with top-level `oldText` and `newText`, while the current schema only accepts `edits: [{ oldText, newText }]`.
```typescript
pi.registerTool({
name: "edit",
label: "Edit",
description: "Edit a single file using exact text replacement",
parameters: Type.Object({
path: Type.String(),
edits: Type.Array(
Type.Object({
oldText: Type.String(),
newText: Type.String(),
}),
),
}),
prepareArguments(args) {
if (!args || typeof args !== "object") return args;
const input = args as {
path?: string;
edits?: Array<{ oldText: string; newText: string }>;
oldText?: unknown;
newText?: unknown;
};
if (typeof input.oldText !== "string" || typeof input.newText !== "string") {
return args;
}
return {
...input,
edits: [...(input.edits ?? []), { oldText: input.oldText, newText: input.newText }],
};
},
async execute(toolCallId, params, signal, onUpdate, ctx) {
// params now matches the current schema
return {
content: [{ type: "text", text: `Applying ${params.edits.length} edit block(s)` }],
details: {},
};
},
});
```
### Overriding Built-in Tools
Extensions can override built-in tools (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`) by registering a tool with the same name. Interactive mode displays a warning when this happens.

View File

@@ -377,6 +377,9 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
/** Parameter schema (TypeBox) */
parameters: TParams;
/** Optional compatibility shim to prepare raw tool call arguments before schema validation. Must return an object conforming to TParams. */
prepareArguments?: (args: unknown) => Static<TParams>;
/** Execute the tool. */
execute(
toolCallId: string,

View File

@@ -44,6 +44,10 @@ const editSchema = Type.Object(
);
export type EditToolInput = Static<typeof editSchema>;
type LegacyEditToolInput = EditToolInput & {
oldText?: unknown;
newText?: unknown;
};
export interface EditToolDetails {
/** Unified diff of the changes made */
@@ -76,13 +80,24 @@ export interface EditToolOptions {
operations?: EditOperations;
}
function validateEditInput(input: EditToolInput): { path: string; edits: Edit[] } {
if (!Array.isArray(input.edits)) {
throw new Error(
"Edit tool input is invalid. edits must be an array of replacements in the form { oldText: string, newText: string }.",
);
function prepareEditArguments(input: unknown): EditToolInput {
if (!input || typeof input !== "object") {
return input as EditToolInput;
}
if (input.edits.length === 0) {
const args = input as LegacyEditToolInput;
if (typeof args.oldText !== "string" || typeof args.newText !== "string") {
return input as EditToolInput;
}
const edits = Array.isArray(args.edits) ? [...args.edits] : [];
edits.push({ oldText: args.oldText, newText: args.newText });
const { oldText: _oldText, newText: _newText, ...rest } = args;
return { ...rest, edits } as EditToolInput;
}
function validateEditInput(input: EditToolInput): { path: string; edits: Edit[] } {
if (!Array.isArray(input.edits) || input.edits.length === 0) {
throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
}
return { path: input.path, edits: input.edits };
@@ -154,6 +169,7 @@ export function createEditToolDefinition(
"Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
],
parameters: editSchema,
prepareArguments: prepareEditArguments,
async execute(_toolCallId, input: EditToolInput, signal?: AbortSignal, _onUpdate?, _ctx?) {
const { path, edits } = validateEditInput(input);
const absolutePath = resolveToCwd(path, cwd);

View File

@@ -11,6 +11,7 @@ export function wrapToolDefinition<TDetails = unknown>(
label: definition.label,
description: definition.description,
parameters: definition.parameters,
prepareArguments: definition.prepareArguments,
execute: (toolCallId, params, signal, onUpdate) =>
definition.execute(toolCallId, params, signal, onUpdate, ctxFactory?.() as ExtensionContext),
};
@@ -36,6 +37,7 @@ export function createToolDefinitionFromAgentTool(tool: AgentTool<any>): ToolDef
label: tool.label,
description: tool.description,
parameters: tool.parameters as any,
prepareArguments: tool.prepareArguments,
execute: async (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),
};
}

View File

@@ -0,0 +1,90 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { ExtensionContext } from "../src/core/extensions/types.js";
import { createEditToolDefinition } from "../src/core/tools/edit.js";
const tempDirs: string[] = [];
async function createTempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "pi-edit-legacy-input-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0, tempDirs.length).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("edit tool prepareArguments", () => {
it("keeps legacy fields out of the public schema", () => {
const definition = createEditToolDefinition(process.cwd());
expect(definition.parameters.properties).not.toHaveProperty("oldText");
expect(definition.parameters.properties).not.toHaveProperty("newText");
});
it("folds top-level oldText/newText into edits", () => {
const definition = createEditToolDefinition(process.cwd());
const prepared = definition.prepareArguments!({
path: "file.txt",
oldText: "before",
newText: "after",
});
expect(prepared).toEqual({
path: "file.txt",
edits: [{ oldText: "before", newText: "after" }],
});
});
it("appends legacy replacement to existing edits", () => {
const definition = createEditToolDefinition(process.cwd());
const prepared = definition.prepareArguments!({
path: "file.txt",
edits: [{ oldText: "a", newText: "b" }],
oldText: "c",
newText: "d",
});
expect(prepared).toEqual({
path: "file.txt",
edits: [
{ oldText: "a", newText: "b" },
{ oldText: "c", newText: "d" },
],
});
});
it("passes through valid input unchanged", () => {
const definition = createEditToolDefinition(process.cwd());
const input = {
path: "file.txt",
edits: [{ oldText: "a", newText: "b" }],
};
const prepared = definition.prepareArguments!(input);
expect(prepared).toBe(input);
});
it("passes through non-object input unchanged", () => {
const definition = createEditToolDefinition(process.cwd());
expect(definition.prepareArguments!(null)).toBe(null);
expect(definition.prepareArguments!(undefined)).toBe(undefined);
expect(definition.prepareArguments!("garbage")).toBe("garbage");
});
it("prepared args execute correctly", async () => {
const dir = await createTempDir();
const filePath = join(dir, "legacy.txt");
await writeFile(filePath, "before\n", "utf8");
const definition = createEditToolDefinition(dir);
const prepared = definition.prepareArguments!({
path: "legacy.txt",
oldText: "before",
newText: "after",
});
const result = await definition.execute("tool-1", prepared, undefined, undefined, {} as ExtensionContext);
expect(result.content).toEqual([{ type: "text", text: "Successfully replaced 1 block(s) in legacy.txt." }]);
expect(await readFile(filePath, "utf8")).toBe("after\n");
});
});