Headroom context compression for Strands Agents.
An agent resends its entire conversation on every model call — turn 50 resends turns 1–49. This plugin compresses a copy of that conversation on the way to the model. The agent's own history is never touched.
from strands import Agent
from strands_headroom import HeadroomPlugin
agent = Agent(plugins=[HeadroomPlugin()])Alpha. Working and tested, but read this section before evaluating it.
- Requires
strands-agents >= 1.50forInvokeModelStage. - It registers middleware through a private API:
agent._middleware_registry.add_middleware(...). The SDK's own_middleware/README.mdstates "The_middleware/package is not part of the public API" and adds a "When this goes public" section, so we are following the documented internal path rather than working around one. See What this needs from Strands. - Not on PyPI yet. Install from source:
pip install git+https://github.com/headroomlabs-ai/strands-headroom
- Needs a running Headroom reachable at
http://127.0.0.1:8787, or whereverHEADROOM_PROXY_URLpoints:Works against any recent Headroom. Prompt-cache pinning additionally needspip install 'headroom-ai[all]' && headroom proxy
config.frozen_message_count, which is on Headroommainbut not yet in a PyPI release; without it the plugin falls back automatically.
Agent(plugins=[HeadroomPlugin()])
│
├─ Plugin ............. registers the middleware and, optionally, a retrieval tool
├─ Middleware ......... InvokeModelStage.Input — where messages are in flight
└─ Converse adapter ... Strands sends {"toolUse": …}, Headroom reads {"type": "tool_use"}
│
▼
POST /v1/compress ──▶ your Headroom (Rust + ONNX, out of process)
│
▼
the model
InvokeModelStage.Input is the right seam because it hands middleware a defensive
copy of the conversation — copy.deepcopy(agent.messages) — and the terminal reads
ctx.messages, not agent.messages. So what is sent and what is stored can differ
without any bookkeeping. agent.messages, MessageAddedEvent, and anything a
SessionManager has persisted are all untouched. Nothing is destroyed to save tokens.
Compression runs in your Headroom instance over HTTP. This package depends only on
strands-agents and httpx — no compression engine, no ONNX runtime, and no 261 MB
model lands in the agent process.
Savings depend heavily on what your tools emit. Measured on a 48-message agent conversation of tool calls returning log output, varying only the shape of the logs:
| tool output | reduction | what fired |
|---|---|---|
| identical across turns | 82% | cross_turn_dedup |
| distinct, log-shaped | 12–18% | tool_result:lossless_search |
| distinct, other shapes | 0–33% | varies |
10–30% is a fair expectation for typical agent traffic, more when tool output repeats. Do not quote the 82% — it comes from a synthetic case where the same payload recurs every turn.
Latency is 50–190 ms per call against a warm Headroom. The first call after Headroom starts costs ~10 s while it loads its compression model.
Headroom's transforms are reconstructable, but they can change how content looks, and
the model reads what it is given. A real example from scripts/smoke_bedrock.py: a tool
returned 60 log lines sharing a timestamp prefix, and lossless_search factored that
prefix into a header line:
2026-08-02 12 <- factored-out common prefix
00:00 INFO worker=0 handled request id=r0 ... <- remainder
00:01 INFO worker=1 handled request id=r1 ...
5,129 chars became 4,303 with nothing lost — every original line is reconstructable. But the model then reported "61 lines total (1 header + 60 entries)". It described what it received accurately; that just was not what the tool returned.
So: compression is safe for reasoning over content, but a model may miscount or describe compression artifacts as if they were in the data. If exact structural fidelity matters for a given tool, exclude it — filter in your own middleware ahead of this one, or tune Headroom's routing.
config is forwarded verbatim as the request's config object:
HeadroomPlugin(config={"mode": "ccr", "protect_recent": 8})The plugin invents no thresholds and overrides none of Headroom's defaults. What
deserves compressing is your decision — express it in that config, in your Headroom's
own settings, or in your own middleware stacked ahead of this one. InvokeModelStage
composes, so filtering before compression is a separate handler, not a fork of this one.
It also never touches ctx.system_prompt or ctx.tool_specs. Tool schemas are often
10–20k tokens and look tempting, but they are API contracts; rewriting them breaks tool
calling. Messages only.
Each message is compressed once, then its bytes are held steady for the rest of the conversation. This matters more than it sounds: compressing the full history afresh every turn makes Headroom render older messages differently as the conversation grows, so the provider's prompt cache misses from the first changed message onward — potentially costing more than the compression saves.
Measured before this was added, compressing the same conversation at growing lengths: the first 16 messages were byte-identical at 4 and 8 turns, then drifted at every tool-result message from 16 turns on.
The plugin carries its previous result forward and pins it via
config.frozen_message_count, added by
headroom#2718. That is merged
on Headroom main but is not in a release yet — the newest on PyPI is 0.33.0, which
predates it. Until the next release, run Headroom from main to get prefix pinning:
pip install 'git+https://github.com/headroomlabs-ai/headroom#egg=headroom-ai[all]'Against a Headroom without it nothing breaks: the plugin notices the pin was ignored
and falls back to stateless compression on its own. Opt out entirely with
HeadroomPlugin(pin_prefix=False).
Pinning also turned out to be strictly better than recompressing, not a trade: past ~24 turns, recompressing the whole history exceeds Headroom's compression deadline and returns the input unchanged (0% saved), while the pinned path only ever compresses the new tail and keeps working.
{"mode": "ccr"} makes Headroom emit hash=… markers for elided content and
contributes a headroom_retrieve tool so the model can pull the original back. Every
other mode is marker-free, so the tool is not registered rather than costing ~320 bytes
of schema on every call that it could never use.
Retrieval reads Headroom's CCR store, which defaults to a 30-minute TTL. Raise
HEADROOM_CCR_TTL_SECONDS on the server if you rely on it in long sessions.
Strands defaults to SlidingWindowConversationManager, which permanently discards
old messages once the conversation grows — independently of Headroom. In our tests it
dropped 10 of 48 seeded messages before compression ever ran.
That is reasonable as an SDK default, but it defeats the point of lossless compression. If Headroom is your context strategy, nothing else should be mutating the list:
from strands.agent.conversation_manager import NullConversationManager
agent = Agent(conversation_manager=NullConversationManager(), plugins=[HeadroomPlugin()])The plugin deliberately does not set this for you — silently replacing a component the user configured would be worse than the papercut.
Nothing blocking, but two things would help:
- A public
InvokeModelStage.Inputregistration path. This is the correct seam and we would rather not reach throughagent._middleware_registry. The SDK's own_middleware/README.mdalready anticipates this. A publicAgent(middleware=[...])oragent.add_middleware(...)— with the per-phase@overloads that README describes — would let this be a fully supported integration. We pinstrands-agents>=1.50,<2and have a smoke test that fails loudly if the seam moves, so a version bump surfaces as a test failure rather than a silent no-op. - Documenting the
SlidingWindowConversationManagerinteraction for anyone plugging in an alternative context strategy — it is easy to end up with two of them competing.
python -m venv .venv && ./.venv/bin/pip install -e '.[dev]'
./.venv/bin/python -m pytest33 tests. test_converse.py is dependency-free; test_plugin.py stubs Headroom at the
HTTP boundary; test_integration.py needs a live Headroom and skips without one.
headroom proxy &
HEADROOM_PROXY_URL=http://127.0.0.1:8787 ./.venv/bin/python -m pytestThe suite uses a spy model, so it never exercises what a real provider emits.
scripts/smoke_bedrock.py closes that gap against real Bedrock — three turns with a
real tool, a few cents of Haiku:
AWS_REGION=us-west-2 ./.venv/bin/python scripts/smoke_bedrock.pyLast run: 3 turns, 2 real tool calls, both success, real toolUse/toolResult/text
blocks round-tripped intact, prefix pinning active throughout.
Apache 2.0.