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
JavaScript
"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