---
title: "ADR-081: Agent run persistence and a durable event stream"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-081@0.35"
source: "Adr/Adr081AgentRunPersistence.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-081: Agent run persistence and a durable event stream

-   *Status:* Accepted (the event vocabulary has grown — see [ADR-151](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-151@0.35))
-   *Date:* 2026-07-18
-   *Amended:* 2026-08-11 by [ADR-151](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-151@0.35) and [ADR-153](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-153@0.35)
-   *Authors:* Netresearch DTT GmbH

> [!NOTE]
> The mechanism below is unchanged. The count in "Type the vocabulary" is not:
> `RunStep` produces a fifth kind, `context` (the round's context-window
> accounting, [ADR-151](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-151@0.35)), and `AgentEventKind` declares it —
> which is what "richer kinds are added by the epics that emit them" asks for.
> `approval` ([ADR-101](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-101@0.35)) and `input`
> ([ADR-105](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-105@0.35)) were added the same way; they are event kinds
> only, never `RunStep` kinds.

## Context

A run of the tool-calling agent loop (`ToolLoopService::runLoop()`) existed only
for the lifetime of one HTTP request. It returned a `ToolLoopResult` value object
and was then gone. The only inspectable trace, `RunTrace`, is an in-memory
collector the admin playground builds to stream steps to the browser; nothing was
written to storage.

That is the missing foundation under every larger agent capability: a run cannot
be resumed after a worker restart, queued for later execution, paused for a human
approval, audited, or replayed by a consumer UI, because there is no persisted run
to attach any of that to. Cross-call usage already carried a `correlation_id`
(`tx_nrllm_telemetry`), but nothing promoted it into a first-class run.

## Decision

**Persist each run and its steps in two UI-less log tables**,
`tx_nrllm_agentrun` (one summary row per run) and `tx_nrllm_agentrun_event` (one
row per recorded step, ordered by `sequence`). Both follow the telemetry
precedent ([ADR-058](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-058@0.35)): raw Doctrine access through
`AgentRunRepository`, no Extbase model and no TCA, because a run log is written
and read programmatically, never edited in FormEngine.

**Drive persistence from the existing \`RunTrace.onRecord\` hook.** A fail-soft
`AgentRunPersister` opens a run (`begin()` → RUNNING), records each `RunStep` as
an event as it is emitted (`recordStep()`), and settles the run to a terminal
state (`settleCompleted()` / `settleFailed()`). The tool loop is **not touched**:
the persister is wired through the same per-step callback the playground already
uses to stream steps, so the loop's control flow is unchanged and unaware of it.

**Type the vocabulary without inventing what the loop cannot emit.**
`AgentRunStatus` carries the full lifecycle the later epics need (`queued`,
`running`, `waiting_for_approval`, `waiting_for_input`, `completed`,
`failed`, `cancelled`), but this change only ever writes RUNNING →
COMPLETED/FAILED. `AgentEventKind` is limited to the four kinds `RunStep` actually
produces (`request`, `llm`, `tool`, `assembled`); richer kinds are added
by the epics that emit them.

**Fail-soft is a hard requirement.** Every persister method logs and swallows a
storage error; `begin()` returns null on failure, which the caller treats exactly
as a null `RunTrace` callback — "record nothing". A database hiccup can therefore
never break an otherwise-successful run. The streamed path settles the run in a
`finally` block (mirroring `StreamingDispatcher`), so a client disconnect
mid-stream cannot leave a run stuck RUNNING.

## Consequences

-   Playground runs (batch and streamed) now persist. The admin playground is the
    first consumer; the tables are the substrate the human-in-the-loop, batch/queue
    and feedback epics build on.
-   A run that exhausts its iteration cap **or** is denied by the budget guard both
    surface as COMPLETED with `truncated = true`, because `ToolLoopService`
    catches the budget denial internally and returns a normal result. Recording a
    budget denial as a distinct FAILED run is deferred to the human-in-the-loop
    epic, which reshapes the loop's exit paths.
-   Event payloads store the full `RunStep` snapshot, which includes the messages
    sent (the prompt). `AgentRunRepository::purgeOlderThan()` provides the retention
    hook; a scheduled purge command is a follow-up. Runs are admin-authored for now
    (the playground is admin-only), so the exposure is limited.
-   The persister depends on `AgentRunRepositoryInterface`, so it is unit-tested
    against a recording double; the raw SQL and schema are covered by a functional
    round-trip.
