Ensinando o CI a ler o Hermes: o fluxo de eventos stream-json em vez de raspar a tela


Você plugou uma tarefa do Hermes no CI: ela lê o repositório, roda as verificações, escreve um resumo. A primeira execução correu bem, então você colocou em produção. A segunda travou no meio do turno e saiu — e o seu pipeline mostrou verde, porque o wrapper só olhava o exit code 0, e “parou no meio” também é 0. Aí você foi cavucar os logs para descobrir quais tools ele chamou e quantos tokens queimou, e viu que a saída foi escrita para um humano ler, não para um programa interpretar.

A causa raiz é que a saída de uma execução one-shot sempre foi uma UI de terminal, nunca uma interface. O hermes chat agora tem uma flag que a transforma em um fluxo de eventos.

Uma flag: saída escrita para um programa

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

Cada linha do stdout é um objeto JSON (JSONL), enquanto os diagnósticos e a linha session_id: ficam no stderr — então o stdout vai direto para um parser. As restrições duras, desde já:

  • Ela exige -q / --query (ou --query-file) — este formato existe para execuções one-shot.
  • Ela implica o modo quiet: sem banner, sem spinner, sem previews de tool, porque “bonito” deixou de ser o ponto.
  • Ela rejeita --tui (pedir os dois explicitamente sai com código 2).
  • Todo evento carrega timestamp em milissegundos de época Unix.

O desenho é inspirado no muse exec --json do Muse Code: transformar uma execução de agent sem supervisão em um contrato que uma ferramenta consegue consumir.

Cinco tipos de evento, uma tabela

Evento type Campos principais
system subtype: "init", model, session_id
text text — um delta de texto do assistant transmitido em stream
tool_use name, mais input quando os argumentos estão disponíveis
tool_result name, output (limitado a 5000 caracteres), duration_ms, is_error
result session_id, exit_code, text, tokens (input / output / total / cache_read / cache_write), duration_ms, e error quando o turno falhou

Assim que uma conversa começa, seu registro terminal é sempre result — inclusive quando você interrompe com Ctrl-C, caso em que ele chega carregando exit_code: 130. Então trate esse registro como o sinal de conclusão, e não “o processo terminou”: o exit code do processo é igual ao exit_code dele.

O exit code é o sinal de conclusão

Uma execução one-shot (-Q, chat --oneshot, ou uma query com stdio não-TTY) agora reporta o desfecho do turno no exit code, de forma idêntica nos caminhos quiet e não-quiet:

  • 0 — o turno foi concluído;
  • 1 — falhou, parou no meio (partial), estourou o orçamento de iterações, ou nem chegou a rodar (falha de credenciais / init do agent);
  • 130 — foi interrompido;
  • 75 (EX_TEMPFAIL) — um worker criado pelo dispatcher do Kanban falhou apenas porque o provider estava com rate limit, sobrecarregado, devolvendo 5xx, dando timeout, ou a conta bateu na parede de cobrança/cota, então o dispatcher recoloca na fila em vez de contar uma falha.

Esse último merece uma frase só para ele: se você roda filas de tarefas sem supervisão com o Hermes, trate 75 e 1 de formas diferentes, ou você vai marcar permanentemente como falha um trabalho que era só “tentar de novo mais tarde”.

Como consumir isso no CI

O uso mais barato é extrair o envelope final com 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 quais tools rodaram e quanto tempo cada uma levou:

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

E para repassar a resposta final do assistant adiante:

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

No GitHub Actions, o formato padrão é envolver o step para que as falhas fiquem visíveis:

- 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

Se o seu consumidor for Python, leia linha por linha — JSONL significa que uma falha no meio do stream nunca custa os eventos que você já capturou:

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"])

As flags que tornam as execuções de CI reproduzíveis

Dois switches de isolamento combinam melhor com este formato:

  • --ignore-user-config — ignora ~/.hermes/config.yaml e usa os padrões internos (credenciais no .env ainda são carregadas). Feito para execuções isoladas de CI, relatos de bug reproduzíveis e integrações de terceiros.
  • --ignore-rules — pula a injeção automática de AGENTS.md, SOUL.md, .cursorrules, memória persistente e skills pré-carregadas. Combinada com a flag acima, você tem uma execução totalmente isolada.

Mais duas que moldam o turno sem tocar no formato dos eventos: --pass-session-id coloca o session ID no prompt do sistema, e --checkpoints tira checkpoints de filesystem antes de alterações destrutivas em arquivos. --worktree roda tudo em um git worktree isolado, que é o formato certo para “revise este repositório e abra um PR”.

Quatro detalhes em que você vai tropeçar

  1. tool_result.output é limitado a 5000 caracteres. Para a saída completa de uma tool, leia o registro da session (hermes sessions) ou faça a tarefa escrever um arquivo — não espere que o fluxo de eventos carregue tudo.
  2. text é um delta; result.text é a resposta final. Acumule os eventos text para acompanhar o stream, leia result.text para saber “o que este turno respondeu” — não misture os dois.
  3. Nunca faça parsing do stderr. A linha session_id: e os diagnósticos pertencem ao canal legível por humanos; tratá-los como interface quebra no próximo patch release.
  4. Ele exige -q e recusa --tui. As duas são validações duras: erre e você sai com código 2 em vez de receber um formato de saída meio funcional.

A versão curta

Plugar um agent sem supervisão em um pipeline nunca foi sobre “ele consegue rodar” — é sobre se uma máquina consegue decidir que a execução deu certo. O --format stream-json fecha essa lacuna: o fluxo de eventos torna o processo observável, result.exit_code torna o desfecho decidível, e tokens mais duration_ms tornam o custo contabilizável. Para ver qual release entregou isso e o que mais mudou na mesma janela, leia o que a v0.21.4 realmente entregou; para a checagem de pré-voo numa máquina de CI, veja o hermes doctor e fixar skills em toda session, que é como você faz um agent de CI carregar o mesmo playbook todas as vezes.