sentence-transformers/all-MiniLM-L6-v2 running locally via no OpenAI API key required — 80 MB model, downloaded once, runs entirely on your CPU. Vector storage uses ChromaDB (local SQLite). No cloud services touched at any point.
M11: Multi-Layer Memory
Every call to your Mistral endpoint starts fresh. Without a memory system, your agent forgets everything the moment a conversation ends — user preferences, past decisions, accumulated facts. This module teaches you to build a three-layer memory architecture that gives your agent durable recall across turns, sessions, and restarts.
Learning Objectives
- Explain the three failure modes of stateless LLM agents
- Choose the right memory layer for a given use case using the decision guide
- Implement a token-aware sliding window buffer (
BufferMemory) - Store and retrieve facts by semantic similarity with ChromaDB and local sentence-transformers
- Compress conversation histories into structured episodic summaries with Mistral
- Assemble
AgentMemorythat orchestrates all three layers in a singlebuild_context()call - Swap ChromaDB for Qdrant when you need production-grade vector performance
- Retrofit the Capstone C3 entity resolution agent with persistent memory across queries
The Stateless API Problem
Imagine a doctor who wakes up every morning with complete amnesia. Each patient appointment begins the same way: "Hello, I don't know who you are. What brings you in today?" The doctor is technically skilled — they can diagnose and prescribe — but every consultation starts from zero. They can't track a chronic condition across visits, can't recall that you mentioned dizziness last month, and can't notice that three of your symptoms together form a pattern they've seen before.
This is exactly what your agent does by default. Each API call to Mistral is a fresh context window with no memory of any prior turn, any previously resolved entity, or any user preference established five minutes ago. The model is not broken — it just has no memory plumbing connecting calls to each other.
The fix is not to send the entire conversation history every time (that's expensive and hits the context window ceiling fast) — it's to build a tiered memory system where each layer stores a different class of information and retrieves only what is relevant to the current query. The doctor metaphor maps directly: the buffer is the current appointment's working notes, the vector store is the patient's searchable chart, and the episodic store is the doctor's narrative summaries filed after each visit.
Three Failure Modes Without Memory
In practice, stateless agents fail in three distinct ways. Recognizing which failure mode you're hitting tells you which memory layer to add.
- Context overflow: The messages list grows until it exceeds the model's context window. Older turns get truncated silently, causing the agent to "forget" things it was told earlier in the same conversation.
- Lost facts across sessions: Everything in the messages list lives in RAM. When your process restarts — for any reason — all accumulated knowledge is gone. The next session starts cold.
- Slow re-grounding: Without persistent memory, every new session forces the user to re-explain background context. Repeated re-grounding burns tokens and degrades user experience.
A production customer support agent without session memory forces users to repeat their account number, order ID, and issue description every time they reconnect. A UCC entity resolution agent without persistent memory re-computes fuzzy match scores for entities it has already resolved. Both are fixable with less than 200 lines of memory plumbing — the code in this module.
Three Memory Layers
A memory layer is an abstraction that stores and retrieves information at a specific granularity and persistence level. The three layers differ in where data lives (RAM vs. disk vs. summarized text), how it is retrieved (by position vs. by semantic similarity vs. by session relevance), and how long it survives (current process vs. restarts vs. multi-day sessions). Well-architected agents compose all three: the buffer handles the immediate turn, the vector store handles facts from any past session, and episodic memory handles long-horizon narrative continuity.
Decision Guide: Which Layer for Which Use Case
Use the guide below to quickly identify which layer(s) to reach for given a specific agent requirement.
Multi-turn conversation coherence. User asks a follow-up question that references earlier turns. Short single-session tasks where full history fits in context. Always active by default — it's the messages list.
Remembering specific facts across sessions ("last time you said your order number is..."). Agents that accumulate domain knowledge over time. Entity resolution agents caching past decisions. Any fact that should persist past process restart.
Long-running agents operating over days or weeks. Session narrative continuity ("picking up where we left off"). Cases where the full message history is too large to embed cheaply but key events must be remembered. Background context for new sessions.
Layer 1: In-Process Buffer
The context windowThe maximum number of tokens a language model can process in a single forward pass, including both the input prompt and the generated output. Mistral-7B has a 32k token context window; mistral-large supports up to 128k. Exceeding the window causes silent truncation of the oldest tokens. is the total token budget for a single model call — system prompt, all messages, and the model's response combined. Exceeding it causes the model to silently drop the oldest tokens, which is far worse than graceful eviction because you don't know what was lost. A token-aware buffer evicts predictably.
Sliding Window with Token Budget
The naive approach drops messages older than a fixed count (e.g., keep last 20). The better approach counts tokens before appending and evicts the oldest messages to stay within a budget. Token counting without a tokenizer is approximated at 4 chars per token — good enough for budget management.
# memory/buffer_memory.py
# WHAT: Sliding window buffer with optional token-aware eviction
# WHY: Naive message list grows unbounded; token awareness prevents
# silent truncation by the model context window
# GOTCHA: Token counting uses 4-chars-per-token approximation.
# Real tokenization (tiktoken) is more accurate but requires
# an extra dependency. For budget management the approximation
# is sufficient.
from __future__ import annotations
import json
from typing import Optional
class BufferMemory:
"""
In-process message buffer with sliding window eviction.
Eviction policy (applied in order when both limits set):
1. If len(messages) > max_messages: drop oldest pairs
2. If token_count() > max_tokens: drop oldest pairs until under budget
"""
def __init__(
self,
max_messages: int = 20,
max_tokens: Optional[int] = 4000,
) -> None:
self.messages: list[dict] = []
self.max_messages = max_messages
self.max_tokens = max_tokens
# ------------------------------------------------------------------
# WHAT: Estimate tokens using 4-chars-per-token heuristic
# WHY: Avoids importing a tokenizer; accurate enough for eviction
# GOTCHA: Code-heavy messages (JSON blobs) tokenize denser than prose.
# If you store tool outputs in the buffer, lower max_tokens by ~15%
# ------------------------------------------------------------------
def _estimate_tokens(self, messages: list[dict]) -> int:
total_chars = sum(
len(str(m.get("content", ""))) + len(m.get("role", ""))
for m in messages
)
return total_chars // 4
def token_count(self) -> int:
"""Return estimated token count of current buffer."""
return self._estimate_tokens(self.messages)
# ------------------------------------------------------------------
# WHAT: Append a message and evict if over limits
# WHY: Enforce limits at write time so get() always returns a safe list
# GOTCHA: Always evict in pairs (user + assistant) to preserve
# turn structure. Evicting a lone user message leaves the model
# confused about conversation flow.
# ------------------------------------------------------------------
def add(self, role: str, content: str) -> None:
"""Add a message and evict oldest messages if over limits."""
self.messages.append({"role": role, "content": content})
# Evict by count (keep pairs by evicting from front in steps of 2)
while len(self.messages) > self.max_messages:
self.messages.pop(0)
if self.messages:
self.messages.pop(0) # evict paired turn
# Evict by tokens
if self.max_tokens is not None:
while (
len(self.messages) >= 2
and self._estimate_tokens(self.messages) > self.max_tokens
):
self.messages.pop(0)
self.messages.pop(0) # keep pairs
def get(self) -> list[dict]:
"""Return current message list, safe to pass directly to the API."""
return list(self.messages)
def clear(self) -> None:
"""Empty the buffer (use at session end before saving episode)."""
self.messages = []
def __repr__(self) -> str:
return (
f"BufferMemory(messages={len(self.messages)}, "
f"~{self.token_count()} tokens)"
)
# ------------------------------------------------------------------
# WHAT: Quick smoke test — run this file directly to verify behavior
# ------------------------------------------------------------------
if __name__ == "__main__":
buf = BufferMemory(max_messages=6, max_tokens=200)
for i in range(5):
buf.add("user", f"Query number {i}: what is the status of order {i}?")
buf.add("assistant", f"Order {i} is shipped. Tracking: TRK{i:04d}.")
print(buf) # should show ~3 pairs (evicted oldest)
print(f"Messages kept: {len(buf.get())}")
for m in buf.get():
print(f" [{m['role']}] {m['content'][:60]}")
// memory/bufferMemory.js
// WHAT: Token-aware sliding window buffer in Node.js
// WHY: Matches the Python class interface so AgentMemory can use
// either implementation depending on runtime
// GOTCHA: Same 4-chars-per-token approximation as Python version.
// For production, swap with a real tokenizer (tiktoken-node).
class BufferMemory {
/**
* @param {Object} opts
* @param {number} [opts.maxMessages=20]
* @param {number|null} [opts.maxTokens=4000]
*/
constructor({ maxMessages = 20, maxTokens = 4000 } = {}) {
this.messages = [];
this.maxMessages = maxMessages;
this.maxTokens = maxTokens;
}
// WHAT: Approximate token count via character heuristic
// GOTCHA: Tool output JSON blobs are token-denser than prose.
// Account for this by reducing maxTokens ~15% if storing tool calls.
_estimateTokens(messages) {
return messages.reduce((total, m) => {
const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
return total + Math.ceil((content.length + (m.role?.length ?? 0)) / 4);
}, 0);
}
tokenCount() {
return this._estimateTokens(this.messages);
}
// WHAT: Append message, evict oldest pairs when over limits
// GOTCHA: Evict in pairs (user+assistant) to preserve turn structure.
// A lone unmatched turn confuses the model on next completion.
add(role, content) {
this.messages.push({ role, content });
// Evict by count
while (this.messages.length > this.maxMessages) {
this.messages.splice(0, 2); // remove oldest pair
}
// Evict by tokens
if (this.maxTokens !== null) {
while (
this.messages.length >= 2 &&
this._estimateTokens(this.messages) > this.maxTokens
) {
this.messages.splice(0, 2);
}
}
}
/** Returns a copy of the message array, safe to spread into API calls. */
get() {
return [...this.messages];
}
clear() {
this.messages = [];
}
toString() {
return `BufferMemory(messages=${this.messages.length}, ~${this.tokenCount()} tokens)`;
}
}
// Quick smoke test
if (process.argv[1] === new URL(import.meta.url).pathname) {
const buf = new BufferMemory({ maxMessages: 6, maxTokens: 200 });
for (let i = 0; i < 5; i++) {
buf.add('user', `Query number ${i}: what is the status of order ${i}?`);
buf.add('assistant', `Order ${i} is shipped. Tracking: TRK${String(i).padStart(4,'0')}.`);
}
console.log(buf.toString());
console.log(`Messages kept: ${buf.get().length}`);
buf.get().forEach(m => console.log(` [${m.role}] ${m.content.slice(0, 60)}`));
}
export { BufferMemory };
You added 5 pairs (10 messages). The buffer evicted pairs 0, 1, and 2 to stay within the 6-message and 200-token limits. The get() call returns only pairs 3 and 4 — the most recent context. The model never sees a truncated half-turn because eviction always happens in pairs.
Layer 2: Semantic Vector Store
A vector embeddingA fixed-length list of floating-point numbers that encodes the semantic meaning of text. Produced by a neural network (embedding model) trained to place semantically similar texts near each other in high-dimensional space. Example: "shipped yesterday" and "dispatched the previous day" produce embeddings very close together. is a list of ~384 floating-point numbers that encodes what a piece of text means. The embedding model (all-MiniLM-L6-v2) is run locally — no API call. Cosine similarityA distance metric between two vectors, ranging from -1 (opposite meanings) to 1 (identical meaning). Computed as the dot product of the two unit vectors. In vector search, the database finds the stored vectors with the highest cosine similarity to the query vector. measures how closely two embeddings point in the same direction in that 384-dimensional space, returning a value between 0 and 1 where 1 means identical meaning.
sentence-transformers/all-MiniLM-L6-v2 is an 80 MB model that runs on CPU and produces 384-dimensional embeddings. It is fast enough for real-time memory save/recall: ~2ms per embedding on a modern laptop CPU. No API key, no network call, no cost per embedding. Install once: pip install sentence-transformers chromadb.
VectorMemory Class
Three methods cover all use cases: save(text, metadata) stores a fact with optional metadata dict, recall(query, k=5) returns the top-k semantically closest facts, and forget(memory_id) deletes a specific memory by ID. Near-duplicate deduplication uses a cosine similarity threshold before saving to avoid redundant embeddings accumulating over time.
# memory/vector_memory.py
# WHAT: Semantic memory layer using ChromaDB and local sentence-transformers
# WHY: Enables "find what I know about X" queries that survive process restarts
# GOTCHA: First import of SentenceTransformer downloads the model (~80 MB).
# Subsequent runs use the local cache. Set SENTENCE_TRANSFORMERS_HOME
# to control where the model is cached.
from __future__ import annotations
import uuid
import chromadb
from chromadb.config import Settings
from sentence_transformers import SentenceTransformer
from typing import Optional
class VectorMemory:
"""
Semantic memory layer backed by ChromaDB (local SQLite) and
sentence-transformers/all-MiniLM-L6-v2 for embeddings.
All embeddings are computed locally — no OpenAI key required.
"""
# WHAT: Load embedding model once at class level (shared across instances)
# WHY: Model load takes ~1s; doing it per-instance would be wasteful
# GOTCHA: Model is ~80 MB; first import triggers download. Subsequent
# runs use local cache in ~/.cache/huggingface/
_embed_model: Optional[SentenceTransformer] = None
@classmethod
def _get_embedder(cls) -> SentenceTransformer:
if cls._embed_model is None:
cls._embed_model = SentenceTransformer("all-MiniLM-L6-v2")
return cls._embed_model
def __init__(
self,
persist_directory: str = "./chroma_memory",
collection_name: str = "agent_memory",
dedup_threshold: float = 0.95,
) -> None:
# WHAT: Persistent client — data survives process restarts
# GOTCHA: Use allow_reset=True only during development/testing;
# it wipes all collections when called
self._client = chromadb.PersistentClient(
path=persist_directory,
settings=Settings(anonymized_telemetry=False),
)
self._collection = self._client.get_or_create_collection(
name=collection_name,
metadata={"hnsw:space": "cosine"}, # cosine similarity index
)
self.dedup_threshold = dedup_threshold
# ------------------------------------------------------------------
# WHAT: Embed text locally and save to ChromaDB
# WHY: Persistent storage means this memory survives agent restarts
# GOTCHA: metadata values must be str, int, float, or bool — not dicts
# or nested objects. Flatten before passing.
# ------------------------------------------------------------------
def save(self, text: str, metadata: Optional[dict] = None) -> str:
"""
Embed text and store in ChromaDB. Returns the generated memory ID.
Skips saving if a near-duplicate already exists (cosine sim > dedup_threshold).
"""
embedder = self._get_embedder()
embedding = embedder.encode(text).tolist()
# Deduplication check — skip if too similar to existing memory
if self._collection.count() > 0:
existing = self._collection.query(
query_embeddings=[embedding],
n_results=1,
include=["distances"],
)
if existing["distances"] and existing["distances"][0]:
# ChromaDB cosine distance = 1 - cosine_similarity
cosine_sim = 1.0 - existing["distances"][0][0]
if cosine_sim >= self.dedup_threshold:
# Near-duplicate found — skip save
existing_id = existing["ids"][0][0]
return existing_id
memory_id = str(uuid.uuid4())
safe_meta = {k: v for k, v in (metadata or {}).items()
if isinstance(v, (str, int, float, bool))}
safe_meta["text"] = text # store original text in metadata for recall
self._collection.add(
ids=[memory_id],
embeddings=[embedding],
metadatas=[safe_meta],
)
return memory_id
# ------------------------------------------------------------------
# WHAT: Recall top-k memories most relevant to query
# WHY: Semantic retrieval finds related facts even with different wording
# GOTCHA: n_results must be <= collection count; cap automatically
# ------------------------------------------------------------------
def recall(self, query: str, k: int = 5) -> list[dict]:
"""
Return up to k memories most semantically similar to query.
Each result: {"id": str, "text": str, "score": float, "metadata": dict}
"""
count = self._collection.count()
if count == 0:
return []
n = min(k, count)
embedder = self._get_embedder()
query_embedding = embedder.encode(query).tolist()
results = self._collection.query(
query_embeddings=[query_embedding],
n_results=n,
include=["metadatas", "distances"],
)
memories = []
for i, mem_id in enumerate(results["ids"][0]):
meta = results["metadatas"][0][i]
distance = results["distances"][0][i]
cosine_sim = 1.0 - distance
memories.append({
"id": mem_id,
"text": meta.get("text", ""),
"score": round(cosine_sim, 4),
"metadata": {k: v for k, v in meta.items() if k != "text"},
})
return sorted(memories, key=lambda m: m["score"], reverse=True)
# ------------------------------------------------------------------
# WHAT: Delete a specific memory by ID
# GOTCHA: Silently does nothing if ID not found — check return value
# if you need to confirm deletion
# ------------------------------------------------------------------
def forget(self, memory_id: str) -> bool:
"""Delete memory by ID. Returns True if found and deleted."""
try:
self._collection.delete(ids=[memory_id])
return True
except Exception:
return False
def count(self) -> int:
return self._collection.count()
# ------------------------------------------------------------------
# WHAT: Smoke test — save three facts, recall with related query
# ------------------------------------------------------------------
if __name__ == "__main__":
import tempfile, os
with tempfile.TemporaryDirectory() as tmp:
mem = VectorMemory(persist_directory=tmp)
id1 = mem.save("Order TRK-001 was shipped via FedEx on Monday", {"order_id": "TRK-001"})
id2 = mem.save("Customer prefers email notifications over SMS", {"type": "preference"})
id3 = mem.save("The order was delayed due to a weather event in Memphis", {"order_id": "TRK-001"})
print(f"Stored {mem.count()} memories")
results = mem.recall("What happened with the delivery?", k=3)
for r in results:
print(f" [{r['score']:.3f}] {r['text'][:70]}")
// memory/vectorMemory.js
// WHAT: Semantic memory layer using chromadb npm package + local Ollama embeddings
// WHY: Node.js agents need the same persistent memory as Python agents
// GOTCHA: The chromadb npm package requires ChromaDB server running locally.
// Start with: docker run -p 8000:8000 chromadb/chroma
// For embeddings, we use Ollama's /api/embeddings endpoint
// with nomic-embed-text (pull once: ollama pull nomic-embed-text)
import { ChromaClient } from 'chromadb';
import { v4 as uuidv4 } from 'uuid';
// WHAT: Embed text using Ollama's local embedding endpoint
// WHY: Keeps all embedding computation local, no external API
// GOTCHA: nomic-embed-text must be pulled first: `ollama pull nomic-embed-text`
// The model produces 768-dim vectors (vs 384 for MiniLM). Adjust
// similarity thresholds slightly if mixing with Python-generated embeddings.
async function embedText(text, model = 'nomic-embed-text', baseUrl = 'http://localhost:11434') {
const res = await fetch(`${baseUrl}/api/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt: text }),
});
if (!res.ok) throw new Error(`Ollama embed error: ${res.status} ${await res.text()}`);
const data = await res.json();
return data.embedding; // float[]
}
function cosineSimilarity(a, b) {
const dot = a.reduce((sum, ai, i) => sum + ai * b[i], 0);
const normA = Math.sqrt(a.reduce((sum, ai) => sum + ai * ai, 0));
const normB = Math.sqrt(b.reduce((sum, bi) => sum + bi * bi, 0));
return dot / (normA * normB);
}
class VectorMemory {
constructor({
collectionName = 'agent_memory',
dedupThreshold = 0.95,
chromaUrl = 'http://localhost:8000',
ollamaUrl = 'http://localhost:11434',
embedModel = 'nomic-embed-text',
} = {}) {
this.collectionName = collectionName;
this.dedupThreshold = dedupThreshold;
this.ollamaUrl = ollamaUrl;
this.embedModel = embedModel;
this.client = new ChromaClient({ path: chromaUrl });
this._collection = null;
}
async _getCollection() {
if (!this._collection) {
this._collection = await this.client.getOrCreateCollection({
name: this.collectionName,
metadata: { 'hnsw:space': 'cosine' },
});
}
return this._collection;
}
// WHAT: Embed text via Ollama and save to ChromaDB
// GOTCHA: metadata values must be primitives — no nested objects
async save(text, metadata = {}) {
const col = await this._getCollection();
const embedding = await embedText(text, this.embedModel, this.ollamaUrl);
// Deduplication check
const count = await col.count();
if (count > 0) {
const existing = await col.query({ queryEmbeddings: [embedding], nResults: 1, include: ['embeddings'] });
if (existing.embeddings?.[0]?.[0]) {
const sim = cosineSimilarity(embedding, existing.embeddings[0][0]);
if (sim >= this.dedupThreshold) {
return existing.ids[0][0]; // skip — near-duplicate
}
}
}
const memId = uuidv4();
const safeMeta = { text, ...Object.fromEntries(
Object.entries(metadata).filter(([, v]) => ['string','number','boolean'].includes(typeof v))
)};
await col.add({ ids: [memId], embeddings: [embedding], metadatas: [safeMeta] });
return memId;
}
// WHAT: Recall top-k semantically similar memories
// GOTCHA: n_results must be <= collection count, clamp automatically
async recall(query, k = 5) {
const col = await this._getCollection();
const count = await col.count();
if (count === 0) return [];
const n = Math.min(k, count);
const embedding = await embedText(query, this.embedModel, this.ollamaUrl);
const results = await col.query({
queryEmbeddings: [embedding],
nResults: n,
include: ['metadatas', 'distances'],
});
return results.ids[0].map((id, i) => {
const meta = results.metadatas[0][i];
const score = 1 - results.distances[0][i]; // distance -> similarity
const { text, ...rest } = meta;
return { id, text, score: Math.round(score * 10000) / 10000, metadata: rest };
}).sort((a, b) => b.score - a.score);
}
async forget(memoryId) {
try {
const col = await this._getCollection();
await col.delete({ ids: [memoryId] });
return true;
} catch { return false; }
}
async count() {
const col = await this._getCollection();
return col.count();
}
}
export { VectorMemory };
The recall query "What happened with the delivery?" semantically matched the two shipping-related facts at scores 0.78 and 0.74. The customer preference fact scored only 0.19 because "delivery" and "email notifications" don't share meaning in embedding space. This is the power of semantic search: you don't need exact keywords to find relevant memories.
Layer 3: Episodic / Summary Memory
Episodic memory stores structured narrative summaries of past conversation sessions, compressed by the LLM itself. After every N turns (or at session end), Mistral is asked to produce a compact episode: what was discussed, what was decided, what was left unresolved. On the next session, the agent retrieves relevant past episodes and prepends them as a context prefix so it can "remember" without replaying the full history.
The Compression Prompt
The quality of episodic memory depends entirely on the compression prompt. A weak prompt produces vague summaries ("we discussed orders"). A strong prompt produces actionable structured summaries with entities, decisions, and open questions explicitly listed.
# memory/episodic_memory.py
# WHAT: Session summary memory — compresses conversations into structured episodes
# WHY: Enables multi-session continuity for long-running agents without
# replaying full message histories
# GOTCHA: Episodes are stored as JSON files in a directory. For production,
# store in a database or vector store for better search. This file-
# backed approach is sufficient for <1000 sessions.
from __future__ import annotations
import json
import uuid
import re
from datetime import datetime
from pathlib import Path
from openai import OpenAI
# WHAT: Compression prompt — explicit structure prevents vague summaries
# GOTCHA: Mistral may wrap JSON in ``` fences. The save_episode() method
# strips these before parsing. Never trust raw model output as valid JSON.
COMPRESSION_PROMPT = """You are a memory compression agent. Summarize the conversation below
into a structured episode that a future agent instance can use to quickly re-establish context.
CONVERSATION:
{conversation}
Produce a JSON object with exactly these fields:
{{
"summary": "2-3 sentence narrative of what happened",
"entities": ["list of named entities mentioned (people, companies, IDs, etc.)"],
"decisions": ["list of decisions or conclusions reached"],
"open_questions": ["list of unresolved questions or next steps"],
"key_facts": ["list of specific facts worth remembering (numbers, dates, IDs)"]
}}
Return ONLY valid JSON — no markdown fences, no preamble."""
class EpisodicMemory:
"""
Episodic memory layer. Compresses conversations with Mistral and stores
structured episode files on disk. Retrieves relevant episodes by keyword
matching on entities, decisions, and key facts.
"""
def __init__(
self,
storage_dir: str = "./episodes",
model: str = "mistral",
base_url: str = "http://localhost:11434/v1",
compress_after_turns: int = 20,
) -> None:
self.storage_dir = Path(storage_dir)
self.storage_dir.mkdir(parents=True, exist_ok=True)
self.model = model
self.compress_after_turns = compress_after_turns
self._client = OpenAI(base_url=base_url, api_key="ollama")
# ------------------------------------------------------------------
# WHAT: Compress a message list into a structured episode with Mistral
# WHY: Reduces a 50-message conversation to a ~200-token context prefix
# GOTCHA: Mistral sometimes wraps JSON in ```json ... ``` blocks.
# Always strip fence markers before json.loads().
# ------------------------------------------------------------------
def save_episode(self, messages: list[dict], session_id: str | None = None) -> str:
"""
Compress messages into an episode and save to disk.
Returns the episode ID.
"""
if not messages:
raise ValueError("Cannot save empty episode")
conversation = "\n".join(
f"[{m['role'].upper()}] {m['content']}" for m in messages
)
try:
resp = self._client.chat.completions.create(
model=self.model,
messages=[{
"role": "user",
"content": COMPRESSION_PROMPT.format(conversation=conversation),
}],
temperature=0,
max_tokens=600,
)
raw = resp.choices[0].message.content or ""
# WHAT: Strip markdown fences if model wraps in ``` blocks
raw = re.sub(r"^```(?:json)?\s*", "", raw.strip())
raw = re.sub(r"\s*```$", "", raw)
episode_data = json.loads(raw)
except json.JSONDecodeError:
# Fallback: store raw summary if parsing fails
episode_data = {
"summary": raw[:500],
"entities": [], "decisions": [],
"open_questions": [], "key_facts": [],
}
episode_id = session_id or str(uuid.uuid4())
episode = {
"id": episode_id,
"timestamp": datetime.utcnow().isoformat(),
"turn_count": len(messages),
**episode_data,
}
ep_path = self.storage_dir / f"{episode_id}.json"
ep_path.write_text(json.dumps(episode, indent=2))
return episode_id
# ------------------------------------------------------------------
# WHAT: Retrieve episodes relevant to a query by keyword overlap
# WHY: Simple keyword matching works well for structured episode fields.
# For advanced use, replace with vector similarity search.
# GOTCHA: This returns episodes ranked by overlap score, not timestamp.
# If you want recency bias, combine overlap with timestamp.
# ------------------------------------------------------------------
def recall_relevant(self, query: str, max_episodes: int = 3) -> list[dict]:
"""
Return up to max_episodes episodes most relevant to the query.
Relevance is scored by keyword overlap across entities, decisions, and key_facts.
"""
query_tokens = set(query.lower().split())
scored = []
for ep_file in self.storage_dir.glob("*.json"):
try:
ep = json.loads(ep_file.read_text())
except (json.JSONDecodeError, OSError):
continue
searchable = " ".join([
ep.get("summary", ""),
" ".join(ep.get("entities", [])),
" ".join(ep.get("decisions", [])),
" ".join(ep.get("key_facts", [])),
]).lower()
overlap = sum(1 for t in query_tokens if t in searchable)
if overlap > 0:
scored.append((overlap, ep))
scored.sort(key=lambda x: x[0], reverse=True)
return [ep for _, ep in scored[:max_episodes]]
def format_as_context_prefix(self, episodes: list[dict]) -> str:
"""
Format retrieved episodes as a context prefix string to prepend to
the system prompt or first user message.
"""
if not episodes:
return ""
lines = ["[PAST SESSION CONTEXT]"]
for ep in episodes:
lines.append(f"Session {ep['id'][:8]} ({ep.get('timestamp','')[:10]}):")
lines.append(f" Summary: {ep.get('summary','')}")
if ep.get("entities"):
lines.append(f" Entities: {', '.join(ep['entities'])}")
if ep.get("open_questions"):
lines.append(f" Open: {'; '.join(ep['open_questions'])}")
return "\n".join(lines)
if __name__ == "__main__":
import tempfile
messages = [
{"role": "user", "content": "I need to check if ACME Logistics LLC and Acme Logistics Inc are the same company."},
{"role": "assistant", "content": "I'll run a fuzzy match. The name similarity is 0.91. They appear to be the same entity. Recommended action: MERGE with confidence 0.87."},
{"role": "user", "content": "What about their UCC filings in California?"},
{"role": "assistant", "content": "I found 3 UCC filings for ACME Logistics LLC in CA, none for Acme Logistics Inc. This is consistent with a name variant, not a separate entity."},
]
with tempfile.TemporaryDirectory() as tmp:
em = EpisodicMemory(storage_dir=tmp)
eid = em.save_episode(messages, session_id="demo-session")
print(f"Episode saved: {eid}")
results = em.recall_relevant("ACME entity resolution California")
for ep in results:
print(f" Found: {ep['summary'][:100]}")
print(f" Entities: {ep['entities']}")
// memory/episodicMemory.js
// WHAT: Episodic memory — compresses sessions via Mistral, stores as JSON files
// WHY: Multi-session continuity without replaying full history
// GOTCHA: Mistral wraps JSON in ``` blocks ~30% of the time.
// Always strip fence markers before JSON.parse().
import { OpenAI } from 'openai';
import { randomUUID } from 'crypto';
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
import { join } from 'path';
const COMPRESSION_PROMPT = `You are a memory compression agent. Summarize the conversation below
into a structured episode that a future agent instance can use to quickly re-establish context.
CONVERSATION:
{conversation}
Produce a JSON object with exactly these fields:
{
"summary": "2-3 sentence narrative of what happened",
"entities": ["list of named entities mentioned (people, companies, IDs, etc.)"],
"decisions": ["list of decisions or conclusions reached"],
"open_questions": ["list of unresolved questions or next steps"],
"key_facts": ["list of specific facts worth remembering (numbers, dates, IDs)"]
}
Return ONLY valid JSON — no markdown fences, no preamble.`;
class EpisodicMemory {
constructor({
storageDir = './episodes',
model = 'mistral',
baseUrl = 'http://localhost:11434/v1',
} = {}) {
this.storageDir = storageDir;
this.model = model;
this.client = new OpenAI({ baseUrl, apiKey: 'ollama' });
mkdirSync(storageDir, { recursive: true });
}
async saveEpisode(messages, sessionId = null) {
if (!messages.length) throw new Error('Cannot save empty episode');
const conversation = messages
.map(m => `[${m.role.toUpperCase()}] ${m.content}`)
.join('\n');
const prompt = COMPRESSION_PROMPT.replace('{conversation}', conversation);
let episodeData;
try {
const resp = await this.client.chat.completions.create({
model: this.model,
messages: [{ role: 'user', content: prompt }],
temperature: 0,
max_tokens: 600,
});
let raw = resp.choices[0].message.content ?? '';
// Strip markdown fences — Mistral adds these ~30% of the time
raw = raw.trim().replace(/^```(?:json)?\s*/m, '').replace(/\s*```$/m, '');
episodeData = JSON.parse(raw);
} catch (e) {
episodeData = {
summary: `Session with ${messages.length} turns (parse error: ${e.message})`,
entities: [], decisions: [], open_questions: [], key_facts: [],
};
}
const episodeId = sessionId ?? randomUUID();
const episode = {
id: episodeId,
timestamp: new Date().toISOString(),
turn_count: messages.length,
...episodeData,
};
const filePath = join(this.storageDir, `${episodeId}.json`);
writeFileSync(filePath, JSON.stringify(episode, null, 2));
return episodeId;
}
recallRelevant(query, maxEpisodes = 3) {
const queryTokens = new Set(query.toLowerCase().split(/\s+/));
const scored = [];
try {
const files = readdirSync(this.storageDir).filter(f => f.endsWith('.json'));
for (const file of files) {
try {
const ep = JSON.parse(readFileSync(join(this.storageDir, file), 'utf8'));
const searchable = [
ep.summary ?? '',
...(ep.entities ?? []),
...(ep.decisions ?? []),
...(ep.key_facts ?? []),
].join(' ').toLowerCase();
let overlap = 0;
queryTokens.forEach(t => { if (searchable.includes(t)) overlap++; });
if (overlap > 0) scored.push({ overlap, ep });
} catch { /* skip malformed episode */ }
}
} catch { return []; }
return scored
.sort((a, b) => b.overlap - a.overlap)
.slice(0, maxEpisodes)
.map(({ ep }) => ep);
}
formatAsContextPrefix(episodes) {
if (!episodes.length) return '';
const lines = ['[PAST SESSION CONTEXT]'];
for (const ep of episodes) {
lines.push(`Session ${ep.id.slice(0, 8)} (${ep.timestamp?.slice(0, 10) ?? ''}):`);
lines.push(` Summary: ${ep.summary ?? ''}`);
if (ep.entities?.length) lines.push(` Entities: ${ep.entities.join(', ')}`);
if (ep.open_questions?.length) lines.push(` Open: ${ep.open_questions.join('; ')}`);
}
return lines.join('\n');
}
}
export { EpisodicMemory };
Mistral read the 4-message conversation and produced a structured JSON episode with the entity names, the merge decision, and the key UCC filing fact. On the next session, recall_relevant("ACME entity resolution California") finds this episode via keyword overlap and format_as_context_prefix() returns a compact string that the new session's system prompt can include — giving the agent instant narrative continuity without replaying the full conversation.
AgentMemory is the facade that calls build_context() and gets back a complete, prioritized message list assembled from all three sources.
Putting All Three Together
The AgentMemory class composes all three layers behind two methods: build_context(query) assembles messages from all layers in priority order, and save_turn(role, content) writes to the buffer and optionally to the vector store.
resolve ACME LLC?"
# memory/agent_memory.py
# WHAT: Facade over all three memory layers — single interface for agent loop
# WHY: The agent loop only needs to call build_context() and save_turn().
# All three-layer logic is encapsulated here.
# GOTCHA: build_context() assembles in priority order:
# 1. Episodic prefix (oldest, least specific)
# 2. Vector memories injected as a synthetic [system] message
# 3. Buffer messages (most recent, highest priority)
# This order ensures the buffer always wins over older context.
from __future__ import annotations
from .buffer_memory import BufferMemory
from .vector_memory import VectorMemory
from .episodic_memory import EpisodicMemory
class AgentMemory:
"""
Orchestrates BufferMemory, VectorMemory, and EpisodicMemory into a single
interface for the agent loop.
"""
def __init__(
self,
buffer_max_messages: int = 20,
buffer_max_tokens: int = 4000,
vector_persist_dir: str = "./chroma_memory",
episode_dir: str = "./episodes",
vector_recall_k: int = 5,
episode_recall_max: int = 2,
auto_save_to_vector: bool = True,
model: str = "mistral",
base_url: str = "http://localhost:11434/v1",
) -> None:
self.buffer = BufferMemory(
max_messages=buffer_max_messages,
max_tokens=buffer_max_tokens,
)
self.vector = VectorMemory(persist_directory=vector_persist_dir)
self.episodic = EpisodicMemory(
storage_dir=episode_dir, model=model, base_url=base_url
)
self.vector_recall_k = vector_recall_k
self.episode_recall_max = episode_recall_max
self.auto_save_to_vector = auto_save_to_vector
self._turn_counter = 0
# ------------------------------------------------------------------
# WHAT: Assemble context from all three layers for a given query
# WHY: Single method call gives the agent a complete, ranked context
# GOTCHA: Vector memories are injected as a SYSTEM message so they
# don't look like a fake "assistant" or "user" turn, which
# can confuse instruction-tuned models.
# ------------------------------------------------------------------
def build_context(self, query: str) -> list[dict]:
"""
Build the full context message list for a given query.
Returns a list of messages in this order:
1. [system] episodic context prefix (if any relevant episodes found)
2. [system] relevant vector memories (if any found)
3. buffer messages (recent conversation)
"""
messages = []
# Layer 3: Episodic prefix
episodes = self.episodic.recall_relevant(
query, max_episodes=self.episode_recall_max
)
if episodes:
prefix = self.episodic.format_as_context_prefix(episodes)
messages.append({"role": "system", "content": prefix})
# Layer 2: Vector memory injection
vector_hits = self.vector.recall(query, k=self.vector_recall_k)
if vector_hits:
mem_lines = ["[RELEVANT PAST FACTS]"]
for hit in vector_hits:
if hit["score"] >= 0.5: # filter low-confidence hits
mem_lines.append(f"- [{hit['score']:.2f}] {hit['text']}")
if len(mem_lines) > 1:
messages.append({
"role": "system",
"content": "\n".join(mem_lines),
})
# Layer 1: Buffer (most recent, highest priority)
messages.extend(self.buffer.get())
return messages
# ------------------------------------------------------------------
# WHAT: Record a new turn into buffer and optionally vector store
# WHY: Single method keeps both layers in sync on every turn
# GOTCHA: Don't save tool outputs to the vector store — they are
# often too long and structurally redundant. Only save
# user messages and final assistant conclusions.
# ------------------------------------------------------------------
def save_turn(self, role: str, content: str) -> None:
"""
Save a turn to the buffer and optionally to the vector store.
Only user messages and assistant conclusions are vector-stored.
"""
self.buffer.add(role, content)
self._turn_counter += 1
# Auto-save semantically interesting turns to vector store
if self.auto_save_to_vector and role in ("user", "assistant"):
self.vector.save(content, metadata={"role": role, "turn": self._turn_counter})
def end_session(self, session_id: str | None = None) -> str | None:
"""
Compress current buffer into an episodic summary and clear buffer.
Returns the episode ID, or None if buffer was empty.
"""
messages = self.buffer.get()
if not messages:
return None
episode_id = self.episodic.save_episode(messages, session_id=session_id)
self.buffer.clear()
return episode_id
# ------------------------------------------------------------------
# WHAT: Integration test with the OpenAI-compatible agent loop
# ------------------------------------------------------------------
if __name__ == "__main__":
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
memory = AgentMemory()
SYSTEM = "You are an entity resolution assistant. Use past context if provided."
def agent_turn(user_input: str) -> str:
memory.save_turn("user", user_input)
messages = [{"role": "system", "content": SYSTEM}]
messages.extend(memory.build_context(user_input))
resp = client.chat.completions.create(
model="mistral",
messages=messages,
max_tokens=400,
temperature=0,
)
answer = resp.choices[0].message.content or ""
memory.save_turn("assistant", answer)
return answer
# Two turns — second should reference context from first
print("Turn 1:", agent_turn("Is ACME Logistics LLC the same as Acme Logistics Inc?"))
print("Turn 2:", agent_turn("What confidence score did you assign?"))
# End session and save episode
ep_id = memory.end_session()
print(f"Session saved as episode: {ep_id}")
// memory/agentMemory.js
// WHAT: AgentMemory orchestrator — Node.js equivalent of Python version
// WHY: Same three-layer architecture for agents running in Node.js
// GOTCHA: build_context() is async because VectorMemory.recall() requires
// an Ollama embedding call. Await it in the agent loop.
import { BufferMemory } from './bufferMemory.js';
import { VectorMemory } from './vectorMemory.js';
import { EpisodicMemory } from './episodicMemory.js';
class AgentMemory {
constructor({
bufferMaxMessages = 20,
bufferMaxTokens = 4000,
vectorRecallK = 5,
episodeRecallMax = 2,
autoSaveToVector = true,
model = 'mistral',
baseUrl = 'http://localhost:11434/v1',
episodeDir = './episodes',
} = {}) {
this.buffer = new BufferMemory({ maxMessages: bufferMaxMessages, maxTokens: bufferMaxTokens });
this.vector = new VectorMemory();
this.episodic = new EpisodicMemory({ storageDir: episodeDir, model, baseUrl });
this.vectorRecallK = vectorRecallK;
this.episodeRecallMax = episodeRecallMax;
this.autoSaveToVector = autoSaveToVector;
this._turnCounter = 0;
}
// WHAT: Assemble full context from all three layers
// GOTCHA: Must await — vector recall requires an Ollama embedding call
async buildContext(query) {
const messages = [];
// Layer 3: Episodic prefix (sync — file-based)
const episodes = this.episodic.recallRelevant(query, this.episodeRecallMax);
if (episodes.length > 0) {
const prefix = this.episodic.formatAsContextPrefix(episodes);
messages.push({ role: 'system', content: prefix });
}
// Layer 2: Vector memories (async — embedding call)
const vectorHits = await this.vector.recall(query, this.vectorRecallK);
const goodHits = vectorHits.filter(h => h.score >= 0.5);
if (goodHits.length > 0) {
const memLines = ['[RELEVANT PAST FACTS]', ...goodHits.map(h => `- [${h.score.toFixed(2)}] ${h.text}`)];
messages.push({ role: 'system', content: memLines.join('\n') });
}
// Layer 1: Buffer (sync, highest priority)
messages.push(...this.buffer.get());
return messages;
}
// WHAT: Record a turn into buffer and optionally vector store
// GOTCHA: Auto-vector-save is async — fire-and-forget in the agent loop
// to avoid blocking the response. Errors are swallowed.
async saveTurn(role, content) {
this.buffer.add(role, content);
this._turnCounter++;
if (this.autoSaveToVector && (role === 'user' || role === 'assistant')) {
this.vector.save(content, { role, turn: this._turnCounter }).catch(() => {});
}
}
async endSession(sessionId = null) {
const messages = this.buffer.get();
if (!messages.length) return null;
const episodeId = await this.episodic.saveEpisode(messages, sessionId);
this.buffer.clear();
return episodeId;
}
}
export { AgentMemory };
AgentMemory orchestrates all three layers behind two calls: save_turn() at the end of each turn, build_context(query) at the start of each turn. The agent loop sees a single list of messages that already contains the episodic prefix, the relevant past facts, and the recent buffer — assembled in priority order so the buffer always wins over older context.
Qdrant: Production Vector Store
ChromaDBAn open-source embedding database designed for developer simplicity. Uses a local SQLite or DuckDB backend. Excellent for development, prototyping, and single-node deployments up to ~1M vectors. Does not support horizontal scaling or filtered vector search at scale. uses an embedded SQLite backend — simple to start, runs in-process. Qdrant is a standalone Rust service designed for production loads, with sub-millisecond filtered vector search and on-disk indexing for collections larger than available RAM. The interface swap requires only changing the client class — the same VectorMemory API works with both.
Start Qdrant with a single Docker command. No account required, no API key, no data leaves your machine.
# Start Qdrant locally — persists data in ./qdrant_storage
docker run -d \
--name qdrant \
-p 6333:6333 \
-p 6334:6334 \
-v "$(pwd)/qdrant_storage:/qdrant/storage" \
qdrant/qdrant
# REST API: http://localhost:6333
# gRPC API: localhost:6334
# Web dashboard: http://localhost:6333/dashboard
# Install Python client
pip install qdrant-client
# Verify Qdrant is running
curl http://localhost:6333/healthz
# {"title":"qdrant - vector search engine","version":"..."}
# memory/vector_memory_qdrant.py
# WHAT: Drop-in VectorMemory replacement using Qdrant instead of ChromaDB
# WHY: Qdrant handles millions of vectors, supports filtered search,
# and has production-grade reliability
# GOTCHA: Collection must be created with the correct vector size BEFORE
# inserting. all-MiniLM-L6-v2 produces 384-dim vectors.
# nomic-embed-text (Ollama) produces 768-dim vectors.
# Mismatch silently causes query errors at insert time.
from __future__ import annotations
import uuid
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance, VectorParams,
PointStruct, Filter, FieldCondition, MatchValue
)
from sentence_transformers import SentenceTransformer
from typing import Optional
VECTOR_SIZE = 384 # all-MiniLM-L6-v2 dimensionality
class VectorMemoryQdrant:
"""
VectorMemory backed by Qdrant. Same interface as ChromaDB version.
Swap by replacing VectorMemory import in agent_memory.py.
"""
_embed_model: Optional[SentenceTransformer] = None
@classmethod
def _get_embedder(cls) -> SentenceTransformer:
if cls._embed_model is None:
cls._embed_model = SentenceTransformer("all-MiniLM-L6-v2")
return cls._embed_model
def __init__(
self,
host: str = "localhost",
port: int = 6333,
collection_name: str = "agent_memory",
dedup_threshold: float = 0.95,
) -> None:
self._client = QdrantClient(host=host, port=port)
self.collection_name = collection_name
self.dedup_threshold = dedup_threshold
self._ensure_collection()
# WHAT: Create collection if it doesn't exist
# GOTCHA: vector_size MUST match your embedding model's output dimension.
# Changing this after data is inserted requires recreating the collection.
def _ensure_collection(self) -> None:
existing = [c.name for c in self._client.get_collections().collections]
if self.collection_name not in existing:
self._client.create_collection(
collection_name=self.collection_name,
vectors_config=VectorParams(size=VECTOR_SIZE, distance=Distance.COSINE),
)
def save(self, text: str, metadata: Optional[dict] = None) -> str:
embedder = self._get_embedder()
embedding = embedder.encode(text).tolist()
# Deduplication: find nearest neighbor
results = self._client.search(
collection_name=self.collection_name,
query_vector=embedding,
limit=1,
score_threshold=self.dedup_threshold,
)
if results:
return results[0].id # near-duplicate found, skip
memory_id = str(uuid.uuid4())
payload = {k: v for k, v in (metadata or {}).items()
if isinstance(v, (str, int, float, bool))}
payload["text"] = text
self._client.upsert(
collection_name=self.collection_name,
points=[PointStruct(id=memory_id, vector=embedding, payload=payload)],
)
return memory_id
def recall(self, query: str, k: int = 5) -> list[dict]:
embedder = self._get_embedder()
query_embedding = embedder.encode(query).tolist()
results = self._client.search(
collection_name=self.collection_name,
query_vector=query_embedding,
limit=k,
)
memories = []
for r in results:
payload = r.payload or {}
memories.append({
"id": r.id,
"text": payload.get("text", ""),
"score": round(r.score, 4),
"metadata": {k: v for k, v in payload.items() if k != "text"},
})
return memories # already sorted by score descending
def forget(self, memory_id: str) -> bool:
try:
self._client.delete(
collection_name=self.collection_name,
points_selector=[memory_id],
)
return True
except Exception:
return False
The interface is identical to VectorMemory. To switch the full AgentMemory to Qdrant, replace one import line in agent_memory.py:
# In memory/agent_memory.py — change ONE import:
# Before (ChromaDB):
from .vector_memory import VectorMemory
# After (Qdrant):
from .vector_memory_qdrant import VectorMemoryQdrant as VectorMemory
# Everything else in AgentMemory stays identical.
# The rest of the agent loop, build_context(), and save_turn() are unchanged.
| Feature | ChromaDB | Qdrant |
|---|---|---|
| Setup | pip install chromadb — in-process | docker run qdrant/qdrant — separate service |
| Persistence | SQLite file in persist_dir | WAL + snapshots in /qdrant/storage |
| Max comfortable size | ~1M vectors (single node) | 100M+ vectors (horizontal scaling) |
| Filtered search | Post-filter (slow at scale) | Pre-filter during vector search (fast) |
| Query latency (10k vectors) | ~5ms | ~1ms |
| Recommendation for this track | Development & labs | Production & capstone |
Lab: Add Memory to the Entity Resolution Agent
You need the Capstone C3 entity resolution agent from CAPSTONE-C3-entity-resolution.html. If you haven't built it yet, complete that capstone first. This lab retrofits that agent with AgentMemory so it remembers resolved entities across queries.
pip install chromadb sentence-transformers
# Verify sentence-transformers model downloads correctly (~80 MB, one-time):
python -c "from sentence_transformers import SentenceTransformer; \
m = SentenceTransformer('all-MiniLM-L6-v2'); \
print('OK:', m.encode('test').shape)"
# Expected: OK: (384,)
# In your capstone agent.py — add at the top of main():
# WHAT: Initialize persistent memory before the agent loop
# WHY: AgentMemory must be created once and reused across turns
# GOTCHA: Use a persistent chroma_memory dir so vector memories survive
# between process restarts. Change "./chroma_memory" to an absolute
# path if you want guaranteed persistence across working directories.
from memory.agent_memory import AgentMemory
memory = AgentMemory(
buffer_max_messages=20,
buffer_max_tokens=4000,
vector_persist_dir="./chroma_memory",
episode_dir="./episodes",
model="mistral",
base_url="http://localhost:11434/v1",
)
print("Memory initialized:", memory.buffer, "| vector:", memory.vector.count(), "stored")
# BEFORE (static context — no memory):
def agent_turn(user_input: str) -> str:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_input},
]
# ... rest of agent loop
# AFTER (memory-aware context):
# WHAT: Build context from all three memory layers before each LLM call
# WHY: The agent sees relevant past resolutions without needing full history
# GOTCHA: Always prepend SYSTEM_PROMPT before build_context() output.
# The system message sets the agent's role; memory context augments it.
def agent_turn(user_input: str) -> str:
memory.save_turn("user", user_input)
# Assemble context from all three layers
context_messages = memory.build_context(user_input)
messages = [{"role": "system", "content": SYSTEM_PROMPT}] + context_messages
# Run the agent loop (tools, ReAct, etc.) — unchanged from capstone
response = run_agent_loop(messages)
memory.save_turn("assistant", response)
return response
# WHAT: After each resolution, explicitly save structured fact to vector store
# WHY: auto_save_to_vector saves all assistant turns, but entity resolutions
# benefit from explicit structured facts that are more searchable
# GOTCHA: Only save when confidence >= threshold. Storing uncertain resolutions
# as "facts" pollutes the memory and creates hallucination risk.
def save_resolution_fact(entity_a: str, entity_b: str, decision: str, confidence: float) -> None:
"""Save a completed entity resolution as a durable vector memory fact."""
if confidence < 0.7:
return # Too uncertain — don't pollute memory with guesses
fact = (
f"Entity resolution: '{entity_a}' and '{entity_b}' "
f"are {decision.upper()} (confidence: {confidence:.2f})"
)
memory.vector.save(
fact,
metadata={
"type": "entity_resolution",
"entity_a": entity_a[:100],
"entity_b": entity_b[:100],
"decision": decision,
"confidence": confidence,
}
)
print(f" [memory] Saved: {fact[:80]}")
# Call this after each resolved turn:
# save_resolution_fact("ACME Logistics LLC", "Acme Logistics Inc", "MERGE", 0.91)
# Test script — run after wiring up AgentMemory
# WHAT: First query resolves an entity; second query should hit vector memory
# WHY: Validates that past resolutions are retrieved without re-running the
# full fuzzy match logic
# GOTCHA: For memory to trigger on turn 2, turn 1 must complete and save its
# result before turn 2's build_context() is called. Async agents may
# need an explicit await before the second turn.
import asyncio
async def test_memory_recall():
print("\n=== TURN 1: First resolution ===")
result1 = await agent_turn(
"Are 'ACME Logistics LLC' and 'Acme Logistics Inc.' the same entity?"
)
print(f"Result: {result1[:150]}")
# Explicitly save the resolution fact (from the parsed result)
save_resolution_fact(
"ACME Logistics LLC", "Acme Logistics Inc.",
decision="MERGE", confidence=0.91
)
print(f" Vector store now has {memory.vector.count()} memories")
print("\n=== TURN 2: Should recall from memory ===")
result2 = await agent_turn(
"Have we seen anything about ACME Logistics before?"
)
print(f"Result: {result2[:150]}")
# The context for turn 2 should include a RELEVANT PAST FACTS block
# showing the resolution from turn 1. Inspect it:
ctx = memory.build_context("ACME Logistics previous resolution")
has_memory = any("[RELEVANT PAST FACTS]" in m.get("content","") for m in ctx)
print(f"\nMemory context injected on turn 2: {has_memory}")
assert has_memory, "Memory recall did not trigger — check vector store save"
print("PASS: Memory recall working correctly")
if __name__ == "__main__":
asyncio.run(test_memory_recall())
Turn 1 resolved the entity and saved a structured fact to the vector store. Turn 2's build_context() called vector.recall("ACME Logistics previous resolution"), found the saved fact with cosine similarity ~0.85, and injected it as a [RELEVANT PAST FACTS] system message. The agent saw the cached decision and answered without running the full fuzzy match pipeline again.
Knowledge Check
1. Your agent is mid-conversation and the user references something said 40 turns ago that was evicted from the buffer. Which memory layer should have preserved that information?
2. You call VectorMemory.save() with the same text twice in a row. What happens?
3. What is the correct build_context() message assembly order, from first to last in the returned list?
4. Your BufferMemory evicts messages in pairs (user + assistant). Why?
5. When should you swap from ChromaDB to Qdrant for the vector memory layer?
6. Mistral's JSON output from the compression prompt sometimes arrives wrapped in ` ```json ``` ` markdown fences. What does EpisodicMemory.save_episode() do about this?
Knowledge check complete