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
TypeScript
/**
* 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;