Skip to content

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 when FrameworkConfig.quick_nap_interval > 0.
  • run(scope) — full roster; returns a ConsolidationReport.
  • nightly_nap(scope) — v7.0 T12 full-roster entrypoint gated by FrameworkConfig.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

  1. Protocol-based DI: All storage and provider access goes through protocols registered in BaseAgentDeps. No module imports concrete implementations directly.
  2. Capability filtering: Tools and features are enabled/disabled based on which capabilities are registered, not by configuration flags.
  3. Composability: Graphs, prompts, and tools are built by composing small, focused pieces rather than inheriting from large base classes.
  4. InMemory-first: Every protocol has an in-memory reference implementation so the framework runs without external services.
  5. Type safety: py.typed marker, strict mypy, and Pydantic models throughout.
  6. Layered independence: symfonic.core knows nothing about memory or agents. symfonic.memory knows nothing about graphs or runtimes. Only symfonic.agent holds the integration logic.
  7. Graceful degradation: Layer retrieval failures, spreading activation errors, and consolidation exceptions are all caught and logged without blocking the primary response path.