Agentic AI · Agent Memory

Long-Term Memory

JrCodex9 min readChapter 11 of 36

Jr Codex Agentic AI Notes

Level: Intermediate–Advanced Prerequisites: Chapter 2: Short-Term Memory; NLP Notes, Module 8, Chapter 2 Time to complete: ~25 minutes


Table of Contents

  1. Memory Is Not Quite RAG
  2. Semantic Memory — Facts
  3. Episodic Memory — Events
  4. Procedural Memory — Lessons
  5. Deciding What to Write
  6. Reading at the Right Moment
  7. Forgetting
  8. Summary & Next Steps

1. Memory Is Not Quite RAG

Long-term memory uses the retrieval machinery from NLP Module 8 — embeddings, a vector store, similarity search. The differences are what make it a distinct problem.

RAG vs Agent Memory
─────────────────────────────────────────
                  RAG              MEMORY
  ─────────────────────────────────────────
  Corpus          fixed, curated   grown by the
                  documents        agent itself

  Writes          a separate       DURING operation,
                  ingest pipeline  by the agent

  Contradiction   rare — docs      COMMON — facts
                  are consistent   change over time

  Deletion        re-index         required, per item

  Trust           source of truth  the agent's own
                                   possibly-wrong
                                   observations
─────────────────────────────────────────
The Consequence
─────────────────────────────────────────
  RAG retrieves from a corpus someone curated.
  Memory retrieves from a corpus the AGENT wrote —
  including its mistakes.

  So memory needs two things RAG does not: an update
  path for facts that change, and provenance so a
  bad memory can be traced and removed.
─────────────────────────────────────────

2. Semantic Memory — Facts

Facts are keyed and overwritten. There is one current answer to "what is the user's timezone."

class SemanticMemory:
    """Keyed facts. A new value for a key REPLACES the old one."""
 
    def __init__(self, store): self.store = store
 
    def set(self, scope: str, key: str, value: str, source: str):
        self.store.upsert(
            id=f"{scope}:{key}",                    # DETERMINISTIC id = automatic overwrite
            content=f"{key}: {value}",
            metadata={"scope": scope, "key": key, "value": value,
                      "source": source, "updated_at": time.time()},
        )
 
    def get(self, scope: str, key: str):
        return self.store.get(f"{scope}:{key}")
 
    def search(self, scope: str, query: str, k=5):
        return self.store.query(query, k=k, where={"scope": scope})
The Scope Key Is Load-Bearing
─────────────────────────────────────────
  scope = "user:8241", "org:acme", "global"

  Without it, one user's preference leaks into
  another user's context. This is both a quality bug
  and a PRIVACY incident, and it is the most common
  serious defect in agent memory implementations.

  Scope every read AND every write. Never rely on
  similarity alone to keep tenants apart.
─────────────────────────────────────────
# Facts change. That is normal, and the store must handle it.
mem.set("user:8241", "timezone", "IST", source="user_stated:2026-08-14")
mem.set("user:8241", "timezone", "GMT", source="user_stated:2026-09-02")
# get() now returns GMT. The old value is gone — which is CORRECT for a fact.

3. Episodic Memory — Events

Events are appended, never overwritten. History does not get rewritten.

class EpisodicMemory:
    """What happened, when. Append-only, retrieved by similarity plus recency."""
 
    def add(self, scope: str, summary: str, outcome: str, task: str):
        self.store.add(
            content=summary,
            metadata={"scope": scope, "task": task, "outcome": outcome,
                      "at": time.time()},
        )
 
    def recall(self, scope: str, task: str, k=3, half_life_days=30):
        hits = self.store.query(task, k=k * 4, where={"scope": scope})
        now = time.time()
        for h in hits:                                   # blend similarity with RECENCY
            age_days = (now - h.metadata["at"]) / 86_400
            h.rank = h.similarity * (0.5 ** (age_days / half_life_days))
        return sorted(hits, key=lambda h: -h.rank)[:k]
Why Recency Weighting
─────────────────────────────────────────
  A highly similar episode from eight months ago is
  usually less useful than a slightly less similar
  one from last week — systems change, and old
  episodes describe a world that no longer exists.

  Exponential decay with a half-life is the standard
  approach. Tune the half-life to how fast YOUR
  domain changes, not to a default.
─────────────────────────────────────────

4. Procedural Memory — Lessons

The highest-value memory type, and the one most teams skip: generalised lessons about how to do the work.

LESSON_PROMPT = """Review this completed agent run and extract any GENERALISABLE lesson.
 
A lesson must be:
  - reusable on a DIFFERENT task, not specific to this one
  - actionable — it changes what an agent would DO
  - grounded in what actually happened in this run
 
Return an empty list if nothing generalises. Most runs teach nothing; that is fine.
 
RUN:
{trace}"""
 
class Lesson(BaseModel):
    trigger: str        # WHEN this applies: "exporting more than 10,000 rows"
    guidance: str       # WHAT to do: "page in 5,000-row chunks; the tool times out"
    confidence: Literal["low", "medium", "high"]
 
def extract_lessons(client, trace) -> list[Lesson]:
    return client.chat.completions.parse(
        model="gpt-4o",
        messages=[{"role": "user", "content": LESSON_PROMPT.format(trace=trace)}],
        response_format=LessonList, temperature=0,
    ).choices[0].message.parsed.lessons
The trigger / guidance Split
─────────────────────────────────────────
  Splitting the lesson in two makes retrieval work.

  You embed and search on TRIGGER ("exporting large
  result sets") — which is what the current task
  resembles.

  You inject GUIDANCE — which is what changes
  behaviour.

  A single blob of prose retrieves badly and reads
  badly. This split is why some lesson stores work
  and most do not.
─────────────────────────────────────────
Gate the Write
─────────────────────────────────────────
  Do NOT write a lesson after every run. Most runs
  teach nothing, and a store full of "the task was
  completed successfully" is worse than an empty one.

  Write when: a tool failed in an instructive way, a
  user corrected the agent, a non-obvious approach
  worked, or a limit was discovered.

  Require confidence == "high" for automatic use;
  route "low" to human review.
─────────────────────────────────────────

5. Deciding What to Write

def maybe_remember(client, run, memory):
    """Extract memories at the END of a run, not during it."""
    if run.steps < 2 or run.outcome == "trivial":
        return
 
    for fact in extract_facts(client, run):              # SEMANTIC — keyed, overwrites
        if fact.durable:                                 # skip one-off task details
            memory.semantic.set(run.scope, fact.key, fact.value,
                                source=f"run:{run.id}")
 
    memory.episodic.add(run.scope, summary=run.summary,  # EPISODIC — always append
                        outcome=run.outcome, task=run.task)
 
    if run.had_failure or run.had_correction:            # PROCEDURAL — gated
        for lesson in extract_lessons(client, run.trace):
            if lesson.confidence == "high":
                memory.procedural.add(lesson, source=f"run:{run.id}")
Write at the End, Not During
─────────────────────────────────────────
  Mid-run, the agent does not yet know whether an
  observation was correct. Writing then stores
  mistakes as facts.

  At the end, the outcome is known and the run can
  be judged as a whole.

  Exception: explicit user statements ("call me
  Sam", "always use metric") should be written
  IMMEDIATELY — they are true regardless of how the
  run turns out.
─────────────────────────────────────────

6. Reading at the Right Moment

def load_memories(memory, scope, task, budget_tokens=1_500):
    """Retrieve ONCE, at the start, into the scratchpad — not on every step."""
    facts    = memory.semantic.search(scope, task, k=5)
    episodes = memory.episodic.recall(scope, task, k=2)
    lessons  = memory.procedural.search(task, k=3)
 
    block = []
    if facts:    block.append("KNOWN:\n"   + "\n".join(f"  {f.content}" for f in facts))
    if lessons:  block.append("LESSONS:\n" + "\n".join(f"  When {l.trigger}: {l.guidance}"
                                                       for l in lessons))
    if episodes: block.append("SIMILAR PAST WORK:\n" + "\n".join(f"  {e.content}"
                                                                 for e in episodes))
    return truncate("\n\n".join(block), budget_tokens)
Retrieve Once, Not Per Step
─────────────────────────────────────────
  Per-step retrieval costs a query every step,
  produces churn as the "current task" text drifts,
  and pushes the context past its budget.

  Load once into the scratchpad (Chapter 2) and let
  it persist for the run. Re-query only if the task
  materially changes — for example after a
  re-planning event (Module 4).
─────────────────────────────────────────

7. Forgetting

Four Mechanisms
─────────────────────────────────────────
  OVERWRITE     a new value for a semantic key
                replaces the old. Automatic.

  EXPIRY        episodic entries past a TTL are
                deleted. Run as a scheduled job.

  DECAY         low-rank, never-retrieved items are
                pruned when the store grows past a
                target size.

  EXPLICIT      a user or operator deletes by scope
  DELETION      or key. Must be supported for
                privacy compliance, and must actually
                remove the vector, not just flag it.
─────────────────────────────────────────
def prune(memory, scope, max_items=1_000, ttl_days=180):
    cutoff = time.time() - ttl_days * 86_400
    removed = memory.episodic.delete_where(scope=scope, before=cutoff)
 
    items = memory.episodic.list(scope)                  # then prune by USEFULNESS
    if len(items) > max_items:
        items.sort(key=lambda i: (i.uses, i.last_used))  # least-used, least-recent first
        removed += memory.episodic.delete([i.id for i in items[:len(items) - max_items]])
    return removed
 
def forget_user(memory, user_id):
    """Right-to-erasure. Must hit EVERY store, not just the obvious one."""
    scope = f"user:{user_id}"
    return sum(store.delete_scope(scope)
               for store in (memory.semantic, memory.episodic, memory.procedural))
The Contradiction Problem
─────────────────────────────────────────
  Semantic keys handle contradictions for facts you
  thought to key. They do not handle the general
  case — two episodic memories asserting opposite
  things, or a lesson that is now wrong.

  Practical approach: attach `source` and
  `updated_at` to everything, surface BOTH when
  retrieval returns conflicting items, and let the
  model prefer the more recent while noting the
  conflict.

  Do not silently pick one. A conflict the agent
  reports is recoverable; a conflict it resolves
  invisibly is not.
─────────────────────────────────────────

8. Summary & Next Steps

Key Takeaways

  • Agent memory uses RAG machinery but differs in kind: the agent writes its own corpus, facts contradict over time, and deletion is a requirement rather than a re-index.
  • Facts are keyed and overwritten, events are appended with recency-weighted recall, lessons are split into trigger and guidance so retrieval matches on the situation and injects the behaviour.
  • Scope every read and write — unscoped similarity search across tenants is a privacy incident, not just a quality bug.
  • Write memories at the end of a run when the outcome is known, except explicit user statements; retrieve once into the scratchpad rather than every step.

Concept Check

  1. Name two requirements agent memory has that a standard RAG pipeline does not, and explain why each arises.
  2. Why is a lesson split into trigger and guidance rather than stored as one paragraph?
  3. Retrieval returns two memories asserting different values for the same fact. Why is silently preferring the newer one the wrong behaviour?

Module 3 Complete — What's Next

Your agent can now remember within a run and across runs. Module 4 addresses the other half of Module 2's weakness list: what happens when a task is too long or too complex to attack one step at a time.

Next Module

→ Module 4: Planning & Self-Correction


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to Agentic AI Index