Files
hermes-agent/hermes_cli/stream_json.py
T
David Dudok de Wit 875577495b fix(gateway): re-attach after session.replay_gap instead of failing the turn
On overflow the owner retires a viewer's subscription and sends one
session.replay_gap while the turn runs on. Every Python subscriber treated
that frame as a failed turn: the classic CLI reported "outcome unknown" and
then hung on every later turn, GatewayChatView (stream-json, -z,
--safe-mode) failed, and ACP stopped its event pump, failing the waiting
prompts of every session. #140292 made gaps much rarer by raising the frame
cap, but kept the overflow path.

A gap now means "this subscription ended; re-attach". The client resumes
that session only, adopts the new subscription, wakes whatever waits on its
pending rows or controls, and answers the controls the snapshot lists. The
snapshot is taken under the event-stream lock: a queued or started
admission it lists publishes its completion on the new subscription; an
unknown one owes none and settles as unknown now, in every client; one it
no longer lists ended in the hole and its receipt settles it, read once and
only against a snapshot that covers it (one whose resume left after the
submit). ACP's pump is the one recovery owner, so a reply is delivered at
most once. After a gap a control blocks only an admission the snapshot
still lists.

Live deltas stop at the gap and the settled reply shows once (the rest of
an exact prefix, else "[output resumed]" and the whole reply), to the
terminal and to the voice stream separately. An interim stays whole, as it
can be the reply's only copy. stream-json emits no further text and flags
its result with replay_gap. After a gap or from a receipt, a reused reply
is suppressed only when all of it was shown; without a gap main's rule
holds. A refused re-attach still ends the wait with today's error; an
unreadable receipt fails only that admission. A CLI turn owns its turn
token and its session from the pre-submit re-attach through _result and
painting, bounded by the interrupt settle deadline, and any received
boundary closes the reply segment.

The real-rig tests share one real owner that pins the fanout frame cap at
256, as #140292's own overflow probe does, so the paused viewer still
overflows.

Refs #134889
Fixes #140388

(cherry picked from commit 0d51f10640c560e0cb0b2205e2dbad18d06c7c5c)
2026-10-18 03:28:00 -07:00

112 lines
5.8 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 bind_session(self, session_id: str) -> None:
"""Record the id the gateway assigned after ``init`` was written, so an interrupted or failed
run still reports which session holds the accepted work."""
self._session_id = session_id or self._session_id
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"])
if data.get("replay_gap"):
# The viewer fell behind and re-attached: ``text`` events stopped there; ``text`` is the whole reply.
payload["replay_gap"] = True
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