Skip to content

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's check_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.