Cancellation & Timeouts

Cancellation is not an instant kill — the SDK cuts the LLM off immediately, but tools are cooperative, so one that ignores the signal only stops at the stepMs timeout, and timeouts ride the same AbortSignal as a user Stop.

Every trigger converges on one AbortController

A running task can be interrupted at any moment. There are three trigger sources, but they all fire the same session.abortController (apps/server/src/services/task-runner.ts), which then flows into the agent engine as control.abortSignal. Understanding cancellation starts with this convergence point: no matter who presses stop, everything downstream sees exactly one signal.

Trigger sourceMechanismScenario it covers
Same-process abortabortManager.abort(taskId) (apps/server/src/lib/abort-manager.ts)inline mode — the run is in this API process
Cross-process abortabort-bus Redis pub/sub → subscribeAbort callbackthe run lives in a separate BullMQ worker
Pre-subscribe window catch-upisAbortRequested(taskId) re-checks the abort-bus durable flaga Stop that lands before the run subscribes

The first two are runtime pushes; the third is a pull-based re-check. Why the third exists: the task is enqueued first, and the worker only installs subscribeAbort a moment later. If the user presses Stop in that gap, fire-and-forget pub/sub drops it — nobody is listening. So publishAbort does more than PUBLISH: it also writes a durable flag (zapvol:aborted:{taskId}, EX 120s), and the run re-checks it with isAbortRequested right after subscribing to close the gap. Conversely, each turn calls clearAbortRequest at the start so a stale Stop from a prior turn cannot kill a fresh run within the TTL.

The full path of one Stop

sequenceDiagram actor User participant Orch as Task Orchestrator participant Bus as abort-bus · Redis participant Run as Agent Run · abortController participant SDK as ToolLoopAgent · stream participant Tool as Running tool rect rgba(244, 114, 182, 0.16) Note over User,Bus: (1) Stop request User->>Orch: POST /tasks/:id/abort Orch->>Orch: taskService.abort (ownership + isActive=false) Orch->>Run: abortManager.abort (same process) Orch->>Bus: publishAbort (durable flag + PUBLISH) Orch-->>User: WS task:event aborted end rect rgba(251, 191, 36, 0.16) Note over Bus,Run: (2) Signal convergence Bus-->>Run: pub/sub hits subscribeAbort (cross-process) Run->>Run: isAbortRequested re-checks flag (pre-subscribe gap) Run->>Run: session.abortController.abort(remote_stop) end rect rgba(96, 165, 250, 0.16) Note over Run,Tool: (3) Propagation and stop Run->>SDK: control.abortSignal SDK->>SDK: LLM generation cancels at next await point SDK->>Tool: options.abortSignal (cooperative) Tool-->>SDK: honors it → early return / tool-error end rect rgba(52, 211, 153, 0.16) Note over SDK,Orch: (4) Cooperative wind-down SDK-->>Run: stream ends Run->>Run: terminatePendingToolParts (Cancelled) Run->>Run: finalizeTurn (aborted) + release lock/heartbeat/MCP end

taskOrchestrator.abort(userId, taskId) is called from both POST /api/tasks/:id/abort (HTTP) and task:stream:abort (WS), and runs in order: first taskService.abort verifies ownership and persists isActive=false (an unauthorized call throws here and never reaches signal dispatch), then it fires the same-process abortManager.abort and the cross-process publishAbort together (both idempotent, so double-firing is harmless), and finally it pushes a WS aborted event immediately so the frontend need not wait for the agent to wind down.

The SDK cuts off the LLM; tools are cooperative

Once the signal enters the engine, agent.stream({ abortSignal, onToolExecutionEnd, timeout }) (packages/backend/src/agent/agent-loop.ts) dispatches it down two very different paths:

The LLM stream is stopped by the SDK, reliably. The moment the signal fires, the in-flight model-generation HTTP request is cancelled at the next await point. This layer needs no business-code involvement — the Vercel AI SDK handles it, and it is the most reliable path.

Tools are cooperative — whether they stop is up to the tool. The SDK forwards abortSignal into every tool’s execute(args, { abortSignal }), but it is only a signal; the tool must actively respond for it to truly stop:

ToolHow it responds to the signal
shell (execute)passes signal: abortSignal to the sandbox exec + withDeadline
browserforwards to BrowserBridge.request(action, signal); the pending request resolves as internal_error "aborted"
search (tavily / exa)passes it to the underlying fetch

A tool that does not honor the signal will not stop immediately — it runs until a timeout forcibly cuts it off. That is exactly the next section: the timeout is cancellation’s backstop, and it uses the same mechanism.

Timeouts and user cancellation are the same signal

buildStreamTimeouts (agent-loop.ts) configures three wall-clock tiers for agent.stream. When any tier elapses, the SDK turns it into an AbortSignal.timeout — the same AbortSignal path a user Stop takes; downstream does not distinguish the two.

TierDefaultWhat it boundsConstant
chunkMs120sthe max silence between two adjacent chunksDEFAULT_CHUNK_TIMEOUT_MS
stepMs300s + allowanceone step’s total wall time (model generation + all its tools)DEFAULT_STEP_WORK_TIMEOUT_MS
toolMs120sa single tool execute() wall time (from tool start)DEFAULT_TOOL_TIMEOUT_MS

stepMs is not tool-grained; it wraps the whole step. And the stepMs value handed to the SDK is work budget + compaction allowance (COMPACTION_SUMMARIZE_TIMEOUT_MS): compaction runs inside prepareStep, after stepMs is armed, and consumes this clock non-resettably — without the allowance it would eat into the work budget tools were meant to get.

execute and task are long-running tools, exempt from toolMs. shell self-manages 240s+grace, and task’s nested subagent loop often runs for minutes; clamping them with the global toolMs would kill them prematurely. The exemption sets their {toolName}Ms to stepMs — i.e. “fall back to the step-level bound, do not stack a tighter tool-level cap on top.” This carries a corollary worth remembering: to let a subagent run past stepMs, you must raise stepMs too; raising taskMs alone does nothing — the whole step is aborted by stepMs first.

The setup phase is interruptible too

Cancellation does not only cover LLM generation and tool execution. Before the LLM is called, buildAgentInputs builds instructions + tools in parallel, and both branches await Skill metadata (SkillStorage R2/FS reads) — not a pure in-memory operation. That span is wrapped by withDeadline([...], { ms: AGENT_SETUP_TIMEOUT_MS, signal: control?.abortSignal }) (default 30s): if R2 stalls, turn setup has a backstop clock, and it also responds to a user Stop / parent abort. So even before the task enters reasoning, a Stop can make it exit.

Cancellation is a cooperative wind-down, not an instant kill

After the signal fires the stream ends, and createUIMessageStream’s onEnd({ messages, isAborted }) triggers finalizeExecution (task-runner.ts). Cancellation does not chop the process off mid-flight; it runs a cooperative wind-down:

  1. Reclaim orphaned tool parts — when the interrupt verdict signal.aborted || isAborted holds, terminatePendingToolParts(parts, "Cancelled") closes out any “in-flight tool part” left dangling at input-available. Skip this and the frontend shimmers (spins) forever.
  2. Persist partial resultsfinalizeTurn(taskId, aiMessage, wasAborted, …) writes the assistant message produced so far, the accumulated usage, and task metadata, and derives the event kind (priority aborted > errored > hitl > completed).
  3. Release process-level resources — the finally block runs unconditionally: unsubscribeAbort()abortManager.remove() → MCP disconnect → stopLockHeartbeat()releaseTaskLock().

In other words, a cancelled turn still leaves a visible partial deliverable and always returns the lock, heartbeat, and MCP connection before ending — a single Stop never strands a later turn in a zombie state.

Tuning points

These are the knobs this mechanism exposes for targeted tuning. Before changing one, read this page’s constraints to understand its knock-on effects.

KnobLocationCurrentWatch out for
Durable abort flag TTLabort-bus.ts FLAG_TTL_SEC120sMust cover the longest “enqueue → worker subscribes” gap; too short misses, too long widens the window where a stale Stop kills a fresh run (clearAbortRequest backstops at turn start)
Chunk-silence capagent-loop.ts DEFAULT_CHUNK_TIMEOUT_MS120sLong silent tools need a heartbeat or they’re misjudged as stalled
Step work budgetagent-loop.ts DEFAULT_STEP_WORK_TIMEOUT_MS300sRaise this to let subagent / execute run longer; raising toolMs alone does nothing
Single-tool wall clockagent-loop.ts DEFAULT_TOOL_TIMEOUT_MS120sOnly applies to non-exempt tools; execute / task already fall back to stepMs
Setup backstop clockagent-loop.ts AGENT_SETUP_TIMEOUT_MS30sCovers Skill-metadata R2 reads; raise when R2 is slow

One standing implementation note: reclaiming orphaned parts happens only in finalizeExecution. So when you add a custom tool that does not honor the signal, expect its worst-case cancellation latency to be “stops only at stepMs.”

Was this page helpful?