feat: add context manager class design doc#3307
Conversation
| new ContextManager({ | ||
| strategies: [ | ||
| // Offload: replace expensive content with cheaper representations | ||
| Offload.truncate("toolResults", { previewTokens: 750 }) |
There was a problem hiding this comment.
Are these running conversation managers under the hood? or are these like separate?
There was a problem hiding this comment.
separate, conversation manager is dead!
There was a problem hiding this comment.
essentially just a compression function that is defined and can be applied to anything (ideally)
There was a problem hiding this comment.
"conversation manager is dead!" long live the context manager! yeey
|
|
||
| ## 1. Problem | ||
|
|
||
| ### What customers hit |
There was a problem hiding this comment.
This is a very helpful style for an intro into a doc 💯
|
|
||
| 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. |
There was a problem hiding this comment.
SlidingWindowConversationManager too?
| 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: |
There was a problem hiding this comment.
Inject adds cheaper representations of expensive external content.
So this would be summaries of the context?
There was a problem hiding this comment.
can be summaries, but often are not.
any cheaper representation of the information stored in the context window can be supported.
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
Where did you get these representations? Are there others that you considered?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
Is references a clearer term for this maybe?
There was a problem hiding this comment.
sure, maybe even structure. open to naming changes, havent thought abt it much
|
|
||
| new ContextManager({ | ||
| strategies: [ | ||
| // Offload: replace expensive content with cheaper representations |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
These look very file format specific. Is it generic?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Wondering is this widely used ?
There was a problem hiding this comment.
this was the best performer on my initial testing.
has some traction in the community afaik.
|
|
||
| | Target | Matches | | ||
| |--------|---------| | ||
| | `"toolResults"` | All successful tool result messages | |
There was a problem hiding this comment.
should we make these as enums?
|
|
||
| ## 1. Problem | ||
|
|
||
| ### What customers hit |
There was a problem hiding this comment.
Are all of these issues today? Haven't we fixed a bunch of these?
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
How do they integrate? I'm not sure if I get that from the doc
There was a problem hiding this comment.
See Appendix A part 4/5
There was a problem hiding this comment.
How does this work with this definition though? it's not extensible
type Strategy = OffloadStrategy | InjectStrategyThere was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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. | |
There was a problem hiding this comment.
won't this keep invalidating cache?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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()]) |
There was a problem hiding this comment.
is this a better devx then context_manager="agentic"? 🤔
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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:** |
There was a problem hiding this comment.
are all of these one tool each? Do we need all of them to be separate? We should combine some of these probably
There was a problem hiding this comment.
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. | |
There was a problem hiding this comment.
why is this p1? can't we reuse agentic mode here?
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
Does it has to be defined by SDK, or user should do that, eg: pass in some callback functions.
There was a problem hiding this comment.
very interested in extending to callback function. would be a good p1/p2, they do that in this pr already for ContextOffloader: #3267
There was a problem hiding this comment.
would also like to include **kwargs to extend
| ### 4.2 Recreating "auto" | ||
|
|
||
| ```python | ||
| agent = Agent(context_manager="auto") |
There was a problem hiding this comment.
I personally prefer agent = Agent(context_manager=ContextManager())?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
would the agent not want a little of the semantic content of each entry to remember what is there?
There was a problem hiding this comment.
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%"> |
There was a problem hiding this comment.
Is there no preview in this example?
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
We should add trigger strategies from writing L1->L2
|
|
||
| --- | ||
|
|
||
| ## 3. The Stash (L1) |
There was a problem hiding this comment.
What is the store back-end here? Can I plug in my own?
There was a problem hiding this comment.
(naive question) could stash just accept a Storage implementation? This ties to another comment of mine about supporting offload/inject with external stores
| | `"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. |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
this is done in the memory Manager already since it can read from L0 (and possibly L1 in future)
|
|
||
| --- | ||
|
|
||
| ## 3. The Stash (L1) |
There was a problem hiding this comment.
Why doesn't L1 survive session restore? This seems like a big gap if I offload a lot and frequently restart sessions.
There was a problem hiding this comment.
this should survive session restore? ContextManager-owned store for recoverable content.
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
what about recreating "agentic"? :)
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
If includes and excludes can't be in the same array, why not separate them into different parameters?
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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" /> |
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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()]) |
There was a problem hiding this comment.
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. | |
There was a problem hiding this comment.
Can we reuse the context injector or extend it?
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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": |
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
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 }) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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)
Adversarial analysis: where the ContextManager design breaksTL;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
Top fix: add the view/history axis + a low-level The six structural breaks (full detail)1. The strategy algebra is a closed union, not an interfacetype 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:
What has no home in
The doc even self-incriminates: 2. Headroom-class libraries integrate at the wrong granularityI verified against the actual Headroom codebase/docs. Its architecture is
And the kicker: Headroom already ships a Strands integration, and it's model wrapping ( 3. Durable mutation vs. ephemeral view — the missing axisThis 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:
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 pluggableThe engine writes originals to the CM-owned stash;
5. Provider-native context management can't be expressed — and will fight the CM
6. Message-level granularity breaks on provider invariants and multimodal content(a) The spec as written produces API errors. (b) Rolling rewrites bust the prompt cache by design. (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. Library-by-library plug-in test
Secondary breaks (8 smaller but real issues)
What the design gets right (credit where due)
What I'd change (7 fixes, ranked by leverage)
Method noteRead the full 1421-line design + all 40+ review threads on PR #3307; pulled Headroom's repo/README/docs (incl. their existing |
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.
hatch run prepareChecklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.