"""``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