Cancellation & Timeouts
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 (aibuddy: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.
Design decisions
Cancellation is cooperative because the system values consistent cleanup over pretending it can kill arbitrary work instantly. The LLM call can be cut immediately by the SDK, but tools, MCP calls, compaction reads, and storage writes cross boundaries where forced termination would leave partial state behind.
The full path of one Stop
Wire the three triggers together and one real Stop — from the user’s click to process wind-down — runs through four stages in order: request, signal convergence, propagate-and-stop, cooperative wind-down.
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/core/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 notification — a knock at the door, not a power cut; whether the door opens depends on whether the tool is listening. The tool has to respond for the signal to actually stop it:
| 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 — as if it pressed Stop on the user’s behalf, down the same AbortSignal path a user Stop takes; downstream can’t tell whether a person or a timeout pressed it.
| 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 a forced kill
Wherever the interrupt lands — setup, model generation, or tool execution — once the signal fires the stream eventually ends, and ending is not the same as stopping dead. The moment it closes, createUIMessageStream’s onEnd({ messages, isAborted }) triggers finalizeExecution (task-runner.ts), which runs a cooperative wind-down rather than chopping the process off mid-flight:
- 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