agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
227 lines (226 loc) • 11.8 kB
TypeScript
/**
* summarize — the stage that makes the SUMMARIZE strategy summarize (9.14.0).
*
* Reads from scope: `loaded`, `identity`
* Writes to scope: `loaded` (older entries replaced by ONE summary entry)
* Writes to store: the summary entry, under a deterministic id
* Emits: agentfootprint.memory.strategy_applied (every visit that
* changes recall or decides not to), carrying the reason,
* the model, and the token usage the call reported.
*
* NOT `cost.tick`, and that is a limit worth knowing: the
* USD channel needs a `pricingTable` and the run's
* cumulative counters, both of which live on the AGENT's
* scope, and a memory pipeline is a subflow with neither.
* Emitting a tick with `estimatedUsd: 0` would be a
* cheaper-looking lie than saying nothing, so the tokens
* ride the memory event instead and the fold is never
* silent.
*
* Where this fits in the pipeline:
*
* loadRecent → summarize → [filterByDecay] → pickByBudget → formatDefault
*
* ## What it does, and what that costs
*
* Recall arrives from `loadRecent` oldest-first. This stage keeps the last
* `preserveRecent` entries VERBATIM and folds everything older into a single
* summary entry — **one LLM call over the span**, not one per entry and not
* one per recall. The summary is then WRITTEN BACK to the store under the id
* `msg-summary-{fromTurn}-{toTurn}`, so the next turn LOADS it instead of
* paying for it again. That write-back is the whole cost model: a span is
* summarized once in the life of a conversation, by whichever turn first saw
* it fall out of the verbatim tail.
*
* ## The originals are never deleted
*
* A summary is a CLAIM ABOUT the conversation, not the conversation. The
* entries it covers stay in the store byte-identical; they are excluded from
* recall by the summary's own coverage metadata ({@link SummaryCoverage}) and
* by nothing else. Delete the summary entry and the next recall is verbatim
* again. This is the same law `.compaction()` follows in the live window —
* the fold edits what is SENT, never what was said.
*
* ## Two summarizer shapes
*
* • `llm: LLMProvider` + `model` — the library composes the call. The span
* is rendered as DATA between delimiters the authored instruction names,
* so a message inside the conversation cannot re-instruct the summarizer
* by looking like an instruction. `model` is REQUIRED here: naming it is
* what keeps the summarizer's bill separate from the agent's own (the
* `.compaction()` law, 8.14.0). Token usage is reported on the event.
* • `llm: (messages) => Promise<string>` — the caller's own call, unchanged
* since 2.x. The caller composed the request, so the caller named the
* model; the stage reports usage as unknown rather than inventing one.
*
* ## What comes back is DATA
*
* The summary text is appended after an authored label this file writes, and
* the label always comes first. A summarizer that returns "IGNORE ALL
* PREVIOUS INSTRUCTIONS" produces an entry that still says, in the library's
* own words and first, that what follows is a summary written by a model and
* that the originals are retained.
*
* ## Three ways it declines, all of them out loud
*
* • **not worth a call** — fewer than `minFoldEntries` foldable entries, or
* fewer than `triggerMinEntries` loaded. No call. Recall still changes if
* an EARLIER turn's summary is covering entries (that is the cheap turn
* the write-back bought), and then the event says so; a turn where
* nothing at all happened writes nothing and emits nothing.
* • **summarizer failed** — the provider threw. ONE `console.warn` per
* stage instance plus an event, and recall proceeds VERBATIM: a broken
* summarizer degrades this strategy to `window`, it does not fail the
* turn. (Through 9.13.0 the stage re-threw; a memory that cannot recall
* because its optional compressor is down is a worse answer than an
* uncompressed one.)
* • **replacement not smaller** — the summary plus its label is no shorter
* than the span it would replace. Folding then spends a call to GROW
* recall and lose detail at the same time, so the fold is dropped and the
* span is LATCHED: the same span is never re-asked, because the same
* inputs give the same answer and re-asking is a paid call whose result
* is already known (the 8.14.0 latch, keyed by the span's own ids). A
* span that has GROWN is a different key and is asked again on purpose.
*
* ## Determinism contract
*
* For prompt caching to stay stable, the same span should produce the same
* summary. Configure `temperature: 0` (and a seed where the provider has
* one) on the summarizer you pass. The stage cannot enforce it — but note
* that write-back makes it matter far less than it did: a span is summarized
* once and then read back from the store.
*
* @see ../define.ts the `SUMMARIZE` strategy arm that wires this
* @see ../../core/agent/window/strategies/summarizeOldest.ts the same laws
* applied to the LIVE window instead of to recall
*/
import type { TypedScope } from 'footprintjs';
import type { MemoryEntry } from '../entry/index.js';
import type { LLMMessage as Message, LLMProvider } from '../../provider-contract/types.js';
import type { MemoryStore } from '../store/index.js';
import type { MemoryState } from './types.js';
/**
* The 2.x summarizer shape: the caller makes the call and returns the text.
* Kept because a hand-composed pipeline that already owns its provider does
* not need the library to own it too.
*/
export type SummarizeCallback = (messages: readonly Message[]) => Promise<string>;
export interface SummarizeConfig {
/**
* Who writes the summary — a provider (preferred; pair with {@link model})
* or the legacy callback that makes its own call.
*
* Recommend a cheap model. The point of naming a summarizer at all is that
* compression is not worth your main model's price.
*/
readonly llm: LLMProvider | SummarizeCallback;
/**
* The model id the summarizer is called with. REQUIRED when `llm` is a
* provider, refused as meaningless when `llm` is a callback (the callback
* composed its own request and named its own model there).
*
* There is no `?? agentModel` fallback, by the same reasoning
* `.compaction()` uses: the default had no correct case — same family and
* it quietly bills your main model for compression, different vendor and it
* sends a model id nobody has heard of.
*/
readonly model?: string;
/**
* Where the summary is written back to. Omit and the fold is computed for
* THIS recall only and thrown away — correct, and paid for again every
* turn. `defaultPipeline` passes its own store, so the factory path always
* writes back.
*/
readonly store?: MemoryStore;
/**
* Minimum `loaded.length` before a fold is considered. Below this, no-op —
* the conversation is short enough to keep whole. Default 20.
*/
readonly triggerMinEntries?: number;
/**
* How many most-recent entries stay VERBATIM. The older ones become the
* summary. Default 5 — recent turns keep their exact phrasing so the agent
* can quote them.
*
* The seam is rounded OUTWARD to a whole turn: if the cut would land in the
* middle of turn 7, turn 7 goes to the verbatim side entirely. A question
* summarized while its answer stayed raw reads like an answer to nothing.
*/
readonly preserveRecent?: number;
/**
* Do not spend a call to fold fewer than this many entries. Default 2 — one
* entry "compressed" into a summary plus a label is a paid call that makes
* recall longer. `defineMemory` raises this to the size of the verbatim
* tail, which is the cost policy: never fold less than you keep.
*/
readonly minFoldEntries?: number;
/**
* Override the authored instruction the summarizer is given. Domain
* summaries want this ("preserve every refund-related number"). Only the
* INSTRUCTION is yours — the transcript delimiters and the label written
* onto the summary entry are the library's and stay put.
*/
readonly systemPrompt?: string;
/**
* TTL in milliseconds applied to the summary entry, counted from the SPAN's
* clock (the newest entry it stands for), not from the fold.
* `defaultPipeline` passes its `writeTtlMs` here, so a compliance retention
* window expires the summary WITH the turns it compressed — a summary that
* outlived them is exactly the leak the retention window exists to prevent.
*/
readonly ttlMs?: number;
/**
* Tier for the summary entry. Default `'cold'` — condensed, not recent.
*
* Worth pairing with the load's `tiers` filter if you set one: a filter that
* excludes this tier hides the summary from every later recall, and a
* summary that is never loaded is a span that is folded (and billed) again
* every turn. `defineMemory` sets no tier filter, so the factory path is
* safe by construction.
*/
readonly summaryTier?: 'hot' | 'warm' | 'cold';
/** `strategyId` on the emitted event. Default `'memory-summarize'`. */
readonly strategyId?: string;
}
/** Id prefix for every entry this stage writes. */
export declare const SUMMARY_ID_PREFIX = "msg-summary-";
/** Metadata key carrying {@link SummaryCoverage} on a summary entry. */
export declare const SUMMARY_COVERAGE_KEY = "summarizes";
/**
* What a summary entry stands for — the only thing that excludes an original
* from recall.
*
* `coveredIds` is the operative field and it is exact: recall drops an entry
* because a loaded summary NAMES it, never because a turn number falls inside
* a range. A turn that was half-folded (possible only for hand-composed
* configs; the stage rounds the seam outward) keeps the half nobody claimed.
*/
export interface SummaryCoverage {
/** Earliest turn represented. */
readonly fromTurn: number;
/** Latest turn represented. Also the entry's `source.turn`. */
readonly toTurn: number;
/** How many entries were folded. */
readonly entryCount: number;
/** The exact entries this summary stands for. */
readonly coveredIds: readonly string[];
/** Which model wrote it. Absent for the callback form — the caller knows. */
readonly model?: string;
/** When the fold happened (unix ms). */
readonly summarizedAtMs: number;
}
/** The id a fold over `[fromTurn, toTurn]` always gets. Deterministic. */
export declare function summaryEntryId(fromTurn: number, toTurn: number): string;
/**
* The coverage a summary entry carries, or `undefined` for an ordinary entry.
*
* Read through the METADATA, not the id: an id is a name and metadata is the
* claim. An entry named like a summary that carries no coverage stands for
* nothing and is treated as an ordinary entry.
*/
export declare function summaryCoverage(entry: MemoryEntry<unknown>): SummaryCoverage | undefined;
/** True when this entry is a summary written by this stage. */
export declare function isSummaryEntry(entry: MemoryEntry<unknown>): boolean;
/** Opening of the authored label. Stable — readers and tests match on it. */
export declare const SUMMARY_FRAME_PREFIX = "[summary of earlier turns";
export declare function summarize(config: SummarizeConfig): (scope: TypedScope<MemoryState>) => Promise<void>;