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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user