Hermes-agent

This commit is contained in:
Zakaria
2026-06-14 14:30:48 -04:00
commit dac4b88b94
5058 changed files with 1884848 additions and 0 deletions
View File
+298
View File
@@ -0,0 +1,298 @@
import os
import sys
# Guard against a local utils/ (or other package) in CWD shadowing installed
# hermes modules. hermes_cli sets HERMES_PYTHON_SRC_ROOT before spawning this
# subprocess; inserting it first ensures the installed packages win.
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
if _src_root and _src_root not in sys.path:
sys.path.insert(0, _src_root)
# Strip '' and '.' — both resolve to CWD at import time and can let a local
# directory shadow installed packages.
sys.path = [p for p in sys.path if p not in {"", "."}]
import json
import logging
import signal
import time
import traceback
from tui_gateway import server
from tui_gateway.server import _CRASH_LOG, dispatch, resolve_skin, write_json
from tui_gateway.transport import TeeTransport
logger = logging.getLogger(__name__)
# Handle for the background MCP tool-discovery thread (see main()). The first
# agent build briefly joins this so already-spawning fast servers land before
# the agent snapshots its tool list (see wait_for_mcp_discovery).
_mcp_discovery_thread = None
def _install_sidecar_publisher() -> None:
"""Mirror every dispatcher emit to the dashboard sidebar via WS.
Activated by `HERMES_TUI_SIDECAR_URL`, set by the dashboard's
``/api/pty`` endpoint when a chat tab passes a ``channel`` query param.
Best-effort: connect failure or runtime drop falls back to stdio-only.
"""
url = os.environ.get("HERMES_TUI_SIDECAR_URL")
if not url:
return
from tui_gateway.event_publisher import WsPublisherTransport
server._stdio_transport = TeeTransport(
server._stdio_transport, WsPublisherTransport(url)
)
# How long to wait for orderly shutdown (atexit + finalisers) before
# falling back to ``os._exit(0)`` so a wedged worker mid-flush can't
# strand the process. 1s covers the gateway's own shutdown work
# (thread-pool drain + session finalize) on every machine we've
# tested; override via ``HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S`` if a
# slower environment needs more headroom (e.g. encrypted disks
# flushing checkpoints) and accept that a longer grace also means a
# longer wait when shutdown actually deadlocks.
_DEFAULT_SHUTDOWN_GRACE_S = 1.0
def _shutdown_grace_seconds() -> float:
raw = (os.environ.get("HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S") or "").strip()
if not raw:
return _DEFAULT_SHUTDOWN_GRACE_S
try:
value = float(raw)
except ValueError:
return _DEFAULT_SHUTDOWN_GRACE_S
return value if value > 0 else _DEFAULT_SHUTDOWN_GRACE_S
def _log_signal(signum: int, frame) -> None:
"""Capture WHICH thread and WHERE a termination signal hit us.
SIG_DFL for SIGPIPE kills the process silently the instant any
background thread (TTS playback, beep, voice status emitter, etc.)
writes to a stdout the TUI has stopped reading. Without this
handler the gateway-exited banner in the TUI has no trace — the
crash log never sees a Python exception because the kernel reaps
the process before the interpreter runs anything.
Termination semantics: ``sys.exit(0)`` here used to race the worker
pool — a thread holding ``_stdout_lock`` mid-flush would block the
interpreter shutdown indefinitely. We now log the stack, give the
process the configured shutdown grace
(``HERMES_TUI_GATEWAY_SHUTDOWN_GRACE_S``, default
``_DEFAULT_SHUTDOWN_GRACE_S``) to drain naturally on a background
thread, and fall back to ``os._exit(0)`` so a wedged write/flush
can never strand the process.
"""
# SIGPIPE and SIGHUP don't exist on Windows — build the lookup
# dict from attributes that actually exist on the current platform.
_signal_names: dict[int, str] = {}
for _attr in ("SIGPIPE", "SIGTERM", "SIGHUP", "SIGINT", "SIGBREAK"):
_sig = getattr(signal, _attr, None)
if _sig is not None:
_signal_names[int(_sig)] = _attr
name = _signal_names.get(signum, f"signal {signum}")
try:
os.makedirs(os.path.dirname(_CRASH_LOG), exist_ok=True)
with open(_CRASH_LOG, "a", encoding="utf-8") as f:
f.write(
f"\n=== {name} received · {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n"
)
if frame is not None:
f.write("main-thread stack at signal delivery:\n")
traceback.print_stack(frame, file=f)
# All live threads — signal may have been triggered by a
# background thread (write to broken stdout from TTS, etc.).
import threading as _threading
for tid, th in _threading._active.items():
f.write(f"\n--- thread {th.name} (id={tid}) ---\n")
f.write("".join(traceback.format_stack(sys._current_frames().get(tid))))
except Exception:
pass
print(f"[gateway-signal] {name}", file=sys.stderr, flush=True)
import threading as _threading
def _hard_exit() -> None:
# If a worker thread is still mid-flush on a half-closed pipe,
# ``sys.exit(0)`` would wait forever for it to drop the GIL on
# interpreter shutdown. ``os._exit`` skips atexit handlers but
# breaks the deadlock. The crash log + stderr line above are
# the forensic trail.
os._exit(0)
timer = _threading.Timer(_shutdown_grace_seconds(), _hard_exit)
timer.daemon = True
timer.start()
try:
sys.exit(0)
except SystemExit:
# Re-raise so the main-thread interpreter unwinds and runs
# atexit + finalisers inside the grace window. Python signal
# handlers always run on the main thread, but a worker thread
# holding ``_stdout_lock`` mid-flush can keep that unwind
# waiting indefinitely; the daemon timer above is the safety
# net for that exact case.
raise
# SIGPIPE: ignore, don't exit. The old SIG_DFL killed the process
# silently whenever a *background* thread (TTS playback chain, voice
# debug stderr emitter, beep thread) wrote to a pipe the TUI had gone
# quiet on — even though the main thread was perfectly fine waiting on
# stdin. Ignoring the signal lets Python raise BrokenPipeError on the
# offending write (write_json already handles that with a clean
# sys.exit(0) + _log_exit), which keeps the gateway alive as long as
# the main command pipe is still readable. Terminal signals still
# route through _log_signal so kills and hangups are diagnosable.
#
# SIGPIPE and SIGHUP don't exist on Windows; guard each installation
# with hasattr so ``python -m tui_gateway.entry`` (spawned by
# ``hermes --tui``) imports cleanly there. SIGBREAK (Windows' Ctrl+Break)
# is installed when available as a weaker equivalent of SIGHUP.
if hasattr(signal, "SIGPIPE"):
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
if hasattr(signal, "SIGTERM"):
signal.signal(signal.SIGTERM, _log_signal)
if hasattr(signal, "SIGHUP"):
signal.signal(signal.SIGHUP, _log_signal)
elif hasattr(signal, "SIGBREAK"):
# Windows-only: Ctrl+Break in a console window delivers SIGBREAK.
# Route it through the same handler so kills are diagnosable.
signal.signal(signal.SIGBREAK, _log_signal)
if hasattr(signal, "SIGINT"):
signal.signal(signal.SIGINT, signal.SIG_IGN)
def _log_exit(reason: str) -> None:
"""Record why the gateway subprocess is shutting down.
Three exit paths (startup write fail, parse-error-response write fail,
dispatch-response write fail, stdin EOF) all collapse into a silent
sys.exit(0) here. Without this trail the TUI shows "gateway exited"
with no actionable clue about WHICH broken pipe or WHICH message
triggered it — the main reason voice-mode turns look like phantom
crashes when the real story is "TUI read pipe closed on this event".
"""
try:
os.makedirs(os.path.dirname(_CRASH_LOG), exist_ok=True)
with open(_CRASH_LOG, "a", encoding="utf-8") as f:
f.write(
f"\n=== gateway exit · {time.strftime('%Y-%m-%d %H:%M:%S')} "
f"· reason={reason} ===\n"
)
except Exception:
pass
print(f"[gateway-exit] {reason}", file=sys.stderr, flush=True)
def wait_for_mcp_discovery(timeout: float = 0.75) -> None:
"""Briefly block until background MCP discovery finishes, up to ``timeout``.
MCP discovery runs in a daemon thread spawned at startup (see main()) so a
slow/dead server can't freeze ``gateway.ready``. But the agent snapshots
its tool list ONCE at build time and never re-reads it, so a reachable-but-
slow server that finishes connecting *after* the first prompt would be
invisible for the whole session. Joining with a short bounded timeout
before the first agent build lets already-spawning fast servers land
without re-introducing the startup hang: a dead server simply isn't waited
on beyond ``timeout``. No-op when no discovery thread was started.
"""
thread = _mcp_discovery_thread
if thread is None or not thread.is_alive():
return
thread.join(timeout=timeout)
def main():
_install_sidecar_publisher()
# MCP tool discovery — runs in a background daemon thread so a slow or
# unreachable MCP server can't freeze TUI startup. Previously this ran
# inline before ``gateway.ready``, which meant any configured-but-down
# server stalled the whole shell on "summoning hermes…" for the full
# connect-retry backoff (e.g. a dead stdio/http server burns 1+2+4s of
# retries → ~7s of dead air before the composer appears). Discovery is
# idempotent and registers tools into the shared registry as servers
# connect. The agent isn't built until the first prompt, at which point
# ``_make_agent`` briefly joins this thread (``wait_for_mcp_discovery``,
# bounded) so already-spawning fast servers land in the tool snapshot —
# a dead server is simply not waited on past the bound. ``/reload-mcp``
# rebuilds the snapshot for servers that connect later in the session.
#
# Cold-start guard: importing ``tools.mcp_tool`` transitively pulls the
# full MCP SDK (mcp, pydantic, httpx, jsonschema, starlette parsers —
# ~200ms on macOS). The overwhelming majority of users have no
# ``mcp_servers`` configured, in which case every byte of that import is
# wasted. Check the config first (cheap) and only spawn the discovery
# thread when there's actually MCP work to do, so the import cost stays
# off the path entirely for the common case.
try:
from hermes_cli.config import read_raw_config
_mcp_servers = (read_raw_config() or {}).get("mcp_servers")
_has_mcp_servers = isinstance(_mcp_servers, dict) and len(_mcp_servers) > 0
except Exception:
# Be conservative: if we can't decide, fall back to attempting
# discovery (still backgrounded, so it can't block startup).
_has_mcp_servers = True
if _has_mcp_servers:
def _discover_mcp_background() -> None:
try:
from tools.mcp_tool import discover_mcp_tools
discover_mcp_tools()
except Exception:
logger.warning(
"Background MCP tool discovery failed", exc_info=True
)
import threading as _mcp_threading
_mcp_thread = _mcp_threading.Thread(
target=_discover_mcp_background,
name="tui-mcp-discovery",
daemon=True,
)
_mcp_thread.start()
# Publish the handle so the first agent build can briefly wait for
# already-spawning fast servers to land (see wait_for_mcp_discovery).
global _mcp_discovery_thread
_mcp_discovery_thread = _mcp_thread
if not write_json({
"jsonrpc": "2.0",
"method": "event",
"params": {"type": "gateway.ready", "payload": {"skin": resolve_skin()}},
}):
_log_exit("startup write failed (broken stdout pipe before first event)")
sys.exit(0)
for raw in sys.stdin:
line = raw.strip()
if not line:
continue
try:
req = json.loads(line)
except json.JSONDecodeError:
if not write_json({"jsonrpc": "2.0", "error": {"code": -32700, "message": "parse error"}, "id": None}):
_log_exit("parse-error-response write failed (broken stdout pipe)")
sys.exit(0)
continue
method = req.get("method") if isinstance(req, dict) else None
resp = dispatch(req)
if resp is not None:
if not write_json(resp):
_log_exit(f"response write failed for method={method!r} (broken stdout pipe)")
sys.exit(0)
_log_exit("stdin EOF (TUI closed the command pipe)")
if __name__ == "__main__":
main()
+126
View File
@@ -0,0 +1,126 @@
"""Best-effort WebSocket publisher transport for the PTY-side gateway.
The dashboard's `/api/pty` spawns `hermes --tui` as a child process, which
spawns its own ``tui_gateway.entry``. Tool/reasoning/status events fire on
*that* gateway's transport — three processes removed from the dashboard
server itself. To surface them in the dashboard sidebar (`/api/events`),
the PTY-side gateway opens a back-WS to the dashboard at startup and
mirrors every emit through this transport.
Wire protocol: newline-framed JSON dicts (the same shape the dispatcher
already passes to ``write``). No JSON-RPC envelope here — the dashboard's
``/api/pub`` endpoint just rebroadcasts the bytes verbatim to subscribers.
Failure mode: silent. The agent loop must never block waiting for the
sidecar to drain. A dead WS short-circuits all subsequent writes.
Actual ``send`` calls run on a daemon thread so the TeeTransport's
``write`` returns after enqueueing (best-effort; drop when the queue is full).
"""
from __future__ import annotations
import json
import logging
import queue
import threading
from typing import Optional
try:
from websockets.sync.client import connect as ws_connect
except ImportError: # pragma: no cover - websockets is a required install path
ws_connect = None # type: ignore[assignment]
_log = logging.getLogger(__name__)
_DRAIN_STOP = object()
_QUEUE_MAX = 256
class WsPublisherTransport:
__slots__ = ("_url", "_lock", "_ws", "_dead", "_q", "_worker")
def __init__(self, url: str, *, connect_timeout: float = 2.0) -> None:
self._url = url
self._lock = threading.Lock()
self._ws: Optional[object] = None
self._dead = False
self._q: queue.Queue[object] = queue.Queue(maxsize=_QUEUE_MAX)
self._worker: Optional[threading.Thread] = None
if ws_connect is None:
self._dead = True
return
try:
self._ws = ws_connect(url, open_timeout=connect_timeout, max_size=None)
except Exception as exc:
_log.debug("event publisher connect failed: %s", exc)
self._dead = True
self._ws = None
return
self._worker = threading.Thread(
target=self._drain,
name="hermes-ws-pub",
daemon=True,
)
self._worker.start()
def _drain(self) -> None:
while True:
item = self._q.get()
if item is _DRAIN_STOP:
return
if not isinstance(item, str):
continue
if self._ws is None:
continue
try:
with self._lock:
if self._ws is not None:
self._ws.send(item) # type: ignore[union-attr]
except Exception as exc:
_log.debug("event publisher write failed: %s", exc)
self._dead = True
self._ws = None
def write(self, obj: dict) -> bool:
if self._dead or self._ws is None or self._worker is None:
return False
line = json.dumps(obj, ensure_ascii=False)
try:
self._q.put_nowait(line)
return True
except queue.Full:
return False
def close(self) -> None:
self._dead = True
w = self._worker
if w is not None and w.is_alive():
try:
self._q.put_nowait(_DRAIN_STOP)
except queue.Full:
# Best-effort: if the queue is wedged, the daemon thread
# will be torn down with the process.
pass
w.join(timeout=3.0)
self._worker = None
if self._ws is None:
return
try:
with self._lock:
if self._ws is not None:
self._ws.close() # type: ignore[union-attr]
except Exception:
pass
self._ws = None
+49
View File
@@ -0,0 +1,49 @@
"""Rendering bridge — routes TUI content through Python-side renderers.
When agent.rich_output exists, its functions are used. When it doesn't,
everything returns None and the TUI falls back to its own markdown.tsx.
"""
from __future__ import annotations
def render_message(text: str, cols: int = 80) -> str | None:
try:
from agent.rich_output import format_response
except ImportError:
return None
try:
return format_response(text, cols=cols)
except TypeError:
return format_response(text)
except Exception:
return None
def render_diff(text: str, cols: int = 80) -> str | None:
try:
from agent.rich_output import render_diff as _rd
except ImportError:
return None
try:
return _rd(text, cols=cols)
except TypeError:
return _rd(text)
except Exception:
return None
def make_stream_renderer(cols: int = 80):
try:
from agent.rich_output import StreamingRenderer
except ImportError:
return None
try:
return StreamingRenderer(cols=cols)
except TypeError:
return StreamingRenderer()
except Exception:
return None
+10305
View File
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
"""Persistent slash-command worker — one HermesCLI per TUI session.
Protocol: reads JSON lines from stdin {id, command}, writes {id, ok, output|error} to stdout.
"""
import argparse
import contextlib
import io
import json
import os
import sys
import threading
import time
import psutil
import cli as cli_mod
from cli import HermesCLI
from rich.console import Console
# Env-overridable so the integration test can drive sub-second timing.
def _env_float(name: str, default: float) -> float:
"""Parse a float env knob, falling back to ``default`` on absent/malformed
values. A bare ``float(os.environ.get(...))`` would raise ValueError at
import time on a typo (e.g. ``HERMES_SLASH_WATCHDOG_POLL_S=2s``) and kill
the worker before it can serve a single command."""
raw = os.environ.get(name)
if not raw:
return default
try:
return float(raw)
except (TypeError, ValueError):
return default
_WATCHDOG_POLL_S = max(0.05, _env_float("HERMES_SLASH_WATCHDOG_POLL_S", 2.0))
_ORPHAN_GRACE_S = max(0.0, _env_float("HERMES_SLASH_WATCHDOG_GRACE_S", 5.0))
_in_flight = threading.Event() # set while a command is executing
def _is_orphaned(original_ppid, parent_create_time, getppid=os.getppid) -> bool:
"""True once our spawning gateway is gone. Compare to the ORIGINAL ppid
(never ==1: Linux reparents to a subreaper) and guard PID reuse via
create_time."""
if getppid() != original_ppid:
return True
try:
if not psutil.pid_exists(original_ppid):
return True
return psutil.Process(original_ppid).create_time() != parent_create_time
except psutil.Error:
return True
def _start_parent_death_watchdog(original_ppid, parent_create_time) -> None:
def _loop():
while not _is_orphaned(original_ppid, parent_create_time):
time.sleep(_WATCHDOG_POLL_S)
deadline = time.monotonic() + _ORPHAN_GRACE_S
while _in_flight.is_set() and time.monotonic() < deadline:
time.sleep(0.05) # let an in-flight command finish/flush
os._exit(0)
threading.Thread(target=_loop, daemon=True).start()
def _run(cli: HermesCLI, command: str) -> str:
cmd = (command or "").strip()
if not cmd:
return ""
if not cmd.startswith("/"):
cmd = f"/{cmd}"
buf = io.StringIO()
# Rich Console captures its file handle at construction time, so
# contextlib.redirect_stdout won't affect it. Swap the console's
# underlying file to our buffer so self.console.print() is captured.
cli.console = Console(file=buf, force_terminal=True, width=120)
old = getattr(cli_mod, "_cprint", None)
if old is not None:
cli_mod._cprint = lambda text: print(text)
try:
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
cli.process_command(cmd)
finally:
if old is not None:
cli_mod._cprint = old
return buf.getvalue().rstrip()
def main():
p = argparse.ArgumentParser(add_help=False)
p.add_argument("--session-key", required=True)
p.add_argument("--model", default="")
args = p.parse_args()
os.environ["HERMES_SESSION_KEY"] = args.session_key
os.environ["HERMES_INTERACTIVE"] = "1"
# Start before the (hundreds-of-ms) HermesCLI build — that window is itself
# an orphan risk if the gateway dies mid-spawn.
orig_ppid = os.getppid()
try:
parent_create_time = psutil.Process(orig_ppid).create_time()
except psutil.Error:
parent_create_time = 0.0
_start_parent_death_watchdog(orig_ppid, parent_create_time)
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
cli = HermesCLI(model=args.model or None, compact=True, resume=args.session_key, verbose=False)
for raw in sys.stdin:
line = raw.strip()
if not line:
continue
_in_flight.set()
rid = None
try:
req = json.loads(line)
rid = req.get("id")
out = _run(cli, req.get("command", ""))
sys.stdout.write(json.dumps({"id": rid, "ok": True, "output": out}) + "\n")
sys.stdout.flush()
except Exception as e:
sys.stdout.write(json.dumps({"id": rid, "ok": False, "error": str(e)}) + "\n")
sys.stdout.flush()
finally:
_in_flight.clear()
if __name__ == "__main__":
main()
+219
View File
@@ -0,0 +1,219 @@
"""Transport abstraction for the tui_gateway JSON-RPC server.
Historically the gateway wrote every JSON frame directly to real stdout. This
module decouples the I/O sink from the handler logic so the same dispatcher
can be driven over stdio (``tui_gateway.entry``) or WebSocket
(``tui_gateway.ws``) without duplicating code.
A :class:`Transport` is anything that can accept a JSON-serialisable dict and
forward it to its peer. The active transport for the current request is
tracked in a :class:`contextvars.ContextVar` so handlers — including those
dispatched onto the worker pool — route their writes to the right peer.
Backward compatibility
----------------------
``tui_gateway.server.write_json`` still works without any transport bound.
When nothing is on the contextvar and no session-level transport is found,
it falls back to the module-level :class:`StdioTransport`, which wraps the
original ``_real_stdout`` + ``_stdout_lock`` pair. Tests that monkey-patch
``server._real_stdout`` continue to work because the stdio transport resolves
the stream lazily through a callback.
"""
from __future__ import annotations
import contextvars
import errno
import json
import logging
import os
import threading
from typing import Any, Callable, Optional, Protocol, runtime_checkable
# Errno values that mean "the peer is gone" rather than "the host has a
# real I/O problem". Anything outside this set re-raises so it surfaces
# in the crash log instead of looking like a clean disconnect.
_PEER_GONE_ERRNOS = frozenset({
errno.EPIPE, # write to closed pipe (POSIX)
errno.ECONNRESET, # peer reset the connection
errno.EBADF, # fd closed under us
errno.ESHUTDOWN, # transport endpoint shut down
getattr(errno, "WSAECONNRESET", -1), # win32 mapping (no-op on POSIX)
getattr(errno, "WSAESHUTDOWN", -1),
} - {-1})
logger = logging.getLogger(__name__)
# Optional knob: when true, StdioTransport does not call ``stream.flush``
# after writing. Use this on environments where a half-closed pipe (TUI
# Node parent quit while the gateway is still emitting events) makes
# flush block long enough to starve the rest of the worker pool.
#
# IMPORTANT: Python text stdout is fully buffered when attached to a
# pipe (the TUI case), so this knob ONLY makes sense when the gateway
# is launched with ``-u`` or ``PYTHONUNBUFFERED=1``. Without one of
# those, JSON-RPC frames will accumulate in the buffer and the TUI
# will hang waiting for ``gateway.ready``. Default stays off so the
# existing flush-after-write behaviour is unchanged.
_DISABLE_FLUSH = (os.environ.get("HERMES_TUI_GATEWAY_NO_FLUSH", "") or "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
@runtime_checkable
class Transport(Protocol):
"""Minimal interface every transport implements."""
def write(self, obj: dict) -> bool:
"""Emit one JSON frame. Return ``False`` when the peer is gone."""
def close(self) -> None:
"""Release any resources owned by this transport."""
_current_transport: contextvars.ContextVar[Optional[Transport]] = (
contextvars.ContextVar(
"hermes_gateway_transport",
default=None,
)
)
def current_transport() -> Optional[Transport]:
"""Return the transport bound for the current request, if any."""
return _current_transport.get()
def bind_transport(transport: Optional[Transport]):
"""Bind *transport* for the current context. Returns a token for :func:`reset_transport`."""
return _current_transport.set(transport)
def reset_transport(token) -> None:
"""Restore the transport binding captured by :func:`bind_transport`."""
_current_transport.reset(token)
class StdioTransport:
"""Writes JSON frames to a stream (usually ``sys.stdout``).
The stream is resolved via a callable so runtime monkey-patches of the
underlying stream continue to work — this preserves the behaviour the
existing test suite relies on (``monkeypatch.setattr(server, "_real_stdout", ...)``).
"""
__slots__ = ("_stream_getter", "_lock")
def __init__(self, stream_getter: Callable[[], Any], lock: threading.Lock) -> None:
self._stream_getter = stream_getter
self._lock = lock
def write(self, obj: dict) -> bool:
"""Return ``True`` on success, ``False`` ONLY when the peer is gone.
Returning ``False`` is the dispatcher's "broken stdout pipe" signal
— ``entry.py`` calls ``sys.exit(0)`` when ``write_json`` reports
``False``. So programming errors (non-JSON-safe payloads, encoding
misconfig, unexpected ValueErrors, host I/O bugs like ENOSPC) MUST
NOT return ``False``, otherwise a real bug looks like a clean
disconnect and is harder to diagnose. Those re-raise so the
existing crash-log infrastructure records the traceback.
Peer-gone branches:
* ``BrokenPipeError``
* ``ValueError("...closed file...")``
* ``OSError`` whose errno is in :data:`_PEER_GONE_ERRNOS`
(EPIPE / ECONNRESET / EBADF / ESHUTDOWN; plus WSA mappings
on Windows). Other OSError errnos (ENOSPC, EACCES, ...) are
real host problems and re-raise.
"""
# Serialization is OUTSIDE the lock so a large payload can't
# block other threads emitting their own frames. A non-JSON-safe
# payload is a programming error: re-raise so the crash log
# captures it instead of silently exiting via the False path.
line = json.dumps(obj, ensure_ascii=False) + "\n"
with self._lock:
stream = self._stream_getter()
try:
stream.write(line)
except BrokenPipeError:
return False
except ValueError as e:
# ValueError("I/O operation on closed file") is the
# ONLY ValueError that means "peer gone". Anything
# else — including UnicodeEncodeError, which is a
# ValueError subclass for misconfigured locales —
# is a real bug; re-raise so it surfaces in the crash log.
if isinstance(e, UnicodeEncodeError) or "closed file" not in str(e):
raise
return False
except OSError as e:
if e.errno not in _PEER_GONE_ERRNOS:
raise
logger.debug("StdioTransport write peer gone: %s", e)
return False
# A flush that *raises* with a peer-gone errno means the
# dispatcher should exit cleanly. A flush that *hangs* on
# a half-closed pipe holds the lock until it returns — see
# ``_DISABLE_FLUSH`` for the "skip flush entirely" escape
# hatch.
if not _DISABLE_FLUSH:
try:
stream.flush()
except BrokenPipeError:
return False
except ValueError as e:
if isinstance(e, UnicodeEncodeError) or "closed file" not in str(e):
raise
return False
except OSError as e:
if e.errno not in _PEER_GONE_ERRNOS:
raise
logger.debug("StdioTransport flush peer gone: %s", e)
return False
return True
def close(self) -> None:
return None
class TeeTransport:
"""Mirrors writes to one primary plus N best-effort secondaries.
The primary's return value (and exceptions) determine the result —
secondaries swallow failures so a wedged sidecar never stalls the
main IO path. Used by the PTY child so every dispatcher emit lands
on stdio (Ink) AND on a back-WS feeding the dashboard sidebar.
"""
__slots__ = ("_primary", "_secondaries")
def __init__(self, primary: "Transport", *secondaries: "Transport") -> None:
self._primary = primary
self._secondaries = secondaries
def write(self, obj: dict) -> bool:
# Primary first so a slow sidecar (WS publisher) never delays Ink/stdio.
ok = self._primary.write(obj)
for sec in self._secondaries:
try:
sec.write(obj)
except Exception:
pass
return ok
def close(self) -> None:
try:
self._primary.close()
finally:
for sec in self._secondaries:
try:
sec.close()
except Exception:
pass
+340
View File
@@ -0,0 +1,340 @@
"""WebSocket transport for the tui_gateway JSON-RPC server.
Reuses :func:`tui_gateway.server.dispatch` verbatim so every RPC method, every
slash command, every approval/clarify/sudo flow, and every agent event flows
through the same handlers whether the client is Ink over stdio or an iOS /
web client over WebSocket.
Wire protocol
-------------
Identical to stdio: newline-delimited JSON-RPC in both directions. The server
emits a ``gateway.ready`` event immediately after connection accept, then
echoes responses/events for inbound requests. No framing differences.
Mounting
--------
from fastapi import WebSocket
from tui_gateway.ws import handle_ws
@app.websocket("/api/ws")
async def ws(ws: WebSocket):
await handle_ws(ws)
"""
from __future__ import annotations
import asyncio
import concurrent.futures
import json
import logging
import socket
from typing import Any
from tui_gateway import server
_log = logging.getLogger(__name__)
# Max seconds a pool-dispatched handler will block waiting for the event loop
# to flush a WS frame before we mark the transport dead. Protects handler
# threads from a wedged socket.
_WS_WRITE_TIMEOUT_S = 10.0
_WS_LOG_PAYLOAD_PREVIEW = 240
# Keep starlette optional at import time; handle_ws uses the real class when
# it's available and falls back to a generic Exception sentinel otherwise.
try:
from starlette.websockets import WebSocketDisconnect as _WebSocketDisconnect
except ImportError: # pragma: no cover - starlette is a required install path
_WebSocketDisconnect = Exception # type: ignore[assignment]
class WSTransport:
"""Per-connection WS transport.
``write`` is safe to call from any thread *other than* the event loop
thread that owns the socket. Pool workers (the only real caller) run in
their own threads, so marshalling onto the loop via
:func:`asyncio.run_coroutine_threadsafe` + ``future.result()`` is correct
and deadlock-free there.
When called from the loop thread itself (e.g. by ``handle_ws`` for an
inline response) the same call would deadlock: we'd schedule work onto
the loop we're currently blocking. We detect that case and fire-and-
forget instead. Callers that need to know when the bytes are on the wire
should use :meth:`write_async` from the loop thread.
"""
def __init__(
self,
ws: Any,
loop: asyncio.AbstractEventLoop,
*,
peer: str = "unknown",
) -> None:
self._ws = ws
self._loop = loop
self._peer = peer
self._closed = False
def write(self, obj: dict) -> bool:
if self._closed:
return False
line = json.dumps(obj, ensure_ascii=False)
try:
on_loop = asyncio.get_running_loop() is self._loop
except RuntimeError:
on_loop = False
if on_loop:
# Fire-and-forget — don't block the loop waiting on itself.
self._loop.create_task(self._safe_send(line))
return True
try:
from agent.async_utils import safe_schedule_threadsafe
fut = safe_schedule_threadsafe(self._safe_send(line), self._loop)
if fut is None:
self._closed = True
return False
fut.result(timeout=_WS_WRITE_TIMEOUT_S)
return not self._closed
except concurrent.futures.TimeoutError: # builtin TimeoutError on 3.11+
# The event loop is stalled (GIL-heavy agent turn, delegation
# running N children), NOT the socket dead. The send coroutine is
# already scheduled and will flush once the loop breathes — latching
# _closed here permanently silenced live windows after one slow
# write (the "subagent window shows zero streaming" bug). Unblock
# the worker thread and keep the transport alive; _safe_send latches
# on a real socket error when the frame actually fails.
_log.warning(
"ws write slow (loop stalled >%ss) peer=%s — frame left in flight",
_WS_WRITE_TIMEOUT_S, self._peer,
)
return not self._closed
except Exception as exc:
self._closed = True
_log.warning(
"ws write failed peer=%s error_type=%s error=%s",
self._peer, type(exc).__name__, exc,
)
return False
async def write_async(self, obj: dict) -> bool:
"""Send from the owning event loop. Awaits until the frame is on the wire."""
if self._closed:
return False
await self._safe_send(json.dumps(obj, ensure_ascii=False))
return not self._closed
async def _safe_send(self, line: str) -> None:
try:
await self._ws.send_text(line)
except Exception as exc:
self._closed = True
_log.warning(
"ws send failed peer=%s error_type=%s error=%s",
self._peer, type(exc).__name__, exc,
)
def close(self) -> None:
self._closed = True
def _ws_peer_label(ws: Any) -> str:
"""Return ``host:port`` when available, else a stable placeholder."""
client = getattr(ws, "client", None)
if client is None:
return "unknown"
host = getattr(client, "host", None) or "unknown"
port = getattr(client, "port", None)
return f"{host}:{port}" if port is not None else host
def _disable_nagle(ws: Any) -> None:
"""Disable Nagle so streamed JSON-RPC frames go out individually.
Without it the kernel coalesces the small per-token frames, so a burst after
the model's think-pause lands on the client in one tick and no client-side
smoothing can recover the cadence. GUI/WS only; chat platforms don't hit
this path. Best-effort — skip silently if the socket isn't reachable.
"""
try:
scope = getattr(ws, "scope", None) or {}
transport = (scope.get("extensions") or {}).get("transport") or getattr(ws, "transport", None)
sock = transport.get_extra_info("socket") if transport is not None else None
if sock is not None:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
except Exception as exc: # pragma: no cover - best-effort tuning
_log.debug("ws TCP_NODELAY skip: %s", exc)
async def handle_ws(ws: Any) -> None:
"""Run one WebSocket session. Wire-compatible with ``tui_gateway.entry``."""
peer = _ws_peer_label(ws)
transport: WSTransport | None = None
messages = 0
parse_errors = 0
dispatch_crashes = 0
send_failures = 0
disconnect_reason = "not_connected"
try:
await ws.accept()
disconnect_reason = "connected"
# Push small streamed frames out immediately instead of letting Nagle
# batch them — keeps the live token cadence intact for GUI clients.
_disable_nagle(ws)
_log.info("ws accepted peer=%s", peer)
transport = WSTransport(ws, asyncio.get_running_loop(), peer=peer)
ready_ok = await transport.write_async(
{
"jsonrpc": "2.0",
"method": "event",
"params": {
"type": "gateway.ready",
"payload": {"skin": server.resolve_skin()},
},
}
)
if not ready_ok:
disconnect_reason = "ready_send_failed"
send_failures += 1
_log.error("ws ready frame send failed peer=%s", peer)
return
while True:
try:
raw = await ws.receive_text()
except _WebSocketDisconnect as exc:
disconnect_reason = (
"client_disconnect("
f"code={getattr(exc, 'code', None)},"
f"reason={getattr(exc, 'reason', None)})"
)
break
except Exception:
disconnect_reason = "receive_failed"
_log.exception("ws receive failed peer=%s", peer)
break
line = raw.strip()
if not line:
continue
messages += 1
try:
req = json.loads(line)
except json.JSONDecodeError as exc:
parse_errors += 1
_log.warning(
"ws parse error peer=%s index=%d error=%s payload=%r",
peer,
messages,
exc,
line[:_WS_LOG_PAYLOAD_PREVIEW],
)
ok = await transport.write_async(
{
"jsonrpc": "2.0",
"error": {"code": -32700, "message": "parse error"},
"id": None,
}
)
if not ok:
disconnect_reason = "send_failed_after_parse_error"
send_failures += 1
_log.warning("ws parse-error reply send failed peer=%s", peer)
break
continue
# dispatch() may schedule long handlers on the pool; it returns
# None in that case and the worker writes the response itself via
# the transport we pass in (a separate thread, so transport.write
# is the safe path there). For inline handlers it returns the
# response dict, which we write here from the loop.
req_id = req.get("id") if isinstance(req, dict) else None
req_method = req.get("method") if isinstance(req, dict) else None
try:
resp = await asyncio.to_thread(server.dispatch, req, transport)
except Exception:
dispatch_crashes += 1
_log.exception(
"ws dispatch crash peer=%s id=%s method=%s",
peer,
req_id,
req_method,
)
ok = await transport.write_async(
{
"jsonrpc": "2.0",
"error": {"code": -32603, "message": "internal error"},
"id": req_id if req_id is not None else None,
}
)
if not ok:
disconnect_reason = "send_failed_after_dispatch_crash"
send_failures += 1
_log.warning(
"ws dispatch-crash reply send failed peer=%s id=%s method=%s",
peer,
req_id,
req_method,
)
break
continue
if resp is not None and not await transport.write_async(resp):
disconnect_reason = "send_failed_after_response"
send_failures += 1
_log.warning(
"ws response send failed peer=%s id=%s method=%s",
peer,
req_id,
req_method,
)
break
finally:
reaped_sessions = 0
detached_sessions = 0
if transport is not None:
transport.close()
# Reap sessions this transport owned (close_on_disconnect sidecar
# sessions) or detach the rest to the drop sentinel so later emits
# don't crash into a closed socket or fall through to desktop stdout
# logs. Detached sessions are handed to the grace-windowed WS-orphan
# reaper inside _close_sessions_for_transport (a quick reconnect /
# session.resume cancels it). This is the single WS-disconnect
# teardown path.
#
# Offloaded: _close_session_by_id does a blocking worker.close()
# (terminate + waits) plus a synchronous DB write — inline that
# would freeze the uvicorn event loop for every other live
# connection.
try:
reaped_sessions, detached_sessions = await asyncio.to_thread(
server._close_sessions_for_transport,
transport,
end_reason="ws_disconnect",
)
except Exception:
_log.exception("ws transport teardown failed peer=%s", peer)
try:
await ws.close()
except Exception as exc:
_log.debug("ws close failed peer=%s error=%s", peer, exc)
_log.info(
"ws closed peer=%s reason=%s messages=%d parse_errors=%d "
"dispatch_crashes=%d send_failures=%d reaped_sessions=%d detached_sessions=%d",
peer,
disconnect_reason,
messages,
parse_errors,
dispatch_crashes,
send_failures,
reaped_sessions,
detached_sessions,
)