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:
Generated
+1
@@ -50,6 +50,7 @@
|
|||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tokenlens": "^1.3.1",
|
"tokenlens": "^1.3.1",
|
||||||
"turndown": "^7.2.4",
|
"turndown": "^7.2.4",
|
||||||
|
"typebox": "1.3.7",
|
||||||
"use-stick-to-bottom": "^1.1.6",
|
"use-stick-to-bottom": "^1.1.6",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@ai-sdk/react": "^4.0.79",
|
"@ai-sdk/react": "^4.0.79",
|
||||||
"@earendil-works/pi-agent-core": "^0.85.1",
|
"@earendil-works/pi-agent-core": "^0.85.1",
|
||||||
"@earendil-works/pi-ai": "^0.85.1",
|
"@earendil-works/pi-ai": "^0.85.1",
|
||||||
|
"typebox": "1.3.7",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.6",
|
"@radix-ui/react-use-controllable-state": "^1.2.6",
|
||||||
"@rive-app/react-webgl2": "^4.32.1",
|
"@rive-app/react-webgl2": "^4.32.1",
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -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 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 type { PiEngine } from "../models.ts";
|
||||||
import { runSubagent } from "./pi-run.ts";
|
import { runSubagent } from "./pi-run.ts";
|
||||||
import type { DemoResult } from "../../core/contracts.ts";
|
import type { DemoResult } from "../../core/contracts.ts";
|
||||||
@@ -77,39 +78,48 @@ interface BuilderState {
|
|||||||
lastResult: DemoRunResult | null;
|
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(
|
export function createDemoBuilderTools(
|
||||||
state: BuilderState,
|
state: BuilderState,
|
||||||
headless: HeadlessApi,
|
headless: HeadlessApi,
|
||||||
onActivity?: (line: string) => void,
|
onActivity?: (line: string) => void,
|
||||||
): Record<string, LhTool<any, any>> {
|
): Record<string, AgentTool<any>> {
|
||||||
return {
|
return {
|
||||||
write_demo: lhTool({
|
write_demo: {
|
||||||
name: "write_demo",
|
name: "write_demo",
|
||||||
|
label: "write_demo",
|
||||||
description:
|
description:
|
||||||
"Write the complete self-contained HTML document for the demo (overwrites any previous version). " +
|
"Write the complete self-contained HTML document for the demo (overwrites any previous version). " +
|
||||||
"Always follow with run_demo to test it headlessly.",
|
"Always follow with run_demo to test it headlessly.",
|
||||||
schema: z.object({
|
parameters: writeDemoInput,
|
||||||
html: z.string().describe("The complete HTML document: <!doctype html>…</html>, all JS inline."),
|
execute: serverExecute("write_demo", writeDemoInput, async ({ html }) => {
|
||||||
}),
|
|
||||||
execute: async ({ html }) => {
|
|
||||||
if (!html || html.trim().length === 0) return "ERROR: empty document — write the full HTML.";
|
if (!html || html.trim().length === 0) return "ERROR: empty document — write the full HTML.";
|
||||||
state.html = html;
|
state.html = html;
|
||||||
onActivity?.(`write_demo: ${(html.length / 1024).toFixed(1)} KB`);
|
onActivity?.(`write_demo: ${(html.length / 1024).toFixed(1)} KB`);
|
||||||
return `Saved (${html.length} bytes). Now call run_demo to test it — never deliver untested.`;
|
return `Saved (${html.length} bytes). Now call run_demo to test it — never deliver untested.`;
|
||||||
},
|
}),
|
||||||
}),
|
},
|
||||||
run_demo: lhTool({
|
run_demo: {
|
||||||
name: "run_demo",
|
name: "run_demo",
|
||||||
|
label: "run_demo",
|
||||||
description:
|
description:
|
||||||
"Headlessly execute the current demo in Chrome and report console errors, uncaught errors, and failed requests. " +
|
"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.",
|
"Optionally pass a probe expression to assert on page state.",
|
||||||
schema: z.object({
|
parameters: runDemoInput,
|
||||||
probe: z
|
execute: serverExecute("run_demo", runDemoInput, async ({ probe }) => {
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Optional JS expression evaluated in the page after settling, e.g. \"document.querySelectorAll('input').length\"."),
|
|
||||||
}),
|
|
||||||
execute: async ({ probe }) => {
|
|
||||||
if (!state.html) return "ERROR: nothing to run — call write_demo first.";
|
if (!state.html) return "ERROR: nothing to run — call write_demo first.";
|
||||||
const reason = headless.unavailableReason();
|
const reason = headless.unavailableReason();
|
||||||
if (reason) return `HEADLESS UNAVAILABLE: ${reason}`;
|
if (reason) return `HEADLESS UNAVAILABLE: ${reason}`;
|
||||||
@@ -127,8 +137,8 @@ export function createDemoBuilderTools(
|
|||||||
result.ok ? "run_demo: clean" : `run_demo: ${summarizeCounts(result)}`,
|
result.ok ? "run_demo: clean" : `run_demo: ${summarizeCounts(result)}`,
|
||||||
);
|
);
|
||||||
return formatRunResult(result);
|
return formatRunResult(result);
|
||||||
},
|
}),
|
||||||
}),
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +200,7 @@ export async function runDemoBuilder(input: DemoBuilderInput, opts: DemoBuilderO
|
|||||||
|
|
||||||
let finalText = "";
|
let finalText = "";
|
||||||
try {
|
try {
|
||||||
const result = await runSubagent(toPiTools(createDemoBuilderTools(state, headless, opts.onActivity)), {
|
const result = await runSubagent(Object.values(createDemoBuilderTools(state, headless, opts.onActivity)), {
|
||||||
role: "demoBuilder",
|
role: "demoBuilder",
|
||||||
engine: opts.engine,
|
engine: opts.engine,
|
||||||
systemPrompt: INSTRUCTIONS,
|
systemPrompt: INSTRUCTIONS,
|
||||||
|
|||||||
+184
-109
@@ -1,8 +1,9 @@
|
|||||||
import * as crypto from "node:crypto";
|
import * as crypto from "node:crypto";
|
||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
import type { Usage } from "@earendil-works/pi-ai";
|
import type { Usage } from "@earendil-works/pi-ai";
|
||||||
import { z } from "zod";
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||||
import { lhTool, toPiTools } from "../lh-tool.ts";
|
import { Type, type Static, type TSchema } from "typebox";
|
||||||
|
import { toToolResult } from "../tool-execute.ts";
|
||||||
import type { ModelRole } from "../../../config.ts";
|
import type { ModelRole } from "../../../config.ts";
|
||||||
import {
|
import {
|
||||||
FACTORY_SETTINGS,
|
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
|
* The LLM stages supply only the judgment calls; the deterministic rebuild
|
||||||
* in the merger owns everything that must never be mis-copied (event kinds,
|
* in the merger owns everything that must never be mis-copied (event kinds,
|
||||||
* timestamps, refs, provenance points). Both agents are single-tool-call
|
* 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.
|
* ToolLoopAgent purely so the model can retry a schema-invalid call.
|
||||||
*
|
*
|
||||||
* Created via createExtractionPipeline(deps) — the composition root
|
* Created via createExtractionPipeline(deps) — the composition root
|
||||||
@@ -101,75 +102,170 @@ export function transcriptView(record: ChatRecord): string {
|
|||||||
|
|
||||||
// ─── the extractor ──────────────────────────────────────────────────────────
|
// ─── the extractor ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const evidence = z
|
const evidenceSchema = Type.Array(Type.String(), {
|
||||||
.array(z.string())
|
minItems: 1,
|
||||||
.min(1)
|
description: "Verbatim quotes from the transcript that back this. The auditor will check every quote.",
|
||||||
.describe("Verbatim quotes from the transcript that back this. The auditor will check every quote.");
|
});
|
||||||
|
|
||||||
const messagesField = z
|
const messagesSchema = Type.Array(Type.String(), {
|
||||||
.array(z.string())
|
description: "The message ids (from the [id] tags in the transcript) where this came up — provenance.",
|
||||||
.describe("The message ids (from the [id] tags in the transcript) where this came up — provenance.");
|
});
|
||||||
|
|
||||||
const propositionSchema = z.object({
|
const understandingSchema = Type.Optional(
|
||||||
id: z.string().describe("Local id: \"p1\", \"p2\", … — referenced by edges and attribution."),
|
Type.Union(
|
||||||
name: z
|
[
|
||||||
.string()
|
Type.Literal("solid"),
|
||||||
.describe(
|
Type.Literal("shaky"),
|
||||||
"A very short label for the proposition — a few words, a compact handle like a glossary headword " +
|
Type.Literal("accepted"),
|
||||||
'(\'Will to power\', \'Divine command theory\'). Not a sentence, needn\'t be unique or ' +
|
Type.Literal("resisted"),
|
||||||
"self-contained: the statement carries the precision. This becomes the node's label in the graph.",
|
Type.Literal("not yet engaged"),
|
||||||
),
|
],
|
||||||
statement: z
|
{
|
||||||
.string()
|
description:
|
||||||
.describe(
|
"ONE word — the learner's grasp of this proposition in THIS session, from the fixed scale " +
|
||||||
"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 " +
|
|
||||||
'"solid" | "shaky" | "accepted" | "resisted" | "not yet engaged". Nothing else: no numbers, ' +
|
'"solid" | "shaky" | "accepted" | "resisted" | "not yet engaged". Nothing else: no numbers, ' +
|
||||||
"no justification, no explanation.",
|
"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({
|
const findingsSchema = Type.Object(findingsProps);
|
||||||
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 = z.object({
|
const auditProps = {
|
||||||
propositions: z
|
...findingsProps,
|
||||||
.array(propositionSchema)
|
propositions: Type.Array(
|
||||||
.describe("Every distinct proposition the session touched — true statements, misconception-free."),
|
Type.Object({
|
||||||
edges: z
|
...propositionProps,
|
||||||
.array(edgeSchema)
|
linkTo: Type.Optional(
|
||||||
.describe("Relations between propositions: follows (derivable) / supports (grounds without entailing). Pass [] when there are none — never omit the field.")
|
Type.String({
|
||||||
.default([]), // weak models omit required-but-empty arrays; omission means "none"
|
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
|
const auditSchema = Type.Object(auditProps);
|
||||||
.object({
|
|
||||||
quiz: z
|
// The notes front door's shapes: everything session findings have, minus
|
||||||
.record(z.string(), z.string().nullable())
|
// quiz attribution — raw notes have no quiz tool parts, so there is
|
||||||
.describe(
|
// nothing to attribute (and no event to build: outcomes inside the text
|
||||||
"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.",
|
// surface only through understanding observations, user-decided).
|
||||||
),
|
const { attribution: _omitted, ...notesFindingsProps } = findingsProps;
|
||||||
})
|
const notesFindingsSchema = Type.Object(notesFindingsProps);
|
||||||
.describe("Quiz attribution — your only event judgment. Pass { quiz: {} } when the session has no quiz lines — never omit the field.")
|
const { attribution: _omitted2, ...notesAuditProps } = auditProps;
|
||||||
.default({ quiz: {} }), // weak models omit empty objects too
|
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 —
|
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
|
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.`;
|
Your entire deliverable is ONE submit_findings call.`;
|
||||||
|
|
||||||
/** One single-submit agent run — the shared shape of EVERY LLM stage
|
/** 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
|
* 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
|
* 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: {
|
async function runSubmitAgent<T>(opts: {
|
||||||
role: ModelRole;
|
role: ModelRole;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
toolDescription: 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;
|
systemPrompt: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
engine: PiEngine | undefined;
|
engine: PiEngine | undefined;
|
||||||
@@ -278,17 +378,18 @@ async function runSubmitAgent<T>(opts: {
|
|||||||
}): Promise<T> {
|
}): Promise<T> {
|
||||||
let submitted: T | undefined;
|
let submitted: T | undefined;
|
||||||
const result = await runSubagent(
|
const result = await runSubagent(
|
||||||
toPiTools({
|
[
|
||||||
[opts.toolName]: lhTool({
|
{
|
||||||
name: opts.toolName,
|
name: opts.toolName,
|
||||||
|
label: opts.toolName,
|
||||||
description: opts.toolDescription,
|
description: opts.toolDescription,
|
||||||
schema: opts.schema,
|
parameters: opts.parameters,
|
||||||
execute: async (findings) => {
|
execute: async (_toolCallId, input) => {
|
||||||
submitted = findings;
|
submitted = opts.fill(input);
|
||||||
return findings;
|
return toToolResult(submitted);
|
||||||
},
|
},
|
||||||
}),
|
} satisfies AgentTool<any>,
|
||||||
}),
|
],
|
||||||
{
|
{
|
||||||
role: opts.role,
|
role: opts.role,
|
||||||
engine: opts.engine,
|
engine: opts.engine,
|
||||||
@@ -326,7 +427,8 @@ function runExtractor(
|
|||||||
role: "extractor",
|
role: "extractor",
|
||||||
toolName: "submit_findings",
|
toolName: "submit_findings",
|
||||||
toolDescription: "Submit the session findings.",
|
toolDescription: "Submit the session findings.",
|
||||||
schema: findingsSchema,
|
parameters: findingsSchema,
|
||||||
|
fill: fillFindings,
|
||||||
systemPrompt: EXTRACTOR_INSTRUCTIONS,
|
systemPrompt: EXTRACTOR_INSTRUCTIONS,
|
||||||
prompt,
|
prompt,
|
||||||
engine,
|
engine,
|
||||||
@@ -348,7 +450,8 @@ function runNotesExtractor(
|
|||||||
role: "extractor",
|
role: "extractor",
|
||||||
toolName: "submit_findings",
|
toolName: "submit_findings",
|
||||||
toolDescription: "Submit the notes findings.",
|
toolDescription: "Submit the notes findings.",
|
||||||
schema: notesFindingsSchema,
|
parameters: notesFindingsSchema,
|
||||||
|
fill: fillNotesFindings,
|
||||||
systemPrompt: NOTES_EXTRACTOR_INSTRUCTIONS,
|
systemPrompt: NOTES_EXTRACTOR_INSTRUCTIONS,
|
||||||
prompt,
|
prompt,
|
||||||
engine,
|
engine,
|
||||||
@@ -360,36 +463,6 @@ function runNotesExtractor(
|
|||||||
|
|
||||||
// ─── the auditor ───────────────────────────────────────────────────────────
|
// ─── 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
|
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
|
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",
|
role: "auditor",
|
||||||
toolName: "submit_review",
|
toolName: "submit_review",
|
||||||
toolDescription: "Submit the verified findings subset (with identity verdicts) + the dropped list.",
|
toolDescription: "Submit the verified findings subset (with identity verdicts) + the dropped list.",
|
||||||
schema: auditSchema,
|
parameters: auditSchema,
|
||||||
|
fill: fillAudit,
|
||||||
systemPrompt: AUDITOR_INSTRUCTIONS,
|
systemPrompt: AUDITOR_INSTRUCTIONS,
|
||||||
prompt,
|
prompt,
|
||||||
engine,
|
engine,
|
||||||
@@ -475,7 +549,8 @@ function runNotesAuditor(
|
|||||||
role: "auditor",
|
role: "auditor",
|
||||||
toolName: "submit_review",
|
toolName: "submit_review",
|
||||||
toolDescription: "Submit the verified findings subset (with identity verdicts) + the dropped list.",
|
toolDescription: "Submit the verified findings subset (with identity verdicts) + the dropped list.",
|
||||||
schema: notesAuditSchema,
|
parameters: notesAuditSchema,
|
||||||
|
fill: fillNotesAudit,
|
||||||
systemPrompt: AUDITOR_INSTRUCTIONS,
|
systemPrompt: AUDITOR_INSTRUCTIONS,
|
||||||
prompt,
|
prompt,
|
||||||
engine,
|
engine,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { AssistantMessage, Message, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
|
import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai";
|
||||||
import { z } from "zod";
|
import { Type } from "typebox";
|
||||||
import { lhTool, piToolDeclarations, type LhTool } from "../lh-tool.ts";
|
|
||||||
import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts";
|
import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts";
|
||||||
import type { PdfResult, PdfToolInput } from "../../core/contracts.ts";
|
import type { PdfResult, PdfToolInput } from "../../core/contracts.ts";
|
||||||
import { getHeadless, type HeadlessApi } from "./headless.ts";
|
import { getHeadless, type HeadlessApi } from "./headless.ts";
|
||||||
@@ -70,79 +69,75 @@ export interface PdfReaderOptions {
|
|||||||
|
|
||||||
export interface PdfReaderInput extends PdfToolInput {}
|
export interface PdfReaderInput extends PdfToolInput {}
|
||||||
|
|
||||||
export function createPdfTools(
|
/** The wire declarations (pi-ai Tool, TypeBox parameters). This loop owns
|
||||||
pdfPath: string,
|
* execution itself — pdf_info/pdf_extract run via the plain pdfInfoText/
|
||||||
): {
|
* pdfExtractText bodies below, pdf_search/pdf_render dispatch inline in
|
||||||
pdf_info: LhTool<any, any>;
|
* the loop (search hits must join the citation set; renders feed the
|
||||||
pdf_extract: LhTool<any, any>;
|
* image-in-the-loop message) — so the declarations carry no execute.
|
||||||
pdf_render: LhTool<any, any>;
|
* dpi/context_lines/literal carry the old zod `.default()`s as schema
|
||||||
pdf_search: LhTool<any, any>;
|
* descriptions + loop-side ??-fills (the loop reads them via numOr). */
|
||||||
} {
|
export function createPdfTools(_pdfPath: string): Record<string, Tool> {
|
||||||
return {
|
return {
|
||||||
pdf_info: lhTool({
|
pdf_info: {
|
||||||
name: "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.",
|
description: "Metadata + per-page triage signals for the PDF (page count, text length, image count, math density). ALWAYS call this first.",
|
||||||
schema: z.object({}),
|
parameters: Type.Object({}),
|
||||||
execute: async () => {
|
},
|
||||||
const info = await pdfInfo(pdfPath);
|
pdf_extract: {
|
||||||
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({
|
|
||||||
name: "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.",
|
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'") }),
|
parameters: Type.Object({
|
||||||
execute: async ({ pages }) => {
|
pages: Type.String({ description: "Page spec, e.g. 'all', '1-5', '1,3,7'" }),
|
||||||
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.]`;
|
pdf_render: {
|
||||||
}
|
|
||||||
return text;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
pdf_render: lhTool({
|
|
||||||
name: "pdf_render",
|
name: "pdf_render",
|
||||||
description:
|
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.",
|
"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({
|
parameters: Type.Object({
|
||||||
pages: z.string().describe("Page spec, e.g. '1-3' or '2,5,8'"),
|
pages: Type.String({ description: "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)"),
|
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
|
pdf_search: {
|
||||||
// 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({
|
|
||||||
name: "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.",
|
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({
|
parameters: Type.Object({
|
||||||
query: z.string().describe("Keyword or regex"),
|
query: Type.String({ description: "Keyword or regex" }),
|
||||||
context_lines: z.number().int().min(0).max(8).default(3).describe("Context lines around each hit"),
|
context_lines: Type.Optional(
|
||||||
literal: z.boolean().default(false).describe("true = treat the query literally (no regex)"),
|
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 {
|
function toolText(toolCallId: string, toolName: string, value: string): ToolResultMessage {
|
||||||
return {
|
return {
|
||||||
role: "toolResult",
|
role: "toolResult",
|
||||||
@@ -179,7 +174,7 @@ export async function runPdfReaderAgent(
|
|||||||
// doctrine degrades to extract+search (render calls return an error note).
|
// doctrine degrades to extract+search (render calls return an error note).
|
||||||
|
|
||||||
const tools = createPdfTools(pdfPath);
|
const tools = createPdfTools(pdfPath);
|
||||||
const toolDeclarations = piToolDeclarations(tools);
|
const toolDeclarations = Object.values(tools);
|
||||||
const engine = opts.engine ?? piEngineFor("pdfReader");
|
const engine = opts.engine ?? piEngineFor("pdfReader");
|
||||||
// BYOK (owner, 2026-09-22): the level rides the injected engine — no
|
// BYOK (owner, 2026-09-22): the level rides the injected engine — no
|
||||||
// factory pin to consult (engine.level is the resolved/clamped value).
|
// factory pin to consult (engine.level is the resolved/clamped value).
|
||||||
@@ -242,12 +237,12 @@ export async function runPdfReaderAgent(
|
|||||||
try {
|
try {
|
||||||
if (call.name === "pdf_info") {
|
if (call.name === "pdf_info") {
|
||||||
opts.onActivity?.("pdf_info: triaging");
|
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)));
|
toolResults.push(toolText(call.id, call.name, String(r)));
|
||||||
} else if (call.name === "pdf_extract") {
|
} else if (call.name === "pdf_extract") {
|
||||||
const spec = String(args.pages ?? "all");
|
const spec = String(args.pages ?? "all");
|
||||||
opts.onActivity?.(`pdf_extract: ${spec}`);
|
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)));
|
toolResults.push(toolText(call.id, call.name, String(r)));
|
||||||
for (const p of specNumbers(spec, Infinity)) {
|
for (const p of specNumbers(spec, Infinity)) {
|
||||||
consulted.add(p);
|
consulted.add(p);
|
||||||
@@ -393,7 +388,7 @@ async function streamOnce(
|
|||||||
engine: PiEngine,
|
engine: PiEngine,
|
||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
tools: ReturnType<typeof piToolDeclarations>,
|
tools: Tool[],
|
||||||
reasoning: ThinkingLevel | undefined,
|
reasoning: ThinkingLevel | undefined,
|
||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
): Promise<AssistantMessage> {
|
): Promise<AssistantMessage> {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { AgentTool } from "@earendil-works/pi-agent-core";
|
|||||||
import type { Usage } from "@earendil-works/pi-ai";
|
import type { Usage } from "@earendil-works/pi-ai";
|
||||||
import { createWebTools } from "../tools/web.ts";
|
import { createWebTools } from "../tools/web.ts";
|
||||||
import type { SearchProvider } from "../../core/search.ts";
|
import type { SearchProvider } from "../../core/search.ts";
|
||||||
import { toPiTools } from "../lh-tool.ts";
|
|
||||||
import type { PiEngine } from "../models.ts";
|
import type { PiEngine } from "../models.ts";
|
||||||
import { runSubagent } from "./pi-run.ts";
|
import { runSubagent } from "./pi-run.ts";
|
||||||
|
|
||||||
@@ -80,7 +79,7 @@ export interface ResearcherRunOptions {
|
|||||||
* the loop scripted.
|
* the loop scripted.
|
||||||
*/
|
*/
|
||||||
export async function runResearcherAgent(prompt: string, opts: ResearcherRunOptions = {}): Promise<string> {
|
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",
|
role: "researcher",
|
||||||
prompt,
|
prompt,
|
||||||
engine: opts.engine,
|
engine: opts.engine,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { AssistantMessage, Message, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
|
import type { AssistantMessage, Message, Tool, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
|
||||||
import { z } from "zod";
|
import { Type } from "typebox";
|
||||||
import { lhTool, piToolDeclarations, type LhTool } from "../lh-tool.ts";
|
|
||||||
import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts";
|
import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts";
|
||||||
import type { VisualResult } from "../../core/contracts.ts";
|
import type { VisualResult } from "../../core/contracts.ts";
|
||||||
import { getHeadless, type HeadlessApi } from "./headless.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
|
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.`;
|
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 {
|
return {
|
||||||
write_svg: lhTool({
|
write_svg: {
|
||||||
name: "write_svg",
|
name: "write_svg",
|
||||||
description: "Write the complete SVG source for the figure (overwrites any previous version).",
|
description: "Write the complete SVG source for the figure (overwrites any previous version).",
|
||||||
schema: z.object({
|
parameters: Type.Object({
|
||||||
svg: z.string().describe('Complete <svg xmlns="…">…</svg> markup.'),
|
svg: Type.String({ description: 'Complete <svg xmlns="…">…</svg> markup.' }),
|
||||||
}),
|
}),
|
||||||
}),
|
},
|
||||||
edit_svg: lhTool({
|
edit_svg: {
|
||||||
name: "edit_svg",
|
name: "edit_svg",
|
||||||
description:
|
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.",
|
"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({
|
parameters: Type.Object({
|
||||||
old_text: z.string().describe("The exact text to replace — must match exactly once in the current SVG."),
|
old_text: Type.String({ description: "The exact text to replace — must match exactly once in the current SVG." }),
|
||||||
new_text: z.string().describe("The replacement text."),
|
new_text: Type.String({ description: "The replacement text." }),
|
||||||
}),
|
}),
|
||||||
}),
|
},
|
||||||
render_svg: lhTool({
|
render_svg: {
|
||||||
name: "render_svg",
|
name: "render_svg",
|
||||||
description: "Render the current SVG to a PNG and show it to you for inspection. No arguments.",
|
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 tools = createVisualTools();
|
||||||
const toolDeclarations = piToolDeclarations(tools);
|
const toolDeclarations = Object.values(tools);
|
||||||
const engine = opts.engine ?? piEngineFor("visualMaker");
|
const engine = opts.engine ?? piEngineFor("visualMaker");
|
||||||
// BYOK (owner, 2026-09-22): the level rides the injected engine — no
|
// BYOK (owner, 2026-09-22): the level rides the injected engine — no
|
||||||
// factory pin to consult (engine.level is the resolved/clamped value).
|
// factory pin to consult (engine.level is the resolved/clamped value).
|
||||||
@@ -301,7 +303,7 @@ async function streamOnce(
|
|||||||
engine: PiEngine,
|
engine: PiEngine,
|
||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
tools: ReturnType<typeof piToolDeclarations>,
|
tools: Tool[],
|
||||||
reasoning: ThinkingLevel | undefined,
|
reasoning: ThinkingLevel | undefined,
|
||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
): Promise<AssistantMessage> {
|
): Promise<AssistantMessage> {
|
||||||
|
|||||||
+46
-34
@@ -1,5 +1,6 @@
|
|||||||
import { Agent } from "@earendil-works/pi-agent-core";
|
import { Agent } from "@earendil-works/pi-agent-core";
|
||||||
import type { AgentEvent, AgentMessage, AgentTool } 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 type { Usage } from "@earendil-works/pi-ai";
|
||||||
import { PATHS } from "../../config.ts";
|
import { PATHS } from "../../config.ts";
|
||||||
import { piEngineFor, type PiEngine } from "./models.ts";
|
import { piEngineFor, type PiEngine } from "./models.ts";
|
||||||
@@ -12,7 +13,6 @@ import {
|
|||||||
type ProberToolContext,
|
type ProberToolContext,
|
||||||
type TeacherToolContext,
|
type TeacherToolContext,
|
||||||
} from "./tools.ts";
|
} from "./tools.ts";
|
||||||
import { toPiTools, type LhTool } from "./lh-tool.ts";
|
|
||||||
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
|
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
|
||||||
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
|
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
|
||||||
|
|
||||||
@@ -93,11 +93,9 @@ export interface AgentBuildOptions {
|
|||||||
messages?: AgentMessage[];
|
messages?: AgentMessage[];
|
||||||
/** Money channel: one call per model request. */
|
/** Money channel: one call per model request. */
|
||||||
onUsage?: StepUsageCollector;
|
onUsage?: StepUsageCollector;
|
||||||
/** Safety net (the old stepCountIs). Default 20 turns. */
|
|
||||||
maxTurns?: number;
|
|
||||||
/** Tests may pass a bare tool registry to drive deterministic flows.
|
/** Tests may pass a bare tool registry to drive deterministic flows.
|
||||||
* An explicit registry is NEVER toggle-filtered — the caller owns it. */
|
* 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
|
/** The resolved per-user tool toggles (core/pending EnabledTools — the
|
||||||
* 2026-09-19 P2 bridge: Platform's chat-handler passes
|
* 2026-09-19 P2 bridge: Platform's chat-handler passes
|
||||||
* effective.agent.tools; the factories drop any tool explicitly false.
|
* 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):
|
* The turn's question-stop rule (docs/pi-engine-swap.md §2; Wave A
|
||||||
* - a question tool call (quiz / ask_user_question) that RESOLVED — its
|
* 2026-09-26, owner Option A): a question tool call (quiz /
|
||||||
* synthetic "question posed" result is not an error — ends the run: the
|
* ask_user_question) that RESOLVED — its synthetic "question posed" result
|
||||||
* learner's answer arrives as the next POST. A call that FAILED authoring
|
* is not an error — ends the run: the learner's answer arrives as the next
|
||||||
* validation (error result) must NOT stop the run — the model retries
|
* POST. A call that FAILED authoring validation (error result) must NOT
|
||||||
* within the same turn, exactly the InvalidToolInputError semantics.
|
* stop the run — the model retries within the same turn, exactly the old
|
||||||
* - the maxTurns safety net replaces stepCountIs(20).
|
* 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) {
|
function makeQuestionStopper() {
|
||||||
let turns = 0;
|
|
||||||
return (context: {
|
return (context: {
|
||||||
message: { content: Array<{ type: string; id?: string; name?: string }> };
|
message: { content: Array<{ type: string; id?: string; name?: string }> };
|
||||||
toolResults: Array<{ toolCallId: string; isError?: boolean }>;
|
toolResults: Array<{ toolCallId: string; isError?: boolean }>;
|
||||||
}): boolean => {
|
}): boolean => {
|
||||||
turns++;
|
|
||||||
if (turns >= maxTurns) return true;
|
|
||||||
const called = new Set(
|
const called = new Set(
|
||||||
context.message.content
|
context.message.content
|
||||||
.filter((c) => c.type === "toolCall" && c.name !== undefined && (QUESTION_TOOLS as readonly string[]).includes(c.name!))
|
.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>[]) {
|
function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: AgentTool<any, any>[]) {
|
||||||
const engine = opts.engine ?? piEngineFor(kind);
|
const engine = opts.engine ?? piEngineFor(kind);
|
||||||
const stopper = makeStopper(opts.maxTurns ?? 20);
|
const stopper = makeQuestionStopper();
|
||||||
const systemPrompt = opts.promptSwitches
|
const systemPrompt = opts.promptSwitches
|
||||||
? composeInstructions(kind, opts.promptSwitches)
|
? composeInstructions(kind, opts.promptSwitches)
|
||||||
: composedInstructions[kind]!;
|
: composedInstructions[kind]!;
|
||||||
@@ -181,7 +186,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
|||||||
* handler keys teacher construction on.
|
* handler keys teacher construction on.
|
||||||
*/
|
*/
|
||||||
export function createProberAgent(ctx: ProberToolContext, opts: AgentBuildOptions = {}) {
|
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,
|
ctx?: TeacherToolContext,
|
||||||
opts: AgentBuildOptions = {},
|
opts: AgentBuildOptions = {},
|
||||||
) {
|
) {
|
||||||
const tools =
|
const tools = opts.tools ?? (ctx ? createTeacherTools(ctx, opts.toolToggles) : bareQuestionTools());
|
||||||
opts.tools ?? (ctx ? createTeacherTools(ctx, opts.toolToggles) : bareQuestionTools());
|
return baseAgentOptions("teacher", opts, Object.values(tools));
|
||||||
return baseAgentOptions("teacher", opts, toPiTools(tools));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The validation-free teach question set (tests, bare agents): quiz with
|
/** The bare teach question set (tests, bare agents): quiz with the shared
|
||||||
* the shared authoring refinement but NO per-session nodeId check (no
|
* authoring refinement (quizInputProblems, run in execute — throws
|
||||||
* record to read), plus ask. */
|
* ToolInputError, which pi's loop converts to the error tool result the
|
||||||
export function bareQuestionTools(): Record<string, LhTool<any, any>> {
|
* model retries on) but NO per-session nodeId check (no record to read),
|
||||||
return {
|
* plus ask. */
|
||||||
quiz: {
|
export function bareQuestionTools(): Record<string, AgentTool<any>> {
|
||||||
name: "quiz",
|
// A typed const (not an inline literal): the specific AgentTool typing
|
||||||
description: "Ask the learner a graded question.",
|
// gives execute its precise Static input; heterogeneous records take
|
||||||
clientQuestion: true,
|
// AgentTool<any>, whose loose Static would smudge it to unknown.
|
||||||
schema: quizInputObject.superRefine((input, add) => {
|
const quiz: AgentTool<typeof quizInputObject> = {
|
||||||
for (const problem of quizInputProblems(input)) {
|
name: "quiz",
|
||||||
add.addIssue({ code: "custom", message: problem });
|
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,
|
ask_user_question: askUserQuestionTool,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
import {
|
||||||
askInputSchema,
|
askInputSchema,
|
||||||
|
askOptionProblems,
|
||||||
probeQuizInputSchema,
|
probeQuizInputSchema,
|
||||||
quizInputObject,
|
quizInputObject,
|
||||||
quizInputProblems,
|
quizInputProblems,
|
||||||
@@ -25,14 +28,22 @@ export type { ProbeDoneContext } from "./tools/probe.ts";
|
|||||||
* The agent-split tool composition (docs/probe-teach-split.md §1): the
|
* The agent-split tool composition (docs/probe-teach-split.md §1): the
|
||||||
* PROBER and the TEACHER are different agents with different tool sets.
|
* PROBER and the TEACHER are different agents with different tool sets.
|
||||||
* The question tools share a NAME but are genuinely DIFFERENT tools —
|
* 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
|
* the prober's quiz has NO nodeId field at all (its declaration is built
|
||||||
* quizInputBase; zod strips the key if the model tries anyway), the
|
* from quizInputBase; the model's schema never mentions nodeId), the
|
||||||
* teacher's carries the validated position field.
|
* teacher's carries the validated position field.
|
||||||
*
|
*
|
||||||
* - Question tools (quiz / ask_user_question): CLIENT-SIDE (no `execute`).
|
* - Question tools (quiz / ask_user_question): CLIENT-ANSWERED. Their
|
||||||
* The call streams to the browser in `input-available` state; the
|
* execute validates the authoring refinements (the cross-field rules no
|
||||||
* learner answers in the dock; the client grades and sends the verdict
|
* schema carries — quizInputProblems / askOptionProblems; a failure
|
||||||
* with addToolOutput. Input schemas live in core/question-core.
|
* 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.
|
* - probe_done (src/agent/tools/probe.ts): SERVER-SIDE seam tool.
|
||||||
* - planTools (src/agent/tools/plan.ts): SERVER-SIDE — author-once
|
* - planTools (src/agent/tools/plan.ts): SERVER-SIDE — author-once
|
||||||
* plan_init. Teacher only.
|
* plan_init. Teacher only.
|
||||||
@@ -40,22 +51,28 @@ export type { ProbeDoneContext } from "./tools/probe.ts";
|
|||||||
* only.
|
* only.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const askUserQuestionTool: LhTool<any, any> = {
|
export const askUserQuestionTool: AgentTool<typeof askInputSchema> = {
|
||||||
name: "ask_user_question",
|
name: "ask_user_question",
|
||||||
clientQuestion: true,
|
label: "ask_user_question",
|
||||||
description:
|
description:
|
||||||
"Ask the learner a single question and pause until they answer. NOT graded — there is no correct answer. " +
|
"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, " +
|
"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 " +
|
"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.",
|
"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) ─────────────
|
// ─── 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",
|
name: "quiz",
|
||||||
clientQuestion: true,
|
label: "quiz",
|
||||||
description:
|
description:
|
||||||
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
|
"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 " +
|
"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 " +
|
"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 — " +
|
"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.",
|
"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 —
|
/** The prober's registry context: the probe's own needs (probe_done —
|
||||||
@@ -98,7 +121,7 @@ export interface ProberToolContext extends ProbeDoneContext {
|
|||||||
export function createProberTools(
|
export function createProberTools(
|
||||||
ctx: ProberToolContext,
|
ctx: ProberToolContext,
|
||||||
enabled?: EnabledTools,
|
enabled?: EnabledTools,
|
||||||
): Record<string, LhTool<any, any>> {
|
): Record<string, AgentTool<any>> {
|
||||||
// probe_done is re-added AFTER the filter, always-on by construction — it
|
// 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.
|
// is how the phase ENDS; a prober that can never conclude is a lockout.
|
||||||
const done = probeDoneTool(ctx);
|
const done = probeDoneTool(ctx);
|
||||||
@@ -146,36 +169,31 @@ export function createProberTools(
|
|||||||
|
|
||||||
// ─── the teacher's question tools (quiz carries the position field) ──────────
|
// ─── 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
|
/** 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
|
* grading contract is untouched). Reads the LIVE chat record, so rewrites
|
||||||
* mid-turn are honored, and with no tree yet every nodeId is rejected.
|
* mid-turn are honored, and with no tree yet every nodeId is rejected.
|
||||||
* Omitting nodeId is legal — an ad-hoc check that moves nothing. */
|
* Omitting nodeId is legal — an ad-hoc check that moves nothing. */
|
||||||
export function teachQuizToolFor(ctx: Pick<PlanToolContext, "chatId" | "chatStore">) {
|
export function teachQuizToolFor(ctx: Pick<PlanToolContext, "chatId" | "chatStore">): AgentTool<typeof quizInputObject> {
|
||||||
const inputSchema = quizInputObject
|
return {
|
||||||
.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({
|
|
||||||
name: "quiz",
|
name: "quiz",
|
||||||
clientQuestion: true,
|
label: "quiz",
|
||||||
description:
|
description:
|
||||||
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
|
"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 " +
|
"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 " +
|
"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. " +
|
"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.",
|
"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 {
|
export interface TeacherToolContext extends PlanToolContext {
|
||||||
@@ -219,7 +247,7 @@ export interface TeacherToolContext extends PlanToolContext {
|
|||||||
export function createTeacherTools(
|
export function createTeacherTools(
|
||||||
ctx: TeacherToolContext,
|
ctx: TeacherToolContext,
|
||||||
enabled?: EnabledTools,
|
enabled?: EnabledTools,
|
||||||
): Record<string, LhTool<any, any>> {
|
): Record<string, AgentTool<any>> {
|
||||||
return filterEnabledTools(
|
return filterEnabledTools(
|
||||||
{
|
{
|
||||||
quiz: teachQuizToolFor(ctx),
|
quiz: teachQuizToolFor(ctx),
|
||||||
|
|||||||
+16
-14
@@ -1,7 +1,8 @@
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { z } from "zod";
|
import { Type } from "typebox";
|
||||||
import { lhTool } from "../lh-tool.ts";
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||||
|
import { serverExecute } from "../tool-execute.ts";
|
||||||
import {
|
import {
|
||||||
capOutput,
|
capOutput,
|
||||||
formatSize,
|
formatSize,
|
||||||
@@ -23,20 +24,21 @@ import {
|
|||||||
|
|
||||||
const DEFAULT_LIMIT = 1000;
|
const DEFAULT_LIMIT = 1000;
|
||||||
|
|
||||||
const findInputSchema = z.object({
|
const findInput = Type.Object({
|
||||||
pattern: z
|
pattern: Type.String({
|
||||||
.string()
|
minLength: 1,
|
||||||
.min(1)
|
description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'",
|
||||||
.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)"),
|
path: Type.Optional(Type.String({ description: "Directory to search in (default: the learner's home directory)" })),
|
||||||
limit: z.number().int().positive().optional().describe("Maximum number of results (default: 1000)"),
|
limit: Type.Optional(Type.Integer({ description: "Maximum number of results (default: 1000)" })),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const findTool = lhTool({
|
export const findTool: AgentTool<typeof findInput> = {
|
||||||
name: "find",
|
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).`,
|
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,
|
parameters: findInput,
|
||||||
async execute(input, ctx) {
|
execute: serverExecute("find", findInput, async (input, ctx) => {
|
||||||
if (ctx?.signal?.aborted) return "Operation aborted";
|
if (ctx?.signal?.aborted) return "Operation aborted";
|
||||||
const searchPath = resolveSourcePath(input.path);
|
const searchPath = resolveSourcePath(input.path);
|
||||||
let stat;
|
let stat;
|
||||||
@@ -81,5 +83,5 @@ export const findTool = lhTool({
|
|||||||
if (truncated) notices.push(`${formatSize(MAX_OUTPUT_BYTES)} limit reached`);
|
if (truncated) notices.push(`${formatSize(MAX_OUTPUT_BYTES)} limit reached`);
|
||||||
if (timedOut) notices.push("search timed out after 10s — partial results, narrow the directory");
|
if (timedOut) notices.push("search timed out after 10s — partial results, narrow the directory");
|
||||||
return notices.length > 0 ? `${content}\n\n[${notices.join(". ")}]` : content;
|
return notices.length > 0 ? `${content}\n\n[${notices.join(". ")}]` : content;
|
||||||
},
|
}),
|
||||||
});
|
};
|
||||||
|
|||||||
+25
-18
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { Type } from "typebox";
|
||||||
import { lhTool, type LhTool } from "../lh-tool.ts";
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||||
|
import { serverExecute } from "../tool-execute.ts";
|
||||||
import { graphSessionIndex, graphSessionPropositions } from "../../core/graph.ts";
|
import { graphSessionIndex, graphSessionPropositions } from "../../core/graph.ts";
|
||||||
import type { ChatStore, GraphStore } from "../../core/ports.ts";
|
import type { ChatStore, GraphStore } from "../../core/ports.ts";
|
||||||
|
|
||||||
@@ -28,17 +29,27 @@ export interface GraphToolContext {
|
|||||||
graphStore: GraphStore;
|
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 };
|
const deps = { chatStore: ctx.chatStore, graphStore: ctx.graphStore };
|
||||||
return {
|
return {
|
||||||
list_sessions: lhTool({
|
list_sessions: {
|
||||||
name: "list_sessions",
|
name: "list_sessions",
|
||||||
|
label: "list_sessions",
|
||||||
description:
|
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. " +
|
"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. " +
|
"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.",
|
"Reading this first shows what he has already covered and when, before you probe anything.",
|
||||||
schema: z.object({}),
|
parameters: listSessionsInput,
|
||||||
execute: async (): Promise<string> => {
|
execute: serverExecute("list_sessions", listSessionsInput, async (): Promise<string> => {
|
||||||
const sessions = graphSessionIndex(deps);
|
const sessions = graphSessionIndex(deps);
|
||||||
if (sessions.length === 0) return "The knowledge graph is empty — no session has contributed propositions yet.";
|
if (sessions.length === 0) return "The knowledge graph is empty — no session has contributed propositions yet.";
|
||||||
const lines = sessions.map((s, i) =>
|
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}`,
|
`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")}`;
|
return `${sessions.length} session(s) in the knowledge graph, most recent first:\n${lines.join("\n")}`;
|
||||||
},
|
}),
|
||||||
}),
|
},
|
||||||
list_propositions: lhTool({
|
list_propositions: {
|
||||||
name: "list_propositions",
|
name: "list_propositions",
|
||||||
|
label: "list_propositions",
|
||||||
description:
|
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. " +
|
"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. " +
|
"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.",
|
"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({
|
parameters: listPropositionsInput,
|
||||||
session: z
|
execute: serverExecute("list_propositions", listPropositionsInput, async ({ session }): Promise<string> => {
|
||||||
.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> => {
|
|
||||||
const propositions = graphSessionPropositions(deps, session);
|
const propositions = graphSessionPropositions(deps, session);
|
||||||
if (propositions.length === 0) {
|
if (propositions.length === 0) {
|
||||||
return `No propositions born in session "${session}". Call list_sessions for the current ids.`;
|
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})` : ""}`,
|
(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")}`;
|
return `${propositions.length} proposition(s) born in this session:\n${lines.join("\n")}`;
|
||||||
},
|
}),
|
||||||
}),
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-18
@@ -1,7 +1,8 @@
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { z } from "zod";
|
import { Type } from "typebox";
|
||||||
import { lhTool } from "../lh-tool.ts";
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||||
|
import { serverExecute } from "../tool-execute.ts";
|
||||||
import {
|
import {
|
||||||
capOutput,
|
capOutput,
|
||||||
formatSize,
|
formatSize,
|
||||||
@@ -29,24 +30,28 @@ import {
|
|||||||
const DEFAULT_LIMIT = 100;
|
const DEFAULT_LIMIT = 100;
|
||||||
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
const grepInputSchema = z.object({
|
const grepInput = Type.Object({
|
||||||
pattern: z.string().min(1).describe("Search pattern (regex or literal string)"),
|
pattern: Type.String({ minLength: 1, description: "Search pattern (regex or literal string)" }),
|
||||||
path: z
|
path: Type.Optional(
|
||||||
.string()
|
Type.String({ description: "Directory or file to search (default: the learner's home directory)" }),
|
||||||
.optional()
|
),
|
||||||
.describe("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'" })),
|
||||||
glob: z.string().optional().describe("Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'"),
|
ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
|
||||||
ignoreCase: z.boolean().optional().describe("Case-insensitive search (default: false)"),
|
literal: Type.Optional(
|
||||||
literal: z.boolean().optional().describe("Treat pattern as literal string instead of regex (default: false)"),
|
Type.Boolean({ description: "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)"),
|
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",
|
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.`,
|
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,
|
parameters: grepInput,
|
||||||
async execute(input, ctx) {
|
execute: serverExecute("grep", grepInput, async (input, ctx) => {
|
||||||
if (ctx?.signal?.aborted) return "Operation aborted";
|
if (ctx?.signal?.aborted) return "Operation aborted";
|
||||||
const searchPath = resolveSourcePath(input.path);
|
const searchPath = resolveSourcePath(input.path);
|
||||||
|
|
||||||
@@ -169,5 +174,5 @@ export const grepTool = lhTool({
|
|||||||
if (truncated) notices.push(`${formatSize(MAX_OUTPUT_BYTES)} limit reached`);
|
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");
|
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;
|
return notices.length > 0 ? `${content}\n\n[${notices.join(". ")}]` : content;
|
||||||
},
|
}),
|
||||||
});
|
};
|
||||||
|
|||||||
+55
-48
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { Type, type Static } from "typebox";
|
||||||
import { lhTool, type LhTool } from "../lh-tool.ts";
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||||
|
import { serverExecute } from "../tool-execute.ts";
|
||||||
import type { JobRecord } from "../../core/contracts.ts";
|
import type { JobRecord } from "../../core/contracts.ts";
|
||||||
import type { JobStore } from "../../core/ports.ts";
|
import type { JobStore } from "../../core/ports.ts";
|
||||||
|
|
||||||
@@ -30,43 +31,41 @@ export interface JobToolContext {
|
|||||||
searchAvailable?: () => boolean;
|
searchAvailable?: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const researchInputSchema = z.object({
|
export const researchInputSchema = Type.Object({
|
||||||
question: z
|
question: Type.String({
|
||||||
.string()
|
description: "The single research question to answer. Make it specific — the researcher sees nothing else of the lesson.",
|
||||||
.describe("The single research question to answer. Make it specific — the researcher sees nothing else of the lesson."),
|
}),
|
||||||
context: z
|
context: Type.Optional(
|
||||||
.string()
|
Type.String({
|
||||||
.optional()
|
description: "Why you need this / what the lesson needs it for — focuses the brief. One or two sentences.",
|
||||||
.describe("Why you need this / what the lesson needs it for — focuses the brief. One or two sentences."),
|
}),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const demoInputSchema = z.object({
|
export const demoInputSchema = Type.Object({
|
||||||
title: z.string().describe("Short title for the demo card, e.g. 'Damped harmonic oscillator'."),
|
title: Type.String({ description: "Short title for the demo card, e.g. 'Damped harmonic oscillator'." }),
|
||||||
brief: z
|
brief: Type.String({
|
||||||
.string()
|
description:
|
||||||
.describe(
|
|
||||||
"Precise brief for the builder: the concept, the parameters/controls and what each does, the interactions, and the visual style. " +
|
"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.",
|
"The builder sees NOTHING else of the lesson — include everything it needs, including necessary formulas and constants.",
|
||||||
),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const visualInputSchema = z.object({
|
export const visualInputSchema = Type.Object({
|
||||||
brief: z
|
brief: Type.String({
|
||||||
.string()
|
description:
|
||||||
.describe(
|
|
||||||
"Precise brief for ONE static figure: exactly what it must show — coordinates, labels, proportions, orientation. " +
|
"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.",
|
"The maker sees NOTHING else of the lesson; a wrong or vague brief produces a wrong figure.",
|
||||||
),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pdfInputSchema = z.object({
|
export const pdfInputSchema = Type.Object({
|
||||||
path: z.string().describe("Path to the learner's PDF (relative or absolute), exactly as they gave it."),
|
path: Type.String({ description: "Path to the learner's PDF (relative or absolute), exactly as they gave it." }),
|
||||||
question: z
|
question: Type.String({
|
||||||
.string()
|
description:
|
||||||
.describe(
|
|
||||||
"What you need from the document — the specific content, explanation, or answer to extract and brief. " +
|
"What you need from the document — the specific content, explanation, or answer to extract and brief. " +
|
||||||
"The reader sees NOTHING else of the lesson.",
|
"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
|
* server can't dispatch doomed research jobs. Jobs ALREADY in flight when
|
||||||
* a key disappears still run to their honest failure (runner backstop).
|
* 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;
|
const searchOn = ctx.searchAvailable?.() ?? true;
|
||||||
return {
|
return {
|
||||||
...(searchOn && {
|
...(searchOn && {
|
||||||
research: lhTool({
|
research: {
|
||||||
name: "research",
|
name: "research",
|
||||||
|
label: "research",
|
||||||
description: RESEARCH_DESCRIPTION,
|
description: RESEARCH_DESCRIPTION,
|
||||||
schema: researchInputSchema,
|
parameters: researchInputSchema,
|
||||||
execute: async (input, { toolCallId }) => dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
|
execute: serverExecute("research", researchInputSchema, (input, { toolCallId }) =>
|
||||||
}),
|
dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
|
||||||
|
),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
demo: lhTool({
|
demo: {
|
||||||
name: "demo",
|
name: "demo",
|
||||||
|
label: "demo",
|
||||||
description: DEMO_DESCRIPTION,
|
description: DEMO_DESCRIPTION,
|
||||||
schema: demoInputSchema,
|
parameters: demoInputSchema,
|
||||||
execute: async (input, { toolCallId }) => dispatch(ctx, "demo", toolCallId, DEMO_ACK, input),
|
execute: serverExecute("demo", demoInputSchema, (input, { toolCallId }) => dispatch(ctx, "demo", toolCallId, DEMO_ACK, input)),
|
||||||
}),
|
},
|
||||||
visual: lhTool({
|
visual: {
|
||||||
name: "visual",
|
name: "visual",
|
||||||
|
label: "visual",
|
||||||
description: VISUAL_DESCRIPTION,
|
description: VISUAL_DESCRIPTION,
|
||||||
schema: visualInputSchema,
|
parameters: visualInputSchema,
|
||||||
execute: async (input, { toolCallId }) => dispatch(ctx, "visual", toolCallId, VISUAL_ACK, input),
|
execute: serverExecute("visual", visualInputSchema, (input, { toolCallId }) =>
|
||||||
}),
|
dispatch(ctx, "visual", toolCallId, VISUAL_ACK, input),
|
||||||
pdf: lhTool({
|
),
|
||||||
|
},
|
||||||
|
pdf: {
|
||||||
name: "pdf",
|
name: "pdf",
|
||||||
|
label: "pdf",
|
||||||
description: PDF_DESCRIPTION,
|
description: PDF_DESCRIPTION,
|
||||||
schema: pdfInputSchema,
|
parameters: pdfInputSchema,
|
||||||
execute: async (input, { toolCallId }) => dispatch(ctx, "pdf", toolCallId, PDF_ACK, input),
|
execute: serverExecute("pdf", pdfInputSchema, (input, { toolCallId }) => dispatch(ctx, "pdf", toolCallId, PDF_ACK, input)),
|
||||||
}),
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ResearchInput = z.infer<typeof researchInputSchema>;
|
export type ResearchInput = Static<typeof researchInputSchema>;
|
||||||
export type DemoInput = z.infer<typeof demoInputSchema>;
|
export type DemoInput = Static<typeof demoInputSchema>;
|
||||||
export type VisualInput = z.infer<typeof visualInputSchema>;
|
export type VisualInput = Static<typeof visualInputSchema>;
|
||||||
export type PdfInput = z.infer<typeof pdfInputSchema>;
|
export type PdfInput = Static<typeof pdfInputSchema>;
|
||||||
|
|||||||
+16
-13
@@ -1,7 +1,8 @@
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { z } from "zod";
|
import { Type } from "typebox";
|
||||||
import { lhTool } from "../lh-tool.ts";
|
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";
|
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 DEFAULT_LIMIT = 500;
|
||||||
|
|
||||||
const lsInputSchema = z.object({
|
const lsInput = Type.Object({
|
||||||
path: z
|
path: Type.Optional(
|
||||||
.string()
|
Type.String({ description: "Directory to list (default: the learner's home directory)" }),
|
||||||
.optional()
|
),
|
||||||
.describe("Directory to list (default: the learner's home directory)"),
|
limit: Type.Optional(
|
||||||
limit: z.number().int().positive().optional().describe("Maximum number of entries to return (default: 500)"),
|
Type.Integer({ description: "Maximum number of entries to return (default: 500)" }),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const lsTool = lhTool({
|
export const lsTool: AgentTool<typeof lsInput> = {
|
||||||
name: "ls",
|
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).`,
|
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,
|
parameters: lsInput,
|
||||||
async execute(input) {
|
execute: serverExecute("ls", lsInput, async (input) => {
|
||||||
const dirPath = resolveSourcePath(input.path);
|
const dirPath = resolveSourcePath(input.path);
|
||||||
|
|
||||||
let stat;
|
let stat;
|
||||||
@@ -70,5 +73,5 @@ export const lsTool = lhTool({
|
|||||||
if (entryLimitReached) notices.push(`${effectiveLimit} entries limit`);
|
if (entryLimitReached) notices.push(`${effectiveLimit} entries limit`);
|
||||||
if (truncated) notices.push(`${formatSize(MAX_OUTPUT_BYTES)} limit`);
|
if (truncated) notices.push(`${formatSize(MAX_OUTPUT_BYTES)} limit`);
|
||||||
return notices.length > 0 ? `${content}\n\n[Truncated: ${notices.join(", ")}]` : content;
|
return notices.length > 0 ? `${content}\n\n[Truncated: ${notices.join(", ")}]` : content;
|
||||||
},
|
}),
|
||||||
});
|
};
|
||||||
|
|||||||
+20
-17
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { Type } from "typebox";
|
||||||
import { lhTool, type LhTool } from "../lh-tool.ts";
|
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 { PlanToolResult, PlanTree } from "../../core/contracts.ts";
|
||||||
import type { ChatStore } from "../../core/ports.ts";
|
import type { ChatStore } from "../../core/ports.ts";
|
||||||
import { createTree, emptyTree, PlanTreeError } from "../../core/plan-tree.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
|
* latest state straight from the transcript's tool outputs (the plan
|
||||||
* window and the plan blocks).
|
* 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 {
|
return {
|
||||||
plan_init: lhTool({
|
plan_init: {
|
||||||
name: "plan_init",
|
name: "plan_init",
|
||||||
|
label: "plan_init",
|
||||||
description:
|
description:
|
||||||
"Write or REWRITE this session's plan map — the dependency graph the learner follows live in the plan window. " +
|
"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). " +
|
"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), " +
|
"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. " +
|
"`-->` 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.",
|
"Every check you fire names the node it assesses via `nodeId`; where the lesson stands derives from those tags.",
|
||||||
schema: z.object({
|
parameters: planInitInput,
|
||||||
mermaid: z
|
execute: serverExecute("plan_init", planInitInput, async ({ mermaid }): Promise<PlanToolResult> => {
|
||||||
.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> => {
|
|
||||||
const { chatId, chatStore } = ctx;
|
const { chatId, chatStore } = ctx;
|
||||||
const record = chatStore.getChat(chatId);
|
const record = chatStore.getChat(chatId);
|
||||||
if (!record) return { ok: false, error: `Chat not found: ${chatId}`, tree: emptyTree() };
|
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 };
|
if (err instanceof PlanTreeError) return { ok: false, error: err.message, tree: current };
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
}),
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { lhTool, type LhTool } from "../lh-tool.ts";
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||||
import { probeDoneInputSchema, type ProbeDoneInput, type ProbeDoneResult } from "../../core/contracts.ts";
|
import { serverExecute } from "../tool-execute.ts";
|
||||||
|
import { probeDoneInputSchema, type ProbeDoneResult } from "../../core/contracts.ts";
|
||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
import { findPendingToolCall } from "../../core/pending.ts";
|
import { findPendingToolCall } from "../../core/pending.ts";
|
||||||
import type { ChatStore } from "../../core/ports.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:
|
* The first probe_done in the transcript is also the construction seam:
|
||||||
* from that message on, every request builds the TEACHER agent.
|
* 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 {
|
return {
|
||||||
probe_done: lhTool({
|
probe_done: {
|
||||||
name: "probe_done",
|
name: "probe_done",
|
||||||
|
label: "probe_done",
|
||||||
description:
|
description:
|
||||||
"Conclude the probing phase and hand off to the teaching phase. Call this ONCE, when the probe is " +
|
"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 " +
|
"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 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
|
// The core-owned schema (contracts/probe.ts) — the SAME schema the UI
|
||||||
// parse seam (tool-parts.ts) runs, so boundary-valid ⇔ consumer-parseable.
|
// parse seam (tool-parts.ts) runs, so boundary-valid ⇔ consumer-parseable.
|
||||||
schema: probeDoneInputSchema,
|
parameters: probeDoneInputSchema,
|
||||||
execute: async (input): Promise<ProbeDoneResult> => {
|
execute: serverExecute("probe_done", probeDoneInputSchema, async (input): Promise<ProbeDoneResult> => {
|
||||||
const record = ctx.chatStore.getChat(ctx.chatId);
|
const record = ctx.chatStore.getChat(ctx.chatId);
|
||||||
if (!record) return { ok: false, error: `Chat not found: ${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.
|
// assessment anyway; the wake is an optimization, not a gate.
|
||||||
}
|
}
|
||||||
return { ok: true, assessment: input.assessment };
|
return { ok: true, assessment: input.assessment };
|
||||||
},
|
}),
|
||||||
}),
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-13
@@ -1,7 +1,8 @@
|
|||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { z } from "zod";
|
import { Type } from "typebox";
|
||||||
import { lhTool, type LhToolImageResult } from "../lh-tool.ts";
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||||
|
import { serverExecute, type ImageFileResult } from "../tool-execute.ts";
|
||||||
import { resolveSourcePath } from "./fs-shared.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.
|
* continuation notices — a huge file never silently floods the context.
|
||||||
* - image files (jpg/png/gif/webp/bmp): returned as native image content
|
* - image files (jpg/png/gif/webp/bmp): returned as native image content
|
||||||
* parts so the vision-capable model actually SEES them (teacher's
|
* 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
|
* glm-5.3-flash accepts image input per pi's zai catalog; tool-execute's
|
||||||
* in lh-tool.ts maps LhToolImageResult to pi's ImageContent).
|
* toToolResult maps ImageFileResult to pi's ImageContent).
|
||||||
* - unreadable/missing files throw — pi turns that into an error tool
|
* - unreadable/missing files throw — pi turns that into an error tool
|
||||||
* result the model sees and recovers from.
|
* result the model sees and recovers from.
|
||||||
*
|
*
|
||||||
@@ -41,10 +42,10 @@ const IMAGE_MIME: Record<string, string> = {
|
|||||||
".bmp": "image/bmp",
|
".bmp": "image/bmp",
|
||||||
};
|
};
|
||||||
|
|
||||||
const readInputSchema = z.object({
|
const readInput = Type.Object({
|
||||||
path: z.string().min(1).describe("Path to the file to read (relative or absolute)"),
|
path: Type.String({ minLength: 1, description: "Path to the file to read (relative or absolute)" }),
|
||||||
offset: z.number().int().positive().optional().describe("Line number to start reading from (1-indexed)"),
|
offset: Type.Optional(Type.Integer({ minimum: 1, description: "Line number to start reading from (1-indexed)" })),
|
||||||
limit: z.number().int().positive().optional().describe("Maximum number of lines to read"),
|
limit: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum number of lines to read" })),
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
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
|
* fragment (prompts/shared/). Relative paths resolve against the
|
||||||
* learner's home (fs-shared.ts), the harness's anchor.
|
* learner's home (fs-shared.ts), the harness's anchor.
|
||||||
*/
|
*/
|
||||||
export const readTool = lhTool({
|
export const readTool: AgentTool<typeof readInput> = {
|
||||||
name: "read",
|
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.`,
|
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,
|
parameters: readInput,
|
||||||
execute: async ({ path: rawPath, offset, limit }): Promise<string | LhToolImageResult> => {
|
execute: serverExecute("read", readInput, async ({ path: rawPath, offset, limit }): Promise<string | ImageFileResult> => {
|
||||||
const absolute = resolveSourcePath(rawPath);
|
const absolute = resolveSourcePath(rawPath);
|
||||||
const ext = path.extname(absolute).toLowerCase();
|
const ext = path.extname(absolute).toLowerCase();
|
||||||
const mime = IMAGE_MIME[ext];
|
const mime = IMAGE_MIME[ext];
|
||||||
@@ -114,5 +116,5 @@ export const readTool = lhTool({
|
|||||||
}
|
}
|
||||||
const text = buffer.toString("utf-8");
|
const text = buffer.toString("utf-8");
|
||||||
return readTextSlice(text, offset, limit);
|
return readTextSlice(text, offset, limit);
|
||||||
},
|
}),
|
||||||
});
|
};
|
||||||
|
|||||||
+28
-19
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { Type } from "typebox";
|
||||||
import { lhTool } from "../lh-tool.ts";
|
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
|
// The search-hit shape lives in the core port since the search wave
|
||||||
// (2026-09-20); this alias keeps callers churn-free.
|
// (2026-09-20); this alias keeps callers churn-free.
|
||||||
import type { SearchHit as WebSearchResult, SearchProvider } from "../../core/search.ts";
|
import type { SearchHit as WebSearchResult, SearchProvider } from "../../core/search.ts";
|
||||||
@@ -114,17 +115,26 @@ async function fetchAndExtract(url: string): Promise<string> {
|
|||||||
|
|
||||||
// ── tool registry ────────────────────────────────────────────────────────────
|
// ── 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 {
|
return {
|
||||||
web_search: lhTool({
|
web_search: {
|
||||||
name: "web_search",
|
name: "web_search",
|
||||||
|
label: "web_search",
|
||||||
description: "Search the web. One search per call — use one call per search angle. " +
|
description: "Search the web. One search per call — use one call per search angle. " +
|
||||||
"Returns an array of results with title, url, and snippet.",
|
"Returns an array of results with title, url, and snippet.",
|
||||||
schema: z.object({
|
parameters: webSearchInput,
|
||||||
query: z.string().describe("The search query (a normal search string, phrases included)"),
|
execute: serverExecute("web_search", webSearchInput, async ({ query, count }): Promise<WebSearchResult[] | string> => {
|
||||||
count: z.number().int().min(1).max(10).optional().describe("Number of results (default 5, max 10)"),
|
|
||||||
}),
|
|
||||||
execute: async ({ query, count }): Promise<WebSearchResult[] | string> => {
|
|
||||||
const provider = search();
|
const provider = search();
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
return (
|
return (
|
||||||
@@ -137,21 +147,20 @@ export function createWebTools(search: () => SearchProvider | null) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return `web_search failed: ${err instanceof Error ? err.message : String(err)}`;
|
return `web_search failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
}),
|
},
|
||||||
|
|
||||||
web_fetch: lhTool({
|
web_fetch: {
|
||||||
name: "web_fetch",
|
name: "web_fetch",
|
||||||
|
label: "web_fetch",
|
||||||
description:
|
description:
|
||||||
"Fetch a web page and extract its readable content as markdown (Readability). " +
|
"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.",
|
"Handles HTML pages and plain text; JS-rendered pages fall back to a reader service.",
|
||||||
schema: z.object({
|
parameters: webFetchInput,
|
||||||
url: z.string().describe("The URL to fetch"),
|
execute: serverExecute("web_fetch", webFetchInput, async ({ url }): Promise<string> => {
|
||||||
}),
|
|
||||||
execute: async ({ url }): Promise<string> => {
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
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 {
|
} catch {
|
||||||
return `web_fetch failed: invalid URL "${url}"`;
|
return `web_fetch failed: invalid URL "${url}"`;
|
||||||
}
|
}
|
||||||
@@ -160,8 +169,8 @@ export function createWebTools(search: () => SearchProvider | null) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return `web_fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}`;
|
return `web_fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}`;
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
}),
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-32
@@ -15,7 +15,7 @@
|
|||||||
* the learner's edge sits.
|
* the learner's edge sits.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { Type, type Static } from "typebox";
|
||||||
|
|
||||||
/** One dependency strand the planned lesson will rest on. */
|
/** One dependency strand the planned lesson will rest on. */
|
||||||
export interface ProbeStrand {
|
export interface ProbeStrand {
|
||||||
@@ -38,41 +38,43 @@ export interface ProbeDoneInput {
|
|||||||
assessment: ProbeAssessment;
|
assessment: ProbeAssessment;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The zod schema of that input — SINGLE SOURCE OF TRUTH, lived by the tool
|
// The TypeBox schema of that input — SINGLE SOURCE OF TRUTH, lived by the
|
||||||
// (src/agent/tools/probe.ts) and by the UI parse seam (src/core/tool-parts.ts):
|
// 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
|
// the boundary validation and the consumer parse run literally the same
|
||||||
// schema, so "schema-valid at the boundary" ⇔ "parseable by a consumer" is
|
// schema, so "schema-valid at the boundary" ⇔ "parseable by a consumer" is
|
||||||
// structural, not a convention. (Live crash 2026-09-12: the model sent
|
// 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`.)
|
// `assessment` as a STRING; the card cast it and died on `.strands.map`.)
|
||||||
export const probeDoneInputSchema = z
|
export const probeDoneInputSchema = Type.Object({
|
||||||
.object({
|
assessment: Type.Object(
|
||||||
assessment: z
|
{
|
||||||
.object({
|
strands: Type.Array(
|
||||||
strands: z
|
Type.Object({
|
||||||
.array(
|
strand: Type.String({
|
||||||
z.object({
|
description:
|
||||||
strand: z
|
"The dependency strand (e.g. 'the derivative as a limit') — a name the plan can hang a node on.",
|
||||||
.string()
|
}),
|
||||||
.describe(
|
assessment: Type.String({
|
||||||
"The dependency strand (e.g. 'the derivative as a limit') — a name the plan can hang a node on.",
|
description:
|
||||||
),
|
"Where his edge sits on this strand: the floor (what he reliably gets right), the ceiling (where it runs out), " +
|
||||||
assessment: z
|
"misconceptions found, and what the lesson should build from.",
|
||||||
.string()
|
}),
|
||||||
.describe(
|
}),
|
||||||
"Where his edge sits on this strand: the floor (what he reliably gets right), the ceiling (where it runs out), " +
|
{ minItems: 1, description: "Every strand the planned lesson will depend on, each with your read of his edge on it." },
|
||||||
"misconceptions found, and what the lesson should build from.",
|
),
|
||||||
),
|
summary: Type.Optional(
|
||||||
}),
|
Type.String({
|
||||||
)
|
description: "Optional overall synthesis: his apparent goal and where the teaching should enter.",
|
||||||
.min(1)
|
}),
|
||||||
.describe("Every strand the planned lesson will depend on, each with your read of his edge on it."),
|
),
|
||||||
summary: z
|
},
|
||||||
.string()
|
{ description: "The learner assessment — the probe's distilled output." },
|
||||||
.optional()
|
),
|
||||||
.describe("Optional overall synthesis: his apparent goal and where the teaching should enter."),
|
});
|
||||||
})
|
|
||||||
.describe("The learner assessment — the probe's distilled output."),
|
/** Schema ↔ interface lock (the old `satisfies z.ZodType` guarantee): the
|
||||||
}) satisfies z.ZodType<ProbeDoneInput>;
|
* 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. */
|
/** probe_done tool output. */
|
||||||
export interface ProbeDoneResult {
|
export interface ProbeDoneResult {
|
||||||
|
|||||||
+110
-107
@@ -2,10 +2,10 @@
|
|||||||
* question-core — the question contract + pure grading logic. No framework
|
* question-core — the question contract + pure grading logic. No framework
|
||||||
* imports, no UI: everything here is deterministic and unit-testable.
|
* imports, no UI: everything here is deterministic and unit-testable.
|
||||||
*
|
*
|
||||||
* The zod schemas below are the tool INPUT contracts (agent/tools declares
|
* The TypeBox schemas below are the tool INPUT contracts (agent/tools
|
||||||
* the tools against them); the browser validates, shuffles, and grades with
|
* declares the tools against them); the browser validates, shuffles, and
|
||||||
* these functions, then sends the verdict back as the tool output via
|
* grades with these functions, then sends the verdict back as the tool
|
||||||
* addToolOutput:
|
* output via addToolOutput:
|
||||||
* 1. The teacher model calls the `quiz` tool (question + options +
|
* 1. The teacher model calls the `quiz` tool (question + options +
|
||||||
* correctAnswer + explanation). No server execute → the call lands in
|
* correctAnswer + explanation). No server execute → the call lands in
|
||||||
* the UI as a typed tool part in `input-available` state.
|
* the UI as a typed tool part in `input-available` state.
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
* exited and resumed mid-question for free.
|
* exited and resumed mid-question for free.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { Type, type Static } from "typebox";
|
||||||
|
|
||||||
// ─── shared types ───────────────────────────────────────────────────────────
|
// ─── shared types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -30,14 +30,18 @@ export interface QuizOption {
|
|||||||
// ─── tool input schemas (the question contract) ─────────────────────────────
|
// ─── tool input schemas (the question contract) ─────────────────────────────
|
||||||
// Parameter names mirror the prompt docs exactly — keep them in sync.
|
// Parameter names mirror the prompt docs exactly — keep them in sync.
|
||||||
|
|
||||||
const optionSchema = z.object({
|
/** One answer option — shared by quiz + ask. */
|
||||||
label: z.string().describe("Display label for the answer option"),
|
const optionProps = {
|
||||||
value: z
|
label: Type.String({ description: "Display label for the answer option" }),
|
||||||
.string()
|
value: Type.Optional(
|
||||||
.optional()
|
Type.String({
|
||||||
.describe("Optional machine-readable value returned for the option. Defaults to the label."),
|
description: "Optional machine-readable value returned for the option. Defaults to the label.",
|
||||||
description: z.string().optional().describe("Optional extra detail shown below the option"),
|
}),
|
||||||
});
|
),
|
||||||
|
description: Type.Optional(
|
||||||
|
Type.String({ description: "Optional extra detail shown below the option" }),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
// ─── cross-field authoring validation (tool-schema refinements) ──────────
|
// ─── cross-field authoring validation (tool-schema refinements) ──────────
|
||||||
// Enforced at the tool-call boundary (see the superRefines below): an invalid
|
// 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-
|
// 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
|
// 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
|
// 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
|
// per-session tree validation runs in the tool's execute, tools.ts).
|
||||||
// are identical, single-sourced here.
|
// Grading rules are identical, single-sourced here. The cross-field
|
||||||
export const quizInputBase = z.object({
|
// authoring contract (quizInputProblems, above) is enforced at the
|
||||||
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."),
|
// tool-call boundary in execute — TypeBox carries the structure to the
|
||||||
options: z
|
// model, the problems function carries the refinements.
|
||||||
.array(optionSchema)
|
//
|
||||||
.min(2)
|
// Argument-order ruling (owner, 2026-09-25): the explanation is REASONING,
|
||||||
.describe(
|
// 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. " +
|
"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. " +
|
"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.",
|
"Never add your own 'I don't know' option — that choice is added for you.",
|
||||||
),
|
}),
|
||||||
multiSelect: z
|
multiSelect: Type.Optional(
|
||||||
.boolean()
|
Type.Boolean({
|
||||||
.optional()
|
description: "Set to true when more than one option is correct and the learner must select all of them.",
|
||||||
.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
|
explanation: Type.String({
|
||||||
// args in schema order, so explanation sits immediately BEFORE correctAnswer.
|
description:
|
||||||
// (All consumers read by key; the order is authoring guidance, nothing else.)
|
|
||||||
explanation: z
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"REQUIRED. Explanation revealed AFTER the learner answers (shown whether they got it right or wrong). " +
|
"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: " +
|
"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.",
|
"reason it out first, then commit the answer the reasoning supports.",
|
||||||
),
|
}),
|
||||||
correctAnswer: z
|
correctAnswer: Type.Union([Type.String(), Type.Array(Type.String())], {
|
||||||
.union([z.string(), z.array(z.string())])
|
description:
|
||||||
.describe(
|
|
||||||
"REQUIRED. The correct answer as the option value(s) — the `value` field of the option you intend. " +
|
"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"]); ' +
|
'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. " +
|
"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. " +
|
"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.",
|
"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."),
|
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.
|
// The TEACH-side quiz object: the shared base + the plan-position field.
|
||||||
// `nodeId` means "this question is ABOUT this node" — a position signal,
|
// `nodeId` means "this question is ABOUT this node" — a position signal,
|
||||||
// never a completion signal (docs/probe-teach-split.md §3).
|
// never a completion signal (docs/probe-teach-split.md §3). The cross-field
|
||||||
export const quizInputObject = quizInputBase.extend({
|
// authoring contract (the Gate-3 hardening: a quiz whose correctAnswer
|
||||||
nodeId: z
|
// can't be graded NEVER reaches the learner — schema-valid ⇔ gradeable, the
|
||||||
.string()
|
// rules exactly what the grading path accepts) runs at the CALL BOUNDARY in
|
||||||
.optional()
|
// the tool's execute: quizInputProblems here, the per-session plan-nodeId
|
||||||
.describe(
|
// validation in tools.ts (it reads the live chat record, which this core
|
||||||
"Optional plan-tree node id this question is about — must EXACTLY match one of this session's " +
|
// 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). " +
|
"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 " +
|
"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). " +
|
"(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.",
|
"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
|
/** The PROBE-side quiz declaration: the shared authoring contract, no
|
||||||
.object({
|
* nodeId field at all — the session's map doesn't exist yet. Structurally
|
||||||
// No `mode` param — the pi original has none: the mode is inferred from
|
* identical to quizInputBase (the old superRefine never reached the wire —
|
||||||
// whether options are present (the dock does exactly this). One less
|
* it ran at the call boundary, where quizInputProblems runs now). */
|
||||||
// knob for the model to misuse.
|
export const probeQuizInputSchema = quizInputBase;
|
||||||
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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export type QuizToolInput = z.infer<typeof quizInputSchema>;
|
/** The TEACHER's quiz declaration (base + the position field). */
|
||||||
export type AskToolInput = z.infer<typeof askInputSchema>;
|
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. */
|
/** A real selected answer, in the order the options were displayed. */
|
||||||
|
|||||||
+49
-26
@@ -3,9 +3,9 @@
|
|||||||
* live crashes).
|
* live crashes).
|
||||||
*
|
*
|
||||||
* WHY THIS EXISTS. Validation IS first at the model-facing boundary — pi
|
* WHY THIS EXISTS. Validation IS first at the model-facing boundary — pi
|
||||||
* validates arguments before execute, and our zod re-run adds the
|
* validates arguments against the tool's TypeBox schema before execute,
|
||||||
* refinements; a failure returns to the model as an error tool result,
|
* and the authoring refinements run in the execute seam; a failure
|
||||||
* same turn. But the same bytes have two other consumers, and there they
|
* 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:
|
* exist UNVALIDATED BY DESIGN:
|
||||||
*
|
*
|
||||||
* - the live wire streams raw tool-input deltas while the model is still
|
* - 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.
|
* lie the checker believes, with no runtime check behind it.
|
||||||
*
|
*
|
||||||
* THE RULE (mechanical, not vigilance): model-authored INPUT is consumed
|
* 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
|
* faced — boundary-valid ⇔ consumer-parseable, structurally. Server- and
|
||||||
* learner-authored OUTPUT stays trusted (our own code authors it: the
|
* learner-authored OUTPUT stays trusted (our own code authors it: the
|
||||||
* execute acks, the learner's buildAnswer). A parse failure renders the
|
* execute acks, the learner's buildAnswer). A parse failure renders the
|
||||||
* quiet fallback, never a crash.
|
* quiet fallback, never a crash.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { Value } from "typebox/value";
|
||||||
import { quizInputBase, askInputSchema, type AskToolInput, type QuizToolInput } from "./question-core.ts";
|
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";
|
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.
|
* Quiz parse for DISPLAY: the shared authoring base, pre-refinement.
|
||||||
* Both boundary schemas (the prober's probeQuizInputSchema over the bare
|
* Both boundaries (the prober's quizInputBase, the teacher's quizInputObject
|
||||||
* base, the teacher's quizInputSchema over base + optional nodeId) refine
|
* over base + optional nodeId) share THE SAME quizInputBase fields, so
|
||||||
* THE SAME quizInputBase, so anything that passed either boundary parses
|
* anything that passed either boundary parses here; the teacher-only
|
||||||
* here: refinements don't weaken on re-run, and the teacher-only `nodeId`
|
* `nodeId` is an unknown key Value.Clean strips (zod's old strip behavior,
|
||||||
* is an unknown key the base strips. This is deliberately the LENIENT
|
* kept so the display shape stays exactly what the dock always saw). This
|
||||||
* common core — the strict per-agent refinements are the boundary's job.
|
* 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 {
|
export function parseQuizInput(input: unknown): QuizToolInput | undefined {
|
||||||
const parsed = quizInputBase.safeParse(input);
|
const cleaned = boundaryParse<QuizToolInput>(quizInputBase, input);
|
||||||
return parsed.success ? (parsed.data as QuizToolInput) : undefined;
|
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
|
* Ask parse for DISPLAY: the full ask schema (options optional — free-text
|
||||||
* mode is the options-less shape). The only refinement is option sanity
|
* mode is the options-less shape), plus the option-sanity refinement the
|
||||||
* (distinct values), which the boundary already enforced.
|
* 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 {
|
export function parseAskInput(input: unknown): AskToolInput | undefined {
|
||||||
const parsed = askInputSchema.safeParse(input);
|
const cleaned = boundaryParse<AskToolInput>(askInputSchema, input);
|
||||||
return parsed.success ? parsed.data : undefined;
|
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. */
|
/** probe_done parse — the core-owned schema the tool itself declares. */
|
||||||
export function parseProbeDoneInput(input: unknown): ProbeDoneInput | undefined {
|
export function parseProbeDoneInput(input: unknown): ProbeDoneInput | undefined {
|
||||||
const parsed = probeDoneInputSchema.safeParse(input);
|
return boundaryParse<ProbeDoneInput>(probeDoneInputSchema, 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;
|
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,7 @@ import assert from "node:assert/strict";
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { after, describe, it } from "node:test";
|
import { after, describe, it } from "node:test";
|
||||||
|
import { Value } from "typebox/value";
|
||||||
import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
|
import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { PATHS } from "../config.ts";
|
import { PATHS } from "../config.ts";
|
||||||
@@ -845,13 +846,13 @@ describe("tool + renderer registry wiring", () => {
|
|||||||
for (const name of ["demo", "visual", "research", "pdf"] as const) {
|
for (const name of ["demo", "visual", "research", "pdf"] as const) {
|
||||||
assert.equal(typeof (tools[name] as { execute?: unknown }).execute, "function", `${name} executes server-side — fire-and-forget ack per THE ASYNC PROTOCOL`);
|
assert.equal(typeof (tools[name] as { execute?: unknown }).execute, "function", `${name} executes server-side — fire-and-forget ack per THE ASYNC PROTOCOL`);
|
||||||
}
|
}
|
||||||
assert.equal(demoInputSchema.safeParse({ title: "T", brief: "b" }).success, true);
|
assert.equal(Value.Check(demoInputSchema, { title: "T", brief: "b" }), true);
|
||||||
assert.equal(demoInputSchema.safeParse({ brief: "b" }).success, false, "demo requires a title");
|
assert.equal(Value.Check(demoInputSchema, { brief: "b" }), false, "demo requires a title");
|
||||||
assert.equal(visualInputSchema.safeParse({ brief: "b" }).success, true);
|
assert.equal(Value.Check(visualInputSchema, { brief: "b" }), true);
|
||||||
assert.equal(visualInputSchema.safeParse({}).success, false, "visual requires a brief");
|
assert.equal(Value.Check(visualInputSchema, {}), false, "visual requires a brief");
|
||||||
assert.equal(pdfInputSchema.safeParse({ path: "/tmp/doc.pdf", question: "q" }).success, true);
|
assert.equal(Value.Check(pdfInputSchema, { path: "/tmp/doc.pdf", question: "q" }), true);
|
||||||
assert.equal(pdfInputSchema.safeParse({ question: "q" }).success, false, "pdf requires a path");
|
assert.equal(Value.Check(pdfInputSchema, { question: "q" }), false, "pdf requires a path");
|
||||||
assert.equal(pdfInputSchema.safeParse({ path: "/tmp/doc.pdf" }).success, false, "pdf requires a question");
|
assert.equal(Value.Check(pdfInputSchema, { path: "/tmp/doc.pdf" }), false, "pdf requires a question");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the artifact renderer registry maps demo/visual to the W6 cards", () => {
|
it("the artifact renderer registry maps demo/visual to the W6 cards", () => {
|
||||||
|
|||||||
+31
-24
@@ -4,6 +4,7 @@ import * as os from "node:os";
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||||
import { expandBraces, globToRegex, matchGlob, walkFiles } from "../src/agent/tools/fs-shared.ts";
|
import { expandBraces, globToRegex, matchGlob, walkFiles } from "../src/agent/tools/fs-shared.ts";
|
||||||
import { findTool } from "../src/agent/tools/find.ts";
|
import { findTool } from "../src/agent/tools/find.ts";
|
||||||
import { grepTool } from "../src/agent/tools/grep.ts";
|
import { grepTool } from "../src/agent/tools/grep.ts";
|
||||||
@@ -28,6 +29,15 @@ import { readTool } from "../src/agent/tools/read.ts";
|
|||||||
|
|
||||||
let FIXTURE: string;
|
let FIXTURE: string;
|
||||||
|
|
||||||
|
/** Drive one tool's native execute and return the model-facing text
|
||||||
|
* (pi signature: execute(toolCallId, params, signal) → AgentToolResult). */
|
||||||
|
async function runText(tool: AgentTool<any>, params: unknown, signal?: AbortSignal): Promise<string> {
|
||||||
|
const result = await tool.execute!("c", params, signal);
|
||||||
|
const first = result.content[0];
|
||||||
|
assert.ok(first !== undefined && first.type === "text", "first content part is text");
|
||||||
|
return (first as { type: "text"; text: string }).text;
|
||||||
|
}
|
||||||
|
|
||||||
async function setup(): Promise<string> {
|
async function setup(): Promise<string> {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "lh-fs-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "lh-fs-"));
|
||||||
await fs.mkdir(path.join(root, "notes", "sub"), { recursive: true });
|
await fs.mkdir(path.join(root, "notes", "sub"), { recursive: true });
|
||||||
@@ -79,7 +89,7 @@ describe("glob helpers (fs-shared)", () => {
|
|||||||
describe("ls (pi port)", () => {
|
describe("ls (pi port)", () => {
|
||||||
it("lists sorted, dirs suffixed, dotfiles included", async () => {
|
it("lists sorted, dirs suffixed, dotfiles included", async () => {
|
||||||
FIXTURE = await setup();
|
FIXTURE = await setup();
|
||||||
const out = (await lsTool.execute!({ path: FIXTURE }, { toolCallId: "c" })) as string;
|
const out = await runText(lsTool, { path: FIXTURE });
|
||||||
assert.ok(out.includes("code/"));
|
assert.ok(out.includes("code/"));
|
||||||
assert.ok(out.includes("notes/"));
|
assert.ok(out.includes("notes/"));
|
||||||
assert.ok(out.includes("binary.bin"));
|
assert.ok(out.includes("binary.bin"));
|
||||||
@@ -90,14 +100,14 @@ describe("ls (pi port)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("honors the entry limit with pi's truncation notice", async () => {
|
it("honors the entry limit with pi's truncation notice", async () => {
|
||||||
const out = (await lsTool.execute!({ path: FIXTURE, limit: 2 }, { toolCallId: "c" })) as string;
|
const out = await runText(lsTool, { path: FIXTURE, limit: 2 });
|
||||||
assert.match(out, /\[Truncated: 2 entries limit\]/);
|
assert.match(out, /\[Truncated: 2 entries limit\]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("missing path and non-directory throw (the model sees the error)", async () => {
|
it("missing path and non-directory throw (the model sees the error)", async () => {
|
||||||
await assert.rejects(async () => lsTool.execute!({ path: "/nonexistent/xyz" }, { toolCallId: "c" }), /Path not found/);
|
await assert.rejects(async () => lsTool.execute!("c", { path: "/nonexistent/xyz" }), /Path not found/);
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
async () => lsTool.execute!({ path: path.join(FIXTURE, "binary.bin") }, { toolCallId: "c" }),
|
async () => lsTool.execute!("c", { path: path.join(FIXTURE, "binary.bin") }),
|
||||||
/Not a directory/,
|
/Not a directory/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -105,31 +115,31 @@ describe("ls (pi port)", () => {
|
|||||||
|
|
||||||
describe("find (pi port)", () => {
|
describe("find (pi port)", () => {
|
||||||
it("finds by glob, relativized to the search root", async () => {
|
it("finds by glob, relativized to the search root", async () => {
|
||||||
const out = (await findTool.execute!({ pattern: "*.md", path: FIXTURE }, { toolCallId: "c" })) as string;
|
const out = await runText(findTool, { pattern: "*.md", path: FIXTURE });
|
||||||
assert.ok(out.includes("notes/calculus.md"));
|
assert.ok(out.includes("notes/calculus.md"));
|
||||||
assert.ok(out.includes("notes/sub/hidden.md"));
|
assert.ok(out.includes("notes/sub/hidden.md"));
|
||||||
assert.ok(!out.includes(FIXTURE), "paths are relative, not absolute");
|
assert.ok(!out.includes(FIXTURE), "paths are relative, not absolute");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never enters node_modules or Library", async () => {
|
it("never enters node_modules or Library", async () => {
|
||||||
const out = (await findTool.execute!({ pattern: "*.txt", path: FIXTURE }, { toolCallId: "c" })) as string;
|
const out = await runText(findTool, { pattern: "*.txt", path: FIXTURE });
|
||||||
assert.ok(!out.includes("node_modules"));
|
assert.ok(!out.includes("node_modules"));
|
||||||
assert.ok(!out.includes("Library"));
|
assert.ok(!out.includes("Library"));
|
||||||
assert.ok(out.includes("notes/linear.txt"));
|
assert.ok(out.includes("notes/linear.txt"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("result limit carries pi's notice", async () => {
|
it("result limit carries pi's notice", async () => {
|
||||||
const out = (await findTool.execute!({ pattern: "*", path: FIXTURE, limit: 2 }, { toolCallId: "c" })) as string;
|
const out = await runText(findTool, { pattern: "*", path: FIXTURE, limit: 2 });
|
||||||
assert.match(out, /\[2 results limit reached\]/);
|
assert.match(out, /\[2 results limit reached\]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("no-match message matches pi", async () => {
|
it("no-match message matches pi", async () => {
|
||||||
const out = (await findTool.execute!({ pattern: "*.nomatch", path: FIXTURE }, { toolCallId: "c" })) as string;
|
const out = await runText(findTool, { pattern: "*.nomatch", path: FIXTURE });
|
||||||
assert.equal(out, "No files found matching pattern");
|
assert.equal(out, "No files found matching pattern");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("slash patterns match paths, not basenames", async () => {
|
it("slash patterns match paths, not basenames", async () => {
|
||||||
const out = (await findTool.execute!({ pattern: "notes/**/*.md", path: FIXTURE }, { toolCallId: "c" })) as string;
|
const out = await runText(findTool, { pattern: "notes/**/*.md", path: FIXTURE });
|
||||||
assert.ok(out.includes("notes/sub/hidden.md"));
|
assert.ok(out.includes("notes/sub/hidden.md"));
|
||||||
assert.ok(!out.includes("code/"));
|
assert.ok(!out.includes("code/"));
|
||||||
});
|
});
|
||||||
@@ -139,7 +149,7 @@ describe("grep (pi port)", () => {
|
|||||||
it("formats matches pi-style: rel:line: text", async () => {
|
it("formats matches pi-style: rel:line: text", async () => {
|
||||||
// case-sensitive: the lowercase "eigenvector" hits main.ts but NOT
|
// case-sensitive: the lowercase "eigenvector" hits main.ts but NOT
|
||||||
// linear.txt's capital-E "Eigenvectors" (that case lives below).
|
// linear.txt's capital-E "Eigenvectors" (that case lives below).
|
||||||
const out = (await grepTool.execute!({ pattern: "eigenvector", path: FIXTURE }, { toolCallId: "c" })) as string;
|
const out = await runText(grepTool, { pattern: "eigenvector", path: FIXTURE });
|
||||||
assert.equal(out, "code/main.ts:2: const eigenvector = 'here';");
|
assert.equal(out, "code/main.ts:2: const eigenvector = 'here';");
|
||||||
assert.ok(!out.includes("node_modules"), "excluded dirs are not searched");
|
assert.ok(!out.includes("node_modules"), "excluded dirs are not searched");
|
||||||
assert.ok(!out.includes("Library"));
|
assert.ok(!out.includes("Library"));
|
||||||
@@ -147,42 +157,42 @@ describe("grep (pi port)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("ignoreCase and literal flags", async () => {
|
it("ignoreCase and literal flags", async () => {
|
||||||
const icase = (await grepTool.execute!({ pattern: "EIGENVECTOR", path: FIXTURE, ignoreCase: true }, { toolCallId: "c" })) as string;
|
const icase = await runText(grepTool, { pattern: "EIGENVECTOR", path: FIXTURE, ignoreCase: true });
|
||||||
assert.match(icase, /linear\.txt:1:/);
|
assert.match(icase, /linear\.txt:1:/);
|
||||||
const literal = (await grepTool.execute!({ pattern: "e.gen", literal: true, path: path.join(FIXTURE, "code") }, { toolCallId: "c" })) as string;
|
const literal = await runText(grepTool, { pattern: "e.gen", literal: true, path: path.join(FIXTURE, "code") });
|
||||||
assert.equal(literal, "No matches found", "dots are literal when literal=true");
|
assert.equal(literal, "No matches found", "dots are literal when literal=true");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("context lines use the dash format", async () => {
|
it("context lines use the dash format", async () => {
|
||||||
const out = (await grepTool.execute!({ pattern: "scaling", path: FIXTURE, context: 1 }, { toolCallId: "c" })) as string;
|
const out = await runText(grepTool, { pattern: "scaling", path: FIXTURE, context: 1 });
|
||||||
assert.match(out, /notes\/linear\.txt:2: only scaling remains/, "the match line uses colons");
|
assert.match(out, /notes\/linear\.txt:2: only scaling remains/, "the match line uses colons");
|
||||||
assert.match(out, /notes\/linear\.txt-1- Eigenvectors keep their direction/, "context before uses dashes");
|
assert.match(out, /notes\/linear\.txt-1- Eigenvectors keep their direction/, "context before uses dashes");
|
||||||
assert.match(out, /notes\/linear\.txt-3- $/, "context after (empty line) uses dashes");
|
assert.match(out, /notes\/linear\.txt-3- $/, "context after (empty line) uses dashes");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("glob filter restricts files", async () => {
|
it("glob filter restricts files", async () => {
|
||||||
const out = (await grepTool.execute!({ pattern: "eigenvector", path: FIXTURE, glob: "*.ts" }, { toolCallId: "c" })) as string;
|
const out = await runText(grepTool, { pattern: "eigenvector", path: FIXTURE, glob: "*.ts" });
|
||||||
assert.match(out, /main\.ts:2:/);
|
assert.match(out, /main\.ts:2:/);
|
||||||
assert.ok(!out.includes("linear.txt"));
|
assert.ok(!out.includes("linear.txt"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("single-file search prints basename; match limit notices pi-style", async () => {
|
it("single-file search prints basename; match limit notices pi-style", async () => {
|
||||||
const file = path.join(FIXTURE, "notes", "calculus.md");
|
const file = path.join(FIXTURE, "notes", "calculus.md");
|
||||||
const out = (await grepTool.execute!({ pattern: "e", path: file }, { toolCallId: "c" })) as string;
|
const out = await runText(grepTool, { pattern: "e", path: file });
|
||||||
assert.match(out, /^calculus\.md:1: /, "basename for a single-file search");
|
assert.match(out, /^calculus\.md:1: /, "basename for a single-file search");
|
||||||
const limited = (await grepTool.execute!({ pattern: "e", path: file, limit: 1 }, { toolCallId: "c" })) as string;
|
const limited = await runText(grepTool, { pattern: "e", path: file, limit: 1 });
|
||||||
assert.match(limited, /\[1 matches limit reached\]/);
|
assert.match(limited, /\[1 matches limit reached\]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("no-match message matches pi", async () => {
|
it("no-match message matches pi", async () => {
|
||||||
const out = (await grepTool.execute!({ pattern: "zzzz", path: FIXTURE }, { toolCallId: "c" })) as string;
|
const out = await runText(grepTool, { pattern: "zzzz", path: FIXTURE });
|
||||||
assert.equal(out, "No matches found");
|
assert.equal(out, "No matches found");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("read (home-anchored re-port)", () => {
|
describe("read (home-anchored re-port)", () => {
|
||||||
it("reads a fixture file verbatim through the shared slice machinery", async () => {
|
it("reads a fixture file verbatim through the shared slice machinery", async () => {
|
||||||
const out = (await readTool.execute!({ path: path.join(FIXTURE, "notes", "linear.txt") }, { toolCallId: "c" })) as string;
|
const out = await runText(readTool, { path: path.join(FIXTURE, "notes", "linear.txt") });
|
||||||
assert.match(out, /Eigenvectors keep their direction/);
|
assert.match(out, /Eigenvectors keep their direction/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -211,14 +221,14 @@ describe("abort plumbing (the 2026-09-19 cascade fix)", () => {
|
|||||||
FIXTURE = await setup();
|
FIXTURE = await setup();
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
ac.abort();
|
ac.abort();
|
||||||
const out = (await findTool.execute!({ pattern: "*.md" }, { toolCallId: "c", signal: ac.signal })) as string;
|
const out = await runText(findTool, { pattern: "*.md" }, ac.signal);
|
||||||
assert.equal(out, "Operation aborted");
|
assert.equal(out, "Operation aborted");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("grep honors a pre-aborted signal without scanning", async () => {
|
it("grep honors a pre-aborted signal without scanning", async () => {
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
ac.abort();
|
ac.abort();
|
||||||
const out = (await grepTool.execute!({ pattern: "eigenvector" }, { toolCallId: "c", signal: ac.signal })) as string;
|
const out = await runText(grepTool, { pattern: "eigenvector" }, ac.signal);
|
||||||
assert.equal(out, "Operation aborted");
|
assert.equal(out, "Operation aborted");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,10 +245,7 @@ describe("abort plumbing (the 2026-09-19 cascade fix)", () => {
|
|||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
setTimeout(() => ac.abort(), 1);
|
setTimeout(() => ac.abort(), 1);
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const out = (await grepTool.execute!(
|
const out = await runText(grepTool, { pattern: "needle", path: root }, ac.signal);
|
||||||
{ pattern: "needle", path: root },
|
|
||||||
{ toolCallId: "c", signal: ac.signal },
|
|
||||||
)) as string;
|
|
||||||
assert.match(out, /aborted/);
|
assert.match(out, /aborted/);
|
||||||
assert.ok(Date.now() - started < 5_000, "returned promptly");
|
assert.ok(Date.now() - started < 5_000, "returned promptly");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ describe("list_sessions", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
||||||
const out = (await tools.list_sessions!.execute!({}, { toolCallId: "tc" })) as string;
|
const out = (await tools.list_sessions!.execute!("tc", {})).details as string;
|
||||||
assert.match(out, /2 session\(s\) in the knowledge graph, most recent first:/);
|
assert.match(out, /2 session\(s\) in the knowledge graph, most recent first:/);
|
||||||
// The chat: name, kind, born count, dates, id — the full resume.
|
// The chat: name, kind, born count, dates, id — the full resume.
|
||||||
assert.match(out, /First lesson \(chat, 2 propositions, born 2026-09-20T10:00:00.000Z, last active 2026-09-21T10:00:00.000Z\) — id: c1/);
|
assert.match(out, /First lesson \(chat, 2 propositions, born 2026-09-20T10:00:00.000Z, last active 2026-09-21T10:00:00.000Z\) — id: c1/);
|
||||||
@@ -151,7 +151,7 @@ describe("list_sessions", () => {
|
|||||||
it("an empty graph gets an honest answer, not an empty string or a throw", async () => {
|
it("an empty graph gets an honest answer, not an empty string or a throw", async () => {
|
||||||
const { chatStore, graphStore } = fakeStores([]);
|
const { chatStore, graphStore } = fakeStores([]);
|
||||||
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
||||||
const out = (await tools.list_sessions!.execute!({}, { toolCallId: "tc" })) as string;
|
const out = (await tools.list_sessions!.execute!("tc", {})).details as string;
|
||||||
assert.equal(out, "The knowledge graph is empty — no session has contributed propositions yet.");
|
assert.equal(out, "The knowledge graph is empty — no session has contributed propositions yet.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -172,7 +172,7 @@ describe("list_propositions", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
||||||
const out = (await tools.list_propositions!.execute!({ session: "c1" }, { toolCallId: "tc" })) as string;
|
const out = (await tools.list_propositions!.execute!("tc", { session: "c1" })).details as string;
|
||||||
assert.match(out, /2 proposition\(s\) born in this session:/);
|
assert.match(out, /2 proposition\(s\) born in this session:/);
|
||||||
assert.match(out, /- Water cycle — solid \(as of 2026-09-22T12:00:00.000Z\)/);
|
assert.match(out, /- Water cycle — solid \(as of 2026-09-22T12:00:00.000Z\)/);
|
||||||
assert.match(out, /- Evaporation — not yet engaged \(as of 2026-09-21T12:00:00.000Z\)/);
|
assert.match(out, /- Evaporation — not yet engaged \(as of 2026-09-21T12:00:00.000Z\)/);
|
||||||
@@ -186,7 +186,7 @@ describe("list_propositions", () => {
|
|||||||
events: [],
|
events: [],
|
||||||
});
|
});
|
||||||
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
||||||
const out = (await tools.list_propositions!.execute!({ session: "c1" }, { toolCallId: "tc" })) as string;
|
const out = (await tools.list_propositions!.execute!("tc", { session: "c1" })).details as string;
|
||||||
assert.match(out, /- Water cycle — never evaluated/);
|
assert.match(out, /- Water cycle — never evaluated/);
|
||||||
assert.ok(!out.includes("(as of"));
|
assert.ok(!out.includes("(as of"));
|
||||||
});
|
});
|
||||||
@@ -194,7 +194,7 @@ describe("list_propositions", () => {
|
|||||||
it("an unknown session id — honest redirect, not a throw", async () => {
|
it("an unknown session id — honest redirect, not a throw", async () => {
|
||||||
const { chatStore, graphStore } = fakeStores([node("n1", "Water cycle", [pt("c1")])]);
|
const { chatStore, graphStore } = fakeStores([node("n1", "Water cycle", [pt("c1")])]);
|
||||||
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
||||||
const out = (await tools.list_propositions!.execute!({ session: "no-such" }, { toolCallId: "tc" })) as string;
|
const out = (await tools.list_propositions!.execute!("tc", { session: "no-such" })).details as string;
|
||||||
assert.equal(out, 'No propositions born in session "no-such". Call list_sessions for the current ids.');
|
assert.equal(out, 'No propositions born in session "no-such". Call list_sessions for the current ids.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ after(() => {
|
|||||||
async function exec(tools: ReturnType<typeof planTools>, name: string, input: unknown): Promise<PlanToolResult> {
|
async function exec(tools: ReturnType<typeof planTools>, name: string, input: unknown): Promise<PlanToolResult> {
|
||||||
const t = tools[name]!;
|
const t = tools[name]!;
|
||||||
assert.ok(typeof t.execute === "function", `${name} must be a server-side tool with execute`);
|
assert.ok(typeof t.execute === "function", `${name} must be a server-side tool with execute`);
|
||||||
return t.execute(input as never, { toolCallId: "test", messages: [] } as never) as Promise<PlanToolResult>;
|
return t.execute("test", input).then((r) => r.details as PlanToolResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
function newChat(title: string): string {
|
function newChat(title: string): string {
|
||||||
|
|||||||
@@ -43,10 +43,7 @@ const ASSESSMENT = {
|
|||||||
async function execute(tools: ReturnType<typeof probeDoneTool>, input: unknown) {
|
async function execute(tools: ReturnType<typeof probeDoneTool>, input: unknown) {
|
||||||
const t = tools.probe_done!;
|
const t = tools.probe_done!;
|
||||||
assert.ok(typeof t.execute === "function");
|
assert.ok(typeof t.execute === "function");
|
||||||
return t.execute(input as never, { toolCallId: "test", messages: [] } as never) as Promise<{
|
return t.execute("test", input).then((r) => r.details as { ok: boolean; error?: string });
|
||||||
ok: boolean;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("probe_done: the probe→planning seam", () => {
|
describe("probe_done: the probe→planning seam", () => {
|
||||||
|
|||||||
+27
-30
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { z } from "zod";
|
import { Value } from "typebox/value";
|
||||||
|
import type { TSchema } from "typebox";
|
||||||
import {
|
import {
|
||||||
askInputSchema,
|
askInputSchema,
|
||||||
askOptionProblems,
|
askOptionProblems,
|
||||||
@@ -29,24 +30,25 @@ import {
|
|||||||
toClientQuestion,
|
toClientQuestion,
|
||||||
} from "../src/core/question-core.ts";
|
} from "../src/core/question-core.ts";
|
||||||
|
|
||||||
// ─── the authoring contract (tool-schema refinements) ───────────────────
|
// ─── the authoring contract (TypeBox schemas + execute-time refinements) ──
|
||||||
// Schema-valid ⇔ gradeable: a quiz whose correctAnswer can't be resolved
|
// Schema-valid ⇔ gradeable: a quiz whose correctAnswer can't be resolved
|
||||||
// dies at the tool-call boundary, and the SDK returns the error to the
|
// dies at the tool-call boundary (structural TypeBox check in pi's loop +
|
||||||
// teacher to retry — it must never reach the learner's dock.
|
// the refinement problems in the tool's execute — a ToolInputError the
|
||||||
|
// model retries on), and never reaches the learner's dock. The boundary
|
||||||
|
// predicate is BOTH: structural check AND zero authoring problems.
|
||||||
|
const boundaryOk = (schema: TSchema, input: unknown, problems: string[]): boolean =>
|
||||||
|
Value.Check(schema, input) && problems.length === 0;
|
||||||
|
|
||||||
describe("the schema's argument order (reasoning before the pick)", () => {
|
describe("the schema's argument order (reasoning before the pick)", () => {
|
||||||
// Argument-order ruling (owner, 2026-09-25): the explanation is
|
// Argument-order ruling (owner, 2026-09-25): the explanation is
|
||||||
// reasoning, and reasoning happens BEFORE the answer is chosen — models
|
// reasoning, and reasoning happens BEFORE the answer is chosen — models
|
||||||
// emit tool args in schema order, so explanation sits immediately before
|
// emit tool args in schema order, so explanation sits immediately before
|
||||||
// correctAnswer. z.toJSONSchema preserves zod's declaration order into
|
// correctAnswer. The TypeBox authoring order IS the model-facing JSON
|
||||||
// the model-facing properties, so this pin guards the emission end to
|
// Schema property order, so this pin guards the emission end to end.
|
||||||
// end. All consumers read by key — the order is authoring guidance.
|
// All consumers read by key — the order is authoring guidance.
|
||||||
it("emits explanation immediately before correctAnswer (both agents' schemas)", () => {
|
it("emits explanation immediately before correctAnswer (both agents' schemas)", () => {
|
||||||
for (const schema of [quizInputSchema, probeQuizInputSchema]) {
|
for (const schema of [quizInputSchema, probeQuizInputSchema]) {
|
||||||
const json = z.toJSONSchema(schema as z.ZodType, { io: "input" }) as {
|
const keys = Object.keys((schema as { properties: Record<string, unknown> }).properties);
|
||||||
properties: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
const keys = Object.keys(json.properties);
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
keys.indexOf("explanation") < keys.indexOf("correctAnswer"),
|
keys.indexOf("explanation") < keys.indexOf("correctAnswer"),
|
||||||
`explanation must precede correctAnswer in the emitted schema (got: ${keys.join(", ")})`,
|
`explanation must precede correctAnswer in the emitted schema (got: ${keys.join(", ")})`,
|
||||||
@@ -73,8 +75,7 @@ describe("quizInputSchema / quizInputProblems (the authoring contract)", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
it("accepts a valid single-select quiz (label as correctAnswer)", () => {
|
it("accepts a valid single-select quiz (label as correctAnswer)", () => {
|
||||||
const parsed = quizInputSchema.safeParse(valid);
|
assert.equal(boundaryOk(quizInputSchema, valid, quizInputProblems(valid)), true);
|
||||||
assert.equal(parsed.success, true);
|
|
||||||
assert.deepEqual(quizInputProblems(valid), []);
|
assert.deepEqual(quizInputProblems(valid), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,10 +90,10 @@ describe("quizInputSchema / quizInputProblems (the authoring contract)", () => {
|
|||||||
correctAnswer: ["a", "b"],
|
correctAnswer: ["a", "b"],
|
||||||
multiSelect: true,
|
multiSelect: true,
|
||||||
};
|
};
|
||||||
assert.equal(quizInputSchema.safeParse(multi).success, true);
|
assert.equal(boundaryOk(quizInputSchema, multi, quizInputProblems(multi)), true);
|
||||||
// The union lists String first, so providers sometimes stringify arrays.
|
// The union lists String first, so providers sometimes stringify arrays.
|
||||||
const stringified = { ...multi, correctAnswer: JSON.stringify(multi.correctAnswer) };
|
const stringified = { ...multi, correctAnswer: JSON.stringify(multi.correctAnswer) };
|
||||||
assert.equal(quizInputSchema.safeParse(stringified).success, true);
|
assert.equal(boundaryOk(quizInputSchema, stringified, quizInputProblems(stringified)), true);
|
||||||
assert.deepEqual(quizInputProblems(stringified), []);
|
assert.deepEqual(quizInputProblems(stringified), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,20 +109,16 @@ describe("quizInputSchema / quizInputProblems (the authoring contract)", () => {
|
|||||||
correctAnswer: "cultural-diagnosis",
|
correctAnswer: "cultural-diagnosis",
|
||||||
explanation: "...",
|
explanation: "...",
|
||||||
};
|
};
|
||||||
const parsed = quizInputSchema.safeParse(bad);
|
assert.equal(boundaryOk(quizInputSchema, bad, quizInputProblems(bad)), false);
|
||||||
assert.equal(parsed.success, false);
|
|
||||||
const problems = quizInputProblems(bad);
|
const problems = quizInputProblems(bad);
|
||||||
assert.equal(problems.length, 1);
|
assert.equal(problems.length, 1);
|
||||||
assert.match(problems[0]!, /does not match any option value/);
|
assert.match(problems[0]!, /does not match any option value/);
|
||||||
assert.match(problems[0]!, /"A cultural diagnosis"/); // the valid values are listed for the retry
|
assert.match(problems[0]!, /"A cultural diagnosis"/); // the valid values are listed for the retry
|
||||||
// The schema's error message carries the problems (the SDK returns them to the teacher).
|
|
||||||
const msg = JSON.stringify(parsed.error?.issues);
|
|
||||||
assert.match(msg, /does not match any option value/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects multiple correct answers without multiSelect (ungradeable single-select)", () => {
|
it("rejects multiple correct answers without multiSelect (ungradeable single-select)", () => {
|
||||||
const bad = { ...valid, correctAnswer: ["Venus", "Mercury"] };
|
const bad = { ...valid, correctAnswer: ["Venus", "Mercury"] };
|
||||||
assert.equal(quizInputSchema.safeParse(bad).success, false);
|
assert.equal(boundaryOk(quizInputSchema, bad, quizInputProblems(bad)), false);
|
||||||
assert.match(quizInputProblems(bad).join(" "), /multiSelect/);
|
assert.match(quizInputProblems(bad).join(" "), /multiSelect/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,38 +130,38 @@ describe("quizInputSchema / quizInputProblems (the authoring contract)", () => {
|
|||||||
{ label: "Venus", value: "same" },
|
{ label: "Venus", value: "same" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
assert.equal(quizInputSchema.safeParse(bad).success, false);
|
assert.equal(boundaryOk(quizInputSchema, bad, quizInputProblems(bad)), false);
|
||||||
assert.match(quizInputProblems(bad).join(" "), /same value/);
|
assert.match(quizInputProblems(bad).join(" "), /same value/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an empty option label", () => {
|
it("rejects an empty option label", () => {
|
||||||
const bad = { ...valid, options: [{ label: " " }, { label: "Mercury" }, { label: "Mars" }] };
|
const bad = { ...valid, options: [{ label: " " }, { label: "Mercury" }, { label: "Mars" }] };
|
||||||
assert.equal(quizInputSchema.safeParse(bad).success, false);
|
assert.equal(boundaryOk(quizInputSchema, bad, quizInputProblems(bad)), false);
|
||||||
assert.match(quizInputProblems(bad).join(" "), /label is empty/);
|
assert.match(quizInputProblems(bad).join(" "), /label is empty/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires a resolvable correctAnswer", () => {
|
it("requires a resolvable correctAnswer", () => {
|
||||||
assert.equal(quizInputSchema.safeParse({ ...valid, correctAnswer: "" }).success, false);
|
assert.equal(boundaryOk(quizInputSchema, { ...valid, correctAnswer: "" }, quizInputProblems({ ...valid, correctAnswer: "" })), false);
|
||||||
assert.equal(quizInputSchema.safeParse({ ...valid, correctAnswer: "[]" }).success, false);
|
assert.equal(boundaryOk(quizInputSchema, { ...valid, correctAnswer: "[]" }, quizInputProblems({ ...valid, correctAnswer: "[]" })), false);
|
||||||
assert.match(quizInputProblems({ ...valid, correctAnswer: "" }).join(" "), /required/);
|
assert.match(quizInputProblems({ ...valid, correctAnswer: "" }).join(" "), /required/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("askInputSchema / askOptionProblems", () => {
|
describe("askInputSchema / askOptionProblems", () => {
|
||||||
it("accepts a valid ask", () => {
|
it("accepts a valid ask", () => {
|
||||||
const parsed = askInputSchema.safeParse({
|
const ask = {
|
||||||
question: "Which topic first?",
|
question: "Which topic first?",
|
||||||
options: [{ label: "Limits" }, { label: "Chain rule" }],
|
options: [{ label: "Limits" }, { label: "Chain rule" }],
|
||||||
});
|
};
|
||||||
assert.equal(parsed.success, true);
|
assert.equal(boundaryOk(askInputSchema, ask, askOptionProblems(ask.options ?? [])), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects duplicate option values and empty labels (they break the dock's keying)", () => {
|
it("rejects duplicate option values and empty labels (they break the dock's keying)", () => {
|
||||||
const bad = { question: "?", options: [{ label: "A", value: "x" }, { label: "B", value: "x" }] };
|
const bad = { question: "?", options: [{ label: "A", value: "x" }, { label: "B", value: "x" }] };
|
||||||
assert.equal(askInputSchema.safeParse(bad).success, false);
|
assert.equal(boundaryOk(askInputSchema, bad, askOptionProblems(bad.options ?? [])), false);
|
||||||
assert.match(askOptionProblems(bad.options).join(" "), /same value/);
|
assert.match(askOptionProblems(bad.options).join(" "), /same value/);
|
||||||
const emptyLabel = { question: "?", options: [{ label: "" }, { label: "B" }] };
|
const emptyLabel = { question: "?", options: [{ label: "" }, { label: "B" }] };
|
||||||
assert.equal(askInputSchema.safeParse(emptyLabel).success, false);
|
assert.equal(boundaryOk(askInputSchema, emptyLabel, askOptionProblems(emptyLabel.options ?? [])), false);
|
||||||
assert.match(askOptionProblems(emptyLabel.options).join(" "), /label is empty/);
|
assert.match(askOptionProblems(emptyLabel.options).join(" "), /label is empty/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import assert from "node:assert/strict";
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { after, describe, it } from "node:test";
|
import { after, describe, it } from "node:test";
|
||||||
|
import { Value } from "typebox/value";
|
||||||
import { createProberTools, createTeacherTools, teachQuizToolFor, type TeacherToolContext } from "../src/agent/tools.ts";
|
import { createProberTools, createTeacherTools, teachQuizToolFor, type TeacherToolContext } from "../src/agent/tools.ts";
|
||||||
import { TOGGLEABLE_TOOLS } from "../src/core/pending.ts";
|
import { TOGGLEABLE_TOOLS } from "../src/core/pending.ts";
|
||||||
import { jobStore } from "../src/store/jobs.ts";
|
import { jobStore } from "../src/store/jobs.ts";
|
||||||
@@ -51,15 +52,33 @@ const validQuiz = {
|
|||||||
explanation: "Arithmetic.",
|
explanation: "Arithmetic.",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface QuizToolSpec {
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||||
schema: { safeParse: (v: unknown) => { success: boolean; error?: { issues: Array<{ message: string }> } } };
|
import { ToolInputError } from "../src/agent/tool-execute.ts";
|
||||||
|
|
||||||
|
/** The Wave A boundary predicate (2026-09-26): pi's loop structurally
|
||||||
|
* validates against the TypeBox parameters, then execute re-runs the
|
||||||
|
* authoring refinements (the old superRefine) and throws ToolInputError
|
||||||
|
* on problems — the model sees both as retryable input errors. This
|
||||||
|
* helper replicates the old safeParse seam end to end. */
|
||||||
|
async function boundaryParse(
|
||||||
|
// `input: never` param: contravariance lets any AgentTool's execute slot in.
|
||||||
|
tool: { parameters: any; execute: (id: string, input: never) => Promise<unknown> },
|
||||||
|
input: unknown,
|
||||||
|
): Promise<{ success: boolean; error?: ToolInputError }> {
|
||||||
|
if (!Value.Check(tool.parameters, input)) return { success: false };
|
||||||
|
try {
|
||||||
|
await tool.execute("tc", input as never);
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
assert.ok(e instanceof ToolInputError, `expected ToolInputError, got: ${e}`);
|
||||||
|
return { success: false, error: e };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/** teachQuizToolFor returns the single quiz tool; createProberTools a registry. */
|
const problemLines = (e?: ToolInputError): string[] =>
|
||||||
const schemaOf = (toolLike: unknown) => (toolLike as QuizToolSpec).schema;
|
(e?.message.split("\n") ?? []).filter((l) => l.startsWith("- "));
|
||||||
const quizSchemaOf = (tools: Record<string, unknown>) => schemaOf(tools.quiz);
|
|
||||||
|
|
||||||
describe("teachQuizToolFor: the dynamic plan-nodeId validation (position signal)", () => {
|
describe("teachQuizToolFor: the dynamic plan-nodeId validation (position signal)", () => {
|
||||||
it("accepts a nodeId that matches a node of the session's tree", () => {
|
it("accepts a nodeId that matches a node of the session's tree", async () => {
|
||||||
const chatId = newChat("NodeId: valid");
|
const chatId = newChat("NodeId: valid");
|
||||||
chatStore.saveTree(chatId, {
|
chatStore.saveTree(chatId, {
|
||||||
nodes: [
|
nodes: [
|
||||||
@@ -69,46 +88,46 @@ describe("teachQuizToolFor: the dynamic plan-nodeId validation (position signal)
|
|||||||
edges: [{ from: "arith", to: "expressions" }],
|
edges: [{ from: "arith", to: "expressions" }],
|
||||||
version: 1,
|
version: 1,
|
||||||
});
|
});
|
||||||
const res = schemaOf(teachQuizToolFor({ chatId, chatStore })).safeParse({
|
const res = await boundaryParse(teachQuizToolFor({ chatId, chatStore }), {
|
||||||
...validQuiz,
|
...validQuiz,
|
||||||
nodeId: "expressions",
|
nodeId: "expressions",
|
||||||
});
|
});
|
||||||
assert.equal(res.success, true);
|
assert.equal(res.success, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a nodeId that matches no node — with a message listing the slugs", () => {
|
it("rejects a nodeId that matches no node — with a message listing the slugs", async () => {
|
||||||
const chatId = newChat("NodeId: typo");
|
const chatId = newChat("NodeId: typo");
|
||||||
chatStore.saveTree(chatId, {
|
chatStore.saveTree(chatId, {
|
||||||
nodes: [{ id: "arith", title: "Arithmetic" }],
|
nodes: [{ id: "arith", title: "Arithmetic" }],
|
||||||
edges: [],
|
edges: [],
|
||||||
version: 1,
|
version: 1,
|
||||||
});
|
});
|
||||||
const res = schemaOf(teachQuizToolFor({ chatId, chatStore })).safeParse({
|
const res = await boundaryParse(teachQuizToolFor({ chatId, chatStore }), {
|
||||||
...validQuiz,
|
...validQuiz,
|
||||||
nodeId: "arithmetic", // the typo — slug is 'arith'
|
nodeId: "arithmetic", // the typo — slug is 'arith'
|
||||||
});
|
});
|
||||||
assert.equal(res.success, false);
|
assert.equal(res.success, false);
|
||||||
const msg = res.error!.issues.map((i) => i.message).join("\n");
|
const msg = problemLines(res.error).join("\n");
|
||||||
assert.match(msg, /matches no node/);
|
assert.match(msg, /matches no node/);
|
||||||
assert.match(msg, /arith/);
|
assert.match(msg, /arith/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("no tree yet: ANY nodeId is rejected — omit it for ad-hoc checks", () => {
|
it("no tree yet: ANY nodeId is rejected — omit it for ad-hoc checks", async () => {
|
||||||
const chatId = newChat("NodeId: no tree");
|
const chatId = newChat("NodeId: no tree");
|
||||||
const schema = schemaOf(teachQuizToolFor({ chatId, chatStore }));
|
const tool = teachQuizToolFor({ chatId, chatStore });
|
||||||
const res = schema.safeParse({ ...validQuiz, nodeId: "anything" });
|
const res = await boundaryParse(tool, { ...validQuiz, nodeId: "anything" });
|
||||||
assert.equal(res.success, false);
|
assert.equal(res.success, false);
|
||||||
assert.match(res.error!.issues.at(-1)!.message, /No plan map exists yet/);
|
assert.match(problemLines(res.error).at(-1)!, /No plan map exists yet/);
|
||||||
// …while an untagged ad-hoc check is fine
|
// …while an untagged ad-hoc check is fine
|
||||||
const plain = schema.safeParse(validQuiz);
|
const plain = await boundaryParse(tool, validQuiz);
|
||||||
assert.equal(plain.success, true);
|
assert.equal(plain.success, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads the LIVE record — a plan_init mutation mid-turn is honored immediately", () => {
|
it("reads the LIVE record — a plan_init mutation mid-turn is honored immediately", async () => {
|
||||||
const chatId = newChat("NodeId: live record");
|
const chatId = newChat("NodeId: live record");
|
||||||
const schema = schemaOf(teachQuizToolFor({ chatId, chatStore }));
|
const tool = teachQuizToolFor({ chatId, chatStore });
|
||||||
|
|
||||||
const before = schema.safeParse({ ...validQuiz, nodeId: "fresh-node" });
|
const before = await boundaryParse(tool, { ...validQuiz, nodeId: "fresh-node" });
|
||||||
assert.equal(before.success, false, "no tree yet → rejected");
|
assert.equal(before.success, false, "no tree yet → rejected");
|
||||||
|
|
||||||
chatStore.saveTree(chatId, {
|
chatStore.saveTree(chatId, {
|
||||||
@@ -116,31 +135,36 @@ describe("teachQuizToolFor: the dynamic plan-nodeId validation (position signal)
|
|||||||
edges: [],
|
edges: [],
|
||||||
version: 1,
|
version: 1,
|
||||||
});
|
});
|
||||||
const after = schema.safeParse({ ...validQuiz, nodeId: "fresh-node" });
|
const after = await boundaryParse(tool, { ...validQuiz, nodeId: "fresh-node" });
|
||||||
assert.equal(after.success, true, "the same id validates once the tree exists — same turn");
|
assert.equal(after.success, true, "the same id validates once the tree exists — same turn");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the shared grading rules still chain FIRST (schema-valid ⇔ gradeable)", () => {
|
it("the shared grading rules still chain FIRST (schema-valid ⇔ gradeable)", async () => {
|
||||||
const chatId = newChat("NodeId: grading rules intact");
|
const chatId = newChat("NodeId: grading rules intact");
|
||||||
const res = schemaOf(teachQuizToolFor({ chatId, chatStore })).safeParse({
|
const res = await boundaryParse(teachQuizToolFor({ chatId, chatStore }), {
|
||||||
...validQuiz,
|
...validQuiz,
|
||||||
correctAnswer: "42", // matches no option — question-core's rule
|
correctAnswer: "42", // matches no option — question-core's rule
|
||||||
nodeId: "ghost", // also bad — BOTH problems surface
|
nodeId: "ghost", // also bad — BOTH problems surface
|
||||||
});
|
});
|
||||||
assert.equal(res.success, false);
|
assert.equal(res.success, false);
|
||||||
const msgs = res.error!.issues.map((i) => i.message).join("\n");
|
const msgs = problemLines(res.error).join("\n");
|
||||||
assert.match(msgs, /correctAnswer/);
|
assert.match(msgs, /correctAnswer/);
|
||||||
assert.match(msgs, /matches no node/);
|
assert.match(msgs, /matches no node/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("the prober's quiz: no nodeId field at all", () => {
|
describe("the prober's quiz: no nodeId field at all", () => {
|
||||||
it("the probe schema accepts a plain quiz and STRIPS a smuggled nodeId", () => {
|
it("the probe schema accepts a plain quiz; a smuggled nodeId is NOT in the wire schema", () => {
|
||||||
const plain = probeQuizInputSchema.safeParse(validQuiz);
|
assert.ok(Value.Check(probeQuizInputSchema, validQuiz));
|
||||||
assert.equal(plain.success, true);
|
// Wave A delta (2026-09-26): pi's TypeBox validation tolerates unknown
|
||||||
const smuggled = probeQuizInputSchema.safeParse({ ...validQuiz, nodeId: "anything" });
|
// keys rather than stripping them — but the probe schema's wire
|
||||||
assert.equal(smuggled.success, true, "the field doesn't exist — zod strips it");
|
// properties carry no nodeId at all, and the display parse seam
|
||||||
assert.equal("nodeId" in smuggled.data, false, "stripped, not tolerated");
|
// (tool-parts parseQuizInput, Value.Clean) strips it before the dock.
|
||||||
|
const smuggled = { ...validQuiz, nodeId: "anything" };
|
||||||
|
assert.ok(Value.Check(probeQuizInputSchema, smuggled));
|
||||||
|
const wireKeys = Object.keys((probeQuizInputSchema as { properties: Record<string, unknown> }).properties);
|
||||||
|
assert.ok(!wireKeys.includes("nodeId"), "no session check to fail — the field isn't the prober's to set");
|
||||||
|
assert.equal("nodeId" in (Value.Clean(probeQuizInputSchema, smuggled) as object), false, "the dock parse strips it");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the prober's tool registry carries the nodeId-less quiz, probe_done, and the learner-filesystem set", () => {
|
it("the prober's tool registry carries the nodeId-less quiz, probe_done, and the learner-filesystem set", () => {
|
||||||
@@ -151,8 +175,8 @@ describe("the prober's quiz: no nodeId field at all", () => {
|
|||||||
assert.deepEqual(Object.keys(tools).sort(), [
|
assert.deepEqual(Object.keys(tools).sort(), [
|
||||||
"ask_user_question", "find", "grep", "ls", "pdf", "probe_done", "quiz", "read", "research",
|
"ask_user_question", "find", "grep", "ls", "pdf", "probe_done", "quiz", "read", "research",
|
||||||
]);
|
]);
|
||||||
const schema = quizSchemaOf(tools);
|
const quiz = tools.quiz as AgentTool<any>;
|
||||||
assert.equal(schema.safeParse({ ...validQuiz, nodeId: "anything" }).success, true, "no session check to fail");
|
assert.equal(Value.Check(quiz.parameters, { ...validQuiz, nodeId: "anything" }), true, "no session check to fail");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// ─── tool availability (2026-09 settings seam) ───────────────────────────────
|
// ─── tool availability (2026-09 settings seam) ───────────────────────────────
|
||||||
|
|||||||
+37
-34
@@ -1,7 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* The read tool (sources v0) — a Node port of pi's read semantics:
|
* The read tool (sources v0) — a Node port of pi's read semantics:
|
||||||
* truncation with continuation offsets, image mime detection, error paths.
|
* truncation with continuation offsets, image mime detection, error paths.
|
||||||
* Plus the lh-tool.ts image-result escape hatch it exercises.
|
* Driven through the NATIVE AgentTool execute (Wave A, 2026-09-26): text
|
||||||
|
* reads return the raw string as details (the record's tool part output
|
||||||
|
* reads it); image reads return native pi content parts (text + image)
|
||||||
|
* with null details (the output-less part until the record persists).
|
||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
@@ -9,7 +12,6 @@ import * as os from "node:os";
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { readTool } from "../src/agent/tools/read.ts";
|
import { readTool } from "../src/agent/tools/read.ts";
|
||||||
import { toPiTool } from "../src/agent/lh-tool.ts";
|
|
||||||
|
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "lh-read-test-"));
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "lh-read-test-"));
|
||||||
|
|
||||||
@@ -19,16 +21,21 @@ function write(name: string, content: string): string {
|
|||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The 2-line/8-byte local config, applied the same way as the real caps. */
|
/** Native execute → the raw string body (text reads ride details verbatim). */
|
||||||
const run = (args: { path: string; offset?: number; limit?: number }) =>
|
const run = (args: { path: string; offset?: number; limit?: number }) =>
|
||||||
(readTool.execute as (i: unknown, c: { toolCallId: string }) => Promise<unknown>)!(args, {
|
readTool.execute!("test", args).then((r) => {
|
||||||
toolCallId: "test",
|
assert.ok(r.content.length >= 1);
|
||||||
|
const first = r.content[0]!;
|
||||||
|
assert.equal(first.type, "text");
|
||||||
|
return { text: (first as { type: "text"; text: string }).text, details: r.details };
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("read tool — text semantics", () => {
|
describe("read tool — text semantics", () => {
|
||||||
it("reads a small file verbatim", async () => {
|
it("reads a small file verbatim", async () => {
|
||||||
const file = write("small.txt", "hello\nworld");
|
const file = write("small.txt", "hello\nworld");
|
||||||
assert.equal(await run({ path: file }), "hello\nworld");
|
const { text, details } = await run({ path: file });
|
||||||
|
assert.equal(text, "hello\nworld");
|
||||||
|
assert.equal(details, "hello\nworld", "the record part output carries the string verbatim");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves relative paths against the learner's home (sources v1 anchoring)", async () => {
|
it("resolves relative paths against the learner's home (sources v1 anchoring)", async () => {
|
||||||
@@ -39,7 +46,7 @@ describe("read tool — text semantics", () => {
|
|||||||
const homeFile = path.join(os.homedir(), rel);
|
const homeFile = path.join(os.homedir(), rel);
|
||||||
fs.writeFileSync(homeFile, "rel", "utf-8");
|
fs.writeFileSync(homeFile, "rel", "utf-8");
|
||||||
try {
|
try {
|
||||||
assert.equal(await run({ path: rel }), "rel");
|
assert.equal((await run({ path: rel })).text, "rel");
|
||||||
} finally {
|
} finally {
|
||||||
fs.unlinkSync(homeFile);
|
fs.unlinkSync(homeFile);
|
||||||
}
|
}
|
||||||
@@ -48,7 +55,7 @@ describe("read tool — text semantics", () => {
|
|||||||
it("honors offset (1-indexed) and limit", async () => {
|
it("honors offset (1-indexed) and limit", async () => {
|
||||||
const body = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`).join("\n");
|
const body = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`).join("\n");
|
||||||
const file = write("thirty.txt", body);
|
const file = write("thirty.txt", body);
|
||||||
const out = (await run({ path: file, offset: 10, limit: 3 })) as string;
|
const out = (await run({ path: file, offset: 10, limit: 3 })).text;
|
||||||
// pi parity: the explicit limit stopping short gets a remaining notice
|
// pi parity: the explicit limit stopping short gets a remaining notice
|
||||||
assert.match(out, /^line 10\nline 11\nline 12\n\n\[18 more lines in file\. Use offset=13 to continue\.\]$/);
|
assert.match(out, /^line 10\nline 11\nline 12\n\n\[18 more lines in file\. Use offset=13 to continue\.\]$/);
|
||||||
});
|
});
|
||||||
@@ -65,7 +72,7 @@ describe("read tool — text semantics", () => {
|
|||||||
it("truncates at 2000 lines with a use-offset continuation notice", async () => {
|
it("truncates at 2000 lines with a use-offset continuation notice", async () => {
|
||||||
const body = Array.from({ length: 3000 }, (_, i) => `l${i + 1}`).join("\n");
|
const body = Array.from({ length: 3000 }, (_, i) => `l${i + 1}`).join("\n");
|
||||||
const file = write("big.txt", body);
|
const file = write("big.txt", body);
|
||||||
const out = (await run({ path: file })) as string;
|
const out = (await run({ path: file })).text;
|
||||||
assert.match(out, /Showing lines 1-2000 of 3000 \(2000 line limit\)/);
|
assert.match(out, /Showing lines 1-2000 of 3000 \(2000 line limit\)/);
|
||||||
assert.match(out, /Use offset=2001 to continue/);
|
assert.match(out, /Use offset=2001 to continue/);
|
||||||
assert.ok(!out.includes("l2001\n"), "line 2001 must not ride along");
|
assert.ok(!out.includes("l2001\n"), "line 2001 must not ride along");
|
||||||
@@ -76,7 +83,7 @@ describe("read tool — text semantics", () => {
|
|||||||
const line = "x".repeat(400);
|
const line = "x".repeat(400);
|
||||||
const body = Array.from({ length: 200 }, () => line).join("\n");
|
const body = Array.from({ length: 200 }, () => line).join("\n");
|
||||||
const file = write("wide.txt", body);
|
const file = write("wide.txt", body);
|
||||||
const out = (await run({ path: file })) as string;
|
const out = (await run({ path: file })).text;
|
||||||
assert.match(out, /50\.0KB limit\)\. Use offset=\d+ to continue\./);
|
assert.match(out, /50\.0KB limit\)\. Use offset=\d+ to continue\./);
|
||||||
// and the truncation actually cut content
|
// and the truncation actually cut content
|
||||||
assert.ok(out.length < 52_000, "the 50KB ceiling must cut the 80KB body");
|
assert.ok(out.length < 52_000, "the 50KB ceiling must cut the 80KB body");
|
||||||
@@ -85,38 +92,34 @@ describe("read tool — text semantics", () => {
|
|||||||
it("an explicit limit that stops short gets a remaining-lines notice", async () => {
|
it("an explicit limit that stops short gets a remaining-lines notice", async () => {
|
||||||
const body = Array.from({ length: 10 }, (_, i) => `l${i + 1}`).join("\n");
|
const body = Array.from({ length: 10 }, (_, i) => `l${i + 1}`).join("\n");
|
||||||
const file = write("ten.txt", body);
|
const file = write("ten.txt", body);
|
||||||
const out = (await run({ path: file, limit: 4 })) as string;
|
const out = (await run({ path: file, limit: 4 })).text;
|
||||||
assert.match(out, /\[6 more lines in file\. Use offset=5 to continue\.\]/);
|
assert.match(out, /\[6 more lines in file\. Use offset=5 to continue\.\]/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("read tool — image semantics", () => {
|
describe("read tool — image semantics (native content parts)", () => {
|
||||||
it("returns the LhToolImageResult escape-hatch shape for image files", async () => {
|
it("returns native text + image parts for image files, details null (output-less part)", async () => {
|
||||||
const file = path.join(tmp, "dot.png");
|
const file = path.join(tmp, "dot.png");
|
||||||
// minimal valid PNG header bytes are enough — detection is by extension
|
// minimal valid PNG header bytes are enough — detection is by extension
|
||||||
fs.writeFileSync(file, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
fs.writeFileSync(file, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||||
const out = (await run({ path: file })) as { text: string; images?: { data: string; mimeType: string }[] };
|
const result = await readTool.execute!("test", { path: file });
|
||||||
assert.match(out.text, /Read image file \[image\/png\]/);
|
assert.equal(result.content.length, 2);
|
||||||
assert.equal(out.images?.length, 1);
|
const parts = result.content as Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
||||||
assert.equal(out.images![0]!.mimeType, "image/png");
|
const textPart = parts[0]!;
|
||||||
assert.match(out.images![0]!.data, /^[A-Za-z0-9+/]+={0,2}$/);
|
const imagePart = parts[1]!;
|
||||||
|
assert.equal(textPart.type, "text");
|
||||||
|
assert.match(textPart.text!, /Read image file \[image\/png\]/);
|
||||||
|
assert.equal(imagePart.type, "image");
|
||||||
|
assert.equal(imagePart.mimeType, "image/png");
|
||||||
|
assert.match(imagePart.data!, /^[A-Za-z0-9+/]+={0,2}$/);
|
||||||
|
assert.equal(result.details, null, "image reads keep the record part output-less");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toPiTool maps the image result to native pi content parts", async () => {
|
it("plain string results map to a single text part (parity)", async () => {
|
||||||
const pi = toPiTool(readTool);
|
|
||||||
const result = await pi.execute("call-1", { path: path.join(tmp, "dot.png") }, undefined as never);
|
|
||||||
assert.ok(!("isError" in result) || !(result as { isError?: boolean }).isError);
|
|
||||||
const content = (result as { content: Array<{ type: string }> }).content;
|
|
||||||
assert.equal(content[0]!.type, "text");
|
|
||||||
assert.equal(content[1]!.type, "image");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("toPiTool maps plain string results to a single text part (parity)", async () => {
|
|
||||||
const file = write("plain.txt", "body");
|
const file = write("plain.txt", "body");
|
||||||
const pi = toPiTool(readTool);
|
const result = await readTool.execute!("test", { path: file });
|
||||||
const result = await pi.execute("call-2", { path: file }, undefined as never);
|
assert.equal(result.content.length, 1);
|
||||||
const content = (result as { content: Array<{ type: string }> }).content;
|
assert.equal(result.content[0]!.type, "text");
|
||||||
assert.equal(content.length, 1);
|
assert.equal((result.content[0] as { type: "text"; text: string }).text, "body");
|
||||||
assert.equal(content[0]!.type, "text");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+91
-61
@@ -1,17 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* The abort-cascade fix (2026-09-19): pi passes every run's AbortSignal to
|
* The abort-cascade fix (2026-09-19), pinned against the pi-native seams
|
||||||
* tool execute — the bridge used to drop it, so agent.abort() only landed
|
* (Wave A, 2026-09-26): serverExecute (tool-execute.ts) races every tool
|
||||||
* at the NEXT step boundary and a long-running local tool (the grep that
|
* body against the run's AbortSignal and the system-level per-call timeout
|
||||||
* outlived its turn by minutes) held the turn hostage until the dev
|
* — aborting the run kills a running tool, the timeout is the net so no
|
||||||
* server's memory restart killed the run mid-tool, freezing the record at
|
* tool can hold a turn hostage (the 2.7-minute grep lesson). Question
|
||||||
* its write-ahead state. toPiTool now threads the signal and races every
|
* tools bypass the guard entirely (questionResult is instant and carries
|
||||||
* execute against the system-level timeout; the fs tools honor it with
|
* terminate). The fs tools honor the signal with partial results
|
||||||
* partial results (fs-tools.test.ts covers their side).
|
* (fs-tools.test.ts covers their side).
|
||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { afterEach, describe, it } from "node:test";
|
import { afterEach, describe, it } from "node:test";
|
||||||
import { z } from "zod";
|
import { Type } from "typebox";
|
||||||
import { lhTool, toPiTool, toolTimeoutMs, ToolInputError } from "../src/agent/lh-tool.ts";
|
import {
|
||||||
|
questionResult,
|
||||||
|
serverExecute,
|
||||||
|
toolTimeoutMs,
|
||||||
|
ToolInputError,
|
||||||
|
toToolResult,
|
||||||
|
type ToolExecuteContext,
|
||||||
|
} from "../src/agent/tool-execute.ts";
|
||||||
|
import { askUserQuestionTool } from "../src/agent/tools.ts";
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
@@ -19,34 +27,37 @@ afterEach(() => {
|
|||||||
toolTimeoutMs.current = DEFAULT_TIMEOUT_MS;
|
toolTimeoutMs.current = DEFAULT_TIMEOUT_MS;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** A tool that captures ctx.signal, then either hangs or resolves `value`. */
|
const probeSchema = Type.Object({});
|
||||||
|
|
||||||
|
/** A native tool whose execute captures ctx.signal, then hangs or resolves. */
|
||||||
function probeTool(captured: { signal?: AbortSignal }, opts: { hang?: boolean; value?: string } = {}) {
|
function probeTool(captured: { signal?: AbortSignal }, opts: { hang?: boolean; value?: string } = {}) {
|
||||||
return lhTool({
|
return {
|
||||||
name: "probe",
|
name: "probe",
|
||||||
|
label: "probe",
|
||||||
description: "test probe",
|
description: "test probe",
|
||||||
schema: z.object({}),
|
parameters: probeSchema,
|
||||||
execute: (_input, ctx) => {
|
execute: serverExecute("probe", probeSchema, (_input: unknown, ctx: ToolExecuteContext) => {
|
||||||
captured.signal = ctx?.signal;
|
captured.signal = ctx.signal;
|
||||||
if (opts.hang) return new Promise<string>(() => {}); // never settles, no timer
|
if (opts.hang) return new Promise<string>(() => {}); // never settles, no timer
|
||||||
return opts.value ?? "ok";
|
return opts.value ?? "ok";
|
||||||
},
|
}),
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("toPiTool abort + timeout guard", () => {
|
describe("serverExecute abort + timeout guard", () => {
|
||||||
it("forwards a live abort signal to the tool's ctx", async () => {
|
it("forwards a live abort signal to the tool's ctx", async () => {
|
||||||
// Even with no parent signal, the tool still gets the guard's combined
|
// Even with no parent signal, the tool still gets the guard's combined
|
||||||
// signal (parent+timeout) — a stable reference tools can poll.
|
// signal (parent+timeout) — a stable reference tools can poll.
|
||||||
const captured: { signal?: AbortSignal } = {};
|
const captured: { signal?: AbortSignal } = {};
|
||||||
const pi = toPiTool(probeTool(captured));
|
const pi = probeTool(captured);
|
||||||
await pi.execute("c1", {}, undefined as never);
|
await pi.execute("c1", {});
|
||||||
assert.ok(captured.signal, "the tool received a signal");
|
assert.ok(captured.signal, "the tool received a signal");
|
||||||
assert.equal(captured.signal!.aborted, false);
|
assert.equal(captured.signal!.aborted, false);
|
||||||
// The forwarded signal is the guard's COMBINED one — aborting the
|
// The forwarded signal is the guard's COMBINED one — aborting the
|
||||||
// parent flips it, which is what long-running tools poll.
|
// parent flips it, which is what long-running tools poll.
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
const captured2: { signal?: AbortSignal } = {};
|
const captured2: { signal?: AbortSignal } = {};
|
||||||
const pi2 = toPiTool(probeTool(captured2, { value: "done" }));
|
const pi2 = probeTool(captured2, { value: "done" });
|
||||||
const pending = pi2.execute("c1b", {}, ac.signal);
|
const pending = pi2.execute("c1b", {}, ac.signal);
|
||||||
ac.abort();
|
ac.abort();
|
||||||
await pending;
|
await pending;
|
||||||
@@ -56,7 +67,7 @@ describe("toPiTool abort + timeout guard", () => {
|
|||||||
it("a signal-wired tool observes the run's abort", async () => {
|
it("a signal-wired tool observes the run's abort", async () => {
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
const captured: { signal?: AbortSignal } = {};
|
const captured: { signal?: AbortSignal } = {};
|
||||||
const pi = toPiTool(probeTool(captured, { hang: true }));
|
const pi = probeTool(captured, { hang: true });
|
||||||
const pending = pi.execute("c2", {}, ac.signal).catch((e: Error) => e.message);
|
const pending = pi.execute("c2", {}, ac.signal).catch((e: Error) => e.message);
|
||||||
ac.abort();
|
ac.abort();
|
||||||
assert.match((await pending) as string, /Operation aborted/);
|
assert.match((await pending) as string, /Operation aborted/);
|
||||||
@@ -65,68 +76,87 @@ describe("toPiTool abort + timeout guard", () => {
|
|||||||
|
|
||||||
it("rejects a hanging tool at the system-level timeout", async () => {
|
it("rejects a hanging tool at the system-level timeout", async () => {
|
||||||
toolTimeoutMs.current = 50;
|
toolTimeoutMs.current = 50;
|
||||||
const pi = toPiTool(probeTool({}, { hang: true }));
|
const pi = probeTool({}, { hang: true });
|
||||||
await assert.rejects(pi.execute("c3", {}, undefined as never), /timed out/);
|
await assert.rejects(pi.execute("c3", {}), /timed out/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a pre-aborted run rejects immediately with pi's wording", async () => {
|
it("a pre-aborted run rejects immediately with pi's wording", async () => {
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
ac.abort();
|
ac.abort();
|
||||||
const pi = toPiTool(probeTool({}, { hang: true }));
|
const pi = probeTool({}, { hang: true });
|
||||||
await assert.rejects(pi.execute("c4", {}, ac.signal), /Operation aborted/);
|
await assert.rejects(pi.execute("c4", {}, ac.signal), /Operation aborted/);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
it("a tool that honors the signal settles before the timeout wins", async () => {
|
describe("serverExecute: cooperative settle, mapping parity, question bypass", () => {
|
||||||
// The race resolves with the tool's own (partial) result — the fs
|
it("a cooperative tool settles partial results before the grace", async () => {
|
||||||
// tools' contract: abort yields partial results, not an error throw.
|
|
||||||
toolTimeoutMs.current = 2_000;
|
toolTimeoutMs.current = 2_000;
|
||||||
const tool = lhTool({
|
const schema = Type.Object({});
|
||||||
|
const pi = {
|
||||||
name: "partial",
|
name: "partial",
|
||||||
|
label: "partial",
|
||||||
description: "test",
|
description: "test",
|
||||||
schema: z.object({}),
|
parameters: schema,
|
||||||
execute: (_input, ctx) =>
|
execute: serverExecute("partial", schema, (_input: unknown, ctx: ToolExecuteContext) =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
// The fs-tools contract: poll upfront (a listener on an
|
// The fs-tools contract: poll upfront (a listener on an
|
||||||
// already-aborted signal never fires), else listen.
|
// already-aborted signal never fires), else listen.
|
||||||
const signal = ctx!.signal!;
|
const signal = ctx.signal!;
|
||||||
if (signal.aborted) return resolve("partial results");
|
if (signal.aborted) return resolve("partial results");
|
||||||
signal.addEventListener("abort", () => resolve("partial results"), { once: true });
|
signal.addEventListener("abort", () => resolve("partial results"), { once: true });
|
||||||
}),
|
})),
|
||||||
});
|
};
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
const pi = toPiTool(tool);
|
|
||||||
const pending = pi.execute("c5", {}, ac.signal);
|
const pending = pi.execute("c5", {}, ac.signal);
|
||||||
ac.abort();
|
ac.abort();
|
||||||
const result = (await pending) as { content: Array<{ text?: string }> };
|
const result = await pending;
|
||||||
assert.equal(result.content[0]!.text, "partial results");
|
assert.equal((result.content[0] as { text?: string }).text, "partial results");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("question tools bypass the guard — the placeholder is instant", async () => {
|
it("questionResult is instant, terminates, and carries no details", async () => {
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
ac.abort();
|
ac.abort(); // a pre-aborted run signal — the placeholder must not care
|
||||||
const tool = lhTool({
|
const result = await askUserQuestionTool.execute("c6", { question: "q" }, ac.signal);
|
||||||
name: "quiz",
|
assert.match((result.content[0] as { text?: string }).text!, /Question posed/);
|
||||||
description: "test",
|
assert.equal(result.details, null);
|
||||||
schema: z.object({}),
|
assert.equal(result.terminate, true, "owner Option A: question results end the batch natively");
|
||||||
clientQuestion: true,
|
|
||||||
});
|
|
||||||
const pi = toPiTool(tool);
|
|
||||||
const result = (await pi.execute("c6", {}, ac.signal)) as { content: Array<{ text?: string }> };
|
|
||||||
assert.match(result.content[0]!.text!, /Question posed/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validation errors pass through the guard unchanged", async () => {
|
it("authoring failures throw ToolInputError with the retryable problem list", async () => {
|
||||||
const tool = lhTool({
|
await assert.rejects(
|
||||||
name: "strict",
|
askUserQuestionTool.execute("c7", {
|
||||||
description: "test",
|
question: "q",
|
||||||
schema: z.object({ n: z.number() }),
|
options: [
|
||||||
execute: () => "never",
|
{ label: "A", value: "same" },
|
||||||
});
|
{ label: "B", value: "same" },
|
||||||
const pi = toPiTool(tool);
|
],
|
||||||
await assert.rejects(pi.execute("c7", { n: "not a number" }, undefined as never), (e: unknown) => {
|
}),
|
||||||
assert.ok(e instanceof ToolInputError);
|
(e: unknown) => {
|
||||||
assert.equal(e.toolName, "strict");
|
assert.ok(e instanceof ToolInputError);
|
||||||
return true;
|
assert.equal(e.toolName, "ask_user_question");
|
||||||
|
assert.match(e.message, /Invalid tool input for tool "ask_user_question":/);
|
||||||
|
assert.match(e.message, /- .*same value/s);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toToolResult parity: string verbatim, object as JSON, details rides the raw value", () => {
|
||||||
|
const str = toToolResult("plain");
|
||||||
|
assert.equal((str.content[0] as { text: string }).text, "plain");
|
||||||
|
assert.equal(str.details, "plain");
|
||||||
|
const obj = toToolResult({ a: 1 });
|
||||||
|
assert.equal((obj.content[0] as { text: string }).text, '{"a":1}');
|
||||||
|
assert.deepEqual(obj.details, { a: 1 });
|
||||||
|
const img = toToolResult({
|
||||||
|
text: "the picture",
|
||||||
|
images: [{ data: "AAAA", mimeType: "image/png" }],
|
||||||
|
details: { page: 2 },
|
||||||
});
|
});
|
||||||
|
assert.equal(img.content.length, 2);
|
||||||
|
assert.equal(img.content[0]!.type, "text");
|
||||||
|
assert.equal(img.content[1]!.type, "image");
|
||||||
|
assert.deepEqual(img.details, { page: 2 });
|
||||||
|
const imgNoDetails = toToolResult({ text: "t", images: [{ data: "AAAA", mimeType: "image/png" }] });
|
||||||
|
assert.equal(imgNoDetails.details, null, "image reads keep the record part output-less (null details)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
|
import { quizInputProblems } from "../src/core/question-core.ts";
|
||||||
|
import { Value } from "typebox/value";
|
||||||
import {
|
import {
|
||||||
parseAskInput,
|
parseAskInput,
|
||||||
parseProbeDoneInput,
|
parseProbeDoneInput,
|
||||||
@@ -59,15 +61,13 @@ describe("parseQuizInput", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("boundary parity: anything the TEACH schema accepts, the display parse accepts", () => {
|
it("boundary parity: anything the TEACH schema accepts, the display parse accepts", () => {
|
||||||
const boundary = quizInputSchema.safeParse(TEACHER_QUIZ);
|
assert.ok(Value.Check(quizInputSchema, TEACHER_QUIZ), "fixture must be boundary-valid for this pin to mean anything");
|
||||||
assert.ok(boundary.success, "fixture must be boundary-valid for this pin to mean anything");
|
assert.ok(parseQuizInput(TEACHER_QUIZ));
|
||||||
assert.ok(parseQuizInput(boundary.data));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("boundary parity: anything the PROBE schema accepts, the display parse accepts", () => {
|
it("boundary parity: anything the PROBE schema accepts, the display parse accepts", () => {
|
||||||
const boundary = probeQuizInputSchema.safeParse(PROBER_QUIZ);
|
assert.ok(Value.Check(probeQuizInputSchema, PROBER_QUIZ));
|
||||||
assert.ok(boundary.success);
|
assert.ok(parseQuizInput(PROBER_QUIZ));
|
||||||
assert.ok(parseQuizInput(boundary.data));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the live crash shape — options as a JSON string — does not parse", () => {
|
it("the live crash shape — options as a JSON string — does not parse", () => {
|
||||||
@@ -75,6 +75,40 @@ describe("parseQuizInput", () => {
|
|||||||
assert.equal(parseQuizInput(stringified), undefined);
|
assert.equal(parseQuizInput(stringified), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("boundary parity under Value.Convert: a NUMERIC correctAnswer parses (live-smoke lockout fix, 2026-09-26)", () => {
|
||||||
|
// The live smoke's model emitted correctAnswer: 17 (number). pi's
|
||||||
|
// boundary runs Value.Convert before Value.Check, so the quiz posed
|
||||||
|
// — but the record keeps the RAW args. A parse seam without Convert
|
||||||
|
// would reject exactly what the boundary accepted: the pending quiz
|
||||||
|
// renders nowhere while the turn has ended = learner lockout.
|
||||||
|
// Dock-visible parity case: the smoke's real emission — options whose
|
||||||
|
// values ARE the numbers, correctAnswer numeric. Convert → "17",
|
||||||
|
// refinement passes (it matches an option), the quiz parses.
|
||||||
|
const numeric = {
|
||||||
|
question: "What is 5 + 3 × 4?",
|
||||||
|
options: [
|
||||||
|
{ label: "17", value: "17" },
|
||||||
|
{ label: "27", value: "27" },
|
||||||
|
{ label: "9", value: "9" },
|
||||||
|
],
|
||||||
|
explanation: "Multiplication first.",
|
||||||
|
correctAnswer: 17,
|
||||||
|
};
|
||||||
|
const parsed = parseQuizInput(numeric);
|
||||||
|
assert.ok(parsed);
|
||||||
|
assert.equal(parsed.correctAnswer, "17", "converted to the string the boundary saw");
|
||||||
|
const numericArray = { ...numeric, multiSelect: true, correctAnswer: [17, "27"] };
|
||||||
|
const parsedArray = parseQuizInput(numericArray);
|
||||||
|
assert.ok(parsedArray);
|
||||||
|
assert.deepEqual(parsedArray.correctAnswer, ["17", "27"]);
|
||||||
|
// Non-convertible garbage still never parses (structure owns it)…
|
||||||
|
assert.equal(parseQuizInput({ ...PROBER_QUIZ, correctAnswer: { bad: true } }), undefined);
|
||||||
|
// …and Convert's widening (undefined → "") is caught by the refinement
|
||||||
|
// chain the boundary itself uses — garbage renders as a quiet fallback.
|
||||||
|
const widened = { ...PROBER_QUIZ, correctAnswer: undefined };
|
||||||
|
assert.equal(parseQuizInput(widened), undefined, "the refinement owns the reject");
|
||||||
|
});
|
||||||
|
|
||||||
it("garbage never parses: non-object, missing question, missing correctAnswer", () => {
|
it("garbage never parses: non-object, missing question, missing correctAnswer", () => {
|
||||||
assert.equal(parseQuizInput("a string"), undefined);
|
assert.equal(parseQuizInput("a string"), undefined);
|
||||||
assert.equal(parseQuizInput({ options: [] }), undefined);
|
assert.equal(parseQuizInput({ options: [] }), undefined);
|
||||||
@@ -102,9 +136,8 @@ describe("parseProbeDoneInput", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("boundary parity: anything the tool schema accepts, the display parse accepts", () => {
|
it("boundary parity: anything the tool schema accepts, the display parse accepts", () => {
|
||||||
const boundary = probeDoneInputSchema.safeParse(VALID_ASSESSMENT);
|
assert.ok(Value.Check(probeDoneInputSchema, VALID_ASSESSMENT));
|
||||||
assert.ok(boundary.success);
|
assert.ok(parseProbeDoneInput(VALID_ASSESSMENT));
|
||||||
assert.ok(parseProbeDoneInput(boundary.data));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the live crash shape — assessment STRINGIFIED — does not parse", () => {
|
it("the live crash shape — assessment STRINGIFIED — does not parse", () => {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
|
import { Type } from "typebox";
|
||||||
|
import { questionResult } from "../src/agent/tool-execute.ts";
|
||||||
import { createProberAgent, createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
import { createProberAgent, createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||||
import type { ProberToolContext } from "../src/agent/tools.ts";
|
import type { ProberToolContext } from "../src/agent/tools.ts";
|
||||||
import { jobStore } from "../src/store/jobs.ts";
|
import { jobStore } from "../src/store/jobs.ts";
|
||||||
@@ -53,7 +55,13 @@ describe("AgentBuildOptions.toolToggles (the P2 bridge)", () => {
|
|||||||
const teacher = createTeacherAgent(undefined, {
|
const teacher = createTeacherAgent(undefined, {
|
||||||
engine: fauxEngine(),
|
engine: fauxEngine(),
|
||||||
tools: {
|
tools: {
|
||||||
quiz: { name: "quiz", description: "test", clientQuestion: true, schema: z.object({}) },
|
quiz: {
|
||||||
|
name: "quiz",
|
||||||
|
label: "quiz",
|
||||||
|
description: "test",
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
execute: async () => questionResult(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
toolToggles: { quiz: false },
|
toolToggles: { quiz: false },
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-13
@@ -31,9 +31,9 @@ function stubProvider(hits: SearchHit[] = [{ title: "t", url: "https://e.com/",
|
|||||||
describe("web_search on the injected port (web.ts)", () => {
|
describe("web_search on the injected port (web.ts)", () => {
|
||||||
it("null provider → the honest unavailability string, never a crash", async () => {
|
it("null provider → the honest unavailability string, never a crash", async () => {
|
||||||
const { web_search } = createWebTools(() => null);
|
const { web_search } = createWebTools(() => null);
|
||||||
const out = await web_search.execute!({ query: "anything" }, { toolCallId: "c1" });
|
const out = await web_search!.execute!("c1", { query: "anything" });
|
||||||
assert.match(String(out), /not available: no search engine is configured/);
|
assert.match(String(out.details), /not available: no search engine is configured/);
|
||||||
assert.match(String(out), /Tavily or Brave/);
|
assert.match(String(out.details), /Tavily or Brave/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a live provider returns its hits (count defaults to 5)", async () => {
|
it("a live provider returns its hits (count defaults to 5)", async () => {
|
||||||
@@ -46,9 +46,9 @@ describe("web_search on the injected port (web.ts)", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const { web_search } = createWebTools(() => provider);
|
const { web_search } = createWebTools(() => provider);
|
||||||
const out = await web_search.execute!({ query: "sigma.js latest version" }, { toolCallId: "c1" });
|
const out = await web_search!.execute!("c1", { query: "sigma.js latest version" });
|
||||||
assert.deepEqual(seen, [{ query: "sigma.js latest version", count: 5 }]);
|
assert.deepEqual(seen, [{ query: "sigma.js latest version", count: 5 }]);
|
||||||
assert.deepEqual(out, [{ title: "sigma.js", url: "https://www.sigmajs.org/", snippet: "v3 released" }]);
|
assert.deepEqual(out.details, [{ title: "sigma.js", url: "https://www.sigmajs.org/", snippet: "v3 released" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("provider errors surface as error strings, never thrown", async () => {
|
it("provider errors surface as error strings, never thrown", async () => {
|
||||||
@@ -59,26 +59,26 @@ describe("web_search on the injected port (web.ts)", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const { web_search } = createWebTools(() => provider);
|
const { web_search } = createWebTools(() => provider);
|
||||||
const out = await web_search.execute!({ query: "q" }, { toolCallId: "c1" });
|
const out = await web_search!.execute!("c1", { query: "q" });
|
||||||
assert.match(String(out), /web_search failed: Brave search API 429: rate limited/);
|
assert.match(String(out.details), /web_search failed: Brave search API 429: rate limited/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the resolution is FRESH per call — a key appearing mid-session takes effect", async () => {
|
it("the resolution is FRESH per call — a key appearing mid-session takes effect", async () => {
|
||||||
let provider: SearchProvider | null = null;
|
let provider: SearchProvider | null = null;
|
||||||
const { web_search } = createWebTools(() => provider);
|
const { web_search } = createWebTools(() => provider);
|
||||||
const first = await web_search.execute!({ query: "q" }, { toolCallId: "c1" });
|
const first = await web_search!.execute!("c1", { query: "q" });
|
||||||
assert.match(String(first), /not available/);
|
assert.match(String(first.details), /not available/);
|
||||||
provider = stubProvider();
|
provider = stubProvider();
|
||||||
const second = await web_search.execute!({ query: "q" }, { toolCallId: "c2" });
|
const second = await web_search!.execute!("c2", { query: "q" });
|
||||||
assert.ok(Array.isArray(second), "the live provider answered on the very next call");
|
assert.ok(Array.isArray(second.details), "the live provider answered on the very next call");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("web_fetch needs no search engine — it stays independent", async () => {
|
it("web_fetch needs no search engine — it stays independent", async () => {
|
||||||
const { web_fetch } = createWebTools(() => null);
|
const { web_fetch } = createWebTools(() => null);
|
||||||
assert.ok(web_fetch, "web_fetch present with no provider");
|
assert.ok(web_fetch, "web_fetch present with no provider");
|
||||||
// (Full fetch behavior is out of scope here — no network in tests.)
|
// (Full fetch behavior is out of scope here — no network in tests.)
|
||||||
const out = await web_fetch.execute!({ url: "not a url" }, { toolCallId: "c1" });
|
const out = await web_fetch!.execute!("c1", { url: "not a url" });
|
||||||
assert.match(String(out), /web_fetch failed: invalid URL/);
|
assert.match(String(out.details), /web_fetch failed: invalid URL/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user