Sub-agent Derivation¶
A sub-agent is simply a new Agent.inherit(parent). What the framework adds on top is the factory layer that turns it into a tool:
register four tools and let the model decide when to hand work out, to whom, and when to collect the result.
Two approaches¶
Do it yourself — create an agent inside an ordinary tool function; this gives you the finest control:
from xun import Agent, ToolBox
def review(code: str) -> str:
"""Review a piece of code with a dedicated agent."""
child = Agent.inherit(parent, share_display=False)
return child.initialize().instruct(f"Review this code:\n{code}").execute().unwrap()
Use the built-in strategies — register derivation as tools and let the model call them on demand:
from xun import Agent, ToolBox
agent = Agent(toolbox=ToolBox().with_subagent_provider()).initialize()
setup_agent(default_tools=True) does exactly the latter.
The four built-in derivation tools¶
| Tool | Parameters | Returns | Description |
|---|---|---|---|
agent_run |
task, name? |
Result[str, ErrorInfo] |
Runs to completion and returns the child agent's final output |
agent_spawn |
task, name? |
{"id": …} |
Runs in the background and returns an ID immediately |
agent_wait |
identifier, timeout_seconds=300 |
Result[str, ErrorInfo] or {"status": "timeout", …} |
Waits for the result and takes it; taking it removes the task from the pool. null means wait indefinitely |
agent_list |
— | [{"id": …, "status": "running" \| "finished"}] |
Lists the tasks in the pool, including finished ones whose results were never taken |
There is no agent_kill
Background tasks cannot be cancelled on their own. The cleanup that a child agent registers on before_finalize joins for five seconds,
and calls cancel() on anything still running after the timeout; cancellation from the parent side travels through the cancellation chain, see Chained Cancellation.
Derivation strategy: agent_getter¶
The optional argument of with_subagent_provider(agent_getter=None) decides "what a child agent looks like":
from xun import AgentGetterParam
def agent_getter(param: AgentGetterParam) -> Agent:
# Must return an **uninitialized** agent and honor what param asks for (the name, for instance)
child = Agent(toolbox=ToolBox().register(sqrt))
child.name = param.name or "subagent"
return child
agent = Agent(toolbox=ToolBox().with_subagent_provider(agent_getter)).initialize()
AgentGetterParam has only two fields:
| Field | Description |
|---|---|
tool_context |
The ToolCallContext that triggered this derivation; the parent agent is on tool_context.agent |
name |
The name requested by the model, possibly None; the getter should respect it |
The default strategy, used when no getter is passed:
Agent.inherit(parent)— shares the display layer and the workspace, clones the toolbox and the command registry, keeps the conversation independent, and attaches the cancel event to the parent chain;- Writes the built-in sub-agent system prompt;
- Renames the agent when
param.nameis not empty; - Depth counting:
agent.state["__subagent_depth"]grows by 1 per level, and beyondSUBAGENT_MAX_DEPTH(3)toolbox.disable_subagent()is called, so the deepest agents no longer have derivation tools.
flowchart TD
P["Parent agent"] -->|"agent_spawn / agent_run"| G["agent_getter(param)"]
G --> D["Agent.inherit(parent)"]
D --> C["Agent[Uninit]"]
C --> I["initialize(): extension replay + display bind"]
I --> T["execute() in a thread"]
T --> Q["Result written back to the task pool"]
Q --> W["agent_wait takes it out → Result"]
Result pool and lifecycle¶
Background tasks are accounted for by AgentPool, a RuntimeEntry stored on agent.state under the key __subagents,
so a derived child agent sees the same pool as its parent:
| Action | Effect |
|---|---|
agent_spawn |
Starts a thread and registers an AgentThread |
agent_wait |
Polls every 0.5 seconds while also checking for parent-side cancellation; removes the task from the pool once its result is taken |
before_finalize |
Pool cleanup: join(5), then child.cancel() for anything still running |
With a custom getter, create the child agent via Agent.inherit, otherwise the cancellation chain is broken —
unless you pass parent.cancel_event down explicitly.
child = Agent.inherit(param.tool_context.agent).system("…") # Cancellation chain inherited automatically
Common setups¶
| Goal | How |
|---|---|
| Child agent runs silently | Agent.inherit(parent, share_display=False), or give it NullDisplay() |
| Child agent uses only a restricted tool set | Custom getter that returns an Agent registering only those tools |
| Forbid further derivation | toolbox.disable_subagent() (the default strategy already does this at the fourth level) |
| Child agent does not load extensions | child.config.enable_extensions = False |
| Share conversation context | Agent.inherit(parent, copy_conversation=True) |
Extensions are replayed
A child agent also runs the extensions when it calls initialize(). Because the toolbox is a shallow copy,
a tool an extension registered on the original toolbox already exists by name in the child toolbox, and registering it again conflicts —
the conflict aborts the rest of that extension's setup for the child agent (downgraded to a warning), but hooks that were already added stay in effect.