Window strategies
Three ways to keep the live context window inside its budget — summarize, slide, or drop on a token budget — sharing one refusal engine and one ledger, so every strategy records what it removed, by id.
Every agent framework ships a way to shrink a context window. They shrink it. Then you spend the next morning trying to explain a run whose middle is missing, and the framework has nothing to tell you about what left.
This library ships three, and every one of them records what it removed, by id.
The three
| trigger | what it does | costs | |
|---|---|---|---|
summarizeOldest | counted tokens | folds the oldest span into one summary message | one summarizer call per fold |
slidingWindow | turn count | keeps the last N turns, drops older ones | nothing |
tokenBudget | counted tokens | drops the oldest span, writes no summary | nothing |
import { Agent, slidingWindow, tokenBudget, summarizeOldest } from 'agentfootprint';
Agent.create({ provider, model }).act({ window: slidingWindow({ keepRecentTurns: 12 }) });
Agent.create({ provider, model }).act({ window: tokenBudget({ thresholdTokens: 120_000 }) });
Agent.create({ provider, model }).act({ window: summarizeOldest({ thresholdTokens: 120_000, summarizer }) });The window is one of the five moments of the loop, and .act({ window }) is where it is written — beside whatever this agent does at the other four. It forwards to .window(strategy), the individual door, which is still there and is the right spelling when you are adding a strategy to an agent somebody else built (composing incrementally).
.compaction({...}) is summarizeOldest({...}) spelled shorter — the same agent, byte for byte, and it keeps its own name because compaction is what the market calls that move and it is the one most people want first.
Exactly one strategy per agent. A second through any door throws at build time: a window policy that quietly changed is a policy you cannot audit.
Configure none and nothing changes — no stage, no extra committed key, the same request bytes as an agent that never heard of any of this.
Coming from another framework
Factual mapping, not a scorecard. These are all reasonable designs; they answer a different question than this one does.
| you know | here | the difference |
|---|---|---|
LangChain trim_messages | slidingWindow({ keepRecentTurns }) | ours segments into turns and refuses to split a tool_use from its tool_result, and it writes down what it dropped |
Mastra TokenLimiter | tokenBudget({ thresholdTokens }) | ours counts tokens the provider reported, and refuses by name when the provider reports none |
| Claude Agent SDK compaction | summarizeOldest({ ... }) | ours files the summary as a claim, keeping the folded turns in the commit log verbatim |
The one-sentence differentiator: every strategy here records what it removed, by id.
The law, for all three
A window strategy edits the WINDOW, never the LEDGER.
- the window is
scope.history— whatcall-llmhands the provider. It is what the model sees, and what costs money. - the ledger is the run's commit log. It is append-only. The stages that wrote those turns (
seed#0,tool-calls#7, …) committed them before any strategy ran.
So no strategy can destroy history. It can only stop re-sending it — and say so, in its own recorded step, naming every runtimeStageId whose messages left.
const snapshot = trimmed.getLastSnapshot();const log = snapshot?.commitLog ?? [];const firstToolCalls = log.findIndex((b) => (b.runtimeStageId ?? '').startsWith('tool-calls#'));const ledgerWindow = commitValueAt(log, firstToolCalls, 'history') as | ReadonlyArray<{ role: string; content: string }> | undefined;const originalLog = ledgerWindow?.find((m) => m.content.startsWith('DEPLOY d1'));console.log('\n── the dropped turn, from the ledger ──────────────');console.log(`in the live window? ${window.some((m) => m.content.startsWith('DEPLOY d1'))}`);console.log(`in the commit log? ${originalLog !== undefined}`);console.log(`recovered ${originalLog?.content.length ?? 0} characters, verbatim`);console.log('nothing was summarized, and nothing was lost — only un-sent.');── the dropped turn, from the ledger ──────────────
in the live window? false
in the commit log? true
recovered 1150 characters, verbatim
nothing was summarized, and nothing was lost — only un-sent.One refusal engine
All three resolve what may leave through the same code, so a refusal reason means the same thing everywhere. Never removed, by any of them:
- the current request — the message the run is executing. See The request never leaves;
- each tool's most recent result — up to
keepLastToolResults(default 2) beyond the recent turns. See The evidence stays too; - the system envelope — it never enters the window at all (it rides
systemPrompt); - the last
keepRecentTurnsturns — what the model is reasoning over right now; - any turn holding an unresolved tool call — an assistant
tool_usewith no matchingtool_result. Removing an unanswered question destroys the referent of an answer that has not arrived yet; - the turn a paused run is waiting on — reported as
paused-tool/pending-check-in, separately fromunresolved-tool-call, because "we are waiting on a human" is the fact you need in the trace.
This is why a message-counting trimmer is not the same thing as this: dropping half a tool_use / tool_result pair produces a request the vendor rejects. Here the turn refuses, by name, and the strategy takes the next oldest instead.
Every removal takes a contiguous span, so a turn that refused never ends up sitting after a summary of things that happened before it. A turn that ends the span this iteration is retried the next one — by which point the tool result it was waiting on has usually arrived.
If nothing can be removed, nothing is. The window stays big and the record says why, with a reason per turn. It is never silently truncated.
The request never leaves
No strategy may drop the message you asked for. Everything else in the window is evidence gathered in service of it; a window that keeps the evidence and loses the request leaves a model working from momentum.
This one was caught in a real recorded run, and it is worth stating plainly because the failure is quiet. A ten-iteration tool loop under a small window dropped the window's head at iteration 4 — and on a fresh window the head is the request, because it is the oldest message and every strategy here removes the oldest thing first. From iteration 4 on, the model's context held the tool traffic, a drop notice, and no statement of the objective anywhere. It finished that task by momentum. A longer one would not have.
So the turn holding it refuses, by name:
{
strategy: 'sliding-window',
removedMessageCount: 2,
refusals: [
{ reason: 'current-request', turnIndex: 0, messageIndex: 0 }, // ← it stays
{ reason: 'inside-keep-window', turnIndex: 3, messageIndex: 5 },
],
}The rule lives in the refusal engine, not in the three shipped strategies — so a strategy you write inherits it without knowing it exists, the same way it inherits the tool-pair rules.
Other history drops ahead of it. If the budget cannot hold even the request plus the recent turns, nothing is removed at all: the window stays big, the record names current-request beside every other refusal, and the run proceeds. Keeping the request and reporting the overrun is the right way round — a request the model can no longer see is not a smaller context, it is a different task.
Which message, exactly. The window is a flat message list, so "turn" here means a wire boundary (a message plus the tool results answering it), not a conversational one. The anchor is the latest thing the person said, matched against the message the run was started with. Earlier turns of a multi-turn conversation stay droppable exactly as they were — only the turn being executed is protected. Three kinds of role: 'user' message are written by this library rather than by anybody, and none of them can become the anchor: a drop notice, a compaction frame, and a message an injection delivered.
A window with no identifiable request — hand-built, or seeded entirely from outside the run — has nothing to protect, and behaves exactly as it did before this rule existed.
The evidence stays too
No strategy may drop the latest result of a tool the agent is still using. 9.55.0 kept the task; this keeps what the task needs.
It comes from the same place — a context-gap audit over recorded runs. An agent
drove a screen through tools. One tool result carried the only list of ids it
could act on. Under slidingWindow({ keepRecentTurns: 2 }) that result survived
about two iterations (an assistant message plus its tool results is ONE turn,
so two kept turns are two tool rounds). The request stayed, so the model still
knew what it had been asked to do — and no longer had the evidence to do it.
Across five runs it assembled a plausible id out of an entity name it remembered
plus the shape of an id it had used earlier, and was refused. In one archived run
the final answer to the person named a host that appears in no tool result.
So the turn refuses, by name:
{
strategy: 'sliding-window',
removedMessageCount: 2,
refusals: [
{ reason: 'current-request', turnIndex: 0, messageIndex: 0 },
{ reason: 'last-tool-result', turnIndex: 1, messageIndex: 1 }, // ← the ids stay
],
droppedObservations: ['focus'],
observations: {
pinned: [{ toolName: 'whats_here', turnIndex: 1, chars: 1531 }],
yielded: 0,
limit: 2,
},
}One pin per tool NAME, always the latest result, superseded the moment that
tool answers again — so the candidate space is your tool roster, not the
transcript. Three more bounds compose on top: a parallel batch is one turn and
costs one slot; a pin already inside keepRecentTurns costs nothing; and nothing
at or before the current request is pinnable, so a new user turn releases the
whole previous loop. The floor is 1 request + keepLastToolResults pins + keepRecentTurns turns, whatever your tool count, iteration count or run length.
The dial is on the agent, not the strategy — so a strategy you wrote inherits it:
Agent.create({
provider,
model,
keepLastToolResults: 2, // the default. `false` or 0 restores 9.56.0 exactly.
}).window(slidingWindow({ keepRecentTurns: 2 }));What it gets wrong, said out loud. The pin is CONTENT-BLIND: it keeps a
tool's last result, which may be a one-word acknowledgement while the
load-bearing screen dump was the call before. Two slots absorb the common case;
nothing eliminates it. And under summarizeOldest a pinned turn stays raw
while everything around it is folded — pinned bytes are the last bytes a
compaction strategy can reduce.
A pin that blocks stands down. If the two previous visits both removed
nothing and both named last-tool-result, the pin is provably what is stopping
progress, so it releases for one visit and files
observations.standDown: true. Two consecutive blocked boundaries is the hard
bound, under any strategy including one you wrote. It is recorded rather than
done quietly: a policy that reverses itself has to say so.
A drop says whose results left
When a tool result does leave, the model is told which tool it came from — and told what to do about it:
[dropped history — 4 earlier message(s) were dropped from this window at iteration 9
by the 'sliding-window' window strategy. Tool results are among them (focus, pan_view) —
call the tool again if you need its output; do not reconstruct ids or values from
memory. Nothing was summarized: those turns are simply not being re-sent. They are
retained verbatim in this run's commit log.]The drop is stated rather than silent. Whether that sentence changes what a
model does next is not measured — the archived runs behind this release have not
been re-run with it on — so it ships as an honesty fix, not as a performance
claim. The pin is the half that is measured. Tool names
are the only caller data that reaches this message, and they are shape-filtered
to a plain identifier and dropped, never truncated, when they are not one —
so a drop still has no prompt-injection surface to speak of. At most four names
appear, then ….
The record carries the same fact uncapped and unfiltered, as
droppedObservations — and it is filed even when no notice was authored at all.
A removal further into the window inserts nothing, so the model is told nothing;
then the record is the only witness.
When a drop is abandoned
The authored notice exists for the wire: an agent window looks like user, assistant+tool, assistant+tool, …, and the providers that care require the
window to open on a user turn. So the decision is a ladder — the notice naming
the dropped tools, else the plain notice, else no notice at all. The removal
is abandoned under replacement-not-smaller only when the wire genuinely needs a
message in that position and none of them is smaller than the span it replaces.
Through 9.56.0 the size test was applied whenever the removal reached the front of what may leave, without asking whether the message that would become the head was already a user turn. When it was — the pinned request, or an older turn of a restored conversation — a notice nobody needed could veto a legitimate drop; and because the span is the longest contiguous removable run, the same verdict came back at every boundary while the window grew without bound. Reproduced by execution, fixed in 9.57.0.
What each visit records
Every strategy appends a WindowRecord to scope.compactions — including the visits that removed nothing, which are the interesting ones:
{
strategy: 'sliding-window', // narrow on this
iteration: 5,
removedStageIds: ['seed#0', 'tool-calls#23'], // real ids, resolvable in the log
removedMessageCount: 3,
windowCharsBefore: 4656,
windowCharsAfter: 3693, // EXACT, and chars — not tokens
refusals: [{ reason: 'inside-keep-window', turnIndex: 3, messageIndex: 5 }],
droppedObservations: ['whats_here'], // whose RESULTS left — full, uncapped
observations: { // what the pin held, and what it cost
pinned: [{ toolName: 'pan_view', turnIndex: 2, chars: 812 }],
yielded: 0,
limit: 2,
},
// …plus the strategy's own facts: keepRecentTurns/turnsBefore/turnsAfter for
// slidingWindow; measuredTokens/thresholdTokens/overBudget for the two
// token-triggered ones; summaryChars/summarizerTokens for summarizeOldest.
}There is deliberately no tokensAfter. Nothing can count the tokens of a window that has not been sent yet, and inventing one would be the exact guess this family exists to refuse. The honest "after" is the next call's reported usage.
The key is compactions because that is what it shipped as in 7.16, when compaction was the family's only member. It is committed state — public surface for anyone reading a run — so it keeps its name rather than break every reader for a better word. Narrow a record by its strategy field, not by the key it lives under.
On the event stream
No new event types were added for any of this:
agentfootprint.context.evicted— one per message that left, with a realsurvivalMsand the samecontentHashthe messages slot used when it reported that piece as injected;agentfootprint.context.budget_pressure— one per over-budget visit, from the strategies that have a budget.slidingWindownever emits it: it triggers on turn count, and reporting acapnobody configured would be an invented number.
slidingWindow
.act({ window: slidingWindow({ keepRecentTurns: 12 }) })Keeps the most recent keepRecentTurns turns and drops what is older. No summarizer, no LLM call, no usage requirement — so it runs on any provider, including the OpenAI-compatible endpoints (Ollama, vLLM) that send no usage while streaming. Nothing here is unmeasurable, so nothing here throws.
keepRecentTurns is required and has no default. It is the policy: how much past your agent needs is a fact about your agent, not about this library.
// The general door. `.compaction({...})` is `.window(summarizeOldest({...}))`// spelled shorter; these are its two siblings.const trimmed = Agent.create({ provider: provider ?? scriptedProvider(false), // reports NO usage — fine here model: 'mock', maxIterations: 8,}) .system('You audit deployments. Read the logs before answering.') .tool(readLog) // keepRecentTurns is required and has no default: how much past your // agent needs is a fact about your agent, not about this library. .window(slidingWindow({ keepRecentTurns: 3 })) .build();const capped = Agent.create({ provider: provider ?? scriptedProvider(true), // MUST report usage model: 'mock', maxIterations: 8,}) .system('You audit deployments. Read the logs before answering.') .tool(readLog) .window(tokenBudget({ thresholdTokens: 1_500, keepRecentTurns: 3 })) .build();tokenBudget
.act({ window: tokenBudget({ thresholdTokens: 120_000, keepRecentTurns: 6 }) })Compaction's trigger, without the summarizer. The number is counted, never guessed: it reads the input tokens the provider itself reported for the last call, off the stream.llm_end event the adapter already emits.
Which means it has the same honest failure mode, and makes the same refusal by the same name:
CompactionUnmeasurableError: Compaction is counted, not guessed: provider 'acme'
reported 0 input and 0 output tokens for the last call, so the window cannot be
measured against thresholdTokens. …CompactionUnmeasurableError is thrown by both token-triggered strategies — summarizeOldest and tokenBudget. It kept its 7.16 name rather than gain a synonym. It is terminal: resuming would walk into the same wall with the same adapter. anthropic(), openai(), bedrock() and the mock provider all report usage.
Use tokenBudget over summarizeOldest when you would rather lose the old turns than pay a model to paraphrase them, and over slidingWindow when the thing you are defending is a token bill rather than a turn depth.
What a drop leaves behind
When a drop removes everything in front of the window, the library inserts one authored notice in its place:
[dropped history — 3 earlier message(s) were dropped from this window at iteration 5
by the 'sliding-window' window strategy. Your current request is kept — it is still
in this window, above this line, and no window strategy may drop it. Nothing was
summarized: those turns are simply not being re-sent. They are retained verbatim in
this run's commit log.]The middle sentence appears when the drop stopped short of the head because the request was sitting there — which is the ordinary case for an agent's first turn. The notice then takes the position just after it. A model that reads "3 earlier messages were dropped" and then finds a request above it should be told which of the two facts to trust.
The first reason for it is the wire, not the prose. An agent window looks like user, assistant+tool, assistant+tool, …, so dropping the oldest turns leaves an assistant message at the head — and the providers that care require the window to open on a user turn. Something has to occupy that position. Given that we must author a message there anyway, it should say what happened rather than be filler.
Unlike the compaction frame, no model wrote a word of it: every character is a library constant plus a count. A drop makes no LLM call, so there is no summarizer output to quarantine.
It appears only when the removal reaches the front of what may leave. A removal further in leaves the original opening turn in place, so there is no wire problem to solve — and splicing a lone user message between two assistant turns is its own risk. The record names that removal either way.
It never accumulates: next iteration the notice is an ordinary oldest turn, so the following drop absorbs it and files a fresh one. And if no version of it would be smaller than the span it replaces, the notice is simply not written — or, when the wire genuinely needs one in that position, the whole drop is abandoned under replacement-not-smaller. See When a drop is abandoned.
Recognise it without matching on prose:
import { DROP_NOTICE_PREFIX, isDropNotice } from 'agentfootprint';
const rendered = history.map((m) => (isDropNotice(m) ? '⟨dropped⟩' : m.content));Writing your own
The window key — and .window(...) behind it — takes any object satisfying WindowStrategy:
import type { WindowStrategy, WindowStrategyInput, WindowStrategyResult } from 'agentfootprint';
const myStrategy: WindowStrategy = {
name: 'my-strategy',
async plan(input: WindowStrategyInput): Promise<WindowStrategyResult | undefined> {
if (/* not my moment */ false) return undefined; // did not engage
const plan = input.planRemoval(6); // THE refusal engine
// …decide, then file a record naming what left.
},
};Two things are deliberately not left to you:
- The refusal rules.
planRemovalarrives already bound to this iteration's turns and guards. Your strategy never receives the guards, only the answer — so it cannot forget that an unanswered tool call must not leave, or that the current request never does. That is safety by construction, not by documentation. - Provenance.
removalFacts(indices, atMs)turns "these indices left" into the stage ids that wrote them and how long each lived. You cannot file a removal you are unable to name.
The trigger is entirely yours: plan is called at every ReAct iteration boundary and returns undefined when your strategy did not engage. That is exactly how slidingWindow runs on a provider that reports nothing while the token-triggered ones refuse by name.
A strategy that replaces messages with something standing for them may also answer with folded — spans the stage carries onto the conversation checkpoint, so a restart can still say what the replacement stands for and, when the policy retained them, produce the originals. 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. See Durable compaction.
Each shipped factory is its own module and registers nothing at import, so a bundle that never mentions summarizeOldest never carries the summarizer machinery.
Where it runs
The window stage is the ReAct loop target when configured — the first thing each iteration, before the injection engine re-evaluates triggers and before the three context slots compose. So the triggers, the slots and the wire all see one window, and no part of the run reasons over a past the model was not shown.
Both chart shapes support it (reactMode: 'dynamic', 'classic' and 'dynamic-grouped'); in the grouped shape it sits in the outer chart, because the window crosses the sf-llm-call boundary as a read-only input.
agent.checkpoint() carries a trimmed window, resumeOnError(checkpoint) restores it, and a standing agent across a restart just keeps talking — a drop notice and a compaction summary are both ordinary messages in the history it was handed. A compaction summary carries one thing more: the span behind it rides checkpoint().folded, so what it stands for survives the process too — see Durable compaction.
What the package exports
Everything below comes from the package root, agentfootprint:
| export | what it is |
|---|---|
slidingWindow(options) | The factory for the keep-the-last-N-turns strategy. |
tokenBudget(options) | The factory for the counted-token drop strategy. |
summarizeOldest(options) | The factory .compaction(...) uses, exposed so you can pass it to the window key yourself. |
SlidingWindowOptions | What slidingWindow accepts: keepRecentTurns (required — it is the policy). |
TokenBudgetOptions | What tokenBudget accepts: thresholdTokens (required) and keepRecentTurns (default 6). |
CompactionOptions | What summarizeOldest and .compaction() accept. |
WindowStrategy | The seam: { name, plan(input) }. Implement it to write your own. |
WindowStrategyInput | Everything a strategy may look at, including the bound planRemoval and removalFacts. |
WindowStrategyResult | What a strategy answers with — the new window, the record, the evictions, optional folded spans, an optional budget reading, an optional spend. Return undefined instead to say "I did not engage". |
FoldedSpan | One span carried onto the conversation checkpoint: the replacement's fingerprint, what it stands for, and — when retained — the messages themselves. |
WindowRecord | The record every strategy files, shown above. |
SlidingWindowRecord | A WindowRecord plus keepRecentTurns, turnsBefore, turnsAfter. |
TokenBudgetRecord | A WindowRecord plus measuredTokens, thresholdTokens, overBudget, keepRecentTurns. |
CompactionRecord | A WindowRecord plus the summarizer's facts — see Compaction. |
WindowObservations | What the last-tool-result pin did on one visit: pinned (tool, turn, exact chars), yielded, limit, and standDown when it released. |
WindowRefusal / WindowRefusalReason | One named refusal, and the closed set of reasons. (FoldRefusal / FoldRefusalReason were the 7.16 names; the aliases were removed in 9.0.0.) |
WindowEviction | One message leaving the window: its index in the pre-change window and its measured survivalMs. |
RemovalPlan | What planRemoval answers: the span { from, to } in turn indices, plus every refusal. from is -1 when nothing may be removed. |
RemovalFacts | What removalFacts answers: removedStageIds and one WindowEviction per message. |
Turn | One turn of the segmentation: a user / assistant / system message plus every tool message answering it. |
DROP_NOTICE_PREFIX | The opening of the authored drop notice, exported so your code can recognise one without matching on prose. |
isDropNotice(msg) | The same recognition as a predicate, for filtering a history you are rendering or persisting. |
CompactionUnmeasurableError | Thrown by both token-triggered strategies when the provider reports no usage, carrying .provider. Terminal. |
Related
- Compaction —
summarizeOldestin depth: the authored frame, the summarizer boundary, and why a summary is filed as a claim - Agent — the loop a strategy plugs into
- Debugging a run — reading the ledger a removal left intact
Compaction
Keep the live context window inside a token budget by folding the oldest turns into a summary — while the commit log keeps every folded turn, byte for byte.
Skills
defineSkill — LLM-activated body + tools. The LLM calls read_skill('billing') to load a body of guidance for the rest of the turn; autoActivate scopes the skill's tools to that window too. The shipped Skills surface today; full conceptual essay in skills-explained.
