ADR-008: Prozessarchitektur des Agenten
Warum Owlat eingehende Nachrichten mit einem sich selbst einplanenden Step-Walker plus einem Lifecycle-Koordinator verarbeitet statt mit einer sequenziellen Funktion.
- Status: Angenommen
- Datum: 2026-03-24
- Verfeinert durch: ADR-0010 (Lifecycle-Modul als alleiniger Status-Writer), ADR-0014 (Step-Modul-Schleife des Walkers; Schritt
plangestrichen)
Die ursprüngliche Fassung dieses ADR beschrieb drei benannte Prozesstypen (Receiver / Analyzer / Worker), koordiniert über eine State-Mutation. Diese Form wurde vor der Produktivsetzung durch zwei spätere interne ADRs — ADR-0010 (docs/adr/0010-inbox-processing-lifecycle-module.md) und ADR-0014 (docs/adr/0014-agent-step-module.md) — zu einem einzigen, sich selbst einplanenden Walker plus einem Lifecycle-Koordinator zusammengeführt. Diese Seite dokumentiert das Design so, wie es tatsächlich ausgeliefert wird.
Kontext
Die Agent Pipeline verarbeitet eingehende Nachrichten in fünf Schritten: Security-Scan, Kontextabruf, Klassifikation, Entwurfserstellung und Routing. Dies sind die Mitglieder der Union AgentStepKind in apps/api/convex/agent/steps/types.ts (security_scan, context_retrieval, classify, draft, route). Ein eigenständiger Schritt zur „Aktionsplanung“ existierte in einem frühen Entwurf, wurde aber mit ADR-0014 vor der Produktivsetzung gestrichen — der Klassifikator wechselte nie in ihn, und der Drafter schrieb eine plan-Zeile, deren Payload lediglich literale JSON-Konstruktion war.
Die einfachste Implementierung wäre eine einzelne Funktion, die alle fünf Schritte nacheinander ausführt. Das schafft jedoch Probleme:
- Kein partieller Retry — schlägt die Entwurfserstellung fehl (LLM-Timeout, Rate Limit), startet die gesamte Pipeline erneut beim Security-Scan und verschwendet LLM-Aufrufe.
- Keine Nebenläufigkeit — eine lang laufende Pipeline blockiert den Start der nächsten Nachricht. Eine 10 Sekunden dauernde Entwurfserstellung verzögert alle nachfolgenden Nachrichten.
- Kein Zustand pro Schritt — Security-Ergebnisse, Klassifikation und Entwurf müssen jeweils an ihrer eigenen Grenze persistiert werden, damit ein Absturz mitten in der Pipeline keine fertige Arbeit verliert.
- Keine Observability — eine monolithische Funktion liefert eine einzige Zeitmetrik, keine Aufschlüsselung pro Schritt.
Das Serverless-Modell von Convex unterstützt unabhängige Funktionsausführung von Haus aus — eine internalAction kann eingeplant werden, sich selbst neu einplanen und läuft in einem eigenen, transaktionsfreien Kontext, während Mutations atomare Schreibvorgänge bieten. Die Architektur trennt genau entlang dieser Linie: Die LLM-/Rechenarbeit liegt in Actions, und jeder Datenbankschreibvorgang, der die Zustandsmaschine antreibt, liegt in einer einzigen Mutation.
Entscheidung
Eingehende Nachrichten mit einem sich selbst einplanenden Step-Walker (einer internalAction) plus einem Lifecycle-Koordinator (einer internalMutation) verarbeiten. Der Walker führt die Rechenarbeit pro Schritt aus; der Koordinator besitzt jeden Schreibvorgang auf der Zustandsmaschine.
| Komponente | Convex-Primitiv | Quelle | Verantwortung |
|---|---|---|---|
| Receiver | internalMutation | apps/api/convex/inbox/messages.ts (receiveMessage) | Löst Kontakt + Thread auf, speichert die Nachricht mit processingStatus: 'received' und plant dann den Walker bei security_scan ein. |
| Walker | internalAction | apps/api/convex/agent/walker.ts (start, runStep) | Führt pro Aufruf genau ein Step-Modul aus und reiht sich anschließend über scheduler.runAfter(0, …runStep) für den nächsten Schritt selbst wieder ein. |
| Lifecycle-Koordinator | internalMutation | apps/api/convex/inbox/processingLifecycle.ts (transition, recordStepBegin/End/Fail) | Der alleinige Writer von inboundMessages.processingStatus, der zugehörigen agentActions-Zeilen und von conversationThreads.latestDraftStatus. Erzwingt den Graphen der zulässigen Kanten. |
Wie eine Nachricht fließt
Empfangen
internal.inbox.messages.receiveMessage (eine internalMutation, keine HTTP-Action — der eingehende MTA-Webhook landet auf dem HTTP-Router, der den Webhook-Dispatcher ausführt; dessen inbound.received-Handler ruft diese Mutation über ctx.runMutation auf) löst Kontakt und Konversations-Thread auf, fügt die inboundMessages-Zeile mit processingStatus: 'received' ein und plant den Walker ein: ctx.scheduler.runAfter(0, internal.agent.walker.start, { inboundMessageId }).
Den Walker starten
walker.start stößt die Pipeline beim ersten Schritt an: Er plant walker.runStep mit kind: 'security_scan' ein. (Der Lifecycle-Effekt schedule_pipeline_start ruft start ebenfalls auf, wenn eine Nachricht aus der Quarantäne freigegeben oder per Cron erneut versucht wird.)
Einen Schritt ausführen
walker.runStep ermittelt über stepModuleFor das Step-Modul für kind, erfasst über recordStepBegin eine agentActions-Zeile, führt module.execute(ctx, input) aus und wendet dann die reine Entscheidung module.route(output, input, runCtx) an.
Zum nächsten Schritt routen
route liefert eine von drei Formen zurück (apps/api/convex/agent/steps/types.ts):
in_state— das Schrittende erfassen und den nächsten Schritt innerhalb desselbenprocessingStatuseinplanen.transition—lifecycle.transitionaufrufen, umprocessingStatusweiterzuschalten (was dieagentActiondes Schritts atomar abschließt), und dann optional den nächsten Schritt einplanen.done— das Schrittende erfassen und anhalten.
Einen Endzustand erreichen
Der Schritt route ist der letzte. Er überführt die Nachricht entweder nach approved (wenn die automatische Antwort aktiviert ist, die Konfidenz den Schwellenwert überschreitet und das Tageslimit nicht erreicht ist) oder nach draft_ready (wartet auf menschliche Prüfung). Bei approved löst der Lifecycle seinen Effekt schedule_send_approved aus, der die Antwort über internal.agent.agentPipeline.sendApprovedReply versendet.
Der Walker trägt den input des nächsten Schritts direkt weiter — es gibt keinen gemeinsamen Akkumulator (ADR-0014, Entscheidung D1). Der Lifecycle ist das einzige Modul, das processingStatus anfasst, sodass ein Absturz zwischen dem Status-Patch und dem Schreibvorgang pro Schritt keine halb aktualisierte Zeile mehr hinterlassen kann.
Warum zwei Module statt einer großen Action
Manche Schritte laufen innerhalb eines einzelnen processingStatus statt an einer Zustandsgrenze — zum Beispiel laufen context_retrieval und classify beide, während die Nachricht im Zustand classifying ist. recordStepBegin / recordStepEnd / recordStepFail aus transition herauszulösen erlaubt es jedem Schritt, seine eigene agentAction-Zeile atomar zu markieren, ohne dass der Lifecycle wissen muss, welcher Schritt gerade läuft — und behält zugleich einen einzigen Eigentümer für processingStatus selbst.
Retry-Semantik
Retry ist über alle Schrittarten hinweg einheitlich, nicht pro Schritt differenziert. Es gibt kein exponentielles Backoff und keinen Retry-Zähler pro Typ.
- Bei jeder Ausnahme in
executeoderrouteeines Schritts überführt der Walker die Nachricht nachfailedund markiert die laufendeagentActionmiterrorMessageals fehlgeschlagen (apps/api/convex/agent/walker.ts). - Ein Cron —
retry failed agent actions, alle 5 Minuten (apps/api/convex/crons.ts) — ruftinternal.inbox.processingLifecycle.retryFailedActionsauf. Diese Mutation nimmt fehlgeschlageneagentActions, derenretryCount < maxRetriesist (wobeimaxRetries = 3für jede Schrittart gilt), setzt jede aufpendingzurück und bringt die zugehörige Nachricht zurück aufreceived, sodass der Walker die Pipeline absecurity_scanerneut durchläuft. - Sobald
retryCountden Wert 3 erreicht, bleibt die Actionfailedund die Nachricht verbleibt in ihrem Zustandfailed, damit ein Mensch sie prüfen kann.
Verzweigung bei mehreren Intents
Das ursprüngliche ADR beschrieb einen Koordinator, der parallele Worker-Prozesse startet — einen je erkanntem Intent — und deren Ergebnisse im Routing-Schritt zusammenführt. Das ist nicht implementiert. Der Walker ist streng sequenziell und einpfadig: classify erzeugt genau eine Classification, und route trifft genau eine Entscheidung (apps/api/convex/agent/steps/route/index.ts). Es gibt nirgends in apps/api/convex/agent/ eine Fork-/Parallel-Spawn-Logik. Behandeln Sie die parallele Verarbeitung mehrerer Intents als Zukunftsrichtung, nicht als ausgelieferte Fähigkeit — die Visionsseite zur Agent Pipeline skizziert, wohin es gehen könnte.
Konsequenzen
Ermöglicht:
- Unabhängiger Retry pro Schritt — ein fehlgeschlagener Schritt wird vom Cron zurückgesetzt und erneut ausgeführt; davor abgeschlossene Schritte behalten ihre persistierte Ausgabe.
- Nebenläufige Verarbeitung — der Walker jeder Nachricht plant unabhängig ein, sodass im Deployment mehrere Nachrichten gleichzeitig verarbeitet werden.
- Observability pro Schritt — jeder Schritt schreibt eine
agentActions-Zeile mitstatus,durationMs,modelUsedundtokenUsage. - Absturzsicherer Zustand — weil der Lifecycle-Koordinator der alleinige Writer von
processingStatusist und Begleitfelder in derselben Mutation patcht, kann ein Absturz keine Diskrepanz zwischen Status und Daten hinterlassen.
Abwägungen:
- Der Zustand liegt über zwei Tabellen verteilt — die Zustandsmaschine auf Nachrichtenebene ist
inboundMessages.processingStatus(sieheapps/api/convex/schema/inbox.ts), das Tracking pro Schritt liegt inagentActions-Zeilen (status,retryCount,durationMs,modelUsed,tokenUsage). Es gibt kein einzelnesstepTimings-Blob — Zeiten pro Schritt liest man, indem man dieagentActionseiner Nachricht zusammenführt. - Die Koordinatorlogik erhöht die Komplexität gegenüber einer einfachen sequenziellen Funktion — der Graph der zulässigen Kanten und der Effekt-Reducer müssen sorgfältig getestet werden, damit keine Nachrichten hängen bleiben.
- Geringfügig höhere Latenz als bei einem einzigen Funktionsaufruf, bedingt durch den Scheduling-Overhead zwischen den Schritten (jedes
runStepreiht das nächste neu ein).