Skip to content

Interrupting & cancelling ​

There are four mechanisms here, and they're genuinely not interchangeable. This table is the short version — the rest of the page is the detail.

CallEffect on the active turnEffect on the queueWork already done
agent.stop()Ends at the next tool-batch boundaryUntouchedKept and committed
agent.clear()UntouchedCancelledN/A
handle.cancel()Aborted immediatelyUntouchedPartially preserved
signal on sendSame as cancel()

stop(): let the work finish ​

typescript
const running = agent.send("Refactor the auth module.");

// later, from a UI event
const wasRunning = agent.stop(); // false if nothing was executing

The tool batch that's in flight — including parallel calls — runs to completion and commits. Then the handle settles without another provider request. Nothing gets wasted, and the history stays coherent.

What comes back may surprise you the first time. A stopped turn ends on its tool-call exchange, so a plain send resolves with whatever text that step produced — often an empty string. An Instruct send may come back ok: false with a parse error, simply because no final answer exists yet. That's by design, not a bug.

stop() won't interrupt a provider request or a tool that's already running. If you need a hard stop, that's cancel().

The steer playbook ​

Three calls, in this order. This is the pattern you'll use most:

typescript
agent.stop(); // active turn wraps up cleanly
agent.clear(); // drop anything queued behind it
agent.send("Actually, make the button blue."); // becomes the very next turn

Stop, clear, send. It gracefully halts what the agent is doing and inserts the user's new instruction as the next turn — which is exactly what a user expects when they interrupt.

Wiring it to a chat box is about as simple as it looks:

typescript
function onUserSubmit(text: string) {
  agent.stop();
  agent.clear();
  return agent.send(text);
}

That's safe to call whether or not the agent is busy. stop() returns false and clear() returns 0 when there's nothing to interrupt, so the same handler covers the idle case.

The new turn runs against a linear committed history that includes the tools that already finished — so the agent knows what it did before you redirected it, and won't repeat that work.

Order matters

Call clear() before stop() and you leave a window where the active turn can finish and pull in the queued work you were about to drop.

cancel(): stop now ​

typescript
const handle = agent.send("Write a very long essay.");
setTimeout(() => handle.cancel({ reason: "timeout", afterMs: 5000 }), 5000);

try {
  const result = await handle.final;
} catch (error) {
  if (error instanceof Error && error.name === "AbortError") {
    console.log("cancelled");
  } else {
    throw error;
  }
}

This is local to that one handle — other sends carry on unaffected.

What survives ​

typescript
import { AxleAgentAbortError } from "@fifthrevision/axle";

catch (error) {
  if (error instanceof AxleAgentAbortError) {
    error.reason; // what you passed to cancel()
    error.usage; // tokens already spent — still billable
    error.partial; // the partially-streamed assistant message
    error.messages; // messages completed before the abort
    error.turn; // the cancelled turn, for rendering
  }
}

Showing partial output after a cancel is usually the right call. Your user paid for those tokens, and half an essay beats a blank screen.

typescript
const text = error.partial?.content.find((p) => p.type === "text");
if (text?.type === "text") render(text.text);

What commits ​

Whether the user message sticks around depends on timing, and the line is the turn:user event:

  • Before it — queued, or during MCP setup — the handle is dropped and nothing commits.
  • After it, the user message stays committed and the turn is marked cancelled. This includes cancelling during beforeTurn compaction: compaction is work inside an already-open turn, and cancelling does not unwind the transcript.

AbortSignal ​

If you already have abort plumbing, it slots straight in:

typescript
const controller = new AbortController();
const result = await agent.send("...", { signal: controller.signal }).final;

A signal that's already aborted fails the send before anything commits.

clear(): drain the queue ​

typescript
const dropped = agent.clear();
console.log(`cancelled ${dropped} queued sends`);

Each cleared handle rejects with AxleAgentAbortError, having committed nothing. The active turn is left alone.

Cancelling a stream() ​

Same shape, one class up the hierarchy:

typescript
import { stream, AxleAbortError } from "@fifthrevision/axle";

const handle = stream({ provider, model, messages });
setTimeout(() => handle.cancel("timeout"), 4000);

try {
  await handle.final;
} catch (error) {
  if (error instanceof AxleAbortError) {
    console.log(error.reason, error.usage, error.partial);
  }
}

AxleAgentAbortError extends AxleAbortError, so an instanceof AxleAbortError check catches both.

Cancellation inside tools ​

Your tools have to cooperate here. Without forwarding the signal, cancelling stops the agent while your work keeps running in the background:

typescript
async execute({ url }, ctx) {
  const res = await fetch(url, { signal: ctx.signal }); // forward it
  return await res.text();
}

For a long CPU-bound loop, poll it:

typescript
for (const item of items) {
  if (ctx.signal.aborted) throw new Error("Aborted");
  await handleItem(item);
}

Timeouts ​

typescript
const result = await agent.send("...", { signal: AbortSignal.timeout(30_000) }).final;

That's a wall-clock cap on the whole send, tool loop included. If you'd rather cap the number of model requests, use maxSteps with stream() — which is a stop rather than an error, so you keep everything produced so far.

See also ​