Skip to main content

Context management

Replaying a whole transcript works until it doesn't. Once a conversation outgrows the model's window, every later turn fails with "context length exceeded" — permanently, because the message that tipped it over is still in the history.

This is configured per project (Dashboard → Context management), not per request.

StrategyWhat it does
trim (default)Drop the oldest material until it fits. Cheap, no added latency.
summarizeCompact the old prefix into one summary, keep recent turns verbatim. One model call on the turn that crosses the budget; the summary is stored, so later turns are free.
noneReplay everything. Fine for short conversations; long ones will start failing.

Under trim, what goes first is configurable. tool_output_first (the default) guts old tool outputs before dropping whole messages — a command's output can be produced again, the reasoning around it cannot.

What always holds

  • Below the budget, nothing changes. Every strategy replays the transcript unaltered. This only ever activates on a conversation that would otherwise break.
  • The task survives. The first user message and the most recent turns are never evicted.
  • The transcript stays valid. Eviction is followed by a repair pass, so trimming can never leave the agent with tool calls nothing answered.
  • Nothing is deleted. Summarized messages keep their content; GET /chat/sessions/{id}/messages still returns the real conversation.

Budget

Leave the token budget blank and it is derived from the model's own window, minus a reserve for the reply. A token can narrow it further via a max_context_tokens claim (maxContextTokens when minting) — like every numeric ceiling here it only ever narrows, so a cheap tier can be given a shorter memory without its own project.

Checking that a ceiling applied. The report says whose number bounded the turn:

{ "budget": 4096, "budget_from": "token", "tokens_before": 8017, "tokens_after": 4087,
"truncated": 1, … }

budget_from is "token", "project" or "model", and a report arrives for a token ceiling even on a turn small enough that nothing had to be cut — so you can confirm the tier you are selling without waiting for it to bite.

Reading a marker back does not prove the history was kept

The first user message is protected on purpose: losing the task is how an agent confidently continues work it can no longer describe. So a marker planted in turn 1 is exactly what survives while the bulk around it is truncated. Use tokens_after and budget as the evidence, not a recalled string.

Images and other media

A single image is worth roughly 1600 tokens, so a multimodal conversation reaches the budget far sooner than its text suggests — media is priced accordingly rather than as the few characters a placeholder occupies.

Under summarize, the media in the evicted span is attached to the summarization call itself: it is the last thing that will ever look at those images, so the model is asked to write down what each one actually shows before it goes. Under trim media is dropped, and a marker is left behind either way so the agent knows a picture was there.

Keeping evicted messages searchable

Turn on archive to memory and whatever leaves the window is embedded into the project's vector store, with the agent given a search_history tool scoped to that one conversation — so it can go back for a detail instead of asking the user to repeat it.

Knowing it happened

A response includes context when history had to be cut — or whenever a token's own ceiling is in force, even on a turn small enough not to hit it. That second case exists because a ceiling you cannot confirm is worth very little: the documented use for maxContextTokens is a cheaper tier, and somebody wiring that up needs to see the budget they bought before it bites.

"context": { "strategy": "trim", "resolved_by": "trim", "fallback": null,
"tokens_before": 210431, "tokens_after": 95880, "budget": 96000,
"compacted": 0, "dropped": 0, "truncated": 23, "summarized": false }

budget_from says whose number that budget is — "token", "project" or "model" — so "did my clamp take effect" is a field to read rather than damage to infer from. Under a token ceiling it reads "token", and tokens_before / tokens_after / truncated say what it cost.

On a stream the context frame arrives before start. Fitting happens before the model is called, so it is the first thing a turn emits — earlier than the frame that carries the session id. Register onContext when you open the stream, not after the turn has started, or you will count zero of them on a turn that fitted perfectly well. Summarizing also emits phase: "compacting" ahead of its model call, so render a "compacting conversation…" state rather than leaving the UI blank.

Its presence is the signal that the model did not see the whole transcript, unless truncated, dropped and summarized are all zero — which is the "your ceiling is in force and cost nothing this turn" case.

Two fields, because there are two questions

strategy is what the project asked for. resolved_by is what actually fitted the window. They are usually the same and the interesting case is when they are not:

{ "phase": "done", "strategy": "summarize", "resolved_by": "trim",
"fallback": "summarizer_returned_nothing", "compacted": 0, "dropped": 3, … }

That is a summarize policy that could not run — here because the summarizer's own model call came back empty or timed out — so the window was fitted by trimming instead. The turn still worked; it worked on a cut view of a conversation you had asked to have summarized, which is worth knowing and used to be invisible. fallback is null when nothing went wrong, and otherwise names which way it gave up:

summarizer_timed_outit did not answer within the deadline — the model behind it is too slow for a transcript this size
summarizer_returned_nothingit answered, with nothing usable in it
nothing_to_summarize, nothing_evictablethere was no prefix to compact, so trimming was all there was to do

The first two are worth telling apart: one points at your infrastructure and one at the model's output. Summarizing is bounded as a whole — including its retry — because it runs before the turn's first token, so every second of it is a second of blank screen.

Which of the three causes it was. summarizer_timed_out names the failure and not the reason, and from outside a ceiling too tight, a model too slow, and a summarizer that never finishes look identical. So the report carries the numbers:

{ "strategy": "summarize", "resolved_by": "trim", "fallback": "summarizer_timed_out",
"summarizer_timeout_s": 45, "summarizer_ms": 45002, "summary_model": "hukuk-db",
"compacted": 0, "dropped": 2, "truncated": 1 }
summarizer_timeout_show long it was allowed (CONTEXT_SUMMARY_TIMEOUT_S, 45s by default)
summarizer_mshow long it actually took
summary_modelwhich model was asked — your summaryModel if set, otherwise the turn's own model

All three are null unless a summarize policy actually ran, and they are reported on a successful compaction too: summarizer_ms close to summarizer_timeout_s is the warning that the next slightly longer conversation will fall back to a trim.

summary_model matters more than it looks. Unless you set one, summarizing a 96k-token transcript is done by your chat model — and a reasoning model that spends its output budget thinking before it emits anything is close to a worst case for this. "Raise the ceiling" and "point summaryModel at something fast" are different fixes, and these fields are what tell you which you need.

Two counts, for the same reason:

compactedmessages that left the window and whose content is in the summary
droppedmessages that left the window and whose content is gone
truncatedmessages kept but shortened (usually tool output)

They were one number, and "we summarised the earlier part of this conversation" is not the same sentence as "we dropped three messages".

Something the turn produced itself

Fitting the transcript happens before the turn runs, and a turn can produce something too big for the window while it runs: a sandbox command that printed a whole build log, an MCP tool that returned a repository. Those are fitted too, at the step boundary before the model reads them, and reported as context with phase: "step":

{ "phase": "step", "strategy": "summarize", "resolved_by": "trim", "fallback": null,
"tokens_before": 212159, "tokens_after": 52827, "budget": 96000,
"compacted": 0, "dropped": 0, "truncated": 1, "summarized": false }

A "step" report says resolved_by: "trim" however the project's strategy is set, with fallback: null — nothing went wrong, this path simply never summarizes (see below).

trim_order decides what gets cut here exactly as it does before the turn, and the tool output carries a marker saying it was shortened — so the agent answers knowing its view was cut, rather than confidently from half a file. A "step" report never summarizes, whatever the project's strategy: a model round trip per step of a tool loop is not a trade worth making, so it is the deterministic trim.

Until this existed the two halves disagreed in a way nobody could have predicted from the settings. A client tool's oversized result comes back on a new request, so it was fitted and the turn survived; an MCP or sandbox tool's ran inside the turn, so it went to the provider whole and the turn died with the provider's own ContextWindowExceededError — same size, same project, same policy.