Skip to content
← All guides

Why your agent forgets everything, and what to do about it

6 min read

Three tiers of memory for coding agents, and why a lint rule enforces your standards better than any paragraph of markdown ever will.

You have told it three times not to use any. It agreed three times, apologised twice, and the fourth file it wrote has any on line nine. You are now the compliance department for a machine that keeps promising to do better.

This is not the agent being lazy or ignoring you. It is a predictable consequence of how the session works, and once you see the mechanics, the fix stops being "remind it harder" and becomes something structural.

What is actually happening

Three things, none of them mysterious.

Agents do not remember previous sessions by default. Yesterday's conversation, where you explained the whole billing model, is gone. Every session starts from the project files and whatever you say in it.

Within a session, the history is finite. As it fills, tools summarise or compact what came earlier to make room. Summaries keep conclusions and lose detail, and instructions are detail. "Never use any" is one short line in a conversation that now contains four file reads, two test runs, and a stack trace.

And even before any summarising, an instruction from fifty turns back is competing for attention with everything since. Recency wins more often than you would like. This is worth understanding properly, and Context windows explained for people who just want to ship goes through the mechanics and the warning signs.

So: repetition in chat is the weakest possible place to put a rule. Here is where to put it instead.

Tier one: the project instruction file

Every major tool reads a file at session start. Claude Code reads CLAUDE.md, Gemini CLI reads GEMINI.md, GitHub Copilot reads .github/copilot-instructions.md, Cursor uses rules files, and AGENTS.md is picked up by several tools. Whichever you use, the same discipline applies.

What belongs in it is narrow and boring:

  • The package manager, exactly. pnpm, not "npm or yarn, whichever".
  • The build, test, lint and typecheck commands, verbatim.
  • How to run a single test file, which is the command an agent needs most and guesses wrong most.
  • A short directory map, five to ten lines.
  • Five to ten non-obvious rules that cannot be inferred by reading code.
## Commands
- Install: `pnpm install --frozen-lockfile`
- One test file: `pnpm vitest run src/lib/money.test.ts`
- Lint: `pnpm eslint . --max-warnings=0`
- Typecheck: `pnpm tsc --noEmit`

## Layout
- `src/routes` — HTTP handlers, thin, no business logic
- `src/services` — business logic, the only layer that touches the DB
- `src/lib/money.ts` — all currency maths

## Rules
- Money is integer pence everywhere. Never floats, never in API payloads.
- New tables need a migration in `db/migrations` and a rollback.
- Refunds always go through `services/refund.ts`, never direct to Stripe.

What does not belong: architecture essays, a rationale for your service boundaries, a history of the codebase. Nor anything derivable from the code itself, because listing every service in prose just gives you a file that is wrong within a fortnight. Nor anything that will go stale, like the name of the person who owns a module.

A stale instruction file is worse than a missing one. It gets read with full confidence at the start of every single session.

Tier two: the codebase itself

Here is the part that actually solves the any problem, and it took me too long to internalise.

Prose instructions are advisory. The toolchain is binding.

A line in markdown is a suggestion competing with everything else in the window. A lint rule is a wall. If something must never happen in your codebase, do not write a sentence about it. Make it fail.

{
  "rules": {
    "@typescript-eslint/no-explicit-any": "error",
    "no-restricted-imports": ["error", {
      "paths": [{
        "name": "node-fetch",
        "message": "Use src/lib/http.ts so retries and timeouts are applied."
      }]
    }]
  }
}

Two things happen with that config. The agent cannot ship any past a lint gate it has been told to run. And the message field is read back to it verbatim when the rule trips, which means your convention arrives at the exact moment it is being violated, which is the only moment it is genuinely useful.

The type checker does the same job at a different layer:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

noUncheckedIndexedAccess alone eliminates a whole family of confidently-wrong array access that no amount of "please be careful with indexes" will prevent. A failing typecheck is unambiguous feedback the agent can act on without you in the loop.

The same logic extends to tests. A test is an executable statement of intent that survives every session boundary and every summarisation, which is a large part of why Tests make you faster with an agent even when you are moving quickly.

Rule of thumb: if you have repeated an instruction to your agent three times, that is not a prompting problem. That is a missing lint rule, a missing type, or a missing test.

Tier three: the session itself

Some things genuinely cannot be encoded. "We are migrating away from the legacy queue, so do not add new consumers to it" is temporary and situational.

For these, restate the constraint at the point of use rather than at the start. A rule stated once at the top of a two-hour session is a rule stated fifty turns ago. The same rule stated in the message where it matters is right in front of the agent.

Reminder before this change: no new consumers on the legacy queue.
Add the handler to src/queue/v2/handlers.ts and register it in
src/queue/v2/index.ts only.

Cheap to type, and dramatically more reliable than trusting a header from earlier.

Handover notes between sessions

The last piece is the gap between sessions. Keep a scratch file, notes/session.md or similar, updated as you go. Three things belong in it: what you are working on right now, decisions already made, and dead ends already tried.

# Current: cursor pagination on /users (session 3)

Decided: cursor is base64 of `createdAt|id`, not an offset.
Done: src/routes/users.ts, src/lib/cursor.ts, tests green.
Next: /invoices still uses offset, migrate it the same way.

Dead ends — do not retry:
- Keyset on `createdAt` alone: ties from bulk imports broke ordering.
- Prisma's `cursor` option: needs an id we do not have on page one.

That dead-ends section is the highest-value part of the file, because failed attempts are the first thing lost when a conversation is summarised. A summary preserves "we implemented cursor pagination". It does not preserve "we tried keyset ordering on a single column and it broke on ties". So the next session cheerfully proposes keyset ordering on a single column, and you burn twenty minutes rediscovering the same wall.

Write the wall down. It is the only note the agent cannot reconstruct from your code, and it is the one that saves the most time.

contextclaude-mdagentslintingworkflow