Skip to content

InterruptAdvisor - Cancelling In-Flight Turns

InterruptAdvisor adds cooperative cancellation to the agentic tool-calling loop. Once ChatClient.call() enters the loop, it normally runs to completion — a UI "stop" button, a session-level interrupt event, or a budget guard has no seam to abort it. This advisor is that seam.

How it works

The advisor polls an application-supplied BooleanSupplier in its before() hook. Its default order (HIGHEST_PRECEDENCE + 400) places it just inside the auto-registered tool-calling advisor (HIGHEST_PRECEDENCE + 300), so the check re-runs on every tool-call round: an interrupt raised while tools execute lands before the next model request, not only between turns.

When the signal reports true, the turn unwinds with a TurnInterruptedException — the caller catches it and decides what an interrupted turn means (discard, partial save, quiet end-of-turn).

Usage

AtomicBoolean interrupted = new AtomicBoolean();

ChatClient chatClient = chatClientBuilder
    .defaultAdvisors(memoryAdvisor,
            InterruptAdvisor.builder().interruptSignal(interrupted::get).build())
    .defaultTools(tools)
    .build();

// From another thread (REST handler, UI event, watchdog):
interrupted.set(true);

// The worker loop:
try {
    String content = chatClient.prompt().user(text).call().content();
}
catch (TurnInterruptedException ex) {
    interrupted.set(false);      // re-arm for the next turn
    // e.g. end the turn quietly with stop_reason "end_turn"
}

The signal isn't limited to a flag — any cheap, thread-safe check works:

// Wall-clock guard: cancel turns that run past a deadline
Instant deadline = Instant.now().plus(Duration.ofMinutes(5));
InterruptAdvisor.builder().interruptSignal(() -> Instant.now().isAfter(deadline)).build();

Streaming

With stream() the check runs inside the reactive pipeline, so the TurnInterruptedException arrives as the Flux's error signal rather than a synchronous throw:

chatClient.prompt().user(text).stream().content()
    .onErrorResume(TurnInterruptedException.class, ex -> Flux.empty())
    .subscribe(...);

Configuration

Builder option Default Notes
interruptSignal(BooleanSupplier) required Polled before each model request; must be cheap and thread-safe
order(int) HIGHEST_PRECEDENCE + 400 Orders below the tool-calling advisor's +300 check only once per turn

See also