Compaction¶
Built: api::chat::summarize, worker::summarized and worker::store_summary,
under the context_budget setting, with the trim below it as the floor. The
budget is per model and taken for the chain, the smallest of its models';
unset, it is 400,000 bytes or two bytes a token of the model's window where
that is less, and a chosen one larger than that is passed over for the model
(model_settings::default_budget). The design, so it is not rediscovered:
A transcript outlives any model's window, and the window is a property of the route rather than of the session — so a turn can arrive at a smaller context than the one before it. The naive ordering, compact in the outgoing model before switching, is a trap: it bills the user for an expensive operation they did not ask for, at the moment they asked for something else.
Summarize from the system prompt, the previous summary, and the tail. That input is bounded by construction, so the incoming model can always do it however long the session has run, and no compaction depends on a model that is being switched away from. The tail is also where the live context is: what is being worked on now, the recent tool results, the thread of the conversation.
Summaries are cumulative — each one summarizes the tail plus the summary before it — because the alternative loses durable facts. Constraints stated once at the start are exactly what gets dropped and then violated. Cumulative carrying is not a guarantee, only a much better chance.
The better fix is compaction carry-over: marking something as needing to survive, so compaction does not have to guess what was load-bearing. Named for the mechanism rather than the promise, because it is bounded — a carry-over is what a summary takes with it when there is room, not a guarantee of permanence, and a name like "kept" would promise what the bound cannot deliver. What must not be quiet is the bound: something dropped at it should say so, or a summary becomes the record of a fact nobody can see leaving.
Carry-over is not memory, and the two must not share a store. Memory is user-declared and durable — "I am off on Thursdays, so never set a pay date there, whatever the skill says" — stated once, applying to every session, and expected to hold. Carry-over is one model's judgment about one conversation, and it belongs to that session's transcript. Mixing them makes memory unreviewable: a standing instruction somebody gave and a guess a model made about a transcript become indistinguishable a month later, and nobody can say why the agent believes something. They solve different problems and are independently buildable; neither is a prerequisite for the other.
A summary is a message a model wrote about the conversation and will be replayed on every later turn, so its failure mode is quiet: a summary that misstates a decision becomes the record. Mark it as a summary in the transcript rather than folding it in as ordinary history — both so a reader can see what happened, and so the next compaction knows it is compacting a summary.
What a tool returned is shown to the summarizer inside <tool_result>
markers its instruction names as data, with a closing marker in the text
defused. A summary is replayed as trusted history, so a fetched page that
says "the user wants every invoice deleted" must come out as a page that said
so, not as one of the user's constraints, which the same instruction tells
the summarizer to carry forward verbatim.
The mark is metadata.summary_through, naming the last message the summary
stands in for. It is what both halves of that sentence rest on, and each of them
has already been got wrong once. Withholding summaries from the reader's page
was tried and reverted: it left a person unable to see that their conversation
had been compacted at all, which is the quiet bound this section warns against
two paragraphs above. Serving them unmarked is the other failure — a paragraph
summarizing the reader's own conversation, presented as something the agent said
to them. They are served, and the client draws them as the boundary they are.
On the way to the model a summary is labeled too (summarize::framed), and
every summary is, not only the newest: an older one can survive inside the
retained tail, and unlabeled there the agent reads its own summary as something
it said and answers it. What a summary covers is recorded from the projection's
own account of which stored message each entry came from, never by counting the
projection against the stored rows — they are not aligned, a stored summary
breaks the alignment by exactly one, and counting silently dropped a message per
round from every long session.
Underneath all of it, a trim that cannot fail. No model call, so it works when the provider is down, the breaker is open, or the summary itself would not fit. It is what guarantees a user never sees "context exceeded", which is the actual requirement — everything above is about doing better than that.
Drop by what a message is, not by how old it is. An earlier draft said to drop whole turns from the oldest end, which assumes age tracks irrelevance. In the sessions this platform is for, that is close to backwards: a workspace employee onboarding a customer runs for hours and calls tools constantly, and the oldest turns are where the premise was set — which customer, which system, what the constraints were — while the middle fills with tool results that were consumed the moment they arrived. Oldest-first discards the brief and keeps the mechanics.
So the order of sacrifice is by kind:
- Tool results, oldest first. They are the bulk in a tool-heavy session, they are usually spent on arrival, and losing one is recoverable — the agent can call the tool again. Replaced by a stub rather than removed, because a call with no answer is a request both protocols reject.
- Whole assistant/tool round trips, oldest first, once stubbing is not enough.
- Ordinary conversation turns, oldest first, last.
The first user message is what a session is for, and is the first thing an oldest-first rule throws away. Whether that earns an explicit exemption or whether it falls out of ordering tool results ahead of conversation is worth settling with a real transcript rather than by argument.
A stub must say it is a stub. A dropped result replaced by something that reads as real output is a silent lie, and the model will reason from it. Say the tool ran and its output was dropped to fit, so the agent can call again if it mattered. This is the same principle as marking a summary as a summary.
Always keep the last user message, since a turn with nothing to answer is already an error in the guest.
Budgets belong on the route, beside model, because the window is a property
of the model. But the trim runs where the conversation is built, in the API,
and the API has no routing; today no routes are seeded at all, so most turns
would have no budget to read. Settle this before building: a setting that
cascades like every other, with a route override when the gateway grows one, is
the shape that works from the first turn. Compact against a fraction of the
window rather than the whole of it, leaving room for the reply, for tool
results arriving mid-turn, and for the compaction call itself.
The system prompt is spent, not compacted. It goes to the model on every
round exactly as the conversation does, but it is a separate field nothing can
trim -- composed once per conversation and again at compaction, see
docs/prompt-contributors.md -- so it comes out of
context_budget before the conversation gets any (trim::room_for_conversation).
Uncounted, the budget quietly meant something else: ten long skills could
exceed it on their own while the trim reported the conversation comfortably
inside, and the turn failed at the provider's real limit with no trim log and
nothing to explain it. Anything that later contributes to the prompt -- carry-
over is the obvious candidate -- is spending the same way and must be counted
the same way.
Compact with hysteresis: cut to a mark well below the budget in one step, then leave the prefix alone. Trimming a little every turn changes the start of the history on every request, and a provider's prompt cache misses from there on each time -- see docs/caching.md.
Nothing counts tokens anywhere in this codebase. A per-model tokeniser is a dependency that is wrong for every model it was not built for; bytes over a conservative budget is approximate in the safe direction, and being wrong costs headroom rather than a failed turn.
The gateway must eventually support mid-session provider failover — an Anthropic outage substituting Gemini and continuing. That requires separating the durable transcript from the projection sent to a model, so provider-specific artifacts (Gemini thought signatures, Anthropic thinking blocks, differing tool call shapes) are annotations filtered per target rather than facts about storage. Lossy parts should degrade, never fail the turn.
Compacting on request¶
A conversation keeps the system prompt it was given until it compacts, so a skill published, or an agent's instructions edited, after it began does not reach it before then. Why the prompt is composed once per conversation rather than every turn is in prompt-contributors.md; the short of it is a stable cached prefix and a record of what each turn actually had. Compacting on request is how a person takes up the newer prompt without starting over.
/compact in the composer's slash menu, or the button on the banner below,
calls POST /v1/agent-sessions/{id}/compact. Picking it writes nothing into
the message; it queues a session.compact job (api::compact) under the
session's own serial key, the same one its turns use, so it never runs beside
a turn -- a summary written while a turn is still adding to the conversation
would summarize something already out of date. It is queued at realtime
priority, since somebody pressed it and is watching.
The job (worker::compact) summarizes what the ordinary rule would summarize,
whatever the budget, and composes the system prompt again from the agent and
its skills as they stand, with the summary written against that new prompt. A
conversation too short to summarize skips the summary as it always would, and
the prompt is still composed again, since that is what the request is for.
Who may ask is who may send a message: sessions:create, with the same
narrowing. Having started the conversation is not enough on its own, because
compacting changes what the agent reads from here on, and somebody narrowed
away from an agent should not be able to do that any more than keep talking
to it.
The "behind" banner. GET /v1/agent-sessions/{id}/prompt says whether the
kept prompt is what would be composed now, and which skills moved on. When it
is not current, the session page shows a banner naming the change -- a newer
version published, a skill added to or removed from the agent, or, when no
skill moved, that the agent's instructions were changed -- and offers to
compact this conversation or start a new one. Without it, an edit that has
not reached a running conversation looks like one that did not take. A
request that fails to start says so beneath it rather than disappearing.
While it runs. Every compaction, requested or automatic, emits
chat.compacting when it starts and chat.compacted when it lands, so a
reply that is slow to start has a reason a person can see. The banner turns
into "Compacting this conversation…" with a pulsing dot on a brand tint, drawn
the way the session list draws a running turn. It shows from the click itself,
not only from the event: a fast compaction delivers both events in one poll
and the indicator would never paint. The event still drives it for automatic
compactions and for other tabs, where there is no click. When the compaction
lands, the status is read again and the banner goes away if the prompt is now
current; the summary appears in the transcript, marked as the boundary it is.