Level 2: Persistent Memory¶
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:
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())
Run it:
Stop the script, then run it again. The agent still remembers "friendly and expert" because facts are persisted in Postgres.
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
)
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.
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.