Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

strands-headroom

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()])

Status

Alpha. Working and tested, but read this section before evaluating it.

  • Requires strands-agents >= 1.50 for InvokeModelStage.
  • It registers middleware through a private API: agent._middleware_registry.add_middleware(...). The SDK's own _middleware/README.md states "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 wherever HEADROOM_PROXY_URL points:
    pip install 'headroom-ai[all]' && headroom proxy
    Works against any recent Headroom. Prompt-cache pinning additionally needs config.frozen_message_count, which is on Headroom main but not yet in a PyPI release; without it the plugin falls back automatically.

How it works

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.

What it saves

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.

Lossless is not the same as invisible

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.

It has no compression policy of its own

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.

Prompt caching

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.

Retrieval

{"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.

Interaction with SlidingWindowConversationManager

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.

What this needs from Strands

Nothing blocking, but two things would help:

  1. A public InvokeModelStage.Input registration path. This is the correct seam and we would rather not reach through agent._middleware_registry. The SDK's own _middleware/README.md already anticipates this. A public Agent(middleware=[...]) or agent.add_middleware(...) — with the per-phase @overloads that README describes — would let this be a fully supported integration. We pin strands-agents>=1.50,<2 and 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.
  2. Documenting the SlidingWindowConversationManager interaction for anyone plugging in an alternative context strategy — it is easy to end up with two of them competing.

Development

python -m venv .venv && ./.venv/bin/pip install -e '.[dev]'
./.venv/bin/python -m pytest

33 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 pytest

The 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.py

Last run: 3 turns, 2 real tool calls, both success, real toolUse/toolResult/text blocks round-tripped intact, prefix pinning active throughout.

Apache 2.0.

About

Headroom context compression for AWS Strands Agents

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages