Share this postFacebookXLinkedInWhatsApp

When Your AI Agent's Memory Gets Full

 
 

A practical guide to context compaction, from my own experience keeping long AI chats working.

IN SHORT
✓Long chats grow until the model rejects every message.
✓Fix: summarise old messages, keep recent ones.
✓Decide using the token count the model reports, not a guess.
✓Keep each tool call together with its result.
✓Still too big? Compact harder and retry once.
✓Compaction must never break the user’s chat.

At work, I built an AI assistant that uses tools: it searches records, looks up details and drafts letters. Users liked it and kept one conversation going for a long time.

Then a long chat stopped working. Every message failed with the prompt is too long, even short questions and retries.

The chat was not broken, it was too big for the model to read. Here is how I solved it.

context compaction
context compaction

The idea in one picture: old messages become a short summary, and recent messages stay as they are.

1.Why a chat becomes full

An AI model remembers nothing between messages. On every message, your app sends the complete conversation again: instructions, old messages, tool results and the new message.

A model can read only a limited amount at once, called the context window, measured in tokens (small pieces of words).

So the conversation grows every turn, fastest with tool results, since one document can be thousands of tokens. For example:

Turn Size sent to the model  
1
3,000 tokens
 
Fine
10
45,000 tokens
 
Slower and more expensive
20
120,000 tokens
 
Close to the limit
21
135,000 tokens
 
Rejected: too big

It never shrinks by itself. Turn 22 sends the same history plus one message, so the user is stuck.

?A COMMON QUESTION

Why not simply delete the oldest messages?

Answer: You lose facts the assistant still needs (the user’s goal, IDs, completed work), and careless deletion can leave the conversation in a form the model rejects (see Rule 2).

2.The idea: minutes of a meeting

When a colleague joins a meeting late, nobody replays the recording. He gets the minutes and follows the current discussion.

Compaction works the same way:

📝
Old messages become a short summary (the minutes).
💬
Recent messages stay unchanged (the current discussion).

The model reads much less but keeps what matters. This runs before every message:

 
1
Check the size
Was the last turn above 80% of the limit? If not, stop.
2
Pick a cut point
Split the chat into the old part and the recent part.
3
Summarise the old part
Ask the model for short notes in a fixed format.
4
Check the summary
Empty or not much smaller? Leave the chat unchanged.
5
Swap
Replace the old messages with the summary.
6
Answer the user
Still too big? Use the backup plan (Rule 6).

Each step has one rule, explained below.

Rule 1:Use the real size, do not guess

I first tried counting tokens myself. It is harder than it looks: each model family splits text differently, languages other than English count very differently, and some inputs, like tool descriptions, are not in your message list at all.

The model already reports the exact number in every response: tokens in and tokens out. Save it after each turn and check it before the next:

 
after each turn:
    save  last_size = tokens_in + tokens_out     # reported by the model

before the next turn:
    if last_size > 80% of the context window:
        compact the conversation

Two points:

  •  

    Count input and output. The answer becomes the next turn’s input, so counting only input gives a number that is too low.

  •  

    Why 80%? The number is from the previous turn. The remaining 20% leaves space for the new message and any large tool results.

⚠MISTAKE I MADE: THE SUMMARY STARTED ANOTHER SUMMARY

The summary call also reports its token count. I saved it in the same place as the chat’s count, so the next turn saw a huge chat and compacted again, and again.

Fix: mark summary calls and ignore them in the size check.

Rule 2:Never separate a tool call from its result

A tool use is stored as a pair, the call and its result:

 
User:       "Please create a draft letter."
Assistant:  [calls tool: create_draft]
Tool:       [result: letter #48213, created]
Assistant:  "Letter #48213 has been created."

Models expect each result right after its call. If your cut keeps the result but drops the call, the model rejects the conversation, and since that state is saved, every later message fails too.

The fix: start the recent part with a user message. A user message never sits between a call and its result, so cutting before one is always safe.

 
cut = number of messages minus recent messages to keep

while the message at the cut is not a user message:
    move the cut one step earlier          # earlier, never later

old part    = everything before the cut
recent part = everything from the cut on

Also keep out of the old part: system instructions (rebuilt every turn) and old summaries (see Rule 3).

⚠ANOTHER MISTAKE: PROTECTING MESSAGES BLOCKED EVERYTHING

I once protected some important tool results from compaction. Since the old part always starts at the beginning of the chat, one protected message near the top blocked the cut forever, and the chat grew until it crashed.

Fix: protect important facts by keeping them in the summary, not by blocking the cut.

Rule 3:Write a useful summary

Three things helped most.

Use a separate, plain model call

Do not ask the main assistant to summarise inside the chat: it may call a tool instead, and its reply becomes an odd extra message. Use a separate call with no tools and temperature 0 for stable output.

Ask for notes with fixed sections, not a paragraph

A paragraph summary slowly loses details. Ask for fixed sections:

Section What goes in it
Goal What the user is trying to do
Open tasks Things still waiting to be done
Done Things already finished, so the assistant does not repeat them
Preferences Tone, language, deadlines, things to avoid
IDs Order numbers, record IDs, references, copied exactly. One wrong character breaks the next tool call
Notes Anything else, in two or three sentences

Update the old notes instead of summarising them again

On later compactions, send the previous summary plus the newly old messages and ask for one updated set of notes. Summarising summaries loses detail each time, like a photocopy of a photocopy.

✦SMALL TIP: MESSY OUTPUT

Models sometimes return nearly correct output, such as extra text or a list instead of text. Repair what you can and fall back to plain notes rather than discarding it. In my first real test, a very good summary (3,682 tokens down to 725) was rejected only because one ID came back as a list.

Rule 4:Check the summary before you delete anything

Deletion cannot be undone, so I run two checks first:

 
if summary is empty:                    reject
if summary is not at least 50% smaller: reject

If a check fails, the chat stays as it is for one more turn. Nothing is lost.

⚠THE CHECK THAT ALWAYS FAILED

A third check required every string that looked like an ID to appear in the summary. It also caught random text that only looked like IDs, which the model never copied. Every summary was rejected, nothing was compacted, and the chat crashed.

Lesson: a slightly imperfect summary is better than a dead chat. A check that can never pass is worse than no check.

Rule 5:Swap correctly (the LangGraph part)

This part needs real code. In LangGraph, the saved message list only grows: anything you write is appended. Saving a shorter list changes nothing, and saving only the summary makes the chat bigger.

To delete, you send LangGraph a special RemoveMessage for each old message, together with the new summary, in one update:

 
from langchain_core.messages import RemoveMessage, SystemMessage

removals = [RemoveMessage(id=m.id) for m in old_part + old_summaries]
summary  = SystemMessage(content=summary_text,
                         additional_kwargs={"is_summary": True})

await graph.aupdate_state(config, {"messages": removals + [summary]})

Two points:

  •  

    Tag the summary (the is_summary flag above) so you can find it later. Then remove every old summary each time, not just the last one. If a past run failed halfway, the next one cleans it up.

  •  

    Do not write “the messages below” in the summary. LangGraph appends it at the end, so avoid referring to its position.

Rule 6:Keep a backup plan

Rule 1 has a weakness: when a turn is too big, the model returns an error with no size number, so the check never runs. One huge tool result can also cross the limit in a single turn.

So catch the “too long” error and recover once:

 
try:
    answer the user
except "prompt is too long" error:
    compact hard        # keep only the latest user message and everything after it
    try once more       # continue the same turn; don't add the message twice
  •  

    Compact hard, but follow Rule 2. Keep only the latest user message and everything after it. Never keep just the newest message: in a failing turn it is often a tool result, and without its call the chat breaks permanently.

  •  

    Detect the error by its text. Providers word it differently (“maximum context length”, “prompt is too long”), so match a few specific phrases. A broad word like “context” can match unrelated errors.

  •  

    Retry only once. If hard compaction fails, the cause is something it cannot shrink, like very long instructions. Tell the user clearly instead of letting them retry forever.

The most important point: never break the user’s chat

Compaction is a background task, and the user came for an answer. If anything fails (summary timeout, slow database, a bug in the cut), log it and continue with the chat unchanged:

 
try:
    compact if needed
except any error:
    log it          # the chat continues uncompacted; next turn tries again
answer the user

Related habits:

  •  

    Check first, delete last.

  •  

    Put compaction behind an on and off switch to enable it gradually and disable it quickly.

  •  

    Log every attempt with its result and the size before and after. Many rejections mean weak summaries, not broken compaction.

  •  

    Keep the full original messages in your database. Compaction only changes what the model reads.

Checklist

✓Save the real token count after every turn (and ignore summary calls).
✓Compact before a turn when the last one passed 80% of the limit.
✓Cut so the recent part starts with a user message.
✓Summarise with a separate call without tools, using fixed sections and copying IDs exactly.
✓Update the previous summary instead of summarising it again.
✓Check the summary (not empty, at least half the size) before deleting.
✓Swap with RemoveMessage, and remove all old summaries.
✓Catch “too long” errors: compact hard, retry once, then explain.
✓Never let compaction fail the user’s turn.

Final thoughts

“Just summarise the old messages” sounded easy, and the summary really was the easy part. The real work was in the details: when to compact, where to cut, how to swap, and what to do when it fails.

If your users keep long conversations, I hope these notes save you some debugging time.