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¶
- Agent and Prompting
- Memory (5-Pentad)
- Retrieval and Hydration
- Tooling and Routing
- Intent Classification
- Fabrication Detection
- Consolidation (Deep Sleep)
- Observability
- Security and Multi-tenancy
- Streaming
- Domain Plugins
- Sub-agents (Delegation)
- 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 withrun(),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), andidentifier_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. UseFrameworkConfig(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
- Guide:
docs/guides/01-quickstart.md - Concept:
docs/concepts/structured-output.md - Guide:
docs/guides/04-plugins-and-domains.md - Release:
docs/releases/v6.0.0.md,docs/releases/v6.1.10.md(TOOL DISCIPLINE),docs/releases/v7.0.0.md(identifier_keys),docs/releases/v7.0.2.md(descriptionwired).
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 layer — SemanticLayer.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
- Guide:
docs/guides/02-memory-persistence.md - Concepts:
docs/concepts/memory.md - Release:
docs/releases/v6.1.4.md(5 POST endpoints).
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
- Guide:
docs/guides/05-production.md(spreading activation, tenant isolation). - Release:
docs/releases/v6.1.8.md(JIT knobs),docs/releases/v6.1.9.md(credential scrub),docs/releases/v7.0.0.md(N-hop + STM summary).
4. Tooling and Routing¶
Tools bind at SymfonicAgent construction via tools=[...]. Per-turn
narrowing of the tool manifest happens through two orthogonal mechanisms:
- In-prompt manifest filtering (v6.1.8) —
DomainTemplate.tool_trigger_keywordsgates each tool's entry in the HMS system prompt. - Wire-level tool narrowing (v7.0.1) —
tool_routing_modedrives per-turn narrowing of the Anthropictools=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
- Guide:
docs/guides/06-lazy-tooling.md - Guide:
docs/guides/07-intent-and-tool-routing.md - Concept:
docs/concepts/writing-tools.md - Guide:
docs/guides/13-tool-call-dispatch-rewriter.md(rewrite seam) - Guide:
docs/guides/18-tool-call-steering.md(steering / guards) - Release:
docs/releases/v6.1.8.md,docs/releases/v7.0.1.md,docs/releases/v7.0.3.md.
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
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
- Guide:
docs/guides/07-intent-and-tool-routing.md - Release:
docs/releases/v7.0.0.md,docs/releases/v7.0.3.md(i18n).
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
- Guide:
docs/guides/08-fabrication-detection.md - Release:
docs/releases/v7.0.0.md,docs/releases/v7.0.3.md.
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 aConsolidationReportwith every phase that actually fired appended toreport.phases_run.SleepConsolidator.nightly_nap(scope)— v7.0 T12 alias forrungated bynightly_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:orAGENT_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
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
- Guide:
docs/guides/09-consolidation-and-deep-sleep.md - Guide:
docs/guides/10-authored-skills.md(v7.5+) - Concepts:
docs/concepts/deep-sleep-phases.md - Release:
docs/releases/v6.0.0.md(Phase 9/10),docs/releases/v6.1.0.md(phases_run),docs/releases/v6.2.0.md(T02/T03),docs/releases/v7.0.0.md(nightly_nap).
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
Deeper
- Guide:
docs/guides/03-streaming-and-callbacks.md - Concepts:
docs/concepts/streaming-events.md - Release:
docs/releases/v6.1.0.md(telemetry),docs/releases/v6.1.7.md(LLMPreCallEvent),docs/releases/v6.2.0.md(richer telemetry).
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:
- Extraction-time (F4) — HMS + JIT + MEMORY_EXTRACT prompts instruct the LLM to omit credential-shaped properties.
- Write-time (F3) —
SymfonicAgent._consolidatescrubs keys beforeMemoryEntrypersist. - Read-time (F2) —
SymfonicAgent._hydratescrubs 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 raisesre.errorat 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
- Guide:
docs/guides/05-production.md - Release:
docs/releases/v6.0.0.md(auth gate),docs/releases/v6.1.9.md(F2-F4 scrub),docs/releases/v6.2.0.md(T01 tunable).
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 evenstream()(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) plusregister_productandupdate_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.pyis 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 defaultstarterdomain plugin — it omitsapp/domains/starter/and shipsapp/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
- Guide:
docs/guides/04-plugins-and-domains.md - Guide:
docs/guides/store-starter.md - Historical context: the original reference domain was an SRE
incident-response plugin shipped in v6.x
(
docs/releases/v6.0.0.md— SRE plugin,docs/releases/v6.1.0.md— SRE backends wired); it has since been replaced by the Store Copilot as the scaffolder's showcase domain. - Release:
docs/releases/v7.0.2.md(DomainTemplate.descriptionwired into prompts).
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
- Guide:
docs/guides/17-sub-agents.md - Example:
examples/sub_agents(runs on the mock provider, no API key)
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
- Concept:
docs/concepts/model-tuning.md
Further reading¶
README.md— Quick Start + Configuration Matrix.docs/concepts/architecture.md— Thalamic pipeline diagram + module map.docs/guides/migrate-6x-to-7x.md— upgrade path including theenable_hms_promptkwarg removal.- Release history:
docs/releases/— every release note from v6.0.0 through v7.0.3.