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 theparentchain, so cancelling a parent automatically affects all descendants.- When the main thread receives SIGINT,
cancellable_execution()callsagent.cancel()on its own initiative, which notifies the sub-agents running in worker threads (child threads do not receive SIGINT). - When
agent_runreceivesCancelledError: if the cancellation was driven by the parent agent it is re-raised unchanged; otherwise it returnsErr(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.