Architecture Overview¶
For the academic foundations behind this architecture, see Research Foundations.
symfonic is organized as a single Python distribution (symfonic-core) that
ships three cooperating subpackages. Each subpackage adds a layer on top of the
one below it, and they share a single dependency injection backbone
(BaseAgentDeps).
symfonic.agent -- orchestration layer (SymfonicAgent, FastAPI bridge)
|
symfonic.memory -- 5-layer Hierarchical Memory System (HMS)
|
symfonic.core -- execution engine (graphs, runtime, tools, streaming)
symfonic.agent depends on both symfonic.core and symfonic.memory.
symfonic.memory is independent of symfonic.core and can be used alone.
symfonic.core has no dependency on the other two subpackages.
Component Diagram¶
+---------------------------+
| SymfonicAgent |
| run() / stream() |
+----+------------------+----+
| |
+----------v------+ +------v-----------+
| AgentRuntime | | MemoryOrchestrator|
| (LangGraph) | | (5-layer HMS) |
+--------+--------+ +---+----+----+----+
| | | |
+--------v--------+ +----v-+ | +--v--------+
| EventTranspiler | |Seman | ... |Procedural|
| ThinkingDelta | |tic | |Prospective
| TextDeltaEvent | |Episod| |Working |
+-----------------+ +--+---+ +-----+----+
| |
+-------------v---------------v--------+
| Backend Layer |
| |
| +------------------+ +------------+ |
| |PostgresGraphBackend| |PostgresVec | |
| | (primary) | |torBackend | |
| +------------------+ +------------+ |
| |
| +------------------+ |
| |MongoGraphBackend | (secondary, |
| | dual-write mirror| graph viz) |
| +------------------+ |
+---------------------------------------+
+---------------------------+
| SleepConsolidator |
| (Deep Sleep engine) |
+----+----------------------+
|
v
phases.py (11 consolidation phases)
quick_nap / run / nightly_nap (v7.0)
Thalamic Architecture (v7.0)¶
Each request flows through four stages: Triage, Hydrate, LLM Execution,
and Consolidate. Every stage has at least one opt-in subsystem that is
off by default so v6.x observable behaviour is preserved at
FrameworkConfig() defaults.
1. Request
User query + FrameworkTenantScope
|
2. Triage (v7.0)
|-- IntentClassifier (deterministic verb+keyword, zero LLM)
| verdict: knowledge / action / ambiguous
| mode: off / observe / enforce (intent_filter_mode)
| emits: ExtensionEvent(type="intent_classified")
|-- apply_tool_routing (v7.0.1)
| narrow Anthropic tools= payload per verdict
| mode: off / observe / enforce (tool_routing_mode)
| emits: ToolRoutingDecisionEvent
'-- always_include_tools pinned regardless of verdict (v7.0.3)
|
3. Hydration (_hydrate)
|-- Retrieve from enabled_layers (default: all 5)
| action + enforce: skip semantic / episodic / procedural
|-- SpreadingActivation.expand()
| |-- 1st-degree neighbours (hydration_max_hops=0, default)
| |-- or N-hop BFS with per-hop decay (v7.0 T08)
| |-- filter by hydration_threshold (v6.1.8)
| |-- cap working-memory raw turns by working_memory_raw_turns
| '-- build activation_log: activated_nodes, inference_paths
|-- stm_summary_mode: off / extractive / llm (v7.0 T09)
|-- _scrub_credential_keys (v6.1.9 / v6.2 tunable)
'-- Format context string with activation metadata
|
4. LLM Execution (AgentRuntime)
|-- Inject HMS system prompt (full, 9 sections, ~1800 tokens)
| OR JIT prompt (<=230 tokens + hydrate_context escape hatch)
|-- TOOL DISCIPLINE directive (v6.1.10) in both templates
|-- DomainTemplate.description rendered (v7.0.2)
|-- LLMStartEvent (engine view) / LLMPreCallEvent (wire view)
|-- EventTranspiler (stream mode):
| |-- MessageStartEvent
| |-- ThinkingDeltaEvent (Anthropic extended thinking)
| |-- TextDeltaEvent (response text chunks)
| |-- ToolCallStartEvent / ToolCallDeltaEvent / ToolResultEvent
| |-- UsageEvent
| '-- MessageEndEvent
'-- LLM generates response + optional MEMORY_EXTRACT block
|
5. Fabrication Check (v7.0, opt-in)
|-- Cross-reference draft identifiers and verb claims against:
| tool results / hydrated context / user message
|-- Finding kinds: uuid / kv / verb_claim / short_hex_near_verb /
| short_hex_near_key
|-- mode: off / observe / revise / refuse (fabrication_check_mode)
| revise: metacog.evaluate() forces revise verdict
| refuse: replace draft with safe envelope
|-- confidence threshold: fabrication_refuse_min_confidence
'-- emits ExtensionEvent(type="fabrication_detected"/"fabrication_blocked")
|
6. Extraction (_parse_and_strip_extraction)
|-- Strategy 1: <MEMORY_EXTRACT>...</MEMORY_EXTRACT> XML blocks
|-- Strategy 2: Loose <GRAPH_OPERATIONS> without closing tag
|-- Strategy 3: Trailing ```json {...} ``` code fence
'-- Strategy 4: Last-resort backward scan for bare JSON
|
7. Consolidation (_consolidate, background task)
|-- Process structured ops by type/layer:
| upsert_node -> Semantic
| create_event -> Episodic
| set_trigger -> Prospective
| register_skill -> Procedural (status=draft if skill_auto_approve=False)
|-- _scrub_credential_keys on payload.properties (v6.1.9)
|-- Mirror to MongoDB (dual-write observer, optional)
|-- Write thinking nodes to Working memory (24h TTL)
|-- Write episodic turn summary + optional telemetry sink
'-- Every Nth turn: quick_nap (Phase 1 + optional Phase 5 + Phase 10)
|
8. Persistence
|-- Postgres (primary): all 5 layers
'-- MongoDB (secondary, optional): semantic nodes for graph visualization
Every capability below the triage stage is also reachable outside the
Thalamic pipeline — direct SDK calls on MemoryOrchestrator.get_layer(...)
or SleepConsolidator.run(scope) bypass the triage stage. The pipeline
diagram describes the end-to-end SymfonicAgent.run() path.
Multi-Tenant Isolation¶
All operations are scoped by TenantScope, a frozen Pydantic model unified
across symfonic.core and symfonic.memory. FrameworkTenantScope is a thin
subclass adding agent-layer validation and the backward-compatible
to_core_scope() / to_memory_scope() converters (now identity — the type is
unified).
v8.0: TenantScope is a hierarchical N-level path. It is an
arbitrary-depth, root-first ordered path of ScopeLevel(kind, id) values (e.g.
org → brand → conversation). The legacy flat fields are retained and computed
off the path:
| Property | Meaning |
|---|---|
path |
tuple[ScopeLevel, ...], root-first (path[0] broadest, path[-1] most-specific) |
tenant_id |
computed: path[0].id (root) — backward-compatible |
sub_tenant_id |
computed: path[1].id (2nd level) — backward-compatible |
namespace |
optional sub-partition, orthogonal to the path |
scope_path |
materialised US-delimited (\x1f), root-first storage string |
Construct through the blessed factories — from_path([...]), root(kind, id),
.child(kind, id), from_request(...), from_state_dict(...). Bare
TenantScope(tenant_id=...) still works but emits a DeprecationWarning and
builds only a 1-/2-level legacy path.
Tenant + hierarchy isolation is enforced at every level:
- Prefix isolation (ALWAYS-ON security boundary): a query at path P sees a
memory at path Q iff Q is a prefix of P, enforced by exact-IN over the
query's ancestor_prefix_paths() on all three backends — never a string
prefix scan. No cross-org, sibling-brand, or sibling-conversation bleed.
- Backend queries filter by scope_path (pre-v8.0 rows dual-read as a 1-level
root path tenant\x1f<tenant_id>).
- Graph nodes carry scope_path (and tenant_id) as fields.
- The HMS system prompt includes the active tenant and namespace.
See hierarchical-tenant-scope.md for the
full model, scope-distance weighting, and the pre-v8.0 row migration.
Subpackage Module Maps¶
symfonic.core¶
symfonic/core/
config.py # AgentConfig, ModelConfig, CompactionConfig
deps.py # BaseAgentDeps -- protocol-based DI container
graph.py # AgentGraph -- node/edge wiring
runtime.py # AgentRuntime -- execution engine
state.py # BaseAgentState -- graph state schema
scope.py # TenantScope -- multi-tenant isolation
providers.py # ModelProvider -- abstract LLM interface
presets.py # GraphPreset -- pre-built graph configurations
callbacks/ # CallbackHandler protocol and manager
streaming/ # EventTranspiler, adapters, stream event types
tools/ # Memory tools, tool implementations
learning/ # SleepConsolidator, consolidation phases
plugins/ # PluginPromptSection
prompt/ # PromptBuilder and composable sections
testing/ # MockModelProvider and test utilities
symfonic.memory¶
symfonic/memory/
types.py # TenantScope, MemoryLayer, ScoringWeights
protocols.py # BaseMemoryStore, VectorBackend, GraphBackend, EmbeddingProvider
backends/ # PostgresGraphBackend, PostgresVectorBackend,
| # MongoGraphBackend, InMemory*, ChromaVectorBackend, pool
layers/ # Five layer implementations (working, episodic, semantic,
| # procedural, prospective)
graph/ # GraphMemoryStore -- entity/relation graph store
models/ # MemoryNode, MemoryEdge, MemoryEntry
orchestrator/ # MemoryOrchestrator facade + OrchestratorConfig
retrieval/ # Scoring engine, SpreadingActivation
compaction/ # Compaction engine (threshold-based and LLM-assisted)
_internal/ # Extraction parsing, LLM helpers
symfonic.agent¶
symfonic/agent/
engine.py # SymfonicAgent -- run() and stream() entry points
config.py # FrameworkConfig -- composes AgentConfig + OrchestratorConfig
factory.py # HMSFactory + MemoryOrchestrator dataclass facade
types.py # FrameworkTenantScope, AgentResponse, StreamChunk
domain.py # DomainTemplate, MonitoringBlueprint
sessions.py # SessionManager for multi-chat
scheduler.py # TaskSchedulerProtocol
observability.py # FrameworkObservabilityHook, LoggingFrameworkHook
fastapi/ # FastAPI router, dependencies, SSE
prompts/ # HMSSystemPromptSection, MemoryExtractionSection
prompts/templates/ # hms_system.txt (9-section system prompt), extraction.txt
Autonomous Learning¶
The agent can learn procedures from its own experience. The pipeline runs across three existing subsystems with a human-in-the-loop review gate:
Episodic Layer SleepConsolidator ProceduralLayer
(every chat turn) --> Phase 12 (skill storage)
promote_episodic_to_procedural()
|
v
draft skill created
|
v
Human Review (admin endpoints)
GET /procedures/drafts
POST /procedures/{id}/approve
POST /procedures/{id}/reject
|
approved?
/ \
Yes No
| |
active skill archived
(agent sees it) (agent never sees it)
|
v
Real-time execution
MetacognitiveMiddleware
checks CorePreferences
before emitting response
Key invariants:
- Phase 12 always stores status="draft". No learned skill is active without
explicit human approval.
- MetacognitiveMiddleware (opt-in) gates real-time register_skill ops
against active CorePreferences.
- SemanticMerge deduplicates against existing skills (threshold: 0.65) so
repeated consolidation runs do not flood the review queue.
- All approve/reject operations emit AuditLogEntry records.
See Autonomous Learning for the full pipeline reference including configuration knobs and the SRE example walkthrough.
Consolidation (Deep Sleep)¶
SleepConsolidator runs 11 distinct phases of graph maintenance. Three
entrypoints:
quick_nap(scope)— lightweight subset (Phase 1 strengthen + optional Phase 5 SOUL + optional Phase 10 episodic). Fires every N turns whenFrameworkConfig.quick_nap_interval > 0.run(scope)— full roster; returns aConsolidationReport.nightly_nap(scope)— v7.0 T12 full-roster entrypoint gated byFrameworkConfig.nightly_nap_enabled=True. Engine does not schedule; operators wire their own scheduler.
Phase list (appended to ConsolidationReport.phases_run when executed):
| # | Name | Effect |
|---|---|---|
| 1 | strengthen |
Boost importance for recurring nodes |
| 1.5 | cooccurrence |
CO_OCCURRED edges between co-temporal nodes |
| 2 | tag_risk |
Mark negative-sentiment nodes |
| 2.5 | pending_edges |
Write inferred edges (conditional) |
| 3 | prune_orphans |
Delete stale orphans; TTL-expired unconditional |
| 4 | meta_nodes |
Cluster raw nodes (excludes META: to prevent cascade) |
| 5 | soul_corrections |
Apply SOUL schema updates (conditional) |
| 8 | working_ttl |
Prune WORKING-layer expired |
| 9 | decay_importance |
Decay stale nodes; skips importance >= 8.0 and SOUL:/AGENT_IDENTITY: |
| 10 | episodic_summary |
Compress old episodes to meta-nodes |
| 11 | synthetic_links |
SYNTHETIC_LINK edges for semantic co-occurrence |
| 12 | procedural_promotion |
Promote episodic patterns to draft skills |
Phases 6 and 7 are reserved slots without bodies — the numbering was preserved from an earlier design to keep downstream references stable.
ConsolidationReport.phases_run lets operators distinguish "phase ran
and found nothing" from "phase was short-circuited". See
Guide 09 for the per-phase
knob mapping and scheduler integration recipes.
Production Limits (v5.5.13)¶
Sprint 4 hardening introduced operator-facing caps to prevent unbounded state growth in long-running multi-tenant deployments. All constants are defined in their respective modules and can be changed by forking the config.
| Constant | Module | Default | Effect |
|---|---|---|---|
MAX_SESSIONS_PER_TENANT |
symfonic.agent.sessions |
10 000 | LRU cap per tenant bucket. On overflow, the oldest 10 % of sessions are evicted. Cross-tenant collisions never leak. |
MAX_CONVERSATIONS |
symfonic.core.observability.metrics |
10 000 | LRU cap on _conversations in ConversationMetricsCollector. call_records per conversation is a deque(maxlen=500). |
max_tokens hydration cap |
OrchestratorConfig.context_budget |
2 000 (fallback) | Post-hydration context is truncated to context_budget.max_tokens (or max_system_prompt_tokens if unset) regardless of JIT mode. Uses tiktoken when available; chars/4 heuristic otherwise. max_memory_entries was removed in v5.6.0 (v6.0 API freeze prep) — hydration is token-budgeted, not count-budgeted. |
_seeded_tenants LRU |
SymfonicAgent |
1 000 | Bounded OrderedDict replacing the prior unbounded set[str]. Prevents memory growth in high-churn tenant environments. |
Budget fan-out exceptions (TokenBudgetTracker.record failures) are now
logged at WARNING with exc_info=True rather than silently swallowed, so
operators can detect misconfigured budget stores without LLM requests being
blocked.
Spreading activation node re-hydration uses asyncio.gather(...,
return_exceptions=True) so one slow graph lookup does not serialize all
neighbors. MCP connections are pooled via a lazily-created httpx.AsyncClient
(closed in close()) to restore TCP keep-alive across RPC calls.
Custom ConversationMetricsCollector subclasses¶
ConversationMetricsCollector.set_tenant(run_id, tenant_id) was introduced
in v5.5.6 to fix a silent failure where TokenBudgetTracker never fired on
real chats (ConversationRecord.tenant_id defaulted to "", gating the
fan-out). SymfonicAgent.run() / stream() / stream_typed() call
set_tenant(run_id, tenant_id) automatically right after generating
run_id, so the in-process collector is wired end-to-end without deployer
action.
If you subclass ConversationMetricsCollector and REPLACE set_tenant
(rather than extending it), budget tracking silently drops requests —
on_llm_end guards the tracker fan-out on conv.tenant_id, and without
the parent call the tenant stays blank. Custom collectors MUST forward the
(run_id, tenant_id) pair to the superclass:
from symfonic.core.observability.metrics import ConversationMetricsCollector
class MyCollector(ConversationMetricsCollector):
def set_tenant(self, run_id: str, tenant_id: str) -> None:
super().set_tenant(run_id, tenant_id) # REQUIRED — do not omit
self._my_extra_logging(run_id, tenant_id)
Omitting the super().set_tenant(...) call does not raise — the engine
uses a hasattr guard for back-compat with pre-5.5.6 custom collectors
that never implemented the method — but multi-replica budget correctness
relies on it. v5.6.1's PostgresBudgetStore depends on the tenant_id
reaching TokenBudgetTracker.record() so upsert_daily writes under the
correct key; a collector that swallows set_tenant produces a cluster-wide
silent zero-usage ledger, and check_budget() will never fire a 429.
Design Principles¶
- Protocol-based DI: All storage and provider access goes through
protocols registered in
BaseAgentDeps. No module imports concrete implementations directly. - Capability filtering: Tools and features are enabled/disabled based on which capabilities are registered, not by configuration flags.
- Composability: Graphs, prompts, and tools are built by composing small, focused pieces rather than inheriting from large base classes.
- InMemory-first: Every protocol has an in-memory reference implementation so the framework runs without external services.
- Type safety:
py.typedmarker, strict mypy, and Pydantic models throughout. - Layered independence:
symfonic.coreknows nothing about memory or agents.symfonic.memoryknows nothing about graphs or runtimes. Onlysymfonic.agentholds the integration logic. - Graceful degradation: Layer retrieval failures, spreading activation errors, and consolidation exceptions are all caught and logged without blocking the primary response path.