symfonic.capabilities.extensions.contracts¶
contracts ¶
The extension contribution contract — the port every extension writes against.
An MCP server and a domain plugin are the same thing from the framework's side:
an extension — code the framework did not write, running inside the framework's
process, wanting to add to what the agent can do. Before this contract there
were two answers to "how does it add something", and neither was a contract. The
plugin path appended to a list on the live engine (engine.load_plugin →
self._plugins.append → self._plugin_section.add_plugin) and had its
output rendered by name; the MCP path handed back LangChain tool objects for
someone to splice in wherever they could.
An extension now declares, and the composer decides. What it may declare is four kinds and no fifth:
=============================== ==========================================
:class:~.declarations.ToolContribution a callable the agent may invoke
:class:~.declarations.PromptFragment text placed in the compiled prompt
:class:~.declarations.PolicyContribution a veto over an action
:class:~.declarations.LifecycleContribution a hook run at install or teardown
=============================== ==========================================
The four are re-exported here so an extension author has one import line, and the two things that make them a contract rather than a data format live here:
- :class:
ExtensionContribution— the whole answer as one value. One value rather than four registration calls, because a registration call is a mutation and the point of this task is that composing an extension mutates nothing. - :class:
ExtensionProvider— the two-member protocol the composer asks. It is structural, so an adopter's existing object satisfies it without inheriting anything from the framework.
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
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.
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.
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.
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.
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.