A "Stop" button that only ends the spinner does not cancel an agent. The model request may continue, an HTTP request may stay open, and a tool may have started a write before it received any signal.

The safe way to interrupt an agent is to treat cancellation as a runtime contract. Combine the caller's signal with a run deadline, pass that signal to every step, and record whether the step was cancelled before or after an external effect might have started. The system can then decide whether to stop, resume, or ask for review.

This article explains the pattern in TypeScript without depending on one framework. The OpenAI Agents JS documentation exposes signal, maxTurns, and function-tool timeouts, while the Vercel AI SDK exposes abortSignal plus total, step, and chunk limits. The code below uses the same ideas with native Node.js APIs.

Diagram showing an AI agent moving through a run, deadline, abort, and resume after a tool hangs.

Short rule

  • The user's cancellation and the server deadline should stop the same run.
  • Every model call and tool call must receive the combined signal.
  • maxTurns prevents long loops, but it does not replace a timeout or cancellation.
  • After an uncertain external effect, do not retry until an idempotency key or resource status makes the outcome clear.

Are cancellation, timeout, and failure the same thing?

No. Cancellation is an external decision to stop a run. A timeout is an expired budget. A failure is an error that may or may not be safe to retry. If the runtime turns all three into Error("failed"), it throws away the information needed for recovery.

The OpenAI Agents JS runner documents signal for cancelling a run and maxTurns for limiting the loop. The same reference describes MaxTurnsExceededError when the limit is reached. These outcomes should become separate states such as cancelled, timed_out, and failed before retry logic runs.

A deadline is not just a setTimeout around a Promise. If the timer rejects the outer function but never reaches fetch, an MCP client, or a database query, the operation that consumes time keeps working. The user sees a closed response while the server accumulates invisible work.

Start by defining the boundary: a run has a cancellation signal, a total budget, and a turn limit. Each step can have a smaller budget, but no step may ignore the run signal.

How do you combine user cancellation and a deadline?

Use one AbortSignal for each reason to stop and compose them with AbortSignal.any(). Node.js documents AbortSignal.timeout() and AbortSignal.any() for creating a signal that expires on its own and another that aborts when any signal in a list is stopped.

The example is illustrative. callModel and executeTool represent adapters for your provider and infrastructure. The testable point is that the run signal reaches every operation.

type RunReason = "user" | "deadline" | "max_turns";

class RunStopped extends Error {
  constructor(
    readonly reason: RunReason,
    readonly cause?: unknown,
  ) {
    super(`run stopped: ${reason}`);
  }
}

function runSignal(userSignal: AbortSignal, totalMs: number) {
  const deadline = AbortSignal.timeout(totalMs);
  return AbortSignal.any([userSignal, deadline]);
}

function reasonFor(signal: AbortSignal): RunReason {
  return signal.reason?.name === "TimeoutError" ? "deadline" : "user";
}

async function runAgent(prompt: string, userSignal: AbortSignal) {
  const signal = runSignal(userSignal, 30_000);

  for (let turn = 0; turn < 8; turn += 1) {
    if (signal.aborted) throw new RunStopped(reasonFor(signal), signal.reason);

    const response = await callModel({ prompt, signal });
    if (response.type === "final") return response.text;

    const results = await Promise.all(
      response.toolCalls.map((call) => executeTool(call, signal)),
    );

    prompt = appendToolResults(prompt, results);
  }

  throw new RunStopped("max_turns");
}

The 8 limit is local policy, not a recommendation for every agent. Choose it from the task and record why the run stopped. The OpenAI Agents JS runner uses maxTurns as a safety limit, but the runtime still has to decide what to do with the interrupted run.

The signal has two jobs. It says that new operations must not start, and it offers a way to interrupt operations already in flight. The second only works when the adapter listens to the signal.

How do you propagate the signal to every tool call?

Pass the signal to the point that waits on I/O. With fetch, use signal in the options. With a model SDK or MCP client, use the abort option that client supports. For local work, check signal.aborted between units that can stop safely.

The OpenAI Agents JS tools guide documents timeoutMs and says a timeout aborts details.signal. That only stops the function if its implementation uses the signal. A wrapper that ignores details.signal turns the result into an error while continuing to consume resources.

async function executeTool(call: ToolCall, runSignal: AbortSignal) {
  const stepSignal = AbortSignal.any([
    runSignal,
    AbortSignal.timeout(toolBudgetMs(call.name)),
  ]);

  if (stepSignal.aborted) {
    throw new RunStopped(reasonFor(stepSignal), stepSignal.reason);
  }

  switch (call.name) {
    case "searchDocs":
      return fetch("https://example.test/search", {
        method: "POST",
        body: JSON.stringify(call.input),
        headers: { "content-type": "application/json" },
        signal: stepSignal,
      });
    case "readMcpResource":
      return mcpClient.readResource(call.input, { signal: stepSignal });
    default:
      throw new Error(`unknown tool: ${call.name}`);
  }
}

The Vercel AI SDK documents abortSignal and total, step, and chunk timeouts on ToolLoopAgent. These layers answer different questions: total limits the run, step limits one round, and chunk limits a stream that stops producing data.

Do not hide the timeout inside each tool. The budget must be visible on the run so a sum of local limits does not accidentally exceed the request deadline. A step with only 200 ms left should fail quickly or choose a smaller fallback, not start a call it already knows cannot finish.

What should happen when a tool may have produced an effect?

Cancelling a read is different from cancelling a write. If a signal interrupts a GET, the usual result is no response. If it interrupts a charge, publication, message send, or database write, the request may have reached the server before the client received confirmation.

In that case, AbortError does not prove that nothing happened. Mark the step as effect_uncertain, keep an idempotency key, and query the external state before retrying. If the provider offers no way to query it, send the run to review or use a compensating operation. Do not let the model decide that a timeout means "try again".

The post about durable execution for AI agents covers where to persist checkpoints and how to resume a step. This article focuses on the earlier boundary: whether the step can be interrupted and what information must survive cancellation.

Situation Recorded state Next step
Signal arrived before the tool started cancelled End the run without an automatic retry.
Read was cancelled without an external effect cancelled Retry only if the caller still wants it.
Write stopped without confirmation effect_uncertain Query by idempotency key or request review.
Total budget expired between steps timed_out Persist state and resume with a new budget.
Turn limit was reached max_turns Record the loop and review the stop condition.

This state model also fits a state machine for AI agents. A cancel event should not be a loose message in the history. It should be an authorized transition that stops new work and sends uncertain effects to a known path.

How do you verify the cancellation contract?

Test cancellation in the middle of a slow step. A test that aborts before the agent starts only proves that the caller can cancel a Promise. A useful test starts a blocking tool, fires the signal, and checks that the adapter received the same signal.

async function blockingTool(signal: AbortSignal) {
  return new Promise<never>((_, reject) => {
    if (signal.aborted) {
      reject(signal.reason);
      return;
    }

    const onAbort = () => reject(signal.reason);
    signal.addEventListener("abort", onAbort, { once: true });
  });
}

const controller = new AbortController();
const run = runAgent("find the document", controller.signal);

setTimeout(() => controller.abort(new Error("user_clicked_stop")), 20);

await expect(run).rejects.toMatchObject({ reason: "user" });

In the real suite, cover the run deadline, a tool timeout, max_turns, and a write that becomes effect_uncertain. Check that no later step starts after the signal and that the log contains run_id, tool_name, stop_reason, and effect_status, without storing prompts or secrets by default.

The OpenAI Agents JS streaming guide recommends awaiting stream.completed after cancelling a stream. The same principle applies to a custom executor: do not mark the run settled just because the consumer stopped reading events. Wait for runtime cleanup and persist the state needed for a later resume.

How do you choose between stopping and resuming?

Resume when persisted state identifies the run, the current step, and confirmed effects. Stop permanently when the user cancelled an intent that must not continue, when the budget expired without a recovery route, or when external state cannot be checked safely.

The post about validating tool calls in TypeScript covers the boundary for arguments and results. Combine that validation with the cancellation policy: an invalid argument is a recoverable error, a read timeout may allow a limited retry, and an uncertain external effect requires a query before any repetition.

Always return an honest result to the caller. "Cancelled" means the runtime ended the execution, not that every external operation disappeared. "Resume available" means a checkpoint and enough identity exist to continue. That distinction keeps the UI from promising cleanup the system cannot prove.

Frequently asked questions

Does AbortSignal automatically cancel every tool?

No. The signal only says that the operation should stop. The tool must pass it to fetch, an SDK, an MCP client, or the local routine doing the work. The OpenAI Agents JS tools guide explains that timeouts abort details.signal, but the function implementation still has to listen to it.

Does maxTurns replace a timeout?

No. maxTurns limits how many agent rounds run. One tool can remain stuck during a round, and a model request can take a long time before the next turn. Use a turn limit, a total deadline, and a per-step timeout as separate controls.

Can I retry a tool after AbortError?

Only when the operation is safe to repeat or an idempotency key lets you query its effect. AbortError says that waiting was interrupted. It does not guarantee that an external server did not receive or apply the request before cancellation.

Conclusion

Safe cancellation is a property of the whole loop. The signal must reach the model, every tool, and downstream calls. The runtime must separate cancelled, timed_out, max_turns, and effect_uncertain. Recovery must know what has already been confirmed.

Start with three tests: cancel a slow read, let a step deadline expire, and interrupt a write before confirmation. Then check whether the system can explain its final state without guessing. For long agent workflows, I use RemoteCode as the author's tool for continuity between sessions, but it does not replace signals, deadlines, or idempotency.

Sources consulted

  • OpenAI Agents JS, "Running agents", retrieved 2026-08-13, https://openai.github.io/openai-agents-js/guides/running-agents/
  • OpenAI Agents JS, "Tools", retrieved 2026-08-13, https://openai.github.io/openai-agents-js/guides/tools/
  • OpenAI Agents JS, "Streaming", retrieved 2026-08-13, https://openai.github.io/openai-agents-js/guides/streaming/
  • Vercel AI SDK, "ToolLoopAgent", retrieved 2026-08-13, https://ai-sdk.dev/docs/reference/ai-sdk-core/tool-loop-agent
  • Node.js, "Globals", retrieved 2026-08-13, https://nodejs.org/dist/latest/docs/api/globals.html