Apprendre à la CI à lire Hermes : le flux d'événements stream-json plutôt que le scraping d'écran

Vous avez branché une tâche Hermes dans votre CI : elle lit le dépôt, exécute les vérifications, écrit une synthèse. Le premier passage s’est bien déroulé, vous l’avez donc mise en production. Le deuxième s’est bloqué à mi-tour puis a rendu la main — et votre pipeline a affiché vert, parce que le script ne regardait que le code de sortie 0 et qu’un arrêt en cours de route vaut aussi 0. Vous avez ensuite fouillé les journaux pour comprendre quels outils avaient été appelés et combien de tokens avaient brûlé, et vous avez découvert que cette sortie était écrite pour être lue par un humain, pas pour être analysée par un programme.
La cause profonde, c’est que la sortie d’une exécution one-shot a toujours été une interface de terminal, jamais une interface pour programme. hermes chat dispose désormais d’une option qui la transforme en flux d’événements.
Une seule option : une sortie écrite pour un programme
hermes chat -q "Summarize this repository" --format stream-json
Chaque ligne de stdout est un objet JSON unique (JSONL), tandis que les diagnostics et la ligne session_id: restent sur stderr — stdout part donc directement dans un analyseur. Les contraintes strictes, d’entrée de jeu :
- Il exige
-q/--query(ou--query-file) — ce format est prévu pour les exécutions one-shot. - Il implique le mode quiet : ni bannière, ni spinner, ni aperçu d’outil, parce que « faire joli » n’est plus l’objectif.
- Il refuse
--tui: demander explicitement les deux se solde par un code de sortie 2. - Chaque événement porte un
timestampen millisecondes d’époque Unix.
Le design s’inspire de muse exec --json de Muse Code : transformer une exécution d’agent sans surveillance en un contrat qu’un outil peut consommer.
Cinq types d’événements, un seul tableau
Événement type |
Champs clés |
|---|---|
system |
subtype: "init", model, session_id |
text |
text — un delta de texte de l’assistant diffusé en flux |
tool_use |
name, plus input quand les arguments sont disponibles |
tool_result |
name, output (plafonné à 5000 caractères), duration_ms, is_error |
result |
session_id, exit_code, text, tokens (input / output / total / cache_read / cache_write), duration_ms, et error quand le tour a échoué |
Une fois qu’une conversation a réellement commencé, son enregistrement terminal est toujours result — y compris lorsque vous interrompez avec Ctrl-C, où il arrive en portant exit_code: 130. Traitez donc cet enregistrement comme le signal de fin, et non comme « le processus s’est arrêté » : le code de sortie du processus correspond à son exit_code.
Le code de sortie est le signal de fin
Une exécution one-shot (-Q, chat --oneshot, ou une requête avec un stdio non-TTY) rapporte désormais l’issue du tour dans son code de sortie, de façon identique sur les chemins quiet et non quiet :
0: le tour est allé au bout ;1: il a échoué, s’est arrêté en cours de route (partial), a atteint le budget d’itérations, ou n’a jamais démarré (échec des credentials / de l’init de l’agent) ;130: il a été interrompu ;75(EX_TEMPFAIL) : un worker lancé par le dispatcher Kanban n’a échoué que parce que le provider était limité en débit, surchargé, renvoyait du 5xx, expirait, ou que le compte avait atteint un mur de facturation/quota — le dispatcher remet donc la tâche en file au lieu de compter un échec.
Ce dernier mérite une phrase à part : si vous faites tourner des files de tâches sans surveillance avec Hermes, traitez 75 et 1 différemment, sinon vous marquerez définitivement comme échoué du travail qui devait être « réessayé plus tard ».
Comment le consommer en CI
L’usage le plus économe consiste à extraire l’enveloppe finale avec jq :
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
Pour voir quels outils ont tourné et combien de temps chacun a pris :
jq -r 'select(.type=="tool_result") | [.name, .duration_ms, .is_error] | @tsv' run.jsonl
Et pour transmettre la réponse finale de l’assistant en aval :
jq -r 'select(.type=="result") | .text' run.jsonl
Dans GitHub Actions, la forme habituelle consiste à envelopper l’étape pour rendre les échecs visibles :
- 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
Si votre consommateur est en Python, lisez le flux ligne par ligne : le JSONL garantit qu’une panne en cours de flux ne vous coûte jamais les événements déjà capturés :
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"])
Les options qui rendent les exécutions CI reproductibles
Deux commutateurs d’isolation se marient particulièrement bien avec ce format :
--ignore-user-config— ignore~/.hermes/config.yamlet utilise les valeurs par défaut intégrées (les credentials du.envsont quand même chargés). Conçu pour les exécutions CI isolées, les rapports de bug reproductibles et les intégrations tierces.--ignore-rules— saute l’injection automatique deAGENTS.md,SOUL.md,.cursorrules, de la mémoire persistante et des skills préchargés. Combiné avec l’option ci-dessus, vous obtenez une exécution entièrement isolée.
Deux autres modifient le tour sans toucher au format des événements : --pass-session-id place l’ID de session dans le system prompt, et --checkpoints prend des instantanés du système de fichiers avant les modifications destructrices. --worktree exécute le tout dans un worktree git isolé, ce qui est la bonne forme pour « auditer ce dépôt et ouvrir une PR ».
Quatre détails qui vous feront trébucher
tool_result.outputest plafonné à 5000 caractères. Pour la sortie complète d’un outil, lisez l’enregistrement de session (hermes sessions) ou faites écrire un fichier à la tâche — n’attendez pas du flux d’événements qu’il transporte tout.textest un delta,result.textest la réponse finale. Accumulez les événementstextpour suivre le flux, lisezresult.textpour savoir « ce que ce tour a répondu » — ne mélangez pas les deux.- Ne parsez jamais stderr. La ligne
session_id:et les diagnostics appartiennent au canal lisible par un humain ; les traiter comme une interface casse à la prochaine release corrective. - Il exige
-qet refuse--tui. Ce sont deux validations strictes : trompez-vous et vous sortez en code 2, au lieu de recevoir un format de sortie à moitié fonctionnel.
En résumé
Brancher un agent sans surveillance dans un pipeline n’a jamais consisté à savoir s’il « peut tourner » — il s’agit de savoir si une machine peut décider que l’exécution a réussi. --format stream-json comble cet écart : le flux d’événements rend le processus observable, result.exit_code rend l’issue décidable, et tokens plus duration_ms rendent le coût imputable. Pour voir quelle release l’a livré et ce qui a changé dans la même fenêtre, lisez ce que la v0.21.4 a réellement livré ; pour le contrôle avant vol sur une machine de CI, voyez hermes doctor et épingler des skills dans chaque session, qui est la façon de faire porter à un agent de CI le même mode opératoire à chaque fois.