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:
@@ -4,7 +4,7 @@
|
|||||||
* Three single-submit workers on the shared runSubmitAgent seam (one
|
* Three single-submit workers on the shared runSubmitAgent seam (one
|
||||||
* TypeBox-validated submit call each; a pi agent purely so the model can
|
* 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
|
* 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).
|
* result the model sees).
|
||||||
*
|
*
|
||||||
* Roles: partitioner | problemGenerator | grader — all strong-tier
|
* Roles: partitioner | problemGenerator | grader — all strong-tier
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import type { Usage } from "@earendil-works/pi-ai";
|
import type { Usage } from "@earendil-works/pi-ai";
|
||||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||||
import type { TSchema } from "typebox";
|
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 { ModelRole } from "../../../config.ts";
|
||||||
import type { PiEngine } from "../models.ts";
|
import type { PiEngine } from "../models.ts";
|
||||||
import { runSubagent } from "./pi-run.ts";
|
import { runSubagent } from "./pi-run.ts";
|
||||||
@@ -63,14 +63,16 @@ export async function runSubmitAgent<T>(opts: {
|
|||||||
/** Turn budget for the loop (default 4 = retry-only; the auditor runs
|
/** Turn budget for the loop (default 4 = retry-only; the auditor runs
|
||||||
* an exploration loop instead — 16). */
|
* an exploration loop instead — 16). */
|
||||||
maxTurns?: number;
|
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
|
/** Weak-model argument heal at pi's prepareArguments seam (runs BEFORE
|
||||||
* TypeBox validation — the sanctioned shim point). Identity by
|
* TypeBox validation — the sanctioned shim point). Identity by
|
||||||
* default; extraction wires healStringifiedArrays here. */
|
* default; extraction wires healStringifiedArrays here. */
|
||||||
prepareArguments?: (args: unknown) => unknown;
|
prepareArguments?: (args: unknown) => unknown;
|
||||||
/** Semantic validation at the submit seam: the value is schema-valid
|
/** Semantic validation at the submit seam: the value is schema-valid
|
||||||
* but may violate a PIPELINE law (coverage, echo-equality). Returning
|
* but may violate a PIPELINE law (coverage, echo-equality). Returning
|
||||||
* a problem string throws ToolInputError → pi turns it into an error
|
* a problem string yields an isError result (invalidInputResult) →
|
||||||
* tool result → the model retries within maxTurns. null = accept. */
|
* the model retries within maxTurns. null = accept. */
|
||||||
validate?: (value: T) => string | null;
|
validate?: (value: T) => string | null;
|
||||||
}): Promise<T> {
|
}): Promise<T> {
|
||||||
let submitted: T | undefined;
|
let submitted: T | undefined;
|
||||||
@@ -87,7 +89,7 @@ export async function runSubmitAgent<T>(opts: {
|
|||||||
execute: async (_toolCallId, input) => {
|
execute: async (_toolCallId, input) => {
|
||||||
const value = opts.fill(input);
|
const value = opts.fill(input);
|
||||||
const problem = opts.validate?.(value) ?? null;
|
const problem = opts.validate?.(value) ?? null;
|
||||||
if (problem) throw new ToolInputError(opts.toolName, [problem]);
|
if (problem) return invalidInputResult(opts.toolName, [problem]);
|
||||||
submitted = value;
|
submitted = value;
|
||||||
return toToolResult(submitted);
|
return toToolResult(submitted);
|
||||||
},
|
},
|
||||||
@@ -100,6 +102,7 @@ export async function runSubmitAgent<T>(opts: {
|
|||||||
prompt: opts.prompt,
|
prompt: opts.prompt,
|
||||||
maxTurns: opts.maxTurns ?? 4,
|
maxTurns: opts.maxTurns ?? 4,
|
||||||
abortSignal: opts.signal,
|
abortSignal: opts.signal,
|
||||||
|
chatId: opts.chatId,
|
||||||
onAssistantMessage: (message, step) =>
|
onAssistantMessage: (message, step) =>
|
||||||
opts.onStep(
|
opts.onStep(
|
||||||
message.usage,
|
message.usage,
|
||||||
|
|||||||
+24
-15
@@ -9,10 +9,10 @@ import type { Static, TSchema } from "typebox";
|
|||||||
* (lh-tool.ts, deleted) used to add around every execute:
|
* (lh-tool.ts, deleted) used to add around every execute:
|
||||||
*
|
*
|
||||||
* - the system-level per-call timeout + abort cascade (runWithAbortGuard),
|
* - the system-level per-call timeout + abort cascade (runWithAbortGuard),
|
||||||
* - the authoring-refinement failure shape (ToolInputError — thrown from
|
* - the authoring-refinement failure shape (invalidInputResult —
|
||||||
* execute; pi's loop converts the throw into an error tool result the
|
* returned from execute as an isError result; pi 1.0's convention, the
|
||||||
* model sees and retries on, exactly the old InvalidToolInputError
|
* model sees the same retryable problem list it always did, and
|
||||||
* semantics),
|
* `details` keeps the problems as data),
|
||||||
* - the result mapping (string verbatim / object JSON / image results to
|
* - the result mapping (string verbatim / object JSON / image results to
|
||||||
* native content parts; details carries the record-facing value),
|
* native content parts; details carries the record-facing value),
|
||||||
* - the question-tool placeholder (quiz / ask_user_question): a synthetic
|
* - the question-tool placeholder (quiz / ask_user_question): a synthetic
|
||||||
@@ -107,17 +107,26 @@ function runWithAbortGuard<T>(
|
|||||||
.finally(cleanup);
|
.finally(cleanup);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** lh's port of the SDK's invalid-input message: what the model retried on.
|
/** The 1.0-native authoring-failure result (pi convention, agent-core
|
||||||
* Thrown from a tool's execute when the authoring refinements fail —
|
* 1.0): return an isError result instead of throwing — the model sees the
|
||||||
* pi's loop catches the throw and turns it into an error tool result. */
|
* same retryable problem list it always did (pi minted exactly this shape
|
||||||
export class ToolInputError extends Error {
|
* from a thrown ToolInputError), but `details` now keeps the problems as
|
||||||
constructor(
|
* DATA for the record and the UI instead of the throw path's empty {}.
|
||||||
public readonly toolName: string,
|
* Content text is byte-identical to the old thrown message. */
|
||||||
public readonly problems: string[],
|
export function invalidInputResult(
|
||||||
) {
|
toolName: string,
|
||||||
super(`Invalid tool input for tool "${toolName}":\n${problems.map((p) => `- ${p}`).join("\n")}`);
|
problems: string[],
|
||||||
this.name = "ToolInputError";
|
): 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
@@ -1,6 +1,6 @@
|
|||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
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 {
|
import {
|
||||||
askInputSchema,
|
askInputSchema,
|
||||||
askOptionProblems,
|
askOptionProblems,
|
||||||
@@ -88,7 +88,7 @@ export const askUserQuestionTool: AgentTool<typeof askInputSchema> = {
|
|||||||
executionMode: "sequential",
|
executionMode: "sequential",
|
||||||
execute: async (_toolCallId, input) => {
|
execute: async (_toolCallId, input) => {
|
||||||
const problems = askOptionProblems(input.options ?? []);
|
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();
|
return questionResult();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -153,7 +153,7 @@ export const probeQuizTool: AgentTool<typeof probeQuizInputSchema> = {
|
|||||||
executionMode: "sequential",
|
executionMode: "sequential",
|
||||||
execute: async (_toolCallId, input) => {
|
execute: async (_toolCallId, input) => {
|
||||||
const problems = quizInputProblems(input);
|
const problems = quizInputProblems(input);
|
||||||
if (problems.length > 0) throw new ToolInputError("quiz", problems);
|
if (problems.length > 0) return invalidInputResult("quiz", problems);
|
||||||
return questionResult();
|
return questionResult();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -174,7 +174,7 @@ export function teachExplainToolFor(
|
|||||||
const problems = [...explainInputProblems(input)];
|
const problems = [...explainInputProblems(input)];
|
||||||
const nodeIdProblemText = nodeIdProblem(ctx, input.nodeId);
|
const nodeIdProblemText = nodeIdProblem(ctx, input.nodeId);
|
||||||
if (nodeIdProblemText) problems.push(nodeIdProblemText);
|
if (nodeIdProblemText) problems.push(nodeIdProblemText);
|
||||||
if (problems.length > 0) throw new ToolInputError("explain", problems);
|
if (problems.length > 0) return invalidInputResult("explain", problems);
|
||||||
return questionResult();
|
return questionResult();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -191,7 +191,7 @@ export const probeExplainTool: AgentTool<typeof explainInputBase> = {
|
|||||||
executionMode: "sequential",
|
executionMode: "sequential",
|
||||||
execute: async (_toolCallId, input) => {
|
execute: async (_toolCallId, input) => {
|
||||||
const problems = explainInputProblems(input);
|
const problems = explainInputProblems(input);
|
||||||
if (problems.length > 0) throw new ToolInputError("explain", problems);
|
if (problems.length > 0) return invalidInputResult("explain", problems);
|
||||||
return questionResult();
|
return questionResult();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -317,7 +317,7 @@ export function teachQuizToolFor(ctx: Pick<PlanToolContext, "chatId" | "chatStor
|
|||||||
const problems = [...quizInputProblems(input)];
|
const problems = [...quizInputProblems(input)];
|
||||||
const nodeIdProblemText = nodeIdProblem(ctx, input.nodeId);
|
const nodeIdProblemText = nodeIdProblem(ctx, input.nodeId);
|
||||||
if (nodeIdProblemText) problems.push(nodeIdProblemText);
|
if (nodeIdProblemText) problems.push(nodeIdProblemText);
|
||||||
if (problems.length > 0) throw new ToolInputError("quiz", problems);
|
if (problems.length > 0) return invalidInputResult("quiz", problems);
|
||||||
return questionResult();
|
return questionResult();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -64,29 +64,29 @@ const validQuiz = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||||
import { ToolInputError } from "../src/agent/tool-execute.ts";
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
||||||
|
|
||||||
/** The Wave A boundary predicate (2026-09-26): pi's loop structurally
|
/** The Wave A boundary predicate (2026-09-26): pi's loop structurally
|
||||||
* validates against the TypeBox parameters, then execute re-runs the
|
* validates against the TypeBox parameters, then execute re-runs the
|
||||||
* authoring refinements (the old superRefine) and throws ToolInputError
|
* authoring refinements (the old superRefine) and returns an isError
|
||||||
* on problems — the model sees both as retryable input errors. This
|
* result on problems (pi 1.0 convention — invalidInputResult; the model
|
||||||
* helper replicates the old safeParse seam end to end. */
|
* sees both as retryable input errors). This helper replicates the old
|
||||||
|
* safeParse seam end to end. */
|
||||||
async function boundaryParse(
|
async function boundaryParse(
|
||||||
// `input: never` param: contravariance lets any AgentTool's execute slot in.
|
// `input: never` param: contravariance lets any AgentTool's execute slot in.
|
||||||
tool: { parameters: any; execute: (id: string, input: never) => Promise<unknown> },
|
tool: { parameters: any; execute: (id: string, input: never) => Promise<unknown> },
|
||||||
input: unknown,
|
input: unknown,
|
||||||
): Promise<{ success: boolean; error?: ToolInputError }> {
|
): Promise<{ success: boolean; error?: AgentToolResult<{ tool: string; problems: string[] }> }> {
|
||||||
if (!Value.Check(tool.parameters, input)) return { success: false };
|
if (!Value.Check(tool.parameters, input)) return { success: false };
|
||||||
try {
|
const result = (await tool.execute("tc", input as never)) as AgentToolResult<{
|
||||||
await tool.execute("tc", input as never);
|
tool: string;
|
||||||
|
problems: string[];
|
||||||
|
}>;
|
||||||
|
if (result.isError) return { success: false, error: result };
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (e) {
|
|
||||||
assert.ok(e instanceof ToolInputError, `expected ToolInputError, got: ${e}`);
|
|
||||||
return { success: false, error: e };
|
|
||||||
}
|
}
|
||||||
}
|
const problemLines = (r?: AgentToolResult<{ tool: string; problems: string[] }>): string[] =>
|
||||||
const problemLines = (e?: ToolInputError): string[] =>
|
r?.details.problems ?? [];
|
||||||
(e?.message.split("\n") ?? []).filter((l) => l.startsWith("- "));
|
|
||||||
|
|
||||||
describe("teachQuizToolFor: the dynamic plan-nodeId validation (position signal)", () => {
|
describe("teachQuizToolFor: the dynamic plan-nodeId validation (position signal)", () => {
|
||||||
it("accepts a nodeId that matches a node of the session's tree", async () => {
|
it("accepts a nodeId that matches a node of the session's tree", async () => {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
questionResult,
|
questionResult,
|
||||||
serverExecute,
|
serverExecute,
|
||||||
toolTimeoutMs,
|
toolTimeoutMs,
|
||||||
ToolInputError,
|
|
||||||
toToolResult,
|
toToolResult,
|
||||||
type ToolExecuteContext,
|
type ToolExecuteContext,
|
||||||
} from "../src/agent/tool-execute.ts";
|
} from "../src/agent/tool-execute.ts";
|
||||||
@@ -121,23 +120,18 @@ describe("serverExecute: cooperative settle, mapping parity, question bypass", (
|
|||||||
assert.equal(result.terminate, true, "owner Option A: question results end the batch natively");
|
assert.equal(result.terminate, true, "owner Option A: question results end the batch natively");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("authoring failures throw ToolInputError with the retryable problem list", async () => {
|
it("authoring failures return an isError result with the retryable problem list", async () => {
|
||||||
await assert.rejects(
|
const result = (await askUserQuestionTool.execute("c7", {
|
||||||
askUserQuestionTool.execute("c7", {
|
|
||||||
question: "q",
|
question: "q",
|
||||||
options: [
|
options: [
|
||||||
{ label: "A", value: "same" },
|
{ label: "A", value: "same" },
|
||||||
{ label: "B", value: "same" },
|
{ label: "B", value: "same" },
|
||||||
],
|
],
|
||||||
}),
|
})) as { isError?: boolean; content: Array<{ text?: string }>; details?: { tool: string; problems: string[] } };
|
||||||
(e: unknown) => {
|
assert.equal(result.isError, true, "pi 1.0 convention: returned, not thrown");
|
||||||
assert.ok(e instanceof ToolInputError);
|
assert.equal(result.details?.tool, "ask_user_question");
|
||||||
assert.equal(e.toolName, "ask_user_question");
|
assert.match(result.content[0]!.text!, /Invalid tool input for tool "ask_user_question":/);
|
||||||
assert.match(e.message, /Invalid tool input for tool "ask_user_question":/);
|
assert.match(result.details!.problems.map((p) => `- ${p}`).join("\n"), /- .*same value/s);
|
||||||
assert.match(e.message, /- .*same value/s);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toToolResult parity: string verbatim, object as JSON, details rides the raw value", () => {
|
it("toToolResult parity: string verbatim, object as JSON, details rides the raw value", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user