You press stop. The spinner disappears, the UI relaxes, the request feels over. It usually isn't.
"Cancel a running agent" reads like a client-side chore: attach an AbortController, pass its signal to the SDK, exit the loop. That part is real and you should do it — the Vercel AI SDK threads an abortSignal through streamText and gives you an onAbort callback for exactly this. But an abort signal does one thing: it closes your end of the pipe. It exits your read loop and frees your socket. It is a statement about your process, not a command the rest of the system is obliged to obey.
Everything downstream of that socket may still be running.
The connection is not the work#
Here is the assumption worth killing: that closing the HTTP connection stops the generation. It doesn't, unless something on the other side is specifically watching for the disconnect and acting on it.
Self-hosted inference makes this legible. In vLLM, a streaming request keeps decoding tokens on the GPU until the server notices the client is gone — request.is_disconnected() — and then explicitly calls engine.abort(request_id) to free the KV cache and halt generation. Wire that path and cancellation works. Miss it and the model finishes a response nobody will read.
And the path is easy to miss. vLLM has shipped bugs where adding a BaseHTTPMiddleware makes is_disconnected() return False even after the client vanishes — one logging or auth middleware, and the disconnect signal is swallowed, and the decode runs to completion. There's a separate class of issue where the runtime doesn't honor context cancellation mid-stream at all. The disconnect happened. The GPU never heard about it.
Managed APIs hide this, which is worse, because you can't see whether your cancel did anything. The tell is in the plumbing: LiteLLM had to ship a fix to cancel the upstream LLM stream when the client disconnects during time-to-first-token. Read that backwards. Before the fix, if your user hit stop while waiting for the first token, LiteLLM dropped your connection but kept the upstream call open — generating, and billing — to completion. The abort you fired stopped nothing you were paying for.
A disconnect frees your socket. It does not, on its own, stop a GPU, and it does not stop a bill.
The tool call you already fired#
Even a clean abort — client signal, server abort(), generation genuinely stopped — leaves the hardest case untouched.
An agent doesn't just generate text. It calls tools. If the cancel lands after the model emitted a tool call and your orchestrator dispatched it, the side effect may already be in flight or already done. You charged the card. You sent the email. You POSTed to the partner API. Closing a socket does not un-send an email. There is no AbortController for a Stripe charge that already settled.
This is why cancellation is not an event — it's a transaction problem. A cancel that arrives mid-action needs one of two things: a compensating action that undoes the effect (a refund, a delete, a tombstone), or an idempotency key so the interrupted operation can be reconciled instead of silently orphaned or blindly retried into a duplicate. The stop button, taken seriously, has to reach into a half-finished distributed operation and leave it consistent.
Borrow the pattern that already solved this#
The good news is that this is a solved problem in a neighboring field, and agents can just adopt the answer. gRPC formalized it years ago as deadline and cancellation propagation.
Two ideas do the work. First, cancellation propagates: a deadline or cancel token set at the top cascades to every nested call, so a request abandoned at the edge tells every downstream hop to stop rather than each one discovering it independently — or never. If the top-level deadline is one second and 0.7s is gone, the nested call inherits 0.3s. Second, cancellation is cooperative: long-running server work periodically checks whether the RPC that started it was canceled, and stops itself. The caller can request a stop. Only the callee can honor one.
Translate that to an agent runtime and you get a concrete design:
- Thread one cancel token through the whole run — client, orchestrator, model call, tool calls — instead of relying on a socket closing to imply intent.
- Check it at every step boundary. Between plan and act, between tool and tool, the loop asks "still wanted?" before spending the next dollar. This is the cheap, clean place to stop — the same seam where a timeout should fire.
- Wire disconnect to abort for the model call, so a closed connection actually reaches
engine.abort()(or the provider's equivalent) rather than leaking a GPU. - Wrap tool calls so a cancel after dispatch triggers compensation, not a dropped connection and a shrug.
None of this is exotic. It's the same discipline that keeps a microservice mesh from wasting a datacenter on requests nobody is waiting for. Agents inherited the shape of that problem — long, nested, stateful calls fanning out to real side effects — without inheriting the reflex to solve it.
So when a product says "click to cancel," treat it as a promise the entire chain has to keep, not a signal one process gets to fire and forget. The button is the easy part. The stop is the engineering.


