agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
349 lines • 16.9 kB
JavaScript
/**
* withFallback — provider decorator that falls back to a secondary
* on error.
*
* Pattern: Decorator (GoF) — composes two `LLMProvider`s into one.
* Role: Outer ring (Hexagonal). Stacks with `withRetry`:
* `withRetry(withFallback(primary, fallback))` first retries
* the primary, then on exhaustion falls back to the secondary.
*
* Common pairings:
* • Anthropic primary, OpenAI fallback (vendor outage tolerance)
* • Real provider primary, Mock fallback (degrade gracefully in dev)
* • Premium model primary, cheaper model fallback (cost ceiling)
*
* `stream()` falls back too — if the primary's stream errors before
* yielding any chunks, we restart on the fallback. Once the primary
* has yielded chunks the stream is committed — fallback would
* duplicate the partial output.
*
* **Status: contract-shaped and tested — independently reproduced against a
* local harness, 2026-08-13.** Somebody who is not this library's author called
* a failed primary once and a healthy fallback once, with
* `agentfootprint.fallback.triggered` on the typed stream; a stream that failed
* BEFORE its first chunk moved to the fallback, and one that failed AFTER a
* chunk did not — the stream-pinning law, measured rather than asserted, and
* the reason no output was duplicated. Both providers were SCRIPTED doubles and
* the run was deterministic and local (the trial's own words: it *"consumed no
* GCP credit"*). **No failover between two live providers has been exercised
* from this repository**, so this is not the field-validated rung.
*/
import { promptCachingFor } from '../cache/cacheStrategyFor.js';
import { dispatchThinkingHandler, tagThinkingResponse } from '../thinking/dispatch.js';
import { DEFAULT_CARRIES_IN_MESSAGES } from '../provider-contract/types.js';
import { forcedToolChoiceFor } from '../provider-contract/forcedToolChoice.js';
import { autoNativeOutputSchemaFor, nativeOutputSchemaFor, } from '../provider-contract/nativeOutputSchema.js';
import { isRequestRefusal } from '../provider-contract/requestRefusal.js';
import { replayAttempts } from './replayAttempt.js';
import { requestAttempt } from '../provider-contract/requestObserver.js';
import { throwIfAborted } from '../lib/abort.js';
/**
* The message roles BOTH providers carry — the only honest capability for a
* pair where either one may serve the call. An undeclared provider counts as
* the user/assistant floor, so pairing with one clamps the result to it.
*/
function carriedByBoth(a, b) {
const left = a.carriesInMessages ?? DEFAULT_CARRIES_IN_MESSAGES;
const right = new Set(b.carriesInMessages ?? DEFAULT_CARRIES_IN_MESSAGES);
return Object.freeze(left.filter((role) => right.has(role)));
}
/**
* The prompt caching of a pair where either side may serve the call.
*
* A COST hint, not a meaning: the pair asks the agent for breakpoints when
* EITHER side takes them (clamped to the smaller count when both do), and is
* automatic when either side caches on its own. Each side is then handed only
* what IT declares (`requestFor`) — a side that declared nothing never sees a
* `cacheMarkers` field. Usage is reported when either side reports it — a
* call the other side served then reads as *unmeasured*, never as a zero.
*/
function cachingOfPair(a, b, model) {
const sides = [promptCachingFor(a, model), promptCachingFor(b, model)].filter((c) => c !== undefined);
if (sides.length === 0)
return undefined;
const reportsUsage = sides.some((c) => c.reportsUsage);
const breakpoints = sides.flatMap((c) => (c.mode === 'breakpoints' ? [c.maxBreakpoints] : []));
if (breakpoints.length > 0) {
const marked = sides.filter((side) => side.mode === 'breakpoints');
const fields = marked.every((side) => side.fields !== undefined)
? [...new Set(marked.flatMap((side) => side.fields ?? []))]
: undefined;
return Object.freeze({
mode: 'breakpoints',
maxBreakpoints: Math.min(...breakpoints),
reportsUsage,
...(fields !== undefined && { fields }),
});
}
return Object.freeze({ mode: 'automatic', reportsUsage });
}
/**
* The request as ONE side can take it: markers only for a side that declares
* breakpoints, sliced to its own count; stripped for any other side.
*/
function requestFor(side, req) {
const markers = req.cacheMarkers;
if (markers === undefined)
return req;
const caching = promptCachingFor(side, req.model);
if (caching?.mode === 'breakpoints') {
const supported = caching.fields === undefined
? markers
: markers.filter((marker) => caching.fields?.includes(marker.field));
return supported === markers && markers.length <= caching.maxBreakpoints
? req
: { ...req, cacheMarkers: supported.slice(0, caching.maxBreakpoints) };
}
const rest = { ...req };
delete rest.cacheMarkers;
return rest;
}
/**
* The thinking handler of a pair. The same handler on both sides is the
* pair's. Different handlers (or one side with none) get a DISPATCHING
* handler: the pair tags each response's `rawThinking` with the side that
* answered, and the handler runs that side's own — one
* wire's handler on the other's thinking would mis-read it, and dropping
* both lost the signed blocks the next call must echo.
*/
function thinkingOfPair(a, b) {
const [ha, hb] = [a.thinkingHandler, b.thinkingHandler];
if (ha === hb)
return ha;
const id = `${ha?.id ?? 'none'}-or-${hb?.id ?? 'none'}`;
return dispatchThinkingHandler(id, { primary: ha, fallback: hb }, {
id,
normalize() {
throw new TypeError("withFallback: this thinking does not say which side answered, so neither side's " +
'handler can read it');
},
});
}
/** Least to most a pair can promise about a model's thinking. */
const THINKING_PROMISE = ['none', 'adaptive', 'budget'];
/**
* The thinking mode of a pair, per model: the LEAST either declared side
* promises — `'none'` if either side's model cannot think (the agent refuses
* `.thinking()` at build rather than fail on the call the fallback serves),
* `'adaptive'` if either side would not send the budget, `'budget'` only when
* both send it. An undeclared side makes no claim. The pair never maps the
* wire itself: each side's adapter sends ITS model's shape for the same
* `req.thinking`.
*/
function thinkingModeOfPair(a, b) {
const [ma, mb] = [a.thinkingMode, b.thinkingMode];
if (ma === undefined || ma === mb)
return mb ?? ma;
if (mb === undefined)
return ma;
return (model) => {
const [x, y] = [ma(model), mb(model)];
// An answer that is not a mode is passed on, so the agent's check refuses
// the malformed declaration instead of this pair hiding it.
if (!THINKING_PROMISE.includes(x))
return x;
if (!THINKING_PROMISE.includes(y))
return y;
return THINKING_PROMISE.indexOf(x) <= THINKING_PROMISE.indexOf(y) ? x : y;
};
}
/**
* Wrap a primary provider with a fallback. Tries primary first; on
* error matching the policy, calls the fallback.
*
* @example
* const provider = withFallback(
* anthropic({ apiKey: A }),
* openai({ apiKey: O }),
* { onFallback: (err) => console.warn('primary failed, falling back:', err) },
* );
*/
export function withFallback(primary, fallback, options = {}) {
const shouldFallback = options.shouldFallback ?? defaultShouldFallback;
const onFallback = options.onFallback;
/**
* Report the ONE fact this decorator owns: the primary failed and the
* fallback is being called instead. Uses the pairwise `primary`/
* `fallback` names — never a composite chain name — so a
* `fallbackProvider(a, b, c)` right-fold reports honest `a→b`, `b→c`
* hops.
*/
function reportFellBack(hooks, err) {
hooks?.onResilience?.({
kind: 'fell-back',
primary: primary.name,
fallback: fallback.name,
reason: err instanceof Error ? err.message : String(err),
});
}
const promptCaching = typeof primary.promptCaching === 'function' || typeof fallback.promptCaching === 'function'
? (model) => cachingOfPair(primary, fallback, model)
: cachingOfPair(primary, fallback);
const thinkingHandler = thinkingOfPair(primary, fallback);
const thinkingMode = thinkingModeOfPair(primary, fallback);
const tagsThinking = primary.thinkingHandler !== fallback.thinkingHandler;
/** A response as the pair returns it: thinking tagged when the sides differ. */
const answered = (res, side) => tagsThinking ? tagThinkingResponse(res, side) : res;
async function* answeredStream(chunks, side) {
for await (const chunk of chunks) {
yield chunk.response === undefined
? chunk
: { ...chunk, response: answered(chunk.response, side) };
}
}
const wrapped = {
name: `${primary.name}|${fallback.name}`,
// The INTERSECTION, and only the intersection. Either provider may serve
// the call, so a role only ONE of them carries is a role the call might
// drop — and a delivery that lands or vanishes depending on which side
// answered is precisely the provider-dependent recording this capability
// exists to prevent. Undeclared means the user/assistant floor on that
// side, so an undeclared partner clamps the pair to the floor.
carriesInMessages: carriedByBoth(primary, fallback),
// AND, for the same reason the roles are an intersection: either side may
// serve the call, so the pair constrains generation only if BOTH do. One
// declared partner and one silent one is a pair whose answer is shaped
// some of the time, which is the shape of guarantee nobody can use.
carriesForcedToolChoice: (primary.carriesForcedToolChoice ?? false) && (fallback.carriesForcedToolChoice ?? false),
...(primary.supportsForcedToolChoice !== undefined ||
fallback.supportsForcedToolChoice !== undefined
? {
supportsForcedToolChoice: (model) => forcedToolChoiceFor(primary, model) && forcedToolChoiceFor(fallback, model),
}
: {}),
// The structured-output format (`strategy: 'native'`): AND, for the same
// reason — a pair whose answer is constrained only when one side serves it.
carriesNativeOutputSchema: (primary.carriesNativeOutputSchema ?? false) && (fallback.carriesNativeOutputSchema ?? false),
...(primary.supportsNativeOutputSchema !== undefined ||
fallback.supportsNativeOutputSchema !== undefined
? {
supportsNativeOutputSchema: (model) => nativeOutputSchemaFor(primary, model) && nativeOutputSchemaFor(fallback, model),
}
: {}),
// Unknown on either side stays unknown to automatic selection.
...(primary.supportsAutoNativeOutputSchema !== undefined ||
fallback.supportsAutoNativeOutputSchema !== undefined
? {
supportsAutoNativeOutputSchema: (model) => autoNativeOutputSchemaFor(primary, model) && autoNativeOutputSchemaFor(fallback, model),
}
: {}),
// The combination, not the intersection — see `cachingOfPair`.
...(promptCaching !== undefined && { promptCaching }),
// The side that answered decides which handler reads its thinking —
// see `thinkingOfPair`.
...(thinkingHandler !== undefined && { thinkingHandler }),
// The least either side promises for a model — see `thinkingModeOfPair`.
...(thinkingMode !== undefined && { thinkingMode }),
async complete(req, hooks) {
const attempts = replayAttempts(req, hooks);
try {
const primaryRequest = requestFor(primary, attempts.request);
const attempt = requestAttempt(attempts.hooks, primaryRequest);
try {
return attempts.response(answered(await primary.complete(primaryRequest, attempt.hooks), 'primary'));
}
finally {
attempt.close();
}
}
catch (err) {
// A cancelled call never moves to the other side, whatever
// `shouldFallback` says: that would start a second billed call for a
// caller who has gone (lib/abort.ts).
throwIfAborted(req.signal);
if (!shouldFallback(err))
throw err;
onFallback?.(err);
reportFellBack(hooks, err);
const fallbackRequest = requestFor(fallback, attempts.request);
const attempt = requestAttempt(attempts.hooks, fallbackRequest);
try {
return attempts.response(answered(await fallback.complete(fallbackRequest, attempt.hooks), 'fallback'));
}
finally {
attempt.close();
}
}
},
};
// Stream fallback — only if the primary stream fails before any
// chunk yields. Once a chunk is consumed downstream, restarting
// would replay tokens. Yields from primary as long as it's working;
// catches errors in the iteration setup or first chunk only.
if (primary.stream || fallback.stream) {
wrapped.stream = async function* fallbackStream(req, hooks) {
const attempts = replayAttempts(req, hooks);
// No primary stream support → fallback's stream (or its complete-only).
// Reports NOTHING: nothing failed here, the primary simply has no
// `stream()`. Calling this a fallback would be a lie.
const fromFallback = async function* () {
const fallbackReq = requestFor(fallback, attempts.request);
const attempt = requestAttempt(attempts.hooks, fallbackReq);
try {
for await (const chunk of answeredStream(fallback.stream
? fallback.stream(fallbackReq, attempt.hooks)
: completeAsStream(fallback, fallbackReq, attempt.hooks), 'fallback'))
yield attempts.chunk(chunk);
}
finally {
attempt.close();
}
};
if (!primary.stream) {
yield* fromFallback();
return;
}
let yieldedAny = false;
try {
const primaryRequest = requestFor(primary, attempts.request);
const attempt = requestAttempt(attempts.hooks, primaryRequest);
try {
for await (const chunk of answeredStream(primary.stream(primaryRequest, attempt.hooks), 'primary')) {
yieldedAny = true;
yield attempts.chunk(chunk);
}
}
finally {
attempt.close();
}
}
catch (err) {
throwIfAborted(req.signal);
if (yieldedAny || !shouldFallback(err))
throw err;
onFallback?.(err);
reportFellBack(hooks, err);
yield* fromFallback();
}
};
}
return wrapped;
}
// ── Defaults ────────────────────────────────────────────────────────
function defaultShouldFallback(err) {
if (!err || typeof err !== 'object')
return true;
const e = err;
if (e.name === 'AbortError' || e.code === 'ABORT_ERR')
return false;
// A request the primary's adapter refused before sending is a wrong
// REQUEST, not a failed vendor: it surfaces, rather than every call of the
// run quietly moving to the other side.
return !isRequestRefusal(err);
}
/**
* Synthesize a stream from a non-streaming provider's `complete()`
* call: one terminal chunk carrying the whole response. Lets the
* fallback chain still satisfy a `stream()` request even when the
* fallback only implements `complete()`.
*/
async function* completeAsStream(provider, req, hooks) {
const response = await provider.complete(req, hooks);
yield {
tokenIndex: 0,
content: '',
done: true,
response,
};
}
//# sourceMappingURL=withFallback.js.map