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

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" }] };