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 timestamp en 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.yaml et utilise les valeurs par défaut intégrées (les credentials du .env sont 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 de AGENTS.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

  1. tool_result.output est 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.
  2. text est un delta, result.text est la réponse finale. Accumulez les événements text pour suivre le flux, lisez result.text pour savoir « ce que ce tour a répondu » — ne mélangez pas les deux.
  3. 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.
  4. Il exige -q et 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.