Subagents
SubAgents lets an agent delegate self-contained tasks to named child agents. It takes a sequence of SubAgent entries and exposes a single delegate_task(agent_name, task) tool. Each delegation runs the chosen sub-agent in its own run — with its own message history, so it never sees the parent conversation — and returns its output to the parent.
While Pydantic AI Harness is on 0.x releases, the API may change between minor releases; when it does, deprecation warnings and release-note migration guidance tell you (or your agent) exactly how to upgrade. See the version policy.
A single agent that does everything accumulates a large tool set and a long context. Splitting the work across specialized sub-agents keeps each context focused, but wiring up delegation by hand means writing a tool per agent, forwarding deps, threading usage limits, and telling the model what it can delegate to.
SubAgents takes a sequence of SubAgent entries and exposes a single delegate_task(agent_name, task) tool. Each delegation runs the chosen sub-agent in its own run — with its own message history, so it never sees the parent conversation — and returns its output to the parent. The available sub-agents are listed in the system prompt as a static instruction, so the listing stays in the cached prefix.
from pydantic_ai import Agent
from pydantic_ai_harness import SubAgent, SubAgents
researcher = Agent('anthropic:claude-opus-5-5', name='researcher', description='Researches a topic and reports findings')
writer = Agent('anthropic:claude-opus-5-5', name='writer', description='Turns notes into polished prose')
orchestrator = Agent(
'anthropic:claude-opus-5-5',
capabilities=[SubAgents(agents=[SubAgent(researcher), SubAgent(writer)])],
)
result = orchestrator.run_sync('Research the history of TLS and write a one-paragraph summary.')
print(result.output)
A delegate’s name — how the parent model refers to it, and how it is listed in the prompt — is the agent’s own name, or a SubAgent(name=...) override. Two delegates resolving to the same name is an error, and an agent with no name and no override is rejected.
| Tool | Purpose |
|---|---|
delegate_task(agent_name, task) | Run the named sub-agent on a self-contained task and return its output. |
- The sub-agent runs with its own message history, so
taskmust be self-contained. - An unknown
agent_nameraisesModelRetry, so the model can correct itself. - The result returned to the parent is
str(result.output). - With a
modelsmenu configured, the tool takes an extramodelargument (see below).
- Deps are forwarded. The parent run’s
depsare passed to each sub-agent, so sub-agents share the parent’sAgentDepsT(enforced by the type signature — every sub-agent is anAbstractAgent[AgentDepsT, Any]). - Delegated agents run in the parent’s workspace, including wrappers such as
ReadOnlyWorkspace. - Usage is shared by default. The parent’s
usageis passed to each sub-agent run, so token usage aggregates and a parentusage_limitsapplies across the whole agent tree. Setforward_usage=Falseto give each sub-agent run its own accounting. - The parent’s tools are not passed on. A sub-agent runs with its own tools. To delegate to an agent that has all of the parent’s tools and capabilities, use
include_self=True(see “Delegating to the agent itself” below). - Capabilities can be shared.
shared_capabilitiesare applied to every sub-agent run — e.g. give all sub-agents a common guardrail, memory, or planning capability without rebuilding eachAgent. - Sub-agent events can be streamed. Pass an
event_stream_handlerand it’s forwarded to each sub-agent run, so the sub-agent’s model-streaming and tool events surface to the caller (the handler receives the sub-agent’s ownRunContext).
inherit_tools=True is deprecated and emits a HarnessDeprecationWarning. It adds the parent agent’s own tools (registered via tools= or toolsets=) to each sub-agent run, minus the delegate tool, but not the tools contributed by the parent’s capabilities. Bind the tools an explicit sub-agent needs directly to its Agent, use include_self=True to delegate to a fresh run of the agent with everything bound to it, or use a tool_resolver to give disk-loaded agents tools.
With include_self=True, the roster also lists the running agent itself, as self. A delegation to self starts a fresh run of that agent (RunContext.agent) on the parent run’s model (or the models option the parent picks), with no parent conversation. Because the child is the same Agent, it has every capability, toolset, and instruction bound to it, and they register again in the child run: a guardrail, approval gate, or audit hook bound next to SubAgents sees the tool calls the delegate makes, not only the delegate_task call. This is what Coder uses by default.
from pydantic_ai import Agent
from pydantic_ai_harness import SubAgents
agent = Agent(
'anthropic:claude-opus-4-7',
capabilities=[SubAgents(include_self=True)],
)
- Only what is bound to the
Agentcarries over. Capabilities, toolsets, instructions, and model settings passed to the parent’srun()are not part of the agent, so the delegate does not get them. PassingSubAgents(include_self=True)itself torun()raises aUserErrorwhen the run starts, since the delegate would come up without it. - Delegation depth is capped. The delegate carries
delegate_tasktoo, somax_depth(default3, counting the top-level run) bounds the tree: the top-level run delegates, its delegates delegate once more, and a run at the limit gets neitherdelegate_tasknor the sub-agent listing. The limit applies to every delegation throughSubAgents, including explicit rosters. - Delegates inherit everything, including what may not suit a sub-task. An
AskUsercapability bound to the agent can prompt the user from inside a delegation, and the delegate returns the agent’s ownoutput_type, rendered withstr(). - The deprecated
inherit_toolsdoes not apply toself, whose tools are already the parent’s. The nameselfis reserved: an explicit delegate with that name is an error, and a disk definition with that name is skipped with a warning.
Each SubAgent carries its own budgets, so one delegate’s controls do not touch the others. A SubAgent with no controls set runs with the SubAgents defaults.
from pydantic_ai import Agent
from pydantic_ai.usage import UsageLimits
from pydantic_ai_harness import SubAgent, SubAgents
reproducer = Agent('anthropic:claude-opus-5-5', instructions='Reproduce the reported bug from a minimal script.')
librarian = Agent('anthropic:claude-opus-5-5', instructions='Find relevant docs, issues, and prior art.')
orchestrator = Agent(
'anthropic:claude-opus-5-5',
capabilities=[
SubAgents(
agents=[
SubAgent(reproducer, usage_limits=UsageLimits(request_limit=35), timeout_seconds=600, max_calls=1),
SubAgent(librarian, usage_limits=UsageLimits(request_limit=18), timeout_seconds=300, max_calls=2),
]
)
],
)
| Field | Effect |
|---|---|
models | Which keys of the SubAgents model menu this delegate may run on, and which one it runs on by default: the first key listed. See “Per-delegation model selection” below. |
usage_limits | A request/token budget for one delegation. The child runs with its own usage accounting, so the budget counts only that child’s requests and tokens (not the parent’s or siblings’). With forward_usage=True, the child’s usage is added to the parent’s usage after the delegation. Reaching the budget is a soft outcome (see below), not a run-stopping UsageLimitExceeded. |
timeout_seconds | A wall-clock budget for one delegation. When the child exceeds it, its run is cancelled and the parent gets a soft steering message instead of hanging on the child. The cancelled child’s event_stream_handler (if any) stops receiving events without a terminal event. |
max_calls | The maximum number of delegations to this sub-agent per parent run. Once reached, further delegations return a soft budget-exhausted message without running the child. Counts are scoped to one Agent.run (a run_id) and cleared when it ends, so each parent run and each level of a nested tree budgets independently. |
on_failure | A steering message returned to the parent for any soft degradation of this delegate, in place of the built-in default. Setting it also makes child failures soft (see below). |
contain_errors | Whether an unexpected crash in this delegate is caught and returned to the parent as a bounded ModelRetry instead of aborting the parent run (see below). Unset inherits the SubAgents(contain_errors=...) default (off). |
The orchestrator knows how hard a task is at the moment it writes the brief, so it is the right place to decide which model runs it. Configure a models menu and the delegate tool gains a model argument that names one of its keys.
from pydantic_ai import Agent
from pydantic_ai.settings import ModelSettings
from pydantic_ai_harness import SubAgent, SubAgents
from pydantic_ai_harness.subagents import ModelOption
reviewer = Agent('anthropic:claude-opus-5-5', name='reviewer', description='Reviews a diff')
linter = Agent('anthropic:claude-opus-5-5', name='linter', description='Runs the linter and reports failures')
orchestrator = Agent(
'anthropic:claude-opus-5-5',
capabilities=[
SubAgents(
agents=[SubAgent(reviewer), SubAgent(linter, models=['fast'])],
models={
'fast': 'anthropic:claude-haiku-4-5',
'standard': 'anthropic:claude-sonnet-5',
'deep': ModelOption(
'anthropic:claude-opus-5-5',
description='hard reasoning, multi-file changes',
settings=ModelSettings(thinking='xhigh'),
),
},
)
],
)
- Off by default. With no
modelsmenu themodelargument is not in the tool schema at all, and every delegation runs exactly as it did before. - The keys are the interface. They are listed in the system prompt with each entry’s model and description, and the tool schema offers them as an enum, so the model picks from the menu instead of inventing a model name. Name them for the job (
'fast','deep'), not for the vendor. - An entry is a model or a
ModelOption.ModelOption(model, description=..., settings=...)adds a routing hint and per-optionModelSettings, so one key can mean “same model, more thinking”. Those settings merge over the sub-agent’s ownmodel_settings, which keeps the parts the option does not set. - Resolution order. The key the parent passed, else the delegate’s first allowed key (see below), else the delegate’s own model, else the parent run’s model.
- A delegate can be restricted.
SubAgent(linter, models=['fast'])pins that delegate tofast: the first listed key is what it runs on when the parent passes nomodel, and the others are refused with aModelRetry. The restriction is rendered in the prompt listing (- linter: Runs the linter (models: fast)). Restricting to a key the menu does not define is aValueErrorat construction. - A rejected key costs nothing. An unknown or unavailable key comes back as a
ModelRetrylisting the valid options, before the delegate’smax_callsbudget is charged.
A soft outcome returns a steering message to the parent as a normal tool result, so its model reads the message and decides what to do next (rather than immediately re-delegating, which a ModelRetry invites). A timeout, a reached usage_limits budget, and an exhausted max_calls budget are always soft. When on_failure is set, the message it carries replaces the built-in default for these outcomes.
A sub-agent run that fails with a soft model error (ModelRetry, UnexpectedModelBehavior, e.g. it exhausted its own retries) is, by default, converted into a ModelRetry for the parent — so the parent’s model sees Sub-agent '<name>' failed: ... and can react by re-delegating. The delegate tool defaults to tool_retries=2, so the parent aborts only after that many consecutive delegate failures; the counter resets after any successful delegation. Raise tool_retries to tolerate a flakier sub-agent, or set None to inherit the parent agent’s default tool retries. Set on_failure for a delegate to make its failures soft instead: the child error returns the on_failure message as a normal tool result.
Hard errors propagate to stop the whole run. A UsageLimitExceeded from a child that has no per-delegate usage_limits (so it shares the parent’s accounting) means the whole tree is out of budget and propagates; a child reaching its own usage_limits is soft, as above.
An unexpected crash — any other exception the child raises, such as a provider ModelAPIError/FallbackExceptionGroup or a plain ValueError from a bad tool argument — propagates by default and aborts the parent run. Set contain_errors=True (per delegate, or as the SubAgents default) to catch it and return it to the parent as a bounded ModelRetry instead, so one delegate crash cannot kill the whole run. Containment stays loud: the exception rides the retry message (Sub-agent '<name>' crashed: ...), it is logged via the standard logging module, and tool_retries still bounds consecutive crashes into an abort. This is orthogonal to on_failure — a contained crash always raises the loud retry, never the soft on_failure return, so a genuine bug is never masked as success. Cancellation, a shared UsageLimitExceeded, pydantic-ai control-flow signals (CallDeferred, ApprovalRequired, the Skip* signals), and UserError bypass containment regardless of contain_errors. Cancellation covers both kinds: external cancellation (asyncio.CancelledError) propagates as-is, and a child’s own first-party cancellation (RunContext.cancel() inside the child, raising RunCancelled) leaves the delegate tool uncontained, after which pydantic-ai isolates it as a failed delegate_task return the parent model can react to, not a crash retry that invites re-delegation.
SubAgents emits typed capability events in the sub_agents namespace so a host can show a delegation as it runs, and how it ended, without parsing the delegate tool’s arguments and result:
| Event | Dispatch | When | Payload |
|---|---|---|---|
DelegationStartEvent | stream | a delegation passed every check and the child run is about to start | agent_name, task, truncated, model (the menu key, or None), inherits_tools |
DelegationEndEvent | stream | the delegation settled into what the parent receives | agent_name, outcome, output, truncated, usage, duration_seconds |
Both are notifications. One delegation is one delegate_task call, so a start and its end share the tool_call_id core stamps on every event; that is how a subscriber pairs them when the model delegates in parallel.
outcome follows the failure handling above: ok (the child’s output went back to the parent), timeout, budget (the child’s own usage_limits), failed (a soft model error, returned as on_failure or raised as a ModelRetry), or contained (a crash contain_errors caught). output is what the delegate tool hands back to the parent in each case: the child’s output, the steering message, or the retry text. It is emitted from inside the tool, before any ToolGuardrail result guard screens that text for the model; a host that needs the screened version reads the ToolReturnPart in core’s FunctionToolResultEvent. usage is the child’s own RunUsage when it has separate accounting (usage_limits set, or forward_usage=False) and None when it accrues into the parent’s usage, where its share is not separable.
A delegation refused before the child runs (an unknown sub-agent, a model key off the menu, an exhausted max_calls budget) emits nothing; the tool result says why. An exception that propagates out of the delegate tool (a shared usage limit, an uncontained crash, a cancellation) ends without an end event. A SubAgentToolset registered directly in Agent(toolsets=[...]) has no owning capability and emits nothing. As with any capability event, a listener that raises aborts the parent run.
task and output are cut at MAX_EVENT_TEXT_CHARS (4096) with a truncated flag, so a persisted or forwarded event stream cannot be flooded by one verbose delegation.
from pydantic_ai import Agent
from pydantic_ai.capabilities import AbstractCapability, on_event
from pydantic_ai_harness.subagents import DelegationEndEvent, SubAgent, SubAgents
class ReportDelegations(AbstractCapability):
@on_event(DelegationEndEvent)
async def on_delegation_end(self, ctx, event: DelegationEndEvent) -> None:
print(f'{event.agent_name}: {event.outcome} in {event.duration_seconds:.1f}s')
researcher = Agent('anthropic:claude-opus-5-5', name='researcher')
agent = Agent(
'anthropic:claude-opus-5-5',
capabilities=[SubAgents(agents=[SubAgent(researcher)]), ReportDelegations()],
)
Nested model streaming from the child run is not an event concern; pass an event_stream_handler for that.
See capability events for how @on_event works.
SubAgents emits no OpenTelemetry spans of its own: the child run is a core agent run with its own spans nested under the parent’s tool-call span, and the events above carry the outcome a trace would only show as an exception or a tool result.
The sub-agents are listed in the system prompt via get_instructions, using each agent’s description (or a SubAgent(description=...) override). A sub-agent with no description is listed by name alone.
A repo’s agent definitions can become delegates without writing any Agent code. With agent_folders set, every Claude-style *.md file and Codex-style *.toml file under those folders is loaded as a sub-agent, alongside the explicitly-passed agents. Files are read in sorted filename order.
from pydantic_ai import Agent
from pydantic_ai_harness import SubAgents
orchestrator = Agent(
'anthropic:claude-opus-5-5',
capabilities=[SubAgents(agent_folders='agents')], # loads .agents/agents/ from the run's workspace
)
Definitions are read at the start of every run from the run’s workspace, so a sandbox’s agent files are found and nothing is read from your home directory. To read them from somewhere else, such as definitions that ship with your application, pass workspace=LocalWorkspaceBackend('/app').
agent_folders controls which folders are read:
- A folder-name
str('agents'is the conventional layout): load from.agents/<name>/,.claude/<name>/, and.codex/<name>/under the workspace’s working directory, in that order, so a workspace that uses.agents/for something else (such as skills) still loads agents from the others. A run without a workspace skips them. - A sequence of workspace paths, absolute or relative to the working directory, loads from exactly those folders, in order.
None, the default, disables disk loading, exposing only the explicitly-passedagents.
Earlier releases loaded the conventional folder by default. When agent_folders is omitted and that folder contains a definition, the run emits a HarnessDeprecationWarning naming the folder it no longer loads. Pass agent_folders='agents' to retain the old behavior, or agent_folders=None to keep the new behavior without a warning.
Until this release, the folders were read from this machine, including the home folder ~/.agents/agents/. A run without a workspace now fails at its start when given a path sequence. Convention discovery warns once when it skips a folder in the current or home directory that the workspace does not reach, naming the folder and the workspace= that reads it.
A definition is a Claude-style markdown file with optional frontmatter, or a Codex-style TOML file. Markdown:
---
name: researcher
description: Researches a topic and reports findings
tools: Read, Grep
---
You research topics. Report your findings, each with a source.
nameis the delegate name (how the parent refers to it and how it is listed). It falls back to the filename stem when absent.descriptiondrives the prompt listing.- The markdown body becomes the agent’s instructions.
tools(orallowed-tools) is a comma-separated string or a YAML block list. See “Tools” below.modelandcolorare ignored: the model is inherited from the parent (see below), andcolorhas no pyai equivalent.
Frontmatter is read by a small, dependency-free parser limited to those keys (pyyaml is not a harness dependency). Full YAML frontmatter is not supported.
A Codex-style standalone TOML file:
name = "reviewer"
description = "Reviews code for bugs"
developer_instructions = "Inspect the code and report findings. Do not edit files."
tools = ["Read", "Grep"]
name,description, anddeveloper_instructionsare required nonempty strings.tools(orallowed-tools) is optional: a list of strings or a comma-separated string, not both keys.model,effort,model_reasoning_effort, andcolorare ignored with a warning; useagent_overridesfor models and effort.- Any other key, including sandbox or permission settings, skips that file with a warning rather than silently granting broader tools. The older
[agents.<name>] config_filelayout is not supported. - TOML is parsed with the standard library
tomllib, so it needs Python 3.11 or newer; on 3.10 TOML files are skipped with a warning.
Nothing in a definition file is executed. A malformed or invalid file is skipped with a warning without blocking the others.
Disk agents inherit the parent run’s model by default. Per agent, the caller can override the model and set a thinking/effort level via agent_overrides, keyed by the agent’s name:
from pydantic_ai_harness import SubAgents
from pydantic_ai_harness.subagents import AgentOverride
SubAgents(
agent_folders='agents',
agent_overrides={'researcher': AgentOverride(model='anthropic:claude-opus-5-5', effort='high')},
)
When effort is unset, the disk agent adds no thinking setting, so the inherited model’s defaults apply. An explicit value, including False or 'minimal', is passed through unchanged via Pydantic AI’s ModelSettings.thinking.
MINIMUM_EFFORT_FLOOR and clamp_effort(level, floor=...) remain importable for compatibility but are deprecated. SubAgents no longer uses them. Pass AgentOverride(effort=...) when a disk agent needs an explicit level, or apply an application-specific floor outside the capability.
Without a tool_resolver, a disk agent gets no tools and its tools frontmatter is ignored. To map the frontmatter tool names to toolsets, pass a tool_resolver: it receives each tool name (so it can honor entries like Bash(git:*)) and returns the toolsets that provide it, or None for an unknown name, which is skipped with a warning.
from collections.abc import Sequence
from pydantic_ai.toolsets import AgentToolset
from pydantic_ai_harness import SubAgents
TOOLSETS: dict[str, Sequence[AgentToolset[object]]] = {} # your tool name -> toolsets mapping
def resolve(tool_name: str) -> Sequence[AgentToolset[object]] | None:
return TOOLSETS.get(tool_name)
SubAgents(agent_folders='agents', tool_resolver=resolve)
When the same name appears in more than one source, the higher-precedence one wins and the others are skipped with a warning: explicitly-passed agents first; for convention discovery, the workspace’s .agents/ folder before its .claude/ folder; and for an explicit path sequence, earlier folders before later ones. A duplicate name within the explicitly-passed agents list is still an error.
from pydantic_ai_harness import SubAgents
SubAgents(
agents=(), # Sequence[SubAgent[AgentDepsT]] -- each pairs an agent with its run controls
models={}, # Mapping[str, Model | str | ModelOption] -- per-delegation model menu (off when empty)
agent_folders=None, # folder-name str ('agents' is conventional) | Sequence[str | Path] workspace paths | None
agent_overrides={}, # Mapping[str, AgentOverride] -- per-disk-agent model/effort override
tool_resolver=None, # Callable[[str], Sequence[AgentToolset[object]] | None] -- disk-agent tool mapping
forward_usage=True, # share the parent's usage with sub-agent runs
inherit_tools=False, # deprecated: use include_self=True, or tool_resolver for disk agents
shared_capabilities=(),# capabilities applied to every sub-agent run
event_stream_handler=None, # forwarded to each sub-agent run to stream its events
tool_name='delegate_task',
tool_retries=2, # extra delegate-tool attempts after a sub-agent error before aborting (None inherits the agent default)
contain_errors=False, # default for SubAgent.contain_errors: contain an unexpected crash as a bounded retry
workspace=None, # WorkspaceBackend to read agent_folders from instead of the run's workspace
include_self=False, # also list the running agent itself as the delegate `self`
max_depth=3, # delegation levels, counting the top-level run
)
from pydantic_ai import Agent
from pydantic_ai_harness import SubAgent
agent = Agent('anthropic:claude-opus-5-5')
SubAgent(
agent, # AbstractAgent[AgentDepsT, Any] -- the child agent to run
name=None, # delegate name; defaults to the agent's own `name`
description=None, # prompt-listing description; defaults to the agent's own `description`
models=None, # Sequence[str] -- menu keys this delegate may run on; the first is its default
usage_limits=None, # per-delegation request/token budget (isolated accounting)
timeout_seconds=None, # per-delegation wall-clock budget
max_calls=None, # max delegations to this sub-agent per parent run
on_failure=None, # steering message for soft degradations of this delegate
contain_errors=None, # contain an unexpected crash as a bounded retry; None inherits the SubAgents default
)
SubAgents is not serializable via the agent spec (it holds live Agent instances), so get_serialization_name() returns None.
- Sub-agents can themselves have
SubAgents, forming a tree. EachSubAgentsstops offering delegation once the run is at its ownmax_depth, so a delegate with a higher limit can go deeper than its parent’s. Shareusage(the default) and set ausage_limitson the top-level run to bound the whole tree. - Delegations the model issues in parallel run as independent sub-agent runs.
Bases: AbstractCapability[AgentDepsT]
Let an agent delegate self-contained tasks to named sub-agents.
Exposes a single delegate_task(agent_name, task) tool. Each delegation
runs the chosen sub-agent in a fresh, isolated run (it never sees the parent
conversation), and the available sub-agents are listed in the system prompt
as a static, cache-stable instruction.
Sub-agents are passed as a sequence of SubAgent entries, each pairing an
agent with its per-delegate run controls (a usage_limits budget, a
wall-clock timeout_seconds, a per-run max_calls budget, an on_failure
steering message, and optional name/description overrides). A delegate’s
name is its SubAgent.name, or the agent’s own name when unset; two
explicitly-passed delegates resolving to the same name is an error.
Delegations run on the sub-agent’s own model unless a models menu is
configured, in which case delegate_task also takes a model argument naming
one of the menu’s keys, so the parent routes each task to the model that fits
it. A SubAgent can restrict which keys it accepts (SubAgent.models).
Sub-agents can also be loaded from disk: each Markdown or standalone TOML definition under
agent_folders in the run’s workspace becomes a delegate, built with the
parent’s model. Folders are read at the start of every run, through
ctx.workspace, or through workspace when set. Disk delegates get no tools
by default; pass a tool_resolver to map their frontmatter tool names.
Disk delegates coexist with explicitly-passed ones; explicitly-passed agents take
precedence. Convention folders use .agents/, then .claude/, then .codex/; explicit folder sequences use
earlier folders before later ones. A disk delegate whose
name is already taken is skipped with a warning. Configure or disable this with
agent_folders; see also agent_overrides and tool_resolver.
Standalone TOML requires Python 3.11+ and nonempty string name, description,
and developer_instructions. Optional tools or allowed-tools accepts a list
of nonempty strings or a comma-separated string, resolved by tool_resolver.
Model/effort/display fields (model, effort, model_reasoning_effort, color)
are ignored with a warning. Other TOML fields cause the file to be skipped,
including unsupported permission/sandbox settings and legacy [agents.name]
config_file declarations. Malformed files warn without blocking valid files.
With include_self=True, the roster also lists the running agent itself, as self:
a delegation starts a fresh run of RunContext.agent, so the delegate has every
capability, tool, and instruction bound to that agent, including guardrails and
approval hooks added next to this one. Delegations nest at most max_depth levels.
The parent’s deps are forwarded to each sub-agent (sub-agents therefore
share the parent’s AgentDepsT), and by default the parent’s usage is
shared so usage limits apply across the whole agent tree. Extra capabilities
can be applied to every sub-agent run (shared_capabilities), and sub-agent
events can be streamed to a handler (event_stream_handler).
from pydantic_ai import Agent
from pydantic_ai_harness.subagents import SubAgent, SubAgents
researcher = Agent('anthropic:claude-sonnet-4-6', name='researcher', description='Researches topics')
writer = Agent('anthropic:claude-sonnet-4-6', name='writer', description='Writes prose')
orchestrator = Agent(
'anthropic:claude-opus-4-7',
capabilities=[SubAgents(agents=[SubAgent(researcher), SubAgent(writer)])],
)
The sub-agents to expose, each a SubAgent pairing an agent with its
per-delegate run controls. See SubAgent. These take precedence over any
disk-loaded agents of the same name.
Type: Sequence[SubAgent[AgentDepsT]] Default: ()
A menu of models the parent can route an individual delegation to, keyed by
the name the parent uses to pick one. Off by default: with no menu the delegate
tool has no model argument and every delegation runs the way it always did.
Each value is a model reference, or a ModelOption carrying a routing hint and
its own ModelSettings (so one key can mean “same model, more thinking”). The
keys and their descriptions are listed in the system prompt, so name them for
the job — 'fast', 'deep' — rather than for the vendor. SubAgent.models
restricts which of them a given delegate accepts.
from pydantic_ai_harness.subagents import SubAgents
SubAgents(models={'fast': 'anthropic:claude-haiku-4-5', 'deep': 'anthropic:claude-opus-4-7'})
Type: Mapping[str, Model | KnownModelName | str | ModelOption] Default: field(default_factory=(dict[str, 'Model | KnownModelName | str | ModelOption']))
Where to load Markdown and standalone Codex TOML definitions from, in addition to agents.
Off by default: only agents are exposed unless this is set. Every folder is
read at the start of each run through the run’s workspace (ctx.workspace),
or through workspace when set.
- a folder-name
str('agents'is the conventional layout): load from.agents/<name>/,.claude/<name>/, and.codex/<name>/under the workspace’s working directory, in that order. Skipped when the run has no workspace. - a sequence of paths in the workspace, absolute or relative to its working
directory: load from exactly those folders, in order. A run with no workspace
fails at its start; pass
workspace=LocalWorkspaceBackend('.')to read them from this machine. None: no disk loading (the default).
Missing folders are skipped. Within a folder every *.md file is a candidate.
This previously defaulted to 'agents'. Leaving it unset while the run’s
workspace contains a conventional agent definition emits a
HarnessDeprecationWarning; pass either value explicitly to stay silent.
Type: str | Sequence[str | Path] | None Default: _UNSET_FOLDERS
Per-disk-agent overrides keyed by the agent’s name. An entry can set the
agent’s model (otherwise the parent’s model is inherited) and its effort
(otherwise no thinking setting is added). Has no effect on explicitly-passed agents.
Type: Mapping[str, AgentOverride] Default: field(default_factory=(dict[str, AgentOverride]))
Optional override for how a disk agent gets its tools. When set, each tool
name in a definition’s tools/allowed-tools frontmatter is passed to this
resolver and the returned toolsets are attached to that agent; an unknown name
(resolver returns None) is skipped with a warning. When unset, the
frontmatter tool list is ignored and disk agents have no tools of their own.
Type: ToolResolver | None Default: None
If True, the parent run’s usage is shared with each sub-agent run, so
token usage aggregates and usage limits apply across the whole agent tree.
Type: bool Default: True
Deprecated: setting it to True emits a HarnessDeprecationWarning.
If True, the parent agent’s own tools (registered via tools= or toolsets=,
not those contributed by capabilities) are exposed to each sub-agent run, minus
the delegate tool. Bind required tools directly to explicit sub-agents, use
include_self=True to delegate to an agent with all of the parent’s tools and
capabilities, or use tool_resolver to give disk agents tools.
Type: bool Default: False
Capabilities applied to every sub-agent run, in addition to whatever each sub-agent already has.
Type: Sequence[AgentCapability[AgentDepsT]] Default: ()
If set, this handler is passed to each sub-agent run, so the sub-agent’s
model-streaming and tool events surface to the caller. The handler receives
the sub-agent’s own RunContext and event stream.
Type: EventStreamHandler[AgentDepsT] | None Default: None
Name of the delegate tool exposed to the model.
Type: str Default: 'delegate_task'
One-off: an agent exposes a single delegate tool, so the id is fixed.
tool_name is one name, so two SubAgents capabilities register the same tool and collide.
Declaring the id here is what makes two of them merge instead, unioning their rosters — which
is what lets a packaged harness that delegates compose with another that does the same.
Keyword-only on the field rather than through a KW_ONLY marker: a marker applies to every
field after it, which would take tool_retries and contain_errors off the positional
contract they already have.
Type: str | None Default: field(default='sub_agents', kw_only=True)
Retries for the delegate tool — how many extra attempts it gets after a
sub-agent error before the parent run aborts. A sub-agent failure (e.g. it
exhausts its own output retries) surfaces to the parent as a tool retry it
can react to by re-delegating with a corrected task. The retry counter
resets after any successful delegation, so this bounds consecutive failures,
not total ones. Defaults to 2 (pydantic-ai’s per-tool default is 1) so a
repeated flaky sub-agent does not abort the parent run on its first repeat;
set None to inherit the parent agent’s default tool retries instead.
Default for SubAgent.contain_errors: whether an unexpected sub-agent crash
is caught and returned to the parent as a bounded ModelRetry instead of
aborting the parent run. Off by default, so a crash propagates. Any SubAgent
can override this per delegate. See SubAgent.contain_errors for the
containment contract and what always propagates regardless.
Type: bool Default: False
A workspace to read agent_folders from instead of the run’s, such as
LocalWorkspaceBackend('/app') for definitions that ship with the application.
A backend, not the LocalWorkspace capability. It is used in-process only in this release: a
durable engine does not route it through its workflow machinery.
Type: WorkspaceBackend | None Default: field(default=None, kw_only=True)
If True, the roster also lists the running agent itself, as self.
A delegation to self starts a fresh run of RunContext.agent on the parent run’s model
(or the models option the parent picks), so the delegate has every capability, toolset, and instruction bound to that Agent —
guardrails, approval gates, and audit hooks included, since they are registered again in
the child run. What was passed to the parent’s run() rather than bound to the Agent
(run-level capabilities, toolsets, instructions, model_settings) does not carry
over, so this capability has to be bound to the Agent itself; passing it to run()
raises a UserError when the run starts.
inherit_tools does not apply to self, whose tools are already the parent’s. The
delegate can delegate in turn, up to max_depth.
Type: bool Default: False
How many levels a delegation tree may have, counting the top-level run as the first.
The default of 3 lets the top-level run delegate, and its delegates delegate once more.
A run at the limit gets neither the delegate tool nor the sub-agent listing. The level
is tracked per task tree, across every SubAgents capability, and each capability enforces
its own limit. This bounds include_self, whose delegate carries the delegate tool again,
and a roster that reaches the same agent through another path.
Type: int Default: DEFAULT_MAX_DEPTH
@async
def for_run(ctx: RunContext[AgentDepsT]) -> SubAgents[AgentDepsT]
A per-run copy when agent folders are read; otherwise self.
A run at max_depth gets a copy without the delegate tool and listing instead. The per-run
copy starts from the explicit roster; before_run adds the folders’ definitions. Its
instructions and toolset are read after that, so they list the delegates this run has.
SubAgents[AgentDepsT]
@async
def before_run(ctx: RunContext[AgentDepsT]) -> None
Read the agent folders’ definitions through the workspace and rebuild this run’s roster.
@async
def wrap_run(
ctx: RunContext[AgentDepsT],
*,
handler: WrapRunHandler,
) -> AgentRunResult[Any]
Run the parent agent, then drop this run’s delegation counts so they don’t accumulate.
def get_instructions() -> AgentInstructions[AgentDepsT] | None
Cache-stable listing of the available sub-agents and models.
A per-run copy returns it as a function, rendered after before_run has read the
workspace’s definitions; it is the same text on every step of the run.
AgentInstructions[AgentDepsT] | None
def get_toolset() -> AgentToolset[AgentDepsT] | None
Toolset providing the delegate tool, or None when no sub-agents are configured.
A per-run copy returns a function yielding the toolset before_run settled on, the same
instance for every step of the run.
AgentToolset[AgentDepsT] | None
@classmethod
def get_serialization_name(cls) -> str | None
Not spec-serializable — the capability holds live Agent instances.
@classmethod
def combine(
cls,
capabilities: Sequence[AbstractCapability[AgentDepsT]],
) -> AbstractCapability[AgentDepsT]
Compose the rosters, and require everything else to already agree.
Two packaged harnesses on one agent each bring their delegates, and composing them is what
the shared id is for. Only agents and models are composed. Every other field decides
how the delegates run — what capabilities they are handed, whether they see the parent’s
tools, what the delegate tool is called, where delegates are loaded from — so merging it
would apply one harness’s policy to the other’s sub-agents, which neither author asked for.
Those must agree, and say so when they do not.
Disk definitions are not part of the merge: the per-run copy reads them in before_run,
after run-level capabilities are combined.
AbstractCapability[AgentDepsT]
Bases: Generic[AgentDepsT]
One delegate: a child agent plus its per-delegate run controls.
Pass a sequence of these as SubAgents(agents=[...]). The delegate’s name —
how the parent model refers to it, and how it is listed in the system prompt —
is name when set, otherwise the agent’s own name. An agent with neither is
rejected by SubAgents.
Every control below is optional; an unset field leaves the corresponding
behaviour at the SubAgents default.
The agent that runs when this delegate is invoked.
Type: AbstractAgent[AgentDepsT, Any]
Name the parent model uses to delegate to this agent. Defaults to the
agent’s own name when unset.
Type: str | None Default: None
Description for the system-prompt listing. Defaults to the agent’s own
description when unset; a delegate with neither is listed by name alone.
Type: str | None Default: None
Which of SubAgents.models this delegate may run on, as menu keys, and
which one it runs on by default: the first key listed. Leave it unset to let
the parent pick any configured option and to fall back to the delegate’s own
model when it picks none. Set it to pin a delegate to one option
(models=['fast']) or to bound an expensive delegate to a subset. Naming a key
the menu does not define is an error.
Type: Sequence[str] | None Default: None
Request/token budget for one delegation. When set, the child runs with
its own usage accounting so the budget counts only the child’s own requests
and tokens (not the parent’s or siblings’). When forward_usage=True, that
usage is added to the parent’s usage after the delegation. Hitting this budget
is a soft outcome (steering message), not a run-stopping UsageLimitExceeded.
Type: UsageLimits | None Default: None
Wall-clock budget for one delegation. When the child exceeds it, the run is cancelled and the parent gets a soft steering message instead of hanging on the child.
Type: float | None Default: None
Maximum number of delegations to this sub-agent per parent run. Once reached, further delegations return a soft budget-exhausted message without running the child.
Type: int | None Default: None
Steering message returned to the parent for any soft degradation of this
delegate (timeout, child failure, usage budget reached, call budget
exhausted), in place of the built-in default. Setting it also makes child
failures soft: a child error returns this message as a normal tool result
instead of raising a parent ModelRetry.
Type: str | None Default: None
Whether an unexpected sub-agent crash is contained instead of aborting the
parent run. When True, an exception the child raises that is not an expected
soft degradation (a provider ModelAPIError/FallbackExceptionGroup, a plain
ValueError from a bad tool argument, etc.) is caught and returned to the parent
as a bounded ModelRetry, so one delegate crash cannot kill the whole run. It
stays loud: the exception rides the retry message and is logged, and
tool_retries still bounds consecutive crashes into an abort. Cancellation, a
shared usage-limit, pydantic-ai control-flow signals, and UserError always
propagate regardless. Unset inherits SubAgents.contain_errors (default off).
Orthogonal to on_failure, which only sets the message for expected soft
degradations; a contained crash always raises the loud ModelRetry.
Type: bool | None Default: None
Run in a ReadOnlyWorkspace; supply only trusted read-only tools as well.
Type: bool Default: dataclass_field(default=False, kw_only=True)
The delegate’s name: name if set, else the agent’s own name.
One entry on the model menu, as a model plus how it should run.
Pass bare model references when the menu keys speak for themselves, and a
ModelOption when an entry needs a routing hint or its own settings:
from pydantic_ai.settings import ModelSettings
from pydantic_ai_harness.subagents import ModelOption, SubAgents
SubAgents(
models={
'fast': 'anthropic:claude-haiku-4-5',
'deep': ModelOption(
'anthropic:claude-opus-4-7',
description='hard reasoning, multi-file changes',
settings=ModelSettings(thinking='xhigh'),
),
},
)
The model a delegation routed to this option runs on.
Type: Model | KnownModelName | str
What this option is for, listed in the prompt next to the key so the parent can route on task difficulty rather than on model names alone.
Type: str | None Default: None
Settings for a delegation routed to this option — thinking effort,
temperature, and so on. They merge over the sub-agent’s own model_settings,
which keeps whatever the sub-agent set and is not overridden here.
Type: ModelSettings | None Default: None
Per-agent override for a disk-loaded sub-agent, keyed by the agent’s name.
Both fields are optional. An unset model inherits the parent run’s model; an
unset effort leaves the inherited model’s thinking setting unchanged.
Model to run this disk agent with, in place of inheriting the parent’s.
Type: Model | KnownModelName | str | None Default: None
Thinking/reasoning level for this disk agent, passed through unchanged.
Type: ThinkingLevel | None Default: None
Bases: CapabilityEvent
A sub-agent run is about to start for one delegation.
Emitted once the delegation has passed every check that could refuse it (an
unknown sub-agent, a model key off the menu, an exhausted max_calls
budget), immediately before the child run starts.
The menu key the delegation runs on, or None when no option was selected: there is
no menu, or the delegate allows the whole menu and the parent named no key.
Whether the parent’s own tools were passed to the child run (SubAgents.inherit_tools).
Type: bool
Bases: CapabilityEvent
A delegation settled into what the parent receives.
output is what the delegate tool hands back to the parent: the child’s
output on ok, otherwise the steering message it returns or the
ModelRetry it raises. An exception that propagates out of the delegate
tool (a shared usage limit, an uncontained crash, a cancellation) ends
without this event for ordinary SubAgents users. An opt-in DelegationTasks
owner emits terminal cancelled or error outcomes with stable task identity.
The child’s own usage when it has separate accounting (SubAgent.usage_limits
set, or forward_usage off); None when it accrues into the parent’s usage.
Wall-clock seconds from just after the start event was emitted until the delegation settled.
Type: float
SubAgents keeps its existing foreground-only behavior unless a caller explicitly
opens and binds DelegationTasks. This owner adds background and resume to the
delegate tool’s schema, gives every child a stable conversation ID, and owns every
worker until shutdown. Use DelegationReports on the parent run to deliver settled
background reports through core’s SystemPromptPart queue. The reports are
explicitly automated, untrusted data, not user instructions or permission grants.
No extra agent loop is implemented. Reports default to priority='when_idle'
so active parents finish their current work first. A host that starts an idle
continuation should use DelegationReports(..., priority='asap') and
agent.run(None, ...): pending reports then enter the first model request, with
no synthetic user prompt. The host owns wake-up scheduling between runs.
from pathlib import Path
from pydantic_ai import Agent
from pydantic_ai_harness.subagents import DelegationReports, DelegationTasks, SubAgents
agent = Agent('anthropic:claude-sonnet-4-6', capabilities=[SubAgents(include_self=True)])
tasks = DelegationTasks(directory=Path('.task-history'))
async def converse():
async with tasks.opened():
with tasks.bind():
result = await agent.run(
'Delegate an independent investigation in the background.',
conversation_id='review',
capabilities=[DelegationReports(tasks, conversation_id='review')],
)
print(result.output)
# Keep this owner open across subsequent parent turns.
opened() drains workers on exit. Keep workspace and plugin resources alive outside
that scope. Detached execution is refused for run-owned non-local workspaces;
foreground delegation still works. max_depth=4 counts the main run and allows
three child layers. An explicitly configured non-default SubAgents.max_depth
still takes precedence. Ordinary SubAgents retains its original depth default.
background(task_id) releases a foreground waiter without restarting the child.
await cancel(task_id) stops and drains that child and its descendants. A user stop
blocks model-requested resume until the application explicitly calls
await allow_resume(task_id). one_shot names never resume. A resume uses the same
child ID and its independent history, a new run ID, and the current direct parent.
A child waits for its own descendants and consumes their reports before its final
output settles. Reports are routed to the direct parent; an idle parent receives
pending reports on its next explicitly started run. Enqueue delivery is acknowledged
only when core emits EnqueuedMessagesEvent, and acknowledgements are persisted.
An observer receives DelegationTaskEvent, with the task identity and an optional
correlated child stream event. Managed start/end events carry task_id and
parent_id. Managed cancellation and uncontained exceptions produce terminal
outcomes; unmanaged events and exception propagation keep their original contract.
Metadata and final/interrupted histories are atomically saved under directory.
Pass step_store to checkpoint through StepPersistence and recover a process-killed
child’s latest frontier. Loading an interrupted record never executes its tools.
Inspect possible partial effects before an explicit resume.
Managed children with forward_usage=True share live usage accounting and inherit
parent ceilings. A per-child budget is converted to an absolute ceiling at launch;
concurrent sibling spend may reach that ceiling earlier, but cannot bypass the
parent’s budget. The ordinary unmanaged per-child accounting contract is unchanged.
agents, aliases, and instructions extend the roster and guidance only inside
the bound scope; they do not add delegation to an agent without SubAgents.
SubAgent(read_only=True) wraps the child’s workspace in ReadOnlyWorkspace.
Also give that agent only trusted read-only capabilities: arbitrary Python tools
can bypass the workspace API. CLAI’s Explore and Plan specialists use filesystem
readers and expose no shell, code execution, or parent plugin tools.
Core’s agent/model/tool spans provide execution telemetry; task IDs, parent IDs, and child run IDs provide correlation. This owner adds no logging exporter.