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_tools → RetrievalBundle.tools →
KernelDelegate.__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 ¶
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
declarable_field ¶
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.