UNPKG

agentfootprint

Version:

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

332 lines • 15.6 kB
"use strict"; /** * withCircuitBreaker — provider decorator that fails fast after N * consecutive failures. * * Pattern: Circuit Breaker (Nygard, *Release It!*) — wraps an * `LLMProvider` and tracks consecutive failures. After * `failureThreshold` failures, the breaker OPENS and * rejects all calls without invoking the wrapped provider. * After `cooldownMs`, the breaker enters HALF-OPEN and * allows probe calls; success closes the breaker, failure * re-opens it. * * Role: Outer ring (Hexagonal). Composes with `withRetry` and * `withFallback`: * * ``` * withFallback( * withCircuitBreaker(anthropic(...)), // ← stop hammering on outage * withCircuitBreaker(openai(...)), * ) * ``` * * When Anthropic 503s for the 5th time, the breaker opens * and `complete()` throws `CircuitOpenError` immediately — * no network round-trip — which `withFallback` then * catches and routes to OpenAI. After 30 seconds the * breaker probes Anthropic with a single call; if it * succeeds, normal operation resumes. * * Why a circuit breaker on top of `withRetry`? * - `withRetry` keeps hammering one provider with exponential * backoff — it doesn't know the vendor is down. * - During a multi-minute Anthropic outage, every request still * burns 3 retries + backoff = ~3 sec of latency before failing * to the fallback. Multiplied by your QPS, that's a lot of * wasted time + tokens (some retries DO get billed). * - The breaker says: "we just saw 5 failures in a row; stop * calling for 30 seconds." Subsequent requests fail in <1ms, * `withFallback` routes immediately to OpenAI. * * Three states: * * CLOSED ──[ N consecutive failures ]──► OPEN * ▲ │ * │ │ [cooldownMs elapsed] * │ ▼ * └──[ M probe successes ]──── HALF-OPEN * * HALF-OPEN ──[ probe failure ]──► OPEN (cooldown restarts) * * `stream()` is decorated identically. `name` passes through unchanged * (the consumer's existing observability hooks still see the underlying * provider's identity), and the optional per-call `LLMCallHooks` is * forwarded inward so a decorator nested inside this one can still * report. Nothing else is copied — `LLMProvider` has exactly `name`, * `complete` and the optional `stream`. * * **Scope: per-instance, NOT distributed.** Each `withCircuitBreaker(...)` * call holds its own breaker state in process memory. If you run 100 * server replicas, each has its own independent breaker — one * instance can be CLOSED while another is OPEN. This is intentional * (no shared state means no Redis dependency, no SPOF, no * partial-cluster-blast-radius surprises) and matches Hystrix's * default behavior. For cluster-wide coordination, layer your own * Redis-backed counter on top via the `onStateChange` hook + * `shouldCount` predicate. * * **Scope: PROVIDER-level, not per-tool — by design (B16).** The breaker * wraps `LLMProvider` because a provider outage has unbounded blast * radius: the LLM call is every run's heartbeat, fired once per * iteration at your full QPS, and a failure aborts the call path. Tool * failures don't share that shape — the agent's tool dispatch catches a * tool throw and feeds the error message back to the model as the tool * result (see `core/agent/stages/toolCalls.ts`), so the ReAct loop * itself absorbs and adapts (retry, alternate tool, give up), serialized * and bounded by the iteration budget. A flaky tool can't hammer * anything the way provider QPS can. For the rare tool that needs a * breaker today: its `execute` is a plain async function — wrap it with * any breaker yourself, or hide it dynamically via a `gatedTools` * predicate (the visible-tool list is recomputed every iteration). * First-class per-tool breakers (state keyed by tool name; run-scoped * vs process-scoped TBD) are a possible future enhancement. * * **Status: contract-shaped and tested — independently reproduced against a * local harness, 2026-08-13.** Somebody who is not this library's author drove * this breaker through its whole walk: it opened after two failures, served the * next request from a fallback WITHOUT calling the primary, half-opened after * cooldown, and closed after two successful probes. The one gap that run named * — *"breaker transitions have no typed AgentFootprint event"* — is what 9.32.0 * closes with the `'circuit-changed'` report below, and the per-process scope * stated further down was restated there and is unchanged. * * The failures were SCRIPTED and the cooldown was a test clock: those tests * *"were local and deterministic, so they consumed no GCP credit"*. **No live * provider has tripped this breaker from this repository**, so this is not the * field-validated rung — a real degradation, with a real vendor's error mix and * a real recovery time, remains unexercised. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.withCircuitBreaker = exports.CircuitOpenError = void 0; const requestRefusal_js_1 = require("../provider-contract/requestRefusal.js"); // ─── Public error type ─────────────────────────────────────────────── /** * Thrown by the wrapped provider when the breaker is OPEN. Carries * the underlying root-cause error from the most recent failure so * consumers can observe what tripped the breaker. */ class CircuitOpenError extends Error { code = 'ERR_CIRCUIT_OPEN'; /** The error that tripped the breaker (or the most recent failure * during HALF-OPEN that re-opened it). */ cause; /** Wall-clock timestamp at which the breaker may next probe. */ retryAfter; constructor(providerName, cause, retryAfter) { super(`[${providerName}] circuit breaker is OPEN — failing fast (next probe at ${new Date(retryAfter).toISOString()}). Underlying error: ${cause?.message ?? String(cause)}`); this.name = 'CircuitOpenError'; this.cause = cause; this.retryAfter = retryAfter; } } exports.CircuitOpenError = CircuitOpenError; /** * Wrap a provider with a circuit breaker. * * @example * ```ts * import { anthropic, openai } from 'agentfootprint/providers'; * import { withCircuitBreaker, withFallback } from 'agentfootprint/resilience'; * * const provider = withFallback( * withCircuitBreaker(anthropic({ apiKey }), { failureThreshold: 5, cooldownMs: 30_000 }), * withCircuitBreaker(openai({ apiKey })), * ); * ``` */ function withCircuitBreaker(inner, options = {}) { const failureThreshold = options.failureThreshold ?? 5; const cooldownMs = options.cooldownMs ?? 30_000; const halfOpenSuccessThreshold = options.halfOpenSuccessThreshold ?? 2; const shouldCount = options.shouldCount ?? defaultShouldCount; const onStateChange = options.onStateChange; const breaker = { state: 'closed', consecutiveFailures: 0, consecutiveSuccesses: 0, openedAt: 0, lastError: undefined, }; /** * Move the breaker, and say so on both channels. * * A no-op re-entry returns before either fires, which is what makes the * report stream a record of TRANSITIONS rather than of calls: a hundred * requests rejected while the breaker is already open produce zero events. * * `hooks` is the per-CALL channel, so it is threaded down from whichever * `complete`/`stream` invocation caused the move. Every transition happens * inside one — including `'cooldown elapsed'`, which is discovered by the * next admitted call rather than by a timer — so the event always lands * with the run's real correlation ids and nothing is ever synthesized. */ function transition(next, reason, hooks) { if (breaker.state === next) return; breaker.state = next; if (next === 'open') { breaker.openedAt = Date.now(); breaker.consecutiveSuccesses = 0; } else if (next === 'half-open') { breaker.consecutiveSuccesses = 0; } else if (next === 'closed') { breaker.consecutiveFailures = 0; breaker.consecutiveSuccesses = 0; breaker.lastError = undefined; } hooks?.onResilience?.({ kind: 'circuit-changed', state: next, reason, providerName: inner.name, }); onStateChange?.(next, reason); } /** Decide whether to admit a call. Mutates state if cooldown * elapsed (open → half-open). Returns true to admit, false to * reject with CircuitOpenError. */ function admit(hooks) { if (breaker.state === 'closed' || breaker.state === 'half-open') return true; // OPEN — check cooldown. if (Date.now() - breaker.openedAt >= cooldownMs) { transition('half-open', 'cooldown elapsed', hooks); return true; } return false; } function recordSuccess(hooks) { if (breaker.state === 'half-open') { breaker.consecutiveSuccesses += 1; if (breaker.consecutiveSuccesses >= halfOpenSuccessThreshold) { transition('closed', `${halfOpenSuccessThreshold} probe successes`, hooks); } } else if (breaker.state === 'closed') { // Successful call resets the failure counter. breaker.consecutiveFailures = 0; } } function recordFailure(err, hooks) { if (!shouldCount(err)) return; breaker.lastError = err; if (breaker.state === 'half-open') { // Probe failed — re-open the breaker. transition('open', 'half-open probe failed', hooks); return; } if (breaker.state === 'closed') { breaker.consecutiveFailures += 1; if (breaker.consecutiveFailures >= failureThreshold) { transition('open', `${breaker.consecutiveFailures} consecutive failures`, hooks); } } } function rejectFastIfOpen(hooks) { if (!admit(hooks)) { throw new CircuitOpenError(inner.name, breaker.lastError, breaker.openedAt + cooldownMs); } } const wrapped = { name: inner.name, // Same wire, same roles — a breaker only decides WHETHER to call. See the // note in withRetry: this rebuilds the provider object, so the capability // has to be carried across explicitly or it degrades to the floor. ...(inner.carriesInMessages !== undefined && { carriesInMessages: inner.carriesInMessages, }), // Likewise for the forced tool choice — a breaker decides WHETHER to // call, never what the wire can express. ...(inner.carriesForcedToolChoice !== undefined && { carriesForcedToolChoice: inner.carriesForcedToolChoice, }), ...(inner.supportsForcedToolChoice !== undefined && { supportsForcedToolChoice: inner.supportsForcedToolChoice, }), // And for the structured-output format (`strategy: 'native'`). ...(inner.carriesNativeOutputSchema !== undefined && { carriesNativeOutputSchema: inner.carriesNativeOutputSchema, }), ...(inner.supportsNativeOutputSchema !== undefined && { supportsNativeOutputSchema: inner.supportsNativeOutputSchema, }), ...(inner.supportsAutoNativeOutputSchema !== undefined && { supportsAutoNativeOutputSchema: inner.supportsAutoNativeOutputSchema, }), // And for prompt caching: a breaker never changes what reaches the wire. ...(inner.promptCaching !== undefined && { promptCaching: inner.promptCaching }), ...(inner.thinkingHandler !== undefined && { thinkingHandler: inner.thinkingHandler }), ...(inner.thinkingMode !== undefined && { thinkingMode: inner.thinkingMode }), async complete(req, hooks) { rejectFastIfOpen(hooks); try { // Forward `hooks` inward as well as using them here: this decorator // rebuilds a fresh provider object, so an inner decorator's reports // are silently swallowed unless they're passed through. const res = await inner.complete(req, hooks); recordSuccess(hooks); return res; } catch (err) { // A call the caller cancelled says nothing about the vendor's health, // whatever the error's name — never counted (lib/abort.ts). if (!req.signal?.aborted) recordFailure(err, hooks); throw err; } }, // `stream` is optional on `LLMProvider`. Only define our wrapper // if the underlying provider supports streaming — otherwise leave // it undefined so the consumer's existing capability check // (`if (provider.stream)`) still works correctly. ...(inner.stream && { async *stream(req, hooks) { rejectFastIfOpen(hooks); let yieldedAnyChunk = false; try { // This stream() method is conditionally defined only when // inner.stream exists (see `...(inner.stream && {` above), so // inner.stream is guaranteed non-null inside this body. // `hooks` is forwarded for the same reason as in complete(). // eslint-disable-next-line @typescript-eslint/no-non-null-assertion for await (const chunk of inner.stream(req, hooks)) { yieldedAnyChunk = true; yield chunk; } recordSuccess(hooks); } catch (err) { // Only count as a breaker-tripping failure if the stream // failed BEFORE yielding any tokens. Mid-stream errors are // less indicative of vendor health (could be a content-filter // trip on this specific request). if (!yieldedAnyChunk && !req.signal?.aborted) recordFailure(err, hooks); throw err; } }, }), }; return wrapped; } exports.withCircuitBreaker = withCircuitBreaker; // ─── Default predicates ────────────────────────────────────────────── function defaultShouldCount(error) { // Don't count user cancellations. const e = error; if (e?.name === 'AbortError') return false; if (e?.code === 'ABORT_ERR') return false; // Nor a request the adapter refused before sending it: that says the // REQUEST is wrong, not the vendor — counting it would open the breaker // on healthy calls. if ((0, requestRefusal_js_1.isRequestRefusal)(error)) return false; return true; } //# sourceMappingURL=withCircuitBreaker.js.map