agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
215 lines • 9.31 kB
JavaScript
;
/**
* eventTail — a bounded, honest copy of an event stream.
*
* Pattern: ring buffer with an explicit drop counter.
* Role: the ONE place the library decides how much of a run's event
* stream it keeps in memory, and how it tells a reader that it
* kept less than everything.
*
* Two features hold a tail of the same stream for the same reason — a
* recording (`recordRun`) and the self-explaining agent's evidence
* (`SelfExplainBinding`) — and both need the same two properties:
*
* 1. BOUNDED. A long-running server must not grow an array per run
* forever. Past the cap the OLDEST events go, because the end of a
* turn is the part a reader asks about.
* 2. HONEST. A tail that dropped events says how many. Silently
* starting a timeline mid-run is the failure this exists to
* prevent — a consumer would read the remainder as the whole run.
*
* Written once here rather than twice, because the drift that matters is
* not the ring buffer (easy) — it is the two features disagreeing about
* what "dropped" means and one of them forgetting to report it.
*
* ── A REPEAT HOLDS NO SLOT ──────────────────────────────────────────────
* Every model call re-announces what is in its context: each slot's pieces
* fire `agentfootprint.context.injected` again on every iteration — by
* design, because that is the per-call record of what the model was shown
* (`recorders/core/ContextRecorder.ts`, and the answer account reads it as
* its witness). Counted like any other event, those repeats grow with the
* square of the iteration count and evicted the START of long runs:
* measured, a 100-iteration agent run fired ~11,400 events into the default
* 10,000-event cap and its recording opened at iteration ~33.
*
* So the cap counts DISTINCT events. An announcement whose payload is
* identical, field for field, to one the tail still holds is a REPEAT: it is
* kept — the stream stays whole and in order — but it holds no slot, and it
* is stored with the held announcement's payload object (identical content,
* one copy). What a repeat costs is its envelope. Identity is decided on the
* full payload (`samePayload`), never on the 32-bit `contentHash`, which only
* narrows the candidates: two different pieces are never merged. Deciding
* reads the payload once, `rawContent` included — the same order of work as
* the content hash the slot already computed to announce it.
*
* Eviction is unchanged in kind: the oldest events go first, repeats with
* them, so the retained events are always ONE contiguous suffix of the
* stream and `firstRetainedIndex` still says where it starts.
*
* The tail stays BOUNDED all the same. A repeat holds no slot, but it is still
* an envelope in memory (its own type and meta beside the shared payload), so
* the whole tail — slots and repeats together — is capped at
* {@link REPEAT_ALLOWANCE} times the cap: 100,000 events at the default. A
* 100-iteration run (~11,500 events, ~10,000 of them repeats) keeps every one;
* a run long enough to cross that loses its oldest events, reported as
* dropped, exactly as before this rule.
*
* Internal: not exported from any barrel. Both consumers wrap it in their
* own surface (`RunRecorder.droppedEvents`, the toolpack's ⚠ marker).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.REPEAT_ALLOWANCE = exports.eventTail = exports.DEFAULT_MAX_EVENTS = void 0;
/**
* Default retained-event cap — a few hundred KB for a typical turn, and
* far more than any single turn fires.
*/
exports.DEFAULT_MAX_EVENTS = 10_000;
// FOLD · the one owner of how much of the event stream was retained, how many were dropped, and where the window starts
// consumers read this and never re-derive it: recordRun.ts · toRecording (its `events` field reads tail.snapshot()),
// recordingEnvelope.ts, and the trace toolpack's bounded marker
// detached: yes — snapshot() returns a fresh copy; the tail may keep growing behind the caller.
/**
* Start a bounded tail.
*
* @param maxEvents cap on retained events (default {@link DEFAULT_MAX_EVENTS}).
* Non-finite or non-positive values fall back to the default
* rather than producing a tail that keeps nothing.
*/
function eventTail(maxEvents = exports.DEFAULT_MAX_EVENTS) {
const cap = Number.isFinite(maxEvents) && maxEvents > 0 ? Math.floor(maxEvents) : exports.DEFAULT_MAX_EVENTS;
// Slots and repeats together — the memory bound (see the module header).
const total = cap * exports.REPEAT_ALLOWANCE;
// The retained window is `events[start…]`; `slot[i]` says whether
// `events[i]` holds a slot (a repeat does not). Eviction advances `start`
// rather than shifting the array, and the dead prefix is cut in bulk.
const events = [];
const slot = [];
let start = 0;
let held = 0;
let dropped = 0;
const announcements = heldAnnouncements();
const evictOldest = () => {
const event = events[start];
const holdsSlot = slot[start];
events[start] = undefined;
start += 1;
dropped += 1;
if (!holdsSlot)
return;
held -= 1;
if (event.type === ANNOUNCEMENT)
announcements.forget(event.payload);
};
return {
push: (event) => {
const original = event.type === ANNOUNCEMENT ? announcements.find(event.payload) : undefined;
if (original !== undefined) {
events.push({ ...event, payload: original });
slot.push(false);
}
else {
events.push(event);
slot.push(true);
held += 1;
if (event.type === ANNOUNCEMENT)
announcements.hold(event.payload);
}
while (held > cap || events.length - start > total)
evictOldest();
if (start > COMPACT_AT && start * 2 > events.length) {
events.splice(0, start);
slot.splice(0, start);
start = 0;
}
},
get count() {
return events.length - start;
},
get dropped() {
return dropped;
},
get firstRetainedIndex() {
return dropped;
},
snapshot: () => ({ events: events.slice(start), dropped, firstRetainedIndex: dropped }),
};
}
exports.eventTail = eventTail;
/** The event a slot re-fires on every call — the one kind a repeat can be. */
const ANNOUNCEMENT = 'agentfootprint.context.injected';
/** Cut the evicted prefix once it is this long and at least half the array. */
const COMPACT_AT = 1024;
/**
* How many events — slots and repeats together — the tail holds per slot of
* its cap: the bound on memory a repeat's envelope still costs. See the module
* header.
*/
exports.REPEAT_ALLOWANCE = 10;
/** The announcement payloads the tail holds a slot for, findable by content. */
function heldAnnouncements() {
// Narrowed by slot + the 32-bit content hash; DECIDED by samePayload.
const byKey = new Map();
const keyOf = (payload) => {
const p = payload;
return `${String(p?.slot)}\u001F${String(p?.contentHash)}`;
};
return {
find(payload) {
return byKey.get(keyOf(payload))?.find((held) => samePayload(held, payload));
},
hold(payload) {
const key = keyOf(payload);
const list = byKey.get(key);
if (list === undefined)
byKey.set(key, [payload]);
else
list.push(payload);
},
forget(payload) {
const key = keyOf(payload);
const list = byKey.get(key);
if (list === undefined)
return;
const at = list.indexOf(payload);
if (at !== -1)
list.splice(at, 1);
if (list.length === 0)
byKey.delete(key);
},
};
}
/**
* Are two payloads the same data, field for field and in the same key order —
* so either one serializes to the same bytes? Payloads are detached plain data
* (the `typedEmit` law), which is what makes this structural walk exact.
*/
function samePayload(a, b) {
if (a === b)
return true;
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object')
return false;
if (Array.isArray(a)) {
if (!Array.isArray(b) || a.length !== b.length)
return false;
for (let i = 0; i < a.length; i++)
if (!samePayload(a[i], b[i]))
return false;
return true;
}
if (Array.isArray(b))
return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length)
return false;
for (let i = 0; i < keysA.length; i++) {
const key = keysA[i];
if (key !== keysB[i])
return false;
if (!samePayload(a[key], b[key])) {
return false;
}
}
return true;
}
//# sourceMappingURL=eventTail.js.map