Skip to content

symfonic-core Feature Catalog

Current as of v7.0.3. This is the one-page map of every capability the framework ships today, grouped into 11 clusters. Each cluster has a summary, minimal default snippet, an opt-in example, and pointers to deeper docs.

If you have pip install symfonic-core in front of you and want to know what you have without reading 15 release notes, start here.

Table of contents

  1. Agent and Prompting
  2. Memory (5-Pentad)
  3. Retrieval and Hydration
  4. Tooling and Routing
  5. Intent Classification
  6. Fabrication Detection
  7. Consolidation (Deep Sleep)
  8. Observability
  9. Security and Multi-tenancy
  10. Streaming
  11. Domain Plugins
  12. Sub-agents (Delegation)
  13. Model Tuning (Length and Sampling)

Every feature below is default-preserving. FrameworkConfig() with no arguments reproduces pre-upgrade behaviour byte-for-byte so you can upgrade and adopt features one knob at a time.


1. Agent and Prompting

SymfonicAgent is the top-level facade. It composes a LangGraph runtime with the 5-layer Hierarchical Memory System (HMS), a domain template, and a structured system-prompt pipeline. The prompt pipeline has two modes: the full HMS prompt (~1800 tokens, all sections) and JIT mode (<=230 tokens, minimal + hydrate_context escape hatch).

Core types

  • SymfonicAgent — entrypoint with run(), stream(), stream_text(), stream_typed().
  • FrameworkConfig — single frozen Pydantic model holding every runtime knob. See the Configuration Matrix in the README.
  • DomainTemplate — business-specific labels, onboarding checklist, SOUL schema, tool manifest, tool_trigger_keywords, description (wired into both full HMS and JIT prompts since v7.0.2), and identifier_keys (per-domain fabrication lexicon).

Default snippet

from symfonic.agent.config import FrameworkConfig
from symfonic.agent.engine import SymfonicAgent
from symfonic.agent.types import FrameworkTenantScope
from symfonic.core.testing import MockModelProvider

agent = SymfonicAgent(
    model_provider=MockModelProvider(response="Hello!"),
    config=FrameworkConfig(),  # HMS off; simplest path
)
scope = FrameworkTenantScope(tenant_id="acme")
response = await agent.run("Hi there!", scope=scope)

Opt-in: full HMS prompt + JIT mode

cfg = FrameworkConfig(
    enable_hms_prompt=True,   # render the 9-section HMS system prompt
    jit_context=True,         # or render the <=230-token JIT template
)

Migration note. The legacy SymfonicAgent(enable_hms_prompt=True) constructor kwarg was removed in v7.0.0. Use FrameworkConfig(enable_hms_prompt=True) instead.

Structured output

Pass a Pydantic response_model to run() and the agent runs its normal tool loop, then a terminal pass validates the final answer against your schema. The validated instance lands on AgentResponse.structured; the prose answer stays on final_response. Provider-neutral (any model whose chat model supports with_structured_output); fail-loud via StructuredOutputError.

from pydantic import BaseModel

class Person(BaseModel):
    name: str
    age: int

resp = await agent.run("John Smith is 30.", response_model=Person)
resp.structured        # -> Person(name='John Smith', age=30)

Deeper


2. Memory (5-Pentad)

Five cognitive layers, each backed by the same BaseMemoryStore protocol. Every layer is tenant-scoped and persistable to Postgres (pgvector for Episodic, graph tables for the other four) with optional MongoDB secondary and in-memory fallbacks.

Layer Purpose Typical content
Semantic Stable knowledge graph (facts, entities, relationships) "Acme's brand voice is friendly"
Episodic Timestamped narrative event log (vector-indexed) "On 2026-04-15 user asked about pricing"
Working Short-term reasoning context with 24h TTL Current-session scratchpad
Procedural Learned skills and workflows (status: draft/approved/rejected) Multi-step runbooks
Prospective Future plans, reminders, scheduled triggers "Notify user when migration finishes"

SDK methods per layerSemanticLayer.store_fact, EpisodicLayer.store_event, WorkingLayer.push / cleanup_ttl, ProceduralLayer.store_skill / approve_skill / reject_skill / query_skills, ProspectiveLayer.set_trigger / get_pending.

HTTP endpoints — available via create_agent_router():

Endpoint Method Introduced
/api/v1/memories/semantic POST v6.1.4
/api/v1/memories/episodic POST v6.1.4
/api/v1/memories/procedural POST v6.1.4
/api/v1/memories/working POST v6.1.4
/api/v1/memories/prospective POST v6.1.4
/api/v1/memories/{id} GET/PATCH/DELETE v5.5.0+
/api/v1/memories/status GET v5.x

Each POST endpoint is a thin adapter over the corresponding SDK write signature, with extra="forbid" validation and an AuditLogEntry emitted on every successful create.

Default snippet

from symfonic.agent.config import FrameworkConfig

cfg = FrameworkConfig()
# enabled_layers default: {"working","episodic","semantic","procedural","prospective"}

Opt-in: narrow the hydrated layer set

cfg = FrameworkConfig(
    enabled_layers={"semantic", "episodic"},  # skip working/procedural/prospective
)

Deeper


3. Retrieval and Hydration

Before each LLM call, _hydrate() pulls context from the enabled layers and runs spreading activation across the semantic graph. The hydrated context is then formatted into the system prompt and token-budgeted.

Knobs

Field Default Effect
spreading_activation True Fetch 1st-degree graph neighbours for each semantic hit
hydration_max_hops 0 0 = v6.1.x single-hop; >=1 enables BFS with per-hop decay (v7.0 T08)
hydration_threshold 0.0 Minimum importance (1-10 scale) a hit must clear before injection (v6.1.8)
working_memory_raw_turns None None = keep every episodic hit; N caps raw [EPISODIC] entries (v6.1.8)
stm_summary_mode "off" "off" / "extractive" (zero-cost) / "llm" (one micro-call, extractive fallback) (v7.0)
credential_patterns None None = built-in v6.1.9 patterns; [] disables scrubbing; custom list replaces (v6.2 T01)

The spreading-activation subsystem also exposes a hydrate_context tool that the LLM can call from JIT mode to load additional context on demand. always_include_tools (v7.0.3) defaults to ("hydrate_context",) so this escape hatch is available even when the tool manifest is narrowed by intent routing.

Default snippet

cfg = FrameworkConfig(
    auto_hydrate=True,            # default True
    spreading_activation=True,    # default True, 1-hop
)

Opt-in: aggressive JIT hydration

cfg = FrameworkConfig(
    jit_context=True,
    hydration_max_hops=2,         # BFS up to 2 hops
    hydration_threshold=7.0,      # drop low-importance hits
    working_memory_raw_turns=3,   # keep last 3 episodic raw entries
    stm_summary_mode="extractive",
)

Deeper


4. Tooling and Routing

Tools bind at SymfonicAgent construction via tools=[...]. Per-turn narrowing of the tool manifest happens through two orthogonal mechanisms:

  1. In-prompt manifest filtering (v6.1.8) — DomainTemplate.tool_trigger_keywords gates each tool's entry in the HMS system prompt.
  2. Wire-level tool narrowing (v7.0.1) — tool_routing_mode drives per-turn narrowing of the Anthropic tools= API payload via the intent classifier's verdict.

Knobs

Field Default Effect
lazy_tooling True CapabilityRouter filters tool schemas by query relevance
tool_routing_mode "off" "off" / "observe" / "enforce" (v7.0.1)
always_include_tools ("hydrate_context",) Tool names always bound regardless of routing verdict (v7.0.3)
jit_manifest_token_budget 0 >0 injects compressed keyword-filtered manifest into JIT prompt (v6.2 T04)
DomainTemplate.tool_trigger_keywords {} Per-tool keyword map; missing entries mean "always include"

Tools without entries in tool_trigger_keywords are always bound. This is the v6.1.8 back-compat semantic — incremental adoption is safe.

Default snippet

cfg = FrameworkConfig()
# tool_routing_mode="off", lazy_tooling=True
# all bound tools are sent to the LLM every turn

Opt-in: dynamic wire-level narrowing

from symfonic.agent.domain import DomainTemplate

cfg = FrameworkConfig(
    tool_routing_mode="observe",  # emit decisions, keep full manifest bound
    # later: tool_routing_mode="enforce"
    domain=DomainTemplate(
        tool_trigger_keywords={
            "mysql_query": ["mysql", "query", "sql", "database"],
            "bash": ["bash", "shell", "command"],
        },
    ),
    jit_manifest_token_budget=80,  # compressed manifest in JIT mode
)

Authoring tools: @symfonic_tool (GA)

from symfonic.core import symfonic_tool

@symfonic_tool(scope="public")          # or "internal" / "admin"
async def get_weather(city: str) -> str:
    """Return the weather for a city.

    Args:
        city: Name of the city.
    """
    ...

Derives the JSON schema from type hints + docstring, preserves async/async-generator semantics, and lifts scope / routing_mode / visibility metadata into the registry. GA — no flag. (The former experimental_tool_decorator config is a deprecated no-op.) Vanilla LangChain @tool functions still register unchanged.

Tool-call steering & guards

The four tool seams: route (on_tool_routing_decision) → rewrite (on_tool_call_dispatch) → steer (on_before_tool_call) → result (on_tool_result). on_before_tool_call decides, per call, whether a tool runs — and when it shouldn't, what synthetic result the model sees. Return {"action": "skip", "content": str, "is_error": True} to refuse a call and hand the model corrective feedback it self-corrects against on the next turn (LangGraph routes the tool node back to the model); is_error=False is a benign cancel. First skip wins; per-call partial veto; error-isolated.

class WhereGuard(BaseCallbackHandler):
    async def on_before_tool_call(self, event, state):
        if event.tool_name == "run_query" and "WHERE" not in event.args["sql"].upper():
            return {"action": "skip", "content": "Add a WHERE clause.", "is_error": True}
        return None

Deeper


5. Intent Classification

IntentClassifier is a deterministic, zero-LLM-cost verb+keyword classifier that labels each turn as knowledge, action, or ambiguous before hydration. Running in enforce mode, action turns skip semantic + episodic + procedural retrieval because tool-binding turns do not need that context. knowledge and ambiguous verdicts always fall back to the full hydration path for conservative recall.

Knobs

Field Default Effect
intent_filter_mode "off" "off" / "observe" / "enforce" (v7.0)

i18n (v7.0.3) — the lexicon's TOKEN_RE is Unicode-aware (\w[\w\-]* with re.UNICODE) and inputs are NFC-normalised, so Spanish, German, French, Portuguese, Chinese, and Japanese queries classify correctly. Accent folding is deliberately not applied ("gráfica" does not match "grafica") — folding changes meaning in some Spanish contexts.

Default snippet

cfg = FrameworkConfig()
# classifier never runs; hydration happens every turn

Opt-in: observe then enforce

cfg = FrameworkConfig(intent_filter_mode="observe")
# later, once telemetry validates:
cfg = FrameworkConfig(intent_filter_mode="enforce")

Observe mode emits an ExtensionEvent(type="intent_classified") via CallbackManager.on_intent_classified so you can validate classifier decisions before enabling enforcement.

Deeper


6. Fabrication Detection

A cross-reference detector catches drafts that reference task IDs, PIDs, UUIDs, or claim-verb patterns ("I spawned ...", "I ran ...") the LLM never actually backed with a tool call. Findings are emitted as FabricationFinding objects via the fabrication_detected callback event and, in refuse mode, can replace the draft with a safe envelope.

Finding patterns (src/symfonic/agent/middleware/fabrication.py)

Pattern Example Confidence
uuid 9f2b1e47-00cb-4b42-8e12-cc07e0f0c1d0 0.95
kv task_id=abc123 / pid=9f2b1e47 0.8
verb_claim "I spawned subagent X" (per-verb grounded) 0.8
short_hex_near_verb 9f2b1e47 within ~60 chars of a claim verb (v7.0.3) 0.6
short_hex_near_key 9f2b1e47 right after task_id= / pid= / etc. (v7.0.3) 0.6

The verb-claim detector uses a _VERB_TO_TOOL_HINTS map (v7.0.3) so "I spawned subagent" only grounds when a tool named spawn_subagent / run_agent / etc. actually fired this turn. Per-domain extra keys live on DomainTemplate.identifier_keys.

Knobs

Field Default Effect
fabrication_check_mode "off" "off" / "observe" / "revise" / "refuse" (v7.0)
fabrication_refuse_min_confidence 0.8 Minimum finding confidence to trip refuse mode (v7.0.3)
DomainTemplate.identifier_keys [] Extra identifier keys to flag alongside the built-in lexicon (v7.0)

observe mode emits telemetry for every finding regardless of confidence. revise requires metacognition_enabled=True — without it, the engine warns and downgrades to refuse semantics.

Default snippet

cfg = FrameworkConfig()  # detector never runs
# TOOL DISCIPLINE directive in the HMS prompt is the only defence

Opt-in: recommended deployment stance

# 1. Start in observe mode to collect findings
cfg = FrameworkConfig(fabrication_check_mode="observe")

# 2. Once telemetry validates, escalate to refuse
cfg = FrameworkConfig(
    fabrication_check_mode="refuse",
    fabrication_refuse_min_confidence=0.8,  # catches UUID + kv + verb_claim
    # 0.5 opts into short-hex refusal; 1.0 is observe-only
)

Deeper


7. Consolidation (Deep Sleep)

SleepConsolidator runs 11 distinct phases that strengthen recurring knowledge, prune stale nodes, generate meta-nodes, correct SOUL schemas, decay importance, summarise old episodes, build synthetic links, and promote episodic patterns into draft procedural skills.

Three entrypoints

  • SleepConsolidator.quick_nap(scope) — lightweight subset (Phase 1 strengthen + optional Phase 5 SOUL + optional Phase 10 episodic). Intended for every-N-turns auto-consolidation (quick_nap_interval=10).
  • SleepConsolidator.run(scope) — full roster, returns a ConsolidationReport with every phase that actually fired appended to report.phases_run.
  • SleepConsolidator.nightly_nap(scope) — v7.0 T12 alias for run gated by nightly_nap_enabled=True. Operator responsibility: wire a scheduler (APScheduler / celery-beat / external cron) to invoke per tenant.

Phase roster (full run)

Phase Name Effect
1 strengthen Boost importance for recurring nodes (combines access_count + phase1_spreading_weight * spreading_access_count)
1.5 cooccurrence Create CO_OCCURRED edges between co-temporal nodes
2 tag_risk Mark nodes with negative-sentiment context
2.5 pending_edges Write inferred pending_connections as real edges (skipped when none)
3 prune_orphans Delete stale low-confidence orphan nodes; TTL-expired pruned unconditionally
4 meta_nodes Cluster nodes by label prefix into META: summaries (excludes existing META: to prevent cascade)
5 soul_corrections Apply SOUL-schema corrections (skipped when no schema provided)
8 working_ttl Prune WORKING-layer nodes with expired TTL
9 decay_importance Reduce importance of stale nodes; skips importance >= 8.0 and SOUL: / AGENT_IDENTITY: labels
10 episodic_summary Compress old episodes into semantic meta-nodes (gated by episodic_summarization_max_entries)
11 synthetic_links Emit SYNTHETIC_LINK edges between semantic nodes that co-occur in episodic content (gated by synthetic_link_min_co_count)
12 procedural_promotion Promote recurring episodic patterns to draft procedural skills (gated by promotion_min_pattern_count)

(Phases 6 and 7 are reserved slots — they do not currently exist, hence "11 distinct phases" in a 12-slot roster.)

Knobs

Field Default Effect
auto_consolidate True Extract MEMORY_EXTRACT + commit after each turn
quick_nap_interval 0 Every N turns run quick_nap (0 disables)
nightly_nap_enabled False Operator gate for nightly_nap entrypoint (v7.0)
nightly_nap_cron "0 3 * * *" Documented recommendation; engine does not parse (v7.0)
phase1_spreading_weight 0.5 Weight on spreading_access_count in Phase 1 (v6.2 T02)
synthetic_link_min_co_count 2 Phase 11 co-occurrence floor (v6.2 T03)
promotion_min_pattern_count 3 Phase 12 minimum occurrences before draft skill created
promotion_recency_days 30 Phase 12 lookback window
promotion_max_drafts_per_run 5 Phase 12 per-run cap
phase_12_action_type_from_tool_calls False v7.4.7 — opt the regex extractor into the metadata['tool_calls'][0]['name'] fallback
phase_12_use_llm_extractor False v7.6 — flip to opt into the LLM-backed extractor (either-or with regex). Requires a bound chat model.
phase_12_llm_model "claude-haiku-4-5" v7.6 — model identifier surfaced to calculate_cost and the telemetry log line
phase_12_llm_max_episodes_per_run 100 v7.6 — cap on episodes fed to the LLM prompt
phase_12_llm_max_drafts_per_run 5 v7.6 — per-run draft cap (mirrors regex setting)
episodic_summarization_max_entries 100 Phase 10 threshold
episodic_summarization_batch_size 50 Phase 10 batch size

Decay exemptions (Phase 9)

  • importance >= 8.0 (CRITICAL_IMPORTANCE_THRESHOLD).
  • Labels starting with SOUL: or AGENT_IDENTITY:.
  • Protects user profile and agent persona from age-based attrition.

META:META idempotence (Phase 4)

generate_meta_nodes excludes nodes whose label starts with "META:" from the clustering input. Phase 4 clusters raw domain nodes; META: nodes are outputs, not inputs. Pinned by test_idempotence_second_run_is_stable (report_2.meta_nodes_created == 0).

Default snippet

cfg = FrameworkConfig()  # auto_consolidate=True, quick_nap off

Opt-in: every-10-turns quick nap + nightly full run

cfg = FrameworkConfig(
    quick_nap_interval=10,
    nightly_nap_enabled=True,
    nightly_nap_cron="0 3 * * *",
)
# Wire your scheduler to call:
#   agent._sleep_consolidator.nightly_nap(scope)
# per tenant at the cron cadence.

Deeper


8. Observability

Three callback surfaces and an observability hook fire across the request lifecycle, each scoped to a specific concern.

Callback events (per turn, in order)

Event View Fires Use it for
AgentStartEvent / AgentEndEvent agent Request boundary Run IDs, duration
NodeStartEvent / NodeEndEvent / NodeErrorEvent graph Each LangGraph node Per-node timing, error logs
LLMStartEvent engine BEFORE consolidation Memory hydration debugging
LLMPreCallEvent (v6.1.7) wire AFTER consolidation, BEFORE provider Gateway trace events, compliance, replay fixtures
LLMEndEvent wire AFTER provider response Token usage, cost, output capture
ToolRoutingDecisionEvent (v7.0.1) routing tool_routing_mode != "off" {intent_label, total_tools, narrowed_tool_names, saved_tokens_estimate}
ExtensionEvent(type="intent_classified") triage intent_filter_mode != "off" Intent verdict payload
ExtensionEvent(type="fabrication_detected") middleware Detector finds hit in observe/revise/refuse Fabrication telemetry
ExtensionEvent(type="fabrication_blocked") middleware refuse mode replaced a draft Safe-envelope audit

Optional hooks (on_llm_pre_call, on_tool_routing_decision, on_intent_classified, on_fabrication_detected) are hasattr-gated so legacy handlers that do not implement them incur zero cost. CallbackManager.has_hook("...") lets emitters skip event construction when nothing listens.

Episodic telemetry (v6.1)

FrameworkConfig.episodic_telemetry_sink accepts None (disabled), "logger" (stdlib logging under "symfonic.memory.telemetry.episodic"), or a filesystem path (JSON-lines). Sink failures are swallowed at DEBUG; telemetry never breaks the episodic write. v6.2 T05 added optional tokens_in, tokens_out, input_tokens, output_tokens, cost_usd, model, embedding_latency_ms fields to EpisodicTelemetryRecord.

Budget tracking

TokenBudgetTracker + PostgresBudgetStore for multi-replica correctness — every replica hydrates its in-memory buckets from the shared store at the start of each check_budget(). The ConversationMetricsCollector fans out to the tracker; subclasses that override set_tenant MUST call super().set_tenant(...) or budget tracking silently drops requests.

Consolidation observability

ConsolidationReport.phases_run: list[str] lists every phase that executed (including zero-mutation runs), so operators can distinguish "phase ran and found nothing" from "phase was short-circuited".

Default snippet

from symfonic.core.callbacks.protocol import CallbackHandler
from symfonic.core.contracts.callbacks import LLMPreCallEvent

class Logger:
    async def on_llm_pre_call(self, event: LLMPreCallEvent) -> None:
        print(f"[wire] {len(event.messages)} msgs, {len(event.tools)} tools")

agent.run("...", scope=scope, callbacks=[Logger()])

Opt-in: budget + episodic telemetry

cfg = FrameworkConfig(
    episodic_telemetry_sink="/var/log/symfonic/episodic.jsonl",
)

Deeper

Model routing -- the asymmetric pattern (v7.8.3 / v7.8.5 / v7.9.0 / v7.9.1)

Four knobs compose to let adopters route procedural-rule turns to a high-compliance model and reasoning turns to a high-reasoning model. The canonical use case: keep claude-opus-4-6 + thinking as the default for general agent reasoning, route the "action" role to gpt-4.1-nano so forcing engages on procedural-rule turns (an adopter's empirical 6/6 vs 1/6 result on the WLM probe).

from symfonic.core.providers import (
    AnthropicProvider, MultiProviderRouter, OpenAIProvider,
)

provider = MultiProviderRouter(
    default=AnthropicProvider(),
    routes={"gpt-": OpenAIProvider()},
)
agent = SymfonicAgent(
    model_provider=provider,
    config=FrameworkConfig(
        agent=AgentConfig(
            model=ModelConfig(
                model_name="claude-opus-4-6",
                thinking={"type": "enabled", "budget_tokens": 4096},
            ),
        ),
        role_models={"action": ModelConfig(model_name="gpt-4.1-nano")},
        procedural_force_first_action_tool=True,
        procedural_render_preflight_in_l1=True,
    ),
)

Architectural principle (v7.8.3): the framework constrains the choice set when it can, abstains cleanly when it cannot; MultiProviderRouter is wiring, not policy. Auto-disabling ModelConfig.thinking to make forcing succeed is explicitly forbidden -- that crosses from choice-set constraint into reasoning-suppression, which is model-owned.

Full vocabulary at docs/concepts/model-routing.md.

Cost-tracking pricing overrides (v7.8.0)

FrameworkConfig.model_pricing_overrides accepts a per-deployment override map for the model registry at pricing.py. Overrides are consulted BEFORE the built-in registry (exact match -> strict prefix), so adopters can correct stale rates, register custom in-house models, or pin negotiated enterprise rates without waiting for a framework release. Partial-row overrides and negative/non-numeric values are rejected at agent construction.

FrameworkConfig(
    model_pricing_overrides={
        "claude-haiku-4-5": {            # exact-match correction
            "input": 0.5, "output": 2.5,
            "cache_read": 0.05, "cache_write": 0.625,
        },
        "internal-llm-": {                # prefix-match for in-house family
            "input": 0.0, "output": 0.0,
            "cache_read": 0.0, "cache_write": 0.0,
        },
    },
)

The full vocabulary lives in docs/concepts/cost-tracking.md. Pre-7.8.0 adopters relied on a release-cadence pricing patch (see the notes in docs/releases/v7.4.5.md); the override knob removes that dependency.

Corpus-scrub tooling (v7.8.0)

symfonic.tools.corpus_scrub is an adopter-side PII redaction module for Phase 12 episodic corpus exports. Required pre-flight for the v7.9.0 LLM-extractor eval — raw production data MUST NOT cross the symfonic-core boundary. See docs/guides/09-corpus-scrub.md and the runnable example at examples/corpus_scrub/.


9. Security and Multi-tenancy

Every operation is scoped by FrameworkTenantScope and enforced at the backend level. create_agent_router() raises RuntimeError at build time if SYMFONIC_ENV=production is set and no tenant-auth verifier was registered via set_tenant_auth_verifier(). Emergency override: ALLOW_INSECURE_PROD=true (logs CRITICAL).

Credential hygiene (v6.1.9 + v6.2)

Three-tier defence-in-depth scrubs credential-shaped property keys:

  1. Extraction-time (F4) — HMS + JIT + MEMORY_EXTRACT prompts instruct the LLM to omit credential-shaped properties.
  2. Write-time (F3)SymfonicAgent._consolidate scrubs keys before MemoryEntry persist.
  3. Read-time (F2)SymfonicAgent._hydrate scrubs keys before formatting the hydration context string.

Default pattern list covers password, secret, token, api_key / api-key, credential, private_key / private-key, bearer, authorization, auth_header, access_key, refresh_token. Tunable via FrameworkConfig.credential_patterns:

  • None — built-in v6.1.9 patterns (byte-for-byte preserved).
  • [] — explicit disable (callers who run their own upstream hygiene).
  • [...] — replaces built-in list (invalid regex raises re.error at agent construction).

Public entrypoint: symfonic.agent.hygiene.scrub_credential_keys.

Audit logger

Every memory create/update/delete + admin action fires an AuditLogEntry via the emit_audit_event hook. The v5.6.1 Postgres audit logger picks these up automatically.

Admin endpoints (role="admin" OR org-owner)

  • GET /admin/audit — paginated audit log.
  • POST /admin/tenants/{tenant_id}/budget — set daily/monthly limits.
  • GET /admin/tenants/{tenant_id}/usage — read tenant usage window.

GDPR endpoints

  • GET /tenants/me/export — Article 20 JSON export (rate-limited 5/day).
  • DELETE /tenants/me/data?confirmation=DELETE-<tenant_id> — Article 17 erase (rate-limited 1/hour).

Default snippet

from symfonic.agent.fastapi.router import create_agent_router
from symfonic.agent.fastapi.auth import set_tenant_auth_verifier

set_tenant_auth_verifier(my_verifier)  # must be called in production
router = create_agent_router(agent, prefix="/api/v1")

Deeper


10. Streaming

Three async generator APIs at different abstraction levels, all sharing the same signature and backed by the same EventTranspiler.

API Yields Best for
stream_text() str Chat UI — just the clean text
stream_typed() typed StreamEvent subclasses Rich UIs (thinking, tools, activation)
stream() StreamChunk Low-level event wrappers with event_type field

Event taxonomy

TextDeltaEvent, ThinkingDeltaEvent, ToolCallStartEvent, ToolCallDeltaEvent, ToolResultEvent, ActivationEvent, MessageStartEvent, MessageEndEvent, UsageEvent, ExtensionEvent, GraphNodeCreatedEvent, GraphEdgeCreatedEvent, MemoryExtractedEvent, ConsolidationDoneEvent, ConsolidationScheduledEvent, ResponseCompleteEvent.

Stream-safe filters

  • ExtractionFilter — strips <MEMORY_EXTRACT> / <GRAPH_OPERATIONS> blocks even when tags split across chunk boundaries.
  • CitationFilter (v6.0.x parity) — strips [semantic] / [episodic] / [procedural] / [working] / [prospective] citation tags at the chunk level so even stream() (raw) output is citation-free.

Default snippet

async for text in agent.stream_text("Explain DI in 3 sentences.", scope=scope):
    print(text, end="", flush=True)

Opt-in: typed event stream

from symfonic.core.contracts.types import TextDeltaEvent, ToolCallStartEvent

async for event in agent.stream_typed("What tools do you have?", scope=scope):
    match event:
        case TextDeltaEvent(text=t): print(t, end="", flush=True)
        case ToolCallStartEvent(tool_name=name): print(f"[tool] {name}")
        case _: pass

Deeper


11. Domain Plugins

BaseDomainPlugin protocol + DomainTemplate is the extension seam for business-specific behaviour. Framework stays generic; everything domain-aware lives in a plugin + template.

Protocol methods

class BaseDomainPlugin:
    name: str
    async def inject_system_prompt(self, state: dict) -> str: ...
    async def validate_state_transition(self, action: str, context: dict) -> bool: ...
    def get_domain_tools(self) -> list: ...

Domain tools MUST be passed at agent construction (SymfonicAgent(tools=[...])) — the ToolRegistry freezes after graph compilation. load_plugin() only registers the prompt + guardrail hooks.

CorePreferences

DomainTemplate.core_preferences: list[CorePreference] expresses domain-specific guardrails that MetacognitiveMiddleware consults. Priority 10 = hard refusal; 5-7 = soft guidance. Each preference carries optional sensitive_tags — when any activated node matches, reflection fires automatically (v6.0 autonomous learning gate).

Store Copilot starter (reference domain)

Opt-in scaffolder component store-starter ships a convenience-store sales & inventory copilot in app/domains/store/:

  • 8 tools: top_sellers, sales_trend, sales_chart, revenue_summary, product_lookup, list_low_stock (read-only analytics/lookup) plus register_product and update_stock (confirm-before-write mutations).
  • CorePreferences guardrails: treat any connected store DB as read-only, confirm before registering a product or changing stock, cite the window and figures behind every sales claim, and proactively flag low stock.
  • A seeded, deterministic demo dataset (~36 SKUs across six categories, ~90 days of sales) so the agent has real numbers out of the box — demo_data.py is the single wiring point to swap for a real store DB (or the read-only DSN captured at onboarding).
  • An Insights charts dashboard backed by GET /api/v1/store/insights, and a store onboarding flow (store name, what it sells, optional read-only DSN).
  • Opt-in via symfonic init --components store-starter (mutually exclusive with the default starter domain plugin — it omits app/domains/starter/ and ships app/domains/store/ instead).

Default snippet

from symfonic.agent.domain import DomainTemplate

MY_DOMAIN = DomainTemplate(
    name="customer-success",
    required_labels=["SOUL", "ACCOUNT", "TICKET"],
    soul_schema={"company_name": "str", "account_tier": "str"},
    tool_manifest=["search_tickets: Search support tickets"],
)

cfg = FrameworkConfig(domain=MY_DOMAIN)

Opt-in: plugin with guardrails + identifier lexicon

MY_DOMAIN = DomainTemplate(
    name="payments",
    tool_manifest=["refund: Issue a refund", "query_txn: Query a transaction"],
    tool_trigger_keywords={"refund": ["refund", "chargeback"]},
    identifier_keys=["transaction_id", "customer_id"],  # fabrication lexicon
    description="Payments domain — never suggest amounts above tenant cap.",
)

agent = SymfonicAgent(
    model_provider=provider,
    tools=[refund_tool, query_txn_tool],  # must be at construction time
    config=FrameworkConfig(domain=MY_DOMAIN),
)
agent.load_plugin(PaymentsPlugin())

Deeper


12. Sub-agents (Delegation)

A parent agent can hand a self-contained task to a named child agent and get its result back. Declaring sub_agents auto-registers a concrete AgentStore plus the built-in run_agent(name, task) / list_agents tools before the graph compiles — the parent's model decides when to delegate. Children are isolated agents (their own model / tools / memory) that inherit the parent's tenant scope and run under a delegation-depth guard.

Core types

Type Role
SubAgent Declares a delegatable child: name, agent, description, when_to_use
SubAgentRegistry Concrete AgentStore that runs registered children in-process
run_agent / list_agents Auto-registered delegation tools added to the parent's palette

Knobs

Field Default Effect
SymfonicAgent(sub_agents=[...]) None Declares children; wires the delegation tools + AgentStore (v8.9.0)
AgentConfig.max_agent_depth 2 Ceiling on nested delegation; the parent refuses to delegate past it

Fully additive: with no sub_agents, construction is byte-identical and no delegation tools are registered.

Opt-in snippet

from symfonic.agent import SubAgent, SymfonicAgent
from symfonic.agent.config import FrameworkConfig

researcher = SymfonicAgent(model_provider=provider, config=FrameworkConfig())

parent = SymfonicAgent(
    model_provider=provider,
    config=FrameworkConfig(),
    sub_agents=[
        SubAgent(
            name="researcher",
            agent=researcher,
            description="Background research specialist.",
            when_to_use="Explain / define / compare questions with no dedicated tool.",
        ),
    ],
)
# The parent's model can now call run_agent("researcher", "<task>").

Deeper


13. Model Tuning (Length and Sampling)

ModelConfig controls how the model generates — reply length and token sampling. These are the knobs that stop runaway output and shape how focused vs. creative answers are.

Knobs

Field Default Effect
max_tokens 16384 Hard cap on reply length — the primary lever against runaway output
temperature 1.0 Randomness; lower = more focused/deterministic
top_p None Nucleus sampling; threaded to all providers (v8.9.0)
top_k None Top-k sampling; Anthropic / Google / Ollama only — the OpenAI chat API has no top_k, so OpenAI / DeepSeek / Kimi ignore it with a one-shot warning (v8.9.0)

top_p / top_k default None (byte-identical construction when unset).

Deeper


Further reading