Skip to content

symfonic.agent.cutover.extension_tools

extension_tools

A contributed extension tool, in the shape the runtime binds (TA8.21).

The third blocker RET-PREP/envelope-guard-inventory.json recorded against plugins was that "the extension contract includes ToolContribution, so it inherits the same missing bundle tool path as sub_agents". TA8.12 built that path — bind_contributed_toolsRetrievalBundle.toolsKernelDelegate.__init__merge_capability_tools — for a DelegationToolSpec, which carries a coroutine and whose schema the runtime infers from that coroutine's signature.

A :class:~symfonic.capabilities.extensions.declarations.ToolContribution does not carry a coroutine. It carries invoke(arguments) -> str plus a declared input_schema, deliberately: the extension package holds no runtime tool library and an MCP server answers a JSON schema, not a Python signature. So something has to turn the declaration into the type the runtime binds, and — exactly as delegation.bind_contributed_tool argues — that something is the composition root.

Why not leave it unbound. bind_contributed_tool returns anything without a coroutine untouched, so a ToolContribution would have travelled the whole path as itself and been refused by merge_capability_tools inside KernelDelegate.__init__ — which is on the dispatch path of an already-admitted turn. Binding it here means the schema is decided at the composition root, once, before any turn is admitted.

Nothing here registers a tool late. This runs when the composition root builds :class:~symfonic.agent.cutover.extensions.ExtensionsCapability, over the tuple ExtensionSurface sealed at that same moment — both before AgentPlanFactory exists. See D5 of evidence/RET-PREP/decision-plugin-tier-layer.md.

bind_extension_tool

bind_extension_tool(tool: Any) -> Any

One ToolContribution, as a StructuredTool the runtime can call.

The callable is captured from the contribution and never looked up again, which is the property ToolContribution was written for: the legacy MCP provider routed every call through a mutable name→server dict, so a later discovery could re-point an already-advertised tool at a different server.

func is deliberately absent. There is no synchronous path: the contribution's invoke is a coroutine, and supplying a blocking shim that spun an event loop would be a second execution path for the same tool.

The import is local, for the reason delegation.bind_contributed_tool gives: agent.cutover is on the import path of every agent, including the overwhelming majority that load no plugin at all.

Source code in src/symfonic/agent/cutover/extension_tools.py
def bind_extension_tool(tool: Any) -> Any:
    """One ``ToolContribution``, as a ``StructuredTool`` the runtime can call.

    The callable is captured from the contribution and never looked up again,
    which is the property ``ToolContribution`` was written for: the legacy MCP
    provider routed every call through a mutable name→server dict, so a later
    discovery could re-point an already-advertised tool at a different server.

    ``func`` is deliberately absent. There is no synchronous path: the
    contribution's ``invoke`` is a coroutine, and supplying a blocking shim that
    spun an event loop would be a second execution path for the same tool.

    The import is local, for the reason ``delegation.bind_contributed_tool``
    gives: ``agent.cutover`` is on the import path of every agent, including the
    overwhelming majority that load no plugin at all.
    """
    from langchain_core.tools import StructuredTool

    invoke = tool.invoke

    async def call(**arguments: Any) -> str:
        return await invoke(arguments)

    return StructuredTool(
        name=tool.name,
        description=tool.description,
        args_schema=_args_model(tool),
        coroutine=call,
    )

declarable_field

declarable_field(name: Any) -> bool

Whether name can be a pydantic field on the generated args model.

A property name is extension-declared text, exactly as its type is, and the argument :func:_args_model already makes about a mis-declared type holds unchanged for a mis-declared name: a model whose field refused to build would take the whole agent down over one property. create_model raises NameError for a leading underscore and complains about the model_ namespace pydantic reserves for its own API, and this capability is built eagerly over every sealed tool — so one such property in one contribution would cost every other extension its fragments and its tools and drop the turn back to legacy.

A predicate rather than an inline test because a test asserts on it: the rule an adopter's schema has to satisfy should be readable in one place.

Source code in src/symfonic/agent/cutover/extension_tools.py
def declarable_field(name: Any) -> bool:
    """Whether ``name`` can be a pydantic field on the generated args model.

    A property name is extension-declared text, exactly as its ``type`` is, and
    the argument :func:`_args_model` already makes about a mis-declared *type*
    holds unchanged for a mis-declared *name*: a model whose field refused to
    build would take the whole agent down over one property. ``create_model``
    raises ``NameError`` for a leading underscore and complains about the
    ``model_`` namespace pydantic reserves for its own API, and this capability
    is built eagerly over every sealed tool — so one such property in one
    contribution would cost every *other* extension its fragments and its tools
    and drop the turn back to legacy.

    A predicate rather than an inline test because a test asserts on it: the
    rule an adopter's schema has to satisfy should be readable in one place.
    """
    if not isinstance(name, str) or not name.isidentifier():
        return False
    return not name.startswith("_") and not name.startswith("model_")