Context
Also known as: context management, context engineering, working memory, prompt curation.
The loop remembers by replaying the transcript. Nothing about that transcript shrinks on its own — every pass appends, and unless something decides otherwise, everything appended stays, in order, forever taking up room the next call has to read through to find what still matters.
Problem
Flaky-test agent re-proposes the fix it already ruled out
An agent was asked to fix tests/orders.test.ts, timing out under load in CI. Six passes in, its final answer was the exact fix it had already tried, watched fail, and correctly diagnosed as the wrong layer — three tool calls earlier.
Every pass, the loop appended the tool's entire raw output — the full jest run, stack trace and console spam included — to the conversation, and called again:
const runAgent = async (client: Anthropic) => {
let response: Response | null = null;
let steps = 0;
const MAX_STEPS = 6;
while (steps < MAX_STEPS) {
steps++;
const message = await client.messages.create({
max_tokens: 4096,
model: 'claude-haiku-4-5',
messages,
});
messages.push({
role: 'assistant',
content: message.content,
});
response = extractResponse(message);
if (response?.finalAnswer === true) {
console.log(response.content);
break;
}
if (response?.finalAnswer === false && response.content.startsWith('run_tests:')) {
const path = response.content.replace('run_tests:', '');
const rawOutput = runTests(path); // full jest stdout — stack traces, console spam, all of it
messages.push({
role: 'tool',
content: rawOutput,
});
// nothing here ever shrinks. Five reruns in, the transcript is five raw
// test dumps deep and the one line that mattered is buried in run #2.
}
}
if (!response) {
console.error('Ended loop without a response (hit MAX_STEPS).');
process.exit(1);
}
};Watch the fact that actually mattered get said once, correctly, and then lose to everything appended on top of it:
Nothing here is a worse model or a shorter memory. The root-cause note is sitting right there in the transcript — it's just three raw jest dumps back by the time pass six needs it, underneath more lines of stack trace than the note itself has words. A wider window doesn't fix this; it just moves the point where the same thing happens again.
Solution
Same model, same tool, same bug. What changes is a pass over the transcript before every call, deciding what still earns a raw slot and what gets folded down to the one line it actually proved:
const KEEP_RAW = 1; // only the most recent tool result stays uncompacted
export function compactContext(messages: Message[]): Message[] {
const toolIndexes = messages.map((m, i) => (m.role === 'tool' ? i : -1)).filter((i) => i !== -1);
const stale = new Set(toolIndexes.slice(0, -KEEP_RAW));
return messages.map((m, i) => (stale.has(i) ? { ...m, content: summarize(m.content) } : m));
}
function summarize(raw: string): string {
// A raw jest dump buries its one useful line under a stack trace and
// console spam. Keep the line that actually changed, drop the rest.
const signal = raw.split('\n').find((line) => /error|fail/i.test(line)) ?? raw.slice(0, 80);
return `[compacted — ${raw.split('\n').length} lines dropped] ${signal.trim()}`;
}It isn't guessing what matters — it's only aggressive about what obviously doesn't: a jest run the model has already read and already responded to. Same six passes, before and after:
Uncompacted
Curated
The root-cause note didn't move. What changed is how much sits on top of it by the time the model reads back through — three sentences instead of three test dumps. The loop wiring is one extra line:
while (steps < MAX_STEPS) {
steps++;
const message = await client.messages.create({
max_tokens: 4096,
model: 'claude-haiku-4-5',
messages,
});
messages.push({ role: 'assistant', content: message.content });
response = extractResponse(message);
if (response?.finalAnswer === true) {
console.log(response.content);
break;
}
if (response?.finalAnswer === false && response.content.startsWith('run_tests:')) {
const path = response.content.replace('run_tests:', '');
const rawOutput = runTests(path);
messages.push({ role: 'tool', content: rawOutput });
messages = compactContext(messages);
// every pass, not just near a hard ceiling — the moment a tool result
// has been read by the model that requested it, it's already stale.
}
}Same model, both final fixes it actually proposed:
// db-pool.ts — proposed at pass six
const pool = createPool({ max: 50 }); // was 20 — the fix pass two already ruled outThe transcript is what a loop can't help but keep growing — curation is the decision, made every pass, about which of the last few raw results still earn their place. Nothing about the model changed between the two runs above. What changed is how much noise it had to read through to find the sentence it wrote itself.
Structure
Three parts, one running every pass. Hover or tap one.
The mechanism that actually rewrites the stale entries in place, so the next call sees a shorter transcript that still holds what mattered — the curation policy’s verdict, applied.
Applicability
Curate context whenever a tool can return more than the fact the model actually needed from it.
A transcript that never shrinks is the simplest thing to build and it works right up until it doesn't — every raw log stays exactly as loud on pass twenty as it was on pass one, and the fact that mattered gets quieter by comparison every time something louder gets appended after it.
| Signal | Verdict |
|---|---|
| A tool can return large, low-signal output (logs, file dumps, search hits) | ✓ use it |
| The task runs more passes than comfortably fit in one window | ✓ use it |
| An early finding still has to hold at pass twenty | ✓ use it |
| Every tool call already returns something small and final | − skip it |
| The task is a handful of turns, well inside the window | − skip it |
Curation only helps if the summary a compaction step writes is one a later pass can still act on — the loop is what decides when to call again, tools are what it calls. Magent compacts before every call, not just near a hard ceiling — same discipline, running continuously — github.com/palamim/magent.
Verification
Unverified, the loop ends the moment the model says all tests pass. Verified, it doesn’t stop until they actually do.
→ Explore Verification