/** * outputEnforcement — what the `outputSchema` contract does INSIDE the run. * * Pattern: an authored envelope around an untrusted payload (the same shape * `window/summarize` uses), plus one writer for one committed key * (the same rule `middleware/ledger` follows). * Role: core/ layer. Until 7.26 the schema was checked once, after the run * was over, by `runTyped()`. That is a fine place to JUDGE an answer * and a useless place to FIX one: the loop has stopped, the model is * gone, and all the caller can do is throw. This module holds the * pieces that let the loop ask again — the corrective message, the * ledger row, and the synthetic tool the `'tool-forced'` strategy * puts on the wire. * Emits: N/A — the Route decider and the retry stage emit; this file only * builds values and commits rows. * * ## Why the failed answer is put into the conversation * * Nothing writes the answering turn back into `scope.history` — the loop * appends an assistant turn only when it carries tool calls. So a corrective * message sent on its own would arrive at a model that cannot see what it * said, and "your answer failed the schema" would be teaching into the void. * The retry stage appends BOTH: the answer that failed, then the correction. * That is also what makes the retry legible afterwards — the conversation * says what was answered, what was wrong with it, and what came back. * * ## Why the frame comes first and nothing follows the error * * The validator's message is DATA. A schema authored elsewhere (or a parser * that formats an error out of model output) can put anything in it, * including text shaped like an instruction. So the library's own words come * FIRST, say what the following text is, and nothing authored is appended * after it — there is no trailing sentence for injected text to pre-empt. * Identical reasoning, identical shape, to the compaction frame. */ import type { LLMMessage, LLMResponse, LLMToolSchema } from '../../adapters/types.js'; import { type OutputSchemaParser } from '../outputSchema.js'; /** * One row per final-answer attempt an enforcing agent made, in order. * * Rows exist ONLY on an agent that opted into `retries`. Without the option * the schema is judged after the run as it always was, nothing in the loop * looks at it, and this key is never written. */ export interface OutputAttempt { /** 1-based attempt number within this run. */ readonly attempt: number; /** The ReAct iteration that produced the answer. */ readonly iteration: number; /** * What became of this attempt: * • `'passed'` — the answer satisfied the schema; the run returns it. * • `'retried'` — it failed and a corrective turn was sent. * • `'exhausted'` — it failed with no retries left; this answer stands, * and `runTyped()` throws on it exactly as it always did. */ readonly outcome: 'passed' | 'retried' | 'exhausted'; /** Which half of validation failed. Absent on a passing row. */ readonly stage?: 'json-parse' | 'schema-validate'; /** The validator's own message, verbatim. Absent on a passing row. */ readonly error?: string; /** Failing field path when the parser exposes one (Zod-style issues). */ readonly path?: string; /** `fnv1a` of the corrective message this row's failure produced. Present * only on a `'retried'` row — it is the join back to the message in the * conversation and to the `output_schema_retry` event's payload. */ readonly correctiveMessageHash?: string; /** * Set on an `'exhausted'` row when the answer the MODEL produced satisfied * the schema and an `act({ output })` middleware's rewrite broke it — the * name of that middleware (8.18.0). * * Its presence is also why the row is `'exhausted'` with retries still on * the clock: a rule that turns a valid answer into an invalid one will do it * to the next answer too, so the run stops paying for re-asks that cannot * converge. */ readonly brokenBy?: string; } /** The scope surface this file needs. Structurally a `TypedScope`. */ export interface OutputLedgerScope { outputAttempts?: readonly OutputAttempt[]; } /** * Append one row to the run's output-attempt ledger. * * The ONLY writer of `outputAttempts`, so the decider (which files the * terminal outcomes) and the retry stage (which files its own, carrying the * message it wrote) cannot record the same fact two different ways. */ export declare function recordOutputAttempt(scope: OutputLedgerScope, row: OutputAttempt): void; /** A validation failure, flattened to the plain strings a row and an event * payload both need. */ export interface OutputFailure { readonly stage: 'json-parse' | 'schema-validate'; /** The validator's message. Prefer the underlying cause's message (Zod and * friends attach the real error there); fall back to the wrapper's. */ readonly error: string; readonly path?: string; } /** * Flatten whatever the parser threw into {@link OutputFailure}. * * Mirrors the extraction `callLLM`'s reliability validator already does, so * the two paths report a failure the same way. */ export declare function describeFailure(err: unknown): OutputFailure; export { SCHEMA_CHECK_FRAME_PREFIX } from '../../lib/saidByPerson.js'; /** * The two messages a failed attempt adds to the conversation: the answer * that failed, and the correction. * * The frame is authored and comes first; `failure.error` is appended to it * verbatim and nothing is written after. The library never edits the * validator's words, and it never lets them speak in the library's voice * either. */ export declare function buildCorrectiveTurn(failedAnswer: string, failure: OutputFailure, facts: { readonly attempt: number; readonly totalAttempts: number; }): readonly [LLMMessage, LLMMessage]; /** True when this message is a correction a previous attempt wrote. */ export declare function isSchemaCheckMessage(msg: LLMMessage | undefined): boolean; /** The join between a corrective message, its ledger row and its event. */ export declare function correctiveMessageHash(content: string): string; /** * Name of the tool the schema is presented as. * * It is the STRATEGY's mechanism, never the agent's surface: it is built at * request-assembly time, so it cannot reach `.tools()`, the tools slot, the * `tools.offered` event, an MCP server's served list, or the dispatcher that * runs tools and files middleware rows. The only places it exists are the * wire and the `llm_start` event — and it belongs in that event, whose whole * claim is that it reports what the model actually saw. */ export declare const SCHEMA_TOOL_NAME = "respond_with_schema"; /** Build the synthetic tool from the resolved JSON Schema. */ export declare function buildSchemaTool(jsonSchema: Readonly>, description?: string): LLMToolSchema; /** * Pull the answer out of a forced tool call. * * Returns the JSON text the rest of the run treats as the final answer, or * `undefined` when this response did not answer through the synthetic tool * (which is what a still-working turn looks like, and is left alone). */ export declare function readSchemaToolAnswer(response: LLMResponse): string | undefined; /** * Resolve the JSON Schema for the synthetic tool. * * Duck-typed, in the same spirit as the parser itself: a schema object that * can render itself as JSON Schema (ArkType's `toJsonSchema()`) is asked to; * anything else must be handed the shape explicitly. Returns `undefined` * when neither is available — the builder turns that into a refusal naming * exactly what to pass. */ export declare function resolveJsonSchema(parser: OutputSchemaParser, explicit?: Readonly>): Readonly> | undefined; /** * What the builder resolved once, and the chart carries for the whole run. * * `parser` is here (not in scope) for the ordinary reason: scope values must * survive `structuredClone`, and a parser is functions. * * @internal */ export interface ResolvedOutputEnforcement { readonly parser: OutputSchemaParser; /** * Corrective re-asks allowed. **May be `0` since 8.18.0**, which is the * whole of that release's output-contract change: enforcement is mounted * whenever a parser exists, and `0` means "judge the answer, do not re-ask" * rather than "do not judge". Before, `retries: 0` mounted nothing at all — * the chart of an agent with a terminal contract was byte-identical to one * without, and a failed contract left no trace in the run's own record. * * `0` never mounts the retry BRANCH; the chart is unchanged. What changes is * which function the Route decider runs. */ readonly retries: number; /** The synthetic tool, pre-built. Present only under `'tool-forced'`. */ readonly schemaTool?: LLMToolSchema; /** True when `.outputFallback()` is configured. The decider carries it so * the unmet-contract signal can say that a tier exists which `run()` does * not reach — the fallback itself lives at the caller's boundary. */ readonly hasFallback: boolean; } /** * Validate an answer against the contract, as the loop sees it. Returns * `undefined` when the answer passed. * * Deliberately calls the SAME `applyOutputSchema` the caller-facing * `runTyped()` calls — one validator, two call sites, so the loop can never * accept an answer the boundary would go on to reject. A parser that throws * something other than `OutputSchemaError` still failed the answer, and is * reported as a failure rather than allowed to escape and kill the run. */ export declare function judgeAnswer(answer: string, parser: OutputSchemaParser): OutputFailure | undefined;