Context Window Management for Python AI Agents - Trim and Summarize with 10xGraph
In shortKeep long agent conversations inside the token limit in 10xGraph with MessageContextManager trimming and SummaryContextManager summaries from a cheaper model.
- 6 min read
- 9 sections
- Updated
- v1.0.0rc4
- Markdown
This example keeps a long chat inside the model’s context window. message_window.py drops old turns with MessageContextManager. summarize.py compresses them into a running summary with SummaryContextManager. The chat agent uses Anthropic claude-sonnet-5-5; in the summary script a different company’s model, Google gemini-2.5-flash, writes the summaries.
What you will build
Two ways to stop a long chat from outgrowing the model’s context window.
message_window.pykeeps only the last 3 user turns. Older turns are dropped, which is cheap but forgetful.summarize.pyreplaces old turns with a short summary and keeps the recent messages word for word. The conversation is answered by Anthropicclaude-sonnet-5-5, while the summaries are written by Googlegemini-2.5-flash. Summarising is a simple task, so a smaller, cheaper model from another company does it at lower cost, and the context manager has its own model setting, independent of the agent’s.
Both print the size of the stored context after each turn, and the second prints context_summary.
Key concepts
MessageContextManager(max_messages=10, remove_tool_msgs=False): window by user-message count.SummaryContextManager(model, max_messages=30, token_budget=None, keep_recent=8, remove_tool_msgs=False, summary_system_prompt=None, max_summary_tokens=600, api_style="responses"): summarises when the message count or the estimated token count passes its limit.ReactAgent(context_manager=..., trim_context=True): attaches the manager and turns on trimming. Both are needed.state.context_summary: the rolling summary text. Read it withResponseGranularity.PARTIAL(keyscontext,summary,messages) orFULL.ResponseGranularity: how much of the stateainvokereturns.
Run it
git clone https://github.com/10xGraph/10xGraph.git
cd 10xGraph/agentflowpip install "10xgraph[anthropic,google-genai]" python-dotenv
export ANTHROPIC_API_KEY="..."
export GEMINI_API_KEY="..."message_window.py only needs the Anthropic key and extra.
python examples/context_management/message_window.py
python examples/context_management/summarize.pyHow the code works
message_window.py
The agent is a plain ReactAgent with two extra arguments.
agent = ReactAgent(
model="claude-sonnet-5-5",
system_prompt=[{"role": "system", "content": "Answer briefly."}],
# Counts USER messages. The oldest turns are dropped; system messages stay.
# remove_tool_msgs=True would also drop tool calls and tool results.
context_manager=MessageContextManager(max_messages=3),
# Without this flag the agent never calls the context manager.
trim_context=True,
)MessageContextManager counts user messages. When there are more than max_messages, it keeps the latest ones with everything after them, and it always keeps system messages. trim_context=True is the switch that makes the agent call the manager. Without it the manager is registered but never used. The trim runs before each LLM call and again when state is saved, so the stored thread stays small too.
result = await graph.ainvoke(
{"messages": [Message.text_message(question)]},
config,
# PARTIAL adds the stored context and summary to the reply.
response_granularity=ResponseGranularity.PARTIAL,
)
users = sum(1 for m in result["context"] if m.role == "user")ResponseGranularity.PARTIAL adds the stored context to the reply. That lets the script count how many user messages are still in the thread. The last question asks for the favourite colour from turn 1, which has been trimmed away, so the agent cannot answer it.
summarize.py
summariser = SummaryContextManager(
# Summarising is a simple task, so a small fast model is enough. Any provider works
# here; it is detected from the model name, like for an Agent.
model="gemini-2.5-flash",
max_messages=6, # summarise when the context holds more than 6 messages
keep_recent=4, # always keep the last 4 non-system messages verbatim
token_budget=None, # or set a rough token cap (1 token ~ 4 characters)
max_summary_tokens=400,
# api_style only matters for OpenAI summary models: "responses" (default) or "chat".
# With a Gemini model it is ignored.
)The summary manager has its own model. It detects the provider from the model name, the same way Agent does, so the agent talks to Anthropic while Gemini writes summaries. When the context holds more than max_messages (or passes token_budget, a rough estimate at 4 characters per token), everything except the last keep_recent non-system messages is sent to the summary model. System messages are never summarised. api_style only matters for OpenAI summary models and is ignored with Gemini.
agent = ReactAgent(
model="claude-sonnet-5-5",
system_prompt=[{"role": "system", "content": "You are a concise travel planner."}],
context_manager=summariser,
trim_context=True, # required, or the manager is never called
)The result is stored in state.context_summary and the older messages are removed. Later summaries are appended to the earlier text, so nothing is thrown away permanently. When the next prompt is built, the summary is injected as an assistant message before the recent messages. If the summary call fails, the context is left unchanged and the run continues.
if result["summary"]:
print(f" -> context_summary: {result['summary']}")The script reads the summary back from the PARTIAL result under the key summary.
Full code
message_window.py
"""Keep only the most recent turns in the prompt with MessageContextManager.
After each run the context holds at most the last 3 user messages (plus any system
messages), so token use stays flat in a long chat. Needs trim_context=True on the agent.
Run: python examples/context_management/message_window.py
"""
import asyncio
from dotenv import load_dotenv
from tenxgraph.core.state import Message, MessageContextManager
from tenxgraph.prebuilt.agent import ReactAgent
from tenxgraph.storage.checkpointer import InMemoryCheckpointer
from tenxgraph.utils import ResponseGranularity
load_dotenv()
QUESTIONS = [
"My favourite colour is green. Remember that.",
"I work as a nurse.",
"I have a dog called Biscuit.",
"I live in Porto.",
"What is my favourite colour?", # the first turn is gone by now
]
async def main() -> None:
agent = ReactAgent(
model="claude-sonnet-5-5",
system_prompt=[{"role": "system", "content": "Answer briefly."}],
# Counts USER messages. The oldest turns are dropped; system messages stay.
# remove_tool_msgs=True would also drop tool calls and tool results.
context_manager=MessageContextManager(max_messages=3),
# Without this flag the agent never calls the context manager.
trim_context=True,
)
graph = agent.compile(checkpointer=InMemoryCheckpointer())
config = {"thread_id": "window-demo", "user_id": "demo-user"}
async with graph:
for question in QUESTIONS:
result = await graph.ainvoke(
{"messages": [Message.text_message(question)]},
config,
# PARTIAL adds the stored context and summary to the reply.
response_granularity=ResponseGranularity.PARTIAL,
)
users = sum(1 for m in result["context"] if m.role == "user")
print(f"user: {question}")
print(f"assistant: {result['messages'][-1].text()}")
print(f" -> context: {len(result['context'])} messages, {users} from the user\n")
if __name__ == "__main__":
asyncio.run(main())summarize.py
"""Compress old turns into a running summary with SummaryContextManager.
The agent talks to Anthropic, but the summaries are written by Gemini, a cheaper model
from another company. Old messages move into state.context_summary and only the recent
ones stay verbatim. Run: python examples/context_management/summarize.py
"""
import asyncio
from dotenv import load_dotenv
from tenxgraph.core.state import Message, SummaryContextManager
from tenxgraph.prebuilt.agent import ReactAgent
from tenxgraph.storage.checkpointer import InMemoryCheckpointer
from tenxgraph.utils import ResponseGranularity
load_dotenv()
QUESTIONS = [
"I'm planning a 5 day trip to Japan in April. I like food and quiet places.",
"I will land in Tokyo and leave from Osaka.",
"My budget is about 2000 euros without flights.",
"I am vegetarian.",
"Suggest a rough day-by-day plan that respects everything I told you.",
]
async def main() -> None:
summariser = SummaryContextManager(
# Summarising is a simple task, so a small fast model is enough. Any provider works
# here; it is detected from the model name, like for an Agent.
model="gemini-2.5-flash",
max_messages=6, # summarise when the context holds more than 6 messages
keep_recent=4, # always keep the last 4 non-system messages verbatim
token_budget=None, # or set a rough token cap (1 token ~ 4 characters)
max_summary_tokens=400,
# api_style only matters for OpenAI summary models: "responses" (default) or "chat".
# With a Gemini model it is ignored.
)
agent = ReactAgent(
model="claude-sonnet-5-5",
system_prompt=[{"role": "system", "content": "You are a concise travel planner."}],
context_manager=summariser,
trim_context=True, # required, or the manager is never called
)
graph = agent.compile(checkpointer=InMemoryCheckpointer())
config = {"thread_id": "summary-demo", "user_id": "demo-user"}
async with graph:
for question in QUESTIONS:
result = await graph.ainvoke(
{"messages": [Message.text_message(question)]},
config,
response_granularity=ResponseGranularity.PARTIAL,
)
print(f"user: {question}")
print(f"assistant: {result['messages'][-1].text()[:200]}")
print(f" -> context: {len(result['context'])} messages")
if result["summary"]:
print(f" -> context_summary: {result['summary']}")
print()
if __name__ == "__main__":
asyncio.run(main())Source on GitHub: examples/context_management
Expected output
Illustrative; model text and the exact turn where summarising starts will differ.
message_window.py:
user: I have a dog called Biscuit.
assistant: Biscuit is a great name!
-> context: 6 messages, 3 from the user
...
user: What is my favourite colour?
assistant: I do not have that in our conversation. What is it?
-> context: 6 messages, 3 from the usersummarize.py:
user: I am vegetarian.
assistant: Noted. I will plan vegetarian-friendly food stops.
-> context: 5 messages
-> context_summary: The user is planning a 5-day trip to Japan in April, likes food and quiet places, lands in Tokyo and departs from Osaka, and has about 2000 euros excluding flights.Using a different model
Change the model strings. For example an OpenAI agent, ReactAgent(model="gpt-5-mini", ...) with pip install "10xgraph[openai]", and a summariser on a third company, SummaryContextManager(model="claude-sonnet-5-5", ...). For an OpenAI summary model on an older endpoint, add api_style="chat".
Next steps
- Persistence: store the trimmed thread in SQLite or Postgres.
- Resilience: fallbacks when the agent model or the summary model fails.
- Human in the loop: pause long-running threads for approval.
Frequently asked questions
- Why is my context manager not doing anything?
- The agent only calls it when trim_context=True is set on the agent (ReactAgent or Agent). Without the flag the context manager is registered but unused during LLM calls.
- What does max_messages count in MessageContextManager?
- User messages. It keeps the most recent max_messages user turns with their replies, and always keeps system messages. Set remove_tool_msgs=True to also drop tool calls and tool results.
- Can the summary model come from a different provider than the agent?
- Yes. SummaryContextManager takes its own model string and detects the provider from the name, so a Claude agent can use gemini-2.5-flash or an OpenAI model for summaries. Set api_style="chat" only for OpenAI-compatible models without Responses API support.
- Where does the summary go?
- Into state.context_summary. It is a rolling summary, so each new summarisation is appended to the earlier one, and it is placed in front of the recent messages when the next prompt is built.