Skip to content

Sub-agents and cancellation

A sub-agent is "a disposable agent with an isolated context": blank conversation by default, nameable, cascadingly cancellable, and depth-limited.

Built-in tools

ToolBox().with_subagent_provider() (already included in setup_agent(default_tools=True)) registers two tools:

Tool Parameters Returns
agent_run task: str, name: str \| None = None Result[str, ErrorInfo]
agent_run_parallel tasks: list[str], names: list[str] \| None = None Result[list[Result[str, ErrorInfo]], ErrorInfo]: normally an Ok(list), with the list in the same order as the input; only a failure of the tool itself produces an outer Err

agent_run_parallel runs concurrently on a thread pool (4 workers by default); tasks/names may also be given as one string, parsed by the generic argument validator (JSON array or comma-separated — see Tool system); when names is given but its length does not match tasks, the returned list contains only a single Err. Only Ctrl+C interrupts sibling tasks (child threads do not receive SIGINT).

Custom getter

Pass a getter to with_subagent_provider() and you decide the sub-agent's composition entirely:

import math
from xun import Agent, AgentGetterParam, ToolBox, ToolCallContext as Context

def sqrt(ctx: Context, a: float) -> float:
    """Square root."""
    return math.sqrt(a)

def agent_getter(param: AgentGetterParam) -> Agent[Agent.T.Uninit]:
    # must return an **uninitialized** agent and honour the requirements in param (name None means any name is fine)
    agent = Agent(toolbox=ToolBox().register(sqrt))
    agent.name = param.name or "subagent"
    return agent

agent = Agent(toolbox=ToolBox().with_subagent_provider(agent_getter)).initialize()
agent.instruct("Compute sqrt(114514) with a sub-agent.").execute()

AgentGetterParam has only two fields:

Field Type Description
tool_context ToolCallContext reach the parent agent through param.tool_context.agent
name str \| None the requested name; AgentGetterProtocol = Callable[[AgentGetterParam], Agent[Agent.T.Uninit]]

The behaviour of the default getter is equivalent to the pseudo-code below (ctx = param.tool_context; get_subagent_prompt() lives in xun.prompt and is not exported from the top level):

agent = Agent.inherit(ctx.agent).system(get_subagent_prompt())
if param.name:
    agent.name = param.name
agent.state["__subagent_depth"] = ctx.agent.state.get("__subagent_depth", 0)   # inherit the parent depth
if agent.state["__subagent_depth"] >= ToolBox.SUBAGENT_MAX_DEPTH:              # 3
    agent.toolbox.disable_subagent()
agent.state["__subagent_depth"] += 1                                           # then increment for the next level

Inheritance semantics

Agent.inherit() parameter Default Effect
share_workspace True parent and child share workdir and the same lazy temporary directory
share_display True the sub-agent's events also show up in the same UI; False uses NullDisplay
copy_toolbox True the tool table is cloned, the sub-agent may change it further
copy_command True session commands are available
copy_conversation False blank context — the core of the isolation
inherit_cancel_event True the cancellation token is attached beneath the parent's token

In addition: config is a clone of the parent's configuration; api_call_semaphore is shared with the parent (concurrent sub-agents all obey the same model-call limit, 3 by default); hooks, state and compactor are not inherited, but extensions are replayed at initialize(), so hooks registered by extensions still appear on the sub-agent.

Both copy_toolbox and copy_command are shallow copies: the containers are new, while the Function / Command objects inside them are still shared with the parent.

Depth limit: SUBAGENT_MAX_DEPTH = 3. The check happens at creation time and uses the parent's depth, so at most three generations of sub-agents can be spawned; the fourth generation (whose parent is already at depth 3) arrives with agent_run / agent_run_parallel switched off by disable_subagent().

Cancellation cascade

flowchart TD
  P["parent cancel"] --> C1["child one"]
  P --> C2["child two"]
  C2 --> C3["grandchild"]
  C3 --> X["check_cancel raises CancelledError"]
  • ChainedEvent.is_set() decides recursively along the parent chain, so cancelling a parent automatically affects all descendants.
  • When the main thread receives SIGINT, cancellable_execution() calls agent.cancel() on its own initiative, which notifies the sub-agents running in worker threads (child threads do not receive SIGINT).
  • When agent_run receives CancelledError: if the cancellation was driven by the parent agent it is re-raised unchanged; otherwise it returns Err(ErrorInfo("Execution cancelled by user.")), letting the parent agent treat the failure as a tool result and carry on.
  • Long-running tools must call ctx.agent.check_cancel() themselves inside their loop:
import time
from xun import ToolCallContext as Context

def long_running(ctx: Context, steps: int = 10) -> str:
    """Do something slow but cancellable."""
    for i in range(steps):
        time.sleep(1)
        ctx.agent.check_cancel()
    return "done"

Cancellation is cooperative

Without a checkpoint there is nothing to interrupt. The bash tool checks inside its output-reading loop and terminates the whole process group; if your own tools need to interrupt path or network operations, you have to add check_cancel() yourself.