feat(ai,agent,coding-agent): validate tool arguments with pi-schema, drop TypeBox

pi-schema: opt-in coercion, compile(schema, { coerce: true }) with coerce() and
parse(), the superset of TypeBox's Value.Convert rules and pi-ai's old coercion;
unresolvedRefs option; url and json-pointer-uri-fragment formats.
pi-ai: Tool takes any JSON Schema; validation through pi-schema; Type, Static
and TSchema are no longer exported (breaking). pi-agent-core and pi-durable:
tools on pi-schema, no TypeBox dependency. Coding agent: extensions keep Type,
Static, TSchema and StringEnum through an extension view of pi-ai; built-in
tools stay on TypeBox.
This commit is contained in:
Mario Zechner 2026-11-01 10:08:10 +00:00
parent 2c8d1feb14
commit 6536f80129
139 changed files with 5115 additions and 1305 deletions

11
package-lock.json generated
View file

@ -5442,7 +5442,7 @@
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "1.4.0",
"typebox": "1.3.27"
"@earendil-works/pi-schema": "1.4.0"
},
"devDependencies": {
"@types/node": "22.19.19",
@ -5466,14 +5466,14 @@
"dependencies": {
"@anthropic-ai/sdk": "0.129.0",
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
"@earendil-works/pi-schema": "1.4.0",
"@earendil-works/pi-telemetry": "1.4.0",
"@google/genai": "2.21.0",
"@smithy/node-http-handler": "4.12.1",
"http-proxy-agent": "9.1.0",
"https-proxy-agent": "9.1.0",
"openai": "7.19.0",
"partial-json": "0.1.7",
"typebox": "1.3.27"
"partial-json": "0.1.7"
},
"bin": {
"pi-ai": "dist/cli.js"
@ -5481,6 +5481,7 @@
"devDependencies": {
"@types/node": "22.19.19",
"canvas": "3.2.3",
"typebox": "1.3.27",
"vitest": "4.1.12"
},
"engines": {
@ -5714,10 +5715,10 @@
"@earendil-works/chord": "1.4.0",
"@earendil-works/pi-ai": "1.4.0",
"@earendil-works/pi-codemode": "1.4.0",
"@earendil-works/pi-schema": "1.4.0",
"@earendil-works/pi-telemetry": "1.4.0",
"@silvia-odwyer/photon": "0.3.3",
"diff": "8.0.4",
"typebox": "1.3.27"
"diff": "8.0.4"
},
"devDependencies": {
"@cloudflare/vitest-plugin": "1.4.10",

View file

@ -17,7 +17,7 @@
"build:native:darwin": "npm --prefix packages/tui run build:native:darwin",
"build:native:linux": "npm --prefix packages/tui run build:native:linux",
"build:native:win32": "npm --prefix packages/tui run build:native:win32",
"check": "node scripts/biome-check.mjs --write && npm run check:pinned-deps && npm run check:runtime-deps && npm run check:ts-imports && npm run check:entry-graphs && npm run check:install-lock:coding-agent && tsc --noEmit && npm run check:browser-smoke",
"check": "node scripts/biome-check.mjs --write && npm run check:pinned-deps && npm run check:runtime-deps && npm run check:ts-imports && npm run check:entry-graphs && npm run check:install-lock:coding-agent && tsc --noEmit && tsc --noEmit -p packages/coding-agent/examples/tsconfig.json && npm run check:browser-smoke",
"check:browser-smoke": "node scripts/check-browser-smoke.mjs",
"check:pinned-deps": "node scripts/check-pinned-deps.mjs",
"check:runtime-deps": "node scripts/check-runtime-deps.mjs",

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Breaking Changes
- `AgentTool` takes any JSON Schema object as `parameters` (`AgentTool<TParameters extends object>`) and types `params` and `prepareArguments` through `@earendil-works/pi-schema` (`AgentToolParams<TParameters>`): pi-schema's builders give their static type, other schemas (TypeBox included) give `unknown`, `AgentTool<any>` stays `any`. `outputSchema` is any JSON Schema object. `typebox` is no longer a dependency. Migration: build parameters with pi-schema (`obj({ path: str() })` instead of `Type.Object({ path: Type.String() })`), or keep TypeBox schemas and narrow `params` yourself.
### Fixed
- Fixed `streamProxy()` re-parsing the accumulated tool-call arguments on every delta and keeping their raw text on the partial content block ([#9265](https://github.com/earendil-works/pi/issues/9265))

View file

@ -455,18 +455,20 @@ const agent = new Agent({
## Tools
Define tools using `AgentTool`:
Define tools using `AgentTool`. `parameters` is any JSON Schema object; built with `@earendil-works/pi-schema`'s builders, it also types `params` (other schemas give `unknown`, `AgentTool<any>` gives `any`):
```typescript
import { Type } from "typebox";
import { obj, str } from "@earendil-works/pi-schema";
const readFileTool: AgentTool = {
const readFileParameters = obj({
path: str({ description: "File path" }),
});
const readFileTool: AgentTool<typeof readFileParameters> = {
name: "read_file",
label: "Read File", // For UI display
description: "Read a file's contents",
parameters: Type.Object({
path: Type.String({ description: "File path" }),
}),
parameters: readFileParameters,
// Override execution mode for this tool (optional).
// "sequential" forces the entire batch to run one at a time.
// "parallel" allows concurrent execution with other tool calls.

View file

@ -18,7 +18,7 @@ import {
renderDeclarations,
} from "@earendil-works/pi-codemode";
import { type McpClient, toLlmContent } from "@earendil-works/pi-mcp";
import { Type } from "typebox";
import { obj, str } from "@earendil-works/pi-schema";
/** Provider tool names are limited to 64 characters of `[A-Za-z0-9_-]`. */
function toToolName(prefix: string, name: string): string {
@ -45,12 +45,12 @@ export async function createMcpTools(client: McpClient, prefix: string): Promise
label: tool.title ?? tool.name,
description: tool.description ?? tool.title ?? `MCP tool ${tool.name}`,
// Providers require an object schema, and some reject one without `properties`.
parameters: Type.Unsafe({
parameters: {
...tool.inputSchema,
type: "object",
properties: tool.inputSchema.properties ?? {},
}),
outputSchema: tool.outputSchema ? Type.Unsafe<Record<string, unknown>>(tool.outputSchema) : undefined,
},
outputSchema: tool.outputSchema,
execute: async (_toolCallId, params, signal, onUpdate) => {
const result = await client.callTool(tool.name, params as Record<string, unknown>, {
signal,
@ -104,9 +104,11 @@ export function createNestedToolRunner(
export const CODEMODE_TOOL_NAME = "codemode";
const codemodeParameters = Type.Object({
code: Type.String({ description: "JavaScript source. Top-level await and return work." }),
});
const codemodeParameters = obj(
{ code: str({ description: "JavaScript source. Top-level await and return work." }) },
// Open, as the tool accepted extra arguments before it moved from TypeBox.
{ open: true },
);
/**
* A tool that runs model-written JavaScript in the codemode sandbox, where `tools.<name>(args)`
@ -121,7 +123,6 @@ export function createCodemodeTool(
const sandboxTools: CodemodeTool[] = callable.map((tool) => ({
name: tool.name,
description: tool.description,
// TypeBox schemas are JSON Schema.
inputSchema: tool.parameters as CodemodeJsonSchema,
// What the script receives: the structured result if the tool declares one, else its text.
outputSchema: (tool.outputSchema as CodemodeJsonSchema | undefined) ?? { type: "string" },

View file

@ -24,7 +24,7 @@
},
"dependencies": {
"@earendil-works/pi-ai": "1.4.0",
"typebox": "1.3.27"
"@earendil-works/pi-schema": "1.4.0"
},
"keywords": [
"ai",

View file

@ -14,7 +14,7 @@ import type {
TranscriptContext,
Usage,
} from "@earendil-works/pi-ai";
import type { Static, TSchema } from "typebox";
import type { Static } from "@earendil-works/pi-schema";
/**
* Stream function used by the agent loop. `Models.streamSimple` satisfies
@ -462,27 +462,39 @@ export interface AgentToolCallOutcome {
*/
export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;
/** Tool definition used by the agent runtime. */
export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {
/**
* The arguments type of a tool with parameter schema `TParameters`: `Static<TParameters>` for schemas built with
* `@earendil-works/pi-schema`'s builders, `unknown` for other schemas, and `any` for `AgentTool<any>`.
*/
export type AgentToolParams<TParameters> = IsAny<TParameters> extends true ? any : Static<TParameters>;
// `0 extends 1 & T` is resolved too early for a `T` constrained to `object`; this form waits for the argument.
type IsAny<T> = boolean extends (T extends never ? true : false) ? true : false;
/**
* Tool definition used by the agent runtime. `parameters` is any JSON Schema object; see {@link AgentToolParams} for
* the type `execute` receives.
*/
export interface AgentTool<TParameters extends object = object, TDetails = any> extends Tool<TParameters> {
/** Human-readable label for UI display. */
label: string;
/**
* Optional compatibility shim for raw tool-call arguments before schema validation.
* Must return an object that matches `TParameters`.
*/
prepareArguments?: (args: unknown) => Static<TParameters>;
prepareArguments?: (args: unknown) => AgentToolParams<TParameters>;
/**
* JSON Schema of `structuredContent` in successful results. Tools that declare it should always
* set `structuredContent`.
*/
outputSchema?: TSchema;
outputSchema?: object;
/**
* Execute the tool call. Throw on failure, or return a result with `isError: true`; do not only
* describe the failure in `content`.
*/
execute: (
toolCallId: string,
params: Static<TParameters>,
params: AgentToolParams<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>,
) => Promise<AgentToolResult<TDetails>>;

View file

@ -6,7 +6,7 @@ import {
type ToolResultMessage,
type UserMessage,
} from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { arr, obj, str } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import { agentLoop, agentLoopContinue, runAgentLoop, runToolCall } from "../src/agent-loop.ts";
import { setDefaultStreamFn } from "../src/index.ts";
@ -303,7 +303,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should handle tool calls and results", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const executed: string[] = [];
const toolUsage = {
input: 1,
@ -400,7 +400,7 @@ describe("agentLoop with AgentMessage", () => {
// #10549
it("records how long execute() took on the tool result, excluding hooks", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
@ -454,7 +454,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should not execute tool calls from a length-truncated assistant message", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
@ -526,7 +526,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should execute mutated beforeToolCall args without revalidation", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const executed: Array<string | number> = [];
const tool: AgentTool<typeof toolSchema, { value: string | number }> = {
name: "echo",
@ -587,8 +587,8 @@ describe("agentLoop with AgentMessage", () => {
});
it("should prepare tool arguments for validation", async () => {
const replaceSchema = Type.Object({ oldText: Type.String(), newText: Type.String() });
const toolSchema = Type.Object({ edits: Type.Array(replaceSchema) });
const replaceSchema = obj({ oldText: str(), newText: str() });
const toolSchema = obj({ edits: arr(replaceSchema) });
const executed: Array<Array<{ oldText: string; newText: string }>> = [];
const tool: AgentTool<typeof toolSchema, { count: number }> = {
name: "edit",
@ -666,7 +666,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should emit tool_execution_end in completion order but persist tool results in source order", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
let firstResolved = false;
let parallelObserved = false;
let releaseFirst: (() => void) | undefined;
@ -760,7 +760,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should inject queued messages after all tool calls complete", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
@ -865,7 +865,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should force sequential execution when a tool has executionMode=sequential even with default parallel config", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
let firstResolved = false;
let parallelObserved = false;
let releaseFirst: (() => void) | undefined;
@ -947,7 +947,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should force sequential execution when one of multiple tools has executionMode=sequential", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const executionOrder: string[] = [];
let releaseSlow: (() => void) | undefined;
const slowDone = new Promise<void>((resolve) => {
@ -1033,7 +1033,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should allow parallel execution when all tools have executionMode=parallel", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
let firstResolved = false;
let parallelObserved = false;
let releaseFirst: (() => void) | undefined;
@ -1106,7 +1106,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("runs finishTurn after tool-result messages and before turn_end", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
@ -1215,7 +1215,7 @@ describe("agentLoop with AgentMessage", () => {
);
it("action:end skips queue polling and next-turn preparation", async () => {
const toolSchema = Type.Object({});
const toolSchema = obj({});
const tool: AgentTool<typeof toolSchema, undefined> = {
name: "noop",
label: "Noop",
@ -1305,7 +1305,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("lets a natural tool-result request satisfy continuation", async () => {
const toolSchema = Type.Object({});
const toolSchema = obj({});
const tool: AgentTool<typeof toolSchema, undefined> = {
name: "noop",
label: "Noop",
@ -1501,7 +1501,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should use prepareNextTurn snapshot before continuing", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
@ -1578,7 +1578,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("picks up steering queued during prepareNextTurn before the next request", async () => {
const toolSchema = Type.Object({});
const toolSchema = obj({});
const tool: AgentTool<typeof toolSchema, undefined> = {
name: "noop",
label: "Noop",
@ -1631,7 +1631,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("action:end receives finalized turn context and stops before queue polling", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
@ -1730,7 +1730,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should stop after a tool batch when every tool result sets terminate=true", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
@ -1781,7 +1781,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should stop after a blocked tool call when beforeToolCall sets terminate=true", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
let executed = false;
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
@ -1839,7 +1839,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should continue after a mixed batch with one terminating blocked call", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
@ -1897,7 +1897,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should continue after parallel tool calls when not all tool results terminate", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
@ -1962,7 +1962,7 @@ describe("agentLoop with AgentMessage", () => {
});
it("should allow afterToolCall to mark a tool batch as terminating", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const toolSchema = obj({ value: str() });
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
@ -2131,13 +2131,13 @@ describe("agentLoopContinue with AgentMessage", () => {
});
describe("runToolCall", () => {
const echoSchema = Type.Object({ value: Type.String() });
const echoSchema = obj({ value: str() });
const echo: AgentTool<typeof echoSchema> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: echoSchema,
outputSchema: Type.Object({ value: Type.String() }),
outputSchema: obj({ value: str() }),
async execute(_toolCallId, params, _signal, onUpdate) {
onUpdate?.({ content: [{ type: "text", text: "partial" }], details: {} });
return {
@ -2151,7 +2151,7 @@ describe("runToolCall", () => {
name: "failing",
label: "Failing",
description: "Returns an error result",
parameters: Type.Object({}),
parameters: obj({}),
async execute() {
return { content: [{ type: "text", text: "bad" }], details: { partial: true }, isError: true };
},

View file

@ -7,7 +7,7 @@ import {
toToolDeclaration,
type UserMessage,
} from "@earendil-works/pi-ai/compat";
import { Type } from "typebox";
import { obj } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import {
Agent,
@ -49,7 +49,7 @@ function createTool(name: string): AgentTool {
name,
label: name,
description: `${name} tool`,
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ content: [{ type: "text", text: name }], details: {} }),
};
}
@ -146,7 +146,7 @@ describe("Agent", () => {
name: "echo",
label: "Echo",
description: "Echo input",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
};
const agent = new Agent({
@ -200,7 +200,7 @@ describe("Agent", () => {
expect(update).toEqual({
role: "system",
content: "",
toolsAdded: [{ name: "second", description: "second tool", parameters: Type.Object({}) }],
toolsAdded: [{ name: "second", description: "second tool", parameters: obj({}) }],
toolsRemoved: [{ name: "first" }],
timestamp: expect.any(Number),
});
@ -214,7 +214,7 @@ describe("Agent", () => {
name: "echo",
label: "Echo",
description: "Echo input",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
};
const agent = new Agent({
@ -239,7 +239,7 @@ describe("Agent", () => {
role: "system",
content: "",
sections: { skills: "<skills>x</skills>" },
toolsAdded: [{ name: "echo", description: "Echo input", parameters: Type.Object({}) }],
toolsAdded: [{ name: "echo", description: "Echo input", parameters: obj({}) }],
timestamp: 1,
});
});
@ -284,7 +284,7 @@ describe("Agent", () => {
name: "echo",
label: "Echo",
description: "Echo input",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ content: [{ type: "text", text: "echo" }], details: {} }),
};
const agent = new Agent({
@ -471,7 +471,7 @@ describe("Agent", () => {
});
it("should ignore tool updates after the tool execution settles", async () => {
const toolSchema = Type.Object({});
const toolSchema = obj({});
let delayedUpdate: AgentToolUpdateCallback<{ status: string }> | undefined;
const events: AgentEvent[] = [];
const unhandledRejections: unknown[] = [];
@ -536,7 +536,7 @@ describe("Agent", () => {
});
it("should ignore a settled parallel tool update while another tool is still running", async () => {
const toolSchema = Type.Object({});
const toolSchema = obj({});
const slowStarted = createDeferred();
const settledToolEnded = createDeferred();
const releaseSlow = createDeferred();
@ -858,7 +858,7 @@ describe("Agent", () => {
});
it("keeps legacy prepareNextTurn signal callback behavior", async () => {
const schema = Type.Object({});
const schema = obj({});
const tool: AgentTool<typeof schema> = {
name: "noop",
label: "Noop",
@ -899,7 +899,7 @@ describe("Agent", () => {
});
it("forwards finishTurn through AgentOptions with the active abort signal", async () => {
const schema = Type.Object({});
const schema = obj({});
const tool: AgentTool<typeof schema> = {
name: "noop",
label: "Noop",

View file

@ -1,5 +1,5 @@
import type { Usage } from "@earendil-works/pi-ai";
import { type Static, Type } from "typebox";
import { obj, type Static, str } from "@earendil-works/pi-schema";
import type { AgentTool, AgentToolResult } from "../../src/types.ts";
export interface CalculateResult extends AgentToolResult<undefined> {
@ -16,9 +16,11 @@ export function calculate(expression: string): CalculateResult {
}
}
const calculateSchema = Type.Object({
expression: Type.String({ description: "The mathematical expression to evaluate" }),
});
const calculateSchema = obj(
{ expression: str({ description: "The mathematical expression to evaluate" }) },
// Open, as the tool accepted extra arguments before it moved from TypeBox.
{ open: true },
);
type CalculateParams = Static<typeof calculateSchema>;

View file

@ -1,4 +1,4 @@
import { type Static, Type } from "typebox";
import { obj, opt, type Static, str } from "@earendil-works/pi-schema";
import type { AgentTool, AgentToolResult } from "../../src/types.ts";
export interface GetCurrentTimeResult extends AgentToolResult<{ utcTimestamp: number }> {}
@ -27,11 +27,11 @@ export async function getCurrentTime(timezone?: string): Promise<GetCurrentTimeR
};
}
const getCurrentTimeSchema = Type.Object({
timezone: Type.Optional(
Type.String({ description: "Optional timezone (e.g., 'America/New_York', 'Europe/London')" }),
),
});
const getCurrentTimeSchema = obj(
{ timezone: opt(str({ description: "Optional timezone (e.g., 'America/New_York', 'Europe/London')" })) },
// Open, as the tool accepted extra arguments before it moved from TypeBox.
{ open: true },
);
type GetCurrentTimeParams = Static<typeof getCurrentTimeSchema>;

View file

@ -4,6 +4,31 @@
### Breaking Changes
- The main entry no longer re-exports TypeBox (`Type`, `Static`, `TSchema`), and `typebox` is no longer a dependency. `Tool.parameters` is any JSON Schema object (`Tool<TParameters extends object>`): `@earendil-works/pi-schema`'s builders, which also type the arguments through pi-schema's `Static`, TypeBox schemas, or plain objects. `validateToolCall()`/`validateToolArguments()` validate with pi-schema (`compile(schema, { coerce: true }).parse()`): every schema gets the same coercion, the superset of TypeBox's `Value.Convert` rules (which only applied to TypeBox-built schemas) and pi-ai's own, so plain JSON Schema tools (MCP tools) now also get, for example, `null` to `"null"` for a required string, `"3.5"` to 3 for an integer and a single value wrapped into an array. The error text lists pi-schema's messages (`- edits.0.oldText: missing`, `- limit: expected number, got string`) instead of TypeBox's. `StringEnum()` returns a pi-schema `Schema<T>` with the same JSON (`{ type: "string", enum }`). The model and compat schemas (`providers/model-schema`, `providers/compat-schema`) are built with pi-schema, with the same JSON Schema, and their types come from pi-schema's `Static`; `ProviderCompat` is exported. Migration:
```ts
// Before
import { Type, type Static, StringEnum } from "@earendil-works/pi-ai";
const params = Type.Object({ path: Type.String(), mode: StringEnum(["a", "b"]) });
type Params = Static<typeof params>;
// After: pi-schema builders (closed objects; pass { open: true } to allow other properties)
import { StringEnum } from "@earendil-works/pi-ai";
import { obj, type Static, str } from "@earendil-works/pi-schema";
const params = obj({ path: str(), mode: StringEnum(["a", "b"]) });
type Params = Static<typeof params>;
// Or keep TypeBox, imported from typebox itself (its schemas are JSON Schema)
import { Type, type Static } from "typebox";
```
- Tool arguments are validated as JSON Schema only; nothing runs through TypeBox's own validator any more, so TypeBox features outside JSON Schema no longer apply to tool arguments:
- `Type.Refine()` refinements are not run: arguments they would reject are accepted.
- `Type.BigInt()`, `Type.Undefined()` and `Type.Void()` reject every JSON argument (TypeBox converted to them).
- Codecs (`Type.Codec()`) do not decode: the tool receives the encoded JSON value.
- Formats registered with TypeBox's `Format.Set()` are not checked; the standard formats plus `url` and `json-pointer-uri-fragment` are.
- Property names are names, not regular expressions: `"a.b"` no longer also converts the argument `axb`.
- `additionalProperties` converts only undeclared properties; TypeBox also converted declared ones with it when an object had more than one property.
- A `$ref` that names nothing (a missing definition, an external document) rejects only arguments at its place (`- t: unresolved reference #/definitions/Missing`) instead of failing every call of the tool, as TypeBox's rejection did before.
- `appendGrammarToolInputJsonDelta()` (`api/constrained-sampling`) takes the input delta instead of the whole input so far and no longer closes the input; close it with the new `closeGrammarToolInputJson()` ([#9265](https://github.com/earendil-works/pi/issues/9265))
### Added

View file

@ -103,15 +103,16 @@ Unified LLM API with provider collections, automatic auth resolution, token and
npm install @earendil-works/pi-ai
```
TypeBox exports are re-exported from `@earendil-works/pi-ai`: `Type`, `Static`, and `TSchema`.
Tool parameters are JSON Schema. The examples build them with `@earendil-works/pi-schema`, which also types the arguments (`npm install @earendil-works/pi-schema`); TypeBox schemas and plain JSON Schema objects work as well.
## Quick Start
You build a `Models` collection of providers and stream through it. The quickest start registers every built-in provider; apps that care about bundle size register individual providers instead (see [Provider Factories](#provider-factories) and [Bundling and Tree Shaking](#bundling-and-tree-shaking)).
```typescript
import { Type, type Context, type Tool } from '@earendil-works/pi-ai';
import { type Context, type Tool } from '@earendil-works/pi-ai';
import { builtinModels } from '@earendil-works/pi-ai/providers/all';
import { obj, opt, str } from '@earendil-works/pi-schema';
// A Models collection with every built-in provider registered
const models = builtinModels();
@ -119,12 +120,12 @@ const models = builtinModels();
// Sync lookup against the collection
const model = models.getModel('openai', 'gpt-4o-mini')!;
// Define tools with TypeBox schemas for type safety and validation
// Define tools with JSON Schema; pi-schema's builders also type the arguments
const tools: Tool[] = [{
name: 'get_time',
description: 'Get the current time',
parameters: Type.Object({
timezone: Type.Optional(Type.String({ description: 'Optional timezone (e.g., America/New_York)' }))
parameters: obj({
timezone: opt(str({ description: 'Optional timezone (e.g., America/New_York)' }))
})
}];
@ -538,34 +539,35 @@ Amazon Bedrock resolves ambient AWS credentials (`AWS_PROFILE`, access key pairs
## Tools
Tools enable LLMs to interact with external systems. This library uses TypeBox schemas for type-safe tool definitions with automatic validation using TypeBox's built-in validator and value conversion utilities. TypeBox schemas can be serialized and deserialized as plain JSON, making them ideal for distributed systems.
Tools enable LLMs to interact with external systems. A tool's `parameters` is a JSON Schema object: built with `@earendil-works/pi-schema`'s builders (which also type the arguments through its `Static`), with TypeBox, or written by hand. `validateToolCall()` and `validateToolArguments()` validate with pi-schema, coercing loosely typed arguments first (numeric strings, `"true"`, a single value for an array, `null` for an omitted optional property). Schemas are plain JSON, so they serialize with the rest of a context.
### Defining Tools
```typescript
import { Type, type Tool, StringEnum } from '@earendil-works/pi-ai';
import { type Tool, StringEnum } from '@earendil-works/pi-ai';
import { arr, obj, str } from '@earendil-works/pi-schema';
// Define tool parameters with TypeBox
// Define tool parameters with pi-schema's builders
const weatherTool: Tool = {
name: 'get_weather',
description: 'Get current weather for a location',
parameters: Type.Object({
location: Type.String({ description: 'City name or coordinates' }),
parameters: obj({
location: str({ description: 'City name or coordinates' }),
units: StringEnum(['celsius', 'fahrenheit'], { default: 'celsius' })
})
};
// Note: For Google API compatibility, use StringEnum helper instead of Type.Enum
// Type.Enum generates anyOf/const patterns that Google doesn't support
// Note: StringEnum gives { type: "string", enum: [...] }, which every provider accepts,
// including Google's, which rejects anyOf/const patterns
const bookMeetingTool: Tool = {
name: 'book_meeting',
description: 'Schedule a meeting',
parameters: Type.Object({
title: Type.String({ minLength: 1 }),
startTime: Type.String({ format: 'date-time' }),
endTime: Type.String({ format: 'date-time' }),
attendees: Type.Array(Type.String({ format: 'email' }), { minItems: 1 })
parameters: obj({
title: str({ minLength: 1 }),
startTime: str({ format: 'date-time' }),
endTime: str({ format: 'date-time' }),
attendees: arr(str({ format: 'email' }), { minItems: 1 })
})
};
```
@ -578,10 +580,7 @@ Tools can opt in to provider-side constrained sampling. For JSON-schema tools, `
const strictTool: Tool = {
name: 'edit_file',
description: 'Edit a file',
parameters: Type.Object({
path: Type.String(),
content: Type.String()
}, { additionalProperties: false }),
parameters: obj({ path: str(), content: str() }), // obj() is closed: additionalProperties: false
constrainedSampling: { type: 'json_schema', strict: 'prefer' }
};
```
@ -592,9 +591,7 @@ Strict JSON-schema constrained sampling is supported for OpenAI, Anthropic, supp
const patchTool: Tool = {
name: 'apply_patch',
description: 'Apply a patch',
parameters: Type.Object({
input: Type.String()
}, { additionalProperties: false }),
parameters: obj({ input: str() }),
constrainedSampling: {
type: 'grammar',
variants: {
@ -698,7 +695,7 @@ for await (const event of s) {
### Validating Tool Arguments
When implementing your own tool execution loop, use `validateToolCall` to validate arguments before passing them to your tools:
When implementing your own tool execution loop, use `validateToolCall` to validate arguments before passing them to your tools. It coerces loosely typed arguments with pi-schema (`compile(schema, { coerce: true }).parse()`), returns the coerced arguments, and throws an error listing pi-schema's errors (`path: message`) followed by the arguments it received:
```typescript
import { validateToolCall, type Tool } from '@earendil-works/pi-ai';
@ -1726,8 +1723,8 @@ models.setProvider(openaiProvider());
Rules:
- `@earendil-works/pi-ai/models` exports the model runtime (`createModels`, `createProvider`, model helpers, and their types) without TypeBox, built-in catalogs, or SDK implementations. Other types can still use `import type` from the root.
- `@earendil-works/pi-ai` is the core entrypoint and does not import built-in catalogs, real provider factories, or SDK implementations, but it eagerly imports TypeBox and schema validation. Unbundled Node scripts do not tree-shake its unused exports; prefer `./models`, `./providers/faux`, and specific `./utils/*` subpaths when those are all you need.
- `@earendil-works/pi-ai/models` exports the model runtime (`createModels`, `createProvider`, model helpers, and their types) without schema validation, built-in catalogs, or SDK implementations. Other types can still use `import type` from the root.
- `@earendil-works/pi-ai` is the core entrypoint and does not import built-in catalogs, real provider factories, or SDK implementations, but it eagerly imports schema validation (pi-schema). Unbundled Node scripts do not tree-shake its unused exports; prefer `./models`, `./providers/faux`, and specific `./utils/*` subpaths when those are all you need.
- `@earendil-works/pi-ai/providers/<provider>` imports that provider's catalog and lazy API wrapper only.
- `@earendil-works/pi-ai/providers/all` imports every built-in provider factory and all catalogs. Use it only when you want the full built-in set.
- With code splitting, provider SDKs stay in lazy chunks and load on first request.

View file

@ -69,14 +69,14 @@
"dependencies": {
"@anthropic-ai/sdk": "0.129.0",
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
"@earendil-works/pi-schema": "1.4.0",
"@earendil-works/pi-telemetry": "1.4.0",
"@google/genai": "2.21.0",
"@smithy/node-http-handler": "4.12.1",
"http-proxy-agent": "9.1.0",
"https-proxy-agent": "9.1.0",
"openai": "7.19.0",
"partial-json": "0.1.7",
"typebox": "1.3.27"
"partial-json": "0.1.7"
},
"keywords": [
"ai",
@ -101,6 +101,7 @@
"devDependencies": {
"@types/node": "22.19.19",
"canvas": "3.2.3",
"typebox": "1.3.27",
"vitest": "4.1.12"
}
}

View file

@ -1,6 +1,3 @@
export type { Static, TSchema } from "typebox";
export { Type } from "typebox";
// Core only, side-effect free: no generated catalogs, no provider factories,
// no api-registry, no OAuth implementations, no compat. Provider factories
// live under "@earendil-works/pi-ai/providers/*", API implementations under
@ -42,9 +39,9 @@ export * from "./utils/json-parse.ts";
export * from "./utils/overflow.ts";
export * from "./utils/retry.ts";
export * from "./utils/streaming-json.ts";
export * from "./utils/string-enum.ts";
export { contentText, getSystemMessageText, renderSystemMessageUpdate } from "./utils/text.ts";
export * from "./utils/transcript.ts";
export * from "./utils/typebox-helpers.ts";
export { uuidv7 } from "./utils/uuid.ts";
export * from "./utils/validation.ts";
export { PI_AI_VERSION } from "./utils/version.ts";

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,17 @@
import { type Static, type TSchemaOptions, Type } from "typebox";
import {
type Annotations,
arr,
int,
nul,
num,
oneOf,
opt,
type Static,
type StaticObject,
str,
union,
} from "@earendil-works/pi-schema";
import { openObject as object } from "./schema-builders.ts";
export const THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
export const MODEL_THINKING_LEVELS = ["off", ...THINKING_LEVELS] as const;
@ -6,19 +19,31 @@ export const MODEL_INPUT_MODALITIES = ["text", "image"] as const;
export const CACHE_RETENTIONS = ["none", "short", "long"] as const;
export const MODEL_PROMPT_CACHE_RETENTIONS = ["short", "long"] as const;
export const ThinkingLevelSchema = Type.Enum(THINKING_LEVELS);
export const ModelThinkingLevelSchema = Type.Enum(MODEL_THINKING_LEVELS);
export const ModelInputModalitySchema = Type.Enum(MODEL_INPUT_MODALITIES);
export const CacheRetentionSchema = Type.Enum(CACHE_RETENTIONS);
export const ThinkingLevelSchema = oneOf(THINKING_LEVELS);
export const ModelThinkingLevelSchema = oneOf(MODEL_THINKING_LEVELS);
export const ModelInputModalitySchema = oneOf(MODEL_INPUT_MODALITIES);
export const CacheRetentionSchema = oneOf(CACHE_RETENTIONS);
const ThinkingLevelMapValueSchema = Type.Union([Type.String(), Type.Null()]);
export const ThinkingLevelMapSchema = Type.Partial(Type.Record(ModelThinkingLevelSchema, ThinkingLevelMapValueSchema), {
description:
"Provider value sent for each thinking level. null marks a level unsupported. Levels the model does not map use the provider's default value through high; xhigh and max are unsupported.",
});
const ThinkingLevelMapValueSchema = union([str(), nul()]);
export const ThinkingLevelMapSchema = object(
{
off: opt(ThinkingLevelMapValueSchema),
minimal: opt(ThinkingLevelMapValueSchema),
low: opt(ThinkingLevelMapValueSchema),
medium: opt(ThinkingLevelMapValueSchema),
high: opt(ThinkingLevelMapValueSchema),
xhigh: opt(ThinkingLevelMapValueSchema),
max: opt(ThinkingLevelMapValueSchema),
},
{
description:
"Provider value sent for each thinking level. null marks a level unsupported. Levels the model does not map use the provider's default value through high; xhigh and max are unsupported.",
},
);
export const ModelPromptCacheSchema = Type.Partial(
Type.Record(Type.Enum(MODEL_PROMPT_CACHE_RETENTIONS), Type.Number({ exclusiveMinimum: 0 })),
const PromptCacheLifetimeSchema = num({ exclusiveMinimum: 0 });
export const ModelPromptCacheSchema = object(
{ short: opt(PromptCacheLifetimeSchema), long: opt(PromptCacheLifetimeSchema) },
{
description:
"Best-effort prompt cache lifetime in seconds for the short and long retention tiers. Pi warms only tiers with a known lifetime.",
@ -26,63 +51,54 @@ export const ModelPromptCacheSchema = Type.Partial(
);
const ModelCostRatesProperties = {
input: Type.Number({ description: "Input cost in USD per million tokens." }),
output: Type.Number({ description: "Output cost in USD per million tokens." }),
cacheRead: Type.Number({ description: "Cache-read cost in USD per million tokens." }),
cacheWrite: Type.Number({ description: "Cache-write cost in USD per million tokens." }),
input: num({ description: "Input cost in USD per million tokens." }),
output: num({ description: "Output cost in USD per million tokens." }),
cacheRead: num({ description: "Cache-read cost in USD per million tokens." }),
cacheWrite: num({ description: "Cache-write cost in USD per million tokens." }),
};
export const ModelCostRatesSchema = Type.Object(ModelCostRatesProperties);
export const ModelCostTierSchema = Type.Object({
inputTokensAbove: Type.Number({
export const ModelCostRatesSchema = object(ModelCostRatesProperties);
export const ModelCostTierSchema = object({
inputTokensAbove: num({
description: "Use this tier when total request input exceeds this token count.",
}),
...ModelCostRatesProperties,
});
export const ModelCostSchema = Type.Object({
export const ModelCostSchema = object({
...ModelCostRatesProperties,
tiers: Type.Optional(
Type.Array(ModelCostTierSchema, {
tiers: opt(
arr(ModelCostTierSchema, {
description: "Request-wide pricing tiers. The highest matching input threshold applies to the full request.",
}),
),
});
function modelImageResizeOptions(options?: TSchemaOptions) {
return Type.Object(
{
maxWidth: Type.Optional(Type.Integer({ minimum: 1 })),
maxHeight: Type.Optional(Type.Integer({ minimum: 1 })),
maxBytes: Type.Optional(
Type.Integer({ minimum: 1, description: "Maximum base64-encoded payload size in bytes." }),
),
jpegQuality: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
},
options,
);
const ModelImageResizeProperties = {
maxWidth: opt(int({ minimum: 1 })),
maxHeight: opt(int({ minimum: 1 })),
maxBytes: opt(int({ minimum: 1, description: "Maximum base64-encoded payload size in bytes." })),
jpegQuality: opt(int({ minimum: 1, maximum: 100 })),
};
function modelImageResizeOptions(options?: Annotations<StaticObject<typeof ModelImageResizeProperties>>) {
return object(ModelImageResizeProperties, options);
}
export const ModelImageResizeOptionsSchema = modelImageResizeOptions();
export const ModelImageInputLimitsSchema = Type.Object({
resize: Type.Optional(
export const ModelImageInputLimitsSchema = object({
resize: opt(
modelImageResizeOptions({
description: "Cache-safe resize profile applied before a new image enters conversation history.",
}),
),
maxPerMessage: Type.Optional(
Type.Integer({ minimum: 1, description: "Maximum images accepted in one provider message." }),
),
maxPerRequest: Type.Optional(
Type.Integer({ minimum: 1, description: "Maximum images accepted across one provider request." }),
),
maxPerMessage: opt(int({ minimum: 1, description: "Maximum images accepted in one provider message." })),
maxPerRequest: opt(int({ minimum: 1, description: "Maximum images accepted across one provider request." })),
});
export const ModelInputLimitsSchema = Type.Object({
maxRequestBytes: Type.Optional(
Type.Integer({ minimum: 1, description: "Maximum serialized provider request size in bytes." }),
),
images: Type.Optional(ModelImageInputLimitsSchema),
export const ModelInputLimitsSchema = object({
maxRequestBytes: opt(int({ minimum: 1, description: "Maximum serialized provider request size in bytes." })),
images: opt(ModelImageInputLimitsSchema),
});
export type ThinkingLevel = Static<typeof ThinkingLevelSchema>;

View file

@ -0,0 +1,34 @@
// Builders for pi-ai's own configuration schemas (model metadata, compat settings): pi-schema's builders, shaped like the
// TypeBox schemas they replace, so models.json and the catalog validate exactly as before.
import {
type Annotations,
obj,
type Properties,
type Schema,
type Static,
type StaticObject,
} from "@earendil-works/pi-schema";
/** An object that allows other properties; `additionalProperties: true` says so explicitly. */
export function openObject<const P extends Properties>(
properties: P,
options: Annotations<StaticObject<P>> & { readonly additionalProperties?: true } = {},
): Schema<StaticObject<P>> {
const { additionalProperties, ...annotations } = options;
const schema = obj(properties, { ...annotations, open: true });
return additionalProperties ? { ...schema, additionalProperties } : schema;
}
/** One string: `{ type: "string", const: value }`. */
export function stringLiteral<const V extends string>(value: V, options: Annotations<V> = {}): Schema<V> {
return { type: "string", const: value, ...options };
}
/** An object with any string keys and values of `value`: `{ type: "object", patternProperties: { "^.*$": value } }`. */
export function stringRecord<S extends Schema>(
value: S,
options: Annotations<Record<string, Static<S>>> = {},
): Schema<Record<string, Static<S>>> {
return { type: "object", patternProperties: { "^.*$": value }, ...options };
}

View file

@ -742,8 +742,6 @@ export interface ClassifierResult {
timestamp: number; // Unix timestamp in milliseconds
}
import type { TSchema } from "typebox";
/** OpenAI grammar variants for constrained sampling. */
export type GrammarFormat = "openai_lark" | "openai_regex";
@ -766,7 +764,11 @@ export type ConstrainedSamplingConfig =
variants: GrammarVariants;
};
export interface Tool<TParameters extends TSchema = TSchema> {
/**
* A tool the model may call. `parameters` is the JSON Schema of its arguments: pi-schema's builders (which also type
* the arguments, see `Static` in `@earendil-works/pi-schema`), TypeBox schemas, or any JSON Schema object.
*/
export interface Tool<TParameters extends object = object> {
name: string;
description: string;
parameters: TParameters;

View file

@ -1,24 +1,24 @@
import { type TUnsafe, Type } from "typebox";
import type { Schema } from "@earendil-works/pi-schema";
/**
* Creates a string enum schema compatible with Google's API and other providers
* that don't support anyOf/const patterns.
* that don't support anyOf/const patterns: `{ type: "string", enum: [...] }`.
*
* @example
* const OperationSchema = StringEnum(["add", "subtract", "multiply", "divide"], {
* description: "The operation to perform"
* });
*
* type Operation = Static<typeof OperationSchema>; // "add" | "subtract" | "multiply" | "divide"
* type Operation = Static<typeof OperationSchema>; // "add" | "subtract" | "multiply" | "divide" (Static from @earendil-works/pi-schema)
*/
export function StringEnum<T extends readonly string[]>(
values: T,
options?: { description?: string; default?: T[number] },
): TUnsafe<T[number]> {
return Type.Unsafe<T[number]>({
): Schema<T[number]> {
return {
type: "string",
enum: values as any,
enum: values,
...(options?.description && { description: options.description }),
...(options?.default && { default: options.default }),
});
};
}

View file

@ -133,7 +133,7 @@ export function toToolDeclaration(tool: Tool): Tool {
* Whether two tools declare the same interface to the model.
*
* Both sides go through {@link toToolDeclaration} first: its JSON round-trip drops the
* typebox symbol keys and `undefined` fields that a structural comparison would see, and
* schema metadata keys (TypeBox's non-enumerable kind marks) and `undefined` fields that a structural comparison would see, and
* builds both objects with the same key order, so comparing the serialized declarations
* is exact. This avoids a deep-equal dependency in a browser-safe package.
*/

View file

@ -1,299 +1,34 @@
import { Compile } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error";
import { Value } from "typebox/value";
import { type CoercingValidator, compile } from "@earendil-works/pi-schema";
import { formats } from "@earendil-works/pi-schema/formats";
import type { Tool, ToolCall } from "../types.ts";
const validatorCache = new WeakMap<object, ReturnType<typeof Compile>>();
const TYPEBOX_KIND = Symbol.for("TypeBox.Kind");
// One compiled validator per schema object: tool schemas are long-lived, so the WeakMap drops entries with their tools.
const validatorCache = new WeakMap<object, CoercingValidator>();
interface JsonSchemaObject {
type?: string | string[];
properties?: Record<string, JsonSchemaObject>;
required?: string[];
items?: JsonSchemaObject | JsonSchemaObject[];
additionalProperties?: boolean | JsonSchemaObject;
allOf?: JsonSchemaObject[];
anyOf?: JsonSchemaObject[];
oneOf?: JsonSchemaObject[];
}
function getSchemaTypes(schema: JsonSchemaObject): string[] {
if (typeof schema.type === "string") {
return [schema.type];
}
if (Array.isArray(schema.type)) {
return schema.type.filter((type): type is string => typeof type === "string");
}
return [];
}
function matchesJsonType(value: unknown, type: string): boolean {
switch (type) {
case "number":
return typeof value === "number";
case "integer":
return typeof value === "number" && Number.isInteger(value);
case "boolean":
return typeof value === "boolean";
case "string":
return typeof value === "string";
case "null":
return value === null;
case "array":
return Array.isArray(value);
case "object":
return typeof value === "object" && value !== null && !Array.isArray(value);
default:
return false;
}
}
function getSubSchemaValidator(schema: JsonSchemaObject): ReturnType<typeof Compile> | undefined {
try {
return getValidator(schema as Tool["parameters"]);
} catch {
return undefined;
}
}
function coercePrimitiveByType(value: unknown, type: string): unknown {
switch (type) {
case "number": {
if (value === null) {
return 0;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
if (typeof value === "boolean") {
return value ? 1 : 0;
}
return value;
}
case "integer": {
if (value === null) {
return 0;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isInteger(parsed)) {
return parsed;
}
}
if (typeof value === "boolean") {
return value ? 1 : 0;
}
return value;
}
case "boolean": {
if (value === null) {
return false;
}
if (typeof value === "string") {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
}
if (typeof value === "number") {
if (value === 1) {
return true;
}
if (value === 0) {
return false;
}
}
return value;
}
case "string": {
if (value === null) {
return "";
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return value;
}
case "null": {
if (value === "" || value === 0 || value === false) {
return null;
}
return value;
}
default:
return value;
}
}
function applySchemaObjectCoercion(value: Record<string, unknown>, schema: JsonSchemaObject): void {
const properties = schema.properties;
const definedKeys = new Set<string>(properties ? Object.keys(properties) : []);
if (properties) {
for (const [key, propertySchema] of Object.entries(properties)) {
if (!(key in value)) {
continue;
}
value[key] = coerceWithJsonSchema(value[key], propertySchema);
}
}
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
for (const [key, propertyValue] of Object.entries(value)) {
if (definedKeys.has(key)) {
continue;
}
value[key] = coerceWithJsonSchema(propertyValue, schema.additionalProperties);
}
}
}
function applySchemaArrayCoercion(value: unknown[], schema: JsonSchemaObject): void {
if (Array.isArray(schema.items)) {
for (let index = 0; index < value.length; index++) {
const itemSchema = schema.items[index];
if (!itemSchema) {
continue;
}
value[index] = coerceWithJsonSchema(value[index], itemSchema);
}
return;
}
if (schema.items && typeof schema.items === "object") {
for (let index = 0; index < value.length; index++) {
value[index] = coerceWithJsonSchema(value[index], schema.items);
}
}
}
function coerceWithUnionSchema(value: unknown, schemas: JsonSchemaObject[]): unknown {
for (const schema of schemas) {
const validator = getSubSchemaValidator(schema);
if (validator?.Check(value)) {
return value;
}
}
for (const schema of schemas) {
const candidate = structuredClone(value);
const coerced = coerceWithJsonSchema(candidate, schema);
const validator = getSubSchemaValidator(schema);
if (validator?.Check(coerced)) {
return coerced;
}
}
return value;
}
function coerceWithJsonSchema(value: unknown, schema: JsonSchemaObject): unknown {
let nextValue = value;
if (Array.isArray(schema.allOf)) {
for (const nested of schema.allOf) {
nextValue = coerceWithJsonSchema(nextValue, nested);
}
}
if (Array.isArray(schema.anyOf)) {
nextValue = coerceWithUnionSchema(nextValue, schema.anyOf);
}
if (Array.isArray(schema.oneOf)) {
nextValue = coerceWithUnionSchema(nextValue, schema.oneOf);
}
const schemaTypes = getSchemaTypes(schema);
const matchesUnionMember =
schemaTypes.length > 1 && schemaTypes.some((schemaType) => matchesJsonType(nextValue, schemaType));
if (schemaTypes.length > 0 && !matchesUnionMember) {
for (const schemaType of schemaTypes) {
const candidate = coercePrimitiveByType(nextValue, schemaType);
if (candidate !== nextValue) {
nextValue = candidate;
break;
}
}
}
if (
schemaTypes.includes("object") &&
typeof nextValue === "object" &&
nextValue !== null &&
!Array.isArray(nextValue)
) {
applySchemaObjectCoercion(nextValue as Record<string, unknown>, schema);
}
if (schemaTypes.includes("array") && Array.isArray(nextValue)) {
applySchemaArrayCoercion(nextValue, schema);
}
return nextValue;
}
function normalizeOptionalNulls(value: unknown, schema: JsonSchemaObject): void {
if (Array.isArray(value)) {
if (Array.isArray(schema.items)) {
for (let index = 0; index < value.length; index++) {
const itemSchema = schema.items[index];
if (itemSchema) normalizeOptionalNulls(value[index], itemSchema);
}
} else if (schema.items) {
for (const item of value) normalizeOptionalNulls(item, schema.items);
}
return;
}
if (typeof value !== "object" || value === null || !schema.properties) return;
const object = value as Record<string, unknown>;
const required = new Set(schema.required ?? []);
for (const [key, propertySchema] of Object.entries(schema.properties)) {
if (!(key in object)) continue;
if (
object[key] === null &&
!required.has(key) &&
typeof (propertySchema as { $ref?: unknown }).$ref !== "string" &&
getSubSchemaValidator(propertySchema)?.Check(null) === false
) {
delete object[key];
} else {
normalizeOptionalNulls(object[key], propertySchema);
}
}
}
function getValidator(schema: Tool["parameters"]): ReturnType<typeof Compile> {
const key = schema as object;
const cached = validatorCache.get(key);
function getValidator(schema: Tool["parameters"]): CoercingValidator {
const cached = validatorCache.get(schema);
if (cached) {
return cached;
}
const validator = Compile(schema);
validatorCache.set(key, validator);
// A $ref that names nothing (a dangling definition in an MCP server's schema) rejects only values at its place,
// instead of making every call of the tool fail.
const validator = compile(schema, { formats, coerce: true, unresolvedRefs: "reject" });
validatorCache.set(schema, validator);
return validator;
}
function formatValidationPath(error: TLocalizedValidationError): string {
if (error.keyword === "required") {
const requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties;
const requiredProperty = requiredProperties?.[0];
if (requiredProperty) {
const basePath = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
return basePath ? `${basePath}.${requiredProperty}` : requiredProperty;
}
}
const path = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
return path || "root";
/** A pi-schema error path (a JSON Pointer) as the dotted path the model sees: `/edits/0/oldText` -> `edits.0.oldText`. */
function formatValidationPath(pointer: string): string {
if (pointer === "") return "root";
return pointer
.slice(1)
.split("/")
.map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"))
.join(".");
}
/**
* Finds a tool by name and validates the tool call arguments against its TypeBox schema
* Finds a tool by name and validates the tool call arguments against its JSON Schema
* @param tools Array of tool definitions
* @param toolCall The tool call from the LLM
* @returns The validated arguments
@ -308,8 +43,10 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): any {
}
/**
* Validates tool call arguments against the tool's TypeBox schema
* @param tool The tool definition with TypeBox schema
* Validates tool call arguments against the tool's JSON Schema (any JSON Schema: pi-schema's builders, TypeBox, plain
* objects). The arguments are coerced first (pi-schema's `parse()`): models often send `"42"` for a number, a single
* value for an array, or `null` for an omitted optional property.
* @param tool The tool definition with its JSON Schema
* @param toolCall The tool call from the LLM
* @returns The validated (and potentially coerced) arguments
* @throws Error with formatted message if validation fails
@ -318,34 +55,13 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {
if (toolCall.argumentsError) {
throw new Error(`Invalid arguments for tool "${toolCall.name}": ${toolCall.argumentsError}`);
}
const args = structuredClone(toolCall.arguments);
normalizeOptionalNulls(args, tool.parameters as JsonSchemaObject);
Value.Convert(tool.parameters, args);
const validator = getValidator(tool.parameters);
if (!Object.getOwnPropertySymbols(tool.parameters).includes(TYPEBOX_KIND)) {
const coerced = coerceWithJsonSchema(args, tool.parameters as JsonSchemaObject);
if (coerced !== args) {
if (typeof args === "object" && args !== null && typeof coerced === "object" && coerced !== null) {
for (const key of Object.keys(args)) {
delete args[key];
}
Object.assign(args, coerced);
} else {
return validator.Check(coerced) ? coerced : args;
}
}
// A copy, so tools that modify their arguments never modify the tool call in the transcript.
const result = getValidator(tool.parameters).parse(structuredClone(toolCall.arguments));
if (result.ok) {
return result.value;
}
if (validator.Check(args)) {
return args;
}
const errors =
validator
.Errors(args)
.map((error) => ` - ${formatValidationPath(error)}: ${error.message}`)
.join("\n") || "Unknown validation error";
const errors = result.errors.map((error) => ` - ${formatValidationPath(error.path)}: ${error.message}`).join("\n");
const errorMessage = `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`;

View file

@ -1,5 +1,4 @@
import type { Static } from "typebox";
import { Compile } from "typebox/compile";
import type { Static } from "@earendil-works/pi-schema";
import { describe, expect, expectTypeOf, it } from "vitest";
import {
AnthropicMessagesCompatSchema,
@ -11,6 +10,16 @@ import {
type ProviderCompat = Static<typeof ProviderCompatSchema>;
/** The `default` annotation of each property of an object schema. */
function defaults(schema: { readonly [keyword: string]: unknown }): Record<string, unknown> {
const properties = schema.properties as Record<string, { default?: unknown }>;
return Object.fromEntries(
Object.entries(properties)
.filter(([, property]) => property.default !== undefined)
.map(([name, property]) => [name, property.default]),
);
}
describe("compatibility schemas", () => {
it("preserves property types in the provider superset", () => {
expectTypeOf<ProviderCompat["supportsStore"]>().toEqualTypeOf<boolean | undefined>();
@ -20,21 +29,21 @@ describe("compatibility schemas", () => {
});
it("preserves API-specific defaults", () => {
const completionsDefaults = Compile(OpenAICompletionsCompatSchema).Default({});
const completionsDefaults = defaults(OpenAICompletionsCompatSchema);
expect(completionsDefaults).not.toHaveProperty("thinkingFormat");
expect(completionsDefaults).not.toHaveProperty("supportsLongCacheRetention");
expect(Compile(OpenAIResponsesCompatSchema).Default({})).toMatchObject({ supportsDeveloperRole: true });
const anthropicDefaults = Compile(AnthropicMessagesCompatSchema).Default({});
expect(defaults(OpenAIResponsesCompatSchema)).toMatchObject({ supportsDeveloperRole: true });
const anthropicDefaults = defaults(AnthropicMessagesCompatSchema);
expect(anthropicDefaults).not.toHaveProperty("sendSessionAffinityHeaders");
expect(anthropicDefaults).toMatchObject({ supportsLongCacheRetention: true });
expect(Compile(BedrockCompatSchema).Default({})).toMatchObject({ supportsStrictMode: false });
expect(defaults(BedrockCompatSchema)).toMatchObject({ supportsStrictMode: false });
});
it("does not assign API-specific defaults to the provider superset", () => {
const defaults = Compile(ProviderCompatSchema).Default({});
expect(defaults).not.toHaveProperty("supportsDeveloperRole");
expect(defaults).not.toHaveProperty("sendSessionAffinityHeaders");
expect(defaults).not.toHaveProperty("supportsLongCacheRetention");
expect(defaults).not.toHaveProperty("supportsStrictMode");
const providerDefaults = defaults(ProviderCompatSchema);
expect(providerDefaults).not.toHaveProperty("supportsDeveloperRole");
expect(providerDefaults).not.toHaveProperty("sendSessionAffinityHeaders");
expect(providerDefaults).not.toHaveProperty("supportsLongCacheRetention");
expect(providerDefaults).not.toHaveProperty("supportsStrictMode");
});
});

View file

@ -1,3 +1,4 @@
import { Type } from "typebox";
import { afterEach, describe, expect, it } from "vitest";
import {
complete,
@ -7,7 +8,6 @@ import {
fauxToolCall,
registerFauxProvider,
stream,
Type,
} from "../src/compat.ts";
import type { AssistantMessageEvent, Context } from "../src/types.ts";

View file

@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import { completeSimple, getModel } from "../src/compat.ts";
import { getEnvApiKey } from "../src/env-api-keys.ts";
import type { Api, Context, Model, StopReason, Tool, ToolCall, ToolResultMessage } from "../src/types.ts";
import { StringEnum } from "../src/utils/typebox-helpers.ts";
import { StringEnum } from "../src/utils/string-enum.ts";
import { hasBedrockCredentials } from "./bedrock-utils.ts";
const calculatorSchema = Type.Object({

View file

@ -9,7 +9,7 @@ import type { Api, Context, ImageContent, Model, StreamOptions, Tool, ToolResult
type StreamOptionsWithExtras = StreamOptions & Record<string, unknown>;
import { StringEnum } from "../src/utils/typebox-helpers.ts";
import { StringEnum } from "../src/utils/string-enum.ts";
import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts";
import { hasBedrockCredentials } from "./bedrock-utils.ts";
import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts";

View file

@ -0,0 +1,779 @@
// The reviewers' probes of the schema switch (round 1) as regression tests: TypeBox-built and plain JSON
// (MCP-shaped) tool schemas with arguments through validateToolArguments. Each case's outcome is "ok <coerced
// arguments>", "REJECT <first error line>" or "THROW <message>". Cases marked as TypeBox-only record the documented
// differences from TypeBox (see the CHANGELOG): Refine and BigInt.
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: TypeBox template literal syntax.
// biome-ignore-all lint/suspicious/noThenProperty: JSON Schema's `then` keyword.
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import type { Tool, ToolCall } from "../src/types.ts";
import { validateToolArguments } from "../src/utils/validation.ts";
/** The outcome of one call, in the probes' format. */
function outcome(name: string, schema: object, args: unknown): string {
const tool = { name, description: "", parameters: schema } as Tool;
const call = { type: "toolCall", id: "1", name, arguments: args } as ToolCall;
try {
return `ok ${JSON.stringify(validateToolArguments(tool, call), (_key, value) => (typeof value === "bigint" ? `${value}n` : value))}`;
} catch (error) {
const message = (error as Error).message;
return message.startsWith("Validation failed")
? `REJECT ${message.split("\n")[1]?.trim()}`
: `THROW ${message.split("\n")[0]}`;
}
}
type Case = { name: string; schema: object; args: unknown[] };
const Node = Type.Cyclic(
{ Node: Type.Object({ name: Type.String(), children: Type.Array(Type.Ref("Node")) }) },
"Node",
);
const cases: Case[] = [
{
name: "cyclic",
schema: Type.Object({ tree: Node }),
args: [{ tree: { name: "a", children: [{ name: "b", children: [] }] } }, { tree: { name: 1, children: [] } }],
},
{ name: "ref-with-$id", schema: Type.Object({ a: Type.Ref("A") }), args: [{ a: { x: "1" } }] },
{
name: "function",
schema: Type.Object({ f: Type.Optional(Type.Function([], Type.Void())) }),
args: [{}, { f: "x" }],
},
{
name: "codec",
schema: Type.Object({
c: Type.Codec(Type.String())
.Decode((s) => Number(s))
.Encode((n) => String(n)),
}),
args: [{ c: "12" }, { c: 12 }],
},
{ name: "undefined-optional", schema: Type.Object({ u: Type.Optional(Type.Undefined()) }), args: [{}, { u: null }] },
{ name: "bigint", schema: Type.Object({ n: Type.BigInt() }), args: [{ n: 1 }, { n: "12" }] },
{
name: "refine",
schema: Type.Object({ n: Type.Refine(Type.Number(), (v) => v > 0) }),
args: [{ n: 5 }, { n: -5 }],
},
{
name: "template-literal",
schema: Type.Object({ t: Type.TemplateLiteral("id-${number}") }),
args: [{ t: "id-12" }, { t: "id-x" }, { t: 3 }],
},
{
name: "record-string",
schema: Type.Object({ r: Type.Record(Type.String(), Type.Number()) }),
args: [{ r: { a: "1" } }, { r: { a: 1, b: "x" } }],
},
{
name: "record-literal-keys",
schema: Type.Object({ r: Type.Record(Type.Union([Type.Literal("a"), Type.Literal("b")]), Type.Number()) }),
args: [{ r: { a: 1, b: 2 } }, { r: { a: 1 } }, { r: { a: 1, b: 2, c: 3 } }],
},
{
name: "intersect",
schema: Type.Intersect([Type.Object({ a: Type.Number() }), Type.Object({ b: Type.Boolean() })]),
args: [
{ a: "1", b: "true" },
{ a: 1, b: true, c: 1 },
],
},
{
name: "union-objects",
schema: Type.Object({
v: Type.Union([
Type.Object({ k: Type.Literal("x"), n: Type.Number() }),
Type.Object({ k: Type.Literal("y"), s: Type.String() }),
]),
}),
args: [{ v: { k: "x", n: "2" } }, { v: { k: "y", s: 2 } }],
},
{ name: "minLength-astral", schema: Type.Object({ s: Type.String({ minLength: 2 }) }), args: [{ s: "😀" }] },
{ name: "maxLength-astral", schema: Type.Object({ s: Type.String({ maxLength: 1 }) }), args: [{ s: "😀" }] },
{ name: "pattern-astral", schema: Type.Object({ s: Type.String({ pattern: "^.$" }) }), args: [{ s: "😀" }] },
{
name: "pattern-class-dash",
schema: Type.Object({ s: Type.String({ pattern: "^[\\w-]+$" }) }),
args: [{ s: "a-b" }],
},
{
name: "pattern-unicode-prop",
schema: Type.Object({ s: Type.String({ pattern: "^\\p{L}+$" }) }),
args: [{ s: "äb" }, { s: "p{L}" }],
},
{
name: "multipleOf-float",
schema: Type.Object({ n: Type.Number({ multipleOf: 0.01 }) }),
args: [{ n: 0.07 }, { n: 1.1 }],
},
{
name: "uniqueItems-keyorder",
schema: Type.Object({ a: Type.Array(Type.Unknown(), { uniqueItems: true }) }),
args: [
{
a: [
{ x: 1, y: 2 },
{ y: 2, x: 1 },
],
},
{ a: [1, 1.0] },
],
},
{ name: "const-object", schema: Type.Object({ c: Type.Literal("a") }), args: [{ c: "a" }] },
{
name: "formats",
schema: Type.Object({
e: Type.Optional(Type.String({ format: "email" })),
u: Type.Optional(Type.String({ format: "uri" })),
d: Type.Optional(Type.String({ format: "date-time" })),
x: Type.Optional(Type.String({ format: "made-up" })),
h: Type.Optional(Type.String({ format: "hostname" })),
r: Type.Optional(Type.String({ format: "regex" })),
i: Type.Optional(Type.String({ format: "uuid" })),
ip: Type.Optional(Type.String({ format: "ipv4" })),
}),
args: [
{ e: "a@b.co" },
{ e: "not-an-email" },
{ u: "relative/path" },
{ u: "https://x.y/z" },
{ d: "2024-01-01T00:00:00Z" },
{ d: "2024-01-01" },
{ x: "anything" },
{ h: "under_score.example" },
{ r: "[" },
{ i: "00000000-0000-0000-0000-000000000000" },
{ i: "not" },
{ ip: "999.1.1.1" },
],
},
{ name: "integer-big", schema: Type.Object({ n: Type.Integer() }), args: [{ n: 1e21 }, { n: 2 ** 53 + 2 }] },
{
name: "additional-false",
schema: Type.Object({ a: Type.String() }, { additionalProperties: false }),
args: [{ a: "x", b: 1 }],
},
{
name: "prototype-key",
schema: Type.Object({ constructor: Type.Optional(Type.String()), toString: Type.Optional(Type.Number()) }),
args: [{}, { constructor: 1 }],
},
{
name: "proto-key-value",
schema: Type.Object({}, { additionalProperties: Type.Array(Type.String()) }),
args: [JSON.parse('{"__proto__": "x", "a": "y"}')],
},
// Plain JSON (MCP-shaped)
{
name: "mcp-draft7-ref-definitions",
schema: {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: { t: { $ref: "#/definitions/T" } },
required: ["t"],
definitions: { T: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } },
},
args: [{ t: { path: "a" } }, { t: { path: 42 } }],
},
{
name: "mcp-ref-unresolved",
schema: { type: "object", properties: { t: { $ref: "#/definitions/Missing" } } },
args: [{ t: 1 }, {}],
},
{
name: "mcp-ref-external",
schema: { type: "object", properties: { t: { $ref: "https://example.com/schemas/t.json" } } },
args: [{ t: 1 }],
},
{
name: "mcp-nullable-openapi",
schema: { type: "object", properties: { s: { type: "string", nullable: true } }, required: ["s"] },
args: [{ s: null }, { s: "x" }],
},
{
name: "mcp-draft7-tuple",
schema: {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: { p: { type: "array", items: [{ type: "number" }, { type: "string" }], additionalItems: false } },
},
args: [{ p: [1, "a"] }, { p: [1, "a", 3] }, { p: ["1", 2] }],
},
{
name: "mcp-draft7-ref-sibling",
schema: {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: { s: { $ref: "#/definitions/S", maxLength: 1 } },
definitions: { S: { type: "string" } },
},
args: [{ s: "abc" }],
},
{
name: "mcp-draft4-exclusive-bool",
schema: {
$schema: "http://json-schema.org/draft-04/schema#",
type: "object",
properties: { n: { type: "number", minimum: 0, exclusiveMinimum: true } },
},
args: [{ n: 0 }, { n: 1 }],
},
{
name: "mcp-id-draft4",
schema: {
$schema: "http://json-schema.org/draft-04/schema#",
id: "http://x/y",
type: "object",
properties: { a: { type: "string" } },
},
args: [{ a: "x" }],
},
{
name: "mcp-bad-pattern",
schema: { type: "object", properties: { s: { type: "string", pattern: "(?<!x)y" } } },
args: [{ s: "y" }],
},
{
name: "mcp-invalid-regex",
schema: { type: "object", properties: { s: { type: "string", pattern: "[" } } },
args: [{ s: "y" }],
},
{
name: "mcp-type-integer-float-0",
schema: { type: "object", properties: { n: { type: "integer" } } },
args: [{ n: 1.0 }, { n: 1.5 }],
},
{
name: "mcp-format-unknown",
schema: { type: "object", properties: { s: { type: "string", format: "binary" } } },
args: [{ s: "xx" }],
},
{
name: "mcp-required-null-string",
schema: { type: "object", properties: { q: { type: "string" } }, required: ["q"] },
args: [{ q: null }],
},
{
name: "mcp-array-json-string",
schema: { type: "object", properties: { p: { type: "array", items: { type: "string" } } }, required: ["p"] },
args: [{ p: '["a","b"]' }],
},
{
name: "mcp-integer-float",
schema: { type: "object", properties: { n: { type: "integer" } }, required: ["n"] },
args: [{ n: 3.7 }, { n: "3.7" }],
},
{ name: "mcp-empty-schema", schema: {}, args: [{ a: 1 }] },
{ name: "mcp-type-object-no-props", schema: { type: "object" }, args: [{ a: 1 }] },
{
name: "mcp-boolean-true-prop",
schema: { type: "object", properties: { a: true, b: false } },
args: [{ a: 1 }, { b: 1 }],
},
{
name: "mcp-huge-enum",
schema: {
type: "object",
properties: { e: { type: "string", enum: Array.from({ length: 5000 }, (_, i) => `v${i}`) } },
},
args: [{ e: "v4999" }, { e: "v5000" }],
},
];
const o = (properties: Record<string, unknown>, extra: Record<string, unknown> = {}) => ({
type: "object",
properties,
...extra,
});
const keywordCases: [string, object, unknown[]][] = [
[
"oneOf-overlap",
o({ v: { oneOf: [{ type: "string" }, { type: "string", maxLength: 5 }] } }),
[{ v: "abc" }, { v: "abcdefgh" }],
],
["oneOf-number-integer", o({ v: { oneOf: [{ type: "number" }, { type: "integer" }] } }), [{ v: 2 }, { v: 2.5 }]],
[
"oneOf-objects-open",
o({
v: {
oneOf: [
{ type: "object", properties: { a: { type: "string" } } },
{ type: "object", properties: { b: { type: "string" } } },
],
},
}),
[{ v: { a: "x" } }, { v: {} }],
],
[
"if-then-else",
o(
{ kind: { type: "string" }, n: { type: "number" } },
{ if: { properties: { kind: { const: "a" } } }, then: { required: ["n"] } },
),
[{ kind: "a" }, { kind: "b" }],
],
[
"dependentRequired",
o({ a: { type: "string" }, b: { type: "string" } }, { dependentRequired: { a: ["b"] } }),
[{ a: "x" }],
],
[
"dependencies-d7",
o({ a: { type: "string" }, b: { type: "string" } }, { dependencies: { a: ["b"] } }),
[{ a: "x" }],
],
["propertyNames", o({}, { propertyNames: { pattern: "^[a-z]+$" } }), [{ abc: 1 }, { ABC: 1 }]],
["contains", o({ l: { type: "array", contains: { type: "number" } } }), [{ l: ["a"] }, { l: ["a", 1] }]],
["not", o({ s: { not: { type: "null" } } }), [{ s: "x" }, { s: null }]],
["minProperties", o({ m: { type: "object", minProperties: 1 } }), [{ m: {} }]],
[
"unevaluatedProperties",
o({ a: { type: "string" } }, { unevaluatedProperties: false }),
[{ a: "x" }, { a: "x", b: 1 }],
],
[
"allOf-additional-false",
{ allOf: [o({ a: { type: "string" } }, { additionalProperties: false }), o({ b: { type: "string" } })] },
[{ a: "x" }, { a: "x", b: "y" }],
],
["const-object", o({ c: { const: { x: 1, y: 2 } } }), [{ c: { y: 2, x: 1 } }]],
["enum-object", o({ e: { enum: [{ x: 1, y: 2 }] } }), [{ e: { y: 2, x: 1 } }]],
["type-array-multi", o({ v: { type: ["string", "number"] } }), [{ v: true }, { v: null }]],
["nullable-type-array", o({ v: { type: ["string", "null"] } }, { required: ["v"] }), [{ v: null }, { v: 5 }]],
["exclusiveMinimum-number", o({ n: { type: "number", exclusiveMinimum: 0 } }), [{ n: 0 }, { n: "0" }]],
["maxItems", o({ l: { type: "array", maxItems: 1 } }), [{ l: [1, 2] }, { l: "x" }]],
[
"discriminator-openapi",
o({
p: {
oneOf: [
o({ kind: { type: "string", enum: ["a"] }, x: { type: "number" } }, { required: ["kind"] }),
o({ kind: { type: "string", enum: ["b"] } }, { required: ["kind"] }),
],
discriminator: { propertyName: "kind" },
},
}),
[{ p: { kind: "a", x: "1" } }, { p: { kind: "b" } }],
],
[
"ref-root-defs",
{ $ref: "#/$defs/Root", $defs: { Root: o({ a: { type: "string" } }, { required: ["a"] }) } },
[{ a: "x" }, { a: 1 }, {}],
],
[
"ref-recursive",
{
$defs: { N: o({ c: { type: "array", items: { $ref: "#/$defs/N" } }, v: { type: "integer" } }) },
...o({ root: { $ref: "#/$defs/N" } }),
},
[{ root: { v: "2", c: [{ v: "3", c: [] }] } }],
],
[
"ref-percent",
{ ...o({ a: { $ref: "#/$defs/a%20b" } }), $defs: { "a b": { type: "string" } } },
[{ a: "x" }, { a: 1 }],
],
["ref-tilde", { ...o({ a: { $ref: "#/$defs/a~1b" } }), $defs: { "a/b": { type: "string" } } }, [{ a: "x" }]],
[
"defs-with-$id",
{ ...o({ a: { $ref: "item.json" } }), $defs: { item: { $id: "item.json", type: "string" } } },
[{ a: "x" }, { a: 2 }],
],
[
"anyOf-null-default",
o({ v: { anyOf: [{ type: "string" }, { type: "null" }], default: null } }),
[{ v: null }, {}],
],
[
"format-date-time-lower",
o({ d: { type: "string", format: "date-time" } }),
[{ d: "2024-01-01t00:00:00z" }, { d: "2024-01-01 00:00:00Z" }],
],
["format-url", o({ u: { type: "string", format: "url" } }), [{ u: "not a url" }]],
["integer-as-float-1e0", o({ n: { type: "integer" } }), [{ n: 1e3 }]],
["required-missing-props", { type: "object", required: ["a"] }, [{}, { a: null }]],
["string-to-number-trim", o({ n: { type: "number" } }), [{ n: " 12 " }, { n: "0x10" }, { n: "1e3" }, { n: "" }]],
];
const tree = {
type: "object",
properties: { t: { $ref: "#/$defs/Tree" } },
$defs: { Tree: { anyOf: [{ type: "string" }, { type: "array", items: { $ref: "#/$defs/Tree" } }] } },
};
const json = {
type: "object",
properties: { data: { $ref: "#/$defs/Json" } },
required: ["data"],
$defs: {
Json: {
anyOf: [
{ type: "string" },
{ type: "number" },
{ type: "boolean" },
{ type: "array", items: { $ref: "#/$defs/Json" } },
{ type: "object", additionalProperties: { $ref: "#/$defs/Json" } },
],
},
},
};
const filter = {
type: "object",
properties: { where: { $ref: "#/$defs/F" } },
$defs: {
F: {
oneOf: [
{
type: "object",
properties: { field: { type: "string" }, eq: { type: "string" } },
required: ["field", "eq"],
additionalProperties: false,
},
{
type: "object",
properties: { and: { type: "array", items: { $ref: "#/$defs/F" } } },
required: ["and"],
additionalProperties: false,
},
],
},
},
};
const recursionCases: [string, object, unknown][] = [
["tree valid", tree, { t: ["a", ["b"]] }],
["tree number", tree, { t: 5 }],
["tree [5]", tree, { t: [5] }],
["json valid", json, { data: { a: [1, "x", { b: true }] } }],
["json null", json, { data: null }],
["json nested null", json, { data: { a: [1, null] } }],
["filter valid", filter, { where: { and: [{ field: "a", eq: "b" }] } }],
["filter bad leaf", filter, { where: { and: [{ field: "a", eq: 1, x: 2 }] } }],
["filter deep bad", filter, { where: { and: [{ and: [{ and: [{ and: [{ and: [{ field: 1 }] }] }] }] }] } }],
];
const expected: Record<string, string> = {
'cyclic {"tree":{"name":"a","children":[{"name":"b","children":[]}]}}':
'ok {"tree":{"name":"a","children":[{"name":"b","children":[]}]}}',
'cyclic {"tree":{"name":1,"children":[]}}': 'ok {"tree":{"name":"1","children":[]}}',
'ref-with-$id {"a":{"x":"1"}}': "REJECT - a: unresolved reference A",
"function {}": "ok {}",
'function {"f":"x"}': "REJECT - f: expected function, got string",
'codec {"c":"12"}': 'ok {"c":"12"}',
'codec {"c":12}': 'ok {"c":"12"}',
"undefined-optional {}": "ok {}",
'undefined-optional {"u":null}': "ok {}",
'bigint {"n":1}': "REJECT - n: expected bigint, got integer",
'bigint {"n":"12"}': "REJECT - n: expected bigint, got string",
'refine {"n":5}': 'ok {"n":5}',
'refine {"n":-5}': 'ok {"n":-5}',
'template-literal {"t":"id-12"}': 'ok {"t":"id-12"}',
'template-literal {"t":"id-x"}': "REJECT - t: expected to match ^id--?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?$",
'template-literal {"t":3}': "REJECT - t: expected to match ^id--?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?$",
'record-string {"r":{"a":"1"}}': 'ok {"r":{"a":1}}',
'record-string {"r":{"a":1,"b":"x"}}': "REJECT - r.b: expected number, got string",
'record-literal-keys {"r":{"a":1,"b":2}}': 'ok {"r":{"a":1,"b":2}}',
'record-literal-keys {"r":{"a":1}}': "REJECT - r.b: missing",
'record-literal-keys {"r":{"a":1,"b":2,"c":3}}': 'ok {"r":{"a":1,"b":2,"c":3}}',
'intersect {"a":"1","b":"true"}': 'ok {"a":1,"b":true}',
'intersect {"a":1,"b":true,"c":1}': 'ok {"a":1,"b":true,"c":1}',
'union-objects {"v":{"k":"x","n":"2"}}': 'ok {"v":{"k":"x","n":2}}',
'union-objects {"v":{"k":"y","s":2}}': 'ok {"v":{"k":"y","s":"2"}}',
'minLength-astral {"s":"😀"}': "REJECT - s: expected at least 2 characters",
'maxLength-astral {"s":"😀"}': 'ok {"s":"😀"}',
'pattern-astral {"s":"😀"}': 'ok {"s":"😀"}',
'pattern-class-dash {"s":"a-b"}': 'ok {"s":"a-b"}',
'pattern-unicode-prop {"s":"äb"}': 'ok {"s":"äb"}',
'pattern-unicode-prop {"s":"p{L}"}': "REJECT - s: expected to match ^\\p{L}+$",
'multipleOf-float {"n":0.07}': 'ok {"n":0.07}',
'multipleOf-float {"n":1.1}': 'ok {"n":1.1}',
'uniqueItems-keyorder {"a":[{"x":1,"y":2},{"y":2,"x":1}]}': "REJECT - a: expected unique items",
'uniqueItems-keyorder {"a":[1,1]}': "REJECT - a: expected unique items",
'const-object {"c":"a"}': 'ok {"c":"a"}',
'formats {"e":"a@b.co"}': 'ok {"e":"a@b.co"}',
'formats {"e":"not-an-email"}': "REJECT - e: expected format email",
'formats {"u":"relative/path"}': "REJECT - u: expected format uri",
'formats {"u":"https://x.y/z"}': 'ok {"u":"https://x.y/z"}',
'formats {"d":"2024-01-01T00:00:00Z"}': 'ok {"d":"2024-01-01T00:00:00Z"}',
'formats {"d":"2024-01-01"}': "REJECT - d: expected format date-time",
'formats {"x":"anything"}': 'ok {"x":"anything"}',
'formats {"h":"under_score.example"}': "REJECT - h: expected format hostname",
'formats {"r":"["}': "REJECT - r: expected format regex",
'formats {"i":"00000000-0000-0000-0000-000000000000"}': 'ok {"i":"00000000-0000-0000-0000-000000000000"}',
'formats {"i":"not"}': "REJECT - i: expected format uuid",
'formats {"ip":"999.1.1.1"}': "REJECT - ip: expected format ipv4",
'integer-big {"n":1e+21}': 'ok {"n":1e+21}',
'integer-big {"n":9007199254740994}': 'ok {"n":9007199254740994}',
'additional-false {"a":"x","b":1}': "REJECT - b: unknown field",
"prototype-key {}": "ok {}",
'prototype-key {"constructor":1}': 'ok {"constructor":"1"}',
'proto-key-value {"__proto__":"x","a":"y"}': 'ok {"__proto__":["x"],"a":["y"]}',
'mcp-draft7-ref-definitions {"t":{"path":"a"}}': 'ok {"t":{"path":"a"}}',
'mcp-draft7-ref-definitions {"t":{"path":42}}': 'ok {"t":{"path":"42"}}',
'mcp-ref-unresolved {"t":1}': "REJECT - t: unresolved reference #/definitions/Missing",
"mcp-ref-unresolved {}": "ok {}",
'mcp-ref-external {"t":1}': "REJECT - t: unresolved reference https://example.com/schemas/t.json",
'mcp-nullable-openapi {"s":null}': 'ok {"s":"null"}',
'mcp-nullable-openapi {"s":"x"}': 'ok {"s":"x"}',
'mcp-draft7-tuple {"p":[1,"a"]}': 'ok {"p":[1,"a"]}',
'mcp-draft7-tuple {"p":[1,"a",3]}': "REJECT - p.2: no value is allowed here",
'mcp-draft7-tuple {"p":["1",2]}': 'ok {"p":[1,"2"]}',
'mcp-draft7-ref-sibling {"s":"abc"}': "REJECT - s: expected at most 1 characters",
'mcp-draft4-exclusive-bool {"n":0}': 'ok {"n":0}',
'mcp-draft4-exclusive-bool {"n":1}': 'ok {"n":1}',
'mcp-id-draft4 {"a":"x"}': 'ok {"a":"x"}',
'mcp-bad-pattern {"s":"y"}': 'ok {"s":"y"}',
'mcp-invalid-regex {"s":"y"}': "THROW Invalid regular expression: /[/: Unterminated character class",
'mcp-type-integer-float-0 {"n":1}': 'ok {"n":1}',
'mcp-type-integer-float-0 {"n":1.5}': 'ok {"n":1}',
'mcp-format-unknown {"s":"xx"}': 'ok {"s":"xx"}',
'mcp-required-null-string {"q":null}': 'ok {"q":"null"}',
'mcp-array-json-string {"p":"[\\"a\\",\\"b\\"]"}': 'ok {"p":["[\\"a\\",\\"b\\"]"]}',
'mcp-integer-float {"n":3.7}': 'ok {"n":3}',
'mcp-integer-float {"n":"3.7"}': 'ok {"n":3}',
'mcp-empty-schema {"a":1}': 'ok {"a":1}',
'mcp-type-object-no-props {"a":1}': 'ok {"a":1}',
'mcp-boolean-true-prop {"a":1}': 'ok {"a":1}',
'mcp-boolean-true-prop {"b":1}': "REJECT - b: no value is allowed here",
'mcp-huge-enum {"e":"v4999"}': 'ok {"e":"v4999"}',
'mcp-huge-enum {"e":"v5000"}':
'REJECT - e: expected one of ["v0","v1","v2","v3","v4","v5","v6","v7","v8","v9","v10","v11","v12","v13","v14","v15","v16","v17","v18","v19", ... and 4980 more]',
'oneOf-overlap {"v":"abc"}': "REJECT - v: matches more than one alternative",
'oneOf-overlap {"v":"abcdefgh"}': 'ok {"v":"abcdefgh"}',
'oneOf-number-integer {"v":2}': "REJECT - v: matches more than one alternative",
'oneOf-number-integer {"v":2.5}': 'ok {"v":2.5}',
'oneOf-objects-open {"v":{"a":"x"}}': "REJECT - v: matches more than one alternative",
'oneOf-objects-open {"v":{}}': "REJECT - v: matches more than one alternative",
'if-then-else {"kind":"a"}': "REJECT - n: missing",
'if-then-else {"kind":"b"}': 'ok {"kind":"b"}',
'dependentRequired {"a":"x"}': "REJECT - b: required when a is present",
'dependencies-d7 {"a":"x"}': "REJECT - b: required when a is present",
'propertyNames {"abc":1}': 'ok {"abc":1}',
'propertyNames {"ABC":1}': "REJECT - ABC: invalid property name",
'contains {"l":["a"]}': "REJECT - l: expected at least 1 matching item",
'contains {"l":["a",1]}': 'ok {"l":["a",1]}',
'not {"s":"x"}': 'ok {"s":"x"}',
'not {"s":null}': "ok {}",
'minProperties {"m":{}}': "REJECT - m: expected at least 1 properties",
'unevaluatedProperties {"a":"x"}': 'ok {"a":"x"}',
'unevaluatedProperties {"a":"x","b":1}': "REJECT - b: unknown field",
'allOf-additional-false {"a":"x"}': 'ok {"a":"x"}',
'allOf-additional-false {"a":"x","b":"y"}': "REJECT - b: unknown field",
'const-object {"c":{"y":2,"x":1}}': 'ok {"c":{"y":2,"x":1}}',
'enum-object {"e":{"y":2,"x":1}}': 'ok {"e":{"y":2,"x":1}}',
'type-array-multi {"v":true}': 'ok {"v":"true"}',
'type-array-multi {"v":null}': "ok {}",
'nullable-type-array {"v":null}': 'ok {"v":null}',
'nullable-type-array {"v":5}': 'ok {"v":"5"}',
'exclusiveMinimum-number {"n":0}': "REJECT - n: expected > 0",
'exclusiveMinimum-number {"n":"0"}': "REJECT - n: expected > 0",
'maxItems {"l":[1,2]}': "REJECT - l: expected at most 1 items",
'maxItems {"l":"x"}': 'ok {"l":["x"]}',
'discriminator-openapi {"p":{"kind":"a","x":"1"}}': 'ok {"p":{"kind":"a","x":1}}',
'discriminator-openapi {"p":{"kind":"b"}}': 'ok {"p":{"kind":"b"}}',
'ref-root-defs {"a":"x"}': 'ok {"a":"x"}',
'ref-root-defs {"a":1}': 'ok {"a":"1"}',
"ref-root-defs {}": "REJECT - a: missing",
'ref-recursive {"root":{"v":"2","c":[{"v":"3","c":[]}]}}': 'ok {"root":{"v":2,"c":[{"v":3,"c":[]}]}}',
'ref-percent {"a":"x"}': 'ok {"a":"x"}',
'ref-percent {"a":1}': 'ok {"a":"1"}',
'ref-tilde {"a":"x"}': 'ok {"a":"x"}',
'defs-with-$id {"a":"x"}': 'ok {"a":"x"}',
'defs-with-$id {"a":2}': 'ok {"a":"2"}',
'anyOf-null-default {"v":null}': 'ok {"v":null}',
"anyOf-null-default {}": "ok {}",
'format-date-time-lower {"d":"2024-01-01t00:00:00z"}': 'ok {"d":"2024-01-01t00:00:00z"}',
'format-date-time-lower {"d":"2024-01-01 00:00:00Z"}': "REJECT - d: expected format date-time",
'format-url {"u":"not a url"}': "REJECT - u: expected format url",
'integer-as-float-1e0 {"n":1000}': 'ok {"n":1000}',
"required-missing-props {}": "REJECT - a: missing",
'required-missing-props {"a":null}': 'ok {"a":null}',
'string-to-number-trim {"n":" 12 "}': 'ok {"n":12}',
'string-to-number-trim {"n":"0x10"}': 'ok {"n":16}',
'string-to-number-trim {"n":"1e3"}': 'ok {"n":1000}',
'string-to-number-trim {"n":""}': 'ok {"n":0}',
"tree valid": 'ok {"t":["a",["b"]]}',
"tree number": 'ok {"t":"5"}',
"tree [5]": 'ok {"t":["5"]}',
"json valid": 'ok {"data":{"a":[1,"x",{"b":true}]}}',
"json null": 'ok {"data":"null"}',
"json nested null": 'ok {"data":{"a":[1,"null"]}}',
"filter valid": 'ok {"where":{"and":[{"field":"a","eq":"b"}]}}',
"filter bad leaf": "REJECT - where.and.0.eq: expected string, got integer",
"filter deep bad": "REJECT - where.and.0.and.0.and.0.and.0.and.0.eq: missing",
};
/** Every case with the key of its expected outcome: the name and the arguments, or the name alone for recursion. */
const all: Array<[string, string, object, unknown]> = [
...cases.flatMap(({ name, schema, args }) =>
args.map((arg): [string, string, object, unknown] => [`${name} ${JSON.stringify(arg)}`, name, schema, arg]),
),
...keywordCases.flatMap(([name, schema, args]) =>
args.map((arg): [string, string, object, unknown] => [`${name} ${JSON.stringify(arg)}`, name, schema, arg]),
),
...recursionCases.map(([name, schema, arg]): [string, string, object, unknown] => [name, name, schema, arg]),
];
describe("reviewer probes", () => {
for (const [key, name, schema, arg] of all) {
it(key, () => {
expect(outcome(name, schema, arg)).toBe(expected[key]);
});
}
});
describe("reviewer probes (Sol)", () => {
const tree = {
type: "object",
properties: { t: { $ref: "#/$defs/Tree" } },
$defs: { Tree: { anyOf: [{ type: "string" }, { type: "array", items: { $ref: "#/$defs/Tree" } }] } },
};
const solCases: Array<[string, object, unknown, string]> = [
// TypeBox-only: Type.BigInt and Type.Undefined have no JSON form; TypeBox converted to them.
[
"BigInt from a JSON string",
Type.Object({ n: Type.BigInt() }),
{ n: "42" },
"REJECT - n: expected bigint, got string",
],
[
"Undefined from JSON null",
Type.Object({ n: Type.Undefined() }),
{ n: null },
"REJECT - n: expected undefined, got null",
],
// TypeBox-only: its additionalProperties also converted declared properties; pi-schema converts only undeclared ones.
[
"additionalProperties on declared properties",
Type.Object({ amount: Type.Number(), label: Type.String() }, { additionalProperties: Type.Integer() }),
{ amount: 3.5, label: "7" },
'ok {"amount":3.5,"label":"7"}',
],
// TypeBox-only: property names are names, not regular expressions.
[
"property names are not patterns",
Type.Object({ "a.b": Type.Integer(), axb: Type.String() }),
{ "a.b": "4", axb: "5.8" },
'ok {"a.b":4,"axb":"5.8"}',
],
// TypeBox-only: Refine runs only in TypeBox's own validator.
[
"Refine is not run",
Type.Object({ mode: Type.Refine(Type.String(), (value) => value === "safe") }),
{ mode: "unsafe" },
'ok {"mode":"unsafe"}',
],
[
"scalar-or-array that refers to itself",
{
type: "object",
properties: {
value: { anyOf: [{ type: "number" }, { type: "array", items: { $ref: "#/properties/value" } }] },
},
required: ["value"],
},
{ value: "42" },
'ok {"value":42}',
],
[
"StringEnum with an empty member from null",
Type.Object({ mode: Type.Unsafe({ type: "string", enum: ["", "x"] }) }),
{ mode: null },
'ok {"mode":""}',
],
[
"Cyclic with named local definitions",
Type.Object({
node: Type.Cyclic(
{ Node: Type.Object({ n: Type.Number(), next: Type.Optional(Type.Ref("Node")) }) },
"Node",
),
}),
{ node: { n: 1 } },
'ok {"node":{"n":1}}',
],
[
"unpaired high surrogate, minLength 2",
Type.Object({ s: Type.String({ minLength: 2 }) }),
{ s: "\ud800A" },
'ok {"s":"\\ud800A"}',
],
[
"unpaired high surrogate, maxLength 1",
Type.Object({ s: Type.String({ maxLength: 1 }) }),
{ s: "\ud800A" },
"REJECT - s: expected at most 1 characters",
],
["integer from a string", Type.Object({ n: Type.Integer() }), { n: "42" }, 'ok {"n":42}'],
["recursive union with a value no branch converts", tree, { t: {} }, "REJECT - t: expected string, got object"],
];
for (const [name, schema, arg, result] of solCases) {
it(name, () => {
expect(outcome(name, schema, arg)).toBe(result);
});
}
it("accepts the last member of a 10,000-value enum", () => {
const values = Array.from({ length: 10_000 }, (_, i) => `member${i}`);
const schema = { type: "object", properties: { choice: { type: "string", enum: values } }, required: ["choice"] };
expect(outcome("enum", schema, { choice: "member9999" })).toBe('ok {"choice":"member9999"}');
expect(outcome("enum", schema, { choice: "x" })).toBe(
`REJECT - choice: expected one of ${JSON.stringify(values.slice(0, 20)).slice(0, -1)}, ... and 9980 more]`,
);
});
it("keeps the validator of a schema object, as before, also when the schema is changed later", () => {
const parameters = Type.Object({ n: Type.Number({ minimum: 0 }) });
expect(outcome("late", parameters, { n: "4" })).toBe('ok {"n":4}');
Object.assign(parameters.properties.n, { minimum: 10 });
expect(outcome("late", parameters, { n: "4" })).toBe('ok {"n":4}');
});
});
describe("reviewer probes (round 2)", () => {
const item = { type: "object", properties: { n: { type: "integer", minimum: 1 } } };
const nested: Case[] = [
{ name: "declared", schema: { type: "object", properties: { entry: item } }, args: [{ entry: { n: null } }] },
{ name: "additional", schema: { type: "object", additionalProperties: item }, args: [{ entry: { n: null } }] },
{
name: "pattern",
schema: { type: "object", patternProperties: { "^entry$": item }, additionalProperties: false },
args: [{ entry: { n: null } }],
},
];
for (const { name, schema, args } of nested) {
it(`drops an optional null inside ${name} property values`, () => {
expect(outcome(name, schema, args[0])).toBe('ok {"entry":{}}');
});
}
for (const keyword of ["anyOf", "oneOf"]) {
it(`converts a ${keyword} branch together with the node's sibling properties`, () => {
const schema = {
type: "object",
[keyword]: [
{ type: "object", properties: { a: { type: "array", items: { type: "integer" } } }, required: ["a"] },
{ type: "object", properties: { b: { type: "boolean" } }, required: ["b"] },
],
properties: { c: { type: "integer" } },
required: ["c"],
};
expect(outcome(keyword, schema, { a: "3", c: "2" })).toBe('ok {"a":[3],"c":2}');
});
}
it("rejects a deep value every branch of a recursive object union rejects, without exponential work", () => {
const S = {
anyOf: [
{ type: "object", additionalProperties: { $ref: "#/$defs/S" }, required: ["a"] },
{ type: "object", additionalProperties: { $ref: "#/$defs/S" }, required: ["b"] },
],
};
const schema = { type: "object", properties: { s: { $ref: "#/$defs/S" } }, $defs: { S } };
let value: unknown = { x: 1 };
for (let i = 0; i < 40; i++) value = { c: value };
expect(outcome("deep", schema, { s: value })).toBe("REJECT - s.a: missing");
});
});

View file

@ -1,5 +1,4 @@
import { Type } from "typebox";
import { Compile } from "typebox/compile";
import { describe, expect, it } from "vitest";
import type { JsonValue, Tool, ToolCall } from "../src/types.ts";
import { validateToolArguments } from "../src/utils/validation.ts";
@ -61,7 +60,7 @@ describe("validateToolArguments", () => {
}
});
it("coerces serialized plain JSON schemas with AJV-compatible primitive rules", () => {
it("coerces serialized plain JSON schemas like TypeBox schemas", () => {
const passingCases: Array<{
schema: Tool["parameters"];
input: unknown;
@ -75,7 +74,8 @@ describe("validateToolArguments", () => {
{ schema: { type: "boolean" } as Tool["parameters"], input: "false", expected: false },
{ schema: { type: "boolean" } as Tool["parameters"], input: 1, expected: true },
{ schema: { type: "boolean" } as Tool["parameters"], input: 0, expected: false },
{ schema: { type: "string" } as Tool["parameters"], input: null, expected: "" },
// TypeBox's conversion rules come first: null is "null" for a required string.
{ schema: { type: "string" } as Tool["parameters"], input: null, expected: "null" },
{ schema: { type: "string" } as Tool["parameters"], input: true, expected: "true" },
{ schema: { type: "null" } as Tool["parameters"], input: "", expected: null },
{ schema: { type: "null" } as Tool["parameters"], input: 0, expected: null },
@ -90,6 +90,11 @@ describe("validateToolArguments", () => {
input: "1",
expected: 1,
},
{ schema: { type: "boolean" } as Tool["parameters"], input: "1", expected: true },
{ schema: { type: "boolean" } as Tool["parameters"], input: "0", expected: false },
{ schema: { type: "null" } as Tool["parameters"], input: "null", expected: null },
{ schema: { type: "integer" } as Tool["parameters"], input: "42.1", expected: 42 },
{ schema: { type: "array", items: { type: "number" } } as Tool["parameters"], input: "7", expected: [7] },
];
for (const testCase of passingCases) {
@ -184,10 +189,6 @@ describe("validateToolArguments", () => {
{ type: ["array", "null"], items: { type: "string" } } as Tool["parameters"],
null,
);
// The CSP test above selects TypeBox's process-wide interpreted fallback, so exercise the generated validator explicitly.
const generatedCheck = new Function(Compile(tool.parameters).Code())() as (value: unknown) => boolean;
expect(generatedCheck(toolCall.arguments)).toBe(true);
expect(validateToolArguments(tool, toolCall)).toEqual({ value: null });
});
@ -196,10 +197,10 @@ describe("validateToolArguments", () => {
schema: Tool["parameters"];
input: unknown;
}> = [
{ schema: { type: "boolean" } as Tool["parameters"], input: "1" },
{ schema: { type: "boolean" } as Tool["parameters"], input: "0" },
{ schema: { type: "null" } as Tool["parameters"], input: "null" },
{ schema: { type: "integer" } as Tool["parameters"], input: "42.1" },
{ schema: { type: "boolean" } as Tool["parameters"], input: "yes" },
{ schema: { type: "number" } as Tool["parameters"], input: "abc" },
{ schema: { type: "null" } as Tool["parameters"], input: "nil" },
{ schema: { type: "integer" } as Tool["parameters"], input: "4x" },
];
for (const testCase of failingCases) {
@ -207,6 +208,25 @@ describe("validateToolArguments", () => {
expect(() => validateToolArguments(tool, toolCall)).toThrow("Validation failed");
}
});
it("lists pi-schema's errors, then the arguments it received", () => {
const { tool, toolCall } = createToolCallWithPlainSchema({ type: "number" } as Tool["parameters"], "many");
expect(() => validateToolArguments(tool, toolCall)).toThrow(
'Validation failed for tool "echo":\n - value: expected number, got string\n\nReceived arguments:\n{\n "value": "many"\n}',
);
});
it("reports a missing required property at the property", () => {
const { tool, toolCall } = createToolCallWithPlainSchema({ type: "string" } as Tool["parameters"], "x");
expect(() => validateToolArguments(tool, { ...toolCall, arguments: {} })).toThrow(" - value: missing");
});
it("returns a copy of the arguments, also when nothing is coerced", () => {
const { tool, toolCall } = createToolCallWithPlainSchema({ type: "string" } as Tool["parameters"], "x");
const args = validateToolArguments(tool, toolCall);
expect(args).toEqual({ value: "x" });
expect(args).not.toBe(toolCall.arguments);
});
// #10897
it("reports malformed arguments JSON instead of validating empty arguments", () => {
const tool: Tool = {

View file

@ -13,5 +13,8 @@ export default defineConfig({
},
resolve: {
alias: [{ find: /^@earendil-works\/pi-telemetry$/, replacement: telemetrySrcIndex }],
// Workspace packages without a build (pi-schema) resolve to their source.
conditions: ["source"],
},
ssr: { resolve: { conditions: ["source"] } },
});

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Changed
- README: the codemode tool example builds its parameters with `@earendil-works/pi-schema` instead of TypeBox.
## [1.4.0] - 2026-10-21
## [1.3.0] - 2026-10-15

View file

@ -275,7 +275,7 @@ import {
type CodemodeTool,
renderDeclarations,
} from "@earendil-works/pi-codemode";
import { Type } from "typebox";
import { obj, str } from "@earendil-works/pi-schema";
const sandboxTools: CodemodeTool[] = agentTools.map((tool) => ({
name: tool.name,
@ -293,7 +293,7 @@ const codemodeTool: AgentTool = {
name: "codemode",
label: "Codemode",
description: `Run JavaScript that calls tools as \`await tools.<name>(args)\`. Output with text() or return.\n\n${renderDeclarations({ tools: sandboxTools })}`,
parameters: Type.Object({ code: Type.String() }),
parameters: obj({ code: str() }),
execute: async (_toolCallId, { code }, signal) => {
const sandbox = new CodemodeSandbox({ tools: sandboxTools });
try {

View file

@ -12,6 +12,8 @@
### Changed
- Tool argument validation runs on `@earendil-works/pi-schema`. TypeBox features outside JSON Schema (`Type.Refine()`, `Type.BigInt()`, `Type.Undefined()`, codecs, `Format.Set()` formats) no longer apply to tool arguments; see [Tool parameter schemas and TypeBox](docs/extensions.md#tool-parameter-schemas-and-typebox). Validation failures the model sees list pi-schema's messages (`- path: missing`, `- limit: expected number, got string`) instead of TypeBox's (`must have required properties path`, `must be number`); which arguments are accepted and how they are coerced is unchanged for extension and built-in tools.
- Extensions keep importing `Type`, `Static` and `TSchema` from `@earendil-works/pi-ai`: Pi maps that import to pi-ai's compat entry plus the TypeBox it bundles, now that pi-ai itself no longer re-exports TypeBox, and its `StringEnum` there stays a TypeBox `TUnsafe<T>`. The built-in tool factories (`createReadTool()` and the others) return `TypeBoxAgentTool<typeof schema>`, which keeps `params` typed for TypeBox.
- Tightened system prompt
- `pi update` for installs from the pi.dev installer no longer passes `--min-release-age=0` to `npm ci`. The release lockfile pins every package, so the flag had no effect, and security scanners that block it stopped the update ([#11262](https://github.com/earendil-works/pi/issues/11262))
- `/model` and `/scoped-models` now show a model's name next to its ID when the name adds words the ID lacks, such as a renamed `models.json` override ([#5916](https://github.com/earendil-works/pi/issues/5916), [#11347](https://github.com/earendil-works/pi/pull/11347))

View file

@ -159,7 +159,7 @@ Settings also accept built-in extensions as `builtin:<name>` entries; see [Setti
| `@earendil-works/pi-coding-agent` | Extension types (`ExtensionAPI`, `ExtensionContext`, events) and helpers |
| `@earendil-works/pi-agent-core` | Agent message and tool types |
| `typebox`, `typebox/compile`, `typebox/value` | Schema definitions for tool parameters |
| `@earendil-works/pi-ai` | AI utilities (`StringEnum` for string enums, transcript helpers, model types) |
| `@earendil-works/pi-ai` | AI utilities (`StringEnum` for string enums, transcript helpers, model types). Inside Pi it also exports TypeBox's `Type`, `Static` and `TSchema`, which the published package no longer re-exports; import them from `typebox` in new code |
| `@earendil-works/pi-ai/compat`, `/models`, `/oauth`, `/providers/all` | Provider construction, built-in APIs, and OAuth helpers |
| `@earendil-works/pi-tui` | TUI components for custom rendering |
@ -169,6 +169,18 @@ npm dependencies work too. Add a `package.json` next to your extension (or in a
For distributed Pi packages installed with `pi install` (npm or git), runtime deps must be in `dependencies`; `devDependencies` are not installed. Do not list the host-provided packages above in `dependencies`. See [Declare dependencies](packages.md#declare-dependencies).
### Tool parameter schemas and TypeBox
Pi validates tool arguments as JSON Schema with `@earendil-works/pi-schema`, whatever built the schema; TypeBox schemas are JSON Schema and work unchanged. Before validation, loosely typed arguments are coerced: numeric strings to numbers, `"true"` to `true`, a single value to a one-item array, `null` in an optional property dropped. TypeBox features outside JSON Schema do not apply to tool arguments:
- `Type.Refine()` refinements are not run. Check such constraints in `execute()`.
- `Type.BigInt()`, `Type.Undefined()` and `Type.Void()` reject every argument, since JSON has no such values. Codecs (`Type.Codec()`) do not decode: `execute()` receives the JSON value.
- Formats registered with TypeBox's `Format.Set()` are not checked. The standard formats are, plus `url` and `json-pointer-uri-fragment`.
- Property names are matched as names, not as regular expressions, and `additionalProperties` applies only to properties the object does not declare.
- A `$ref` that names nothing rejects only arguments at its place.
Validation errors the model sees use pi-schema's messages, such as `- path: missing` or `- limit: expected number, got string`.
Node.js built-ins (`node:fs`, `node:path`, etc.) are also available.
## Writing an Extension

View file

@ -0,0 +1,180 @@
// Type-checks the example extensions against the modules extensions see at run time (core/extensions/virtual-modules.ts):
// "@earendil-works/pi-ai" is the extension view, which adds TypeBox's Type, Static and TSchema to the compat entry (the
// view imports "@earendil-works/pi-ai/compat", so that path stays the package's own).
// The other paths repeat the root tsconfig.json; keep them in sync.
// Also the test that imports the subagent example: it type-checks only against the extension view.
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"paths": {
"@earendil-works/chord": [
"../../../packages/chord/src/index.ts"
],
"@earendil-works/chord/context": [
"../../../packages/chord/src/context/index.ts"
],
"@earendil-works/chord/delta": [
"../../../packages/chord/src/delta/index.ts"
],
"@earendil-works/chord/next": [
"../../../packages/chord/src/next/index.ts"
],
"@earendil-works/chord/next/plugins": [
"../../../packages/chord/src/next-node/plugins/index.ts"
],
"@earendil-works/chord/next/plugins/runtime": [
"../../../packages/chord/src/next-node/plugins/runtime.ts"
],
"@test/contracts/*": [
"../../../packages/chord/test/next-node/fixtures/plugins/contracts/*.ts"
],
"@example/contracts/*": [
"../../../packages/chord/test/next/examples/35-plugins/plugins/contracts/*.ts"
],
"@example/weather/*": [
"../../../packages/chord/test/next/examples/36-plugin-failures/plugins/weather/*.ts"
],
"@example/prices/*": [
"../../../packages/chord/test/next/examples/36-plugin-failures/plugins/prices/*.ts"
],
"@example/shapes/*": [
"../../../packages/chord/test/next/examples/37-plugin-imports/plugins/shapes/*.ts"
],
"@example/shared/*": [
"../../../packages/chord/test/next/examples/38-watch-and-rollout/plugins/contracts/*.ts"
],
"@earendil-works/chord/next/http": [
"../../../packages/chord/src/next-node/http/index.ts"
],
"@earendil-works/chord/next/workers": [
"../../../packages/chord/src/next-node/workers/index.ts"
],
"@earendil-works/chord/node": [
"../../../packages/chord/src/node.ts"
],
"@earendil-works/chord/output": [
"../../../packages/chord/src/output.ts"
],
"@earendil-works/chord/telemetry-sqlite": [
"../../../packages/chord/src/telemetry-sqlite/index.ts"
],
"@earendil-works/chord/sqlite": [
"../../../packages/chord/src/sqlite/index.ts"
],
"@earendil-works/chord/sqlite/node": [
"../../../packages/chord/src/sqlite/node.ts"
],
"@earendil-works/chord/sqlite/cloudflare": [
"../../../packages/chord/src/sqlite/cloudflare.ts"
],
"@earendil-works/pi-telemetry": [
"../../../packages/telemetry/src/index.ts"
],
"@earendil-works/pi-telemetry/testing": [
"../../../packages/telemetry/src/testing/index.ts"
],
"@earendil-works/pi-telemetry/render": [
"../../../packages/telemetry/src/render.ts"
],
"@earendil-works/pi-codemode": [
"../../../packages/codemode/src/index.ts"
],
"@earendil-works/pi-codemode/cloudflare": [
"../../../packages/codemode/src/cloudflare.ts"
],
"@earendil-works/pi-codemode/declarations": [
"../../../packages/codemode/src/declarations.ts"
],
"@earendil-works/pi-codemode/portable": [
"../../../packages/codemode/src/portable.ts"
],
"@earendil-works/pi-codemode/source": [
"../../../packages/codemode/src/source.ts"
],
"@earendil-works/pi-codemode/worker": [
"../../../packages/codemode/src/runtime/worker.ts"
],
"@earendil-works/pi-schema": [
"../../../packages/schema/src/index.ts"
],
"@earendil-works/pi-schema/formats": [
"../../../packages/schema/src/formats/index.ts"
],
"@earendil-works/pi-mcp": [
"../../../packages/mcp/src/index.ts"
],
"@earendil-works/pi-mcp/oauth": [
"../../../packages/mcp/src/oauth/index.ts"
],
"@earendil-works/pi-mcp/testing": [
"../../../packages/mcp/src/testing/index.ts"
],
"@earendil-works/pi-ai": [
"../../../packages/coding-agent/src/core/extensions/pi-ai-extension-view.ts"
],
"@earendil-works/pi-ai/oauth": [
"../../../packages/ai/src/oauth.ts"
],
"@earendil-works/pi-ai/*": [
"../../../packages/ai/src/*.ts",
"../../../packages/ai/src/providers/*.ts"
],
"@earendil-works/pi-ai/dist/*": [
"../../../packages/ai/src/*"
],
"@earendil-works/pi-durable": [
"../../../packages/durable/src/index.ts"
],
"@earendil-works/pi-durable/testing": [
"../../../packages/durable/src/testing/index.ts"
],
"@earendil-works/pi-durable/*": [
"../../../packages/durable/src/*.ts",
"../../../packages/durable/src/*/index.ts",
"../../../packages/durable/src/*"
],
"@earendil-works/pi-env": [
"../../../packages/env/src/index.ts"
],
"@earendil-works/pi-agent-core": [
"../../../packages/agent/src/index.ts"
],
"@earendil-works/pi-coding-agent": [
"../../../packages/coding-agent/src/index.ts"
],
"@earendil-works/pi-coding-agent/hooks": [
"../../../packages/coding-agent/src/core/hooks/index.ts"
],
"@earendil-works/pi-coding-agent/*": [
"../../../packages/coding-agent/src/*"
],
"typebox": [
"../../../node_modules/typebox"
],
"@earendil-works/pi-tui": [
"../../../packages/tui/src/index.ts"
],
"@earendil-works/pi-tui/*": [
"../../../packages/tui/src/*"
],
"@earendil-works/pi-agent-old": [
"../../../packages/agent-old/src/index.ts"
],
"@earendil-works/pi-agent-old/*": [
"../../../packages/agent-old/src/*"
],
"@mariozechner/pi-ai": [
"../../../packages/coding-agent/src/core/extensions/pi-ai-extension-view.ts"
]
}
},
"include": [
"./extensions/**/*",
"../../*/src/**/*.d.ts",
"../test/suite/regressions/8261-subagent-project-trust.test.ts"
],
"exclude": [
"**/node_modules/**",
"./extensions/gondolin/**"
]
}

View file

@ -391,7 +391,7 @@
"license": "MIT",
"dependencies": {
"@earendil-works/pi-ai": "1.4.0",
"typebox": "1.3.27"
"@earendil-works/pi-schema": "1.4.0"
},
"engines": {
"node": ">=22.19.0"
@ -404,14 +404,14 @@
"dependencies": {
"@anthropic-ai/sdk": "0.129.0",
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
"@earendil-works/pi-schema": "1.4.0",
"@earendil-works/pi-telemetry": "1.4.0",
"@google/genai": "2.21.0",
"@smithy/node-http-handler": "4.12.1",
"http-proxy-agent": "9.1.0",
"https-proxy-agent": "9.1.0",
"openai": "7.19.0",
"partial-json": "0.1.7",
"typebox": "1.3.27"
"partial-json": "0.1.7"
},
"bin": {
"pi-ai": "dist/cli.js"
@ -537,6 +537,14 @@
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-schema": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-schema/-/pi-schema-1.4.0.tgz",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@earendil-works/pi-telemetry": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.4.0.tgz",

View file

@ -255,13 +255,6 @@
"type": "array",
"items": {
"type": "object",
"required": [
"inputTokensAbove",
"input",
"output",
"cacheRead",
"cacheWrite"
],
"properties": {
"inputTokensAbove": {
"type": "number",
@ -283,7 +276,14 @@
"type": "number",
"description": "Cache-write cost in USD per million tokens."
}
}
},
"required": [
"inputTokensAbove",
"input",
"output",
"cacheRead",
"cacheWrite"
]
},
"description": "Request-wide pricing tiers. The highest matching input threshold applies to the full request."
}
@ -383,12 +383,6 @@
"$defs": {
"ModelCost": {
"type": "object",
"required": [
"input",
"output",
"cacheRead",
"cacheWrite"
],
"properties": {
"input": {
"type": "number",
@ -410,13 +404,6 @@
"type": "array",
"items": {
"type": "object",
"required": [
"inputTokensAbove",
"input",
"output",
"cacheRead",
"cacheWrite"
],
"properties": {
"inputTokensAbove": {
"type": "number",
@ -438,11 +425,24 @@
"type": "number",
"description": "Cache-write cost in USD per million tokens."
}
}
},
"required": [
"inputTokensAbove",
"input",
"output",
"cacheRead",
"cacheWrite"
]
},
"description": "Request-wide pricing tiers. The highest matching input threshold applies to the full request."
}
}
},
"required": [
"input",
"output",
"cacheRead",
"cacheWrite"
]
},
"ModelInputLimits": {
"type": "object",
@ -628,9 +628,6 @@
},
{
"type": "object",
"required": [
"$var"
],
"properties": {
"$var": {
"anyOf": [
@ -651,7 +648,10 @@
"omitWhenOff": {
"type": "boolean"
}
}
},
"required": [
"$var"
]
}
]
}
@ -677,9 +677,6 @@
},
{
"type": "object",
"required": [
"$var"
],
"properties": {
"$var": {
"anyOf": [
@ -700,7 +697,10 @@
"omitWhenOff": {
"type": "boolean"
}
}
},
"required": [
"$var"
]
}
]
}
@ -1085,11 +1085,6 @@
"type": "array",
"items": {
"type": "object",
"required": [
"provider",
"model",
"cost"
],
"properties": {
"provider": {
"type": "string",
@ -1103,6 +1098,11 @@
"$ref": "#/$defs/ModelCost"
}
},
"required": [
"provider",
"model",
"cost"
],
"description": "An Anthropic server-side refusal fallback model with local pricing metadata."
},
"maxItems": 3,

View file

@ -88,10 +88,11 @@ function getAliases(): Record<string, string> {
const piCodingAgentEntry = packageIndex;
const piAgentCoreEntry = resolveWorkspaceOrImport("agent/dist/index.js", "@earendil-works/pi-agent-core");
const piTuiEntry = resolveWorkspaceOrImport("tui/dist/index.js", "@earendil-works/pi-tui");
// Extensions resolve the pi-ai root to the compat entrypoint (a strict
// superset of the core entrypoint): existing extensions using the old
// global API keep working at runtime until compat is removed.
const piAiCompatEntry = resolveWorkspaceOrImport("ai/dist/compat.js", "@earendil-works/pi-ai/compat");
// Extensions resolve the pi-ai root to the extension view: the compat
// entrypoint (a strict superset of the core entrypoint) plus TypeBox's Type,
// Static and TSchema, so extensions using the old global API and pi-ai's
// former TypeBox re-exports keep working.
const piAiCompatEntry = path.resolve(__dirname, "pi-ai-extension-view.js");
const piAiModelsEntry = resolveWorkspaceOrImport("ai/dist/models.js", "@earendil-works/pi-ai/models");
const piAiOauthEntry = resolveWorkspaceOrImport("ai/dist/oauth.js", "@earendil-works/pi-ai/oauth");
const piAiProvidersEntry = resolveWorkspaceOrImport(

View file

@ -0,0 +1,28 @@
/**
* The `@earendil-works/pi-ai` module extensions import: pi-ai's compat entry point plus TypeBox's `Type`, `Static` and
* `TSchema`, which pi-ai's main entry no longer re-exports, and a `StringEnum` built with TypeBox (`TUnsafe<T>`), so
* extensions that use it inside `Type.Object` type-check and infer as before. The TypeBox is the coding agent's own,
* the same one extensions get for `typebox`.
*/
import { type TUnsafe, Type } from "typebox";
export * from "@earendil-works/pi-ai/compat";
export type { Static, TSchema } from "typebox";
export { Type };
/**
* Creates a string enum schema compatible with Google's API and other providers that don't support anyOf/const
* patterns: `{ type: "string", enum: [...] }`, typed for TypeBox's `Static`.
*/
export function StringEnum<T extends readonly string[]>(
values: T,
options?: { description?: string; default?: T[number] },
): TUnsafe<T[number]> {
return Type.Unsafe<T[number]>({
type: "string",
enum: values as unknown as T[number][],
...(options?.description && { description: options.description }),
...(options?.default && { default: options.default }),
});
}

View file

@ -1,5 +1,4 @@
import * as bundledPiAgentCore from "@earendil-works/pi-agent-core";
import * as bundledPiAiCompat from "@earendil-works/pi-ai/compat";
import * as bundledPiAiModels from "@earendil-works/pi-ai/models";
import * as bundledPiAiOauth from "@earendil-works/pi-ai/oauth";
import * as bundledPiAiProviders from "@earendil-works/pi-ai/providers/all";
@ -10,6 +9,7 @@ import * as bundledTypeboxValue from "typebox/value";
// This import is safe because loader.ts exports are not re-exported from index.ts.
// Extensions can therefore import from @earendil-works/pi-coding-agent.
import * as bundledPiCodingAgent from "../../index.ts";
import * as bundledPiAiExtensionView from "./pi-ai-extension-view.ts";
/** Modules available to extensions in source and compiled binary runtimes. */
export const VIRTUAL_MODULES: Record<string, unknown> = {
@ -22,18 +22,19 @@ export const VIRTUAL_MODULES: Record<string, unknown> = {
"@earendil-works/pi-agent-core": bundledPiAgentCore,
"@earendil-works/pi-tui": bundledPiTui,
// Extensions resolve the pi-ai root to the compat entrypoint (a strict
// superset of the core entrypoint): existing extensions using the old
// global API keep working at runtime until compat is removed.
"@earendil-works/pi-ai": bundledPiAiCompat,
"@earendil-works/pi-ai/compat": bundledPiAiCompat,
// superset of the core entrypoint) plus TypeBox's Type, Static and TSchema:
// existing extensions using the old global API and pi-ai's former TypeBox
// re-exports keep working.
"@earendil-works/pi-ai": bundledPiAiExtensionView,
"@earendil-works/pi-ai/compat": bundledPiAiExtensionView,
"@earendil-works/pi-ai/models": bundledPiAiModels,
"@earendil-works/pi-ai/oauth": bundledPiAiOauth,
"@earendil-works/pi-ai/providers/all": bundledPiAiProviders,
"@earendil-works/pi-coding-agent": bundledPiCodingAgent,
"@mariozechner/pi-agent-core": bundledPiAgentCore,
"@mariozechner/pi-tui": bundledPiTui,
"@mariozechner/pi-ai": bundledPiAiCompat,
"@mariozechner/pi-ai/compat": bundledPiAiCompat,
"@mariozechner/pi-ai": bundledPiAiExtensionView,
"@mariozechner/pi-ai/compat": bundledPiAiExtensionView,
"@mariozechner/pi-ai/models": bundledPiAiModels,
"@mariozechner/pi-ai/oauth": bundledPiAiOauth,
"@mariozechner/pi-ai/providers/all": bundledPiAiProviders,

View file

@ -1,20 +1,21 @@
/** Immutable, credential-blind models.json snapshot. */
import { readFile } from "node:fs/promises";
import { ProviderCompatSchema } from "@earendil-works/pi-ai/providers/compat-schema";
import {
ModelCostSchema,
ModelInputLimitsSchema,
ModelInputModalitySchema,
ModelPromptCacheSchema,
ThinkingLevelMapSchema,
} from "@earendil-works/pi-ai/providers/model-schema";
import { type Static, Type } from "typebox";
import { Compile } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error";
import { stripJsonComments } from "../utils/json.ts";
import { normalizePath } from "../utils/paths.ts";
import { stripBom } from "../utils/text.ts";
import {
ModelCostSchema,
ModelInputLimitsSchema,
ModelInputModalitySchema,
ModelPromptCacheSchema,
PartialModelCostSchema,
ProviderCompatSchema,
ThinkingLevelMapSchema,
} from "./pi-ai-schemas.ts";
const SamplingParamsSchema = Type.Record(Type.String(), Type.Unknown());
const SamplingParamsByThinkingLevelSchema = Type.Object(
@ -74,7 +75,7 @@ const ModelOverrideSchema = Type.Object({
thinkingLevelMap: Type.Optional(ThinkingLevelMapSchema),
input: Type.Optional(Type.Array(ModelInputModalitySchema)),
inputLimits: Type.Optional(ModelInputLimitsSchema),
cost: Type.Optional(Type.Partial(ModelCostSchema)),
cost: Type.Optional(PartialModelCostSchema),
promptCache: Type.Optional(ModelPromptCacheSchema),
contextWindow: Type.Optional(
Type.Number({ exclusiveMinimum: 0, description: ModelFieldDescriptions.contextWindow }),

View file

@ -0,0 +1,40 @@
/**
* pi-ai's configuration schemas (built with pi-schema) as TypeBox schemas, for the coding agent's own TypeBox-built
* configuration schemas (models.json, settings.json): the same JSON, typed for TypeBox's `Static`.
*/
import {
type ProviderCompat,
ProviderCompatSchema as ProviderCompatJsonSchema,
} from "@earendil-works/pi-ai/providers/compat-schema";
import {
type ModelCost,
ModelCostSchema as ModelCostJsonSchema,
type ModelInputLimits,
ModelInputLimitsSchema as ModelInputLimitsJsonSchema,
type ModelInputModality,
ModelInputModalitySchema as ModelInputModalityJsonSchema,
type ModelPromptCache,
ModelPromptCacheSchema as ModelPromptCacheJsonSchema,
type ModelThinkingLevel,
ModelThinkingLevelSchema as ModelThinkingLevelJsonSchema,
type ThinkingLevelMap,
ThinkingLevelMapSchema as ThinkingLevelMapJsonSchema,
} from "@earendil-works/pi-ai/providers/model-schema";
import { Type } from "typebox";
export const ProviderCompatSchema = Type.Unsafe<ProviderCompat>(ProviderCompatJsonSchema);
export const ModelCostSchema = Type.Unsafe<ModelCost>(ModelCostJsonSchema);
export const ModelInputLimitsSchema = Type.Unsafe<ModelInputLimits>(ModelInputLimitsJsonSchema);
export const ModelInputModalitySchema = Type.Unsafe<ModelInputModality>(ModelInputModalityJsonSchema);
export const ModelPromptCacheSchema = Type.Unsafe<ModelPromptCache>(ModelPromptCacheJsonSchema);
export const ModelThinkingLevelSchema = Type.Unsafe<ModelThinkingLevel>(ModelThinkingLevelJsonSchema);
export const ThinkingLevelMapSchema = Type.Unsafe<ThinkingLevelMap>(ThinkingLevelMapJsonSchema);
/** `ModelCostSchema` with every field optional, for model overrides that change some of the rates. */
export const PartialModelCostSchema = Type.Unsafe<Partial<ModelCost>>(withoutRequired(ModelCostJsonSchema));
function withoutRequired(schema: object): object {
const { required: _required, ...rest } = schema as { required?: unknown };
return rest;
}

View file

@ -1,5 +1,5 @@
import { ModelThinkingLevelSchema } from "@earendil-works/pi-ai/providers/model-schema";
import { type Static, type TSchemaOptions, Type } from "typebox";
import { ModelThinkingLevelSchema } from "./pi-ai-schemas.ts";
import { SETTINGS_DEFAULTS } from "./settings-defaults.ts";
function nonNegativeSafeInteger(options: { default?: number } = {}) {

View file

@ -1,7 +1,6 @@
import { constants } from "node:fs";
import { access as fsAccess } from "node:fs/promises";
import { constants as osConstants } from "node:os";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { spawn } from "child_process";
import { type Static, Type } from "typebox";
import { waitForChildProcess } from "../../utils/child-process.ts";
@ -16,7 +15,7 @@ import {
import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts";
import { OutputAccumulator } from "./output-accumulator.ts";
import { BASH_UPDATE_THROTTLE_MS, createShellRenderers } from "./renderers/bash.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { type TypeBoxAgentTool, wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "./truncate.ts";
const MAX_TIMEOUT_MS = 2_147_483_647;
@ -431,7 +430,7 @@ export function createBashToolDefinition(
return createShellToolDefinition(cwd, bashToolConfig, options);
}
export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool<typeof bashSchema> {
export function createBashTool(cwd: string, options?: BashToolOptions): TypeBoxAgentTool<typeof bashSchema> {
const definition = createBashToolDefinition(cwd, options);
const tool = wrapToolDefinition(definition);
Object.assign(tool, {

View file

@ -1,4 +1,3 @@
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
import { type Static, Type } from "typebox";
@ -16,7 +15,7 @@ import {
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToCwd } from "./path-utils.ts";
import { type EditRenderState, editRenderers } from "./renderers/edit.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { type TypeBoxAgentTool, wrapToolDefinition } from "./tool-definition-wrapper.ts";
const replaceEditSchema = Type.Object(
{
@ -213,6 +212,6 @@ export function createEditToolDefinition(
};
}
export function createEditTool(cwd: string, options?: EditToolOptions): AgentTool<typeof editSchema> {
export function createEditTool(cwd: string, options?: EditToolOptions): TypeBoxAgentTool<typeof editSchema> {
return wrapToolDefinition(createEditToolDefinition(cwd, options));
}

View file

@ -1,5 +1,4 @@
import { createInterface } from "node:readline";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { spawn } from "child_process";
import path from "path";
import { type Static, Type } from "typebox";
@ -7,7 +6,7 @@ import { ensureTool } from "../../utils/tools-manager.ts";
import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts";
import { pathExists, resolveToCwd } from "./path-utils.ts";
import { findRenderers } from "./renderers/find.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { type TypeBoxAgentTool, wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
/** Relativize a find result against the search root and normalize it to posix separators. */
@ -313,6 +312,6 @@ export function createFindToolDefinition(
};
}
export function createFindTool(cwd: string, options?: FindToolOptions): AgentTool<typeof findSchema> {
export function createFindTool(cwd: string, options?: FindToolOptions): TypeBoxAgentTool<typeof findSchema> {
return wrapToolDefinition(createFindToolDefinition(cwd, options));
}

View file

@ -1,6 +1,5 @@
import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
import { createInterface } from "node:readline";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { spawn } from "child_process";
import path from "path";
import { type Static, Type } from "typebox";
@ -8,7 +7,7 @@ import { ensureTool } from "../../utils/tools-manager.ts";
import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts";
import { resolveToCwd } from "./path-utils.ts";
import { grepRenderers } from "./renderers/grep.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { type TypeBoxAgentTool, wrapToolDefinition } from "./tool-definition-wrapper.ts";
import {
DEFAULT_MAX_BYTES,
formatSize,
@ -320,6 +319,6 @@ export function createGrepToolDefinition(
};
}
export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentTool<typeof grepSchema> {
export function createGrepTool(cwd: string, options?: GrepToolOptions): TypeBoxAgentTool<typeof grepSchema> {
return wrapToolDefinition(createGrepToolDefinition(cwd, options));
}

View file

@ -1,11 +1,10 @@
import { readdir as fsReaddir, stat as fsStat } from "node:fs/promises";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import nodePath from "path";
import { type Static, Type } from "typebox";
import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts";
import { pathExists, resolveToCwd } from "./path-utils.ts";
import { lsRenderers } from "./renderers/ls.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { type TypeBoxAgentTool, wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
const lsSchema = Type.Object({
@ -170,6 +169,6 @@ export function createLsToolDefinition(
};
}
export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool<typeof lsSchema> {
export function createLsTool(cwd: string, options?: LsToolOptions): TypeBoxAgentTool<typeof lsSchema> {
return wrapToolDefinition(createLsToolDefinition(cwd, options));
}

View file

@ -1,4 +1,3 @@
import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { Api, ImageContent, Model, ModelImageResizeOptions, TextContent } from "@earendil-works/pi-ai";
import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile } from "fs/promises";
@ -8,7 +7,7 @@ import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts";
import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts";
import { resolveReadPathAsync } from "./path-utils.ts";
import { readRenderers } from "./renderers/read.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { type TypeBoxAgentTool, wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
const readSchema = Type.Object({
@ -219,6 +218,6 @@ export function createReadToolDefinition(
};
}
export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> {
export function createReadTool(cwd: string, options?: ReadToolOptions): TypeBoxAgentTool<typeof readSchema> {
return wrapToolDefinition(createReadToolDefinition(cwd, options));
}

View file

@ -1,6 +1,25 @@
import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
import type { Static, TSchema } from "typebox";
import type { ExtensionToolContext, ToolDefinition } from "../extensions/types.ts";
/**
* An `AgentTool` with a TypeBox parameter schema whose `execute` and `prepareArguments` are typed by TypeBox's
* `Static`, as the built-in tools are (pi-agent-core types `params` through pi-schema, which gives `unknown` for
* TypeBox schemas). Spreads into a `ToolDefinition` of the same schema.
*/
export type TypeBoxAgentTool<TParams extends TSchema = TSchema, TDetails = any> = Omit<
AgentTool<TParams, TDetails>,
"prepareArguments" | "execute"
> & {
prepareArguments?: (args: unknown) => Static<TParams>;
execute: (
toolCallId: string,
params: Static<TParams>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>,
) => Promise<AgentToolResult<TDetails>>;
};
/** Creates the context for one tool call. */
export type ToolContextFactory = (toolCallId: string, signal: AbortSignal | undefined) => ExtensionToolContext;

View file

@ -1,4 +1,3 @@
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises";
import { dirname } from "path";
import { type Static, Type } from "typebox";
@ -6,7 +5,7 @@ import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts";
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToCwd } from "./path-utils.ts";
import { writeRenderers } from "./renderers/write.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { type TypeBoxAgentTool, wrapToolDefinition } from "./tool-definition-wrapper.ts";
const writeSchema = Type.Object({
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
@ -92,6 +91,6 @@ export function createWriteToolDefinition(
};
}
export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool<typeof writeSchema> {
export function createWriteTool(cwd: string, options?: WriteToolOptions): TypeBoxAgentTool<typeof writeSchema> {
return wrapToolDefinition(createWriteToolDefinition(cwd, options));
}

View file

@ -32,7 +32,7 @@ import type { ToolDefinition, ToolLoadout, ToolLoadoutChanges, ToolNamespace } f
import type { ModelRegistry } from "../../core/model-registry.ts";
import { SETTINGS_DEFAULTS } from "../../core/settings-defaults.ts";
import type { CodemodeMode } from "../../core/settings-manager.ts";
import { wrapToolDefinition } from "../../core/tools/tool-definition-wrapper.ts";
import { type TypeBoxAgentTool, wrapToolDefinition } from "../../core/tools/tool-definition-wrapper.ts";
import { loadCodemodeExecutor } from "./execute.lazy.ts";
import { codemodeRenderers } from "./renderer.ts";
@ -422,7 +422,7 @@ export function createCodemodeToolDefinition(
export function createCodemodeTool(
tools: readonly AgentTool<any>[] = [],
options: CodemodeToolOptions = {},
): AgentTool<typeof codemodeSchema> {
): TypeBoxAgentTool<typeof codemodeSchema> {
const definition = createCodemodeToolDefinition(options);
const tool = wrapToolDefinition(definition);
Object.assign(tool, {

View file

@ -21,8 +21,8 @@ import {
getModel,
type Model,
type SimpleStreamOptions,
Type,
} from "@earendil-works/pi-ai/compat";
import { Type } from "typebox";
import {
getOpenAICodexWebSocketDebugStats,
streamSimple as streamSimpleOpenAICodexResponses,

View file

@ -18,7 +18,10 @@ import { Type } from "typebox";
import { afterEach, describe, expect, it } from "vitest";
import type { ExtensionAPI } from "../../src/core/extensions/types.ts";
import type { CustomEntry } from "../../src/core/session-manager.ts";
import { createToolDefinitionFromAgentTool } from "../../src/core/tools/tool-definition-wrapper.ts";
import {
createToolDefinitionFromAgentTool,
type TypeBoxAgentTool,
} from "../../src/core/tools/tool-definition-wrapper.ts";
import { readCodemodeStore } from "../../src/extensions/codemode/execute.ts";
import { createCodemodeExtension } from "../../src/extensions/codemode/index.ts";
import {
@ -33,7 +36,7 @@ const TINY_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
const echoSchema = Type.Object({ text: Type.String({ description: "Text to echo" }) });
const echoTool: AgentTool<typeof echoSchema> = {
const echoTool: TypeBoxAgentTool<typeof echoSchema> = {
name: "echo",
label: "Echo",
description: "Echo text back.\n\nSecond paragraph.",

View file

@ -4,6 +4,7 @@
### Breaking Changes
- Tool schemas are JSON Schema typed through `@earendil-works/pi-schema`: `defineTool()` and `ToolRegistration` take any JSON Schema object as `parameters` and `structuredOutputSchema`, and type `args` with pi-schema's `Static` (`ToolArgs<TParameters>`; other schemas give `unknown`). The built-in tools (`read`, `write`, `edit`, `bash`, `powershell`, `codemode`, `tool_search`, the model tools) are built with pi-schema, with the same JSON Schema up to key order. `structuredOutput` is validated with pi-schema, so `invalid_structured_output` messages use its wording. `typebox` is no longer a dependency. Migration: replace `Type.Object({ n: Type.Number() })` from `@earendil-works/pi-ai` with `obj({ n: num() })` from `@earendil-works/pi-schema` (closed by default; `{ open: true }` allows other properties).
- `SqliteExecutor` (re-exported from Chord) gains `runCounted(sql, ...params)`, resolving with the rows the statement changed; custom `SqliteDatabase` implementations add it.
- `Storage.commit()` stores a group of commits in one call: `commit(batches, receive, context)` resolves with a `CommitResult` (`{ stored, refused? }`) and replaces `commit(writes, adopt, context)`. Each batch is one atomic commit with its own seq, checked against the state the batches before it left. The first batch Durable's own checks refuse (global IDs, document actions, copy sources) is not stored, nor anything after it; the batches before it are, and the call resolves with their number in `stored` and the refusal in `refused`. Any other failure (a driver or I/O error, a failed `COMMIT`) rejects the whole call as an unknown outcome and stores nothing the backend could roll back. `receive(index, seq)` is called once per stored batch, in order, synchronously, once they are durable and before the next call starts; receipts made before a rejection stand. Every batch has at least one write. The optional `publishing(publish, context)` runs each commit's publication inside the storage's own scope (`StoreStorage`: the attached host's batch). Memory, SQLite, JSONL, IndexedDB and `StoreStorage` store a call in one transaction or append, and the storage conformance suite checks the contract. Migration for a custom Storage:

View file

@ -257,15 +257,15 @@ registry.install(createCodingTools({ images: await createNodePhotonImages() }));
A model without image input sees a placeholder instead of the image, and `read` says so in a diagnostic. `/images/node` prepares images in a worker thread, so a large photo (a second or two of work) never blocks the event loop; the worker exits after 30 s without images, freeing Photon's WebAssembly memory (`createNodePhotonImages({ worker: false, idleMs })`). Elsewhere, `createPhotonImages()` and `/images/cloudflare` work on the calling thread, and decoding takes the image's full size in WebAssembly memory, which does not shrink again; leave the processor out on memory-constrained hosts such as Cloudflare Workers unless images stay small.
Define your own tool with a TypeBox schema. `defineTool()` types `args` from `parameters`, which the Harness validates before `execute()`. `api.output()` streams running output, which becomes the result when `execute()` returns no `output`:
Define your own tool with a JSON Schema, built here with `@earendil-works/pi-schema`. `defineTool()` types `args` from `parameters` (through pi-schema's `Static`; other JSON Schema gives `unknown`), which the Harness coerces and validates before `execute()`. `api.output()` streams running output, which becomes the result when `execute()` returns no `output`:
```typescript
import { Type } from "@earendil-works/pi-ai";
import { num, obj, str } from "@earendil-works/pi-schema";
const count = defineTool({
name: "count",
description: "Count from 1 to n",
parameters: Type.Object({ n: Type.Number() }),
parameters: obj({ n: num() }),
execute: async (args, api) => {
for (let i = 1; i <= args.n; i++) api.output(`${i}\n`);
return {};
@ -282,8 +282,8 @@ A result has three channels: `output` for the model, `structuredOutput` for prog
const lines = defineTool({
name: "lines",
description: "Count the lines of a file",
parameters: Type.Object({ path: Type.String() }),
structuredOutputSchema: Type.Object({ lines: Type.Number() }),
parameters: obj({ path: str() }),
structuredOutputSchema: obj({ lines: num() }),
execute: async (args, api, context) => {
const text = await readText(api, args.path, context);
const count = text.split("\n").length;
@ -312,7 +312,7 @@ A tool calls another tool with `api.executeTool()`, as a code mode script or an
const testsPass = defineTool({
name: "tests_pass",
description: "Run the test suite and say whether it passed",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, context) => {
const run = await api.executeTool("bash", { command: "npm test" }, context, { progress: false });
// Absent when bash did not finish, for example on a timeout.
@ -722,7 +722,7 @@ const Subagent: Extension = defineExtension({
defineTool({
name: "subagent",
description: "Delegate a self-contained task to a subagent and get its answer back.",
parameters: Type.Object({ task: Type.String() }),
parameters: obj({ task: str() }),
replay: "safe", // a rerun after a crash finds the same child and submission
execute: async (args, api, context) => {
const child = await api.commit(async (tx) => {
@ -905,7 +905,7 @@ registerStorageConformance({ describe, expect, it }, "My Storage", async (use) =
});
```
The package root loads TypeBox, because the tool task validates arguments with pi-ai's `validateToolArguments()`. That costs about 23 MB of peak RSS unbundled, about 4 MB in a tree-shaken bundle.
The package root loads pi-schema, because the tool task validates arguments with pi-ai's `validateToolArguments()`.
## Errors

View file

@ -161,10 +161,10 @@
"@earendil-works/chord": "1.4.0",
"@earendil-works/pi-ai": "1.4.0",
"@earendil-works/pi-codemode": "1.4.0",
"@earendil-works/pi-schema": "1.4.0",
"@earendil-works/pi-telemetry": "1.4.0",
"@silvia-odwyer/photon": "0.3.3",
"diff": "8.0.4",
"typebox": "1.3.27"
"diff": "8.0.4"
},
"devDependencies": {
"@cloudflare/vitest-plugin": "1.4.10",

View file

@ -46,7 +46,7 @@ import type {
CodemodeSandboxBase,
} from "@earendil-works/pi-codemode/portable";
import { CODEMODE_SOURCE_GRAMMAR, parseCodemodeSource } from "@earendil-works/pi-codemode/source";
import { type Static, Type } from "typebox";
import { obj, type Static, str } from "@earendil-works/pi-schema";
import { defineDoc } from "../documents.ts";
import type { ExecutionEnv } from "../env/index.ts";
import { errorMessage } from "../errors.ts";
@ -210,7 +210,7 @@ export type CodemodeGlobal = {
execute(args: unknown, api: ToolExecutionApi, context: Context): Promise<JsonValue | undefined>;
};
const codemodeSchema = Type.Object({ code: Type.String({ description: "Raw JavaScript source." }) });
const codemodeSchema = obj({ code: str({ description: "Raw JavaScript source." }) }, { open: true });
export type CodemodeToolInput = Static<typeof codemodeSchema>;

View file

@ -17,7 +17,7 @@ import type {
ModelTypeMap,
Usage,
} from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { obj, unknown } from "@earendil-works/pi-schema";
import { defineTool } from "../harness/define.ts";
import type { ToolRegistration } from "../harness/types.ts";
@ -227,7 +227,7 @@ function resolveModel<TType extends "classifier" | "image">(
);
}
const ModelCallParameters = Type.Object({ model: Type.Unknown(), context: Type.Unknown() });
const ModelCallParameters = obj({ model: unknown(), context: unknown() }, { open: true });
/** The result of a model call as the tool returns it: the whole result for the script, usage counted by the Harness. */
function modelResult(
@ -255,7 +255,7 @@ export function createModelTools(): ToolRegistration[] {
name: CLASSIFY_TOOL,
description: "models.classify() of code mode scripts.",
parameters: ModelCallParameters,
structuredOutputSchema: Type.Unknown(),
structuredOutputSchema: unknown(),
callers: ["tools"],
execute: async (args, api, context) => {
const model = resolveModel("models.classify", "classifier", args.model, api.models);
@ -271,7 +271,7 @@ export function createModelTools(): ToolRegistration[] {
name: GENERATE_IMAGES_TOOL,
description: "models.generateImages() of code mode scripts.",
parameters: ModelCallParameters,
structuredOutputSchema: Type.Unknown(),
structuredOutputSchema: unknown(),
callers: ["tools"],
execute: async (args, api, context) => {
const model = resolveModel("models.generateImages", "image", args.model, api.models);

View file

@ -1,5 +1,4 @@
import type { JsonValue } from "@earendil-works/chord";
import type { TSchema } from "@earendil-works/pi-ai";
import type { Task } from "../types.ts";
import type {
AnyTask,
@ -20,7 +19,7 @@ export function defineExtension<Tool extends ToolRegistration = ToolRegistration
}
/** Identity function that types a tool: `args` from `parameters`, details from what it reports. */
export function defineTool<TParameters extends TSchema, TDetails extends JsonValue = JsonValue>(
export function defineTool<TParameters extends object, TDetails extends JsonValue = JsonValue>(
tool: ToolRegistration<TParameters, TDetails>,
): ToolRegistration<TParameters, TDetails> {
return tool;

View file

@ -11,7 +11,8 @@ import {
} from "@earendil-works/chord/output";
import type { ImageContent, TextContent, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
import { validateToolArguments } from "@earendil-works/pi-ai/utils/validation";
import { Compile } from "typebox/compile";
import { compile, type Validator } from "@earendil-works/pi-schema";
import { formats } from "@earendil-works/pi-schema/formats";
import { defineDocFamily } from "../documents.ts";
import { AssistantEntry, ToolResultEntry } from "../entries.ts";
import type { ExecutionEnv } from "../env/index.ts";
@ -770,7 +771,7 @@ async function afterHarnessResult(
return { ...rest, ...value, output: bounded.content, diagnostics: [...(rest.diagnostics ?? []), ...harness] };
}
const structuredValidators = new WeakMap<object, ReturnType<typeof Compile>>();
const structuredValidators = new WeakMap<object, Validator>();
/** Why a result's `structuredOutput` breaks the tool's contract, or `undefined` when it keeps it. */
function structuredOutputError(tool: ToolRegistration, result: ToolExecutionResult): string | undefined {
@ -786,12 +787,17 @@ function structuredOutputError(tool: ToolRegistration, result: ToolExecutionResu
}
let validator = structuredValidators.get(schema);
if (validator === undefined) {
validator = Compile(schema);
validator = compile(schema, { formats, unresolvedRefs: "reject" });
structuredValidators.set(schema, validator);
}
if (validator.Check(value)) return undefined;
const [first] = validator.Errors(value);
const path = first?.instancePath.replace(/^\//, "").replace(/\//g, ".") || "root";
if (validator.check(value)) return undefined;
const [first] = validator.errors(value);
const path =
first?.path
.slice(1)
.split("/")
.map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"))
.join(".") || "root";
return `Tool ${tool.name} returned structuredOutput that does not match its schema: ${path}: ${first?.message ?? "invalid"}`;
}

View file

@ -8,15 +8,14 @@ import type {
Model,
Models,
ModelThinkingLevel,
Static,
Tool,
ToolCall,
ToolResultMessage,
Transport,
TSchema,
Usage,
UserMessage,
} from "@earendil-works/pi-ai";
import type { Static } from "@earendil-works/pi-schema";
import type { TelemetryContext } from "@earendil-works/pi-telemetry";
import type { ExecutionEnv, ShellOutputSkip, ShellOutputWindow } from "../env/index.ts";
import type {
@ -333,12 +332,22 @@ export interface ToolExecutionApi<TDetails extends JsonValue = JsonValue> extend
): Promise<NestedToolExecutionResult>;
}
/**
* The arguments type of a tool with parameter schema `TParameters`: `Static<TParameters>` for schemas built with
* `@earendil-works/pi-schema`'s builders, `unknown` for other JSON Schema, and `any` for `ToolRegistration<any>`.
*/
export type ToolArgs<TParameters> = IsAny<TParameters> extends true ? any : Static<TParameters>;
// `0 extends 1 & T` is resolved too early for a `T` constrained to `object`; this form waits for the argument.
type IsAny<T> = boolean extends (T extends never ? true : false) ? true : false;
/**
* Executable tool registered in a registry. Only pi-ai `Tool` fields enter the transcript. `args` are typed by
* `parameters`, which the Harness validates them against before `execute()`; `defineTool()` infers both generics.
* `parameters` (any JSON Schema object; see {@link ToolArgs}), which the Harness validates them against before
* `execute()`; `defineTool()` infers both generics.
*/
export type ToolRegistration<
TParameters extends TSchema = TSchema,
TParameters extends object = object,
TDetails extends JsonValue = JsonValue,
> = Tool<TParameters> & {
/** Whether an interrupted execution may rerun on recovery. Default `unsafe`. */
@ -350,12 +359,12 @@ export type ToolRegistration<
* pure and must not mutate `args`: it runs again when a call is retried before its intent is recorded. Its result is
* still validated against `parameters`.
*/
prepareArguments?(args: unknown): Static<TParameters>;
prepareArguments?(args: unknown): ToolArgs<TParameters>;
/**
* Schema of `structuredOutput`, which programs that call the tool receive. Omitted: they receive the result's bounded
* output, success or error, as `NestedToolExecutionResult.structuredOutput` describes.
*/
readonly structuredOutputSchema?: TSchema;
readonly structuredOutputSchema?: object;
/**
* Who may call the tool: the model, other tools through `executeTool()`, or both (default). A code mode tool is
* `["model"]`, so scripts cannot start scripts; tools behind code mode, such as MCP tools, can be `["tools"]`, so the
@ -373,7 +382,7 @@ export type ToolRegistration<
readonly retain?: "head" | "tail";
};
execute(
args: Static<TParameters>,
args: ToolArgs<TParameters>,
api: ToolExecutionApi<TDetails>,
context: Context,
): Promise<ToolExecutionResult<TDetails>>;

View file

@ -3,7 +3,7 @@
* and the matches are loaded with `control.addTools`, so the next request offers them. Loading writes the
* conversation's `modelTools` (its `add`), so loaded tools stay offered on that branch, also after a compaction.
*/
import { Type } from "@earendil-works/pi-ai";
import { num, obj, opt, str } from "@earendil-works/pi-schema";
import { defineExtension, defineTool } from "../harness/define.ts";
import type { ToolRegistration } from "../harness/types.ts";
import { rankDocuments } from "../search.ts";
@ -14,12 +14,13 @@ export const DEFAULT_TOOL_SEARCH_LIMIT = 8;
/** Fixed, so it does not change while deferred tools are registered, for example as MCP servers connect. */
const TOOL_SEARCH_DESCRIPTION = `# Tool discovery\n\nSearches over deferred tool metadata with BM25 and exposes matching tools for the next model call.\n\nSome of the tools, such as tools of MCP servers, may not have been provided to you upfront, and you should use this tool (\`${TOOL_SEARCH_TOOL_NAME}\`) to search for the required tools. For MCP tool discovery, always use \`${TOOL_SEARCH_TOOL_NAME}\`.`;
const toolSearchSchema = Type.Object({
query: Type.String({ description: "Search query for deferred tools." }),
limit: Type.Optional(
Type.Number({ description: `Maximum number of tools to return. Defaults to ${DEFAULT_TOOL_SEARCH_LIMIT}.` }),
),
});
const toolSearchSchema = obj(
{
query: str({ description: "Search query for deferred tools." }),
limit: opt(num({ description: `Maximum number of tools to return. Defaults to ${DEFAULT_TOOL_SEARCH_LIMIT}.` })),
},
{ open: true },
);
/** `tool_search` result details: the tools it loaded. */
export type ToolSearchToolDetails = { loaded: string[] };

View file

@ -1,5 +1,5 @@
import type { Context } from "@earendil-works/chord";
import { type Static, Type } from "typebox";
import { bool, num, obj, opt, type Static, str } from "@earendil-works/pi-schema";
import { defineTool } from "../harness/define.ts";
import type { ToolExecutionApi, ToolExecutionResult, ToolRegistration } from "../harness/types.ts";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "../truncate.ts";
@ -7,23 +7,33 @@ import { requireEnv } from "./env.ts";
const MAX_TIMEOUT_SECONDS = 2_147_483_647 / 1000;
const bashSchema = Type.Object({
command: Type.String({ description: "Bash command to execute" }),
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
});
// Open objects, as the tools accepted extra arguments when their schemas were TypeBox's.
const bashSchema = obj(
{
command: str({ description: "Bash command to execute" }),
timeout: opt(num({ description: "Timeout in seconds (optional, no default timeout)" })),
},
{ open: true },
);
const powershellSchema = Type.Object({
command: Type.String({ description: "PowerShell command to execute" }),
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
});
const powershellSchema = obj(
{
command: str({ description: "PowerShell command to execute" }),
timeout: opt(num({ description: "Timeout in seconds (optional, no default timeout)" })),
},
{ open: true },
);
/** What a program that calls `bash` or `powershell` receives. */
const shellStructuredOutputSchema = Type.Object({
output: Type.String({ description: "Combined stdout and stderr: the retained tail, as the model sees it" }),
truncated: Type.Boolean({ description: "Whether earlier output was dropped" }),
fullOutputPath: Type.Optional(Type.String({ description: "File with the complete output, when it was spilled" })),
exitCode: Type.Number(),
});
const shellStructuredOutputSchema = obj(
{
output: str({ description: "Combined stdout and stderr: the retained tail, as the model sees it" }),
truncated: bool({ description: "Whether earlier output was dropped" }),
fullOutputPath: opt(str({ description: "File with the complete output, when it was spilled" })),
exitCode: num(),
},
{ open: true },
);
export type BashToolInput = Static<typeof bashSchema>;
export type PowerShellToolInput = Static<typeof powershellSchema>;

View file

@ -1,4 +1,4 @@
import { type Static, Type } from "typebox";
import { arr, obj, type Static, str } from "@earendil-works/pi-schema";
import type { FileError } from "../env/index.ts";
import { defineTool } from "../harness/define.ts";
import type { ToolRegistration } from "../harness/types.ts";
@ -16,21 +16,27 @@ import { requireEnv } from "./env.ts";
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToolPath } from "./path-utils.ts";
const replaceEditSchema = Type.Object({
oldText: Type.String({
description:
"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.",
}),
newText: Type.String({ description: "Replacement text for this targeted edit." }),
});
const replaceEditSchema = obj(
{
oldText: str({
description:
"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.",
}),
newText: str({ description: "Replacement text for this targeted edit." }),
},
{ open: true },
);
const editSchema = Type.Object({
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
edits: Type.Array(replaceEditSchema, {
description:
"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.",
}),
});
const editSchema = obj(
{
path: str({ description: "Path to the file to edit (relative or absolute)" }),
edits: arr(replaceEditSchema, {
description:
"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.",
}),
},
{ open: true },
);
export type EditToolInput = Static<typeof editSchema>;
type LegacyEditToolInput = EditToolInput & { oldText?: unknown; newText?: unknown };

View file

@ -1,6 +1,6 @@
import type { Context } from "@earendil-works/chord";
import { characterEnd } from "@earendil-works/chord/output";
import { type Static, Type } from "typebox";
import { lit, num, obj, opt, type Static, str, union } from "@earendil-works/pi-schema";
import {
type BinaryReader,
type FileInfo,
@ -31,11 +31,14 @@ import {
} from "./image-processor.ts";
import { resolveReadToolPath } from "./path-utils.ts";
const readSchema = Type.Object({
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
});
const readSchema = obj(
{
path: str({ description: "Path to the file to read (relative or absolute)" }),
offset: opt(num({ description: "Line number to start reading from (1-indexed)" })),
limit: opt(num({ description: "Maximum number of lines to read" })),
},
{ open: true },
);
export type ReadToolInput = Static<typeof readSchema>;
@ -43,9 +46,9 @@ export type ReadToolInput = Static<typeof readSchema>;
* What programs such as code mode scripts get: the text of a text file, or the image block of an image, which code
* mode's `image()` shows.
*/
const readStructuredOutputSchema = Type.Union([
Type.String(),
Type.Object({ type: Type.Literal("image"), data: Type.String(), mimeType: Type.String() }),
const readStructuredOutputSchema = union([
str(),
obj({ type: lit("image"), data: str(), mimeType: str() }, { open: true }),
]);
export type ReadToolDetails = {

View file

@ -1,4 +1,4 @@
import { type Static, Type } from "typebox";
import { obj, type Static, str } from "@earendil-works/pi-schema";
import { getOrThrow } from "../env/index.ts";
import { defineTool } from "../harness/define.ts";
import type { ToolRegistration } from "../harness/types.ts";
@ -6,10 +6,13 @@ import { requireEnv } from "./env.ts";
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToolPath } from "./path-utils.ts";
const writeSchema = Type.Object({
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
content: Type.String({ description: "Content to write to the file" }),
});
const writeSchema = obj(
{
path: str({ description: "Path to the file to write (relative or absolute)" }),
content: str({ description: "Content to write to the file" }),
},
{ open: true },
);
export type WriteToolInput = Static<typeof writeSchema>;

View file

@ -5,6 +5,7 @@
*
* node --conditions=source --experimental-strip-types test/codemode-bench.ts [N,...]
*/
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@ -12,7 +13,7 @@ import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { CodemodeSandbox } from "@earendil-works/pi-codemode";
import { Type } from "typebox";
import { num, obj, str } from "@earendil-works/pi-schema";
import { createCodemodeTool } from "../src/codemode/codemode.ts";
import { createRegistry, defineExtension, defineTool, Harness } from "../src/index.ts";
import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
@ -22,14 +23,14 @@ type Shape = "sequential" | "parallel";
const echo = defineTool({
name: "echo",
description: "echo",
parameters: Type.Object({ i: Type.Number() }),
parameters: obj({ i: num() }),
execute: async (args) => ({ output: [{ type: "text", text: String(args.i) }] }),
});
const plain = defineTool({
name: "plain",
description: "plain",
parameters: Type.Object({ n: Type.Number(), shape: Type.String() }),
parameters: obj({ n: num(), shape: str() }),
replay: "safe",
execute: async (args, api, callContext) => {
if (args.shape === "sequential") {

View file

@ -1,7 +1,8 @@
import { type AssistantMessage, fauxAssistantMessage, fauxText, Type } from "@earendil-works/pi-ai";
import { type AssistantMessage, fauxAssistantMessage, fauxText } from "@earendil-works/pi-ai";
import { CodemodeSandbox } from "@earendil-works/pi-codemode";
import { CODEMODE_SOURCE_GRAMMAR } from "@earendil-works/pi-codemode/source";
import { defineTool, MemoryStorage, type ToolRegistration } from "@earendil-works/pi-durable";
import { obj, str } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import { createCodemodeExtension } from "../src/codemode/codemode.ts";
import { type CodemodeNamespace, codemodeDescription, codemodeSection } from "../src/codemode/describe.ts";
@ -12,7 +13,7 @@ function tool(name: string, description: string, extra: Partial<ToolRegistration
return defineTool({
name,
description,
parameters: Type.Object({ path: Type.String({ description: "File path" }) }),
parameters: obj({ path: str({ description: "File path" }) }),
execute: async () => ({ output: [] }),
...extra,
});

View file

@ -2,7 +2,7 @@ import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import type { Context } from "@earendil-works/chord";
import { type AssistantMessage, fauxAssistantMessage, fauxText, fauxToolCall, Type } from "@earendil-works/pi-ai";
import { type AssistantMessage, fauxAssistantMessage, fauxText, fauxToolCall } from "@earendil-works/pi-ai";
import { type CodemodeRun, CodemodeSandbox } from "@earendil-works/pi-codemode";
import {
defineTool,
@ -13,6 +13,7 @@ import {
ToolTask,
type ToolTaskInput,
} from "@earendil-works/pi-durable";
import { bool, num, obj, opt, str } from "@earendil-works/pi-schema";
import { afterEach, describe, expect, it } from "vitest";
import {
type CodemodeGlobal,
@ -30,7 +31,7 @@ import { addHooks, addTool } from "./harness-support.ts";
import { ControlledStorage, context } from "./session-support.ts";
import { aborted, type Deferred, deferred, eventually } from "./task-support.ts";
const Parameters = Type.Object({ text: Type.Optional(Type.String()), id: Type.Optional(Type.Number()) });
const Parameters = obj({ text: opt(str()), id: opt(num()) });
function tool(
name: string,
@ -715,13 +716,13 @@ describe("durable code mode", () => {
it("resolves a schema tool's error result that carries its value, and rejects one without with its text", async () => {
const chat = codemodeChat();
const Exit = Type.Object({ output: Type.String(), exitCode: Type.Number() });
const Exit = obj({ output: str(), exitCode: num() });
addTool(
chat.setup.registry,
defineTool({
name: "run",
description: "run",
parameters: Type.Object({}),
parameters: obj({}),
structuredOutputSchema: Exit,
// Like bash on a nonzero exit: an error result with its value.
execute: async () => ({
@ -736,7 +737,7 @@ describe("durable code mode", () => {
defineTool({
name: "denied",
description: "denied",
parameters: Type.Object({}),
parameters: obj({}),
structuredOutputSchema: Exit,
execute: async () => ({ output: [{ type: "text", text: "permission denied" }], isError: true }),
}),
@ -767,7 +768,7 @@ describe("durable code mode", () => {
defineTool({
name: "picture",
description: "picture",
parameters: Type.Object({ caption: Type.Boolean() }),
parameters: obj({ caption: bool() }),
execute: async (args) => ({
output: [
...(args.caption ? [{ type: "text" as const, text: "a cat" }] : []),
@ -1078,7 +1079,7 @@ describe("durable code mode output and tool search", () => {
defineTool({
name: "later",
description: "A deferred tool",
parameters: Type.Object({}),
parameters: obj({}),
deferred: true,
execute: async () => ({ output: [{ type: "text", text: "later ran" }] }),
}),

View file

@ -1,9 +1,10 @@
// Open a Harness with a registry of extensions.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/05-harness.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { obj, str } from "@earendil-works/pi-schema";
import {
AgentDoc,
createRegistry,
@ -23,7 +24,7 @@ const context = BACKGROUND_CONTEXT;
const read = defineTool({
name: "read",
description: "Read a file",
parameters: Type.Object({ path: Type.String() }),
parameters: obj({ path: str() }),
execute: async (args) => ({ output: [{ type: "text", text: `contents of ${args.path}` }] }),
});
const Files = defineExtension({ name: "files", tools: [read] });

View file

@ -1,9 +1,10 @@
// Per-conversation agent choices and Harness-wide settings.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/06-configuration.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { obj, str } from "@earendil-works/pi-schema";
import {
AgentDoc,
createRegistry,
@ -25,7 +26,7 @@ function exampleTool(name: string, description: string): AppTool {
const tool = defineTool({
name,
description,
parameters: Type.Object({ path: Type.String() }),
parameters: obj({ path: str() }),
execute: async (args) => ({ output: [{ type: "text", text: `${name} ${args.path}` }] }),
});
return { ...tool, snippet: `Use ${name} for files.` };

View file

@ -1,9 +1,10 @@
// Reload extension code through the registry.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/09-registry-reload.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { obj, str } from "@earendil-works/pi-schema";
import { createRegistry, defineExtension, defineTool, Harness, MemoryStorage, wrapTool } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
@ -12,7 +13,7 @@ function exampleTool(name: string, description: string) {
return defineTool({
name,
description,
parameters: Type.Object({ path: Type.String() }),
parameters: obj({ path: str() }),
execute: async (args) => ({ output: [{ type: "text", text: `${name} ${args.path}` }] }),
});
}

View file

@ -1,10 +1,11 @@
// An extension that keeps its own per-conversation document.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/10-extension-state.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { obj, str } from "@earendil-works/pi-schema";
import {
createRegistry,
defineDoc,
@ -38,7 +39,7 @@ const Todo = defineExtension({
defineTool({
name: "todo",
description: "Add an item to your todo list",
parameters: Type.Object({ item: Type.String() }),
parameters: obj({ item: str() }),
execute: async (args, api, callContext) => {
await api.commit(async (tx) => {
(await tx.doc(Todos, api.conversationId)).items.push(args.item);

View file

@ -2,10 +2,11 @@
// view, and then only what changes after that.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/20-late-join.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { obj } from "@earendil-works/pi-schema";
import {
createRegistry,
defineExtension,
@ -27,7 +28,7 @@ registry.install(
defineTool({
name: "count",
description: "Counts to ten",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api) => {
for (let n = 1; n <= 10; n++) {
api.output(`${n}\n`);

View file

@ -4,12 +4,14 @@
// Uses OpenAI when OPENAI_API_KEY is set, and a scripted faux model otherwise.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/21-subagent-foreground.ts
import type { Context } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { type AssistantMessage, type FauxResponseStep, Type } from "@earendil-works/pi-ai";
import type { AssistantMessage, FauxResponseStep } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { obj, str } from "@earendil-works/pi-schema";
import {
AssistantEntry,
type ConversationId,
@ -42,7 +44,7 @@ const Subagent: Extension = defineExtension({
defineTool({
name: "subagent",
description: "Delegate a self-contained task to a subagent and get its answer back.",
parameters: Type.Object({ task: Type.String({ description: "What the subagent should do" }) }),
parameters: obj({ task: str({ description: "What the subagent should do" }) }),
// Safe to rerun after a crash: a rerun finds the child it already created and the submission it already made.
replay: "safe",
execute: async (args, api, callContext) => {

View file

@ -5,14 +5,16 @@
// Uses OpenAI when OPENAI_API_KEY is set, and a scripted faux model otherwise.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/22-subagent-background.ts
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { type AssistantMessage, type FauxResponseStep, Type } from "@earendil-works/pi-ai";
import type { AssistantMessage, FauxResponseStep } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { bool, obj, oneOf, opt, str } from "@earendil-works/pi-schema";
import {
AssistantEntry,
type ConversationId,
@ -134,11 +136,11 @@ const subagentTool = defineTool({
"Manage persistent subagents that work in the background. Actions: spawn (name, message), send (name, message; " +
"followUp: true queues it after the current answer instead of steering), stop (name: aborts its current work), " +
"status (name, or all subagents without one). Answers are reported back to you when they arrive.",
parameters: Type.Object({
action: Type.Union([Type.Literal("spawn"), Type.Literal("send"), Type.Literal("stop"), Type.Literal("status")]),
name: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
followUp: Type.Optional(Type.Boolean()),
parameters: obj({
action: oneOf(["spawn", "send", "stop", "status"]),
name: opt(str()),
message: opt(str()),
followUp: opt(bool()),
}),
// A call interrupted by a crash is not rerun: repeating `stop` could stop newer work. The model sees that the call
// was interrupted and can check with `status`.

View file

@ -2,13 +2,14 @@
// extension's own document, and switching back restores the full tool set.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/26-plan-mode.ts
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { arr, obj, str } from "@earendil-works/pi-schema";
import { NodeExecutionEnv } from "../../src/env/node.ts";
import {
createRegistry,
@ -39,7 +40,7 @@ const PlanDoc = defineDoc<{ steps: string[] }>({
const submitPlan = defineTool({
name: "submit_plan",
description: "Submit the plan as a list of steps.",
parameters: Type.Object({ steps: Type.Array(Type.String()) }),
parameters: obj({ steps: arr(str()) }),
execute: async (args, api, callContext) => {
await api.commit(async (tx) => {
(await tx.doc(PlanDoc, api.conversationId)).steps = args.steps;

View file

@ -2,13 +2,15 @@
// with; stored choices are names, so they survive a restart and bind to whatever code the new process installs.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/30-reload-and-restart.ts
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { type ToolResultMessage, Type } from "@earendil-works/pi-ai";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { obj } from "@earendil-works/pi-schema";
import { createRegistry, defineExtension, defineTool, Harness, ToolResultEntry } from "../../src/index.ts";
import { openNodeSqliteStorage } from "../../src/storage/sqlite/node.ts";
@ -24,7 +26,7 @@ function loadVersioned(version: string) {
defineTool({
name: "version",
description: "Report the tool's code version",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => {
started();
await gate;

View file

@ -2,10 +2,11 @@
// tests, a failure starts a fix run, and input sent meanwhile waits for them too.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/32-tests-after-edits.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { obj, str } from "@earendil-works/pi-schema";
import {
createRegistry,
defineExtension,
@ -34,7 +35,7 @@ const runTests = () => {
const edit = defineTool({
name: "edit",
description: "Replace a file's content",
parameters: Type.Object({ path: Type.String(), content: Type.String() }),
parameters: obj({ path: str(), content: str() }),
execute: async (args) => {
files.set(args.path, args.content);
return { output: [{ type: "text", text: `wrote ${args.path}` }] };

View file

@ -2,10 +2,11 @@
// message, with a fresh budget. A host learns that a run stopped from `pi.live.runFailure`.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/33-turn-limit-and-continue.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { num, obj } from "@earendil-works/pi-schema";
import {
ConversationBusy,
createRegistry,
@ -21,7 +22,7 @@ const context = BACKGROUND_CONTEXT;
const step = defineTool({
name: "step",
description: "Do one step of the work",
parameters: Type.Object({ n: Type.Number() }),
parameters: obj({ n: num() }),
execute: async (args) => ({ output: [{ type: "text", text: `step ${args.n} done` }] }),
});
const call = (n: number) => fauxAssistantMessage(fauxToolCall("step", { n }), { stopReason: "toolUse" });

View file

@ -3,10 +3,11 @@
// state; `whenIdle: "reject"` refuses it when no run is working.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/34-submit-in-commit.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { obj } from "@earendil-works/pi-schema";
import {
ConversationIdle,
createRegistry,
@ -38,7 +39,7 @@ const working = new Promise<void>((resolve) => {
const build = defineTool({
name: "build",
description: "Build the project",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => {
await working;
return { output: [{ type: "text", text: "built" }] };

View file

@ -2,7 +2,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Draft, JsonValue } from "@earendil-works/chord";
import { fauxAssistantMessage, fauxToolCall, Type } from "@earendil-works/pi-ai";
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
import {
CompactionTask,
ConversationBusy,
@ -25,6 +25,7 @@ import {
type TaskId,
type Tx,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { afterEach, describe, expect, it } from "vitest";
import { endRun } from "../src/harness/live.ts";
import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
@ -132,7 +133,7 @@ function install(setup: ChatSetup, state: Probe, afterRun?: GenerationHooks["aft
return Tests;
}
const Parameters = Type.Object({});
const Parameters = obj({});
const tools = defineExtension({
name: "tools",
tools: [

View file

@ -1,4 +1,4 @@
import { type AssistantMessage, fauxAssistantMessage, fauxText, fauxToolCall, Type } from "@earendil-works/pi-ai";
import { type AssistantMessage, fauxAssistantMessage, fauxText, fauxToolCall } from "@earendil-works/pi-ai";
import {
AgentDoc,
type Conversation,
@ -7,6 +7,7 @@ import {
type NestedToolExecutionResult,
type ToolRegistration,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import { type ChatSetup, chatSetup, openChat } from "./chat-support.ts";
import { addTool } from "./harness-support.ts";
@ -16,7 +17,7 @@ function tool(name: string, extra: Partial<ToolRegistration> = {}): ToolRegistra
return defineTool({
name,
description: name,
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ output: [{ type: "text", text: `${name} ran` }] }),
...extra,
});
@ -34,7 +35,7 @@ function prober(names: readonly string[]) {
const registration = defineTool({
name: "probe",
description: "probe",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, callContext) => {
for (const name of names) received.set(name, await api.executeTool(name, {}, callContext));
return {};

View file

@ -9,7 +9,6 @@ import {
type Message,
type SimpleStreamOptions,
type TranscriptContext,
Type,
type UserMessage,
} from "@earendil-works/pi-ai";
import {
@ -33,6 +32,7 @@ import {
type TaskId,
UsageDoc,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { afterEach, describe, expect, it } from "vitest";
import { selectCut, serializeConversation } from "../src/harness/compaction.ts";
import { orderToolResults } from "../src/harness/context.ts";
@ -471,7 +471,7 @@ describe("manual compaction", () => {
defineTool({
name: "wait",
description: "wait",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ output: [{ type: "text", text: "waited" }] }),
}),
);
@ -886,7 +886,7 @@ describe("compaction outcomes", () => {
defineTool({
name: "read",
description: "read",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ output: [] }),
}),
);
@ -1343,7 +1343,7 @@ describe("compaction estimates and interactions", () => {
defineTool({
name: "slow",
description: "slow",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => {
toolReached.resolve();
await toolGate.promise;

View file

@ -1,4 +1,5 @@
// `submit({ type: "continue" })`: a run without a new user message (#11043).
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@ -10,7 +11,6 @@ import {
fauxText,
fauxToolCall,
type Message,
Type,
} from "@earendil-works/pi-ai";
import {
AssistantEntry,
@ -26,6 +26,7 @@ import {
type Storage,
UserEntry,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { InMemoryTelemetryContext } from "@earendil-works/pi-telemetry";
import { afterEach, describe, expect, it } from "vitest";
import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
@ -125,7 +126,7 @@ describe("continue", () => {
defineTool({
name: "wait",
description: "Waits until aborted",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, _api, callContext) => {
started.resolve();
return aborted(callContext.abortSignal!);
@ -312,7 +313,7 @@ describe("continue", () => {
defineTool({
name: "delegate",
description: "Delegates",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, callContext) => {
const child = await api.commit(async (tx) => {
const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });

View file

@ -2,8 +2,9 @@
// the next commit that reads or writes `pi.live` or within `settings.progress.persistWithinMs`. Crashes are simulated
// by failing the Harness: from that moment nothing more reaches Storage, as when the process dies; the reopened Harness
// resumes from what was stored.
import { applyImmutable } from "@earendil-works/chord/delta";
import { fauxAssistantMessage, fauxText, fauxToolCall, Type } from "@earendil-works/pi-ai";
import { fauxAssistantMessage, fauxText, fauxToolCall } from "@earendil-works/pi-ai";
import {
AssistantEntry,
type ConversationId,
@ -15,6 +16,7 @@ import {
type LiveState,
ToolResultEntry,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import type { PartialMessage } from "../src/harness/partial-changes.ts";
import type { SessionImpl } from "../src/session/session.ts";
@ -257,7 +259,7 @@ describe("tool output", () => {
const registration = defineTool({
name: "work",
description: "work",
parameters: Type.Object({}),
parameters: obj({}),
replay,
execute: async (_args, api, callContext) => {
const run = ++state.runs;
@ -306,7 +308,7 @@ describe("tool output", () => {
defineTool({
name: "work",
description: "work",
parameters: Type.Object({}),
parameters: obj({}),
replay: "safe",
execute: async (_args, api, callContext) => {
runs++;

View file

@ -6,7 +6,6 @@ import {
fauxToolCall,
type Models,
type SimpleStreamOptions,
Type,
} from "@earendil-works/pi-ai";
import {
type Conversation,
@ -19,6 +18,7 @@ import {
MemoryStorage,
type Registry,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import { allEntries, textOf } from "./chat-support.ts";
import { context } from "./session-support.ts";
@ -86,7 +86,7 @@ describe("HarnessOptions.models and settings as functions", () => {
defineTool({
name: "probe",
description: "Records api.models before and after the host swaps it",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api) => {
seen.push(api.models);
current = second;

View file

@ -9,7 +9,6 @@ import {
fauxText,
fauxToolCall,
type ToolResultMessage,
Type,
type Usage,
} from "@earendil-works/pi-ai";
import {
@ -29,6 +28,7 @@ import {
ToolTask,
UsageDoc,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { afterEach, describe, expect, it } from "vitest";
import { recordUsage } from "../src/harness/usage.ts";
import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
@ -67,7 +67,7 @@ function holdTool(setup: ChatSetup, gate: Deferred, result: ToolExecutionResult
defineTool({
name: "hold",
description: "Waits for the test",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => {
await gate.promise;
return result;
@ -474,7 +474,7 @@ describe("inbox", () => {
defineTool({
name: "hold",
description: "Hands off",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ output: [], control: { handoff: `h${++handoffs}` } }),
}),
);
@ -796,7 +796,7 @@ describe("inbox", () => {
defineTool({
name: "later",
description: "Finishes first",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => {
await secondGate.promise;
return { output: [], control: { handoff: "two" } };

View file

@ -1,4 +1,5 @@
// Inputs placed as application entries, and data-only writes appended at once (#10407).
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@ -9,7 +10,6 @@ import {
fauxText,
fauxToolCall,
type Context as ModelContext,
Type,
} from "@earendil-works/pi-ai";
import {
type Conversation,
@ -23,6 +23,7 @@ import {
MemoryStorage,
type SubmissionDraft,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
import { allEntries, type ChatSetup, chatSetup, openChat, textOf, waitFor } from "./chat-support.ts";
@ -54,7 +55,7 @@ function holdTool(setup: ChatSetup, gate: Deferred): void {
defineTool({
name: "hold",
description: "Waits for the test",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => {
await gate.promise;
return { output: [{ type: "text", text: "held" }] };

View file

@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Context, JsonValue } from "@earendil-works/chord";
import { withAbortSignal } from "@earendil-works/chord/context";
import { createModels, fauxAssistantMessage, fauxText, fauxToolCall, Type } from "@earendil-works/pi-ai";
import { createModels, fauxAssistantMessage, fauxText, fauxToolCall } from "@earendil-works/pi-ai";
import {
type Conversation,
type ConversationId,
@ -17,6 +17,7 @@ import {
type Storage,
type TaskId,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import type { SessionImpl } from "../src/session/session.ts";
import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
@ -168,7 +169,7 @@ describe("Harness close", () => {
defineTool({
name: "wait",
description: "wait",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => {
if (where === "tool") await stubborn();
return { output: [] };

View file

@ -1,5 +1,5 @@
import type { Op } from "@earendil-works/chord/delta";
import { fauxAssistantMessage, fauxText, fauxToolCall, Type } from "@earendil-works/pi-ai";
import { fauxAssistantMessage, fauxText, fauxToolCall } from "@earendil-works/pi-ai";
import {
type CommitPublication,
defineTool,
@ -10,6 +10,7 @@ import {
type ToolExecutionApi,
type ToolRegistration,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import { chatSetup, openChat, waitFor } from "./chat-support.ts";
import { addTool } from "./harness-support.ts";
@ -30,7 +31,7 @@ async function drive(outputLimits: ToolRegistration["outputLimits"] = {}) {
defineTool({
name: "drive",
description: "Driven by the test",
parameters: Type.Object({}),
parameters: obj({}),
outputLimits,
execute: async (_args, api, callContext) => {
for (;;) {
@ -104,7 +105,7 @@ describe("pi.live deltas", () => {
defineTool({
name: "noop",
description: "noop",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ output: [] }),
}),
);
@ -317,7 +318,7 @@ describe("pi.live deltas", () => {
defineTool({
name,
description: name,
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api) => {
api.output(`${name} output\n`);
await new Promise((resolve) => setTimeout(resolve, 150));
@ -368,7 +369,7 @@ describe("pi.live deltas", () => {
defineTool({
name: "bad",
description: "bad",
parameters: Type.Object({}),
parameters: obj({}),
// Not strict JSON: the result commit throws and the scheduler faults the task.
execute: async () => ({ output: [], details: { fn: (() => 1) as never } }),
}),
@ -412,7 +413,7 @@ describe("pi.live deltas", () => {
defineTool({
name: "noop",
description: "noop",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ output: [] }),
}),
);

View file

@ -8,7 +8,6 @@ import {
fauxText,
fauxToolCall,
type ToolResultMessage,
Type,
} from "@earendil-works/pi-ai";
import {
defineExtension,
@ -23,6 +22,7 @@ import {
ToolTask,
type ToolTaskInput,
} from "@earendil-works/pi-durable";
import { obj, opt, str } from "@earendil-works/pi-schema";
import { afterEach, describe, expect, it } from "vitest";
import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
import { allEntries, type ChatSetup, chatSetup, openChat } from "./chat-support.ts";
@ -31,7 +31,7 @@ import { nestedResultOf, nestedResults, viewHistory } from "./live-support.ts";
import { context } from "./session-support.ts";
import { aborted, deferred, eventually } from "./task-support.ts";
const Parameters = Type.Object({ text: Type.Optional(Type.String()) });
const Parameters = obj({ text: opt(str()) });
function tool(
name: string,

View file

@ -10,7 +10,6 @@ import {
fauxText,
fauxToolCall,
type ToolResultMessage,
Type,
type Usage,
} from "@earendil-works/pi-ai";
import {
@ -33,6 +32,7 @@ import {
type ToolTaskResult,
UsageDoc,
} from "@earendil-works/pi-durable";
import { obj, opt, str } from "@earendil-works/pi-schema";
import { afterEach, describe, expect, it } from "vitest";
import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
import { commitWrites } from "../src/testing/commit-writes.ts";
@ -42,7 +42,7 @@ import { endedAt, nestedResultOf, nestedResults, slotsOf, startOrder, viewHistor
import { context, onValue } from "./session-support.ts";
import { aborted, deferred, eventually } from "./task-support.ts";
const Parameters = Type.Object({ text: Type.Optional(Type.String()) });
const Parameters = obj({ text: opt(str()) });
type Tool = ToolRegistration<typeof Parameters>;
function tool(name: string, execute: Tool["execute"], extra: Partial<Tool> = {}): Tool {
@ -329,7 +329,7 @@ describe("nested tool calls", () => {
defineTool({
name: "pair",
description: "pair",
parameters: Type.Object({ a: Type.String(), b: Type.String() }),
parameters: obj({ a: str(), b: str() }),
execute: async (args) => {
runs++;
return { output: [{ type: "text", text: `${args.a}${args.b}` }] };

View file

@ -1,7 +1,7 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { type AssistantMessage, fauxAssistantMessage, fauxText, fauxToolCall, Type } from "@earendil-works/pi-ai";
import { type AssistantMessage, fauxAssistantMessage, fauxText, fauxToolCall } from "@earendil-works/pi-ai";
import {
type Conversation,
type ConversationHandle,
@ -20,6 +20,7 @@ import {
type Submission,
type TaskId,
} from "@earendil-works/pi-durable";
import { obj, str } from "@earendil-works/pi-schema";
import { afterEach, describe, expect, it } from "vitest";
import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
import { chatSetup, openChat } from "./chat-support.ts";
@ -1019,7 +1020,7 @@ describe("owned conversations from tools and supervisors", () => {
defineTool({
name: "delegate",
description: "Delegates",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, callContext) => {
const child = await api.commit(async (tx) => {
const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
@ -1074,7 +1075,7 @@ describe("owned conversations from tools and supervisors", () => {
defineTool({
name: "delegate",
description: "Delegates late",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, callContext) => {
const child = await api.commit(async (tx) => {
const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
@ -1159,7 +1160,7 @@ describe("owned conversations from tools and supervisors", () => {
defineTool({
name: "delegate",
description: "Delegates",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, callContext) => {
child = await api.commit(async (tx) => {
const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
@ -1208,7 +1209,7 @@ describe("owned conversations from tools and supervisors", () => {
defineTool({
name: "delegate",
description: "Delegates and cancels",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, callContext) => {
const child = await api.commit(async (tx) => {
const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
@ -1247,7 +1248,7 @@ describe("owned conversations from tools and supervisors", () => {
defineTool({
name: "spawn",
description: "Starts owned work, then throws or hangs",
parameters: Type.Object({ mode: Type.String() }),
parameters: obj({ mode: str() }),
execute: async (args, api, callContext) => {
const child = await api.commit(async (tx) => {
const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
@ -1305,7 +1306,7 @@ describe("owned conversations from tools and supervisors", () => {
defineTool({
name: "subagent",
description: "Delegates",
parameters: Type.Object({}),
parameters: obj({}),
replay: "safe",
execute: async (_args, api, callContext) => {
const child = await api.commit(async (tx) => {

View file

@ -1,4 +1,4 @@
import { type SystemMessage, type Tool, Type } from "@earendil-works/pi-ai";
import type { SystemMessage, Tool } from "@earendil-works/pi-ai";
import { getCurrentTools, toToolDeclaration } from "@earendil-works/pi-ai/utils/transcript";
import {
type Conversation,
@ -12,6 +12,7 @@ import {
type ToolRegistration,
wrapSection,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import { resolveAgent, resolveSettings } from "../src/harness/agent.ts";
import { planSystemEntries, renderSections, replaySections } from "../src/harness/prompt.ts";
@ -207,7 +208,7 @@ describe("tool loadout preparation", () => {
const declaration = (name: string, description = name): Tool => ({
name,
description,
parameters: Type.Object({}),
parameters: obj({}),
});
/** Plan tools only, append the plan, check that replay offers `tools` in order, and return each message's changes. */

View file

@ -1,4 +1,3 @@
import { Type } from "@earendil-works/pi-ai";
import {
type AgentState,
CompactionTask,
@ -15,6 +14,7 @@ import {
wrapSection,
wrapTool,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import { agentHooks, resolveAgent, resolveSettings } from "../src/harness/agent.ts";
import { context } from "./session-support.ts";
@ -25,7 +25,7 @@ function tool(name: string, extra: Partial<AppTool> = {}): AppTool {
return {
name,
description: `${name} tool`,
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ output: [] }),
...extra,
};

View file

@ -1,4 +1,4 @@
import { createModels, fauxAssistantMessage, Type } from "@earendil-works/pi-ai";
import { createModels, fauxAssistantMessage } from "@earendil-works/pi-ai";
import {
type AnyTask,
createRegistry,
@ -14,6 +14,7 @@ import {
section,
wrapTool,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import { chatSetup } from "./chat-support.ts";
import { ControlledStorage, context } from "./session-support.ts";
@ -189,7 +190,7 @@ describe("reports", () => {
const echo = defineTool({
name: "echo",
description: "echo",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({}),
});
registry.install(defineExtension({ name: "tools", tools: [echo] }));

View file

@ -1,6 +1,7 @@
// What a crash (a host that dies, is evicted, or is reset) does to work that was running: a generation's interrupted
// request is a failed attempt under the retry policy, and every other task ends visibly once it was cut off
// `settings.restartLimit` times in a row (#11139).
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@ -13,7 +14,6 @@ import {
type Message,
type Models,
type ToolResultMessage,
Type,
} from "@earendil-works/pi-ai";
import {
defineTool,
@ -27,6 +27,7 @@ import {
type SubmissionId,
type TaskId,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { afterEach, describe, expect, it } from "vitest";
import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
import { commitWrites } from "../src/testing/commit-writes.ts";
@ -282,7 +283,7 @@ describe("a generation interrupted while the model answers (#11139)", () => {
defineTool({
name: "hold",
description: "Waits",
parameters: Type.Object({}),
parameters: obj({}),
replay: "safe",
execute: async () => {
started.resolve();
@ -375,7 +376,7 @@ describe("other tasks cut off restartLimit times in a row (#11139)", () => {
defineTool({
name: "heavy",
description: "Never finishes",
parameters: Type.Object({}),
parameters: obj({}),
replay: "safe",
execute: async (_args, api, ctx) => {
runs++;
@ -751,7 +752,7 @@ describe("the restart limit on the normal path (#11139)", () => {
defineTool({
name: "t",
description: "t",
parameters: Type.Object({}),
parameters: obj({}),
replay: "safe",
execute: async () => ({}),
}),

View file

@ -1,5 +1,6 @@
// `pi.live.runFailure`: why the newest run ended without an answer, as state a renderer shows (#11327).
import { type FauxResponseStep, fauxAssistantMessage, fauxText, fauxToolCall, Type } from "@earendil-works/pi-ai";
import { type FauxResponseStep, fauxAssistantMessage, fauxText, fauxToolCall } from "@earendil-works/pi-ai";
import {
type AfterFailedRun,
type Conversation,
@ -10,6 +11,7 @@ import {
MemoryStorage,
type RunFailure,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import { chatSetup, openChat, unanswered } from "./chat-support.ts";
import { addTool } from "./harness-support.ts";
@ -104,7 +106,7 @@ describe("pi.live.runFailure", () => {
defineTool({
name: "hold",
description: "Waits",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => {
started.resolve();
await gate.promise;

View file

@ -8,7 +8,6 @@ import {
fauxText,
fauxToolCall,
type ToolResultMessage,
Type,
} from "@earendil-works/pi-ai";
import {
defineTool,
@ -21,6 +20,7 @@ import {
ToolResultEntry,
ToolTask,
} from "@earendil-works/pi-durable";
import { nul, num, obj } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../src/env/node.ts";
import { createBashTool } from "../src/tools/index.ts";
@ -28,14 +28,14 @@ import { allEntries, type ChatSetup, chatSetup, openChat } from "./chat-support.
import { addHooks, addTool } from "./harness-support.ts";
import { context } from "./session-support.ts";
const Count = Type.Object({ count: Type.Number() });
const Count = obj({ count: num() });
/** A tool that returns `result`, with `extra` such as a schema. */
function returning(name: string, result: ToolExecutionResult, extra: Partial<ToolRegistration> = {}): ToolRegistration {
return defineTool({
name,
description: name,
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => result,
...extra,
});
@ -55,7 +55,7 @@ async function nested(setup: ChatSetup, name: string): Promise<NestedToolExecuti
defineTool({
name: "probe",
description: "probe",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, callContext) => {
received = await api.executeTool(name, {}, callContext);
return {};
@ -132,17 +132,14 @@ describe("structured output", () => {
it("stores exactly what callers and events get, without the model's output, and keeps a stored null", async () => {
const setup = chatSetup();
addTool(setup.registry, returning("plain", { output: [{ type: "text", text: "a" }] }));
addTool(
setup.registry,
returning("nothing", { structuredOutput: null }, { structuredOutputSchema: Type.Null() }),
);
addTool(setup.registry, returning("nothing", { structuredOutput: null }, { structuredOutputSchema: nul() }));
const received: NestedToolExecutionResult[] = [];
addTool(
setup.registry,
defineTool({
name: "probe",
description: "probe",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, callContext) => {
received.push(await api.executeTool("plain", {}, callContext));
received.push(await api.executeTool("nothing", {}, callContext));
@ -272,7 +269,7 @@ describe("structured output", () => {
defineTool({
name: "throws",
description: "throws",
parameters: Type.Object({}),
parameters: obj({}),
structuredOutputSchema: Count,
execute: async () => {
throw new Error("permission denied");
@ -344,7 +341,7 @@ describe("structured output", () => {
defineTool({
name: "probe",
description: "probe",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, callContext) => {
plain = await api.executeTool("plain", {}, callContext);
counter = await api.executeTool("counter", {}, callContext);
@ -375,7 +372,7 @@ describe("structured output", () => {
defineTool({
name: "probe",
description: "probe",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, callContext) => {
received.push(await api.executeTool("bash", { command: "printf ok" }, callContext));
received.push(await api.executeTool("bash", { command: "printf bad; exit 4" }, callContext));

Some files were not shown because too many files have changed in this diff Show more