agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
171 lines • 7.78 kB
JavaScript
/**
* outputFallback — 3-tier degradation for structured-output validation
* failures.
*
* Pairs with `outputSchema(parser)`. When the LLM's final answer
* fails schema validation (after the agent loop has done what it
* could), instead of throwing `OutputSchemaError` to the caller,
* the agent falls through:
*
* 1. **Primary** — LLM emitted schema-valid JSON. Caller gets the
* parsed value.
* 2. **Fallback** — `OutputSchemaError` thrown by the parser. The
* consumer-supplied async `fallback(error, raw)` runs; its
* return value is parsed against the same schema. If valid →
* caller gets it. If `fallback` itself throws OR its return
* value fails schema → tier 3.
* 3. **Canned** — static `canned` value (validated against the
* schema at builder time so it's guaranteed to satisfy). The
* agent NEVER throws when `canned` is set.
*
* Pattern: chain-of-responsibility (GoF) over typed degradation tiers.
* Same shape as `withRetry` / `withFallback` for LLM
* providers, but at the SCHEMA layer instead of the network
* layer.
*
* Role: Layer-6 (Agent) — terminal contract failure handler.
* Composable with `outputSchema` (which it supplements;
* one without the other is incoherent).
*
* @example
* ```ts
* import { z } from 'zod';
*
* const Refund = z.object({
* amount: z.number().nonnegative(),
* reason: z.string().min(1),
* });
*
* const agent = Agent.create({...})
* .system('You decide refund amounts.')
* .outputSchema(Refund)
* .outputFallback({
* // Tier 2: try a more permissive prompt; if it also fails,
* // escalate to a human.
* fallback: async (err, raw) => ({
* amount: 0,
* reason: `manual review required (LLM output: ${raw.slice(0, 200)})`,
* }),
* // Tier 3: guaranteed-valid safety net.
* canned: { amount: 0, reason: 'unable to process — please retry' },
* })
* .build();
*
* // Caller never sees OutputSchemaError; gets a typed Refund either way.
* const refund = await agent.runTyped({ message: '...' });
* ```
*
* Why this matters in production:
* - LLMs occasionally emit prose despite the system prompt asking
* for JSON ("Sure! Here's your refund: {...}").
* - Schema-violating outputs are bursty under model load (vendor
* A/B tests, model rollouts, content-filter trips).
* - A B2C agent that THROWS on every malformed output cascades
* into 5xx for the end user; the FAIL-OPEN pattern degrades
* gracefully and lets you triage offline.
*
* Two typed events fire so observability backends can alert on
* degradation:
* - `agentfootprint.resilience.output_fallback_triggered`
* (tier 2 fired)
* - `agentfootprint.resilience.output_canned_used`
* (tier 3 fired — fallback also failed; safety net engaged)
*/
// ─── Builder-time validation ─────────────────────────────────────────
/**
* Validate the consumer-supplied `canned` value against the schema
* at builder time. Fail-fast on misconfig — a `canned` value that
* doesn't satisfy the schema would cascade into runtime errors
* AFTER the agent loop has already failed, which defeats the
* fail-open guarantee.
*
* Throws `TypeError` with a hint if validation fails.
*/
export function validateCannedAgainstSchema(canned, parser) {
try {
parser.parse(canned);
}
catch (cause) {
throw new TypeError(`[outputFallback] canned value does not satisfy outputSchema. ` +
`The canned value is the safety net — it must always validate. ` +
`Underlying error: ${cause?.message ?? String(cause)}`);
}
}
// ─── Runtime application ─────────────────────────────────────────────
/**
* The 3-tier resolver. Called by `agent.parseOutput()` /
* `agent.runTyped()` when an `outputFallback` is configured. Replaces
* the bare-throw behavior of `applyOutputSchema()`.
*
* Returns the typed value from whichever tier wins. Emits typed
* events at every tier transition so observability backends can
* alert on degradation.
*
* @param raw — the LLM's original final-answer string
* @param parser — the outputSchema parser
* @param fallbackCfg — the resolved fallback configuration
* @param emit — agentfootprint dispatcher's `dispatch()` entry
* (typed via the runner; we accept a thin
* function so this module stays import-free of
* the dispatcher).
*/
export async function applyOutputFallback(raw, parser, fallbackCfg, emit, primaryError,
/** Corrective re-asks the run paid for before the answer reached here — the
* `outputAttempts` ledger, minus the first attempt. `0` when the agent had
* no retries, and `undefined` when the caller parsed a string that did not
* come from this agent's last run. */
retriesSpent) {
// Tier 2 — fallback function.
emit('agentfootprint.resilience.output_fallback_triggered', {
stage: primaryError.stage,
rawOutputPreview: raw.slice(0, 200),
primaryErrorMessage: primaryError.message,
...(retriesSpent !== undefined && { retriesSpent }),
});
let tier2Value;
try {
tier2Value = await fallbackCfg.fallback(primaryError, raw);
}
catch (fallbackError) {
return cannedOrRethrow(parser, fallbackCfg, emit, fallbackError, raw, retriesSpent);
}
// Validate tier 2's output against the schema.
try {
return parser.parse(tier2Value);
}
catch (validationError) {
return cannedOrRethrow(parser, fallbackCfg, emit, validationError, raw, retriesSpent);
}
}
function cannedOrRethrow(parser, fallbackCfg, emit, failureCause, raw, retriesSpent) {
if (!fallbackCfg.hasCanned) {
// No safety net — propagate. Consumer chose fail-closed by
// omitting `canned`.
if (failureCause instanceof Error)
throw failureCause;
throw new Error(String(failureCause));
}
emit('agentfootprint.resilience.output_canned_used', {
rawOutputPreview: raw.slice(0, 200),
fallbackErrorMessage: failureCause instanceof Error ? failureCause.message : String(failureCause),
...(retriesSpent !== undefined && { retriesSpent }),
});
// The combination that cannot fail and cannot be free (8.18.0): with a
// `canned` value present, `runTyped()` is structurally unable to throw — so
// the ONLY way to learn that N corrective re-asks were billed and ended in a
// static object is to be told. Warned rather than raised, because the whole
// point of a safety net is that it holds.
if (retriesSpent !== undefined && retriesSpent > 0) {
// eslint-disable-next-line no-console
console.warn(`[agentfootprint] the canned outputFallback value was returned after ${retriesSpent} ` +
`corrective re-ask(s) that were billed, and the fallback function failed too. ` +
`runTyped() cannot throw while \`canned\` is set, so nothing else would have said ` +
`so: read agent.outputContractUnmet() for the validator's own words, and treat a ` +
`rising rate here as a schema the model has stopped hitting.`);
}
// Re-validate canned defensively. Builder-time validation already
// ran, but if a consumer mutates the canned object after build,
// we'd rather throw than corrupt the contract.
return parser.parse(fallbackCfg.canned);
}
//# sourceMappingURL=outputFallback.js.map