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 source | Mechanism | Scenario it covers |
|---|---|---|
| Same-process abort | abortManager.abort(taskId) (apps/server/src/lib/abort-manager.ts) | inline mode — the run is in this API process |
| Cross-process abort | abort-bus Redis pub/sub → subscribeAbort callback | the run lives in a separate BullMQ worker |
| Pre-subscribe window catch-up | isAbortRequested(taskId) re-checks the abort-bus durable flag | a 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
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:
| Tool | How it responds to the signal |
|---|---|
shell (execute) | passes signal: abortSignal to the sandbox exec + withDeadline |
browser | forwards 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.
| Tier | Default | What it bounds | Constant |
|---|---|---|---|
chunkMs | 120s | the max silence between two adjacent chunks | DEFAULT_CHUNK_TIMEOUT_MS |
stepMs | 300s + allowance | one step’s total wall time (model generation + all its tools) | DEFAULT_STEP_WORK_TIMEOUT_MS |
toolMs | 120s | a 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:
- Reclaim orphaned tool parts — when the interrupt verdict
signal.aborted || isAbortedholds,terminatePendingToolParts(parts, "Cancelled")closes out any “in-flight tool part” left dangling atinput-available. Skip this and the frontend shimmers (spins) forever. - Persist partial results —
finalizeTurn(taskId, aiMessage, wasAborted, …)writes the assistant message produced so far, the accumulated usage, and task metadata, and derives the event kind (priorityaborted > errored > hitl > completed). - Release process-level resources — the
finallyblock 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.
| Knob | Location | Current | Watch out for |
|---|---|---|---|
| Durable abort flag TTL | abort-bus.ts FLAG_TTL_SEC | 120s | Must 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 cap | agent-loop.ts DEFAULT_CHUNK_TIMEOUT_MS | 120s | Long silent tools need a heartbeat or they’re misjudged as stalled |
| Step work budget | agent-loop.ts DEFAULT_STEP_WORK_TIMEOUT_MS | 300s | Raise this to let subagent / execute run longer; raising toolMs alone does nothing |
| Single-tool wall clock | agent-loop.ts DEFAULT_TOOL_TIMEOUT_MS | 120s | Only applies to non-exempt tools; execute / task already fall back to stepMs |
| Setup backstop clock | agent-loop.ts AGENT_SETUP_TIMEOUT_MS | 30s | Covers 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.”
Related docs
- Task Orchestration — the five-phase execute lifecycle; abort is one link in it
- Agent Engine — the ReAct loop and
agent.stream’s tiered timeouts - Background Job Queue — the worker process model, where cross-process abort comes from
- Streaming Architecture — stream construction and recovery, how an interrupt terminates the stream