agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
260 lines (259 loc) • 13 kB
TypeScript
/**
* window/strategy — PUBLIC. How a window strategy is shaped.
*
* Pattern: Strategy (GoF), with the dangerous half of the decision handed in
* pre-bound rather than left to the implementer to re-derive.
* Role: core/ layer. The window stage does the wiring — read the meter,
* write the window, emit, record, cost — and delegates the one
* interesting question to a strategy:
*
* given the segmented turns and what the provider actually
* counted → what should the window become, and what does the
* ledger need to be told about it?
*
* Emits: N/A. A strategy is pure decision + (optionally) its own LLM call;
* it never touches scope, never emits, and never writes. That is
* what makes it testable without a chart, and what keeps the
* "record everything you did" duty in ONE place (the stage) rather
* than duplicated per strategy.
*
* Three strategies ship — `summarizeOldest` (what `.compaction()`
* configures), `slidingWindow`, `tokenBudget` — and `.window(...)` takes any
* object that satisfies `WindowStrategy`.
*
* Two things are deliberately NOT left to the implementer:
*
* 1. **The refusal rules.** `planRemoval` arrives already bound to this
* iteration's turns and guards. A strategy cannot forget that an
* unanswered tool call must not leave the window — or that the CURRENT
* REQUEST never does (9.55.0) — because it never gets the chance to
* decide that for itself: it asks, and it is told, with every refusal
* named. That is safety by construction, not by docs. It is also why a
* rule added there arrives in every strategy at once, including the ones
* consumers wrote before the rule existed.
* 2. **Provenance.** `removalFacts` turns "these indices left" into the
* stage ids that wrote them and how long each lived. A strategy cannot
* file a removal it cannot name.
*
* The TRIGGER, by contrast, is entirely the strategy's own: `plan` is called
* at every ReAct iteration boundary and answers `undefined` when it did not
* engage. That is why `slidingWindow` can run on a provider that reports no
* usage at all while `summarizeOldest` and `tokenBudget` refuse by name.
*/
import type { LLMMessage, LLMProvider } from '../../../provider-contract/types.js';
import type { Standing } from '../findings/types.js';
import type { Turn, RemovalPlan } from './turns.js';
import type { FoldedSpan, WindowRecord } from './types.js';
/** One message leaving the window, with the facts an eviction event needs. */
export interface WindowEviction {
/** Index in the PRE-change window — the index the content hash was built on. */
readonly index: number;
/** How long it lived in the window. Exact; 0 when its birth is unknown. */
readonly survivalMs: number;
}
/** The provenance of a set of removed messages, as the ledger needs it. */
export interface RemovalFacts {
/** `runtimeStageId`s of the stages that appended those messages, in order. */
readonly removedStageIds: readonly string[];
/** One eviction per message, with its measured lifetime. */
readonly evictions: readonly WindowEviction[];
}
/** Everything a strategy is allowed to look at. */
export interface WindowStrategyInput {
/** The window as it stands, detached. */
readonly history: readonly LLMMessage[];
/** The same window, segmented into turns. */
readonly turns: readonly Turn[];
/**
* What the provider REPORTED for the last completed call. Counted, never
* guessed. `undefined` before the first call of the run — a strategy that
* acted on that would be guessing, which is the one thing this family
* refuses to do.
*
* `{ input: 0, output: 0 }` is a provider that reported NOTHING, not a call
* that cost nothing. A token-triggered strategy should throw
* `CompactionUnmeasurableError` there rather than invent a size.
*/
readonly measured: {
readonly input: number;
readonly output: number;
} | undefined;
/** The ReAct iteration this decision belongs to. */
readonly iteration: number;
/**
* The run this decision belongs to.
*
* A strategy that retains what it removed has to name the run whose commit
* log held it — that is the honest answer to "where else could I have found
* this?", and the answer is "nowhere, once that process ended", which is the
* whole reason retention exists. `'unknown'` when the runtime could not name
* the run, never a fabricated id.
*/
readonly runId: string;
/** The agent's own model — the sensible default for a strategy that bills. */
readonly agentModel: string;
/** `provider.name` of the MAIN provider, for a refusal that names it. */
readonly providerName: string;
/** The run's cancellation signal, when there is one. */
readonly signal: AbortSignal | undefined;
/** Wall clock, injectable so a caller can pin `survivalMs`. */
readonly now: () => number;
/**
* THE shared refusal engine, bound to this iteration.
*
* Answers: which contiguous span of turns may leave, and every turn that
* refused, named. Never removes the system envelope, the CURRENT REQUEST,
* the last `keepRecentTurns` turns, an unanswered tool call, the paused
* tool, or a pending check-in.
*
* @param keepRecentTurns how many trailing turns are off-limits
* @param isExistingSummary optional predicate marking a turn that is a
* summary a previous fold wrote; when the whole span is one of those, the
* plan refuses with `only-existing-summary`. Pass it only if your strategy
* spends an LLM call — a drop has nothing to protect against.
*/
readonly planRemoval: (keepRecentTurns: number, isExistingSummary?: (turn: Turn) => boolean) => RemovalPlan;
/**
* Turn removed message indices into the facts the ledger needs: which
* stages wrote them, and how long each lived in the window.
*
* @param indices indices in the PRE-change window that are leaving
* @param atMs the moment they leave (usually `input.now()`)
*/
readonly removalFacts: (indices: readonly number[], atMs: number) => RemovalFacts;
/**
* What the model DECLARED a turn's results to be, on its findings ledger
* (9.102.0) — `fact`, `open`, `noise`, `ruled-out`, or `undefined` when it
* said nothing (undeclared; never defaulted to `'open'`). A Turn is the
* removal unit, so a turn's standing is its most valuable result's
* (`fact > open > undeclared > ruled-out > noise`, the
* `ledgerFactPins.ts · turnStandingOf` rule); a turn with no tool result
* has none. A message the batch settlement wrote for a call that never ran
* (9.113.0, `LLMMessage.notDispatched`) is no result and takes no part.
*
* BOUND BY THE STAGE, like `planRemoval` and `removalFacts`: the stage is
* the one place that reads scope, and it resolves the ledger ONCE per visit
* and hands the answer in. A strategy never reads scope for it — that is
* the strategies/README one law, and it is what keeps a consumer-written
* strategy honest by construction.
*
* OPTIONAL, and absent on an agent without `.findings()`: an unarmed agent
* has no ledger to ask, and a strategy compiled before this field existed
* keeps compiling. A strategy does not NEED it to get 'facts last' — the
* hold lives in the refusal engine (`'ledger-fact'`) and arrives through
* `planRemoval` under every strategy; this is for one that wants to ORDER
* or REPORT among the turns the engine left removable. Never infer a
* standing from a result's text: absent here means the model said nothing.
*/
readonly standingOf?: (turn: Turn) => Standing | undefined;
}
/** What the stage should do next. */
export interface WindowStrategyResult {
/** The new window. Absent = leave the window alone. */
readonly window?: readonly LLMMessage[];
/**
* How the meter must re-align its provenance to the new window, which is
* `[...head, (one new message)?, ...tail]`. Present exactly when `window`
* is. `insertedAtMs` is the birth of the message the strategy put in the
* span's place — omit it when the strategy removed messages and inserted
* nothing.
*/
readonly rebase?: {
readonly headCount: number;
readonly keptTailCount: number;
readonly insertedAtMs?: number;
};
/** What the ledger is told. Always present — an engaged visit explains itself. */
readonly record: WindowRecord;
/** Messages that left the window, for `context.evicted`. */
readonly evictions: readonly WindowEviction[];
/**
* Spans this visit removed, in the form that OUTLIVES the process: appended
* to the conversation checkpoint, so a restart can still say what a summary
* stands for — and, under `retain: 'conversation'`, produce it verbatim.
*
* OMIT IT unless your strategy replaced messages with something that stands
* for them. `summarizeOldest` fills it because a summary is a claim that
* needs its evidence; the drop strategies do not, because a drop replaces
* nothing and its authored notice claims nothing.
*
* The stage writes these in the SAME commit as the window change, so there
* is no state in which messages left the window and the record of what they
* were did not follow them.
*/
readonly folded?: readonly FoldedSpan[];
/**
* The budget reading to report on `agentfootprint.context.budget_pressure`.
*
* OMIT IT when the strategy has no token budget. `slidingWindow` does: it
* triggers on turn count, and filling `capTokens` with a number nobody
* configured would be the invented figure this family refuses. No budget,
* no budget_pressure event.
*
* `unit` says what the two numbers count, because the context SLOTS emit
* this same event name with the same `slot: 'messages'` and count in CHARS.
* It defaults to `'tokens'` — every shipped strategy compares against a
* `thresholdTokens`, so that is what all three already mean. Set it to
* `'chars'` if yours measures characters, and the event will say so.
*/
readonly budgetPressure?: {
readonly capTokens: number;
readonly projectedTokens: number;
readonly planAction: 'evict' | 'summarize' | 'none';
readonly unit?: 'chars' | 'tokens';
};
/** A billed call the strategy made, for the cost channel. */
readonly spend?: {
readonly model: string;
readonly usage: {
readonly input: number;
readonly output: number;
};
};
/** A one-per-run dev warning the stage should print. */
readonly warning?: string;
}
/**
* A window strategy: what the live window should become at this iteration
* boundary, and what the record must say about the change.
*
* Pass one to `AgentBuilder.window(...)`. Exactly one per agent —
* `.compaction(...)` is the same door with `summarizeOldest` already in it.
*/
export interface WindowStrategy {
/**
* Stable name — it is written onto every record this strategy files
* (`WindowRecord.strategy`), so a reader can tell which policy produced a
* window, and it names the strategy on the chart's `compact` stage.
*/
readonly name: string;
/**
* What this strategy will BILL, when it bills anything (8.14.0).
*
* Omit it if your strategy makes no LLM call — the drop strategies do, and
* a strategy that spends nothing has no billing to declare.
*
* It exists so the agent BUILDER can check a strategy's spending against
* the agent's own provider and model before the first run. Without it,
* `.compaction({...})` could be refused for a configuration that
* `.window(summarizeOldest({...}))` — the same strategy through the other
* door — accepted silently. Two doors onto one policy must refuse the same
* things, or the refusal is advice rather than a rule.
*/
readonly billing?: {
readonly provider: LLMProvider;
readonly model: string;
};
/**
* Decide. Called at EVERY ReAct iteration boundary.
*
* Return `undefined` when this strategy did not engage — nothing was over
* budget, nothing was old enough, nothing has been counted yet. The ledger
* stays untouched and the run proceeds.
*
* Return a result for anything else, INCLUDING a visit that changed
* nothing because every candidate refused. Those are the visits a person
* debugging an oversized window actually needs.
*/
plan(input: WindowStrategyInput): Promise<WindowStrategyResult | undefined>;
}