/** * Shared tool argument boundary. Pi owns preparation and acceptance; tool checks * report semantic issues. Failure-only diagnostics observe Pi's prepared value, * then project and aggregate the declared schema in argument-diagnostics.ts. * Pi still validates returned arguments in its mandatory tool-call pipeline. * Tool schemas have no cross-field constraints. */ import { validateToolArguments, type ToolCall } from "@earendil-works/pi-ai"; import { ObjectOptions, Type, type Static, type TObject, type TSchema } from "typebox"; import { errorMessage } from "../core/errors.ts"; import { diagnoseArguments } from "./argument-diagnostics.ts"; import { formatArgumentError } from "./argument-error.ts"; import type { OutputBudgetProvider } from "./budgets.ts"; import type { ArgumentIssue, ReportTool } from "./report-schema.ts"; /** Report one semantic issue: what is wrong (a fact), and how to correct it when the fact alone does not say. */ export type ReportArgumentIssue = (field: string, reason: string, fix?: string) => void; export type CheckArguments = (args: unknown, report: ReportArgumentIssue) => void; /** Inspect arrays and the singleton objects Pi may coerce, without changing input. */ export function argumentItems(value: unknown): readonly unknown[] { return Array.isArray(value) ? value : value !== null && typeof value === "object" ? [value] : []; } export function createArgumentPreparer( name: ReportTool, parameters: T, check: CheckArguments | undefined, budgets: OutputBudgetProvider, ): (args: unknown) => Static { const validate = (schema: TSchema, args: unknown): unknown => validateToolArguments( { name, parameters: schema, description: "" }, // Pi owns shape checking and coercion at this external boundary. { type: "toolCall", id: "argument-validation", name, arguments: args as ToolCall["arguments"], }, ); return (args) => { let checked: unknown = args; let schemaFailure: unknown; try { checked = validate(parameters, args); } catch (error) { schemaFailure = error; } if (schemaFailure !== undefined) { let observation: { readonly value: unknown } | undefined; // Pi does not expose failed prepared values. A successful allOf refinement // observes its error pass without changing coercion, null handling or acceptance. const observer = Type.Object(parameters.properties, { ...ObjectOptions(parameters), allOf: [ Type.Refine(Type.Unknown(), (value: unknown) => { observation = { value }; return true; }), ], }); try { validate(observer, args); } catch { // The original schema already failed; this call only observes preparation. } if (!observation) { throw new Error( formatArgumentError( [{ field: "$", reason: errorMessage(schemaFailure) }], false, budgets(), ), { cause: schemaFailure }, ); } checked = observation.value; } const issues: ArgumentIssue[] = []; check?.(checked, (field, reason, fix) => issues.push(fix === undefined ? { field, reason } : { field, reason, fix }), ); // Keep Pi's preparation contract: the framework performs its own coercion next. if (schemaFailure === undefined && issues.length === 0) return args as Static; const result = diagnoseArguments(parameters, checked, issues, schemaFailure !== undefined); const reported = result.issues.length > 0 ? result.issues : [{ field: "$", reason: errorMessage(schemaFailure) }]; throw new Error(formatArgumentError(reported, result.limited, budgets()), { cause: schemaFailure, }); }; }