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
--tuiab (beides explizit anzufordern endet mit Exit 2). - Jedes Event trägt
timestampin 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.yamlund verwendet die eingebauten Defaults (Credentials in.envwerden weiterhin geladen). Gebaut für isolierte CI-Läufe, reproduzierbare Bug-Reports und Drittanbieter-Integrationen.--ignore-rules— überspringt die automatische Injektion vonAGENTS.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
tool_result.outputist 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.textist ein Delta;result.textist die finale Antwort. Akkumulieretext-Events, um dem Stream zu folgen, liesresult.textfür „was dieser Turn geantwortet hat“ — vermische die beiden nicht.- 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. - Es erfordert
-qund 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.