Skip to content

symfonic.platform.extensions

extensions

Extensions as something a composition root can compose.

Why this lives in platform. The extensions package is contained by its own suite: it may import only itself and the shared error taxonomy, so that a capability cannot reach into the engine, the legacy plugin surface or a transport. Binding it to the kernel needs the contribution contracts and the prompting types, and putting those imports inside would relax a rule that is doing its job -- the same reason the governance door sits here.

The package already merges providers: each one answers contribute() with an :class:ExtensionContribution, and :func:compose folds them into one ComposedExtensions with the name collisions resolved. What was missing was the step after -- turning that into the kernel's CapabilityContribution -- and the collision is why nobody noticed. ExtensionProvider.contribute() takes no argument and returns an extension contribution; the kernel's contribute(request) takes one and returns a capability contribution. Two different methods with one name, so a structural check for "exports something with contribute()" reports this package as composable and it is not.

What an extension may contribute, and what it may not.

Tools and prompt fragments pass through, by different routes. Tools go in the capability contribution, which has a field for them. Fragments come out of :attr:ExtensionCapability.sources and are handed to the prompting capability, because that is what compiles a prompt and a capability contribution has no field for one. Both are bounded: a tool is admitted by name against the reserved set, and a fragment carries its own layer, tier and order, so a plugin cannot promote its text above the deployment's own.

Lifecycle hooks are delivered, but not through a stage. install runs when the extensions are composed and teardown when :meth:aclose is called -- the close path the host already walks, since teardown is a kernel-owned phase no capability may register into. The MCP adapter's only hook is mcp.close, so refusing it outright would have blocked the commonest adapter over a hook that had somewhere to go all along.

Policies do not pass. Nothing on a kernel turn asks a plugin to veto an action, so an extension that declared one would be a deployment believing it has a check it does not have. Those are refused at composition: a policy that never runs is not a weaker guarantee than one that does, it is a false one.

Nothing here widens authority. Grants stay whatever the plan already granted, which is the bound the fold's grant checking exists to enforce -- a capability that could ask for more by being composed would make the grant list a description rather than a limit.

ExtensionCapability

ExtensionCapability(composed: Any)

The composed extensions, in the shape Agent accepts.

Source code in src/symfonic/platform/extensions.py
def __init__(self, composed: Any) -> None:
    self._composed = composed

composed property

composed: Any

The merged extension set, for a caller that wants the diagnostics.

sources property

sources: tuple[Any, ...]

The extensions' prompt fragments, for PromptingCapability.

capabilities=[
    exts,
    PromptingCapability(sources=[*persona, *exts.sources]),
]

Every fragment is declared untrusted. A plugin's text is not the deployment's own, and rendering it verbatim at an authored tier is how an extension writes instructions nobody in the deployment approved.

aclose async

aclose() -> None

Run the teardown hooks the composed extensions declared.

Called by whatever owns this capability -- the host, for a generated app. teardown is a kernel-owned phase, so a capability cannot register a stage there; the hook reaches its moment through the close path instead. Every hook runs even if an earlier one raises, because a server left open by a failed close is worse than a traceback.

Source code in src/symfonic/platform/extensions.py
async def aclose(self) -> None:
    """Run the ``teardown`` hooks the composed extensions declared.

    Called by whatever owns this capability -- the host, for a generated
    app. ``teardown`` is a kernel-owned phase, so a capability cannot
    register a stage there; the hook reaches its moment through the close
    path instead. Every hook runs even if an earlier one raises, because a
    server left open by a failed close is worse than a traceback.
    """
    failures: list[BaseException] = []
    for hook in self._composed.lifecycle:
        if str(hook.phase) != "teardown":
            continue
        try:
            outcome = hook.run()
            if hasattr(outcome, "__await__"):
                await outcome
        except Exception as failed:  # noqa: BLE001, PERF203
            failures.append(failed)
    if failures:
        raise ExtensionContractError(
            f"{len(failures)} extension teardown hook(s) failed; the first "
            f"was {failures[0]!r}"
        )

contribute

contribute(request: Any) -> Any

Offer the extensions' tools to the turn.

Tools only. CapabilityContribution carries tools, stages, handlers and grants -- there is no field for a prompt contribution, because the prompt is compiled by the prompting capability from sources. So the fragments come out of :attr:sources instead and go where every other source goes, which is the same answer the knowledge door reached.

request is read for its grants and found to need none: an extension contributes what it declared and performs no effect of its own.

Source code in src/symfonic/platform/extensions.py
def contribute(self, request: Any) -> Any:
    """Offer the extensions' tools to the turn.

    Tools only. ``CapabilityContribution`` carries tools, stages, handlers
    and grants -- there is no field for a prompt contribution, because the
    prompt is compiled by the prompting capability from *sources*. So the
    fragments come out of :attr:`sources` instead and go where every other
    source goes, which is the same answer the knowledge door reached.

    ``request`` is read for its grants and found to need none: an extension
    contributes what it declared and performs no effect of its own.
    """
    from symfonic.kernel.contracts.contributions import CapabilityContribution

    # #147: policies too, not tools only. A contributed policy used to
    # compose cleanly and be consulted before nothing, because this method
    # offered no stage for it to be asked in. Contributed only when there
    # is a policy: a deployment with none pays for no rung.
    stages: tuple[Any, ...] = ()
    handlers: dict[str, Any] = {}
    if getattr(self._composed, "policies", ()):  # noqa: PLC0415
        from symfonic.platform.extension_policy_stage import (
            POLICY_STAGE_ID,
            policy_handler,
            policy_stage,
        )

        stages = (policy_stage(),)
        handlers = {POLICY_STAGE_ID: policy_handler(self._composed)}

    return CapabilityContribution(
        capability="extensions",
        tools=tuple(_executable(tool) for tool in self._composed.tools),
        stages=stages,
        handlers=handlers,
    )

extensions

extensions(providers: Sequence[Any], *, reserved_tool_names: frozenset[str] = frozenset()) -> ExtensionCapability

Compose extension providers into one capability.

Agent(provider, capabilities=[extensions([McpExtensionAdapter(...)])])

Parameters:

Name Type Description Default
providers Sequence[Any]

objects answering the extension contribute() -- an McpExtensionAdapter, a LegacyPluginBridge, or an adopter's own.

required
reserved_tool_names frozenset[str]

names the deployment has already bound, so an extension cannot shadow one.

frozenset()

Raises:

Type Description
ExtensionContractError

if a provider declares a policy. Nothing on a kernel turn consults one, and accepting it would report a check that never runs.

Source code in src/symfonic/platform/extensions.py
def extensions(
    providers: Sequence[Any], *, reserved_tool_names: frozenset[str] = frozenset()
) -> ExtensionCapability:
    """Compose extension providers into one capability.

        Agent(provider, capabilities=[extensions([McpExtensionAdapter(...)])])

    Args:
        providers: objects answering the extension ``contribute()`` -- an
            ``McpExtensionAdapter``, a ``LegacyPluginBridge``, or an adopter's
            own.
        reserved_tool_names: names the deployment has already bound, so an
            extension cannot shadow one.

    Raises:
        ExtensionContractError: if a provider declares a policy. Nothing on a
            kernel turn consults one, and accepting it would report a check
            that never runs.
    """
    contributions = [provider.contribute() for provider in providers]
    with_policies = [c.extension for c in contributions if c.policies]
    if with_policies:
        raise ExtensionContractError(
            f"these extensions declare policies the kernel has no seam to ask: "
            f"{', '.join(with_policies)}. Nothing on a turn consults a plugin "
            "before an action, so composing them would report a check that "
            "never runs. Compose without them, or keep the extension on the "
            "compatibility route until the seam exists."
        )
    return ExtensionCapability(
        compose(contributions, reserved_tool_names=reserved_tool_names)
    )