---
title: "ADR-103: Cooperative cancellation at step boundaries"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-103@0.35"
source: "Adr/Adr103CooperativeCancellation.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-103: Cooperative cancellation at step boundaries

-   *Status:* Accepted
-   *Date:* 2026-07-21
-   *Authors:* Netresearch DTT GmbH

## Context

Cancellation has been a persistence-level fence since [ADR-092](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-092@0.35):
the guarded terminal transition wins the row and a late settle is discarded —
but the in-flight loop itself kept running to completion, spending provider
calls and executing tools whose outcome was then thrown away. With queued runs
([ADR-102](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-102@0.35)) that gap grows: a worker run can be long, and
`nrllm:agent:cancel` is the only brake an operator has. The P1 roadmap asks
for exactly this: check the cancel flag *between* model and tool steps.

## Decision

The run executor's trace hook gains a **cancellation probe**. Every step
boundary — after a provider response, after each tool execution, before the
next round — already records a step through the runtime's `onRecord` closure;
the probe re-reads the run row there and, when it is CANCELLED, throws the
internal `RunCancellationRequestedException`. The ladder catches it as
control flow (before the generic Throwable), attempts **no** settle (the
cancel already won the terminal transition; a late settle would be discarded
anyway) and returns the new `AgentRunOutcome::CANCELLED`.

Properties:

-   **The loop stays persistence-unaware** (ADR-081): the probe lives entirely
    in the runtime's trace closure; `ToolLoopServiceInterface` is untouched.
-   **A step in flight runs to its boundary** — cancellation is a cooperative
    check, not a signal. The boundary step itself is still emitted and
    persisted, so the audit stream is complete up to the abort point.
-   **No further spend after the boundary**: the next provider call and the
    pending tool executions of the following round never start.
-   **Fail-soft probe**: the row read goes through the fail-soft persister — a
    store hiccup yields null, never a fabricated cancellation. One indexed row
    read per step; steps are provider-call-slow, so the cost is noise.
-   Works identically for interactive (`run()`), resumed (`approve()`) and
    queued (`runQueued()`) segments — they share the one trace builder.

## Consequences

-   `AgentRunOutcome` gained `CANCELLED` (the documented minor-release
    growth path; consumers match with a default arm). The playground maps it to
    a `status: 'cancelled'` payload / `cancelled` stream event — a decision,
    not an error.
-   `AgentRuntimeInterface::cancel()`'s contract is upgraded from "fence only"
    to "fence + cooperative stop at the next step boundary".
-   A cancelled run's in-flight segment now ends within one step instead of
    running to completion — the remaining latency is bounded by the longest
    single step (one provider call or one tool execution), which the future
    heartbeat/lease epic can further constrain.
