Contact

Features

Agent Runs & Human Approval

One lifecycle for every agent run, synchronous or on a queue worker, and a human approval gate in front of every builtin tool call that writes to the installation.

Why it exists

An agent run is a loop: the model asks for tools, the tools answer, the model asks again. Starting it, persisting each step, pausing it, resuming it and settling it was first assembled inside the tool playground controller. AgentRuntimeInterface owns that lifecycle once, so a playground, a CLI command, a queue worker and an editor action all get the same fail-closed rules (ADR-101).

Some runs take longer than a web request should. They go onto the TYPO3 message bus and run on a worker. Workers die, so a queued run carries a lease that the worker renews at every step. A stale-run reaper puts abandoned runs back on the queue or dead-letters them (ADR-102, ADR-104).

A tool that changes the installation must not run because a model asked for it. A builtin that declares a write effect therefore suspends the run until a person approves the pending turn (ADR-134). The approval is bound to the turn the person reviewed and to the before-state the preview showed them (ADR-132, ADR-184).

Approval is an authority of its own. The approver must be allowed to run the write they release, a configuration can demand a second person, and the Approval column of the run list and the run timeline show when someone approved their own run (ADR-133, ADR-172, ADR-173).

When to use it

  • You start agent runs from your own extension and want persistence, approval and cancellation handled for you: inject AgentRuntimeInterface and call run() or enqueue().
  • Runs take minutes and should not block a web request: route AgentRunQueuedMessage to the doctrine transport and run messenger:consume.
  • Your agent may create, update, move or delete records: the writing builtins suspend the run. Administrators decide in AI > Operation > Agent Runs; the run's initiator and holders of the agent_approve grant decide in AI > AI Tasks.
  • Editorial changes need a second person: set require_second_approver on the configuration, and the person who started a run can no longer approve it.
  • A tool needs data from the user before it can run: implement RequiresInputInterface, and the run waits as WAITING_FOR_INPUT for a form submission validated against your schema.
  • You operate workers: schedule nrllm:agent:reap and monitor nrllm:agent:status.

How it works

AgentRuntimeInterface is the public surface: run() executes synchronously, enqueue() persists a QUEUED row with the serialised request and dispatches an AgentRunQueuedMessage, and approve(), submitInput() and cancel() act on a run by uuid. Neither run() nor approve() throws for a run outcome; the AgentRunResult is already settled and says COMPLETED, AWAITING_APPROVAL, AWAITING_INPUT, FAILED and so on. AgentRunQueuedHandler calls runQueued(), which claims the row atomically, so exactly one worker wins. The run executes under the identity of the person who started it, also on a worker.

A worker holds a lease of AgentRuntime::LEASE_SECONDS (900) and renews it at every step boundary. Before a writing tool executes, the executor stamps the tool's effect on the run row. A run found mid NON_IDEMPOTENT_WRITE is dead-lettered, never retried. Other retryable failures are requeued with exponential backoff, up to AgentRuntime::MAX_REQUEUES. Cancellation is cooperative: the executor reads the row at each step boundary and stops when it is CANCELLED. An MCP call over HTTP can also be aborted mid-transfer through AgentRunCancellationSignal when the nr-vault client supports it.

ToolApprovalRule decides whether a call needs a human: the RequiresApprovalInterface marker, or a declared write effect from ToolEffectInterface. MCP tools are judged on the operator's declaration on the server row instead. At suspension the loop stores each pending call's ToolPreviewInterface lines with the state. ResumeCoordinator::approve() then claims the run and checks three gates before anything executes: the decision's turn digest must match the pending calls, the approver must be permitted to run every pending write, and for a turn that declares a write, the APPROVAL event must be stored. A refused gate puts the run back into WAITING_FOR_APPROVAL.

Before an approved call runs, ApprovalPreviewComparator re-runs its preview. If the lines differ from what the approver saw, nothing is written and the run suspends again with the current preview. A writing tool returns ToolResult::withWriteTarget(), which names the record, the WriteKind (CREATED, UPDATED, DELETED) and whether the write was complete. The executor records that as a tool_write step, and a write whose audit step cannot be stored fails the run. Writers run through ToolDataHandler, so a hook that throws after all records are written does not turn a finished write into a failure.

Netresearch\NrLlm\Service\Agent\AgentRuntimeInterface

Public run lifecycle: run(), enqueue(), runQueued(), approve(), submitInput(), cancel(), status() and events(). Returns settled AgentRunResult values instead of throwing for outcomes.

Netresearch\NrLlm\Service\Agent\Queue\AgentRunQueuedHandler

#[AsMessageHandler] for AgentRunQueuedMessage. Calls runQueued(), which claims the QUEUED row atomically and rehydrates the stored request.

Netresearch\NrLlm\Command\ReapStaleAgentRunsCommand

nrllm:agent:reap. Finds RUNNING runs with an expired lease, requeues those with a stored request and budget left, and dead-letters the rest.

Netresearch\NrLlm\Service\Agent\ResumeCoordinator

Decides a waiting run: access and four-eyes checks before the claim, then turn digest, approver permission and audit gates before execution under the owner's identity.

Netresearch\NrLlm\Service\Tool\ToolApprovalRule

The single rule for when a call needs approval: the RequiresApprovalInterface marker or a declared write effect; remote tools by their server-row declaration.

Netresearch\NrLlm\Service\Tool\ToolPreviewInterface

Opt-in for tools: previewCall() describes what a call would do, mayViewerReadPreview() decides whether the person looking at the card may see it. Previews must be deterministic.

Netresearch\NrLlm\Service\Tool\ApprovalPreviewComparator

Re-runs the previews of an approved turn at resume. A changed preview returns a re-suspended state with the current lines instead of executing.

Netresearch\NrLlm\Domain\ValueObject\ToolResult

A tool's answer. withWriteTarget() names the written record (RecordReference), its WriteKind and its WriteCompleteness for the run's audit stream.

For developers

Start runs through the runtime, decide whether they run in-process or on a worker, carry the turn digest with every decision, and let writing tools declare their effect.

Start a run

Build an AgentRunRequest with the configuration, the first message and the actor who starts the run, then call run(). The result is settled. Match on its outcome, and keep a default arm: new AgentRunOutcome cases may be added in minor releases. A non-null maxIterations is clamped to AgentRuntime::MAX_ITERATIONS (20).

php
use Netresearch\NrLlm\Domain\Enum\AgentRunOutcome;
use Netresearch\NrLlm\Domain\Model\LlmConfiguration;
use Netresearch\NrLlm\Domain\ValueObject\AiActorContext;
use Netresearch\NrLlm\Domain\ValueObject\ChatMessage;
use Netresearch\NrLlm\Service\Agent\AgentRunRequest;
use Netresearch\NrLlm\Service\Agent\AgentRuntimeInterface;

final class PageReviewer
{
    public function __construct(
        private readonly AgentRuntimeInterface $runtime,
    ) {}

    public function review(LlmConfiguration $configuration, int $beUserUid, bool $isAdmin): string
    {
        $request = new AgentRunRequest(
            configuration: $configuration,
            messages: [ChatMessage::user('Check the meta description of page 42.')],
            actor: AiActorContext::backendUser($beUserUid, $isAdmin),
            maxIterations: 10,
        );

        $result = $this->runtime->run($request);

        return match ($result->outcome) {
            AgentRunOutcome::COMPLETED => $result->loopResult?->finalContent ?? '',
            AgentRunOutcome::AWAITING_APPROVAL,
            AgentRunOutcome::AWAITING_INPUT => 'Waiting in the Agent Runs inbox: ' . $result->runUuid,
            default => 'Run ended: ' . $result->outcome->value,
        };
    }
}

Run queued work on a worker

enqueue() stores a QUEUED row and dispatches AgentRunQueuedMessage. On TYPO3's default synchronous transport the run executes before enqueue() returns. Route the message to doctrine and run vendor/bin/typo3 messenger:consume doctrine to execute it in a separate process. Schedule vendor/bin/typo3 nrllm:agent:reap (default --limit 50) to reclaim runs of dead workers, and use nrllm:agent:status --json for health checks.

php
// settings.php / additional.php
$GLOBALS['TYPO3_CONF_VARS']['SYS']['messenger']['routing']
    [\Netresearch\NrLlm\Service\Agent\Queue\AgentRunQueuedMessage::class] = 'doctrine';

// Caller code: returns the run uuid for status() / events() polling.
$runUuid = $runtime->enqueue($request);

Decide a waiting run

A decision covers the whole pending turn and carries the turn digest the reviewer was shown. The backend inbox does it like this. A null or outdated digest is refused with StaleApprovalTurnException. ApproverNotPermittedException and SelfApprovalDeniedException leave the run waiting, so someone else can still decide it.

php
try {
    $result = $this->agentRuntime->approve(
        $this->currentActor(),
        $runUuid,
        new ApprovalDecision($approve, $this->currentBackendUserUid(), $turnDigest),
    );
} catch (StaleApprovalTurnException) {
    // The run was released, not consumed — re-review and decide again.
    return $this->flashRedirect('runs.error.staleReview', ContextualFeedbackSeverity::WARNING);
} catch (ApproverNotPermittedException) {
    // ADR-133: the run was released, not consumed — someone who may run
    // the pending write can still decide it.
    return $this->flashRedirect('runs.error.approverNotPermitted', ContextualFeedbackSeverity::ERROR);
} catch (SelfApprovalDeniedException) {
    // ADR-172: the run was released, not consumed — a colleague can
    // still decide it, and the initiator can still deny it.
    return $this->flashRedirect('runs.error.selfApproval', ContextualFeedbackSeverity::WARNING);
}

Declare and record a write

A writing tool implements ToolEffectInterface and returns a write effect, which makes approval mandatory. It implements ToolPreviewInterface so the card shows the change, and it names the record it wrote with withWriteTarget(). Excerpt from AttachFileToContentElementTool, which also returns false from isEnabledByDefault().

php
public function getEffect(): ToolEffect
{
    // Calling twice attaches the file twice. That is a legitimate outcome —
    // two references to one image are valid — so it is declared rather than
    // deduplicated behind the caller's back.
    return ToolEffect::NON_IDEMPOTENT_WRITE;
}

// at the end of execute():
return ToolResult::text(sprintf('Attached file [%d] "%s" to %s [%d] "%s" as %s reference %d of %d.', /* ... */))
    ->withWriteTarget(new RecordReference(self::REFERENCE_TABLE, $newUid), WriteKind::CREATED, WriteCompleteness::COMPLETE);

Gotchas

The doctrine route needs a consumer

Once AgentRunQueuedMessage is routed to doctrine, nothing executes a queued run until messenger:consume runs. The reaper does not help: it only requeues, and a requeued run also waits for a consumer. With no route configured, enqueue() executes the run in-process before it returns.

A decision on an outdated view is refused

approve() and submitInput() compare the decision's turn digest with the state they claimed. A null digest is refused like a wrong one. If a colleague decided first, the run usually no longer waits and you get RunNotAwaitingApprovalException. If it has meanwhile paused on a different turn, you get StaleApprovalTurnException, or StaleInputTurnException for an input form. In both stale cases the run goes back to waiting: reload the inbox and decide again.

A changed record sends the approval back

If a field the preview displayed changed between suspend and approval, nothing is written and the run waits again with the current preview. On a busy page this can repeat. A preview that contains a clock, a random value or an unstable order makes every approval of that tool stale, so previewCall() must be a pure function of the record and the arguments.

An interrupted write is not retried

A run found mid NON_IDEMPOTENT_WRITE is dead-lettered, by the reaper and by the queued failure path, whatever the retry budget says: the side effect may already have landed. A stale run without a stored request is dead-lettered too, because there is nothing to re-execute.

Who can release a write

An approver must be allowed to run every pending write themselves. A service account can never release a turn that declares a write, and a backend user who is disabled is refused. With require_second_approver set, the initiator's approval is refused, but the initiator can still deny.