Skip to content

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

refusals: tuple[ExtensionDiagnostic, ...]

Only the error-severity diagnostics: what an operator must see.

decide async

decide(request: PolicyRequest) -> PolicyVerdict

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
async def decide(self, request: PolicyRequest) -> PolicyVerdict:
    """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.
    """
    return (await self.decide_detailed(request)).verdict

decide_detailed async

decide_detailed(request: PolicyRequest) -> PolicyOutcome

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
async def decide_detailed(self, request: PolicyRequest) -> PolicyOutcome:
    """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.
    """
    abstentions: list[PolicyVerdict] = []
    for policy in self.policies:
        if not policy.applies_to(request.action):
            continue
        verdict = await self._ask(policy, request)
        if verdict.denied:
            return PolicyOutcome(verdict=verdict, abstentions=tuple(abstentions))
        if verdict.abstained:
            abstentions.append(verdict)
    return PolicyOutcome(
        verdict=PolicyVerdict.allow(reason="no contributed policy objected"),
        abstentions=tuple(abstentions),
    )

install async

install() -> tuple[ExtensionDiagnostic, ...]

Run every install hook. A hook that raises costs a diagnostic.

Source code in src/symfonic/capabilities/extensions/composition.py
async def install(self) -> tuple[ExtensionDiagnostic, ...]:
    """Run every install hook. A hook that raises costs a diagnostic."""
    return await self._run_phase(LifecyclePhase.INSTALL)

teardown async

teardown() -> tuple[ExtensionDiagnostic, ...]

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
async def teardown(self) -> tuple[ExtensionDiagnostic, ...]:
    """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.
    """
    return await self._run_phase(LifecyclePhase.TEARDOWN, reverse=True)

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
@classmethod
def build(
    cls,
    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."""
    return cls(
        extension=extension,
        tools=tuple(tools),
        prompts=tuple(prompts),
        policies=tuple(policies),
        lifecycle=tuple(lifecycle),
    )

validate

validate() -> None

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
def validate(self) -> None:
    """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.
    """
    if not self.extension:
        raise ExtensionContractError("an extension contribution must name itself.")
    require_id(self.extension, what="extension", owner=self.extension)
    for group in (self.tools, self.prompts, self.policies, self.lifecycle):
        for member in group:
            member.validate()
            if member.extension != self.extension:
                raise PrivilegeWideningError(
                    f"{self.extension}: contributed a member attributed to "
                    f"{member.extension!r}. An extension may not contribute on "
                    "another extension's behalf."
                )

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

LegacyPluginBridge(plugin: Any, *, limits: TrustLimits | None = None)

One legacy plugin, read as an extension provider.

Source code in src/symfonic/capabilities/extensions/bridge.py
def __init__(self, plugin: Any, *, limits: TrustLimits | None = None) -> None:
    self._plugin = plugin
    self._limits = limits or TrustLimits()
    self._limits.validate()
    self._name = self._read_name(plugin)
    self._reader = LegacyPromptReader(plugin, self._name, limits=self._limits)
    self._prompts: tuple[PromptFragment, ...] = ()
    self._refuse_tools()

contribute

contribute() -> ExtensionContribution

The bundle, using whatever the last :meth:harvest produced.

Source code in src/symfonic/capabilities/extensions/bridge.py
def contribute(self) -> ExtensionContribution:
    """The bundle, using whatever the last :meth:`harvest` produced."""
    return ExtensionContribution.build(
        self._name,
        prompts=self._prompts,
        policies=self.policies(),
        lifecycle=self.lifecycle(),
    )

contribute_for async

contribute_for(state: Mapping[str, Any]) -> ExtensionContribution

Harvest this turn's prompts, then answer the whole bundle.

Source code in src/symfonic/capabilities/extensions/bridge.py
async def contribute_for(self, state: Mapping[str, Any]) -> ExtensionContribution:
    """Harvest this turn's prompts, then answer the whole bundle."""
    await self.harvest(state)
    return self.contribute()

harvest async

harvest(state: Mapping[str, Any]) -> tuple[PromptFragment, ...]

Call the plugin's prompt hook once and keep what it answered.

Source code in src/symfonic/capabilities/extensions/bridge.py
async def harvest(self, state: Mapping[str, Any]) -> tuple[PromptFragment, ...]:
    """Call the plugin's prompt hook once and keep what it answered."""
    self._prompts = await self._reader.read(state)
    return self._prompts

lifecycle

lifecycle() -> tuple[LifecycleContribution, ...]

Bind whichever optional lifecycle hooks the plugin actually defines.

Source code in src/symfonic/capabilities/extensions/bridge.py
def lifecycle(self) -> tuple[LifecycleContribution, ...]:
    """Bind whichever optional lifecycle hooks the plugin actually defines."""
    hooks: list[LifecycleContribution] = []
    for attribute, phase in _LIFECYCLE_HOOKS:
        hook = getattr(self._plugin, attribute, None)
        if not callable(hook):
            continue
        hooks.append(
            LifecycleContribution(
                hook_id=f"{self._name}.{attribute}",
                extension=self._name,
                phase=phase,
                run=hook,
            )
        )
    return tuple(hooks)

policies

policies() -> tuple[PolicyContribution, ...]

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
def policies(self) -> tuple[PolicyContribution, ...]:
    """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.
    """
    return legacy_guard_policies(self._plugin, self._name)

tools

tools() -> tuple[ToolContribution, ...]

Always empty. Present so the bridge reads like every other adapter.

Source code in src/symfonic/capabilities/extensions/bridge.py
def tools(self) -> tuple[ToolContribution, ...]:
    """Always empty. Present so the bridge reads like every other adapter."""
    return ()

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
def __init__(
    self,
    name: str = "mcp",
    *,
    servers: Mapping[str, McpServerPort] | None = None,
    limits: TrustLimits | None = None,
) -> None:
    self._limits = limits or TrustLimits()
    self._limits.validate()
    self._name = validate_identifier(
        name, field="extension name", origin="mcp", limits=self._limits
    )
    self._servers: dict[str, McpServerPort] = {}
    for server_name, port in (servers or {}).items():
        self.add_server(server_name, port)
    self._tools: list[ToolContribution] = []
    self._diagnostics: list[ExtensionDiagnostic] = []

add_server

add_server(server_name: str, port: McpServerPort) -> None

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
def add_server(self, server_name: str, port: McpServerPort) -> None:
    """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.
    """
    validate_identifier(
        server_name,
        field="server name",
        origin=self._name,
        limits=self._limits,
        allow_dot=False,
    )
    if server_name in self._servers:
        raise UntrustedPayloadError(
            f"{self._name}: server {server_name!r} is already registered; "
            "two servers under one name would make routing ambiguous."
        )
    self._servers[server_name] = port

close async

close() -> None

Close every pinned server, surviving individual failures.

Source code in src/symfonic/capabilities/extensions/mcp.py
async def close(self) -> None:
    """Close every pinned server, surviving individual failures."""
    for server_name, port in self._servers.items():
        try:
            await port.close()
        except Exception:  # noqa: BLE001 - one bad close must not strand the rest
            logger.debug(
                "MCP server %r raised on close", server_name, exc_info=True,
            )

contribute

contribute() -> ExtensionContribution

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
def contribute(self) -> ExtensionContribution:
    """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.
    """
    return ExtensionContribution.build(
        self._name,
        tools=tuple(self._tools),
        lifecycle=(
            LifecycleContribution(
                hook_id=f"{self._name}.close",
                extension=self._name,
                phase=LifecyclePhase.TEARDOWN,
                run=self.close,
            ),
        ),
    )

discover async

discover() -> tuple[ExtensionDiagnostic, ...]

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
async def discover(self) -> tuple[ExtensionDiagnostic, ...]:
    """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.
    """
    self._tools = []
    self._diagnostics = []
    for server_name in sorted(self._servers):
        await self._discover_server(server_name, self._servers[server_name])
    return tuple(self._diagnostics)

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

call_tool(tool_name: str, arguments: Mapping[str, Any]) -> Mapping[str, Any]

Invoke one tool and return the raw result payload.

Source code in src/symfonic/capabilities/extensions/ports.py
async def call_tool(
    self, tool_name: str, arguments: Mapping[str, Any]
) -> Mapping[str, Any]:
    """Invoke one tool and return the raw result payload."""
    ...

close async

close() -> None

Release the connection. Called by the composition's teardown hook.

Source code in src/symfonic/capabilities/extensions/ports.py
async def close(self) -> None:
    """Release the connection. Called by the composition's teardown hook."""
    ...

list_tools async

list_tools() -> Sequence[Mapping[str, Any]]

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
async def list_tools(self) -> Sequence[Mapping[str, Any]]:
    """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.
    """
    ...

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

PolicyOutcome(verdict: PolicyVerdict, abstentions: tuple[PolicyVerdict, ...] = ())

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

PolicyRequest(action: str, context: Mapping[str, object], extension: str = '')

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

PolicyVerdict(decision: PolicyDecision, reason: str = '', policy: str = '')

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.

Source code in src/symfonic/capabilities/extensions/composition.py
def compose(
    contributions: Sequence[ExtensionContribution],
    *,
    reserved_tool_names: frozenset[str] = frozenset(),
) -> ComposedExtensions:
    """Validate, de-duplicate, and order every contribution into one value.

    Args:
        contributions: 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.
        reserved_tool_names: Names the host already owns. A contributed tool
            claiming one is refused.

    Raises:
        ExtensionContractError: A contribution is malformed.
        PrivilegeWideningError: A contribution claims authority it lacks —
            an authored tier, a foreign attribution, the kernel layer.
    """
    diagnostics: list[ExtensionDiagnostic] = []
    tools: list[ToolContribution] = []
    prompts: list[PromptFragment] = []
    policies: list[PolicyContribution] = []
    lifecycle: list[LifecycleContribution] = []
    seen: list[str] = []
    tool_names: set[str] = set()
    fragment_ids: set[str] = set()

    for contribution in contributions:
        contribution.validate()
        if contribution.extension in seen:
            diagnostics.append(
                ExtensionDiagnostic(
                    extension=contribution.extension,
                    kind=ContributionKind.TOOL,
                    detail="refused: a second contribution under this name; one "
                    "extension contributes once per composition.",
                    severity=Severity.ERROR,
                )
            )
            continue
        seen.append(contribution.extension)
        for tool in contribution.tools:
            admit_tool(tool, tools, tool_names, reserved_tool_names, diagnostics)
        for fragment in contribution.prompts:
            admit_fragment(fragment, prompts, fragment_ids, diagnostics)
        policies.extend(contribution.policies)
        lifecycle.extend(contribution.lifecycle)

    return ComposedExtensions(
        extensions=tuple(seen),
        tools=tuple(sorted(tools, key=lambda t: (t.extension, t.name))),
        prompts=tuple(sorted(prompts, key=fragment_key)),
        policies=tuple(sorted(policies, key=lambda p: (p.extension, p.policy_id))),
        lifecycle=tuple(lifecycle),
        diagnostics=tuple(diagnostics),
    )