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.
| Strategy | What it does |
|---|---|
trim (default) | Drop the oldest material until it fits. Cheap, no added latency. |
summarize | Compact 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. |
none | Replay 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}/messagesstill 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.
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_out | it did not answer within the deadline — the model behind it is too slow for a transcript this size |
summarizer_returned_nothing | it answered, with nothing usable in it |
nothing_to_summarize, nothing_evictable | there 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_s | how long it was allowed (CONTEXT_SUMMARY_TIMEOUT_S, 45s by default) |
summarizer_ms | how long it actually took |
summary_model | which 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:
compacted | messages that left the window and whose content is in the summary |
dropped | messages that left the window and whose content is gone |
truncated | messages 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.