Delegating to sub-agents¶
A single request runs a single agent. When a task splits into specialised
sub-tasks — research this, then draft that — you can give the agent a roster of
sub-agents to delegate to.
pydantic-ai-harness's
SubAgents capability exposes one delegate_task(agent_name, task) tool:
the parent picks a named sub-agent, hands it a task, and gets its result back —
each child runs as a fresh, isolated run.
Like CodeMode, this is pure composition over the
capabilities= seam plus the optional
[harness] extra — no django-ag-ui configuration beyond one capability in a
list.
Install¶
The [harness] extra pulls pydantic-ai-harness; subagents ships in its base
package. The core install stays django + pydantic-ai-slim — the harness is
lazy, only imported by the capability you wire in.
Wire it in¶
capabilities= takes capability instances, or zero-argument callables that
return one. Add one that returns a SubAgents with your child agents:
# myproject/agent.py
from pydantic_ai import Agent
from pydantic_ai_harness.subagents import SubAgent, SubAgents
researcher = Agent(
"anthropic:claude-sonnet-4.6",
name="researcher",
instructions="Research the topic and return concise findings.",
)
writer = Agent(
"anthropic:claude-sonnet-4.6",
name="writer",
instructions="Draft prose from the findings you are given.",
)
def subagents():
return SubAgents(
agents=[
SubAgent(researcher, description="Gathers facts on a topic."),
SubAgent(writer, description="Turns findings into prose."),
],
agent_folders=None, # see the note below — disables on-disk auto-discovery
)
# urls.py
from myproject.agent import subagents
agent = AGUIServer(registry, capabilities=[subagents])
Now the agent exposes a delegate_task tool. When it calls
delegate_task("researcher", "…"), the child runs and its answer returns as the
tool result — which renders in the web component as an ordinary tool card. The
tool name is configurable (SubAgents(tool_name="…")).
Disable on-disk agent discovery unless you want it
SubAgents defaults agent_folders="agents", which auto-loads Markdown
agent definitions from ./.agents/agents/, ~/.agents/agents/, and the
.claude/ equivalents at construction time. In a server process that's
rarely what you want — pass agent_folders=None to rely only on the
agents=[…] you list explicitly.
Per-delegate limits¶
Guardrails are fields on each SubAgent (there is no separate limits class), so
you can bound each child independently:
from pydantic_ai.usage import UsageLimits
SubAgent(
researcher,
description="Gathers facts on a topic.",
usage_limits=UsageLimits(request_limit=4), # isolated child accounting
timeout_seconds=30, # cancels a runaway child
max_calls=2, # budget per parent run
on_failure="Research is unavailable; answer from what you know.",
)
A child that trips a limit degrades softly — the parent receives a steering message as the tool result rather than an exception, so the run continues.
Note
SubAgents is one of several pydantic-ai-harness capabilities that drop
into the same capabilities= seam (compaction, step-persistence, CodeMode,
…). They ride the [harness] extra, except CodeMode, which needs the
sandbox in [code-mode].
Showing the child's work¶
A delegated child runs to completion inside one delegate_task tool call, and a
tool call emits nothing between its arguments and its result. So a parent that
hands a long task to a sub-agent shows a tool card that simply sits there — for
a minute, for five — with no way to tell a working run from a wedged one.
Wrap the capability in a
SubAgentObserver and the run reports itself as
it goes:
from django_ag_ui import SubAgentObserver
from pydantic_ai_harness.subagents import SubAgent, SubAgents
def subagents():
return SubAgentObserver(
SubAgents(
agents=[SubAgent(researcher, description="Gathers facts on a topic.")],
agent_folders=None,
)
)
Opt-in by construction: passing SubAgents unwrapped emits nothing, and there is
no setting behind the wrapping. The observer installs itself onto the capability
you hand it — it needs SubAgents.event_stream_handler, which only the
capability that starts the child run can pass on — so wrapping one that already
carries a handler is refused rather than replacing it.
What reaches the client¶
Two kinds of event, interleaved into the run as they happen rather than at
the end of the delegation. The delegation's own lifetime rides the protocol's
events; each tool call the child makes rides a CUSTOM one.
The delegation opens and closes on the protocol's own events¶
{"type": "SUBAGENT_STARTED", "subagentRunId": "subagent-call_abc123",
"name": "researcher", "parentToolCallId": "call_abc123"}
and closes with exactly one SUBAGENT_FINISHED carrying the same
subagentRunId, or a SUBAGENT_ERROR carrying it plus a message.
parentToolCallIdis the parent's owndelegate_tasktool call id — thetoolCallIdthe client already drew a card for. That is what makes this an augmentation of the card on screen rather than a second row beside it, and it is the protocol's field for exactly this "agents as tools" shape.subagentRunIdnames the child run. It is derived from the delegation's tool call id, which is already unique per invocation — but read the link fromparentToolCallId, never by taking the prefix off this one.- Exactly one open, exactly one close, on every path including cancellation.
@ag-ui/clientverifies this: it refuses a reusedsubagentRunId, and refusesRUN_FINISHEDwhile any delegation is still open.
Each of the child's tool calls rides a CUSTOM event¶
{
"type": "CUSTOM",
"name": "ag_ui.subagent",
"value": {
"delegationId": "call_abc123",
"agent": "researcher",
"phase": "tool_call",
"status": "researcher: calling search_docs",
"tool": {"toolCallId": "call_def456", "name": "search_docs", "ok": null}
}
}
delegationIdis the same parent tool call id the lifecycle events carry asparentToolCallId. That is what joins the two carriers.phaseistool_callortool_result. Any number of the pair sit between the delegation's open and its close.statusis a rendered one-line summary, so a client that draws a collapsed row and never expands it needs nothing else.toolalways has all three keys.okisnullontool_call,trueon a result the child accepted andfalseon one that came back as a retry — a fixed shape, so a client creates the row ontool_calland updates it in place ontool_result.
A client that does not know the CUSTOM name ignores those events and the run
streams exactly as it did before: name is an open string the protocol leaves
to conventions like this one. A client that speaks AG-UI at all understands the
three lifecycle events without knowing anything about this package.
tests/fixtures/subagent_progress_stream.json in this repository is a full
recorded run — both a successful delegation and a failing one, on both carriers
— generated by scripts/generate_subagent_fixture.py driving the real endpoint,
and regenerated by the test suite on every run so it cannot drift from the
server. Build a client against that rather than against a hand-typed sample: the
interleaving of the two carriers is the part neither contract states on its own.
What it deliberately does not carry¶
The child's failure text. A SUBAGENT_ERROR names the sub-agent and stops:
its required message says only which one failed, and the optional code is
left unset. An exception's own words are written for an operator, which is the
same reasoning TOOL_FAILURE["INCLUDE_DETAIL"] applies to RUN_ERROR — and
nothing is lost, because whatever the delegation returns to the parent model
travels the ordinary TOOL_CALL_RESULT and is rendered on the card this
progress belongs to.
The child's prose. Progress is a status line, not a second transcript.
Why the steps stay on CUSTOM
@ag-ui/client materialises an ACTIVITY_SNAPSHOT into a
role: "activity" message, the message list is persisted wholesale, and the
client replays it on every thread restore. That is right for a
chart, which is content. Replayed progress is a lie: a run
that finished last week would redraw "calling search_docs" on every reload.
Shared state is out for a third reason — it round-trips into the next
RunAgentInput, so progress placed there would be echoed back to the model.
The protocol does have a way to say "the child called a tool": an ordinary
TOOL_CALL_START tagged with subagentRunId. It is not used here, and this
is the same objection — the client materialises those into agent.messages
exactly as it does the parent's own, so they would be persisted and
replayed. The three lifecycle events have no such problem, which is why they
were adoptable and these are not.