isError results replace thrown ToolInputError (pi 1.0 convention)

Authoring refinements (quizInputProblems, nodeId checks, ask/explain
refinements, submit-agent pipeline laws) now return invalidInputResult —
same model-facing failure and retry semantics pi minted from the throw,
but details keeps {tool, problems} as data instead of the throw path's
empty {}. ToolInputError deleted; wire shape unchanged (verified against
pi's own thrown-error conversion in agent-loop).
This commit is contained in:
amosblomqvist
2026-10-04 20:21:10 +03:00
parent 02e7840b2f
commit d2d159d8ae
6 changed files with 64 additions and 58 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
* Three single-submit workers on the shared runSubmitAgent seam (one
* TypeBox-validated submit call each; a pi agent purely so the model can
* retry a schema-invalid or LAW-violating call — the validate hook throws
* ToolInputError at the execute seam and pi turns it into an error tool
* an invalidInputResult isError result at the execute seam (pi 1.0 convention)
* result the model sees).
*
* Roles: partitioner | problemGenerator | grader — all strong-tier
+7 -4
View File
@@ -10,7 +10,7 @@
import type { Usage } from "@earendil-works/pi-ai";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { TSchema } from "typebox";
import { toToolResult, ToolInputError } from "../tool-execute.ts";
import { invalidInputResult, toToolResult } from "../tool-execute.ts";
import type { ModelRole } from "../../../config.ts";
import type { PiEngine } from "../models.ts";
import { runSubagent } from "./pi-run.ts";
@@ -63,14 +63,16 @@ export async function runSubmitAgent<T>(opts: {
/** Turn budget for the loop (default 4 = retry-only; the auditor runs
* an exploration loop instead — 16). */
maxTurns?: number;
/** Debug: raw provider-event recording key (LH_PROVIDER_EVENTS=1). */
chatId?: string;
/** Weak-model argument heal at pi's prepareArguments seam (runs BEFORE
* TypeBox validation — the sanctioned shim point). Identity by
* default; extraction wires healStringifiedArrays here. */
prepareArguments?: (args: unknown) => unknown;
/** Semantic validation at the submit seam: the value is schema-valid
* but may violate a PIPELINE law (coverage, echo-equality). Returning
* a problem string throws ToolInputError → pi turns it into an error
* tool result → the model retries within maxTurns. null = accept. */
* a problem string yields an isError result (invalidInputResult) →
* the model retries within maxTurns. null = accept. */
validate?: (value: T) => string | null;
}): Promise<T> {
let submitted: T | undefined;
@@ -87,7 +89,7 @@ export async function runSubmitAgent<T>(opts: {
execute: async (_toolCallId, input) => {
const value = opts.fill(input);
const problem = opts.validate?.(value) ?? null;
if (problem) throw new ToolInputError(opts.toolName, [problem]);
if (problem) return invalidInputResult(opts.toolName, [problem]);
submitted = value;
return toToolResult(submitted);
},
@@ -100,6 +102,7 @@ export async function runSubmitAgent<T>(opts: {
prompt: opts.prompt,
maxTurns: opts.maxTurns ?? 4,
abortSignal: opts.signal,
chatId: opts.chatId,
onAssistantMessage: (message, step) =>
opts.onStep(
message.usage,
+24 -15
View File
@@ -9,10 +9,10 @@ import type { Static, TSchema } from "typebox";
* (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 authoring-refinement failure shape (invalidInputResult —
* returned from execute as an isError result; pi 1.0's convention, the
* model sees the same retryable problem list it always did, and
* `details` keeps the problems as data),
* - 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
@@ -107,17 +107,26 @@ function runWithAbortGuard<T>(
.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";
}
/** The 1.0-native authoring-failure result (pi convention, agent-core
* 1.0): return an isError result instead of throwing — the model sees the
* same retryable problem list it always did (pi minted exactly this shape
* from a thrown ToolInputError), but `details` now keeps the problems as
* DATA for the record and the UI instead of the throw path's empty {}.
* Content text is byte-identical to the old thrown message. */
export function invalidInputResult(
toolName: string,
problems: string[],
): AgentToolResult<{ tool: string; problems: string[] }> {
return {
content: [
{
type: "text",
text: `Invalid tool input for tool "${toolName}":\n${problems.map((p) => `- ${p}`).join("\n")}`,
},
],
isError: true,
details: { tool: toolName, problems },
};
}
/**
+6 -6
View File
@@ -1,6 +1,6 @@
import { Type } from "typebox";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { questionResult, serverExecute, ToolInputError } from "./tool-execute.ts";
import { invalidInputResult, questionResult, serverExecute } from "./tool-execute.ts";
import {
askInputSchema,
askOptionProblems,
@@ -88,7 +88,7 @@ export const askUserQuestionTool: AgentTool<typeof askInputSchema> = {
executionMode: "sequential",
execute: async (_toolCallId, input) => {
const problems = askOptionProblems(input.options ?? []);
if (problems.length > 0) throw new ToolInputError("ask_user_question", problems);
if (problems.length > 0) return invalidInputResult("ask_user_question", problems);
return questionResult();
},
};
@@ -153,7 +153,7 @@ export const probeQuizTool: AgentTool<typeof probeQuizInputSchema> = {
executionMode: "sequential",
execute: async (_toolCallId, input) => {
const problems = quizInputProblems(input);
if (problems.length > 0) throw new ToolInputError("quiz", problems);
if (problems.length > 0) return invalidInputResult("quiz", problems);
return questionResult();
},
};
@@ -174,7 +174,7 @@ export function teachExplainToolFor(
const problems = [...explainInputProblems(input)];
const nodeIdProblemText = nodeIdProblem(ctx, input.nodeId);
if (nodeIdProblemText) problems.push(nodeIdProblemText);
if (problems.length > 0) throw new ToolInputError("explain", problems);
if (problems.length > 0) return invalidInputResult("explain", problems);
return questionResult();
},
};
@@ -191,7 +191,7 @@ export const probeExplainTool: AgentTool<typeof explainInputBase> = {
executionMode: "sequential",
execute: async (_toolCallId, input) => {
const problems = explainInputProblems(input);
if (problems.length > 0) throw new ToolInputError("explain", problems);
if (problems.length > 0) return invalidInputResult("explain", problems);
return questionResult();
},
};
@@ -317,7 +317,7 @@ export function teachQuizToolFor(ctx: Pick<PlanToolContext, "chatId" | "chatStor
const problems = [...quizInputProblems(input)];
const nodeIdProblemText = nodeIdProblem(ctx, input.nodeId);
if (nodeIdProblemText) problems.push(nodeIdProblemText);
if (problems.length > 0) throw new ToolInputError("quiz", problems);
if (problems.length > 0) return invalidInputResult("quiz", problems);
return questionResult();
},
};