Guide 22 — Capability-Architecture Migration¶
Symfonic grew to roughly 90,000 lines in which one class knew about memory, prompts, tools, sessions, sub-agents, HTTP, and telemetry at once. The refactor splits that into six layers with one invocation pipeline underneath. This guide is the map: what you have, where its replacement lives, and when — if ever — you have to move.
Read this first: adoption is not retirement¶
The migration preserves compatibility surfaces while moving implementations. For the upcoming release documentation, read Moving to 11.0. That preview introduces no new deprecations and does not authorize removal of legacy code.
The preservation programme is tracked separately from runtime acceptance:
- No feature was dropped. Every export, route, CLI command, event, extra, environment variable, and curated example in the preservation catalogue is preserved. A feature can only leave through an explicit recorded decision, and this program made zero such decisions. The row-by-row status is the Feature Preservation Reference.
- No import path was vacated. Tier-1 and tier-2 import paths do not break within a major line. Where code moved, the old path still imports the same object; a move that vacated a path would have to ship its alias in the same release, registered with an owner, a rationale, and a review date.
- Compatibility entry points remain importable; two no longer construct.
From 11.3.0
SymfonicAgent(...)andAgentBuilder().build()raiseEntryPointRemovedErrorbefore anything is built; their imports still resolve. The other entry points' presence does not imply that every historical configuration argument is accepted. Existing validation and retirement refusals still apply. - State compatibility is path-specific. Test sessions, checkpoints and outstanding pauses against the exact routes you deploy. In particular, legacy pause tokens are not universally redeemable by kernel-native checkpoint paths. The continuation migration window retains a legacy destination and is bounded by that destination's retirement contract.
So this is not a "port your code by Friday" guide. It is a ladder you climb only as far as you need.
Before choosing a replacement, consult the behavior parity table. It links the executable comparisons, native migration recipes, and unresolved legacy-default rows, with the evidence boundary for each behavior.
The ladder¶
| Rung | You are working with | Import root |
|---|---|---|
| 1 | The simple agent — one provider, tools, one turn | symfonic |
| 2 | Capabilities — tools, prompts, memory, knowledge, delegation, human interaction, safety | symfonic.capabilities.* |
| 3 | Runtime services — sessions, checkpoints, budgets, cost, consolidation, observability fan-out | symfonic.services.* |
| 4 | Platform — tenancy, authn/z, HTTP and SSE, privacy, billing | symfonic.platform, symfonic.agent.fastapi |
| 5 | Integrations — provider SDKs, embeddings, storage backends, MCP, plugins, OpenTelemetry | symfonic.core.providers, symfonic.memory.backends, symfonic.capabilities.extensions, symfonic.observability |
| 6 | Low-level core — the kernel, contracts, the event stream | symfonic.kernel, symfonic.core.contracts |
Two rules make the ladder navigable:
- Dependencies point inward. A capability may import kernel contracts and its own package — nothing else. The kernel imports no provider SDK, no memory implementation, no transport, no exporter. Collaboration between capabilities is not an import edge; it is a port declared to the compiler and wired into the compiled plan.
- Absence means disabled. Optional behaviour attaches by passing a
typed configuration object, not by flipping a boolean on a mega-config.
No module under
symfonicreaches for a disabled integration, so a cold container pays for nothing it asked symfonic for. (That is an attribution guarantee, and deliberately so: transitive requirements put modules insys.modulesbefore symfonic runs a line, and no change here can make a blanket "nothing is imported" claim true.)
Start at rung 1¶
See Guide 00 — The Simple Agent. Code that constructs
SymfonicAgent stops at that call from 11.3.0; move it to Agent with the
capabilities below, or to symfonic.platform.AgentHost for a hosted
application, as Moving to 11.0 describes.
Which child declaration, and why it matters¶
delegated_children takes two kinds of declaration, and picking the wrong one
is a tenant-isolation bug rather than a style preference.
ScopedChild(build=...) |
PrebuiltChild(agent=...) |
|
|---|---|---|
| What you pass | a builder, scope -> agent |
a finished agent |
| Built | once per tenant scope, inside compose |
once, wherever you made it |
| Use it for | any child with state or tenant-bound capabilities | stateless specialists, remote proxies, test doubles |
A stateful child must be a ScopedChild. A finished agent is built with
one scope, and a composition root has two places to build one — the convenient
place being next to the provider, above compose. Do that with a child that
has memory and it answers one tenant's delegation out of another tenant's
recall. Measured, not feared: see
tests/capabilities/delegation/test_scoped_children.py.
Nothing checks this for you. Agent exposes close, run and stream and
nothing about what it was composed with, so a declaration-time gate cannot
tell a stateless child from one holding scope-bound memory. The lockdown check
that does exist reads a child's tools, which are reachable; its capabilities
are not. Until that changes this is a rule you apply, not one the framework
enforces.
Then rung 2: one call per capability¶
Every capability package has a single entry point. You pass what only your deployment can supply, and the call composes the rest.
from symfonic import Agent
from symfonic.agent.domain import CorePreference
from symfonic.capabilities.delegation import ScopedChild, delegated_children
from symfonic.capabilities.human import PauseBinding, human_interaction
from symfonic.capabilities.knowledge import knowledge_sources
from symfonic.capabilities.memory import GraphBackedHms, memory_capabilities
from symfonic.capabilities.prompting import (
DomainPersona, Guardrail, PromptingCapability,
guardrail_sources, persona_sources,
)
from symfonic.capabilities.tools import ToolsCapability, keyword_router, tool_name
from symfonic.platform import core_preference_migration, extensions, governance
legacy_preferences = core_preference_migration([
CorePreference(
statement="Confirm with an owner before changing stock.",
sensitive_tags=["inventory", "write"],
priority=9,
),
])
agent = Agent(
provider,
tools=TOOLS,
capabilities=[
*memory_capabilities(GraphBackedHms(graph_backend), scope),
PromptingCapability(sources=[
*persona_sources(DomainPersona(name="store", role="copilot")),
*guardrail_sources(legacy_preferences.guardrails),
*knowledge_sources(retriever=retriever, query=question),
]),
ToolsCapability(
entries_for=keyword_router(KEYWORDS, registered=names),
registered=names,
),
delegated_children([ScopedChild(name="researcher", build=build_child,
description="...", when_to_use="...")],
scope=scope),
governance(
reflector=reflector,
meter=meter,
sensitive_terms=legacy_preferences.sensitive_terms,
),
extensions([McpExtensionAdapter(servers=servers)]),
],
)
Add a durable conversation window¶
The native window is opt-in. Create a PostgresConversationLog on the same
PostgresPoolManager used by the deployment, call ensure_schema() at startup,
and pass a NativeConversationHistory to memory_capabilities. The factory
adds its final publication stage. recent_turns counts individual utterances,
and zero skips window reads.
from symfonic.capabilities.memory.conversation_history import (
NativeConversationHistory, SessionScopeAuthorizer,
)
from symfonic.memory.backends.postgres_conversation import PostgresConversationLog
log = PostgresConversationLog(pool)
await log.ensure_schema()
scope = MemoryScope("acme", "alice", "chat-1")
history = NativeConversationHistory(log, SessionScopeAuthorizer(), scope)
capabilities = memory_capabilities(
store, scope, conversation=history, recent_turns=6,
)
agent = Agent(provider, capabilities=[*capabilities, PromptingCapability()])
await agent.run("Hello", session_id="chat-1")
SessionScopeAuthorizer accepts only a request whose session ID matches the
authenticated session-level scope. For agents shared across sessions under a
principal scope, supply a ConversationAuthorizer that verifies each request's
session ownership. Wire the same log into MemoryAdminService(store,
conversation=log) so its forget(scope) includes transcript rows;
GraphAdminService(graph, admin).export(scope) then includes them as well.
The window renders first. A recalled episodic copy of a turn the window
already shows, matched by its turn_id (the run's run_id, which the
built-in native conversation history reports back) and speaker, is left out of the
block and reported in the hydration's dropped as "already rendered by the
conversation window". Only window lines the block actually renders count: a
turn the block's character or byte ceiling leaves out does not suppress its
recalled copy. The match is by identity, not text: the same words from
another turn, the other side of a window turn, and classified or semantic
memories still render. The remaining recall keeps retrieval's ranked order;
it is not re-sorted by time.
Suppression happens after retrieval has selected its results, so a
top-ranked duplicate still occupies one of retrieval's limit slots and can
crowd out an older, distinct memory: earlier memories and facts are never
removed by this rule, but they are not guaranteed a slot. The behaviour above
is verified for the normal hydration block with no custom compressor. A
HydrationPolicy(compressor=...) you configure runs after suppression, on the
assembled block, and may rewrite or drop a window line; duplicate suppression
and source preservation are not guaranteed after arbitrary custom compression.
Ordering the conversation window oldest first (CORE-65) is a separate
requirement from recall order.
Add post-consolidation reflection¶
MemoryReflectionService extracts durable insights after the ordinary memory
flush. It is not the governance(reflector=...) collaborator: governance
metacognition evaluates a draft before egress, while reflection receives the
completed exchange and can persist a semantic record. Compose it explicitly so
the reflection model, prompt bytes, store, and scope are visible at one root.
from symfonic.capabilities.memory import MemoryReflectionService, memory_capabilities
reflection = MemoryReflectionService(
reflection_model,
prompt_template="Extract delivery insights.\n{conversation}\nReturn JSON array.",
)
agent = Agent(
provider,
capabilities=memory_capabilities(store, scope, reflection=reflection),
)
Leaving reflection= out disables the phase and never calls
reflection_model. The runnable reflection migration example
uses only public imports and asserts the persisted semantic insight.
Add bounded graph activation¶
An activation composition has one public surface. Supply an
AssociationSource that returns the graph edges for each frontier, build a
SpreadingActivation around it, then pass it to memory_capabilities. The
factory keeps the normal direct recall and adds only the bounded neighbours the
activation admits.
from symfonic.capabilities.memory import MemoryScope, memory_capabilities
from symfonic.capabilities.memory.recall import (
Association,
AssociationSource,
SpreadingActivation,
)
class Relationships(AssociationSource):
async def neighbours(
self, scope: MemoryScope, record_ids: tuple[str, ...]
) -> tuple[Association, ...]:
return () # Query the deployment's graph for this scope and frontier.
activation = SpreadingActivation(source=Relationships(), max_hops=1, max_nodes=10)
capabilities = memory_capabilities(store, scope, activation=activation)
Tune native retrieval without reviving FrameworkConfig¶
The public memory factory accepts a RetrievalPolicy, so floors and hierarchy
ranking are applied by the coordinator before the prompt compiler sees a
recall block. For an adopter-owned scorer, construct the coordinator over a
CandidateSource and pass it as retrieval=. That coordinator is the native
hot path; a pre-ranked store keeps its own ordering, while an adopter source
lets the policy order candidates.
from symfonic.capabilities.memory import memory_capabilities
from symfonic.capabilities.memory.retrieval import (
CandidateSource,
RetrievalCoordinator,
RetrievalPolicy,
RelevanceWeights,
ScopeBlend,
ScopeBlendMode,
)
policy = RetrievalPolicy(
min_relevance=0.45,
weights=RelevanceWeights(cue=1.0, salience=0.0),
blend=ScopeBlend(mode=ScopeBlendMode.BACKFILL),
)
retrieval = RetrievalCoordinator(source=my_candidate_source, policy=policy)
capabilities = memory_capabilities(store, scope, retrieval=retrieval)
exclude_speakers=frozenset({"assistant"}) filters only turns returned by an
adopter ConversationSource; recalled records have no speaker schema and are
not inferred from quoted text. A policy whose enabled_layers leaves a layer
out, or a query whose layers does, also keeps the built-in
SpreadingActivation from walking into that layer: no neighbour there is
recalled, logged or reached through. A custom activation is not bounded this
way. Pass LearningPolicy(phase1_spreading_weight=…)
to quick_phases(..., policy=...) to configure Phase 1 at the native nap
factory.
Add an explicit JIT STM summary¶
WorkingMemorySummaryService consumes an adopter-owned async
evicted(scope) source. It never borrows the response model: mode="llm"
requires an explicit model provider, while the default extractive mode is
local. The contribution is materialized for the request scope and admitted
only by ContextStrategy.JIT, so it cannot leak into a stratified prompt.
from symfonic.capabilities.memory import WorkingMemorySummaryService, memory_capabilities
summary = WorkingMemorySummaryService(evicted_turn_source, mode="extractive")
capabilities = memory_capabilities(store, scope, summary=summary)
Omit summary= to disable the feature without calling the evicted-turn source.
See the runnable JIT STM summary example.
Observe successful episodic publication¶
The retired FrameworkConfig.episodic_telemetry_sink setting does not configure
the capability route. Compose an async EpisodicTelemetrySink with the public
memory factory instead:
from symfonic.capabilities.memory import (
EpisodicLifecycleEvent, EpisodicTelemetrySink, memory_capabilities,
)
class AuditSink(EpisodicTelemetrySink):
async def emit(self, event: EpisodicLifecycleEvent) -> None:
# Forward identifiers to the deployment's telemetry service.
await audit.publish(event.scope_path, event.run_id, event.committed_record_ids)
capabilities = memory_capabilities(
store,
scope,
episodic_telemetry_sink=AuditSink(),
)
The sink is called only after memory.lifecycle successfully publishes records.
Its receipt contains the flush scope, lifecycle run lineage, and committed
record ids; it never contains memory text, prompts, responses, or metadata.
Sink failures are logged without payload and do not roll back publication. Omit
the argument to disable telemetry without creating lifecycle telemetry work.
examples.migration_parity.episodic_telemetry
is a runnable, offline adopter example:
# repository only: this migration probe is intentionally not part of the public sdist.
PYTHONPATH="$PWD/src:$PWD" python -m examples.migration_parity.episodic_telemetry
Association, AssociationSource, and SpreadingActivation are imported from
symfonic.capabilities.memory.recall. The four conversation-window types share
that surface: ConversationSource, ConversationTurn, WorkingContext, and
WorkingWindow. Their established imports from symfonic.capabilities.memory
remain supported for compatibility.
Migrating an existing deployment¶
These recipes are for a deployment that already has its own persisted scope keys, transcript store, or fabrication checks. They preserve the real adapter boundary instead of treating the new types as a second store or a detector replacement. Each module contains assertions for the shown result and runs without a model, network, or database:
PYTHONPATH="$PWD/src:$PWD" python -m examples.migration_parity.legacy_scope_bridge
PYTHONPATH="$PWD/src:$PWD" python -m examples.migration_parity.conversation_continuity
PYTHONPATH="$PWD/src:$PWD" python -m examples.migration_parity.fabrication_adapter
1. Preserve legacy scope keys¶
Read an existing key with scope_from_legacy_path, use the returned
MemoryScope in the native composition, and write the legacy shape only at a
compatibility boundary. The recipe proves this exact round trip:
from symfonic.capabilities.memory import legacy_scope_path, scope_from_legacy_path
legacy = "tenant\x1facme\x1fsub_tenant\x1fclient1"
scope = scope_from_legacy_path(legacy)
assert scope.path == "acme/client1"
assert legacy_scope_path(scope) == legacy
The separator and the historical level labels belong to the legacy persistence
format. Do not derive a scope by splitting on /; use the compatibility pair
so a rollback continues to read rows written during migration. See the
executable legacy_scope_bridge.py.
2. Adapt the transcript store to ConversationSource¶
The working window reads recency-ordered turns from the deployment's store; it
does not create a new transcript store or infer speakers from recalled memory.
Implement the focused public protocol and pass it to WorkingWindow:
from collections.abc import Sequence
from symfonic.capabilities.memory import MemoryScope
from symfonic.capabilities.memory.recall import (
ConversationSource, ConversationTurn, WorkingWindow,
)
class TranscriptStore:
async def recent(
self, scope: MemoryScope, limit: int,
) -> Sequence[ConversationTurn]:
rows = await transcript_db.last_turns(scope.path, limit)
return tuple(
ConversationTurn(turn_id=row.id, speaker=row.role, text=row.text)
for row in rows
)
window = WorkingWindow(source=TranscriptStore(), recent_turns=2)
Return turns oldest first and preserve the store's real IDs and ordering.
Recall recognises a window turn only by its turn_id and speaker, and
Symfonic stores a turn's episodic copy under the run's run_id; a store whose
IDs differ from that run_id keeps rendering the recalled duplicate. The
fully runnable recipe uses an in-memory stand-in only so it can assert the
rendered two-turn window; replace that query with the deployment's transcript
read. See conversation_continuity.py.
3. Reuse a legacy fabrication implementation through governance¶
FabricationDetector is a small public port. The adapter in the runnable
recipe calls detect_fabrication and maps each actual legacy finding's kind,
reason, and confidence into public Finding values. GovernanceCapability
then produces the verified public REFUSE verdict for the sample unsupported
claim at the legacy confidence of 0.8.
This is an adapter, not a claim of complete legacy equivalence: the public
detector port receives only draft, grounding, and public tool-call records. It
does not invent the legacy function's optional user_message, intent verdict,
or identifier-key configuration. Provide identifier keys explicitly when the
deployment has them, and keep any behavior needing the omitted inputs on its
legacy route until that input has a public contract. See the executable
fabrication_adapter.py.
activation_log_mode is unrelated to these three adapter paths. It remains a
retired legacy-entry-point setting: non-default activation-label behavior is
not reintroduced by this guide, and its migration decision remains tracked
separately under issue #175.
| Capability | Entry point | What only you can supply |
|---|---|---|
| Memory | memory_capabilities(store, scope) |
the store and the scope |
| Prompting | persona_sources(), guardrail_sources() |
who the agent is, and its rules |
| Tools | keyword_router() + ToolsCapability |
which words surface which tool |
| Delegation | delegated_children([...]) |
the children, already built |
| Human interaction | human_interaction(signer=, binding=, encode_token=) |
see below |
| Governance | platform.governance(**ports) |
a reflector, a meter, objectors |
| Knowledge | knowledge_sources(...) → prompting |
a retriever, a store, documents |
| Extensions | platform.extensions([...]) |
the adapters |
| Telemetry | platform.telemetry(services, scope) |
the observability bundle and the scope |
Two of them sit in symfonic.platform rather than in their own package.
That is not an inconsistency: governance and extensions are each
contained by a rule their own suites enforce — a module inside may import
only from inside — and a contribute() needs the kernel's contribution
contracts. Composition is the platform layer's job, so the binding sits at
the boundary and the packages stay pure.
Write an adopter capability¶
CapabilityConfig is structural: any object with
contribute(request) -> CapabilityContribution can be passed to
Agent(capabilities=[...]). The public authoring types live at
symfonic.capabilities:
from symfonic import Agent, ContractViolationError
from symfonic.capabilities import (
CapabilityContribution,
Phase,
StageDescriptor,
no_change,
)
class BudgetExceeded(ContractViolationError):
preserve_contract_identity = True
class BudgetCapability:
def __init__(self, tracker, tenant_id):
self._tracker = tracker
self._tenant_id = tenant_id
def contribute(self, request):
stage_id = "adopter.budget"
stage = StageDescriptor(
stage_id=stage_id,
phase=Phase.PRE_MODEL,
capability=stage_id,
)
async def enforce(context):
decision = await self._tracker.check_budget(self._tenant_id)
if not decision.allowed:
raise BudgetExceeded(f"budget exceeded: {decision.reason}")
return no_change(
"tenant is within budget",
counts={"checked": 1},
)
return CapabilityContribution(
capability=stage_id,
stages=(stage,),
handlers={stage_id: enforce},
)
agent = Agent(
provider,
capabilities=[BudgetCapability(tracker, tenant_id="acme")],
)
PRE_MODEL runs once per model round, immediately before the provider call.
That makes it the right phase for a cumulative tracker that is updated between
rounds: an over-budget handler stops the turn before another model request.
An ordinary handler exception is wrapped as ContractViolationError and kept
as its __cause__. When callers need to distinguish a deliberate refusal,
subclass the public ContractViolationError and set
preserve_contract_identity = True, as BudgetExceeded does above; that exact
exception reaches the caller through the Agent facade from PRE_MODEL,
POST_MODEL, PRE_TOOL, POST_TOOL, and FINALIZE. PROMPT_ASSEMBLY is the
exception: its guard always wraps a handler failure, including an opted-in
subclass, and retains the original as __cause__. This gate also cannot
interrupt a completion already in flight, so keep the provider's own output
limit configured.
The six adopter-facing phases are, in order: PROMPT_ASSEMBLY, PRE_MODEL,
POST_MODEL, PRE_TOOL, POST_TOOL, and FINALIZE. BIND and TEARDOWN
are kernel-owned and reject adopter stages. A handler that observes without
changing the turn returns no_change(reason, counts=...). The other phases'
mutation envelopes remain capability-specific; use a shipped capability as
the starting point when the handler needs to alter prompts, model output, tool
calls, or final results.
Knowledge is not a capability. Its four sources have read(), which is
the shape the prompting sources have, so retrieved documents reach the model
through the prompt compiler rather than through a second stage competing for
the same budget.
human_interaction requires three arguments and defaults the rest,
because the capability contributes nothing without them and a factory that
returned that silently would hand you an agent whose model never sees
ask_user. A shared signer would make every deployment honour every other's
tokens; a PauseBinding with empty fields is a token bound to nothing —
pause refuses one, saying a token bound to no checkpoint could never
recover its own request; and the token format is yours to choose. Read the
pause off stream(): the blocking run() returns one finished result and
has no shape for a pause.
Bind the pause to the run that is pausing with current_run_binding.
binding takes no arguments and is called by the pause stage during the turn.
You own the pin, scope, session, thread and checkpoint and close over them. The
run's own ids come from the turn:
from symfonic.capabilities.human import current_run_binding, human_interaction
from symfonic.platform import EnvelopeSigner, InvocationPin, Keyset, KeyState
keys = Keyset()
keys.provision("app", secret=your_signing_secret, state=KeyState.ACTIVE)
signer = EnvelopeSigner(keys, producer_package_version="app")
pin = InvocationPin(...) # the pin your deployment issues
human_interaction(
signer=signer,
encode_token=encode,
decode_token=decode,
binding=lambda: current_run_binding(
pin=pin, scope=scope, session_id=session_id, thread_id=thread_id,
),
)
run_id is the pausing turn's id and root_run_id is its root, or the turn
itself when it is top-level. The helper takes no run_id and no
root_run_id, so passing one is a TypeError. It keeps only the two strings,
not the run. Called outside a turn, for example while composing the agent, it
raises InteractionConfigurationError instead of returning a binding with no
run. It proves nothing: AgentHost.resume still checks the token, the run's
lineage and the single claim, and a run it cannot prove is refused as before.
A hand-written PauseBinding(...) behaves exactly as it did.
Only the two run ids come from the turn. The pin, scope, session, thread and checkpoint are yours and are copied as given: they are signed into the pause, but the token, lineage and claim checks do not know which session or scope the resuming request expects. If a resume must belong to a particular session, compare it in your own authority (app-owned code, not something the SDK does for you).
EnvelopeSigner, InvocationPin, Keyset, KeyState and PinEnvelope on
symfonic.platform are the framework's existing signer classes, the same
objects, named on a public package so the snippet above needs no private
import and no hand-written EnvelopeSignerPort. Nothing supplies a key: the
secret is yours.
Your encode_token/decode_token choose the token format. To keep the token
outside memory, serialize minted.envelope (a PinEnvelope: its fields,
the pin's fields, the payload bytes and the signature) and build a new
PinEnvelope from them when decoding; return None for anything you cannot
parse. A rebuilt envelope is not trusted for being well-formed: the signer
verifies the signature over every field and the payload before the pause is
honoured, and a wrong key, edited field or missing signature is refused.
tests/platform/test_public_pause_bootstrap.py runs this composition on
public imports only, adapters included (tests/platform/public_pause_ports.py
stores the envelope as JSON in SQLite): a real Agent.stream pause, then
AgentHost.resume in the same process, once.
tests/platform/test_public_pause_codec.py shows forged and broken tokens
refused before any model call, effect or claim. A fresh process,
resume_stream, HTTP and budgets are not covered.
What these do not do yet¶
Stated here rather than left to be discovered:
- Credential hygiene examines nothing at ingress, and its real work is one rung later. A kernel turn request carries prompt, history, scope and attachments, none of which is the key/value bag it scrubs; the arguments of a call about to be admitted are. It is carried onto every rung for that reason, and a credential-shaped key stops the call before the tool sees it.
- An extension's policies are refused, not accepted and ignored. Nothing on a turn consults a plugin before an action, so composing one would leave you believing you have a veto you do not have.
- A fail-open stage that asks for a change it cannot make is allowed through, and says so in a diagnostic. A reflection that requested a revision and produced none is not the same as a critic that approved.
- A turn composed without
telemetry(...)is charged to nobody, and one composed without a scope is recorded against the run rather than a tenant, so no ceiling can fire.platform.attribution_is_boundanswers which you have.
Trust, and the default that protects you¶
A source wrapped for the compiler lands on a learned tier unless it
declares itself authored (untrusted = False on the source object, which
every source in capabilities.prompting sets). Authored tiers render
verbatim, so an unlabelled source — a retrieved document, a plugin's text —
would otherwise be read to the model as instruction you wrote. The safe tier
is the one you get without asking.
Domain-by-domain map¶
Each section names the legacy pattern (still supported), the layer that
now owns the behaviour, and the module you reach for when you want the
narrow contract instead of the facade. The D numbers are the
preservation-catalogue domains; the
reference carries the per-feature
rows.
D1 — Model providers, routing, and tuning¶
| Legacy pattern | Replacement |
|---|---|
symfonic.core.providers.AnthropicProvider (and the seven siblings) |
unchanged — provider adapters are an integration, and this is their canonical home |
| per-dispatch resolver hooks, role tables | symfonic.services.models.ModelResolutionService — one precedence chain over per-dispatch hooks, role tables, caller defaults, and the provider's own declaration, recording which won |
MultiProviderRouter |
unchanged — it is the ModelProvider that dispatches each call to a backing provider by model-name prefix. ModelResolutionService decides which ModelConfig a call uses and reads through the router to do it; the router then sends that config to the right provider. The two compose; neither replaces the other |
| "does this provider support thinking / forced tool choice / a custom http client?" answered by branching on the class | describe_provider(), detect_provider_family(), cache_dialect_for() |
Provider adapters keep their SDK imports behind their extras. The resolution service is pure: no I/O, no client construction, no credential reads.
D2 — Tools, registry, routing, policies, MCP¶
| Legacy pattern | Replacement |
|---|---|
@symfonic_tool, ToolRegistry |
unchanged |
| tool metadata re-derived by whoever needed it | symfonic.capabilities.tools.ToolCatalog — one reading of the registry, and the only place the agent-facing manifest is derived |
| intent triage, lazy routing, operator allowlists, role palettes, forced choice, registry policy each wired at a different call site | one ordered SelectionStage pipeline (IntentRoutingStage, LazyRoutingStage, AllowlistStage, RolePaletteStage, ForcedChoiceStage, PolicyStage) that records what every stage decided |
symfonic.tools.mcp wired by hand |
still there as the client; composition goes through symfonic.capabilities.extensions (D12) |
The order is a list you can read, not an emergent property of where the code happened to live.
D3 — Prompts, context, and the prompt-block layer¶
| Legacy pattern | Replacement |
|---|---|
PromptBuilder, CacheBlock, CacheablePrompt, the block surface |
unchanged, and public at three package roots: symfonic.core.prompt, symfonic.core.prompt.blocks and symfonic.core.prompt.blocks.sources. Each defines an __all__ that is the contract. Their submodules (sections, blocks.render, .resolver, .snapshot, .taxonomy) are internal — import from the package root, or your generated tests/test_public_api_imports.py will refuse the import |
ContextManager / JITContextManager / StratifiedHMSContextManager chosen by strategy name |
symfonic.capabilities.prompting.compile_prompt — one pipeline: accept, validate, admit, resolve, gate, order, budget, region, freeze |
| every producer deciding its own position, budget share, and cache tier | producers declare a contribution; position, budget, cache region, and untrusted-content wrapping are decided once, over the whole set |
Untrusted content keeps its delimiter neutralisation, tier/trust mismatch errors, and the child self-edit denial — those are contracts, not defaults.
D4 — The five-layer HMS¶
| Legacy pattern | Replacement |
|---|---|
symfonic.memory.MemoryOrchestrator, the five layers, GraphMemoryStore, embeddings, Postgres backends |
unchanged |
| the engine calling memory to hydrate, a post-response path calling it to extract, a scheduler calling it to consolidate | symfonic.capabilities.memory — three narrow ports plus a bridge that declares stages instead of being called |
| "run this agent without memory" as a code fork | absence of the capability |
| ranking, floors, scope blend, and spreading activation decided inline | .coordinator / .hydration / .activation |
| extraction, owned writes, consolidation, promotion, entity linking | .extraction, .writes, .consolidation |
Everything these services persist stays readable by the legacy path
(.compat), which is the property wave rollback depends on.
D5 — Knowledge, documents, attachments, multimodal¶
| Legacy pattern | Replacement |
|---|---|
symfonic.knowledge provider protocol, InMemoryDocumentStore, symfonic.agent.attachments |
unchanged |
| a knowledge provider formatting its own citation lines; a document store building its own block; extractors returning unbounded text; OCR output going wherever the caller put it | symfonic.capabilities.knowledge — each becomes a contribution that states what it supplies and how far it may be trusted, never where it goes |
| media-type normalisation duplicated per call site | .multimodal / .outbound, with explicit allowed media types and URL schemes |
D6 — History, sessions, transcripts, checkpoints, structured output¶
| Legacy pattern | Replacement |
|---|---|
ConversationManager and the three strategies |
symfonic.services.conversation.history |
SessionManager, get_transcript() |
.session, .transcript |
symfonic.agent.checkpointer factories (memory / sqlite / postgres / mongo) |
unchanged as adapters; readiness, registry, and restart recovery are .checkpoint, .registry, .recovery |
extract_structured_output |
unchanged; the kernel's StructuredOutputAdapter is the same behaviour on the compiled path, and Agent.run(output_type=...) is its simple form |
State written by either path is readable by the other, through an additive envelope under one reserved metadata key.
D7 — Sub-agents, delegation, lockdown, snapshots¶
| Legacy pattern | Replacement |
|---|---|
SubAgent, SubAgentSpec, SubAgentRegistry |
unchanged |
| inheritance, sanitisation, ownership, and duplicate refusal spread across a registry, a tool factory, three context variables on the parent, and a teardown sweep in two dunder methods | symfonic.capabilities.delegation — declarations, a child compiler, a roster, a depth ceiling, a per-run scope, and the run_agent / list_agents tool surface |
assert_no_block_edit_surface, deny_child_self_edit |
unchanged, and still unconditional: a child never gets a block-edit surface |
D8 — ask_user, interrupts, pause tokens, resume¶
| Legacy pattern | Replacement |
|---|---|
AskUserQuestionEvent / InterruptEvent contracts, POST /resume/{pause_token} |
unchanged |
| a tool module, a graph node, two contract modules, a token facade with a process-wide default manager, three consumption stores, and two nearly-identical resume methods | symfonic.capabilities.human — registration, binding, tokens, consumption, ledger, drain, resume |
| replay resistance implemented per store | one atomic conditional write on the adopter's backend, with a durability check |
Expiry, replay resistance, and scope binding are hard invariants carrying mutation evidence: the suites are required to fail when the guarantee is deliberately broken.
D9 — Safety, governance, budgets, audit¶
| Legacy pattern | Replacement |
|---|---|
a scrubber in agent/hygiene.py, an intent filter threaded through three engine entry points, a fabrication detector and a metacognitive critic in agent/middleware/, a precondition gate in core/nodes/, steering in the tool span seam |
symfonic.capabilities.governance — seven stages, one canonical order, one declared failure mode each, and one record of what was decided about a turn |
| budget checks inline on the hot path | symfonic.services.budget (context-window budget: estimation, allocation, truncation — offline by construction) and symfonic.services.cost (money: may this scope incur another billable call) |
AuditLogger module globals |
symfonic.platform.audit with an injected sink |
Ordering is the one property no individual stage can enforce, so the pipeline refuses a composition that contradicts the rulebook.
D10 — Callbacks, events, streaming, metrics, cost, traces, OTel¶
| Legacy pattern | Replacement |
|---|---|
CallbackHandler, the typed event contracts, symfonic.core.streaming |
unchanged |
| five consumers with five sources: hand-placed emission sites, a boolean capture flag read at four of them, two cost calculations, a trace context that only existed with an optional extra | symfonic.services.observability — one source (KernelEvent), six narrow ports, one bridge that owns fan-out, error isolation, and terminal cardinality |
symfonic.observability.otel |
unchanged; it is one observer among the six |
An observability failure cannot fail a run, and with nobody watching the
sink is None — the disabled path allocates nothing.
D11 — Tenancy, HTTP API, privacy, billing¶
| Legacy pattern | Replacement |
|---|---|
the 40 routes on create_agent_router and friends (default prefix /api/v1) |
unchanged |
set_tenant_auth_verifier as a module global |
symfonic.platform.scope.HeaderScopeResolver, constructed and injected, so two agents in one process no longer share one verifier (the legacy function still works) |
str(exc).startswith("Budget exceeded:") branching plus a raw header read |
symfonic.platform.budget.BudgetService through the narrow BudgetCheck port with a typed refusal |
| privacy export/deletion coupled to the HTTP layer | symfonic.platform.privacy at the edge, symfonic.services.privacy for the erasure fence and subject-store registry a background worker can use without importing HTTP |
| transport details spread through the routers | symfonic.agent.fastapi.transport — codec, frames, SSE, error mapping, policies |
The platform derives who the caller is, decides whether the request may proceed, and hands the request to the public facade. It implements nothing an invocation does.
D12 — Plugins, schedulers, CLI, scaffolding, examples, testing¶
| Legacy pattern | Replacement |
|---|---|
BaseDomainPlugin + agent.load_plugin(...) mutating the engine |
symfonic.capabilities.extensions — an MCP server and a domain plugin are the same thing: code the framework did not write, wanting to add to what the agent can do. Both declare tools, prompt fragments, policies, and lifecycle hooks; compose() is a pure function to one frozen value. LegacyPluginBridge keeps existing plugins working |
TaskSchedulerProtocol, NullScheduler |
unchanged (symfonic.infra) |
symfonic CLI, symfonic init, templates |
unchanged |
audit_hms, audit_hms_sync, AuditRunner (deprecated: they warn and still run) |
No equivalent. The supported path is symfonic doctor PROJECT_PATH --scope <tenant path> for a composed Agent or symfonic doctor --offline --config <file> for a configuration. Neither reproduces the five legacy checks: the doctor without --offline lists them as UNKNOWN, and --offline omits them. Plus symfonic.devtools for the architecture checks the repository runs on itself |
MockChatModel, MockModelProvider |
unchanged |
Installing an extension changes nothing else — which is now an assertion a test can make, because composition is a pure function rather than a mutation.
How to adopt incrementally¶
Migrating the legacy defaults¶
The legacy field list is not a list of always-running behaviours. The executable legacy-default parity table records each field's trigger, public composition, evidence, or the issue that still owns the gap. Start there before treating a default-valued legacy field as migration work.
The verified subset has one composition root:
from symfonic.migration import SUPPORTED_DEFAULT_BEHAVIORS, compose_legacy_defaults
agent = compose_legacy_defaults(
provider,
store=store,
scope=scope,
behaviors=SUPPORTED_DEFAULT_BEHAVIORS,
conversation=conversation,
extractor=extractor,
activation=activation,
consolidation=consolidation,
)
Those dependencies are deliberately required. Calling the factory with no
behaviors requests the entire legacy-default claim and raises a named
refusal for every unresolved row; it does not return an agent with defaults
silently missing.
- Code already on
Agent,AgentHostor a capability package needs no further move. Adopt another rung when it buys you something. This does not cover the legacy entry points: from 11.3.0SymfonicAgent(...)andAgentBuilder().build()raiseEntryPointRemovedError, so code that constructs either one must move as item 2 describes. - New code starts at rung 1.
from symfonic import Agentfor stateless work. For memory, a conversation window or human interaction, pass those capabilities to the sameAgent(rung 2, above); for tenancy and HTTP, host it withsymfonic.platform.AgentHost. An existingFrameworkConfigcarries over throughsymfonic.migration.agent_from_config. Do not drop toSymfonicAgent.symfonic-core==9.12.*is the newest public PyPI line on which either entry point still constructs (private 11.0 to 11.2.6 did too). It predates 11.x, so pinning it does not migrate an 11.x application. - Take one capability at a time. Each has its own narrow contract and
its own contract suite; you can adopt
capabilities.toolswithout touching how you do memory. - Move module globals to injection when you touch them. The auth verifier, the audit logger, and the budget tracker all have injected forms now. This is the one class of change that matters even when nothing breaks: process-global mutable configuration is why two agents in one process could share state they should not.
- Check yourself with the diagnostics.
symfonic doctor --offlinereports legacy configuration, missing extras, and architecture misconfiguration offline — no network, no hidden I/O.audit_hmsis deprecated and warns, pointing atsymfonic doctoras the supported path. The doctor does not reproduce its five checks (embedding_provider.stub,orchestrator.layers,chat_model.resolution,lazy_tooling.orphan,consolidate.schedule) either way: without--offlineit lists each asUNKNOWN, and--offlineomits them. ASymfonicAgentno longer constructs from 11.3.0, so there is no instance foraudit_hmsto audit there; an application that relied onaudit_hmson an earlier line loses those findings when it moves to the doctor, including theEMBEDDING_PROVIDERstub error.
--offline answers eight questions without constructing an agent,
importing a line of your project, or opening a socket: which of your
configuration keys are legacy spellings and what each becomes, which
cross-capability rules your configuration breaks, which packaging
extras it commits you to that are not installed, which state a restart
or a second replica will lose, which consequential values you never
chose, which imports have a documented new owner, where the production
auth gate will not fire, and which imports reach past what the package
declares. --config is optional: without it the source and platform
checks still run, and every question with no input is reported as not
inspected rather than as clean.
Run it before you change anything. It is the list this guide's domain-by-domain map applies to your project.
If something is missing¶
It should not be. If you cannot find a feature in the Feature Preservation Reference, that is a defect in the migration, not a decision — the catalogue's ground rule is that nothing is dropped implicitly, and the reference is the list the refactor is measured against.