Skip to content

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
def __init__(
    self,
    *,
    snapshots: Any | None = None,
    resolve_scope: Callable[[], Any] | None = None,
    report: Callable[[str], None] | None = None,
) -> None:
    super().__init__(snapshots=snapshots, resolve_scope=resolve_scope)
    self._report = report

bind_contributed_tool

bind_contributed_tool(offered: Any) -> Any

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
def bind_contributed_tool(offered: Any) -> Any:
    """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.
    """
    if _already_bound(offered):
        return offered

    coroutine = getattr(offered, "coroutine", None)
    if not callable(coroutine):
        return offered

    _check_declared_parameters(offered, coroutine)

    from langchain_core.tools import StructuredTool

    return StructuredTool.from_function(
        coroutine=coroutine,
        name=offered.name,
        description=offered.description,
    )

bind_contributed_tools

bind_contributed_tools(contributed: Sequence[tuple[str, Any]]) -> tuple[tuple[str, Any], ...]

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
def bind_contributed_tools(
    contributed: Sequence[tuple[str, Any]],
) -> tuple[tuple[str, Any], ...]:
    """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.
    """
    return tuple(
        (capability, bind_contributed_tool(offered))
        for capability, offered in contributed
    )

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
def 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.
    """
    entries = tuple(
        RosterEntry(
            name=child.name,
            description=child.description,
            runner=child.agent,
            when_to_use=getattr(child, "when_to_use", None),
            owned=False,
        )
        for child in children
    )
    if not entries:
        return None

    roster = DelegationRoster(entries)
    context = RecordingDelegationContext(
        resolve_scope=resolve_scope,
        report=report_delegation,
    )
    policy = DepthPolicy(max_depth=max_depth)
    return DelegationCapability(
        roster=roster,
        tools=DelegationTools(roster=roster, depth=policy, context=context),
        lifecycle=ChildLifecycle(()),
        context=context,
        depth=policy,
    )

delegation_scope

delegation_scope(delegation: Any, agent_depth: Any) -> AbstractAsyncContextManager[Any]

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.

Source code in src/symfonic/agent/cutover/delegation.py
def delegation_scope(
    delegation: Any, agent_depth: Any
) -> AbstractAsyncContextManager[Any]:
    """**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.
    """
    if delegation is None:
        return nullcontext()
    return delegation.run_scope(depth=agent_depth)