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. Their presence does not imply that every historical configuration argument is accepted. Existing validation and retirement refusals still apply; this documentation adds none.
- 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.
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. If you already run
SymfonicAgent, you are on rung 2+ and nothing forces you down.
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.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 extensions, governance
agent = Agent(
provider,
tools=TOOLS,
capabilities=[
*memory_capabilities(GraphBackedHms(graph_backend), scope),
PromptingCapability(sources=[
*persona_sources(DomainPersona(name="store", role="copilot")),
*guardrail_sources([Guardrail(statement="never invent a number")]),
*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),
extensions([McpExtensionAdapter(servers=servers)]),
],
)
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)
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.
| 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.
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 |
MultiProviderRouter, 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 |
| "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, symfonic doctor |
symfonic.diagnostics, 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¶
- Do nothing. Everything above still runs. Adopt when a rung buys you something.
- New code starts at rung 1.
from symfonic import Agentfor stateless work; drop toSymfonicAgentwhen you need memory, sessions, or the platform. - 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 doctorandaudit_hmsreport legacy configuration, missing extras, and architecture misconfiguration offline — no network, no hidden I/O.
--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.