Kontakt

Features

Agent-Läufe und menschliche Freigabe

Ein Lebenszyklus für jeden Agent-Lauf, ob synchron oder auf einem Queue-Worker, und eine menschliche Freigabe vor jedem Aufruf eines Builtin-Tools, das in die Installation schreibt.

Warum es existiert

Ein Agent-Lauf ist eine Schleife: Das Modell fordert Tools an, die Tools antworten, das Modell fragt erneut. Starten, jeden Schritt speichern, pausieren, fortsetzen und abschließen – das war zuerst im Controller des Tool-Playgrounds zusammengebaut. AgentRuntimeInterface bündelt diesen Lebenszyklus an einer Stelle. Playground, CLI-Befehl, Queue-Worker und Editor-Aktion folgen damit denselben Fail-closed-Regeln (ADR-101).

Manche Läufe dauern länger, als ein Web-Request dauern sollte. Sie gehen auf den TYPO3-Message-Bus und laufen auf einem Worker. Worker können ausfallen. Ein Lauf aus der Queue trägt deshalb eine Lease, die der Worker bei jedem Schritt erneuert. Ein Reaper für verwaiste Läufe stellt sie zurück in die Queue oder legt sie als Dead Letter ab (ADR-102, ADR-104).

Ein Tool, das die Installation verändert, darf nicht laufen, nur weil ein Modell es angefordert hat. Ein Builtin mit deklarierter Schreibwirkung hält den Lauf deshalb an, bis ein Mensch den offenen Turn freigibt (ADR-134). Die Freigabe ist an den Turn gebunden, den die Person geprüft hat, und an den Vorher-Zustand, den ihr die Vorschau gezeigt hat (ADR-132, ADR-184).

Freigeben ist eine eigene Berechtigung. Wer freigibt, muss den Schreibvorgang selbst ausführen dürfen. Eine Konfiguration kann eine zweite Person verlangen. Und die Spalte „Freigabe“ in der Liste der Läufe sowie die Timeline zeigen an, wenn jemand den eigenen Lauf freigegeben hat (ADR-133, ADR-172, ADR-173).

Wann man es einsetzt

  • Eine Extension startet Agent-Läufe und soll Persistenz, Freigabe und Abbruch nicht selbst bauen: AgentRuntimeInterface injizieren und run() oder enqueue() aufrufen.
  • Läufe dauern Minuten und sollen keinen Web-Request aufhalten: AgentRunQueuedMessage auf den Transport doctrine routen und messenger:consume ausführen.
  • Der Agent darf Datensätze anlegen, ändern, verschieben oder löschen: Die schreibenden Builtins halten den Lauf an. Administratoren entscheiden im Modul „Agent-Läufe“; wer den Lauf gestartet hat oder die Berechtigung agent_approve besitzt, entscheidet im Modul „KI-Aufgaben“.
  • Redaktionelle Änderungen brauchen eine zweite Person: require_second_approver an der Konfiguration setzen. Wer einen Lauf gestartet hat, kann ihn dann nicht mehr selbst freigeben.
  • Ein Tool braucht vor dem Lauf Daten vom Benutzer: RequiresInputInterface implementieren. Der Lauf wartet dann als WAITING_FOR_INPUT auf ein Formular, das gegen das deklarierte Schema geprüft wird.
  • Sie betreiben Worker: nrllm:agent:reap einplanen und nrllm:agent:status überwachen.

Wie es funktioniert

AgentRuntimeInterface ist die öffentliche Schnittstelle. run() führt synchron aus. enqueue() speichert eine QUEUED-Zeile mit dem serialisierten Request und verschickt eine AgentRunQueuedMessage. approve(), submitInput() und cancel() wirken auf einen Lauf anhand seiner UUID. Weder run() noch approve() werfen eine Exception für das Ergebnis eines Laufs: Das AgentRunResult ist bereits abgeschlossen und meldet COMPLETED, AWAITING_APPROVAL, AWAITING_INPUT, FAILED und weitere Fälle. AgentRunQueuedHandler ruft runQueued() auf. Diese Methode übernimmt die Zeile atomar, sodass genau ein Worker den Lauf bekommt. Der Lauf wird unter der Identität der Person ausgeführt, die ihn gestartet hat – auch auf einem Worker.

Ein Worker hält eine Lease von AgentRuntime::LEASE_SECONDS (900) und erneuert sie an jeder Schrittgrenze. Bevor ein schreibendes Tool läuft, vermerkt der Executor dessen Wirkung an der Zeile des Laufs. Ein Lauf, der mitten in einem NON_IDEMPOTENT_WRITE gefunden wird, landet als Dead Letter und wird nie wiederholt. Andere wiederholbare Fehler gehen mit exponentiellem Backoff zurück in die Queue, höchstens AgentRuntime::MAX_REQUEUES-mal. Der Abbruch ist kooperativ: Der Executor liest die Zeile an jeder Schrittgrenze und hält an, sobald sie CANCELLED ist. Ein MCP-Aufruf über HTTP lässt sich zusätzlich mitten in der Übertragung über AgentRunCancellationSignal abbrechen, wenn der nr-vault-Client das unterstützt.

ToolApprovalRule entscheidet, ob ein Aufruf einen Menschen braucht: der Marker RequiresApprovalInterface oder eine deklarierte Schreibwirkung aus ToolEffectInterface. MCP-Tools werden stattdessen nach der Angabe des Betreibers am Server-Datensatz beurteilt. Beim Anhalten speichert die Schleife die Zeilen aus ToolPreviewInterface für jeden offenen Aufruf mit dem Zustand. ResumeCoordinator::approve() übernimmt dann den Lauf und prüft drei Gates, bevor etwas ausgeführt wird: Der Turn-Digest der Entscheidung muss zu den offenen Aufrufen passen. Wer freigibt, muss jeden offenen Schreibvorgang ausführen dürfen. Und bei einem Turn, der einen Schreibvorgang deklariert, muss das APPROVAL-Ereignis gespeichert sein. Schlägt ein Gate fehl, steht der Lauf wieder auf WAITING_FOR_APPROVAL.

Bevor ein freigegebener Aufruf läuft, erzeugt ApprovalPreviewComparator seine Vorschau neu. Weichen die Zeilen von dem ab, was die freigebende Person gesehen hat, wird nichts geschrieben, und der Lauf hält erneut an – mit der aktuellen Vorschau. Ein schreibendes Tool gibt ToolResult::withWriteTarget() zurück. Das benennt den Datensatz, die WriteKind (CREATED, UPDATED, DELETED) und ob der Schreibvorgang vollständig war. Der Executor speichert das als Schritt tool_write. Lässt sich dieser Audit-Schritt nicht speichern, schlägt der Lauf fehl. Die Writer schreiben über ToolDataHandler: Ein Hook, der erst nach dem Schreiben aller Datensätze eine Exception wirft, macht aus einem fertigen Schreibvorgang keinen Fehler.

Netresearch\NrLlm\Service\Agent\AgentRuntimeInterface

Öffentlicher Lebenszyklus eines Laufs: run(), enqueue(), runQueued(), approve(), submitInput(), cancel(), status() und events(). Liefert abgeschlossene AgentRunResult-Werte, statt für Ergebnisse eine Exception zu werfen.

Netresearch\NrLlm\Service\Agent\Queue\AgentRunQueuedHandler

#[AsMessageHandler] für AgentRunQueuedMessage. Ruft runQueued() auf; die Methode übernimmt die QUEUED-Zeile atomar und stellt den gespeicherten Request wieder her.

Netresearch\NrLlm\Command\ReapStaleAgentRunsCommand

nrllm:agent:reap. Findet Läufe im Status RUNNING mit abgelaufener Lease, stellt solche mit gespeichertem Request und verbleibendem Budget zurück in die Queue und legt den Rest als Dead Letter ab.

Netresearch\NrLlm\Service\Agent\ResumeCoordinator

Entscheidet über einen wartenden Lauf: Zugriffs- und Vier-Augen-Prüfung vor der Übernahme, danach Turn-Digest, Berechtigung der freigebenden Person und Audit-Gate vor der Ausführung unter der Identität des Besitzers.

Netresearch\NrLlm\Service\Tool\ToolApprovalRule

Die eine Regel, wann ein Aufruf eine Freigabe braucht: Marker RequiresApprovalInterface oder deklarierte Schreibwirkung; Remote-Tools nach der Angabe am Server-Datensatz.

Netresearch\NrLlm\Service\Tool\ToolPreviewInterface

Opt-in für Tools: previewCall() beschreibt, was ein Aufruf tun würde; mayViewerReadPreview() entscheidet, ob die Person vor der Karte die Vorschau sehen darf. Vorschauen müssen deterministisch sein.

Netresearch\NrLlm\Service\Tool\ApprovalPreviewComparator

Erzeugt beim Fortsetzen die Vorschauen eines freigegebenen Turns neu. Bei einer geänderten Vorschau liefert sie einen erneut angehaltenen Zustand mit den aktuellen Zeilen, statt auszuführen.

Netresearch\NrLlm\Domain\ValueObject\ToolResult

Die Antwort eines Tools. withWriteTarget() nennt für den Audit-Stream des Laufs den geschriebenen Datensatz (RecordReference), seine WriteKind und seine WriteCompleteness.

Für Entwickler

Läufe über die Runtime starten, entscheiden, ob sie im Prozess oder auf einem Worker laufen, bei jeder Entscheidung den Turn-Digest mitgeben und schreibende Tools ihre Wirkung deklarieren lassen.

Einen Lauf starten

Ein AgentRunRequest mit der Konfiguration, der ersten Nachricht und dem startenden Akteur bauen, dann run() aufrufen. Das Ergebnis ist bereits abgeschlossen. Sein outcome mit match auswerten und einen default-Zweig behalten: Neue Fälle von AgentRunOutcome können in Minor-Releases hinzukommen. Ein gesetztes maxIterations wird auf AgentRuntime::MAX_ITERATIONS (20) begrenzt.

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,
        };
    }
}

Queue-Arbeit auf einem Worker ausführen

enqueue() speichert eine QUEUED-Zeile und verschickt AgentRunQueuedMessage. Auf dem synchronen Standard-Transport von TYPO3 läuft der Lauf, bevor enqueue() zurückkehrt. Für einen eigenen Prozess die Nachricht auf doctrine routen und vendor/bin/typo3 messenger:consume doctrine ausführen. vendor/bin/typo3 nrllm:agent:reap (Standard --limit 50) einplanen, damit Läufe ausgefallener Worker zurückgeholt werden; für Health-Checks nrllm:agent:status --json verwenden.

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);

Über einen wartenden Lauf entscheiden

Eine Entscheidung gilt für den ganzen offenen Turn und trägt den Turn-Digest, der der prüfenden Person angezeigt wurde. Der Backend-Posteingang macht es so. Ein fehlender oder veralteter Digest wird mit StaleApprovalTurnException zurückgewiesen. Bei ApproverNotPermittedException und SelfApprovalDeniedException wartet der Lauf weiter, und jemand anderes kann noch entscheiden.

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);
}

Einen Schreibvorgang deklarieren und festhalten

Ein schreibendes Tool implementiert ToolEffectInterface und gibt eine Schreibwirkung zurück; damit ist die Freigabe Pflicht. Es implementiert ToolPreviewInterface, damit die Karte die Änderung zeigt, und nennt mit withWriteTarget() den geschriebenen Datensatz. Auszug aus AttachFileToContentElementTool, das außerdem false aus isEnabledByDefault() zurückgibt.

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);

Fallstricke

Die doctrine-Route braucht einen Consumer

Sobald AgentRunQueuedMessage auf doctrine geroutet ist, führt niemand einen Lauf aus der Queue aus, bis messenger:consume läuft. Der Reaper hilft nicht: Er stellt nur zurück in die Queue, und auch ein zurückgestellter Lauf wartet auf einen Consumer. Ohne Routing führt enqueue() den Lauf im Prozess aus, bevor die Methode zurückkehrt.

Eine Entscheidung auf veraltetem Stand wird zurückgewiesen

approve() und submitInput() vergleichen den Turn-Digest der Entscheidung mit dem übernommenen Zustand. Ein fehlender Digest wird wie ein falscher zurückgewiesen. Hat eine Kollegin zuerst entschieden, wartet der Lauf meist nicht mehr, und es kommt RunNotAwaitingApprovalException. Hat er inzwischen bei einem anderen Turn angehalten, kommt StaleApprovalTurnException, bei einem Eingabeformular StaleInputTurnException. In beiden Fällen eines veralteten Stands wartet der Lauf wieder: den Posteingang neu laden und erneut entscheiden.

Ein geänderter Datensatz schickt die Freigabe zurück

Hat sich ein Feld, das die Vorschau angezeigt hat, zwischen Anhalten und Freigabe geändert, wird nichts geschrieben, und der Lauf wartet erneut mit der aktuellen Vorschau. Auf einer viel bearbeiteten Seite kann das mehrmals passieren. Enthält eine Vorschau eine Uhrzeit, einen Zufallswert oder eine instabile Reihenfolge, veraltet jede Freigabe dieses Tools. previewCall() muss deshalb eine reine Funktion des Datensatzes und der Argumente sein.

Ein unterbrochener Schreibvorgang wird nicht wiederholt

Ein Lauf, der mitten in einem NON_IDEMPOTENT_WRITE gefunden wird, landet als Dead Letter – beim Reaper wie im Fehlerpfad der Queue, unabhängig vom Retry-Budget: Die Wirkung kann schon eingetreten sein. Auch ein verwaister Lauf ohne gespeicherten Request wird als Dead Letter abgelegt, weil es nichts erneut auszuführen gibt.

Wer einen Schreibvorgang freigeben darf

Wer freigibt, muss jeden offenen Schreibvorgang selbst ausführen dürfen. Ein Service-Account kann einen Turn mit deklariertem Schreibvorgang nie freigeben, und ein deaktivierter Backend-Benutzer wird zurückgewiesen. Ist require_second_approver gesetzt, nimmt das System keine Freigabe durch den Initiator an. Über „Ablehnen“ kann der Initiator weiterhin ablehnen.