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
timestampem 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.yamle usa os padrões internos (credenciais no.envainda 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 deAGENTS.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
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.texté um delta;result.texté a resposta final. Acumule os eventostextpara acompanhar o stream, leiaresult.textpara saber “o que este turno respondeu” — não misture os dois.- 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. - Ele exige
-qe 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.