dendrux
v0.4.0Get started →

Stop a paused or in-flight run cleanly. The same call works for paused, running, and terminal runs, with deterministic per-state behavior.

Cancelling a run

agent.cancel_run(run_id) is the single entry point for stopping a run. It works whether the run is paused, executing in another process, or already finished. The same call has different behavior in each case, but the surface is one method and one return value: the persisted state after the cancel attempt.

The call

result = await agent.cancel_run(run_id)
print(result.status.value)   # "cancelled" if cancel won, original terminal otherwise

That is it. No sequencing, no polling, no separate claim step. You give it a run_id and it makes the right call against the DB. Persistence is required (PersistenceNotConfiguredError otherwise), and the run must exist (RunNotFoundError otherwise).

A complete, runnable example. A run pauses for approval; instead of approving, cancel it:

import asyncio
 
from dendrux import Agent, tool
from dendrux.llm.anthropic import AnthropicProvider
 
 
@tool(target="server")
async def refund(order_id: int) -> str:
    """Issue a refund for an order."""
    return f"Refunded order {order_id}"
 
 
async def main() -> None:
    async with Agent(
        name="Support",
        provider=AnthropicProvider(model="claude-haiku-4-5"),
        prompt="You are a support agent. Use the refund tool when a refund is requested.",
        tools=[refund],
        require_approval=["refund"],
        database_url="sqlite+aiosqlite:///cancel.db",
    ) as agent:
        paused = await agent.run("Refund order 42 using the refund tool.")
        print(paused.status.value)                       # waiting_approval
 
        cancelled = await agent.cancel_run(paused.run_id)
        print(cancelled.status.value)                    # cancelled
 
        # Idempotent: cancelling a terminal run is a no-op, never raises.
        print((await agent.cancel_run(paused.run_id)).status.value)  # cancelled
 
 
asyncio.run(main())

What happens, by state

cancel_run looks at the run's current persisted status and picks one of four paths.

Paused run (waiting_client_tool / waiting_human_input / waiting_approval)

Atomic CAS finalize to CANCELLED in one round-trip. Deterministic. The run row transitions, run.cancelled is emitted with {"reason": "cancel_requested"}, and the call returns. Captured trace from a refund-approval pause:

STATE AT PAUSE (waiting_approval)
  status: waiting_approval
  cancel_requested: 0
  pause_data: <set>
  run_events:
    seq=0  run.started
    seq=1  llm.completed
    seq=2  approval.requested
    seq=3  run.paused
 
STATE AFTER CANCEL
  status: cancelled
  cancel_requested: 1
  pause_data: null
  run_events:
    seq=0  run.started
    seq=1  llm.completed
    seq=2  approval.requested
    seq=3  run.paused
    seq=4  run.cancelled

pause_data is cleared on finalize. The cancel event is sequenced after the existing paused event so an SSE client reading by after_sequence_index cannot miss it.

Running run (anywhere, persisted)

Sets cancel_requested=True on the row. The runner observes the flag at two checkpoints: the top of the next iteration, and the pre-pause boundary after the current iteration's loop body returns.

When the run is executing in another process, or on a different Agent instance, tool calls and the in-flight LLM call are not preempted. Cancellation is observed between steps. An interrupted tool can leave external systems in a partial state, so cooperative beats preemptive there. See Cancellation for the design tradeoffs. (A run executing on the Agent instance you call cancel_run on is interrupted; see the next two sections.)

When the runner observes the flag, it finalizes the run as CANCELLED and emits run.cancelled. Whichever party wins the atomic CAS owns the event.

The returned RunResult is a receipt: status is still running and meta["cancel_requested"] is True. Confirmation is the terminal event (or status == "cancelled" from RunStore.get_run, which also exposes cancel_requested).

Batch run() on this Agent instance

Since 0.3.0 a batch run() is interruptible too. If agent.run(...) is executing in this process and you call cancel_run(run_id) on the same Agent object, the in-flight model call or tool is cancelled and run() returns a result instead of raising:

task = asyncio.create_task(agent.run("Do the long thing"))
...
await agent.cancel_run(run_id)          # from a request handler in the same process
result = await task                      # RunResult(status=CANCELLED)
assert result.meta["interrupted"] is True and result.meta["partial_output"] is False

run.cancelled carries {"reason": "cancel_requested", "interrupted": true, "partial_output": false}. A batch call has no partial text, so answer is None. Cancelling the task yourself (task.cancel(), asyncio.wait_for) still raises CancelledError, as before. A tool interrupted this way receives CancelledError; shield any section that must not be abandoned halfway. The run's delegated children can be cancelled in the same call with cancel_run(run_id, cascade=True).

Streamed run on this Agent instance

If the run was started with stream() or resume_stream() on the same Agent object, cancel_run also fires an in-process interrupt. The loop abandons the in-flight model stream immediately, even while waiting for the next token, closes the provider connection, and yields RUN_CANCELLED whose answer is the text streamed so far:

async with agent.stream("Draft the announcement") as stream:
    consumer = asyncio.create_task(read_events(stream))   # forwards TEXT_DELTA to the UI
 
    await user_pressed_stop.wait()
    await agent.cancel_run(stream.run_id)
 
    events = await consumer
    final = events[-1]                       # RunEventType.RUN_CANCELLED
    kept = final.run_result.answer           # partial text, also persisted as the run's answer
    assert final.run_result.meta["interrupted"] is True

run.cancelled then carries {"reason": "cancel_requested", "interrupted": true, "partial_output": true}. If the model's final event had already arrived when the cancel landed, the run completes as success instead: a finished answer is never thrown away.

The interrupt only reaches runs and streams driven by this Agent instance in this process. From another process, cancel_run sets the durable flag and the run cancels at its next checkpoint. Route interruption requests to the server that holds the stream.

Closing the stream yourself (aclose() / leaving async with) also ends the run as cancelled and persists the partial answer, with {"reason": "stream_closed"}.

In-process submit/resume task

If you hold the Agent instance that started the submit/resume task (the asyncio task spawned by submit_tool_results, submit_input, or submit_approval), cancel_run cancels that task synchronously in addition to setting the DB flag. The task awaits release immediately; the DB cancel signal is the durable backstop in case the cancel call comes from a different process.

Terminal run

No-op. Returns the current persisted state. Does not raise. This is the one place where cancel_run diverges from the submit methods (which raise RunAlreadyTerminalError): cancelling something that is already done should be safe and idempotent, not an error.

Submit + cancel race

If a submit method is in flight when you cancel, the runner's pre-pause checkpoint observes cancel_requested=True before re-pausing or running the next iteration, and finalizes as CANCELLED. The submit method receives the cancelled RunResult from the awaited task. The run does not bounce between paused and running; the cancel wins cleanly.

If you call a submit method after cancel_run has set the flag (but before the runner has observed it), the submit's CAS preflight notices cancel_requested=True and raises RunAlreadyTerminalError(run_id, RunStatus.CANCELLED). This avoids resuming a run that is on the path to cancelled.

HTTP route

A minimal cancel endpoint:

from fastapi import APIRouter, Depends, HTTPException
from dendrux.errors import PersistenceNotConfiguredError, RunNotFoundError
 
@router.delete("/runs/{run_id}")
async def cancel_route(run_id: str, _=Depends(authorize)):
    try:
        result = await agent.cancel_run(run_id)
    except RunNotFoundError:
        raise HTTPException(404, "run not found")
    except PersistenceNotConfiguredError:
        raise HTTPException(500, "persistence not configured")
    return {"run_id": result.run_id, "status": result.status.value}

Two error mappings. No 409 path because terminal cancel is a no-op, not a conflict.

SSE stream as the cancel signal

If a browser is watching a run via SSE, it should treat the run.cancelled frame as the end of the conversation:

es.addEventListener("message", (e) => {
  const frame = JSON.parse(e.data);
  if (["run.completed", "run.cancelled", "run.error"].includes(frame.event_type)) {
    es.close();
    // refetch run detail; show final state
  }
});

run.cancelled carries {"reason": "cancel_requested"}, plus "interrupted": true and "partial_output": true|false when a run or stream was interrupted mid-generation, or {"reason": "stream_closed", "partial_output": ...} when the consumer closed the stream. Other terminal events (run.completed, run.error) close the conversation just the same. Refetch run detail to read the partial answer.

Stopping a run and its children

Delegated child runs have their own rows and cancel flags. Cancelling the parent does not cancel them unless you ask:

result = await agent.cancel_run(run_id, cascade=True)
print(result.meta["cascade"])    # [{'run_id': ..., 'outcome': 'cancelled' | 'cancel_requested' | 'terminal'}, ...]

Descendants are cancelled deepest first and run_id last, over a snapshot of the tree taken at the call. See Cancellation.

Notes

  • Cancellation is not retroactive. Tokens spent before the runner observes the flag have been billed. Side effects from tools that already executed are not undone. The cancel is forward-looking: stop further iterations, do not roll back.
  • Cancelling after the last checkpoint does not undo a finished run. If the run's final answer is already being produced when the cancel lands, it completes as success.
  • Interrupted model calls report no usage. Providers send token counts at the end of a stream. A call abandoned mid-stream contributes nothing to usage; only completed iterations are counted.
  • Cancellation does not require holding the agent instance that started the run. Any process with a DB handle and the same agent class can cancel. A dashboard, a webhook, an admin script — they all work.
  • cancel_run is the only sanctioned way to stop a run. Do not delete the row, do not edit the status by hand, do not kill the asyncio task in isolation. The CAS / flag dance encodes the safety properties.

Where this fits