Agent Memory Quickstart¶
Add programmable memory to your AI agent. Level 0 is the whole loop in six lines. Levels 1 through 6 build the same thing up one field at a time, so you understand what each piece buys before you keep or drop it.
Prerequisites:
pip install popotoand Redis running onlocalhost:6379.Full reference: Agent Memory maps all 14 primitives and the layers composed on them.
Level 0: Import the defaults¶
Skip the schema. DefaultMemory ships the benchmarked configuration (decay,
confidence, keyword search, and an association graph) and SubconsciousMemory
wraps it into a per-turn loop:
from popoto.recipes import SubconsciousMemory
sm = SubconsciousMemory(agent_id="agent-1")
messages, assembly = sm.inject_context(messages) # pre-turn: retrieve + inject
answer = call_your_llm(messages) # your LLM call
sm.extract_memories(answer, importance=0.6) # post-turn: save what was learned
sm.report_outcomes(assembly, outcome="acted") # feedback: reinforce what was used
That is a working memory loop with query-sensitive retrieval on by default.
agent_id is the only required argument; it partitions every index, so two
agents sharing one Redis never see each other's memories.
This is also the configuration the published benchmarks run. If you take nothing else from this page, take this.
The levels below exist for when you want to shape the schema yourself. See the SubconsciousMemory recipe for the default model's exact fields and every knob on the loop.
Level 1: Recall, time-weighted and query-sensitive¶
The smallest useful memory. Records decay over time so recent, important ones
surface first, and a BM25Field makes retrieval respond to the query text
rather than only to the clock.
from popoto import Model, AutoKeyField, KeyField, StringField, FloatField
from popoto import DecayingSortedField, BM25Field
class Memory(Model):
memory_id = AutoKeyField()
agent_id = KeyField()
content = StringField(default="")
importance = FloatField(default=1.0)
relevance = DecayingSortedField(
base_score_field="importance",
partition_by="agent_id",
)
content_bm25 = BM25Field(source="content") # keyword search index
# Save
Memory(agent_id="agent-1", content="Deploy uses blue-green strategy", importance=2.0).save()
Memory(agent_id="agent-1", content="Lunch order: salad", importance=0.5).save()
# Retrieve top memories ranked by recency * importance
results = Memory.query.filter(agent_id="agent-1").top_by_decay(n=5)
for m in results:
print(m.content)
What you get: records that matter surface first, old low-importance ones
fade, and a later ContextAssembler over this model reads the query text.
Why BM25Field is here at Level 1. Without it, ContextAssembler's
retrieval_mode="auto" resolves to the query-blind composite path: query
cues are accepted and then ignored. That is a legitimate mode for some
workloads and the wrong default for most. See
Query-Blind Retrieval for which side you are on.
Adding BM25Field to an existing model: it indexes via the on_save()
hook, so new records are indexed automatically and existing ones need one
re-save:
This is idempotent, so it is safe to run more than once.
Level 2: Attention, filtering noise and tracking reads¶
Add WriteFilterMixin to discard low-value records before they hit Redis, and
AccessTrackerMixin to know which memories the agent actually uses.
from popoto import WriteFilterMixin, AccessTrackerMixin
class Memory(WriteFilterMixin, AccessTrackerMixin, Model):
memory_id = AutoKeyField()
agent_id = KeyField()
content = StringField(default="")
importance = FloatField(default=1.0)
relevance = DecayingSortedField(
base_score_field="importance",
partition_by="agent_id",
)
content_bm25 = BM25Field(source="content")
_wf_min_threshold = 0.1 # below this: silently discarded
_wf_priority_threshold = 0.7 # above this: tagged as priority
def compute_filter_score(self):
return self.importance or 0.0
# Low-value record is silently dropped (save returns False)
result = Memory(agent_id="agent-1", content="noise", importance=0.05).save()
assert result is False
# High-value record persists normally
Memory(agent_id="agent-1", content="critical finding", importance=0.9).save()
# After retrieving, confirm the agent used it
results = Memory.query.filter(agent_id="agent-1").top_by_decay(n=5)
results[0].confirm_access() # marks as actually used
What you get: cleaner memory, since noise never persists. Read tracking shows which memories drive agent behavior.
Level 3: Learning, where outcomes strengthen or weaken beliefs¶
Add ConfidenceField for certainty tracking. Use ObservationProtocol to
report how the agent used each memory: acted on, dismissed, or contradicted.
from popoto import ConfidenceField, ObservationProtocol
class Memory(WriteFilterMixin, AccessTrackerMixin, Model):
memory_id = AutoKeyField()
agent_id = KeyField()
content = StringField(default="")
importance = FloatField(default=1.0)
relevance = DecayingSortedField(
base_score_field="importance",
partition_by="agent_id",
)
confidence = ConfidenceField(initial_confidence=0.5)
content_bm25 = BM25Field(source="content")
_wf_min_threshold = 0.1
_wf_priority_threshold = 0.7
def compute_filter_score(self):
return self.importance or 0.0
m = Memory(agent_id="agent-1", content="API key rotates monthly", importance=0.8)
m.save()
# Corroborate: evidence confirms the belief
ConfidenceField.update_confidence(m, "confidence", signal=0.9)
# Or contradict: evidence weakens the belief
ConfidenceField.update_confidence(m, "confidence", signal=0.1)
# Report agent outcomes in bulk
outcome_map = {m.db_key.redis_key: "acted"} # or "dismissed", "contradicted", "deferred", "used"
ObservationProtocol.on_context_used([m], outcome_map)
What you get: memories the agent acts on grow stronger. Contradicted memories fade. The system learns from outcomes.
Level 4: Association and multi-factor ranking¶
Add CoOccurrenceField for weighted associations between memories. Use
composite_score() to rank by several factors at once.
from popoto import CoOccurrenceField
class Memory(WriteFilterMixin, AccessTrackerMixin, Model):
memory_id = AutoKeyField()
agent_id = KeyField()
content = StringField(default="")
importance = FloatField(default=1.0)
relevance = DecayingSortedField(
base_score_field="importance",
partition_by="agent_id",
)
confidence = ConfidenceField(initial_confidence=0.5)
content_bm25 = BM25Field(source="content")
associations = CoOccurrenceField(symmetric=True, max_edges=50)
_wf_min_threshold = 0.1
_wf_priority_threshold = 0.7
def compute_filter_score(self):
return self.importance or 0.0
m1 = Memory(agent_id="agent-1", content="deploy process", importance=0.8)
m1.save()
m2 = Memory(agent_id="agent-1", content="rollback steps", importance=0.8)
m2.save()
# Link related memories
assoc_field = Memory._meta.fields["associations"]
assoc_field.link(Memory, m1.db_key.redis_key, m2.db_key.redis_key, initial_weight=0.5)
# Multi-factor retrieval: combine relevance with other indexes
results = Memory.query.filter(agent_id="agent-1").composite_score(
indexes={"relevance": 1.0},
limit=10,
)
What you get: memories form a graph. Retrieving one can surface related ones, and multiple ranking factors combine into a single query.
This model now carries the same fields as the shipped DefaultMemory from
Level 0.
Level 5: Cognition, assembling LLM-ready context¶
Use ContextAssembler to orchestrate the primitives into a single assemble()
call returning formatted, token-budgeted context.
from popoto import ContextAssembler
# Use any Memory model from Levels 1-4
assembler = ContextAssembler(
model_class=Memory,
score_weights={"relevance": 1.0}, # benchmarked default; see the note below
max_items=10,
max_tokens=4000,
)
result = assembler.assemble(
query_cues={"topic": "deployment"},
agent_id="agent-1",
)
# result.records — selected model instances
# result.formatted : LLM-ready string
# result.metadata — scores, timing, token counts
# Inject into your LLM prompt
system_prompt = f"You are a helpful assistant.\n\nRelevant context:\n{result.formatted}"
Complete LLM integration example¶
Wire assembled context into an OpenAI SDK v1+ call and report outcomes:
from openai import OpenAI
from popoto import ContextAssembler, ObservationProtocol
client = OpenAI() # uses OPENAI_API_KEY env var
# Assemble memory context
result = assembler.assemble(query_cues={"topic": "deployment"}, agent_id="agent-1")
# Build messages with injected memory
messages = [
{"role": "system", "content": f"You are a helpful assistant.\n\nRelevant context:\n{result.formatted}"},
{"role": "user", "content": "What's our deployment strategy?"},
]
# Call the LLM
response = client.chat.completions.create(
model="gpt-4.1-nano",
messages=messages,
)
answer = response.choices[0].message.content
# Report outcomes — which memories did the agent actually use?
outcome_map = {r.db_key.redis_key: "acted" for r in result.records}
ObservationProtocol.on_context_used(result.records, outcome_map)
What you get: one call assembles the right memories, respects token budgets, and formats output for your LLM. Query-driven and proactive retrieval in a single pipeline.
On score_weights={"relevance": 1.0}: this is the best_value from the
Tier 4 sweep (tests/benchmarks/results/sweep_20260326_125145.json, coding
assistant / research agent / support agent scenarios, 18/18 points OK). How
strong that evidence is: all six swept weight vectors tied at nDCG@5 = 1.0 on
those scenarios, so this is the selected best value and the simplest
single-index vector, not a configuration measured to beat the others. In
lexical and hybrid modes the pull path ignores score_weights entirely.
Wrapping this in a per-turn loop is what
SubconsciousMemory does, which brings you
back to Level 0.
Level 6: Semantic search, finding memories by meaning¶
Add ContentField and EmbeddingField to store large content on the
filesystem and search it by semantic similarity. Redis stays lean; content and
vectors live on disk.
Keep BM25Field when you add EmbeddingField. The two together resolve to
hybrid retrieval, BM25 and vector fused by weighted Reciprocal Rank
Fusion. EmbeddingField alone, without BM25Field, resolves to the
query-blind composite path and is a regression, not an upgrade.
import popoto
from popoto import (
Model, AutoKeyField, KeyField, FloatField,
ContentField, EmbeddingField, DecayingSortedField, ConfidenceField, BM25Field,
)
from popoto.embeddings.voyage import VoyageProvider
# Configure once at startup — sets the default embedding provider
# and content storage path for all fields
popoto.configure(
embedding_provider=VoyageProvider(api_key="your-key"),
content_path="/data/agent-memory",
)
# ----
# Prefer no API keys? Run Ollama locally and swap providers.
# Prerequisite: `ollama pull nomic-embed-text` and `ollama serve`.
#
# from popoto.embeddings.ollama import OllamaProvider
# popoto.configure(
# embedding_provider=OllamaProvider(model="nomic-embed-text"),
# content_path="/data/agent-memory",
# )
# ----
class SemanticMemory(Model):
memory_id = AutoKeyField()
agent_id = KeyField()
content = ContentField() # large text stored on filesystem
importance = FloatField(default=1.0)
relevance = DecayingSortedField(
base_score_field="importance",
partition_by="agent_id",
)
confidence = ConfidenceField(initial_confidence=0.5)
content_bm25 = BM25Field(source="content") # keep this: hybrid needs both arms
embedding = EmbeddingField(source="content") # auto-generates vector on save
# Save memories — embeddings are generated automatically
SemanticMemory.create(
agent_id="agent-1",
content="Q4 revenue exceeded projections by 12%, driven by enterprise deals.",
importance=0.9,
)
SemanticMemory.create(
agent_id="agent-1",
content="Engineering headcount target is 50 by end of year.",
importance=0.7,
)
# Similarity-only search — ranked by cosine similarity to the query
results = SemanticMemory.query.semantic_search("revenue performance", limit=5)
for m in results:
print(m.content[:80])
# Combined search — blends similarity with decay and confidence signals
results = SemanticMemory.query.semantic_search(
"revenue performance",
indexes={"relevance": 0.4, "confidence": 0.3},
limit=5,
)
What you get: memories searchable by meaning through semantic_search(),
and hybrid retrieval through ContextAssembler. On LongMemEval-S, hybrid beats
the lexical baseline on every metric; on LoCoMo's name-anchored questions the
fusion weighting routes the vector arm out of the way so hybrid does not fall
below lexical. Both runs are in Benchmarks.
Install extras:
pip install popoto[voyage]for Voyage AI embeddings, orpip install popoto[openai]for OpenAI. For a no-API-key setup, run Ollama locally and useOllamaProvider(stdlib only, no extras needed). See Content and Embedding Fields for all provider options.
Writing memories: store the turn, do not rewrite it¶
extract_memories() decides what a turn leaves behind. The measured-best write
path is the default: save the raw turn.
LLM fact extraction was measured against raw turn ingestion on the judged-answer harness across three models plus a heuristic sentence splitter, and every extraction arm lost. The heuristic splitter scores 0.21 against raw ingestion's 0.36 on the same 77 scored items; the Claude arms cost more, in proportion to how many turns they discard. Both failure modes are documented in LLM Memory Extraction.
Treat extraction as an opt-in to test against your own corpus, not a default to turn on:
from popoto.extraction.claude import ClaudeExtractionProvider
sm = SubconsciousMemory(
agent_id="agent-1",
extraction_provider=ClaudeExtractionProvider(), # requires popoto[anthropic]
)
Import cheat sheet¶
All imports come from the top-level popoto package:
# Models and fields
from popoto import Model, AutoKeyField, KeyField, StringField, FloatField
from popoto import DecayingSortedField, ConfidenceField, CoOccurrenceField
from popoto import BM25Field
from popoto import ContentField, EmbeddingField
# Mixins (listed before Model in class definition)
from popoto import WriteFilterMixin, AccessTrackerMixin
# Observation and context
from popoto import ObservationProtocol, ContextAssembler
# Constants for tuning
from popoto import InteractionWeight, TemporalPeriod, Defaults
Recipes — including the batteries-included model — live one level down:
Never use from popoto.fields import .... The popoto.fields subpackage
does not re-export field types. Always import from popoto directly.
What's next¶
- SubconsciousMemory Recipe: the per-turn loop from Level 0, with every knob
- Query-Blind Retrieval: when composite ranking is right, and when it costs you the answer
- Agent Memory: all 14 primitives and the layers composed on them
- Benchmarks: the numbers behind the defaults, including the runs that came out badly
- Tuning Magic Numbers: decay rates, confidence signals, and thresholds
- PolicyCache Recipe: RL-style learned action selection on these primitives
- Trajectory Memory Recipe: fingerprint-keyed procedural patterns
- RAG Chatbot Recipe: retrieval-augmented chatbot with Popoto