Enseñar a CI a leer Hermes: el flujo de eventos stream-json en lugar de raspar la pantalla

Enganchaste una tarea de Hermes a CI: lee el repositorio, ejecuta las comprobaciones, escribe un resumen. La primera ejecución fue bien, así que lo desplegaste. La segunda se atascó a mitad del turno y terminó — y tu pipeline se mostró en verde, porque el wrapper solo miraba el código de salida 0 y “se detuvo a mitad” también es 0. Después te pusiste a bucear en los logs para averiguar qué herramientas llamó y cuántos token quemó, y descubriste que esa salida estaba escrita para que la leyera una persona, no para que la parseara un programa.
La causa de fondo es que la salida de una ejecución puntual siempre fue una interfaz de terminal, nunca una interfaz para programas. Ahora hermes chat tiene un flag que la convierte en un flujo de eventos.
Un solo flag: salida escrita para un programa
hermes chat -q "Summarize this repository" --format stream-json
Cada línea de stdout es un objeto JSON (JSONL), mientras que los diagnósticos y la línea session_id: se quedan en stderr — así que stdout entra directamente en un parser. Las restricciones duras, por delante:
- Requiere
-q/--query(o--query-file) — este formato existe para ejecuciones puntuales. - Implica el modo quiet: sin banner, sin spinner y sin vistas previas de herramientas, porque lo “bonito” ya no es el objetivo.
- Rechaza
--tui(pedir explícitamente ambos termina con código 2). - Cada evento lleva
timestampen milisegundos de epoch Unix.
El diseño se inspira en muse exec --json de Muse Code: convertir una ejecución desatendida del agent en un contrato que una herramienta pueda consumir.
Cinco tipos de evento en una sola tabla
Evento type |
Campos clave |
|---|---|
system |
subtype: "init", model, session_id |
text |
text — un fragmento de texto del asistente transmitido en streaming |
tool_use |
name, y además input cuando los argumentos están disponibles |
tool_result |
name, output (limitado a 5.000 caracteres), duration_ms, is_error |
result |
session_id, exit_code, text, tokens (input / output / total / cache_read / cache_write), duration_ms, y error cuando el turno falló |
Una vez que arranca una conversación, su registro final siempre es result — incluso cuando la interrumpes con Ctrl-C, donde llega con exit_code: 130. Así que trata ese registro como la señal de finalización, y no como “el proceso ha terminado”: el código de salida del proceso coincide con su exit_code.
El código de salida es la señal de finalización
Una ejecución puntual (-Q, chat --oneshot, o una consulta con stdio no TTY) ahora informa del resultado del turno en su código de salida, de forma idéntica en la ruta quiet y en la no quiet:
0— el turno se completó;1— falló, se detuvo a mitad (partial), agotó el presupuesto de iteraciones, o no llegó a ejecutarse (fallo de credenciales o de inicialización del agent);130— fue interrumpido;75(EX_TEMPFAIL) — un worker lanzado por el dispatcher de Kanban falló solo porque el provider estaba limitando la tasa, sobrecargado, devolviendo 5xx o agotando el tiempo, o porque la cuenta chocó con un muro de facturación o cuota, de modo que el dispatcher lo vuelve a encolar en lugar de contarlo como un fallo.
Ese último merece una frase propia: si ejecutas colas de tareas desatendidas con Hermes, trata 75 y 1 de forma distinta, o marcarás para siempre como fallidos trabajos que en realidad eran “reintentar más tarde”.
Cómo consumirlo en CI
El uso más barato es extraer el sobre final con 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
Para ver qué herramientas se ejecutaron y cuánto tardó cada una:
jq -r 'select(.type=="tool_result") | [.name, .duration_ms, .is_error] | @tsv' run.jsonl
Y para pasar la respuesta final del asistente a los pasos siguientes:
jq -r 'select(.type=="result") | .text' run.jsonl
En GitHub Actions, la forma habitual es envolver el paso para que los fallos sean 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 tu consumidor está escrito en Python, léelo línea a línea — JSONL significa que un fallo a mitad del flujo nunca te cuesta los eventos que ya habías capturado:
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"])
Los flags que hacen reproducibles las ejecuciones de CI
Dos interruptores de aislamiento son los que mejor encajan con este formato:
--ignore-user-config— ignora~/.hermes/config.yamly usa los valores predeterminados integrados (las credenciales del.envse siguen cargando). Pensado para ejecuciones de CI aisladas, informes de bugs reproducibles e integraciones de terceros.--ignore-rules— omite la inyección automática deAGENTS.md,SOUL.md,.cursorrules, la memoria persistente y los skills precargados. Combinado con el flag anterior, obtienes una ejecución totalmente aislada.
Hay dos más que modelan el turno sin tocar el formato de eventos: --pass-session-id coloca el ID de session en el prompt de sistema, y --checkpoints toma checkpoints del sistema de archivos antes de cambios destructivos en archivos. --worktree ejecuta todo en un git worktree aislado, que es la forma adecuada para “revisa este repositorio y abre un PR”.
Cuatro detalles con los que tropezarás
tool_result.outputestá limitado a 5.000 caracteres. Para obtener la salida completa de una herramienta, lee el registro de la session (hermes sessions) o haz que la tarea escriba un archivo — no esperes que el flujo de eventos lo lleve todo.textes un fragmento;result.textes la respuesta final. Acumula los eventostextpara seguir el flujo, y leeresult.textpara saber “qué respondió este turno” — no mezcles los dos.- Nunca parsees stderr. La línea
session_id:y los diagnósticos pertenecen al canal legible por humanos; tratarlos como una interfaz se rompe en la siguiente release de patch. - Requiere
-qy rechaza--tui. Ambas son validaciones duras: si te equivocas, sales con código 2 en lugar de recibir un formato de salida que funciona a medias.
La versión corta
Conectar un agent desatendido a un pipeline nunca fue cuestión de “¿puede ejecutarse?” — se trata de si una máquina puede decidir que la ejecución tuvo éxito. --format stream-json cierra esa brecha: el flujo de eventos hace observable el proceso, result.exit_code hace decidible el resultado, y tokens más duration_ms hacen auditable el coste. Para ver en qué release llegó y qué más cambió en la misma ventana, lee qué trajo realmente la v0.21.4; para la comprobación previa en una máquina de CI, consulta hermes doctor y fijar skills en cada session, que es la forma de que un agent de CI lleve siempre el mismo playbook.