diff --git a/package-lock.json b/package-lock.json index 3d56219..fe28a57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "tailwind-merge": "^3.6.0", "tokenlens": "^1.3.1", "turndown": "^7.2.4", + "typebox": "1.3.7", "use-stick-to-bottom": "^1.1.6", "zod": "^4.4.3" }, diff --git a/package.json b/package.json index 3dad78d..6ea7b04 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@ai-sdk/react": "^4.0.79", "@earendil-works/pi-agent-core": "^0.85.1", "@earendil-works/pi-ai": "^0.85.1", + "typebox": "1.3.7", "@mozilla/readability": "^0.6.0", "@radix-ui/react-use-controllable-state": "^1.2.6", "@rive-app/react-webgl2": "^4.32.1", diff --git a/src/agent/lh-tool.ts b/src/agent/lh-tool.ts deleted file mode 100644 index bf46a5c..0000000 --- a/src/agent/lh-tool.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { z } from "zod"; -import type { AgentTool } from "@earendil-works/pi-agent-core"; - -/** - * lh's neutral tool definition + the converter to pi-agent-core's AgentTool - * (docs/pi-engine-swap.md §3). The agent-band swap must not re-risk the - * battle-tested zod authoring contracts (the quiz correctAnswer wording, - * the nodeId superRefines) — so tools are AUTHORED exactly as before - * (zod schemas, same execute signatures) in this shape, and converted - * once, centrally, here. - * - * What the converter owns (the semantics the Vercel SDK gave implicitly): - * - parameters: z.toJSONSchema feeds the model-facing declaration (pi - * structurally validates plain JSON Schema itself). - * - refinements: superRefine rules don't survive JSON Schema — the schema - * is re-run inside execute, and a failure THROWS. pi's loop catches - * execute throws and turns them into an error tool result the model - * sees and retries on — the exact InvalidToolInputError semantics. - * - client question tools (quiz / ask_user_question): execute returns a - * synthetic "question posed" placeholder that is NEVER persisted — the - * record keeps the part output-less until the client answers; the - * learner's answer re-enters the next turn as the tool result. The - * turn itself stops via shouldStopAfterTurn (teacher-agent.ts). - */ - -export interface LhTool { - name: string; - description: string; - /** The authoring contract (zod, refinements included). */ - schema: z.ZodType; - /** - * Server-side execution. Absent + clientQuestion = the client answers. - * Throws on validation failure — the loop converts to an error result. - * - * ctx.signal (2026-09-19, the abort-cascade fix): the run's abort signal - * COMBINED with the system-level per-call timeout — aborting the run - * kills a running tool, the timeout is the net so no tool can hold a - * turn hostage (the 2.7-minute grep lesson). Tools with loops check it - * and return partial results; fast single-op tools may ignore it. - */ - execute?: (input: TInput, ctx: { toolCallId: string; signal?: AbortSignal }) => TResult | Promise; - /** Client-answered question tool (quiz/ask): a call ends the turn. */ - clientQuestion?: boolean; -} - -/** Author a tool (the `tool({...})` shape, engine-neutral). */ -export function lhTool(def: LhTool): LhTool { - return def; -} - -/** - * Rich tool-result escape hatch (2026-09 sources v0): a tool whose result - * includes IMAGES (the read tool reading an image file) returns this shape - * instead of a plain value — toPiTool maps it to pi's native tool-result - * content parts (TextContent | ImageContent)[] so the vision-capable model - * actually SEES the image. `images` carries base64 data + mimeType, exactly - * as pi's ImageContent wants. details rides through to the record untouched. - */ -export interface LhToolImageResult { - text: string; - images?: { data: string; mimeType: string }[]; - /** The record-facing result (client rendering / persistence). */ - details?: unknown; -} - -function isImageResult(v: unknown): v is LhToolImageResult { - return ( - typeof v === "object" && v !== null && "text" in v && typeof (v as LhToolImageResult).text === "string" - ); -} - -/** lh's port of the SDK's invalid-input message: what the model retried on. */ -export class ToolInputError extends Error { - constructor( - public readonly toolName: string, - public readonly zodError: z.ZodError, - ) { - super( - `Invalid tool input for tool "${toolName}":\n${zodError.issues - .map((issue) => { - const path = issue.path.length > 0 ? ` at ${issue.path.join(".")}` : ""; - return `- ${issue.message}${path}`; - }) - .join("\n")}`, - ); - this.name = "ToolInputError"; - } -} - -/** - * System-level per-call timeout (2026-09-19, the abort-cascade fix): pi - * passes every run's AbortSignal to tool execute, but the bridge used to - * drop it — so agent.abort() only landed at the NEXT step boundary and a - * long-running local tool (the grep that outlived its turn by minutes) - * held the turn hostage until the dev server's memory restart killed the - * run mid-tool, freezing the record at its write-ahead state (the "went - * back in time" rollback). The guard now races every execute against (a) - * the run's signal and (b) this timeout, rejecting with pi's own wording - * ("Operation aborted") on abort and a clear timeout message otherwise — - * pi turns the throw into an error tool result the model can see. - */ -const TOOL_TIMEOUT_MS = 30_000; -/** Test seam (runner.ts's pdfModelOverride idiom). */ -export const toolTimeoutMs = { current: TOOL_TIMEOUT_MS }; - -/** How long an aborted/timed-out call may take to settle on its own before - * the hard reject lands — cooperative tools poll the signal and return - * partial results within a tick or two (the fs tools check per file). */ -const TOOL_ABORT_GRACE_MS = 250; - -function runWithAbortGuard( - toolName: string, - run: (signal: AbortSignal) => T | Promise, - parent: AbortSignal | undefined, -): Promise { - const controller = new AbortController(); - const forwardAbort = () => controller.abort(); - if (parent) { - if (parent.aborted) controller.abort(); - else parent.addEventListener("abort", forwardAbort, { once: true }); - } - const timeoutMs = toolTimeoutMs.current; - const timer = setTimeout(() => controller.abort(), timeoutMs); - const humanTimeout = timeoutMs >= 1_000 ? `${Math.round(timeoutMs / 1000)}s` : `${timeoutMs}ms`; - let graceTimer: ReturnType | undefined; - const cleanup = () => { - clearTimeout(timer); - if (graceTimer) clearTimeout(graceTimer); - parent?.removeEventListener("abort", forwardAbort); - }; - return new Promise((resolve, reject) => { - const settle = (fn: () => void) => { - controller.signal.removeEventListener("abort", onAbort); - if (graceTimer) clearTimeout(graceTimer); - fn(); - }; - const onAbort = () => { - // Cooperative tools poll the signal and settle with partial - // results within a tick or two (the fs tools check per file) — - // give them a brief grace to deliver those before the hard - // reject, so abort KILLS a stuck tool without racing away a - // graceful one's partial result. - graceTimer = setTimeout( - () => - reject( - new Error( - parent?.aborted - ? "Operation aborted" - : `Tool "${toolName}" timed out after ${humanTimeout} — the result was discarded`, - ), - ), - TOOL_ABORT_GRACE_MS, - ); - }; - controller.signal.addEventListener("abort", onAbort, { once: true }); - if (controller.signal.aborted) onAbort(); // an already-aborted signal never fires listeners - Promise.resolve() - .then(() => run(controller.signal)) - .then( - (v) => settle(() => resolve(v)), - (e) => settle(() => reject(e)), - ); - }) - .finally(cleanup); -} - -/** Convert one lh tool to a pi AgentTool. */ -export function toPiTool(def: LhTool): AgentTool { - return { - name: def.name, - label: def.name, - description: def.description, - // io: "input" — the declaration describes what the MODEL must send, - // so .default() fields are optional in the schema (omission = the - // default); required-empty arrays are a known weak-model trap. - parameters: z.toJSONSchema(def.schema as z.ZodType, { io: "input" }) as never, - // Question tools opt into SEQUENTIAL execution: pi runs the whole - // batch in call order when any called tool opts in, so the - // tool-input-available chunks arrive in the model's call order — the - // dock's subject can never flip mid-turn between two parallel - // questions. Question executes are instant; no latency is lost. - executionMode: def.clientQuestion ? "sequential" : "parallel", - execute: async (toolCallId, args, signal) => { - // The authoring check: structural JSON Schema validation already - // ran in the loop's prepare step; this re-run adds the refinements - // (superRefine rules) the JSON Schema can't carry. - const parsed = def.schema.safeParse(args); - if (!parsed.success) throw new ToolInputError(def.name, parsed.error); - if (def.clientQuestion || !def.execute) { - // The synthetic placeholder — in-memory only; the record's tool - // part keeps no output until the learner answers. - return { - content: [{ type: "text", text: "Question posed — the turn ends here; the learner's graded answer arrives as this call's result." }], - details: null, - }; - } - const result = await runWithAbortGuard(def.name, (toolSignal) => def.execute!(parsed.data, { toolCallId, signal: toolSignal }), signal); - // Rich image result: model-facing content parts carry the images - // (pi's ToolResultMessage.content is (TextContent | ImageContent)[]). - if (isImageResult(result)) { - return { - content: [ - { type: "text", text: result.text }, - ...(result.images ?? []).map((img) => ({ - type: "image" as const, - data: img.data, - mimeType: img.mimeType, - })), - ], - details: result.details ?? null, - }; - } - return { - // The model's view: today's parity — string results pass verbatim, - // objects as JSON. - content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }], - details: result, - }; - }, - }; -} - -/** Convert a tool registry (name → LhTool) to pi's AgentTool[]. */ -export function toPiTools(registry: Record>): AgentTool[] { - return Object.entries(registry).map(([name, def]) => toPiTool({ ...def, name })); -} - -/** Declaration-only pi Tools (name/description/parameters, no execute) — - * for hand-rolled loops that own tool execution themselves. */ -export function piToolDeclarations(registry: Record>) { - return Object.entries(registry).map(([name, def]) => ({ - name, - label: name, - description: def.description, - // io: "input" — the declaration describes what the MODEL must send, - // so .default() fields are optional in the schema (omission = the - // default); required-empty arrays are a known weak-model trap. - parameters: z.toJSONSchema(def.schema as z.ZodType, { io: "input" }) as never, - })); -} \ No newline at end of file diff --git a/src/agent/subagents/demo-builder.ts b/src/agent/subagents/demo-builder.ts index 15397f6..1f434d2 100644 --- a/src/agent/subagents/demo-builder.ts +++ b/src/agent/subagents/demo-builder.ts @@ -1,6 +1,7 @@ -import { z } from "zod"; +import { Type } from "typebox"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; import type { Usage } from "@earendil-works/pi-ai"; -import { lhTool, toPiTools, type LhTool } from "../lh-tool.ts"; +import { serverExecute } from "../tool-execute.ts"; import type { PiEngine } from "../models.ts"; import { runSubagent } from "./pi-run.ts"; import type { DemoResult } from "../../core/contracts.ts"; @@ -77,39 +78,48 @@ interface BuilderState { lastResult: DemoRunResult | null; } +const writeDemoInput = Type.Object({ + html: Type.String({ description: "The complete HTML document: …, all JS inline." }), +}); + +const runDemoInput = Type.Object({ + probe: Type.Optional( + Type.String({ + description: + "Optional JS expression evaluated in the page after settling, e.g. \"document.querySelectorAll('input').length\".", + + }), + ), +}); + export function createDemoBuilderTools( state: BuilderState, headless: HeadlessApi, onActivity?: (line: string) => void, -): Record> { +): Record> { return { - write_demo: lhTool({ + write_demo: { name: "write_demo", + label: "write_demo", description: "Write the complete self-contained HTML document for the demo (overwrites any previous version). " + "Always follow with run_demo to test it headlessly.", - schema: z.object({ - html: z.string().describe("The complete HTML document: …, all JS inline."), - }), - execute: async ({ html }) => { + parameters: writeDemoInput, + execute: serverExecute("write_demo", writeDemoInput, async ({ html }) => { if (!html || html.trim().length === 0) return "ERROR: empty document — write the full HTML."; state.html = html; onActivity?.(`write_demo: ${(html.length / 1024).toFixed(1)} KB`); return `Saved (${html.length} bytes). Now call run_demo to test it — never deliver untested.`; - }, - }), - run_demo: lhTool({ + }), + }, + run_demo: { name: "run_demo", + label: "run_demo", description: "Headlessly execute the current demo in Chrome and report console errors, uncaught errors, and failed requests. " + "Optionally pass a probe expression to assert on page state.", - schema: z.object({ - probe: z - .string() - .optional() - .describe("Optional JS expression evaluated in the page after settling, e.g. \"document.querySelectorAll('input').length\"."), - }), - execute: async ({ probe }) => { + parameters: runDemoInput, + execute: serverExecute("run_demo", runDemoInput, async ({ probe }) => { if (!state.html) return "ERROR: nothing to run — call write_demo first."; const reason = headless.unavailableReason(); if (reason) return `HEADLESS UNAVAILABLE: ${reason}`; @@ -127,8 +137,8 @@ export function createDemoBuilderTools( result.ok ? "run_demo: clean" : `run_demo: ${summarizeCounts(result)}`, ); return formatRunResult(result); - }, - }), + }), + }, }; } @@ -190,7 +200,7 @@ export async function runDemoBuilder(input: DemoBuilderInput, opts: DemoBuilderO let finalText = ""; try { - const result = await runSubagent(toPiTools(createDemoBuilderTools(state, headless, opts.onActivity)), { + const result = await runSubagent(Object.values(createDemoBuilderTools(state, headless, opts.onActivity)), { role: "demoBuilder", engine: opts.engine, systemPrompt: INSTRUCTIONS, diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index e65fcb3..af4c2b3 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -1,8 +1,9 @@ import * as crypto from "node:crypto"; import type { UIMessage } from "ai"; import type { Usage } from "@earendil-works/pi-ai"; -import { z } from "zod"; -import { lhTool, toPiTools } from "../lh-tool.ts"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { Type, type Static, type TSchema } from "typebox"; +import { toToolResult } from "../tool-execute.ts"; import type { ModelRole } from "../../../config.ts"; import { FACTORY_SETTINGS, @@ -45,7 +46,7 @@ import { batchRuns, batchTag, coverageOf, messageViewLines } from "../../core/co * The LLM stages supply only the judgment calls; the deterministic rebuild * in the merger owns everything that must never be mis-copied (event kinds, * timestamps, refs, provenance points). Both agents are single-tool-call - * workers: their whole deliverable is one zod-validated submit call — a + * workers: their whole deliverable is one TypeBox-validated submit call — a * ToolLoopAgent purely so the model can retry a schema-invalid call. * * Created via createExtractionPipeline(deps) — the composition root @@ -101,75 +102,170 @@ export function transcriptView(record: ChatRecord): string { // ─── the extractor ────────────────────────────────────────────────────────── -const evidence = z - .array(z.string()) - .min(1) - .describe("Verbatim quotes from the transcript that back this. The auditor will check every quote."); +const evidenceSchema = Type.Array(Type.String(), { + minItems: 1, + description: "Verbatim quotes from the transcript that back this. The auditor will check every quote.", +}); -const messagesField = z - .array(z.string()) - .describe("The message ids (from the [id] tags in the transcript) where this came up — provenance."); +const messagesSchema = Type.Array(Type.String(), { + description: "The message ids (from the [id] tags in the transcript) where this came up — provenance.", +}); -const propositionSchema = z.object({ - id: z.string().describe("Local id: \"p1\", \"p2\", … — referenced by edges and attribution."), - name: z - .string() - .describe( - "A very short label for the proposition — a few words, a compact handle like a glossary headword " + - '(\'Will to power\', \'Divine command theory\'). Not a sentence, needn\'t be unique or ' + - "self-contained: the statement carries the precision. This becomes the node's label in the graph.", - ), - statement: z - .string() - .describe( - "The proposition as one reasoning step — a TRUE, self-contained, world-facing statement. " + - "Never reference the learner, the teacher, or the session: not \"the learner understands X\" but " + - '"X". A misconception is stated as the TRUE correction ("Nietzsche did not endorse ' + - 'might-makes-right social Darwinism") — the wrongness lives in the quiz outcomes, never here.', - ), - aliases: z.array(z.string()).describe("Verbatim phrasings this session used for the same proposition, if any."), - messages: messagesField, - evidence, - understanding: z - .enum(["solid", "shaky", "accepted", "resisted", "not yet engaged"]) - .optional() - .describe( - "ONE word — the learner's grasp of this proposition in THIS session, from the fixed scale " + +const understandingSchema = Type.Optional( + Type.Union( + [ + Type.Literal("solid"), + Type.Literal("shaky"), + Type.Literal("accepted"), + Type.Literal("resisted"), + Type.Literal("not yet engaged"), + ], + { + description: + "ONE word — the learner's grasp of this proposition in THIS session, from the fixed scale " + '"solid" | "shaky" | "accepted" | "resisted" | "not yet engaged". Nothing else: no numbers, ' + "no justification, no explanation.", + }, + ), +); + +const propositionProps = { + id: Type.String({ description: 'Local id: "p1", "p2", … — referenced by edges and attribution.' }), + name: Type.String({ + description: + "A very short label for the proposition — a few words, a compact handle like a glossary headword " + + "('Will to power', 'Divine command theory'). Not a sentence, needn't be unique or " + + "self-contained: the statement carries the precision. This becomes the node's label in the graph.", + }), + statement: Type.String({ + description: + "The proposition as one reasoning step — a TRUE, self-contained, world-facing statement. " + + 'Never reference the learner, the teacher, or the session: not "the learner understands X" but ' + + '"X". A misconception is stated as the TRUE correction ("Nietzsche did not endorse ' + + 'might-makes-right social Darwinism") — the wrongness lives in the quiz outcomes, never here.', + }), + aliases: Type.Array(Type.String(), { + description: "Verbatim phrasings this session used for the same proposition, if any.", + }), + messages: messagesSchema, + evidence: evidenceSchema, + understanding: understandingSchema, +}; + +const edgeSchema = Type.Object({ + from: Type.String({ description: "Proposition id that is followed / supported." }), + to: Type.String({ description: "Proposition id that follows / is supported by `from`." }), + type: Type.Union([Type.Literal("follows"), Type.Literal("supports")], { + description: '"follows" = to is derivable from from; "supports" = from grounds to without entailing it.', + }), + messages: messagesSchema, + evidence: evidenceSchema, +}); + +// Weak-model tolerance (owner, 2026-09-25): the old zod `.default([])` / +// `.default({ quiz: {} })` on required-but-empty fields becomes Type.Optional +// here + a ??-fill at the submit seam (fillFindings/fillAudit below) — same +// contract on the wire (omission tolerated, empty never means "invalid"), +// no zod left in the agent band. +const findingsProps = { + propositions: Type.Array(Type.Object(propositionProps), { + description: "Every distinct proposition the session touched — true statements, misconception-free.", + }), + edges: Type.Optional( + Type.Array(edgeSchema, { + description: + "Relations between propositions: follows (derivable) / supports (grounds without entailing). Pass [] when there are none — never omit the field.", + }), + ), + attribution: Type.Optional( + Type.Object( + { + quiz: Type.Record(Type.String(), Type.Union([Type.String(), Type.Null()]), { + description: + "toolCallId of EVERY quiz line → the proposition id it was really testing. Attribute RETROACTIVELY — the whole transcript is visible; map each quiz to the proposition it tests (often articulated elsewhere in the session). null when genuinely unattributable.", + }), + }, + { + description: + "Quiz attribution — your only event judgment. Pass { quiz: {} } when the session has no quiz lines — never omit the field.", + }, ), -}); + ), +}; -const edgeSchema = z.object({ - from: z.string().describe("Proposition id that is followed / supported."), - to: z.string().describe("Proposition id that follows / is supported by `from`."), - type: z - .enum(["follows", "supports"]) - .describe('"follows" = to is derivable from from; "supports" = from grounds to without entailing it.'), - messages: messagesField, - evidence, -}); +const findingsSchema = Type.Object(findingsProps); -const findingsSchema = z.object({ - propositions: z - .array(propositionSchema) - .describe("Every distinct proposition the session touched — true statements, misconception-free."), - edges: z - .array(edgeSchema) - .describe("Relations between propositions: follows (derivable) / supports (grounds without entailing). Pass [] when there are none — never omit the field.") - .default([]), // weak models omit required-but-empty arrays; omission means "none" +const auditProps = { + ...findingsProps, + propositions: Type.Array( + Type.Object({ + ...propositionProps, + linkTo: Type.Optional( + Type.String({ + description: + "The id of the EXISTING graph node this proposition IS — your identity verdict. Absent = create a new node. When unsure, omit: duplicates over wrong merges.", + }), + ), + }), + { + description: "Every distinct proposition the session touched — true statements, misconception-free.", + }, + ), + dropped: Type.Optional( + Type.Array( + Type.Object({ + ref: Type.String({ + description: 'Proposition id, "from→to" for an edge, or attribution toolCallId.', + }), + reason: Type.String(), + }), + { + description: + "Everything you dropped, with the reason — the audit trail. Pass [] when you dropped nothing — never omit the field.", + }, + ), + ), +}; - attribution: z - .object({ - quiz: z - .record(z.string(), z.string().nullable()) - .describe( - "toolCallId of EVERY quiz line → the proposition id it was really testing. Attribute RETROACTIVELY — the whole transcript is visible; map each quiz to the proposition it tests (often articulated elsewhere in the session). null when genuinely unattributable.", - ), - }) - .describe("Quiz attribution — your only event judgment. Pass { quiz: {} } when the session has no quiz lines — never omit the field.") - .default({ quiz: {} }), // weak models omit empty objects too -}); +const auditSchema = Type.Object(auditProps); + +// The notes front door's shapes: everything session findings have, minus +// quiz attribution — raw notes have no quiz tool parts, so there is +// nothing to attribute (and no event to build: outcomes inside the text +// surface only through understanding observations, user-decided). +const { attribution: _omitted, ...notesFindingsProps } = findingsProps; +const notesFindingsSchema = Type.Object(notesFindingsProps); +const { attribution: _omitted2, ...notesAuditProps } = auditProps; +const notesAuditSchema = Type.Object(notesAuditProps); +type NotesFindings = Omit; +type NotesAuditResult = Omit; + +// ── the weak-model ??-fills (the old zod defaults, applied at submit) ── + +function fillFindings(raw: Static): SessionFindings { + return { + propositions: raw.propositions, + edges: raw.edges ?? [], + attribution: raw.attribution ?? { quiz: {} }, + }; +} + +function fillNotesFindings(raw: Static): NotesFindings { + return { propositions: raw.propositions, edges: raw.edges ?? [] }; +} + +function fillAudit(raw: Static): AuditResult { + return { + propositions: raw.propositions, + edges: raw.edges ?? [], + attribution: raw.attribution ?? { quiz: {} }, + dropped: raw.dropped ?? [], + }; +} + +function fillNotesAudit(raw: Static): NotesAuditResult { + return { propositions: raw.propositions, edges: raw.edges ?? [], dropped: raw.dropped ?? [] }; +} const NOTES_EXTRACTOR_INSTRUCTIONS = `You are the extraction specialist in a learning application. You read ONE source — the user's own NOTES, in an ARBITRARY format (personal notes, an exported conversation, a document — anything) — and @@ -260,15 +356,19 @@ are no quizzes. NEVER omit a field because it is empty. Your entire deliverable is ONE submit_findings call.`; /** One single-submit agent run — the shared shape of EVERY LLM stage - * here (session + notes): the whole deliverable is ONE zod-validated + * here (session + notes): the whole deliverable is ONE TypeBox-validated * submit call (a pi agent purely so the model can retry a schema-invalid * call). The submitted value is captured from the execute seam, which - * only fires on a schema-valid call. */ + * only fires on a schema-valid call — the ??-fill (the old zod defaults) + * applies at the capture. */ async function runSubmitAgent(opts: { role: ModelRole; toolName: string; toolDescription: string; - schema: z.ZodType; + parameters: TSchema; + /** Weak-model tolerance: the old zod `.default()`s, applied to the + * schema-valid raw input at the submit seam. */ + fill: (input: any) => T; systemPrompt: string; prompt: string; engine: PiEngine | undefined; @@ -278,17 +378,18 @@ async function runSubmitAgent(opts: { }): Promise { let submitted: T | undefined; const result = await runSubagent( - toPiTools({ - [opts.toolName]: lhTool({ + [ + { name: opts.toolName, + label: opts.toolName, description: opts.toolDescription, - schema: opts.schema, - execute: async (findings) => { - submitted = findings; - return findings; + parameters: opts.parameters, + execute: async (_toolCallId, input) => { + submitted = opts.fill(input); + return toToolResult(submitted); }, - }), - }), + } satisfies AgentTool, + ], { role: opts.role, engine: opts.engine, @@ -326,7 +427,8 @@ function runExtractor( role: "extractor", toolName: "submit_findings", toolDescription: "Submit the session findings.", - schema: findingsSchema, + parameters: findingsSchema, + fill: fillFindings, systemPrompt: EXTRACTOR_INSTRUCTIONS, prompt, engine, @@ -348,7 +450,8 @@ function runNotesExtractor( role: "extractor", toolName: "submit_findings", toolDescription: "Submit the notes findings.", - schema: notesFindingsSchema, + parameters: notesFindingsSchema, + fill: fillNotesFindings, systemPrompt: NOTES_EXTRACTOR_INSTRUCTIONS, prompt, engine, @@ -360,36 +463,6 @@ function runNotesExtractor( // ─── the auditor ─────────────────────────────────────────────────────────── -const auditSchema = findingsSchema.extend({ - propositions: z.array( - propositionSchema.extend({ - linkTo: z - .string() - .optional() - .describe( - "The id of the EXISTING graph node this proposition IS — your identity verdict. Absent = create a new node. When unsure, omit: duplicates over wrong merges.", - ), - }), - ), - dropped: z - .array( - z.object({ - ref: z.string().describe("Proposition id, \"from→to\" for an edge, or attribution toolCallId."), - reason: z.string(), - }), - ) - .describe("Everything you dropped, with the reason — the audit trail. Pass [] when you dropped nothing — never omit the field.") - .default([]), // same weak-model tolerance as findings.edges -}); - -// The notes front door's shapes: everything session findings have, minus -// quiz attribution — raw notes have no quiz tool parts, so there is -// nothing to attribute (and no event to build: outcomes inside the text -// surface only through understanding observations, user-decided). -const notesFindingsSchema = findingsSchema.omit({ attribution: true }); -const notesAuditSchema = auditSchema.omit({ attribution: true }); -type NotesFindings = Omit; -type NotesAuditResult = Omit; const AUDITOR_INSTRUCTIONS = `You are the audit stage of a learning application's extraction pipeline. You receive ONE source — a session transcript, or the author's personal notes in an ARBITRARY format (the notes front door) — a @@ -453,7 +526,8 @@ function runAuditor( role: "auditor", toolName: "submit_review", toolDescription: "Submit the verified findings subset (with identity verdicts) + the dropped list.", - schema: auditSchema, + parameters: auditSchema, + fill: fillAudit, systemPrompt: AUDITOR_INSTRUCTIONS, prompt, engine, @@ -475,7 +549,8 @@ function runNotesAuditor( role: "auditor", toolName: "submit_review", toolDescription: "Submit the verified findings subset (with identity verdicts) + the dropped list.", - schema: notesAuditSchema, + parameters: notesAuditSchema, + fill: fillNotesAudit, systemPrompt: AUDITOR_INSTRUCTIONS, prompt, engine, diff --git a/src/agent/subagents/pdf-reader.ts b/src/agent/subagents/pdf-reader.ts index fea9349..8cd1d1d 100644 --- a/src/agent/subagents/pdf-reader.ts +++ b/src/agent/subagents/pdf-reader.ts @@ -1,6 +1,5 @@ -import type { AssistantMessage, Message, ToolResultMessage, Usage } from "@earendil-works/pi-ai"; -import { z } from "zod"; -import { lhTool, piToolDeclarations, type LhTool } from "../lh-tool.ts"; +import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts"; import type { PdfResult, PdfToolInput } from "../../core/contracts.ts"; import { getHeadless, type HeadlessApi } from "./headless.ts"; @@ -70,79 +69,75 @@ export interface PdfReaderOptions { export interface PdfReaderInput extends PdfToolInput {} -export function createPdfTools( - pdfPath: string, -): { - pdf_info: LhTool; - pdf_extract: LhTool; - pdf_render: LhTool; - pdf_search: LhTool; -} { +/** The wire declarations (pi-ai Tool, TypeBox parameters). This loop owns + * execution itself — pdf_info/pdf_extract run via the plain pdfInfoText/ + * pdfExtractText bodies below, pdf_search/pdf_render dispatch inline in + * the loop (search hits must join the citation set; renders feed the + * image-in-the-loop message) — so the declarations carry no execute. + * dpi/context_lines/literal carry the old zod `.default()`s as schema + * descriptions + loop-side ??-fills (the loop reads them via numOr). */ +export function createPdfTools(_pdfPath: string): Record { return { - pdf_info: lhTool({ + pdf_info: { name: "pdf_info", description: "Metadata + per-page triage signals for the PDF (page count, text length, image count, math density). ALWAYS call this first.", - schema: z.object({}), - execute: async () => { - const info = await pdfInfo(pdfPath); - const lines = [ - `File: ${info.path}`, - `Pages: ${info.page_count} (mean text length/page: ${info.mean_text_length})`, - ...info.pages.map( - (p) => - `p${p.page}: len=${p.text_length} img=${p.image_count} math=${p.math_density.toFixed(2)}`, - ), - ]; - return lines.join("\n"); - }, - }), - pdf_extract: lhTool({ + parameters: Type.Object({}), + }, + pdf_extract: { name: "pdf_extract", description: "Extract text from pages (spec: 'all', '1-5', '1,3,7', '3'). Use pdf_info first; narrow the spec for big documents.", - schema: z.object({ pages: z.string().describe("Page spec, e.g. 'all', '1-5', '1,3,7'") }), - execute: async ({ pages }) => { - const text = await pdfExtract(pdfPath, pages); - if (text.length > EXTRACT_CAP) { - return `${text.slice(0, EXTRACT_CAP)}\n\n[Output truncated at ${EXTRACT_CAP} chars — narrow the page spec and extract in smaller batches.]`; - } - return text; - }, - }), - pdf_render: lhTool({ + parameters: Type.Object({ + pages: Type.String({ description: "Page spec, e.g. 'all', '1-5', '1,3,7'" }), + }), + }, + pdf_render: { name: "pdf_render", description: "Render pages to images and show them to you for visual reading (equations, diagrams, layout). DPI: 100 text, 150 default, 200 dense small print. Max 6 pages per call.", - schema: z.object({ - pages: z.string().describe("Page spec, e.g. '1-3' or '2,5,8'"), - dpi: z.number().int().min(72).max(300).default(150).describe("Render DPI (100 text / 150 default / 200 dense)"), + parameters: Type.Object({ + pages: Type.String({ description: "Page spec, e.g. '1-3' or '2,5,8'" }), + dpi: Type.Optional( + Type.Integer({ minimum: 72, maximum: 300, description: "Render DPI (100 text / 150 default / 200 dense)" }), + ), }), - execute: async () => { - // Actual rendering happens OUTSIDE execute — the loop appends the - // images as a user message after the tool results (the - // image-in-the-loop idiom). execute returns the plan the loop uses. - return "render requested"; - }, - }), - pdf_search: lhTool({ + }, + pdf_search: { name: "pdf_search", description: "Search the PDF's text by keyword or regex (case-insensitive); returns hits with context lines. Use for long documents before reading anything at length.", - schema: z.object({ - query: z.string().describe("Keyword or regex"), - context_lines: z.number().int().min(0).max(8).default(3).describe("Context lines around each hit"), - literal: z.boolean().default(false).describe("true = treat the query literally (no regex)"), + parameters: Type.Object({ + query: Type.String({ description: "Keyword or regex" }), + context_lines: Type.Optional( + Type.Integer({ minimum: 0, maximum: 8, description: "Context lines around each hit (default 3)" }), + ), + literal: Type.Optional(Type.Boolean({ description: "true = treat the query literally (no regex; default false)" })), }), - execute: async ({ query, context_lines, literal }) => { - const { hits } = await pdfSearch(pdfPath, query, { contextLines: context_lines, literal }); - if (hits.length === 0) return "No hits."; - const cap = hits.slice(0, 40); - const body = cap.map((h: PdfSearchHit) => `=== Page ${h.page}, line ${h.line} ===\n${h.context}`).join("\n"); - const more = hits.length > cap.length ? `\n\n[${hits.length - cap.length} more hits — narrow the query.]` : ""; - return body + more; - }, - }), + }, }; } +/** pdf_info's body (the loop's dispatch target): the triage readout. */ +async function pdfInfoText(pdfPath: string): Promise { + const info = await pdfInfo(pdfPath); + const lines = [ + `File: ${info.path}`, + `Pages: ${info.page_count} (mean text length/page: ${info.mean_text_length})`, + ...info.pages.map( + (p) => + `p${p.page}: len=${p.text_length} img=${p.image_count} math=${p.math_density.toFixed(2)}`, + ), + ]; + return lines.join("\n"); +} + +/** pdf_extract's body: capped text extraction with the narrow-the-spec notice. */ +async function pdfExtractText(pdfPath: string, pages: string): Promise { + const text = await pdfExtract(pdfPath, pages); + if (text.length > EXTRACT_CAP) { + return `${text.slice(0, EXTRACT_CAP)}\n\n[Output truncated at ${EXTRACT_CAP} chars — narrow the page spec and extract in smaller batches.]`; + } + return text; +} + function toolText(toolCallId: string, toolName: string, value: string): ToolResultMessage { return { role: "toolResult", @@ -179,7 +174,7 @@ export async function runPdfReaderAgent( // doctrine degrades to extract+search (render calls return an error note). const tools = createPdfTools(pdfPath); - const toolDeclarations = piToolDeclarations(tools); + const toolDeclarations = Object.values(tools); const engine = opts.engine ?? piEngineFor("pdfReader"); // BYOK (owner, 2026-09-22): the level rides the injected engine — no // factory pin to consult (engine.level is the resolved/clamped value). @@ -242,12 +237,12 @@ export async function runPdfReaderAgent( try { if (call.name === "pdf_info") { opts.onActivity?.("pdf_info: triaging"); - const r = await tools.pdf_info.execute!({}, { toolCallId: call.id }); + const r = await pdfInfoText(pdfPath); toolResults.push(toolText(call.id, call.name, String(r))); } else if (call.name === "pdf_extract") { const spec = String(args.pages ?? "all"); opts.onActivity?.(`pdf_extract: ${spec}`); - const r = await tools.pdf_extract.execute!({ pages: spec }, { toolCallId: call.id }); + const r = await pdfExtractText(pdfPath, spec); toolResults.push(toolText(call.id, call.name, String(r))); for (const p of specNumbers(spec, Infinity)) { consulted.add(p); @@ -393,7 +388,7 @@ async function streamOnce( engine: PiEngine, systemPrompt: string, messages: Message[], - tools: ReturnType, + tools: Tool[], reasoning: ThinkingLevel | undefined, abortSignal?: AbortSignal, ): Promise { diff --git a/src/agent/subagents/researcher.ts b/src/agent/subagents/researcher.ts index 297c54b..7557aba 100644 --- a/src/agent/subagents/researcher.ts +++ b/src/agent/subagents/researcher.ts @@ -2,7 +2,6 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; import type { Usage } from "@earendil-works/pi-ai"; import { createWebTools } from "../tools/web.ts"; import type { SearchProvider } from "../../core/search.ts"; -import { toPiTools } from "../lh-tool.ts"; import type { PiEngine } from "../models.ts"; import { runSubagent } from "./pi-run.ts"; @@ -80,7 +79,7 @@ export interface ResearcherRunOptions { * the loop scripted. */ export async function runResearcherAgent(prompt: string, opts: ResearcherRunOptions = {}): Promise { - const { text } = await runSubagent(toPiTools(createWebTools(opts.search ?? (() => null))) as AgentTool[], { + const { text } = await runSubagent(Object.values(createWebTools(opts.search ?? (() => null))), { role: "researcher", prompt, engine: opts.engine, diff --git a/src/agent/subagents/visual-maker.ts b/src/agent/subagents/visual-maker.ts index 8ff6313..ab54b5a 100644 --- a/src/agent/subagents/visual-maker.ts +++ b/src/agent/subagents/visual-maker.ts @@ -1,6 +1,5 @@ -import type { AssistantMessage, Message, ToolResultMessage, Usage } from "@earendil-works/pi-ai"; -import { z } from "zod"; -import { lhTool, piToolDeclarations, type LhTool } from "../lh-tool.ts"; +import type { AssistantMessage, Message, Tool, ToolResultMessage, Usage } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts"; import type { VisualResult } from "../../core/contracts.ts"; import { getHeadless, type HeadlessApi } from "./headless.ts"; @@ -88,29 +87,32 @@ Your FINAL message: 1-2 sentences of notes for the teacher (what the figure show NEVER the SVG source itself. If you genuinely cannot make a correct picture of the brief, say exactly that in your final message and why.`; -export function createVisualTools(): Record> { +/** The wire declarations (pi-ai Tool, TypeBox parameters): this loop owns + * execution itself — write/edit dispatch inline below, render feeds the + * image-in-the-loop message — so the declarations carry no execute. */ +export function createVisualTools(): Record { return { - write_svg: lhTool({ + write_svg: { name: "write_svg", description: "Write the complete SVG source for the figure (overwrites any previous version).", - schema: z.object({ - svg: z.string().describe('Complete … markup.'), + parameters: Type.Object({ + svg: Type.String({ description: 'Complete … markup.' }), }), - }), - edit_svg: lhTool({ + }, + edit_svg: { name: "edit_svg", description: "Replace one exact spot in the current SVG: old_text must appear EXACTLY ONCE and is replaced by new_text. Call write_svg first. For larger changes, write_svg the whole document.", - schema: z.object({ - old_text: z.string().describe("The exact text to replace — must match exactly once in the current SVG."), - new_text: z.string().describe("The replacement text."), + parameters: Type.Object({ + old_text: Type.String({ description: "The exact text to replace — must match exactly once in the current SVG." }), + new_text: Type.String({ description: "The replacement text." }), }), - }), - render_svg: lhTool({ + }, + render_svg: { name: "render_svg", description: "Render the current SVG to a PNG and show it to you for inspection. No arguments.", - schema: z.object({}), - }), + parameters: Type.Object({}), + }, }; } @@ -130,7 +132,7 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO } const tools = createVisualTools(); - const toolDeclarations = piToolDeclarations(tools); + const toolDeclarations = Object.values(tools); const engine = opts.engine ?? piEngineFor("visualMaker"); // BYOK (owner, 2026-09-22): the level rides the injected engine — no // factory pin to consult (engine.level is the resolved/clamped value). @@ -301,7 +303,7 @@ async function streamOnce( engine: PiEngine, systemPrompt: string, messages: Message[], - tools: ReturnType, + tools: Tool[], reasoning: ThinkingLevel | undefined, abortSignal?: AbortSignal, ): Promise { diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts index 853e400..c9be2be 100644 --- a/src/agent/teacher-agent.ts +++ b/src/agent/teacher-agent.ts @@ -1,5 +1,6 @@ import { Agent } from "@earendil-works/pi-agent-core"; import type { AgentEvent, AgentMessage, AgentTool } from "@earendil-works/pi-agent-core"; +import { questionResult, ToolInputError } from "./tool-execute.ts"; import type { Usage } from "@earendil-works/pi-ai"; import { PATHS } from "../../config.ts"; import { piEngineFor, type PiEngine } from "./models.ts"; @@ -12,7 +13,6 @@ import { type ProberToolContext, type TeacherToolContext, } from "./tools.ts"; -import { toPiTools, type LhTool } from "./lh-tool.ts"; import { quizInputObject, quizInputProblems } from "../core/question-core.ts"; import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts"; @@ -93,11 +93,9 @@ export interface AgentBuildOptions { messages?: AgentMessage[]; /** Money channel: one call per model request. */ onUsage?: StepUsageCollector; - /** Safety net (the old stepCountIs). Default 20 turns. */ - maxTurns?: number; /** Tests may pass a bare tool registry to drive deterministic flows. * An explicit registry is NEVER toggle-filtered — the caller owns it. */ - tools?: Record>; + tools?: Record>; /** The resolved per-user tool toggles (core/pending EnabledTools — the * 2026-09-19 P2 bridge: Platform's chat-handler passes * effective.agent.tools; the factories drop any tool explicitly false. @@ -113,22 +111,29 @@ export interface AgentBuildOptions { } /** - * The turn's stop rule (docs/pi-engine-swap.md §2): - * - a question tool call (quiz / ask_user_question) that RESOLVED — its - * synthetic "question posed" result is not an error — ends the run: the - * learner's answer arrives as the next POST. A call that FAILED authoring - * validation (error result) must NOT stop the run — the model retries - * within the same turn, exactly the InvalidToolInputError semantics. - * - the maxTurns safety net replaces stepCountIs(20). + * The turn's question-stop rule (docs/pi-engine-swap.md §2; Wave A + * 2026-09-26, owner Option A): a question tool call (quiz / + * ask_user_question) that RESOLVED — its synthetic "question posed" result + * is not an error — ends the run: the learner's answer arrives as the next + * POST. A call that FAILED authoring validation (error result) must NOT + * stop the run — the model retries within the same turn, exactly the old + * InvalidToolInputError semantics. + * + * Question results also carry terminate: true (tool-execute's + * questionResult), so single-question batches stop natively at the pi + * loop's own batch check; this shouldStopAfterTurn hook is the + * belt-and-suspenders HALF for the mixed batch (a question + other calls + * in one message) that terminate-only semantics would let run on — the + * learner cannot answer while a turn is in flight (per-chat 409), so a + * cleanly-posed question MUST always end the turn. maxTurns is gone + * (owner ruling 2026-09-25: no maximum-agent-turns net on the teaching + * turn). */ -function makeStopper(maxTurns: number) { - let turns = 0; +function makeQuestionStopper() { return (context: { message: { content: Array<{ type: string; id?: string; name?: string }> }; toolResults: Array<{ toolCallId: string; isError?: boolean }>; }): boolean => { - turns++; - if (turns >= maxTurns) return true; const called = new Set( context.message.content .filter((c) => c.type === "toolCall" && c.name !== undefined && (QUESTION_TOOLS as readonly string[]).includes(c.name!)) @@ -145,7 +150,7 @@ function makeStopper(maxTurns: number) { function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: AgentTool[]) { const engine = opts.engine ?? piEngineFor(kind); - const stopper = makeStopper(opts.maxTurns ?? 20); + const stopper = makeQuestionStopper(); const systemPrompt = opts.promptSwitches ? composeInstructions(kind, opts.promptSwitches) : composedInstructions[kind]!; @@ -181,7 +186,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent * handler keys teacher construction on. */ export function createProberAgent(ctx: ProberToolContext, opts: AgentBuildOptions = {}) { - return baseAgentOptions("prober", opts, toPiTools(createProberTools(ctx, opts.toolToggles))); + return baseAgentOptions("prober", opts, Object.values(createProberTools(ctx, opts.toolToggles))); } /** @@ -197,26 +202,33 @@ export function createTeacherAgent( ctx?: TeacherToolContext, opts: AgentBuildOptions = {}, ) { - const tools = - opts.tools ?? (ctx ? createTeacherTools(ctx, opts.toolToggles) : bareQuestionTools()); - return baseAgentOptions("teacher", opts, toPiTools(tools)); + const tools = opts.tools ?? (ctx ? createTeacherTools(ctx, opts.toolToggles) : bareQuestionTools()); + return baseAgentOptions("teacher", opts, Object.values(tools)); } -/** The validation-free teach question set (tests, bare agents): quiz with - * the shared authoring refinement but NO per-session nodeId check (no - * record to read), plus ask. */ -export function bareQuestionTools(): Record> { - return { - quiz: { - name: "quiz", - description: "Ask the learner a graded question.", - clientQuestion: true, - schema: quizInputObject.superRefine((input, add) => { - for (const problem of quizInputProblems(input)) { - add.addIssue({ code: "custom", message: problem }); - } - }), +/** The bare teach question set (tests, bare agents): quiz with the shared + * authoring refinement (quizInputProblems, run in execute — throws + * ToolInputError, which pi's loop converts to the error tool result the + * model retries on) but NO per-session nodeId check (no record to read), + * plus ask. */ +export function bareQuestionTools(): Record> { + // A typed const (not an inline literal): the specific AgentTool typing + // gives execute its precise Static input; heterogeneous records take + // AgentTool, whose loose Static would smudge it to unknown. + const quiz: AgentTool = { + name: "quiz", + label: "quiz", + description: "Ask the learner a graded question.", + parameters: quizInputObject, + executionMode: "sequential", + execute: async (_toolCallId, input) => { + const problems = quizInputProblems(input); + if (problems.length > 0) throw new ToolInputError("quiz", problems); + return questionResult(); }, + }; + return { + quiz, ask_user_question: askUserQuestionTool, }; } diff --git a/src/agent/tool-execute.ts b/src/agent/tool-execute.ts new file mode 100644 index 0000000..bd693e8 --- /dev/null +++ b/src/agent/tool-execute.ts @@ -0,0 +1,223 @@ +import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { Static, TSchema } from "typebox"; + +/** + * tool-execute — the execution machinery every server-side lh tool shares, + * expressed natively (Wave A of the pi-native migration, 2026-09-26): tools + * are authored as pi-agent-core AgentTools (TypeBox parameters, native + * execute), and this module owns the semantics the old LhTool wrapper + * (lh-tool.ts, deleted) used to add around every execute: + * + * - the system-level per-call timeout + abort cascade (runWithAbortGuard), + * - the authoring-refinement failure shape (ToolInputError — thrown from + * execute; pi's loop converts the throw into an error tool result the + * model sees and retries on, exactly the old InvalidToolInputError + * semantics), + * - the result mapping (string verbatim / object JSON / image results to + * native content parts; details carries the record-facing value), + * - the question-tool placeholder (quiz / ask_user_question): a synthetic + * "question posed" result that is NEVER persisted — the record keeps the + * tool part output-less until the client answers; the learner's answer + * re-enters the next turn as this call's result. terminate: true (owner + * Option A, 2026-09-25) ends single-question batches natively; the slim + * shouldStopAfterTurn stopper (teacher-agent.ts) holds the belt-and- + * suspenders rule for mixed batches. + * + * Structural validation is NOT here: pi's loop validates arguments against + * the tool's TypeBox parameters before execute ever runs (pi-ai's + * validateToolArguments — the fast TypeBox path). Refinements that no + * schema can carry (quizInputProblems, the live-tree nodeId check) run at + * the top of the tool's own execute and throw ToolInputError. + */ + +/** + * System-level per-call timeout (2026-09-19, the abort-cascade fix): pi + * passes every run's AbortSignal to tool execute, but the signal only + * lands at a step boundary — so a long-running local tool (the grep that + * outlived its turn by minutes) could hold the turn hostage until the dev + * server's memory restart killed the run mid-tool, freezing the record at + * its write-ahead state (the "went back in time" rollback). The guard + * races every execute against (a) the run's signal and (b) this timeout, + * rejecting with pi's own wording ("Operation aborted") on abort and a + * clear timeout message otherwise — pi turns the throw into an error tool + * result the model can see. + */ +const TOOL_TIMEOUT_MS = 30_000; +/** Test seam (runner.ts's pdfModelOverride idiom). */ +export const toolTimeoutMs = { current: TOOL_TIMEOUT_MS }; + +/** How long an aborted/timed-out call may take to settle on its own before + * the hard reject lands — cooperative tools poll the signal and return + * partial results within a tick or two (the fs tools check per file). */ +const TOOL_ABORT_GRACE_MS = 250; + +function runWithAbortGuard( + toolName: string, + run: (signal: AbortSignal) => T | Promise, + parent: AbortSignal | undefined, +): Promise { + const controller = new AbortController(); + const forwardAbort = () => controller.abort(); + if (parent) { + if (parent.aborted) controller.abort(); + else parent.addEventListener("abort", forwardAbort, { once: true }); + } + const timeoutMs = toolTimeoutMs.current; + const timer = setTimeout(() => controller.abort(), timeoutMs); + const humanTimeout = timeoutMs >= 1_000 ? `${Math.round(timeoutMs / 1000)}s` : `${timeoutMs}ms`; + let graceTimer: ReturnType | undefined; + const cleanup = () => { + clearTimeout(timer); + if (graceTimer) clearTimeout(graceTimer); + parent?.removeEventListener("abort", forwardAbort); + }; + return new Promise((resolve, reject) => { + const settle = (fn: () => void) => { + controller.signal.removeEventListener("abort", onAbort); + if (graceTimer) clearTimeout(graceTimer); + fn(); + }; + const onAbort = () => { + // Cooperative tools poll the signal and settle with partial + // results within a tick or two (the fs tools check per file) — + // give them a brief grace to deliver those before the hard + // reject, so abort KILLS a stuck tool without racing away a + // graceful one's partial result. + graceTimer = setTimeout( + () => + reject( + new Error( + parent?.aborted + ? "Operation aborted" + : `Tool "${toolName}" timed out after ${humanTimeout} — the result was discarded`, + ), + ), + TOOL_ABORT_GRACE_MS, + ); + }; + controller.signal.addEventListener("abort", onAbort, { once: true }); + if (controller.signal.aborted) onAbort(); // an already-aborted signal never fires listeners + Promise.resolve() + .then(() => run(controller.signal)) + .then( + (v) => settle(() => resolve(v)), + (e) => settle(() => reject(e)), + ); + }) + .finally(cleanup); +} + +/** lh's port of the SDK's invalid-input message: what the model retried on. + * Thrown from a tool's execute when the authoring refinements fail — + * pi's loop catches the throw and turns it into an error tool result. */ +export class ToolInputError extends Error { + constructor( + public readonly toolName: string, + public readonly problems: string[], + ) { + super(`Invalid tool input for tool "${toolName}":\n${problems.map((p) => `- ${p}`).join("\n")}`); + this.name = "ToolInputError"; + } +} + +/** + * Rich tool-result escape hatch (2026-09 sources v0): a tool whose result + * includes IMAGES (the read tool reading an image file) returns this shape + * instead of a plain value — toToolResult maps it to pi's native + * tool-result content parts (TextContent | ImageContent)[] so the + * vision-capable model actually SEES the image. `images` carries base64 + * data + mimeType, exactly as pi's ImageContent wants. `details` rides + * through to the record untouched. + */ +export interface ImageFileResult { + text: string; + images?: { data: string; mimeType: string }[]; + /** The record-facing result (client rendering / persistence). */ + details?: unknown; +} + +function isImageResult(v: unknown): v is ImageFileResult { + return ( + typeof v === "object" && v !== null && "text" in v && typeof (v as ImageFileResult).text === "string" + ); +} + +/** + * Map a tool body's return to the native AgentToolResult — the parity the + * old toPiTool tail owned: string results pass verbatim to the model, + * objects as JSON; `details` carries the RAW value (the record's tool part + * output reads it, ui-stream), and image results become native content + * parts with details ?? null. + */ +export function toToolResult(result: unknown): AgentToolResult { + if (isImageResult(result)) { + return { + content: [ + { type: "text", text: result.text }, + ...(result.images ?? []).map((img) => ({ + type: "image" as const, + data: img.data, + mimeType: img.mimeType, + })), + ], + details: result.details ?? null, + }; + } + return { + content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }], + details: result, + }; +} + +/** What a server-executed tool's body receives: the call id + the guard's + * combined signal (run abort + per-call timeout) for cooperative polling. */ +export interface ToolExecuteContext { + toolCallId: string; + signal?: AbortSignal; +} + +/** + * Build a native AgentTool execute from a tool body — the guard (timeout + + * abort cascade) plus the shared result mapping, byte-identical to the old + * toPiTool pipeline for every server-side tool. Question tools do NOT use + * this: their placeholder is instant (questionResult below) and their + * refinement checks run before it. + */ +export function serverExecute

( + toolName: string, + schema: P, + run: (input: Static

, ctx: ToolExecuteContext) => unknown, +): AgentTool["execute"] { + // The returned execute is deliberately typed loose (params: unknown) so + // it satisfies every AgentTool slot — heterogeneous registries type + // AgentTool, whose Static is unknown, and strictFunctionTypes + // rejects a params-narrower function against it. The loop has already + // validated arguments against `schema` (runtime) and `run` keeps the + // precise Static

input; `schema` exists in the signature precisely so + // P infers from the call site, not the loose surface. + return async (toolCallId, input: unknown, signal) => + toToolResult( + await runWithAbortGuard(toolName, (toolSignal) => run(input as Static

, { toolCallId, signal: toolSignal }), signal), + ); +} + +/** + * The synthetic question-tool result (in-memory only; the record's tool + * part keeps no output until the learner answers). terminate: true ends + * the batch natively — pi stops the loop when every finalized result in + * the batch terminates; the shouldStopAfterTurn stopper covers the mixed + * batch (question + other calls in one message) that terminate-only + * semantics would let run on (owner Option A, 2026-09-25). + */ +export function questionResult(): AgentToolResult { + return { + content: [ + { + type: "text", + text: "Question posed — the turn ends here; the learner's graded answer arrives as this call's result.", + }, + ], + details: null, + terminate: true, + }; +} diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 76c8f6e..1f73f27 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -1,6 +1,9 @@ -import { lhTool, type LhTool } from "./lh-tool.ts"; +import { Type } from "typebox"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { questionResult, serverExecute, ToolInputError } from "./tool-execute.ts"; import { askInputSchema, + askOptionProblems, probeQuizInputSchema, quizInputObject, quizInputProblems, @@ -25,14 +28,22 @@ export type { ProbeDoneContext } from "./tools/probe.ts"; * The agent-split tool composition (docs/probe-teach-split.md §1): the * PROBER and the TEACHER are different agents with different tool sets. * The question tools share a NAME but are genuinely DIFFERENT tools — - * the prober's quiz has NO nodeId field at all (its schema is built from - * quizInputBase; zod strips the key if the model tries anyway), the + * the prober's quiz has NO nodeId field at all (its declaration is built + * from quizInputBase; the model's schema never mentions nodeId), the * teacher's carries the validated position field. * - * - Question tools (quiz / ask_user_question): CLIENT-SIDE (no `execute`). - * The call streams to the browser in `input-available` state; the - * learner answers in the dock; the client grades and sends the verdict - * with addToolOutput. Input schemas live in core/question-core. + * - Question tools (quiz / ask_user_question): CLIENT-ANSWERED. Their + * execute validates the authoring refinements (the cross-field rules no + * schema carries — quizInputProblems / askOptionProblems; a failure + * throws ToolInputError, which pi's loop converts to an error tool + * result the model retries on), then returns the synthetic + * "question posed" placeholder (questionResult: terminate: true — the + * turn ends; the call streams to the browser in `input-available` + * state; the learner answers in the dock; the client grades and sends + * the verdict with addToolOutput). Input schemas live in + * core/question-core. Question tools opt into SEQUENTIAL execution so + * parallel calls resolve in the model's call order (the dock's subject + * can never flip mid-turn). * - probe_done (src/agent/tools/probe.ts): SERVER-SIDE seam tool. * - planTools (src/agent/tools/plan.ts): SERVER-SIDE — author-once * plan_init. Teacher only. @@ -40,22 +51,28 @@ export type { ProbeDoneContext } from "./tools/probe.ts"; * only. */ -export const askUserQuestionTool: LhTool = { +export const askUserQuestionTool: AgentTool = { name: "ask_user_question", - clientQuestion: true, + label: "ask_user_question", description: "Ask the learner a single question and pause until they answer. NOT graded — there is no correct answer. " + "Use when requirements are ambiguous, preferences are needed, a decision would materially affect the lesson, " + "or you need an open-ended response. ONE question per turn: call a single ask_user_question, then end your " + "turn — his answer determines the next.", - schema: askInputSchema, + parameters: askInputSchema, + executionMode: "sequential", + execute: async (_toolCallId, input) => { + const problems = askOptionProblems(input.options ?? []); + if (problems.length > 0) throw new ToolInputError("ask_user_question", problems); + return questionResult(); + }, }; // ─── the prober's question tools (no nodeId — no map exists yet) ───────────── -export const probeQuizTool: LhTool = { +export const probeQuizTool: AgentTool = { name: "quiz", - clientQuestion: true, + label: "quiz", description: "Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " + "Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " + @@ -67,7 +84,13 @@ export const probeQuizTool: LhTool = { "free-text note to ANY answer; it reaches you only when non-empty. No free-text answers — for non-graded " + "questions use ask_user_question instead. ONE question per turn: call a single quiz, then end your turn — " + "his answer determines the next question to ask.", - schema: probeQuizInputSchema, + parameters: probeQuizInputSchema, + executionMode: "sequential", + execute: async (_toolCallId, input) => { + const problems = quizInputProblems(input); + if (problems.length > 0) throw new ToolInputError("quiz", problems); + return questionResult(); + }, }; /** The prober's registry context: the probe's own needs (probe_done — @@ -98,7 +121,7 @@ export interface ProberToolContext extends ProbeDoneContext { export function createProberTools( ctx: ProberToolContext, enabled?: EnabledTools, -): Record> { +): Record> { // probe_done is re-added AFTER the filter, always-on by construction — it // is how the phase ENDS; a prober that can never conclude is a lockout. const done = probeDoneTool(ctx); @@ -146,36 +169,31 @@ export function createProberTools( // ─── the teacher's question tools (quiz carries the position field) ────────── +/** The nodeId-against-live-tree check the teacher's quiz chains after the + * shared authoring contract (the old superRefine, verbatim). */ +function nodeIdProblem(ctx: Pick, nodeId: string): string | null { + const record = ctx.chatStore.getChat(ctx.chatId); + const known = record?.tree?.nodes.map((n) => n.id) ?? []; + if (!known.includes(nodeId)) { + return ( + `nodeId "${nodeId}" matches no node in this session's plan map. ` + + (known.length > 0 + ? `Copy one of your plan_init slugs exactly (${known.slice(0, 5).join(", ")}${known.length > 5 ? ", …" : ""}), or omit nodeId for an ad-hoc check.` + : "No plan map exists yet — omit nodeId (ad-hoc checks only until you call plan_init).") + ); + } + return null; +} + /** Build the teach-side quiz tool for one request (exported for tests): the authoring contract - * + the per-session plan-nodeId validation (chained superRefines — the + * + the per-session plan-nodeId validation (both run in execute — the * grading contract is untouched). Reads the LIVE chat record, so rewrites * mid-turn are honored, and with no tree yet every nodeId is rejected. * Omitting nodeId is legal — an ad-hoc check that moves nothing. */ -export function teachQuizToolFor(ctx: Pick) { - const inputSchema = quizInputObject - .superRefine((input, add) => { - for (const problem of quizInputProblems(input)) { - add.addIssue({ code: "custom", message: problem }); - } - }) - .superRefine((input, add) => { - if (typeof input.nodeId !== "string" || input.nodeId.length === 0) return; - const record = ctx.chatStore.getChat(ctx.chatId); - const known = record?.tree?.nodes.map((n) => n.id) ?? []; - if (!known.includes(input.nodeId)) { - add.addIssue({ - code: "custom", - message: - `nodeId "${input.nodeId}" matches no node in this session's plan map. ` + - (known.length > 0 - ? `Copy one of your plan_init slugs exactly (${known.slice(0, 5).join(", ")}${known.length > 5 ? ", …" : ""}), or omit nodeId for an ad-hoc check.` - : "No plan map exists yet — omit nodeId (ad-hoc checks only until you call plan_init)."), - }); - } - }); - return lhTool({ +export function teachQuizToolFor(ctx: Pick): AgentTool { + return { name: "quiz", - clientQuestion: true, + label: "quiz", description: "Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " + "Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " + @@ -187,8 +205,18 @@ export function teachQuizToolFor(ctx: Pick { + const problems = [...quizInputProblems(input)]; + if (typeof input.nodeId === "string" && input.nodeId.length > 0) { + const nodeIdProblemText = nodeIdProblem(ctx, input.nodeId); + if (nodeIdProblemText) problems.push(nodeIdProblemText); + } + if (problems.length > 0) throw new ToolInputError("quiz", problems); + return questionResult(); + }, + }; } export interface TeacherToolContext extends PlanToolContext { @@ -219,7 +247,7 @@ export interface TeacherToolContext extends PlanToolContext { export function createTeacherTools( ctx: TeacherToolContext, enabled?: EnabledTools, -): Record> { +): Record> { return filterEnabledTools( { quiz: teachQuizToolFor(ctx), diff --git a/src/agent/tools/find.ts b/src/agent/tools/find.ts index 9d4c018..58070e0 100644 --- a/src/agent/tools/find.ts +++ b/src/agent/tools/find.ts @@ -1,7 +1,8 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { z } from "zod"; -import { lhTool } from "../lh-tool.ts"; +import { Type } from "typebox"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { serverExecute } from "../tool-execute.ts"; import { capOutput, formatSize, @@ -23,20 +24,21 @@ import { const DEFAULT_LIMIT = 1000; -const findInputSchema = z.object({ - pattern: z - .string() - .min(1) - .describe("Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'"), - path: z.string().optional().describe("Directory to search in (default: the learner's home directory)"), - limit: z.number().int().positive().optional().describe("Maximum number of results (default: 1000)"), +const findInput = Type.Object({ + pattern: Type.String({ + minLength: 1, + description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'", + }), + path: Type.Optional(Type.String({ description: "Directory to search in (default: the learner's home directory)" })), + limit: Type.Optional(Type.Integer({ description: "Maximum number of results (default: 1000)" })), }); -export const findTool = lhTool({ +export const findTool: AgentTool = { name: "find", + label: "find", description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Ignores node_modules, .git, and Library directories. Output is truncated to ${DEFAULT_LIMIT} results or ${formatSize(MAX_OUTPUT_BYTES)} (whichever is hit first).`, - schema: findInputSchema, - async execute(input, ctx) { + parameters: findInput, + execute: serverExecute("find", findInput, async (input, ctx) => { if (ctx?.signal?.aborted) return "Operation aborted"; const searchPath = resolveSourcePath(input.path); let stat; @@ -81,5 +83,5 @@ export const findTool = lhTool({ if (truncated) notices.push(`${formatSize(MAX_OUTPUT_BYTES)} limit reached`); if (timedOut) notices.push("search timed out after 10s — partial results, narrow the directory"); return notices.length > 0 ? `${content}\n\n[${notices.join(". ")}]` : content; - }, -}); + }), +}; diff --git a/src/agent/tools/graph.ts b/src/agent/tools/graph.ts index 5efadc1..b9130aa 100644 --- a/src/agent/tools/graph.ts +++ b/src/agent/tools/graph.ts @@ -1,5 +1,6 @@ -import { z } from "zod"; -import { lhTool, type LhTool } from "../lh-tool.ts"; +import { Type } from "typebox"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { serverExecute } from "../tool-execute.ts"; import { graphSessionIndex, graphSessionPropositions } from "../../core/graph.ts"; import type { ChatStore, GraphStore } from "../../core/ports.ts"; @@ -28,17 +29,27 @@ export interface GraphToolContext { graphStore: GraphStore; } -export function proberGraphTools(ctx: GraphToolContext): Record> { +const listSessionsInput = Type.Object({}); + +const listPropositionsInput = Type.Object({ + session: Type.String({ + minLength: 1, + description: "The session's id exactly as list_sessions reported it (a chat id or a notes source id)", + }), +}); + +export function proberGraphTools(ctx: GraphToolContext): Record> { const deps = { chatStore: ctx.chatStore, graphStore: ctx.graphStore }; return { - list_sessions: lhTool({ + list_sessions: { name: "list_sessions", + label: "list_sessions", description: "List the sessions of the learner's persistent knowledge graph — every chat or imported-notes file that ever contributed a proposition, most recent first. The map is organized into birth-session clusters (propositions grouped by the session they were first learned in), and session names are those clusters' labels. " + "Each line: the session's name, kind (chat/file), how many propositions were born in it, its dates, and its id — use that id with list_propositions. " + "Reading this first shows what he has already covered and when, before you probe anything.", - schema: z.object({}), - execute: async (): Promise => { + parameters: listSessionsInput, + execute: serverExecute("list_sessions", listSessionsInput, async (): Promise => { const sessions = graphSessionIndex(deps); if (sessions.length === 0) return "The knowledge graph is empty — no session has contributed propositions yet."; const lines = sessions.map((s, i) => @@ -46,21 +57,17 @@ export function proberGraphTools(ctx: GraphToolContext): Record => { + parameters: listPropositionsInput, + execute: serverExecute("list_propositions", listPropositionsInput, async ({ session }): Promise => { const propositions = graphSessionPropositions(deps, session); if (propositions.length === 0) { return `No propositions born in session "${session}". Call list_sessions for the current ids.`; @@ -69,7 +76,7 @@ export function proberGraphTools(ctx: GraphToolContext): Record `- ${p.name} — ${p.understanding ?? "never evaluated"}${p.evaluatedAt ? ` (as of ${p.evaluatedAt})` : ""}`, ); return `${propositions.length} proposition(s) born in this session:\n${lines.join("\n")}`; - }, - }), + }), + }, }; } diff --git a/src/agent/tools/grep.ts b/src/agent/tools/grep.ts index ec8312c..8f28f1a 100644 --- a/src/agent/tools/grep.ts +++ b/src/agent/tools/grep.ts @@ -1,7 +1,8 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { z } from "zod"; -import { lhTool } from "../lh-tool.ts"; +import { Type } from "typebox"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { serverExecute } from "../tool-execute.ts"; import { capOutput, formatSize, @@ -29,24 +30,28 @@ import { const DEFAULT_LIMIT = 100; const MAX_FILE_BYTES = 2 * 1024 * 1024; -const grepInputSchema = z.object({ - pattern: z.string().min(1).describe("Search pattern (regex or literal string)"), - path: z - .string() - .optional() - .describe("Directory or file to search (default: the learner's home directory)"), - glob: z.string().optional().describe("Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'"), - ignoreCase: z.boolean().optional().describe("Case-insensitive search (default: false)"), - literal: z.boolean().optional().describe("Treat pattern as literal string instead of regex (default: false)"), - context: z.number().int().nonnegative().optional().describe("Number of lines to show before and after each match (default: 0)"), - limit: z.number().int().positive().optional().describe("Maximum number of matches to return (default: 100)"), +const grepInput = Type.Object({ + pattern: Type.String({ minLength: 1, description: "Search pattern (regex or literal string)" }), + path: Type.Optional( + Type.String({ description: "Directory or file to search (default: the learner's home directory)" }), + ), + glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" })), + ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })), + literal: Type.Optional( + Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" }), + ), + context: Type.Optional( + Type.Integer({ minimum: 0, description: "Number of lines to show before and after each match (default: 0)" }), + ), + limit: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum number of matches to return (default: 100)" })), }); -export const grepTool = lhTool({ +export const grepTool: AgentTool = { name: "grep", + label: "grep", description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Ignores node_modules, .git, and Library directories. Output is truncated to ${DEFAULT_LIMIT} matches or ${formatSize(MAX_OUTPUT_BYTES)} (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`, - schema: grepInputSchema, - async execute(input, ctx) { + parameters: grepInput, + execute: serverExecute("grep", grepInput, async (input, ctx) => { if (ctx?.signal?.aborted) return "Operation aborted"; const searchPath = resolveSourcePath(input.path); @@ -169,5 +174,5 @@ export const grepTool = lhTool({ if (truncated) notices.push(`${formatSize(MAX_OUTPUT_BYTES)} limit reached`); if (timedOut || scanTimedOut) notices.push("search timed out after 10s — partial results, narrow the directory"); return notices.length > 0 ? `${content}\n\n[${notices.join(". ")}]` : content; - }, -}); + }), +}; diff --git a/src/agent/tools/jobs.ts b/src/agent/tools/jobs.ts index 245bf61..edbb9d1 100644 --- a/src/agent/tools/jobs.ts +++ b/src/agent/tools/jobs.ts @@ -1,5 +1,6 @@ -import { z } from "zod"; -import { lhTool, type LhTool } from "../lh-tool.ts"; +import { Type, type Static } from "typebox"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { serverExecute } from "../tool-execute.ts"; import type { JobRecord } from "../../core/contracts.ts"; import type { JobStore } from "../../core/ports.ts"; @@ -30,43 +31,41 @@ export interface JobToolContext { searchAvailable?: () => boolean; } -export const researchInputSchema = z.object({ - question: z - .string() - .describe("The single research question to answer. Make it specific — the researcher sees nothing else of the lesson."), - context: z - .string() - .optional() - .describe("Why you need this / what the lesson needs it for — focuses the brief. One or two sentences."), +export const researchInputSchema = Type.Object({ + question: Type.String({ + description: "The single research question to answer. Make it specific — the researcher sees nothing else of the lesson.", + }), + context: Type.Optional( + Type.String({ + description: "Why you need this / what the lesson needs it for — focuses the brief. One or two sentences.", + }), + ), }); -export const demoInputSchema = z.object({ - title: z.string().describe("Short title for the demo card, e.g. 'Damped harmonic oscillator'."), - brief: z - .string() - .describe( +export const demoInputSchema = Type.Object({ + title: Type.String({ description: "Short title for the demo card, e.g. 'Damped harmonic oscillator'." }), + brief: Type.String({ + description: "Precise brief for the builder: the concept, the parameters/controls and what each does, the interactions, and the visual style. " + "The builder sees NOTHING else of the lesson — include everything it needs, including necessary formulas and constants.", - ), + }), }); -export const visualInputSchema = z.object({ - brief: z - .string() - .describe( +export const visualInputSchema = Type.Object({ + brief: Type.String({ + description: "Precise brief for ONE static figure: exactly what it must show — coordinates, labels, proportions, orientation. " + "The maker sees NOTHING else of the lesson; a wrong or vague brief produces a wrong figure.", - ), + }), }); -export const pdfInputSchema = z.object({ - path: z.string().describe("Path to the learner's PDF (relative or absolute), exactly as they gave it."), - question: z - .string() - .describe( +export const pdfInputSchema = Type.Object({ + path: Type.String({ description: "Path to the learner's PDF (relative or absolute), exactly as they gave it." }), + question: Type.String({ + description: "What you need from the document — the specific content, explanation, or answer to extract and brief. " + "The reader sees NOTHING else of the lesson.", - ), + }), }); /** @@ -133,39 +132,47 @@ const PDF_DESCRIPTION = * server can't dispatch doomed research jobs. Jobs ALREADY in flight when * a key disappears still run to their honest failure (runner backstop). */ -export function jobTools(ctx: JobToolContext): Record> { +export function jobTools(ctx: JobToolContext): Record> { const searchOn = ctx.searchAvailable?.() ?? true; return { ...(searchOn && { - research: lhTool({ + research: { name: "research", + label: "research", description: RESEARCH_DESCRIPTION, - schema: researchInputSchema, - execute: async (input, { toolCallId }) => dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input), - }), + parameters: researchInputSchema, + execute: serverExecute("research", researchInputSchema, (input, { toolCallId }) => + dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input), + ), + }, }), - demo: lhTool({ + demo: { name: "demo", + label: "demo", description: DEMO_DESCRIPTION, - schema: demoInputSchema, - execute: async (input, { toolCallId }) => dispatch(ctx, "demo", toolCallId, DEMO_ACK, input), - }), - visual: lhTool({ + parameters: demoInputSchema, + execute: serverExecute("demo", demoInputSchema, (input, { toolCallId }) => dispatch(ctx, "demo", toolCallId, DEMO_ACK, input)), + }, + visual: { name: "visual", + label: "visual", description: VISUAL_DESCRIPTION, - schema: visualInputSchema, - execute: async (input, { toolCallId }) => dispatch(ctx, "visual", toolCallId, VISUAL_ACK, input), - }), - pdf: lhTool({ + parameters: visualInputSchema, + execute: serverExecute("visual", visualInputSchema, (input, { toolCallId }) => + dispatch(ctx, "visual", toolCallId, VISUAL_ACK, input), + ), + }, + pdf: { name: "pdf", + label: "pdf", description: PDF_DESCRIPTION, - schema: pdfInputSchema, - execute: async (input, { toolCallId }) => dispatch(ctx, "pdf", toolCallId, PDF_ACK, input), - }), + parameters: pdfInputSchema, + execute: serverExecute("pdf", pdfInputSchema, (input, { toolCallId }) => dispatch(ctx, "pdf", toolCallId, PDF_ACK, input)), + }, }; } -export type ResearchInput = z.infer; -export type DemoInput = z.infer; -export type VisualInput = z.infer; -export type PdfInput = z.infer; +export type ResearchInput = Static; +export type DemoInput = Static; +export type VisualInput = Static; +export type PdfInput = Static; diff --git a/src/agent/tools/ls.ts b/src/agent/tools/ls.ts index d099be1..a63802b 100644 --- a/src/agent/tools/ls.ts +++ b/src/agent/tools/ls.ts @@ -1,7 +1,8 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { z } from "zod"; -import { lhTool } from "../lh-tool.ts"; +import { Type } from "typebox"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { serverExecute } from "../tool-execute.ts"; import { capOutput, formatSize, MAX_OUTPUT_BYTES, resolveSourcePath } from "./fs-shared.ts"; /** @@ -14,19 +15,21 @@ import { capOutput, formatSize, MAX_OUTPUT_BYTES, resolveSourcePath } from "./fs const DEFAULT_LIMIT = 500; -const lsInputSchema = z.object({ - path: z - .string() - .optional() - .describe("Directory to list (default: the learner's home directory)"), - limit: z.number().int().positive().optional().describe("Maximum number of entries to return (default: 500)"), +const lsInput = Type.Object({ + path: Type.Optional( + Type.String({ description: "Directory to list (default: the learner's home directory)" }), + ), + limit: Type.Optional( + Type.Integer({ description: "Maximum number of entries to return (default: 500)" }), + ), }); -export const lsTool = lhTool({ +export const lsTool: AgentTool = { name: "ls", + label: "ls", description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${formatSize(MAX_OUTPUT_BYTES)} (whichever is hit first).`, - schema: lsInputSchema, - async execute(input) { + parameters: lsInput, + execute: serverExecute("ls", lsInput, async (input) => { const dirPath = resolveSourcePath(input.path); let stat; @@ -70,5 +73,5 @@ export const lsTool = lhTool({ if (entryLimitReached) notices.push(`${effectiveLimit} entries limit`); if (truncated) notices.push(`${formatSize(MAX_OUTPUT_BYTES)} limit`); return notices.length > 0 ? `${content}\n\n[Truncated: ${notices.join(", ")}]` : content; - }, -}); + }), +}; diff --git a/src/agent/tools/plan.ts b/src/agent/tools/plan.ts index 6cd186b..f6bcc06 100644 --- a/src/agent/tools/plan.ts +++ b/src/agent/tools/plan.ts @@ -1,5 +1,6 @@ -import { z } from "zod"; -import { lhTool, type LhTool } from "../lh-tool.ts"; +import { Type } from "typebox"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { serverExecute } from "../tool-execute.ts"; import type { PlanToolResult, PlanTree } from "../../core/contracts.ts"; import type { ChatStore } from "../../core/ports.ts"; import { createTree, emptyTree, PlanTreeError } from "../../core/plan-tree.ts"; @@ -37,27 +38,29 @@ export interface PlanToolContext { * latest state straight from the transcript's tool outputs (the plan * window and the plan blocks). */ -export function planTools(ctx: PlanToolContext): Record> { +const planInitInput = Type.Object({ + mermaid: Type.String({ + minLength: 1, + description: + "A mermaid graph block (any direction), e.g.:\ngraph TD\n primitives[Number primitives] --> operators[Arithmetic operators] --> expressions[Evaluating expressions]\n" + + "The only strict rule: ids are permanent lowercase slugs (a-z, 0-9, hyphens/underscores) — checks reference them via nodeId. " + + "Titles are free single-line text; `-->` means 'from = prerequisite' (chains fine); no subgraphs, styles, or classes.", + }), +}); + +export function planTools(ctx: PlanToolContext): Record> { return { - plan_init: lhTool({ + plan_init: { name: "plan_init", + label: "plan_init", description: "Write or REWRITE this session's plan map — the dependency graph the learner follows live in the plan window. " + "Call once right after the probe concludes, to present the map; re-call whenever the map is wrong (the new block replaces the old tree). " + "A mermaid graph block (any direction): one node per line (`slug[Short Title]` — slugs are permanent lowercase ids: a-z, 0-9, hyphens/underscores), " + "`-->` edges meaning 'from must be understood before to' (chains fine). 5–15 nodes: a map, not a syllabus — one reasoning step per node, compress. " + "Every check you fire names the node it assesses via `nodeId`; where the lesson stands derives from those tags.", - schema: z.object({ - mermaid: z - .string() - .min(1) - .describe( - "A mermaid graph block (any direction), e.g.:\ngraph TD\n primitives[Number primitives] --> operators[Arithmetic operators] --> expressions[Evaluating expressions]\n" + - "The only strict rule: ids are permanent lowercase slugs (a-z, 0-9, hyphens/underscores) — checks reference them via nodeId. " + - "Titles are free single-line text; `-->` means 'from = prerequisite' (chains fine); no subgraphs, styles, or classes.", - ), - }), - execute: async ({ mermaid }): Promise => { + parameters: planInitInput, + execute: serverExecute("plan_init", planInitInput, async ({ mermaid }): Promise => { const { chatId, chatStore } = ctx; const record = chatStore.getChat(chatId); if (!record) return { ok: false, error: `Chat not found: ${chatId}`, tree: emptyTree() }; @@ -78,7 +81,7 @@ export function planTools(ctx: PlanToolContext): Record if (err instanceof PlanTreeError) return { ok: false, error: err.message, tree: current }; throw err; } - }, - }), + }), + }, }; } \ No newline at end of file diff --git a/src/agent/tools/probe.ts b/src/agent/tools/probe.ts index db27a01..6d241c6 100644 --- a/src/agent/tools/probe.ts +++ b/src/agent/tools/probe.ts @@ -1,5 +1,6 @@ -import { lhTool, type LhTool } from "../lh-tool.ts"; -import { probeDoneInputSchema, type ProbeDoneInput, type ProbeDoneResult } from "../../core/contracts.ts"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { serverExecute } from "../tool-execute.ts"; +import { probeDoneInputSchema, type ProbeDoneResult } from "../../core/contracts.ts"; import type { UIMessage } from "ai"; import { findPendingToolCall } from "../../core/pending.ts"; import type { ChatStore } from "../../core/ports.ts"; @@ -43,10 +44,11 @@ function probeConcluded(messages: UIMessage[]): boolean { * The first probe_done in the transcript is also the construction seam: * from that message on, every request builds the TEACHER agent. */ -export function probeDoneTool(ctx: ProbeDoneContext): Record> { +export function probeDoneTool(ctx: ProbeDoneContext): Record> { return { - probe_done: lhTool({ + probe_done: { name: "probe_done", + label: "probe_done", description: "Conclude the probing phase and hand off to the teaching phase. Call this ONCE, when the probe is " + "complete: every strand the lesson depends on is bracketed (a floor he reliably gets right AND a " + @@ -57,8 +59,8 @@ export function probeDoneTool(ctx: ProbeDoneContext): Record => { + parameters: probeDoneInputSchema, + execute: serverExecute("probe_done", probeDoneInputSchema, async (input): Promise => { const record = ctx.chatStore.getChat(ctx.chatId); if (!record) return { ok: false, error: `Chat not found: ${ctx.chatId}` }; @@ -82,7 +84,7 @@ export function probeDoneTool(ctx: ProbeDoneContext): Record = { ".bmp": "image/bmp", }; -const readInputSchema = z.object({ - path: z.string().min(1).describe("Path to the file to read (relative or absolute)"), - offset: z.number().int().positive().optional().describe("Line number to start reading from (1-indexed)"), - limit: z.number().int().positive().optional().describe("Maximum number of lines to read"), +const readInput = Type.Object({ + path: Type.String({ minLength: 1, description: "Path to the file to read (relative or absolute)" }), + offset: Type.Optional(Type.Integer({ minimum: 1, description: "Line number to start reading from (1-indexed)" })), + limit: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum number of lines to read" })), }); function formatSize(bytes: number): string { @@ -94,11 +95,12 @@ function readTextSlice(text: string, offset: number | undefined, limit: number | * fragment (prompts/shared/). Relative paths resolve against the * learner's home (fs-shared.ts), the harness's anchor. */ -export const readTool = lhTool({ +export const readTool: AgentTool = { name: "read", + label: "read", description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50.0KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, - schema: readInputSchema, - execute: async ({ path: rawPath, offset, limit }): Promise => { + parameters: readInput, + execute: serverExecute("read", readInput, async ({ path: rawPath, offset, limit }): Promise => { const absolute = resolveSourcePath(rawPath); const ext = path.extname(absolute).toLowerCase(); const mime = IMAGE_MIME[ext]; @@ -114,5 +116,5 @@ export const readTool = lhTool({ } const text = buffer.toString("utf-8"); return readTextSlice(text, offset, limit); - }, -}); \ No newline at end of file + }), +}; diff --git a/src/agent/tools/web.ts b/src/agent/tools/web.ts index b6fe4d5..dfb2c64 100644 --- a/src/agent/tools/web.ts +++ b/src/agent/tools/web.ts @@ -1,5 +1,6 @@ -import { z } from "zod"; -import { lhTool } from "../lh-tool.ts"; +import { Type } from "typebox"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { serverExecute } from "../tool-execute.ts"; // The search-hit shape lives in the core port since the search wave // (2026-09-20); this alias keeps callers churn-free. import type { SearchHit as WebSearchResult, SearchProvider } from "../../core/search.ts"; @@ -114,17 +115,26 @@ async function fetchAndExtract(url: string): Promise { // ── tool registry ──────────────────────────────────────────────────────────── -export function createWebTools(search: () => SearchProvider | null) { +const webSearchInput = Type.Object({ + query: Type.String({ description: "The search query (a normal search string, phrases included)" }), + count: Type.Optional( + Type.Integer({ minimum: 1, maximum: 10, description: "Number of results (default 5, max 10)" }), + ), +}); + +const webFetchInput = Type.Object({ + url: Type.String({ description: "The URL to fetch" }), +}); + +export function createWebTools(search: () => SearchProvider | null): Record> { return { - web_search: lhTool({ + web_search: { name: "web_search", + label: "web_search", description: "Search the web. One search per call — use one call per search angle. " + "Returns an array of results with title, url, and snippet.", - schema: z.object({ - query: z.string().describe("The search query (a normal search string, phrases included)"), - count: z.number().int().min(1).max(10).optional().describe("Number of results (default 5, max 10)"), - }), - execute: async ({ query, count }): Promise => { + parameters: webSearchInput, + execute: serverExecute("web_search", webSearchInput, async ({ query, count }): Promise => { const provider = search(); if (!provider) { return ( @@ -137,21 +147,20 @@ export function createWebTools(search: () => SearchProvider | null) { } catch (err) { return `web_search failed: ${err instanceof Error ? err.message : String(err)}`; } - }, - }), + }), + }, - web_fetch: lhTool({ + web_fetch: { name: "web_fetch", + label: "web_fetch", description: "Fetch a web page and extract its readable content as markdown (Readability). " + "Handles HTML pages and plain text; JS-rendered pages fall back to a reader service.", - schema: z.object({ - url: z.string().describe("The URL to fetch"), - }), - execute: async ({ url }): Promise => { + parameters: webFetchInput, + execute: serverExecute("web_fetch", webFetchInput, async ({ url }): Promise => { try { const parsed = new URL(url); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("Only http(s) URLs are supported"); + if (parsed.protocol !== "http" && parsed.protocol !== "https") throw new Error("Only http(s) URLs are supported"); } catch { return `web_fetch failed: invalid URL "${url}"`; } @@ -160,8 +169,8 @@ export function createWebTools(search: () => SearchProvider | null) { } catch (err) { return `web_fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}`; } - }, - }), + }), + }, }; } diff --git a/src/core/contracts/probe.ts b/src/core/contracts/probe.ts index 36e2c74..e2e2901 100644 --- a/src/core/contracts/probe.ts +++ b/src/core/contracts/probe.ts @@ -15,7 +15,7 @@ * the learner's edge sits. */ -import { z } from "zod"; +import { Type, type Static } from "typebox"; /** One dependency strand the planned lesson will rest on. */ export interface ProbeStrand { @@ -38,41 +38,43 @@ export interface ProbeDoneInput { assessment: ProbeAssessment; } -// The zod schema of that input — SINGLE SOURCE OF TRUTH, lived by the tool -// (src/agent/tools/probe.ts) and by the UI parse seam (src/core/tool-parts.ts): +// The TypeBox schema of that input — SINGLE SOURCE OF TRUTH, lived by the +// tool (src/agent/tools/probe.ts) and by the UI parse seam (src/core/tool-parts.ts): // the boundary validation and the consumer parse run literally the same // schema, so "schema-valid at the boundary" ⇔ "parseable by a consumer" is // structural, not a convention. (Live crash 2026-09-12: the model sent // `assessment` as a STRING; the card cast it and died on `.strands.map`.) -export const probeDoneInputSchema = z - .object({ - assessment: z - .object({ - strands: z - .array( - z.object({ - strand: z - .string() - .describe( - "The dependency strand (e.g. 'the derivative as a limit') — a name the plan can hang a node on.", - ), - assessment: z - .string() - .describe( - "Where his edge sits on this strand: the floor (what he reliably gets right), the ceiling (where it runs out), " + - "misconceptions found, and what the lesson should build from.", - ), - }), - ) - .min(1) - .describe("Every strand the planned lesson will depend on, each with your read of his edge on it."), - summary: z - .string() - .optional() - .describe("Optional overall synthesis: his apparent goal and where the teaching should enter."), - }) - .describe("The learner assessment — the probe's distilled output."), - }) satisfies z.ZodType; +export const probeDoneInputSchema = Type.Object({ + assessment: Type.Object( + { + strands: Type.Array( + Type.Object({ + strand: Type.String({ + description: + "The dependency strand (e.g. 'the derivative as a limit') — a name the plan can hang a node on.", + }), + assessment: Type.String({ + description: + "Where his edge sits on this strand: the floor (what he reliably gets right), the ceiling (where it runs out), " + + "misconceptions found, and what the lesson should build from.", + }), + }), + { minItems: 1, description: "Every strand the planned lesson will depend on, each with your read of his edge on it." }, + ), + summary: Type.Optional( + Type.String({ + description: "Optional overall synthesis: his apparent goal and where the teaching should enter.", + }), + ), + }, + { description: "The learner assessment — the probe's distilled output." }, + ), +}); + +/** Schema ↔ interface lock (the old `satisfies z.ZodType` guarantee): the + * declaration and ProbeDoneInput can never drift apart — this only compiles + * when the schema's static shape IS the interface. */ +export const probeDoneSchemaLock = (v: Static): ProbeDoneInput => v; /** probe_done tool output. */ export interface ProbeDoneResult { diff --git a/src/core/question-core.ts b/src/core/question-core.ts index 9a777a8..627874e 100644 --- a/src/core/question-core.ts +++ b/src/core/question-core.ts @@ -2,10 +2,10 @@ * question-core — the question contract + pure grading logic. No framework * imports, no UI: everything here is deterministic and unit-testable. * - * The zod schemas below are the tool INPUT contracts (agent/tools declares - * the tools against them); the browser validates, shuffles, and grades with - * these functions, then sends the verdict back as the tool output via - * addToolOutput: + * The TypeBox schemas below are the tool INPUT contracts (agent/tools + * declares the tools against them); the browser validates, shuffles, and + * grades with these functions, then sends the verdict back as the tool + * output via addToolOutput: * 1. The teacher model calls the `quiz` tool (question + options + * correctAnswer + explanation). No server execute → the call lands in * the UI as a typed tool part in `input-available` state. @@ -17,7 +17,7 @@ * exited and resumed mid-question for free. */ -import { z } from "zod"; +import { Type, type Static } from "typebox"; // ─── shared types ─────────────────────────────────────────────────────────── @@ -30,14 +30,18 @@ export interface QuizOption { // ─── tool input schemas (the question contract) ───────────────────────────── // Parameter names mirror the prompt docs exactly — keep them in sync. -const optionSchema = z.object({ - label: z.string().describe("Display label for the answer option"), - value: z - .string() - .optional() - .describe("Optional machine-readable value returned for the option. Defaults to the label."), - description: z.string().optional().describe("Optional extra detail shown below the option"), -}); +/** One answer option — shared by quiz + ask. */ +const optionProps = { + label: Type.String({ description: "Display label for the answer option" }), + value: Type.Optional( + Type.String({ + description: "Optional machine-readable value returned for the option. Defaults to the label.", + }), + ), + description: Type.Optional( + Type.String({ description: "Optional extra detail shown below the option" }), + ), +}; // ─── cross-field authoring validation (tool-schema refinements) ────────── // Enforced at the tool-call boundary (see the superRefines below): an invalid @@ -111,114 +115,113 @@ export function quizInputProblems(input: { // and teach agents use DIFFERENT tools under the same name (docs/probe-teach- // split.md §1): the prober's quiz has NO nodeId field at all (there is no // map to position on), the teacher's adds it (the position signal — the -// per-session tree validation chains on top in tools.ts). Grading rules -// are identical, single-sourced here. -export const quizInputBase = z.object({ - question: z.string().describe("The single quiz question to ask. ONE question per turn: ask, then end your turn — the answer determines the next question."), - options: z - .array(optionSchema) - .min(2) - .describe( +// per-session tree validation runs in the tool's execute, tools.ts). +// Grading rules are identical, single-sourced here. The cross-field +// authoring contract (quizInputProblems, above) is enforced at the +// tool-call boundary in execute — TypeBox carries the structure to the +// model, the problems function carries the refinements. +// +// Argument-order ruling (owner, 2026-09-25): the explanation is REASONING, +// and reasoning happens BEFORE the answer is chosen — models emit tool +// args in schema order, so explanation sits immediately BEFORE correctAnswer. +// (All consumers read by key; the order is authoring guidance, nothing else.) +export const quizInputBase = Type.Object({ + question: Type.String({ + description: + "The single quiz question to ask. ONE question per turn: ask, then end your turn — the answer determines the next question.", + }), + options: Type.Array(Type.Object(optionProps), { + minItems: 2, + description: "The answer options (2 or more). Options only — there is no free-text mode. " + - "Give each option a stable `value`; you reference the correct one by that value in correctAnswer. " + - "Never add your own 'I don't know' option — that choice is added for you.", - ), - multiSelect: z - .boolean() - .optional() - .describe("Set to true when more than one option is correct and the learner must select all of them."), - // Argument-order ruling (owner, 2026-09-25): the explanation is REASONING, - // and reasoning happens BEFORE the answer is chosen — models emit tool - // args in schema order, so explanation sits immediately BEFORE correctAnswer. - // (All consumers read by key; the order is authoring guidance, nothing else.) - explanation: z - .string() - .describe( + "Give each option a stable `value`; you reference the correct one by that value in correctAnswer. " + + "Never add your own 'I don't know' option — that choice is added for you.", + }), + multiSelect: Type.Optional( + Type.Boolean({ + description: "Set to true when more than one option is correct and the learner must select all of them.", + }), + ), + explanation: Type.String({ + description: "REQUIRED. Explanation revealed AFTER the learner answers (shown whether they got it right or wrong). " + - "Use it to reinforce why the correct answer is correct. It is written BEFORE correctAnswer by design: " + - "reason it out first, then commit the answer the reasoning supports.", - ), - correctAnswer: z - .union([z.string(), z.array(z.string())]) - .describe( + "Use it to reinforce why the correct answer is correct. It is written BEFORE correctAnswer by design: " + + "reason it out first, then commit the answer the reasoning supports.", + }), + correctAnswer: Type.Union([Type.String(), Type.Array(Type.String())], { + description: "REQUIRED. The correct answer as the option value(s) — the `value` field of the option you intend. " + - 'Single-select: a single string (e.g. "mercury"). Multi-select: an array of strings (e.g. ["belize", "niue"]); ' + - "the learner is only correct if their selection matches this set exactly. " + - "Always pass the value, not a position number — this is self-checking and prevents miscounting. " + - "A value that matches no option fails validation and the call is returned to you to fix.", - ), - details: z.string().optional().describe("Optional extra context or instructions shown under the question."), + 'Single-select: a single string (e.g. "mercury"). Multi-select: an array of strings (e.g. ["belize", "niue"]); ' + + "the learner is only correct if their selection matches this set exactly. " + + "Always pass the value, not a position number — this is self-checking and prevents miscounting. " + + "A value that matches no option fails validation and the call is returned to you to fix.", + }), + details: Type.Optional( + Type.String({ description: "Optional extra context or instructions shown under the question." }), + ), }); // The TEACH-side quiz object: the shared base + the plan-position field. // `nodeId` means "this question is ABOUT this node" — a position signal, -// never a completion signal (docs/probe-teach-split.md §3). -export const quizInputObject = quizInputBase.extend({ - nodeId: z - .string() - .optional() - .describe( - "Optional plan-tree node id this question is about — must EXACTLY match one of this session's " + +// never a completion signal (docs/probe-teach-split.md §3). The cross-field +// authoring contract (the Gate-3 hardening: a quiz whose correctAnswer +// can't be graded NEVER reaches the learner — schema-valid ⇔ gradeable, the +// rules exactly what the grading path accepts) runs at the CALL BOUNDARY in +// the tool's execute: quizInputProblems here, the per-session plan-nodeId +// validation in tools.ts (it reads the live chat record, which this core +// never touches). +export const quizInputObject = Type.Object({ + ...quizInputBase.properties, + nodeId: Type.Optional( + Type.String({ + description: + "Optional plan-tree node id this question is about — must EXACTLY match one of this session's " + "plan_init node slugs (validated at call time; a mismatched id is rejected back to you). " + "Tag every question that targets a map node: the lesson's position on the map derives from these tags " + "(the node the latest tagged question is about is where the map stands; earlier ones count as visited). " + "Omit for quick ad-hoc checks that don't target a node.", - ), -}) - // The cross-field authoring contract (the Gate-3 hardening): a quiz whose - // correctAnswer can't be graded NEVER reaches the learner. The SDK validates - // client-side tool inputs against this schema at the call boundary and, on - // failure, returns the validation error to the teacher as a tool result — - // the teacher retries with a fixed quiz in the same turn. Schema-valid ⇔ - // gradeable: the rules are exactly what the grading path accepts. - // -// NOTE the split: the raw objects (pre-refinement) are exported for -// composition — tools.ts teachQuestionTools chains the per-session -// plan-nodeId validation (it reads the live chat record, which this core -// never touches) on top of this shared refinement, and the PROBE quiz -// (no nodeId at all) refines quizInputBase directly. -export const quizInputSchema = quizInputObject - .superRefine((input, ctx) => { - for (const problem of quizInputProblems(input)) { - ctx.addIssue({ code: "custom", message: problem }); - } - }); - -/** The PROBE-side quiz schema: the shared authoring contract, no nodeId - * field at all — the session's map doesn't exist yet. */ -export const probeQuizInputSchema = quizInputBase.superRefine((input, ctx) => { - for (const problem of quizInputProblems(input)) { - ctx.addIssue({ code: "custom", message: problem }); - } + }), + ), }); -export const askInputSchema = z - .object({ - // No `mode` param — the pi original has none: the mode is inferred from - // whether options are present (the dock does exactly this). One less - // knob for the model to misuse. - question: z.string().describe("The single question to ask the learner. ONE question per turn: ask, then end your turn — the answer determines the next question."), - details: z.string().optional().describe("Optional extra context or instructions shown under the question."), - options: z - .array(optionSchema) - .min(2) - .optional() - .describe( - "Optional multiple-choice options. Omit for free-form text input. " + - "The learner can always choose 'Other' and type a custom answer when options are provided.", - ), - multiSelect: z.boolean().optional().describe("Set to true to allow multiple answers to the same question."), - }) - // Same option sanity as the quiz (labels non-empty, values distinct — ask - // is not graded, but duplicate values break the dock's value keying). - .superRefine((input, ctx) => { - for (const problem of askOptionProblems(input.options ?? [])) { - ctx.addIssue({ code: "custom", message: problem }); - } - }); +/** The PROBE-side quiz declaration: the shared authoring contract, no + * nodeId field at all — the session's map doesn't exist yet. Structurally + * identical to quizInputBase (the old superRefine never reached the wire — + * it ran at the call boundary, where quizInputProblems runs now). */ +export const probeQuizInputSchema = quizInputBase; -export type QuizToolInput = z.infer; -export type AskToolInput = z.infer; +/** The TEACHER's quiz declaration (base + the position field). */ +export const quizInputSchema = quizInputObject; + +export const askInputSchema = Type.Object({ + // No `mode` param — the pi original has none: the mode is inferred from + // whether options are present (the dock does exactly this). One less + // knob for the model to misuse. + question: Type.String({ + description: + "The single question to ask the learner. ONE question per turn: ask, then end your turn — the answer determines the next question.", + }), + details: Type.Optional( + Type.String({ description: "Optional extra context or instructions shown under the question." }), + ), + options: Type.Optional( + Type.Array(Type.Object(optionProps), { + minItems: 2, + description: + "Optional multiple-choice options. Omit for free-form text input. " + + "The learner can always choose 'Other' and type a custom answer when options are provided.", + }), + ), + multiSelect: Type.Optional( + Type.Boolean({ description: "Set to true to allow multiple answers to the same question." }), + ), +}); +// Ask's option sanity (labels non-empty, values distinct — ask is not +// graded, but duplicate values break the dock's value keying) runs at the +// call boundary in execute (askOptionProblems, above). + +export type QuizToolInput = Static; +export type AskToolInput = Static; /** A real selected answer, in the order the options were displayed. */ diff --git a/src/core/tool-parts.ts b/src/core/tool-parts.ts index f199955..c2d86c5 100644 --- a/src/core/tool-parts.ts +++ b/src/core/tool-parts.ts @@ -3,9 +3,9 @@ * live crashes). * * WHY THIS EXISTS. Validation IS first at the model-facing boundary — pi - * validates arguments before execute, and our zod re-run adds the - * refinements; a failure returns to the model as an error tool result, - * same turn. But the same bytes have two other consumers, and there they + * validates arguments against the tool's TypeBox schema before execute, + * and the authoring refinements run in the execute seam; a failure + * returns to the model as an error tool result, same turn. But the same bytes have two other consumers, and there they * exist UNVALIDATED BY DESIGN: * * - the live wire streams raw tool-input deltas while the model is still @@ -20,49 +20,72 @@ * lie the checker believes, with no runtime check behind it. * * THE RULE (mechanical, not vigilance): model-authored INPUT is consumed - * only through these parsers, each running the SAME zod schema the model + * only through these parsers, each running the SAME TypeBox schema the model * faced — boundary-valid ⇔ consumer-parseable, structurally. Server- and * learner-authored OUTPUT stays trusted (our own code authors it: the * execute acks, the learner's buildAnswer). A parse failure renders the * quiet fallback, never a crash. */ -import { z } from "zod"; -import { quizInputBase, askInputSchema, type AskToolInput, type QuizToolInput } from "./question-core.ts"; +import { Value } from "typebox/value"; +import type { TSchema } from "typebox"; +import { quizInputBase, askInputSchema, askOptionProblems, quizInputProblems, type AskToolInput, type QuizToolInput } from "./question-core.ts"; import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts"; +/** The pi-boundary parse step, mirrored here (live-smoke fix, 2026-09-26): + * pi-ai's validateToolArguments runs TypeBox's Value.Convert on the model's + * raw arguments before Value.Check — a correctAnswer sent as the NUMBER 17 + * (what the live smoke's model actually emitted) converts to "17" at the + * boundary, so the tool poses the quiz fine. But the persisted record keeps + * the RAW arguments, so a parse seam that skips Convert would reject the + * exact input the boundary accepted — an invisible pending quiz and a + * learner lockout. Every display parse converts first, exactly like the + * boundary; Convert mutates, so the input is cloned before touching. */ +function boundaryParse(schema: TSchema, input: unknown): T | undefined { + if (typeof input !== "object" || input === null) return undefined; + const converted = structuredClone(input); + Value.Convert(schema, converted); + if (!Value.Check(schema, converted)) return undefined; + return Value.Clean(schema, converted) as T; +} + /** * Quiz parse for DISPLAY: the shared authoring base, pre-refinement. - * Both boundary schemas (the prober's probeQuizInputSchema over the bare - * base, the teacher's quizInputSchema over base + optional nodeId) refine - * THE SAME quizInputBase, so anything that passed either boundary parses - * here: refinements don't weaken on re-run, and the teacher-only `nodeId` - * is an unknown key the base strips. This is deliberately the LENIENT - * common core — the strict per-agent refinements are the boundary's job. + * Both boundaries (the prober's quizInputBase, the teacher's quizInputObject + * over base + optional nodeId) share THE SAME quizInputBase fields, so + * anything that passed either boundary parses here; the teacher-only + * `nodeId` is an unknown key Value.Clean strips (zod's old strip behavior, + * kept so the display shape stays exactly what the dock always saw). This + * is deliberately the LENIENT common core — the strict per-agent authoring + * checks are the boundary's job — but it runs the shared grading + * refinement (quizInputProblems) after Convert, mirroring the boundary's + * full chain: pi accepts `correctAnswer: 17` structurally (Convert → + * "17") and execute's refinement is what makes it a posed-or-rejected + * call. The display parse follows the same order so an input that could + * never have been posed (e.g. `correctAnswer: undefined`, which Convert + * widens to "") renders as a quiet fallback, not a broken quiz. */ export function parseQuizInput(input: unknown): QuizToolInput | undefined { - const parsed = quizInputBase.safeParse(input); - return parsed.success ? (parsed.data as QuizToolInput) : undefined; + const cleaned = boundaryParse(quizInputBase, input); + if (!cleaned) return undefined; + if (quizInputProblems(cleaned).length > 0) return undefined; + return cleaned; } /** * Ask parse for DISPLAY: the full ask schema (options optional — free-text - * mode is the options-less shape). The only refinement is option sanity - * (distinct values), which the boundary already enforced. + * mode is the options-less shape), plus the option-sanity refinement the + * boundary enforced (askOptionProblems — duplicate values break the dock's + * keying, so they parse to undefined exactly like the zod seam did). */ export function parseAskInput(input: unknown): AskToolInput | undefined { - const parsed = askInputSchema.safeParse(input); - return parsed.success ? parsed.data : undefined; + const cleaned = boundaryParse(askInputSchema, input); + if (!cleaned) return undefined; + if (askOptionProblems(cleaned.options ?? []).length > 0) return undefined; + return cleaned; } /** probe_done parse — the core-owned schema the tool itself declares. */ export function parseProbeDoneInput(input: unknown): ProbeDoneInput | undefined { - const parsed = probeDoneInputSchema.safeParse(input); - return parsed.success ? parsed.data : undefined; -} - -/** Parse helper for schemas this seam doesn't own (kept for future cards). */ -export function parseWith(schema: z.ZodType, input: unknown): T | undefined { - const parsed = schema.safeParse(input); - return parsed.success ? parsed.data : undefined; + return boundaryParse(probeDoneInputSchema, input); } \ No newline at end of file diff --git a/tests/demos.test.tsx b/tests/demos.test.tsx index c3aaa9d..e5ed24b 100644 --- a/tests/demos.test.tsx +++ b/tests/demos.test.tsx @@ -28,6 +28,7 @@ import assert from "node:assert/strict"; import * as fs from "node:fs"; import * as path from "node:path"; import { after, describe, it } from "node:test"; +import { Value } from "typebox/value"; import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai"; import { renderToStaticMarkup } from "react-dom/server"; import { PATHS } from "../config.ts"; @@ -845,13 +846,13 @@ describe("tool + renderer registry wiring", () => { for (const name of ["demo", "visual", "research", "pdf"] as const) { assert.equal(typeof (tools[name] as { execute?: unknown }).execute, "function", `${name} executes server-side — fire-and-forget ack per THE ASYNC PROTOCOL`); } - assert.equal(demoInputSchema.safeParse({ title: "T", brief: "b" }).success, true); - assert.equal(demoInputSchema.safeParse({ brief: "b" }).success, false, "demo requires a title"); - assert.equal(visualInputSchema.safeParse({ brief: "b" }).success, true); - assert.equal(visualInputSchema.safeParse({}).success, false, "visual requires a brief"); - assert.equal(pdfInputSchema.safeParse({ path: "/tmp/doc.pdf", question: "q" }).success, true); - assert.equal(pdfInputSchema.safeParse({ question: "q" }).success, false, "pdf requires a path"); - assert.equal(pdfInputSchema.safeParse({ path: "/tmp/doc.pdf" }).success, false, "pdf requires a question"); + assert.equal(Value.Check(demoInputSchema, { title: "T", brief: "b" }), true); + assert.equal(Value.Check(demoInputSchema, { brief: "b" }), false, "demo requires a title"); + assert.equal(Value.Check(visualInputSchema, { brief: "b" }), true); + assert.equal(Value.Check(visualInputSchema, {}), false, "visual requires a brief"); + assert.equal(Value.Check(pdfInputSchema, { path: "/tmp/doc.pdf", question: "q" }), true); + assert.equal(Value.Check(pdfInputSchema, { question: "q" }), false, "pdf requires a path"); + assert.equal(Value.Check(pdfInputSchema, { path: "/tmp/doc.pdf" }), false, "pdf requires a question"); }); it("the artifact renderer registry maps demo/visual to the W6 cards", () => { diff --git a/tests/fs-tools.test.ts b/tests/fs-tools.test.ts index 85bfc56..40e3e9b 100644 --- a/tests/fs-tools.test.ts +++ b/tests/fs-tools.test.ts @@ -4,6 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { describe, it } from "node:test"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; import { expandBraces, globToRegex, matchGlob, walkFiles } from "../src/agent/tools/fs-shared.ts"; import { findTool } from "../src/agent/tools/find.ts"; import { grepTool } from "../src/agent/tools/grep.ts"; @@ -28,6 +29,15 @@ import { readTool } from "../src/agent/tools/read.ts"; let FIXTURE: string; +/** Drive one tool's native execute and return the model-facing text + * (pi signature: execute(toolCallId, params, signal) → AgentToolResult). */ +async function runText(tool: AgentTool, params: unknown, signal?: AbortSignal): Promise { + const result = await tool.execute!("c", params, signal); + const first = result.content[0]; + assert.ok(first !== undefined && first.type === "text", "first content part is text"); + return (first as { type: "text"; text: string }).text; +} + async function setup(): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), "lh-fs-")); await fs.mkdir(path.join(root, "notes", "sub"), { recursive: true }); @@ -79,7 +89,7 @@ describe("glob helpers (fs-shared)", () => { describe("ls (pi port)", () => { it("lists sorted, dirs suffixed, dotfiles included", async () => { FIXTURE = await setup(); - const out = (await lsTool.execute!({ path: FIXTURE }, { toolCallId: "c" })) as string; + const out = await runText(lsTool, { path: FIXTURE }); assert.ok(out.includes("code/")); assert.ok(out.includes("notes/")); assert.ok(out.includes("binary.bin")); @@ -90,14 +100,14 @@ describe("ls (pi port)", () => { }); it("honors the entry limit with pi's truncation notice", async () => { - const out = (await lsTool.execute!({ path: FIXTURE, limit: 2 }, { toolCallId: "c" })) as string; + const out = await runText(lsTool, { path: FIXTURE, limit: 2 }); assert.match(out, /\[Truncated: 2 entries limit\]/); }); it("missing path and non-directory throw (the model sees the error)", async () => { - await assert.rejects(async () => lsTool.execute!({ path: "/nonexistent/xyz" }, { toolCallId: "c" }), /Path not found/); + await assert.rejects(async () => lsTool.execute!("c", { path: "/nonexistent/xyz" }), /Path not found/); await assert.rejects( - async () => lsTool.execute!({ path: path.join(FIXTURE, "binary.bin") }, { toolCallId: "c" }), + async () => lsTool.execute!("c", { path: path.join(FIXTURE, "binary.bin") }), /Not a directory/, ); }); @@ -105,31 +115,31 @@ describe("ls (pi port)", () => { describe("find (pi port)", () => { it("finds by glob, relativized to the search root", async () => { - const out = (await findTool.execute!({ pattern: "*.md", path: FIXTURE }, { toolCallId: "c" })) as string; + const out = await runText(findTool, { pattern: "*.md", path: FIXTURE }); assert.ok(out.includes("notes/calculus.md")); assert.ok(out.includes("notes/sub/hidden.md")); assert.ok(!out.includes(FIXTURE), "paths are relative, not absolute"); }); it("never enters node_modules or Library", async () => { - const out = (await findTool.execute!({ pattern: "*.txt", path: FIXTURE }, { toolCallId: "c" })) as string; + const out = await runText(findTool, { pattern: "*.txt", path: FIXTURE }); assert.ok(!out.includes("node_modules")); assert.ok(!out.includes("Library")); assert.ok(out.includes("notes/linear.txt")); }); it("result limit carries pi's notice", async () => { - const out = (await findTool.execute!({ pattern: "*", path: FIXTURE, limit: 2 }, { toolCallId: "c" })) as string; + const out = await runText(findTool, { pattern: "*", path: FIXTURE, limit: 2 }); assert.match(out, /\[2 results limit reached\]/); }); it("no-match message matches pi", async () => { - const out = (await findTool.execute!({ pattern: "*.nomatch", path: FIXTURE }, { toolCallId: "c" })) as string; + const out = await runText(findTool, { pattern: "*.nomatch", path: FIXTURE }); assert.equal(out, "No files found matching pattern"); }); it("slash patterns match paths, not basenames", async () => { - const out = (await findTool.execute!({ pattern: "notes/**/*.md", path: FIXTURE }, { toolCallId: "c" })) as string; + const out = await runText(findTool, { pattern: "notes/**/*.md", path: FIXTURE }); assert.ok(out.includes("notes/sub/hidden.md")); assert.ok(!out.includes("code/")); }); @@ -139,7 +149,7 @@ describe("grep (pi port)", () => { it("formats matches pi-style: rel:line: text", async () => { // case-sensitive: the lowercase "eigenvector" hits main.ts but NOT // linear.txt's capital-E "Eigenvectors" (that case lives below). - const out = (await grepTool.execute!({ pattern: "eigenvector", path: FIXTURE }, { toolCallId: "c" })) as string; + const out = await runText(grepTool, { pattern: "eigenvector", path: FIXTURE }); assert.equal(out, "code/main.ts:2: const eigenvector = 'here';"); assert.ok(!out.includes("node_modules"), "excluded dirs are not searched"); assert.ok(!out.includes("Library")); @@ -147,42 +157,42 @@ describe("grep (pi port)", () => { }); it("ignoreCase and literal flags", async () => { - const icase = (await grepTool.execute!({ pattern: "EIGENVECTOR", path: FIXTURE, ignoreCase: true }, { toolCallId: "c" })) as string; + const icase = await runText(grepTool, { pattern: "EIGENVECTOR", path: FIXTURE, ignoreCase: true }); assert.match(icase, /linear\.txt:1:/); - const literal = (await grepTool.execute!({ pattern: "e.gen", literal: true, path: path.join(FIXTURE, "code") }, { toolCallId: "c" })) as string; + const literal = await runText(grepTool, { pattern: "e.gen", literal: true, path: path.join(FIXTURE, "code") }); assert.equal(literal, "No matches found", "dots are literal when literal=true"); }); it("context lines use the dash format", async () => { - const out = (await grepTool.execute!({ pattern: "scaling", path: FIXTURE, context: 1 }, { toolCallId: "c" })) as string; + const out = await runText(grepTool, { pattern: "scaling", path: FIXTURE, context: 1 }); assert.match(out, /notes\/linear\.txt:2: only scaling remains/, "the match line uses colons"); assert.match(out, /notes\/linear\.txt-1- Eigenvectors keep their direction/, "context before uses dashes"); assert.match(out, /notes\/linear\.txt-3- $/, "context after (empty line) uses dashes"); }); it("glob filter restricts files", async () => { - const out = (await grepTool.execute!({ pattern: "eigenvector", path: FIXTURE, glob: "*.ts" }, { toolCallId: "c" })) as string; + const out = await runText(grepTool, { pattern: "eigenvector", path: FIXTURE, glob: "*.ts" }); assert.match(out, /main\.ts:2:/); assert.ok(!out.includes("linear.txt")); }); it("single-file search prints basename; match limit notices pi-style", async () => { const file = path.join(FIXTURE, "notes", "calculus.md"); - const out = (await grepTool.execute!({ pattern: "e", path: file }, { toolCallId: "c" })) as string; + const out = await runText(grepTool, { pattern: "e", path: file }); assert.match(out, /^calculus\.md:1: /, "basename for a single-file search"); - const limited = (await grepTool.execute!({ pattern: "e", path: file, limit: 1 }, { toolCallId: "c" })) as string; + const limited = await runText(grepTool, { pattern: "e", path: file, limit: 1 }); assert.match(limited, /\[1 matches limit reached\]/); }); it("no-match message matches pi", async () => { - const out = (await grepTool.execute!({ pattern: "zzzz", path: FIXTURE }, { toolCallId: "c" })) as string; + const out = await runText(grepTool, { pattern: "zzzz", path: FIXTURE }); assert.equal(out, "No matches found"); }); }); describe("read (home-anchored re-port)", () => { it("reads a fixture file verbatim through the shared slice machinery", async () => { - const out = (await readTool.execute!({ path: path.join(FIXTURE, "notes", "linear.txt") }, { toolCallId: "c" })) as string; + const out = await runText(readTool, { path: path.join(FIXTURE, "notes", "linear.txt") }); assert.match(out, /Eigenvectors keep their direction/); }); }); @@ -211,14 +221,14 @@ describe("abort plumbing (the 2026-09-19 cascade fix)", () => { FIXTURE = await setup(); const ac = new AbortController(); ac.abort(); - const out = (await findTool.execute!({ pattern: "*.md" }, { toolCallId: "c", signal: ac.signal })) as string; + const out = await runText(findTool, { pattern: "*.md" }, ac.signal); assert.equal(out, "Operation aborted"); }); it("grep honors a pre-aborted signal without scanning", async () => { const ac = new AbortController(); ac.abort(); - const out = (await grepTool.execute!({ pattern: "eigenvector" }, { toolCallId: "c", signal: ac.signal })) as string; + const out = await runText(grepTool, { pattern: "eigenvector" }, ac.signal); assert.equal(out, "Operation aborted"); }); @@ -235,10 +245,7 @@ describe("abort plumbing (the 2026-09-19 cascade fix)", () => { const ac = new AbortController(); setTimeout(() => ac.abort(), 1); const started = Date.now(); - const out = (await grepTool.execute!( - { pattern: "needle", path: root }, - { toolCallId: "c", signal: ac.signal }, - )) as string; + const out = await runText(grepTool, { pattern: "needle", path: root }, ac.signal); assert.match(out, /aborted/); assert.ok(Date.now() - started < 5_000, "returned promptly"); }); diff --git a/tests/graph-tools.test.ts b/tests/graph-tools.test.ts index 55655f3..13f10f8 100644 --- a/tests/graph-tools.test.ts +++ b/tests/graph-tools.test.ts @@ -138,7 +138,7 @@ describe("list_sessions", () => { }, ); const tools = createProberTools({ ...baseCtx, chatStore, graphStore }); - const out = (await tools.list_sessions!.execute!({}, { toolCallId: "tc" })) as string; + const out = (await tools.list_sessions!.execute!("tc", {})).details as string; assert.match(out, /2 session\(s\) in the knowledge graph, most recent first:/); // The chat: name, kind, born count, dates, id — the full resume. assert.match(out, /First lesson \(chat, 2 propositions, born 2026-09-20T10:00:00.000Z, last active 2026-09-21T10:00:00.000Z\) — id: c1/); @@ -151,7 +151,7 @@ describe("list_sessions", () => { it("an empty graph gets an honest answer, not an empty string or a throw", async () => { const { chatStore, graphStore } = fakeStores([]); const tools = createProberTools({ ...baseCtx, chatStore, graphStore }); - const out = (await tools.list_sessions!.execute!({}, { toolCallId: "tc" })) as string; + const out = (await tools.list_sessions!.execute!("tc", {})).details as string; assert.equal(out, "The knowledge graph is empty — no session has contributed propositions yet."); }); }); @@ -172,7 +172,7 @@ describe("list_propositions", () => { }, ); const tools = createProberTools({ ...baseCtx, chatStore, graphStore }); - const out = (await tools.list_propositions!.execute!({ session: "c1" }, { toolCallId: "tc" })) as string; + const out = (await tools.list_propositions!.execute!("tc", { session: "c1" })).details as string; assert.match(out, /2 proposition\(s\) born in this session:/); assert.match(out, /- Water cycle — solid \(as of 2026-09-22T12:00:00.000Z\)/); assert.match(out, /- Evaporation — not yet engaged \(as of 2026-09-21T12:00:00.000Z\)/); @@ -186,7 +186,7 @@ describe("list_propositions", () => { events: [], }); const tools = createProberTools({ ...baseCtx, chatStore, graphStore }); - const out = (await tools.list_propositions!.execute!({ session: "c1" }, { toolCallId: "tc" })) as string; + const out = (await tools.list_propositions!.execute!("tc", { session: "c1" })).details as string; assert.match(out, /- Water cycle — never evaluated/); assert.ok(!out.includes("(as of")); }); @@ -194,7 +194,7 @@ describe("list_propositions", () => { it("an unknown session id — honest redirect, not a throw", async () => { const { chatStore, graphStore } = fakeStores([node("n1", "Water cycle", [pt("c1")])]); const tools = createProberTools({ ...baseCtx, chatStore, graphStore }); - const out = (await tools.list_propositions!.execute!({ session: "no-such" }, { toolCallId: "tc" })) as string; + const out = (await tools.list_propositions!.execute!("tc", { session: "no-such" })).details as string; assert.equal(out, 'No propositions born in session "no-such". Call list_sessions for the current ids.'); }); }); diff --git a/tests/plan-tree.test.ts b/tests/plan-tree.test.ts index 737c924..9aecab4 100644 --- a/tests/plan-tree.test.ts +++ b/tests/plan-tree.test.ts @@ -101,7 +101,7 @@ after(() => { async function exec(tools: ReturnType, name: string, input: unknown): Promise { const t = tools[name]!; assert.ok(typeof t.execute === "function", `${name} must be a server-side tool with execute`); - return t.execute(input as never, { toolCallId: "test", messages: [] } as never) as Promise; + return t.execute("test", input).then((r) => r.details as PlanToolResult); } function newChat(title: string): string { diff --git a/tests/probe-done.test.ts b/tests/probe-done.test.ts index b1c76f7..1a55447 100644 --- a/tests/probe-done.test.ts +++ b/tests/probe-done.test.ts @@ -43,10 +43,7 @@ const ASSESSMENT = { async function execute(tools: ReturnType, input: unknown) { const t = tools.probe_done!; assert.ok(typeof t.execute === "function"); - return t.execute(input as never, { toolCallId: "test", messages: [] } as never) as Promise<{ - ok: boolean; - error?: string; - }>; + return t.execute("test", input).then((r) => r.details as { ok: boolean; error?: string }); } describe("probe_done: the probe→planning seam", () => { diff --git a/tests/question-core.test.ts b/tests/question-core.test.ts index c7c8d95..5bcec0e 100644 --- a/tests/question-core.test.ts +++ b/tests/question-core.test.ts @@ -4,7 +4,8 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { z } from "zod"; +import { Value } from "typebox/value"; +import type { TSchema } from "typebox"; import { askInputSchema, askOptionProblems, @@ -29,24 +30,25 @@ import { toClientQuestion, } from "../src/core/question-core.ts"; -// ─── the authoring contract (tool-schema refinements) ─────────────────── +// ─── the authoring contract (TypeBox schemas + execute-time refinements) ── // Schema-valid ⇔ gradeable: a quiz whose correctAnswer can't be resolved -// dies at the tool-call boundary, and the SDK returns the error to the -// teacher to retry — it must never reach the learner's dock. +// dies at the tool-call boundary (structural TypeBox check in pi's loop + +// the refinement problems in the tool's execute — a ToolInputError the +// model retries on), and never reaches the learner's dock. The boundary +// predicate is BOTH: structural check AND zero authoring problems. +const boundaryOk = (schema: TSchema, input: unknown, problems: string[]): boolean => + Value.Check(schema, input) && problems.length === 0; describe("the schema's argument order (reasoning before the pick)", () => { // Argument-order ruling (owner, 2026-09-25): the explanation is // reasoning, and reasoning happens BEFORE the answer is chosen — models // emit tool args in schema order, so explanation sits immediately before - // correctAnswer. z.toJSONSchema preserves zod's declaration order into - // the model-facing properties, so this pin guards the emission end to - // end. All consumers read by key — the order is authoring guidance. + // correctAnswer. The TypeBox authoring order IS the model-facing JSON + // Schema property order, so this pin guards the emission end to end. + // All consumers read by key — the order is authoring guidance. it("emits explanation immediately before correctAnswer (both agents' schemas)", () => { for (const schema of [quizInputSchema, probeQuizInputSchema]) { - const json = z.toJSONSchema(schema as z.ZodType, { io: "input" }) as { - properties: Record; - }; - const keys = Object.keys(json.properties); + const keys = Object.keys((schema as { properties: Record }).properties); assert.ok( keys.indexOf("explanation") < keys.indexOf("correctAnswer"), `explanation must precede correctAnswer in the emitted schema (got: ${keys.join(", ")})`, @@ -73,8 +75,7 @@ describe("quizInputSchema / quizInputProblems (the authoring contract)", () => { }; it("accepts a valid single-select quiz (label as correctAnswer)", () => { - const parsed = quizInputSchema.safeParse(valid); - assert.equal(parsed.success, true); + assert.equal(boundaryOk(quizInputSchema, valid, quizInputProblems(valid)), true); assert.deepEqual(quizInputProblems(valid), []); }); @@ -89,10 +90,10 @@ describe("quizInputSchema / quizInputProblems (the authoring contract)", () => { correctAnswer: ["a", "b"], multiSelect: true, }; - assert.equal(quizInputSchema.safeParse(multi).success, true); + assert.equal(boundaryOk(quizInputSchema, multi, quizInputProblems(multi)), true); // The union lists String first, so providers sometimes stringify arrays. const stringified = { ...multi, correctAnswer: JSON.stringify(multi.correctAnswer) }; - assert.equal(quizInputSchema.safeParse(stringified).success, true); + assert.equal(boundaryOk(quizInputSchema, stringified, quizInputProblems(stringified)), true); assert.deepEqual(quizInputProblems(stringified), []); }); @@ -108,20 +109,16 @@ describe("quizInputSchema / quizInputProblems (the authoring contract)", () => { correctAnswer: "cultural-diagnosis", explanation: "...", }; - const parsed = quizInputSchema.safeParse(bad); - assert.equal(parsed.success, false); + assert.equal(boundaryOk(quizInputSchema, bad, quizInputProblems(bad)), false); const problems = quizInputProblems(bad); assert.equal(problems.length, 1); assert.match(problems[0]!, /does not match any option value/); assert.match(problems[0]!, /"A cultural diagnosis"/); // the valid values are listed for the retry - // The schema's error message carries the problems (the SDK returns them to the teacher). - const msg = JSON.stringify(parsed.error?.issues); - assert.match(msg, /does not match any option value/); }); it("rejects multiple correct answers without multiSelect (ungradeable single-select)", () => { const bad = { ...valid, correctAnswer: ["Venus", "Mercury"] }; - assert.equal(quizInputSchema.safeParse(bad).success, false); + assert.equal(boundaryOk(quizInputSchema, bad, quizInputProblems(bad)), false); assert.match(quizInputProblems(bad).join(" "), /multiSelect/); }); @@ -133,38 +130,38 @@ describe("quizInputSchema / quizInputProblems (the authoring contract)", () => { { label: "Venus", value: "same" }, ], }; - assert.equal(quizInputSchema.safeParse(bad).success, false); + assert.equal(boundaryOk(quizInputSchema, bad, quizInputProblems(bad)), false); assert.match(quizInputProblems(bad).join(" "), /same value/); }); it("rejects an empty option label", () => { const bad = { ...valid, options: [{ label: " " }, { label: "Mercury" }, { label: "Mars" }] }; - assert.equal(quizInputSchema.safeParse(bad).success, false); + assert.equal(boundaryOk(quizInputSchema, bad, quizInputProblems(bad)), false); assert.match(quizInputProblems(bad).join(" "), /label is empty/); }); it("requires a resolvable correctAnswer", () => { - assert.equal(quizInputSchema.safeParse({ ...valid, correctAnswer: "" }).success, false); - assert.equal(quizInputSchema.safeParse({ ...valid, correctAnswer: "[]" }).success, false); + assert.equal(boundaryOk(quizInputSchema, { ...valid, correctAnswer: "" }, quizInputProblems({ ...valid, correctAnswer: "" })), false); + assert.equal(boundaryOk(quizInputSchema, { ...valid, correctAnswer: "[]" }, quizInputProblems({ ...valid, correctAnswer: "[]" })), false); assert.match(quizInputProblems({ ...valid, correctAnswer: "" }).join(" "), /required/); }); }); describe("askInputSchema / askOptionProblems", () => { it("accepts a valid ask", () => { - const parsed = askInputSchema.safeParse({ + const ask = { question: "Which topic first?", options: [{ label: "Limits" }, { label: "Chain rule" }], - }); - assert.equal(parsed.success, true); + }; + assert.equal(boundaryOk(askInputSchema, ask, askOptionProblems(ask.options ?? [])), true); }); it("rejects duplicate option values and empty labels (they break the dock's keying)", () => { const bad = { question: "?", options: [{ label: "A", value: "x" }, { label: "B", value: "x" }] }; - assert.equal(askInputSchema.safeParse(bad).success, false); + assert.equal(boundaryOk(askInputSchema, bad, askOptionProblems(bad.options ?? [])), false); assert.match(askOptionProblems(bad.options).join(" "), /same value/); const emptyLabel = { question: "?", options: [{ label: "" }, { label: "B" }] }; - assert.equal(askInputSchema.safeParse(emptyLabel).success, false); + assert.equal(boundaryOk(askInputSchema, emptyLabel, askOptionProblems(emptyLabel.options ?? [])), false); assert.match(askOptionProblems(emptyLabel.options).join(" "), /label is empty/); }); }); diff --git a/tests/question-tools.test.ts b/tests/question-tools.test.ts index 15cf79c..e0697aa 100644 --- a/tests/question-tools.test.ts +++ b/tests/question-tools.test.ts @@ -14,6 +14,7 @@ import assert from "node:assert/strict"; import * as fs from "node:fs"; import * as path from "node:path"; import { after, describe, it } from "node:test"; +import { Value } from "typebox/value"; import { createProberTools, createTeacherTools, teachQuizToolFor, type TeacherToolContext } from "../src/agent/tools.ts"; import { TOGGLEABLE_TOOLS } from "../src/core/pending.ts"; import { jobStore } from "../src/store/jobs.ts"; @@ -51,15 +52,33 @@ const validQuiz = { explanation: "Arithmetic.", }; -interface QuizToolSpec { - schema: { safeParse: (v: unknown) => { success: boolean; error?: { issues: Array<{ message: string }> } } }; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { ToolInputError } from "../src/agent/tool-execute.ts"; + +/** The Wave A boundary predicate (2026-09-26): pi's loop structurally + * validates against the TypeBox parameters, then execute re-runs the + * authoring refinements (the old superRefine) and throws ToolInputError + * on problems — the model sees both as retryable input errors. This + * helper replicates the old safeParse seam end to end. */ +async function boundaryParse( + // `input: never` param: contravariance lets any AgentTool's execute slot in. + tool: { parameters: any; execute: (id: string, input: never) => Promise }, + input: unknown, +): Promise<{ success: boolean; error?: ToolInputError }> { + if (!Value.Check(tool.parameters, input)) return { success: false }; + try { + await tool.execute("tc", input as never); + return { success: true }; + } catch (e) { + assert.ok(e instanceof ToolInputError, `expected ToolInputError, got: ${e}`); + return { success: false, error: e }; + } } -/** teachQuizToolFor returns the single quiz tool; createProberTools a registry. */ -const schemaOf = (toolLike: unknown) => (toolLike as QuizToolSpec).schema; -const quizSchemaOf = (tools: Record) => schemaOf(tools.quiz); +const problemLines = (e?: ToolInputError): string[] => + (e?.message.split("\n") ?? []).filter((l) => l.startsWith("- ")); describe("teachQuizToolFor: the dynamic plan-nodeId validation (position signal)", () => { - it("accepts a nodeId that matches a node of the session's tree", () => { + it("accepts a nodeId that matches a node of the session's tree", async () => { const chatId = newChat("NodeId: valid"); chatStore.saveTree(chatId, { nodes: [ @@ -69,46 +88,46 @@ describe("teachQuizToolFor: the dynamic plan-nodeId validation (position signal) edges: [{ from: "arith", to: "expressions" }], version: 1, }); - const res = schemaOf(teachQuizToolFor({ chatId, chatStore })).safeParse({ + const res = await boundaryParse(teachQuizToolFor({ chatId, chatStore }), { ...validQuiz, nodeId: "expressions", }); assert.equal(res.success, true); }); - it("rejects a nodeId that matches no node — with a message listing the slugs", () => { + it("rejects a nodeId that matches no node — with a message listing the slugs", async () => { const chatId = newChat("NodeId: typo"); chatStore.saveTree(chatId, { nodes: [{ id: "arith", title: "Arithmetic" }], edges: [], version: 1, }); - const res = schemaOf(teachQuizToolFor({ chatId, chatStore })).safeParse({ + const res = await boundaryParse(teachQuizToolFor({ chatId, chatStore }), { ...validQuiz, nodeId: "arithmetic", // the typo — slug is 'arith' }); assert.equal(res.success, false); - const msg = res.error!.issues.map((i) => i.message).join("\n"); + const msg = problemLines(res.error).join("\n"); assert.match(msg, /matches no node/); assert.match(msg, /arith/); }); - it("no tree yet: ANY nodeId is rejected — omit it for ad-hoc checks", () => { + it("no tree yet: ANY nodeId is rejected — omit it for ad-hoc checks", async () => { const chatId = newChat("NodeId: no tree"); - const schema = schemaOf(teachQuizToolFor({ chatId, chatStore })); - const res = schema.safeParse({ ...validQuiz, nodeId: "anything" }); + const tool = teachQuizToolFor({ chatId, chatStore }); + const res = await boundaryParse(tool, { ...validQuiz, nodeId: "anything" }); assert.equal(res.success, false); - assert.match(res.error!.issues.at(-1)!.message, /No plan map exists yet/); + assert.match(problemLines(res.error).at(-1)!, /No plan map exists yet/); // …while an untagged ad-hoc check is fine - const plain = schema.safeParse(validQuiz); + const plain = await boundaryParse(tool, validQuiz); assert.equal(plain.success, true); }); - it("reads the LIVE record — a plan_init mutation mid-turn is honored immediately", () => { + it("reads the LIVE record — a plan_init mutation mid-turn is honored immediately", async () => { const chatId = newChat("NodeId: live record"); - const schema = schemaOf(teachQuizToolFor({ chatId, chatStore })); + const tool = teachQuizToolFor({ chatId, chatStore }); - const before = schema.safeParse({ ...validQuiz, nodeId: "fresh-node" }); + const before = await boundaryParse(tool, { ...validQuiz, nodeId: "fresh-node" }); assert.equal(before.success, false, "no tree yet → rejected"); chatStore.saveTree(chatId, { @@ -116,31 +135,36 @@ describe("teachQuizToolFor: the dynamic plan-nodeId validation (position signal) edges: [], version: 1, }); - const after = schema.safeParse({ ...validQuiz, nodeId: "fresh-node" }); + const after = await boundaryParse(tool, { ...validQuiz, nodeId: "fresh-node" }); assert.equal(after.success, true, "the same id validates once the tree exists — same turn"); }); - it("the shared grading rules still chain FIRST (schema-valid ⇔ gradeable)", () => { + it("the shared grading rules still chain FIRST (schema-valid ⇔ gradeable)", async () => { const chatId = newChat("NodeId: grading rules intact"); - const res = schemaOf(teachQuizToolFor({ chatId, chatStore })).safeParse({ + const res = await boundaryParse(teachQuizToolFor({ chatId, chatStore }), { ...validQuiz, correctAnswer: "42", // matches no option — question-core's rule nodeId: "ghost", // also bad — BOTH problems surface }); assert.equal(res.success, false); - const msgs = res.error!.issues.map((i) => i.message).join("\n"); + const msgs = problemLines(res.error).join("\n"); assert.match(msgs, /correctAnswer/); assert.match(msgs, /matches no node/); }); }); describe("the prober's quiz: no nodeId field at all", () => { - it("the probe schema accepts a plain quiz and STRIPS a smuggled nodeId", () => { - const plain = probeQuizInputSchema.safeParse(validQuiz); - assert.equal(plain.success, true); - const smuggled = probeQuizInputSchema.safeParse({ ...validQuiz, nodeId: "anything" }); - assert.equal(smuggled.success, true, "the field doesn't exist — zod strips it"); - assert.equal("nodeId" in smuggled.data, false, "stripped, not tolerated"); + it("the probe schema accepts a plain quiz; a smuggled nodeId is NOT in the wire schema", () => { + assert.ok(Value.Check(probeQuizInputSchema, validQuiz)); + // Wave A delta (2026-09-26): pi's TypeBox validation tolerates unknown + // keys rather than stripping them — but the probe schema's wire + // properties carry no nodeId at all, and the display parse seam + // (tool-parts parseQuizInput, Value.Clean) strips it before the dock. + const smuggled = { ...validQuiz, nodeId: "anything" }; + assert.ok(Value.Check(probeQuizInputSchema, smuggled)); + const wireKeys = Object.keys((probeQuizInputSchema as { properties: Record }).properties); + assert.ok(!wireKeys.includes("nodeId"), "no session check to fail — the field isn't the prober's to set"); + assert.equal("nodeId" in (Value.Clean(probeQuizInputSchema, smuggled) as object), false, "the dock parse strips it"); }); it("the prober's tool registry carries the nodeId-less quiz, probe_done, and the learner-filesystem set", () => { @@ -151,8 +175,8 @@ describe("the prober's quiz: no nodeId field at all", () => { assert.deepEqual(Object.keys(tools).sort(), [ "ask_user_question", "find", "grep", "ls", "pdf", "probe_done", "quiz", "read", "research", ]); - const schema = quizSchemaOf(tools); - assert.equal(schema.safeParse({ ...validQuiz, nodeId: "anything" }).success, true, "no session check to fail"); + const quiz = tools.quiz as AgentTool; + assert.equal(Value.Check(quiz.parameters, { ...validQuiz, nodeId: "anything" }), true, "no session check to fail"); }); }); // ─── tool availability (2026-09 settings seam) ─────────────────────────────── diff --git a/tests/read-tool.test.ts b/tests/read-tool.test.ts index 04ae4f3..6b000d3 100644 --- a/tests/read-tool.test.ts +++ b/tests/read-tool.test.ts @@ -1,7 +1,10 @@ /** * The read tool (sources v0) — a Node port of pi's read semantics: * truncation with continuation offsets, image mime detection, error paths. - * Plus the lh-tool.ts image-result escape hatch it exercises. + * Driven through the NATIVE AgentTool execute (Wave A, 2026-09-26): text + * reads return the raw string as details (the record's tool part output + * reads it); image reads return native pi content parts (text + image) + * with null details (the output-less part until the record persists). */ import assert from "node:assert/strict"; import * as fs from "node:fs"; @@ -9,7 +12,6 @@ import * as os from "node:os"; import * as path from "node:path"; import { describe, it } from "node:test"; import { readTool } from "../src/agent/tools/read.ts"; -import { toPiTool } from "../src/agent/lh-tool.ts"; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "lh-read-test-")); @@ -19,16 +21,21 @@ function write(name: string, content: string): string { return file; } -/** The 2-line/8-byte local config, applied the same way as the real caps. */ +/** Native execute → the raw string body (text reads ride details verbatim). */ const run = (args: { path: string; offset?: number; limit?: number }) => - (readTool.execute as (i: unknown, c: { toolCallId: string }) => Promise)!(args, { - toolCallId: "test", + readTool.execute!("test", args).then((r) => { + assert.ok(r.content.length >= 1); + const first = r.content[0]!; + assert.equal(first.type, "text"); + return { text: (first as { type: "text"; text: string }).text, details: r.details }; }); describe("read tool — text semantics", () => { it("reads a small file verbatim", async () => { const file = write("small.txt", "hello\nworld"); - assert.equal(await run({ path: file }), "hello\nworld"); + const { text, details } = await run({ path: file }); + assert.equal(text, "hello\nworld"); + assert.equal(details, "hello\nworld", "the record part output carries the string verbatim"); }); it("resolves relative paths against the learner's home (sources v1 anchoring)", async () => { @@ -39,7 +46,7 @@ describe("read tool — text semantics", () => { const homeFile = path.join(os.homedir(), rel); fs.writeFileSync(homeFile, "rel", "utf-8"); try { - assert.equal(await run({ path: rel }), "rel"); + assert.equal((await run({ path: rel })).text, "rel"); } finally { fs.unlinkSync(homeFile); } @@ -48,7 +55,7 @@ describe("read tool — text semantics", () => { it("honors offset (1-indexed) and limit", async () => { const body = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`).join("\n"); const file = write("thirty.txt", body); - const out = (await run({ path: file, offset: 10, limit: 3 })) as string; + const out = (await run({ path: file, offset: 10, limit: 3 })).text; // pi parity: the explicit limit stopping short gets a remaining notice assert.match(out, /^line 10\nline 11\nline 12\n\n\[18 more lines in file\. Use offset=13 to continue\.\]$/); }); @@ -65,7 +72,7 @@ describe("read tool — text semantics", () => { it("truncates at 2000 lines with a use-offset continuation notice", async () => { const body = Array.from({ length: 3000 }, (_, i) => `l${i + 1}`).join("\n"); const file = write("big.txt", body); - const out = (await run({ path: file })) as string; + const out = (await run({ path: file })).text; assert.match(out, /Showing lines 1-2000 of 3000 \(2000 line limit\)/); assert.match(out, /Use offset=2001 to continue/); assert.ok(!out.includes("l2001\n"), "line 2001 must not ride along"); @@ -76,7 +83,7 @@ describe("read tool — text semantics", () => { const line = "x".repeat(400); const body = Array.from({ length: 200 }, () => line).join("\n"); const file = write("wide.txt", body); - const out = (await run({ path: file })) as string; + const out = (await run({ path: file })).text; assert.match(out, /50\.0KB limit\)\. Use offset=\d+ to continue\./); // and the truncation actually cut content assert.ok(out.length < 52_000, "the 50KB ceiling must cut the 80KB body"); @@ -85,38 +92,34 @@ describe("read tool — text semantics", () => { it("an explicit limit that stops short gets a remaining-lines notice", async () => { const body = Array.from({ length: 10 }, (_, i) => `l${i + 1}`).join("\n"); const file = write("ten.txt", body); - const out = (await run({ path: file, limit: 4 })) as string; + const out = (await run({ path: file, limit: 4 })).text; assert.match(out, /\[6 more lines in file\. Use offset=5 to continue\.\]/); }); }); -describe("read tool — image semantics", () => { - it("returns the LhToolImageResult escape-hatch shape for image files", async () => { +describe("read tool — image semantics (native content parts)", () => { + it("returns native text + image parts for image files, details null (output-less part)", async () => { const file = path.join(tmp, "dot.png"); // minimal valid PNG header bytes are enough — detection is by extension fs.writeFileSync(file, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); - const out = (await run({ path: file })) as { text: string; images?: { data: string; mimeType: string }[] }; - assert.match(out.text, /Read image file \[image\/png\]/); - assert.equal(out.images?.length, 1); - assert.equal(out.images![0]!.mimeType, "image/png"); - assert.match(out.images![0]!.data, /^[A-Za-z0-9+/]+={0,2}$/); + const result = await readTool.execute!("test", { path: file }); + assert.equal(result.content.length, 2); + const parts = result.content as Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + const textPart = parts[0]!; + const imagePart = parts[1]!; + assert.equal(textPart.type, "text"); + assert.match(textPart.text!, /Read image file \[image\/png\]/); + assert.equal(imagePart.type, "image"); + assert.equal(imagePart.mimeType, "image/png"); + assert.match(imagePart.data!, /^[A-Za-z0-9+/]+={0,2}$/); + assert.equal(result.details, null, "image reads keep the record part output-less"); }); - it("toPiTool maps the image result to native pi content parts", async () => { - const pi = toPiTool(readTool); - const result = await pi.execute("call-1", { path: path.join(tmp, "dot.png") }, undefined as never); - assert.ok(!("isError" in result) || !(result as { isError?: boolean }).isError); - const content = (result as { content: Array<{ type: string }> }).content; - assert.equal(content[0]!.type, "text"); - assert.equal(content[1]!.type, "image"); - }); - - it("toPiTool maps plain string results to a single text part (parity)", async () => { + it("plain string results map to a single text part (parity)", async () => { const file = write("plain.txt", "body"); - const pi = toPiTool(readTool); - const result = await pi.execute("call-2", { path: file }, undefined as never); - const content = (result as { content: Array<{ type: string }> }).content; - assert.equal(content.length, 1); - assert.equal(content[0]!.type, "text"); + const result = await readTool.execute!("test", { path: file }); + assert.equal(result.content.length, 1); + assert.equal(result.content[0]!.type, "text"); + assert.equal((result.content[0] as { type: "text"; text: string }).text, "body"); }); -}); \ No newline at end of file +}); diff --git a/tests/tool-abort.test.ts b/tests/tool-abort.test.ts index bf16e66..8f390d8 100644 --- a/tests/tool-abort.test.ts +++ b/tests/tool-abort.test.ts @@ -1,17 +1,25 @@ /** - * The abort-cascade fix (2026-09-19): pi passes every run's AbortSignal to - * tool execute — the bridge used to drop it, so agent.abort() only landed - * at the NEXT step boundary and a long-running local tool (the grep that - * outlived its turn by minutes) held the turn hostage until the dev - * server's memory restart killed the run mid-tool, freezing the record at - * its write-ahead state. toPiTool now threads the signal and races every - * execute against the system-level timeout; the fs tools honor it with - * partial results (fs-tools.test.ts covers their side). + * The abort-cascade fix (2026-09-19), pinned against the pi-native seams + * (Wave A, 2026-09-26): serverExecute (tool-execute.ts) races every tool + * body against the run's AbortSignal and the system-level per-call timeout + * — aborting the run kills a running tool, the timeout is the net so no + * tool can hold a turn hostage (the 2.7-minute grep lesson). Question + * tools bypass the guard entirely (questionResult is instant and carries + * terminate). The fs tools honor the signal with partial results + * (fs-tools.test.ts covers their side). */ import assert from "node:assert/strict"; import { afterEach, describe, it } from "node:test"; -import { z } from "zod"; -import { lhTool, toPiTool, toolTimeoutMs, ToolInputError } from "../src/agent/lh-tool.ts"; +import { Type } from "typebox"; +import { + questionResult, + serverExecute, + toolTimeoutMs, + ToolInputError, + toToolResult, + type ToolExecuteContext, +} from "../src/agent/tool-execute.ts"; +import { askUserQuestionTool } from "../src/agent/tools.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -19,34 +27,37 @@ afterEach(() => { toolTimeoutMs.current = DEFAULT_TIMEOUT_MS; }); -/** A tool that captures ctx.signal, then either hangs or resolves `value`. */ +const probeSchema = Type.Object({}); + +/** A native tool whose execute captures ctx.signal, then hangs or resolves. */ function probeTool(captured: { signal?: AbortSignal }, opts: { hang?: boolean; value?: string } = {}) { - return lhTool({ + return { name: "probe", + label: "probe", description: "test probe", - schema: z.object({}), - execute: (_input, ctx) => { - captured.signal = ctx?.signal; + parameters: probeSchema, + execute: serverExecute("probe", probeSchema, (_input: unknown, ctx: ToolExecuteContext) => { + captured.signal = ctx.signal; if (opts.hang) return new Promise(() => {}); // never settles, no timer return opts.value ?? "ok"; - }, - }); + }), + }; } -describe("toPiTool abort + timeout guard", () => { +describe("serverExecute abort + timeout guard", () => { it("forwards a live abort signal to the tool's ctx", async () => { // Even with no parent signal, the tool still gets the guard's combined // signal (parent+timeout) — a stable reference tools can poll. const captured: { signal?: AbortSignal } = {}; - const pi = toPiTool(probeTool(captured)); - await pi.execute("c1", {}, undefined as never); + const pi = probeTool(captured); + await pi.execute("c1", {}); assert.ok(captured.signal, "the tool received a signal"); assert.equal(captured.signal!.aborted, false); // The forwarded signal is the guard's COMBINED one — aborting the // parent flips it, which is what long-running tools poll. const ac = new AbortController(); const captured2: { signal?: AbortSignal } = {}; - const pi2 = toPiTool(probeTool(captured2, { value: "done" })); + const pi2 = probeTool(captured2, { value: "done" }); const pending = pi2.execute("c1b", {}, ac.signal); ac.abort(); await pending; @@ -56,7 +67,7 @@ describe("toPiTool abort + timeout guard", () => { it("a signal-wired tool observes the run's abort", async () => { const ac = new AbortController(); const captured: { signal?: AbortSignal } = {}; - const pi = toPiTool(probeTool(captured, { hang: true })); + const pi = probeTool(captured, { hang: true }); const pending = pi.execute("c2", {}, ac.signal).catch((e: Error) => e.message); ac.abort(); assert.match((await pending) as string, /Operation aborted/); @@ -65,68 +76,87 @@ describe("toPiTool abort + timeout guard", () => { it("rejects a hanging tool at the system-level timeout", async () => { toolTimeoutMs.current = 50; - const pi = toPiTool(probeTool({}, { hang: true })); - await assert.rejects(pi.execute("c3", {}, undefined as never), /timed out/); + const pi = probeTool({}, { hang: true }); + await assert.rejects(pi.execute("c3", {}), /timed out/); }); it("a pre-aborted run rejects immediately with pi's wording", async () => { const ac = new AbortController(); ac.abort(); - const pi = toPiTool(probeTool({}, { hang: true })); + const pi = probeTool({}, { hang: true }); await assert.rejects(pi.execute("c4", {}, ac.signal), /Operation aborted/); }); - - it("a tool that honors the signal settles before the timeout wins", async () => { - // The race resolves with the tool's own (partial) result — the fs - // tools' contract: abort yields partial results, not an error throw. +}); +describe("serverExecute: cooperative settle, mapping parity, question bypass", () => { + it("a cooperative tool settles partial results before the grace", async () => { toolTimeoutMs.current = 2_000; - const tool = lhTool({ + const schema = Type.Object({}); + const pi = { name: "partial", + label: "partial", description: "test", - schema: z.object({}), - execute: (_input, ctx) => + parameters: schema, + execute: serverExecute("partial", schema, (_input: unknown, ctx: ToolExecuteContext) => new Promise((resolve) => { // The fs-tools contract: poll upfront (a listener on an // already-aborted signal never fires), else listen. - const signal = ctx!.signal!; + const signal = ctx.signal!; if (signal.aborted) return resolve("partial results"); signal.addEventListener("abort", () => resolve("partial results"), { once: true }); - }), - }); + })), + }; const ac = new AbortController(); - const pi = toPiTool(tool); const pending = pi.execute("c5", {}, ac.signal); ac.abort(); - const result = (await pending) as { content: Array<{ text?: string }> }; - assert.equal(result.content[0]!.text, "partial results"); + const result = await pending; + assert.equal((result.content[0] as { text?: string }).text, "partial results"); }); - it("question tools bypass the guard — the placeholder is instant", async () => { + it("questionResult is instant, terminates, and carries no details", async () => { const ac = new AbortController(); - ac.abort(); - const tool = lhTool({ - name: "quiz", - description: "test", - schema: z.object({}), - clientQuestion: true, - }); - const pi = toPiTool(tool); - const result = (await pi.execute("c6", {}, ac.signal)) as { content: Array<{ text?: string }> }; - assert.match(result.content[0]!.text!, /Question posed/); + ac.abort(); // a pre-aborted run signal — the placeholder must not care + const result = await askUserQuestionTool.execute("c6", { question: "q" }, ac.signal); + assert.match((result.content[0] as { text?: string }).text!, /Question posed/); + assert.equal(result.details, null); + assert.equal(result.terminate, true, "owner Option A: question results end the batch natively"); }); - it("validation errors pass through the guard unchanged", async () => { - const tool = lhTool({ - name: "strict", - description: "test", - schema: z.object({ n: z.number() }), - execute: () => "never", - }); - const pi = toPiTool(tool); - await assert.rejects(pi.execute("c7", { n: "not a number" }, undefined as never), (e: unknown) => { - assert.ok(e instanceof ToolInputError); - assert.equal(e.toolName, "strict"); - return true; + it("authoring failures throw ToolInputError with the retryable problem list", async () => { + await assert.rejects( + askUserQuestionTool.execute("c7", { + question: "q", + options: [ + { label: "A", value: "same" }, + { label: "B", value: "same" }, + ], + }), + (e: unknown) => { + assert.ok(e instanceof ToolInputError); + assert.equal(e.toolName, "ask_user_question"); + assert.match(e.message, /Invalid tool input for tool "ask_user_question":/); + assert.match(e.message, /- .*same value/s); + return true; + }, + ); + }); + + it("toToolResult parity: string verbatim, object as JSON, details rides the raw value", () => { + const str = toToolResult("plain"); + assert.equal((str.content[0] as { text: string }).text, "plain"); + assert.equal(str.details, "plain"); + const obj = toToolResult({ a: 1 }); + assert.equal((obj.content[0] as { text: string }).text, '{"a":1}'); + assert.deepEqual(obj.details, { a: 1 }); + const img = toToolResult({ + text: "the picture", + images: [{ data: "AAAA", mimeType: "image/png" }], + details: { page: 2 }, }); + assert.equal(img.content.length, 2); + assert.equal(img.content[0]!.type, "text"); + assert.equal(img.content[1]!.type, "image"); + assert.deepEqual(img.details, { page: 2 }); + const imgNoDetails = toToolResult({ text: "t", images: [{ data: "AAAA", mimeType: "image/png" }] }); + assert.equal(imgNoDetails.details, null, "image reads keep the record part output-less (null details)"); }); }); diff --git a/tests/tool-parts.test.ts b/tests/tool-parts.test.ts index 1072e5c..2dbd585 100644 --- a/tests/tool-parts.test.ts +++ b/tests/tool-parts.test.ts @@ -10,6 +10,8 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; +import { quizInputProblems } from "../src/core/question-core.ts"; +import { Value } from "typebox/value"; import { parseAskInput, parseProbeDoneInput, @@ -59,15 +61,13 @@ describe("parseQuizInput", () => { }); it("boundary parity: anything the TEACH schema accepts, the display parse accepts", () => { - const boundary = quizInputSchema.safeParse(TEACHER_QUIZ); - assert.ok(boundary.success, "fixture must be boundary-valid for this pin to mean anything"); - assert.ok(parseQuizInput(boundary.data)); + assert.ok(Value.Check(quizInputSchema, TEACHER_QUIZ), "fixture must be boundary-valid for this pin to mean anything"); + assert.ok(parseQuizInput(TEACHER_QUIZ)); }); it("boundary parity: anything the PROBE schema accepts, the display parse accepts", () => { - const boundary = probeQuizInputSchema.safeParse(PROBER_QUIZ); - assert.ok(boundary.success); - assert.ok(parseQuizInput(boundary.data)); + assert.ok(Value.Check(probeQuizInputSchema, PROBER_QUIZ)); + assert.ok(parseQuizInput(PROBER_QUIZ)); }); it("the live crash shape — options as a JSON string — does not parse", () => { @@ -75,6 +75,40 @@ describe("parseQuizInput", () => { assert.equal(parseQuizInput(stringified), undefined); }); + it("boundary parity under Value.Convert: a NUMERIC correctAnswer parses (live-smoke lockout fix, 2026-09-26)", () => { + // The live smoke's model emitted correctAnswer: 17 (number). pi's + // boundary runs Value.Convert before Value.Check, so the quiz posed + // — but the record keeps the RAW args. A parse seam without Convert + // would reject exactly what the boundary accepted: the pending quiz + // renders nowhere while the turn has ended = learner lockout. + // Dock-visible parity case: the smoke's real emission — options whose + // values ARE the numbers, correctAnswer numeric. Convert → "17", + // refinement passes (it matches an option), the quiz parses. + const numeric = { + question: "What is 5 + 3 × 4?", + options: [ + { label: "17", value: "17" }, + { label: "27", value: "27" }, + { label: "9", value: "9" }, + ], + explanation: "Multiplication first.", + correctAnswer: 17, + }; + const parsed = parseQuizInput(numeric); + assert.ok(parsed); + assert.equal(parsed.correctAnswer, "17", "converted to the string the boundary saw"); + const numericArray = { ...numeric, multiSelect: true, correctAnswer: [17, "27"] }; + const parsedArray = parseQuizInput(numericArray); + assert.ok(parsedArray); + assert.deepEqual(parsedArray.correctAnswer, ["17", "27"]); + // Non-convertible garbage still never parses (structure owns it)… + assert.equal(parseQuizInput({ ...PROBER_QUIZ, correctAnswer: { bad: true } }), undefined); + // …and Convert's widening (undefined → "") is caught by the refinement + // chain the boundary itself uses — garbage renders as a quiet fallback. + const widened = { ...PROBER_QUIZ, correctAnswer: undefined }; + assert.equal(parseQuizInput(widened), undefined, "the refinement owns the reject"); + }); + it("garbage never parses: non-object, missing question, missing correctAnswer", () => { assert.equal(parseQuizInput("a string"), undefined); assert.equal(parseQuizInput({ options: [] }), undefined); @@ -102,9 +136,8 @@ describe("parseProbeDoneInput", () => { }); it("boundary parity: anything the tool schema accepts, the display parse accepts", () => { - const boundary = probeDoneInputSchema.safeParse(VALID_ASSESSMENT); - assert.ok(boundary.success); - assert.ok(parseProbeDoneInput(boundary.data)); + assert.ok(Value.Check(probeDoneInputSchema, VALID_ASSESSMENT)); + assert.ok(parseProbeDoneInput(VALID_ASSESSMENT)); }); it("the live crash shape — assessment STRINGIFIED — does not parse", () => { diff --git a/tests/tool-toggles-bridge.test.ts b/tests/tool-toggles-bridge.test.ts index 8d20c46..130e670 100644 --- a/tests/tool-toggles-bridge.test.ts +++ b/tests/tool-toggles-bridge.test.ts @@ -13,6 +13,8 @@ import assert from "node:assert/strict"; import { z } from "zod"; import { describe, it } from "node:test"; +import { Type } from "typebox"; +import { questionResult } from "../src/agent/tool-execute.ts"; import { createProberAgent, createTeacherAgent } from "../src/agent/teacher-agent.ts"; import type { ProberToolContext } from "../src/agent/tools.ts"; import { jobStore } from "../src/store/jobs.ts"; @@ -53,7 +55,13 @@ describe("AgentBuildOptions.toolToggles (the P2 bridge)", () => { const teacher = createTeacherAgent(undefined, { engine: fauxEngine(), tools: { - quiz: { name: "quiz", description: "test", clientQuestion: true, schema: z.object({}) }, + quiz: { + name: "quiz", + label: "quiz", + description: "test", + parameters: Type.Object({}), + execute: async () => questionResult(), + }, }, toolToggles: { quiz: false }, }); diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index 1b062ac..283d3c1 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -31,9 +31,9 @@ function stubProvider(hits: SearchHit[] = [{ title: "t", url: "https://e.com/", describe("web_search on the injected port (web.ts)", () => { it("null provider → the honest unavailability string, never a crash", async () => { const { web_search } = createWebTools(() => null); - const out = await web_search.execute!({ query: "anything" }, { toolCallId: "c1" }); - assert.match(String(out), /not available: no search engine is configured/); - assert.match(String(out), /Tavily or Brave/); + const out = await web_search!.execute!("c1", { query: "anything" }); + assert.match(String(out.details), /not available: no search engine is configured/); + assert.match(String(out.details), /Tavily or Brave/); }); it("a live provider returns its hits (count defaults to 5)", async () => { @@ -46,9 +46,9 @@ describe("web_search on the injected port (web.ts)", () => { }, }; const { web_search } = createWebTools(() => provider); - const out = await web_search.execute!({ query: "sigma.js latest version" }, { toolCallId: "c1" }); + const out = await web_search!.execute!("c1", { query: "sigma.js latest version" }); assert.deepEqual(seen, [{ query: "sigma.js latest version", count: 5 }]); - assert.deepEqual(out, [{ title: "sigma.js", url: "https://www.sigmajs.org/", snippet: "v3 released" }]); + assert.deepEqual(out.details, [{ title: "sigma.js", url: "https://www.sigmajs.org/", snippet: "v3 released" }]); }); it("provider errors surface as error strings, never thrown", async () => { @@ -59,26 +59,26 @@ describe("web_search on the injected port (web.ts)", () => { }, }; const { web_search } = createWebTools(() => provider); - const out = await web_search.execute!({ query: "q" }, { toolCallId: "c1" }); - assert.match(String(out), /web_search failed: Brave search API 429: rate limited/); + const out = await web_search!.execute!("c1", { query: "q" }); + assert.match(String(out.details), /web_search failed: Brave search API 429: rate limited/); }); it("the resolution is FRESH per call — a key appearing mid-session takes effect", async () => { let provider: SearchProvider | null = null; const { web_search } = createWebTools(() => provider); - const first = await web_search.execute!({ query: "q" }, { toolCallId: "c1" }); - assert.match(String(first), /not available/); + const first = await web_search!.execute!("c1", { query: "q" }); + assert.match(String(first.details), /not available/); provider = stubProvider(); - const second = await web_search.execute!({ query: "q" }, { toolCallId: "c2" }); - assert.ok(Array.isArray(second), "the live provider answered on the very next call"); + const second = await web_search!.execute!("c2", { query: "q" }); + assert.ok(Array.isArray(second.details), "the live provider answered on the very next call"); }); it("web_fetch needs no search engine — it stays independent", async () => { const { web_fetch } = createWebTools(() => null); assert.ok(web_fetch, "web_fetch present with no provider"); // (Full fetch behavior is out of scope here — no network in tests.) - const out = await web_fetch.execute!({ url: "not a url" }, { toolCallId: "c1" }); - assert.match(String(out), /web_fetch failed: invalid URL/); + const out = await web_fetch!.execute!("c1", { url: "not a url" }); + assert.match(String(out.details), /web_fetch failed: invalid URL/); }); });