maybeCompact function. While the history is under budget, it returns it unchanged. Once it’s over, it returns the system prompt, one summary message, and the most recent turns.
Outcome: The history stays under budget however many times it’s compacted. Facts from the summarized turns can still be recalled afterwards. The kept turns never start with an orphaned tool result, so the next model call isn’t rejected, and a failed summarize call leaves the history as it was.
1. Call Summarize
summarize sends your instructions as the system message and the text as the user message, and returns the plain-text summary with its usage. The instructions tell the model what to keep: the goal, the decisions made and why, the files and commands the agent touched, the approaches that failed, and the facts it confirmed. They also tell it to copy names exactly, such as file paths, table names, and ticket IDs, because our summaries lost many of them otherwise. Longer histories get split into chunks before they reach summarize, so each summary stays short and focused.
2. Turn old messages into text
Summarize reads one block of text and rejectstool messages with a 400, so turn the old messages into text first. Keep the tool calls and their results, since that’s where most of what the agent learned lives. The message type below is the OpenAI chat format; swap in your own.
3. Summarize in chunks
One call can’t hold everything in a long history, so it drops facts. Split the old turns into chunks of about 16k characters (4k to 5k tokens), summarize each chunk, and join the results. Each chunk gets its own summary, and the calls run in parallel.4. Compact when the history gets big
maybeCompact keeps the system prompt and the last KEEP_RECENT turns, and replaces everything in between with one summary message. Three details keep it safe over a long run:
- Cut between whole turns. If the kept turns started with a tool result whose tool call had been summarized away, the next model call would be rejected. So
turnBoundarymoves the cut back until the kept turns start with a user or assistant message. If that reaches the first turn, which happens when one assistant turn made more thanKEEP_RECENTtool calls, it moves forward instead, so that whole batch gets summarized. - Carry the summary forward. On the next compaction, the earlier summary is kept and the new one is added after it. Once the combined summary passes
SUMMARY_MAX_CHARS, it’s summarized again inFOLD_WINDOW_CHARSchunks. One call over the whole summary would drop facts the same way one call over the whole history does. - Keep the full history if a call fails. On any error,
maybeCompactlogs it and returns the history unchanged. The run carries on with a bigger context instead of a gap in its memory.
agentStep stands for your own loop body:
Worked example
The session below is made up, but it’s the one the captured output came from, so you can run it yourself.syntheticSession builds a 100-step coding session (202 messages, 86k characters) in which the agent reads one file per step, with ten facts planted along the way to check what survives: a config path, a failed approach, a rate limit, a table name, a ticket ID, a branch name, a test command, the root cause, a reviewer, and an environment variable. Save steps 1 to 4, without the loop sketch, and these two blocks as one file and run it with bun run compact.ts.
maybeCompact, and the first two of the six parts of its summary:
CHUNK_CHARS.
We then appended syntheticSession(80, 200).slice(2), 80 more steps, and compacted again, then once more after another 80 (syntheticSession(80, 400).slice(2)). The second compaction carried the first summary forward and grew it to 11.8k characters, just under SUMMARY_MAX_CHARS. The third crossed the limit, so the carried summary was folded as well: 11 calls instead of 5, and the summary came back at 10.5k to 10.8k characters, since each 4k window returned about 1.7k. Facts after the third compaction: 8 of 10 in one run, 10 of 10 in the other. Folding re-summarizes a summary, which is where facts go missing, so keep the limit as high as your context allows. With SUMMARY_MAX_CHARS lowered to 6_000, the fold ran on both the second and third compaction (8 and 9 calls), the summary stayed between 2.8k and 4.8k characters, and 10 of 10 facts survived in that run, so a fold is a risk, not a certain loss.
Check your work
- Shape. After compaction, the history is your system prompt, then a
usermessage starting withSUMMARY_PREFIX, then auserorassistantmessage, never atoolresult. - Budget.
historySizeafter compaction is well underCOMPACT_AT_CHARS: 7k against 50k in our run. - Memory. Ask the agent about something that only appears in the summarized turns, like a file it read or an approach that failed. It should answer correctly. Our summaries kept 9 or 10 of the 10 hidden facts, so expect to lose one now and then. Keep anything that must never be lost in your own state, not in the summary.
- Repeat. Compact twice more and check which early facts survive. Ours kept 8 to 10 of 10 after the third compaction, the one that folds the summary.
- Fail safe. Break the API key.
maybeCompactlogs the error and returns the history unchanged.
Variations
With different instructions and no chunking, the samesummarize call can turn a meeting transcript into decisions and open questions, condense a support ticket’s history before an agent picks it up, or boil a long email thread down to one paragraph. Once the input is longer than a few thousand tokens, chunk it as in step 3.
Next steps
- Detect Stuck and Looping Agents with ScaleDown Classify to check the same transcript for loops every few steps
- Build a Long-Horizon Agent for the cost limits, saved state, and streaming around this compaction
- Extract Invoice Fields with ScaleDown Extract to pull named fields out of documents