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
active
property
¶
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
¶
Which extensions were composed at the seam, in declaration order.
sealed
property
¶
The composition as it stood when the plan was compiled.
tools
property
¶
The tool set, decided once. See the module docstring.
decide
async
¶
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
harvest
async
¶
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.