CI beibringen, Hermes zu lesen: der stream-json-Event-Stream statt Log-Scraping


Du hast einen Hermes-Task in die CI gehängt: Er liest das Repository, führt die Checks aus, schreibt eine Zusammenfassung. Der erste Lauf lief gut, also hast du ihn ausgeliefert. Der zweite Lauf blieb mitten im Turn hängen und beendete sich — und deine Pipeline zeigte grün, weil der Wrapper nur auf Exit-Code 0 schaute und „mittendrin gestoppt“ ebenfalls 0 ist. Danach hast du dich durch die Logs gegraben, um herauszufinden, welche Tools aufgerufen wurden und wie viele Token verbrannt wurden, und musstest feststellen, dass die Ausgabe für einen Menschen zum Lesen geschrieben war, nicht für ein Programm zum Parsen.

Die Ursache: Die Ausgabe von One-Shot-Läufen war immer ein Terminal-UI, nie ein Interface. hermes chat hat jetzt ein Flag, das sie in einen Event-Stream verwandelt.

Ein Flag: Ausgabe, die für ein Programm geschrieben ist

hermes chat -q "Summarize this repository" --format stream-json

Jede stdout-Zeile ist ein JSON-Objekt (JSONL), während Diagnostik und die session_id:-Zeile auf stderr bleiben — stdout geht also direkt in einen Parser. Die harten Randbedingungen vorab:

  • Es erfordert -q / --query (oder --query-file) — dieses Format existiert für One-Shot-Läufe.
  • Es impliziert den Quiet-Modus: kein Banner, kein Spinner, keine Tool-Previews, denn „hübsch“ ist nicht mehr das Ziel.
  • Es lehnt --tui ab (beides explizit anzufordern endet mit Exit 2).
  • Jedes Event trägt timestamp in Unix-Epoch-Millisekunden.

Das Design ist von muse exec --json aus Muse Code inspiriert: einen unbeaufsichtigten Agent-Lauf in einen Vertrag verwandeln, den ein Tool konsumieren kann.

Fünf Event-Typen, eine Tabelle

Event-type Wichtige Felder
system subtype: "init", model, session_id
text text — ein gestreamtes Assistant-Text-Delta
tool_use name, plus input, wenn die Argumente verfügbar sind
tool_result name, output (auf 5000 Zeichen begrenzt), duration_ms, is_error
result session_id, exit_code, text, tokens (input / output / total / cache_read / cache_write), duration_ms und error, wenn der Turn fehlgeschlagen ist

Sobald ein Gespräch begonnen hat, ist sein abschließender Record immer result — auch wenn du mit Strg-C unterbrichst, wo er mit exit_code: 130 ankommt. Behandle diesen Record also als das Abschlusssignal statt als „der Prozess ist beendet“: Der Exit-Code des Prozesses stimmt mit seinem exit_code überein.

Der Exit-Code ist das Abschlusssignal

Ein One-Shot-Lauf (-Q, chat --oneshot, oder eine Query mit Nicht-TTY-stdio) meldet jetzt das Ergebnis des Turns in seinem Exit-Code, identisch auf dem Quiet- und dem Nicht-Quiet-Pfad:

  • 0 — der Turn wurde abgeschlossen;
  • 1 — er ist fehlgeschlagen, mitten im Turn gestoppt (partial), ans Iterationsbudget gestoßen oder lief überhaupt nie (Credentials / Agent-Init fehlgeschlagen);
  • 130 — er wurde unterbrochen;
  • 75 (EX_TEMPFAIL) — ein vom Kanban-Dispatcher gestarteter Worker ist nur deshalb fehlgeschlagen, weil der Provider rate-limited war, überlastet war, 5xx zurückgab, ein Timeout hatte oder das Konto an eine Abrechnungs-/Quota-Wand gestoßen ist; der Dispatcher stellt die Aufgabe dann erneut in die Warteschlange, statt einen Fehler zu zählen.

Der letzte Punkt verdient einen eigenen Satz: Wenn du mit Hermes unbeaufsichtigte Task-Queues betreibst, behandle 75 und 1 unterschiedlich, sonst markierst du Arbeit, die eigentlich „später erneut versuchen“ bedeutet, dauerhaft als fehlgeschlagen.

Konsum in der CI

Die günstigste Verwendung ist, das finale Envelope mit jq herauszuziehen:

hermes chat -q "Run the repository health check and report failures" --format stream-json > run.jsonl
jq -r 'select(.type=="result") | [.exit_code, .tokens.total, .duration_ms] | @tsv' run.jsonl

Um zu sehen, welche Tools gelaufen sind und wie lange jedes gedauert hat:

jq -r 'select(.type=="tool_result") | [.name, .duration_ms, .is_error] | @tsv' run.jsonl

Und um die finale Antwort des Assistenten nachgelagert weiterzugeben:

jq -r 'select(.type=="result") | .text' run.jsonl

In GitHub Actions ist die übliche Form, den Schritt so zu verpacken, dass Fehler sichtbar werden:

- name: Hermes repository check
  run: |
    hermes chat -q "Review this repo and write findings to REPORT.md" \
      --format stream-json > hermes-run.jsonl
    jq -r 'select(.type=="result") | .exit_code' hermes-run.jsonl | grep -qx 0

Wenn dein Konsument Python ist, lies es Zeile für Zeile — JSONL bedeutet, dass ein Fehler mitten im Stream dich nie die Events kostet, die du bereits erfasst hast:

import json, subprocess

p = subprocess.run(
    ["hermes", "chat", "-q", "Summarize the diff", "--format", "stream-json"],
    capture_output=True, text=True,
)
final = None
for line in p.stdout.splitlines():
    ev = json.loads(line)
    if ev["type"] == "result":
        final = ev
print(final["exit_code"], final["tokens"]["total"])

Die Flags, die CI-Läufe reproduzierbar machen

Zwei Isolations-Schalter passen am besten zu diesem Format:

  • --ignore-user-config — ignoriert ~/.hermes/config.yaml und verwendet die eingebauten Defaults (Credentials in .env werden weiterhin geladen). Gebaut für isolierte CI-Läufe, reproduzierbare Bug-Reports und Drittanbieter-Integrationen.
  • --ignore-rules — überspringt die automatische Injektion von AGENTS.md, SOUL.md, .cursorrules, persistentem Memory und vorab geladenen Skills. In Kombination mit dem Flag oben bekommst du einen vollständig isolierten Lauf.

Zwei weitere, die den Turn formen, ohne das Event-Format anzutasten: --pass-session-id legt die Session-ID in den System-Prompt, und --checkpoints nimmt Dateisystem-Checkpoints vor destruktiven Dateiänderungen. --worktree lässt das Ganze in einem isolierten Git-Worktree laufen, was die richtige Form für „dieses Repo prüfen und einen PR öffnen“ ist.

Vier Details, über die du stolpern wirst

  1. tool_result.output ist auf 5000 Zeichen begrenzt. Für die vollständige Tool-Ausgabe lies den Session-Record (hermes sessions) oder lass den Task eine Datei schreiben — erwarte nicht, dass der Event-Stream alles transportiert.
  2. text ist ein Delta; result.text ist die finale Antwort. Akkumuliere text-Events, um dem Stream zu folgen, lies result.text für „was dieser Turn geantwortet hat“ — vermische die beiden nicht.
  3. Parse niemals stderr. Die session_id:-Zeile und die Diagnostik gehören zum human-readable Kanal; sie als Interface zu behandeln bricht beim nächsten Patch-Release.
  4. Es erfordert -q und verweigert --tui. Beides sind harte Validierungen: Mach sie falsch und du endest mit Exit 2, statt ein halb funktionierendes Ausgabeformat zu bekommen.

Die Kurzfassung

Einen unbeaufsichtigten Agent in eine Pipeline einzubinden ging nie darum, „kann es laufen“ — es geht darum, ob eine Maschine entscheiden kann, dass der Lauf erfolgreich war. --format stream-json schließt diese Lücke: Der Event-Stream macht den Prozess beobachtbar, result.exit_code macht das Ergebnis entscheidbar, und tokens plus duration_ms machen die Kosten rechenschaftsfähig. Um zu sehen, welches Release das ausgeliefert hat und was sich sonst im selben Fenster geändert hat, lies was v0.21.4 tatsächlich ausgeliefert hat; für den Preflight auf einer CI-Maschine siehe hermes doctor und Skills in jede Session heften, womit dein CI-Agent jedes Mal dieselbe Vorgehensweise mitbringt.