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 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.
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 byAgentRunOutcome(COMPLETED / AWAITING_APPROVAL / SUSPEND_FAILED / GUARDRAIL_BLOCKED / GUARDRAIL_APPROVAL_REQUIRED / FAILED).approve()throws typedAgentRuntimeExceptions only for an invalid request, before any execution: not-awaiting-approval, configuration gone, corrupt state, event position unavailable, claim lost.onStepis 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, sorun()is synchronous begin-and-execute;AgentRunRequestis 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'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 explicitmaxIterationsis 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.
suspendRunis 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::APPROVALevent (payload{approved, decidedBy}, best-effort like every event write).fromRunStepKind()deliberately does not resolve it — it is not aRunStepkind; stored kinds hydrate viatryFrom(). No free-text note: the event stream is privacy-filtered (ADR-064) 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
AgentRuntimeInterfaceis a public DI alias (Category B), raising the audited public-service count from 34 to 35. 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 andAgentRunOutcomecases may be added in minor releases (the queue epic will), so exhaustive matches need a default arm.ToolLoopServiceInterfacestays public for consumers that want the bare loop; the runtime is the preferred surface.nrllm:agent:cancelgoes through the runtime; the playground controller lost itsToolLoopService/AgentRunPersisterdependencies.- Breaking:
AgentRunPersister::resumeHandle()now returns?AgentRunHandle(null = refuse the resume), andAgentRunRepositoryInterfacegainedmaxEventSequence(). - 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.