Skip to content

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:

  1. 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;
  2. Writes the built-in sub-agent system prompt;
  3. Renames the agent when param.name is not empty;
  4. Depth counting: agent.state["__subagent_depth"] grows by 1 per level, and beyond SUBAGENT_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.