Mem0 markets its add() call as "just talk to it, it figures out memory." Mostly true — under the hood, every add() runs an LLM-driven pipeline that extracts facts from the input, searches for semantically similar existing memories, and issues one of four operations per fact: ADD, UPDATE, DELETE, or NONE. That's a real feature, not a demo simplification, and it's genuinely useful: you don't have to hand-write the merge logic every memory-backed app eventually needs.
The problem is that this conflict resolver runs on text similarity, not on your application's notion of scope. It cannot tell the difference between "the user changed their mind" and "the user has two preferences that both hold, just in different contexts." When it gets that distinction wrong, it doesn't warn you — it quietly issues a DELETE, and the fact is gone from every future search() call. I hit this running a long-lived agent that accumulates behavioral memory over weeks, and it took a missing fact silently reappearing as wrong behavior — not an exception — for me to notice.
This article shows the failure mode with working code, then a scoped-key pattern plus an audit wrapper that stops it from costing you memories you still need.
Reproducing the failure
Say your agent supports a user across two very different working contexts:
from mem0 import Memory
m = Memory()
user_id = "alice"
m.add(
[{"role": "user", "content": "For design reviews, I prefer async written feedback over live calls."}],
user_id=user_id,
)
m.add(
[{"role": "user", "content": "For incident calls, I prefer synchronous voice over Slack threads."}],
user_id=user_id,
)
Read those two sentences and the scoping is obvious to a human: design reviews get async, incidents get sync. But Mem0's fact extractor often collapses each input down to something closer to "user prefers async communication" and "user prefers synchronous communication" before it ever reaches the conflict-resolution step, because the extraction prompt is optimized for concise, retrievable facts, not for preserving every qualifying clause. Once you're comparing those two stripped-down facts, they read as a straight contradiction. The update-memory step frequently resolves it by issuing DELETE on the first fact and ADD on the second — the model reasons the user "changed their mind," and the async-feedback preference for design reviews disappears.
You can verify this happened by pulling the full memory set:
for mem in m.get_all(user_id=user_id):
print(mem["memory"])
If the design-review preference is missing, that's the collapse. It doesn't throw, doesn't log a warning by default, and doesn't show up until your agent starts routing design-review feedback the wrong way — a purely behavioral bug with no stack trace.
Why this isn't a Mem0 bug
It's worth being precise here because it changes the fix. The conflict resolver is doing exactly what it's designed to do: given two facts that look contradictory, pick one. The actual gap is upstream — nothing in the pipeline knows that "design reviews" and "incident calls" are two different scopes that shouldn't be allowed to compete for the same slot in the first place. Scope is domain knowledge your application has and Mem0's generic extraction prompt doesn't.
That means the fix isn't disabling the conflict resolver (you'd lose the genuinely useful cases, like the user actually changing a stable preference). It's making scope explicit enough that the resolver can see it, and adding a safety net for when it still gets it wrong.
Pattern 1: put scope in the text, not just the metadata
Mem0's metadata field is searchable and filterable, but the update-memory LLM call reasons over the fact text, not your metadata dict. If the scope only lives in metadata, the resolver never sees it. Fold scope into the sentence itself:
def remember_scoped(text: str, scope: str, user_id: str, **extra_metadata):
scoped_text = f"[{scope}] {text}"
m.add(
[{"role": "user", "content": scoped_text}],
user_id=user_id,
metadata={"scope": scope, **extra_metadata},
)
remember_scoped("Prefers async written feedback over live calls.", "design-reviews", user_id)
remember_scoped("Prefers synchronous voice over Slack threads.", "incident-calls", user_id)
The [scope] prefix survives fact extraction far more reliably than a clause buried mid-sentence, because it sits at the start where the extraction prompt tends to preserve it verbatim. It also gives you a second, structured way to retrieve by scope without relying on semantic search:
design_prefs = [
mem for mem in m.get_all(user_id=user_id)
if mem.get("metadata", {}).get("scope") == "design-reviews"
]
This alone eliminates most false-positive collapses, because two facts prefixed with different scope tags rarely score as similar enough to trigger the update pipeline in the first place.
Pattern 2: an audit wrapper around every write
Scoping reduces the failure rate; it doesn't guarantee zero. For anything you can't afford to silently lose, wrap add() so you know exactly what changed:
import json
from datetime import datetime
def audited_add(text: str, user_id: str, log_path: str = "memory_audit.jsonl", **kwargs):
before = {mem["id"]: mem["memory"] for mem in m.get_all(user_id=user_id)}
result = m.add([{"role": "user", "content": text}], user_id=user_id, **kwargs)
after = {mem["id"]: mem["memory"] for mem in m.get_all(user_id=user_id)}
deleted = [v for k, v in before.items() if k not in after]
added = [v for k, v in after.items() if k not in before]
if deleted:
with open(log_path, "a") as f:
f.write(json.dumps({
"ts": datetime.utcnow().isoformat(),
"user_id": user_id,
"input": text,
"deleted": deleted,
"added": added,
}) + "\n")
return result
This costs two extra get_all() calls per write — cheap relative to the LLM calls add() already makes internally, and negligible next to the cost of a preference silently vanishing in production. The append-only log gives you exactly what you need to catch a bad collapse: every deletion, the input that triggered it, and what replaced it. Run a daily check against it (or alert on any deleted entry for a scope you've marked as protected) and you'll catch false-positive merges within a day instead of discovering them weeks later as a behavior regression nobody can explain.
Pattern 3: use history() as a recovery path, not a safety net
Mem0 keeps a version history per memory ID via m.history(memory_id), showing prior versions and the event that changed them (ADD, UPDATE, DELETE). It's tempting to treat this as your safety net and skip the audit log above — don't. history() is indexed by memory ID, and a DELETE removes the memory from search() and get_all() results entirely, so you have no way to discover which ID to look up after the fact unless you already logged it elsewhere. History is a recovery tool for an ID you already know is suspect; it's not a detection mechanism. The audit wrapper is what tells you an ID needs investigating in the first place.
The actual takeaway
Automatic memory conflict resolution is a genuine time-saver, and disabling it to avoid this failure mode throws out the baby with the bathwater — you'd be back to hand-rolling the merge logic Mem0 exists to replace. The fix that scales is narrower: make scope legible to the model doing the resolving (in the text, not just metadata), and instrument every write cheaply enough that a bad collapse shows up in a log instead of in a support ticket. Neither pattern requires forking Mem0 or dropping to its lower-level APIs — both sit entirely in how you call add().
Top comments (0)