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

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

Live smoke (PORT=3004, real model): native ls turn streams end-to-end; a
real quiz pose (first attempt missing correctAnswer → tool-input-error →
model retry → posed) terminates the turn cleanly and persists pending.
This commit is contained in:
amosblomqvist
2026-09-25 23:32:13 +03:00
parent b07cb313f0
commit b87a6925af
35 changed files with 1357 additions and 1083 deletions
+223
View File
@@ -0,0 +1,223 @@
import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core";
import type { Static, TSchema } from "typebox";
/**
* tool-execute — the execution machinery every server-side lh tool shares,
* expressed natively (Wave A of the pi-native migration, 2026-09-26): tools
* are authored as pi-agent-core AgentTools (TypeBox parameters, native
* execute), and this module owns the semantics the old LhTool wrapper
* (lh-tool.ts, deleted) used to add around every execute:
*
* - the system-level per-call timeout + abort cascade (runWithAbortGuard),
* - the authoring-refinement failure shape (ToolInputError — thrown from
* execute; pi's loop converts the throw into an error tool result the
* model sees and retries on, exactly the old InvalidToolInputError
* semantics),
* - the result mapping (string verbatim / object JSON / image results to
* native content parts; details carries the record-facing value),
* - the question-tool placeholder (quiz / ask_user_question): a synthetic
* "question posed" result that is NEVER persisted — the record keeps the
* tool part output-less until the client answers; the learner's answer
* re-enters the next turn as this call's result. terminate: true (owner
* Option A, 2026-09-25) ends single-question batches natively; the slim
* shouldStopAfterTurn stopper (teacher-agent.ts) holds the belt-and-
* suspenders rule for mixed batches.
*
* Structural validation is NOT here: pi's loop validates arguments against
* the tool's TypeBox parameters before execute ever runs (pi-ai's
* validateToolArguments — the fast TypeBox path). Refinements that no
* schema can carry (quizInputProblems, the live-tree nodeId check) run at
* the top of the tool's own execute and throw ToolInputError.
*/
/**
* System-level per-call timeout (2026-09-19, the abort-cascade fix): pi
* passes every run's AbortSignal to tool execute, but the signal only
* lands at a step boundary — so a long-running local tool (the grep that
* outlived its turn by minutes) could hold the turn hostage until the dev
* server's memory restart killed the run mid-tool, freezing the record at
* its write-ahead state (the "went back in time" rollback). The guard
* races every execute against (a) the run's signal and (b) this timeout,
* rejecting with pi's own wording ("Operation aborted") on abort and a
* clear timeout message otherwise — pi turns the throw into an error tool
* result the model can see.
*/
const TOOL_TIMEOUT_MS = 30_000;
/** Test seam (runner.ts's pdfModelOverride idiom). */
export const toolTimeoutMs = { current: TOOL_TIMEOUT_MS };
/** How long an aborted/timed-out call may take to settle on its own before
* the hard reject lands — cooperative tools poll the signal and return
* partial results within a tick or two (the fs tools check per file). */
const TOOL_ABORT_GRACE_MS = 250;
function runWithAbortGuard<T>(
toolName: string,
run: (signal: AbortSignal) => T | Promise<T>,
parent: AbortSignal | undefined,
): Promise<T> {
const controller = new AbortController();
const forwardAbort = () => controller.abort();
if (parent) {
if (parent.aborted) controller.abort();
else parent.addEventListener("abort", forwardAbort, { once: true });
}
const timeoutMs = toolTimeoutMs.current;
const timer = setTimeout(() => controller.abort(), timeoutMs);
const humanTimeout = timeoutMs >= 1_000 ? `${Math.round(timeoutMs / 1000)}s` : `${timeoutMs}ms`;
let graceTimer: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
clearTimeout(timer);
if (graceTimer) clearTimeout(graceTimer);
parent?.removeEventListener("abort", forwardAbort);
};
return new Promise<T>((resolve, reject) => {
const settle = (fn: () => void) => {
controller.signal.removeEventListener("abort", onAbort);
if (graceTimer) clearTimeout(graceTimer);
fn();
};
const onAbort = () => {
// Cooperative tools poll the signal and settle with partial
// results within a tick or two (the fs tools check per file) —
// give them a brief grace to deliver those before the hard
// reject, so abort KILLS a stuck tool without racing away a
// graceful one's partial result.
graceTimer = setTimeout(
() =>
reject(
new Error(
parent?.aborted
? "Operation aborted"
: `Tool "${toolName}" timed out after ${humanTimeout} — the result was discarded`,
),
),
TOOL_ABORT_GRACE_MS,
);
};
controller.signal.addEventListener("abort", onAbort, { once: true });
if (controller.signal.aborted) onAbort(); // an already-aborted signal never fires listeners
Promise.resolve()
.then(() => run(controller.signal))
.then(
(v) => settle(() => resolve(v)),
(e) => settle(() => reject(e)),
);
})
.finally(cleanup);
}
/** lh's port of the SDK's invalid-input message: what the model retried on.
* Thrown from a tool's execute when the authoring refinements fail —
* pi's loop catches the throw and turns it into an error tool result. */
export class ToolInputError extends Error {
constructor(
public readonly toolName: string,
public readonly problems: string[],
) {
super(`Invalid tool input for tool "${toolName}":\n${problems.map((p) => `- ${p}`).join("\n")}`);
this.name = "ToolInputError";
}
}
/**
* Rich tool-result escape hatch (2026-09 sources v0): a tool whose result
* includes IMAGES (the read tool reading an image file) returns this shape
* instead of a plain value — toToolResult maps it to pi's native
* tool-result content parts (TextContent | ImageContent)[] so the
* vision-capable model actually SEES the image. `images` carries base64
* data + mimeType, exactly as pi's ImageContent wants. `details` rides
* through to the record untouched.
*/
export interface ImageFileResult {
text: string;
images?: { data: string; mimeType: string }[];
/** The record-facing result (client rendering / persistence). */
details?: unknown;
}
function isImageResult(v: unknown): v is ImageFileResult {
return (
typeof v === "object" && v !== null && "text" in v && typeof (v as ImageFileResult).text === "string"
);
}
/**
* Map a tool body's return to the native AgentToolResult — the parity the
* old toPiTool tail owned: string results pass verbatim to the model,
* objects as JSON; `details` carries the RAW value (the record's tool part
* output reads it, ui-stream), and image results become native content
* parts with details ?? null.
*/
export function toToolResult(result: unknown): AgentToolResult<unknown> {
if (isImageResult(result)) {
return {
content: [
{ type: "text", text: result.text },
...(result.images ?? []).map((img) => ({
type: "image" as const,
data: img.data,
mimeType: img.mimeType,
})),
],
details: result.details ?? null,
};
}
return {
content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }],
details: result,
};
}
/** What a server-executed tool's body receives: the call id + the guard's
* combined signal (run abort + per-call timeout) for cooperative polling. */
export interface ToolExecuteContext {
toolCallId: string;
signal?: AbortSignal;
}
/**
* Build a native AgentTool execute from a tool body — the guard (timeout +
* abort cascade) plus the shared result mapping, byte-identical to the old
* toPiTool pipeline for every server-side tool. Question tools do NOT use
* this: their placeholder is instant (questionResult below) and their
* refinement checks run before it.
*/
export function serverExecute<P extends TSchema>(
toolName: string,
schema: P,
run: (input: Static<P>, ctx: ToolExecuteContext) => unknown,
): AgentTool<any>["execute"] {
// The returned execute is deliberately typed loose (params: unknown) so
// it satisfies every AgentTool slot — heterogeneous registries type
// AgentTool<any>, whose Static is unknown, and strictFunctionTypes
// rejects a params-narrower function against it. The loop has already
// validated arguments against `schema` (runtime) and `run` keeps the
// precise Static<P> input; `schema` exists in the signature precisely so
// P infers from the call site, not the loose surface.
return async (toolCallId, input: unknown, signal) =>
toToolResult(
await runWithAbortGuard(toolName, (toolSignal) => run(input as Static<P>, { toolCallId, signal: toolSignal }), signal),
);
}
/**
* The synthetic question-tool result (in-memory only; the record's tool
* part keeps no output until the learner answers). terminate: true ends
* the batch natively — pi stops the loop when every finalized result in
* the batch terminates; the shouldStopAfterTurn stopper covers the mixed
* batch (question + other calls in one message) that terminate-only
* semantics would let run on (owner Option A, 2026-09-25).
*/
export function questionResult(): AgentToolResult<null> {
return {
content: [
{
type: "text",
text: "Question posed — the turn ends here; the learner's graded answer arrives as this call's result.",
},
],
details: null,
terminate: true,
};
}