Skip to content

feat: add context manager class design doc#3307

Draft
lizradway wants to merge 1 commit into
strands-agents:mainfrom
lizradway:context-manager-design
Draft

feat: add context manager class design doc#3307
lizradway wants to merge 1 commit into
strands-agents:mainfrom
lizradway:context-manager-design

Conversation

@lizradway

Copy link
Copy Markdown
Member

Description

Related Issues

Documentation PR

Type of Change

Bug fix
New feature
Breaking change
Documentation update
Other (please describe):

Testing

How have you tested the change? Verify that the changes do not break functionality or introduce new warnings.

  • I ran hatch run prepare

Checklist

  • I have read the CONTRIBUTING document
  • I have reviewed and understand every line of code in this PR, including any generated by AI tools, and I can explain why it works
  • My change is focused and reasonably small; I have split unrelated work into separate PRs
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@lizradway lizradway changed the title 0015-context-manager.md feat: add context manager class design doc Jul 16, 2026
new ContextManager({
strategies: [
// Offload: replace expensive content with cheaper representations
Offload.truncate("toolResults", { previewTokens: 750 })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these running conversation managers under the hood? or are these like separate?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

separate, conversation manager is dead!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

essentially just a compression function that is defined and can be applied to anything (ideally)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"conversation manager is dead!" long live the context manager! yeey


## 1. Problem

### What customers hit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a very helpful style for an intro into a doc 💯

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yay thanks!


Every story above is hitting customers *today*: in issues, in community reports, in benchmark regressions. Internal teams are building custom wrappers to work around limitations that should be solved at the SDK level.

The switch exists (`contextManager: "auto"`), but the experience behind it isn't good enough. Users hit the stories above and the only escape hatch is to drop down into three disconnected components (`SummarizingConversationManager`, `ContextOffloader`, `ContextInjector`) that were designed independently, don't share state, and don't compose. We should be able to make `"auto"` genuinely great: truncation, summarization, stash, retrieval, all tuned and composable. One line that keeps getting better. And when users do need to customize, one axis at a time without restating everything else.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SlidingWindowConversationManager too?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, can add!

@github-actions github-actions Bot added size/xl blog documentation Documentation changes, improvements, additions, content updates, site improvements, examples, guides area-context Session or context related labels Jul 16, 2026
1. **Offload**: take content OUT of what the model sees (L0 → L1)
2. **Inject**: put content IN to what the model sees (→ L0)

Both follow the same mental model: **choose a representation for content in the context window**. Offload replaces expensive representations with cheaper ones. Inject adds cheaper representations of expensive external content. The API shape is unified:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inject adds cheaper representations of expensive external content.

So this would be summaries of the context?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can be summaries, but often are not.

any cheaper representation of the information stored in the context window can be supported.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

built in strategies to start are summarize, truncate (sliding window basically), and skeleton (AST).

but built to be extensible for any future strategies and to allow community supported strategies.


A representation is how content is mutated to appear in the context window. Representations work in both directions:

| Representation | What it produces | Offload use | Inject use |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where did you get these representations? Are there others that you considered?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

summarize and truncate are what we support now in Summarizing Conversation Manager and Sliding Window Conversation Manager, so I wanted to support those for parity.

Skeleton is essentially AST which is what is proving most effective nowadays on the frontier, so i wanted to include this in the initial P1/P2s.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Future methods to implement/consider in Appendix. Also accepting community methods/strategies.

|----------------|-----------------|-------------|------------|
| `truncate` | Partial content (head/tail/head-tail) | Keep a preview, stash the full thing | Inject a truncated view of external content |
| `summarize` | LLM-generated summary | Condense old conversation | Inject a summary of external content |
| `skeleton` | Structural outline (signatures, refs) | Code: just signatures | Stash: just refs + metadata |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is references a clearer term for this maybe?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure, maybe even structure. open to naming changes, havent thought abt it much


new ContextManager({
strategies: [
// Offload: replace expensive content with cheaper representations

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about Drop if I do not want something in my L1 at all because it may write to my long term memory? Like ToolResult or ToolResultErrors.

Is there an option to just summarize and drop?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i have thought about that and Drop was included in some initial implementations of this design (originally dropped ToolResultErrors).

my final thoughts on it + why it is left off this doc now is bc I think that could be a configuration on the method (ie. stash: false). i'd also want to add this as a follow up depending on how much of an ask we have for this/use case.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Main reason I'm calling this out is because what if my L1 is not free? If I'm using some managed service for L1 offloading (e.g. AgentCore), I'm paying for every API call / storage

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting. i think for the purpose of this doc, the stash is meant to be an extension of the agent's context window outside of itself and is not meant to be integrated with managed solutions.

but would be curious to follow up more on this use case.

- `"research"`: vended prompt that preserves sources, claims, evidence, contradictions
- custom string: pass a full prompt for domain-specific summarization; see **Appendix D**

Skeleton modes (P2):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These look very file format specific. Is it generic?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its typically used for structured items (ie. code, json, etc).

id imagine people would use this when offloading specific tool results, ie. read file or code tools.

Truncation modes:
- `"head"`: keep the first N tokens
- `"tail"`: keep the last N tokens
- `"head-tail"` (default): keep both ends, drop the middle

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering is this widely used ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was the best performer on my initial testing.

has some traction in the community afaik.


| Target | Matches |
|--------|---------|
| `"toolResults"` | All successful tool result messages |

@poshinchen poshinchen Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we make these as enums?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense to me.


## 1. Problem

### What customers hit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are all of these issues today? Haven't we fixed a bunch of these?

@lizradway lizradway Jul 16, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these all still are open/exist


1. **One-liner preserved**: `contextManager: "auto"` still works, same defaults. 80% of users never see the class.
2. **No cliff**: override one axis (threshold, method, storage, content-type rules) without restating everything else.
3. **Pluggable**: 3P compressors (Headroom, enterprise) slot in with ~15 lines. No adapter layer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do they integrate? I'm not sure if I get that from the doc

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See Appendix A part 4/5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this work with this definition though? it's not extensible

type Strategy = OffloadStrategy | InjectStrategy

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a limited set of strategies. is there a strategy interface?

| `"userMessages"` | User messages (no tool result) |
| `"images"` | Messages containing image blocks |
| `"documents"` | Messages containing document blocks |
| `"code"` | Messages heuristically detected as code |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth it to mix 1:1 mappings of model shaped content types with heuristics like this?

Might we start with standard model/framework types and then give an interface for users to do dynamic detection.

Maintaining our own detectors seems like a large can of worms

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, id like to start with with the obvious sdk-defined types (ie. assistant messages, user messages), and then as P1/P2 do more heuristics, (ie. detecting document from document type, code from .TS/.PY etc)

| Strategy execution engine | **P0** | 1.5 weeks | Constructor | Event subscription, target matching (content types + tool name arrays + `!` exclusions), condition evaluation, multi-strategy composition on same message, idempotency |
| `Offload.truncate` | **P0** | 3 days | Strategy engine | All three modes (`"head"`, `"tail"`, `"head-tail"`), per-tool targeting, preview token slicing |
| `Offload.summarize` | **P0** | 4 days | Strategy engine | `"cache-aligned"` mode (default), custom prompt string support, wrap existing `ConversationManager` summarization in the strategy interface, expose `ratio`/`preserve_recent` params |
| Inject system | **P1** | 4 days | Strategy engine | Middleware that strips + re-injects before each model call. Built-in sources: `Inject.skeleton("stash")`, `Inject("budget")`, `Inject("clock")`. Custom `render` function support. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

won't this keep invalidating cache?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im pretty sure context injector right now injects at the end as a message (rather than sys prompt) for this very reason (as one of the reasons) cc: @opieter-aws to check me?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd fold into the latest message rather than system prompt, keeping the cache intact


# Agent manages its own context: explicit opt-in
cm = ContextManager("auto")
agent = Agent(context_manager=cm, tools=[cm.as_tool()])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a better devx then context_manager="agentic"? 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could always keep context_manager="agentic" around as a facade that maps to passing CM as a tool if we want.


| Condition | Type | Meaning |
|-----------|------|---------|
| `threshold` | `number` (absolute tokens) | Only fire if the target message exceeds this size |

@opieter-aws opieter-aws Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make this more extensible? E.g. what if I want to offload at a certain amount of turns or if e.g. identified by the model to be insignificant (integration with an agentic mode or flag)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skipRecent is supposed to fufill turn based detection.

priority based/relevance based offloading is P2/P3. would love to do as follow up.

agent = Agent(context_manager=cm, tools=[cm.as_tool()])
```

**What `.as_tool()` exposes:**

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are all of these one tool each? Do we need all of them to be separate? We should combine some of these probably

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes these are all separate. i think pin and unpin could be combined, but im hesitant of parameter overload of tools

| Inject system | **P1** | 4 days | Strategy engine | Middleware that strips + re-injects before each model call. Built-in sources: `Inject.skeleton("stash")`, `Inject("budget")`, `Inject("clock")`. Custom `render` function support. |
| TokenBudget | **P1** | 2 days | Constructor | Read `model.context_window_limit` + `projected_input_tokens`, expose as typed `TokenBudget` object, wire to condition evaluation and inject render context |
| Stash (L1) | **P1** | 1.5 weeks | Strategy engine, Inject | Write-on-offload, manifest generation, `get_context` tool (bounded retrieval with offset/limit/grep), `search_context` tool (unified L0+L1 search with query/tool/type/scope filters), in-memory fallback when no storage configured |
| Agentic mode (`.as_tool()`) | **P1** | 3 days | Stash, `.as_tool()` primitive | `offload_context`, `pin_context`, `unpin_context`, `context_status` per [`.as_tool()` design](https://gist.github.com/lizradway/82dea3e7832c2d336595d77a8f9e42f1). Depends on `.as_tool()` being implemented on the Plugin interface. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this p1? can't we reuse agentic mode here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can keep around agentic mode as is for now, but i don't want to wrap a bunch of tools if we are going to implement over it in the future and have decided we want a different devX for this experience.


| Target | Matches |
|--------|---------|
| `"toolResults"` | All successful tool result messages |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about toolResults that have images, documents, etc. does that get offladed under generic targets?


**Condition fields** (second arg to `.when()`, optional):

| Condition | Type | Meaning |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it has to be defined by SDK, or user should do that, eg: pass in some callback functions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very interested in extending to callback function. would be a good p1/p2, they do that in this pr already for ContextOffloader: #3267

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would also like to include **kwargs to extend

### 4.2 Recreating "auto"

```python
agent = Agent(context_manager="auto")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally prefer agent = Agent(context_manager=ContextManager())?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how would this extend to other modes? ie. minimal

Truncation modes:
- `"head"`: keep the first N tokens
- `"tail"`: keep the last N tokens
- `"head-tail"` (default): keep both ends, drop the middle

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will it be possible to configure the number of tokens on each end to be different (ex. 100 token head and 200 token tail)? It seems similar to the existing pinFirst + preserveRecentMessages in conversation manager

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could add as configs eventually if we get a call for that, but not sure of the customer use case of that for now.


The stash manifest (injected via `Inject.skeleton("stash")`) tells the agent what's available:

```xml

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would the agent not want a little of the semantic content of each entry to remember what is there?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah fair enough probably a good idea. i didn't scope out the ref super indepth i would want to run some experimentation on what works best for this.

im wondering if tool name/id could be sufficient for most use cases or if we would want a parcel preview.

The stash manifest (injected via `Inject.skeleton("stash")`) tells the agent what's available:

```xml
<stash count="3" budget="82%">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there no preview in this example?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment above, responded there


### 3.3 Relationship to MemoryManager

ContextManager owns L0 (context window) and L1 (stash). MemoryManager owns L2 (cross-session knowledge) and can read from the stash for extraction. They share the token budget but don't reference each other.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add trigger strategies from writing L1->L2

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes!


---

## 3. The Stash (L1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the store back-end here? Can I plug in my own?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

storage woo!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(naive question) could stash just accept a Storage implementation? This ties to another comment of mine about supporting offload/inject with external stores

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jinx

| `"clock"` | Current datetime | bare: as-is |
| custom | User-defined render function | any |

**What about memory, RAG, state?** Those are injected by their own subsystems (MemoryManager, retriever plugins, etc.), not by the ContextManager. Each subsystem owns its own injection lifecycle.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those are injected by their own subsystems (MemoryManager, retriever plugins, etc.), not by the ContextManager. Each subsystem owns its own injection lifecycle.

Is this sentence referring to the fact that those plugins register their own processes/behavior tied to hook events?

Regardless, I think it'd be very useful for developers to be able to offload to / inject from external sources through the ContextManager itself - for instance, setting up an Offload.summarize rule that stores some embedded summary in a vector DB / knowledge base

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is done in the memory Manager already since it can read from L0 (and possibly L1 in future)


---

## 3. The Stash (L1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why doesn't L1 survive session restore? This seems like a big gap if I offload a lot and frequently restart sessions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should survive session restore? ContextManager-owned store for recoverable content.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, I interpreted this as 'recoverable this session', given that below is stated: For v1, the stash grows until the session ends or storage is cleaned up externally.

Would dedicate a short section to session restore with L1 versus session manager


Setting `stash=False` makes offloading destructive (originals lost after summarization), matching today's behavior. No `storage` param is needed since there's nothing to persist.

### 4.2 Recreating "auto"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about recreating "agentic"? :)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

context_manager.as_tool()!

| `["!read_file"]` | Tool results from all tools *except* `read_file` |
| _(omitted)_ | Everything not matched by a more specific strategy |

Passing an array of tool names implicitly scopes to tool results from those tools. Prefix a name with `!` to exclude: `["!read_file", "!write_file"]` means "all tool results except these." Don't mix includes and excludes in the same array.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If includes and excludes can't be in the same array, why not separate them into different parameters?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parameter overload tbh.

i think if a user wants to do both, they can just define two offload methods its only one extra line so not a big deal imo.

@@ -0,0 +1,1421 @@
# Design: ContextManager Class

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this improvement from the current state worth 11-12 weeks of our time? where can we cut, speed up?

Offload.summarize(ratio=0.3, preserve_recent=10)
.when(BeforeModelCallEvent, utilization=0.7),
],
stash=False, # no stash: destructive, same as today (no storage needed)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can I not choose to stash per strategy?


```xml
<stash count="3" budget="82%">
<entry ref="msg-a1b2c3d4" type="tool_result" tool="read_file" tokens="2400" age="4 turns" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the shape of the object defined here, but this gets me thinking about future updates. How do you envision us updating this shape in the future? Versions?

---

<details>
<summary><b>Appendix E: Alternative API Shapes Considered</b></summary>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc Feedback: going forward, I'd like to see alternatives featured more prominently. This doc basically presumes the accepted design which guides the conversation that way, while I think choosing the right approach is a gate that should be explicitly walked-through - if the proposed design is the superior one, that should quickly come through and we can move on. Keeping alternatives in the appendix basically makes it an optional discussion IMHO

}
```

**Where the data comes from**: `limit` reads from `agent.model.context_window_limit` (already exists on the model config). `used` reads from `BeforeModelCallEvent.projected_input_tokens` (already computed by the existing proactive compression hook). The ContextManager wraps the existing infrastructure into a typed object that strategies, tools, and 3P code can consume uniformly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note the timing issue here with calculating projected_input_tokens in the loop versus message array modification


# Agent manages its own context: explicit opt-in
cm = ContextManager("auto")
agent = Agent(context_manager=cm, tools=[cm.as_tool()])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this actually confused me a bit, first class param object has different functionality when it becomes a tool

| Strategy execution engine | **P0** | 1.5 weeks | Constructor | Event subscription, target matching (content types + tool name arrays + `!` exclusions), condition evaluation, multi-strategy composition on same message, idempotency |
| `Offload.truncate` | **P0** | 3 days | Strategy engine | All three modes (`"head"`, `"tail"`, `"head-tail"`), per-tool targeting, preview token slicing |
| `Offload.summarize` | **P0** | 4 days | Strategy engine | `"cache-aligned"` mode (default), custom prompt string support, wrap existing `ConversationManager` summarization in the strategy interface, expose `ratio`/`preserve_recent` params |
| Inject system | **P1** | 4 days | Strategy engine | Middleware that strips + re-injects before each model call. Built-in sources: `Inject.skeleton("stash")`, `Inject("budget")`, `Inject("clock")`. Custom `render` function support. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we reuse the context injector or extend it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in its current state, this utilizes the context injector under the hood

- `"tail"`: keep the last N tokens
- `"head-tail"` (default): keep both ends, drop the middle

Summarization modes:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these modes defined in context management summarization strategies? Other strategies that could be beneficial could be:

  • "decisions": retain what was decided and why. Useful for remembering outcomes across context summarization boundaries
  • "intent": what the user wants and what's left to do. Task-list shaped.


### 5.5 Token Budget

The `TokenBudget` is a read-only view the ContextManager exposes for "how full is the context window":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we up-level it to agent? otherwise, how can it work when we switch model? this makes cm stateful right

|--------|-----------------|----------------------|
| `"stash"` | Offloaded content manifest (see §3) | `skeleton`: refs + metadata |
| `"budget"` | Token utilization stats | bare: as-is |
| `"clock"` | Current datetime | bare: as-is |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you see ContextManager differing from SystemPrompt? Ive seen people inject the datetime in the system prompt, but here its injected in via the ContextManager. What is the pit of success here?


```typescript
// Event implicit: tool results only arrive after tool calls
Offload.truncate("toolResults", { previewTokens: 750 })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if there is value around building a graph relationship between evicted content? That would open the door to new retrieval mechanisms.

Offload.summarize(ratio=0.3, preserve_recent=10)
.when(BeforeModelCallEvent, utilization=0.7),
],
stash=False, # no stash: destructive, same as today (no storage needed)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar thought from before - instead of a stash boolean flag can the user just pass a Storage implementation here (and if absent then no stashing)

@agent-of-mkmeral

Copy link
Copy Markdown
Contributor

Adversarial analysis: where the ContextManager design breaks

TL;DR: Stress-tested this design against real systems (Headroom — verified from its actual repo/docs — LLMLingua, Letta/MemGPT, Anthropic context editing, OpenAI Responses, LangGraph, pydantic-ai, Bedrock invariants). The Offload/Inject algebra works for the 80% rule-based case, but success criterion #3 ("3P compressors slot in with ~15 lines") is the weakest claim in the doc. Six structural breaks:

  1. Closed algebraStrategy = OffloadStrategy | InjectStrategy + closed enums; reorder, rewrite-in-place, and request mutation have no home (the doc's own collapse-pairs roadmap item can't be expressed by its own taxonomy)
  2. Wrong granularity for Headroom-class libraries — they need whole-request, per-call access; Headroom's shipped Strands integration is model wrapping, and the doc's ~15-line example captures ~20% of the library
  3. Missing view-vs-history axis — offload permanently mutates L0; LangGraph/pydantic-ai/Headroom all converged on "compress the view, keep pristine history," which the design cannot express
  4. Retrieval not pluggable — no StashProvider; Headroom CCR, vector search, filesystem offload compete with get_context instead of backing it
  5. Provider-native context management breaks the L0 assumption — Anthropic server-side edits and OpenAI previous_response_id can't be expressed; running both diverges stash from reality
  6. Spec-level correctness bug — bare Offload("toolResultErrors") orphans toolUse blocks → ValidationException on Bedrock/Anthropic; the invariant is never mentioned

Top fix: add the view/history axis + a low-level ContextTransform escape hatch that Offload/Inject become sugar over. Full detail below.

The six structural breaks (full detail)

1. The strategy algebra is a closed union, not an interface

type Strategy = OffloadStrategy | InjectStrategy

@mkmeral already flagged this in review and it's the root problem. The design hardcodes exactly two directions, and inside them, closed enums:

  • representation: "truncate" | "summarize" | "skeleton" | "full" — yet Appendix F's own 3P example uses representation: "headroom". The type as written rejects the doc's own example.
  • OffloadConditions = { threshold?, utilization?, skipRecent? } — a closed struct, no predicate function. Any library that triggers on its own signal (drift detection, relevance scoring, "stale for N turns") cannot express its trigger.
  • OffloadTarget — a stringly-typed taxonomy ("toolResults", "code", tool names, ! exclusions). No predicate matcher (match by regex, metadata, token count, mimetype, content block type).

What has no home in offload | inject:

  • Reorder — LongLLMLingua's document reordering (mitigating lost-in-the-middle) changes position, not representation. Neither offload nor inject.
  • Rewrite in place — dedupe across messages, PII redaction, normalization. Not "choose a cheaper representation," and must NOT stash the original (redaction!). The stash-on-offload coupling is wrong for these.
  • Request mutation — see break ci: update sphinx-autodoc-typehints requirement from <2.0.0,>=1.12.0 to >=1.12.0,<4.0.0 #5.

The doc even self-incriminates: "collapse-pairs" (P2, Appendix D) merges a toolUse assistant message with a toolResult user message — that crosses the target taxonomy's own category boundaries. The engine's per-message target matching can't express its own roadmap item.

2. Headroom-class libraries integrate at the wrong granularity

I verified against the actual Headroom codebase/docs. Its architecture is CacheAligner → ContentRouter → SmartCrusher/CodeCompressor/Kompress-v2 operating on the entire request: system prompt, tool schemas, full message array, and request parameters. Specifically:

  • CacheAligner stabilizes the whole prompt prefix so provider KV caches hit. It needs to see and control full request layout — impossible from compress(matchedMessages, budget).
  • Live-zone compression compresses only new bytes; "frozen prefix stays byte-identical so provider cache is not busted. History is never dropped." This is philosophically opposite to the design's engine, which rewrites mid-history messages (see break #6b).
  • ContentRouter does its own per-content routing — the SDK's target matching would fight it. Feeding it pre-filtered "assistantMessages" (the doc's own Appendix I example) neuters it; Headroom's biggest wins are tool outputs (92% on code search). The doc's flagship 3P example misuses the library it's named after.
  • Output shaper appends to the system prompt and mutates request params (reasoning_effort, thinking.budget_tokens). Zero reach from an OffloadMethod.

And the kicker: Headroom already ships a Strands integration, and it's model wrapping (HeadroomStrandsModel(wrapped_model=...)) plus a hook provider for tool results. They chose the model boundary because that's the only seam that gives whole-request, per-call, non-destructive access. The design's ~15-line HeadroomStrategy captures maybe 20% of the library and would be a regression from their existing integration.

3. Durable mutation vs. ephemeral view — the missing axis

This is the single deepest break. The design's offload permanently mutates L0 (the session-persisted message array). But the industry has converged on distinguishing two modes:

  • LangGraph pre_model_hook: return llm_input_messages (ephemeral view, history untouched) or messages + RemoveMessage (durable mutation). Two explicit channels.
  • pydantic-ai history_processors: transform what's sent to the model each call; stored history stays pristine.
  • Headroom model wrapping: compresses per call from the pristine original each time.

Why the view mode matters: compressing a view re-derives from lossless originals every call — quality doesn't degrade. Compressing history compounds: summarize-of-truncate-of-summarize, each pass eating information. It also means a mid-session model switch (ModelRouter, P3) hands the big-window model a pre-degraded history it didn't need. The design cannot express "compress what the model sees, keep what the agent has." There is no strategy attribute, no channel, nothing. Any library built on the view model (Headroom, LLMLingua-style prompt compression) either doesn't fit or gets forcibly downgraded to destructive mode.

4. The retrieval path is not pluggable

The engine writes originals to the CM-owned stash; get_context/search_context read from it. But there's no retrieve() on OffloadMethod and no StashProvider interface:

  • Headroom CCR stores originals in its own local cache with a TTL and its own retrieval tools (headroom_retrieve / rtk via MCP). Plugged in as an OffloadStrategy, you get two stashes, two ref vocabularies, two retrieval tools — and the SDK's get_context can't serve CCR-held content. The agent now needs to know which of two systems holds which original.
  • A compressor whose "original" lives remotely (vendor-side handle) can't answer get_context at all.
  • search_context({query}) implies a content index. With what backend? In-memory grep won't scale to a 50-entry stash of 30K-token blobs, and a team with a vector store can't plug it in — search is not an extension point.
  • Filesystem-offload patterns (Manus-style: agent writes results to files, retrieves with cat/grep — which the doc's own "internal ref 2" team fell back to!) bypass the stash entirely, and the manifest can't represent them.

5. Provider-native context management can't be expressed — and will fight the CM

  • Anthropic context editing (context_management: {edits: [{type: "clear_tool_uses_...", trigger, keep, ...}]}): the server clears old tool results and substitutes placeholders. Expressing "delegate truncation to the provider" requires a strategy to patch the request, which compress(messages) -> messages cannot do. Worse, running both: CM stashes originals it thinks are in L0 while the provider silently clears them server-side — the manifest and reality diverge, budget accounting (projected_input_tokens) is wrong, and two triggers race at different thresholds.
  • OpenAI Responses API (previous_response_id / server-held conversation state): the client doesn't even hold the history. There is no L0 array to offload from. The entire two-layer model assumes SDK-owned client-side messages; a growing class of provider features breaks that assumption.
  • Same story for AgentCore-style managed memory (opieter's "paid L1" thread is a special case of this).

6. Message-level granularity breaks on provider invariants and multimodal content

(a) The spec as written produces API errors. Offload("toolResultErrors") — "Nothing remains in L0." On Bedrock Converse and Anthropic, every toolUse must be followed by a matching toolResult; deleting a toolResult message while its toolUse remains → ValidationException. The SDK's own SlidingWindowConversationManager has explicit pair-boundary handling for exactly this (sliding_window_conversation_manager.py:235,280), and the doc never mentions the invariant. Same landmine when Offload.summarize(ratio=0.3) cuts a range mid-pair. The engine needs a guaranteed invariant-repair pass, and bare offload of tool results must leave a placeholder — contradicting §2.6's table.

(b) Rolling rewrites bust the prompt cache by design. .when({threshold: 1500, skipRecent: 3}) firing on AfterToolCallEvent rewrites message n−3 on every tool call — a moving mutation point that invalidates the provider prefix cache every single turn. On Anthropic, uncached input is ~10× cached input: the strategy can save tokens and raise the bill. The doc has one "cache-aligned" summarize mode, but truncate strategies have no cache awareness, the TokenBudget exposes no cache-breakpoint info, and a 3P strategy has no way to even ask where the stable prefix ends. Headroom's frozen-prefix invariant is unimplementable through this interface. (@mkmeral raised cache invalidation on inject; offload is the much bigger offender.)

(c) The unit is the message, but content is blocks. A toolResult containing text + image: offload just the image block? (@mehtarac asked; no answer in the spec.) Anthropic's native editing works at block granularity. previewTokens/head-tail are meaningless for images; Headroom does image→smaller-image ML compression, which is "same modality, cheaper" — not representable in the truncate/summarize/skeleton vocabulary and awkward as a message-level compress.

Library-by-library plug-in test
Library / system Fits OffloadMethod? What breaks
Headroom ~20% Needs whole-request view, cache alignment, view-not-history semantics, own retrieval store (CCR/TTL), request-param mutation. Already integrates via model wrapping instead.
LLMLingua / LongLLMLingua Partially Question-aware compression needs the current query + instructioncompress(messages, budget) passes neither. Document reordering = neither offload nor inject. Token-level compression is prompt-scoped, not message-scoped; needs a local GPU model (latency profile the sync-looking hook path never discusses).
Letta / MemGPT No Core memory = agent-editable blocks compiled into the system prompt. The design: "system prompt is always implicitly pinned" and inject is messages-only. Self-editing memory (rewrite-in-place via tools) has no direction in the algebra. .as_tool() covers the tool half, but the injection target is off-limits.
Anthropic context editing / memory tool No Request parameter + server-side mutation. No requestPatch capability; dual-manager divergence (break #5).
OpenAI Responses (previous_response_id) No No client-side L0 exists.
LangGraph SummarizationNode / pydantic-ai history_processors Shoehorned Their primitive is the fully-general (messages) -> messages over the whole array with an ephemeral-view option. The design offers no low-level escape hatch — everything must be re-vocabularized into Offload/Inject targets. A one-line pydantic-ai processor becomes "write a custom strategy, pick a fake target, accept durable mutation."
Claude-Code-style compaction Yes /compactOffload.summarize at utilization threshold. This one fits fine.
Secondary breaks (8 smaller but real issues)
  1. Composition semantics are hand-waved. "Multiple strategies can match the same message: they compose, not compete" — in what order? Is a summary output (an assistantMessage) itself a target for the next pass (summary-of-summary loops)? After truncate stashes the original, what does a later summarize on the same message stash — the preview? Idempotency is listed as an engine task but the semantics are unspecified, and lossy transforms don't commute.
  2. Three coexisting injection subsystems, no arbiter. §7.3 keeps ContextInjector as an independent plugin forever; MemoryManager injects on its own lifecycle; CM Inject strategies fire before model calls. They "share the token budget but don't reference each other" — when the sum overflows, who yields? Unspecified. Also §2.4 says RAG belongs to other subsystems while Appendix I demos "RAG providers that don't use MemoryManager" as Inject sources — the boundary is contradictory within the doc.
  3. Strip-and-reinject invalidates cache from the previous injection point. Last turn's injected message (appended at end) is now mid-history; stripping it mutates the prefix. The review-thread answer ("inject at end avoids invalidation") only holds for a single turn.
  4. Budget jurisdiction gap: tool schemas and system prompt. TokenBudget.used counts the whole request, but strategies can only touch messages. For big agents, tool definitions are a top-3 context consumer (hence Anthropic's tool-defs clearing and tool-search). A "context" manager that can't manage a third of the context will push users back to hooks.
  5. Token accounting is provider-shaped. threshold (absolute tokens per message) — counted by whose tokenizer? Images/documents? projected_input_tokens exists per-model; strategy decisions become model-dependent, and JackYPCOnline's "what happens when the model switches" thread is unresolved: budget-conditioned strategies silently change behavior mid-session.
  6. The stash default recreates the doc's own opening bug. Storage "falls back to in-memory" while L0 (with preview stubs) persists via session manager. Restore session → stubs point at nothing → "agent hallucinates instead of admitting it can't retrieve" — the exact problem statement from §1, reintroduced by the default config. In-memory stash + any session persistence should be a hard error or auto-degrade to stash: false.
  7. Refs go stale under the agent's feet. Agentic mode: agent reads context_status, plans pin_context(refs); meanwhile a rule-based summarize merges those N messages into one. The refs the agent is holding now dangle mid-turn. Also unaddressed: parallel tool calls firing AfterToolCallEvent concurrently against a shared mutable L0 — no locking/serialization story.
  8. Serialization claim vs. closures. Appendix E rejects builders as "less serializable," but the chosen design's custom strategies/inject sources are closures over clients and render functions — equally unserializable. Matters the moment you want config-file-defined or dashboard-defined strategies.
What the design gets right (credit where due)
  • Stash + manifest + bounded, paginated get_context directly fixes the "retrieval re-creates the overflow" bug — and independently matches Headroom's CCR + rtk shape. Convergent evolution = validated need.
  • Per-tool targeting with ! exclusions maps to the exact internal-team workaround cited.
  • .as_tool() for agent-managed context matches Letta's proven pattern.
  • Observability section (stream events + OTEL + lifecycle events) is the most complete answer to [Feature Request] Real-time Streaming Events During Context Reduction #1511/[FEATURE] Expose OnEviction hook for context reduction events #2804 anywhere in the doc's lineage.
  • Killing three disconnected components for one system is the right call. The question is whether the one system's floor is an open primitive or a closed vocabulary.
What I'd change (7 fixes, ranked by leverage)
  1. Add the view/history axis (mutates: "view" | "history" per offload strategy, or a distinct Transform channel applied at model-call time). Single highest-leverage fix; it's what LangGraph, pydantic-ai, and Headroom all converged on, and it makes cache-aligned + non-compounding compression possible.
  2. Open the algebra at the bottom. Ship a low-level interface — e.g. ContextTransform: (view: RequestView) => ContextEdit[] where edits cover replace/remove/insert/reorder + requestPatch — and define Offload/Inject as sugar over it. This answers mkmeral's Strategy = A | B thread, absorbs reorder/rewrite/provider-native delegation, and is the escape hatch competitors ship as one line.
  3. StashProvider interface (write/get/search/manifest/ttl) so CCR, vector stores, and filesystem offload back get_context/search_context instead of competing with them. (Also resolves gautam's and opieter's storage threads.)
  4. Accept predicates in .when() and in targets. Cheapest fix in the doc; eliminates most of the closed-taxonomy complaints without new concepts.
  5. Specify the invariant-repair pass (toolUse/toolResult pairing, alternation) as an engine guarantee, and fix §2.6: bare offload of tool results leaves a placeholder, period.
  6. Make cache economics first-class: expose stable-prefix/cache-breakpoint info on TokenBudget (or an EngineContext), and change the default engine behavior from rolling skipRecent rewrites to compress-at-stable-boundaries.
  7. Define the multi-injector budget arbiter (or fold ContextInjector in now rather than v2-forever).
Method note

Read the full 1421-line design + all 40+ review threads on PR #3307; pulled Headroom's repo/README/docs (incl. their existing HeadroomStrandsModel integration guide) rather than trusting the doc's characterization; checked the current SDK's SlidingWindowConversationManager pairing logic and vended context-offloader plugin for what invariants exist today.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-context Session or context related blog documentation Documentation changes, improvements, additions, content updates, site improvements, examples, guides size/xl

Projects

None yet

Development

Successfully merging this pull request may close these issues.