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:
@@ -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 { lhTool, toPiTools, type LhTool } from "../lh-tool.ts";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
import type { PiEngine } from "../models.ts";
|
||||
import { runSubagent } from "./pi-run.ts";
|
||||
import type { DemoResult } from "../../core/contracts.ts";
|
||||
@@ -77,39 +78,48 @@ interface BuilderState {
|
||||
lastResult: DemoRunResult | null;
|
||||
}
|
||||
|
||||
const writeDemoInput = Type.Object({
|
||||
html: Type.String({ description: "The complete HTML document: <!doctype html>…</html>, all JS inline." }),
|
||||
});
|
||||
|
||||
const runDemoInput = Type.Object({
|
||||
probe: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Optional JS expression evaluated in the page after settling, e.g. \"document.querySelectorAll('input').length\".",
|
||||
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export function createDemoBuilderTools(
|
||||
state: BuilderState,
|
||||
headless: HeadlessApi,
|
||||
onActivity?: (line: string) => void,
|
||||
): Record<string, LhTool<any, any>> {
|
||||
): Record<string, AgentTool<any>> {
|
||||
return {
|
||||
write_demo: lhTool({
|
||||
write_demo: {
|
||||
name: "write_demo",
|
||||
label: "write_demo",
|
||||
description:
|
||||
"Write the complete self-contained HTML document for the demo (overwrites any previous version). " +
|
||||
"Always follow with run_demo to test it headlessly.",
|
||||
schema: z.object({
|
||||
html: z.string().describe("The complete HTML document: <!doctype html>…</html>, all JS inline."),
|
||||
}),
|
||||
execute: async ({ html }) => {
|
||||
parameters: writeDemoInput,
|
||||
execute: serverExecute("write_demo", writeDemoInput, async ({ html }) => {
|
||||
if (!html || html.trim().length === 0) return "ERROR: empty document — write the full HTML.";
|
||||
state.html = html;
|
||||
onActivity?.(`write_demo: ${(html.length / 1024).toFixed(1)} KB`);
|
||||
return `Saved (${html.length} bytes). Now call run_demo to test it — never deliver untested.`;
|
||||
},
|
||||
}),
|
||||
run_demo: lhTool({
|
||||
}),
|
||||
},
|
||||
run_demo: {
|
||||
name: "run_demo",
|
||||
label: "run_demo",
|
||||
description:
|
||||
"Headlessly execute the current demo in Chrome and report console errors, uncaught errors, and failed requests. " +
|
||||
"Optionally pass a probe expression to assert on page state.",
|
||||
schema: z.object({
|
||||
probe: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional JS expression evaluated in the page after settling, e.g. \"document.querySelectorAll('input').length\"."),
|
||||
}),
|
||||
execute: async ({ probe }) => {
|
||||
parameters: runDemoInput,
|
||||
execute: serverExecute("run_demo", runDemoInput, async ({ probe }) => {
|
||||
if (!state.html) return "ERROR: nothing to run — call write_demo first.";
|
||||
const reason = headless.unavailableReason();
|
||||
if (reason) return `HEADLESS UNAVAILABLE: ${reason}`;
|
||||
@@ -127,8 +137,8 @@ export function createDemoBuilderTools(
|
||||
result.ok ? "run_demo: clean" : `run_demo: ${summarizeCounts(result)}`,
|
||||
);
|
||||
return formatRunResult(result);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,7 +200,7 @@ export async function runDemoBuilder(input: DemoBuilderInput, opts: DemoBuilderO
|
||||
|
||||
let finalText = "";
|
||||
try {
|
||||
const result = await runSubagent(toPiTools(createDemoBuilderTools(state, headless, opts.onActivity)), {
|
||||
const result = await runSubagent(Object.values(createDemoBuilderTools(state, headless, opts.onActivity)), {
|
||||
role: "demoBuilder",
|
||||
engine: opts.engine,
|
||||
systemPrompt: INSTRUCTIONS,
|
||||
|
||||
+184
-109
@@ -1,8 +1,9 @@
|
||||
import * as crypto from "node:crypto";
|
||||
import type { UIMessage } from "ai";
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { z } from "zod";
|
||||
import { lhTool, toPiTools } from "../lh-tool.ts";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { Type, type Static, type TSchema } from "typebox";
|
||||
import { toToolResult } from "../tool-execute.ts";
|
||||
import type { ModelRole } from "../../../config.ts";
|
||||
import {
|
||||
FACTORY_SETTINGS,
|
||||
@@ -45,7 +46,7 @@ import { batchRuns, batchTag, coverageOf, messageViewLines } from "../../core/co
|
||||
* The LLM stages supply only the judgment calls; the deterministic rebuild
|
||||
* in the merger owns everything that must never be mis-copied (event kinds,
|
||||
* timestamps, refs, provenance points). Both agents are single-tool-call
|
||||
* workers: their whole deliverable is one zod-validated submit call — a
|
||||
* workers: their whole deliverable is one TypeBox-validated submit call — a
|
||||
* ToolLoopAgent purely so the model can retry a schema-invalid call.
|
||||
*
|
||||
* Created via createExtractionPipeline(deps) — the composition root
|
||||
@@ -101,75 +102,170 @@ export function transcriptView(record: ChatRecord): string {
|
||||
|
||||
// ─── the extractor ──────────────────────────────────────────────────────────
|
||||
|
||||
const evidence = z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.describe("Verbatim quotes from the transcript that back this. The auditor will check every quote.");
|
||||
const evidenceSchema = Type.Array(Type.String(), {
|
||||
minItems: 1,
|
||||
description: "Verbatim quotes from the transcript that back this. The auditor will check every quote.",
|
||||
});
|
||||
|
||||
const messagesField = z
|
||||
.array(z.string())
|
||||
.describe("The message ids (from the [id] tags in the transcript) where this came up — provenance.");
|
||||
const messagesSchema = Type.Array(Type.String(), {
|
||||
description: "The message ids (from the [id] tags in the transcript) where this came up — provenance.",
|
||||
});
|
||||
|
||||
const propositionSchema = z.object({
|
||||
id: z.string().describe("Local id: \"p1\", \"p2\", … — referenced by edges and attribution."),
|
||||
name: z
|
||||
.string()
|
||||
.describe(
|
||||
"A very short label for the proposition — a few words, a compact handle like a glossary headword " +
|
||||
'(\'Will to power\', \'Divine command theory\'). Not a sentence, needn\'t be unique or ' +
|
||||
"self-contained: the statement carries the precision. This becomes the node's label in the graph.",
|
||||
),
|
||||
statement: z
|
||||
.string()
|
||||
.describe(
|
||||
"The proposition as one reasoning step — a TRUE, self-contained, world-facing statement. " +
|
||||
"Never reference the learner, the teacher, or the session: not \"the learner understands X\" but " +
|
||||
'"X". A misconception is stated as the TRUE correction ("Nietzsche did not endorse ' +
|
||||
'might-makes-right social Darwinism") — the wrongness lives in the quiz outcomes, never here.',
|
||||
),
|
||||
aliases: z.array(z.string()).describe("Verbatim phrasings this session used for the same proposition, if any."),
|
||||
messages: messagesField,
|
||||
evidence,
|
||||
understanding: z
|
||||
.enum(["solid", "shaky", "accepted", "resisted", "not yet engaged"])
|
||||
.optional()
|
||||
.describe(
|
||||
"ONE word — the learner's grasp of this proposition in THIS session, from the fixed scale " +
|
||||
const understandingSchema = Type.Optional(
|
||||
Type.Union(
|
||||
[
|
||||
Type.Literal("solid"),
|
||||
Type.Literal("shaky"),
|
||||
Type.Literal("accepted"),
|
||||
Type.Literal("resisted"),
|
||||
Type.Literal("not yet engaged"),
|
||||
],
|
||||
{
|
||||
description:
|
||||
"ONE word — the learner's grasp of this proposition in THIS session, from the fixed scale " +
|
||||
'"solid" | "shaky" | "accepted" | "resisted" | "not yet engaged". Nothing else: no numbers, ' +
|
||||
"no justification, no explanation.",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const propositionProps = {
|
||||
id: Type.String({ description: 'Local id: "p1", "p2", … — referenced by edges and attribution.' }),
|
||||
name: Type.String({
|
||||
description:
|
||||
"A very short label for the proposition — a few words, a compact handle like a glossary headword " +
|
||||
"('Will to power', 'Divine command theory'). Not a sentence, needn't be unique or " +
|
||||
"self-contained: the statement carries the precision. This becomes the node's label in the graph.",
|
||||
}),
|
||||
statement: Type.String({
|
||||
description:
|
||||
"The proposition as one reasoning step — a TRUE, self-contained, world-facing statement. " +
|
||||
'Never reference the learner, the teacher, or the session: not "the learner understands X" but ' +
|
||||
'"X". A misconception is stated as the TRUE correction ("Nietzsche did not endorse ' +
|
||||
'might-makes-right social Darwinism") — the wrongness lives in the quiz outcomes, never here.',
|
||||
}),
|
||||
aliases: Type.Array(Type.String(), {
|
||||
description: "Verbatim phrasings this session used for the same proposition, if any.",
|
||||
}),
|
||||
messages: messagesSchema,
|
||||
evidence: evidenceSchema,
|
||||
understanding: understandingSchema,
|
||||
};
|
||||
|
||||
const edgeSchema = Type.Object({
|
||||
from: Type.String({ description: "Proposition id that is followed / supported." }),
|
||||
to: Type.String({ description: "Proposition id that follows / is supported by `from`." }),
|
||||
type: Type.Union([Type.Literal("follows"), Type.Literal("supports")], {
|
||||
description: '"follows" = to is derivable from from; "supports" = from grounds to without entailing it.',
|
||||
}),
|
||||
messages: messagesSchema,
|
||||
evidence: evidenceSchema,
|
||||
});
|
||||
|
||||
// Weak-model tolerance (owner, 2026-09-25): the old zod `.default([])` /
|
||||
// `.default({ quiz: {} })` on required-but-empty fields becomes Type.Optional
|
||||
// here + a ??-fill at the submit seam (fillFindings/fillAudit below) — same
|
||||
// contract on the wire (omission tolerated, empty never means "invalid"),
|
||||
// no zod left in the agent band.
|
||||
const findingsProps = {
|
||||
propositions: Type.Array(Type.Object(propositionProps), {
|
||||
description: "Every distinct proposition the session touched — true statements, misconception-free.",
|
||||
}),
|
||||
edges: Type.Optional(
|
||||
Type.Array(edgeSchema, {
|
||||
description:
|
||||
"Relations between propositions: follows (derivable) / supports (grounds without entailing). Pass [] when there are none — never omit the field.",
|
||||
}),
|
||||
),
|
||||
attribution: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
quiz: Type.Record(Type.String(), Type.Union([Type.String(), Type.Null()]), {
|
||||
description:
|
||||
"toolCallId of EVERY quiz line → the proposition id it was really testing. Attribute RETROACTIVELY — the whole transcript is visible; map each quiz to the proposition it tests (often articulated elsewhere in the session). null when genuinely unattributable.",
|
||||
}),
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Quiz attribution — your only event judgment. Pass { quiz: {} } when the session has no quiz lines — never omit the field.",
|
||||
},
|
||||
),
|
||||
});
|
||||
),
|
||||
};
|
||||
|
||||
const edgeSchema = z.object({
|
||||
from: z.string().describe("Proposition id that is followed / supported."),
|
||||
to: z.string().describe("Proposition id that follows / is supported by `from`."),
|
||||
type: z
|
||||
.enum(["follows", "supports"])
|
||||
.describe('"follows" = to is derivable from from; "supports" = from grounds to without entailing it.'),
|
||||
messages: messagesField,
|
||||
evidence,
|
||||
});
|
||||
const findingsSchema = Type.Object(findingsProps);
|
||||
|
||||
const findingsSchema = z.object({
|
||||
propositions: z
|
||||
.array(propositionSchema)
|
||||
.describe("Every distinct proposition the session touched — true statements, misconception-free."),
|
||||
edges: z
|
||||
.array(edgeSchema)
|
||||
.describe("Relations between propositions: follows (derivable) / supports (grounds without entailing). Pass [] when there are none — never omit the field.")
|
||||
.default([]), // weak models omit required-but-empty arrays; omission means "none"
|
||||
const auditProps = {
|
||||
...findingsProps,
|
||||
propositions: Type.Array(
|
||||
Type.Object({
|
||||
...propositionProps,
|
||||
linkTo: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"The id of the EXISTING graph node this proposition IS — your identity verdict. Absent = create a new node. When unsure, omit: duplicates over wrong merges.",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
{
|
||||
description: "Every distinct proposition the session touched — true statements, misconception-free.",
|
||||
},
|
||||
),
|
||||
dropped: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Object({
|
||||
ref: Type.String({
|
||||
description: 'Proposition id, "from→to" for an edge, or attribution toolCallId.',
|
||||
}),
|
||||
reason: Type.String(),
|
||||
}),
|
||||
{
|
||||
description:
|
||||
"Everything you dropped, with the reason — the audit trail. Pass [] when you dropped nothing — never omit the field.",
|
||||
},
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
attribution: z
|
||||
.object({
|
||||
quiz: z
|
||||
.record(z.string(), z.string().nullable())
|
||||
.describe(
|
||||
"toolCallId of EVERY quiz line → the proposition id it was really testing. Attribute RETROACTIVELY — the whole transcript is visible; map each quiz to the proposition it tests (often articulated elsewhere in the session). null when genuinely unattributable.",
|
||||
),
|
||||
})
|
||||
.describe("Quiz attribution — your only event judgment. Pass { quiz: {} } when the session has no quiz lines — never omit the field.")
|
||||
.default({ quiz: {} }), // weak models omit empty objects too
|
||||
});
|
||||
const auditSchema = Type.Object(auditProps);
|
||||
|
||||
// The notes front door's shapes: everything session findings have, minus
|
||||
// quiz attribution — raw notes have no quiz tool parts, so there is
|
||||
// nothing to attribute (and no event to build: outcomes inside the text
|
||||
// surface only through understanding observations, user-decided).
|
||||
const { attribution: _omitted, ...notesFindingsProps } = findingsProps;
|
||||
const notesFindingsSchema = Type.Object(notesFindingsProps);
|
||||
const { attribution: _omitted2, ...notesAuditProps } = auditProps;
|
||||
const notesAuditSchema = Type.Object(notesAuditProps);
|
||||
type NotesFindings = Omit<SessionFindings, "attribution">;
|
||||
type NotesAuditResult = Omit<AuditResult, "attribution">;
|
||||
|
||||
// ── the weak-model ??-fills (the old zod defaults, applied at submit) ──
|
||||
|
||||
function fillFindings(raw: Static<typeof findingsSchema>): SessionFindings {
|
||||
return {
|
||||
propositions: raw.propositions,
|
||||
edges: raw.edges ?? [],
|
||||
attribution: raw.attribution ?? { quiz: {} },
|
||||
};
|
||||
}
|
||||
|
||||
function fillNotesFindings(raw: Static<typeof notesFindingsSchema>): NotesFindings {
|
||||
return { propositions: raw.propositions, edges: raw.edges ?? [] };
|
||||
}
|
||||
|
||||
function fillAudit(raw: Static<typeof auditSchema>): AuditResult {
|
||||
return {
|
||||
propositions: raw.propositions,
|
||||
edges: raw.edges ?? [],
|
||||
attribution: raw.attribution ?? { quiz: {} },
|
||||
dropped: raw.dropped ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function fillNotesAudit(raw: Static<typeof notesAuditSchema>): NotesAuditResult {
|
||||
return { propositions: raw.propositions, edges: raw.edges ?? [], dropped: raw.dropped ?? [] };
|
||||
}
|
||||
|
||||
const NOTES_EXTRACTOR_INSTRUCTIONS = `You are the extraction specialist in a learning application. You read ONE source —
|
||||
the user's own NOTES, in an ARBITRARY format (personal notes, an exported conversation, a document — anything) — and
|
||||
@@ -260,15 +356,19 @@ are no quizzes. NEVER omit a field because it is empty.
|
||||
Your entire deliverable is ONE submit_findings call.`;
|
||||
|
||||
/** One single-submit agent run — the shared shape of EVERY LLM stage
|
||||
* here (session + notes): the whole deliverable is ONE zod-validated
|
||||
* here (session + notes): the whole deliverable is ONE TypeBox-validated
|
||||
* submit call (a pi agent purely so the model can retry a schema-invalid
|
||||
* call). The submitted value is captured from the execute seam, which
|
||||
* only fires on a schema-valid call. */
|
||||
* only fires on a schema-valid call — the ??-fill (the old zod defaults)
|
||||
* applies at the capture. */
|
||||
async function runSubmitAgent<T>(opts: {
|
||||
role: ModelRole;
|
||||
toolName: string;
|
||||
toolDescription: string;
|
||||
schema: z.ZodType<T>;
|
||||
parameters: TSchema;
|
||||
/** Weak-model tolerance: the old zod `.default()`s, applied to the
|
||||
* schema-valid raw input at the submit seam. */
|
||||
fill: (input: any) => T;
|
||||
systemPrompt: string;
|
||||
prompt: string;
|
||||
engine: PiEngine | undefined;
|
||||
@@ -278,17 +378,18 @@ async function runSubmitAgent<T>(opts: {
|
||||
}): Promise<T> {
|
||||
let submitted: T | undefined;
|
||||
const result = await runSubagent(
|
||||
toPiTools({
|
||||
[opts.toolName]: lhTool({
|
||||
[
|
||||
{
|
||||
name: opts.toolName,
|
||||
label: opts.toolName,
|
||||
description: opts.toolDescription,
|
||||
schema: opts.schema,
|
||||
execute: async (findings) => {
|
||||
submitted = findings;
|
||||
return findings;
|
||||
parameters: opts.parameters,
|
||||
execute: async (_toolCallId, input) => {
|
||||
submitted = opts.fill(input);
|
||||
return toToolResult(submitted);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
} satisfies AgentTool<any>,
|
||||
],
|
||||
{
|
||||
role: opts.role,
|
||||
engine: opts.engine,
|
||||
@@ -326,7 +427,8 @@ function runExtractor(
|
||||
role: "extractor",
|
||||
toolName: "submit_findings",
|
||||
toolDescription: "Submit the session findings.",
|
||||
schema: findingsSchema,
|
||||
parameters: findingsSchema,
|
||||
fill: fillFindings,
|
||||
systemPrompt: EXTRACTOR_INSTRUCTIONS,
|
||||
prompt,
|
||||
engine,
|
||||
@@ -348,7 +450,8 @@ function runNotesExtractor(
|
||||
role: "extractor",
|
||||
toolName: "submit_findings",
|
||||
toolDescription: "Submit the notes findings.",
|
||||
schema: notesFindingsSchema,
|
||||
parameters: notesFindingsSchema,
|
||||
fill: fillNotesFindings,
|
||||
systemPrompt: NOTES_EXTRACTOR_INSTRUCTIONS,
|
||||
prompt,
|
||||
engine,
|
||||
@@ -360,36 +463,6 @@ function runNotesExtractor(
|
||||
|
||||
// ─── the auditor ───────────────────────────────────────────────────────────
|
||||
|
||||
const auditSchema = findingsSchema.extend({
|
||||
propositions: z.array(
|
||||
propositionSchema.extend({
|
||||
linkTo: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"The id of the EXISTING graph node this proposition IS — your identity verdict. Absent = create a new node. When unsure, omit: duplicates over wrong merges.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
dropped: z
|
||||
.array(
|
||||
z.object({
|
||||
ref: z.string().describe("Proposition id, \"from→to\" for an edge, or attribution toolCallId."),
|
||||
reason: z.string(),
|
||||
}),
|
||||
)
|
||||
.describe("Everything you dropped, with the reason — the audit trail. Pass [] when you dropped nothing — never omit the field.")
|
||||
.default([]), // same weak-model tolerance as findings.edges
|
||||
});
|
||||
|
||||
// The notes front door's shapes: everything session findings have, minus
|
||||
// quiz attribution — raw notes have no quiz tool parts, so there is
|
||||
// nothing to attribute (and no event to build: outcomes inside the text
|
||||
// surface only through understanding observations, user-decided).
|
||||
const notesFindingsSchema = findingsSchema.omit({ attribution: true });
|
||||
const notesAuditSchema = auditSchema.omit({ attribution: true });
|
||||
type NotesFindings = Omit<SessionFindings, "attribution">;
|
||||
type NotesAuditResult = Omit<AuditResult, "attribution">;
|
||||
|
||||
const AUDITOR_INSTRUCTIONS = `You are the audit stage of a learning application's extraction pipeline. You receive
|
||||
ONE source — a session transcript, or the author's personal notes in an ARBITRARY format (the notes front door) — a
|
||||
@@ -453,7 +526,8 @@ function runAuditor(
|
||||
role: "auditor",
|
||||
toolName: "submit_review",
|
||||
toolDescription: "Submit the verified findings subset (with identity verdicts) + the dropped list.",
|
||||
schema: auditSchema,
|
||||
parameters: auditSchema,
|
||||
fill: fillAudit,
|
||||
systemPrompt: AUDITOR_INSTRUCTIONS,
|
||||
prompt,
|
||||
engine,
|
||||
@@ -475,7 +549,8 @@ function runNotesAuditor(
|
||||
role: "auditor",
|
||||
toolName: "submit_review",
|
||||
toolDescription: "Submit the verified findings subset (with identity verdicts) + the dropped list.",
|
||||
schema: notesAuditSchema,
|
||||
parameters: notesAuditSchema,
|
||||
fill: fillNotesAudit,
|
||||
systemPrompt: AUDITOR_INSTRUCTIONS,
|
||||
prompt,
|
||||
engine,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AssistantMessage, Message, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
|
||||
import { z } from "zod";
|
||||
import { lhTool, piToolDeclarations, type LhTool } from "../lh-tool.ts";
|
||||
import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai";
|
||||
import { Type } from "typebox";
|
||||
import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts";
|
||||
import type { PdfResult, PdfToolInput } from "../../core/contracts.ts";
|
||||
import { getHeadless, type HeadlessApi } from "./headless.ts";
|
||||
@@ -70,79 +69,75 @@ export interface PdfReaderOptions {
|
||||
|
||||
export interface PdfReaderInput extends PdfToolInput {}
|
||||
|
||||
export function createPdfTools(
|
||||
pdfPath: string,
|
||||
): {
|
||||
pdf_info: LhTool<any, any>;
|
||||
pdf_extract: LhTool<any, any>;
|
||||
pdf_render: LhTool<any, any>;
|
||||
pdf_search: LhTool<any, any>;
|
||||
} {
|
||||
/** The wire declarations (pi-ai Tool, TypeBox parameters). This loop owns
|
||||
* execution itself — pdf_info/pdf_extract run via the plain pdfInfoText/
|
||||
* pdfExtractText bodies below, pdf_search/pdf_render dispatch inline in
|
||||
* the loop (search hits must join the citation set; renders feed the
|
||||
* image-in-the-loop message) — so the declarations carry no execute.
|
||||
* dpi/context_lines/literal carry the old zod `.default()`s as schema
|
||||
* descriptions + loop-side ??-fills (the loop reads them via numOr). */
|
||||
export function createPdfTools(_pdfPath: string): Record<string, Tool> {
|
||||
return {
|
||||
pdf_info: lhTool({
|
||||
pdf_info: {
|
||||
name: "pdf_info",
|
||||
description: "Metadata + per-page triage signals for the PDF (page count, text length, image count, math density). ALWAYS call this first.",
|
||||
schema: z.object({}),
|
||||
execute: async () => {
|
||||
const info = await pdfInfo(pdfPath);
|
||||
const lines = [
|
||||
`File: ${info.path}`,
|
||||
`Pages: ${info.page_count} (mean text length/page: ${info.mean_text_length})`,
|
||||
...info.pages.map(
|
||||
(p) =>
|
||||
`p${p.page}: len=${p.text_length} img=${p.image_count} math=${p.math_density.toFixed(2)}`,
|
||||
),
|
||||
];
|
||||
return lines.join("\n");
|
||||
},
|
||||
}),
|
||||
pdf_extract: lhTool({
|
||||
parameters: Type.Object({}),
|
||||
},
|
||||
pdf_extract: {
|
||||
name: "pdf_extract",
|
||||
description: "Extract text from pages (spec: 'all', '1-5', '1,3,7', '3'). Use pdf_info first; narrow the spec for big documents.",
|
||||
schema: z.object({ pages: z.string().describe("Page spec, e.g. 'all', '1-5', '1,3,7'") }),
|
||||
execute: async ({ pages }) => {
|
||||
const text = await pdfExtract(pdfPath, pages);
|
||||
if (text.length > EXTRACT_CAP) {
|
||||
return `${text.slice(0, EXTRACT_CAP)}\n\n[Output truncated at ${EXTRACT_CAP} chars — narrow the page spec and extract in smaller batches.]`;
|
||||
}
|
||||
return text;
|
||||
},
|
||||
}),
|
||||
pdf_render: lhTool({
|
||||
parameters: Type.Object({
|
||||
pages: Type.String({ description: "Page spec, e.g. 'all', '1-5', '1,3,7'" }),
|
||||
}),
|
||||
},
|
||||
pdf_render: {
|
||||
name: "pdf_render",
|
||||
description:
|
||||
"Render pages to images and show them to you for visual reading (equations, diagrams, layout). DPI: 100 text, 150 default, 200 dense small print. Max 6 pages per call.",
|
||||
schema: z.object({
|
||||
pages: z.string().describe("Page spec, e.g. '1-3' or '2,5,8'"),
|
||||
dpi: z.number().int().min(72).max(300).default(150).describe("Render DPI (100 text / 150 default / 200 dense)"),
|
||||
parameters: Type.Object({
|
||||
pages: Type.String({ description: "Page spec, e.g. '1-3' or '2,5,8'" }),
|
||||
dpi: Type.Optional(
|
||||
Type.Integer({ minimum: 72, maximum: 300, description: "Render DPI (100 text / 150 default / 200 dense)" }),
|
||||
),
|
||||
}),
|
||||
execute: async () => {
|
||||
// Actual rendering happens OUTSIDE execute — the loop appends the
|
||||
// images as a user message after the tool results (the
|
||||
// image-in-the-loop idiom). execute returns the plan the loop uses.
|
||||
return "render requested";
|
||||
},
|
||||
}),
|
||||
pdf_search: lhTool({
|
||||
},
|
||||
pdf_search: {
|
||||
name: "pdf_search",
|
||||
description: "Search the PDF's text by keyword or regex (case-insensitive); returns hits with context lines. Use for long documents before reading anything at length.",
|
||||
schema: z.object({
|
||||
query: z.string().describe("Keyword or regex"),
|
||||
context_lines: z.number().int().min(0).max(8).default(3).describe("Context lines around each hit"),
|
||||
literal: z.boolean().default(false).describe("true = treat the query literally (no regex)"),
|
||||
parameters: Type.Object({
|
||||
query: Type.String({ description: "Keyword or regex" }),
|
||||
context_lines: Type.Optional(
|
||||
Type.Integer({ minimum: 0, maximum: 8, description: "Context lines around each hit (default 3)" }),
|
||||
),
|
||||
literal: Type.Optional(Type.Boolean({ description: "true = treat the query literally (no regex; default false)" })),
|
||||
}),
|
||||
execute: async ({ query, context_lines, literal }) => {
|
||||
const { hits } = await pdfSearch(pdfPath, query, { contextLines: context_lines, literal });
|
||||
if (hits.length === 0) return "No hits.";
|
||||
const cap = hits.slice(0, 40);
|
||||
const body = cap.map((h: PdfSearchHit) => `=== Page ${h.page}, line ${h.line} ===\n${h.context}`).join("\n");
|
||||
const more = hits.length > cap.length ? `\n\n[${hits.length - cap.length} more hits — narrow the query.]` : "";
|
||||
return body + more;
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** pdf_info's body (the loop's dispatch target): the triage readout. */
|
||||
async function pdfInfoText(pdfPath: string): Promise<string> {
|
||||
const info = await pdfInfo(pdfPath);
|
||||
const lines = [
|
||||
`File: ${info.path}`,
|
||||
`Pages: ${info.page_count} (mean text length/page: ${info.mean_text_length})`,
|
||||
...info.pages.map(
|
||||
(p) =>
|
||||
`p${p.page}: len=${p.text_length} img=${p.image_count} math=${p.math_density.toFixed(2)}`,
|
||||
),
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** pdf_extract's body: capped text extraction with the narrow-the-spec notice. */
|
||||
async function pdfExtractText(pdfPath: string, pages: string): Promise<string> {
|
||||
const text = await pdfExtract(pdfPath, pages);
|
||||
if (text.length > EXTRACT_CAP) {
|
||||
return `${text.slice(0, EXTRACT_CAP)}\n\n[Output truncated at ${EXTRACT_CAP} chars — narrow the page spec and extract in smaller batches.]`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function toolText(toolCallId: string, toolName: string, value: string): ToolResultMessage {
|
||||
return {
|
||||
role: "toolResult",
|
||||
@@ -179,7 +174,7 @@ export async function runPdfReaderAgent(
|
||||
// doctrine degrades to extract+search (render calls return an error note).
|
||||
|
||||
const tools = createPdfTools(pdfPath);
|
||||
const toolDeclarations = piToolDeclarations(tools);
|
||||
const toolDeclarations = Object.values(tools);
|
||||
const engine = opts.engine ?? piEngineFor("pdfReader");
|
||||
// BYOK (owner, 2026-09-22): the level rides the injected engine — no
|
||||
// factory pin to consult (engine.level is the resolved/clamped value).
|
||||
@@ -242,12 +237,12 @@ export async function runPdfReaderAgent(
|
||||
try {
|
||||
if (call.name === "pdf_info") {
|
||||
opts.onActivity?.("pdf_info: triaging");
|
||||
const r = await tools.pdf_info.execute!({}, { toolCallId: call.id });
|
||||
const r = await pdfInfoText(pdfPath);
|
||||
toolResults.push(toolText(call.id, call.name, String(r)));
|
||||
} else if (call.name === "pdf_extract") {
|
||||
const spec = String(args.pages ?? "all");
|
||||
opts.onActivity?.(`pdf_extract: ${spec}`);
|
||||
const r = await tools.pdf_extract.execute!({ pages: spec }, { toolCallId: call.id });
|
||||
const r = await pdfExtractText(pdfPath, spec);
|
||||
toolResults.push(toolText(call.id, call.name, String(r)));
|
||||
for (const p of specNumbers(spec, Infinity)) {
|
||||
consulted.add(p);
|
||||
@@ -393,7 +388,7 @@ async function streamOnce(
|
||||
engine: PiEngine,
|
||||
systemPrompt: string,
|
||||
messages: Message[],
|
||||
tools: ReturnType<typeof piToolDeclarations>,
|
||||
tools: Tool[],
|
||||
reasoning: ThinkingLevel | undefined,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<AssistantMessage> {
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { createWebTools } from "../tools/web.ts";
|
||||
import type { SearchProvider } from "../../core/search.ts";
|
||||
import { toPiTools } from "../lh-tool.ts";
|
||||
import type { PiEngine } from "../models.ts";
|
||||
import { runSubagent } from "./pi-run.ts";
|
||||
|
||||
@@ -80,7 +79,7 @@ export interface ResearcherRunOptions {
|
||||
* the loop scripted.
|
||||
*/
|
||||
export async function runResearcherAgent(prompt: string, opts: ResearcherRunOptions = {}): Promise<string> {
|
||||
const { text } = await runSubagent(toPiTools(createWebTools(opts.search ?? (() => null))) as AgentTool<any, any>[], {
|
||||
const { text } = await runSubagent(Object.values(createWebTools(opts.search ?? (() => null))), {
|
||||
role: "researcher",
|
||||
prompt,
|
||||
engine: opts.engine,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AssistantMessage, Message, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
|
||||
import { z } from "zod";
|
||||
import { lhTool, piToolDeclarations, type LhTool } from "../lh-tool.ts";
|
||||
import type { AssistantMessage, Message, Tool, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
|
||||
import { Type } from "typebox";
|
||||
import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts";
|
||||
import type { VisualResult } from "../../core/contracts.ts";
|
||||
import { getHeadless, type HeadlessApi } from "./headless.ts";
|
||||
@@ -88,29 +87,32 @@ Your FINAL message: 1-2 sentences of notes for the teacher (what the figure show
|
||||
NEVER the SVG source itself. If you genuinely cannot make a correct picture of the brief, say exactly
|
||||
that in your final message and why.`;
|
||||
|
||||
export function createVisualTools(): Record<string, LhTool<any, any>> {
|
||||
/** The wire declarations (pi-ai Tool, TypeBox parameters): this loop owns
|
||||
* execution itself — write/edit dispatch inline below, render feeds the
|
||||
* image-in-the-loop message — so the declarations carry no execute. */
|
||||
export function createVisualTools(): Record<string, Tool> {
|
||||
return {
|
||||
write_svg: lhTool({
|
||||
write_svg: {
|
||||
name: "write_svg",
|
||||
description: "Write the complete SVG source for the figure (overwrites any previous version).",
|
||||
schema: z.object({
|
||||
svg: z.string().describe('Complete <svg xmlns="…">…</svg> markup.'),
|
||||
parameters: Type.Object({
|
||||
svg: Type.String({ description: 'Complete <svg xmlns="…">…</svg> markup.' }),
|
||||
}),
|
||||
}),
|
||||
edit_svg: lhTool({
|
||||
},
|
||||
edit_svg: {
|
||||
name: "edit_svg",
|
||||
description:
|
||||
"Replace one exact spot in the current SVG: old_text must appear EXACTLY ONCE and is replaced by new_text. Call write_svg first. For larger changes, write_svg the whole document.",
|
||||
schema: z.object({
|
||||
old_text: z.string().describe("The exact text to replace — must match exactly once in the current SVG."),
|
||||
new_text: z.string().describe("The replacement text."),
|
||||
parameters: Type.Object({
|
||||
old_text: Type.String({ description: "The exact text to replace — must match exactly once in the current SVG." }),
|
||||
new_text: Type.String({ description: "The replacement text." }),
|
||||
}),
|
||||
}),
|
||||
render_svg: lhTool({
|
||||
},
|
||||
render_svg: {
|
||||
name: "render_svg",
|
||||
description: "Render the current SVG to a PNG and show it to you for inspection. No arguments.",
|
||||
schema: z.object({}),
|
||||
}),
|
||||
parameters: Type.Object({}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,7 +132,7 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
|
||||
}
|
||||
|
||||
const tools = createVisualTools();
|
||||
const toolDeclarations = piToolDeclarations(tools);
|
||||
const toolDeclarations = Object.values(tools);
|
||||
const engine = opts.engine ?? piEngineFor("visualMaker");
|
||||
// BYOK (owner, 2026-09-22): the level rides the injected engine — no
|
||||
// factory pin to consult (engine.level is the resolved/clamped value).
|
||||
@@ -301,7 +303,7 @@ async function streamOnce(
|
||||
engine: PiEngine,
|
||||
systemPrompt: string,
|
||||
messages: Message[],
|
||||
tools: ReturnType<typeof piToolDeclarations>,
|
||||
tools: Tool[],
|
||||
reasoning: ThinkingLevel | undefined,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<AssistantMessage> {
|
||||
|
||||
+46
-34
@@ -1,5 +1,6 @@
|
||||
import { Agent } from "@earendil-works/pi-agent-core";
|
||||
import type { AgentEvent, AgentMessage, AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { questionResult, ToolInputError } from "./tool-execute.ts";
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { PATHS } from "../../config.ts";
|
||||
import { piEngineFor, type PiEngine } from "./models.ts";
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
type ProberToolContext,
|
||||
type TeacherToolContext,
|
||||
} from "./tools.ts";
|
||||
import { toPiTools, type LhTool } from "./lh-tool.ts";
|
||||
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
|
||||
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
|
||||
|
||||
@@ -93,11 +93,9 @@ export interface AgentBuildOptions {
|
||||
messages?: AgentMessage[];
|
||||
/** Money channel: one call per model request. */
|
||||
onUsage?: StepUsageCollector;
|
||||
/** Safety net (the old stepCountIs). Default 20 turns. */
|
||||
maxTurns?: number;
|
||||
/** Tests may pass a bare tool registry to drive deterministic flows.
|
||||
* An explicit registry is NEVER toggle-filtered — the caller owns it. */
|
||||
tools?: Record<string, LhTool<any, any>>;
|
||||
tools?: Record<string, AgentTool<any>>;
|
||||
/** The resolved per-user tool toggles (core/pending EnabledTools — the
|
||||
* 2026-09-19 P2 bridge: Platform's chat-handler passes
|
||||
* effective.agent.tools; the factories drop any tool explicitly false.
|
||||
@@ -113,22 +111,29 @@ export interface AgentBuildOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn's stop rule (docs/pi-engine-swap.md §2):
|
||||
* - a question tool call (quiz / ask_user_question) that RESOLVED — its
|
||||
* synthetic "question posed" result is not an error — ends the run: the
|
||||
* learner's answer arrives as the next POST. A call that FAILED authoring
|
||||
* validation (error result) must NOT stop the run — the model retries
|
||||
* within the same turn, exactly the InvalidToolInputError semantics.
|
||||
* - the maxTurns safety net replaces stepCountIs(20).
|
||||
* The turn's question-stop rule (docs/pi-engine-swap.md §2; Wave A
|
||||
* 2026-09-26, owner Option A): a question tool call (quiz /
|
||||
* ask_user_question) that RESOLVED — its synthetic "question posed" result
|
||||
* is not an error — ends the run: the learner's answer arrives as the next
|
||||
* POST. A call that FAILED authoring validation (error result) must NOT
|
||||
* stop the run — the model retries within the same turn, exactly the old
|
||||
* InvalidToolInputError semantics.
|
||||
*
|
||||
* Question results also carry terminate: true (tool-execute's
|
||||
* questionResult), so single-question batches stop natively at the pi
|
||||
* loop's own batch check; this shouldStopAfterTurn hook is the
|
||||
* belt-and-suspenders HALF for the mixed batch (a question + other calls
|
||||
* in one message) that terminate-only semantics would let run on — the
|
||||
* learner cannot answer while a turn is in flight (per-chat 409), so a
|
||||
* cleanly-posed question MUST always end the turn. maxTurns is gone
|
||||
* (owner ruling 2026-09-25: no maximum-agent-turns net on the teaching
|
||||
* turn).
|
||||
*/
|
||||
function makeStopper(maxTurns: number) {
|
||||
let turns = 0;
|
||||
function makeQuestionStopper() {
|
||||
return (context: {
|
||||
message: { content: Array<{ type: string; id?: string; name?: string }> };
|
||||
toolResults: Array<{ toolCallId: string; isError?: boolean }>;
|
||||
}): boolean => {
|
||||
turns++;
|
||||
if (turns >= maxTurns) return true;
|
||||
const called = new Set(
|
||||
context.message.content
|
||||
.filter((c) => c.type === "toolCall" && c.name !== undefined && (QUESTION_TOOLS as readonly string[]).includes(c.name!))
|
||||
@@ -145,7 +150,7 @@ function makeStopper(maxTurns: number) {
|
||||
|
||||
function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: AgentTool<any, any>[]) {
|
||||
const engine = opts.engine ?? piEngineFor(kind);
|
||||
const stopper = makeStopper(opts.maxTurns ?? 20);
|
||||
const stopper = makeQuestionStopper();
|
||||
const systemPrompt = opts.promptSwitches
|
||||
? composeInstructions(kind, opts.promptSwitches)
|
||||
: composedInstructions[kind]!;
|
||||
@@ -181,7 +186,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
||||
* handler keys teacher construction on.
|
||||
*/
|
||||
export function createProberAgent(ctx: ProberToolContext, opts: AgentBuildOptions = {}) {
|
||||
return baseAgentOptions("prober", opts, toPiTools(createProberTools(ctx, opts.toolToggles)));
|
||||
return baseAgentOptions("prober", opts, Object.values(createProberTools(ctx, opts.toolToggles)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,26 +202,33 @@ export function createTeacherAgent(
|
||||
ctx?: TeacherToolContext,
|
||||
opts: AgentBuildOptions = {},
|
||||
) {
|
||||
const tools =
|
||||
opts.tools ?? (ctx ? createTeacherTools(ctx, opts.toolToggles) : bareQuestionTools());
|
||||
return baseAgentOptions("teacher", opts, toPiTools(tools));
|
||||
const tools = opts.tools ?? (ctx ? createTeacherTools(ctx, opts.toolToggles) : bareQuestionTools());
|
||||
return baseAgentOptions("teacher", opts, Object.values(tools));
|
||||
}
|
||||
|
||||
/** The validation-free teach question set (tests, bare agents): quiz with
|
||||
* the shared authoring refinement but NO per-session nodeId check (no
|
||||
* record to read), plus ask. */
|
||||
export function bareQuestionTools(): Record<string, LhTool<any, any>> {
|
||||
return {
|
||||
quiz: {
|
||||
name: "quiz",
|
||||
description: "Ask the learner a graded question.",
|
||||
clientQuestion: true,
|
||||
schema: quizInputObject.superRefine((input, add) => {
|
||||
for (const problem of quizInputProblems(input)) {
|
||||
add.addIssue({ code: "custom", message: problem });
|
||||
}
|
||||
}),
|
||||
/** The bare teach question set (tests, bare agents): quiz with the shared
|
||||
* authoring refinement (quizInputProblems, run in execute — throws
|
||||
* ToolInputError, which pi's loop converts to the error tool result the
|
||||
* model retries on) but NO per-session nodeId check (no record to read),
|
||||
* plus ask. */
|
||||
export function bareQuestionTools(): Record<string, AgentTool<any>> {
|
||||
// A typed const (not an inline literal): the specific AgentTool typing
|
||||
// gives execute its precise Static input; heterogeneous records take
|
||||
// AgentTool<any>, whose loose Static would smudge it to unknown.
|
||||
const quiz: AgentTool<typeof quizInputObject> = {
|
||||
name: "quiz",
|
||||
label: "quiz",
|
||||
description: "Ask the learner a graded question.",
|
||||
parameters: quizInputObject,
|
||||
executionMode: "sequential",
|
||||
execute: async (_toolCallId, input) => {
|
||||
const problems = quizInputProblems(input);
|
||||
if (problems.length > 0) throw new ToolInputError("quiz", problems);
|
||||
return questionResult();
|
||||
},
|
||||
};
|
||||
return {
|
||||
quiz,
|
||||
ask_user_question: askUserQuestionTool,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
askInputSchema,
|
||||
askOptionProblems,
|
||||
probeQuizInputSchema,
|
||||
quizInputObject,
|
||||
quizInputProblems,
|
||||
@@ -25,14 +28,22 @@ export type { ProbeDoneContext } from "./tools/probe.ts";
|
||||
* The agent-split tool composition (docs/probe-teach-split.md §1): the
|
||||
* PROBER and the TEACHER are different agents with different tool sets.
|
||||
* The question tools share a NAME but are genuinely DIFFERENT tools —
|
||||
* the prober's quiz has NO nodeId field at all (its schema is built from
|
||||
* quizInputBase; zod strips the key if the model tries anyway), the
|
||||
* the prober's quiz has NO nodeId field at all (its declaration is built
|
||||
* from quizInputBase; the model's schema never mentions nodeId), the
|
||||
* teacher's carries the validated position field.
|
||||
*
|
||||
* - Question tools (quiz / ask_user_question): CLIENT-SIDE (no `execute`).
|
||||
* The call streams to the browser in `input-available` state; the
|
||||
* learner answers in the dock; the client grades and sends the verdict
|
||||
* with addToolOutput. Input schemas live in core/question-core.
|
||||
* - Question tools (quiz / ask_user_question): CLIENT-ANSWERED. Their
|
||||
* execute validates the authoring refinements (the cross-field rules no
|
||||
* schema carries — quizInputProblems / askOptionProblems; a failure
|
||||
* throws ToolInputError, which pi's loop converts to an error tool
|
||||
* result the model retries on), then returns the synthetic
|
||||
* "question posed" placeholder (questionResult: terminate: true — the
|
||||
* turn ends; the call streams to the browser in `input-available`
|
||||
* state; the learner answers in the dock; the client grades and sends
|
||||
* the verdict with addToolOutput). Input schemas live in
|
||||
* core/question-core. Question tools opt into SEQUENTIAL execution so
|
||||
* parallel calls resolve in the model's call order (the dock's subject
|
||||
* can never flip mid-turn).
|
||||
* - probe_done (src/agent/tools/probe.ts): SERVER-SIDE seam tool.
|
||||
* - planTools (src/agent/tools/plan.ts): SERVER-SIDE — author-once
|
||||
* plan_init. Teacher only.
|
||||
@@ -40,22 +51,28 @@ export type { ProbeDoneContext } from "./tools/probe.ts";
|
||||
* only.
|
||||
*/
|
||||
|
||||
export const askUserQuestionTool: LhTool<any, any> = {
|
||||
export const askUserQuestionTool: AgentTool<typeof askInputSchema> = {
|
||||
name: "ask_user_question",
|
||||
clientQuestion: true,
|
||||
label: "ask_user_question",
|
||||
description:
|
||||
"Ask the learner a single question and pause until they answer. NOT graded — there is no correct answer. " +
|
||||
"Use when requirements are ambiguous, preferences are needed, a decision would materially affect the lesson, " +
|
||||
"or you need an open-ended response. ONE question per turn: call a single ask_user_question, then end your " +
|
||||
"turn — his answer determines the next.",
|
||||
schema: askInputSchema,
|
||||
parameters: askInputSchema,
|
||||
executionMode: "sequential",
|
||||
execute: async (_toolCallId, input) => {
|
||||
const problems = askOptionProblems(input.options ?? []);
|
||||
if (problems.length > 0) throw new ToolInputError("ask_user_question", problems);
|
||||
return questionResult();
|
||||
},
|
||||
};
|
||||
|
||||
// ─── the prober's question tools (no nodeId — no map exists yet) ─────────────
|
||||
|
||||
export const probeQuizTool: LhTool<any, any> = {
|
||||
export const probeQuizTool: AgentTool<typeof probeQuizInputSchema> = {
|
||||
name: "quiz",
|
||||
clientQuestion: true,
|
||||
label: "quiz",
|
||||
description:
|
||||
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
|
||||
"Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " +
|
||||
@@ -67,7 +84,13 @@ export const probeQuizTool: LhTool<any, any> = {
|
||||
"free-text note to ANY answer; it reaches you only when non-empty. No free-text answers — for non-graded " +
|
||||
"questions use ask_user_question instead. ONE question per turn: call a single quiz, then end your turn — " +
|
||||
"his answer determines the next question to ask.",
|
||||
schema: probeQuizInputSchema,
|
||||
parameters: probeQuizInputSchema,
|
||||
executionMode: "sequential",
|
||||
execute: async (_toolCallId, input) => {
|
||||
const problems = quizInputProblems(input);
|
||||
if (problems.length > 0) throw new ToolInputError("quiz", problems);
|
||||
return questionResult();
|
||||
},
|
||||
};
|
||||
|
||||
/** The prober's registry context: the probe's own needs (probe_done —
|
||||
@@ -98,7 +121,7 @@ export interface ProberToolContext extends ProbeDoneContext {
|
||||
export function createProberTools(
|
||||
ctx: ProberToolContext,
|
||||
enabled?: EnabledTools,
|
||||
): Record<string, LhTool<any, any>> {
|
||||
): Record<string, AgentTool<any>> {
|
||||
// probe_done is re-added AFTER the filter, always-on by construction — it
|
||||
// is how the phase ENDS; a prober that can never conclude is a lockout.
|
||||
const done = probeDoneTool(ctx);
|
||||
@@ -146,36 +169,31 @@ export function createProberTools(
|
||||
|
||||
// ─── the teacher's question tools (quiz carries the position field) ──────────
|
||||
|
||||
/** The nodeId-against-live-tree check the teacher's quiz chains after the
|
||||
* shared authoring contract (the old superRefine, verbatim). */
|
||||
function nodeIdProblem(ctx: Pick<PlanToolContext, "chatId" | "chatStore">, nodeId: string): string | null {
|
||||
const record = ctx.chatStore.getChat(ctx.chatId);
|
||||
const known = record?.tree?.nodes.map((n) => n.id) ?? [];
|
||||
if (!known.includes(nodeId)) {
|
||||
return (
|
||||
`nodeId "${nodeId}" matches no node in this session's plan map. ` +
|
||||
(known.length > 0
|
||||
? `Copy one of your plan_init slugs exactly (${known.slice(0, 5).join(", ")}${known.length > 5 ? ", …" : ""}), or omit nodeId for an ad-hoc check.`
|
||||
: "No plan map exists yet — omit nodeId (ad-hoc checks only until you call plan_init).")
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build the teach-side quiz tool for one request (exported for tests): the authoring contract
|
||||
* + the per-session plan-nodeId validation (chained superRefines — the
|
||||
* + the per-session plan-nodeId validation (both run in execute — the
|
||||
* grading contract is untouched). Reads the LIVE chat record, so rewrites
|
||||
* mid-turn are honored, and with no tree yet every nodeId is rejected.
|
||||
* Omitting nodeId is legal — an ad-hoc check that moves nothing. */
|
||||
export function teachQuizToolFor(ctx: Pick<PlanToolContext, "chatId" | "chatStore">) {
|
||||
const inputSchema = quizInputObject
|
||||
.superRefine((input, add) => {
|
||||
for (const problem of quizInputProblems(input)) {
|
||||
add.addIssue({ code: "custom", message: problem });
|
||||
}
|
||||
})
|
||||
.superRefine((input, add) => {
|
||||
if (typeof input.nodeId !== "string" || input.nodeId.length === 0) return;
|
||||
const record = ctx.chatStore.getChat(ctx.chatId);
|
||||
const known = record?.tree?.nodes.map((n) => n.id) ?? [];
|
||||
if (!known.includes(input.nodeId)) {
|
||||
add.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
`nodeId "${input.nodeId}" matches no node in this session's plan map. ` +
|
||||
(known.length > 0
|
||||
? `Copy one of your plan_init slugs exactly (${known.slice(0, 5).join(", ")}${known.length > 5 ? ", …" : ""}), or omit nodeId for an ad-hoc check.`
|
||||
: "No plan map exists yet — omit nodeId (ad-hoc checks only until you call plan_init)."),
|
||||
});
|
||||
}
|
||||
});
|
||||
return lhTool({
|
||||
export function teachQuizToolFor(ctx: Pick<PlanToolContext, "chatId" | "chatStore">): AgentTool<typeof quizInputObject> {
|
||||
return {
|
||||
name: "quiz",
|
||||
clientQuestion: true,
|
||||
label: "quiz",
|
||||
description:
|
||||
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
|
||||
"Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " +
|
||||
@@ -187,8 +205,18 @@ export function teachQuizToolFor(ctx: Pick<PlanToolContext, "chatId" | "chatStor
|
||||
"An always-present optional note field lets the learner attach a free-text note to ANY answer; it reaches you " +
|
||||
"only when non-empty. No free-text answers — for non-graded questions use ask_user_question instead. " +
|
||||
"ONE question per turn: call a single quiz, then end your turn — his answer determines the next.",
|
||||
schema: inputSchema,
|
||||
});
|
||||
parameters: quizInputObject,
|
||||
executionMode: "sequential",
|
||||
execute: async (_toolCallId, input) => {
|
||||
const problems = [...quizInputProblems(input)];
|
||||
if (typeof input.nodeId === "string" && input.nodeId.length > 0) {
|
||||
const nodeIdProblemText = nodeIdProblem(ctx, input.nodeId);
|
||||
if (nodeIdProblemText) problems.push(nodeIdProblemText);
|
||||
}
|
||||
if (problems.length > 0) throw new ToolInputError("quiz", problems);
|
||||
return questionResult();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface TeacherToolContext extends PlanToolContext {
|
||||
@@ -219,7 +247,7 @@ export interface TeacherToolContext extends PlanToolContext {
|
||||
export function createTeacherTools(
|
||||
ctx: TeacherToolContext,
|
||||
enabled?: EnabledTools,
|
||||
): Record<string, LhTool<any, any>> {
|
||||
): Record<string, AgentTool<any>> {
|
||||
return filterEnabledTools(
|
||||
{
|
||||
quiz: teachQuizToolFor(ctx),
|
||||
|
||||
+16
-14
@@ -1,7 +1,8 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { lhTool } from "../lh-tool.ts";
|
||||
import { Type } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
import {
|
||||
capOutput,
|
||||
formatSize,
|
||||
@@ -23,20 +24,21 @@ import {
|
||||
|
||||
const DEFAULT_LIMIT = 1000;
|
||||
|
||||
const findInputSchema = z.object({
|
||||
pattern: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'"),
|
||||
path: z.string().optional().describe("Directory to search in (default: the learner's home directory)"),
|
||||
limit: z.number().int().positive().optional().describe("Maximum number of results (default: 1000)"),
|
||||
const findInput = Type.Object({
|
||||
pattern: Type.String({
|
||||
minLength: 1,
|
||||
description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'",
|
||||
}),
|
||||
path: Type.Optional(Type.String({ description: "Directory to search in (default: the learner's home directory)" })),
|
||||
limit: Type.Optional(Type.Integer({ description: "Maximum number of results (default: 1000)" })),
|
||||
});
|
||||
|
||||
export const findTool = lhTool({
|
||||
export const findTool: AgentTool<typeof findInput> = {
|
||||
name: "find",
|
||||
label: "find",
|
||||
description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Ignores node_modules, .git, and Library directories. Output is truncated to ${DEFAULT_LIMIT} results or ${formatSize(MAX_OUTPUT_BYTES)} (whichever is hit first).`,
|
||||
schema: findInputSchema,
|
||||
async execute(input, ctx) {
|
||||
parameters: findInput,
|
||||
execute: serverExecute("find", findInput, async (input, ctx) => {
|
||||
if (ctx?.signal?.aborted) return "Operation aborted";
|
||||
const searchPath = resolveSourcePath(input.path);
|
||||
let stat;
|
||||
@@ -81,5 +83,5 @@ export const findTool = lhTool({
|
||||
if (truncated) notices.push(`${formatSize(MAX_OUTPUT_BYTES)} limit reached`);
|
||||
if (timedOut) notices.push("search timed out after 10s — partial results, narrow the directory");
|
||||
return notices.length > 0 ? `${content}\n\n[${notices.join(". ")}]` : content;
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
+25
-18
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { lhTool, type LhTool } from "../lh-tool.ts";
|
||||
import { Type } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
import { graphSessionIndex, graphSessionPropositions } from "../../core/graph.ts";
|
||||
import type { ChatStore, GraphStore } from "../../core/ports.ts";
|
||||
|
||||
@@ -28,17 +29,27 @@ export interface GraphToolContext {
|
||||
graphStore: GraphStore;
|
||||
}
|
||||
|
||||
export function proberGraphTools(ctx: GraphToolContext): Record<string, LhTool<any, any>> {
|
||||
const listSessionsInput = Type.Object({});
|
||||
|
||||
const listPropositionsInput = Type.Object({
|
||||
session: Type.String({
|
||||
minLength: 1,
|
||||
description: "The session's id exactly as list_sessions reported it (a chat id or a notes source id)",
|
||||
}),
|
||||
});
|
||||
|
||||
export function proberGraphTools(ctx: GraphToolContext): Record<string, AgentTool<any>> {
|
||||
const deps = { chatStore: ctx.chatStore, graphStore: ctx.graphStore };
|
||||
return {
|
||||
list_sessions: lhTool({
|
||||
list_sessions: {
|
||||
name: "list_sessions",
|
||||
label: "list_sessions",
|
||||
description:
|
||||
"List the sessions of the learner's persistent knowledge graph — every chat or imported-notes file that ever contributed a proposition, most recent first. The map is organized into birth-session clusters (propositions grouped by the session they were first learned in), and session names are those clusters' labels. " +
|
||||
"Each line: the session's name, kind (chat/file), how many propositions were born in it, its dates, and its id — use that id with list_propositions. " +
|
||||
"Reading this first shows what he has already covered and when, before you probe anything.",
|
||||
schema: z.object({}),
|
||||
execute: async (): Promise<string> => {
|
||||
parameters: listSessionsInput,
|
||||
execute: serverExecute("list_sessions", listSessionsInput, async (): Promise<string> => {
|
||||
const sessions = graphSessionIndex(deps);
|
||||
if (sessions.length === 0) return "The knowledge graph is empty — no session has contributed propositions yet.";
|
||||
const lines = sessions.map((s, i) =>
|
||||
@@ -46,21 +57,17 @@ export function proberGraphTools(ctx: GraphToolContext): Record<string, LhTool<a
|
||||
`born ${s.createdAt ?? "unknown date"}${s.updatedAt ? `, last active ${s.updatedAt}` : ""}) — id: ${s.id}`,
|
||||
);
|
||||
return `${sessions.length} session(s) in the knowledge graph, most recent first:\n${lines.join("\n")}`;
|
||||
},
|
||||
}),
|
||||
list_propositions: lhTool({
|
||||
}),
|
||||
},
|
||||
list_propositions: {
|
||||
name: "list_propositions",
|
||||
label: "list_propositions",
|
||||
description:
|
||||
"List the propositions BORN in one knowledge-graph session — what was first learned there — each with his latest understanding level on the scale solid / shaky / accepted / resisted / not yet engaged, and the date that level was set. " +
|
||||
"Names, levels, and dating only — no statement text, no session contents. " +
|
||||
"The level is that session's own latest evaluation; other sessions may have moved it since, so cross-session levels can differ.",
|
||||
schema: z.object({
|
||||
session: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("The session's id exactly as list_sessions reported it (a chat id or a notes source id)"),
|
||||
}),
|
||||
execute: async ({ session }): Promise<string> => {
|
||||
parameters: listPropositionsInput,
|
||||
execute: serverExecute("list_propositions", listPropositionsInput, async ({ session }): Promise<string> => {
|
||||
const propositions = graphSessionPropositions(deps, session);
|
||||
if (propositions.length === 0) {
|
||||
return `No propositions born in session "${session}". Call list_sessions for the current ids.`;
|
||||
@@ -69,7 +76,7 @@ export function proberGraphTools(ctx: GraphToolContext): Record<string, LhTool<a
|
||||
(p) => `- ${p.name} — ${p.understanding ?? "never evaluated"}${p.evaluatedAt ? ` (as of ${p.evaluatedAt})` : ""}`,
|
||||
);
|
||||
return `${propositions.length} proposition(s) born in this session:\n${lines.join("\n")}`;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+23
-18
@@ -1,7 +1,8 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { lhTool } from "../lh-tool.ts";
|
||||
import { Type } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
import {
|
||||
capOutput,
|
||||
formatSize,
|
||||
@@ -29,24 +30,28 @@ import {
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
const grepInputSchema = z.object({
|
||||
pattern: z.string().min(1).describe("Search pattern (regex or literal string)"),
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Directory or file to search (default: the learner's home directory)"),
|
||||
glob: z.string().optional().describe("Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'"),
|
||||
ignoreCase: z.boolean().optional().describe("Case-insensitive search (default: false)"),
|
||||
literal: z.boolean().optional().describe("Treat pattern as literal string instead of regex (default: false)"),
|
||||
context: z.number().int().nonnegative().optional().describe("Number of lines to show before and after each match (default: 0)"),
|
||||
limit: z.number().int().positive().optional().describe("Maximum number of matches to return (default: 100)"),
|
||||
const grepInput = Type.Object({
|
||||
pattern: Type.String({ minLength: 1, description: "Search pattern (regex or literal string)" }),
|
||||
path: Type.Optional(
|
||||
Type.String({ description: "Directory or file to search (default: the learner's home directory)" }),
|
||||
),
|
||||
glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" })),
|
||||
ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
|
||||
literal: Type.Optional(
|
||||
Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" }),
|
||||
),
|
||||
context: Type.Optional(
|
||||
Type.Integer({ minimum: 0, description: "Number of lines to show before and after each match (default: 0)" }),
|
||||
),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum number of matches to return (default: 100)" })),
|
||||
});
|
||||
|
||||
export const grepTool = lhTool({
|
||||
export const grepTool: AgentTool<typeof grepInput> = {
|
||||
name: "grep",
|
||||
label: "grep",
|
||||
description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Ignores node_modules, .git, and Library directories. Output is truncated to ${DEFAULT_LIMIT} matches or ${formatSize(MAX_OUTPUT_BYTES)} (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`,
|
||||
schema: grepInputSchema,
|
||||
async execute(input, ctx) {
|
||||
parameters: grepInput,
|
||||
execute: serverExecute("grep", grepInput, async (input, ctx) => {
|
||||
if (ctx?.signal?.aborted) return "Operation aborted";
|
||||
const searchPath = resolveSourcePath(input.path);
|
||||
|
||||
@@ -169,5 +174,5 @@ export const grepTool = lhTool({
|
||||
if (truncated) notices.push(`${formatSize(MAX_OUTPUT_BYTES)} limit reached`);
|
||||
if (timedOut || scanTimedOut) notices.push("search timed out after 10s — partial results, narrow the directory");
|
||||
return notices.length > 0 ? `${content}\n\n[${notices.join(". ")}]` : content;
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
+55
-48
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { lhTool, type LhTool } from "../lh-tool.ts";
|
||||
import { Type, type Static } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
import type { JobRecord } from "../../core/contracts.ts";
|
||||
import type { JobStore } from "../../core/ports.ts";
|
||||
|
||||
@@ -30,43 +31,41 @@ export interface JobToolContext {
|
||||
searchAvailable?: () => boolean;
|
||||
}
|
||||
|
||||
export const researchInputSchema = z.object({
|
||||
question: z
|
||||
.string()
|
||||
.describe("The single research question to answer. Make it specific — the researcher sees nothing else of the lesson."),
|
||||
context: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Why you need this / what the lesson needs it for — focuses the brief. One or two sentences."),
|
||||
export const researchInputSchema = Type.Object({
|
||||
question: Type.String({
|
||||
description: "The single research question to answer. Make it specific — the researcher sees nothing else of the lesson.",
|
||||
}),
|
||||
context: Type.Optional(
|
||||
Type.String({
|
||||
description: "Why you need this / what the lesson needs it for — focuses the brief. One or two sentences.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const demoInputSchema = z.object({
|
||||
title: z.string().describe("Short title for the demo card, e.g. 'Damped harmonic oscillator'."),
|
||||
brief: z
|
||||
.string()
|
||||
.describe(
|
||||
export const demoInputSchema = Type.Object({
|
||||
title: Type.String({ description: "Short title for the demo card, e.g. 'Damped harmonic oscillator'." }),
|
||||
brief: Type.String({
|
||||
description:
|
||||
"Precise brief for the builder: the concept, the parameters/controls and what each does, the interactions, and the visual style. " +
|
||||
"The builder sees NOTHING else of the lesson — include everything it needs, including necessary formulas and constants.",
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
export const visualInputSchema = z.object({
|
||||
brief: z
|
||||
.string()
|
||||
.describe(
|
||||
export const visualInputSchema = Type.Object({
|
||||
brief: Type.String({
|
||||
description:
|
||||
"Precise brief for ONE static figure: exactly what it must show — coordinates, labels, proportions, orientation. " +
|
||||
"The maker sees NOTHING else of the lesson; a wrong or vague brief produces a wrong figure.",
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
export const pdfInputSchema = z.object({
|
||||
path: z.string().describe("Path to the learner's PDF (relative or absolute), exactly as they gave it."),
|
||||
question: z
|
||||
.string()
|
||||
.describe(
|
||||
export const pdfInputSchema = Type.Object({
|
||||
path: Type.String({ description: "Path to the learner's PDF (relative or absolute), exactly as they gave it." }),
|
||||
question: Type.String({
|
||||
description:
|
||||
"What you need from the document — the specific content, explanation, or answer to extract and brief. " +
|
||||
"The reader sees NOTHING else of the lesson.",
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -133,39 +132,47 @@ const PDF_DESCRIPTION =
|
||||
* server can't dispatch doomed research jobs. Jobs ALREADY in flight when
|
||||
* a key disappears still run to their honest failure (runner backstop).
|
||||
*/
|
||||
export function jobTools(ctx: JobToolContext): Record<string, LhTool<any, any>> {
|
||||
export function jobTools(ctx: JobToolContext): Record<string, AgentTool<any>> {
|
||||
const searchOn = ctx.searchAvailable?.() ?? true;
|
||||
return {
|
||||
...(searchOn && {
|
||||
research: lhTool({
|
||||
research: {
|
||||
name: "research",
|
||||
label: "research",
|
||||
description: RESEARCH_DESCRIPTION,
|
||||
schema: researchInputSchema,
|
||||
execute: async (input, { toolCallId }) => dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
|
||||
}),
|
||||
parameters: researchInputSchema,
|
||||
execute: serverExecute("research", researchInputSchema, (input, { toolCallId }) =>
|
||||
dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
|
||||
),
|
||||
},
|
||||
}),
|
||||
demo: lhTool({
|
||||
demo: {
|
||||
name: "demo",
|
||||
label: "demo",
|
||||
description: DEMO_DESCRIPTION,
|
||||
schema: demoInputSchema,
|
||||
execute: async (input, { toolCallId }) => dispatch(ctx, "demo", toolCallId, DEMO_ACK, input),
|
||||
}),
|
||||
visual: lhTool({
|
||||
parameters: demoInputSchema,
|
||||
execute: serverExecute("demo", demoInputSchema, (input, { toolCallId }) => dispatch(ctx, "demo", toolCallId, DEMO_ACK, input)),
|
||||
},
|
||||
visual: {
|
||||
name: "visual",
|
||||
label: "visual",
|
||||
description: VISUAL_DESCRIPTION,
|
||||
schema: visualInputSchema,
|
||||
execute: async (input, { toolCallId }) => dispatch(ctx, "visual", toolCallId, VISUAL_ACK, input),
|
||||
}),
|
||||
pdf: lhTool({
|
||||
parameters: visualInputSchema,
|
||||
execute: serverExecute("visual", visualInputSchema, (input, { toolCallId }) =>
|
||||
dispatch(ctx, "visual", toolCallId, VISUAL_ACK, input),
|
||||
),
|
||||
},
|
||||
pdf: {
|
||||
name: "pdf",
|
||||
label: "pdf",
|
||||
description: PDF_DESCRIPTION,
|
||||
schema: pdfInputSchema,
|
||||
execute: async (input, { toolCallId }) => dispatch(ctx, "pdf", toolCallId, PDF_ACK, input),
|
||||
}),
|
||||
parameters: pdfInputSchema,
|
||||
execute: serverExecute("pdf", pdfInputSchema, (input, { toolCallId }) => dispatch(ctx, "pdf", toolCallId, PDF_ACK, input)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ResearchInput = z.infer<typeof researchInputSchema>;
|
||||
export type DemoInput = z.infer<typeof demoInputSchema>;
|
||||
export type VisualInput = z.infer<typeof visualInputSchema>;
|
||||
export type PdfInput = z.infer<typeof pdfInputSchema>;
|
||||
export type ResearchInput = Static<typeof researchInputSchema>;
|
||||
export type DemoInput = Static<typeof demoInputSchema>;
|
||||
export type VisualInput = Static<typeof visualInputSchema>;
|
||||
export type PdfInput = Static<typeof pdfInputSchema>;
|
||||
|
||||
+16
-13
@@ -1,7 +1,8 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { lhTool } from "../lh-tool.ts";
|
||||
import { Type } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
import { capOutput, formatSize, MAX_OUTPUT_BYTES, resolveSourcePath } from "./fs-shared.ts";
|
||||
|
||||
/**
|
||||
@@ -14,19 +15,21 @@ import { capOutput, formatSize, MAX_OUTPUT_BYTES, resolveSourcePath } from "./fs
|
||||
|
||||
const DEFAULT_LIMIT = 500;
|
||||
|
||||
const lsInputSchema = z.object({
|
||||
path: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Directory to list (default: the learner's home directory)"),
|
||||
limit: z.number().int().positive().optional().describe("Maximum number of entries to return (default: 500)"),
|
||||
const lsInput = Type.Object({
|
||||
path: Type.Optional(
|
||||
Type.String({ description: "Directory to list (default: the learner's home directory)" }),
|
||||
),
|
||||
limit: Type.Optional(
|
||||
Type.Integer({ description: "Maximum number of entries to return (default: 500)" }),
|
||||
),
|
||||
});
|
||||
|
||||
export const lsTool = lhTool({
|
||||
export const lsTool: AgentTool<typeof lsInput> = {
|
||||
name: "ls",
|
||||
label: "ls",
|
||||
description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${formatSize(MAX_OUTPUT_BYTES)} (whichever is hit first).`,
|
||||
schema: lsInputSchema,
|
||||
async execute(input) {
|
||||
parameters: lsInput,
|
||||
execute: serverExecute("ls", lsInput, async (input) => {
|
||||
const dirPath = resolveSourcePath(input.path);
|
||||
|
||||
let stat;
|
||||
@@ -70,5 +73,5 @@ export const lsTool = lhTool({
|
||||
if (entryLimitReached) notices.push(`${effectiveLimit} entries limit`);
|
||||
if (truncated) notices.push(`${formatSize(MAX_OUTPUT_BYTES)} limit`);
|
||||
return notices.length > 0 ? `${content}\n\n[Truncated: ${notices.join(", ")}]` : content;
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
+20
-17
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { lhTool, type LhTool } from "../lh-tool.ts";
|
||||
import { Type } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
import type { PlanToolResult, PlanTree } from "../../core/contracts.ts";
|
||||
import type { ChatStore } from "../../core/ports.ts";
|
||||
import { createTree, emptyTree, PlanTreeError } from "../../core/plan-tree.ts";
|
||||
@@ -37,27 +38,29 @@ export interface PlanToolContext {
|
||||
* latest state straight from the transcript's tool outputs (the plan
|
||||
* window and the plan blocks).
|
||||
*/
|
||||
export function planTools(ctx: PlanToolContext): Record<string, LhTool<any, any>> {
|
||||
const planInitInput = Type.Object({
|
||||
mermaid: Type.String({
|
||||
minLength: 1,
|
||||
description:
|
||||
"A mermaid graph block (any direction), e.g.:\ngraph TD\n primitives[Number primitives] --> operators[Arithmetic operators] --> expressions[Evaluating expressions]\n" +
|
||||
"The only strict rule: ids are permanent lowercase slugs (a-z, 0-9, hyphens/underscores) — checks reference them via nodeId. " +
|
||||
"Titles are free single-line text; `-->` means 'from = prerequisite' (chains fine); no subgraphs, styles, or classes.",
|
||||
}),
|
||||
});
|
||||
|
||||
export function planTools(ctx: PlanToolContext): Record<string, AgentTool<any>> {
|
||||
return {
|
||||
plan_init: lhTool({
|
||||
plan_init: {
|
||||
name: "plan_init",
|
||||
label: "plan_init",
|
||||
description:
|
||||
"Write or REWRITE this session's plan map — the dependency graph the learner follows live in the plan window. " +
|
||||
"Call once right after the probe concludes, to present the map; re-call whenever the map is wrong (the new block replaces the old tree). " +
|
||||
"A mermaid graph block (any direction): one node per line (`slug[Short Title]` — slugs are permanent lowercase ids: a-z, 0-9, hyphens/underscores), " +
|
||||
"`-->` edges meaning 'from must be understood before to' (chains fine). 5–15 nodes: a map, not a syllabus — one reasoning step per node, compress. " +
|
||||
"Every check you fire names the node it assesses via `nodeId`; where the lesson stands derives from those tags.",
|
||||
schema: z.object({
|
||||
mermaid: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"A mermaid graph block (any direction), e.g.:\ngraph TD\n primitives[Number primitives] --> operators[Arithmetic operators] --> expressions[Evaluating expressions]\n" +
|
||||
"The only strict rule: ids are permanent lowercase slugs (a-z, 0-9, hyphens/underscores) — checks reference them via nodeId. " +
|
||||
"Titles are free single-line text; `-->` means 'from = prerequisite' (chains fine); no subgraphs, styles, or classes.",
|
||||
),
|
||||
}),
|
||||
execute: async ({ mermaid }): Promise<PlanToolResult> => {
|
||||
parameters: planInitInput,
|
||||
execute: serverExecute("plan_init", planInitInput, async ({ mermaid }): Promise<PlanToolResult> => {
|
||||
const { chatId, chatStore } = ctx;
|
||||
const record = chatStore.getChat(chatId);
|
||||
if (!record) return { ok: false, error: `Chat not found: ${chatId}`, tree: emptyTree() };
|
||||
@@ -78,7 +81,7 @@ export function planTools(ctx: PlanToolContext): Record<string, LhTool<any, any>
|
||||
if (err instanceof PlanTreeError) return { ok: false, error: err.message, tree: current };
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lhTool, type LhTool } from "../lh-tool.ts";
|
||||
import { probeDoneInputSchema, type ProbeDoneInput, type ProbeDoneResult } from "../../core/contracts.ts";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
import { probeDoneInputSchema, type ProbeDoneResult } from "../../core/contracts.ts";
|
||||
import type { UIMessage } from "ai";
|
||||
import { findPendingToolCall } from "../../core/pending.ts";
|
||||
import type { ChatStore } from "../../core/ports.ts";
|
||||
@@ -43,10 +44,11 @@ function probeConcluded(messages: UIMessage[]): boolean {
|
||||
* The first probe_done in the transcript is also the construction seam:
|
||||
* from that message on, every request builds the TEACHER agent.
|
||||
*/
|
||||
export function probeDoneTool(ctx: ProbeDoneContext): Record<string, LhTool<any, any>> {
|
||||
export function probeDoneTool(ctx: ProbeDoneContext): Record<string, AgentTool<any>> {
|
||||
return {
|
||||
probe_done: lhTool({
|
||||
probe_done: {
|
||||
name: "probe_done",
|
||||
label: "probe_done",
|
||||
description:
|
||||
"Conclude the probing phase and hand off to the teaching phase. Call this ONCE, when the probe is " +
|
||||
"complete: every strand the lesson depends on is bracketed (a floor he reliably gets right AND a " +
|
||||
@@ -57,8 +59,8 @@ export function probeDoneTool(ctx: ProbeDoneContext): Record<string, LhTool<any,
|
||||
"the probe is already concluded. After it succeeds, say your closing sentence and end your turn.",
|
||||
// The core-owned schema (contracts/probe.ts) — the SAME schema the UI
|
||||
// parse seam (tool-parts.ts) runs, so boundary-valid ⇔ consumer-parseable.
|
||||
schema: probeDoneInputSchema,
|
||||
execute: async (input): Promise<ProbeDoneResult> => {
|
||||
parameters: probeDoneInputSchema,
|
||||
execute: serverExecute("probe_done", probeDoneInputSchema, async (input): Promise<ProbeDoneResult> => {
|
||||
const record = ctx.chatStore.getChat(ctx.chatId);
|
||||
if (!record) return { ok: false, error: `Chat not found: ${ctx.chatId}` };
|
||||
|
||||
@@ -82,7 +84,7 @@ export function probeDoneTool(ctx: ProbeDoneContext): Record<string, LhTool<any,
|
||||
// assessment anyway; the wake is an optimization, not a gate.
|
||||
}
|
||||
return { ok: true, assessment: input.assessment };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+15
-13
@@ -1,7 +1,8 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { lhTool, type LhToolImageResult } from "../lh-tool.ts";
|
||||
import { Type } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute, type ImageFileResult } from "../tool-execute.ts";
|
||||
import { resolveSourcePath } from "./fs-shared.ts";
|
||||
|
||||
/**
|
||||
@@ -17,8 +18,8 @@ import { resolveSourcePath } from "./fs-shared.ts";
|
||||
* continuation notices — a huge file never silently floods the context.
|
||||
* - image files (jpg/png/gif/webp/bmp): returned as native image content
|
||||
* parts so the vision-capable model actually SEES them (teacher's
|
||||
* glm-5.3-flash accepts image input per pi's zai catalog; the converter
|
||||
* in lh-tool.ts maps LhToolImageResult to pi's ImageContent).
|
||||
* glm-5.3-flash accepts image input per pi's zai catalog; tool-execute's
|
||||
* toToolResult maps ImageFileResult to pi's ImageContent).
|
||||
* - unreadable/missing files throw — pi turns that into an error tool
|
||||
* result the model sees and recovers from.
|
||||
*
|
||||
@@ -41,10 +42,10 @@ const IMAGE_MIME: Record<string, string> = {
|
||||
".bmp": "image/bmp",
|
||||
};
|
||||
|
||||
const readInputSchema = z.object({
|
||||
path: z.string().min(1).describe("Path to the file to read (relative or absolute)"),
|
||||
offset: z.number().int().positive().optional().describe("Line number to start reading from (1-indexed)"),
|
||||
limit: z.number().int().positive().optional().describe("Maximum number of lines to read"),
|
||||
const readInput = Type.Object({
|
||||
path: Type.String({ minLength: 1, description: "Path to the file to read (relative or absolute)" }),
|
||||
offset: Type.Optional(Type.Integer({ minimum: 1, description: "Line number to start reading from (1-indexed)" })),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum number of lines to read" })),
|
||||
});
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
@@ -94,11 +95,12 @@ function readTextSlice(text: string, offset: number | undefined, limit: number |
|
||||
* fragment (prompts/shared/). Relative paths resolve against the
|
||||
* learner's home (fs-shared.ts), the harness's anchor.
|
||||
*/
|
||||
export const readTool = lhTool({
|
||||
export const readTool: AgentTool<typeof readInput> = {
|
||||
name: "read",
|
||||
label: "read",
|
||||
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50.0KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
|
||||
schema: readInputSchema,
|
||||
execute: async ({ path: rawPath, offset, limit }): Promise<string | LhToolImageResult> => {
|
||||
parameters: readInput,
|
||||
execute: serverExecute("read", readInput, async ({ path: rawPath, offset, limit }): Promise<string | ImageFileResult> => {
|
||||
const absolute = resolveSourcePath(rawPath);
|
||||
const ext = path.extname(absolute).toLowerCase();
|
||||
const mime = IMAGE_MIME[ext];
|
||||
@@ -114,5 +116,5 @@ export const readTool = lhTool({
|
||||
}
|
||||
const text = buffer.toString("utf-8");
|
||||
return readTextSlice(text, offset, limit);
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
+28
-19
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { lhTool } from "../lh-tool.ts";
|
||||
import { Type } from "typebox";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { serverExecute } from "../tool-execute.ts";
|
||||
// The search-hit shape lives in the core port since the search wave
|
||||
// (2026-09-20); this alias keeps callers churn-free.
|
||||
import type { SearchHit as WebSearchResult, SearchProvider } from "../../core/search.ts";
|
||||
@@ -114,17 +115,26 @@ async function fetchAndExtract(url: string): Promise<string> {
|
||||
|
||||
// ── tool registry ────────────────────────────────────────────────────────────
|
||||
|
||||
export function createWebTools(search: () => SearchProvider | null) {
|
||||
const webSearchInput = Type.Object({
|
||||
query: Type.String({ description: "The search query (a normal search string, phrases included)" }),
|
||||
count: Type.Optional(
|
||||
Type.Integer({ minimum: 1, maximum: 10, description: "Number of results (default 5, max 10)" }),
|
||||
),
|
||||
});
|
||||
|
||||
const webFetchInput = Type.Object({
|
||||
url: Type.String({ description: "The URL to fetch" }),
|
||||
});
|
||||
|
||||
export function createWebTools(search: () => SearchProvider | null): Record<string, AgentTool<any>> {
|
||||
return {
|
||||
web_search: lhTool({
|
||||
web_search: {
|
||||
name: "web_search",
|
||||
label: "web_search",
|
||||
description: "Search the web. One search per call — use one call per search angle. " +
|
||||
"Returns an array of results with title, url, and snippet.",
|
||||
schema: z.object({
|
||||
query: z.string().describe("The search query (a normal search string, phrases included)"),
|
||||
count: z.number().int().min(1).max(10).optional().describe("Number of results (default 5, max 10)"),
|
||||
}),
|
||||
execute: async ({ query, count }): Promise<WebSearchResult[] | string> => {
|
||||
parameters: webSearchInput,
|
||||
execute: serverExecute("web_search", webSearchInput, async ({ query, count }): Promise<WebSearchResult[] | string> => {
|
||||
const provider = search();
|
||||
if (!provider) {
|
||||
return (
|
||||
@@ -137,21 +147,20 @@ export function createWebTools(search: () => SearchProvider | null) {
|
||||
} catch (err) {
|
||||
return `web_search failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
|
||||
web_fetch: lhTool({
|
||||
web_fetch: {
|
||||
name: "web_fetch",
|
||||
label: "web_fetch",
|
||||
description:
|
||||
"Fetch a web page and extract its readable content as markdown (Readability). " +
|
||||
"Handles HTML pages and plain text; JS-rendered pages fall back to a reader service.",
|
||||
schema: z.object({
|
||||
url: z.string().describe("The URL to fetch"),
|
||||
}),
|
||||
execute: async ({ url }): Promise<string> => {
|
||||
parameters: webFetchInput,
|
||||
execute: serverExecute("web_fetch", webFetchInput, async ({ url }): Promise<string> => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("Only http(s) URLs are supported");
|
||||
if (parsed.protocol !== "http" && parsed.protocol !== "https") throw new Error("Only http(s) URLs are supported");
|
||||
} catch {
|
||||
return `web_fetch failed: invalid URL "${url}"`;
|
||||
}
|
||||
@@ -160,8 +169,8 @@ export function createWebTools(search: () => SearchProvider | null) {
|
||||
} catch (err) {
|
||||
return `web_fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user