Bi-temporal facts
Every agent.run() against a real model auto-extracts structured
(subject, predicate, object) claims into the bi-temporal fact store,
partitioned by user_id. Facts are typed, validated, and queryable
at any historical 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
source_episode_id="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. |
source_episode_id | The episode 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
agent.run() triggers a small Consolidator pass after the run
completes:
- The consolidator reads the new episode’s messages.
- A small LLM call (text-only, separate from the agent’s main model)
is asked to return 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 an existing one
(same subject + predicate, different object), the old fact’s
valid_untilis set to the new fact’svalid_from. Historical facts are not deleted, just closed off.
Querying facts
# Currently-valid facts about Alice
facts = await agent.memory.facts.query(user_id="alice", subject="alice")
# Facts with a specific predicate
facts = await agent.memory.facts.query(
user_id="alice", subject="alice", predicate="lives_in",
)
# Historical query — what did we know on Jan 1, 2026?
from datetime import datetime, UTC
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, only currently-valid
facts (valid_until is None) are returned.
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. Asking “where does Alice live?” gets Berlin
(currently valid). Asking “where did Alice live in February 2026?”
gets Tokyo (valid_at= Feb 2026).
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 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,
source_episode_id=None, # 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
agent = Agent("...", model="gpt-4o", auto_extract=False)For cost-sensitive workloads where the consolidator’s per-run LLM call adds up. See Auto-extract observability.
Backend matrix
Each memory backend ships a paired fact store:
| Memory backend | Fact store |
|---|---|
InMemoryMemory | InMemoryFactStore |
SqliteMemory | SqliteFactStore |
ChromaMemory | ChromaFactStore |
PostgresMemory | PostgresFactStore |
RedisMemory | RedisFactStore |
The bi-temporal contract is identical across all five. Supersession,
valid_at queries, 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.