Context management, section 1 of 2, 4h

Working inside the window

By the end: You can keep a long-running conversation or agent inside its limits without losing what matters.

  • What fills a context window
  • Trimming, summarising, offloading
  • Ordering for cache stability
  • What must never be dropped

New to context management? An agent working on a long task accumulates a transcript: every instruction, every tool call, every page of output it read along the way. All of it is resent on every turn, and eventually it will not fit. Context management is deciding what to carry forward — summarising old turns, dropping tool output nobody needs any more, keeping notes in a file instead of in the conversation, and fetching earlier material back only when it becomes relevant again. Why it matters

A model has no memory between calls. Everything it knows about your conversation — the system prompt, every earlier turn, the tool definitions, every tool result — is resent on each request, and all of it has to fit in one bounded space called the context window, measured in tokens. Context management is the work of deciding what goes in that space when the material outgrows it, which for a long conversation or a working agent it always does. This section is about staying inside the window without losing the thread: measuring what is actually filling the window, then the three ways to make space — dropping old turns, replacing them with a summary, or moving detail somewhere the model can fetch back — each written out as code you can lift. The judgement is in what must never be dropped, whatever else goes.

Measure before you cut#

Almost everyone trims the wrong thing, because the intuition about what is large is wrong. Tool results are usually the biggest item by a wide margin and the least valuable to keep once they have been acted on. Count before deciding.

def audit(messages, system, tools, count_tokens):
    """Where the window is actually going, largest first."""
    rows = [
        ("system", count_tokens(system)),
        ("tool definitions", count_tokens(str(tools))),
    ]
    by_role = {}
    for m in messages:
        by_role[m["role"]] = by_role.get(m["role"], 0) + count_tokens(str(m["content"]))
    rows += sorted(by_role.items(), key=lambda kv: -kv[1])

    total = sum(n for _, n in rows)
    for label, n in sorted(rows, key=lambda kv: -kv[1]):
        print(f"{label:20} {n:7,}  {n / total:5.1%}")
    return total

# system                 420   1.2%
# tool                22,100  63.4%   <- the whole problem
# user                 6,300  18.1%
# assistant            5,030  14.4%
A real audit from an agent run. Nobody guesses tool results at 63 percent.

Trimming: drop the oldest, keep the shape#

The cheapest technique, and the one with a trap. You cannot simply slice the list: the API rejects a tool result whose matching tool call has been trimmed away, and a conversation that starts on an assistant turn is malformed. Trim in whole exchanges, and keep the system prompt pinned.

def trim(messages, count_tokens, budget, keep_recent=6):
    """Keep the most recent exchanges that fit, without orphaning tool results."""
    kept, used = [], 0
    for m in reversed(messages):
        size = count_tokens(str(m["content"]))
        if len(kept) >= keep_recent and used + size > budget:
            break
        kept.append(m)
        used += size
    kept.reverse()

    # A tool result whose tool_use is gone will be rejected by the API.
    call_ids = {
        c["id"]
        for m in kept if isinstance(m["content"], list)
        for c in m["content"] if c.get("type") == "tool_use"
    }
    kept = [
        m for m in kept
        if not (isinstance(m["content"], list)
                and any(c.get("type") == "tool_result"
                        and c.get("tool_use_id") not in call_ids
                        for c in m["content"]))
    ]

    # A conversation may not begin on an assistant turn.
    while kept and kept[0]["role"] != "user":
        kept.pop(0)
    return kept
The three lines after the slice are the ones people leave out, and the API error they cause is unhelpful.

Summarising: fold the old turns into one#

When the early conversation still matters, replace it rather than dropping it. Summarise everything older than the recent window into a single message and carry that forward. The summary must be about decisions and state, not narrative, or it will read pleasantly and be useless to the model.

SUMMARISE = """Summarise the conversation below for an assistant that will
continue it and cannot see the original. Record only:
- decisions made and constraints agreed, verbatim where exact wording matters
- facts established, with the identifiers, names and numbers involved
- what has been tried and failed, so it is not tried again
- what the user is currently trying to do

Omit pleasantries, restated questions and anything already superseded.
Write it as notes, not prose."""

def compact(client, messages, count_tokens, budget, keep_recent=6):
    if count_tokens(str(messages)) <= budget:
        return messages

    old, recent = messages[:-keep_recent], messages[-keep_recent:]
    if not old:
        return messages

    summary = client.messages.create(
        model="claude-sonnet-5",
        max_tokens=1024,
        system=SUMMARISE,
        messages=[{"role": "user", "content": str(old)}],
    ).content[0].text

    return [
        {"role": "user", "content": f"Summary of the conversation so far:
{summary}"},
        {"role": "assistant", "content": "Understood, I have that context."},
        *recent,
    ]
The synthetic exchange keeps user and assistant turns alternating, which the API requires.

Summarise once, not repeatedly

Summarising a summary loses a little each time, and after several rounds an agent will confidently contradict something it agreed to an hour ago. Keep the original summary and re-summarise from the full transcript you still hold on disk, or accept a hard limit on how many times a session may compact before it must start fresh.

Offloading: keep a reference, not the content#

The best option for tool results, which dominate the audit above. Write the full result somewhere the model can read back on request, and leave a short handle in the conversation. The model keeps the ability to consult it without paying for it on every turn.

STORE = {}

def offload(result, keep=200):
    """Replace a large tool result with a stub the model can expand."""
    text = str(result)
    if len(text) <= keep * 2:
        return text
    ref = f"res_{len(STORE)}"
    STORE[ref] = text
    return (
        f"{text[:keep]}
"
        f"[truncated, {len(text):,} chars total. "
        f"Call read_result(ref={ref!r}, start=..., length=...) for the rest.]"
    )

def read_result(ref, start=0, length=2000):
    """Expose this as a tool so the model can pull back what it needs."""
    return STORE[ref][start : start + length]
The head of the result is usually enough to decide whether the rest is worth fetching.

Order it so the cache still hits#

Every technique here rewrites history, and prefix caching matches from the very first character, so a careless compaction invalidates the cache on every turn and costs more than it saves. Put the things that never change first — system prompt, tool definitions, the stable summary — and confine edits to the tail.

system prompt          never changes        cached
tool definitions       never changes        cached
summary of old turns   changes rarely       cached until it does
recent exchanges       changes every turn   not cached
current question       changes every turn   not cached
Compaction that rewrites the top of this list pays full price on every subsequent call.

What must never be dropped#

  • The system prompt. Trimming it produces an agent that forgets its own constraints and no error to explain why.
  • Anything the user explicitly asked to be remembered. Losing it is the failure people never forgive.
  • Decisions and their reasons. An agent that re-litigates a settled choice looks broken.
  • The most recent exchange, whatever the budget says. Without it there is no conversation.
  • Identifiers: file paths, ticket numbers, order references. They are short and irreplaceable.

Where people go wrong

Summarising with the same expensive model running the agent. Compaction then costs more than it saves, and nobody notices because it is buried in the same bill.

If this comes up in an interview

How do you handle a conversation that outgrows the context window?

Trim what is no longer needed, especially old tool results. Summarise older turns while keeping recent ones verbatim. Offload large content to storage with an id. Order everything from stable to volatile so prompt caching still applies.

What gets lost when you compact a conversation, and why does it matter?

The risk is losing the original task, user corrections, decisions already made and failures already hit. The agent then continues confidently while solving a slightly different problem, which is hard to spot because nothing errors.

Why not just use a model with a larger context window?

Cost scales with every token on every call, latency grows, and recall across a long window is uneven with material in the middle attended to least. A bigger window postpones the problem and makes it more expensive.

Build this: Run an agent until it overflows, add compaction, and measure three things before and after: total tokens, total cost, and whether the task still completes correctly. The third is the one people skip.

This is one section of Context management, 8 hours across 2 sections. Read it in full to get the whole topic in order.

Stop 19 of 50 on the AI Engineer route

0 of 50 topics done · 0h behind you, 603h to go

Next on this routeAgent reliabilityDegrading instead of collapsing.8h

What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.