Files
hermes-agent/hermes_cli/stream_json.py
teknium1 aa75d3724f fix(stream-json): verbatim text deltas, closed protocol on init failure, per-call tool keys
- on_text_delta dropped whitespace-only deltas, so concatenating the `text`
  events no longer reproduced the answer (a newline between paragraphs was
  lost). Only None/"" (the turn-end sentinel) is skipped now.
- The emitter was attached only after credentials + agent init succeeded, so a
  missing key or unknown provider exited 1 with an EMPTY stdout and the
  provider error rendered through ChatConsole (stdout). The emitter is now
  built before _ensure_runtime_credentials/_init_agent; that path closes the
  protocol with init + a failed `result` (exit_code 1, error) and the
  credential error goes to stderr whenever stdout is machine-readable
  (tool_progress_mode == "off", i.e. -Q and stream-json).
- _tool_started was keyed by tool name, so concurrent same-name calls
  clobbered each other's start time; key on tool_call_id when the caller
  passes one and surface it on tool_use/tool_result.

Live: `hermes chat -q … --format stream-json` with no provider and with a dead
custom base_url both yield pure JSONL (`system` + `result`, exit 1).
2026-09-15 03:53:13 -07:00

104 lines
5.3 KiB
Python

"""``hermes chat -q … --format stream-json``: one JSON object per stdout line.
CI runners and orchestrators consume a one-shot run without scraping human-formatted text:
``system/init`` → ``text`` deltas / ``tool_use`` / ``tool_result`` → one terminal ``result``
envelope (exit code, final text, token stats). Diagnostics and ``session_id`` stay on stderr.
"""
from __future__ import annotations
import json
import sys
import time
from typing import Any
_TOOL_OUTPUT_CAP = 5000
def stream_json_requested(args) -> bool:
"""True when ``--format stream-json`` was passed; exits 2 on the combinations the protocol forbids
(no query to answer, or the interactive TUI transport) and forces quiet mode on ``args``."""
if getattr(args, "output_format", "text") != "stream-json":
return False
if not (getattr(args, "query", None) or getattr(args, "query_file", None)):
print("Error: --format stream-json requires -q/--query.", file=sys.stderr)
raise SystemExit(2)
if getattr(args, "tui", False):
print("Error: --format stream-json cannot be used with --tui.", file=sys.stderr)
raise SystemExit(2)
args.quiet = True
return True
def _now_ms() -> int:
return int(time.time() * 1000)
class StreamJsonEmitter:
"""Agent-callback sink that writes JSONL events to stdout and flushes each line."""
def __init__(self, model: str = "", session_id: str = ""):
self._session_id = session_id
self._start = time.time()
self._tool_started: dict[str, float] = {}
self._emit({"type": "system", "subtype": "init", "model": model, "session_id": session_id})
def attach(self, agent) -> "StreamJsonEmitter":
"""Route the agent's streaming/tool callbacks into this emitter (``init`` was already written at
construction, before credentials/agent init, so a failed start still yields init + result)."""
agent.stream_delta_callback = self.on_text_delta
agent.tool_progress_callback = self.on_tool_progress
return self
def on_text_delta(self, text: str | None) -> None:
# Only None/"" (the turn-end sentinel) is dropped: whitespace deltas are part of the text, and
# a consumer concatenating ``text`` events must reproduce the answer byte for byte.
if text:
self._emit({"type": "text", "text": str(text)})
def on_tool_progress(self, event_type: str, tool_name: str | None = None, preview: Any = None, args: Any = None,
**kwargs: Any) -> None:
"""``tool.started`` → ``tool_use`` (with ``input`` when the args are a dict); ``tool.completed`` →
``tool_result``. Other progress events (reasoning, output risk) are not part of the protocol."""
name = tool_name or "unknown"
# Parallel same-name calls would clobber each other's start time under a name-only key.
key = kwargs.get("tool_call_id") or name
if event_type == "tool.started":
self._tool_started[key] = time.time()
payload: dict[str, Any] = {"type": "tool_use", "name": name}
if kwargs.get("tool_call_id"):
payload["tool_call_id"] = kwargs["tool_call_id"]
if isinstance(args, dict):
payload["input"] = args
self._emit(payload)
elif event_type == "tool.completed":
duration = kwargs.get("duration") or (time.time() - self._tool_started.pop(key, time.time()))
output = str(kwargs.get("result") or "")
self._emit({"type": "tool_result", "name": name,
**({"tool_call_id": kwargs["tool_call_id"]} if kwargs.get("tool_call_id") else {}),
"output": output if len(output) <= _TOOL_OUTPUT_CAP else output[:_TOOL_OUTPUT_CAP] + "...",
"duration_ms": int(float(duration) * 1000), "is_error": bool(kwargs.get("is_error", False))})
def emit_result(self, result: Any, session_id: str = "", exit_code: int = 0) -> int:
"""Write the terminal ``result`` record (once) and return the process exit code it reports."""
data = result if isinstance(result, dict) else {"final_response": "" if result is None else str(result)}
exit_code = exit_code or (1 if data.get("failed") else 0)
payload = {"type": "result", "session_id": session_id or self._session_id, "exit_code": exit_code,
"text": data.get("final_response") or "",
"tokens": {"input": data.get("input_tokens") or 0, "output": data.get("output_tokens") or 0,
"total": data.get("total_tokens") or 0, "cache_read": data.get("cache_read_tokens") or 0,
"cache_write": data.get("cache_write_tokens") or 0},
"duration_ms": int((time.time() - self._start) * 1000)}
if data.get("error"):
payload["error"] = str(data["error"])
self._emit(payload)
print(f"\nsession_id: {session_id or self._session_id}", file=sys.stderr) # same stderr contract as -Q
return exit_code
def _emit(self, obj: dict) -> None:
try:
sys.stdout.write(json.dumps({**obj, "timestamp": _now_ms()}, ensure_ascii=False) + "\n")
sys.stdout.flush()
except (BrokenPipeError, OSError):
pass # consumer closed the pipe — nothing left to report to