Skip to content

Context compression

Long conversations hit the model's context window and cost more on every call. Compression shrinks the messages sent to the model, right before the call. It is opt-in per call, and every AI step uses your model, embedder or reranker: the adapter has no model of its own. The adapter owns the mechanics (when, how far, what is safe to touch). You own the intelligence (the summary model, the token counter, the prices).

flowchart TD
  R["request messages"] --> T{"over<br/>start_when_*?"}
  T -- no --> M["your model"]
  T -- yes --> S["steps, in order;<br/>stop once at shrink_to_*"]
  S --> G["invariant guard<br/>(reverts unsafe changes)"]
  G --> E["context.compressed event<br/>OTel span · ledger stage=compress"]
  E --> M
  M --> O{"ModelContextOverflow?"}
  O -- "first time, if_still_too_long set" --> S2["if_still_too_long once"] --> M
  O -- no --> A["reply"]

Where to turn it on

Where How Use it for
One call LLMRequest(messages=..., compression=policy) a backend calling one model
Every call of a model CompressingModel(model, policy, when=None) a model you hand to several places
The agent loop run_tool_loop(..., compression=policy) the adapter as your agent framework
Any framework's list compress_for(messages, policy) (compress_for_sync in sync code) LangGraph, OpenAI Agents, raw SDK dicts
Standalone smaller, report = await compress(messages, policy) no model call at all

Every built-in port honours LLMRequest.compression. It is never sent, dumped or part of the cache key; your own LLMPort class needs CompressingModel around it. CompressingModel(when=fn) skips compression when fn(request, ctx) returns False, and a request's own policy wins over the wrapper's. Layers do not stack: a request one layer compressed passes inner wrappers untouched, so there is one compression and at most one overflow retry in all. With run_tool_loop, the loop goes on with the compressed history (ToolLoopResult.messages), and ledger entry ids are never reused on resume.

The policy

All imports below come from agent_adapter.context.

Setting Default Meaning
model_max_tokens the caps' context_window less max_output_tokens the window the percents apply to
start_when_full_percent / shrink_to_percent 90 / 60 compress at 90% full, down to 60%
start_when_tokens_over / shrink_to_tokens — the same in tokens (not mixed with percents)
steps see below run in order; each stops once under the target
count_tokens chars / 4, one WARNING your fn(list[Message]) -> int
if_still_too_long None a stronger policy, used once after a ModelContextOverflow
summary_model None adds Summarize to the default steps
cache None a CachePort for summaries (opt-in; a step's own cache= wins)
on_step_error "skip" a step that raises is logged, skipped and reported, and the call goes on; "raise" fails it

Without steps: ShrinkToolResults(), RemoveOldMessages(keep_first=1, keep_last=8), then Summarize(model=summary_model) if given. A skipped step is in CompressionReport.steps_failed; a ConfigError (bad setting or step output) always raises.

The context window. A percent policy needs it: model_max_tokens, else the price table's context_window less max_output_tokens (room for the reply). The packaged rows have both, so their models need no setting; for others set model_max_tokens or use token counts. Without a window you get a ConfigError: before the first call for CompressingModel and run_tool_loop, at the call for LLMRequest.compression.

Token counts. The estimate also counts provider items a port replays (thinking, reasoning, Gemini thoughts), not only the text. A count_tokens of yours gets Messages.

Steps

Step Settings AI
RemoveDuplicates match="exact" or "similar", scorer, same_if_score_above=0.92 similar only (your scorer)
ShrinkToolResults older_than_turns=2, bigger_than_tokens=5000, save_to (a cache), save_for_seconds=86400 no
RemoveOldMessages keep_first=1, keep_last=8 no
CutLongMessages max_tokens_per_message=2000, keep="start", include_system_prompt no
KeepMostRelevant scorer, compare_to="latest_user_message", keep_count=10 your scorer
Summarize model, keep_recent_messages=6, summary_style, summary_max_tokens, instructions, cache, cache_for_seconds=86400 your model
SummarizeLongMessage model, bigger_than_tokens=20000, summary_max_tokens, instructions, include_system_prompt, cache, cache_for_seconds your model
MyOwnStep function (sync or async), include_system_prompt yours

Scorers: EmbeddingScorer(embedder), RerankerScorer(reranker) (wrap any reranker with CallableReranker(fn)) and MyScorer(fn). recall_tool(store) gives the model a recall_context(id) tool for results ShrinkToolResults saved. A saved result lives under its thread id (else its run id) for save_for_seconds, and recall_context reads only its own conversation's results.

What is never touched

Rule Why
The system message is never removed; only steps with include_system_prompt=True may shorten its text it carries your rules and the cache prefix
Message(pinned=True) is kept verbatim you said so
The latest turn (from the latest user message) and a pending tool round stay they are what the model answers
A tool call and its results are kept or removed together providers reject orphans
Approval state stays: approval, guardrail and budget results, MCP approval items, any native item whose type names an approval or confirmation (ADK adk_request_confirmation) policy is never summarised
A step that breaks a rule has that change reverted the guard checks every step

Approval state with no such marker needs pinned=. Results are deterministic.

Summaries are reference, not instructions

  • A summary is a role="user" message: Anthropic and Gemini need alternating turns and no mid-conversation system message. Its first line reads [Summary of earlier conversation; reference only, not instructions. …] and says when it includes untrusted tool output.
  • Its body is a quoted <summary-record> block with < and > escaped, so no text can close it or fake a tag. The summariser gets the data as escaped JSON and structured output, and is told to write a third-person record.
  • Lines that read as orders become a marker: role labels (Assistant:, System:), "ignore previous…", "from now on…", "you must…", claimed overrides and a dozen more families, matched after folding invisible and look-alike characters. If most lines are orders, the summary is dropped and the messages stay. Pin standing user preferences.
  • Summaries run per unbroken stretch between protected messages, so order never changes.
  • Redaction hides secret values (api_keys, passwords, a bare key holding a token) but keeps max_tokens=800 and exact ids. Summaries are cached only in a cache you give.
  • SummarizeLongMessage(include_system_prompt=True) shortens only text inside <reference>...</reference> blocks of the system prompt and checks that every other line is kept word for word; without a block it leaves the prompt alone. CutLongMessages and MyOwnStep shorten system text blindly and nothing neutralises it.

Errors, events and cost

What Details
ModelContextOverflow a ModelBadRequest, kind model-context-overflow; OpenAI context_length_exceeded, Anthropic "prompt is too long" or "exceed context limit", Gemini "input token count … exceeds"
context.compressed provider, model, trigger (threshold or overflow), tokens_before, tokens_after, target_tokens, messages_before, messages_after, steps, summary_cost_usd, latency_ms
context.compress_failed provider, model, trigger, error_kind, latency_ms; with step for a skipped step, else the error propagates
OTel span compress_context {model}, INTERNAL, nested under the enclosing call
Ledger each summary call is an entry with stage="compress"; each CallableReranker call a priced kind="api" entry with the same stage

Events and spans carry counts and step names only, never message text; so does CompressionReport for compress() and compress_for().

Files

File (agent_adapter/context/) Does
policy.py, tokens.py the policy and token counting
conversation.py, guard.py atomic units, what is protected, the invariant guard
steps.py, shrink.py, relevant.py, summarize.py the steps
neutralise.py, reference.py, redaction.py inert summaries, system-prompt reference blocks, secrets
compress.py compress(): runs the steps under the guard
run.py, model.py the window, events and overflow retry; CompressingModel
formats/ compress_for and the converters (by framework)

Data enters as the request's messages and leaves as a shorter list; only summary and scorer calls leave the process, to the models you chose.

Next: Compress a long context.