让 CI 读懂 Hermes:用 stream-json 事件流替代抓日志

你在 CI 里挂了一个 Hermes 任务:让它在流水线里读仓库、跑检查、写总结。第一次跑得很顺,你信心满满地上了生产;第二次它在中间卡住、回合只做了一半就退出,而你的流水线显示绿色通过——因为脚本只看了退出码 0,而“部分完成”也被算作 0。你回头去翻日志,想在漂亮的终端输出里找出它到底调了哪些工具、花了多少 token,结果发现那些信息是给人看的,机器读不动。
这个问题的根子在于:一次性运行的输出一直是“终端界面”,不是“接口”。现在 hermes chat 多了一个选项,把输出变成事件流。
一行命令,输出从给人看变成给程序看
hermes chat -q "Summarize this repository" --format stream-json
stdout 上每一行都是一个独立的 JSON 对象(JSONL),诊断信息和 session_id: 那一行留在 stderr,所以你可以放心地把 stdout 直接喂给解析器。几条硬约束先记住:
- 它要求
-q/--query(或--query-file)——这是为一次性运行准备的格式; - 它隐含 quiet 模式:没有 banner、没有 spinner、没有工具预览,因为你不再需要“好看”;
- 它拒绝与
--tui组合(显式要求 TUI 直接报错退出 2); - 每个事件都带
timestamp字段(Unix epoch 毫秒)。
设计上它参考了 Muse Code 的 muse exec --json:把“一次无人值守的 agent 运行”变成可被工具消费的契约。
五种事件,一张表
事件 type |
关键字段 |
|---|---|
system |
subtype: "init"、model、session_id |
text |
text —— 流式文本增量 |
tool_use |
name,以及参数可得时的 input |
tool_result |
name、output(上限 5000 字符)、duration_ms、is_error |
result |
session_id、exit_code、text、tokens(input / output / total / cache_read / cache_write)、duration_ms,失败时还有 error |
会话一旦真正开始,末尾那条记录永远是 result:包括你用 Ctrl-C 打断时,它会带着 exit_code: 130 出来。所以正确的做法是“把 result 当作完成信号”,而不是“进程结束了就算完”——进程的退出码与这条记录的 exit_code 保持一致。
退出码才是完成信号
一次性运行(-Q、chat --oneshot,或者 stdio 非 TTY 的查询)现在会在退出码里报告这一回合的结果,quiet 与非 quiet 路径一致:
0:回合完成;1:失败、中途停止(partial)、撞到迭代预算,或者根本没跑起来(凭据 / agent 初始化失败);130:被中断;75(EX_TEMPFAIL):Kanban dispatcher 派生的 worker 只是因为 provider 限流、过载、5xx、超时或账号撞到计费/配额墙而失败——dispatcher 会重新排队,而不是把它记成一次失败。
最后一条值得单独说:如果你用 Hermes 跑无人值守的任务队列,75 和 1 必须分开处理,否则你会把“稍后重试就能成功”的任务永久标记为失败。
在 CI 里怎么用
最省事的用法是用 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
想知道它到底调了哪些工具、每个工具花了多久:
jq -r 'select(.type=="tool_result") | [.name, .duration_ms, .is_error] | @tsv' run.jsonl
只想把最终的助手文本交给下游:
jq -r 'select(.type=="result") | .text' run.jsonl
在 GitHub Actions 里,典型写法是把这一步包起来,让失败能被“看见”:
- 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
如果解析器本身是 Python,直接逐行 json.loads 即可——JSONL 的好处正是“一行一个对象”,流式读取不会因为半途中断而丢掉前面已经拿到的事件:
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"])
复现性强相关的几个开关
CI 场景里最值得和它搭配的是两个“隔离”开关:
--ignore-user-config:忽略~/.hermes/config.yaml,用内置默认值(.env里的凭据仍会加载)。适合做隔离的 CI 运行、可复现的 bug 报告、第三方集成。--ignore-rules:跳过AGENTS.md、SOUL.md、.cursorrules、持久记忆与预加载技能的自动注入。两者组合就是一次完全隔离的运行。
另外 --pass-session-id 会把 session ID 送进系统提示;--checkpoints 在破坏性文件改动前打检查点;--worktree 会在一个隔离的 git worktree 里跑(适合“审这个仓库并开 PR”这类任务)。这些都不影响事件格式,只是改变回合的行为。
四个必须注意的细节
tool_result.output上限 5000 字符。 想拿到完整工具输出就去看会话记录(hermes sessions)或让任务自己写文件,别指望事件流里带全量内容。text是增量,result.text是最终文本。 想拼对话流就累加text事件,想要“这一回合答了什么”就用result.text,不要两者混用。- 别解析 stderr。
session_id:与诊断信息在 stderr 上,属于人类可读通道;把它们当接口会在小版本升级时碎掉。 - 它要求
-q,且不能与--tui组合。 这两条是硬性校验,写错直接退出 2,不会给你一个“看起来能用”的半成品输出。
小结
把无人值守的 Agent 运行接进流水线,关键从来不是“能不能跑”,而是“跑完之后机器能不能判断它成功了”。--format stream-json 补的就是这一环:事件流负责过程可观测,result.exit_code 负责结果可判定,tokens 与 duration_ms 负责成本可核算。想先看这个功能随哪个版本发布、以及同窗口还有什么变化,可以读 v0.21.4 都带了什么;跑任务前的环境自检可以看 hermes doctor 与 把技能钉进每个会话——后者能让 CI 里的 Agent 每次都带上同一套流程说明。