Agent Patterns
PATTERN 03/WORKING SET

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

INCIDENT-4538SEV-3open

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:

TSPY
src/run-agent-naive.tsCopy
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:

run_tests — no compaction

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:

TSPY
src/compact-context.tsCopy
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

userassistanttool
~0 tokens (approx.)
[user]Incident + orders.test.ts
[assistant]Hypothesis: pool too small
[tool]raw jest output — poolSize 5
[assistant]Bumps poolSize 5 → 20
[tool]raw jest output — poolSize 20
[assistant]Root cause found: release on error path
[tool]raw jest output — rerun
[tool]raw jest output — rerun again
[assistant]Final: poolSize 50 (already ruled out)

Curated

userassistanttool
~0 tokens (approx.)
[user]Incident + orders.test.ts
[assistant]Hypothesis: pool too small
[tool]compacted — poolSize 5 → timeout
[assistant]Bumps poolSize 5 → 20
[tool]compacted — poolSize 20 → too many connections
[assistant]Root cause found: release on error path
[tool]compacted — rerun confirms leak
[tool]raw jest output — rerun again (kept raw, most recent)
[assistant]Final: release in finally — verified

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:

TSPY
src/run-agent-curated.tsCopy
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.
  }
}
run_tests — compacted every pass

Same model, both final fixes it actually proposed:

✗ re-proposed a ruled-out fix
TSCopy
// db-pool.ts — proposed at pass six
const pool = createPool({ max: 50 }); // was 20 — the fix pass two already ruled out

The 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.

EVERY PASSBudgetCurationCompactionforcesdecides
Compaction

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.

hover or click a part

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.

SignalVerdict
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.

← All patterns

What's next

Verification

Unverified, the loop ends the moment the model says all tests pass. Verified, it doesn’t stop until they actually do.

→ Explore Verification