Skip to content

symfonic.agent.cutover.plugin_providers

plugin_providers

An adopter's loaded plugins, read as extension providers (TA8.21).

Left :mod:~symfonic.agent.cutover.extensions when that module passed its line budget, and the seam is a real one rather than a line count: this module owns the legacy end -- how an object written against the pre-refactor plugin API becomes something the composer can ask -- and extensions owns the kernel end, where the answer becomes a CapabilityContribution.

Both halves of what a plugin is handed and how it is reached live here: :func:plugin_state builds the mapping its prompt hook receives, and :class:PluginProviders turns the engine's live list into bridges.

Nothing here downgrades a plugin. An earlier revision carried a plugin the bridge refused -- and a plugin whose name another had already claimed -- as a guard-only shadow: its veto kept, its prompt fragment dropped, its identity synthesised, and no error anywhere. That is accept-and-do-less with a rationale, and it is what this module now refuses to do. A roster that cannot be composed whole raises :class:~symfonic.capabilities.extensions.errors.ExtensionAdmissionError out of :meth:PluginProviders.__call__, and the one caller that can act on it -- SymfonicAgent.load_plugin -- refuses the load before any registry is touched. What survives a refused plugin is not a downgraded contribution but a quarantine the engine keeps: see SymfonicAgent.validate_action.

PluginProviders

PluginProviders(plugins: Callable[[], Sequence[Any]], *, limits: TrustLimits | None = None)

The engine's live plugin list, read as extension providers.

One bridge per plugin object, kept across turns. Rebuilding them per turn would re-run LegacyPluginBridge.__init__, which calls the plugin's get_domain_tools() and re-validates its name on every dispatch -- work bridge.py states happens once, at construction and never per run.

A callable rather than a fixed sequence, because the roster is read again on every turn and on every guarded action. It does not reopen the tool question: :class:~symfonic.capabilities.extensions.harvest.ExtensionSurface seals that at the compile seam.

A plugin that cannot be bridged, or whose name is already claimed, is refused whole. Both used to be survivable here -- the first by dropping the plugin, the second by re-emitting it under a synthetic identity -- and both were the same defect: an adopter's plugin was accepted by load_plugin and then served by less than the whole of what it declared, with the shortfall visible only in a log line. Raising :class:~symfonic.capabilities.extensions.errors.ExtensionAdmissionError instead moves the decision to the one place an adopter can act on it. The engine calls this on a trial roster from load_plugin before it registers anything, so the raise becomes a refused load rather than an agent that cannot take a turn.

A refusal never costs a veto. The plugin the engine refused goes into its quarantine and validate_action still asks it, deny-only; and if a roster is somehow made unbridgeable after admission -- a plugin appended to _plugins directly, a name mutated in place -- this raise reaches _extensions_capability, which answers None, and :func:~symfonic.agent.cutover.guardrails.enforce_guardrails asks the whole population directly. There is no path from a refusal to an allow.

Source code in src/symfonic/agent/cutover/plugin_providers.py
def __init__(
    self,
    plugins: Callable[[], Sequence[Any]],
    *,
    limits: TrustLimits | None = None,
) -> None:
    self._plugins = plugins
    self._limits = limits
    self._bridges: list[tuple[Any, Any]] = []

plugin_state

plugin_state(request: Any) -> Mapping[str, Any]

The state a plugin's prompt hook is handed on the migrated path (D4).

Built from the turn, not from a rendered HMS template. The legacy HMS-shaped keys -- TOOL_MANIFEST, MEMORY_CONTEXT, SOUL_SCHEMA and the rest -- are deliberately absent, and evidence/RET-PREP/decision-plugin-tier-layer.md D4 is where that is decided rather than discovered: several of them are the output of the legacy prompt build this path replaces, so supplying them would mean running that build in order to feed a plugin whose output then goes somewhere else entirely.

Read-only, because it is handed to third-party code: a hook that edited the mapping would otherwise change what the next plugin was told about the same turn.

Source code in src/symfonic/agent/cutover/plugin_providers.py
def plugin_state(request: Any) -> Mapping[str, Any]:
    """The state a plugin's prompt hook is handed on the migrated path (D4).

    Built from the *turn*, not from a rendered HMS template. The legacy
    HMS-shaped keys -- ``TOOL_MANIFEST``, ``MEMORY_CONTEXT``, ``SOUL_SCHEMA``
    and the rest -- are deliberately absent, and
    ``evidence/RET-PREP/decision-plugin-tier-layer.md`` D4 is where that is
    decided rather than discovered: several of them are the *output* of the
    legacy prompt build this path replaces, so supplying them would mean running
    that build in order to feed a plugin whose output then goes somewhere else
    entirely.

    Read-only, because it is handed to third-party code: a hook that edited the
    mapping would otherwise change what the next plugin was told about the same
    turn.
    """
    scope = getattr(request, "scope", None)
    path = str(getattr(scope, "path", "") or "") if scope is not None else ""
    return MappingProxyType(
        {
            "QUERY": str(getattr(request, "prompt", "") or ""),
            # The tenant is the scope path's root segment; a deeper scope still
            # belongs to its own tenant. Same derivation as
            # ``capabilities/prompting/hms._tenant_of``, and named the same key
            # legacy used, so a plugin reading ``TENANT_ID`` keeps working.
            "TENANT_ID": path.split("/", 1)[0] if path else "",
            "SCOPE_PATH": path,
        }
    )