Chained Cancellation¶
Cancellation is cooperative: the framework propagates the flag, and your code decides where to check it. Cancellation events form a chain — cancelling a parent agent cascades to every child agent, never the other way round.
API¶
| Member | Description |
|---|---|
agent.cancel_event |
ChainedEvent, made of an event plus a parent |
ChainedEvent.is_set() |
Recursively checks whether this event or any ancestor is set |
ChainedEvent.derive() |
Derives a new event with this one as its parent |
agent.cancel() |
Sets the flag; when the agent is idle it returns False and sets nothing, so a cancel signal cannot linger |
agent.check_cancel() |
Raises CancelledError if set, with a default reason attached |
agent.is_running |
Whether the agent is inside run_scope() |
agent.run_scope() |
Context manager: tracks running state, checks cancellation on entry and on exit, turns Ctrl+C into a cancellation, clears the event on exit |
flowchart TD
P["parent cancel_event"] -->|derive| C1["child 1 cancel_event"]
P -->|derive| C2["child 2 cancel_event"]
P --> X["cancel()"]
X -.cascade.-> C1
X -.cascade.-> C2
C1 -."never propagates back".-> P
Checkpoints in long-running work¶
Tools are synchronous functions, so a long loop must leave its own checkpoints; otherwise cancellation only takes effect once the function returns:
import time
def long_running_task(ctx):
for i in range(10):
print(f"step {i + 1}/10")
time.sleep(1)
ctx.agent.check_cancel() # checkpoint
The checkpoints inside the framework are already in place: one check before every iteration, before every chunk of
the model stream, and before every tool call. Built-in tools such as sleep and bash / bash_wait each call
check_cancel() themselves.
Propagation rules¶
Agent.inherit(parent) points the child's cancel_event.parent at the parent event by default, which means:
- parent
cancel()→ the child'scheck_cancel()raises immediately; - child
cancel()→ affects only that child and its own descendants.
Sub-agents you create yourself must have the chain wired up; these two forms are equivalent:
child = Agent.inherit(parent) # links the chain automatically
child = Agent(display=..., cancel_event=parent.cancel_event.derive())
The web front end reacts to cancellation while waiting for the user to answer a prompt as well: a pending prompt
carries a cancel_check, and as soon as the cancellation chain is set the prompt is dropped and the dismissal is
broadcast, so a parent agent never hangs waiting on a human.
Triggering cancellation¶
| Entry point | How |
|---|---|
| Terminal | Ctrl+C: run_scope() converts the KeyboardInterrupt into a cancellation and keeps raising it; the interactive layer pops the last user message back out so /retry is convenient |
| Web | The stop button sends a cancel message and the server calls cancel() for that agent_id; ignored while idle |
| In-process | Call agent.cancel() from any thread |
| Background sub-tasks | There is no separate cancel tool; rely on the chain cascading, or on the task pool cleaning up when the parent agent terminates |
CancelledError carries a reason¶
from xun import CancelledError
try:
agent.instruct("…").execute()
except CancelledError as e:
print(e.reason) # e.g. “Operation cancelled by user.” or a custom reason
execute() first reports the reason to the display layer through agent.error(), then re-raises it unchanged —
CancelledError and KeyboardInterrupt are not wrapped into a Result.Err, unlike other exceptions.
To veto a run from inside a hook, raising a CancelledError with a custom reason is the only reliable way.
Teardown order¶
The finally block on run_scope() exit clears the cancellation event so the next run does not begin already
cancelled. Background-derived children therefore cannot rely on the event chain alone for teardown —
agent_wait and the task pool additionally call child.cancel() directly, ensuring that children do not keep
running model calls after the parent agent has finished.