Agent Patterns
PATTERN 05/PERSISTENT STATE

Memory

Also known as: persistent memory, long-term memory, agent notes, memory store, learned preferences.

A loop remembers everything inside one session and nothing between two. Close the transcript and the agent is exactly as new as it was the first time — including about the thing it was corrected on fourteen hours ago, by the same person, in the same repository.

Problem

INCIDENT-4602SEV-3open

Agent reintroduces the mocked database it was corrected on yesterday

An agent was asked to write tests/refunds.integration.test.ts. It opened with jest.mock('../src/db') — the exact pattern the same engineer had rejected the previous evening, for the exact reason a mocked checkout test had stayed green while migration 0042_refund_reason.sql failed on staging. The correction had lived only in a transcript that no longer exists.

Nothing was thrown away by mistake. The session simply ends, and everything it learned ends with it — a fresh run rebuilds the message list from the system prompt and the task, every time:

TSPY
src/run-session-stateless.tsCopy
const runSession = async (client: Anthropic, task: string) => {
  // Every session starts from exactly these two messages. Nothing carries over.
  const messages: Message[] = [
    { role: 'system', content: SYSTEM_PROMPT },
    { role: 'user', content: task },
  ];

  let response: Response | null = null;

  let steps = 0;
  const MAX_STEPS = 12;
  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('write_file:')) {
      const path = response.content.replace('write_file:', '');
      messages.push({ role: 'tool', content: writeFile(path) });
    }
  }

  // The session returns and `messages` goes out of scope. The correction the
  // engineer typed at step seven goes with it — tomorrow starts from those
  // same two messages and nothing else.
};

The next morning, the same model gets a task from the same repository and starts from nothing it has ever been told:

write_test — Wednesday, fresh context

The correction was specific, correct, and acted on the first time it was given. It just lived in a transcript that stopped existing when the session did, so the second session had no way to be wrong about it — it had never heard it. A bigger context window changes nothing here; the window was never the thing that was lost.

Solution

Same model, same empty context on Wednesday morning. What changes is one write on Tuesday evening and one read before Wednesday's first call — against a store that outlives both sessions:

TSPY
src/memory-store.tsCopy
export type MemoryRecord = {
  id: string;
  scope: string[]; // what it applies to — matched against the task before recall
  rule: string; // what to do differently
  because: string; // why — the part that still holds on a case the rule never named
  evidence: string; // where it came from, so a later session can re-check it
  createdAt: string;
  lastConfirmedAt: string;
  supersededBy?: string;
};

const STALE_AFTER_DAYS = 90;
const store = openStore('.agent/memory.jsonl');

export function remember(draft: Pick<MemoryRecord, 'scope' | 'rule' | 'because' | 'evidence'>): MemoryRecord {
  const now = new Date().toISOString();
  const saved = { ...draft, id: newId(8), createdAt: now, lastConfirmedAt: now };
  store.append(saved);
  return saved;
}

export function recall(scope: string[], now = new Date()): MemoryRecord[] {
  // Matched against what this session is about to touch — not dumped wholesale.
  return store
    .all()
    .filter((r) => r.supersededBy === undefined)
    .filter((r) => r.scope.some((s) => scope.includes(s)))
    .filter((r) => daysSince(r.lastConfirmedAt, now) < STALE_AFTER_DAYS);
}

export function supersede(oldId: string, replacement: MemoryRecord): void {
  // A rule that stopped being true isn't deleted — it's pointed at the one
  // that replaced it, so a later session can see what changed and when.
  store.patch(oldId, { supersededBy: replacement.id });
}

rule alone would have been enough to avoid this exact line and nothing else. because is what makes the record transfer — it is the difference between an agent that never writes jest.mock in that one filename pattern and an agent that knows what an integration test is for. Here is what Tuesday evening actually wrote:

JSONCopy
{
  "id": "MEM-7f3a",
  "scope": ["repo:payments", "tests:integration"],
  "rule": "Never mock the db module in tests/*.integration.test.ts — use withTestDatabase().",
  "because": "A mocked checkout test stayed green while migration 0042_refund_reason.sql failed on staging. Mocking the db is precisely what makes an integration test unable to catch a migration.",
  "evidence": "session 2026-08-11, tests/checkout.integration.test.ts, staging deploy 0042",
  "createdAt": "2026-08-11T18:41:07Z",
  "lastConfirmedAt": "2026-08-11T18:41:07Z"
}

Wiring it in is two edits to the loop: recall before the first call, and a write path the model can reach for the moment it gets corrected —

TSPY
src/run-session-remembering.tsCopy
const runSession = async (client: Anthropic, task: string, scope: string[]) => {
  // Read before doing anything, not after the mistake.
  const memories = recall(scope); // scope: ['repo:payments', 'tests:integration']

  const messages: Message[] = [
    { role: 'system', content: SYSTEM_PROMPT },
    { role: 'user', content: renderMemories(memories) },
    { role: 'user', content: task },
  ];

  let response: Response | null = null;

  let steps = 0;
  const MAX_STEPS = 12;
  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;
    }

    // Written the moment a correction lands, while the reasoning behind it is
    // still in the window — a rule stored without its reason can't be applied
    // to the next case, only pattern-matched against the last one.
    if (response?.finalAnswer === false && response.content.startsWith('remember:')) {
      const draft = JSON.parse(response.content.replace('remember:', ''));
      const saved = remember({ scope, ...draft });
      messages.push({ role: 'tool', content: `remembered ${saved.id}` });
    }

    if (response?.finalAnswer === false && response.content.startsWith('write_file:')) {
      const path = response.content.replace('write_file:', '');
      messages.push({ role: 'tool', content: writeFile(path) });
    }
  }
};
write_test — Wednesday, memory recalled

Same model, same task, both files it actually wrote on Wednesday:

✗ corrected a second time
TSCopy
// tests/refunds.integration.test.ts — Wednesday, second session
jest.mock('../src/db'); // the exact line rejected fourteen hours earlier

it('refunds a captured payment', async () => {
  (db.query as jest.Mock).mockResolvedValue([{ id: 'pay_88', status: 'captured' }]);

  const result = await refund('pay_88');
  expect(result.status).toBe('refunded'); // green against a database that isn't there
});

Memory is the decision to write down what one session paid to learn, in a form a later session can find — a rule with its reason attached, scoped to the work it applies to. Nothing about the model changed between Tuesday and Wednesday. What changed is that Wednesday started with something Tuesday had written down.

Structure

Three parts, one store. Hover or tap one.

ACROSS SESSIONSCaptureRecallDecaywritesages
Decay

What keeps the store honest. Every record carries when it was last confirmed, and gets re-checked, superseded, or dropped when the thing it describes changes. Without it, yesterday’s correct rule becomes tomorrow’s confidently wrong one, and nothing in the loop notices.

hover or click a part

Applicability

Write a memory whenever a session produces a fact the next session would otherwise have to be told again.

The test is not whether a fact is interesting — it is whether re-deriving it costs more than storing it did. A correction, a stated preference, a root cause that took six passes to find: each of those was paid for once, and a store is the only thing standing between paying once and paying every morning.

SignalVerdict
The same person corrects the same mistake more than once✓ use it
Work recurs against the same codebase, project, or person across many sessions✓ use it
The fact was expensive to establish — a debugged root cause, a stated convention✓ use it
The task is one-shot and nothing about it recurs− skip it
The facts go stale faster than sessions can confirm they still hold− skip it

Memory is what survives a session; context is what fits inside one — a record does nothing until recall puts it back in the window. And a record is a claim like any other, which is why verification applies to it too. Magent keeps its memories in a plain file the human can read, edit, and delete — github.com/palamim/magent.

← All patterns

What's next

Planning

Improvised step by step, the agent calls a ten-file refactor done after step three. Planned up front, every step stays accountable to the goal.

→ Explore Planning