agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
116 lines (115 loc) • 6.13 kB
TypeScript
/**
* 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).
*/
import type { AgentfootprintEvent } from './registry.js';
/**
* Default retained-event cap — a few hundred KB for a typical turn, and
* far more than any single turn fires.
*/
export declare const DEFAULT_MAX_EVENTS = 10000;
/** A frozen read of the tail: the events kept, and the count of those not. */
export interface EventTailSnapshot {
/** A fresh copy — the tail may keep growing behind the caller's back. */
readonly events: readonly AgentfootprintEvent[];
/** How many events were discarded to stay under the cap. `0` normally. */
readonly dropped: number;
/**
* The ORIGINAL stream position of `events[0]` (9.60.0) — the retained
* window is `[firstRetainedIndex, firstRetainedIndex + events.length)`
* of the stream as fired, so `events[i]` was stream event
* `firstRetainedIndex + i`.
*
* Under oldest-first eviction from one stream this is always equal to
* `dropped` — stated as its own named field because a drop COUNT alone
* cannot say WHICH range is gone: a reader aligning this tail against
* another record of the same run (a commit log, a second recording)
* needs the offset, not just the loss. Before this field, an archived
* envelope could say "312 events dropped" and nothing could say where
* the retained window starts.
*/
readonly firstRetainedIndex: number;
}
/** A bounded tail of one event stream. */
export interface EventTail {
/** Append one event, dropping the oldest if the cap is reached. A repeated
* announcement is kept without taking a slot — see the module header. */
push(event: AgentfootprintEvent): void;
/** How many events are currently retained. */
readonly count: number;
/** How many were discarded to stay under the cap. `0` on a normal turn. */
readonly dropped: number;
/** Original stream position of the oldest retained event — see
* {@link EventTailSnapshot.firstRetainedIndex}. */
readonly firstRetainedIndex: number;
/** Freeze the tail — a fresh array plus the drop count beside it. */
snapshot(): EventTailSnapshot;
}
/**
* 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.
*/
export declare function eventTail(maxEvents?: number): EventTail;
/**
* 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.
*/
export declare const REPEAT_ALLOWANCE = 10;