UNPKG

agentfootprint

Version:

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

285 lines • 12.8 kB
/** * CacheDecision subflow — provider-agnostic translation from * `activeInjections + DSL directives` → `CacheMarker[]`. * * This is the core "policy → markers" Lego layer. It runs every * iteration (after slot subflows produce their output, before the * CacheGate decider). Pure transform: no IO, no LLM calls, no * provider knowledge. * * Algorithm: * 1. Build a `CachePolicyContext` from agent state * 2. For each injection in `activeInjections`, evaluate its * `metadata.cache` directive against the context → cacheable boolean * 3. For each slot (system / tools / messages): * a. Walk the slot's contributions in order * b. Find the LAST index that's contiguous-from-start cacheable * c. Emit one CacheMarker at that boundary if any cacheable * 4. The moving breakpoint: when EVERYTHING the policies govern before * the conversation is cacheable — every tools entry, every system * entry, every delivered message — mark the conversation's LAST * message, so the next call (which repeats the conversation and * appends to it) reads the whole prefix back. See * `conversationTailMarker`. * * Nothing is marked when the tool schemas change on every call * (`toolsVaryPerCall`, the findings offer): tools render first on a * breakpoint wire, so every breakpoint would pay the write premium on bytes * the next call does not send. * * Each marker is provider-agnostic. Provider strategy translates * to wire format in Phase 6+. * * Special case — base system prompt: the agent's * `agent.getSystemPromptCachePolicy()` value is folded in at index 0 * of the system slot. Always-on injections (Steering / Fact / * always-active rules) follow. */ import { iterationsRemainingOf } from '../lib/iterationBudget.js'; /** * Evaluate a `CachePolicy` against the current context. * Returns `true` if the policy says THIS iteration's content is cacheable. */ export function evaluateCachePolicy(policy, ctx) { if (policy === 'always') return true; if (policy === 'never') return false; if (policy === 'while-active') { // Membership in `activeInjections` IS being-active. By the time // the subflow walks an injection, the InjectionEngine has already // confirmed it's active for THIS iteration. So 'while-active' // policy → cacheable while in the list. return true; } if (typeof policy === 'object' && policy !== null && 'until' in policy) { // Cache UNTIL predicate returns true. So cacheable iff !predicate. try { return !policy.until(ctx); } catch { // Failing predicates are treated as "do not cache" — fail-closed. // Avoids the failure mode where a buggy predicate accidentally // caches volatile content. return false; } } // Unknown policy form — fail-closed (don't cache). return false; } /** * Identify which slots an injection contributes to. An injection can * target multiple slots simultaneously (Skills target both system + * tools); we visit each contributing slot independently. */ export function injectionTargetSlots(injection) { const slots = []; if (injection.inject.systemPrompt && injection.inject.systemPrompt.length > 0) { slots.push('system'); } if (injection.inject.tools && injection.inject.tools.length > 0) { slots.push('tools'); } if (injection.inject.messages && injection.inject.messages.length > 0) { slots.push('messages'); } return slots; } /** * Pure transform: state → markers. Exported so tests can exercise * the algorithm directly without the FlowChartExecutor ceremony of * mounting the subflow as a child of a parent chart. * * The subflow body (`decide` below) is a thin wrapper that pulls * state from scope and delegates here. */ export function computeCacheMarkers(state) { // Kill switch short-circuits immediately if (state.cachingDisabled || state.toolsVaryPerCall === true) return []; const ctx = { iteration: state.iteration, iterationsRemaining: iterationsRemainingOf(state.maxIterations, state.iteration), userMessage: state.userMessage, ...(state.lastToolName !== undefined && { lastToolName: state.lastToolName }), cumulativeInputTokens: state.cumulativeInputTokens, }; const perSlot = { system: [], tools: [], }; /** Cacheability by injection id — what the messages pass indexes with. */ const cacheableById = new Map(); // Index 0 of system slot is the base system prompt perSlot.system.push({ cacheable: evaluateCachePolicy(state.systemPromptCachePolicy, ctx), reason: 'base system prompt', }); // Walk each active injection for (const inj of state.activeInjections) { // The policy the consumer declared. `metadata.cache` is where a full // `Injection` carries it; `cache` is where the scope-safe // `ActiveInjection` projection carries it (the projection drops // `metadata`, so reading only the former made every real run resolve to // 'never' — see ActiveInjection.cache). const policy = inj.metadata?.cache ?? inj.cache ?? 'never'; const cacheable = evaluateCachePolicy(policy, ctx); const reason = `${inj.flavor}:${inj.id}`; cacheableById.set(inj.id, cacheable); for (const slot of injectionTargetSlots(inj)) { if (slot === 'messages') continue; // indexed against the wire, below perSlot[slot].push({ cacheable, reason }); } } // Find per-slot last-contiguous-cacheable boundary; emit a marker per // slot that has at least one cacheable entry from index 0. const markers = []; for (const slot of ['system', 'tools']) { const entries = perSlot[slot]; let boundary = -1; let lastReason = ''; for (let i = 0; i < entries.length; i++) { if (!entries[i].cacheable) break; boundary = i; lastReason = entries[i].reason; } if (boundary >= 0) { markers.push({ field: slot, boundaryIndex: boundary, ttl: 'short', reason: `${slot} stable prefix (${boundary + 1} entries, ending at ${lastReason})`, }); } } const history = state.history ?? []; const messagesMarker = messagesMarkerFor(history, cacheableById); if (messagesMarker) markers.push(messagesMarker); const prefixCacheable = perSlot.system.every((e) => e.cacheable) && perSlot.tools.every((e) => e.cacheable); const tail = conversationTailMarker(history, cacheableById, prefixCacheable); if (tail && tail.boundaryIndex !== messagesMarker?.boundaryIndex) markers.push(tail); return markers; } /** * The moving breakpoint — the conversation's LAST message, marked. * * An agent loop sends the whole conversation on every call and only ever * appends to it (the window stage is its one writer; a compaction is the * rare exception and simply misses once). So everything up to this call's * last message is the next call's prefix, byte for byte, and a breakpoint * here lets the next call read it back instead of paying for it again. The * index is a position in `history`, which is where `callLLM` puts the * conversation — request-only lines (the staged-refs nudge, the time line, * the evidence conclusion) are appended AFTER it and are never inside the * cached prefix, so a line that is gone on the next call cannot break it. * * Placed only when the prefix before the conversation is cacheable this * iteration — every system and tools entry (`prefixCacheable`) — and every * message an injection delivered into the conversation is too: on a * breakpoint wire this marker covers all of them, and marking past a piece * its policy calls volatile would write a cache entry the next call cannot * read. Fail-closed, like every other unknown in this file. */ function conversationTailMarker(history, cacheableById, prefixCacheable) { if (!prefixCacheable || history.length === 0) return undefined; for (const m of history) { const by = m.injectedBy; if (by !== undefined && cacheableById.get(by.injectionId) !== true) return undefined; } const boundary = history.length - 1; return { field: 'messages', boundaryIndex: boundary, ttl: 'short', reason: `conversation tail through wire index ${boundary} — the moving breakpoint: the next ` + 'call repeats this prefix and appends after it', }; } /** * The `messages` marker, computed against the ACTUAL wire array (7.21). * * The old version counted entries in a per-slot list of injections and handed * that count to providers who read it as a position in `request.messages` — * two different index spaces wearing one name. It could not be caught because * it could not fire: nothing had been able to target the messages slot since * 7.19.1. Delivery makes it reachable, so it is computed the only way that * can be true — from the array the marker points into. * * The prefix rule is unchanged in spirit, just re-anchored: walk the DELIVERED * messages in wire order from the first one, stop at the first that is not * cacheable this iteration, and name the last one that was. An injection that * delivered on an earlier iteration and is no longer active has no policy now, * so it counts as not cacheable — fail-closed, like every other unknown in * this file. No delivered message, or the first one not cacheable, means no * marker at all: the conversation itself declares no cache policy, and this * decision does not invent one for it. */ function messagesMarkerFor(history, cacheableById) { let boundary = -1; let lastReason = ''; let count = 0; for (let i = 0; i < history.length; i++) { const by = history[i].injectedBy; if (by === undefined) continue; if (cacheableById.get(by.injectionId) !== true) break; boundary = i; lastReason = `${by.flavor}:${by.injectionId}`; count++; } if (boundary < 0) return undefined; // The reason says what was actually CHECKED. The prefix physically includes // the conversation messages sitting between the delivered ones, and those are // safe to cache for a reason this function never evaluated — a committed turn // does not change — not because a policy said so. Claiming otherwise would // describe a stability nobody measured. return { field: 'messages', boundaryIndex: boundary, ttl: 'short', reason: `messages prefix through wire index ${boundary} ` + `(${count} cacheable delivered message${count === 1 ? '' : 's'}, ending at ${lastReason}; ` + `the conversation turns inside the prefix carry no policy of their own)`, }; } /** * The decision stage, built for one chart. Thin scope-binding wrapper around * `computeCacheMarkers`, so it can serve as the ROOT stage of the `sf-cache` * subflow (a chart cannot start with a nested subflow) — no logic * duplication. */ export function cacheDecisionStage(options = {}) { const toolsVaryPerCall = options.toolsVaryPerCall === true; return (scope) => { scope.cacheMarkers = computeCacheMarkers({ activeInjections: scope.activeInjections, iteration: scope.iteration, maxIterations: scope.maxIterations, userMessage: scope.userMessage, ...(scope.lastToolName !== undefined && { lastToolName: scope.lastToolName }), cumulativeInputTokens: scope.cumulativeInputTokens, systemPromptCachePolicy: scope.systemPromptCachePolicy, cachingDisabled: scope.cachingDisabled, ...(scope.history !== undefined && { history: scope.history }), ...(toolsVaryPerCall && { toolsVaryPerCall: true }), }); }; } /** The decision stage for a chart whose tool schemas are stable per call. */ export const decideCacheMarkers = cacheDecisionStage(); // NOTE: the cache decision is now the ROOT stage of the `sf-cache` subflow // (see core/agent/buildCacheSubflow.ts) via the exported `decideCacheMarkers` // above. The former standalone `cacheDecisionSubflow` FlowChart export was // removed when the agent stopped mounting it directly — `computeCacheMarkers` // + `decideCacheMarkers` are the reusable pieces. //# sourceMappingURL=CacheDecisionSubflow.js.map