AI Engineering
tutorial
Featured

Your Agent Kept Working After the User Left

A user closes the tab and your agent keeps going: three tool calls in flight, two subagents still reasoning, tokens still burning for an answer nobody will read. The fix is a deadline that every layer respects and a cancel that propagates down the whole tree, tearing in-flight work down cleanly instead of leaving it to finish alone.

Viral Ruparel
10 min read
Share:

A user asks your agent a question, waits eight seconds, decides it is too slow, and closes the tab. On the server, nothing notices. The agent finishes its current model call, fires off three tool calls, spawns a subagent to research one of them, and that subagent makes two more model calls of its own. All of it completes, correctly, forty seconds later. The result is serialized, cached, and thrown away, because there is no longer anyone to send it to. You paid for the model tokens, the tool calls, and the subagent tree in full, to produce an answer that existed for nobody.

This is one of the quieter ways agent systems waste money, and it scales with exactly the things that make agents useful: more tools, more parallelism, more subagents, more steps. A single chatbot turn that overshoots by a few seconds is nothing. A research agent that fans out into a tree of subagents and keeps every branch running after the user has left, across thousands of requests a day, is a real line on the bill. The fix is not a bigger timeout. It is making the whole tree agree on one clock and one stop signal.

One outer timeout does not stop the work

The instinct is to wrap the request in a timeout. Give it thirty seconds, and if it has not answered by then, return an error. This does bound how long the user waits, and if that were the only goal it would be enough. But it does nothing about the cost, because the timeout fires on the outer coroutine and the work underneath it does not know it happened.

Picture the call stack. The request handler is waiting on the agent loop, which is waiting on a tool dispatcher, which is waiting on three tool calls, one of which is waiting on a subagent. When the outer timeout expires and you stop waiting, you have unblocked the top of that stack. Everything below it is still running. The model is still generating, the subagent is still reasoning, the tool calls are still hitting their backends. You have stopped listening, not stopped spending. The tokens keep flowing until each leaf finishes on its own schedule.

So the goal is sharper than "give up after N seconds." It is: the deadline has to reach the leaves. Every layer that can start new expensive work needs to check, before it starts, whether there is still time and whether anyone still wants the result. And when the answer is no, the stop has to travel down, not just up.

An absolute deadline, not a per-step timeout

Start with the clock, because the most common version of this bug is getting the clock wrong. The naive move is to pass a timeout value down: give the agent thirty seconds, give each tool call thirty seconds, give the subagent thirty seconds. Now a run that takes ten steps, each just under its thirty second limit, runs for five minutes and never once trips a timeout. Every layer thought it was being careful. Nobody was measuring the total.

The fix is to pass an absolute deadline, a single point in time the whole run must finish by, computed once at the top and shared unchanged all the way down. Every layer measures against the same instant, so the budget cannot stretch by being subdivided.

import time
from dataclasses import dataclass

@dataclass(frozen=True)
class Deadline:
    # An absolute point on the monotonic clock, set once at the top of the run
    # and passed down unchanged. Every layer measures against THIS, so ten
    # careful steps can never each spend the full budget.
    at: float

    @classmethod
    def in_seconds(cls, budget: float) -> "Deadline":
        return cls(at=time.monotonic() + budget)

    def remaining(self) -> float:
        return self.at - time.monotonic()

    def expired(self) -> bool:
        return self.remaining() <= 0

Use time.monotonic, not time.time. Wall clock time can jump backwards when the machine syncs its clock, and a deadline that briefly travels into the past will fire in the middle of a healthy run. The monotonic clock only ever moves forward, which is the only property a deadline actually needs.

Now the agent loop checks the deadline before each model call, and hands the model only the time that is genuinely left. A single slow generation should not be able to blow the whole budget by itself.

import asyncio

class DeadlineExceeded(Exception):
    pass

async def run_agent(task: str, deadline: Deadline) -> str:
    messages = [{"role": "user", "content": task}]
    while True:
        if deadline.expired():
            raise DeadlineExceeded("ran out of time before the next model call")

        # The model gets only the remaining budget, not a fresh fixed timeout.
        step = await asyncio.wait_for(
            model_step(messages),
            timeout=max(0.0, deadline.remaining()),
        )
        if step.done:
            return step.text

        # Tool calls inherit the SAME deadline object, not a new one.
        results = await run_tools(step.tool_calls, deadline)
        messages.extend(results)

The one detail that matters here is the last line: run_tools receives deadline, the same instance, not a freshly minted timeout. That is the whole discipline. The deadline is created once and only ever passed, never recomputed, so it means the same thing at every depth.

Propagate the stop, do not just wait less

Checking the deadline before starting work handles the case where you have not launched the expensive thing yet. The harder case is work already in flight: three tool calls running in parallel, and the deadline expires while they are mid-execution, or one of them fails and the other two are now pointless. You want the siblings torn down immediately, not left to finish for a result you will discard.

This is where cancellation, not just timing, does the job. In asyncio, cancelling a task raises CancelledError inside it at its next suspension point, which unwinds it and, crucially, unwinds whatever it was awaiting. Propagation falls out of the structure if you build it right: cancel the parent and the children go too.

async def run_tools(calls, deadline: Deadline):
    async def one(call):
        remaining = deadline.remaining()
        if remaining <= 0:
            raise DeadlineExceeded(f"no time left to start {call.name}")
        # Each tool sees the shared deadline, so a subagent underneath it
        # inherits the same absolute stop time all the way down.
        return await asyncio.wait_for(dispatch(call, deadline), timeout=remaining)

    tasks = [asyncio.create_task(one(c)) for c in calls]
    try:
        return await asyncio.gather(*tasks)
    except BaseException:
        # A deadline hit or one failure makes the remaining siblings useless.
        # Cancel them so we stop paying for work whose result is now dead,
        # then wait for the cancellations to actually settle before re-raising.
        for t in tasks:
            t.cancel()
        await asyncio.gather(*tasks, return_exceptions=True)
        raise

Two things are load bearing. First, dispatch gets the same deadline, so a subagent spawned inside a tool call runs its own run_agent loop against the identical stop time. The tree shares one clock, top to leaf. Second, the except BaseException cancels the siblings and then awaits them again with return_exceptions=True. That second await is not optional. cancel() only requests cancellation; the task is not actually done until you await it and let the CancelledError propagate through its cleanup. Skip the await and you re-raise while the siblings are still tearing down in the background, which is the exact leak you were trying to prevent.

If you have read the piece on backpressure and concurrency control, this is the same tree of tasks, viewed from the other end. There the concern was not launching too much at once. Here it is not letting what you launched outlive its usefulness. You want both bounds on the same fan-out.

Clean teardown, especially for side effects

Cancellation is only safe if every layer cleans up as it unwinds. A tool that opened a connection, wrote a temp file, or started a remote job cannot simply vanish when CancelledError arrives. It has to release what it holds on the way out. The rule is narrow and absolute: catch the cancellation only to run cleanup, and always re-raise it. Swallowing it tells the layer above that the child finished normally, which is a lie that corrupts the whole propagation.

async def dispatch(call, deadline: Deadline):
    if call.name == "deploy":
        handle = await start_deploy(call.args)
        try:
            return await poll_until_done(handle, deadline)
        except (asyncio.CancelledError, DeadlineExceeded):
            # We are being torn down. Abort the in-flight deploy instead of
            # leaking a half-finished rollout, then let the cancellation
            # continue up. Give cleanup its own time budget, because the
            # deadline that triggered this has, by definition, no time left.
            await asyncio.wait_for(abort_deploy(handle), timeout=5.0)
            raise
    # ... other tools

Notice the cleanup has its own five second timeout rather than reusing the deadline. The deadline is what expired to get us here, so it has zero time left, and reusing it would mean your rollback gets no chance to run. Cleanup needs a small, separate budget of its own, shielded from the very deadline it is responding to.

For side effects that genuinely cannot be undone, cancellation is the wrong tool and no amount of careful teardown saves you. You cannot abort a sent email or a charged card. Those belong behind a checkpoint so the commit is its own atomic unit, the discipline from durable execution and checkpointing, or behind a human-in-the-loop approval gate so the irreversible step never fires unattended in the first place. Cancellation cleans up work that is still reversible. It is not a license to leave irreversible work half done.

The external cancel: nobody is waiting anymore

Everything so far handles the deadline, the internal clock running out. The other trigger is external: the user disconnected, a newer request replaced this one, an operator killed the job. Mechanically it is the same teardown, and if you have built the deadline path, the cancel path is nearly free. You wire the disconnect to cancelling the top-level task, and the CancelledError you already handle everywhere does the rest.

async def handle_request(task: str, budget: float, disconnected: asyncio.Event):
    deadline = Deadline.in_seconds(budget)
    run = asyncio.create_task(run_agent(task, deadline))

    async def watch_disconnect():
        await disconnected.wait()
        run.cancel()  # User left. Same teardown the deadline path uses.

    watcher = asyncio.create_task(watch_disconnect())
    try:
        return await run
    finally:
        watcher.cancel()

Now an abandoned request stops costing money the instant the client goes away, and a run that overruns its budget stops at the deadline, and both flow through one teardown path that cancels tool calls and subagents and lets each one clean up. The deadline caps the cost of runs that go long; the cancel caps the cost of runs nobody wants. On a system that meters spend per customer, the way per-tenant budgets do, this is the difference between charging for work delivered and charging for work abandoned.

Where this breaks

A few pitfalls, in the order you will hit them.

Blocking calls ignore cancellation. CancelledError fires at an await, so a synchronous database driver or a plain requests call cannot be interrupted mid-flight, the cancel just waits behind it. Run blocking work in an executor, or at minimum check the deadline before starting it, so you never launch a ten second blocking call with two seconds left on the clock.

Swallowed cancellation is worse than none. A bare except Exception that eats the CancelledError breaks the entire chain, because CancelledError derives from BaseException, not Exception, precisely so that careless handlers do not catch it. If you catch it to clean up, re-raise it. Always.

Cleanup needs its own budget. The deadline that triggered teardown has no time left by definition, so any cleanup that awaits will instantly fail unless you give it a fresh, small, shielded window. Budget for teardown separately from the run.

Already-streamed tokens are already paid for. If you stream output to the user token by token, cancelling mid-stream still cost you everything up to that point. Cancellation caps future spend, not sunk spend, so the deadline should fire while there is still meaningful work ahead, not as a last resort after you are deep into a response.

The takeaway

An agent that keeps working after the user leaves is not a rare edge case, it is the default behavior of every system that does not explicitly prevent it. The compute does not know the request was abandoned unless you tell it, and a single outer timeout tells the top of the stack while every expensive leaf keeps running. The fix is two disciplines that share one path: an absolute deadline created once and passed unchanged so the whole tree measures against the same clock, and a cancel that propagates down through every tool call and subagent, tearing in-flight work down cleanly and releasing what it held. It is not a large amount of code. It is the difference between paying for the answers you deliver and paying for the ones nobody ever reads.

If your agents fan out into tools and subagents and you have never checked what happens when a user walks away mid-run, that is worth measuring before it shows up as an unexplained line on the bill. Book a consultation call and we can trace where your runs keep spending after everyone has stopped waiting.

Viral Ruparel

Generative AI consultant helping teams ship reliable LLM and agent systems in production.

Contact Viral about your AI project →

Frequently Asked Questions

Why not just set a timeout on the whole agent request?+

A single outer timeout stops you from returning a result after N seconds, but it does not stop the work. The model call, the tool calls, and the subagents underneath keep running until they finish on their own, and you pay for every token they produce after you have already given up on the answer. The point of propagation is that the deadline reaches the leaves, so the actual compute stops, not just the response you were waiting on.

What is the difference between a deadline and a cancel signal?+

A deadline is internal and time based: the run has a fixed amount of wall clock to finish, and every layer checks how much is left before starting more work. A cancel signal is external and event based: the user closed the tab, a newer request superseded this one, or an operator killed the run. They are handled the same way at the leaves, both tear the tree down, but they fire for different reasons and you usually want both. The deadline caps cost on runs that go long, the cancel stops cost on runs nobody wants anymore.

How do I cancel a tool that has already started a side effect?+

You cannot un-send an email or un-charge a card, so cancellation of a side-effecting tool means either aborting an in-flight operation that exposes an abort (a deploy you can roll back, a job you can stop) or, for truly irreversible steps, checkpointing before the step and treating the commit as its own small unit that either happens fully or not at all. For the genuinely irreversible actions, a human approval gate in front of the step is a better control than trying to cancel it after it fired.

Will cancellation ever corrupt state or leak resources?+

It will if your cleanup is careless. The two rules are: never swallow the cancellation without re-raising it, or the layer above thinks the child finished normally, and always run teardown in a finally block or an except that re-raises, so open connections, temp files, and in-flight jobs get released even when the task is being torn down. Give cleanup its own small shielded time budget, because a deadline that has already expired leaves zero time for the cleanup that the expiry itself triggered.