Skip to content

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

pip install symfonic-core[postgres]

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:

export POSTGRES_DSN="postgresql://symfonic:symfonic_dev@localhost:5432/symfonic"

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:

ANTHROPIC_API_KEY=sk-ant-... python memory_agent.py

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:

psql "$POSTGRES_DSN" -X -v scope_schema=public \
  -f scripts/migrations/core204_c_scope_index.sql

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 limit across 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 MemoryContractError and writes nothing.
  • MemoryAdminService.stage, publish and discard carry the record of the running Agent turn (for example when a tool calls them). They therefore fail with MemoryContractError whenever 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) from symfonic.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 with asyncio.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 with asyncio.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 with loop.call_soon or run_in_executor without 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 through turn_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 (PostgresGraphBackend mutators, 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 history to Agent.run or Agent.stream on such an Agent fails with ContractViolationError (caused by MemoryContractError) 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 own NativeConversationHistory supply the transcript, or pass history=(). 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 MemoryCapability built by hand takes only an exact InMemoryHms or GraphBackedHms as its writer and lifecycle, and writable graph-only PostgreSQL memory not even then: it must be composed by memory_capabilities, which records where each turn starts. The writer memory_capabilities composes 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 its scope with dataclasses.replace keeps it; replacing either of those fails too. A wrapper or subclass fails with MemoryContractError when the capability is built, also when it is the store given to MemoryBundleFactory or memory_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 forget of its scope, and PostgresNativeEraser.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:

  1. On the live source, as memory_nodes' owner, call postgres_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, with to_json/from_json). Take the backup or dump after this, from one consistent snapshot of content and generations.
  2. 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:

  1. 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.
  2. 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.
  3. Keep the receipt key stable across restarts. Rotating it has no supported procedure and refuses every connection.
  4. 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 run eraser.proof.genesis(owner_conn) once. The runtime login needs SELECT and INSERT on memory_erase_log (granted by grant_runtime and bootstrap_graph); the log's trigger functions are not executable by PUBLIC.
  5. 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.
  6. 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). Run ensure_schema() as the tables' owner (or scripts/migrations/core171_conversation_erase_log.sql), grant the runtime SELECT and INSERT on native_conversation_erase_log, and run log.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_ANCHORED otherwise; UNANCHORABLE inside a caller transaction), a physical copy from before a forget refuses BEHIND, another database MOVED, and a name that already holds any lineage refuses genesis. A logical restore re-opens only through postgres_conversation_restore.seal_and_export on the source and validate_restore on 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 UNLOGGED table: 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)
pip install symfonic-core[agent-api]
uvicorn my_app:app --reload

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(...). Bare FrameworkTenantScope(tenant_id="acme-corp") still works but emits a DeprecationWarning (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 by delete_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 (PostgresGraphBackend or PostgresGraphRuntime): 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.