Skip to content

symfonic.capabilities.delegation.runner_adapter

runner_adapter

Normalise a caller-built child to the roster's runner contract.

:class:~.contracts.ChildRunner is run(query, **kwargs) and returns something exposing final_response. The framework's own :class:Agent is run(prompt, ...) returning an AgentResult with .text -- so the composition the migration guide documents, and the one every scaffolded project ships, satisfied neither half.

It failed quietly in both. The call raised TypeError, which DelegationTools.delegate turns into a tool message by design (a child's death is the parent's information, not the parent's death), so the parent's turn succeeded carrying an error string where the answer belonged. Had the call worked, final_response would have been missing and the message empty.

This module closes that at the boundary where a child is adopted: a child that already speaks the protocol is passed through untouched, an Agent is wrapped, and anything else is refused when it is declared rather than mid-turn.

ChildResponse dataclass

ChildResponse(final_response: str, result: Any = None, run_id: str = '', root_run_id: str = '', parent_run_id: str | None = None)

What the roster reads back from a child.

result keeps the child's own return value. The record only needs final_response, but discarding the rest would make the adapter the place where a child's structured output goes to die.

accepts_run_lineage

accepts_run_lineage(child: Any) -> bool

May this runner be told root_run_id and parent_run_id?

Answered from the declared signature, never by making the call and reading the exception. TypeError would be the easy detector and the wrong one: a child that raises TypeError from inside its own body is indistinguishable from one that refused the keyword, so the parent would file a real failure as "this runner is old" and drop the correlation for every later turn as well.

Yes for the three shapes that can take it:

  • the run signature names both parameters -- what this module's own adapter does;
  • a **kwargs catch-all, which is what ChildRunner documents a proxy or a test double may have, and which forwards;
  • an explicit :data:ACCEPTS_RUN_LINEAGE marker, for a runner whose forwarding is real but whose signature cannot say so.

No for exactly one: a catch-all named :data:RETIRED_VAR_KEYWORD. That is the retired inheritance argument of the shipped legacy engine, and its catch-all refuses unknown keys rather than forwarding them -- it raises naming the retirement. Sending lineage there turned a working delegation into "Sub-agent 'x' failed: the state_overrides argument ... was retired", an error string standing where the child's answer belonged.

Narrow on purpose. The rule is not "opt in to lineage", which would take it away from every proxy the protocol promises it to; it is "one named signature is known to refuse it".

Correlation is not lost for such a child. The adapter opens a run scope carrying the identity, which is where every other observer reads it.

Source code in src/symfonic/capabilities/delegation/runner_adapter.py
def accepts_run_lineage(child: Any) -> bool:
    """May this runner be told ``root_run_id`` and ``parent_run_id``?

    Answered from the declared signature, never by making the call and reading
    the exception. ``TypeError`` would be the easy detector and the wrong one:
    a child that raises ``TypeError`` from inside its own body is
    indistinguishable from one that refused the keyword, so the parent would
    file a real failure as "this runner is old" and drop the correlation for
    every later turn as well.

    Yes for the three shapes that can take it:

    * the run signature *names* both parameters -- what this module's own
      adapter does;
    * a ``**kwargs`` catch-all, which is what ``ChildRunner`` documents a
      proxy or a test double may have, and which forwards;
    * an explicit :data:`ACCEPTS_RUN_LINEAGE` marker, for a runner whose
      forwarding is real but whose signature cannot say so.

    No for exactly one: a catch-all named :data:`RETIRED_VAR_KEYWORD`. That is
    the retired inheritance argument of the shipped legacy engine, and its
    catch-all *refuses* unknown keys rather than forwarding them -- it raises
    naming the retirement. Sending lineage there turned a working delegation
    into "Sub-agent 'x' failed: the state_overrides argument ... was retired",
    an error string standing where the child's answer belonged.

    Narrow on purpose. The rule is not "opt in to lineage", which would take
    it away from every proxy the protocol promises it to; it is "one named
    signature is known to refuse it".

    Correlation is not lost for such a child. The adapter opens a run scope
    carrying the identity, which is where every other observer reads it.
    """
    if bool(getattr(child, ACCEPTS_RUN_LINEAGE, False)):
        return True
    parameters = _run_parameters(child)
    if parameters is None:
        return False
    if "root_run_id" in parameters and "parent_run_id" in parameters:
        return True
    catch_all = next(
        (
            parameter
            for parameter in parameters.values()
            if parameter.kind is inspect.Parameter.VAR_KEYWORD
        ),
        None,
    )
    return catch_all is not None and catch_all.name != RETIRED_VAR_KEYWORD

adapt_child

adapt_child(name: str, child: Any, context: Any = None) -> Any

Return something satisfying :class:ChildRunner, or raise.

Raising here is the point. The old behaviour accepted any object with a run and surfaced the mismatch mid-turn, in the one place the framework deliberately swallows failures -- so a broken composition looked like a working one that got an unhelpful answer.

Source code in src/symfonic/capabilities/delegation/runner_adapter.py
def adapt_child(name: str, child: Any, context: Any = None) -> Any:
    """Return something satisfying :class:`ChildRunner`, or raise.

    Raising here is the point. The old behaviour accepted any object with a
    ``run`` and surfaced the mismatch mid-turn, in the one place the framework
    deliberately swallows failures -- so a broken composition looked like a
    working one that got an unhelpful answer.
    """
    from symfonic.capabilities.delegation.errors import ChildDeclarationError

    if speaks_runner_contract(child):
        return child
    if _speaks_agent_contract(child):
        return _AgentRunner(child, context)
    raise ChildDeclarationError(
        f"PrebuiltChild.agent for {name!r} must expose an async "
        "run(query, ...) method (the ChildRunner protocol) or an "
        "Agent-shaped run(prompt, ...); got "
        f"{type(child).__name__} with run"
        f"{inspect.signature(child.run) if callable(getattr(child, 'run', None)) else ' missing'}"
    )

speaks_runner_contract

speaks_runner_contract(child: Any) -> bool

Does this child already accept run(query, ...)?

A **kwargs catch-all counts: the protocol is structural, and a proxy that forwards everything is exactly the kind of child PrebuiltChild documents itself as accepting.

Source code in src/symfonic/capabilities/delegation/runner_adapter.py
def speaks_runner_contract(child: Any) -> bool:
    """Does this child already accept ``run(query, ...)``?

    A ``**kwargs`` catch-all counts: the protocol is structural, and a proxy
    that forwards everything is exactly the kind of child ``PrebuiltChild``
    documents itself as accepting.
    """
    parameters = _run_parameters(child)
    if parameters is None:
        return False
    if "query" in parameters:
        return True
    return any(
        parameter.kind is inspect.Parameter.VAR_KEYWORD
        for parameter in parameters.values()
    )