An agent invocation is a long, multi-step computation: model calls, tool calls, session writes and callbacks, sometimes over minutes. Two things can interrupt it. The caller can stop wanting the answer: a browser tab closes, a request deadline expires, a user presses stop. Or the workflow itself can need to wait for something slow, such as a human approval or a batch job. The first is cancellation and the second is a pause. In both, events stop arriving, but ADK Java implements them differently, and confusing them causes duplicated side effects or stuck sessions.

This article explains both from first principles: what disposing a run actually stops, what has already been persisted when it stops, how a resumable app pauses and resumes, and how to design tools and request handlers so that either ending leaves the system consistent. Everything named here was checked on 2026-10-04 against the google/adk-java repository at tag v1.11.0. The resume overload and ResumabilityConfig are marked experimental there, so recheck them on upgrade. The step-by-step pause walkthrough lives in task resumability in ADK Java; this page concentrates on cancellation and on how the two interact.

Runs are cold streams, and cancelling means disposing

Runner.runAsync(userId, sessionId, message, runConfig) returns a Flowable<Event>, an RxJava stream. It is cold: calling the method only assembles a pipeline, and nothing happens until something subscribes. When you subscribe, the runner loads or creates the session, wraps your message in a user event, appends that event to the session, and then runs the root agent, emitting each event the agents produce. Cancellation in this world has one meaning: disposing the subscription. There is no separate cancel method on the runner. If you call blockingForEach or toList().blockingGet(), you have no handle to dispose, so handlers that need cancellation must subscribe and keep the Disposable.

Disposal travels upstream through the operator chain. The model client in the repository's chat-completions HTTP path, for example, registers the HTTP call's cancel as its cancellation action, so disposing mid-stream aborts the request rather than waiting for the response. What disposal cannot do is stop code already executing synchronously on the subscribing thread. In the default and SEQUENTIAL tool modes, a tool method halfway through a blocking database call runs until it returns, and only then does the stream notice that nobody is listening.

What has been persisted when you cancel

The most important question after a cancel is what the session now contains. The runner answers it with a strict ordering. For each event an agent produces, it first calls sessionService.appendEvent, and only after that write succeeds does it emit the event downstream and let the flow continue. In v1.11.0 a barrier also makes the next model step wait until the previous step's events are persisted, so the next request is never built from a stale session. Partial events, the incremental text chunks of a streaming response, are emitted to the caller but never persisted; the final aggregated event carries the full content instead.

That ordering gives cancellation a clean shape. Every event the caller saw as final is in the session log, and anything after the cancellation point is not. Cancel during a streaming response and the session holds everything up to the previous complete event: the half-streamed answer the user watched is gone. Cancel while a tool runs and the log may hold the model's function call with no function response. Reload the UI from the session after a cancel.

Two more things do not happen on a cancelled run. The runner attaches the after-run plugin callbacks and event compaction with concatWith, so they run only when the agent stream completes normally. Disposal skips both. If you rely on an after-run callback for billing, audit records or cleanup, a cancelled invocation will not produce them; do that work in doFinally on your own subscription, or in a tool that commits its own records.

One invocation, three endings: complete, cancel (dispose) or pause (long-running call)subscribe()runAsync is colduser eventappendEvent firstagent stepLLM call, toolspersist + emitnon-partial onlynext step waits for persistenceCompleteafterRun callbacks, compactionCancel: dispose()stops at next operator boundaryPauselong-running call, resumable appfinal responseclient gonepending callSession event logpersisted events stay; partials lostResume runAsync(...invocationId...)rehydrate checkpointsfunction responseNo afterRun, no compactionon the cancelled run
The three endings of an invocation. Persisted events survive every ending; partial events and the after-run bracket survive only completion. Only a pause, in a resumable app, is designed to be continued.

Per-session serialisation and in-flight work

The runner serialises runs per session. Each run for a session id is chained behind the previous one, so two requests never read and append the same session concurrently, and the slot is released in doFinally, which fires on completion, error and disposal alike. So a disconnected client does not leave the session locked.

The subtle part follows from the previous section. Releasing the slot does not prove that every piece of work started by the cancelled run has stopped. A blocking tool that was mid-call when you disposed can still be running, and its result is simply dropped. If that tool writes to an external system, the next run may start while the write is still in progress. Design for it: make side-effecting tools idempotent, keyed on the function call id.

Making tools cancellable and idempotent

How promptly a tool stops depends on how it is written and on RunConfig.toolExecutionMode. A FunctionTool calls your method reflectively. If the method returns a plain value, the work happens synchronously, on whichever thread subscribed. If it returns a Single or Maybe, the runner subscribes to it, and disposal propagates into it. The source documents SEQUENTIAL (each tool starts after the previous one finishes), PARALLEL (the behaviour of the builder default, NONE: all tools are subscribed eagerly on the caller thread, so asynchronous tools overlap but blocking tools still run one after another), and PARALLEL_SUBSCRIBE (each tool is subscribed on the agent's executor or the IO scheduler, so blocking tools overlap too).

The practical rule: if a tool can take long enough that a user might cancel, make it asynchronous and cancellable. Return a Single built from a source that does something on disposal, check an explicit cancellation flag between chunks of work, and always restore the thread's interrupt status if you catch an InterruptedException. An exception thrown synchronously from a FunctionTool method becomes a generic error result for the model, but an error emitted by a returned Single fails the run unless an on-tool-error callback supplies a result, so return an error map instead.

public final class ExportTools {
  private static final ExecutorService POOL = Executors.newFixedThreadPool(8);

  /** Cancellable: disposing the subscription cancels the future and interrupts the worker. */
  public static Single<Map<String, Object>> exportReport(
      @Annotations.Schema(name = "reportId") String reportId,
      ToolContext toolContext) {                 // bound by the name "toolContext"
    String callId = toolContext.functionCallId().orElse("unknown");   // idempotency key
    return Single.create(emitter -> {
      Future<?> job = POOL.submit(() -> {
        try {
          for (Chunk chunk : Reports.chunks(reportId)) {
            if (Thread.currentThread().isInterrupted()) {
              return;                       // stop between chunks
            }
            Exports.writeChunk(callId, chunk);  // upsert keyed by callId
          }
          emitter.onSuccess(Map.of("status", "done", "exportId", callId));
        } catch (InterruptedException e) {
          Thread.currentThread().interrupt();   // keep the stop visible
        } catch (Exception e) {
          emitter.onSuccess(Map.of("status", "error", "message", e.getMessage()));
        }
      });
      emitter.setCancellable(() -> job.cancel(true));
    });
  }
}

The Chunk, Reports and Exports types stand in for your own code; the ADK pieces are the toolContext parameter, which FunctionTool binds by that name, ToolContext.functionCallId(), the Single return type and RxJava's setCancellable. Because the writes are keyed by the function call id, a rerun after a cancel or a resume overwrites rather than duplicates. For more on bridging futures into Single, see running ADK Java tools asynchronously.

Cooperative stops from inside the agent

Sometimes the stop decision belongs inside the agent rather than the caller: a budget is exhausted, a guardrail fires, a precondition fails. ADK offers cooperative endings for that. A before-agent callback that returns content skips that agent and emits the content as its reply; on a single root agent, as below, that ends the run. RunConfig.maxLlmCalls (default 500 in v1.11.0) caps model calls per invocation and fails the run with LlmCallsLimitExceededException when exceeded. That makes it a backstop against loops, not a graceful stop. Watch out for one naming trap: EventActions.setEndInvocation is deprecated and is now an alias for setEndOfAgent, which marks one agent finished for resumability rather than ending the whole run.

LlmAgent agent = LlmAgent.builder()
    .name("support_agent")
    .model("gemini-2.0-flash")                // any model your project uses
    .instruction("Answer billing questions using the tools.")
    .tools(FunctionTool.create(ExportTools.class, "exportReport"))
    .beforeAgentCallbackSync(ctx -> {
      Object stop = ctx.state().get("stop_requested");
      return Boolean.TRUE.equals(stop)
          ? Optional.of(Content.fromParts(Part.fromText("Stopped at your request.")))
          : Optional.empty();
    })
    .build();

Cooperative endings complete normally, so the after-run bracket runs and the session gets a clean final event. Use them for business decisions, and disposal when the consumer is gone. The callback design space is covered in ADK Java callback architecture.

Pausing and resuming in a resumable app

A pause is a different thing: the workflow stops on purpose, at a point it can continue from. It requires an App built with ResumabilityConfig.builder().resumable(true).build(). In a resumable app, when an agent emits an event carrying a long-running function call (a tool created with LongRunningFunctionTool.create(...), or the synthetic adk_request_confirmation call used for tool confirmation), the invocation pauses after that event, and the stream completes normally. Agents also checkpoint their own progress into events (agentState and endOfAgent in EventActions), so a later request can work out which agent should continue and which already finished.

App app = App.builder()
    .name("refunds")
    .rootAgent(workflow)                      // e.g. a SequentialAgent
    .resumabilityConfig(ResumabilityConfig.builder().resumable(true).build())
    .build();
Runner runner = Runner.builder().app(app).sessionService(sessionService).build();

// Later, in a different request and possibly a different process:
Content answer = Content.fromParts(Part.builder()
    .functionResponse(FunctionResponse.builder()
        .id(pendingCallId)                    // the paused call's id is required
        .name("requestRefundApproval")
        .response(Map.of("approved", true))
        .build())
    .build());
Disposable d = runner.runAsync(userId, sessionId, /* invocationId= */ null,
        answer, RunConfig.builder().build(), /* stateDelta= */ null)
    .subscribe(this::forward, this::fail);

This six-argument overload is annotated experimental. It throws IllegalStateException if the app is not resumable, infers the invocation from the function response when you pass a null id, and returns an empty stream when the invocation's active agent has already finished. That no-op guard is what makes duplicate resume deliveries harmless. The config's own documentation states the contract plainly: resumption is best-effort and at-least-once, a resuming tool must be idempotent, and in-memory state is lost on resumption. The older plainTextContinuationAutoResume flag selects a deprecated legacy flow and cannot be combined with resumable.

When cancellation meets resumption

What happens when you cancel a run in a resumable app? Disposal does not create a pause point. No long-running call marks the spot, and no special event is written. The session simply holds the events persisted before the cancel, including whatever checkpoints the agents had already recorded. The resume overload also accepts an explicit invocation id with no message, and in that case the runner rehydrates checkpoints from history and runs the resolved agent again. It is a reasonable inference that this can continue a cancelled invocation from its last checkpoint, with the interrupted step running again. That matches the at-least-once wording, but it is not a documented cancellation feature, so test it against your own workflow before depending on it.

A safer default is to treat cancellation as final for that invocation. Record the cancel in session state on the next request and let the next message start a new invocation, with enough context for the model to decide what to redo. Reserve resumption for designed pause points, where the contract is explicit. Two related signals are often confused with cancellation. Event.interrupted() reports that a live, bidirectional model stream was interrupted, usually by user barge-in. And in v1.11.0 the Gemini live connection logs a server-side tool-call cancellation message with a TODO rather than acting on it, so do not expect a live session to cancel your running tools for you.

Failure modes

  • Using blockingGet in a request handler, so a disconnected client leaves the invocation running to completion and paying for every model call.
  • Relying on after-run plugin callbacks for audit or billing; a disposed run never reaches them.
  • Rendering partial text and treating it as saved; after a cancel the session has none of it.
  • Non-idempotent side-effecting tools that run again on resume, or keep running after a cancel while the next run starts.
  • Catching InterruptedException in a tool and returning normally, turning a stop into a fake success or a generic error.
  • Expecting setEndInvocation to stop the run; it is a deprecated alias for marking one agent finished.
  • Resuming with a function response that lacks the call id; the runner rejects it.
  • Changing the agent tree between pause and resume, so checkpoints name agents that no longer exist.

Trade-offs and related reading

Cancellation is cheap and immediate but lossy: you save compute and money, and accept losing partial output and the after-run bracket. Pausing is durable but demands discipline: persistent sessions, idempotent tools, stable agent structure, and an experimental API. Cooperative endings sit between the two. They are clean and complete but need the agent to reach a decision point. Production systems usually combine all three. For what each persisted event contains, see ADK Java events; for streaming and persistence detail, see ADK Java streaming events.

What to do next

  1. Replace blocking calls in request handlers with subscribe, keep the Disposable, and dispose it when the client disconnects or the deadline passes.
  2. Move audit, billing and cleanup out of after-run callbacks into doFinally or into tools that commit their own records.
  3. Audit every side-effecting tool: key writes on ToolContext.functionCallId() and make reruns overwrite rather than duplicate.
  4. Make long tools return a cancellable Single, check interruption between chunks, and restore the interrupt flag when catching it.
  5. Choose the toolExecutionMode deliberately; use PARALLEL_SUBSCRIBE only if blocking tools must overlap.
  6. If you need pauses, build an App with ResumabilityConfig.resumable(true), use LongRunningFunctionTool, and test duplicate resume delivery.
  7. After any cancel, reload the UI from the session, and record the cancellation in state for the next invocation.
Key takeaway: In ADK Java, cancelling means disposing the runAsync subscription: persisted events stay, partial output and the after-run bracket are lost, and blocking tools may finish anyway. Pausing is a separate, designed mechanism for resumable apps, with at-least-once resumption. Make side-effecting tools cancellable and idempotent on the function call id, use cooperative stops for business decisions, and treat cancellation as final unless you have tested resuming it.