Wave A: pi-native tool layer — delete LhTool/toPiTool, TypeBox everywhere, terminate on questions

- src/agent/lh-tool.ts deleted: the zod-wrapped LhTool + toPiTool bridge
  (z.toJSONSchema per call, clientQuestion flag, hand-rolled result mapping)
  is gone. Tools are pi-agent-core native AgentTools now.
- New src/agent/tool-execute.ts owns what the wrapper added: the abort guard
  (runWithAbortGuard: run signal + 30s per-call timeout + 250ms grace,
  'Operation aborted' / timeout wording preserved), ToolInputError (the
  retryable '- problem at path' format), result mapping (string verbatim /
  object JSON / image parts, details rides the record's tool part output),
  and questionResult() — placeholder + terminate:true (owner Option A:
  belt of terminate + slim makeQuestionStopper with the clean-question
  rule; maxTurns deleted from the teaching turn entirely per owner).
- All 11 tool files + tools.ts registries converted to TypeBox parameters
  (typebox@1.3.7, pinned to pi-agent-core's copy). zod refinements
  (quizInputProblems, askOptionProblems, live-nodeId) moved from schemas
  into execute — the model sees the same retryable error shape.
- question-core + contracts/probe TypeBox-authored (single source shared
  with the UI); tool-parts parse seam runs the boundary's full chain
  (Convert → Check → Clean → refinement), fixing the live-smoke lockout:
  pi's boundary Value.Converts correctAnswer: 17 → '17' but the record
  keeps raw args, so a parse without Convert rejected a posed quiz
  (invisible pending question, turn ended). Pinned with the real smoke
  emission shape.
- Subagents converted: demo-builder/researcher AgentTool-native,
  visual-maker/pdf-reader declaration-only pi-ai Tools with local execute
  fns, extraction submit schemas re-authored in TypeBox (owner option 2:
  zod defaults → Type.Optional + fill at the submit seam).
- Accepted delta, flagged: TypeBox-native schemas skip pi's JSON-Schema
  primitive coercion fast path... (boundary still Value.Converts), and
  prober-quiz unknown keys ride through (Value.Clean strips at display).
- Tests: 988 passing (was 967 at the recon baseline); tool-abort and
  read-tool rewritten against the native seams; question-core/question-
  tools/tool-parts/demos/web-search/graph-tools/plan-tree/probe-done/
  tool-toggles converted to Value.Check/execute pins.

Live smoke (PORT=3004, real model): native ls turn streams end-to-end; a
real quiz pose (first attempt missing correctAnswer → tool-input-error →
model retry → posed) terminates the turn cleanly and persists pending.
This commit is contained in:
amosblomqvist
2026-09-25 23:32:13 +03:00
parent b07cb313f0
commit b87a6925af
35 changed files with 1357 additions and 1083 deletions
-240
View File
@@ -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<TInput = any, TResult = any> {
name: string;
description: string;
/** The authoring contract (zod, refinements included). */
schema: z.ZodType<TInput>;
/**
* 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<TResult>;
/** Client-answered question tool (quiz/ask): a call ends the turn. */
clientQuestion?: boolean;
}
/** Author a tool (the `tool({...})` shape, engine-neutral). */
export function lhTool<TInput, TResult = unknown>(def: LhTool<TInput, TResult>): LhTool<TInput, TResult> {
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<T>(
toolName: string,
run: (signal: AbortSignal) => T | Promise<T>,
parent: AbortSignal | undefined,
): Promise<T> {
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<typeof setTimeout> | undefined;
const cleanup = () => {
clearTimeout(timer);
if (graceTimer) clearTimeout(graceTimer);
parent?.removeEventListener("abort", forwardAbort);
};
return new Promise<T>((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<any, any>): AgentTool<any, any> {
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<string, LhTool<any, any>>): AgentTool<any, any>[] {
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<string, LhTool<any, any>>) {
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,
}));
}
+31 -21
View File
@@ -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: <!doctype html>…</html>, 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<string, LhTool<any, any>> {
): Record<string, AgentTool<any>> {
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: <!doctype html>…</html>, 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,
+184 -109
View File
@@ -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<SessionFindings, "attribution">;
type NotesAuditResult = Omit<AuditResult, "attribution">;
// ── the weak-model ??-fills (the old zod defaults, applied at submit) ──
function fillFindings(raw: Static<typeof findingsSchema>): SessionFindings {
return {
propositions: raw.propositions,
edges: raw.edges ?? [],
attribution: raw.attribution ?? { quiz: {} },
};
}
function fillNotesFindings(raw: Static<typeof notesFindingsSchema>): NotesFindings {
return { propositions: raw.propositions, edges: raw.edges ?? [] };
}
function fillAudit(raw: Static<typeof auditSchema>): AuditResult {
return {
propositions: raw.propositions,
edges: raw.edges ?? [],
attribution: raw.attribution ?? { quiz: {} },
dropped: raw.dropped ?? [],
};
}
function fillNotesAudit(raw: Static<typeof notesAuditSchema>): 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<T>(opts: {
role: ModelRole;
toolName: string;
toolDescription: string;
schema: z.ZodType<T>;
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<T>(opts: {
}): Promise<T> {
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<any>,
],
{
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<SessionFindings, "attribution">;
type NotesAuditResult = Omit<AuditResult, "attribution">;
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,
+60 -65
View File
@@ -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<any, any>;
pdf_extract: LhTool<any, any>;
pdf_render: LhTool<any, any>;
pdf_search: LhTool<any, any>;
} {
/** 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<string, Tool> {
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<string> {
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<string> {
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<typeof piToolDeclarations>,
tools: Tool[],
reasoning: ThinkingLevel | undefined,
abortSignal?: AbortSignal,
): Promise<AssistantMessage> {
+1 -2
View File
@@ -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<string> {
const { text } = await runSubagent(toPiTools(createWebTools(opts.search ?? (() => null))) as AgentTool<any, any>[], {
const { text } = await runSubagent(Object.values(createWebTools(opts.search ?? (() => null))), {
role: "researcher",
prompt,
engine: opts.engine,
+20 -18
View File
@@ -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<string, LhTool<any, any>> {
/** 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<string, Tool> {
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 <svg xmlns="…">…</svg> markup.'),
parameters: Type.Object({
svg: Type.String({ description: 'Complete <svg xmlns="…">…</svg> 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<typeof piToolDeclarations>,
tools: Tool[],
reasoning: ThinkingLevel | undefined,
abortSignal?: AbortSignal,
): Promise<AssistantMessage> {
+46 -34
View File
@@ -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<string, LhTool<any, any>>;
tools?: Record<string, AgentTool<any>>;
/** 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<any, any>[]) {
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<string, LhTool<any, any>> {
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<string, AgentTool<any>> {
// A typed const (not an inline literal): the specific AgentTool typing
// gives execute its precise Static input; heterogeneous records take
// AgentTool<any>, whose loose Static would smudge it to unknown.
const quiz: AgentTool<typeof quizInputObject> = {
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,
};
}
+223
View File
@@ -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<T>(
toolName: string,
run: (signal: AbortSignal) => T | Promise<T>,
parent: AbortSignal | undefined,
): Promise<T> {
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<typeof setTimeout> | undefined;
const cleanup = () => {
clearTimeout(timer);
if (graceTimer) clearTimeout(graceTimer);
parent?.removeEventListener("abort", forwardAbort);
};
return new Promise<T>((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<unknown> {
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<P extends TSchema>(
toolName: string,
schema: P,
run: (input: Static<P>, ctx: ToolExecuteContext) => unknown,
): AgentTool<any>["execute"] {
// The returned execute is deliberately typed loose (params: unknown) so
// it satisfies every AgentTool slot — heterogeneous registries type
// AgentTool<any>, 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<P> 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<P>, { 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<null> {
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,
};
}
+70 -42
View File
@@ -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<any, any> = {
export const askUserQuestionTool: AgentTool<typeof askInputSchema> = {
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<any, any> = {
export const probeQuizTool: AgentTool<typeof probeQuizInputSchema> = {
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<any, any> = {
"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<string, LhTool<any, any>> {
): Record<string, AgentTool<any>> {
// 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<PlanToolContext, "chatId" | "chatStore">, 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<PlanToolContext, "chatId" | "chatStore">) {
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<PlanToolContext, "chatId" | "chatStore">): AgentTool<typeof quizInputObject> {
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<PlanToolContext, "chatId" | "chatStor
"An always-present optional note field lets the learner attach a 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.",
schema: inputSchema,
});
parameters: quizInputObject,
executionMode: "sequential",
execute: async (_toolCallId, input) => {
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<string, LhTool<any, any>> {
): Record<string, AgentTool<any>> {
return filterEnabledTools(
{
quiz: teachQuizToolFor(ctx),
+16 -14
View File
@@ -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<typeof findInput> = {
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;
},
});
}),
};
+25 -18
View File
@@ -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<string, LhTool<any, any>> {
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<string, AgentTool<any>> {
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<string> => {
parameters: listSessionsInput,
execute: serverExecute("list_sessions", listSessionsInput, async (): Promise<string> => {
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<string, LhTool<a
`born ${s.createdAt ?? "unknown date"}${s.updatedAt ? `, last active ${s.updatedAt}` : ""}) — id: ${s.id}`,
);
return `${sessions.length} session(s) in the knowledge graph, most recent first:\n${lines.join("\n")}`;
},
}),
list_propositions: lhTool({
}),
},
list_propositions: {
name: "list_propositions",
label: "list_propositions",
description:
"List the propositions BORN in one knowledge-graph session — what was first learned there — each with his latest understanding level on the scale solid / shaky / accepted / resisted / not yet engaged, and the date that level was set. " +
"Names, levels, and dating only — no statement text, no session contents. " +
"The level is that session's own latest evaluation; other sessions may have moved it since, so cross-session levels can differ.",
schema: z.object({
session: z
.string()
.min(1)
.describe("The session's id exactly as list_sessions reported it (a chat id or a notes source id)"),
}),
execute: async ({ session }): Promise<string> => {
parameters: listPropositionsInput,
execute: serverExecute("list_propositions", listPropositionsInput, async ({ session }): Promise<string> => {
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<string, LhTool<a
(p) => `- ${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")}`;
},
}),
}),
},
};
}
+23 -18
View File
@@ -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<typeof grepInput> = {
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;
},
});
}),
};
+55 -48
View File
@@ -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<string, LhTool<any, any>> {
export function jobTools(ctx: JobToolContext): Record<string, AgentTool<any>> {
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<typeof researchInputSchema>;
export type DemoInput = z.infer<typeof demoInputSchema>;
export type VisualInput = z.infer<typeof visualInputSchema>;
export type PdfInput = z.infer<typeof pdfInputSchema>;
export type ResearchInput = Static<typeof researchInputSchema>;
export type DemoInput = Static<typeof demoInputSchema>;
export type VisualInput = Static<typeof visualInputSchema>;
export type PdfInput = Static<typeof pdfInputSchema>;
+16 -13
View File
@@ -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<typeof lsInput> = {
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;
},
});
}),
};
+20 -17
View File
@@ -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<string, LhTool<any, any>> {
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<string, AgentTool<any>> {
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<PlanToolResult> => {
parameters: planInitInput,
execute: serverExecute("plan_init", planInitInput, async ({ mermaid }): Promise<PlanToolResult> => {
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<string, LhTool<any, any>
if (err instanceof PlanTreeError) return { ok: false, error: err.message, tree: current };
throw err;
}
},
}),
}),
},
};
}
+10 -8
View File
@@ -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<string, LhTool<any, any>> {
export function probeDoneTool(ctx: ProbeDoneContext): Record<string, AgentTool<any>> {
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<string, LhTool<any,
"the probe is already concluded. After it succeeds, say your closing sentence and end your turn.",
// The core-owned schema (contracts/probe.ts) — the SAME schema the UI
// parse seam (tool-parts.ts) runs, so boundary-valid ⇔ consumer-parseable.
schema: probeDoneInputSchema,
execute: async (input): Promise<ProbeDoneResult> => {
parameters: probeDoneInputSchema,
execute: serverExecute("probe_done", probeDoneInputSchema, async (input): Promise<ProbeDoneResult> => {
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<string, LhTool<any,
// assessment anyway; the wake is an optimization, not a gate.
}
return { ok: true, assessment: input.assessment };
},
}),
}),
},
};
}
+15 -13
View File
@@ -1,7 +1,8 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { z } from "zod";
import { lhTool, type LhToolImageResult } from "../lh-tool.ts";
import { Type } from "typebox";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { serverExecute, type ImageFileResult } from "../tool-execute.ts";
import { resolveSourcePath } from "./fs-shared.ts";
/**
@@ -17,8 +18,8 @@ import { resolveSourcePath } from "./fs-shared.ts";
* continuation notices — a huge file never silently floods the context.
* - image files (jpg/png/gif/webp/bmp): returned as native image content
* parts so the vision-capable model actually SEES them (teacher's
* glm-5.3-flash accepts image input per pi's zai catalog; the converter
* in lh-tool.ts maps LhToolImageResult to pi's ImageContent).
* glm-5.3-flash accepts image input per pi's zai catalog; tool-execute's
* toToolResult maps ImageFileResult to pi's ImageContent).
* - unreadable/missing files throw — pi turns that into an error tool
* result the model sees and recovers from.
*
@@ -41,10 +42,10 @@ const IMAGE_MIME: Record<string, string> = {
".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<typeof readInput> = {
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<string | LhToolImageResult> => {
parameters: readInput,
execute: serverExecute("read", readInput, async ({ path: rawPath, offset, limit }): Promise<string | ImageFileResult> => {
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);
},
});
}),
};
+28 -19
View File
@@ -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<string> {
// ── 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<string, AgentTool<any>> {
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<WebSearchResult[] | string> => {
parameters: webSearchInput,
execute: serverExecute("web_search", webSearchInput, async ({ query, count }): Promise<WebSearchResult[] | string> => {
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<string> => {
parameters: webFetchInput,
execute: serverExecute("web_fetch", webFetchInput, async ({ url }): Promise<string> => {
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)}`;
}
},
}),
}),
},
};
}
+34 -32
View File
@@ -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<ProbeDoneInput>;
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<typeof probeDoneInputSchema>): ProbeDoneInput => v;
/** probe_done tool output. */
export interface ProbeDoneResult {
+110 -107
View File
@@ -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<typeof quizInputSchema>;
export type AskToolInput = z.infer<typeof askInputSchema>;
/** 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<typeof quizInputObject>;
export type AskToolInput = Static<typeof askInputSchema>;
/** A real selected answer, in the order the options were displayed. */
+49 -26
View File
@@ -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<T>(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<QuizToolInput>(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<AskToolInput>(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<T>(schema: z.ZodType<T>, input: unknown): T | undefined {
const parsed = schema.safeParse(input);
return parsed.success ? parsed.data : undefined;
return boundaryParse<ProbeDoneInput>(probeDoneInputSchema, input);
}