Why Most Multi-Agent Systems Fail in Production
Published March 2026 · 14 min read
We deployed a multi-agent system with three agents (billing, sales, support) all sharing memory through mem0. It worked perfectly in staging.
In production, within hours, the system had created three separate user profiles for the same person:
[email protected][email protected]+1-555-012-3456
Each agent believed these were different people. Memory fragmented. Agents contradicted each other. Context disappeared between channels.
The problem wasn't orchestration. It wasn't prompt engineering. It was identity.
This post walks through the problem, why current tooling doesn't solve it, and the architecture pattern that does, with working code from a real test scenario.
The Assumption Every Memory System Makes
Agent memory systems (mem0, LangChain memory modules, CrewAI shared state) all follow the same pattern: store and retrieve by user_id.
from mem0 import MemoryClient
m = MemoryClient()
m.add(
"User requested enterprise pricing",
user_id="[email protected]"
)This works when you control the identifier. In demos, user_id is a clean string you set yourself.
In production, identity is messy. The same person reaches your system as:
- A personal email (
[email protected]) - A work email (
[email protected]) - A phone number (
+1-555-012-3456) - A Slack handle
- A CRM contact ID
- Natural language: "Hi, it's Bill from Acme"
Every memory system treats each identifier as a separate user. Each agent builds an isolated silo of context. Agent B has no idea what Agent A learned, even though they talked to the same person.
This isn't a corner case. It's the default behavior of user_id-keyed memory.
The Test Scenario
Three agents interact with the same person: Bill Smith, VP of Engineering at Acme Corp. Each agent knows him by a different identifier.
| Agent | Role | Identifier |
|---|---|---|
| Agent A | SDR bot | [email protected] (personal email) |
| Agent B | Support bot | [email protected] (work email) |
| Agent C | Account bot | +1-555-012-3456 (phone) |
With vanilla mem0
from mem0 import MemoryClient
m = MemoryClient()
# Agent A: SDR bot
m.add(
"I'm Bill, VP of Engineering at Acme Corp. Switch us to annual billing.",
user_id="[email protected]",
)
# Agent B: Support bot
m.add(
"This is William Smith from Acme. We need a quote for 50 Enterprise seats.",
user_id="[email protected]",
)
# Agent C: Account bot
m.add(
"Hi, it's Bill from Acme. Can we get SSO enabled on our account?",
user_id="+1-555-012-3456",
)Now Agent B searches for context about billing:
results = m.search("billing", filters={"user_id": "[email protected]"})
print(f"[email protected] sees {len(results)} memories")
# → [email protected] sees 0 memoriesZero results. Agent A stored the billing request under [email protected], a completely different key. Agent B has no context. Agent C has no context. Three agents, three silos, zero shared understanding.
In production, this means:
- Agent B asks Bill to repeat himself: "Can you tell me your role?" (Agent A already knows)
- Agent C starts from scratch despite two prior conversations existing in the system
- No unified customer view: three partial profiles instead of one complete one
Why string matching doesn't work
The three identifiers share zero characters in common:
[email protected][email protected]+1-555-012-3456
No amount of regex, substring matching, or email normalization connects these. The link exists in the content of the conversations (names, titles, companies) not in the identifiers themselves.
The Fix: One Line Change
Replace MemoryClient() with KanonivMemory(). Every add() and search() call now goes through identity resolution before hitting mem0.
from kanoniv_mem0 import KanonivMemory
m = KanonivMemory(kanoniv_api_key="...", source_name="mem0-demo")The agents don't change. The messages don't change. The identifiers don't change. The only difference is what happens between the agent and the memory store.
# Identical agent code - same messages, same user_ids
m.add(
"I'm Bill, VP of Engineering at Acme Corp. Switch us to annual billing.",
user_id="[email protected]",
)
m.add(
"This is William Smith from Acme. We need a quote for 50 Enterprise seats.",
user_id="[email protected]",
)
m.add(
"Hi, it's Bill from Acme. Can we get SSO enabled on our account?",
user_id="+1-555-012-3456",
)Now search from any identifier:
results = m.search("billing", user_id="[email protected]")
print(f"[email protected] sees {len(results)} memories")
# → [email protected] sees 3 memoriesAll three memories. From all three agents. Through any identifier.
How it resolved the identifiers
Under the hood, Kanoniv examines each message and extracts identity signals:
| Message | Name | Title | Company | Identifier |
|---|---|---|---|---|
| Agent A | "Bill" | VP of Engineering | Acme Corp | [email protected] |
| Agent B | "William Smith" | - | Acme | [email protected] |
| Agent C | "Bill" | - | Acme | +1-555-012-3456 |
The identity plan (applied from the agent-contact template) defines how to match:
- Nickname resolution: "Bill" and "William" are the same first name
- Fuzzy company matching: "Acme Corp" and "Acme" match (Jaro-Winkler)
- Email domain signal: both emails share
acme.com - Fellegi-Sunter scoring: probabilistic weights per field. Each signal is weak alone, conclusive combined
Result: all three identifiers resolve to one canonical entity.
eid1 = m.get_entity_id("[email protected]")
eid2 = m.get_entity_id("[email protected]")
eid3 = m.get_entity_id("+1-555-012-3456")
print(f"[email protected] → {eid1}")
print(f"[email protected] → {eid2}")
print(f"+1-555-012-3456 → {eid3}")
# [email protected] → 297d2829-6bb7-42cd-8c6f-e87aa088ea80
# [email protected] → 297d2829-6bb7-42cd-8c6f-e87aa088ea80
# +1-555-012-3456 → 297d2829-6bb7-42cd-8c6f-e87aa088ea80Same entity. Three handles. Zero schema changes.
The Architecture: The Missing Layer
The pattern that solves this is an identity resolution layer between agents and their memory backend:
Agents (SDR, Support, Account, ...)
│
▼
Memory Wrapper (KanonivMemory)
│
├── Identity Resolution ← the missing layer
│ • Extract signals from text (name, company, title)
│ • Resolve identifier → canonical entity ID
│ • Nickname matching, fuzzy company, email domain
│ • Fellegi-Sunter probabilistic scoring
│
└── Memory Backend (mem0)
• Store/retrieve by canonical entity ID
• All agents share the same entity namespaceKey primitives:
| Primitive | What it does |
|---|---|
resolve(identifier, text) | Maps a raw identifier + message content → canonical entity ID |
| Handles | Multiple identifiers (email, phone, Slack) linked to one entity |
| Shared memory | All agents read/write against the same entity graph |
| Entity timeline | Full interaction history across all agents and channels |
The identity plan (a set of matching rules) is applied once and governs all resolution:
import httpx
# Apply the agent-contact identity plan
httpx.post(
"https://api.kanoniv.com/v1/spec-templates/agent-contact/apply",
json={"source_mappings": {}},
headers={"X-API-Key": KANONIV_API_KEY},
)The plan defines blocking keys (email, phone, company, name), matching rules (exact email/phone, fuzzy name/company via Jaro-Winkler), nickname tables, and Fellegi-Sunter scoring weights. Without a plan, every record becomes a new entity. With a plan, the system knows which signals indicate the same person.
What Unified Identity Unlocks
Once identity is resolved, behaviors emerge that are impossible with siloed memory. These aren't theoretical. They come directly from the test scenario.
Cross-Agent Intelligence
Agent D is an analyst bot. It has never spoken to Bill. It just has his work email from a CRM export. It queries unified memory and sees the full interaction history from all three agents:
[Agent A (SDR)] via [email protected]: "Switch us to annual billing."
[Agent B (Support)] via [email protected]: "Quote for 50 Enterprise seats."
[Agent C (Account)] via +1-555-012-3456: "Can we get SSO enabled?"Individually, these look like unrelated requests from unrelated people.
Together, they signal something very clear: Acme is preparing an enterprise expansion. Annual billing, 50 seats, SSO. These are enterprise readiness signals.
Pass this to an LLM for analysis:
analysis = ask_llm(
system_prompt=(
"You are an account intelligence analyst. Given interaction logs "
"from different agents talking to the same person, produce a brief "
"account assessment: key signals, intent level, recommended action."
),
user_prompt=f"Unified history for Bill Smith (Acme):\n\n{log_to_text(conversation_log)}",
)The LLM returns:
Intent Level: HIGH
- Multiple contact methods indicate active, multi-stakeholder engagement
- Three distinct requests: billing upgrade, bulk seat purchase, security feature
- Progression suggests moving from evaluation to implementation phase
Recommended Next Action: Consolidate contacts into single account owner. Schedule executive sync with Bill to confirm budget and timeline.
This analysis is only possible because the LLM received the complete interaction history, not the fragment that any single agent had.
Conflict Detection
Agent A offers Bill 20% off for annual billing. Agent B, unaware, sends a quote at full price:
# Agent A promised a discount
m.add(
"I offered Bill 20% off the Enterprise plan if they commit to annual.",
user_id="[email protected]",
)
# Agent B quotes full price
m.add(
"Sent William Smith the standard Enterprise pricing at $45/seat/month, no discount.",
user_id="[email protected]",
)With siloed memory, this passes undetected. Bill gets contradictory pricing from two agents.
With unified memory, a QA agent can audit the full history and catch it:
Critical Conflict: Pricing/Discount Contradiction
- Agent A promised 20% off Enterprise plan for annual commitment
- Agent B provided standard pricing at $45/seat/month with no discount
- Impact: Customer received contradictory pricing; risks deal credibility
Relationship Mapping
Sarah Chen, Head of IT at Acme, reaches out through a fifth agent using her own email. The system resolves her as a different person at the same company:
m.add(
"I'm Sarah Chen, Head of IT at Acme Corp. Bill Smith told me to reach out about SSO setup.",
user_id="[email protected]",
)
bill_id = m.get_entity_id("[email protected]") # 297d2829...
sarah_id = m.get_entity_id("[email protected]") # 2a12ed91...
# Different entities - different people, correctly resolvedThe org chart emerges from conversations:
Acme Corp
├── Bill Smith - VP of Engineering (entity: 297d2829)
└── Sarah Chen - Head of IT (entity: 2a12ed91)
Relationship: Sarah was referred by Bill for SSO setupNo CRM import. No manual mapping. The relationship is embedded in the conversation text and surfaced by identity resolution.
Shared Agent Memory
Beyond mem0's per-user memory, Kanoniv provides a shared memory layer for agents. This memory is typed (decisions, investigations, intents, tasks) and linked to canonical entities.
# SDR agent records a pricing decision using LLM
decision_summary = ask_llm(
system_prompt="Write a concise decision memo documenting what was agreed.",
user_prompt=f"Interaction history:\n\n{log_to_text(conversation_log)}",
)
# Store in shared memory, linked to Bill's canonical entity
kanoniv_post("/v1/memory", {
"entry_type": "decision",
"slug": "acme-annual-discount-approval",
"title": "Approved 20% annual discount for Acme Corp",
"content": decision_summary,
"linked_entities": [bill_entity_id],
"author": "sdr-agent",
})A completely different agent, the support bot, can later look up recent decisions:
results = kanoniv_get("/v1/memory", params={"entry_type": "decision", "limit": 5})
# → [decision] Approved 20% annual discount for Acme Corp
# by: sdr-agent
# linked to entity: 297d2829 (Bill Smith)The support agent now knows about the discount without ever talking to the SDR agent.
Agent Coordination
Agents can declare intent (so others don't duplicate work) and create tasks for each other:
# QA agent declares: "I'm auditing Acme's pricing"
kanoniv_post("/v1/memory", {
"entry_type": "intent",
"slug": "qa-audit-acme-pricing",
"title": "Auditing pricing consistency for Acme Corp",
"linked_entities": [bill_entity_id],
"author": "qa-agent",
"ttl_minutes": 30,
})
# QA agent creates a task for the account manager
kanoniv_post("/v1/memory", {
"entry_type": "task",
"slug": "resolve-acme-pricing-conflict",
"title": "Resolve pricing conflict for Acme Corp - Bill Smith",
"content": task_description,
"linked_entities": [bill_entity_id],
"author": "qa-agent",
"metadata": {
"assigned_to": "account-manager",
"priority": "high",
"status": "open"
},
})This turns a collection of independent agents into a coordinated team. Decisions persist, intents prevent duplication, tasks flow between agents. All linked to canonical entities.
The Comparison
| Capability | Without Identity Resolution | With Identity Resolution |
|---|---|---|
| Memory per person | 1 silo per identifier | All memories unified under one entity |
| Cross-agent context | None. Each agent is blind to others | Full history from all agents and channels |
| Analysis | N/A. No shared data to analyze | LLM reasons across all agent interactions |
| Conflict detection | Impossible. Contradictions are invisible | Automated auditing of unified history |
| Relationship mapping | Manual CRM entry | Emerges organically from conversations |
| Proactive follow-up | Only within one agent's silo | Action items surfaced across all channels |
| Shared decisions | Not possible | Agents memorize and recall decisions by entity |
| Task coordination | Requires external ticketing system | Agents assign tasks to each other in-band |
The Principle
Most teams building multi-agent systems focus on orchestration: routing, tool calling, chain of thought.
But orchestration isn't the hardest problem. Identity is.
Until agents share a canonical understanding of who they're interacting with, memory fragments and coordination fails. You get N agents × M identifiers = N×M isolated silos instead of one unified context.
Multi-agent systems don't scale without a shared identity layer.
Try It
The full working demo is a Jupyter notebook with the complete scenario above. Vanilla mem0 vs KanonivMemory, side by side.
pip install kanoniv-mem0- Demo notebook: examples/demo.ipynb
- Package: kanoniv-mem0 on PyPI
- Get API keys: Free at app.kanoniv.com
- Docs: docs.kanoniv.com
