DEV Community

Jack M
Jack M

Posted on

AI Agent Cancellation: Stop Work That Continues After the User Clicks Stop

A Stop button can be honest in the browser and still be a lie in production. The chat stream disappears, but a model request may be generating, a browser tool may be clicking, and a background worker may be one step away from sending an email.

That is not a cosmetic bug. It is a broken contract. Users interpret “Stop” as “do not do more work for me.” Your system needs to translate that request across every layer, record what actually stopped, and make the remaining uncertainty visible. This guide gives you that contract.

The key idea is simple: cancellation is a state transition, not a boolean and not a client disconnect.

The five meanings hidden behind “Stop”

Teams often store one cancelled flag and call it a day. That collapses several different facts:

Event What it proves What it does not prove
Browser aborts a stream The browser stopped listening The server or model stopped working
API accepts a cancel request The request was recorded Every child task observed it
Worker sees the request New work should not begin An in-flight tool did not finish
Provider accepts cancellation The cancellation reached that provider No already-buffered output or usage exists
Run becomes terminal Your ledger has a final outcome An external write was reversed

Keep those facts separate. A robust lifecycle might be:

running → cancel_requested → cancelling → cancelled

with other terminal paths such as completed, failed, and needs_reconciliation. The last state is important. If a payment, ticket, or record write was in flight when cancellation arrived, pretending it is cancelled can make a later retry unsafe.

Define the cancellation contract before wiring a button

Write down the semantics your product promises. A useful default is:

  1. A user can request cancellation for a run they are authorized to control.
  2. No new model turn, tool call, or queued step starts after the durable request is observed.
  3. In-flight work gets a best-effort cancel signal and has a deadline for acknowledging it.
  4. The API never reports cancelled until the orchestrator records a terminal decision.
  5. Side effects already dispatched are reconciled, not guessed away.
  6. A cancelled run does not silently resume; resumption creates a new attempt or requires an explicit resume action.

This contract gives product, API, and operations teams the same vocabulary. It also prevents a dangerous UX pattern: changing the button label to “Stopped” the moment the browser disconnects.

Use one durable run record

Put the authoritative state in a database or workflow engine, not in a process-local abort controller. A minimal record can look like this:

type RunStatus =
  | "queued" | "running" | "cancel_requested" | "cancelling"
  | "cancelled" | "completed" | "failed" | "needs_reconciliation";

type AgentRun = {
  id: string;
  tenantId: string;
  status: RunStatus;
  cancelRequestedAt?: string;
  cancelReason?: "user" | "deadline" | "policy" | "operator";
  activeStepId?: string;
  version: number;
  terminalAt?: string;
};
Enter fullscreen mode Exit fullscreen mode

The version field enables compare-and-set updates. It stops a late worker from replacing cancel_requested with running after another worker has already processed the stop request.

Your POST /runs/:id/cancel endpoint should be idempotent. It records the request once, returns the current state, and publishes a cancellation event. Repeated taps, reconnects, and retries should all produce the same outcome.

async function requestCancel(runId: string, actorId: string) {
  return db.transaction(async (tx) => {
    const run = await tx.runs.lockForUpdate(runId);
    assertCanCancel(actorId, run);

    if (["completed", "failed", "cancelled"].includes(run.status)) return run;

    const next = await tx.runs.update(runId, {
      status: "cancel_requested",
      cancelRequestedAt: new Date().toISOString(),
      cancelReason: "user",
      version: run.version + 1,
    });
    await tx.outbox.insert({ type: "run.cancel_requested", runId });
    return next;
  });
}
Enter fullscreen mode Exit fullscreen mode

The transactional outbox is not required by every stack, but the principle is: persist the decision before notifying workers. A process crash between an in-memory publish and a database write should not make a stop request disappear.

Propagate cancellation across every boundary

An agent run usually crosses more boundaries than a normal web request. Treat cancellation as a signal that travels with the run.

Browser and streaming edge

AbortController can stop fetch and response-body consumption in the browser, as MDN documents. Use it to stop rendering and then call your durable cancellation endpoint. Do not rely on disconnect detection alone: mobile networks reconnect, tabs sleep, and a user may close a page without meaning “cancel.”

const controller = new AbortController();

stopButton.onclick = async () => {
  controller.abort("user_pressed_stop"); // end local streaming now
  await fetch(`/api/runs/${runId}/cancel`, { method: "POST" });
  showStatus("Stopping…");
};
Enter fullscreen mode Exit fullscreen mode

The status is “Stopping,” not “Stopped,” until the run event feed reports a terminal state. If an SSE connection reconnects, replay events from a monotonic sequence number rather than starting another run.

Orchestrator and model calls

At step boundaries, read the durable record. In long model streams, also pass a cancellation primitive that can close the network request or invoke a provider-specific cancellation API where available. A close tells your HTTP client to stop waiting; it does not automatically prove upstream inference stopped or that no tokens were billed.

Use an absolute run deadline as well. Pass the remaining time, not a fresh full timeout, to each downstream call. A run that spends 50 seconds retrieving should not receive another 60 seconds of model time merely because each layer starts its own clock.

Parallel tools

Parallelism makes cancellation interesting. If a run launches a search, an internal lookup, and a write tool together, cancellation must reach each child. But not all children have the same policy:

  • Read-only tools can normally be abandoned or cancelled.
  • A write before its commit point can be cancelled.
  • A write after dispatch needs a receipt or authoritative read-back.
  • Cleanup work may need a short shielded window so it can record the correct final state.

In Python, cancellation is cooperative: asyncio injects CancelledError at an await point, and the official guidance is to use try/finally for cleanup and generally propagate the exception afterward. Do not catch it broadly and continue doing more agent work.

async def execute_step(run_id: str):
    try:
        await assert_not_cancelled(run_id)
        result = await call_tool_with_deadline(run_id)
        await persist_result(run_id, result)
    except asyncio.CancelledError:
        await record_step_interrupted(run_id)
        raise
Enter fullscreen mode Exit fullscreen mode

Queued and durable work

A cancellation request must invalidate queued messages too. Workers should acquire a lease, re-check the run state immediately before each externally visible action, and release the lease on exit. A message already in a queue is not permission to execute it.

For longer-running orchestration, model cancellation and external-step cancellation as explicit workflow commands. Systems such as Temporal distinguish cancellation commands from ordinary completion; the general lesson applies even if you use Postgres and workers instead of a workflow engine.

If you need the underlying persistence pattern, see AI Agent Durable Queue: Stop Long-Running Work From Dying Mid-Task.

Never confuse cancellation with rollback

Cancellation prevents future work. Rollback tries to compensate for past work. They are related but not interchangeable.

Imagine an agent creates an invoice and then receives a stop request before it records the tool response. The correct terminal state is often needs_reconciliation, not cancelled. Query the payment system by a stable operation key. If the invoice exists, show it as created and offer a distinct authorized reversal. If it does not, mark the step cancelled.

This is why every write tool needs an idempotency key and an operation ledger. The full pattern is covered in AI Agent Idempotency: Prevent Duplicate Charges, Emails, and Records. Cancellation reduces how often you enter an unknown state; it never removes the need to resolve one.

Design an honest user experience

The UI should expose progress without flooding people with internals:

State User-facing message Allowed action
running Working Stop
cancel_requested Stopping work Hide or wait
cancelling Finishing safe shutdown View details
needs_reconciliation Checking an action before closing Retry check or contact support
cancelled Stopped; no new work will run Start a new run

Do not show partial generated text as a completed answer. Mark it as interrupted, keep it separate from the final result, and never automatically feed it into the next agent turn. It may have been cut off mid-instruction or before a tool result arrived.

Structured errors help clients make this distinction. An error envelope should include a stable code, whether a retry is safe, the run state, and a correlation ID. Agent Tool Error Envelopes shows a compatible pattern.

Test the race conditions, not just the button

The happy path is easy: click Stop and see the stream close. Production correctness lives in the races. Add deterministic test hooks before model calls, before a tool dispatch, after dispatch but before receipt storage, and while a worker holds a lease.

Your cancellation test matrix should cover at least:

  1. Cancel while queued: no worker starts the first step.
  2. Cancel during token streaming: client stops, provider result is recorded as interrupted or reconciled.
  3. Cancel between model output and tool dispatch: no tool begins.
  4. Cancel during two parallel tools: every child reaches a known terminal state.
  5. Cancel after a write dispatch: reconciliation runs; the write is never blindly retried.
  6. Cancel during a process crash: a replacement worker respects the durable request.
  7. Duplicate cancel calls: one lifecycle transition and no duplicate cleanup.
  8. Resume attempt after cancellation: it creates a new attempt with explicit provenance.

Capture traces for each test and replay them when the runtime changes. AI Agent Trace Replay is useful for turning an escaped cancellation race into a permanent regression test.

A practical implementation checklist

  • Store run lifecycle state durably, including cancellation reason and timestamps.
  • Make cancellation requests authorized and idempotent.
  • Send a durable signal to workers; do not trust page disconnects as intent.
  • Check for cancellation before every new turn and side effect.
  • Propagate an abort signal and remaining deadline to streams and tools.
  • Record a confirmed terminal state only after worker cleanup finishes.
  • Reconcile writes that crossed a commit boundary.
  • Preserve interrupted output as partial, never as final.
  • Emit lifecycle events that reconnecting clients can replay.
  • Test cancellation at every boundary and after simulated crashes.

A Stop button is a promise about control. Keep that promise by treating cancellation as a first-class workflow outcome, not a disappearing HTTP connection.

FAQ

Does aborting an LLM stream stop the model?

Not necessarily. It reliably stops your client from consuming the response, but the provider may stop asynchronously, may have already buffered output, and may still report usage. Record provider request IDs and reconcile their final status when the distinction matters.

Should a cancelled AI agent run be retried automatically?

Usually no. Cancellation expresses changed user intent. Create an explicit new attempt instead. Only retry internal cleanup or a clearly idempotent read when that work is necessary to reach a known terminal state.

What is the difference between a timeout and cancellation?

A timeout is a system deadline; cancellation can come from a user, policy, operator, or parent workflow. Both can use the same propagation mechanism, but record the cause separately because the correct product response differs.

How do I cancel an agent tool that has already started a write?

Request cancellation, then query the target using the operation key or receipt. If the write committed, treat it as completed and use a separate compensating action if appropriate. Never assume a network timeout means the write failed.

Can I resume a cancelled agent run?

You can, but make it explicit. Persist a checkpoint with its inputs, completed steps, and evidence. Start a new attempt that references the old one, re-checks policy, and never repeats uncertain writes without reconciliation.

What metrics show whether cancellation works?

Track time from request to terminal state, cancelled runs with work still starting afterward, orphaned tool calls, reconciliation rate after cancellation, duplicate cancellation requests, and the fraction of partial outputs mistakenly shown as final.

Top comments (0)