Episode vs Fact
Memory holds two different things, with different shapes and lifecycles.
Episode
An Episode is the raw record of one conversation. Created at the
end of every agent.run(), written by memory.remember(episode).
@dataclass
class Episode:
id: str
user_id: str | None
session_id: str | None
messages: list[Message] # the full conversation
summary: str # auto-generated, short
created_at: datetime
metadata: dict[str, Any]What episodes are good for:
- Conversation continuity. When a new run uses the same
session_id, the framework rehydrates the prior episodes’ messages into the message stack. You get coherent multi-turn dialogue across process restarts without writing any state code. - Vector recall,
memory.recall(query, user_id=)returns the top-k episodes most similar to a query. The model sees them as context if relevant. - Audit and debug. Every conversation is in the store; you can read what the agent actually said.
Fact
A Fact is a structured (subject, predicate, object) claim the
framework extracts from episodes. Bi-temporal. Every fact has
valid_from and valid_until.
@dataclass
class Fact:
id: str
user_id: str | None
subject: str # "alice"
predicate: str # "lives_in"
object: str # "Tokyo"
valid_from: datetime # when it started being true
valid_until: datetime | None # None = currently true
source_episode_id: str | None # which episode produced it
confidence: floatWhat facts are good for:
- Recall by structure. “What is Alice’s plan?” maps to
memory.facts.query(user_id="alice", subject="alice", predicate="subscription_plan"). - Historical queries. “What did we know on Jan 1?” maps to
memory.facts.query(..., valid_at=datetime(2026, 1, 1)). - Cheap context. Facts are short. Surfacing the top facts in the system message scales; surfacing all episodes doesn’t.
Auto-extract turns episodes into facts
Every agent.run() against a real network model triggers a small
Consolidator pass that reads the new episode’s messages and emits
JSON (subject, predicate, object, confidence) tuples. Each new
fact goes through bi-temporal supersession. Contradicting an older
claim closes off the older fact’s valid_until.
This is on by default for OpenAIModel / AnthropicModel /
LiteLLMModel. Off for EchoModel / ScriptedModel. Toggle via
Agent(auto_extract=True/False).
When the model sees them
| Source | Where it lands in the message stack |
|---|---|
| Working memory blocks | A “User profile:” section in the system message. |
| Recalled facts | A “Known facts:” section in the system message (auto-extracted). |
| Recent episodes | Vector-recalled and surfaced as a “Relevant past:” section if recall_k > 0. |
| Rehydrated session | Prior turns of the SAME session_id are rebuilt as actual role=user / assistant messages. |
Lifecycles
| Episode | Fact | |
|---|---|---|
| Created at | end of every agent.run() | by Consolidator (auto) or memory.facts.append() (manual) |
| Updated | never (immutable) | supersession (older fact’s valid_until updated) |
| Deleted by | memory.forget(user_id=) | memory.forget(user_id=) |
| Multi-tenant | partitioned by user_id | partitioned by user_id |
| Backend | episode store | fact store (paired with the memory backend) |
When to write facts manually
Auto-extract is great for chat, but for trusted ingestion (CRM, LDAP, billing), write directly:
from loomflow import Fact
from datetime import datetime, UTC
await agent.memory.facts.append(Fact(
user_id="alice",
subject="alice",
predicate="subscription_plan",
object="enterprise",
valid_from=datetime.now(UTC),
confidence=1.0,
source_episode_id=None,
))Manual writes go through the same supersession logic. Appending “enterprise” when there’s an existing “professional” closes the older one off.
Why two stores? Episodes are what was said. Long, free-form,
useful for recall. Facts are what’s true. Short, structured,
useful for querying. Conflating the two would produce a store that’s
either too big to query cheaply or too lossy to recall accurately.
Two stores, one Memory protocol.
Read more
- Memory backends. Five backends, one protocol.
- Bi-temporal facts. Supersession,
valid_atqueries, manual writes. - GDPR ops,
profile()/forget()/export().