Skip to content

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:

  1. 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.
  2. Absence means disabled. Optional behaviour attaches by passing a typed configuration object, not by flipping a boolean on a mega-config. No module under symfonic reaches 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 in sys.modules before symfonic runs a line, and no change here can make a blanket "nothing is imported" claim true.)

Start at rung 1

from symfonic import Agent

agent = Agent(provider, tools=[...])
result = await agent.run("...")

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_bound answers 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

  1. Do nothing. Everything above still runs. Adopt when a rung buys you something.
  2. New code starts at rung 1. from symfonic import Agent for stateless work; drop to SymfonicAgent when you need memory, sessions, or the platform.
  3. Take one capability at a time. Each has its own narrow contract and its own contract suite; you can adopt capabilities.tools without touching how you do memory.
  4. 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.
  5. Check yourself with the diagnostics. symfonic doctor and audit_hms report legacy configuration, missing extras, and architecture misconfiguration offline — no network, no hidden I/O.
symfonic doctor --offline . \
    --config my-config.json \
    --migration-report migration-inspection.md

--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.