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

@ -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));

View file

@ -8,7 +8,6 @@ import {
fauxToolCall,
type Models,
type ToolResultMessage,
Type,
} from "@earendil-works/pi-ai";
import {
type Conversation,
@ -35,6 +34,7 @@ import {
ToolResultEntry,
type Tx,
} 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 { allEntries, chatSetup, openChat, waitFor } from "./chat-support.ts";
@ -1192,7 +1192,7 @@ function blockingTool(name: string): { started: Promise<void>; registration: Too
registration: defineTool({
name,
description: `The ${name} tool`,
parameters: Type.Object({}),
parameters: obj({}),
executionMode: "sequential",
execute: async (_args, api, callContext) => {
api.output("partial");
@ -1218,7 +1218,7 @@ describe("tool rounds", () => {
defineTool({
name: "noop",
description: "Does nothing",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ output: [] }),
}),
);
@ -1298,7 +1298,7 @@ describe("tool rounds", () => {
defineTool({
name: "noop",
description: "Does nothing",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ output: [] }),
}),
);
@ -1826,7 +1826,7 @@ describe("tool rounds as the view shows them", () => {
defineTool({
name: "noop",
description: "Does nothing",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ output: [] }),
}),
);
@ -1866,7 +1866,7 @@ describe("tool rounds as the view shows them", () => {
defineTool({
name: "delegate",
description: "Starts work in a conversation it owns",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, callContext) => {
await api.commit(async (tx) => {
const child = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
@ -1906,7 +1906,7 @@ describe("tool rounds as the view shows them", () => {
defineTool({
name: "broken",
description: "Starts owned work, then returns a result that is not strict JSON",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, callContext) => {
await api.commit((tx) => tx.createTask(Node, { name: "held" }, owned(api.taskId)), callContext);
return { output: [], details: { fn: (() => 1) as unknown as JsonValue } };

View file

@ -1,5 +1,6 @@
// Admission for hosts that must not start runs: `whenIdle: "reject"`, and `submit(tx, draft)` inside the caller's
// commit (#11149).
import { withTelemetry } from "@earendil-works/chord/context";
import {
type AssistantMessage,
@ -7,7 +8,6 @@ import {
fauxAssistantMessage,
fauxText,
fauxToolCall,
Type,
} from "@earendil-works/pi-ai";
import {
type Conversation,
@ -26,6 +26,7 @@ import {
type Tx,
UserEntry,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { InMemoryTelemetryContext } from "@earendil-works/pi-telemetry";
import { describe, expect, it } from "vitest";
import { type ChatSetup, chatSetup, openChat, unanswered, waitFor } from "./chat-support.ts";
@ -80,7 +81,7 @@ function addHold(setup: ChatSetup) {
defineTool({
name: "hold",
description: "Holds until released",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => {
reached.resolve();
await release.promise;
@ -180,7 +181,7 @@ describe("whenIdle reject", () => {
defineTool({
name: "nudge",
description: "Steers another conversation",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, callContext) => {
const handle = (await api.conversation(other!.id, callContext))!;
const draft = { type: "input", content: "nudge", whenBusy: "steer", whenIdle: "reject" } as const;
@ -525,7 +526,7 @@ describe("submit inside the caller's commit", () => {
defineTool({
name: "delegate",
description: "Delegates to another conversation",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, callContext) => {
toolTask = api.taskId;
const handle = (await api.conversation(other!.id, callContext))!;
@ -611,7 +612,7 @@ describe("submit inside the caller's commit", () => {
defineTool({
name: "hold",
description: "Holds until released, also when aborted",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, callContext) => {
handle = (await api.conversation(other!.id, callContext))!;
callContext.abortSignal!.addEventListener("abort", () => abortSeen.resolve(), { once: true });

View file

@ -7,7 +7,7 @@ import type {
ToolResultMessage,
UserMessage,
} from "@earendil-works/pi-ai";
import { createModels, Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai";
import {
type AnyTask,
createRegistry,
@ -23,10 +23,11 @@ import {
section,
type ToolRegistration,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { context } from "./session-support.ts";
export function tool(name: string, description = `${name} tool`): ToolRegistration {
return defineTool({ name, description, parameters: Type.Object({}), execute: async () => ({ output: [] }) });
return defineTool({ name, description, parameters: obj({}), execute: async () => ({ output: [] }) });
}
/** Open a Harness with a fresh registry holding the named tools. */

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 { Context, JsonValue } from "@earendil-works/chord";
import { createModels, Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai";
import {
createRegistry,
defineDoc,
@ -15,6 +15,7 @@ import {
type TaskId,
type TaskRuntime,
} 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 { addTask, addTool } from "./harness-support.ts";
@ -482,7 +483,7 @@ describe("blocked tasks", () => {
defineTool({
name: "unrelated",
description: "unrelated",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({}),
}),
);

View file

@ -1,6 +1,6 @@
import type { Context } from "@earendil-works/chord";
import { withAbortSignal, withCancel } from "@earendil-works/chord/context";
import { createModels, Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai";
import {
type CommitPublication,
type Conversation,
@ -18,6 +18,7 @@ import {
type TaskId,
type TaskRuntime,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { InMemoryTelemetryContext } from "@earendil-works/pi-telemetry";
import { describe, expect, it } from "vitest";
import type { SessionImpl } from "../src/session/session.ts";
@ -424,7 +425,7 @@ describe("task phases", () => {
defineTool({
name: "late",
description: "late",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({}),
}),
);

View file

@ -1,5 +1,5 @@
import { withTelemetry } from "@earendil-works/chord/context";
import { fauxAssistantMessage, fauxToolCall, Type } from "@earendil-works/pi-ai";
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
import { CodemodeSandbox } from "@earendil-works/pi-codemode";
import {
CompactionTask,
@ -11,6 +11,7 @@ import {
type Storage,
type TaskId,
} from "@earendil-works/pi-durable";
import { obj, str } from "@earendil-works/pi-schema";
import {
InMemoryTelemetryContext,
NOOP_TELEMETRY_CONTEXT,
@ -53,7 +54,7 @@ describe("Durable telemetry", () => {
defineTool({
name: "inner",
description: "inner",
parameters: Type.Object({ secret: Type.String() }),
parameters: obj({ secret: str() }),
execute: async () => ({ output: [{ type: "text", text: "private-result" }] }),
}),
);
@ -62,7 +63,7 @@ describe("Durable telemetry", () => {
defineTool({
name: "outer",
description: "outer",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, ctx) => {
await api.executeTool("inner", { secret: "private-argument" }, ctx);
return { output: [{ type: "text", text: "private-output" }] };
@ -347,7 +348,7 @@ describe("Durable telemetry", () => {
defineTool({
name: "hold",
description: "hold",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => {
reached.resolve();
await gate.promise;
@ -415,7 +416,7 @@ describe("Durable telemetry", () => {
defineTool({
name: "delegate",
description: "delegate",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api, ctx) => {
const child = await api.commit(
(tx) => tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }),
@ -952,7 +953,7 @@ describe("Durable telemetry", () => {
defineTool({
name: "once",
description: "once",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => {
runs++;
return { output: [] };
@ -965,7 +966,7 @@ describe("Durable telemetry", () => {
name: "hold",
description: "hold",
replay: "safe",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, _api, ctx) => {
if (!recover) {
reached.resolve();

View file

@ -1,5 +1,6 @@
// `afterTool` runs on every result a tool task writes, also those the Harness decides without executing the tool
// (#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 ToolResultMessage,
Type,
} from "@earendil-works/pi-ai";
import {
defineTool,
@ -29,6 +29,7 @@ import {
ToolResultEntry,
ToolTask,
} from "@earendil-works/pi-durable";
import { bool, 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";
@ -79,7 +80,7 @@ function stored(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 50));
}
const Parameters = Type.Object({ text: Type.Optional(Type.String()) });
const Parameters = obj({ text: opt(str()) });
function tool(
name: string,
@ -318,7 +319,7 @@ describe("afterTool on results the call phase decides", () => {
addTool(
setup.registry,
tool("typed", async () => ({ output: [], structuredOutput: { ok: true } }), {
structuredOutputSchema: Type.Object({ ok: Type.Boolean() }),
structuredOutputSchema: obj({ ok: bool() }),
}),
);
const nested: NestedToolExecutionResult[] = [];

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 { obj } from "@earendil-works/pi-schema";
import { afterEach, describe, expect, it } from "vitest";
import type { ExecutionEnv } from "../src/env/index.ts";
import { NodeExecutionEnv } from "../src/env/node.ts";
@ -55,7 +55,7 @@ function tool(
execute: ToolRegistration["execute"],
extra: Partial<ToolRegistration> = {},
): ToolRegistration {
return defineTool({ name, description: name, parameters: Type.Object({}), execute, ...extra });
return defineTool({ name, description: name, parameters: obj({}), execute, ...extra });
}
function call(name: string, id = "c1"): AssistantMessage {

View file

@ -11,7 +11,6 @@ import {
type Models,
type SystemMessage,
type ToolResultMessage,
Type,
} from "@earendil-works/pi-ai";
import {
AgentDoc,
@ -32,6 +31,7 @@ import {
ToolResultEntry,
ToolTask,
} from "@earendil-works/pi-durable";
import { obj, opt, str } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../src/env/node.ts";
import { createBashTool, createEditTool, createReadTool } from "../src/tools/index.ts";
@ -40,7 +40,7 @@ import { addHooks, addTask, addTool, type Installed } from "./harness-support.ts
import { context } from "./session-support.ts";
import { aborted, deferred } from "./task-support.ts";
const EchoParameters = Type.Object({ text: Type.Optional(Type.String()) });
const EchoParameters = obj({ text: opt(str()) });
type Echo = ToolRegistration<typeof EchoParameters>;
type Execute = Echo["execute"];

View file

@ -1,8 +1,9 @@
// Per-conversation turn limits (`AgentState.maxTurns`) and continuing a run that reached one (#11043).
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 AfterFailedRun,
type Conversation,
@ -16,6 +17,7 @@ import {
ToolResultEntry,
ToolTask,
} 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 { allEntries, type ChatSetup, chatSetup, openChat, textOf } from "./chat-support.ts";
@ -33,7 +35,7 @@ afterEach(async () => {
directories.clear();
});
const Parameters = Type.Object({});
const Parameters = obj({});
function tool(
name: string,

View file

@ -1,5 +1,6 @@
// What a renderer sees of a run through the conversation view: the transcript window and `pi.live`, every change a
// commit makes, rebuilt from operations alone. This replaced the event stream (`watchEvents`, #11327).
import { GCProfiler, getHeapStatistics, setFlagsFromString } from "node:v8";
import { runInNewContext } from "node:vm";
import type { Draft } from "@earendil-works/chord";
@ -11,7 +12,6 @@ import {
fauxText,
fauxThinking,
fauxToolCall,
Type,
} from "@earendil-works/pi-ai";
import {
type Conversation,
@ -22,6 +22,7 @@ import {
type LiveState,
MemoryStorage,
} from "@earendil-works/pi-durable";
import { num, obj } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import { type ChatSetup, chatSetup, openChat, textOf, waitFor } from "./chat-support.ts";
import { addTool } from "./harness-support.ts";
@ -126,7 +127,7 @@ describe("a run as the conversation view shows it", () => {
defineTool({
name: "print",
description: "Prints",
parameters: Type.Object({ n: Type.Number() }),
parameters: obj({ n: num() }),
execute: async (_args, api) => {
api.output("one\n");
await new Promise((resolve) => setTimeout(resolve, 150));
@ -259,7 +260,7 @@ describe("a run as the conversation view shows it", () => {
defineTool({
name: "tail",
description: "Prints lines",
parameters: Type.Object({}),
parameters: obj({}),
outputLimits: { maxLines: 3, retain: "tail" },
execute: async (_args, api) => {
for (let line = 0; line < 6; line++) {
@ -339,7 +340,7 @@ describe("a run as the conversation view shows it", () => {
defineTool({
name: "wait",
description: "Waits until aborted",
parameters: Type.Object({}),
parameters: obj({}),
execute: (_args, _api, callContext) => aborted(callContext.abortSignal!),
}),
);
@ -380,7 +381,7 @@ describe("a run as the conversation view shows it", () => {
defineTool({
name: "hold",
description: "Waits",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => {
await gate.promise;
return {};

View file

@ -10,6 +10,7 @@
*
* node --conditions=source --experimental-strip-types --expose-gc test/nested-images-bench.ts [count]
*/
import { randomBytes } from "node:crypto";
import { mkdtemp, readdir, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
@ -18,7 +19,7 @@ import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context";
import type { ImageContent } 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 { Type } from "typebox";
import { arr, lit, num, obj, opt, str } from "@earendil-works/pi-schema";
import {
createRegistry,
defineExtension,
@ -95,11 +96,11 @@ function noWrites(): Written {
};
}
const ImageArgument = Type.Object({
type: Type.Literal("image"),
mimeType: Type.String(),
data: Type.Optional(Type.String()),
source: Type.Optional(Type.Object({ taskId: Type.Number() })),
const ImageArgument = obj({
type: lit("image"),
mimeType: str(),
data: opt(str()),
source: opt(obj({ taskId: num() })),
});
function registryFor(scenario: Scenario, probe: Probe) {
@ -107,7 +108,7 @@ function registryFor(scenario: Scenario, probe: Probe) {
const readImage = defineTool({
name: "read_image",
description: "Read an image",
parameters: Type.Object({ n: Type.Number() }),
parameters: obj({ n: num() }),
// One image, so programs get it as an `ImageContent`. Unique per call: base64 of random bytes.
execute: async () => ({
output: [
@ -118,7 +119,7 @@ function registryFor(scenario: Scenario, probe: Probe) {
const classify = defineTool({
name: "classify",
description: "Classify images",
parameters: Type.Object({ images: Type.Array(ImageArgument) }),
parameters: obj({ images: arr(ImageArgument) }),
execute: async (args, api, callContext) => {
for (const image of args.images) {
let data = image.data;
@ -141,7 +142,7 @@ function registryFor(scenario: Scenario, probe: Probe) {
const script = defineTool({
name: "script",
description: "Read and classify images",
parameters: Type.Object({ count: Type.Number() }),
parameters: obj({ count: num() }),
callers: ["model"],
execute: async (args, api, callContext) => {
for (let n = 0; n < args.count; n++) {

View file

@ -9,13 +9,14 @@
*
* node --conditions=source --experimental-strip-types --expose-gc test/nested-tools-bench.ts [streaming|nesting]
*/
import { mkdtemp, readdir, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
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 { Type } from "typebox";
import { num, obj } from "@earendil-works/pi-schema";
import {
type Conversation,
createRegistry,
@ -103,7 +104,7 @@ async function runScenario(scenario: Scenario): Promise<{ perRun: Run[]; closedB
const blob = defineTool({
name: "blob",
description: "Stream a blob",
parameters: Type.Object({ n: Type.Number() }),
parameters: obj({ n: num() }),
execute: async (_args, api) => {
for (let n = 0; n < CHUNKS; n++) {
api.output(chunk);
@ -118,7 +119,7 @@ async function runScenario(scenario: Scenario): Promise<{ perRun: Run[]; closedB
defineTool({
name,
description: `Call ${target} in parallel`,
parameters: Type.Object({ count: Type.Number() }),
parameters: obj({ count: num() }),
execute: async (args, api, callContext) => {
const calls = Array.from({ length: args.count }, (_, n) =>
api.executeTool(target, { n, count: FAN_OUT }, callContext, options),

View file

@ -5,10 +5,11 @@
*
* node --conditions=source --experimental-strip-types test/scheduler-fanout-bench.ts [calls...]
*/
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 { Type } from "typebox";
import { num, obj } from "@earendil-works/pi-schema";
import { createRegistry, defineExtension, defineTool, Harness, ToolResultEntry } from "../src/index.ts";
import { MemoryStorage } from "../src/storage/memory.ts";
@ -20,13 +21,13 @@ async function run(calls: number): Promise<{ ms: number; commits: number }> {
const leaf = defineTool({
name: "leaf",
description: "Return at once",
parameters: Type.Object({ n: Type.Number() }),
parameters: obj({ n: num() }),
execute: async () => ({ output: [{ type: "text", text: "ok" }] }),
});
const fan = defineTool({
name: "fan",
description: "Call leaf in parallel",
parameters: Type.Object({ count: Type.Number() }),
parameters: obj({ count: num() }),
execute: async (args, api, callContext) => {
const results = await Promise.all(
Array.from({ length: args.count }, (_, n) =>

View file

@ -1,6 +1,7 @@
// The Session's document cache is bounded by use: unattached documents leave it after the retention period and load
// from Storage again; documents with a watch or a document state never leave it.
import { fauxAssistantMessage, fauxText, fauxToolCall, Type } from "@earendil-works/pi-ai";
import { fauxAssistantMessage, fauxText, fauxToolCall } from "@earendil-works/pi-ai";
import {
type ConversationId,
defineDoc,
@ -11,6 +12,7 @@ import {
MemoryStorage,
type StorageWrite,
} from "@earendil-works/pi-durable";
import { obj } from "@earendil-works/pi-schema";
import { describe, expect, it, onTestFinished } from "vitest";
import { SessionImpl, setDefaultDocumentRetention } from "../src/session/session.ts";
import { chatSetup, openChat } from "./chat-support.ts";
@ -519,7 +521,7 @@ describe("document cache retention", () => {
defineTool({
name: "steps",
description: "Streams three steps",
parameters: Type.Object({}),
parameters: obj({}),
execute: async (_args, api) => {
for (const step of ["a\n", "b\n", "c\n"]) {
api.output(step);

View file

@ -3,8 +3,9 @@
* the names the spec leaves to the application declared below. Keep the two in sync; `test/examples/` runs the same
* patterns end to end.
*/
import type { Context, Draft } from "@earendil-works/chord";
import { type AssistantMessage, type Models, type ToolCall, Type } from "@earendil-works/pi-ai";
import type { AssistantMessage, Models, ToolCall } from "@earendil-works/pi-ai";
import {
type Conversation,
type ConversationId,
@ -34,6 +35,7 @@ import {
type UserInput,
wrapTool,
} from "@earendil-works/pi-durable";
import { obj, str } from "@earendil-works/pi-schema";
import { expect, expectTypeOf, it } from "vitest";
import type { ExecutionEnv } from "../src/env/index.ts";
import { CodingTools, createBashTool, createEditTool, createReadTool } from "../src/tools/index.ts";
@ -169,7 +171,7 @@ function examples() {
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",
execute: async (args, api, context) => {
const { task } = args;

View file

@ -4,7 +4,6 @@ import {
type Model,
type Models,
normalizeContext,
Type,
uuidv7,
} from "@earendil-works/pi-ai";
import { builtinModels } from "@earendil-works/pi-ai/providers/all";
@ -18,6 +17,7 @@ import {
hook,
MemoryStorage,
} from "@earendil-works/pi-durable";
import { obj, str } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import { streamSimple as streamSimpleAnthropic } from "../../ai/src/api/anthropic-messages.ts";
import { resolveApiKey } from "../../ai/test/oauth.ts";
@ -41,7 +41,7 @@ function instructions(nonce: string): string {
return lines.join("\n");
}
const Parameters = Type.Object({ text: Type.String() });
const Parameters = obj({ text: str() });
const first = defineTool({
name: "probe_first",
description: "Unused probe tool.",

View file

@ -7,6 +7,7 @@
* then close the Harness mid-round (persistent backends) to measure replay of the round's delta chain on reopen, and
* finish the run to measure the stored size after the round's base and reclamation. Throughput scenarios push 1 GiB.
*/
import { execFile } from "node:child_process";
import { mkdtemp, open, readdir, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
@ -16,7 +17,7 @@ import { promisify } from "node:util";
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 { Type } from "typebox";
import { obj } from "@earendil-works/pi-schema";
import { NodeExecutionEnv } from "../src/env/node.ts";
import { createRegistry, defineTool, Harness, LiveDoc, MemoryStorage, type Storage } from "../src/index.ts";
import { openNodeJsonlStorage } from "../src/storage/jsonl/node.ts";
@ -145,7 +146,7 @@ async function runScenario(scenario: Scenario): Promise<Metrics> {
defineTool({
name: "emit",
description: "",
parameters: Type.Object({}),
parameters: obj({}),
outputLimits: { retain: scenario.retain },
execute: async (_args, api, callContext) => {
let counter = 0;

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,
@ -9,6 +9,7 @@ import {
MemoryStorage,
type ToolRegistration,
} from "@earendil-works/pi-durable";
import { num, obj, opt } from "@earendil-works/pi-schema";
import { describe, expect, it } from "vitest";
import { createToolSearchExtension } from "../src/tool-search/index.ts";
import { allEntries, type ChatSetup, chatSetup, openChat } from "./chat-support.ts";
@ -19,7 +20,7 @@ function tool(name: string, description: string, extra: Partial<ToolRegistration
return defineTool({
name,
description,
parameters: Type.Object({ number: Type.Optional(Type.Number({ description: "Issue number" })) }),
parameters: obj({ number: opt(num({ description: "Issue number" })) }),
execute: async () => ({ output: [{ type: "text", text: `${name} ran` }] }),
...extra,
});
@ -120,7 +121,7 @@ describe("deferred tools and tool_search", () => {
const grow = defineTool({
name: "grow",
description: "grow",
parameters: Type.Object({}),
parameters: obj({}),
execute: async () => ({ output: [], control: { addTools: ["github_get_issue", "alpha", "ghost"] } }),
});
addTool(chat.registry, grow);