UNPKG

agentfootprint

Version:

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

290 lines • 12.5 kB
"use strict"; /** * ContextRecorder — observes footprintjs subflow + scope events, emits * grouped `context.*` domain events via the EventDispatcher. * * Pattern: Observer (GoF) + Pipes & Filters (Hohpe & Woolf, 2003). * Role: Core semantic grouping layer for the 3-slot model. Watches * slot subflows (sf-system-prompt / sf-messages / sf-tools) and * translates raw writes into context.injected / evicted / * slot_composed / budget_pressure events. * Emits: agentfootprint.context.injected * agentfootprint.context.evicted * agentfootprint.context.slot_composed * agentfootprint.context.budget_pressure */ Object.defineProperty(exports, "__esModule", { value: true }); exports.ContextRecorder = void 0; const conventions_js_1 = require("../../lib/conventions.js"); const eventMeta_js_1 = require("../../bridge/eventMeta.js"); const served_js_1 = require("../../redaction/served.js"); const types_js_1 = require("./types.js"); class ContextRecorder { id; dispatcher; getRunContext; realWrites; // Per-write slot attribution is resolved from each write's own // runtimeStageId (see onWrite) — NOT a "currently-open slot" stack. // The 3 slot subflows run in PARALLEL (selector fan-out), so their // entry/write/exit events interleave and a stack top would mis-route // or drop writes. onSubflowEntry/Exit only manage the per-slot // seen-set lifecycle below. // Previously seen injections per slot, by scope key. We diff old-vs-new // on each write to identify NEW injections (the builder may write the // whole array multiple times; we only emit events for the additions). seenInjections = new Map(); constructor(options) { this.dispatcher = options.dispatcher; this.id = options.id ?? 'agentfootprint.context-recorder'; this.getRunContext = options.getRunContext; this.realWrites = options.realWrites; } // ─── Subflow boundaries ──────────────────────────────────────── onSubflowEntry(event) { const slot = event.subflowId ? (0, conventions_js_1.slotFromSubflowId)(event.subflowId) : undefined; if (!slot) return; // Reset the seen-set for this slot — new iteration. Safe under parallel // entry of all 3 slots: each slot owns its own seen-set key. this.seenInjections.set(slot, new Set()); } onSubflowExit(event) { if (!event.subflowId) return; const slot = (0, conventions_js_1.slotFromSubflowId)(event.subflowId); if (!slot) return; this.seenInjections.delete(slot); } // ─── Scope writes — the injection / eviction / pressure signals ── onWrite(event) { // Resolve the slot from THIS write's own runtimeStageId path — correct // even when the 3 slots run concurrently and their events interleave. const activeSlot = (0, conventions_js_1.slotFromRuntimeStageId)(event.runtimeStageId); if (!activeSlot) return; const key = event.key; // Injection signals (INJECTION_KEYS) — per-slot arrays of InjectionRecord. if (key === conventions_js_1.INJECTION_KEYS.SYSTEM_PROMPT && activeSlot === 'system-prompt') { this.handleInjectionsWrite(activeSlot, event, this.written(event)); return; } if (key === conventions_js_1.INJECTION_KEYS.MESSAGES && activeSlot === 'messages') { this.handleInjectionsWrite(activeSlot, event, this.written(event)); return; } if (key === conventions_js_1.INJECTION_KEYS.TOOLS && activeSlot === 'tools') { this.handleInjectionsWrite(activeSlot, event, this.written(event)); return; } // Composition summary — ONE record per slot exit, written just before exit. if (key === types_js_1.COMPOSITION_KEYS.SLOT_COMPOSED) { this.handleSlotComposedWrite(event, this.written(event)); return; } // Evictions — per-piece removals under budget pressure. if (key === types_js_1.COMPOSITION_KEYS.EVICTED) { this.handleEvictionsWrite(event, this.written(event)); return; } // Budget-pressure warnings — fired BEFORE evictions. if (key === types_js_1.COMPOSITION_KEYS.BUDGET_PRESSURE) { this.handleBudgetPressureWrite(event, this.written(event)); return; } } /** * The value the stage wrote: the one it relayed (`realWrites`), else the * write as the scope channel served it. Taken ONCE per write, so a relayed * value is matched to the write it came with. `keptOut`: the run's policy * selects the key itself, so every record derived from the value keeps that * verdict — its content is served as the placeholder, its structure stays * (`structureOf`). */ written(event) { const relayed = this.realWrites?.takeRealWrite(event.runtimeStageId ?? '', event.key); return relayed ?? { value: event.value, keptOut: false }; } // ─── Internals ───────────────────────────────────────────────── handleInjectionsWrite(slot, event, written) { const records = this.asInjectionArray(written.value); if (!records) return; const seen = this.seenInjections.get(slot) ?? new Set(); for (const rec of records) { if (seen.has(rec.contentHash)) continue; seen.add(rec.contentHash); this.emitInjected(written.keptOut ? structureOf(rec, INJECTION_STRUCTURE) : rec, event); } this.seenInjections.set(slot, seen); } handleSlotComposedWrite(event, written) { const rec = this.asSlotComposition(written.value); if (!rec) return; this.dispatch('agentfootprint.context.slot_composed', written.keptOut ? structureOf(rec, COMPOSITION_STRUCTURE) : rec, event); } handleEvictionsWrite(event, written) { const value = written.value; const records = this.asEvictionArray(value); if (!records) return; for (const rec of records) { this.dispatch('agentfootprint.context.evicted', rec, event); } } handleBudgetPressureWrite(event, written) { const records = this.asPressureArray(written.value); if (!records) return; for (const rec of records) { // The payload REQUIRES `unit`; the record does not, because slot // builders — including any a consumer wrote — still typecheck without // it. Filling `'chars'` here is not a guess: this handler only ever // sees writes to `COMPOSITION_KEYS.BUDGET_PRESSURE`, which come off a // slot composition, and a slot composition counts `String.length`. A // window strategy never travels this path — it emits the event // directly, with `unit: 'tokens'`. const { cap, projected } = rec; this.dispatch('agentfootprint.context.budget_pressure', { slot: rec.slot, overflowBy: rec.overflowBy, planAction: rec.planAction, unit: rec.unit ?? 'chars', cap, projected, }, event); } } emitInjected(rec, event) { // Payload is a structural subset of InjectionRecord — InjectionRecord is // designed to carry exactly what ContextInjectedPayload needs, so we // copy through directly. // // Redaction is never decided here. The record is the one the slot wrote // (relayed when the policy selects its key — `written`), and the // dispatcher serves the event like every other: a field the policy names // (`rawContent`, `contentSummary`, …) at any depth of the payload is the // placeholder (`src/redaction/served.ts`). One rule, footprintjs's — and // a kept-out injection is still an injection the record shows. this.dispatch('agentfootprint.context.injected', rec, event); } dispatch(type, payload, source) { if (!this.dispatcher.hasListenersFor(type)) return; // FlowSubflowEvent nests traversal info under .traversalContext. // WriteEvent flattens runtimeStageId + stageId at the top level via // RecorderContext. buildEventMeta accepts either shape. const origin = 'traversalContext' in source && source.traversalContext ? source.traversalContext : source; const meta = (0, eventMeta_js_1.buildEventMeta)(origin, this.getRunContext()); this.dispatcher.dispatch({ type, payload, meta }); } // ─── Type-narrowing helpers ──────────────────────────────────── asInjectionArray(value) { if (!Array.isArray(value)) return undefined; // Duck-type — require at least `contentHash` + `slot` + `source`. for (const r of value) { if (!r || typeof r !== 'object') return undefined; const rec = r; if (typeof rec.contentHash !== 'string') return undefined; if (typeof rec.slot !== 'string') return undefined; if (typeof rec.source !== 'string') return undefined; } return value; } asSlotComposition(value) { if (!value || typeof value !== 'object') return undefined; const rec = value; if (typeof rec.slot !== 'string') return undefined; if (typeof rec.iteration !== 'number') return undefined; if (!rec.budget || typeof rec.budget !== 'object') return undefined; if (!rec.sourceBreakdown || typeof rec.sourceBreakdown !== 'object') return undefined; if (typeof rec.droppedCount !== 'number') return undefined; if (!Array.isArray(rec.droppedSummaries)) return undefined; return value; } asEvictionArray(value) { if (!Array.isArray(value)) return undefined; for (const r of value) { if (!r || typeof r !== 'object') return undefined; const rec = r; if (typeof rec.slot !== 'string') return undefined; if (typeof rec.contentHash !== 'string') return undefined; } return value; } asPressureArray(value) { if (!Array.isArray(value)) return undefined; for (const r of value) { if (!r || typeof r !== 'object') return undefined; const rec = r; if (typeof rec.slot !== 'string') return undefined; if (typeof rec.cap !== 'number' || typeof rec.projected !== 'number') return undefined; } return value; } } exports.ContextRecorder = ContextRecorder; /** The structure of an injection: who, where, why, how much — never the words. */ const INJECTION_STRUCTURE = new Set([ 'contentHash', 'slot', 'source', 'sourceId', 'upstreamRef', 'reason', 'asRole', 'asRecency', 'position', 'sectionTag', 'retrievalScore', 'rankPosition', 'threshold', 'budgetSpent', 'expiresAfter', ]); /** The structure of a slot composition: the counts and the budget — never the dropped text. */ const COMPOSITION_STRUCTURE = new Set([ 'slot', 'iteration', 'budget', 'sourceBreakdown', 'orderingStrategy', 'droppedCount', ]); /** * `record` with every field outside `structure` served as the placeholder — * the derived record of a value the run's policy keeps out. Fails closed: a * field this list does not name (a custom slot builder's own) is content. */ function structureOf(record, structure) { return Object.fromEntries(Object.entries(record).map(([field, value]) => [ field, structure.has(field) ? value : served_js_1.SERVED_PLACEHOLDER, ])); } //# sourceMappingURL=ContextRecorder.js.map