Skip to Content
MemoryBi-temporal facts

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, )
FieldDescription
user_idMulti-tenant partition key. Every query is scoped by it.
subject / predicate / objectThe triple. Free-form strings.
valid_fromWhen this claim started being true.
valid_untilWhen it stopped being true. None = currently valid.
recorded_atWhen the fact was learned (defaults to now).
sourcesList of episode IDs the consolidator extracted it from.
confidenceLLM-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:

  1. The consolidator reads the new episode.
  2. One extra LLM call, using the agent’s model but separate from the conversation, returns a JSON list of (subject, predicate, object, confidence) tuples.
  3. Each new fact is appended via memory.facts.append(fact).
  4. 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’s valid_from, while a backfilled older fact (learned after the current one) is stored closed at the current fact’s valid_from instead of closing it. No interval is ever closed before it opens. Historical facts are not deleted, just closed off. Naive datetimes (on facts and in valid_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 backendFact store
InMemoryMemoryInMemoryFactStore
VectorMemoryInMemoryFactStore
SqliteMemorySqliteFactStore
ChromaMemoryChromaFactStore
PostgresMemoryPostgresFactStore
RedisMemoryRedisFactStore

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.

Last updated on