symfonic.capabilities.extensions¶
extensions ¶
Extension composition: MCP servers and domain plugins as typed contributions.
Satisfies plan task T4.2.2. An MCP server and a domain plugin are the same thing from the framework's side — code the framework did not write, wanting to add to what the agent can do — and both now do it the same way: they declare tools, prompt fragments, policies, and lifecycle hooks, and the composer decides what survives.
Nothing here mutates an engine, patches a method, or registers into global
state. :func:~.composition.compose is a pure function from contributions to
one frozen value, which is what makes "installing this extension changed
nothing else" an assertion a test can make.
Start at :mod:~symfonic.capabilities.extensions.contracts for the contract an
extension author writes against.
ComposedExtensions
dataclass
¶
ComposedExtensions(extensions: tuple[str, ...] = (), tools: tuple[ToolContribution, ...] = (), prompts: tuple[PromptFragment, ...] = (), policies: tuple[PolicyContribution, ...] = (), lifecycle: tuple[LifecycleContribution, ...] = (), diagnostics: tuple[ExtensionDiagnostic, ...] = ())
What a set of extensions adds up to, and what was refused on the way.
refusals
property
¶
Only the error-severity diagnostics: what an operator must see.
decide
async
¶
Combine every applicable policy's verdict, deny-wins.
Answers only the combined verdict. Use :meth:decide_detailed when the
caller needs to know which guards failed to answer as well.
Source code in src/symfonic/capabilities/extensions/composition.py
decide_detailed
async
¶
Ask every applicable policy and report the verdict with the abstentions.
The first DENY short-circuits: once one policy has refused, asking the rest costs latency on a turn that is already refused, and no later verdict could change the answer. The abstentions reported are therefore those gathered before the refusal, which is the whole set whenever nothing denied.
A policy that raises abstains. That is the legacy engine's fail-open behaviour preserved on purpose — flipping it to fail-closed would turn a broken guard into a total outage — but the abstention is now a value the caller can see rather than a DEBUG log nobody reads.
Source code in src/symfonic/capabilities/extensions/composition.py
install
async
¶
Run every install hook. A hook that raises costs a diagnostic.
teardown
async
¶
Run every teardown hook, in reverse declaration order.
Reverse because unwinding is the inverse of building: an extension installed after another may depend on it, and closing in declaration order would tear the dependency out first.
Source code in src/symfonic/capabilities/extensions/composition.py
ContributionKind ¶
Bases: StrEnum
The four things an extension may contribute, and there is no fifth.
ExtensionContractError ¶
Bases: ExtensionError
A contribution's own declaration is malformed.
Raised at declaration time — when the contribution value is validated — not at use time. A tool with no name is not a tool that fails when called; it is a tool that should never have been admitted to the catalogue.
ExtensionContribution
dataclass
¶
ExtensionContribution(extension: str, tools: tuple[ToolContribution, ...] = (), prompts: tuple[PromptFragment, ...] = (), policies: tuple[PolicyContribution, ...] = (), lifecycle: tuple[LifecycleContribution, ...] = ())
Everything one extension offers, in one value.
An extension is asked, it answers, and the answer is validated, ordered, and refused as a unit. Nothing here holds a reference to an engine, a registry, or a graph, which is why an extension cannot install itself.
build
classmethod
¶
build(extension: str, *, tools: Sequence[ToolContribution] = (), prompts: Sequence[PromptFragment] = (), policies: Sequence[PolicyContribution] = (), lifecycle: Sequence[LifecycleContribution] = ()) -> ExtensionContribution
Build a bundle from any sequences, freezing each into a tuple.
Source code in src/symfonic/capabilities/extensions/contracts.py
validate ¶
Validate the bundle, every member, and the attribution of each.
Attribution is checked here rather than on each member because it is a property of the pair: a well-formed tool contributed under someone else's name is exactly the confused-deputy shape AS-INT-5 is about, and the member alone cannot see that it was misfiled.
Source code in src/symfonic/capabilities/extensions/contracts.py
ExtensionDiagnostic
dataclass
¶
ExtensionDiagnostic(extension: str, kind: ContributionKind, detail: str, subject: str = '', severity: Severity = Severity.WARNING)
One thing the composer decided, in names, for the record.
Diagnostics are how a dropped contribution stays visible. Nothing is
discarded silently: every refusal, truncation, and remap emits one of these,
and :class:~.composition.ComposedExtensions carries them alongside what
survived.
ExtensionError ¶
Bases: ConfigurationError
Root of the extension-composition taxonomy.
Never raised directly. It exists so a composition root can write one
except ExtensionError around the whole compose step and know it caught
every way an extension can refuse to be composed.
ExtensionProvider ¶
Bases: Protocol
What the composer asks. Two members, both deliberate.
name is a declared member so an isinstance check requires it: an
anonymous provider produces contributions nobody can revoke.
contribute is synchronous. Anything an extension needs to discover
over the network happens before composition — the MCP adapter's
discover() is its own async step — so composition itself is a pure
function over values, replayable and comparable without an event loop.
LegacyPluginBridge ¶
One legacy plugin, read as an extension provider.
Source code in src/symfonic/capabilities/extensions/bridge.py
contribute ¶
The bundle, using whatever the last :meth:harvest produced.
Source code in src/symfonic/capabilities/extensions/bridge.py
contribute_for
async
¶
Harvest this turn's prompts, then answer the whole bundle.
harvest
async
¶
Call the plugin's prompt hook once and keep what it answered.
lifecycle ¶
Bind whichever optional lifecycle hooks the plugin actually defines.
Source code in src/symfonic/capabilities/extensions/bridge.py
policies ¶
One policy when the plugin has a guard, none when it does not.
A plugin without validate_state_transition contributes no policy
rather than an always-allow one. An always-allow stage in a trace is
indistinguishable from a stage that examined the action and approved.
Source code in src/symfonic/capabilities/extensions/bridge.py
tools ¶
LegacyPluginPort ¶
Bases: Protocol
The pre-refactor domain-plugin surface, declared structurally.
Every member is optional at runtime; the bridge probes. The declaration
exists so the expected signatures are written down somewhere a plugin
author can read, and so a type checker can flag a plugin whose
validate_state_transition takes the wrong arity before it silently
fails open on every turn.
LifecycleContribution
dataclass
¶
LifecycleContribution(hook_id: str, extension: str, phase: LifecyclePhase, run: Callable[[], Awaitable[None] | None])
A hook the composition runs when it opens or unwinds.
Lifecycle is a contract rather than a convention because the alternative is what the legacy path did: nothing. A plugin holding an HTTP client had no place to close it, so the client closed when the process did.
LifecyclePhase ¶
Bases: StrEnum
When a lifecycle hook runs relative to the composition that owns it.
McpExtensionAdapter ¶
McpExtensionAdapter(name: str = 'mcp', *, servers: Mapping[str, McpServerPort] | None = None, limits: TrustLimits | None = None)
One extension backed by one or more pinned MCP servers.
Source code in src/symfonic/capabilities/extensions/mcp.py
add_server ¶
Pin one server under a name the deployment chose (AS-INT-5).
The name is configuration, never payload: it prefixes every tool the server advertises, so a server cannot name itself into another server's namespace by answering cleverly.
It may not contain the . that joins it to a tool name. Otherwise the
mapping (server, tool) -> "{server}.{tool}" stops being injective —
servers a and a.b would both be able to produce a.b.c, and
the payload-supplied half of the name would decide which server won
the exposed name, which is the legacy takeover this adapter closed.
Source code in src/symfonic/capabilities/extensions/mcp.py
close
async
¶
Close every pinned server, surviving individual failures.
Source code in src/symfonic/capabilities/extensions/mcp.py
contribute ¶
Return the frozen contribution built by the last discover().
Calling it before discovery answers a contribution with no tools rather than raising. An adapter whose servers were never reached contributes nothing, which is the same outcome as an adapter with no servers, and both are ordinary deployments rather than errors.
Source code in src/symfonic/capabilities/extensions/mcp.py
discover
async
¶
Read every server's tool list and build the contribution.
Re-discovery replaces the previous reading rather than merging into it. The legacy provider accumulated, so a tool a server had withdrawn stayed callable for the life of the process.
Source code in src/symfonic/capabilities/extensions/mcp.py
McpServerPort ¶
Bases: Protocol
One MCP server, as the adapter uses it.
An implementation answers raw JSON-shaped mappings; every field is untrusted until the boundary has read it.
call_tool
async
¶
close
async
¶
list_tools
async
¶
Return the server's advertised tools as raw payloads.
Each payload is expected to carry name and optionally
description / inputSchema, but the adapter assumes nothing:
a payload that is not a mapping is refused, not coerced.
Source code in src/symfonic/capabilities/extensions/ports.py
PolicyContribution
dataclass
¶
PolicyContribution(policy_id: str, extension: str, decide: Callable[[PolicyRequest], Awaitable[PolicyVerdict]], actions: frozenset[str] = frozenset())
One extension's veto over agent actions.
decide answers a :class:~.values.PolicyVerdict. An ALLOW from a
contributed policy is not authority: the composer combines verdicts
deny-wins and treats every non-deny as "this policy had no objection", so a
plugin can narrow what the agent does and can never widen it (AS-INT-3).
actions empty means "every action". Naming actions is the cheap way to
keep a policy off hot paths it has no opinion about.
PolicyDecision ¶
Bases: StrEnum
One contributed policy's answer about one action.
PolicyOutcome
dataclass
¶
The combined verdict and every policy that failed to answer.
Exists because "nobody objected" and "the only guard that had an opinion
was down" are different facts about a turn, and
:meth:ComposedExtensions.decide can only return one verdict. An
abstention recorded here is the value that keeps a permanently broken guard
distinguishable from an approving one.
PolicyRequest
dataclass
¶
What a contributed policy is asked about: one action, once.
context is frozen at construction, all the way down: a policy answers a
question, it does not edit the question. Freezing rather than copying is
what makes that a property instead of a claim — a shallow copy leaves every
nested value writable, so a policy handed the live tool arguments could
rewrite the very call it was only allowed to veto (AS-INT-3), and the
caller's own mapping would come back mutated.
The caller's mapping is never touched: the frozen view is built over a fresh dict, so whoever passed the context still holds their own mutable one.
PolicyVerdict
dataclass
¶
One policy's answer, with the reason that makes it auditable.
PrivilegeWideningError ¶
Bases: ExtensionError
A contribution asked for authority the contributor does not have.
The three cases the composer knows about, all from AS-INT-3: a prompt fragment declaring an authored trust tier, a plugin returning tools after the catalogue is closed, and a policy claiming it can grant rather than only refuse.
PromptFragment
dataclass
¶
PromptFragment(fragment_id: str, text: str, extension: str, layer: str = 'l1', tier: str = 'session', scope: str = 'deployment', order: int = 100, truncated: bool = False)
Text an extension contributes to the compiled prompt.
The fields mirror the prompt compiler's contribution contract by name — layer, tier, scope, order — because the composition root's job is then a lookup rather than a translation. What it does not mirror is the tier range: a contributed fragment is restricted to the learned tiers, so no extension can place text where the model reads operator instruction.
Severity ¶
Bases: StrEnum
How loudly a composition diagnostic should read.
ERROR is reserved for a refusal an operator must see — a shadowed tool
name, a rejected payload — and never used for an extension declining to
contribute, which is ordinary.
ToolContribution
dataclass
¶
ToolContribution(name: str, extension: str, invoke: Callable[[Mapping[str, Any]], Awaitable[str]], description: str = '', input_schema: Mapping[str, Any] = dict(), origin: str = '')
One callable an extension offers the agent.
invoke is an async callable taking the bound arguments and answering a
string. It is captured at declaration time and never looked up again: the
legacy MCP provider routed each call through a mutable name→server dict, so
a later discovery could re-point an already-advertised tool at a different
server. Holding the callable makes that unrepresentable.
TrustLimits
dataclass
¶
TrustLimits(max_identifier_length: int = 128, max_description_length: int = 8192, max_schema_bytes: int = 65536, max_schema_depth: int = 16, max_tools_per_server: int = 512, max_result_length: int = 262144, max_result_parts: int = 64)
The ceilings every untrusted payload is read under.
Defaults are generous enough that a well-behaved server never notices them and small enough that a hostile one cannot exhaust the process. They are a value rather than module constants so a deployment facing an unusually chatty server can raise one without patching the framework.
UntrustedPayloadError ¶
Bases: ExtensionError
A payload from an MCP server or a plugin failed boundary validation.
Per AS-INT-2 the rejection is typed and names the offending field, because the recipient of this error cannot fix the server that sent it and needs enough detail to report the problem to whoever can.
compose ¶
compose(contributions: Sequence[ExtensionContribution], *, reserved_tool_names: frozenset[str] = frozenset()) -> ComposedExtensions
Validate, de-duplicate, and order every contribution into one value.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
contributions
|
Sequence[ExtensionContribution]
|
The bundles, in the order the deployment declared them. Order is the tie-break for every collision, so it is the deployment's statement of precedence. |
required |
reserved_tool_names
|
frozenset[str]
|
Names the host already owns. A contributed tool claiming one is refused. |
frozenset()
|
Raises:
| Type | Description |
|---|---|
ExtensionContractError
|
A contribution is malformed. |
PrivilegeWideningError
|
A contribution claims authority it lacks — an authored tier, a foreign attribution, the kernel layer. |