symfonic.agent.cutover.delegation¶
delegation ¶
Delegation, folded into the bundle the migrated path compiles (TA8.12).
Two seams live here, and they are the two halves of what sub_agents needs
before the envelope may admit it.
The tool path. A capability contributes tools as
:class:~symfonic.capabilities.delegation.contracts.DelegationToolSpec
descriptions — a name, a description, a coroutine, the parameter names — and
deliberately not as runtime tool objects: a capability that constructed one
would put a third-party tool library on the import path of a package that has
no other use for it. Something has to wrap the description in the type the
runtime actually binds, and that something is the composition root. This module
is that step for :mod:symfonic.agent.cutover.
The depth reader. agent_depth had no consumer on the migrated side —
KernelDelegate accepted the argument nowhere and read it nowhere, which is
why :func:~symfonic.agent.cutover.envelope.admit_invocation refused it. The
consumer is :meth:~symfonic.agent.cutover.delegate.KernelDelegate._delegation_scope,
which opens :meth:DelegationCapability.run_scope at the turn's depth; the
scope is what :class:~symfonic.capabilities.delegation.tools.DelegationTools
reads through :meth:DelegationContext.current_depth before it applies
:class:~symfonic.capabilities.delegation.depth.DepthPolicy. Depth in, ceiling
enforced, child stamped one level deeper: that is the whole chain, and every
link of it is named so the allowlist entry can cite it.
Nothing here registers a tool late. The specs are read at fold time, the
bundle carries them, and KernelDelegate.__init__ merges them into the one
normalized set the plan factory compiles from. The kernel's tool registry
freezes at compile exactly as the legacy graph's does, so a delegation tool
path that expected to add a tool during a turn would be a path that quietly
never bound one.
RecordingDelegationContext ¶
RecordingDelegationContext(*, snapshots: Any | None = None, resolve_scope: Callable[[], Any] | None = None, report: Callable[[str], None] | None = None)
Bases: DelegationContext
A delegation scope that also reports hand-offs to the composing engine.
The capability tallies delegations on its own :class:ActiveRun, which is
the right home for them: the tally belongs to the run, and the capability
owns the run scope. The facade has a second tally of its own —
symfonic.agent.engine._delegated_agents, the one
AgentResponse.delegated_to is stamped from — and an admitted turn that
filled only the first would answer correctly and report an empty
delegated_to, which is precisely the "served, and silently different"
outcome the envelope exists to prevent.
So the second tally is fed, not replaced. Two tallies with one writer, rather than one tally the two routes disagree about.
Source code in src/symfonic/agent/cutover/delegation.py
bind_contributed_tool ¶
One contributed tool, in the shape the runtime binds.
A :class:DelegationToolSpec — anything carrying name, description
and a coroutine — becomes a StructuredTool. Anything else is
returned untouched, because a capability that already offers a runtime tool
has nothing for this function to do and re-wrapping one would destroy it.
"Anything else" includes an already bound runtime tool, and the check for
one has to come first. A StructuredTool carries a callable
coroutine too, so a guard that asked only about that would rebuild it
from StructuredTool.from_function(coroutine=...) — which discards the
args_schema the binder that produced it derived and re-infers one from
the wrapper's signature. That is exactly what happened to a
ToolContribution bound by
:func:~symfonic.agent.cutover.extension_tools.bind_extension_tool: a tool
declaring query was re-advertised as taking a single free-form
arguments object, and every call the model made arrived at
ToolContribution.invoke wrapped in an extra dict level. See
:func:_already_bound.
parameters is read, and read here rather than nowhere. The runtime
infers the argument schema from the coroutine, so a spec whose declared
parameter list disagreed with the coroutine it carries would bind the
coroutine's signature and say nothing -- the tool would work, under a
description of itself that was false, and the field would be documentation
nobody could rely on. So the declaration is held against the signature and
a divergence is a ConfigurationError raised at fold time, at the
composition root, before any turn is admitted. A spec declaring no
parameters declares nothing to check and is left alone.
The import is local. agent.cutover is on the import path of every agent,
including the overwhelming majority that declare no child, and a
module-level import of the tool library for their sake would be a cost paid
by everyone for a feature almost nobody in that population uses.
Source code in src/symfonic/agent/cutover/delegation.py
bind_contributed_tools ¶
Bind every (capability, tool) pair the fold produced.
The pairing survives the binding. merge_capability_tools reports a name
collision by capability, and a composition root that flattened the pairs
here would turn "delegation contributed a tool named 'run_agent', which is
already registered by a tool passed to Agent(tools=[...])" into "two tools
called run_agent" — true, and useless to whoever has to fix it.
Source code in src/symfonic/agent/cutover/delegation.py
delegation_for_children ¶
delegation_for_children(children: Sequence[Any], *, max_depth: int, resolve_scope: Callable[[], Any] | None = None, report_delegation: Callable[[str], None] | None = None) -> DelegationCapability | None
Wrap already-built children as the capability the bundle folds.
children are the facade's own SubAgent records — a name, a
description, optional guidance, and a child object exposing run. They
are already built: the engine constructed them from specs, or the adopter
handed them over, long before a bundle is folded. So this is a
:class:DelegationRoster over existing runners and never a compilation, and
the lifecycle it carries owns nothing: these children are the engine's
to shut down, and a second owner would close a child out from under the
route that is still using it.
Returns None when there is nothing to delegate to, so the caller folds
no delegation rather than folding a capability with an empty roster.
Source code in src/symfonic/agent/cutover/delegation.py
delegation_scope ¶
The consumer of agent_depth on the migrated path (TA8.12).
This is the symbol :data:~symfonic.agent.cutover.policy.ADMITTED_ARGUMENTS
cites through :meth:KernelDelegate._delegation_scope, and it is cited
because it reads the value rather than carrying it. The chain, end to end:
SymfonicAgent.run(agent_depth=N) -> KernelDelegate.run -> here ->
:meth:DelegationCapability.run_scope -> DelegationContext.run_scope
sets the run-local depth -> :meth:DelegationTools.delegate reads it back
through DelegationContext.current_depth -> :meth:DepthPolicy.admits
refuses the hand-off at the ceiling, and :meth:DepthPolicy.child_depth
stamps N + 1 on the child that does run.
Both directions of that are observable and both are asserted: the child is
entered at N + 1, and a turn at the ceiling gets DepthPolicy.refusal()
back as the tool's answer instead of a child's. Change N and the answer
changes -- which is the property separating an argument that arrived from
one that was merely passed.
nullcontext is the answer for an agent with no children, and it is the
honest one rather than a shortcut: there is no ceiling to enforce because
there is no hand-off to refuse, which is exactly the state the legacy body
is in for the same agent -- it binds no run_agent either.