Skip to content

symfonic.agent.subagents

subagents

Sub-agent delegation primitive.

Declare child agents a parent can delegate to via run_agent(name, task):

from symfonic.agent.subagents import SubAgent

parent = SymfonicAgent(
    model_provider=provider,
    config=FrameworkConfig(),
    sub_agents=[
        SubAgent(name="researcher", agent=research_agent,
                 description="Deep web research"),
    ],
)

Declaring sub_agents auto-registers a concrete AgentStore plus the run_agent / list_agents tools before the graph compiles. Children are isolated agents that inherit the parent's tenant scope and run under a depth guard (max_agent_depth).

A child never holds a prompt-block write surface, whichever way it was declared -- see :mod:symfonic.agent.subagents.lockdown.

SubAgent dataclass

SubAgent(name: str, agent: _Runnable, description: str, when_to_use: str | None = None)

A named child agent the parent may delegate to.

Attributes:

Name Type Description
name str

Unique routing key the parent's model passes to run_agent(name, ...). Must be non-empty and whitespace-free.

agent _Runnable

The child agent — any object exposing async run(query, ...) (typically another SymfonicAgent).

description str

One-line summary shown to the parent model in the run_agent tool schema so it knows when to delegate here.

when_to_use str | None

Optional longer guidance appended to the roster listing.

SubAgentRegistry

SubAgentRegistry(sub_agents: Sequence[SubAgent])

Concrete AgentStore that runs registered child agents in-process.

Satisfies the AgentStore protocol (list / read / run). The run method inherits the parent's tenant scope into the child and threads the (already incremented) agent depth so the child's own delegation guard can enforce max_agent_depth for nested delegation.

Registration is the last point at which a child can be refused, so it is where the block-edit lockdown lands for children the parent did not build. Every child passes through here -- specs the parent constructed and pre-built SubAgent(agent=...) instances alike -- which makes it the one place the guarantee covers every construction path.

Source code in symfonic/agent/subagents/registry.py
def __init__(self, sub_agents: Sequence[SubAgent]) -> None:
    self._agents: dict[str, SubAgent] = {}
    for sa in sub_agents:
        if sa.name in self._agents:
            raise ValueError(f"duplicate sub-agent name: {sa.name!r}")
        # Raises: a pre-built child holding a block-edit tool cannot be
        # sanitised after construction, and registering it anyway would
        # leave the caller trusting a lock that did not hold.
        assert_no_block_edit_surface(sa.name, sa.agent)
        self._agents[sa.name] = sa

run async

run(name: str, agent_input: Any, *, parent_scope: Any = None, agent_depth: int = 1, run_id: str | None = None, parent_run_id: str | None = None) -> Any

Run child name on agent_input; returns its AgentResponse.

Parameters:

Name Type Description Default
name str

Registered sub-agent name.

required
agent_input Any

The delegated task (coerced to str for run).

required
parent_scope Any

Tenant scope inherited from the parent run.

None
agent_depth int

Depth to stamp on the child run (parent depth + 1).

1
run_id str | None

The id the child runs under. Minted here when the caller names none, so the value is known rather than left for the child to invent privately -- parent_run_id below has to point at something.

None
parent_run_id str | None

v9.12.0 (issue #64) the run delegating this task. Recorded as the child's lineage; never passed as the child's own run_id. A child that ran under its parent's id would share every run-keyed map with it, and ConversationMetricsCollector.on_agent_end pops six of those -- so a child finishing mid-turn would tear down its still-running parent's tenant and conversation entries, and the parent's remaining token usage would be attributed to nobody and skipped by the budget tracker.

None

Raises:

Type Description
StorageError

If name is not a registered sub-agent.

Source code in symfonic/agent/subagents/registry.py
async def run(
    self,
    name: str,
    agent_input: Any,
    *,
    parent_scope: Any = None,
    agent_depth: int = 1,
    run_id: str | None = None,
    parent_run_id: str | None = None,
) -> Any:
    """Run child ``name`` on ``agent_input``; returns its ``AgentResponse``.

    Args:
        name: Registered sub-agent name.
        agent_input: The delegated task (coerced to ``str`` for ``run``).
        parent_scope: Tenant scope inherited from the parent run.
        agent_depth: Depth to stamp on the child run (parent depth + 1).
        run_id: The id the child runs under. Minted here when the caller
            names none, so the value is *known* rather than left for the
            child to invent privately -- ``parent_run_id`` below has to
            point at something.
        parent_run_id: v9.12.0 (issue #64) the run delegating this task.
            Recorded as the child's lineage; never passed as the child's
            own ``run_id``. A child that ran under its parent's id would
            share every run-keyed map with it, and
            ``ConversationMetricsCollector.on_agent_end`` *pops* six of
            those -- so a child finishing mid-turn would tear down its
            still-running parent's tenant and conversation entries, and the
            parent's remaining token usage would be attributed to nobody
            and skipped by the budget tracker.

    Raises:
        StorageError: If ``name`` is not a registered sub-agent.
    """
    sa = self._agents.get(name)
    if sa is None:
        raise StorageError(f"unknown sub-agent: {name!r}")
    child_run_id = run_id or uuid.uuid4().hex[:12]
    if parent_run_id:
        self._record_lineage(sa, child_run_id, parent_run_id)
    return await sa.agent.run(
        query=str(agent_input),
        scope=parent_scope,
        run_id=child_run_id,
        agent_depth=agent_depth,
    )

SubAgentSpec dataclass

SubAgentSpec(name: str, description: str, when_to_use: str | None = None, tools: Sequence[Any] = tuple(), domain_description: str | None = None, model_name: str | None = None, temperature: float | None = None, max_tokens: int | None = None, provider: Any | None = None, config: Any | None = None)

A declarative sub-agent: describe the child, let the parent build it.

v9.1.0 (issue #27). SubAgent requires a fully pre-constructed child agent; SubAgentSpec closes that ergonomics gap -- you declare what the child should be and the parent constructs the inner SymfonicAgent at wiring time, inheriting the parent's :class:ModelProvider (#26A) and base :class:FrameworkConfig via :meth:FrameworkConfig.child (#28). Pass either form in SymfonicAgent(sub_agents=[...]); mix freely.

The child is an isolated agent with its own fresh domain (scoped to name/domain_description) so its tool manifest auto-derives from its own tools rather than leaking the parent's. Every behaviour flag (auto_hydrate, lazy_tooling, enable_hms_prompt, ...) is inherited from the parent unless a full config is supplied. For a child that needs shared backends or bespoke wiring, build it yourself and use SubAgent(agent=...) as the escape hatch.

Attributes:

Name Type Description
name str

Unique routing key the parent's model passes to run_agent(name, ...). Non-empty and whitespace-free.

description str

One-line summary shown to the parent model in the run_agent tool schema so it knows when to delegate here.

when_to_use str | None

Optional longer guidance appended to the roster listing.

tools Sequence[Any]

Tools the child agent gets (its manifest auto-derives from them).

domain_description str | None

Optional child DomainTemplate.description -- the child's system-prompt domain directive. Defaults to description.

model_name str | None

Optional child model override; inherits the parent model.

temperature float | None

Optional child sampling temperature override.

max_tokens int | None

Optional child max-output-tokens override.

provider Any | None

Optional :class:ModelProvider for the child; when unset the child inherits the parent's provider (#26A -- providers are stateless, so sharing one instance is safe).

config Any | None

Optional full :class:FrameworkConfig for the child, bypassing the FrameworkConfig.child(parent, ...) inheritance path. One field is sanitised rather than honoured: prompt_block_self_edit is cleared, because a delegated child holds no prompt-block write surface on any construction path (see :mod:symfonic.agent.subagents.lockdown). Your object is not mutated -- the child gets a copy.

assert_no_block_edit_surface

assert_no_block_edit_surface(name: str, agent: Any) -> None

Raise if pre-built child agent can write a prompt block.

Parameters:

Name Type Description Default
name str

The child's routing key, for the error message.

required
agent Any

The child agent being registered.

required

Raises:

Type Description
ValueError

If the child carries a block-edit tool, or a config requesting one.

Source code in symfonic/agent/subagents/lockdown.py
def assert_no_block_edit_surface(name: str, agent: Any) -> None:
    """Raise if pre-built child ``agent`` can write a prompt block.

    Args:
        name: The child's routing key, for the error message.
        agent: The child agent being registered.

    Raises:
        ValueError: If the child carries a block-edit tool, or a config
            requesting one.
    """
    tools = registered_block_edit_tools(agent)
    if tools:
        raise ValueError(
            f"sub-agent {name!r} was built with the block-edit tool(s) "
            f"{list(tools)!r} registered. A delegated child holds no block-edit "
            "tool on any construction path: a prompt block has one writer, and a "
            "child that can write one is a second writer the parent never sees. "
            "Remove the tool from the child, or declare the child as a "
            "SubAgentSpec -- specs are built by the parent, which clears the "
            "write surface for you."
        )
    if wants_block_self_edit(getattr(agent, "_config", None)):
        raise ValueError(
            f"sub-agent {name!r} was built with a config setting "
            f"{SELF_EDIT_FIELD}=True. A delegated child never receives the block "
            "self-edit surface, and a pre-built child cannot be sanitised after "
            "the fact -- its tool registry is already built. Set "
            f"{SELF_EDIT_FIELD}=False on the child's config, or declare the child "
            "as a SubAgentSpec so the parent builds it with the flag cleared."
        )

deny_child_self_edit

deny_child_self_edit(config: Any) -> Any

Return config with the block self-edit request cleared.

The parent's answer on every path where it builds the child's config itself. Returns the object unchanged when the flag is already off -- which is the default, so the common path allocates nothing and keeps config identity intact for callers that compare it.

A config that asks for self-edit but cannot be copied (a stub, a plain namespace) is refused rather than passed through: a lock that silently gives up on an input shape it did not expect is not a lock.

Parameters:

Name Type Description Default
config Any

The child FrameworkConfig (or any object carrying the flag).

required

Returns:

Type Description
Any

A config whose self-edit flag is False.

Raises:

Type Description
TypeError

If the flag is set and config exposes no pydantic model_copy to clear it through.

Source code in symfonic/agent/subagents/lockdown.py
def deny_child_self_edit(config: Any) -> Any:
    """Return ``config`` with the block self-edit request cleared.

    The parent's answer on every path where it builds the child's config
    itself. Returns the object unchanged when the flag is already off --
    which is the default, so the common path allocates nothing and keeps
    config identity intact for callers that compare it.

    A config that asks for self-edit but cannot be copied (a stub, a
    plain namespace) is refused rather than passed through: a lock that
    silently gives up on an input shape it did not expect is not a lock.

    Args:
        config: The child ``FrameworkConfig`` (or any object carrying the
            flag).

    Returns:
        A config whose self-edit flag is ``False``.

    Raises:
        TypeError: If the flag is set and ``config`` exposes no
            pydantic ``model_copy`` to clear it through.
    """
    if not wants_block_self_edit(config):
        return config
    copier = getattr(config, "model_copy", None)
    if not callable(copier):
        raise TypeError(
            f"a delegated child was given a config with {SELF_EDIT_FIELD}=True "
            f"of type {type(config).__name__!r}, which exposes no model_copy to "
            "clear it through. A child never holds a block-edit tool, and a "
            "config that cannot be sanitised cannot be used for one -- pass a "
            "FrameworkConfig, or set the flag False yourself."
        )
    return copier(update={SELF_EDIT_FIELD: False})