How to Implement Abort Signals for Cancelling Long-Running Agent Operations in Flue

Every async operation in Flue returns a CallHandle that exposes an AbortSignal and an abort() method, allowing you to cancel prompt(), skill(), task(), or shell() calls via timeout signals, manual triggers, or composite controllers.

The Flue SDK (from withastro/flue) provides first-class support for aborting long-running AI agent operations through a unified cancellation API. Whether you need to enforce timeouts on expensive LLM calls or let users manually cancel an in-flight task, the SDK wraps every async operation in a cancellable handle that propagates abort signals down to underlying tools like the sandboxed shell executor.

Core Abort Signal Architecture

The cancellation system centers on three components that work together to provide abort semantics across all session methods.

The CallHandle Interface

In packages/sdk/src/types.ts, the SDK defines a CallHandle<T> interface that extends PromiseLike<T>. This design allows handles to be awaited directly while carrying additional control properties:

  • signal: The merged AbortSignal that reflects the current abort state
  • abort(reason?): A method to trigger cancellation programmatically

Every session method—prompt(), skill(), task(), and shell()—returns this handle type, ensuring consistent cancellation patterns regardless of the operation type.

Signal Composition in createCallHandle

The createCallHandle function in packages/sdk/src/abort.ts implements the actual wrapping logic. When you invoke a session method, the SDK:

  1. Creates a fresh AbortController for the internal operation
  2. Checks if the caller provided an external signal via options.signal
  3. If the external signal is already aborted, immediately aborts the internal controller with the same reason
  4. Otherwise, registers a listener that forwards abort events from the external signal to the internal controller

This composition allows external signals (like timeouts) and manual handle.abort() calls to trigger the same cancellation pathway.

How Abort Propagation Works

When an abort triggers, the SDK transforms the raw signal into a standardized error format. The abortErrorFor utility in abort.ts generates a DOMException of type AbortError, using Object.defineProperty to attach the original abort reason to the error's cause property.

The signal propagates through the following chain:

  1. Session method (session.prompt() or session.shell()) receives the options
  2. createCallHandle merges external and internal signals
  3. Sandbox executor (packages/sdk/src/sandbox.ts) receives the signal and respects it during exec() calls, killing subprocesses if aborted
  4. Error surfacing rejects the handle's promise with the AbortError containing the original reason

Practical Implementation Examples

Timeout-Based Cancellation

Pass AbortSignal.timeout() to any session method to enforce automatic cancellation after a specified duration. This works with prompt(), skill(), task(), and shell() operations.

// Cancel a prompt after 2 seconds
await session.prompt(
  "Run `sleep 30` via the bash tool, then describe what happened.",
  { signal: AbortSignal.timeout(2_000) },
);

When the timeout fires, the SDK rejects the promise with an AbortError. The error's cause property contains the timeout's reason (typically undefined for timeouts).

Manual Abort with CallHandle

For user-driven cancellation, retain the returned handle and call its abort() method:

const handle = session.prompt(
  "Run `sleep 30` via the bash tool, then describe what happened."
);

// Cancel after 1 second from another async branch
setTimeout(() => handle.abort("user-cancel"), 1_000);

try {
  await handle;
} catch (e) {
  console.log(e.name, e.message, (e as any).cause); 
  // Output: AbortError ... "user-cancel"
}

This approach gives you programmatic control over the lifecycle of long-running agent operations without pre-defining timeouts.

Pre-Aborted Signals

If you already know an operation should cancel before it starts, pass an already-aborted signal:

await session.prompt("Say hi.", {
  signal: AbortSignal.abort("already done"),
});

createCallHandle detects that the signal is already aborted and immediately triggers the internal controller, rejecting the promise without starting any work. This prevents unnecessary compute for operations invalidated by prior context changes.

Composite Signals for Complex Scenarios

Combine multiple abort conditions using AbortSignal.any() to support both timeouts and external cancellation triggers:

const external = new AbortController();
const combined = AbortSignal.any([
  external.signal,
  AbortSignal.timeout(3_000),
]);

await session.shell("sleep 30", { signal: combined });

// Elsewhere, manual cancellation still works
external.abort("user-requested-stop");

Handling Abort Errors

When an operation aborts, the SDK rejects the handle's promise with an AbortError. Detect these errors by checking the error name or using the cause property for custom logic:

try {
  await session.task("Long computation");
} catch (err) {
  if (err.name === "AbortError") {
    console.log("Task aborted:", (err as any).cause);
    // Handle cleanup or UI updates
  }
}

The error structure ensures that aborts are distinguishable from operational failures like tool errors or LLM API exceptions, allowing you to handle cancellation gracefully without confusing it with actual execution failures.

Summary

  • CallHandle wraps every async operation in Flue, exposing signal and abort() for cancellation control
  • createCallHandle in packages/sdk/src/abort.ts merges external and internal signals, supporting both pre-emptive and mid-flight cancellation
  • Session methods (prompt, skill, task, shell) uniformly support options.signal for timeout and composite signal patterns
  • Sandbox operations respect abort signals, terminating subprocesses when shell() calls are cancelled
  • Error handling produces AbortError DOMExceptions with the original reason preserved in the cause property

Frequently Asked Questions

How do I cancel a Flue agent operation after it has already started?

Retain the CallHandle returned by any session method and call handle.abort(reason). For example: const handle = session.prompt("Analyze this code"); handle.abort("User clicked cancel");. The promise will reject with an AbortError containing your reason in the cause property.

Can I set a timeout on shell commands executed through Flue?

Yes. Pass AbortSignal.timeout(milliseconds) in the options object: await session.shell("sleep 30", { signal: AbortSignal.timeout(1_000) });. When the timeout fires, the SDK propagates the abort signal to the sandbox, which terminates the underlying subprocess before rejecting the promise.

What is the difference between passing an external signal and using handle.abort()?

Passing an external signal (like AbortSignal.timeout()) allows the caller to control cancellation timing before the call starts. Using handle.abort() allows post-start cancellation from the returned handle. Both methods trigger the same internal abort logic and result in an AbortError, but handle.abort() provides imperative control while external signals provide declarative control.

Where can I find a complete working example of abort patterns in Flue?

The repository includes a reference implementation at examples/hello-world/.flue/agents/with-abort.ts that demonstrates timeout-based aborts on prompt and shell, manual aborts via handle.abort(), and handling of pre-aborted signals. This example exercises all abort scenarios supported by the SDK.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →