
ENGINEERING
A practical engineering guide for preventing hallucinations, contradiction, and self-reinforcing errors in agent memory systems.
TL;DR
Most agents need a lot less memory machinery than the standard toolkit implies. A short list of validated facts and a scratchpad that resets every run covers most agents built for one workflow.
The reason to be careful is what happens when you skip validation, not what happens when you skip the fancy retrieval. A hallucination written straight into durable storage doesn't get it wrong once; it gets it wrong every time that memory is read back, and the error compounds because the model conditions on its own bad write.
So the standard advice, separate episodic and semantic stores, add embeddings, add decay policies, targets a scale of problem most teams don't have yet.
None of this argues against the machinery existing. Embedding dedupe, NLI checks, and pruning are real tools for a real later problem: a store grown large enough that retrieval quality is degrading. The argument is about order.
Build the write gate first: a validator that rejects anything contradicting what's already stored, or that the model isn't confident about. Add the rest once the rejection log says the gate alone isn't enough.
Agent memory sounds simple: store what matters, retrieve it later, done. A single-turn prompt can only be wrong once, though. A memory store that's wrong stays wrong, and it keeps getting handed back to the model until someone notices.
Take a scheduling agent that remembers a user's stated timezone from an earlier session. Say it mishears "I'm usually free after 5, my time" as after 5pm Eastern, when the user is actually on the West Coast, and writes that into durable memory. Every meeting it books after that inherits the same three-hour error, and the agent has no reason to doubt itself: the memory it reads back agrees with what it just did.
That's memory drift, and it's the failure this post exists to prevent. The cause is almost always architectural rather than a model problem: a store the agent can write to freely, with nothing standing between a candidate memory and the record except the model's own confidence in itself.
The asymmetry that matters: reads can be cheap and frequent, writes have to be deliberate. Everything below is one gate or another standing between what the agent believes and what gets treated as durably true.
Most of what gets written about agent memory assumes a scale of problem most teams don't have. Episodic versus semantic versus procedural stores, tiered retrieval, a vector database with its own uptime to manage, all of it is infrastructure for an agent juggling many users, many sessions, and a durable store large enough that retrieval precision is a real concern.
If your agent runs one workflow and its memory need amounts to "what did this user tell me a few minutes ago, plus a handful of durable preferences," we'd build exactly two things: a transient scratchpad discarded every run, and a short, append-only list of validated facts. Build the gate that decides what earns a place on that list before building anything else on this page.
The signal to add more isn't the calendar. It's evidence: once the durable list is big enough that retrieval starts missing things, or you've actually caught two entries disagreeing, add embeddings and pruning. Not before. A dedupe system built against a list of forty facts is solving a problem you don't have yet, and it's one more thing that can itself go wrong.
Most agent failures start when everything, scratch reasoning, half-finished tool output, and verified user facts, lives in a single blob called memory. The agent can't tell a guess from a fact when it reads back, and neither can your retrieval layer.
Split the store into two lifecycles instead. Transient memory is short-term, task-scoped, and discarded when the task ends; it's allowed to be messy, since nothing reads it after the run. Durable memory is long-term and only updated after validation; every entry is something you're willing to let the agent believe weeks from now.
class Memory:
def __init__(self):
self.transient = {} # reset every run
self.durable = [] # validated, persistent
def reset_transient(memory):
memory.transient = {}
def add_durable_memory(memory, item):
memory.durable.append(item)There's no path for the agent loop to write straight into durable — the only way in runs through the validator built next. Keeping add_durable_memory out of the model's reach is the cheapest guardrail in this entire system.
A candidate memory earns its place by passing three checks, cheapest first: what kind of statement is it, does it contradict what's already stored, and is the model confident enough to trust. We'd order it exactly that way, not because the order changes the outcome, but because it's the order that wastes the fewest tokens on a candidate that was always going to get rejected.
Type first. A lexical filter catches the obvious anthropomorphic case for nothing: block "I feel," "I want," "I am becoming," and similar before any model runs. Those are the seeds of an agent reading its own invented internal state back into context.
BLOCKLIST = ["i feel", "i want", "i am becoming", "as an ai"]
def is_self_referential(text):
return any(p in text.lower() for p in BLOCKLIST)What survives that goes to a cheap classifier. Reject speculation, opinion, and chain-of-thought before spending anything on the more expensive checks:
CLASSIFIER_PROMPT = """
Classify the memory candidate as one of:
A) factual B) hypothetical/speculative C) opinion/emotion D) internal reasoning
Candidate: "{candidate}"
Respond with a single letter.
"""
def classify_memory(candidate, model_call):
return model_call(CLASSIFIER_PROMPT.format(candidate=candidate)).strip().upper()Contradiction second. Ask the model directly whether the candidate conflicts with what's already stored, and make it commit to a confidence:
VALIDATION_PROMPT = """
Does "{candidate}" contradict any of these known facts?
{facts}
Respond as JSON: {{"contradiction": true|false, "confidence": 0-1}}
"""
def validate_memory(candidate, durable_facts, model_call):
try:
return json.loads(model_call(VALIDATION_PROMPT.format(
candidate=candidate, facts="\n".join(durable_facts))))
except (json.JSONDecodeError, TypeError):
return {"contradiction": True, "confidence": 0.0} # fail closedA malformed response fails closed rather than crashing the run. An unparseable verdict is treated as a contradiction, never as permission to write.
def safe_write(memory, candidate, model_call):
verdict = validate_memory(candidate, [m.text for m in memory.durable], model_call)
if verdict["contradiction"] or verdict["confidence"] < 0.7:
return False
add_durable_memory(memory, candidate)
return TrueAn agent must never write directly to durable memory. Only the validator can, and the validator's default answer is no.
The 0.7 threshold isn't a law: raise it where a wrong durable memory is expensive, lower it where a missing one hurts more. A stricter version also requires the candidate to cite the exact tool output or document it came from, and rejects anything that can't. Add that once ungrounded facts start showing up in the rejection log, same as everything else here. This gate alone catches the largest failure, a hallucination stored as fact, but it has a blind spot: a confident model can still contradict a memory that wasn't in the facts list it was shown. That's what the next section solves.
This is the second tier from the position above: don't build it until the validator's rejection log shows duplicates or lookalike contradictions actually slipping through.
An LLM validator is good at meaning but too slow and expensive to run on every pair of memories. Embeddings give you a cheap first pass for the obvious case:
from sentence_transformers import SentenceTransformer, util
model = SentenceTransformer("all-MiniLM-L6-v2")
def is_duplicate(candidate, memory, threshold=0.85):
c = model.encode(candidate, convert_to_tensor=True)
return any(util.cos_sim(c, model.encode(m.text, convert_to_tensor=True)) > threshold
for m in memory.durable)High similarity isn't agreement, though. "Alice lives in NY" and "Alice lives in SF" score nearly identical to a similarity model and directly contradict each other. Catching that needs a natural-language-inference model, scoring a pair across contradiction, entailment, and neutral, run only on the handful of candidates the embedding pass flags as plausibly about the same thing:
from sentence_transformers import CrossEncoder
nli = CrossEncoder("cross-encoder/nli-deberta-v3-base")
LABELS = ["contradiction", "entailment", "neutral"]
def nli_contradicts(candidate, stored, threshold=0.5):
scores = nli.predict([(stored, candidate)])[0]
return LABELS[scores.argmax()] == "contradiction" and scores.max() > thresholdUse embeddings to narrow the field and NLI to adjudicate it. Running NLI on every stored pair is exactly what makes this tier expensive enough to defer in the first place.
A store that only grows eventually drowns its own signal: retrieval precision falls as size climbs, the agent conditions on weaker context, and drift accelerates. Three mechanisms keep it bounded, and like the section above, none is worth building until the store is actually large enough to need it.
Decay removes stale entries on a schedule. Uniform expiry is the simplest policy and rarely the right one, a stated allergy should outlive a preferred meeting time, so lifetimes want to vary by category rather than share one constant.
Pruning keeps only what's relevant to the current task once the store exceeds a budget, scored by similarity to the live query. It's aggressive: anything off-topic gets dropped, so reserve it for stores you can rebuild, or pair it with a "pinned" flag for anything that must survive regardless of momentary relevance.
Summarization compresses what remains, with one instruction doing all the work: don't infer. Compression is exactly the moment a model is tempted to invent connective tissue that was never in the source, so the prompt has to forbid it outright, and the pass should run rarely, since compression is itself a write.
Whatever survives decay and pruning still needs to be auditable. Make durable memory append-only, log every accepted and rejected candidate with the verdict that produced it, and attach provenance, source and timestamp, to every entry. A corrupt memory then becomes one revocable event traceable to the write that caused it, instead of an overwrite of something that used to be true with no record it ever changed.
Order the gates by cost and bail early: lexical checks first, the type classifier next, embedding dedupe after that, and the LLM validator last, since it's the most expensive call in the chain.
Figure 1 — Every candidate memory passes through the same four gates, cheapest first, before it's allowed to become durably true.
def safe_memory_update(memory, candidate, model_call):
if is_self_referential(candidate):
return False, "self_referential"
if classify_memory(candidate, model_call) != "A":
return False, "non_factual"
if is_duplicate(candidate, memory):
return False, "duplicate"
verdict = validate_memory(candidate, [m.text for m in memory.durable], model_call)
if verdict["contradiction"] or verdict["confidence"] < 0.7:
return False, "failed_validation"
item = MemoryItem(candidate, source="agent", created_at=time.time())
add_durable_memory(memory, item)
return True, "accepted"This is deliberately conservative: when in doubt, it rejects. For most agents that bias is correct. A missed memory costs one re-derivation. A corrupt one costs every future decision that touches it.
We think memory is the easiest way for an agent to become a system nobody trusts, not because remembering things is hard, but because writing them down without a gate turns the agent into something that edits its own beliefs with no one watching.
Most of what's above is optional. The gate in front of durable writes is not: classify the candidate, check it against what's already stored, and reject anything the model isn't confident about. Add embeddings, decay, and pruning once the rejection log says the gate alone isn't enough. Not before.
Most of what's written about agent memory is a scaling problem. Most agents don't have a scaling problem yet — they have a validation problem. Build the gate before the retrieval layer, and add the rest only once the rejection log says the gate isn't enough.