Skip to content

Sub-agents (delegation)

A sub-agent is a named child agent the parent can hand a self-contained task to at runtime. The parent's model calls a built-in run_agent(name, task) tool, the child runs in isolation, and its answer comes back as the tool result.

Use sub-agents when one agent needs specialized help — a research pass, a coding step, a summarizer — without cramming every tool and instruction into a single prompt.

Why delegate

  • Focused prompts. Each child carries only its own tools, persona, and memory. The parent stays small and routes.
  • Independent models. A child can run on a different (often cheaper) model or provider than the parent — construct it with its own model_provider.
  • Isolation with governance. Children are separate agents with their own memory, but they inherit the parent's tenant scope, so data access stays governed. A depth guard (max_agent_depth) stops runaway recursion.

Quick start

from symfonic.agent import SubAgent, SymfonicAgent, FrameworkConfig

# A specialized child — its own tools/model/memory.
researcher = SymfonicAgent(
    model_provider=research_provider,
    config=FrameworkConfig(),
    tools=[web_search_tool],
)

# The parent declares who it can delegate to.
assistant = SymfonicAgent(
    model_provider=assistant_provider,
    config=FrameworkConfig(),
    sub_agents=[
        SubAgent(
            name="researcher",
            agent=researcher,
            description="Runs deep web research and returns a sourced summary.",
            when_to_use="Use for anything needing fresh external facts.",
        ),
    ],
)

reply = await assistant.run("What changed in the EU AI Act this quarter?", scope=scope)

Declaring sub_agents=[...] automatically:

  1. builds a concrete AgentStore backed by your children,
  2. registers two tools the parent's model can call — run_agent(name, task) and list_agents()before the graph compiles, and
  3. exposes the store as a deps capability.

The child roster (name + description) is injected into the run_agent tool description, so the model knows exactly which children exist and when to use each.

Declarative sub-agents (SubAgentSpec, 9.1+)

The quick-start above pre-builds each child SymfonicAgent by hand. When the child just needs its own tools, model, and domain — and should otherwise behave like the parent — declare it with SubAgentSpec and let the parent build it:

from symfonic.agent import SubAgentSpec, SymfonicAgent, FrameworkConfig

assistant = SymfonicAgent(
    model_provider=assistant_provider,
    config=FrameworkConfig(auto_hydrate=True, lazy_tooling=True),
    sub_agents=[
        SubAgentSpec(
            name="researcher",
            description="Runs deep web research and returns a sourced summary.",
            when_to_use="Use for anything needing fresh external facts.",
            tools=[web_search_tool],
            temperature=0.2,          # optional child model overrides
        ),
    ],
)

At wiring time the parent constructs the child as an isolated SymfonicAgent that:

  • inherits the parent's ModelProvider — providers are stateless, so one instance is safe to share (pass provider= on the spec to override);
  • inherits every parent behaviour flag (auto_hydrate, lazy_tooling, enable_hms_prompt, …) via FrameworkConfig.child, so children don't drift;
  • gets a fresh domain scoped to the spec (name / domain_description), so its tool manifest auto-derives from its tools rather than the parent's.

Mix SubAgentSpec and pre-built SubAgent(agent=...) in the same sub_agents=[...] list freely; reach for the pre-built form when a child needs shared backends or bespoke wiring the spec doesn't express.

FrameworkConfig.child(parent_cfg, name=..., model_name=..., temperature=..., max_tokens=..., **overrides) is the inheritance primitive underneath — use it directly when you build child configs by hand.

Knowing which child answered

The parent's AgentResponse.delegated_to is a tuple[str, ...] of the sub-agent names it delegated to during the run, in delegation order — empty when the parent answered directly. Handy for tracing and tests:

reply = await assistant.run("What changed in the EU AI Act this quarter?", scope=scope)
assert reply.delegated_to == ("researcher",)

How a delegated call flows

parent.run(query)                      # agent_depth = 0
  └─ model emits run_agent("researcher", "…")
       └─ depth guard: 0 + 1 ≤ max_agent_depth ✓
       └─ researcher.run(task, scope=<inherited>, agent_depth=1)
            └─ returns AgentResponse.final_response
       └─ tool result = that text
  └─ model composes the final answer
  • Task is self-contained. The child does not see the parent's conversation — pass everything it needs in task.
  • Scope is inherited. The active tenant scope flows into the child unchanged.
  • Depth is enforced. Each hop increments agent_depth. When the next hop would exceed max_agent_depth (default 2), run_agent returns a refusal string instead of delegating — the model sees it and can respond directly.

Configuring the depth ceiling

max_agent_depth lives on the agent config (default 2, i.e. parent → child → grandchild). Raise or lower it per agent:

from symfonic.core.config import AgentConfig

config = FrameworkConfig(agent=AgentConfig(max_agent_depth=1))  # parent → child only

Giving a child a different model

A child is just another SymfonicAgent, so it resolves its model independently. Point it at another provider (or another model on the same provider):

summarizer = SymfonicAgent(
    model_provider=cheap_provider,          # e.g. a small/fast model
    config=FrameworkConfig(),
)

To keep everything on one provider but vary the model, use a MultiProviderRouter and per-role model config.

Cost and observability

Each run_agent call is a normal tool call, so it lands as a tool span in your tracing backend. To roll child cost up into the parent, construct the parent and its children with the same metrics_collector — they share the sink and usage aggregates across the whole delegation tree.

Constraints & gotchas

  • Declare children at construction. The tool registry freezes when the graph compiles, so the full set of delegatable children must be passed to SymfonicAgent(sub_agents=[...]) up front. You cannot add a new sub-agent after the agent is built.
  • Unique, whitespace-free names. SubAgent.name is the routing key; duplicates raise at construction.
  • ask_user children need a scope. If a child enables ask_user_enabled, it requires a tenant scope — make sure the parent runs with one so it can be inherited.
  • Isolated memory. Children keep their own memory namespace; they do not read or write the parent's HMS layers.

See also