UNPKG

agentfootprint

Version:

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

407 lines • 22 kB
"use strict"; /** * buildReliabilityGateChart — produces a footprintjs FlowChart that wraps * an LLM call with rules-based reliability semantics, using the native * `decide()` DSL via `addDeciderFunction` decider stages. * * ⚠ NOT REACHABLE FROM ANY SHIPPED PATH (verified 2026-07-28). This header * used to say the chart "is mounted as a subflow in the agent's chart at * Agent.build() time (only when reliability is configured)". It is not: * `buildReliabilityGateChart` is exported from no barrel, and its only * consumer in the repository is its own test * (`test/reliability/gate-chart-7pattern.test.ts`). The live * `.reliability()` path is `executeWithReliability` * (`core/agent/stages/reliabilityExecution.ts`) driving * `singleProviderCall` inline in `core/agent/stages/callLLM.ts` — which * trades this chart's subflow visibility for streaming support. Keep this * file honest rather than deleting it: it is the only implementation that * honours the `providers` failover list (see MENTAL_MODEL §14 item 3). * * Inside the subflow, when something does mount it: * * PreCheck (decider) → CallProvider (function) → PostDecide (decider) * │ * ┌───────────────┘ * ▼ loopTo('pre-check') * * Branch outcomes (escape via $break() to stop the gate's loop; * fall-through via no-$break to trigger loopTo back to PreCheck): * * PreCheck: * 'continue' → no-op → falls through to CallProvider * 'fail-fast' → set failKind, $emit, $break(reason) * * PostDecide: * 'ok' → $break() (subflow exits normally; agent continues) * 'retry' → bump attempt; falls through to loopTo * 'retry-other' → bump providerIdx; falls through to loopTo * 'fallback' → call config.fallback(); $break() on success * 'fail-fast' → set failKind, $emit, $break(reason) * * The design intends the subflow to be mounted WITHOUT `propagateBreak: * true`, so subflow `$break` stays local and an agent-level * `TranslateFailFast` stage reads `scope.reliabilityFailKind` and converts * it into an agent-level `$break(reason)` — letting normal subflow exits * (`ok`/`fallback`) leave the agent running while fail-fast stops it. That * stage was never built (`grep TranslateFailFast` finds only comments), which * is part of why nothing mounts this chart; the live path throws * `ReliabilityFailFastError` from `Agent.run` instead. * * Three-channel discipline preserved: * • SCOPE STATE — failKind/failPayload/failReason mapped to parent via * outputMapper; for the intended TranslateFailFast. * • $emit — passive observability for external consumers. * • $break(reason)— control flow + human reason for narrative. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.buildReliabilityGateChart = void 0; const footprintjs_1 = require("footprintjs"); const CircuitBreaker_js_1 = require("./CircuitBreaker.js"); const classifyError_js_1 = require("./classifyError.js"); const typedEmit_js_1 = require("../recorders/core/typedEmit.js"); const resilienceHooks_js_1 = require("../recorders/core/resilienceHooks.js"); const abort_js_1 = require("../lib/abort.js"); // ─── Stage IDs (also used for narrative/topology readability) ──────── const STAGE_IDS = { INIT: 'reliability-init', PRE_CHECK: 'pre-check', CALL_PROVIDER: 'call-provider', POST_DECIDE: 'post-decide', }; // Branch IDs must be globally unique within the chart's stageMap, so // each branch carries its decider's prefix. The decider's RETURN value // from preCheckDeciderFn / postDecideDeciderFn is the branch's local id // (e.g., 'continue', 'ok') — the decide() result map below translates. const BRANCH_IDS = { // PreCheck branches PRE_CONTINUE: 'pre-continue', PRE_FAIL_FAST: 'pre-fail-fast', // PostDecide branches POST_OK: 'post-ok', POST_RETRY: 'post-retry', POST_RETRY_OTHER: 'post-retry-other', POST_FALLBACK: 'post-fallback', POST_FAIL_FAST: 'post-fail-fast', }; // Map from `ReliabilityDecision` (consumer-facing rule.then values) to // the prefixed branch ids. The decider functions translate before // returning so consumers never see the prefixed names. const PRE_DECISION_TO_BRANCH = { continue: BRANCH_IDS.PRE_CONTINUE, 'fail-fast': BRANCH_IDS.PRE_FAIL_FAST, }; const POST_DECISION_TO_BRANCH = { ok: BRANCH_IDS.POST_OK, retry: BRANCH_IDS.POST_RETRY, 'retry-other': BRANCH_IDS.POST_RETRY_OTHER, fallback: BRANCH_IDS.POST_FALLBACK, 'fail-fast': BRANCH_IDS.POST_FAIL_FAST, }; // ─── Helpers ───────────────────────────────────────────────────────── /** Build the structured failure payload from current scope state. */ function buildFailPayload(scope, phase) { return { phase, attempt: scope.attempt, providerUsed: scope.currentProvider, errorKind: scope.errorKind, ...(scope.error?.message !== undefined && { errorMessage: scope.error.message }), }; } /** Find the matched rule's index in a DecisionResult evidence list. */ function findMatchedIndex(evidence) { for (let i = 0; i < evidence.rules.length; i++) { if (evidence.rules[i].matched) return i; } return undefined; } /** Resolve {kind, label} from a rule list and the matched index. */ function matchedKindLabel(rules, matchedIdx) { if (matchedIdx === undefined || matchedIdx < 0 || matchedIdx >= rules.length) { return { kind: 'unknown', label: 'unknown' }; } const rule = rules[matchedIdx]; return { kind: rule.kind, label: rule.label ?? rule.kind }; } // ─── Build the chart ───────────────────────────────────────────────── /** * Build the reliability gate FlowChart from a config. Mount via * `addSubFlowChartNext` in the agent's chart — see `Agent.build()`. * * Closure state captured by stage functions: * • `breakers` — Map<providerName, CircuitBreaker>; per-instance state * persists across gate invocations within ONE agent process. * • `preRules` / `postRules` — frozen rule arrays. * • `fallbackFn` — consumer's fallback function, if configured. */ function buildReliabilityGateChart(config) { // FROZEN CONFIG captured by the factory closure. None of these mutate // at runtime — they're the chart's CODE, not state. Same pattern as // every other footprintjs chart factory (buildCacheSubflow, // injectionEngineSubflow, etc.) capturing rules/directives at build. // // • providers — functions can't structuredClone into scope // • preRules/postRules — frozen at chart-build time // • fallbackFn — function, can't structuredClone into scope // // RUNTIME STATE (attempt counts, errorKind, breaker counters, // response, latency, etc.) lives in SCOPE. Breaker state in // particular is a plain serializable record that round-trips across // gate invocations via inputMapper/outputMapper — no closure. const providers = config.providers ?? []; const breakerConfig = config.circuitBreaker; // No-op pass-through for the PreContinue branch — pre-check rules // returned 'continue', so just fall through to CallProvider with no // state change. Lifted to a named const to satisfy the no-empty-function // lint rule (intentional empty body, not a forgotten implementation). const preContinueNoop = () => undefined; const preRules = config.preCheck ?? []; const postRules = config.postDecide ?? []; const fallbackFn = config.fallback; // ─── PreCheck decider function ─────────────────────────────── // Returns one of the PRE_* branch ids. If consumer rules use // ReliabilityDecision values ('continue'/'fail-fast'), map them. const preCheckDeciderFn = (scope) => { if (preRules.length === 0) return BRANCH_IDS.PRE_CONTINUE; const result = (0, footprintjs_1.decide)(scope, preRules, 'continue'); return PRE_DECISION_TO_BRANCH[result.branch] ?? BRANCH_IDS.PRE_CONTINUE; }; // ─── PreCheck 'fail-fast' branch fn ────────────────────────── const preFailFastBranchFn = (scope) => { // Pre-check rules carry kind via the decide() result. Re-evaluate // to extract the matched rule's kind/label for the failure payload. if (preRules.length === 0) return; const result = (0, footprintjs_1.decide)(scope, preRules, 'continue'); const matchedIdx = findMatchedIndex(result.evidence); const { kind, label } = matchedKindLabel(preRules, matchedIdx); scope.failKind = kind; scope.failPayload = buildFailPayload(scope, 'pre-check'); (0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.reliability.fail_fast', { phase: 'pre-check', kind, label, attempt: scope.attempt, providerUsed: scope.currentProvider, }); scope.$break(`reliability-pre-check: ${label}`); }; // ─── CallProvider stage fn ─────────────────────────────────── const callProviderStageFn = async (scope) => { const providerEntry = providers[scope.providerIdx]; if (!providerEntry) { // Misconfiguration — no provider at this index. Fail-fast cleanly. scope.failKind = 'misconfigured-provider'; scope.failPayload = buildFailPayload(scope, 'pre-check'); // Conform to ReliabilityFailFastPayload — the out-of-bounds index // surfaces in errorMessage (the typed payload has no providerIdx field; // keeping the shape uniform across all fail_fast sites). (0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.reliability.fail_fast', { phase: 'pre-check', kind: 'misconfigured-provider', attempt: scope.attempt, providerUsed: scope.currentProvider, errorMessage: `provider index ${scope.providerIdx} out of bounds`, }); scope.$break(`reliability-pre-check: misconfigured-provider (idx ${scope.providerIdx} out of bounds)`); return; } const t0 = Date.now(); const runSignal = scope.$getEnv?.()?.signal; try { // Breaker check (pure): admit + transition based on cooldown. if (breakerConfig !== undefined) { const current = scope.breakerStates[providerEntry.name] ?? (0, CircuitBreaker_js_1.initialBreakerState)(); const { admitted, nextState } = (0, CircuitBreaker_js_1.admitCall)(current, breakerConfig); scope.breakerStates[providerEntry.name] = nextState; if (!admitted) { throw new CircuitBreaker_js_1.CircuitOpenError(providerEntry.name, nextState.lastErrorMessage, (0, CircuitBreaker_js_1.nextProbeTime)(nextState, breakerConfig)); } } // Hooks are passed here too, so a DECORATED provider does not go // dark. This is not cross-emitting: the decorator's own account of // its own fixed-cap decision merely happens to occur inside a // reliability stage. The two families stay distinct — // `reliability.*` = this rules loop's dynamic decisions, `error.*` = // the decorator's. // // NOTE: this chart is currently DEAD — see the file header. The // live `.reliability()` path is `executeWithReliability` driving // `singleProviderCall` in `core/agent/stages/callLLM.ts`, which // passes the same hooks. Wiring the dead site anyway keeps it from // being a silent hole if the chart is ever revived. // The run's signal rides the request (lib/abort.ts · callUnder). const response = await (0, abort_js_1.callUnder)(scope.request, runSignal, (wired) => providerEntry.provider.complete(wired, (0, resilienceHooks_js_1.resilienceHooks)(scope))); scope.response = response; scope.error = undefined; scope.errorKind = 'ok'; // Record success (pure): may CLOSE a HALF-OPEN breaker if (breakerConfig !== undefined) { scope.breakerStates[providerEntry.name] = (0, CircuitBreaker_js_1.recordSuccess)(scope.breakerStates[providerEntry.name] ?? (0, CircuitBreaker_js_1.initialBreakerState)(), breakerConfig); } } catch (err) { // A cancelled run is not a provider failure: never classified, counted // against a breaker or handed to a rule that might re-ask it. (0, abort_js_1.throwIfAborted)(runSignal); scope.response = undefined; scope.error = err instanceof Error ? err : new Error(String(err)); scope.errorKind = (0, classifyError_js_1.classifyError)(err); // CircuitOpenError is the breaker rejecting; don't double-count. if (breakerConfig !== undefined && !(err instanceof CircuitBreaker_js_1.CircuitOpenError)) { scope.breakerStates[providerEntry.name] = (0, CircuitBreaker_js_1.recordFailure)(scope.breakerStates[providerEntry.name] ?? (0, CircuitBreaker_js_1.initialBreakerState)(), err, breakerConfig); } } finally { scope.latencyMs = Date.now() - t0; scope.attemptsPerProvider[providerEntry.name] = (scope.attemptsPerProvider[providerEntry.name] ?? 0) + 1; scope.attempt += 1; scope.canSwitchProvider = scope.providerIdx < providers.length - 1; } }; // ─── PostDecide decider function ───────────────────────────── // Returns one of the POST_* branch ids. Maps ReliabilityDecision // values from rule.then to the prefixed branch ids. const postDecideDeciderFn = (scope) => { if (postRules.length === 0) { return scope.error === undefined ? BRANCH_IDS.POST_OK : BRANCH_IDS.POST_FAIL_FAST; } const result = (0, footprintjs_1.decide)(scope, postRules, 'ok'); return POST_DECISION_TO_BRANCH[result.branch] ?? BRANCH_IDS.POST_OK; }; // ─── PostDecide branch fns ─────────────────────────────────── const okBranchFn = (scope) => { // Success exit — $break stops subflow; outputMapper still runs. scope.$break(); }; const retryBranchFn = (_scope) => { // No-op — `attempt` was already incremented by CallProvider's // finally block. Falling through (no $break) triggers loopTo // back to PreCheck. void _scope; }; const retryOtherBranchFn = (scope) => { // Advance to the next provider in the failover list (held in closure). scope.providerIdx += 1; if (scope.providerIdx < providers.length) { scope.currentProvider = providers[scope.providerIdx].name; } scope.canSwitchProvider = scope.providerIdx < providers.length - 1; // No $break — loopTo re-enters PreCheck with the new provider. }; const fallbackBranchFn = async (scope) => { if (!fallbackFn) { // Routed here but no fallback configured — convert to fail-fast. scope.failKind = 'fallback-not-configured'; scope.failPayload = buildFailPayload(scope, 'post-decide'); (0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.reliability.fail_fast', { phase: 'post-decide', kind: 'fallback-not-configured', attempt: scope.attempt, providerUsed: scope.currentProvider, }); scope.$break('reliability-post-decide: fallback-not-configured'); return; } try { const repaired = await fallbackFn(scope.request, scope.error); scope.response = repaired; scope.error = undefined; scope.errorKind = 'ok'; scope.$break(); // success via fallback; exit subflow } catch (fallbackErr) { // Fallback threw — re-classify and let next iteration's // post-decide rules route on the new error (typically fail-fast). scope.error = fallbackErr instanceof Error ? fallbackErr : new Error(String(fallbackErr)); scope.errorKind = (0, classifyError_js_1.classifyError)(fallbackErr); // Don't $break — loopTo re-enters PreCheck with new error state. } }; const failFastBranchFn = (scope) => { if (postRules.length === 0) { // Default-path fail-fast (no rules configured but error occurred) scope.failKind = scope.errorKind; scope.failPayload = buildFailPayload(scope, 'post-decide'); (0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.reliability.fail_fast', { phase: 'post-decide', kind: scope.errorKind, attempt: scope.attempt, providerUsed: scope.currentProvider, ...(scope.error?.message !== undefined && { errorMessage: scope.error.message }), }); scope.$break(`reliability-post-decide: ${scope.errorKind}`); return; } // Re-evaluate to extract the matched rule's kind/label. const result = (0, footprintjs_1.decide)(scope, postRules, 'ok'); const matchedIdx = findMatchedIndex(result.evidence); const { kind, label } = matchedKindLabel(postRules, matchedIdx); scope.failKind = kind; scope.failPayload = buildFailPayload(scope, 'post-decide'); (0, typedEmit_js_1.typedEmit)(scope, 'agentfootprint.reliability.fail_fast', { phase: 'post-decide', kind, label, attempt: scope.attempt, providerUsed: scope.currentProvider, errorKind: scope.errorKind, ...(scope.error?.message !== undefined && { errorMessage: scope.error.message }), }); scope.$break(`reliability-post-decide: ${label}`); }; // ─── Compose the chart ─────────────────────────────────────── // The chart starts with an Init stage that seeds the MUTABLE scope // state (attempt, providerIdx, errorKind, attemptsPerProvider, etc.). // We can't seed mutable state via inputMapper because inputMapper // supplies READ-ONLY inputs (the subflow's "args"). Instead, only // truly-readonly inputs (request, providersCount, hasFallback, // cumulativeCostUsd) come via inputMapper; the rest is initialized // here. return (0, footprintjs_1.flowChart)('Init', (scope) => { // Seed mutable state. Read-only inputs (request, providersCount, // hasFallback, cumulativeCostUsd, AND incomingBreakerStates) come // via inputMapper and are accessible via $getArgs(); only mutable // fields seeded into scope here. const args = scope.$getArgs(); scope.attempt = 0; scope.providerIdx = 0; scope.currentProvider = providers[0]?.name ?? ''; scope.canSwitchProvider = providers.length > 1; scope.errorKind = 'ok'; scope.latencyMs = 0; scope.attemptsPerProvider = {}; // Breaker state restoration: if the agent passed prior breaker // states (across gate invocations within one ReAct loop, or // hydrated from a persistence store), use them; else start fresh. // This is the round-trip mechanism that replaces closure state. const incoming = args.incomingBreakerStates ?? {}; scope.breakerStates = {}; if (breakerConfig !== undefined) { for (const p of providers) { scope.breakerStates[p.name] = incoming[p.name] ?? (0, CircuitBreaker_js_1.initialBreakerState)(); } } }, STAGE_IDS.INIT, { description: 'Reliability gate state init' }) .addDeciderFunction('PreCheck', preCheckDeciderFn, STAGE_IDS.PRE_CHECK, 'Reliability: pre-call rule check') .addFunctionBranch(BRANCH_IDS.PRE_CONTINUE, 'PreContinue', preContinueNoop) .addFunctionBranch(BRANCH_IDS.PRE_FAIL_FAST, 'PreFailFast', preFailFastBranchFn) .setDefault(BRANCH_IDS.PRE_CONTINUE) .end() .addFunction('CallProvider', callProviderStageFn, STAGE_IDS.CALL_PROVIDER, 'Invoke LLM provider; capture response/error to scope') .addDeciderFunction('PostDecide', postDecideDeciderFn, STAGE_IDS.POST_DECIDE, 'Reliability: post-call rule check') .addFunctionBranch(BRANCH_IDS.POST_OK, 'PostOk', okBranchFn) .addFunctionBranch(BRANCH_IDS.POST_RETRY, 'PostRetry', retryBranchFn) .addFunctionBranch(BRANCH_IDS.POST_RETRY_OTHER, 'PostRetryOther', retryOtherBranchFn) .addFunctionBranch(BRANCH_IDS.POST_FALLBACK, 'PostFallback', fallbackBranchFn) .addFunctionBranch(BRANCH_IDS.POST_FAIL_FAST, 'PostFailFast', failFastBranchFn) .setDefault(BRANCH_IDS.POST_OK) .end() .loopTo(STAGE_IDS.PRE_CHECK) .build(); } exports.buildReliabilityGateChart = buildReliabilityGateChart; //# sourceMappingURL=buildReliabilityGateChart.js.map