UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

235 lines (234 loc) • 12.4 kB
/** * 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, LLMRequest, LLMResponse, LLMToolSchema } from '../../provider-contract/types.js'; import { type OutputSchemaParser } from '../../answer-validation/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; /** * Set when the attempt failed because the provider STOPPED, not because the * text failed the parser: a `strategy: 'native'` answer that ended with a * {@link NativeStopReason}, where the structured-output format does not * hold. Such an answer fails even when it parses; a `'refusal'` is never * re-asked (the row is `'exhausted'` at once). */ readonly stopReason?: NativeStopReason; } /** * The stops under which a structured-output format does not hold: a refusal, * and the two truncations — the answer's own cap (`'max_tokens'`) and the * context window (`'model_context_window_exceeded'`). */ export type NativeStopReason = 'refusal' | 'max_tokens' | 'model_context_window_exceeded'; /** The scope surface this file needs. Structurally a `TypedScope<AgentState>`. */ 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; /** The provider's stop that failed the attempt — see `OutputAttempt.stopReason`. */ readonly stopReason?: NativeStopReason; } /** * Fail a `strategy: 'native'` answer by its stop reason, before the parser. * * Anthropic's structured-outputs page: on `stop_reason: "refusal"` "the output * may not match your schema because the refusal message takes precedence", * and on `"max_tokens"` "the output may be incomplete and not match your * schema". So the format was not in force for this text, and an answer that * happens to parse is still not one the format produced — delivering it as * constrained would be a silent pass. Returns `undefined` under any other * strategy or stop. The words are the library's own, naming the stop; the * failure then takes the ordinary path (retry, or exhausted and unmet). */ export declare function nativeStopFailure(enforcement: Pick<ResolvedOutputEnforcement, 'outputFormat'>, stopReason: string | undefined): OutputFailure | undefined; /** * 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<Record<string, unknown>>, 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<unknown>, explicit?: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> | 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<unknown>; /** * 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; /** The structured-output format, pre-built by `nativeOutputFormat`. * Present under `'native'` and schema-capable `'auto'`. */ readonly outputFormat?: NonNullable<LLMRequest['outputFormat']>; /** Auto selects this format only for a capable effective provider/model. */ readonly nativeOutputAuto?: true; /** 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; /** An additional delivery gate exists; do not promise raw output on failure. */ readonly answerValidationEnforced?: boolean; /** Typed negatives share one request-only, no-tools correction with evidence repair. */ readonly negativeClaimsEnforced?: true; } /** * 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<unknown>): OutputFailure | undefined;