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:
- builds a concrete
AgentStorebacked by your children, - registers two tools the parent's model can call —
run_agent(name, task)andlist_agents()— before the graph compiles, and - exposes the store as a
depscapability.
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 (passprovider=on the spec to override); - inherits every parent behaviour flag (
auto_hydrate,lazy_tooling,enable_hms_prompt, …) viaFrameworkConfig.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 exceedmax_agent_depth(default2),run_agentreturns 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.nameis the routing key; duplicates raise at construction. ask_userchildren need a scope. If a child enablesask_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¶
- Plugins & domains — give a child its persona and guardrails
- Model routing — per-child model/provider selection
- Lazy tooling — keep large tool sets cheap on the parent