Level 2: Persistent Memory¶
Legacy entry point: removed in 11.3.0
This page uses SymfonicAgent and has not been rewritten for
symfonic.Agent. From 11.3.0, SymfonicAgent(...) and
AgentBuilder().build() raise EntryPointRemovedError, a migration error,
before anything is built, with no compatibility shim, so the examples below
that construct either one do not run on 11.3.0 or later.
symfonic-core==9.12.* is the newest public PyPI line on which both still
construct (private 11.0 to 11.2.6 did too), but it predates the 11.x features, and this page may use some
of them; pinning it does not migrate an 11.x application. For new code
start with
The Simple Agent, and migrate existing code with
Guide 22 and
Moving to 11.0.
Give your agent a brain that survives restarts. This level connects symfonic to PostgreSQL with pgvector for the full 5-layer Hierarchical Memory System (HMS).
Prerequisites¶
- Completed Level 1
- Docker (for Postgres)
Install¶
Start Postgres with pgvector¶
docker run -d \
--name symfonic-pg \
-e POSTGRES_USER=symfonic \
-e POSTGRES_PASSWORD=symfonic_dev \
-e POSTGRES_DB=symfonic \
-p 5432:5432 \
pgvector/pgvector:pg16
Set the connection string:
Agent with Persistent Memory¶
Create memory_agent.py:
Legacy 9.12 example: Install symfonic-core==9.12.* for this SymfonicAgent snippet. For version 11, see the Agent migration guide.
import asyncio
import os
from symfonic.agent.config import FrameworkConfig
from symfonic.agent.engine import SymfonicAgent
from symfonic.agent.types import FrameworkTenantScope
from symfonic.core.providers import AnthropicProvider
from symfonic.memory.backends.pool import PostgresPoolManager
from symfonic.memory.backends.postgres_graph import PostgresGraphBackend
from symfonic.memory.backends.postgres_vector import PostgresVectorBackend
class SimpleEmbeddingProvider:
"""Minimal embedding provider for development.
Replace with sentence-transformers or OpenAI embeddings in production.
"""
@property
def dimensions(self) -> int:
return 384
async def embed(self, texts: list[str]) -> list[list[float]]:
results = []
for text in texts:
h = hash(text) & 0xFFFFFFFF
vec = [((h >> (i % 32)) & 1) * 0.1 for i in range(384)]
results.append(vec)
return results
async def main():
dsn = os.environ["POSTGRES_DSN"]
# 1. Create connection pool and backends
pool = PostgresPoolManager(dsn=dsn, min_size=2, max_size=10)
await pool.open()
graph_backend = PostgresGraphBackend(pool)
vector_backend = PostgresVectorBackend(pool)
await graph_backend.ensure_schema()
await vector_backend.ensure_schema()
# 2. Build agent with full HMS
agent = SymfonicAgent(
model_provider=AnthropicProvider(),
embedding_provider=SimpleEmbeddingProvider(),
graph_backend=graph_backend,
vector_backend=vector_backend,
config=FrameworkConfig(
auto_hydrate=True, # inject memory context before LLM
auto_consolidate=True, # extract + commit memories after LLM
spreading_activation=True, # fetch neighbor nodes on semantic hits
enable_hms_prompt=True, # inject HMS system prompt sections
),
)
scope = FrameworkTenantScope(tenant_id="acme-corp")
# 3. First conversation -- agent learns
r1 = await agent.run("Our brand voice is friendly and expert.", scope=scope)
print(f"Turn 1: {r1.final_response}")
# 4. Second conversation -- agent remembers
r2 = await agent.run("What do you know about our brand?", scope=scope)
print(f"Turn 2: {r2.final_response}")
print(f"Memory entries used: {r2.memory_entries_used}")
await pool.close()
asyncio.run(main())
Postgres vector retrieval defaults to search_strategy="exact": it filters to
authorized ancestor scopes before ranking by cosine distance. Returned rows are
bounded by top_k, but database distance work grows with the visible vector
population. Measure that cost for your tenant sizes; a bounded response is not
a constant-cost database query.
For deployments that explicitly accept approximate recall, construct
PostgresVectorBackend(pool, search_strategy="approximate"). This uses the
HNSW index and can return fewer results—or miss a relevant memory—after scope
filtering. It never falls back to an undeclared exact scan. Isolation remains
mandatory in both strategies.
Run it:
Stop the script, then run it again. The agent still remembers "friendly and expert" because facts are persisted in Postgres.
Install the native scope index¶
Native (v1) scope recall on PostgreSQL (GraphBackedHms.retrieve) and the
native subtree reads require the operator-installed index
idx_nodes_scope_path_c. ensure_schema() never creates it. Until a valid
index of that exact shape exists, those reads refuse with RuntimeError("CORE-204 C range index missing
or invalid"). Legacy v0 scopes do not need it.
Run the migration once ensure_schema() has created memory_nodes. The
script lives in the source repository, not in the installed wheel:
Set scope_schema to the schema that holds memory_nodes. Do not pass -1
or wrap the script in a transaction, because CREATE INDEX CONCURRENTLY
refuses to run inside one. The script checks the index shape before and
after the build, and running it again is a no-op. If an index with that name
already exists but is invalid or has a different shape, the script stops and
never drops it. In that case an operator has to recover it.
Native recall also has a row budget. It refuses with MemoryContractError
rather than return a truncated page when the scopes it may read hold more
than 4,096 memories. Without the optional index below, the count includes
every row in those scopes, including rows that recall would never return:
other layers, other sessions' working memory, and pending, retracted or
expired rows.
The native subtree reads (query_subtree on a v1 scope) and subtree forget
have their own budget of 1,024 stored rows per read. Above it they refuse
with MemoryContractError and return or delete nothing. Expired Working
memories count toward this budget, although they are never returned. Both
read each table in the order of its C range index (idx_nodes_scope_path_c
for nodes) with sequential scans, bitmap scans, hash joins and merge joins
disabled for that statement, under custom and generic plans alike.
The budget limits how many index entries a read returns, not how many pages
PostgreSQL visits: dead row versions that VACUUM cannot yet reclaim are
still read.
What these reads guarantee unconditionally is the row budget, a 1 s time
limit per read with server-side cancellation, and the index-ordered plan.
A page bound holds only while the operator keeps two preconditions on the
scanned range. With at most one dead or snapshot-retained version per live
row, and at most two deleted versions per live row since the index was built
or last reindexed, each range-reading statement visits at most 2,114 shared
buffers.
Outside those preconditions no page bound is claimed; the read still
refuses at the time limit rather than return a partial result. A subtree
forget also holds an EXCLUSIVE lock on memory_nodes, memory_edges and
memory_workset_progress for at most its 1 s deadline, plus up to 0.25 s for a
commit still unacknowledged at that point, which stalls writers of every
tenant for that time. See
the scan bound contract.
A subtree forget whose content exceeds 1,024 rows in a table refuses and
changes nothing, unless the operator installed chunked content erasure and
the host composes a PostgresNativeEraser. Then the forget records a durable
job and returns ForgetInProgress; from that moment every read hides the
whole subtree and every write under it refuses, and forgetting the same
scope again erases it in chunks until a LifecycleReceipt with no ids
reports it complete. See
the chunked content erasure rollout.
Count only returnable memories¶
The optional index idx_nodes_candidate_eligible_c makes the 4,096 limit
count only memories that the recall could return. Install it with the same
procedure as the scope index:
psql "$POSTGRES_DSN" -X -v scope_schema=public \
-f scripts/migrations/core199_candidate_eligible_index.sql
With the index installed:
- Pending, retracted and unapproved procedural rows do not count. Neither do other sessions' rows, rows in layers the recall did not ask for, or expired rows.
- Recall with an empty cue returns its exact ranked page even when more than
4,096 memories qualify, for scopes up to 23 levels deep, as long as its
per-seek reads fit the limit. A large
limitacross many scope levels and layers can still refuse; it never returns a partial page. - Recall with a cue still refuses when more than 4,096 memories qualify.
- A memory whose id, scope path, tenant and layer together exceed 2,560 bytes
is kept in the companion index
idx_nodes_candidate_wide, which the same script installs. It is written and recalled like any other memory; recall reads every such memory in the requested scopes and counts each one.
The indexes cost disk (about 150 bytes per row in our measurements) and are
maintained on every write to memory_nodes; neither limits the size of a
write. Recall checks the exact shape of both and ignores either of any other
shape. Dropping either index returns recall to the full row count. See the
eligible candidate index rollout
for the operator procedure.
Writing after an admin forget¶
This applies to graph-only PostgreSQL v1 memory composed by
memory_capabilities, with or without a PostgresConversationLog history.
When MemoryAdminService.forget erases a scope, with or without its
transcript, it records that it did so in the memory database itself, so the
record survives restarts and is seen from every process and pool. Every later
write, flush or discard through a GraphBackedHms into that scope then
has to show that it started after the forget:
- An Agent turn does this for you. It records where it started before it reads
anything, and its own writes carry that record. A turn that started before
the forget fails with
MemoryContractErrorand writes nothing. MemoryAdminService.stage,publishanddiscardcarry the record of the running Agent turn (for example when a tool calls them). They therefore fail withMemoryContractErrorwhenever the scope they write, or a scope above it, was forgotten at a point the turn's own start did not see: after the turn started, or at any time if the forgotten scope is not the turn's own scope or one above it. A forget of any scope below the turn's own scope after the turn started also fails them, whatever they write, as it fails every write the turn makes. This holds for every later call in that turn. Outside any turn, or in a turn that read no memory, they have no record to carry and always fail: they never take a fresh one. The error says the turn read erased state only for the turn's own scope or one above it; for any other scope it says the scope was erased.- A tool or background task that writes through the store must use
turn_writer(store)fromsymfonic.capabilities.memory.turn_generation, called while the turn is running. The writer keeps the turn's record even in a task created with an empty context or one that outlives the run. So if the forget ran after the turn started, its writes are refused. - A bare
store.write(...)with no record is refused in any scope a completed forget covered, and while a forget is still in progress. This is a breaking change from earlier 11.x releases. - Once an Agent is composed over a store, a bare
store.write(...)through it is refused in every scope unless it runs inside a turn (a tool that keeps the turn's context). A task that drops its context, or code holding the store after the turn, cannot itself write what the turn read into a sibling, a parent of a forgotten scope, or the forgotten scope itself. A new Agent turn it starts counts as part of the turn that started the task, so after the forget it is refused too. That holds for tasks created withasyncio.create_task(with or without an empty context) and for a coroutine the turn's own task runs by hand in an empty context. A task created while the event loop's task factory was replaced, or constructed directly withasyncio.Task, counts as started by every turn that may have been running when it was created, so a forget of any of their scopes refuses it too; keep the default task factory, or chain to it, if that is too broad. It does not hold for a task or event loop that already existed before the turn and receives the turn's data through a queue, a global, or a callback, for work scheduled withloop.call_soonorrun_in_executorwithout the turn's context, for other threads running their own event loop, or for other processes: a turn started there is new as far as the fence can tell and may write what it is handed. The exception is a turn that names where it came from: a run started with a parent or root run id (through a delegation runner, for instance) that is no longer running in this process cannot show which turns it descends from, so on such memory it is refused before the model runs, with or without a forget, and so is every write made inside it, whatever scope it targets. Start that work from a running turn, or name no parent. This is also breaking: write throughturn_writer(store), or seed the store before you compose the Agent. - Forgetting a scope stops background work that a turn reading that scope
started from writing anywhere, not only into the forgotten scope. A worker
started from a turn in scope A with that turn's
turn_writer(store)is refused on every write after a forget of A completes, including writes to an unrelated scope B the forget did not touch: writes through that worker's bound writer, and through new turns it or its descendant tasks start, cannot carry what it read from A into B. Other pool objects and raw pathways (PostgresGraphBackendmutators, raw SQL) are outside this contract. It stays refused: restart the worker from a new turn. - A turn whose scope has more than 1024 forgotten scopes below it refuses to start, and a write whose turn crosses that number after it started is refused: the check never reads fewer than it must.
- Passing
historytoAgent.runorAgent.streamon such an Agent fails withContractViolationError(caused byMemoryContractError) before the model, a tool or a write runs: a transcript the caller holds says nothing about which forgets it predates. Let the composition's ownNativeConversationHistorysupply the transcript, or passhistory=(). A host that hands such an Agent a transcript from its own conversation store is refused the same way. This is breaking. - On a kind-aware scope, a
MemoryCapabilitybuilt by hand takes only an exactInMemoryHmsorGraphBackedHmsas its writer and lifecycle, and writable graph-only PostgreSQL memory not even then: it must be composed bymemory_capabilities, which records where each turn starts. The writermemory_capabilitiescomposes is accepted only with the recall it was composed with; taking it out and setting it beside your own recall fails too, because the record covers only what the composition itself reads. Replacing a field other than its recall (hydrator) or itsscopewithdataclasses.replacekeeps it; replacing either of those fails too. A wrapper or subclass fails withMemoryContractErrorwhen the capability is built, also when it is the store given toMemoryBundleFactoryormemory_capabilities. This is breaking. - A turn keeps its own record however its stream is consumed: stepped from different tasks, or alternately with another turn from one task. Between steps the consuming code carries neither turn's record.
Bare writes through a store that no Agent was composed over in this process, through another pool object, or from another process are checked only against the scope they write and the scopes above it. A forget covers the forgotten scope and everything below it, not the scopes above it, so such a write to a parent of a forgotten scope is accepted. Do not treat the fence as covering writes outside the store an Agent was composed over.
These rules apply only to GraphBackedHms. Writing through
PostgresGraphBackend methods such as create_node, or running your own SQL
on the pool, skips them. Deleting a
single record with delete_record records nothing.
On graph-only PostgreSQL v1 with an attested eraser, delete_record(scope,
record_id) (directly or through MemoryAdminService) deletes only a
published record stored at exactly scope. It returns None for a record
that belongs to an ancestor, a descendant, another kind or another tenant, for
a pending record, and for a row with a positional or missing scope. It also
deletes that record's claim history and the edges touching it that its own
scope, a scope above it or a scope below it owns; an edge a sibling scope, a
positional scope or no scope owns stays. A record that still exists inside a
scope a forget is erasing refuses; one the forget already removed returns
None. A delete is not a forget: it advances no erase generation, so an Agent
turn that recalled the record before the delete can still write its content
back afterwards. Use forget when content must stay out. Without an attested
eraser it refuses as before.
The record is the memory_erase_generations table. Install it with
scripts/migrations/core199_memory_erase_generations.sql, connected as the
role that owns memory_nodes (or as a member of it, with
PGOPTIONS='-c role=<owner>'), then re-run your role setup before starting
any runtime. Do not run it as a superuser or any other role that does not own
memory_nodes: the script refuses. The table and its trigger function belong
to the role that creates them, and every reader refuses unless both are owned
by the owner of memory_nodes. Until the role setup runs, every turn, memory
write and forget refuses (fails closed). Without the table, Agent turns over
this memory refuse to start, the eraser does not attest (so writes and forgets
refuse), and a native forget refuses with MemoryContractError before it
deletes anything, creates an erase job or records a transcript erase intent.
Install it before the eraser's role setup, which now requires it.
Dropping the table later is operator DDL; Symfonic never drops it:
- A forget that is already deleting rolls back.
- An erase job in progress stalls. Each further
forgetof its scope, andPostgresNativeEraser.resume(job_id), refuses until the table is back. The job's claims stay fenced meanwhile. A claim-only job's content may already be deleted; a chunked-content job keeps its remaining content stored, hidden from reads and fenced against writes, until it completes. - A joint forget whose transcript lives on another pool may already have committed its erase intent. The transcript then stays fenced and memory is unchanged, exactly as after a crash between those two steps. Reinstall the table, re-run the eraser's role setup, and repeat the forget to finish.
- A turn that recalled memory before the drop and is still running refuses
its memory writes after the repair, with the stale-turn error, and so does
any writer it handed out (
turn_writer), whatever scope it writes. Its admission is bound to the table's install witness, not only to the table's rows: the transaction ID (xid8) of the transaction that installed the table, which PostgreSQL documents as not wrapping during an installation's life. A table dropped and reinstalled by the script gets a new witness even when PostgreSQL gives it the old table's OID (observed on PostgreSQL 16). The check against the catalog rebuilds the trigger row's transaction ID to 64 bits (see below). Turns that start after the repair run normally. This binding covers the memory generation table only, and only processes running this release.
The install witness is the forward row trigger's arguments, written by the
script in the installing transaction and checked against that trigger's
catalog row. Run the script in one transaction (-1) and outside any
savepoint. Re-running it while the witness binds keeps every generation row
and mints a new witness; running turns then refuse their later memory writes.
A table installed by an earlier release has no witness: readers refuse
(UNWITNESSED) until you re-run the script once when you upgrade.
The witness is two arguments: the installing transaction's xid8 and the
cluster's system_identifier. A reader rebuilds the trigger row's 32-bit
xmin to 64 bits and requires both to match, so a copied trigger -- a dump
restored into this cluster or another, or pg_upgrade -- never binds. The
cost is a horizon: 2^32 transactions after the install even the original no
longer binds. Re-run the script before then (it refreshes a witness that
still binds; check the age with
SELECT pg_current_xact_id()::text::numeric - <first trigger argument>).
"Readers" here are the generation-table readers: Agent turn admission and
the erase gates. A direct backend read outside a turn (get_node,
query_subtree) does not check the witness yet; that coverage is pending.
Every mint also marks the table v2 in its comment, which the previous release's script keeps and a dump carries. On a marked table, the script refuses a witness that does not bind whatever its argument count, so running the previous release's script first does not re-open a restore. An unmarked table with a no-argument trigger (an earlier release, or a backup from before this format) is still minted; telling those apart needs proof kept outside the database.
The script refuses to re-mint a witness that no longer binds, whatever the cause: a restore, a copy, a seal, a trigger re-enabled in a later transaction, an install inside a savepoint, or the horizon. To restore:
- On the live source, as
memory_nodes' owner, callpostgres_memory_restore.seal_and_export(conn, proof=eraser.proof). Its witness must still bind, and the erase anchor (below) must hold this database at its erase-log head: acknowledge every forget first. It seals the table -- every reader and every write refuses from then on -- and returns every generation row (GenerationExport, withto_json/from_json). Take the backup or dump after this, from one consistent snapshot of content and generations. - On the restored database, as its owner, call
validate_restore(conn, export, proof=eraser.proof). Its row trigger must carry this export's seal, or its source's witness for a snapshot from before the seal; an export from another lineage refuses. Its rows must equal the export's: a database restored from before a forget refuses and stays closed, because it holds the content that forget erased. Its erase log must end at the export's seq with the anchored digest. On success it mints a new witness and moves the anchor to this database, once: the sealed source, a second restore of the same export, and an export the anchor has since moved past all refuse. Re-run the role setup.
Turns paused before either step refuse their later writes. A table re-enabled in a later transaction, installed inside a savepoint, or past the horizon cannot be a source, and this release has no procedure to re-open it. Restoring content and generations from different snapshots is not supported.
The erase anchor (11.3.0, required)¶
This section requires 11.3.0 or later. The 9.12 SymfonicAgent example
above does not include the erase anchor.
A physical copy (base backup, storage snapshot, point-in-time recovery, a
promoted replica) is byte-identical to the live cluster, so nothing inside it
shows that it lacks a forget. Since 11.3.0 every generation advance appends a
row to a serialized, hash-chained erase log in the same transaction
(scripts/migrations/core171_memory_erase_log.sql), and the log's head is
kept in an anchor database outside the memory cluster
(scripts/migrations/core171_erase_anchor.sql). Compose it on the eraser:
anchor = PostgresEraseAnchor(anchor_pool, memory="prod-memory")
eraser = PostgresNativeEraser(eraser_pool, schema="public", receipt_key=key,
erase_anchor=anchor)
Without erase_anchor= the eraser refuses to build (EraseAnchorRequired).
memory= is required: a stable name, unique per memory database, under which
the anchor keeps that database's lineage. Two memory databases may share one
anchor database and the same schema name only under different names. The
anchor records which database holds the name (cluster system_identifier,
database OID and name); any other database refuses until a validated
restore moves it. A PostgresGraphBackend built without an anchored eraser
refuses every read once the erase generations are installed.
Every connection the runtime, eraser and composed conversation pools hand out
checks first that this database holds the anchor and every forget it
records; otherwise it refuses before any read, tool or model call. A forget
returns its receipt only after the anchor has recorded it. If the anchor
write fails after the erase commits, the forget raises NOT_ANCHORED: it is
not acknowledged until a later forget or resume anchors it.
Operator contract. These are prerequisites; Core cannot check them:
- The anchor database lives in a different PostgreSQL cluster. Core refuses
one in the same cluster. Grant its login SELECT, INSERT and UPDATE on
memory_erase_anchor. - Never restore the anchor from a backup. Rolling it back would admit a memory copy that lacks later forgets. Losing it closes memory: there is no re-genesis under an anchored name.
- Keep the receipt key stable across restarts. Rotating it has no supported procedure and refuses every connection.
- Upgrade every process to 11.3.0 before running the log migration. The
log refuses an earlier release's forget at commit, but an earlier
release's reads are not fenced. Then grant the runtime and eraser logins
SELECT and INSERT on
memory_erase_log, re-run the eraser role setup, and runeraser.proof.genesis(owner_conn)once. The runtime login needs SELECT and INSERT onmemory_erase_log(granted bygrant_runtimeandbootstrap_graph); the log's trigger functions are not executable by PUBLIC. - A conversation log is admitted beside raw PostgreSQL v1 memory only in the same database as memory: its pool gets the same check when an Agent is composed with it or when an admin forget reaches it.
- A conversation log used on its own (any other memory store) carries its
own anchor, under a name unique to its database:
PostgresConversationLog(pool, erase_anchor=PostgresEraseAnchor(anchor_pool, memory="prod-conversation"), receipt_key=key). Runensure_schema()as the tables' owner (orscripts/migrations/core171_conversation_erase_log.sql), grant the runtime SELECT and INSERT onnative_conversation_erase_log, and runlog.proof.genesis(owner_conn)once; the anchor login also needs UPDATE (genesis locks the anchor table). Every checkout then proves the anchored lineage, every forget is logged and returns only once the anchor recorded it (NOT_ANCHOREDotherwise;UNANCHORABLEinside a caller transaction), a physical copy from before a forget refusesBEHIND, another databaseMOVED, and a name that already holds any lineage refuses genesis. A logical restore re-opens only throughpostgres_conversation_restore.seal_and_exporton the source andvalidate_restoreon the copy. A standalone log without an anchor refuses every read and write once its erase generations are installed. A standalone turn captures the log's erase generations before it reads, and its final publication re-proves them, so a turn that read before an acknowledged forget refuses to publish after it; a recovered or caller-supplied transcript refuses before the model. Its memory writes into a non-PostgreSQL store are not fenced (CORE-199 P3-4).
Not covered: anchor rollback, tamper by the owner or a superuser, mixed-snapshot restores, and forgets that were not acknowledged. Forgets on one memory database run one at a time.
The table's forward-only triggers, not the grants, stop a runtime or eraser
from lowering, deleting or resetting a generation. They are ENABLE ALWAYS,
so session_replication_role=replica still fires them. Every reader checks
the table each time and refuses if it finds:
- a trigger that is missing, not enabled always, or changed
- an extra trigger, a rewrite rule or row security
- an inheritance child or parent
- an
UNLOGGEDtable: crash recovery empties it, which would reopen every forgotten scope - a column, key or constraint that differs from the shipped definition
- a table or trigger function not owned by the owner of
memory_nodes
PostgreSQL 16 is the supported server version for this table. Readers compare its constraints to the text PostgreSQL 16 produces for them, so on another major version they may refuse a correctly installed table (they fail closed).
These checks have a limit. The owner of memory_nodes, or a superuser, can
still tamper with the table, and nothing here can prevent it. A lasting change
of the kinds above is caught, but a trigger disabled and re-enabled in the
same transaction is not: that transaction's own writes pass unchecked. After
it commits, readers refuse until the script is re-run. Treat the owner and superusers as trusted, and keep
both away from the runtime and eraser logins.
Restart every process that uses this memory database on the new release before relying on these rules: a process from an older release does not read the table. Do not drop the table to roll back: turns would refuse, but writes outside a turn into forgotten scopes would be accepted again.
How HMS Works¶
The key constructor parameters that enable memory:
| Parameter | Effect |
|---|---|
enable_hms_prompt=True |
Injects system prompt sections instructing the LLM to emit <GRAPH_OPERATIONS> blocks |
auto_hydrate=True |
Before each LLM call, reads relevant memory from all enabled layers into the system prompt |
auto_consolidate=True |
After each LLM call, parses extraction blocks and commits new facts to the graph |
spreading_activation=True |
When semantic retrieval hits, automatically fetches 1st-degree neighbor nodes |
The 5 Memory Layers¶
HMS organizes memory into 5 cognitive layers, each backed by a different storage strategy:
| Layer | Purpose | Backend | Example |
|---|---|---|---|
| Semantic | Long-term knowledge graph (facts, entities, relationships) | Graph | "Acme's brand voice is friendly" |
| Episodic | Past interaction records (vector-indexed similarity search) | Vector | "On March 15, user asked about pricing" |
| Working | Short-term context for the current conversation | Graph | Current session state |
| Procedural | Learned workflows and step-by-step procedures | Graph | "To generate a report: 1. Pull data 2. Chart 3. PDF" |
| Prospective | Future goals and intentions | Graph | "User wants to launch in Q3" |
Enable or disable layers selectively:
config = FrameworkConfig(
enabled_layers={"semantic", "episodic", "working"}, # skip procedural + prospective
)
Prospective triggers on a native Agent¶
prospective_trigger_capabilities(source, evaluator, scope) evaluates each
stored condition against the turn and offers only a satisfied trigger's task to
the prompt. The default RetrievalPolicy searches every layer, so the same
reminder is also recalled as bare [prospective] text whatever its condition
says. To let the trigger stage alone decide, compose recall with
recall_policy_for:
from symfonic.capabilities.memory import (
GraphBackedHms, GraphPendingTriggers, memory_capabilities,
prospective_trigger_capabilities, recall_policy_for,
)
triggers = prospective_trigger_capabilities(GraphPendingTriggers(graph), evaluator, scope)
capabilities = [
*memory_capabilities(GraphBackedHms(graph), scope,
retrieval_policy=recall_policy_for(triggers)),
*triggers,
]
recall_policy_for returns your policy unchanged when no trigger capability is
composed, and otherwise removes only the prospective layer from it. It is
opt-in: nothing applies it for you, the generated scaffold does not, and the
default recall is unchanged. A custom activation is not bounded by it. The
evaluator is yours; none ships. Selection does not resolve a trigger.
Both POST /memories/prospective routes now store the trigger's kind on the
node, as the merged value the response echoes (caller metadata wins over the
body field; an explicit null is stored as ""). No selection or recall
logic reads it: the trigger stage evaluates every pending trigger as before,
and entity resolution never treats a prospective row as an entity. The create
response leaves kind out of properties. Responses that return a node's
stored properties do include it for a row created from now on (an older row
has none): the SymfonicAgent FastAPI GET /memories list, its
/tenants/me/export, and the MemoryNodeAdminService update response. The
native transport's memory list, record read and export return record bodies
without stored properties, so kind does not appear there. Rows created
before this change are not backfilled.
Querying Memory Directly: label_prefix¶
The graph store supports prefix matching on node labels. This is essential
when labels follow the "PREFIX: detail" convention used by HMS:
from symfonic.memory.graph.store import GraphMemoryStore
from symfonic.memory.types import MemoryLayer
# Access the graph store from the agent's orchestrator
graph_store = agent.orchestrator.graph_store
# Exact match -- only returns nodes labeled exactly "SOUL"
exact = await graph_store.query_nodes(scope, label="SOUL")
# Prefix match -- returns "SOUL: Joy", "SOUL: name: Amiel", etc.
prefixed = await graph_store.query_nodes(scope, label_prefix="SOUL")
# Combine with layer filter
semantic_soul = await graph_store.query_nodes(
scope,
layer=MemoryLayer.SEMANTIC,
label_prefix="SOUL",
)
All three backends (Postgres, InMemory, MongoDB) implement prefix matching safely with injection-proof queries.
Verify Memory with the FastAPI Bridge¶
Wrap your agent in HTTP endpoints to inspect memory state:
from fastapi import FastAPI
from symfonic.agent.fastapi.router import create_agent_router
app = FastAPI(title="Memory Agent")
router = create_agent_router(agent, prefix="/api/v1")
app.include_router(router)
Then query the memory status:
# Chat with the agent
curl -X POST http://localhost:8000/api/v1/chat \
-H "Content-Type: application/json" \
-H "X-Tenant-ID: acme-corp" \
-d '{"query": "What is our brand voice?", "tenant_id": "acme-corp"}'
# Check memory status
curl http://localhost:8000/api/v1/memories/status \
-H "X-Tenant-ID: acme-corp"
The /memories/status endpoint returns counts per layer, total nodes, and
the last consolidation timestamp.
Multi-Tenant Isolation¶
Every memory operation requires a FrameworkTenantScope. Tenants never
see each other's data:
acme = FrameworkTenantScope.root("tenant", "acme-corp")
globex = FrameworkTenantScope.root("tenant", "globex-inc")
# These operate on completely separate memory graphs
await agent.run("Our brand is premium.", scope=acme)
await agent.run("Our brand is budget-friendly.", scope=globex)
# Acme's agent knows "premium", not "budget-friendly"
r = await agent.run("What's our brand positioning?", scope=acme)
Construction: prefer the factories —
root(kind, id),.child(kind, id),from_path([...]),from_state_dict(...). BareFrameworkTenantScope(tenant_id="acme-corp")still works but emits aDeprecationWarning(v8.0) and can only express a 1-/2-level scope.
Multi-level scoping (v8.0)¶
TenantScope is a root-first path of arbitrary depth. Model real
hierarchies — e.g. org → brand → conversation — directly:
scope = (
FrameworkTenantScope.root("org", "acme")
.child("brand", "myhalos")
.child("conversation", "c-123")
)
A query at the conversation tip sees memories at its own path and every
ancestor prefix (its conversation, its brand, its org) — and NEVER a sibling
conversation or sibling brand. This prefix isolation is an always-on security
boundary. With FrameworkConfig(scope_blend_mode="blend"), ancestor memories
are blended into the result set, down-weighted by hierarchy distance (tip ×1.0,
parent ×0.5, grandparent ×0.25). See
Hierarchical TenantScope for the
full model.
Rows written under positional scopes¶
Before kind-aware scopes, memory rows were stored under a positional
tenant/sub_tenant/session key that dropped your kinds, so org → brand and
org → team with equal IDs shared one key. Kind-aware agents never read those
rows: there is no fallback that reads positional rows into a kind-aware scope,
so this migration is the only way one becomes visible there. Any part of your
app still composed with positional scopes (the default scaffold, for example)
keeps writing positional rows, and each is a candidate for your next plan; a
plan covers only the rows present when it was made. To move them, declare every kind path your app used at each depth; only
rows whose owner that identifies exactly migrate, and the rest are quarantined
until you delete them by name:
from symfonic.capabilities.memory.positional_migration import (
apply_positional_migration,
plan_positional_migration,
)
from symfonic.capabilities.memory.positional_rows import PositionalOwnerDeclaration
declared = PositionalOwnerDeclaration(
kinds_by_depth={1: (("org",),), 2: (("org", "brand"),)},
max_written_depth=2, # the deepest scope ever used with this store
)
plan = await plan_positional_migration(store, "acme", declared) # changes nothing
print(plan.counts()) # e.g. {"migrate": 40, "quarantine": 2}
await apply_positional_migration(store, plan, declared, audit=audit_logger, actor="ops:jane")
Apply, rollback and deletion refuse without an audit logger: pass audit=,
for example get_audit_logger() from symfonic.core.observability.audit
when your app registered its durable logger there. Before any row changes, each records the tenant, every
row's id, previous owner and the reason it was moved or deleted, never the
memory's text; if that record fails, nothing changes. The actor you pass is
recorded as given, not verified.
rollback_positional_migration(store, plan, audit=audit_logger) undoes a
plan that only quarantined rows, and only where the store can prove that
doing so brings back nothing a forget was asked to erase. No forget reaches a
quarantined row, so a forget that ran after apply (of the row's positional
scope, of a kind-aware owner, or anything else) found nothing to erase.
Restoring the row would serve that content again on the positional reads.
Rollback therefore refuses, changing nothing and recording nothing, unless
the store proves otherwise:
- In-process graph store: the store records each row its apply
quarantined and counts every forget and delete in the tenant, whether or
not it found anything. Rollback restores a row only if this store's apply
recorded it with that exact origin, nothing has been forgotten or deleted
in the tenant since, and no recorded kind-aware forget covers an owner the
row could have mapped to. Anything else refuses with
RESTORE_UNPROVEN. - PostgreSQL and Mongo: rollback always refuses (
RESTORE_UNPROVABLE). A positional forget there records nothing, and on PostgreSQL neither does a forget from an earlier release, so no store record can rule one out. Quarantined rows leave only bydelete_quarantined_rows.
Run apply and rollback on their own, never inside a transaction you opened
on the in-process graph (graph.transaction()); there both refuse with
CALLER_TRANSACTION before recording or changing anything. The store's
lock admits every task that joined your transaction, so a forget from such a
task, or from your audit logger, could run after the store's check and
before the re-key: rollback would then restore, and apply would migrate,
what that forget erased. PostgreSQL rollback already refuses and Mongo does
not let a transaction nest, so neither needs this refusal.
A restored row is an ordinary positional row again. Every positional read
serves it (the in-process store's v0 retrieve included), and it no longer
appears in list_quarantined_rows, so re-plan before paging that list for a
tenant erasure. Rollback also refuses while any of the rows holds a write
your app has not flushed yet: flush or discard it first.
A migration that moved any row to a kind-aware owner cannot be rolled back.
Rollback refuses it with ROLLBACK_UNPROVABLE before reading anything, on
every store. Nothing the store or the audit logger keeps proves that a row
now under a kind-aware owner is one the migration moved there, rather than
one your app wrote there since. Moving such a row to a positional key would
take it out of reach of that owner's forget, and on the in-process store it
would serve it to positional reads after that forget. Review the plan before
you apply it; a move is one-way.
Pass apply and rollback the plan exactly as plan_positional_migration
returned it. A plan is a plain value you can edit, so neither step trusts it.
Both refuse, before recording or changing anything, a plan that holds
anything but plain values (a str subclass, for example) or whose rows no
longer match its digest. The digest is computed from the rows, so a matching
one does not prove the planner produced them. Apply therefore moves only the
rows it re-reads from the store, never the ones in your plan, and refuses
unless the two are identical. Rollback refuses a row whose target is not the
quarantine key of its stated origin, or whose origin is itself a kind-aware
or quarantine key.
delete_quarantined_rows(store, "acme", (...)) is the only way a quarantined
row is removed: no forget, in either scope language, reaches one. When you
erase a whole tenant, page list_quarantined_rows(store, "acme") into
delete_quarantined_rows until it comes back empty. Deleting a row also
deletes its quarantined links; if that would exceed 4096 rows, the deletion
refuses and you delete some links by name first.
Where it runs:
- In-process graph store: plan, apply, rollback (of quarantine-only plans, where provable) and cleanup.
- PostgreSQL (
PostgresGraphBackendorPostgresGraphRuntime): plan, apply and cleanup; rollback refuses. No row ever moves to a kind-aware owner: a plan that would move one refuses (see below), so only a plan that quarantines every row it touches runs. Each step is one transaction that waits for any running forget, and apply moves every copy of a row's owner or none of them. - Mongo (the guarded session domain): plan, apply and cleanup; rollback refuses. Each step is one transaction of the tenant's guard, so it and a forget of that tenant never interleave; apply moves every copy of a row's owner, its vector copies included, or none of them. Apply also refuses, changing nothing, when the Mongo forget below would refuse the tenant afterwards because of its limits: more than 1024 memories or links in the tenant, more than 1024 vectors in the database, or a row whose owner it cannot read. Before apply, the old forget erases those rows; after it, only that forget would.
A row is never moved under an owner whose erase was recorded: if you
forgot org:acme/brand:b and an old row maps to that scope or below it, the
plan quarantines it with the reason owner erased by a native forget
instead of bringing the erased memory back. A forget is recorded on the
in-process store by every subtree erase, and on Mongo by the admin forget
below. PostgreSQL records a forget in memory_erase_generations only if it
ran after scripts/migrations/core199_memory_erase_generations.sql, from a
release that writes that table; an earlier forget left no record, and
nothing in the database reconstructs one. A migration therefore cannot prove
on PostgreSQL that an old row's owner was never erased, so any plan that
would move a row there refuses with ERASE_UNPROVABLE, whether or not that
table is installed. No declaration, argument or edited plan overrides it.
Old rows stay under their positional keys, where the old
forget still reaches them. Only a plan that quarantines every row it touches
runs, and quarantine cleanup still runs. A tenant with even one row whose
owner your declaration identifies cannot plan on PostgreSQL at all, so it
cannot quarantine its ambiguous rows either; the migration does not yet
serve that case.
Kind-aware Mongo memory is forgotten through the admin service composed
with the same operation journal its Agent uses:
MemoryAdminService(store, operation_journal=journal).forget(scope) erases
the scope and every scope below it in one transaction, and records the
erase. Composed with a conversation log it refuses, because the transcript
cannot join that transaction. A failure inside the transaction raises
MemoryOperationOutcomeUnknown. journal.outcome(error.operation) then
answers COMMITTED, which proves the forget committed, or UNKNOWN, which
never proves it did not. Either way a new forget of the scope is safe.
It erases nothing while the database holds a memory row with no owner or an
unreadable one (old rows a migration has not yet moved or quarantined), or
more than 1024 memories or links in the tenant, or more than 1024 vectors in
the whole database: it inspects every one before erasing any. It also
refuses while a link outside the forgotten scope touches a memory it would
erase. A quarantined link is outside every scope, so migration apply refuses
(QUARANTINED_LINK, naming the links, changing nothing) while one would
remain touching a memory the forget can erase and the plan moves either of
them. Delete a link quarantined by an earlier migration with
delete_quarantined_rows first. A link this plan quarantines is not
quarantined yet, so delete_quarantined_rows cannot reach it: pass it as
apply_positional_migration(..., delete_quarantined_edges=("link-id",)) to
delete it, audited, in the same transaction right after the re-key. Apply
does not check a settled link from another scope; that refusal depends on
the scope a later forget names. That inspection
runs inside the transaction, so these refusals also surface as
MemoryOperationOutcomeUnknown, not as MemoryContractError.
A migration refuses, changing nothing, while the tenant has unflushed rows, a running consolidation (a live lease of that tenant) or erase, or more rows than one transaction holds (4096 on PostgreSQL, 1024 on Mongo). On PostgreSQL it also refuses while the tenant has old vector rows: kind-aware PostgreSQL memory has no vector index, so no forget would reach them. Delete those vectors first; they can be rebuilt from the rows.
HTTP routes and kind-aware agents¶
The kernel router cannot tell which scope your agent stores memory under: from the same request scope, a kind-aware agent keeps kinds while the default scaffold converts to positional scopes. Tell it:
from symfonic.capabilities.memory import as_native_memory_scope
router = create_kernel_agent_router(host, memory=admin, memory_identity=as_native_memory_scope)
Listing memories, and reading or deleting one, then reach the rows your agent
wrote. Routes that still read positional storage answer 501 instead of showing rows your
agent never sees. Without memory_identity, every route stays positional.
memory_identity receives the request's scope: the X-Tenant-ID scope when
the router has no scope_resolver, or the resolver's subject scope when it
has one. as_native_memory_scope only reads the first, so with a resolver
write a function over the scope it passes (it has tenant_id). A
declaration that fails, returns something other than a MemoryScope, or
names another tenant gets a 500 with a fixed message, and nothing is read.
Next Steps¶
Your agent has persistent memory. Level 3 shows how to stream responses in real-time and observe the full execution lifecycle.
Next: Streaming & Callbacks
See also:
- Feature Catalog § Memory (5-Pentad) —
the five SDK methods + five POST /api/v1/memories/{layer} endpoints
(v6.1.4).
- Feature Catalog § Retrieval and Hydration —
spreading activation, N-hop BFS, credential scrubbing.