---
title: "ADR-101: AgentRuntime — the agent-run lifecycle as a public service"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-101@0.35"
source: "Adr/Adr101AgentRuntime.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-101: AgentRuntime — the agent-run lifecycle as a public service

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

## Context

Persistence ([ADR-081](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-081@0.35)), human-in-the-loop approval
([ADR-084](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-084@0.35)) and termination semantics ([ADR-092](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-092@0.35))
work, but they were assembled inside `ToolPlaygroundController`: it began the
run, built the trace, persisted events, caught the suspension, settled the
status and executed the resume. The lifecycle ladder — `runLoop` → catch
suspension → catch guardrail → catch failure → settle completed — was copied
three times (batch, resume, stream), and any new consumer (a scheduler task, a
queue worker, an editor action) would have had to re-assemble it again, each
copy a chance to get the fail-closed rules wrong.

## Decision

`AgentRuntimeInterface` (`Classes/Service/Agent/`) is the public
application service for agent runs; `AgentRuntime` implements it and owns the
ladder exactly once. The playground controller is now a UI adapter: it parses
the request into an `AgentRunRequest` / `ApprovalDecision` and maps the
returned `AgentRunResult` onto its JSON / NDJSON shapes, which are unchanged.

```php
interface AgentRuntimeInterface
{
    public function run(AgentRunRequest $request, ?Closure $onStep = null): AgentRunResult;
    public function approve(string $runUuid, ApprovalDecision $decision, ?Closure $onStep = null): AgentRunResult;
    public function cancel(string $runUuid): bool;
    /** @return list<AgentRunEvent> */
    public function events(string $runUuid, int $afterSequence = -1): array;
    public function status(string $runUuid): ?AgentRun;
}
```

-   `run()` / `approve()` **never throw for a run outcome** — the result is
    already settled, discriminated by `AgentRunOutcome` (COMPLETED /
    AWAITING_APPROVAL / SUSPEND_FAILED / GUARDRAIL_BLOCKED /
    GUARDRAIL_APPROVAL_REQUIRED / FAILED). `approve()` throws typed
    `AgentRuntimeException`s only for an invalid request, before any
    execution: not-awaiting-approval, configuration gone, corrupt state, event
    position unavailable, claim lost.
-   `onStep` is a live observer fired for each recorded step **before** it is
    persisted (preserving the streaming path's emit-before-persist order); the
    NDJSON stream is built on it.
-   The roadmap sketched `start(request): handle` \+ `run(handle)`. That split
    is only real once the request payload is persisted so a *different process*
    can execute it — the queue epic. Until then a handle would not be
    rehydratable, so `run()` is synchronous begin-and-execute;
    `AgentRunRequest` is a plain value object so the queue epic can serialise
    it and add asynchronous execution without changing these signatures.

### Hardening folded in

-   **Suspension-safe finally-guard.** The abandoned-run settle (previously
    stream-only) now guards every path, and every branch — including a
    *successful* suspension — marks the run settled first. An unguarded
    finally-settle would flip a just-suspended run (WAITING_FOR_APPROVAL is
    non-terminal) to FAILED and destroy its resumable state.
-   **Fail-closed re-suspension.** The old resume path silently ignored a failed
    re-suspension and still answered `awaiting_approval` — an unresumable
    promise. The unified ladder applies [ADR-092](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-092@0.35)'s fail-closed
    rule everywhere; the distinct SUSPEND_FAILED outcome makes it visible.
-   **Iteration ceiling.** The per-run round cap (20) is a runtime invariant
    (`AgentRuntime::MAX_ITERATIONS`), no longer a controller constant. Only an
    explicit `maxIterations` is clamped; null keeps the loop's own lower
    default.
-   **Deterministic event sequence.** A resume continues the stream at
    `MAX(sequence) + 1` (was: a count that silently restarted at 0 on a query
    failure, interleaving segments). The position is probed *before* the claim —
    a failure refuses the resume while the run is still suspended, so the
    approval can simply be retried — and resolved *again* after winning it, so a
    request that stalled across another approval's continuation can never write
    duplicate sequences. (The narrower pre-existing window that such a stale
    request resumes the earlier suspension's *state* is unchanged from the
    previous controller code and bounded by the claim fence.)
-   **Cancel is a real fence.** `suspendRun` is now a guarded transition
    (suspends only a run still RUNNING). Previously an in-flight loop's late
    suspension could resurrect a just-CANCELLED row to WAITING_FOR_APPROVAL,
    offering an approval flow — and an executable gated tool — for a run the
    operator was told was stopped. A refused suspension takes the fail-closed
    SUSPEND_FAILED path; the terminal-status guard then discards its settle, so
    the run stays CANCELLED.
-   **No fail-open unpersisted suspension.** A run whose persistence was down at
    begin (null handle) that then hits an approval-gated tool is SUSPEND_FAILED,
    not AWAITING_APPROVAL: nothing was stored, so `approve('')` could only
    ever fail — the old code announced an approval flow that did not exist.
-   **Single-sourced logging.** The runtime logs each failure / guardrail block /
    refused suspension once, with the run uuid; the controller no longer re-logs
    the same event (the old per-channel messages, including "Tool playground
    resume failed", are replaced by the runtime's).
-   **Audited decisions.** The operator's decision is persisted as a new
    `AgentEventKind::APPROVAL` event (payload `{approved, decidedBy}`,
    best-effort like every event write). `fromRunStepKind()` deliberately does
    not resolve it — it is not a `RunStep` kind; stored kinds hydrate via
    `tryFrom()`. No free-text note: the event stream is privacy-filtered
    ([ADR-064](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-064@0.35)) and prose would bypass that.
-   **Privacy-safe status.** `status()` strips the suspended-state transcript:
    it is stored verbatim for resume and bypasses the privacy filter, so it is
    not part of the status surface.

## Consequences

-   `AgentRuntimeInterface` is a **public DI alias** (Category B), raising the
    audited public-service count from 34 to **35** (later raised to **36** by
    [ADR-106](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-106@0.35), which makes `GuardrailRegistry` public for its TCA
    itemsProcFunc). This ADR supersedes ADR-094 as the count authority. It is a
    *consumer* interface: call it, do not
    implement or decorate it outside nr_llm — methods and `AgentRunOutcome`
    cases may be added in minor releases (the queue epic will), so exhaustive
    matches need a default arm. `ToolLoopServiceInterface` stays public for
    consumers that want the bare loop; the runtime is the preferred surface.
-   `nrllm:agent:cancel` goes through the runtime; the playground controller
    lost its `ToolLoopService` / `AgentRunPersister` dependencies.
-   **Breaking:** `AgentRunPersister::resumeHandle()` now returns
    `?AgentRunHandle` (null = refuse the resume), and
    `AgentRunRepositoryInterface` gained `maxEventSequence()`.
-   Now possible without touching the playground: scheduler and messenger
    workers, consumer extensions, batch runs, review queues, editor actions and
    status polling (`events()` pages by sequence) — the run lifecycle they get
    is the tested one.
