agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
143 lines • 7.19 kB
JavaScript
/**
* compactionMeter — the instrument a window strategy is allowed to read.
*
* Pattern: CombinedRecorder (Convention 1 — single purpose: measurement),
* with a handle the window stage reads MID-run. That mid-run read is
* deliberate and has precedent: the causal-evidence recorder is
* attached inline for the same reason (the memory write stage
* consumes its accumulators before the run ends).
* Role: recorders/core layer. Attached ONLY when a window strategy is
* configured (`.window()` / `.compaction()`), and always INLINE — a
* window decision cannot ride one beat behind the loop it is
* deciding for.
* Emits: nothing. It only measures.
*
* It answers two questions a stage function cannot answer for itself:
*
* 1. "How big was the last window, as the PROVIDER counted it?" — from the
* `stream.llm_end` usage the adapter reported. Counted, not guessed. Fed
* by the agent's REAL-value event path (`noteLlmEnd`), never by a served
* event: the window decides on this number, so a redaction policy can
* never take it away (`src/redaction/runRedaction.ts`).
* 2. "Which stage put each message in the window?" — from the
* `runtimeStageId` on every write to `history`. A stage function has no
* access to its own runtimeStageId (footprintjs exposes no such scope
* method), so the recorder channel is the ONLY honest source for the
* `removedStageIds` a strategy has to name. That channel is a RECORD:
* under a redaction policy that selects `history` whole, footprintjs
* serves the write masked, the meter cannot see the window's length, and
* an eviction's provenance reads as unknown (`removedStageIds` names none,
* `survivalMs` is 0 — the documented "birth unknown"). The record of a
* masked key loses its shape; the window's decision does not change.
*/
/**
* Build the meter.
*
* Origin tracking assumes the ONE shape the agent's window actually has:
* writes either replace the window wholesale (seed, and resume seeding) or
* append to it (tool-calls). Growth fills the new tail with the writing
* stage's id; a wholesale replacement re-stamps everything with the writer
* that produced it — which is exactly true, since that writer is where those
* messages entered THIS run. Shrink only ever comes from a window strategy,
* and the stage announces it through `rebaseForWindowChange`.
*/
export function compactionMeter(options = {}) {
const id = options.id ?? 'compaction-meter';
const key = options.key ?? 'history';
const now = options.now ?? (() => Date.now());
let origins = [];
let last;
let unmetered = 0;
/** Set by `rebaseForWindowChange`; consumed by the very next write to `key`. */
let pendingRebase;
return {
id,
// A reading survives exactly one iteration boundary — the one it was taken
// for. Anything older means the newest call did not report, and a number
// that is no longer being refreshed is not a measurement any more.
lastCall: (currentIteration) => last !== undefined && last.meteredAtIteration >= currentIteration - 1 ? last : undefined,
unmeteredSinceLastGood: () => unmetered,
origins: () => origins,
rebaseForWindowChange(headCount, keptTailCount, insertedAtMs) {
pendingRebase = {
head: headCount,
tail: keptTailCount,
...(insertedAtMs !== undefined && { bornAtMs: insertedAtMs }),
};
},
clear() {
origins = [];
last = undefined;
unmetered = 0;
pendingRebase = undefined;
},
onWrite(event) {
if (event.key !== key || !Array.isArray(event.value))
return;
const length = event.value.length;
const stageId = event.runtimeStageId;
const atMs = now();
if (pendingRebase !== undefined) {
// The window stage's own write. Anything it INSERTED is born here;
// the head and the kept tail carry their original provenance forward,
// which is the point — a message the strategy did NOT write must
// never end up looking like the strategy wrote it.
const head = origins.slice(0, pendingRebase.head);
const tail = pendingRebase.tail > 0 ? origins.slice(origins.length - pendingRebase.tail) : [];
const inserted = pendingRebase.bornAtMs === undefined
? []
: [{ stageId, bornAtMs: pendingRebase.bornAtMs }];
origins = [...head, ...inserted, ...tail];
pendingRebase = undefined;
// Trust the write's own length over our bookkeeping if they disagree.
if (origins.length !== length)
origins = normalize(origins, length, stageId, atMs);
return;
}
if (length >= origins.length) {
for (let i = origins.length; i < length; i++)
origins.push({ stageId, bornAtMs: atMs });
return;
}
// An unannounced shrink (a consumer stage rewriting history). Nothing
// is inferred: re-stamp to the writer that actually produced this array.
origins = normalize(origins, length, stageId, atMs);
},
noteLlmEnd(raw) {
const payload = raw !== null && typeof raw === 'object'
? raw
: undefined;
const usage = payload?.usage;
if (typeof usage?.input !== 'number' || typeof usage?.output !== 'number') {
// A call happened and reported nothing usable. The PREVIOUS reading is
// deliberately left in place rather than deleted — `lastCall` decides
// whether it is still current, and it will not be for long. What we do
// record is that the provider answered without counting, so the stage
// can say so once instead of the run going quiet.
unmetered += 1;
return;
}
// No iteration on the payload is a shape this build does not produce
// (`LLMEndPayload.iteration` is required), but a reading that cannot say
// WHEN it was taken can never be checked for staleness — so it is not
// taken. Counting it as unmetered is the honest answer.
if (typeof payload?.iteration !== 'number') {
unmetered += 1;
return;
}
unmetered = 0;
last = {
input: usage.input,
output: usage.output,
meteredAtIteration: payload.iteration,
};
},
};
}
function normalize(current, length, stageId, atMs) {
const next = [];
for (let i = 0; i < length; i++)
next.push(current[i] ?? { stageId, bornAtMs: atMs });
return next;
}
//# sourceMappingURL=CompactionMeter.js.map