Skip to content

symfonic.capabilities.extensions.harvest

harvest

One agent's extensions over time: sealed tools, per-turn prompts (TA8.21).

:func:~.composition.compose is a pure function over one set of contributions. An agent is not one set: a legacy plugin's prompt hook is asked again on every turn and answers something different each time, while its tools — of which there are none, by contract — must be decided once and never again. :class:ExtensionSurface is that distinction made structural.

Tools are sealed at construction. The composition root builds one surface before it folds a bundle, which is before AgentPlanFactory exists — the compile seam. Whatever tools the providers offered then are the tools this surface will ever report: :meth:ExtensionSurface.harvest re-reads prompts and policies and returns the sealed tuple regardless. A provider that grows a tool afterwards earns an ERROR diagnostic naming the seam, so the attempt is visible rather than silently ineffective. LegacyPluginBridge already refuses a non-empty get_domain_tools() at its construction and SymfonicAgent.load_plugin refuses it at the facade; this makes the same rule hold for a provider the bridge never sees.

The admission is atomic. A seal that produced any ERROR refusal raises ExtensionAdmissionError rather than returning a surface, so no provider is ever half-composed. SymfonicAgent.load_plugin runs this seal over a trial roster and turns the raise into a refused load, before any registry is touched.

Prompts and policies are still read live. providers is a callable, so the roster is re-read every turn and decide asks whatever it holds now. What the callable does not do is reopen the tool question the seal closed.

Nothing here imports the kernel, the engine, or the prompt compiler; that translation is the composition root's, in symfonic.agent.cutover.extensions.

ExtensionSurface

ExtensionSurface(providers: Callable[[], Sequence[Any]], *, reserved_tool_names: frozenset[str] = frozenset())

The providers of one agent, with the tool seam held closed.

Source code in src/symfonic/capabilities/extensions/harvest.py
def __init__(
    self,
    providers: Callable[[], Sequence[Any]],
    *,
    reserved_tool_names: frozenset[str] = frozenset(),
) -> None:
    self._providers = providers
    self._reserved = frozenset(reserved_tool_names)
    sealed = self._compose_now(providers())
    refusals = sealed.refusals
    if refusals:
        # A refusal at the seam means one contribution could not be carried
        # whole -- a name already claimed, a bundle that will not validate,
        # a reserved tool name. Sealing anyway admits the *rest* of that
        # extension, which is the accept-and-do-less shape TA8.21 removes.
        raise ExtensionAdmissionError(
            "extensions could not be admitted whole: "
            + "; ".join(
                f"{refusal.extension}: {refusal.detail}" for refusal in refusals
            )
        )
    self._sealed = sealed

active property

active: bool

True when at least one provider was composed.

A surface over nothing is folded by nobody: a capability contributing an empty everything would put a stage on the ladder that can only ever report NO_CHANGE.

extensions property

extensions: tuple[str, ...]

Which extensions were composed at the seam, in declaration order.

sealed property

sealed: ComposedExtensions

The composition as it stood when the plan was compiled.

tools property

tools: tuple[ToolContribution, ...]

The tool set, decided once. See the module docstring.

decide async

decide(action: str, context: Mapping[str, Any]) -> PolicyOutcome

Ask every contributed guardrail about one action, deny-wins.

Composed fresh rather than read off :attr:sealed, because the roster may legitimately have grown since the seal and narrowing the population a guard is asked over would be a security regression.

A policy that raises abstains, and the abstention is a value on the returned :class:~.composition.PolicyOutcome -- so "the only guard with an opinion was down" stays distinguishable from "nobody objected". What an abstention costs is the enforcement point's rule, not this one's: cutover/guardrails.py denies on one.

Source code in src/symfonic/capabilities/extensions/harvest.py
async def decide(
    self, action: str, context: Mapping[str, Any]
) -> PolicyOutcome:
    """Ask every contributed guardrail about one action, deny-wins.

    Composed fresh rather than read off :attr:`sealed`, because the roster
    may legitimately have grown since the seal and narrowing the population
    a guard is asked over would be a security regression.

    A policy that raises abstains, and the abstention is a value on the
    returned :class:`~.composition.PolicyOutcome` -- so "the only guard with
    an opinion was down" stays distinguishable from "nobody objected". What
    an abstention costs is the enforcement point's rule, not this one's:
    ``cutover/guardrails.py`` denies on one.
    """
    composed = self._compose_now(self._providers())
    for refusal in composed.refusals:
        logger.warning(
            "extension %r contributed nothing to this decision: %s",
            refusal.extension,
            refusal.detail,
        )
    return await composed.decide_detailed(
        PolicyRequest(action=action, context=context)
    )

harvest async

harvest(state: Mapping[str, Any]) -> ComposedExtensions

Compose this turn's contributions, with the sealed tool set.

The tool tuple is substituted rather than merged: merging would make a late tool half present, in the composition and absent from the plan.

Source code in src/symfonic/capabilities/extensions/harvest.py
async def harvest(self, state: Mapping[str, Any]) -> ComposedExtensions:
    """Compose this turn's contributions, with the sealed tool set.

    The tool tuple is substituted rather than merged: merging would make a
    late tool *half* present, in the composition and absent from the plan.
    """
    bundles: list[ExtensionContribution] = []
    problems: list[ExtensionDiagnostic] = []
    for provider in self._providers():
        try:
            bundle = await _contribute_for(provider, state)
        except Exception as exc:  # noqa: BLE001 - one bad provider
            logger.debug("Extension provider raised", exc_info=True)
            problems.extend(_provider_diagnostics(provider))
            problems.append(_refusal(provider, _raised(exc)))
            continue
        problems.extend(_provider_diagnostics(provider))
        admitted, problem = _admissible(provider, bundle)
        if problem is not None:
            problems.append(problem)
        if admitted is not None:
            bundles.append(admitted)
    composed = compose(bundles, reserved_tool_names=self._reserved)
    return replace(
        composed,
        tools=self._sealed.tools,
        diagnostics=(
            tuple(problems) + composed.diagnostics + self._late_tools(composed)
        ),
    )