Bi-temporal facts
A fact is a small structured claim about a user, such as
(alice, lives_in, Tokyo), that the agent remembers across
conversations. Facts are what let an agent answer “where does Alice
live?” without searching old transcripts. Because each fact records
when it was true, the agent can also answer “where did Alice live in
February?” after she moves.
Every agent.run() against a real model auto-extracts these
(subject, predicate, object) claims into the fact store,
partitioned by user_id. Facts are typed, validated, and queryable
at any point in time.
What a Fact looks like
from loomflow import Fact
from datetime import datetime, UTC
fact = Fact(
user_id="alice",
subject="alice",
predicate="lives_in",
object="Tokyo",
valid_from=datetime(2026, 1, 15, tzinfo=UTC),
valid_until=None, # currently true
sources=["ep_01J..."],
confidence=0.94,
)| Field | Description |
|---|---|
user_id | Multi-tenant partition key. Every query is scoped by it. |
subject / predicate / object | The triple. Free-form strings. |
valid_from | When this claim started being true. |
valid_until | When it stopped being true. None = currently valid. |
recorded_at | When the fact was learned (defaults to now). |
sources | List of episode IDs the consolidator extracted it from. |
confidence | LLM-reported confidence in [0, 1]. |
Auto-extract pipeline
When auto_extract=True (the default for real network models), every
episode the run saves triggers a Consolidator pass:
- The consolidator reads the new episode.
- One extra LLM call, using the agent’s model but separate from the
conversation, returns a JSON list of
(subject, predicate, object, confidence)tuples. - Each new fact is appended via
memory.facts.append(fact). - Supersession. When a new fact contradicts a stored one whose
validity overlaps it (same user, subject + predicate, different
object), the one that started earlier ends where the later one
begins. This follows valid time, not arrival order (the Zep /
Graphiti rule): a stored fact that started no later than the new
one gets
valid_until= the new fact’svalid_from, while a backfilled older fact (learned after the current one) is stored closed at the current fact’svalid_frominstead of closing it. No interval is ever closed before it opens. Historical facts are not deleted, just closed off. Naive datetimes (on facts and invalid_at=) are read as UTC.
Extraction is best-effort: a model error or malformed JSON is logged
and never breaks the run. It runs in the background, after run()
returns, so the run isn’t slowed by the extra call. To read the facts
a run just produced, wait for it first:
await agent.run("I'm Alice and I live in Tokyo.", user_id="alice")
await agent.aclose() # waits for pending extraction; the agent stays usable
facts = await agent.memory.facts.query(user_id="alice")Querying facts
from datetime import datetime, UTC
# What's true about Alice right now
facts = await agent.memory.facts.query(
user_id="alice", subject="alice", valid_at=datetime.now(UTC),
)
# Facts with a specific predicate (full history, newest first)
facts = await agent.memory.facts.query(
user_id="alice", subject="alice", predicate="lives_in",
)
# Historical query: what did we know on Jan 1, 2026?
facts_at_jan_1 = await agent.memory.facts.query(
user_id="alice",
subject="alice",
valid_at=datetime(2026, 1, 1, tzinfo=UTC),
)valid_at= returns only facts whose [valid_from, valid_until)
range covers the given timestamp. Without it, query() returns every
matching fact, superseded ones included, most recently recorded
first, up to limit (default 10). Pass valid_at=datetime.now(UTC)
when you want only what is true now.
Supersession in action
# Day 1
await agent.run(
"Hi, I'm Alice and I live in Tokyo.",
user_id="alice",
)
# memory.facts now has:
# Fact(subject="alice", predicate="lives_in", object="Tokyo",
# valid_from=2026-01-15, valid_until=None)
# Day 90
await agent.run(
"Update: I moved to Berlin last month.",
user_id="alice",
)
# memory.facts now has:
# Fact(subject="alice", predicate="lives_in", object="Tokyo",
# valid_from=2026-01-15, valid_until=2026-04-10)
# Fact(subject="alice", predicate="lives_in", object="Berlin",
# valid_from=2026-04-10, valid_until=None)Both facts are stored. When facts are recalled into the prompt, a
closed fact carries its end date (... (until 2026-04-10...)), so
asking “where does Alice live?” gets Berlin. A query with
valid_at= set to February 2026 returns only Tokyo.
Manual writes
Auto-extract is not always what you want. For facts ingested from a trusted source (CRM, LDAP, billing system), write them directly:
from datetime import datetime, UTC
from loomflow import Fact
await agent.memory.facts.append(Fact(
user_id="alice",
subject="alice",
predicate="subscription_plan",
object="enterprise",
valid_from=datetime.now(UTC),
confidence=1.0,
sources=[], # not from a chat
))Manual writes go through the same supersession logic. Appending an “enterprise” plan fact when there’s an existing “professional” plan fact closes the old one off.
Disabling auto-extract
from loomflow import Agent
agent = Agent("...", model="gpt-4o", auto_extract=False)Turn it off for cost-sensitive workloads where the extra LLM call per run adds up. See Auto-extract observability.
Backend matrix
Each memory backend ships a paired fact store:
| Memory backend | Fact store |
|---|---|
InMemoryMemory | InMemoryFactStore |
VectorMemory | InMemoryFactStore |
SqliteMemory | SqliteFactStore |
ChromaMemory | ChromaFactStore |
PostgresMemory | PostgresFactStore |
RedisMemory | RedisFactStore |
The bi-temporal contract is identical across all six: supersession,
valid_at queries and multi-tenant scoping all work the same way.
Why bi-temporal? Production systems need to answer “what did the agent believe at time T?” for audit trails, compliance reviews, and debugging. Plain “last value wins” stores can’t do this. Bi-temporal facts are append-only at write time; supersession is a metadata update that preserves history.