Managing the context window
Budgets, clearing old tool results, compaction and a cacheable prefix.
Create a free account Try part of it here
Why it matters
Every model call sends the whole conversation. A coding agent that reads twenty files and runs the tests ten times can fill even a large context window within an hour. Then calls get slow and costly, quality drops as old material crowds out what matters, and finally the request is too big to send. Long-running agents depend on managing context well. This is one of the main things that separates a toy harness from a professional one.
What you'll be able to do
- Treat the context window as a budget, and measure how much each part uses
- Clear old tool results while keeping every tool_use paired with its result
- Compact a long conversation into a summary at a safe cut point
- Order requests so the stable prefix can be cached, and keep it stable
What it covers
- A budget with four parts
- Clear old results, keep the pairing
- Where to cut when compacting
- Finding a safe place to cut
- The cacheable prefix
- Seeing the cache work
- Bigger windows don't remove the work
- Compaction loses things
A budget with four parts
Picture a desk of a fixed size. Your tools, your instructions, the papers for the job and the space to write your reply all have to fit on it. A request works the same way. Each one holds: tool definitions, the system prompt, the messages, and room for the reply (max_tokens). They all share one window. The response's usage tells you the real input size, so log it every turn:
used = u["input_tokens"] + u["cache_read_input_tokens"] + u["cache_creation_input_tokens"]
Bigger isn't free even when it fits. On a buried desk, the note you need is still there, and you won't find it. Models attend less sharply to details buried in a huge context, sometimes called context rot. So the goal isn't only to fit, but to keep the context full of what matters now.
Five ways to keep it lean
- Send less in. Output caps, concise tool results,
offsetandlimit(earlier lessons). - Clear old tool results. A file read 40 turns ago is probably stale. Replace its content with a short placeholder. Keep the
tool_useandtool_resultblocks, so the pairing stays valid. - Compact. Replace the older part of the conversation with a summary: the task, decisions made, files changed, what's left. Keep the recent turns word for word.
- Move it out. Write notes and plans to files the agent can read back (next lesson).
- Delegate. A subagent explores in its own context and returns only a short answer (later lesson).
The Claude API can do 2 and 3 on the server. Context editing (beta) has a clear_tool_uses_20250919 strategy that clears old tool results past a threshold. Compaction (beta compact-2026-01-12) summarizes automatically when the context gets long; your loop appends the whole response.content each turn, compaction block included, and the API handles the rest.
Clear old results, keep the pairing
Replacing an old tool result frees its tokens while the conversation stays valid, because each tool_use still has its tool_result.
messages = [
{"role": "assistant", "content": [{"type": "tool_use", "id": "a", "name": "read"}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "a", "content": "x" * 9000}]},
{"role": "assistant", "content": [{"type": "tool_use", "id": "b", "name": "read"}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "b", "content": "y" * 4000}]},
]
def clear_old(messages, keep_last=1):
results = [b for m in messages for b in m["content"] if b["type"] == "tool_result"]
for block in results[:-keep_last]:
block["content"] = "[cleared: read again if you need it]"
def size(messages):
return sum(len(b.get("content", "")) for m in messages for b in m["content"])
print("before:", size(messages))
clear_old(messages)
print("after:", size(messages))
print(messages[1]["content"][0])The placeholder tells the model that the file is still there and how to get it back. The newest result stays whole, since that's probably what it's working on now.
Show the answer
Every tool_use must still have a matching tool_result, or the request is invalid
The API checks the pairing. Keep the structure; shrink the content.
Where to cut when compacting
You can't cut anywhere. If you tear pages out of an interview transcript, you mustn't leave an answer without its question. If the recent part starts with a user message of tool_result blocks, their tool_use blocks are in the part you summarized away, and the request is invalid. A safe cut is right before an assistant message: put the summary in a new first user message, then the recent turns from that assistant message on. Roles still alternate, and every result in the recent part has its tool call there too.
The summary prompt matters. Treat it as a handover note at the end of a shift. Ask for the original task, key decisions and their reasons, files touched, errors met and fixed, and the next steps. A summary that loses the task leaves an agent that's lost.
Finding a safe place to cut
A cut must fall right before an assistant message, or some tool_result loses its tool_use. Here's a function that finds the latest safe cut that keeps enough recent turns.
roles = ["user", "assistant", "user", "assistant", "user", "assistant", "user"] kinds = ["text", "tool_use", "tool_result", "tool_use", "tool_result", "text", "text"] def safe_cut(roles, keep_recent): for i in range(len(roles) - keep_recent, 0, -1): if roles[i] == "assistant": return i return None cut = safe_cut(roles, keep_recent=3) print("cut before message", cut, f"({roles[cut]}, {kinds[cut]})") print("summarized:", kinds[:cut]) print("kept word for word:", kinds[cut:]) cut = safe_cut(roles, keep_recent=2) print("asking to keep 2 keeps:", kinds[cut:])
The search walks backward to an assistant turn, so it may keep a little more than you asked for. That's fine: keeping one extra turn costs little, and an invalid request costs the whole call.
The cacheable prefix
If you've already checked the first 50 pages of a contract, you can skip them next time, as long as not one word has changed. Prompt caching does the same thing. It makes a repeated prefix, the unchanged start of a request, cheaper and faster. The order is tools, then system, then messages, so:
- Put stable things first: tool definitions, then the system prompt, then the conversation.
- Don't put the time, a random ID or a changing count in the system prompt; it would change the prefix every call.
- Keep tool order fixed; sort them once.
- To change instructions mid-session, append rather than edit. The current Claude API accepts
systemrole messages later in the conversation for this.
Clearing and compaction do change the prefix, so the next call pays to rebuild the cache. That's why you clear in large batches when crossing a threshold, not a little every turn.
In plain words: keep the start of every request exactly the same, and put whatever changes at the end.
The cut starts on a user message of tool results, so it steps back once, to the assistant message that made those calls.
Show the answer
Sort tool definitions once and never reorder them
Caching reuses an unchanged start of the request. Anything that changes it early makes later calls pay full price.
Seeing the cache work
The second call sends the same tools and system prompt, so that prefix is read from the cache. (The practice server follows the same rules: it caches prefixes of 512 tokens or more.)
import requests SYSTEM = [{"type": "text", "text": "You are a careful coding agent for the gradient-shop project. " * 60, "cache_control": {"type": "ephemeral"}}] def call(question): r = requests.post("https://api.anthropic.com/v1/messages", headers={"x-api-key": "added-by-the-academy-server", "anthropic-version": "2026-10-21"}, json={"model": "claude-opus-5-5", "max_tokens": 200, "system": SYSTEM, "messages": [{"role": "user", "content": question}]}, timeout=60) r.raise_for_status() return r.json()["usage"] for q in ["List the test files.", "Which tests are slow?"]: u = call(q) print(q, "| new:", u["input_tokens"], "| written to cache:", u["cache_creation_input_tokens"], "| read from cache:", u["cache_read_input_tokens"])
messages = ["task", "a1", "r1", "a2", "r2", "a3", "r3"] roles = ["user", "assistant", "user", "assistant", "user", "assistant", "user"] cut = len(messages) - 3 while cut > 0 and roles[cut] != "assistant": cut -= 1 print(cut, messages[cut:])
Show the answer
3 ['a2', 'r2', 'a3', 'r3']
Index 4 holds tool results, so the cut moves back to the assistant message at 3. Every kept result still has its tool call.
Bigger windows don't remove the work
A million-token window sounds like the end of the problem. It isn't. A bigger desk still gets buried. You pay for every token on every call, so a huge context costs more each turn, and the model pays less attention to details buried deep inside. Managing context is about quality as much as fitting in.
Don't delete results to save space
Removing tool_result blocks breaks the conversation, because each tool_use must have its result. Replace the contents with a placeholder and keep the block.
Keep dynamic values out of the start
Putting today's date or a request id at the top of the system prompt looks harmless. It changes the prefix on every call, so the cache never gets a hit, and you pay full price each time. Put values like that at the end, in the latest message.
Compaction loses things
A movie recap never has every scene, and a summary always drops detail. So write important decisions and plans to files, which survive compaction word for word, and read them back whenever you need them again later on in a session.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Where is it safe to cut a conversation when compacting?