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), human-in-the-loop approval
(ADR-084) and termination semantics (ADR-092)
work, but they were assembled inside Tool: it began the
run, built the trace, persisted events, caught the suspension, settled the
status and executed the resume. The lifecycle ladder — run → 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
Agent (Classes/) is the public
application service for agent runs; Agent implements it and owns the
ladder exactly once. The playground controller is now a UI adapter: it parses
the request into an Agent / Approval and maps the
returned Agent onto its JSON / NDJSON shapes, which are unchanged.
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/() approvenever throw for a run outcome — the result is already settled, discriminated by() Agent(COMPLETED / AWAITING_APPROVAL / SUSPEND_FAILED / GUARDRAIL_BLOCKED / GUARDRAIL_APPROVAL_REQUIRED / FAILED).Run Outcome approvethrows typed() Agents only for an invalid request, before any execution: not-awaiting-approval, configuration gone, corrupt state, event position unavailable, claim lost.Runtime Exception onis 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.Step - The roadmap sketched
start+(request): handle run. 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(handle) runis synchronous begin-and-execute;() Agentis a plain value object so the queue epic can serialise it and add asynchronous execution without changing these signatures.Run Request
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_— an unresumable promise. The unified ladder applies ADR-092's fail-closed rule everywhere; the distinct SUSPEND_FAILED outcome makes it visible.approval - Iteration ceiling. The per-run round cap (20) is a runtime invariant
(
Agent), no longer a controller constant. Only an explicitRuntime:: MAX_ ITERATIONS maxis clamped; null keeps the loop's own lower default.Iterations - Deterministic event sequence. A resume continues the stream at
MAX(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.)(sequence) + 1 - Cancel is a real fence.
suspendis 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.Run - 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
approvecould 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
Agentevent (payloadEvent Kind:: APPROVAL {approved, decided, best-effort like every event write).By} fromdeliberately does not resolve it — it is not aRun Step Kind () Runkind; stored kinds hydrate viaStep try. No free-text note: the event stream is privacy-filtered (ADR-064) and prose would bypass that.From () - Privacy-safe status.
statusstrips 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
Agentis a public DI alias (Category B), raising the audited public-service count from 34 to 35 (later raised to 36 by ADR-106, which makesRuntime Interface Guardrailpublic for its TCA itemsProcFunc, to 37 by ADR-201, whoseRegistry Unavailablea consumer looks up in the container by name to feature-detect it — Category B, and to 38 by ADR-206, whoseTools Resolver Interface Toolevery writing tool creates throughData Handler make— Category E). 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 andInstance () Agentcases may be added in minor releases (the queue epic will), so exhaustive matches need a default arm.Run Outcome Toolstays public for consumers that want the bare loop; the runtime is the preferred surface.Loop Service Interface nrllm:goes through the runtime; the playground controller lost itsagent: cancel Tool/Loop Service Agentdependencies.Run Persister - Breaking:
Agentnow returnsRun Persister:: resume Handle () ?Agent(null = refuse the resume), andRun Handle AgentgainedRun Repository Interface max.Event Sequence () - Now possible without touching the playground: scheduler and messenger
workers, consumer extensions, batch runs, review queues, editor actions and
status polling (
eventspages by sequence) — the run lifecycle they get is the tested one.()