From df373c0e3549b6ac167fb9534effedbefbc7aac8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:25:54 -0700 Subject: [PATCH 1/6] =?UTF-8?q?refactor(tui):=20server.py=20=E2=80=94=20ph?= =?UTF-8?q?ase=20helpers=20for=20=5Fstart=5Fagent=5Fbuild,=20shared=20conf?= =?UTF-8?q?ig=20path/expand=20helpers,=20=5Fsession=5Ffor=5Fkey,=20=5FEXPI?= =?UTF-8?q?RING=5FREQUESTS=20table,=20compact=20docstrings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tui_gateway/server.py | 946 +++++++++++++++--------------------------- 1 file changed, 332 insertions(+), 614 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 6987c7ffcd..258222154d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -224,23 +224,16 @@ _detached_ws_transport = _DropTransport() def _prepend_tool_paths(env: dict[str, str]) -> dict[str, str]: - """Prepend Hermes' managed bin, the venv bin dir, and the user-local - bin dir to PATH so slash_worker child processes can resolve - Hermes-managed CLIs (browser-use, uvx, uv) even when the parent - gateway was launched with a minimal PATH (e.g. by the - Desktop/Dashboard app). Managed bin leads, matching the managed-first - resolution policy for the Browser Use CLI.""" + """Prepend managed bin, venv bin and ~/.local/bin to PATH so slash_worker children resolve + Hermes-managed CLIs (browser-use, uvx, uv) under the Desktop's minimal PATH. Managed bin leads + (managed-first policy for the Browser Use CLI).""" managed_bin = "" with contextlib.suppress(Exception): - from hermes_constants import get_hermes_home managed_bin = str(Path(get_hermes_home()) / "bin") venv_bin = str(Path(sys.executable).parent) # /bin (POSIX) or /Scripts (Windows) user_bin = str(Path.home() / ".local" / "bin") existing = env.get("PATH") or "" - env["PATH"] = os.pathsep.join( - [p for p in (managed_bin, venv_bin, user_bin) if p] - + ([existing] if existing else []) - ) + env["PATH"] = os.pathsep.join([p for p in (managed_bin, venv_bin, user_bin) if p] + ([existing] if existing else [])) return env @@ -257,40 +250,22 @@ class _SlashWorker: argv += ["--model", model] self._closed = False from hermes_cli._subprocess_compat import windows_hide_flags - - # The worker runs the agent → needs provider credentials; tier-1 secrets - # (gateway/GitHub/infra) are still stripped. Multi-profile sessions must - # resolve against the session's profile home, via the factory's `extra` - # (applied last, always wins). from tools.environments.local import build_subprocess_env - env = build_subprocess_env( - hermes_subprocess_env(inherit_credentials=True), - scrub_secrets=False, - inherit_profile_home=False, # base already carries the HOME contract - extra={"HERMES_HOME": str(profile_home)} if profile_home else None, - ) - # Hermes venv/user-local bin on PATH so worker children resolve - # Hermes-managed CLIs under the Desktop's minimal PATH. - env = _prepend_tool_paths(env) - # start_new_session=True: otherwise the worker inherits the gateway's - # pgid and mcp_tool's orphan sweep, racing the spawn, killpg()s the TUI - # parent itself (see agent/lsp/client.py, mcp_tool._filter_mcp_children). + # The worker runs the agent → needs provider credentials; tier-1 secrets (gateway/GitHub/ + # infra) are still stripped. Multi-profile sessions resolve against the session's profile + # home via `extra` (applied last, always wins); the base already carries the HOME contract. + env = _prepend_tool_paths(build_subprocess_env( + hermes_subprocess_env(inherit_credentials=True), scrub_secrets=False, + inherit_profile_home=False, extra={"HERMES_HOME": str(profile_home)} if profile_home else None, + )) + # start_new_session: otherwise the worker inherits the gateway's pgid and mcp_tool's orphan + # sweep, racing the spawn, killpg()s the TUI parent itself. errors="replace": bytes invalid + # in the system locale (GBK Windows) must not raise UnicodeDecodeError in the drain threads. self.proc = subprocess.Popen( - argv, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - # Lossy UTF-8: bytes invalid in the system locale (GBK Windows) - # must not raise UnicodeDecodeError in the drain threads. - encoding="utf-8", - errors="replace", - bufsize=1, - cwd=os.getcwd(), - env=env, - creationflags=windows_hide_flags(), - start_new_session=True, + argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", errors="replace", bufsize=1, cwd=os.getcwd(), env=env, + creationflags=windows_hide_flags(), start_new_session=True, ) threading.Thread(target=self._drain_stdout, daemon=True).start() threading.Thread(target=self._drain_stderr, daemon=True).start() @@ -368,20 +343,14 @@ def _load_busy_input_mode() -> str: def _load_interim_assistant_messages() -> bool: - """Return whether interim assistant commentary should be surfaced to UIs. - - Honors ``display.interim_assistant_messages`` (default true). When false, - the tui_gateway does not install ``interim_assistant_callback``, so - interim text from tool-call turns and verify-on-stop candidates is never - emitted as ``message.interim`` — mirroring the messaging gateway's gating. - """ + """``display.interim_assistant_messages`` (default true); when false no ``interim_assistant_callback`` + is installed, so tool-call/verify-on-stop interim text never becomes ``message.interim`` (gateway parity).""" return is_truthy_value(_display_cfg().get("interim_assistant_messages", True)) def _shutdown_sessions() -> None: - # Durable-first: flush every un-flushed transcript (bounded budget) BEFORE - # the slow per-session teardown, so a supervisor SIGKILL mid-shutdown - # can no longer lose them. + # Durable-first: flush every un-flushed transcript (bounded budget) BEFORE the slow + # per-session teardown, so a supervisor SIGKILL mid-shutdown can no longer lose them. with contextlib.suppress(Exception): _flush_sessions_before_exit() with contextlib.suppress(Exception): @@ -437,15 +406,9 @@ def _get_db(): def _db_for_profile(profile: str | None = None): - """Return SessionDB for ``params.profile`` when it differs from launch. - - App-global remote mode passes ``profile`` on session.* RPCs so history/list/ - create operate on that profile's ``state.db``. Launch/own profile → shared - ``_get_db()`` handle (left open). Non-launch profile → a dedicated handle - the caller should ``close()`` (see :func:`_profile_db` contextmanager). - - Returns (db, owns_handle). ``db`` is None when unavailable. - """ + """(db, owns_handle) for ``params.profile`` (app-global remote mode): launch/own profile → the + shared ``_get_db()`` handle (left open); another profile → a dedicated handle the caller must + ``close()`` (see :func:`_profile_db`). ``db`` is None when unavailable.""" profile_home = _profile_home(profile) if profile_home is None: return _get_db(), False @@ -458,30 +421,18 @@ def _db_for_profile(profile: str | None = None): def _transfer_db_to_agent(agent, db) -> bool: - """Hand a DEDICATED profile handle to *agent*, which closes it on teardown. + """Hand a DEDICATED profile ``state.db`` handle to *agent* (``AIAgent.close()`` then releases it). - The build sites open a per-profile ``state.db`` handle, pass it to - ``_make_agent``, and own it until the built agent is the one that will be - torn down. This marks that transfer: from here ``AIAgent.close()`` (reached - via :func:`_teardown_session`) releases the handle, so the caller must stop - closing it. - - Returns True only when the transfer actually happened. It is refused when - *agent* is not holding *this* handle — the build failed before - ``_make_agent``, or the agent was given a different db — because a False - return is what tells the caller the handle is still its own to close. - Never called for the shared launch handle: that one is opened by - ``_get_db()``, outlives every agent, and stays at ``_owns_session_db`` - False. + True only when the transfer happened; False (agent not holding *this* handle — build failed + before ``_make_agent`` or it got a different db) tells the caller the handle is still its own + to close. The shared launch handle never transfers: it outlives every agent, and ownership + would let session.close() tear down the process-wide database. """ if agent is None or db is None: return False try: if getattr(agent, "_session_db", None) is not db: return False - # The shared launch handle must never transfer: identity alone passes - # for it, and ownership would let session.close() tear down the - # process-wide database every other session shares. if db is _get_db(): logger.warning( "Refused transfer of the shared launch SessionDB to a session " @@ -497,14 +448,9 @@ def _transfer_db_to_agent(agent, db) -> bool: def _open_profile_session_db(profile_home): """Open a DEDICATED handle on ``profile_home``'s ``state.db`` — FAIL CLOSED. - A named-profile agent whose profile store cannot be opened must surface a - clear error and never get built against the launch ``state.db``: a silent - fallback bleeds the session's rows and messages into the wrong profile's - store exactly when the profile store is briefly unopenable (locked, - unreadable, mid-restore), and the named profile then looks blank. Callers - let the raised error abort the agent build (deferred builds route it to - the build's ``agent_error`` path) instead of swallowing it back onto the - launch handle. + A silent fallback to the launch ``state.db`` would bleed the session's rows into the wrong + profile's store exactly when the profile store is briefly unopenable (locked, mid-restore), so + callers let the raised error abort the agent build (deferred builds → ``agent_error``). """ from hermes_state import get_shared_session_db db_path = Path(profile_home) / "state.db" @@ -516,14 +462,9 @@ def _open_profile_session_db(profile_home): @contextlib.contextmanager def _profile_db(params: dict | None = None): - """Yield the SessionDB for ``params['profile']`` (app-global remote mode). - - Closes dedicated profile handles; leaves the launch-profile shared handle open. - Yields None when the db is unavailable. - """ - profile = None - if isinstance(params, dict): - profile = (params.get("profile") or "").strip() or None + """Yield the SessionDB for ``params['profile']`` (None when unavailable); closes dedicated + profile handles, leaves the launch-profile shared handle open.""" + profile = (params.get("profile") or "").strip() or None if isinstance(params, dict) else None db, owns = _db_for_profile(profile) try: yield db @@ -534,15 +475,9 @@ def _profile_db(params: dict | None = None): def _response_profile_name(profile: str | None = None) -> str: - """Profile name to report on session.* payloads. - - Prefer the RPC's requested profile when it is a real non-launch profile; - otherwise the process launch profile. - """ + """Profile name for session.* payloads: the requested real non-launch profile, else the launch one.""" name = (profile or "").strip() - if name and _profile_home(name) is not None: - return name - return _current_profile_name() + return name if name and _profile_home(name) is not None else _current_profile_name() def _db_unavailable_error(rid, *, code: int): @@ -551,10 +486,9 @@ def _db_unavailable_error(rid, *, code: int): # ── per-session profile scoping (global remote mode) ─────────────────────────── -# The desktop's app-global remote mode points every profile at this backend, so -# calls carry ``profile``: open that profile's db and bind its HERMES_HOME -# (ContextVar override) for the call so config/skills/model/persistence resolve -# to it. Omitted/own profile → the launch profile. +# The desktop's app-global remote mode points every profile at this backend, so calls carry +# ``profile``: open that profile's db and bind its HERMES_HOME (ContextVar override) for the +# call so config/skills/model/persistence resolve to it. Omitted/own profile → launch profile. def _profile_home(profile: str | None) -> Path | None: """Resolve a named profile's home on THIS host, or None for the launch profile.""" name = (profile or "").strip() @@ -565,31 +499,22 @@ def _profile_home(profile: str | None) -> Path | None: home = Path(profiles_mod.get_profile_dir(name)) except Exception: return None - # Already the launch profile? No override needed. if home.resolve() == Path(_hermes_home).resolve(): - return None + return None # already the launch profile: no override needed if (home / "state.db").exists() or home.exists(): - # Remember every sibling home this backend was asked to serve so the - # change watcher stats its store too (#99333 class). - _served_profile_homes.add(home) + _served_profile_homes.add(home) # the change watcher must stat every served sibling store too return home return None -# Profile homes served by this process besides the launch home — the only -# extra stores the sessions watcher must probe. Empty on single-profile -# installs, so their watcher stays byte-identical (two stats per tick). +# Profile homes served besides the launch home — the only extra stores the sessions watcher +# probes. Empty on single-profile installs, so their watcher stays byte-identical. _served_profile_homes: set[Path] = set() def _profile_scoped(handler): - """Bind ``params['profile']``'s HERMES_HOME around a handler. - - Pets (config + sprites) and projects (projects.db, discovery policy) both - resolve via ``get_hermes_home``. The desktop sends ``profile`` so a single - backend serving every profile in app-global remote mode still hits the - focused profile's home. No-op for the launch profile. - """ + """Bind ``params['profile']``'s HERMES_HOME around a handler (pets/projects resolve via + ``get_hermes_home``, so app-global remote mode still hits the focused profile). No-op for launch.""" def wrapper(rid, params): home = _profile_home(params.get("profile") if isinstance(params, dict) else None) @@ -610,17 +535,9 @@ _CWD_PLACEHOLDERS = {".", "auto", "cwd"} def _configured_cwd_from_cfg(cfg: dict | None) -> str | None: - """Return an absolute, existing ``terminal.cwd`` from a config mapping. - - Returns None for placeholders (``.``/``auto``/``cwd``), missing values, or - paths that don't resolve to a real directory. - """ - if not isinstance(cfg, dict): - return None - terminal_cfg = cfg.get("terminal") - if not isinstance(terminal_cfg, dict): - return None - raw = str(terminal_cfg.get("cwd") or "").strip() + """Absolute, existing ``terminal.cwd`` from a config mapping; None for placeholders/missing/invalid.""" + terminal_cfg = cfg.get("terminal") if isinstance(cfg, dict) else None + raw = str(terminal_cfg.get("cwd") or "").strip() if isinstance(terminal_cfg, dict) else "" if not raw or raw in _CWD_PLACEHOLDERS: return None resolved = os.path.abspath(os.path.expanduser(raw)) @@ -628,43 +545,26 @@ def _configured_cwd_from_cfg(cfg: dict | None) -> str | None: def _profile_configured_cwd(profile_home: Path | None) -> str | None: - """Resolve a non-launch profile's ``terminal.cwd`` from its own config.yaml. + """A non-launch profile's ``terminal.cwd`` from ITS config.yaml (fail-open → None). - The desktop's app-global remote mode serves every profile from one backend, - so the process-global ``TERMINAL_CWD`` belongs to the *launch* profile. A new - session bound to another profile must take its workspace from THAT profile's - config, not the stale env var (issue #40334). Returns an absolute, existing - directory, or None for placeholders / missing / invalid paths. + The process-global ``TERMINAL_CWD`` belongs to the *launch* profile; a session bound to + another profile must take its workspace from that profile's config, not the stale env var. + load_config() resolves the ACTIVE profile, so read the file directly + the _load_cfg pipeline. """ if profile_home is None: return None try: - from hermes_cli.config import _expand_env_vars, read_user_config_raw + from hermes_cli.config import read_user_config_raw p = Path(profile_home) / "config.yaml" - if not p.exists(): - return None - # load_config() resolves the ACTIVE profile, so read this profile's - # file directly + the same read-side pipeline as _load_cfg. Fail-open. - data = _apply_managed(read_user_config_raw(p)) - expanded = _expand_env_vars(data) - if isinstance(expanded, dict): - data = expanded - return _configured_cwd_from_cfg(data) + return _configured_cwd_from_cfg(_expand_cfg(_apply_managed(read_user_config_raw(p)))) if p.exists() else None except Exception: return None def _launch_configured_cwd() -> str | None: - """Resolve the launch profile's ``terminal.cwd`` from config.yaml. - - Dashboard ``/chat`` for the launch profile attaches to the dashboard - process's in-memory TUI gateway. The Node PTY child receives a bridged - ``TERMINAL_CWD`` env var, but this in-memory process does not — so reading - the process env alone leaves a fresh chat starting in ``os.getcwd()`` - (wherever ``hermes dashboard`` was launched) instead of the configured - ``terminal.cwd``. Read config directly so changing ``terminal.cwd`` affects - new in-memory TUI sessions too. - """ + """Launch profile's ``terminal.cwd`` from config.yaml: the dashboard's in-memory gateway gets no + bridged ``TERMINAL_CWD`` env (only the Node PTY child does), so a fresh /chat would start in + ``os.getcwd()`` unless config is read directly.""" try: return _configured_cwd_from_cfg(_load_cfg()) except Exception: @@ -672,43 +572,27 @@ def _launch_configured_cwd() -> str | None: def _default_session_cwd() -> str: - """Fallback cwd for a session with no explicit / stored / profile cwd. - - Mirrors the launch-config-aware tail of :func:`_completion_cwd` so freshly - created AND resumed sessions land in the configured ``terminal.cwd`` rather - than ``os.getcwd()`` when the in-memory gateway's process env has no bridged - ``TERMINAL_CWD``. - """ + """Fallback cwd for a session with no explicit / stored / profile cwd (mirrors the launch-config-aware + tail of :func:`_completion_cwd`, so created AND resumed sessions land in the configured ``terminal.cwd``).""" return _launch_configured_cwd() or os.getenv("TERMINAL_CWD") or os.getcwd() def write_json(obj: dict) -> bool: - """Emit one JSON frame. Routes via the most-specific transport available. + """Emit one JSON frame via the most-specific transport. - Precedence: - - 1. Event frames with a session id → the transport stored on that session, - so async events land with the client that owns the session even if - the emitting thread has no contextvar binding. - 2. Otherwise the transport bound on the current context (set by - :func:`dispatch` for the lifetime of a request). - 3. Otherwise the module-level stdio transport, matching the historical - behaviour and keeping tests that monkey-patch ``_real_stdout`` green. - - Every routed event frame is stamped with a per-session monotonic - ``seq`` and recorded in the bounded replay ring (tui_gateway.event_replay) - so a WS client can resume losslessly after a reconnect via - ``session.events.since``. + Precedence: (1) event frames with a session id → that session's transport, so async events + reach the owning client even from threads with no contextvar binding; (2) the transport bound + on the current context (:func:`dispatch`); (3) the module stdio transport (historical + behaviour; tests monkey-patch ``_real_stdout``). Every event frame gets a per-session + monotonic ``seq`` + replay-ring entry (event_replay) so ``session.events.since`` can resume. """ + from tui_gateway.event_replay import _stamp_event + _stamp_event(obj) if obj.get("method") == "event": params = obj.get("params") sid = ((params or {}).get("session_id")) if isinstance(params, dict) else "" if sid and (t := (_sessions.get(sid) or {}).get("transport")) is not None: - from tui_gateway.event_replay import _stamp_event - _stamp_event(obj) return t.write(obj) - from tui_gateway.event_replay import _stamp_event - _stamp_event(obj) return (current_transport() or _stdio_transport).write(obj) @@ -723,9 +607,8 @@ def _emit(event: str, sid: str, payload: dict | None = None): write_json(_event_frame(event, sid, payload)) -# Live WS peer transports (maintained by tui_gateway.ws): the only route for -# session-less background events, which write_json would otherwise drop on -# stdio. See _broadcast_global_event. +# Live WS peer transports (maintained by tui_gateway.ws): the only route for session-less +# background events, which write_json would otherwise drop on stdio. See _broadcast_global_event. _live_transports: set[Transport] = set() _live_transports_lock = threading.Lock() @@ -745,12 +628,9 @@ def unregister_live_transport(transport: Transport | None) -> None: def _broadcast_global_event(event: str, payload: dict | None = None) -> None: - """Fan a session-less, surface-global event (``skin.changed``) to every - connected client. Emitters like the skin watcher run on background threads - where ``write_json``'s ladder bottoms out at stdio and WS peers never see - the frame. No registered transports (stdio TUI, tests) → plain ``_emit``, - which that path already tees where it needs to go. - """ + """Fan a session-less, surface-global event (``skin.changed``) to every connected client. + Background emitters (skin watcher) bottom out at stdio in ``write_json``'s ladder, so WS peers + would never see the frame. No registered transports (stdio TUI, tests) → plain ``_emit``.""" with _live_transports_lock: targets = list(_live_transports) if not targets: @@ -760,9 +640,7 @@ def _broadcast_global_event(event: str, payload: dict | None = None) -> None: for transport in targets: try: transport.write(frame) - except Exception: - # One wedged peer must not stall the rest; disconnect teardown - # unregisters it. + except Exception: # one wedged peer must not stall the rest; disconnect teardown unregisters it logger.debug("global-event broadcast write failed type=%s", event, exc_info=True) @@ -787,14 +665,12 @@ def _approval_request_payload(data: dict | None) -> dict: def _pending_clarify_request_payload(sid: str) -> dict | None: - """Read the clarify prompt still blocking a session, if there is one. + """Read-only snapshot of the clarify prompt still blocking a session, if any. - Clarify prompts share `_block()`'s pending registry, so a reconnecting - client whose transport was detached when `clarify.request` was emitted - would otherwise never see the question — the agent thread stays parked on - the Event until timeout. Same replay contract as `pending_approval`: a - read-only snapshot, the registry stays authoritative and `clarify.respond` - with the embedded request_id resolves it. + A client whose transport was detached when `clarify.request` was emitted would otherwise never + see the question (the agent thread stays parked until timeout). Same replay contract as + `pending_approval`: the registry stays authoritative; `clarify.respond` with the embedded + request_id resolves it. """ with _prompt_lock: for rid, (owner_sid, _ev) in _pending.items(): @@ -803,9 +679,8 @@ def _pending_clarify_request_payload(sid: str) -> dict | None: event, prompt_payload = _pending_prompt_payloads.get(rid, ("", {})) if event == "clarify.request": snapshot = dict(prompt_payload) - # Batch clarify: replay the answers locked so far, so a - # reconnecting client restores its per-question ✓ state - # instead of presenting every question as unanswered. + # Batch clarify: replay the answers locked so far so a reconnecting + # client restores its per-question ✓ state. batch = _batch_clarify.get(rid) if batch is not None and batch["answers"]: snapshot["answers"] = dict(batch["answers"]) @@ -831,14 +706,10 @@ def _pending_approval_request_payload(session_key: str) -> dict | None: def _emit_approval_request(sid: str, data: dict | None) -> None: - """Emit an ``approval.request`` event to the TUI client with the command - redacted. The approval payload is built from the RAW command string, so a - credential-shaped value Tirith flagged would otherwise be echoed verbatim - to the TUI client (#48456 — third egress transport alongside the chat - platforms and the SSE/API stream fixed in #50767). Reuse the shared gateway - seam so all approval transports redact consistently.""" - payload = _approval_request_payload(data) - _emit("approval.request", sid, payload) + """Emit ``approval.request`` with the command redacted: the payload is built from the RAW + command, so a credential-shaped value Tirith flagged would otherwise echo verbatim to the + TUI (third egress transport alongside chat platforms and the SSE/API stream).""" + _emit("approval.request", sid, _approval_request_payload(data)) def _status_update(sid: str, kind: str, text: str | None = None): @@ -846,9 +717,8 @@ def _status_update(sid: str, kind: str, text: str | None = None): if not body: return out_kind = kind if text is not None else "status" - # Auto-compaction reaches us as a generic "lifecycle" status. Re-tag it so - # drivers (TUI / desktop) can show an explicit summarizing indicator — - # otherwise idle/preflight compaction looks like a hung turn (#97239). + # Auto-compaction arrives as a generic "lifecycle" status; re-tag so drivers can show a + # summarizing indicator — otherwise idle/preflight compaction looks like a hung turn. if out_kind == "lifecycle": from agent.conversation_compression import is_compaction_progress_status if is_compaction_progress_status(body): @@ -857,11 +727,7 @@ def _status_update(sid: str, kind: str, text: str | None = None): def _estimate_image_tokens(width: int, height: int) -> int: - """Very rough UI estimate for image prompt cost. - - Uses 512px tiles at ~85 tokens/tile as a lightweight cross-provider hint. - This is intentionally approximate and only used for attachment display. - """ + """Rough attachment-display estimate: 512px tiles at ~85 tokens/tile (cross-provider hint).""" if width <= 0 or height <= 0: return 0 return max(1, (width + 511) // 512) * max(1, (height + 511) // 512) * 85 @@ -929,13 +795,9 @@ def handle_request(req: dict) -> dict | None: def _current_session_steer_authority(session_id: str) -> tuple[Transport | None, dict | None]: - """Resolve unforgeable steering authority for this exact RPC context. - - The public session id is only a lookup hint. Authority is the identity of - both the request's ContextVar-bound transport and the live in-memory - session record currently stored under that id. Session transport rebinding, - removal, or id reuse therefore invalidates an earlier generation. - """ + """Unforgeable steering authority for this RPC context: the public session id is only a lookup + hint; authority is the identity of BOTH the ContextVar-bound transport and the live in-memory + record under that id, so transport rebinding, removal or id reuse invalidates an earlier generation.""" transport = current_transport() if transport is None or not session_id: return None, None @@ -952,17 +814,10 @@ def _current_session_steer_authority(session_id: str) -> tuple[Transport | None, def dispatch(req: dict, transport: Optional[Transport] = None) -> dict | None: - """Route inbound RPCs — long handlers to the pool, everything else inline. - - Returns a response dict when handled inline. Returns None when the - handler was scheduled on the pool; the worker writes its own response - via the bound transport when done. - - *transport* (optional): pins every write produced by this request — - including any events emitted by the handler — to the given transport. - Omitting it falls back to the module-level stdio transport, preserving - the original behaviour for ``tui_gateway.entry``. - """ + """Route inbound RPCs — long handlers to the pool (returns None; the worker writes its own + response via the bound transport), everything else inline (returns the response dict). + *transport* pins every write of this request — events included — to that transport; + omitted → the module stdio transport (``tui_gateway.entry`` behaviour).""" t = transport or _stdio_transport token = bind_transport(t) try: @@ -972,9 +827,7 @@ def dispatch(req: dict, transport: Optional[Transport] = None) -> dict | None: _rid, method, _params = normalized if method not in _LONG_HANDLERS: return handle_request(req) - - # Snapshot the context so the pool worker sees the bound transport. - ctx = contextvars.copy_context() + ctx = contextvars.copy_context() # the pool worker must see the bound transport def run(): try: @@ -1005,36 +858,24 @@ _AGENT_BUILD_SLOW_NOTICE_KEY = "agent-build-slow" def _agent_build_wait_cap() -> float: - """Upper bound (seconds) a submitted prompt waits for the deferred agent - build before failing permanently. ``agent.build_wait_timeout`` in - config.yaml overrides the 600s default (raise it for deployments with - many slow/unreachable MCP servers or high-latency provider metadata).""" + """Seconds a submitted prompt waits for the deferred build before failing; ``agent.build_wait_timeout`` + overrides the 600s default (raise it for many slow MCP servers / high-latency provider metadata).""" with contextlib.suppress(Exception): - agent_cfg = _load_cfg().get("agent") or {} - raw = agent_cfg.get("build_wait_timeout") - if raw is not None: - value = float(raw) - if value > 0: - return value + raw = (_load_cfg().get("agent") or {}).get("build_wait_timeout") + if raw is not None and float(raw) > 0: + return float(raw) return 600.0 def _wait_agent_for_prompt(session: dict, rid: str, sid: str) -> dict | None: - """Patient variant of ``_wait_agent`` for the deferred prompt.submit path. + """Patient ``_wait_agent`` for the deferred prompt.submit path. - prompt.submit has already answered ``{"status": "streaming"}`` and the - user's first message IS the turn in flight, while a cold deferred build - (MCP discovery, model-metadata HTTP, skills scan) routinely outlives the - flat 30s ceiling — timing out here silently discarded that first message. - So: keep the prompt attached to this (off-RPC) thread and deliver it when - the build lands; wait in short slices so a cancel is honored promptly; - tell the client once (keyed notice) when the build outlives - ``_AGENT_BUILD_SLOW_NOTICE_AFTER``; fail only when the build thread died - without signalling ready or the bounded cap (``agent.build_wait_timeout``, - default 600s) expired on a genuinely hung build. - - Returns ``None`` on success OR when the turn was cancelled mid-wait (the - caller's cancel branch owns that messaging), an ``_err`` dict otherwise. + prompt.submit already answered ``{"status": "streaming"}`` and the user's first message IS the + turn in flight, while a cold deferred build routinely outlives the flat 30s ceiling — timing + out here silently discarded that message. So: wait in short slices (cancel honored promptly), + notify the client once (keyed) past ``_AGENT_BUILD_SLOW_NOTICE_AFTER``, and fail only when the + build thread died without signalling ready or the bounded cap expired on a hung build. + Returns None on success OR cancel mid-wait (the caller's cancel branch owns that messaging). """ ready = session.get("agent_ready") if ready is None: @@ -1046,46 +887,29 @@ def _wait_agent_for_prompt(session: dict, rid: str, sid: str) -> dict | None: with session["history_lock"]: cancelled = session.get("_turn_cancel_requested") or not session.get("running") if cancelled: - # The caller's cancel/not-running branch emits the user-visible - # event for this — bail without an error of our own. return None waited = time.monotonic() - start if waited >= cap: return _err( - rid, - 5032, + rid, 5032, f"agent initialization timed out after {int(waited)}s — " "your message was not sent; retry once the session is ready", ) build_thread = session.get("_agent_build_thread") - if (build_thread is not None and not build_thread.is_alive() and not ready.is_set()): - # _build's finally guarantees ready.set(); dead thread + unset - # ready = the build died hard — don't wait on a corpse. - return _err( - rid, - 5032, - session.get("agent_error") - or "agent initialization failed before completing", - ) + if build_thread is not None and not build_thread.is_alive() and not ready.is_set(): + # _build's finally guarantees ready.set(); dead thread + unset ready = died hard. + return _err(rid, 5032, session.get("agent_error") or "agent initialization failed before completing") if not notified_slow and waited >= _AGENT_BUILD_SLOW_NOTICE_AFTER: - # One keyed, replace-in-place notice (toast / status bar). - notified_slow = True - _emit( - "notification.show", - sid, - { - "text": ( - "Still starting the agent (tool discovery / model " - "setup) — your message will be sent as soon as it's " - "ready." - ), - "level": "info", - "kind": "agent", - "ttl_ms": None, - "key": _AGENT_BUILD_SLOW_NOTICE_KEY, - "id": _AGENT_BUILD_SLOW_NOTICE_KEY, - }, - ) + notified_slow = True # one keyed, replace-in-place notice (toast / status bar) + _emit("notification.show", sid, { + "text": ( + "Still starting the agent (tool discovery / model " + "setup) — your message will be sent as soon as it's " + "ready." + ), + "level": "info", "kind": "agent", "ttl_ms": None, + "key": _AGENT_BUILD_SLOW_NOTICE_KEY, "id": _AGENT_BUILD_SLOW_NOTICE_KEY, + }) if notified_slow: _emit("notification.clear", sid, {"key": _AGENT_BUILD_SLOW_NOTICE_KEY}) err = session.get("agent_error") @@ -1095,20 +919,17 @@ def _wait_agent_for_prompt(session: dict, rid: str, sid: str) -> dict | None: def _bind_build_profile_scopes(profile_home: str) -> "_TurnScopes": """Bind a session profile's HERMES_HOME / secret / terminal scopes for an agent build. - Fail-open per scope (the build must not die on a scope helper), except that - the terminal scope installer itself fails closed (malformed policy → - refusal scope) so _make_agent's terminal probing / cwd hints resolve the - routed profile, never the launch process. + Fail-open per scope (the build must not die on a scope helper); the terminal installer itself + fails closed (malformed policy → refusal scope) so _make_agent's terminal probing / cwd hints + resolve the routed profile, never the launch process. """ scopes = _TurnScopes() scopes.home = set_hermes_home_override(profile_home) with contextlib.suppress(Exception): from agent.secret_scope import build_profile_secret_scope, set_secret_scope - scopes.secret = set_secret_scope(build_profile_secret_scope(Path(profile_home))) try: from tools.terminal_scope import install_profile_terminal_scope - scopes.terminal = install_profile_terminal_scope(Path(profile_home)) except Exception: scopes.terminal = None @@ -1121,41 +942,28 @@ def _release_build_profile_scopes(scopes: "_TurnScopes") -> None: if scopes.secret is not None: with contextlib.suppress(Exception): from agent.secret_scope import reset_secret_scope - reset_secret_scope(scopes.secret) if scopes.terminal is not None: with contextlib.suppress(Exception): from tools.terminal_scope import reset_terminal_scope - reset_terminal_scope(scopes.terminal) def _deferred_build_agent_kwargs(current: dict, session_db) -> dict: """_make_agent kwargs for a deferred (first-prompt) build. - A lazy-resumed (watch) session carries the stored conversation id so the - upgrade continues that session instead of starting a fresh one under the - same key. A cold deferred resume restores the full persisted runtime - identity exactly as the eager resume path's _stored_session_runtime_overrides - splat did, so a deferred build can't drop the provider and fail with "No LLM - provider configured". When there is no stored runtime, or its provider no - longer resolves (renamed/removed), fall back to the model/effort/tier the - desktop picked for THIS session, else the configured default — never sink - agent init with "Unknown provider". + A lazy-resumed (watch) session carries the stored conversation id so the upgrade continues it. + A cold deferred resume restores the full persisted runtime identity (as the eager resume's + _stored_session_runtime_overrides splat does) so the build can't drop the provider ("No LLM + provider configured"). No stored runtime, or its provider no longer resolves → the model/effort/ + tier the desktop picked for THIS session, else the configured default — never "Unknown provider". """ - kw = { - "session_db": session_db, - "context_cwd_is_launch_artifact": _context_cwd_is_launch_artifact(current), - } + kw = {"session_db": session_db, "context_cwd_is_launch_artifact": _context_cwd_is_launch_artifact(current)} if resume_sid := current.get("resume_session_id"): kw["session_id"] = resume_sid kw["platform_override"] = _session_source(current) resume_overrides = current.get("resume_runtime_overrides") - if ( - isinstance(resume_overrides, dict) - and resume_overrides - and _overrides_have_routable_provider(resume_overrides) - ): + if isinstance(resume_overrides, dict) and resume_overrides and _overrides_have_routable_provider(resume_overrides): kw.update(resume_overrides) else: if override := current.get("model_override"): @@ -1168,28 +976,21 @@ def _deferred_build_agent_kwargs(current: dict, session_db) -> dict: def _wire_session_agent(sid: str, key: str, agent) -> bool: - """Common post-build wiring for a session agent; returns whether notify registered. + """Post-build wiring for a session agent; returns whether the approval notify got registered. - Approval prompts route to the client; the self-improvement review's "💾 …" - summary is emitted as review.summary so the TUI/desktop render it in the - transcript (the CLI prints it via prompt_toolkit; the TUI has no print - surface), honoring display.memory_notifications like the gateway and CLI. + Approval prompts route to the client; the self-improvement review's "💾 …" summary is emitted + as review.summary (the TUI has no print surface), honoring display.memory_notifications. """ notify_registered = False with contextlib.suppress(Exception): from tools.approval import load_permanent_allowlist, register_gateway_notify - register_gateway_notify(key, lambda data: _emit_approval_request(sid, data)) notify_registered = True load_permanent_allowlist() _wire_callbacks(sid) - try: - agent.background_review_callback = lambda message, _sid=sid: _emit( - "review.summary", _sid, {"text": str(message)} - ) + with contextlib.suppress(Exception): # bare agents without the attribute must not break startup + agent.background_review_callback = lambda message, _sid=sid: _emit("review.summary", _sid, {"text": str(message)}) agent.memory_notifications = _load_memory_notifications() - except Exception: - pass # bare agents without the attribute must not break startup return notify_registered @@ -1201,20 +1002,83 @@ def _start_session_services(sid: str, key: str, current: dict) -> None: _notify_session_boundary("on_session_reset", key, _session_source(current)) +def _await_resume_history(sid: str, current: dict) -> bool: + """Block on a cold resume's transcript hydration; False when this record was replaced meanwhile.""" + history_ready = current.get("resume_history_ready") + if history_ready is None: + return True + if not history_ready.wait(timeout=300.0): + raise TimeoutError("session history hydration timed out") + if history_error := current.get("resume_history_error"): + raise RuntimeError(str(history_error)) + with _sessions_lock: + return _sessions.get(sid) is current + + +def _attach_built_agent(current: dict, agent) -> None: + """Attach a freshly built agent to its live record (session DB row deferred to first run_conversation()).""" + # Bot Mode gate hint: the DB title lands post-first-turn but the system prompt builds at + # turn START, so hand the agent its title. + if _title_hint := str(current.get("pending_title") or "").strip(): + agent._session_title_hint = _title_hint + current["agent"] = agent + _session_todo_state(current) + # Baseline for the per-turn config sync (profile home override still active). + current["config_model_seen"] = _config_model_target() + + +def _announce_built_agent(sid: str, key: str, current: dict, agent) -> None: + """Post-wiring tail of a build: credits seed, session services, session.info, late MCP catch-up.""" + # Credits notices at session OPEN (after notice_callback is wired) so depletion warnings show at "ready". + with contextlib.suppress(Exception): + from agent.credits_tracker import seed_credits_at_session_start + seed_credits_at_session_start(agent) + _start_session_services(sid, key, current) + info = _session_info(agent, current) + if cfg_warn := _probe_config_health(_load_cfg()): + info["config_warning"] = cfg_warn + logger.warning(cfg_warn) + _emit("session.info", sid, info) + # MCP servers slower than _make_agent's bounded discovery wait are missing from the tool + # list; catch up once they land (cache-safe: pre-first-turn only). + _schedule_mcp_late_refresh(sid, agent) + + +def _finish_agent_build(sid: str, key: str, current: dict, *, notify_registered: bool, scopes, session_db) -> None: + """Release build scopes and settle ownership of the late notify registration + dedicated db handle.""" + if scopes is not None: + _release_build_profile_scopes(scopes) + # _attach_worker already closed the worker if this session was reaped mid-build; only the + # late notify registration can still leak (session.close unregistered before _build registered). + with _sessions_lock: + replaced = _sessions.get(sid) is not current + if replaced and notify_registered: + with contextlib.suppress(Exception): + from tools.approval import unregister_gateway_notify + unregister_gateway_notify(key) + # Dedicated profile handle: hand it to the agent that will actually be torn down, or close + # it when no such agent exists (build failed, or `replaced`: this agent is discarded and + # _teardown_session never reaches it). + if session_db is not None: + built = None if replaced else current.get("agent") + if not _transfer_db_to_agent(built, session_db): + with contextlib.suppress(Exception): + session_db.close() + + def _start_agent_build(sid: str, session: dict) -> None: """Start building the real AIAgent for a TUI session, once. - Deferred until the first prompt (or any command that needs the agent) so - the composer is responsive instead of blocked on tool discovery / model - metadata; the ready/error event contract for the frontend is unchanged. + Deferred until the first prompt (or any command that needs the agent) so the composer is + responsive instead of blocked on tool discovery / model metadata; the ready/error event + contract for the frontend is unchanged. """ ready = session.get("agent_ready") if ready is None: return - # A lazy watch session spectating an in-flight child must stay lazy so the - # subagent live-mirror keeps flowing (the mirror bails once agent is set); - # incidental RPCs resolve through _sess() and would otherwise upgrade it - # mid-stream. Once the child completes the guard lifts. + # A lazy watch session spectating an in-flight child must stay lazy so the subagent + # live-mirror keeps flowing (the mirror bails once agent is set); incidental RPCs resolve + # through _sess() and would otherwise upgrade it mid-stream. Lifts once the child completes. if session.get("lazy") and _child_run_active(str(session.get("session_key") or "")): return lock = session.setdefault("agent_build_lock", threading.Lock()) @@ -1222,9 +1086,7 @@ def _start_agent_build(sid: str, session: dict) -> None: if ready.is_set() or session.get("agent_build_started"): return session["agent_build_started"] = True - # An upgrading lazy session is now genuinely mid-construction — restore - # its "still starting" eviction exemption. - session.pop("lazy", None) + session.pop("lazy", None) # now genuinely mid-construction: restore the "still starting" eviction exemption key = session["session_key"] def _build() -> None: @@ -1233,111 +1095,47 @@ def _start_agent_build(sid: str, session: dict) -> None: if current is None: ready.set() return - notify_registered = False scopes = None session_db = None profile_home = current.get("profile_home") try: - history_ready = current.get("resume_history_ready") - if history_ready is not None: - if not history_ready.wait(timeout=300.0): - raise TimeoutError("session history hydration timed out") - if history_error := current.get("resume_history_error"): - raise RuntimeError(str(history_error)) - with _sessions_lock: - if _sessions.get(sid) is not current: - return + if not _await_resume_history(sid, current): + return tokens = _set_session_context(key) - # Build against the session's profile (global-remote): bind its - # HERMES_HOME so config/skills/model resolve to it, and hand the - # agent that profile's db so turns persist to the right state.db. + # Build against the session's profile (global-remote): bind its HERMES_HOME so + # config/skills/model resolve to it, and hand the agent that profile's db. The handle + # is DEDICATED and ours until _transfer_db_to_agent in the finally; FAIL CLOSED on open + # failure rather than binding the launch DB and bleeding rows into the wrong state.db. if profile_home: scopes = _bind_build_profile_scopes(profile_home) - # DEDICATED handle, ours until _transfer_db_to_agent in the - # finally; any non-transfer exit must close it. FAIL CLOSED on - # open failure (routes to the except) rather than binding the - # launch DB and bleeding rows into the wrong profile's state.db. session_db = _open_profile_session_db(profile_home) - try: from tui_gateway.entry import ensure_mcp_discovery_started - ensure_mcp_discovery_started() except Exception: logger.warning("MCP discovery startup failed", exc_info=True) - try: agent = _make_agent(sid, key, **_deferred_build_agent_kwargs(current, session_db)) finally: _clear_session_context(tokens) - - # Bot Mode gate hint: the DB title lands post-first-turn but the - # system prompt builds at turn START, so hand the agent its title. - _title_hint = str(current.get("pending_title") or "").strip() - if _title_hint: - agent._session_title_hint = _title_hint - - # Session DB row deferred to first run_conversation() call. - current["agent"] = agent - _session_todo_state(current) - # Baseline for the per-turn config sync (profile home override still active). - current["config_model_seen"] = _config_model_target() - - # No eager slash-worker pre-warm (slash.exec spawns on demand): - # each worker forks the full stdio MCP fleet (~20 processes), and - # live-transport sessions are never reaped, so fleets accumulate - # until the OS refuses new spawns. + _attach_built_agent(current, agent) + # No eager slash-worker pre-warm (slash.exec spawns on demand): each worker forks the + # full stdio MCP fleet, and live-transport sessions are never reaped, so fleets would accumulate. notify_registered = _wire_session_agent(sid, key, agent) - # Credits notices at session OPEN so depletion / usage-band - # warnings show at "ready"; after notice_callback is wired. Fail-open. - with contextlib.suppress(Exception): - from agent.credits_tracker import seed_credits_at_session_start - - seed_credits_at_session_start(agent) - _start_session_services(sid, key, current) - - info = _session_info(agent, current) - cfg_warn = _probe_config_health(_load_cfg()) - if cfg_warn: - info["config_warning"] = cfg_warn - logger.warning(cfg_warn) - _emit("session.info", sid, info) - # MCP servers slower than _make_agent's bounded discovery wait are - # missing from the agent's tool list; catch up once they land - # (cache-safe: pre-first-turn only). - _schedule_mcp_late_refresh(sid, agent) + _announce_built_agent(sid, key, current, agent) except Exception as e: current["agent_error"] = str(e) _emit("error", sid, {"message": f"agent init failed: {e}"}) finally: - if scopes is not None: - _release_build_profile_scopes(scopes) - # _attach_worker already closed the worker if this session was - # reaped mid-build; only the late notify registration can still - # leak (session.close unregistered before _build registered it). - with _sessions_lock: - replaced = _sessions.get(sid) is not current - if replaced and notify_registered: - with contextlib.suppress(Exception): - from tools.approval import unregister_gateway_notify - - unregister_gateway_notify(key) - # Dedicated profile handle: hand it to the agent that will actually - # be torn down, or close it when no such agent exists — the except - # above (nothing holds it) and `replaced` (session reaped mid-build, - # this agent is discarded and _teardown_session never reaches it). - if session_db is not None: - built = None if replaced else current.get("agent") - if not _transfer_db_to_agent(built, session_db): - with contextlib.suppress(Exception): - session_db.close() + _finish_agent_build( + sid, key, current, notify_registered=notify_registered, scopes=scopes, session_db=session_db, + ) ready.set() build_thread = threading.Thread(target=_build, daemon=True) - # Handle for _wait_agent_for_prompt: a dead build thread with agent_ready - # still unset means the build died hard — waiters must not sit out the - # full cap on a corpse. + # Handle for _wait_agent_for_prompt: a dead build thread with agent_ready still unset means + # the build died hard — waiters must not sit out the full cap on a corpse. session["_agent_build_thread"] = build_thread build_thread.start() @@ -1347,15 +1145,12 @@ def _sess_nowait(params, rid): s = _sessions.get(sid) if s: return (s, None) - # Stale runtime id (orphan-reaped, LRU-evicted, or idle-TTL torn down); the - # client should session.resume the STORED id. Log it so a "message vanished" - # report reads as "arrived and was rejected", not "never arrived". + # Stale runtime id (orphan-reaped, LRU-evicted, idle-TTL torn down); the client should + # session.resume the STORED id. Logged so "message vanished" reads as "arrived and was rejected". logger.warning( "session-scoped RPC rejected: method=%s session_id=%r not in memory " "(detached/reaped runtime; client should resume the stored session), rid=%r", - _current_rpc_method.get() or "?", - sid, - rid, + _current_rpc_method.get() or "?", sid, rid, ) return (None, _err(rid, 4001, "session not found")) @@ -1370,13 +1165,9 @@ def _sess(params, rid): def _sess_building(params, rid): """Resolve a session and warm its agent build WITHOUT waiting for it. - For handlers that need the session record but not the agent — the attach - RPCs (image/file/pdf attach, clipboard.paste, image.detach) only read - ``cwd``/``profile_home`` and mutate ``attached_images``, all populated at - record creation. They run inline on the socket reader thread, so waiting - on a cold deferred build there stalled the paste AND every RPC queued - behind it ("text is instant, images hang"). The build is still kicked off - to warm the agent the following ``prompt.submit`` needs. + For the attach RPCs (image/file/pdf attach, clipboard.paste, image.detach), which only touch + record fields populated at creation. They run inline on the socket reader thread, so waiting on + a cold build there stalled the paste AND every RPC behind it ("text is instant, images hang"). """ s, err = _sess_nowait(params, rid) if err: @@ -1402,49 +1193,39 @@ def _coerce_int_config_value(value: Any, default: int, *, min_value: int) -> int def _load_dashboard_process_isolation_config(cfg: dict | None = None) -> dict[str, Any]: - """Return dashboard process-isolation config with read-site defaults. - - ``_load_cfg()`` intentionally returns the user ``config.yaml`` plus the - managed overlay and ``${VAR}`` expansion; it does not deep-merge - ``hermes_cli.config.DEFAULT_CONFIG``. Keep - the Phase-0 defaults here so dashboard runtime and the REST editor's - DEFAULT_CONFIG-backed schema cannot drift. - """ + """Dashboard process-isolation config with read-site defaults: ``_load_cfg()`` does not + deep-merge DEFAULT_CONFIG, so the Phase-0 defaults live here to stay in step with the REST editor.""" root = _load_cfg() if cfg is None else cfg dashboard = root.get("dashboard") if isinstance(root, dict) else {} if not isinstance(dashboard, dict): dashboard = {} return { - "turn_isolation": is_truthy_value( - dashboard.get("turn_isolation"), default=_DASHBOARD_TURN_ISOLATION_DEFAULT, - ), + "turn_isolation": is_truthy_value(dashboard.get("turn_isolation"), default=_DASHBOARD_TURN_ISOLATION_DEFAULT), "compute_host_heartbeat_secs": _coerce_int_config_value( - dashboard.get("compute_host_heartbeat_secs"), - _DASHBOARD_COMPUTE_HOST_HEARTBEAT_SECS_DEFAULT, min_value=1, + dashboard.get("compute_host_heartbeat_secs"), _DASHBOARD_COMPUTE_HOST_HEARTBEAT_SECS_DEFAULT, min_value=1, ), "compute_host_respawn_max": _coerce_int_config_value( - dashboard.get("compute_host_respawn_max"), _DASHBOARD_COMPUTE_HOST_RESPAWN_MAX_DEFAULT, - min_value=0, + dashboard.get("compute_host_respawn_max"), _DASHBOARD_COMPUTE_HOST_RESPAWN_MAX_DEFAULT, min_value=0, ), } -def _load_cfg_raw() -> dict: - """Read the active profile's config.yaml EXACTLY as written (write-back primitive). +def _active_config_path() -> Path: + """config.yaml of the per-session profile override (session.resume) when bound, else the launch home.""" + override = get_hermes_home_override() + return Path(override if isinstance(override, str) and override else _hermes_home) / "config.yaml" - ONLY legal for read→mutate→``_save_cfg`` round-trips (and raw-file - inspection): merging defaults, the managed overlay, or ``${VAR}`` - expansion here would be persisted into the user's file on the next - save. Behavioral reads must use :func:`_load_cfg`, which layers the - managed overlay + env expansion on top of this raw read. + +def _load_cfg_raw() -> dict: + """The active profile's config.yaml EXACTLY as written — the write-back primitive. + + ONLY for read→mutate→``_save_cfg`` round-trips and raw inspection: defaults, the managed overlay + or ``${VAR}`` expansion applied here would be persisted into the user's file on the next save. + Behavioral reads use :func:`_load_cfg`. Cache keyed on the resolved path so profiles don't clobber. """ global _cfg_cache, _cfg_mtime, _cfg_path try: - # Per-session profile override (session.resume) → that profile's - # config; cache keyed on the resolved path so profiles don't clobber. - override = get_hermes_home_override() - home = override if isinstance(override, str) and override else _hermes_home - p = Path(home) / "config.yaml" + p = _active_config_path() mtime = p.stat().st_mtime if p.exists() else None with _cfg_lock: if _cfg_cache is not None and _cfg_mtime == mtime and _cfg_path == p: @@ -1454,48 +1235,39 @@ def _load_cfg_raw() -> dict: data = read_user_config_raw(p) else: data = {} - with _cfg_lock: - # Cache the RAW config: _save_cfg writes _cfg_cache back to disk, - # so managed values must be overlaid read-side only. + with _cfg_lock: # cache the RAW config: _save_cfg writes _cfg_cache back to disk _cfg_cache = copy.deepcopy(data) _cfg_mtime = mtime _cfg_path = p return data except Exception: - pass - return {} + return {} + + +def _expand_cfg(cfg: dict) -> dict: + """``${ENV_VAR}`` expansion (same as ``load_config_readonly``); non-dict results keep the input.""" + from hermes_cli.config import _expand_env_vars + expanded = _expand_env_vars(cfg) + return expanded if isinstance(expanded, dict) else cfg def _load_cfg() -> dict: """Behavioral config read: raw user file + managed overlay + ${VAR} expansion. - Delegates the disk read to :func:`_load_cfg_raw` (shared cache), then - applies the same read-side pipeline as the canonical - ``hermes_cli.config.load_config_readonly`` — managed-scope overlay and - ``${ENV_VAR}`` expansion — minus the DEFAULT_CONFIG merge (callers here - treat a missing key as "unset" and apply their own defaults; merging - would also break ``_load_cfg() == {}`` sentinels). Do NOT pass the - result to ``_save_cfg``: use ``_load_cfg_raw()`` for write-back - round-trips or expanded/overlaid values get persisted into the user's - file. + Same read-side pipeline as ``hermes_cli.config.load_config_readonly`` minus the DEFAULT_CONFIG + merge (callers treat a missing key as "unset"; merging would break ``_load_cfg() == {}`` + sentinels). Never pass the result to ``_save_cfg`` — use ``_load_cfg_raw()`` for write-back. """ cfg = _apply_managed(_load_cfg_raw()) with contextlib.suppress(Exception): - from hermes_cli.config import _expand_env_vars - expanded = _expand_env_vars(cfg) - if isinstance(expanded, dict): - cfg = expanded + cfg = _expand_cfg(cfg) return cfg def _apply_managed(cfg: dict) -> dict: - """Overlay administrator-pinned managed-scope values on a config dict. - - The TUI/desktop backend builds config independently of - hermes_cli.config.load_config, so without this a managed skin / reasoning_effort - / service_tier / provider_routing would be silently ignored here. Read-side - only — the raw user config is what gets cached and saved. Fail-open. - """ + """Overlay administrator-pinned managed-scope values (read-side only, fail-open): this backend + builds config independently of load_config, so a managed skin / reasoning_effort / service_tier + / provider_routing would otherwise be silently ignored.""" try: from hermes_cli import managed_scope return managed_scope.apply_managed_overlay(cfg if isinstance(cfg, dict) else {}) @@ -1506,11 +1278,9 @@ def _apply_managed(cfg: dict) -> dict: def _save_cfg(cfg: dict): global _cfg_cache, _cfg_mtime, _cfg_path from utils import atomic_roundtrip_yaml_save - override = get_hermes_home_override() - path = Path(override if isinstance(override, str) and override else _hermes_home) / "config.yaml" - # Comment-, ordering-, and Unicode-preserving write (a plain safe_dump - # clobbered hand-written configs). Fails closed on an unreadable existing - # config.yaml like atomic_config_write does. + path = _active_config_path() + # Comment-, ordering- and Unicode-preserving write (a plain safe_dump clobbered hand-written + # configs); fails closed on an unreadable existing config.yaml like atomic_config_write. atomic_roundtrip_yaml_save(path, cfg) with _cfg_lock: _cfg_cache = copy.deepcopy(cfg) @@ -1521,54 +1291,38 @@ def _save_cfg(cfg: dict): _cfg_mtime = None -def _cwd_for_session_key(session_key: str) -> str: - """Reverse-map session_key to the session's logical cwd. - - Snapshots ``_sessions`` first: concurrent RPC handlers mutate it from the - thread pool, so iterating the live view risks ``RuntimeError: dictionary - changed size during iteration``. - """ - if not session_key: - return "" +def _session_for_key(session_key: str) -> dict | None: + """First live record with this session_key (snapshot under the lock: pool handlers mutate ``_sessions``).""" with _sessions_lock: - for sess in list(_sessions.values()): - if sess.get("session_key") == session_key: - return str(sess.get("cwd") or "") - return "" + return next((s for s in list(_sessions.values()) if s.get("session_key") == session_key), None) -def _set_session_context( - session_key: str, cwd: str | None = None, *, ui_session_id: str = "", -) -> list: +def _cwd_for_session_key(session_key: str) -> str: + """Reverse-map session_key to the session's logical cwd ("" when unknown).""" + sess = _session_for_key(session_key) if session_key else None + return str(sess.get("cwd") or "") if sess is not None else "" + + +def _set_session_context(session_key: str, cwd: str | None = None, *, ui_session_id: str = "") -> list: try: from gateway.session_context import set_session_vars - - # Ephemeral task ids aren't in `_sessions` (reverse-map → "" would - # clear the cwd override); callers that know the workspace pass it. + # Ephemeral task ids aren't in `_sessions` (reverse-map → "" would clear the cwd override); + # callers that know the workspace pass it. resolved = cwd if cwd is not None else _cwd_for_session_key(session_key) source = _resolve_session_platform() browser_control_principal = "" browser_control_transport_family = "" - # Live conversation id for subprocess HERMES_SESSION_ID: an explicitly - # empty contextvar is authoritative for the subprocess-env bridge (no - # os.environ fallback), so never leave it "". Prefer the agent's durable - # session_id, then session_key (same derivation as session-finalize). + # Live conversation id for subprocess HERMES_SESSION_ID: an explicitly empty contextvar is + # authoritative for the subprocess-env bridge (no os.environ fallback), so never leave it "". + # Prefer the agent's durable session_id, then session_key (same derivation as session-finalize). session_id = session_key - with _sessions_lock: - for sess in list(_sessions.values()): - if sess.get("session_key") == session_key: - source = _session_source(sess) - session_id = (getattr(sess.get("agent"), "session_id", None) or session_key) - transport = sess.get("transport") - identity = getattr(transport, "auth_identity", None) - if _methods_browser_control._is_authenticated_identity(identity): - browser_control_principal = ( - _methods_browser_control._principal_digest(identity) - ) - browser_control_transport_family = ( - _methods_browser_control._CLOUD_TRANSPORT_FAMILY - ) - break + if (sess := _session_for_key(session_key)) is not None: + source = _session_source(sess) + session_id = getattr(sess.get("agent"), "session_id", None) or session_key + identity = getattr(sess.get("transport"), "auth_identity", None) + if _methods_browser_control._is_authenticated_identity(identity): + browser_control_principal = _methods_browser_control._principal_digest(identity) + browser_control_transport_family = _methods_browser_control._CLOUD_TRANSPORT_FAMILY return set_session_vars( session_key=session_key, session_id=session_id, source=source, browser_control_principal=browser_control_principal, @@ -1597,9 +1351,18 @@ def _enable_gateway_prompts() -> None: # ── Blocking prompt factory ────────────────────────────────────────── +# Blocking bridges whose `*.respond` tolerates a late reply (allow_expired=True): on timeout the +# tool returns empty, but a slow renderer can still answer and would otherwise hit a raw 4009, +# so `.expire` tells it to tear the card down. +_EXPIRING_REQUESTS = frozenset({ + "secret.request", "sudo.request", "clarify.request", "terminal.read.request", + "preview.read.request", "preview.act.request", "window.read.request", "mcp.setup.request", + "tour.request", +}) + + def _block( - event: str, sid: str, payload: dict, timeout: float | None = 300, - batch_qids: list[str] | None = None, + event: str, sid: str, payload: dict, timeout: float | None = 300, batch_qids: list[str] | None = None, ) -> str: rid = uuid.uuid4().hex[:8] ev = threading.Event() @@ -1608,19 +1371,15 @@ def _block( payload["request_id"] = rid _pending_prompt_payloads[rid] = (event, dict(payload)) if batch_qids: - # Multi-question clarify: per-question answers accumulate here - # (update-in-place until every qid is locked). Locked answers - # survive a timeout — see the batch read-out below. + # Multi-question clarify: per-question answers accumulate here (update-in-place until + # every qid is locked). Locked answers survive a timeout — see the batch read-out below. _batch_clarify[rid] = {"qids": list(batch_qids), "answers": {}} answered = False - answer = "" - answer_present = False batch_answers: dict | None = None try: _emit(event, sid, payload) - # Natural Event semantics: None → wait forever (clarify configured with - # clarify_timeout <= 0, released only by a real answer or - # session.interrupt), 0 → return immediately, > 0 → bounded wait. + # Natural Event semantics: None → wait forever (clarify_timeout <= 0, released only by a + # real answer or session.interrupt), 0 → return immediately, > 0 → bounded wait. answered = ev.wait(timeout) finally: with _prompt_lock: @@ -1631,37 +1390,27 @@ def _block( batch_state = _batch_clarify.pop(rid, None) if batch_state is not None: batch_answers = dict(batch_state["answers"]) + expire = lambda: _emit(f"{event.removesuffix('.request')}.expire", sid, {"request_id": rid}) if batch_qids is not None: - # Cancel-all (respond with no question_id) resolves via _answers with - # an empty string — that stays a plain cancel, not a partial result. + # Cancel-all (respond with no question_id) resolves via _answers with an empty string — + # that stays a plain cancel, not a partial result. if answer_present: return answer result: dict[str, object] = {"answers": batch_answers or {}} if not answered: - # Deadline hit: keep whatever was locked, tell the tool the rest - # are absences (not skips), and still fire the expire - # notification so live cards tear down. + # Deadline hit: keep whatever was locked, tell the tool the rest are absences (not + # skips), and still fire the expire notification so live cards tear down. result["timed_out"] = True - _emit(f"{event.removesuffix('.request')}.expire", sid, {"request_id": rid}) + expire() return json.dumps(result, ensure_ascii=False) - - # `.expire` on timeout for every blocking bridge whose `*.respond` tolerates - # a late reply (allow_expired=True): the tool returns empty, but a slow - # renderer can still answer and would otherwise hit a raw 4009. - if not answered and not answer_present and event in { - "secret.request", "sudo.request", "clarify.request", "terminal.read.request", - "preview.read.request", "preview.act.request", "window.read.request", "mcp.setup.request", - "tour.request", - }: - _emit(f"{event.removesuffix('.request')}.expire", sid, {"request_id": rid}) + if not answered and not answer_present and event in _EXPIRING_REQUESTS: + expire() return answer def _clarify_timeout_seconds() -> float | None: - """Clarify wait (seconds) for the TUI/desktop bridge, from the same - canonical config the messaging gateway and CLI use. Falls back to the - historical 300s _block default if config can't be read. ``<= 0`` in config - means unlimited and is returned as ``None`` (never auto-skip).""" + """Clarify wait for the TUI/desktop bridge from the canonical config (gateway/CLI parity); 300s + historical default if config can't be read; ``<= 0`` = unlimited → None (never auto-skip).""" try: from tools.clarify_gateway import get_clarify_timeout timeout = get_clarify_timeout() @@ -1673,46 +1422,29 @@ def _clarify_timeout_seconds() -> float | None: def _clarify_block(sid: str, q, c, multi_select=False, questions=None) -> str: """Bridge the clarify tool callback onto _block. - Single-question calls keep the exact historical payload shape (older - renderers never see a new field). Batch calls emit one clarify.request - carrying the question list — only wire fields (qid/question/choices/ - multi_select) are forwarded; the tool-side normalized entries also carry + Single-question payloads keep their exact historical shape (``multi_select`` only when True, + so older renderers never see a new field). Batch calls emit one clarify.request with only the + wire fields (qid/question/choices/multi_select) — the tool-side entries also carry result-assembly keys (id, choices_offered) the renderer must not see. - The tool decodes the JSON reply via its batch answer parser. """ if questions: wire = [ - { - "qid": entry["qid"], "question": entry["question"], "choices": entry["choices"], - "multi_select": bool(entry["multi_select"]), - } - for entry in questions + {"qid": e["qid"], "question": e["question"], "choices": e["choices"], "multi_select": bool(e["multi_select"])} + for e in questions ] return _block( "clarify.request", sid, {"questions": wire}, timeout=_clarify_timeout_seconds(), batch_qids=[entry["qid"] for entry in questions], ) - # multi_select is a pass-through hint older renderers ignore; emitted - # only when True so single-select payloads keep their exact shape. - return _block( - "clarify.request", - sid, - ( - {"question": q, "choices": c, "multi_select": True} - if multi_select - else {"question": q, "choices": c} - ), - timeout=_clarify_timeout_seconds(), - ) + payload = {"question": q, "choices": c, "multi_select": True} if multi_select else {"question": q, "choices": c} + return _block("clarify.request", sid, payload, timeout=_clarify_timeout_seconds()) -# A tour action is a DOM operation the renderer performs and answers straight -# back, so a client that implements the bridge replies in milliseconds. The -# generous deadline exists for one case only: a preview tour's first action -# injects the engine into a live page. +# A tour action is a DOM operation the renderer answers in milliseconds; the generous deadline +# exists only because a preview tour's first action injects the engine into a live page. _TOUR_TIMEOUT_S = 45 -# Until a session's client has proven it answers at all, hold it to a deadline -# a working renderer cannot miss. See _tour_request. +# Until a session's client has proven it answers at all, hold it to a deadline a working +# renderer cannot miss. See _tour_request. _TOUR_PROBE_TIMEOUT_S = 10 _TOUR_BRIDGE_UNAVAILABLE = json.dumps( @@ -1730,23 +1462,15 @@ _TOUR_BRIDGE_UNAVAILABLE = json.dumps( def _tour_request(sid: str, payload: dict) -> str: - """Bridge the tour tool callback onto _block, without paying for a client - that cannot answer it. + """Bridge the tour tool callback onto _block without paying for a client that cannot answer. - The renderer's ``tour.request`` handler and this backend's tool update on - different clocks: against an older app nobody ever calls ``tour.respond`` - and each action blocks for the full deadline, stacking per turn. So a - session's first action gets the short probe deadline; an unanswered probe - marks the bridge unavailable for that session (later calls return at once - with what to fix). Once a client has answered, actions get the full - deadline and one slow action no longer condemns it. The verdict lives on - the session record, so a new session re-probes. + Renderer and backend update on different clocks: against an older app nobody calls + ``tour.respond`` and each action would block the full deadline, stacking per turn. So the + session's first action gets the short probe deadline; an unanswered probe marks the bridge + unavailable for that session; once a client has answered, actions get the full deadline. The + verdict lives on the session record, so a new session re-probes. """ - # A detached caller has no session record; the throwaway keeps it on the - # plain bridge, unprobed. - session = _sessions.get(sid) - if session is None: - session = {} + session = _sessions.get(sid) or {} # detached caller: throwaway record, plain bridge, unprobed state = session.get("tour_bridge") if state == "unanswered": return _TOUR_BRIDGE_UNAVAILABLE @@ -1762,14 +1486,8 @@ def _tour_request(sid: str, payload: dict) -> str: def _clear_pending(sid: str | None = None) -> None: - """Release pending prompts with an empty answer. - - When *sid* is provided, only prompts owned by that session are - released — critical for session.interrupt, which must not - collaterally cancel clarify/sudo/secret prompts on unrelated - sessions sharing the same tui_gateway process. When *sid* is - None, every pending prompt is released (used during shutdown). - """ + """Release pending prompts with an empty answer: only *sid*'s (session.interrupt must not cancel + clarify/sudo/secret prompts on unrelated sessions), or every one when *sid* is None (shutdown).""" with _prompt_lock: for rid, (owner_sid, ev) in list(_pending.items()): if sid is None or owner_sid == sid: From 4023fe4a9bc72a17cab6583bc834b6ceaa45b71a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:34:28 -0700 Subject: [PATCH 2/6] =?UTF-8?q?refactor(tui):=20server.py=20=E2=80=94=20?= =?UTF-8?q?=5Fparse=5Fmodel=5Fconfig/=5Fis=5Froutable=5Fprovider/=5Fenv=5F?= =?UTF-8?q?model=5Fseed=20helpers,=20alias=20tables=20for=20service=20tier?= =?UTF-8?q?=20+=20tool=20progress,=20compact=20runtime-override=20docstrin?= =?UTF-8?q?gs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tui_gateway/server.py | 456 ++++++++++++++---------------------------- 1 file changed, 153 insertions(+), 303 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 258222154d..08f8648a47 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1498,21 +1498,21 @@ def _clear_pending(sid: str | None = None) -> None: # ── Agent factory ──────────────────────────────────────────────────── +def _env_model_seed() -> str: + """The launch-scoped model seed (``hermes --tui -m``, hosted provisioning); "" when unset.""" + return (os.environ.get("HERMES_MODEL", "") or os.environ.get("HERMES_INFERENCE_MODEL", "")).strip() + + def _resolve_model() -> str: - env = ( - os.environ.get("HERMES_MODEL", "") - or os.environ.get("HERMES_INFERENCE_MODEL", "") - ).strip() - if env: + if env := _env_model_seed(): return env m = _load_cfg().get("model", "") if isinstance(m, dict): return str(m.get("default", "") or "").strip() if isinstance(m, str) and m: return m.strip() - # No env seed and no config preference: fall back to the cost-safe silent - # default (catalog-labeled, cache-only read), never an expensive Anthropic - # flagship the user didn't pick. + # No env seed and no config preference: the cost-safe silent default (catalog-labeled, + # cache-only read), never an expensive flagship the user didn't pick. try: from hermes_cli.models import get_preferred_silent_default_model return get_preferred_silent_default_model() @@ -1521,33 +1521,17 @@ def _resolve_model() -> str: def _resolve_session_platform() -> str: - """Resolve the platform tag for a tui_gateway-routed session. - - Stamping the desktop chat panel ``platform="tui"`` makes the agent suggest - TUI-only slash commands to chat-panel users. - * ``HERMES_DESKTOP=1`` with ``HERMES_DESKTOP_TERMINAL`` unset → "desktop" - * ``HERMES_DESKTOP_TERMINAL=1`` → "tui" (embedded terminal pane; the tui - hint's clarifier in system_prompt.py describes the embedding) - * neither → "tui" (standalone ``hermes --tui``) - """ - if is_truthy_value(os.environ.get("HERMES_DESKTOP")) and not is_truthy_value( - os.environ.get("HERMES_DESKTOP_TERMINAL") - ): - return "desktop" - return "tui" + """Platform tag for a tui_gateway-routed session: ``HERMES_DESKTOP=1`` without + ``HERMES_DESKTOP_TERMINAL`` → "desktop" (chat panel; the agent then suggests TUI-only slash + commands), else "tui" (embedded terminal pane or standalone ``hermes --tui``).""" + desktop = is_truthy_value(os.environ.get("HERMES_DESKTOP")) + return "desktop" if desktop and not is_truthy_value(os.environ.get("HERMES_DESKTOP_TERMINAL")) else "tui" def _resolve_session_source(explicit: str | None) -> str: - """Default the session DB ``source`` field from the resolved platform. - - A caller that explicitly passes ``source`` (e.g. a plugin session tagged - ``"telegram"``) keeps its value. Only an empty/None ``source`` falls back - to the env-resolved platform — so env-driven resolution never silently - rewrites a caller's intent. - """ - if explicit: - return explicit - return _resolve_session_platform() + """Session DB ``source``: an explicit caller value (plugin session tagged ``"telegram"``) is + never rewritten; only empty/None falls back to the env-resolved platform.""" + return explicit or _resolve_session_platform() def _resolve_agent_platform(source: str | None) -> str: @@ -1555,40 +1539,24 @@ def _resolve_agent_platform(source: str | None) -> str: def _config_model_target() -> tuple[str, str]: - """(model, provider) currently selected by config.yaml — and ONLY config. + """(model, provider) selected by config.yaml — and ONLY config. - Unlike `_resolve_model()`, this never reads HERMES_MODEL / - HERMES_INFERENCE_MODEL. Those env vars are a launch-scoped seed - (`hermes --tui -m `, hosted-instance provisioning); if they - fed the per-turn sync, the seed would be replayed as a /model switch - and persisted globally, or would pin the session so dashboard/CLI - model changes never reach an open chat. + Never reads HERMES_MODEL / HERMES_INFERENCE_MODEL: that launch-scoped seed fed into the + per-turn sync would be replayed as a /model switch and persisted globally, or pin the session + so dashboard/CLI model changes never reach an open chat. Empty model = "no preference" → no-op sync. """ cfg_model = _load_cfg().get("model") - model = "" - provider = "" if isinstance(cfg_model, dict): - model = str(cfg_model.get("default", "") or "").strip() provider = str(cfg_model.get("provider") or "").strip() - if provider.lower() == "auto": - provider = "" - elif isinstance(cfg_model, str): - model = cfg_model.strip() - # No _resolve_model() fallback: that reads the launch-scoped -m env seed, - # which the per-turn sync would replay as a /model switch and persist - # globally. Empty model = "config expresses no preference" → sync is a no-op. - return model, provider + return str(cfg_model.get("default", "") or "").strip(), "" if provider.lower() == "auto" else provider + return (cfg_model.strip() if isinstance(cfg_model, str) else ""), "" def _resolve_startup_runtime() -> tuple[str, str | None]: model = _resolve_model() - explicit_provider = os.environ.get("HERMES_TUI_PROVIDER", "").strip() - if explicit_provider: + if explicit_provider := os.environ.get("HERMES_TUI_PROVIDER", "").strip(): return model, explicit_provider - explicit_model = ( - os.environ.get("HERMES_MODEL", "") - or os.environ.get("HERMES_INFERENCE_MODEL", "") - ).strip() + explicit_model = _env_model_seed() if not explicit_model: return model, None with contextlib.suppress(Exception): @@ -1599,34 +1567,19 @@ def _resolve_startup_runtime() -> tuple[str, str | None]: or os.environ.get("HERMES_INFERENCE_PROVIDER", "").strip().lower() or "auto" ) - detected = detect_static_provider_for_model(explicit_model, current_provider) - if detected: + if detected := detect_static_provider_for_model(explicit_model, current_provider): provider, detected_model = detected return detected_model, provider return model, None -# Bare billing buckets are not routable provider identities; restoring one as a -# session provider override breaks resume. ``openrouter`` is deliberately NOT in -# this set (fully routable; dropping it resumed OpenRouter sessions on the wrong -# provider) — agent_init's fail-fast gate is a different set that skips it. +# Bare billing buckets are not routable provider identities; restoring one as a session +# provider override breaks resume. ``openrouter`` is deliberately NOT in this set (fully +# routable) — agent_init's fail-fast gate is a different set that skips it. from hermes_state import _BARE_BILLING_PROVIDERS -def _overrides_have_routable_provider(overrides: dict) -> bool: - """Whether persisted runtime overrides still name a routable provider. - - A session row written under a provider that has since been renamed or - removed would otherwise fail agent init with "Unknown provider". - Empty provider counts as NOT routable here, so the caller falls back - to the model the user picked for this session / the configured - default instead of restoring a provider-less snapshot override. - """ - provider = str(overrides.get("provider_override") or "").strip() - if not provider: - provider = str((overrides.get("model_override") or {}).get("provider") or "").strip() - if not provider: - return False +def _is_routable_provider(provider: str) -> bool: try: from hermes_cli.runtime_provider import is_routable_provider return is_routable_provider(provider) @@ -1634,37 +1587,45 @@ def _overrides_have_routable_provider(overrides: dict) -> bool: return False +def _overrides_have_routable_provider(overrides: dict) -> bool: + """Whether persisted runtime overrides still name a routable provider (renamed/removed → + "Unknown provider" at agent init). Empty counts as NOT routable, so the caller falls back to the + session's picked model / configured default instead of a provider-less snapshot.""" + provider = str(overrides.get("provider_override") or "").strip() + if not provider: + provider = str((overrides.get("model_override") or {}).get("provider") or "").strip() + return bool(provider) and _is_routable_provider(provider) + + +def _parse_model_config(raw, *, quiet: bool = False) -> dict: + """A row's ``model_config`` (dict or JSON text) as a dict; ``{}`` when absent/invalid.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw.strip(): + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + return parsed + except Exception: + if not quiet: + raise + logger.debug("failed to parse stored session model_config", exc_info=True) + return {} + + def _stored_session_runtime_overrides(row: dict | None) -> dict: - """Return runtime fields persisted with a stored session. + """Runtime fields persisted with a stored session (model column, ``billing_provider``, JSON ``model_config``). - ``session.resume`` is session-scoped: reopening an older chat must restore - the model/provider/reasoning state that chat actually used, not the global - model most recently selected elsewhere. The row stores the model directly, - the billing provider in ``billing_provider``, and richer knobs in JSON - ``model_config``. - - Plugin-owned Bot-Mode sessions are exempt and always rebuild from the member - profile's CURRENT config — restoring a stale provider pin is what left room - bots / bot DMs failing ("out of Nous credits") after the profile switched. - Signals, in order: the explicit ``room_plumbing`` / ``follow_profile_config`` - markers persisted by session.create consumers; the legacy hidden + - "Group:" title shape (older desktop builds sent no marker); and the title - exactly "Bot Chat" (the plugin's own identity rule for the forever-DM, - UNIQUE(title) makes it exact; pre-policy rows may be visible or hidden). + ``session.resume`` is session-scoped: reopening an older chat restores the model/provider/ + reasoning THAT chat used, not the global model most recently selected elsewhere. + Plugin-owned Bot-Mode sessions are exempt and always rebuild from the member profile's CURRENT + config (a stale provider pin left room bots failing "out of Nous credits" after a profile + switch). Signals, in order: explicit ``room_plumbing`` / ``follow_profile_config`` markers; + the legacy hidden + "Group:" title shape; the title exactly "Bot Chat" (UNIQUE(title)). """ if not row: return {} - raw_config = row.get("model_config") - model_config: dict = {} - if isinstance(raw_config, dict): - model_config = raw_config - elif isinstance(raw_config, str) and raw_config.strip(): - try: - parsed = json.loads(raw_config) - if isinstance(parsed, dict): - model_config = parsed - except Exception: - logger.debug("failed to parse stored session model_config", exc_info=True) + model_config = _parse_model_config(row.get("model_config"), quiet=True) _row_title = str(row.get("title") or "").strip() if ( model_config.get("room_plumbing") @@ -1675,64 +1636,45 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict: return {} overrides: dict = {} model = str(row.get("model") or model_config.get("model") or "").strip() - # ``billing_provider`` is only the billing bucket — for a custom endpoint it - # is the bare class ``"custom"``, which agent_init treats as non-routable, so - # restoring it as the provider override fails resume with "No LLM provider - # configured". Only restore an explicit provider; otherwise leave it unset - # so resume falls back to the configured default (CLI parity). - explicit_provider = str(model_config.get("provider") or "").strip() - billing_provider = str( - model_config.get("billing_provider") or row.get("billing_provider") or "" - ).strip() - provider = explicit_provider + # ``billing_provider`` is only the billing bucket — for a custom endpoint the bare class + # "custom", which agent_init treats as non-routable ("No LLM provider configured" on resume). + # Only restore an explicit provider; otherwise resume falls back to the configured default. + provider = str(model_config.get("provider") or "").strip() + billing_provider = str(model_config.get("billing_provider") or row.get("billing_provider") or "").strip() if not provider and billing_provider.lower() not in _BARE_BILLING_PROVIDERS: provider = billing_provider base_url = str(model_config.get("base_url") or "").strip() api_mode = str(model_config.get("api_mode") or "").strip() reasoning_config = model_config.get("reasoning_config") service_tier = str(model_config.get("service_tier") or "").strip() - - # Heal a stale/expired provider name persisted by an older build (a renamed - # or removed custom provider would fail agent init with "Unknown provider"). - # Recover the durable ``custom:`` key from the stored base_url, then - # from the entry serving the stored model; when nothing names a real entry, - # drop the provider so resume falls back to the configured default. - if provider: + # Heal a stale provider name persisted by an older build (renamed/removed custom provider → + # "Unknown provider"): recover the durable ``custom:`` key from the stored base_url, then + # from the entry serving the stored model; when nothing names a real entry, drop the provider. + if provider and not _is_routable_provider(provider): + healed = None try: - from hermes_cli.runtime_provider import is_routable_provider - routable = is_routable_provider(provider) + from hermes_cli.runtime_provider import canonical_custom_identity + healed = canonical_custom_identity(base_url=base_url or None, model=model or None) except Exception: - routable = False - if not routable: - healed = None - try: - from hermes_cli.runtime_provider import canonical_custom_identity - healed = canonical_custom_identity(base_url=base_url or None, model=model or None) - except Exception: - logger.debug("custom provider identity recovery failed", exc_info=True) - if healed: - logger.info("healed stale session provider %r to %r", provider, healed) - provider = healed - # The healed identity owns a registered endpoint; the snapshot's - # base_url must not override the registry URL. - base_url = "" - else: - provider = "" + logger.debug("custom provider identity recovery failed", exc_info=True) + if healed: + logger.info("healed stale session provider %r to %r", provider, healed) + provider = healed + base_url = "" # the healed identity owns a registered endpoint; the snapshot URL must not override it + else: + provider = "" if model: - # Same dict-shaped override live /model switches use, so a DB-restored - # session keeps custom endpoint metadata across resume and rebuilds - # (/new). Raw api_key is deliberately never persisted or restored here. + # Same dict-shaped override live /model switches use, so a DB-restored session keeps custom + # endpoint metadata across resume and rebuilds (/new). Raw api_key is never persisted/restored. overrides["model_override"] = { - "model": model, "provider": provider or None, "base_url": base_url or None, - "api_mode": api_mode or None, + "model": model, "provider": provider or None, "base_url": base_url or None, "api_mode": api_mode or None, } if provider: overrides["provider_override"] = provider if isinstance(reasoning_config, dict): overrides["reasoning_config_override"] = reasoning_config if service_tier.lower() == "normal": - # None means "inherit the profile" at _make_agent; "" is a real override - # meaning "do not request a priority service tier". + # None = "inherit the profile" at _make_agent; "" = real override "no priority tier". overrides["service_tier_override"] = "" elif service_tier: overrides["service_tier_override"] = service_tier @@ -1740,30 +1682,21 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict: def _runtime_model_config(agent, existing: dict | None = None) -> dict: - """Merge the agent's CURRENT runtime identity onto an existing config. + """Merge the agent's CURRENT runtime identity onto the row's persisted ``model_config``. - ``existing`` is the row's previously-persisted ``model_config`` JSON (may - be absent on first write). The returned dict must mirror the agent's live - state: falsy agent attributes DELETE the corresponding key rather than - merely omit the write, so a stale value from an earlier session state can - never survive into the merged config. Keeping stale values here is what - desynced the ``sessions.model`` column (fresh) from ``model_config`` - (stale provider/endpoint): ``_persist_live_session_runtime`` writes the - model column separately, and on resume ``_stored_session_runtime_overrides`` - reads provider/endpoint from this JSON — so a stale provider would silently - route the resumed chat to the wrong endpoint while the model column claimed - the new one. + Falsy agent attributes DELETE the key rather than skip the write, so a stale value can never + survive the merge: ``_persist_live_session_runtime`` writes the model column separately and + resume reads provider/endpoint from this JSON — a stale provider would silently route the + resumed chat to the wrong endpoint while the model column claimed the new one. """ config = dict(existing or {}) model = str(getattr(agent, "model", "") or "").strip() provider = str(getattr(agent, "provider", "") or "").strip() base_url = str(getattr(agent, "base_url", "") or "").strip() if provider.lower() == "custom": - # ``agent.provider`` resolves every named custom entry to the literal - # "custom", which loses the entry identity (api_key is never persisted, - # so resume couldn't re-resolve credentials). Recover the canonical - # ``custom:`` key from the endpoint URL, else from the configured - # provider (the no-base_url case that routed to OpenRouter with no key). + # ``agent.provider`` resolves every named custom entry to the literal "custom", losing the + # entry identity (api_key is never persisted, so resume couldn't re-resolve credentials). + # Recover the canonical ``custom:`` key from the endpoint URL, else the configured provider. try: from hermes_cli.runtime_provider import canonical_custom_identity provider = canonical_custom_identity(base_url=base_url, model=model or None) or provider @@ -1771,9 +1704,7 @@ def _runtime_model_config(agent, existing: dict | None = None) -> dict: logger.debug("custom provider identity lookup failed", exc_info=True) reasoning_config = getattr(agent, "reasoning_config", None) live = { - "model": model, - "provider": provider, - "base_url": base_url, + "model": model, "provider": provider, "base_url": base_url, "api_mode": str(getattr(agent, "api_mode", "") or "").strip(), # An empty dict is still a real (present) reasoning config. "reasoning_config": reasoning_config if isinstance(reasoning_config, dict) else None, @@ -1800,20 +1731,11 @@ def _persist_live_session_runtime(session: dict | None) -> None: return try: row = db.get_session(session_key) or {} - raw_config = row.get("model_config") - existing_config = {} - if isinstance(raw_config, dict): - existing_config = raw_config - elif isinstance(raw_config, str) and raw_config.strip(): - parsed = json.loads(raw_config) - if isinstance(parsed, dict): - existing_config = parsed - model_config = _runtime_model_config(agent, existing_config) + model_config = _runtime_model_config(agent, _parse_model_config(row.get("model_config"))) create_service_tier_override = session.get("create_service_tier_override") if create_service_tier_override is not None: - # _runtime_model_config sees agent.service_tier=None for explicit - # normal and would otherwise erase the distinction on every live - # metadata persist. + # _runtime_model_config sees agent.service_tier=None for explicit normal and would + # otherwise erase the distinction on every live metadata persist. model_config["service_tier"] = create_service_tier_override or "normal" model = str(getattr(agent, "model", "") or "").strip() if hasattr(db, "update_session_meta"): @@ -1836,13 +1758,11 @@ def _persist_live_session_system_prompt(session: dict | None) -> None: if db is None or not hasattr(db, "update_system_prompt"): return - # Re-bind HERMES_HOME to the session's profile: the build's finally already - # reset it, and the rebuilt prompt would use the root profile's SOUL.md/skills. + # Re-bind HERMES_HOME to the session's profile (the build's finally reset it; the rebuilt + # prompt would use the root profile's SOUL.md/skills) and the session context (on the RPC + # thread _SESSION_CWD is unset, so the prompt would persist the process TERMINAL_CWD). profile_home = session.get("profile_home") - home_token = (set_hermes_home_override(profile_home) if profile_home else None) - # Bind the session context too: on the RPC dispatcher thread _SESSION_CWD is - # unset, so resolve_agent_cwd() falls back to the process TERMINAL_CWD and - # the rebuilt prompt persists the wrong cwd (later turns reuse the bytes). + home_token = set_hermes_home_override(profile_home) if profile_home else None session_tokens = _set_session_context(session_key, cwd=_session_cwd(session)) try: prompt = agent._build_system_prompt(None) @@ -1873,28 +1793,18 @@ def _is_model_switch_marker(entry: Any) -> bool: def _is_pivot_marker(entry: Any) -> bool: - """Whether a history entry is a marker the gateway splices in mid-turn. - - Model switches and personality changes both inject a ``role=user`` pivot - into the live history from the RPC thread while a turn may be running, so - either one can be the sole reason turn-start and current history differ. - Only the model-switch marker is self-replacing, which is why the dedup in - :func:`_append_model_switch_marker` stays narrower than this. - """ - if _is_model_switch_marker(entry): - return True - return isinstance(entry, dict) and entry.get("display_kind") == "personality_switch" + """Whether a history entry is a ``role=user`` pivot the gateway splices in mid-turn (model switch + or personality change) — either can be the sole reason turn-start and current history differ. + Only the model-switch marker is self-replacing, so :func:`_append_model_switch_marker` dedups narrower.""" + return _is_model_switch_marker(entry) or (isinstance(entry, dict) and entry.get("display_kind") == "personality_switch") def _append_model_switch_marker(session: dict | None, *, model: str, provider: str) -> None: """Record a real system-history pivot after a live model switch. - Only the most recent marker is kept: each new switch first strips any - prior model-switch markers from the live history, so N switches leave one - marker (naming the active model), not N stale ones accumulating tokens on - every subsequent API call (#65891). The in-memory history is the payload - re-sent each turn; the dedup is self-healing across resumes because the - next switch collapses whatever markers a reload brought back. + Only the newest marker is kept: each switch strips prior model-switch markers first, so N + switches leave one marker rather than N stale ones re-sent on every API call; self-healing + across resumes because the next switch collapses whatever a reload brought back. """ if not session: return @@ -1907,44 +1817,31 @@ def _append_model_switch_marker(session: dict | None, *, model: str, provider: s f"{model}{provider_part}. From this point forward, use this runtime " "metadata when answering questions about what model/provider is active.]" ) - # A user message, not system: strict OpenAI-compatible providers (vLLM, - # Qwen) reject non-leading system messages. + # A user message, not system: strict OpenAI-compatible providers (vLLM, Qwen) reject + # non-leading system messages. entry = {"role": "user", "content": marker, "display_kind": "model_switch"} - - def _replace_markers() -> None: + with session.get("history_lock") or contextlib.nullcontext(): history = session.setdefault("history", []) - # Drop any earlier markers in place before appending the new one. history[:] = [h for h in history if not _is_model_switch_marker(h)] history.append(entry) session["history_version"] = int(session.get("history_version", 0)) + 1 - lock = session.get("history_lock") - if lock is not None: - with lock: - _replace_markers() - else: - _replace_markers() try: agent = session.get("agent") db = getattr(agent, "_session_db", None) if agent is not None else None - if db is not None: - db.append_message( - session_id=session_key, role="user", content=marker, display_kind="model_switch", - ) - return - _ensure_session_db_row(session) - with _session_db(session) as scoped_db: - if scoped_db is not None: - scoped_db.append_message( - session_id=session_key, role="user", content=marker, - display_kind="model_switch", - ) + if db is None: + _ensure_session_db_row(session) + with _session_db(session) as db: + if db is not None: + db.append_message(session_id=session_key, role="user", content=marker, display_kind="model_switch") + else: + db.append_message(session_id=session_key, role="user", content=marker, display_kind="model_switch") except Exception: logger.debug("failed to persist model switch marker", exc_info=True) def _write_config_key(key_path: str, value): - # Write-back round-trip: raw read is mandatory — saving the managed- - # overlaid / env-expanded view would persist those values into the file. + # Write-back round-trip: raw read is mandatory — saving the managed-overlaid / env-expanded + # view would persist those values into the file. cfg = _load_cfg_raw() current = cfg keys = key_path.split(".") @@ -1959,26 +1856,18 @@ def _write_config_key(key_path: str, value): _STATUSBAR_MODES = frozenset({"off", "top", "bottom"}) _APPROVAL_MODES = frozenset({"manual", "smart", "off"}) -# Appearance switches the renderer owns but the AGENT must see (each gates a -# tool's `check_fn`), so the toggle must reach whichever gateway the app talks -# to. `config.set` answers 4002 for unlisted keys — a mirrored switch missing -# here writes nothing and its tool stays dark. Add renderer mirrors here too. -_DISPLAY_TOGGLE_KEYS = frozenset( - {"display.message_reactions", "display.in_app_tips", "display.in_app_tours"} -) +# Appearance switches the renderer owns but the AGENT must see (each gates a tool's `check_fn`), +# so the toggle must reach whichever gateway the app talks to. `config.set` answers 4002 for +# unlisted keys — a mirrored switch missing here writes nothing and its tool stays dark. +_DISPLAY_TOGGLE_KEYS = frozenset({"display.message_reactions", "display.in_app_tips", "display.in_app_tours"}) _BOOL_WORDS = { - "1": True, "on": True, "true": True, "yes": True, "0": False, "off": False, "false": False, - "no": False, + "1": True, "on": True, "true": True, "yes": True, "0": False, "off": False, "false": False, "no": False, } def _load_approval_mode() -> str: - """Resolve the effective ``approvals.mode`` for the TUI surface. - - Delegates to ``tools.approval._get_approval_mode`` so the mode cannot drift - from the approval gate's own view (a local raw-config re-read missed the - managed overlay and ``${VAR}`` expansion). - """ + """Effective ``approvals.mode`` via ``tools.approval._get_approval_mode`` so it cannot drift from + the gate's own view (a raw-config re-read missed the managed overlay and ``${VAR}`` expansion).""" from tools.approval import _get_approval_mode mode = _get_approval_mode() return mode if mode in _APPROVAL_MODES else "manual" @@ -2000,62 +1889,37 @@ _MOUSE_TRACKING_ALIASES = { def _display_mouse_tracking(display: dict) -> str: - """Resolve display.mouse_tracking to one of ``off|wheel|buttons|all``. - - Boolean values keep their legacy meaning (``True`` → ``all``, ``False`` → - ``off``). The ``wheel`` preset (DEC 1000+1006) is the tmux-friendly - subset — wheel + click only, no hover events to trigger prompt-row - clipboard probes. Legacy ``tui_mouse`` is honored only when - ``mouse_tracking`` is absent. - """ + """display.mouse_tracking → ``off|wheel|buttons|all``. Booleans keep their legacy meaning + (True → all, False → off); ``wheel`` (DEC 1000+1006) is the tmux-friendly subset without hover + events that trigger prompt-row clipboard probes. Legacy ``tui_mouse`` only when ``mouse_tracking`` is absent.""" if not isinstance(display, dict): return "all" - if "mouse_tracking" in display: - raw = display.get("mouse_tracking") - else: - raw = display.get("tui_mouse", True) + raw = display.get("mouse_tracking") if "mouse_tracking" in display else display.get("tui_mouse", True) if raw is False or raw == 0: return "off" - if raw is True or raw is None: - return "all" - if isinstance(raw, (int, float)): - return "all" if isinstance(raw, str): return _MOUSE_TRACKING_ALIASES.get(raw.strip().lower(), "all") return "all" def _load_reasoning_config(model: str = "") -> dict | None: - """Load reasoning effort from config.yaml, respecting per-model overrides. - - Thin wrapper over the shared chokepoint - :func:`hermes_constants.resolve_reasoning_config` (per-model override > - global ``agent.reasoning_effort``; YAML boolean False = disabled). - Closes #21256. - """ + """Reasoning effort via the shared chokepoint :func:`hermes_constants.resolve_reasoning_config` + (per-model override > global ``agent.reasoning_effort``; YAML False = disabled).""" from hermes_constants import resolve_reasoning_config return resolve_reasoning_config(_load_cfg(), model) +_SERVICE_TIER_ALIASES = {"fast": "priority", "priority": "priority", "on": "priority", "auto": "auto", "cold": "cold"} + + def _load_service_tier() -> str | None: - raw = (str((_load_cfg().get("agent") or {}).get("service_tier", "") or "") .strip() .lower()) - if not raw or raw in {"normal", "default", "standard", "off", "none"}: - return None - if raw in {"fast", "priority", "on"}: - return "priority" - if raw in {"auto", "cold"}: - return raw - return None + raw = str((_load_cfg().get("agent") or {}).get("service_tier", "") or "").strip().lower() + return _SERVICE_TIER_ALIASES.get(raw) def _load_provider_routing() -> dict: - """OpenRouter provider-routing prefs from config.yaml (``provider_routing``). - - Parity with the messaging gateway (``gateway/run.py::_load_provider_routing``) - and the classic CLI: without this the desktop/TUI backend builds agents with - no routing prefs, so OpenRouter falls back to its default (effectively random) - provider selection even when the user configured ``provider_routing``. - """ + """OpenRouter ``provider_routing`` prefs (gateway/CLI parity — without them OpenRouter picks an + effectively random provider even when the user configured routing).""" try: return _load_cfg().get("provider_routing", {}) or {} except Exception: @@ -2063,30 +1927,25 @@ def _load_provider_routing() -> dict: def _load_show_reasoning() -> bool: - # Fallback True — keep in sync with DEFAULT_CONFIG display.show_reasoning - # (this loader reads the raw user YAML without the DEFAULT_CONFIG merge). + # Fallback True — keep in sync with DEFAULT_CONFIG display.show_reasoning (no DEFAULT_CONFIG merge here). return bool(_display_cfg().get("show_reasoning", True)) def _load_memory_notifications() -> str: - """Self-improvement review notification mode from config.yaml. - - Parity with the messaging gateway (``gateway/run.py``) and the classic CLI: - ``display.memory_notifications`` controls whether the background review's - "💾 Self-improvement review: …" summary is surfaced. Without this the - TUI/desktop backend always behaved as ``"on"`` and silently ignored a user - who set ``off``. Accepts ``off`` / ``on`` (default) / ``verbose``; a bool is - normalized for back-compat. - """ + """``display.memory_notifications`` (``off`` / ``on`` default / ``verbose``; bool normalized) — gates + the "💾 Self-improvement review" summary, gateway/CLI parity (this backend used to ignore ``off``).""" raw = _display_cfg().get("memory_notifications") if isinstance(raw, bool): return "on" if raw else "off" return str(raw).lower() if raw else "on" +_TOOL_PROGRESS_MODES = frozenset({"off", "new", "all", "verbose"}) + + def _load_tool_progress_mode() -> str: env = os.environ.get("HERMES_TUI_TOOL_PROGRESS", "").strip().lower() - if env in {"off", "new", "all", "verbose"}: + if env in _TOOL_PROGRESS_MODES: return env raw = _display_cfg().get("tool_progress", "all") if raw is False: @@ -2094,23 +1953,14 @@ def _load_tool_progress_mode() -> str: if raw is True: return "all" mode = str(raw or "all").strip().lower() - return mode if mode in {"off", "new", "all", "verbose"} else "all" + return mode if mode in _TOOL_PROGRESS_MODES else "all" def _gui_surface_toolsets(platform: str) -> set[str]: - """Toolsets that exist because of the CLIENT on the other end, not the host. - - Both entries are off ``_HERMES_CORE_TOOLS`` (no other platform should carry - their schema), so this resolver is the one gate that exposes them. - ``platform`` is the SESSION's source, never a process env var: the desktop - may drive a URL/cloud backend where ``HERMES_DESKTOP`` is unset, and keying - off the env var stripped every pane/browser tool there. See the - surface-capability rule in AGENTS.md. - """ - surfaces = {"project"} - if platform == "desktop": - surfaces.add("desktop_ui") - return surfaces + """Toolsets that exist because of the CLIENT, not the host — both off ``_HERMES_CORE_TOOLS``, so this + is the one gate exposing them. ``platform`` is the SESSION's source, never a process env var: the + desktop may drive a URL/cloud backend where ``HERMES_DESKTOP`` is unset (see AGENTS.md surface rule).""" + return {"project", "desktop_ui"} if platform == "desktop" else {"project"} def _enabled_mcp_server_names() -> tuple[set[str], set[str]]: From fe08ed631b5b2ff0e7c72367ead3858d768ca554 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:41:53 -0700 Subject: [PATCH 3/6] =?UTF-8?q?refactor(tui):=20server.py=20=E2=80=94=20?= =?UTF-8?q?=5Fstartup=5Fsystem=5Fprompt/=5Fhydrate=5Fsession=5Fcwd/=5Feffe?= =?UTF-8?q?ctive=5Fapproval=5Fstate/=5Fturn=5Fstarted=5Fat=20helpers,=20?= =?UTF-8?q?=5Ftui=5Fnotice,=20compact=20session-info/toolset/runtime=20doc?= =?UTF-8?q?strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tui_gateway/server.py | 619 ++++++++++++++++-------------------------- 1 file changed, 230 insertions(+), 389 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 08f8648a47..eb3fd170c7 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1983,6 +1983,10 @@ def _enabled_mcp_server_names() -> tuple[set[str], set[str]]: return set(), set() +def _tui_notice(text: str) -> None: + print(text, file=sys.stderr, flush=True) + + def _resolve_explicit_toolsets(explicit: list[str], validate_toolset) -> list[str] | None | bool: """Resolve a HERMES_TUI_TOOLSETS pin: list, None for "all", False when nothing was valid.""" built_in = [name for name in explicit if validate_toolset(name)] @@ -1998,13 +2002,10 @@ def _resolve_explicit_toolsets(explicit: list[str], validate_toolset) -> list[st built_in.extend(plugin_valid) unresolved = [name for name in unresolved if name not in plugin_valid] if any(name in {"all", "*"} for name in built_in): - ignored = [name for name in explicit if name not in {"all", "*"}] - if ignored: - print( + if ignored := [name for name in explicit if name not in {"all", "*"}]: + _tui_notice( "[tui] HERMES_TUI_TOOLSETS=all enables every toolset; " - f"ignoring additional entries: {', '.join(ignored)}", - file=sys.stderr, - flush=True, + f"ignoring additional entries: {', '.join(ignored)}" ) return None if not unresolved: @@ -2014,38 +2015,26 @@ def _resolve_explicit_toolsets(explicit: list[str], validate_toolset) -> list[st disabled = [name for name in unresolved if name in mcp_disabled] unknown = [name for name in unresolved if name not in mcp_names and name not in mcp_disabled] if unknown: - print( - f"[tui] ignoring unknown HERMES_TUI_TOOLSETS entries: {', '.join(unknown)}", - file=sys.stderr, flush=True, - ) + _tui_notice(f"[tui] ignoring unknown HERMES_TUI_TOOLSETS entries: {', '.join(unknown)}") if disabled: - print( + _tui_notice( "[tui] ignoring disabled MCP servers in HERMES_TUI_TOOLSETS " "(set enabled: true in config.yaml to use): " - f"{', '.join(disabled)}", - file=sys.stderr, - flush=True, + f"{', '.join(disabled)}" ) return (built_in + mcp_valid) or False def _load_enabled_toolsets(platform: str | None = None) -> list[str] | None: - """Resolve the agent's toolsets for this desktop/TUI session (None = all). + """The agent's toolsets for this desktop/TUI session (None = all). - Order: an explicit HERMES_TUI_TOOLSETS pin; else the coding posture - (collapse to the coding toolset + enabled MCP servers when sitting in a code - workspace — agent/coding_context.py, config loaded lazily there); else the - configured CLI toolsets. The client-surface (pane/project) toolsets are off - _HERMES_CORE_TOOLS so no other platform carries their schema; this resolver - runs only in the desktop/TUI gateway, so folding them in here is the gate - that exposes them on exactly the surface that can answer them. + Order: an explicit HERMES_TUI_TOOLSETS pin; else the coding posture (agent/coding_context.py + collapses to the coding toolset + enabled MCP servers inside a code workspace); else the + configured CLI toolsets. The client-surface (pane/project) toolsets are folded in here because + this resolver runs only on the surface that can answer them. """ session_platform = platform or _resolve_session_platform() - explicit = [ - item.strip() - for item in os.environ.get("HERMES_TUI_TOOLSETS", "").split(",") - if item.strip() - ] + explicit = [item.strip() for item in os.environ.get("HERMES_TUI_TOOLSETS", "").split(",") if item.strip()] fallback_notice = None if not explicit: with contextlib.suppress(Exception): @@ -2061,28 +2050,21 @@ def _load_enabled_toolsets(platform: str | None = None) -> list[str] | None: resolved = _resolve_explicit_toolsets(explicit, validate_toolset) if resolved is not False: return resolved - fallback_notice = ( - "[tui] no valid HERMES_TUI_TOOLSETS entries; using configured CLI toolsets" - ) + fallback_notice = "[tui] no valid HERMES_TUI_TOOLSETS entries; using configured CLI toolsets" try: from hermes_cli.config import load_config from hermes_cli.tools_config import _get_platform_tools cfg = load_config() - # include_default_mcp_servers=True is the runtime variant (the agent - # must be able to call default MCP servers); False is the config-editing - # variant. Using the wrong one here silently drops MCP tools from the TUI. + # include_default_mcp_servers=True is the runtime variant (the agent must be able to call + # default MCP servers); the config-editing variant would silently drop MCP tools from the TUI. enabled = _get_platform_tools(cfg, "cli", include_default_mcp_servers=True) if fallback_notice is not None: - print(fallback_notice, file=sys.stderr, flush=True) - if not enabled: - return None - return sorted(enabled | _gui_surface_toolsets(session_platform)) + _tui_notice(fallback_notice) + return sorted(enabled | _gui_surface_toolsets(session_platform)) if enabled else None except Exception: if fallback_notice is not None: - print( - "[tui] no valid HERMES_TUI_TOOLSETS entries and configured CLI toolsets could not be loaded; enabling all toolsets", - file=sys.stderr, - flush=True, + _tui_notice( + "[tui] no valid HERMES_TUI_TOOLSETS entries and configured CLI toolsets could not be loaded; enabling all toolsets" ) return None @@ -2100,18 +2082,15 @@ def _tool_progress_enabled(sid: str) -> bool: def _tool_lifecycle_required_for_ui(name: str) -> bool: - """Return True for tool events that are interactive UI, not optional chrome.""" - # Desktop renders clarify / setup_mcp cards from the tool-call part; with - # tool progress off, suppressing them would leave only the sidebar dot. + """Tool events that are interactive UI, not optional chrome: Desktop renders clarify / setup_mcp + cards from the tool-call part; suppressing them with progress off would leave only the sidebar dot.""" return name in ("clarify", "setup_mcp") def _restart_slash_worker(sid: str, session: dict): worker = session.get("slash_worker") - # Nothing to replace for a session that never spawned a worker; spawning - # here would fork the per-worker MCP fleet for nothing. if worker is None: - return + return # never spawned one; spawning here would fork the per-worker MCP fleet for nothing with contextlib.suppress(Exception): worker.close() try: @@ -2122,8 +2101,8 @@ def _restart_slash_worker(sid: str, session: dict): except Exception: session["slash_worker"] = None return - # Store-iff-still-mapped: the post-turn restart races a close_on_disconnect - # reap, and a bare store would orphan the fresh worker. + # Store-iff-still-mapped: the post-turn restart races a close_on_disconnect reap, and a + # bare store would orphan the fresh worker. _attach_worker(sid, session, new_worker) @@ -2139,32 +2118,26 @@ def _get_usage(agent) -> dict: } comp = getattr(agent, "context_compressor", None) if comp: - # context_used is *current-window* occupancy. Never fall back to - # usage["total"] (cumulative lifetime) — an external engine without - # last_prompt_tokens then showed 1.9m/120k clamped to 100%. A falsy - # last_prompt_tokens emits NO gauge rather than a fabricated one; the -1 - # "compression just ran" sentinel is clamped to 0 for the same reason - # (matches cli.py _get_status_bar_snapshot). - last_prompt = getattr(comp, "last_prompt_tokens", 0) or 0 - if last_prompt < 0: - last_prompt = 0 + # context_used is *current-window* occupancy. Never fall back to usage["total"] (cumulative + # lifetime: an external engine without last_prompt_tokens showed 1.9m/120k clamped to 100%). + # A falsy last_prompt_tokens emits NO gauge rather than a fabricated one; the -1 "compression + # just ran" sentinel is clamped to 0 (matches cli.py _get_status_bar_snapshot). + last_prompt = max(0, getattr(comp, "last_prompt_tokens", 0) or 0) ctx_max = getattr(comp, "context_length", 0) or 0 if ctx_max and last_prompt: usage["context_used"] = last_prompt usage["context_max"] = ctx_max usage["context_percent"] = max(0, min(100, round(last_prompt / ctx_max * 100))) usage["compressions"] = getattr(comp, "compression_count", 0) or 0 - # Cache-hit ratio + rolling latency/tps (CLI status-bar parity): - # hit = cache_read / prompt_tokens (prompt = input + cache_read + cache_write); - # latency/tps read the per-call deque history from conversation_loop. - # Omitted, not fabricated, when there is no data (Codex reports no latency; - # zero cache reads shows no hit% rather than an alarming 0). + # Cache-hit ratio + rolling latency/tps (CLI status-bar parity): hit = cache_read / prompt_tokens; + # latency/tps read the per-call deque history. Omitted, not fabricated, when there is no data + # (Codex reports no latency; zero cache reads shows no hit% rather than an alarming 0). with contextlib.suppress(Exception): _prompt_total = int(getattr(agent, "session_prompt_tokens", 0) or 0) _cache_read = int(getattr(agent, "session_cache_read_tokens", 0) or 0) if _prompt_total > 0 and _cache_read > 0: usage["cache_hit_pct"] = max(0, min(100, round(_cache_read / _prompt_total * 100))) - try: + with contextlib.suppress(Exception): # a status-bar readout must never break usage reporting _lhist = list(getattr(agent, "_api_latency_history", []) or []) _ohist = list(getattr(agent, "_api_output_history", []) or []) _n = min(len(_lhist), len(_ohist)) @@ -2179,17 +2152,11 @@ def _get_usage(agent) -> dict: usage["avg_latency_s"] = round(float(_avg_lat), 1) if _avg_vel is not None and _avg_vel == _avg_vel and 0 < _avg_vel < 1e6: usage["avg_tps"] = round(float(_avg_vel), 1) - except Exception: - # A status-bar readout must never break usage reporting. - pass - # Live count of background/async subagents still running (delegate_task - # batches + background single delegations). Mirrors the classic CLI status - # bar's ⛓ indicator; sourced from the same async_delegation registry. + # Live count of background/async subagents (CLI status bar ⛓ parity, same async_delegation registry). with contextlib.suppress(Exception): from tools.async_delegation import active_count as _async_active_count usage["active_subagents"] = _async_active_count() - # Dev-only live credits-spent readout (L0 usage-aware-credits). Gated on - # HERMES_DEV_CREDITS so the payload stays clean when the flag is off. + # Dev-only live credits-spent readout, gated on HERMES_DEV_CREDITS so the payload stays clean otherwise. if is_truthy_value(os.environ.get("HERMES_DEV_CREDITS")): with contextlib.suppress(Exception): spent = agent.get_credits_spent_micros() @@ -2199,29 +2166,22 @@ def _get_usage(agent) -> dict: def _probe_credentials(agent) -> str: - """Light credential check at session creation — returns warning or ''. - - ``no-key-required`` is a valid sentinel for keyless custom providers; only - warn when the key is genuinely missing. - """ + """Light credential check at session creation — warning or ''. (``no-key-required`` is a valid + sentinel for keyless custom providers; only a genuinely missing key warns.)""" with contextlib.suppress(Exception): - key = getattr(agent, "api_key", "") or "" - provider = getattr(agent, "provider", "") or "" - if not key: + if not (getattr(agent, "api_key", "") or ""): + provider = getattr(agent, "provider", "") or "" return f"No API key configured for provider '{provider}'. First message will fail." return "" def _probe_config_health(cfg: dict) -> str: - """Flag bare YAML keys (`agent:` with no value → None) that silently - drop nested settings. Returns warning or ''.""" + """Flag bare YAML keys (`agent:` with no value → None) that silently drop nested settings, + and a ``display.personality`` naming no known overlay. Returns warning or ''.""" if not isinstance(cfg, dict): return "" warnings: list[str] = [] - null_keys = sorted(k for k, v in cfg.items() if v is None) - if not null_keys: - pass - else: + if null_keys := sorted(k for k, v in cfg.items() if v is None): keys = ", ".join(f"`{k}`" for k in null_keys) warnings.append( f"config.yaml has empty section(s): {keys}. " @@ -2229,7 +2189,6 @@ def _probe_config_health(cfg: dict) -> str: f"empty sections silently drop nested settings." ) display_cfg = cfg.get("display") - agent_cfg = cfg.get("agent") if isinstance(display_cfg, dict): personality = str(display_cfg.get("personality", "") or "").strip().lower() if personality and personality not in {"default", "none", "neutral"}: @@ -2241,7 +2200,6 @@ def _probe_config_health(cfg: dict) -> str: "built-in or `agent.personalities` entry; personality " "overlay will be skipped." ) - _ = agent_cfg # retained for shape parity; built-ins exist without config return " ".join(warnings).strip() @@ -2253,15 +2211,11 @@ def _current_profile_name() -> str: return "default" -# Monotonic GUI<->backend contract version: the desktop refuses a backend -# reporting less (or none) with a one-click "update to align" prompt. Bump -# whenever the desktop's backend contract changes. -# v2: adds the file.attach RPC (remote-gateway non-image file upload). -# v3: adds approvals.mode config RPCs and session.info reconciliation. -# v4: session.create fast=false is an explicit per-session normal-tier override. -# v5: uvicorn ws_max_size raised for one-shot base64 file.attach frames (>16 MiB). -# v6: plugins.manage list rows carry the canonical registry key; toggles are -# key-addressed (keyless rows render read-only in Desktop Settings). +# Monotonic GUI<->backend contract version: the desktop refuses a backend reporting less (or none) +# with a one-click "update to align" prompt. Bump whenever the desktop's backend contract changes. +# v2 file.attach RPC; v3 approvals.mode RPCs + session.info reconciliation; v4 session.create +# fast=false = explicit normal-tier override; v5 ws_max_size raised for >16 MiB file.attach frames; +# v6 plugins.manage rows carry the canonical registry key (toggles key-addressed). DESKTOP_BACKEND_CONTRACT = 6 @@ -2276,14 +2230,9 @@ def _session_usage_snapshot(session: dict | None) -> dict: def _project_info_for_cwd(cwd: str) -> dict | None: - """Return the first-class Project owning ``cwd`` for UI status surfaces. - - Backed by the per-profile projects.db (the same store the desktop's project - tree caches), so the TUI status label, the desktop status bar, and ``/status`` - all name the session's workspace identically. Only explicit, named projects - resolve here — an auto-discovered repo root has no projects.db row, so it - falls back to the cwd leaf on every surface. - """ + """The first-class Project owning ``cwd`` (per-profile projects.db, the store the desktop's project + tree caches) so TUI status, desktop status bar and ``/status`` name the workspace identically. + Only explicit named projects resolve; an auto-discovered repo root falls back to the cwd leaf.""" if not str(cwd or "").strip(): return None try: @@ -2292,68 +2241,56 @@ def _project_info_for_cwd(cwd: str) -> dict | None: project = pdb.project_for_path(conn, cwd) if project is None: return None - return { - "id": project.id, "slug": project.slug, "name": project.name, - "primary_path": project.primary_path, - } + return {"id": project.id, "slug": project.slug, "name": project.name, "primary_path": project.primary_path} except Exception: logger.debug("failed to resolve project for cwd", exc_info=True) return None +def _turn_started_at(session: dict | None) -> float | None: + """Epoch seconds the current turn started, or None when idle — lets the desktop keep the + turn-elapsed timer across session switches (cold resume) instead of resetting to 0:00.""" + inflight = (session or {}).get("inflight_turn") + return float(inflight["started_at"]) if isinstance(inflight, dict) and inflight.get("started_at") else None + + +def _effective_approval_state(session_key: str) -> tuple[bool, str]: + """(yolo, approval_mode): the same three sources check_all_command_guards() ORs — approvals.mode=off, + the process --yolo env, the per-session flag. The session flag alone would show YOLO "off" while + config silently auto-approves every dangerous command.""" + try: + from tools.approval import _YOLO_MODE_FROZEN, is_session_yolo_enabled + session_yolo = bool(is_session_yolo_enabled(session_key)) if session_key else False + approval_mode = _load_approval_mode() + return bool(_YOLO_MODE_FROZEN) or session_yolo or approval_mode == "off", approval_mode + except Exception: + return False, "manual" + + def _session_info(agent, session: dict | None = None) -> dict: if session is None: - for candidate in _sessions.values(): - if candidate.get("agent") is agent: - session = candidate - break + session = next((c for c in _sessions.values() if c.get("agent") is agent), None) + sess = session or {} mirror = _metadata_mirror(session) cwd = _display_session_cwd(session) - session_key = str((session or {}).get("session_key") or getattr(agent, "session_id", "") or "") - cfg_personality = _display_cfg().get("personality") or "" - personality = (session or {}).get("personality", cfg_personality) + session_key = str(sess.get("session_key") or getattr(agent, "session_id", "") or "") + personality = sess.get("personality", _display_cfg().get("personality") or "") reasoning_config = getattr(agent, "reasoning_config", None) reasoning_effort = "" if isinstance(reasoning_config, dict): - if reasoning_config.get("enabled") is False: - # Disabled must differ from unset ("" = provider default), or the - # desktop adopts "" after the first turn and loses "thinking off". - reasoning_effort = "none" - else: - reasoning_effort = str(reasoning_config.get("effort", "") or "") + # Disabled must differ from unset ("" = provider default), or the desktop adopts "" after + # the first turn and loses "thinking off". + reasoning_effort = "none" if reasoning_config.get("enabled") is False else str(reasoning_config.get("effort", "") or "") service_tier = getattr(agent, "service_tier", None) or mirror.get("service_tier") or "" - # Effective approval bypass = the same three sources check_all_command_guards() - # ORs: approvals.mode=off, the process --yolo env, the per-session flag. - # Reporting only the session flag would show YOLO "off" while config - # silently auto-approves every dangerous command. - yolo = False - approval_mode = "manual" - try: - from tools.approval import _YOLO_MODE_FROZEN, is_session_yolo_enabled - session_yolo = (bool(is_session_yolo_enabled(session_key)) if session_key else False) - approval_mode = _load_approval_mode() - yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or approval_mode == "off" - except Exception: - yolo = False - # A switch queued mid-turn applies at the next turn start, so agent.model - # still reads the OLD model; report the pending pick so the end-of-turn - # settle doesn't blip the UI back before the switch lands. - pending_switch = (session or {}).get("pending_model_switch") or {} + yolo, approval_mode = _effective_approval_state(session_key) + # A switch queued mid-turn applies at the next turn start, so agent.model still reads the OLD + # model; report the pending pick so the end-of-turn settle doesn't blip the UI back first. + pending_switch = sess.get("pending_model_switch") or {} pending_model = str(pending_switch.get("display_model") or "").strip() pending_provider = str(pending_switch.get("display_provider") or "").strip() - # Epoch seconds the current turn started, or None when idle. Lets the - # desktop preserve the turn-elapsed timer across session switches (cold - # resume path) instead of resetting it to 0:00. - inflight = (session or {}).get("inflight_turn") - turn_started_at = ( - float(inflight["started_at"]) - if isinstance(inflight, dict) and inflight.get("started_at") - else None - ) info: dict = { "model": pending_model or mirror.get("model", getattr(agent, "model", "")), - "provider": pending_provider - or mirror.get("provider", getattr(agent, "provider", "")), + "provider": pending_provider or mirror.get("provider", getattr(agent, "provider", "")), "reasoning_effort": reasoning_effort, "service_tier": service_tier, "fast": service_tier == "priority", @@ -2366,9 +2303,9 @@ def _session_info(agent, session: dict | None = None) -> dict: "project": _project_info_for_cwd(cwd), "terminal_backend": _effective_terminal_backend(), "personality": str(personality or ""), - "running": bool((session or {}).get("running")), - "turn_started_at": turn_started_at, - "title": _session_live_title(session or {}, session_key) if session_key else "", + "running": bool(sess.get("running")), + "turn_started_at": _turn_started_at(session), + "title": _session_live_title(sess, session_key) if session_key else "", "stored_session_id": session_key or "", "desktop_contract": DESKTOP_BACKEND_CONTRACT, "version": "", @@ -2386,7 +2323,7 @@ def _session_info(agent, session: dict | None = None) -> dict: from hermes_cli import __version__, __release_date__ info["version"] = __version__ info["release_date"] = __release_date__ - live_agent = agent is not None and not (session or {}).get("_compute_host_active") + live_agent = agent is not None and not sess.get("_compute_host_active") if live_agent: with contextlib.suppress(Exception): from model_tools import get_toolset_for_tool @@ -2404,9 +2341,7 @@ def _session_info(agent, session: dict | None = None) -> dict: info["mcp_servers"] = [] with contextlib.suppress(Exception): info["system_prompt"] = ( - mirror.get("system_prompt") - if "system_prompt" in mirror - else getattr(agent, "_cached_system_prompt", "") or "" + mirror.get("system_prompt") if "system_prompt" in mirror else getattr(agent, "_cached_system_prompt", "") or "" ) with contextlib.suppress(Exception): from hermes_cli.banner import get_update_result @@ -2419,16 +2354,9 @@ def _session_info(agent, session: dict | None = None) -> dict: def _tool_ctx(name: str, args: dict) -> str: - """Argument preview for a tool row — never a phrased label. - - Clients own their own phrasing: the TUI wraps this as ``Terminal("...")`` - and the desktop prepends its own localized verb ("Running"/"Ran"). Sending - ``build_tool_label`` here instead of the raw preview stutters the verb on - both surfaces ("Running Running sleep 70 + 2 commands") and leaks a display - label into the desktop's ``args.context``, where it stands in for the real - command. The friendly labels belong on the CLI spinner, which builds them - from ``build_tool_label`` at its own call sites. - """ + """Argument preview for a tool row — never a phrased label: clients own their phrasing (the TUI + wraps it as ``Terminal("...")``, the desktop prepends a localized verb), so ``build_tool_label`` + here would stutter ("Running Running …") and leak a display label into the desktop's ``args.context``.""" try: from agent.display import build_tool_preview return build_tool_preview(name, args, max_len=80) or "" @@ -2445,35 +2373,23 @@ def _emit_session_info_for_session(sid: str, session: dict) -> None: def broadcast_session_info() -> None: - """Re-emit ``session.info`` to every live session. - - For approvals-config writers that bypass the ``config.set`` RPC (which - re-emits itself): the REST config saves and the ``/approvals`` slash - mirror. Only reaches sessions in THIS process; a spawned - ``tui_gateway.entry`` child gateway has its own ``_sessions``. - """ + """Re-emit ``session.info`` to every live session — for approvals-config writers that bypass the + self-re-emitting ``config.set`` RPC (REST config saves, the ``/approvals`` slash mirror). Only + reaches THIS process; a spawned ``tui_gateway.entry`` child gateway has its own ``_sessions``.""" with _sessions_lock: sessions = list(_sessions.items()) for sid, sess in sessions: _emit_session_info_for_session(sid, sess) -# Tool Args/Result text shipped to the TUI for the verbose trail line. The TUI -# renders only a small persisted preview (ui-tui VERBOSE_TRAIL_MAX_CHARS), kept -# all session and expanded by default — so shipping more than that is pure pipe def _schedule_mcp_late_refresh(sid: str, agent) -> None: """Refresh a session's tool snapshot when MCP discovery lands late. - The agent snapshots ``agent.tools`` once at build; ``_make_agent`` only - waits a bounded ``mcp_discovery_timeout`` (default 1.5s), so a slow server - (HTTP MCP on first connect) lands after the build and its tools are missing - for the whole session. A daemon waits for discovery to finish, then does - the same rebuild ``/reload-mcp`` performs and re-emits ``session.info``. - - Cache safety: the rebuild runs only while the session is pre-first-turn - (nothing cached to invalidate). Once a message was sent the snapshot stays - frozen — late tools then need an explicit, consent-gated ``/reload-mcp``. - No-op when discovery already finished before the build. + ``_make_agent`` waits only a bounded ``mcp_discovery_timeout``, so a slow server (HTTP MCP on + first connect) lands after the build and its tools are missing all session. A daemon joins + discovery, then does the same rebuild ``/reload-mcp`` performs and re-emits ``session.info``. + Cache safety: only while pre-first-turn (nothing cached to invalidate); afterwards the snapshot + stays frozen and late tools need an explicit, consent-gated ``/reload-mcp``. """ try: from tui_gateway.entry import mcp_discovery_in_flight, join_mcp_discovery @@ -2483,39 +2399,26 @@ def _schedule_mcp_late_refresh(sid: str, agent) -> None: return def _wait_then_refresh() -> None: - # Bounded but generous — a server still not connected after this is - # genuinely slow/dead; the user can /reload-mcp once it recovers. + # Bounded but generous — a server still not connected after this is genuinely slow/dead. if not join_mcp_discovery(timeout=30.0): return with _sessions_lock: session = _sessions.get(sid) - # Session may have been closed/reset while we waited. if session is None or session.get("agent") is not agent: - return - # Cache safety: never rebuild the tool list once the conversation - # has started — that would invalidate the cached prompt prefix. - if ( - int(getattr(agent, "_user_turn_count", 0) or 0) > 0 - or int(getattr(agent, "_api_call_count", 0) or 0) > 0 - ): - return + return # closed/reset while we waited + if int(getattr(agent, "_user_turn_count", 0) or 0) > 0 or int(getattr(agent, "_api_call_count", 0) or 0) > 0: + return # conversation started: a rebuild would invalidate the cached prompt prefix try: from tools.mcp_tool import refresh_agent_mcp_tools added = refresh_agent_mcp_tools(agent, quiet_mode=True) except Exception as exc: - logger.warning( - "Late MCP refresh: tool snapshot rebuild failed for %s: %s", sid, exc, - ) + logger.warning("Late MCP refresh: tool snapshot rebuild failed for %s: %s", sid, exc) return - # No new tools landed (discovery added nothing) → don't churn the client. if not added: - return + return # discovery added nothing → don't churn the client info = _session_info(agent, session) - # Emit outside the lock — write_json must not block under _sessions_lock. - _emit("session.info", sid, info) - threading.Thread( - target=_wait_then_refresh, name=f"tui-mcp-late-refresh-{sid}", daemon=True, - ).start() + _emit("session.info", sid, info) # outside the lock — write_json must not block under _sessions_lock + threading.Thread(target=_wait_then_refresh, name=f"tui-mcp-late-refresh-{sid}", daemon=True).start() class _RuntimeFallbackResolution(NamedTuple): @@ -2524,24 +2427,19 @@ class _RuntimeFallbackResolution(NamedTuple): used_fallback: bool -def _resolve_runtime_with_fallback( - resolve_kwargs: dict | None = None, -) -> _RuntimeFallbackResolution: +def _resolve_runtime_with_fallback(resolve_kwargs: dict | None = None) -> _RuntimeFallbackResolution: """Resolve the primary runtime or one complete provider/model fallback. - Setup-time auth fallback only accepts entries with both fields. Provider- - only entries are skipped so the unavailable primary model can never leak - into a different runtime. ``used_fallback`` remains explicit rather than - overloading a nullable model as control flow. + Setup-time auth fallback only accepts entries with both fields: provider-only entries are + skipped so the unavailable primary model can never leak into a different runtime. + ``used_fallback`` stays explicit rather than overloading a nullable model as control flow. """ from hermes_cli.auth import AuthError from hermes_cli.runtime_provider import resolve_runtime_provider - kwargs = resolve_kwargs or {} try: - return _RuntimeFallbackResolution(resolve_runtime_provider(**kwargs), None, False) + return _RuntimeFallbackResolution(resolve_runtime_provider(**(resolve_kwargs or {})), None, False) except AuthError as primary_exc: - fb_chain = _load_fallback_model() or [] - for entry in fb_chain: + for entry in _load_fallback_model() or []: if not isinstance(entry, dict): continue fb_provider = str(entry.get("provider") or "").strip() @@ -2553,14 +2451,11 @@ def _resolve_runtime_with_fallback( fb_kwargs: dict = {"requested": fb_provider, "target_model": fb_model} if entry.get("base_url"): fb_kwargs["explicit_base_url"] = entry["base_url"] - fb_api_key = resolve_entry_api_key(entry) - if fb_api_key: + if fb_api_key := resolve_entry_api_key(entry): fb_kwargs["explicit_api_key"] = fb_api_key runtime = resolve_runtime_provider(**fb_kwargs) - import logging logging.getLogger(__name__).warning( - "Primary auth failed (%s), falling back to %s model %s", primary_exc, - fb_provider, fb_model, + "Primary auth failed (%s), falling back to %s model %s", primary_exc, fb_provider, fb_model, ) return _RuntimeFallbackResolution(runtime, fb_model, True) except Exception: @@ -2569,16 +2464,13 @@ def _resolve_runtime_with_fallback( def _resolve_agent_model_runtime(model_override, provider_override) -> tuple[str, dict]: - """Resolve (model, runtime) for a new agent. + """(model, runtime) for a new agent; a per-session override (in-session /model switch or a + resumed row's persisted runtime) wins over global config/env. - A per-session override (prior in-session /model switch, or the persisted - runtime of a resumed row) wins over global config/env resolution. Rows - persisted before the custom-provider identity fix stored the resolved - provider "custom", which no named ``providers:`` entry matches — recover - the entry identity from the persisted base_url (falling back to the - configured provider) or the rebuild surfaces as "No LLM provider - configured". Persisted base_url/api_key/api_mode are honored only while - the original runtime is used; they must not leak into a fallback pair. + Rows persisted before the custom-provider identity fix stored the resolved provider "custom", + which no named ``providers:`` entry matches — recover the identity from the persisted base_url + or the rebuild surfaces as "No LLM provider configured". Persisted base_url/api_key/api_mode are + honored only while the original runtime is used; they must not leak into a fallback pair. """ if isinstance(model_override, dict) and model_override.get("model"): model = str(model_override.get("model") or "") @@ -2587,18 +2479,16 @@ def _resolve_agent_model_runtime(model_override, provider_override) -> tuple[str resolve_kwargs = {} if str(requested_provider or "").strip().lower() == "custom": from hermes_cli.runtime_provider import canonical_custom_identity - recovered = canonical_custom_identity(base_url=override_base_url or None, model=model or None) - if recovered: + if recovered := canonical_custom_identity(base_url=override_base_url or None, model=model or None): requested_provider = recovered if override_base_url: - # Failing identity recovery, still hand the base_url to the - # direct-alias branch so pool/env credentials resolve for it. + # Failing identity recovery, still hand the base_url to the direct-alias branch so + # pool/env credentials resolve for it. resolve_kwargs["explicit_base_url"] = override_base_url resolve_kwargs["requested"] = requested_provider resolve_kwargs["target_model"] = model or None overrides = { - "base_url": override_base_url, "api_key": model_override.get("api_key"), - "api_mode": model_override.get("api_mode"), + "base_url": override_base_url, "api_key": model_override.get("api_key"), "api_mode": model_override.get("api_mode"), } else: model, requested_provider = _resolve_startup_runtime() @@ -2614,12 +2504,35 @@ def _resolve_agent_model_runtime(model_override, provider_override) -> tuple[str if not resolution.selected_model: raise RuntimeError("Auth fallback resolved without a model") return resolution.selected_model, runtime - for k, v in overrides.items(): - if v: - runtime[k] = v + runtime.update({k: v for k, v in overrides.items() if v}) return model, runtime +def _startup_system_prompt(cfg: dict, task_id: str) -> str: + """Config ephemeral system prompt + the HERMES_TUI_SKILLS preload block. Hard-fails only when EVERY + requested skill is missing (cli.py parity): a typo'd name must not auto-block the Kanban task.""" + from hermes_cli.config import resolve_ephemeral_system_prompt_from_config + system_prompt = resolve_ephemeral_system_prompt_from_config(cfg) + startup_skills = _parse_tui_skills_env() + if not startup_skills: + return system_prompt + from agent.skill_commands import build_preloaded_skills_prompt + skills_prompt, loaded_skills, missing_skills = build_preloaded_skills_prompt(startup_skills, task_id=task_id) + if missing_skills: + missing_display = ", ".join(missing_skills) + if not loaded_skills: + raise ValueError(f"Unknown skill(s): {missing_display}") + logger.warning( + "Unknown skill(s) requested, skipping: %s. " + "Continuing with: %s. " + "List available skills with `hermes skills list`.", + missing_display, ", ".join(loaded_skills), + ) + if skills_prompt: + system_prompt = "\n\n".join(part for part in (system_prompt, skills_prompt) if part).strip() + return system_prompt + + def _make_agent( sid: str, key: str, session_id: str | None = None, session_db=None, model_override: dict | str | None = None, provider_override: str | None = None, @@ -2632,43 +2545,18 @@ def _make_agent( if synthetic is not None: return synthetic from run_agent import AIAgent - - # MCP discovery runs in a background daemon thread so a dead server can't - # freeze the shell; the agent snapshots its tool list once, so briefly - # (bounded) wait for in-flight discovery. Dashboard /api/ws uses - # hermes_cli.mcp_startup; TUI stdio keeps the tui_gateway.entry thread. + # MCP discovery runs in a background daemon thread so a dead server can't freeze the shell; the + # agent snapshots its tool list once, so briefly (bounded) wait for in-flight discovery. + # Dashboard /api/ws uses hermes_cli.mcp_startup; TUI stdio keeps the tui_gateway.entry thread. for _mod in ("hermes_cli.mcp_startup", "tui_gateway.entry"): with contextlib.suppress(Exception): importlib.import_module(_mod).wait_for_mcp_discovery() cfg = _load_cfg() - from hermes_cli.config import resolve_ephemeral_system_prompt_from_config - system_prompt = resolve_ephemeral_system_prompt_from_config(cfg) - startup_skills = _parse_tui_skills_env() - if startup_skills: - from agent.skill_commands import build_preloaded_skills_prompt - skills_prompt, loaded_skills, missing_skills = build_preloaded_skills_prompt( - startup_skills, task_id=session_id or key, - ) - if missing_skills: - missing_display = ", ".join(missing_skills) - # Hard-fail only when EVERY requested skill is missing (cli.py - # parity): a typo'd name must not auto-block the Kanban task. - if loaded_skills: - logger.warning( - "Unknown skill(s) requested, skipping: %s. " - "Continuing with: %s. " - "List available skills with `hermes skills list`.", - missing_display, - ", ".join(loaded_skills), - ) - else: - raise ValueError(f"Unknown skill(s): {missing_display}") - if skills_prompt: - system_prompt = "\n\n".join( - part for part in (system_prompt, skills_prompt) if part - ).strip() + system_prompt = _startup_system_prompt(cfg, session_id or key) model, runtime = _resolve_agent_model_runtime(model_override, provider_override) _pr = _load_provider_routing() + platform = _resolve_agent_platform(platform_override) + ignore_rules = is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")) agent = AIAgent( model=model, max_iterations=_cfg_max_turns(cfg, 500), @@ -2682,16 +2570,10 @@ def _make_agent( quiet_mode=True, verbose_logging=False, # DEBUG agent logging; independent of tool_progress_mode reasoning_config=( - reasoning_config_override - if reasoning_config_override is not None - else _load_reasoning_config(str(model or "")) + reasoning_config_override if reasoning_config_override is not None else _load_reasoning_config(str(model or "")) ), - service_tier=( - service_tier_override - if service_tier_override is not None - else _load_service_tier() - ), - enabled_toolsets=_load_enabled_toolsets(_resolve_agent_platform(platform_override)), + service_tier=service_tier_override if service_tier_override is not None else _load_service_tier(), + enabled_toolsets=_load_enabled_toolsets(platform), # OpenRouter provider_routing prefs (gateway + CLI parity). providers_allowed=_pr.get("only"), providers_ignored=_pr.get("ignore"), @@ -2699,14 +2581,14 @@ def _make_agent( provider_sort=_pr.get("sort"), provider_require_parameters=_pr.get("require_parameters", False), provider_data_collection=_pr.get("data_collection"), - platform=_resolve_agent_platform(platform_override), + platform=platform, session_id=session_id or key, session_db=session_db if session_db is not None else _get_db(), ephemeral_system_prompt=system_prompt or None, checkpoints_enabled=is_truthy_value(os.environ.get("HERMES_TUI_CHECKPOINTS")), pass_session_id=is_truthy_value(os.environ.get("HERMES_TUI_PASS_SESSION_ID")), - skip_context_files=is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")), - skip_memory=is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")), + skip_context_files=ignore_rules, + skip_memory=ignore_rules, fallback_model=_load_fallback_model(), **_agent_cbs(sid), ) @@ -2718,61 +2600,23 @@ def _make_agent( return agent -def _init_session( - sid: str, key: str, agent, history: list, cols: int = 80, cwd: str | None = None, - session_db=None, source: str | None = None, profile_home: str | None = None, - explicit_cwd: bool = False, -): - now = time.time() - with _sessions_lock: - _sessions[sid] = { - "agent": agent, - "session_key": key, - "history": history, - "history_lock": threading.Lock(), - "history_version": 0, - "inflight_turn": None, - "created_at": now, - "last_active": now, - "running": False, - "attached_images": [], - "image_counter": 0, - "cwd": cwd or _completion_cwd(), - "explicit_cwd": bool(explicit_cwd), - "cols": cols, - "slash_worker": None, - "show_reasoning": _load_show_reasoning(), - "source": _resolve_session_source(source), - "tool_progress_mode": _load_tool_progress_mode(), - "edit_snapshots": {}, - "tool_started_at": {}, - # Profile-scoped HERMES_HOME (None = launch profile); SessionBranch - # copies the parent's so the child stays on the same state.db. - "profile_home": profile_home, - # In-session /model switch, honored on rebuild (/new, resume) so it - # never leaks into siblings via process-global env vars. - "model_override": None, - # Async events go to the transport that created the session - # (stdio for Ink, JSON-RPC WS for the dashboard sidebar). - "transport": current_transport() or _stdio_transport, - } - _session_todo_state(_sessions[sid]) - _init_owns_db = False +def _hydrate_session_cwd(sid: str, key: str, session_db, profile_home: str | None) -> None: + """Adopt the stored row's cwd, or persist the fresh session's cwd (+ schedule git meta) when the row has none.""" + owns_db = False if session_db is not None: db = session_db elif profile_home: try: db = _open_profile_session_db(profile_home) - _init_owns_db = True + owns_db = True except Exception: - # FAIL CLOSED (same class as the deferred-build bind): a named-profile - # session must never touch the launch state.db — skip cwd hydration - # (the row lands on the agent's own lazy-create once the store recovers). + # FAIL CLOSED (same class as the deferred-build bind): a named-profile session must never + # touch the launch state.db — skip cwd hydration (the row lands on the agent's own + # lazy-create once the store recovers). logger.warning( "profile session store unavailable for %s — skipping cwd " "hydration instead of touching the launch state.db", - profile_home, - exc_info=True, + profile_home, exc_info=True, ) db = None else: @@ -2792,12 +2636,38 @@ def _init_session( except Exception: logger.debug("failed to persist resumed session cwd", exc_info=True) finally: - if _init_owns_db and db is not None: + if owns_db and db is not None: with contextlib.suppress(Exception): db.close() + + +def _init_session( + sid: str, key: str, agent, history: list, cols: int = 80, cwd: str | None = None, + session_db=None, source: str | None = None, profile_home: str | None = None, + explicit_cwd: bool = False, +): + now = time.time() + with _sessions_lock: + _sessions[sid] = { + "agent": agent, "session_key": key, "history": history, "history_lock": threading.Lock(), + "history_version": 0, "inflight_turn": None, "created_at": now, "last_active": now, + "running": False, "attached_images": [], "image_counter": 0, "cwd": cwd or _completion_cwd(), + "explicit_cwd": bool(explicit_cwd), "cols": cols, "slash_worker": None, + "show_reasoning": _load_show_reasoning(), "source": _resolve_session_source(source), + "tool_progress_mode": _load_tool_progress_mode(), "edit_snapshots": {}, "tool_started_at": {}, + # Profile-scoped HERMES_HOME (None = launch profile); SessionBranch copies the parent's + # so the child stays on the same state.db. + "profile_home": profile_home, + # In-session /model switch, honored on rebuild (/new, resume) so it never leaks into + # siblings via process-global env vars. + "model_override": None, + # Async events go to the transport that created the session (stdio for Ink, WS for the dashboard). + "transport": current_transport() or _stdio_transport, + } + _session_todo_state(_sessions[sid]) + _hydrate_session_cwd(sid, key, session_db, profile_home) _register_session_cwd(_sessions[sid]) - # No eager slash-worker pre-warm (see _start_agent_build). - _wire_session_agent(sid, key, agent) + _wire_session_agent(sid, key, agent) # no eager slash-worker pre-warm (see _start_agent_build) _start_session_services(sid, key, _sessions.get(sid, {})) _emit("session.info", sid, _session_info(agent, _sessions.get(sid, {}))) _schedule_mcp_late_refresh(sid, agent) @@ -2825,11 +2695,8 @@ def _resolve_checkpoint_hash(mgr, cwd: str, ref: str) -> str: # ── Methods: session ───────────────────────────────────────────────── -def _lazy_resume_info( - cwd: str, *, model: str = "", provider: str = "", profile: str | None = None, -) -> dict: - """session.info for a not-yet-built session (the shape session.create - returns). tools/skills land later when the deferred build emits session.info.""" +def _lazy_resume_info(cwd: str, *, model: str = "", provider: str = "", profile: str | None = None) -> dict: + """session.info for a not-yet-built session (session.create's shape); tools/skills land with the deferred build.""" info = { "cwd": cwd, "branch": _git_branch_for_cwd(cwd), "project": _project_info_for_cwd(cwd), "model": model or _resolve_model(), "tools": {}, "skills": {}, "lazy": True, @@ -2848,8 +2715,7 @@ def _deferred_session_record( resume_runtime_overrides: dict | None = None, todo_state: dict | None = None, explicit_cwd: bool = False, ) -> dict: - """A live-session record whose AIAgent is built later (lazy watch / cold - resume) — _init_session's shape minus the agent.""" + """A live-session record whose AIAgent is built later (lazy watch / cold resume) — _init_session's shape minus the agent.""" now = time.time() return { "agent": None, "agent_error": None, "agent_ready": threading.Event(), "attached_images": [], @@ -2872,63 +2738,44 @@ _ANY_PROFILE = object() # default: match a live session regardless of profile def _live_profile_matches(session: dict, profile_home) -> bool: - """True when ``session`` belongs to ``profile_home`` (None = launch profile). - - Same string compare as session.resume's ``_find_live_unpersisted``: a - record with no ``profile_home`` is the launch profile's. ``_ANY_PROFILE`` - disables the check for callers that have no profile to scope by. - """ + """True when ``session`` belongs to ``profile_home`` (None = launch profile; a record with no + ``profile_home`` is the launch profile's). ``_ANY_PROFILE`` disables the check.""" if profile_home is _ANY_PROFILE: return True - want = str(profile_home) if profile_home else None - return (session.get("profile_home") or None) == want + return (session.get("profile_home") or None) == (str(profile_home) if profile_home else None) -def _claim_or_reuse_live( - sid: str, session_key: str, record: dict, lease -) -> tuple[str, dict] | None: - """Register ``record`` as the live session for ``session_key`` under the - resume lock, or — if a concurrent resume already won — release ``lease`` and - return the winner for the caller to reuse.""" - # The record carries the home this resume resolved; a live runtime of the - # same stored id under ANOTHER profile is not a winner to reuse (#100029). +def _claim_or_reuse_live(sid: str, session_key: str, record: dict, lease) -> tuple[str, dict] | None: + """Register ``record`` as the live session for ``session_key`` under the resume lock, or — if a + concurrent resume already won — release ``lease`` and return the winner for the caller to reuse.""" + # The record carries the home this resume resolved; a live runtime of the same stored id under + # ANOTHER profile is not a winner to reuse. profile_home = record.get("profile_home") with _session_resume_lock: live = _find_live_session_by_key(session_key, profile_home) if live is not None: if lease is not None: lease.release() - # The winner is being reattached by this resume: any pending - # ws-orphan reap for it must not fire against the reclaimed - # client (storm killer — see _cancel_ws_orphan_reap). + # The winner is being reattached by this resume: a pending ws-orphan reap must not + # fire against the reclaimed client (storm killer — see _cancel_ws_orphan_reap). _cancel_ws_orphan_reap(live[0]) return live with _sessions_lock: _sessions[sid] = record _register_session_cwd(_sessions[sid]) - # A PRIOR runtime for this stored id may still be sentinel-parked with - # a reap Timer armed; cancel + finalize it quietly so the reap doesn't - # broadcast session.reclaimed for a just-re-resumed session (storm). + # A PRIOR runtime for this stored id may still be sentinel-parked with a reap Timer armed; + # cancel + finalize it quietly so the reap doesn't broadcast session.reclaimed (storm). _cancel_ws_orphan_reap(sid) stale = _claim_parked_runtimes(session_key, keep_sid=sid, profile_home=profile_home) - # Slow finalization work stays OUTSIDE _session_resume_lock (see - # _pop_session_by_id) — the stale records are already claimed above. - _finalize_superseded_runtimes(stale) + _finalize_superseded_runtimes(stale) # slow finalization stays OUTSIDE _session_resume_lock return None -def _claim_parked_runtimes( - session_key: str, *, keep_sid: str, profile_home=_ANY_PROFILE -) -> list[tuple[str, dict]]: - """Claim sentinel-parked stale runtimes of ``session_key`` for supersession. - - When a resume mints a fresh runtime for stored session id ``session_key``, - any older runtime record for the same stored id that is still parked on - the detached-WS sentinel is superseded: its pending orphan-reap Timer is - cancelled and the record is atomically popped from ``_sessions`` here - (under the caller's _session_resume_lock), then finalized by - :func:`_finalize_superseded_runtimes` after the lock is released. - """ +def _claim_parked_runtimes(session_key: str, *, keep_sid: str, profile_home=_ANY_PROFILE) -> list[tuple[str, dict]]: + """Claim sentinel-parked stale runtimes of ``session_key`` for supersession: older records for the + same stored id still parked on the detached-WS sentinel get their orphan-reap Timer cancelled and + are popped from ``_sessions`` here (under the caller's _session_resume_lock), then finalized by + :func:`_finalize_superseded_runtimes` after the lock is released.""" stale: list[tuple[str, dict]] = [] with _sessions_lock: candidates = [ @@ -2949,15 +2796,10 @@ def _claim_parked_runtimes( def _finalize_superseded_runtimes(stale: list[tuple[str, dict]]) -> None: - """Quietly finalize runtimes claimed by :func:`_claim_parked_runtimes`. - - Ends them with end_reason ``superseded_by_resume`` — deliberately NOT in - _RECLAIM_END_REASONS, so no ``session.reclaimed`` broadcast fires (that - broadcast triggers client auto-re-resume and fed the - reap->broadcast->resume feedback loop). ``superseded_by_resume`` IS in - hermes_state_common._RECOVERABLE_END_REASONS so canonical Bot Chat - resurrection still applies to the stored session. - """ + """Quietly finalize runtimes claimed by :func:`_claim_parked_runtimes` with end_reason + ``superseded_by_resume`` — deliberately NOT in _RECLAIM_END_REASONS (no ``session.reclaimed`` + broadcast, which fed the reap->broadcast->resume loop) but IN _RECOVERABLE_END_REASONS so + canonical Bot Chat resurrection still applies.""" for old_sid, popped in stale: try: _teardown_popped_session(popped, end_reason="superseded_by_resume") @@ -2966,8 +2808,7 @@ def _finalize_superseded_runtimes(stale: list[tuple[str, dict]]) -> None: def _schedule_agent_build(sid: str, delay: float = 0.05) -> None: - """Pre-warm a deferred session's agent off the response path (session.create - and cold resume both build through here; _sess() also builds on demand).""" + """Pre-warm a deferred session's agent off the response path (session.create + cold resume; _sess() also builds on demand).""" def _run(): session = _sessions.get(sid) From a87a3818afbeca1a695b6b536cbaefbb4e81f517 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:50:11 -0700 Subject: [PATCH 4/6] =?UTF-8?q?refactor(tui):=20server.py=20=E2=80=94=20?= =?UTF-8?q?=5Fload=5Fresume=5Ftranscript/=5Fpet=5Fcfg=20helpers,=20=5FCLI?= =?UTF-8?q?=5FEXEC=5FBLOCKED=20table,=20dead=20section=20banners=20removed?= =?UTF-8?q?,=20compact=20pet/spawn-tree/completion=20docstrings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tui_gateway/server.py | 504 ++++++++++++++---------------------------- 1 file changed, 171 insertions(+), 333 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index eb3fd170c7..cc806b848c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2819,6 +2819,34 @@ def _schedule_agent_build(sid: str, delay: float = 0.05) -> None: timer.start() +def _load_resume_transcript(db, stored_id: str) -> tuple[list, list, list]: + """(raw_history, display_history, ancestor_prefix) for a cold resume. + + The ancestor prefix is an in-memory convenience (the transcript is REST-paginated): the full + lineage is materialized only while it fits sessions.max_resume_messages, else the tip alone. + """ + from hermes_state import SessionResumeTooLargeError + prefix_fits = True + guard = getattr(db, "assert_resume_safe", None) + if callable(guard): + try: + guard(stored_id) + except SessionResumeTooLargeError as exc: + prefix_fits = False + logger.info( + "resume %s: compression lineage exceeds the resume " + "limit (%s); hydrating the tip segment only", + stored_id, exc, + ) + except Exception: + logger.debug("resume lineage guard failed; loading full lineage", exc_info=True) + if prefix_fits: + raw_history, display_history = db.get_resume_conversations(stored_id) + return raw_history, display_history, db.get_ancestor_display_prefix(stored_id) + raw_history = db.get_messages_as_conversation(stored_id, repair_alternation=True, include_row_ids=True) + return raw_history, raw_history, [] + + def _schedule_resume_hydration(sid: str, stored_id: str, db, *, close_db: bool = False) -> None: """Load a cold resume's transcript off the JSON-RPC response path.""" @@ -2829,34 +2857,7 @@ def _schedule_resume_hydration(sid: str, stored_id: str, db, *, close_db: bool = return _emit("session.resume_progress", sid, {"phase": "history", "status": "loading"}) db.reopen_session(stored_id) - from hermes_state import SessionResumeTooLargeError - - # The ancestor prefix is an in-memory convenience (the transcript - # is REST-paginated): materialize the full lineage only while it - # fits sessions.max_resume_messages, else hydrate the tip alone. - prefix_fits = True - guard = getattr(db, "assert_resume_safe", None) - if callable(guard): - try: - guard(stored_id) - except SessionResumeTooLargeError as exc: - prefix_fits = False - logger.info( - "resume %s: compression lineage exceeds the resume " - "limit (%s); hydrating the tip segment only", - stored_id, exc, - ) - except Exception: - logger.debug("resume lineage guard failed; loading full lineage", exc_info=True) - if prefix_fits: - raw_history, display_history = db.get_resume_conversations(stored_id) - prefix = db.get_ancestor_display_prefix(stored_id) - else: - raw_history = db.get_messages_as_conversation( - stored_id, repair_alternation=True, include_row_ids=True - ) - display_history = raw_history - prefix = [] + raw_history, display_history, prefix = _load_resume_transcript(db, stored_id) history = sanitize_replay_history(raw_history) if _sessions.get(sid) is not session: return @@ -2865,8 +2866,8 @@ def _schedule_resume_hydration(sid: str, stored_id: str, db, *, close_db: bool = session["display_history_prefix"] = prefix session["resume_hydrating"] = False session["resume_message_count"] = len(display_history) - # Deferred resumes answered before the transcript existed; cache - # the derived todo snapshot now so later payload attaches carry it. + # Deferred resumes answered before the transcript existed; cache the derived todo + # snapshot now so later payload attaches carry it. todo_state = _todo_state_from_history(history) if todo_state is not None and session.get("todo_state") is None: session["todo_state"] = todo_state @@ -2886,10 +2887,7 @@ def _schedule_resume_hydration(sid: str, stored_id: str, db, *, close_db: bool = session["agent_error"] = message session["resume_history_ready"].set() session["agent_ready"].set() - _emit( - "session.resume_progress", sid, - {"message": message, "phase": "history", "status": "failed"}, - ) + _emit("session.resume_progress", sid, {"message": message, "phase": "history", "status": "failed"}) _emit("error", sid, {"message": message}) with _sessions_lock: discarded = _sessions.pop(sid, None) if _sessions.get(sid) is session else None @@ -2907,10 +2905,9 @@ def _schedule_resume_hydration(sid: str, stored_id: str, db, *, close_db: bool = def _session_pending_kind(sid: str) -> str: for rid, (owner_sid, _ev) in list(_pending.items()): - if owner_sid != sid: - continue - event, _payload = _pending_prompt_payloads.get(rid, ("input.request", {})) - return str(event).removesuffix(".request") + if owner_sid == sid: + event, _payload = _pending_prompt_payloads.get(rid, ("input.request", {})) + return str(event).removesuffix(".request") return "" @@ -2918,13 +2915,10 @@ def _session_live_status(sid: str, session: dict) -> str: if _session_pending_kind(sid): return "waiting" ready = session.get("agent_ready") - # Unset + build never started = a lazy watch session sitting idle, not a - # session stuck mid-construction. + # Unset + build never started = a lazy watch session idling, not one stuck mid-construction. if ready is not None and not ready.is_set() and session.get("agent_build_started"): return "starting" - if session.get("running"): - return "working" - return "idle" + return "working" if session.get("running") else "idle" def _message_preview(history: list) -> str: @@ -2953,11 +2947,9 @@ def _session_live_item(sid: str, session: dict, current_sid: str = "") -> dict: queued = _queued_prompt_snapshot(session) preview = _message_preview(history) if queued: - preview = queued.get("user") or preview - preview = " ".join(str(preview).split())[:160] + preview = " ".join(str(queued.get("user") or preview).split())[:160] elif inflight: - preview = inflight.get("assistant") or inflight.get("user") or preview - preview = " ".join(str(preview).split())[:160] + preview = " ".join(str(inflight.get("assistant") or inflight.get("user") or preview).split())[:160] now = time.time() return { "current": sid == current_sid, "id": sid, @@ -2974,18 +2966,13 @@ def _session_lookup_key(session: dict, *, fallback: str = "") -> str: return str(getattr(agent, "session_id", None) or session.get("session_key") or fallback or "") -def _find_live_session_by_key( - session_key: str, profile_home=_ANY_PROFILE -) -> tuple[str, dict] | None: - # Timestamp-based stored ids can exist in several profiles' stores; a - # bare-id match would hand profile B's resume profile A's runtime, so - # profile-aware callers match on (profile_home, session_key). +def _find_live_session_by_key(session_key: str, profile_home=_ANY_PROFILE) -> tuple[str, dict] | None: + # Timestamp-based stored ids can exist in several profiles' stores; a bare-id match would hand + # profile B's resume profile A's runtime, so profile-aware callers match on (profile_home, key). for sid, session in list(_sessions.items()): if session.get("_finalized"): continue - if _session_lookup_key(session, fallback=sid) == session_key and _live_profile_matches( - session, profile_home - ): + if _session_lookup_key(session, fallback=sid) == session_key and _live_profile_matches(session, profile_home): return sid, session return None @@ -2994,45 +2981,25 @@ def _fallback_session_info(session: dict) -> dict: agent = session.get("agent") if agent is not None: return _session_info(agent) - # The SESSION's own workspace, not the gateway launch dir (that painted the - # wrong project in the desktop Files pane). `branch` is always emitted ("" - # outside git) so a client clears a stale label — same as _lazy_session_info. + # The SESSION's own workspace, not the gateway launch dir (that painted the wrong project in the + # desktop Files pane). `branch` is always emitted ("" outside git) so a client clears a stale label. + # `desktop_contract`: a lazy session is still served by THIS backend; missing reads as 0 = "out of date". cwd = _session_cwd(session) return { - "cwd": cwd, - "branch": _git_branch_for_cwd(cwd), - "project": _project_info_for_cwd(cwd), - "lazy": True, - "model": _resolve_model(), - "skills": {}, - "tools": {}, - # A lazy session is still served by THIS backend: a missing contract - # field reads as 0 and flags a current backend "out of date". - "desktop_contract": DESKTOP_BACKEND_CONTRACT, + "cwd": cwd, "branch": _git_branch_for_cwd(cwd), "project": _project_info_for_cwd(cwd), "lazy": True, + "model": _resolve_model(), "skills": {}, "tools": {}, "desktop_contract": DESKTOP_BACKEND_CONTRACT, } def _reconcile_display_with_live(db_display: list[dict], in_memory: list[dict]) -> list[dict]: """Merge the persisted DISPLAY lineage with the in-memory live history. - Two projections of the same session that each hold something the other - lacks: - - - ``db_display`` — the verbatim persisted lineage. It includes - *model-invisible* rows (verification candidates, finish_reason - ``verification_required`` / ``verify_hook_continue``) that the in-memory - model history collapses out via ``repair_message_sequence`` (#65919), but - it can lag the newest turn by a flush. - - ``in_memory`` — ``display_history_prefix + session["history"]``. It is the - freshest recency authority (a just-appended turn may not be flushed yet) - but it is the collapsed *model* projection, so it is missing candidates. - - The merge keeps the DB display (candidate-inclusive) as the base and appends - only the in-memory tail that the DB does not yet cover, anchored on the last - DB row's ``(role, text)``. This satisfies BOTH invariants at once: the - substantive verification answer survives a warm/live switch (matching the - eager resume + REST payloads), and a not-yet-flushed live turn is not - dropped. + ``db_display`` is verbatim and candidate-inclusive (verification rows the model history collapses + out via ``repair_message_sequence``) but can lag the newest turn by a flush; ``in_memory`` + (``display_history_prefix + session["history"]``) is the recency authority but the collapsed + *model* projection. Keep the DB display as base and append only the in-memory tail past the + last DB row's ``(role, text)`` anchor, so the verification answer survives a warm switch AND + a not-yet-flushed live turn is not dropped. """ if not db_display: return in_memory @@ -3047,35 +3014,21 @@ def _reconcile_display_with_live(db_display: list[dict], in_memory: list[dict]) if isinstance(msg, dict) and _key(msg) == anchor: last_shared = idx if last_shared == -1: - # The DB tail isn't present in memory (DB is ahead, or the histories - # diverged) — trust the persisted display rather than risk duplicating. - return db_display + return db_display # DB tail not in memory (DB ahead, or diverged) — trust it over duplicating return list(db_display) + list(in_memory[last_shared + 1 :]) def _live_visible_history(session: dict, db, in_memory_fallback: list[dict]) -> list[dict]: - """Return the user-visible DISPLAY projection for a live/warm session. - - The raw in-memory *model* history lacks model-invisible rows (verification - candidates) that the eager ``session.resume`` display lineage shows, so the - two payloads disagreed ("substantive answer vanishes on switch"). Reconcile - the persisted display lineage (``get_messages_as_conversation(..., - include_ancestors=True)``, same read as resume/REST) with the fresh - in-memory tail; fall back to in-memory when the DB/session_key is - unavailable or the read fails. - """ + """User-visible DISPLAY projection for a live/warm session: the persisted display lineage (same + read as resume/REST, so the two payloads agree — "substantive answer vanishes on switch") + reconciled with the fresh in-memory tail; in-memory when the DB/session_key is unavailable.""" key = session.get("session_key") if db is not None and key: try: + # include_compacted: a compacted session's archived turns are still the user's + # conversation; without them a warm switch repainted the chat as summary + tail only. display = db.get_messages_as_conversation( - key, - include_ancestors=True, - include_row_ids=True, - # Display read: a compacted session's archived turns are still - # the user's conversation. Without them a warm switch repainted - # the chat as just the summary + tail while the REST transcript - # showed everything (#92080). - include_compacted=True, + key, include_ancestors=True, include_row_ids=True, include_compacted=True, ) return _reconcile_display_with_live(display, in_memory_fallback) except Exception: @@ -3092,34 +3045,21 @@ def _live_session_payload( session["cols"] = cols if transport is not None: session["transport"] = transport - # Every transport that has shown this session (pop-out windows - # resume the same sid); the last viewer becomes the transport on - # disconnect instead of stranding it on the drop sentinel. - viewers = session.setdefault("viewers", {}) - viewers[transport] = time.time() + # Every transport that has shown this session (pop-out windows resume the same sid); + # the last viewer becomes the transport on disconnect instead of the drop sentinel. + session.setdefault("viewers", {})[transport] = time.time() if transport is not _detached_ws_transport: - # A live transport rebind means the client is back — any - # pending ws-orphan reap must not fire (storm killer). - _cancel_ws_orphan_reap(sid) + _cancel_ws_orphan_reap(sid) # the client is back — a pending ws-orphan reap must not fire if touch: session["last_active"] = time.time() - in_memory_history = list(session.get("display_history_prefix") or []) + list( - session.get("history") or [] - ) + in_memory_history = list(session.get("display_history_prefix") or []) + list(session.get("history") or []) inflight = _inflight_snapshot(session) queued = _queued_prompt_snapshot(session) running = bool(session.get("running")) - inflight_turn = session.get("inflight_turn") - turn_started_at = ( - float(inflight_turn["started_at"]) - if isinstance(inflight_turn, dict) and inflight_turn.get("started_at") - else None - ) - # Persisted display lineage (candidate-inclusive) so this matches the eager - # resume + REST transcript; via the session's profile-aware DB, not the - # launch ``_get_db()`` (remote-profile candidates live in profile_home). - # The DB has its own lock — read outside the history lock. ``omit_messages`` - # skips the read entirely (fast path for counts/status). + turn_started_at = _turn_started_at(session) + # Persisted display lineage (candidate-inclusive) so this matches resume + REST; via the session's + # profile-aware DB, not the launch ``_get_db()``. The DB has its own lock — read outside the + # history lock. ``omit_messages`` skips the read entirely (fast path for counts/status). if omit_messages: history = in_memory_history else: @@ -3145,12 +3085,8 @@ def _live_session_payload( def _main_runtime_from_agent(agent) -> dict | None: - """Build an aux-client main_runtime override from a live agent. - - Lets a one-shot inherit the session's provider/model/credentials so its - output matches the model the user is actually coding with, instead of - falling back to the cheapest auto-detected backend. - """ + """Aux-client main_runtime override from a live agent, so a one-shot inherits the session's + provider/model/credentials instead of the cheapest auto-detected backend.""" if agent is None: return None runtime: dict = {} @@ -3163,16 +3099,14 @@ def _main_runtime_from_agent(agent) -> dict | None: return runtime or None +# Pet helpers are fail-open throughout: a decode hiccup degrades to a static fallback rather than +# breaking the (cosmetic) pet surface. def _pet_frame_counts(spritesheet) -> dict: - """Real (padding-trimmed) frame count per state, for the desktop canvas. - - Fail-open: a decode hiccup returns ``{}`` and the canvas falls back to its - static ``framesPerState`` rather than breaking the (cosmetic) pet. - """ + """Real (padding-trimmed) frame count per state for the desktop canvas ({} → static ``framesPerState``).""" try: from agent.pet import render return render.state_frame_counts(str(spritesheet)) - except Exception: # noqa: BLE001 - cosmetic, never break the surface + except Exception: # noqa: BLE001 return {} @@ -3185,7 +3119,7 @@ def _pet_sheet_revision(spritesheet) -> str: try: stat = spritesheet.stat() return f"{stat.st_mtime_ns}:{stat.st_size}" - except Exception: # noqa: BLE001 - cosmetic, never break the surface + except Exception: # noqa: BLE001 return "0:0" @@ -3195,21 +3129,15 @@ def _pet_payload_cache_key(pet, *, scale: float) -> tuple | None: stat = pet.spritesheet.stat() except Exception: # noqa: BLE001 return None - return ( - str(pet.spritesheet), stat.st_mtime_ns, stat.st_size, pet.slug, pet.display_name, - round(scale, 4), - ) + return (str(pet.spritesheet), stat.st_mtime_ns, stat.st_size, pet.slug, pet.display_name, round(scale, 4)) def _clone_pet_payload(payload: dict) -> dict: """Shallow-clone cached payloads so callers can't mutate shared state.""" out = dict(payload) - if isinstance(payload.get("framesByState"), dict): - out["framesByState"] = dict(payload["framesByState"]) - if isinstance(payload.get("framesByRow"), dict): - out["framesByRow"] = dict(payload["framesByRow"]) - if isinstance(payload.get("stateRows"), list): - out["stateRows"] = list(payload["stateRows"]) + for key, kind in (("framesByState", dict), ("framesByRow", dict), ("stateRows", list)): + if isinstance(payload.get(key), kind): + out[key] = kind(payload[key]) return out @@ -3235,7 +3163,18 @@ def _pet_row_frame_counts(spritesheet) -> dict: count += 1 out[name] = count return out - except Exception: # noqa: BLE001 - cosmetic, never break the surface + except Exception: # noqa: BLE001 + return {} + + +def _pet_cfg() -> dict: + """``display.pet`` from the canonical config ({} on any failure).""" + try: + from hermes_cli.config import load_config + cfg = load_config() + display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {} + return display.get("pet", {}) if isinstance(display.get("pet"), dict) else {} + except Exception: # noqa: BLE001 return {} @@ -3243,21 +3182,14 @@ def _pet_config_scale() -> float: """Configured ``display.pet.scale`` (or the engine default), never raises.""" from agent.pet import constants try: - from hermes_cli.config import load_config - cfg = load_config() - display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {} - pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {} - return float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE) + return float(_pet_cfg().get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE) except Exception: # noqa: BLE001 return constants.DEFAULT_SCALE def _pet_sprite_payload(pet, *, scale: float) -> dict: - """Build the renderer payload (spritesheet bytes + geometry) for *pet*. - - Shared by ``pet.info`` (the active mascot) and ``pet.hatch`` (the unadopted - preview) so both feed the desktop canvas / TUI from one shape. - """ + """Renderer payload (spritesheet bytes + geometry) for *pet* — one shape for ``pet.info`` (active + mascot) and ``pet.hatch`` (unadopted preview).""" import base64 from agent.pet import constants cache_key = _pet_payload_cache_key(pet, scale=scale) @@ -3289,13 +3221,7 @@ def _pet_sprite_payload(pet, *, scale: float) -> dict: def _pet_active_selection(): """Resolve configured active pet + scale from config.""" from agent.pet import constants, store - try: - from hermes_cli.config import load_config - cfg = load_config() - display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {} - pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {} - except Exception: - pet_cfg = {} + pet_cfg = _pet_cfg() enabled = is_truthy_value(pet_cfg.get("enabled"), default=False) configured_slug = str(pet_cfg.get("slug", "") or "") pet = store.resolve_active_pet(configured_slug) if enabled else None @@ -3304,26 +3230,20 @@ def _pet_active_selection(): def _pet_state_rows(spritesheet) -> list[str]: - """Row taxonomy for the concrete active pet sheet. - - Hermes has to support both the legacy 8-row petdex atlas and the current - Codex/petdex 9-row atlas. The desktop canvas gets this list and indexes it - with the same `PetState` names the Python renderer uses. - """ + """Row taxonomy for the concrete active pet sheet (legacy 8-row petdex atlas or current 9-row + atlas); the desktop canvas indexes it with the same `PetState` names the Python renderer uses.""" + from agent.pet import constants try: from PIL import Image - from agent.pet import constants with Image.open(spritesheet) as image: row_count = max(1, image.height // constants.FRAME_H) return list(constants.state_rows_for_grid(row_count)) - except Exception: # noqa: BLE001 - cosmetic, never break the surface - from agent.pet import constants + except Exception: # noqa: BLE001 return list(constants.STATE_ROWS) def _pet_gen_root(): """Profile-scoped staging dir for in-progress generation drafts.""" - from hermes_constants import get_hermes_home root = get_hermes_home() / "cache" / "pet-gen" root.mkdir(parents=True, exist_ok=True) return root @@ -3332,7 +3252,6 @@ def _pet_gen_root(): def _pet_gen_sweep(root, *, max_age_s: float = 3600.0) -> None: """Drop stale draft staging dirs so cache never grows unbounded.""" import shutil - import time try: now = time.time() for child in root.iterdir(): @@ -3355,16 +3274,13 @@ def _pet_png_data_uri(path, *, max_px: int = 160) -> str: return "data:image/png;base64," + base64.standard_b64encode(buf.getvalue()).decode("ascii") -# Cooperative cancellation for pet generation: Stop aborts the RPC, but the -# pool job keeps running unless pet.cancel flips its token (polled between -# provider calls). +# Cooperative cancellation for pet generation: Stop aborts the RPC, but the pool job keeps +# running unless pet.cancel flips its token (polled between provider calls). _pet_cancel_lock = threading.Lock() _pet_cancelled: set[str] = set() _PET_REFERENCE_MIME_EXT = {"png": "png", "jpeg": "jpg", "jpg": "jpg", "webp": "webp", "gif": "gif"} try: - _PET_REFERENCE_MAX_BYTES = max( - 1, int(os.environ.get("HERMES_PET_REFERENCE_MAX_BYTES") or str(16 * 1024 * 1024)), - ) + _PET_REFERENCE_MAX_BYTES = max(1, int(os.environ.get("HERMES_PET_REFERENCE_MAX_BYTES") or str(16 * 1024 * 1024))) except (TypeError, ValueError): _PET_REFERENCE_MAX_BYTES = 16 * 1024 * 1024 @@ -3402,6 +3318,9 @@ def _pet_cancel_arm(token: str) -> None: _pet_cancelled.discard(token) +_pet_cancel_release = _pet_cancel_arm + + def _pet_cancel_request(token: str) -> None: with _pet_cancel_lock: _pet_cancelled.add(token) @@ -3412,40 +3331,27 @@ def _pet_is_cancelled(token: str) -> bool: return token in _pet_cancelled -def _pet_cancel_release(token: str) -> None: - with _pet_cancel_lock: - _pet_cancelled.discard(token) - - -# ── Delegation: subagent tree observability + controls ─────────────── -# Powers the TUI's /agents overlay (see ui-tui/src/components/agentsOverlay). -# The registry lives in tools/delegate_tool — these handlers are thin -# translators between JSON-RPC and the Python API. - - # ── Spawn-tree snapshots: TUI-written, disk-persisted ──────────────── -# The TUI owns subagent state; on turn-complete it posts the final tree here, -# /replay fetches by session_id + filename. +# The TUI owns subagent state (the /agents overlay; registry in tools/delegate_tool); on +# turn-complete it posts the final tree here and /replay fetches by session_id + filename. # Layout: $HERMES_HOME/spawn-trees//.json def _spawn_trees_root(): - from hermes_constants import get_hermes_home root = get_hermes_home() / "spawn-trees" root.mkdir(parents=True, exist_ok=True) return root def _spawn_tree_session_dir(session_id: str): - safe = ("".join(c if c.isalnum() or c in "-_" else "_" for c in session_id) or "unknown") + safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id) or "unknown" d = _spawn_trees_root() / safe d.mkdir(parents=True, exist_ok=True) return d -# Per-session append-only index of lightweight snapshot metadata. Read by -# `spawn_tree.list` so scanning doesn't require reading every full snapshot -# file (Copilot review on #14045). One JSON object per line. +# Per-session append-only index (one JSON object per line) so `spawn_tree.list` needn't read every +# full snapshot. It is a cache: a lost line just means list() falls back to a directory scan. _SPAWN_TREE_INDEX = "_index.jsonl" @@ -3454,9 +3360,7 @@ def _append_spawn_tree_index(session_dir, entry: dict) -> None: with (session_dir / _SPAWN_TREE_INDEX).open("a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") except OSError as exc: - # Index is a cache — losing a line just means list() falls back - # to a directory scan for that entry. Never block the save. - logger.debug("spawn_tree index append failed: %s", exc) + logger.debug("spawn_tree index append failed: %s", exc) # never block the save def _read_spawn_tree_index(session_dir) -> list[dict]: @@ -3467,13 +3371,9 @@ def _read_spawn_tree_index(session_dir) -> list[dict]: try: with index_path.open("r", encoding="utf-8") as f: for line in f: - line = line.strip() - if not line: - continue - try: - out.append(json.loads(line)) - except json.JSONDecodeError: - continue + if line := line.strip(): + with contextlib.suppress(json.JSONDecodeError): + out.append(json.loads(line)) except OSError: return [] return out @@ -3485,31 +3385,19 @@ def _read_spawn_tree_index(session_dir) -> list[dict]: _GOAL_COMPRESSION_RECOVERY_ATTEMPTS = "_goal_compression_recovery_attempts" _GOAL_COMPRESSION_RECOVERY_LIMIT = 1 - - - -# Captured at import time: tests monkeypatch threading.Thread with a synchronous -# stub, and this ticker only exits once `stop` is set AFTER run_conversation -# returns — inline it would spin forever. Always a real daemon thread. +# Captured at import time: tests monkeypatch threading.Thread with a synchronous stub, and this +# ticker only exits once `stop` is set AFTER run_conversation returns — inline it would spin forever. _RealThread = threading.Thread -def _start_usage_ticker( - sid: str, agent, interval: float = 1.0 -) -> tuple[threading.Event, threading.Thread]: - """Push live ``session.usage`` snapshots every ``interval`` s while a turn runs. - - Otherwise the status-bar context figure is frozen until ``message.complete``. - (The codex app-server runtime folds usage in only at turn end, so it gets no - mid-turn ticks.) The caller must set the Event AND join the thread before - emitting ``message.complete``: a late tick would roll the client's final - usage back to a stale snapshot. - """ +def _start_usage_ticker(sid: str, agent, interval: float = 1.0) -> tuple[threading.Event, threading.Thread]: + """Push live ``session.usage`` snapshots every ``interval`` s while a turn runs (otherwise the + status-bar context figure freezes until ``message.complete``). The caller must set the Event AND + join the thread before emitting ``message.complete``: a late tick would roll the client's final + usage back to a stale snapshot.""" stop = threading.Event() - - # Dedup baseline sampled BEFORE the thread starts (the client already has - # the turn-start values); a late-scheduled thread would otherwise absorb the - # first counter growth and never emit it. + # Dedup baseline sampled BEFORE the thread starts (the client already has the turn-start + # values); a late-scheduled thread would otherwise absorb the first counter growth and never emit it. try: baseline: dict | None = _get_usage(agent) except Exception: @@ -3521,25 +3409,16 @@ def _start_usage_ticker( with contextlib.suppress(Exception): usage = _get_usage(agent) if usage == last: - # Counters frozen (e.g. one long API call in flight) — - # skip the redundant frame so idle ticks don't re-render - # the client status bar every second. - continue + continue # counters frozen (one long API call in flight): don't re-render the status bar last = usage if stop.is_set(): - # Turn ended while snapshotting — drop the tick; - # message.complete carries the authoritative usage. - break + break # turn ended while snapshotting; message.complete carries the authoritative usage _emit("session.usage", sid, {"usage": usage}) thread = _RealThread(target=_loop, daemon=True) thread.start() return stop, thread - - -# Byte-upload attach caps. 25 MB matches Anthropic's per-image limit; 50 MB / 25 -# pages bounds a single PDF drop so it can't blow the context budget. # ── Methods: respond ───────────────────────────────────────────────── @@ -3555,8 +3434,8 @@ def _respond(rid, params, key, *, allow_expired=False): _, ev = entry batch = _batch_clarify.get(r) if batch is not None and question_id: - # Per-question lock; update-in-place so a locked answer stays - # editable until every qid is locked (the Confirm click). + # Per-question lock; update-in-place so a locked answer stays editable until every + # qid is locked (the Confirm click). if question_id not in batch["qids"]: return _err(rid, 4002, f"unknown question_id {question_id!r}") batch["answers"][question_id] = params.get(key, "") @@ -3569,11 +3448,6 @@ def _respond(rid, params, key, *, allow_expired=False): return _ok(rid, {"status": "ok"}) -# ── Methods: config ────────────────────────────────────────────────── - - - - # ── Methods: tools & system ────────────────────────────────────────── @@ -3586,42 +3460,36 @@ def _session_processes(session: dict) -> list: proc = process_registry.get(entry["session_id"]) if proc is None or str(getattr(proc, "session_key", "") or "") != key: continue - # The 200-char list preview is too thin for the desktop's inline - # terminal viewer — ship a real tail alongside it. + # The 200-char list preview is too thin for the desktop's inline terminal viewer. entry["output_tail"] = (proc.output_buffer or "")[-4000:] owned.append(entry) return owned -# Serialize reload.mcp (it runs on the pool): overlapping shutdown+discover -# pairs would leave the registry half-built. +# Serialize reload.mcp (runs on the pool): overlapping shutdown+discover pairs would leave the +# registry half-built. _mcp_reload_lock = threading.Lock() -# Bumped per SUCCESSFUL reload; a follower skips only if it advanced while it -# waited (a leader that threw leaves it unchanged → follower reloads itself). +# Bumped per SUCCESSFUL reload; a follower skips only if it advanced while it waited (a leader +# that threw leaves it unchanged → follower reloads itself). _mcp_reload_gen = 0 -# The mcp_rev the last successful reload actually LOADED (re-hashed after -# discovery). A follower coalesces only when its requested rev matches; -# otherwise the config changed under the leader and it must reload itself. +# The mcp_rev the last successful reload actually LOADED (re-hashed after discovery). A follower +# coalesces only when its requested rev matches; otherwise the config changed under the leader. _mcp_reload_loaded_rev = "" -# Bounded convergence for a config edit racing a slow reload: the leader -# re-hashes after discovery and repeats until the hash is stable. +# Bounded convergence for a config edit racing a slow reload: the leader re-hashes after +# discovery and repeats until the hash is stable. _MCP_RELOAD_MAX_PASSES = 3 def _compute_mcp_rev() -> str: - """Hash of the MCP-relevant config sections (server definitions, - settings, toolset enables). ``config.get mtime`` ships it to the TUI so - cosmetic writes don't trigger reloads; ``reload.mcp`` uses it for - revision-aware coalescing. Empty string = unknown (fail open).""" + """Hash of the MCP-relevant config sections: mcp_servers (definitions — omitting it meant an + edited server never bumped the rev and never connected) + mcp (settings) + tools (enables). + ``config.get mtime`` ships it so cosmetic writes don't trigger reloads; ``reload.mcp`` uses it + for revision-aware coalescing. "" = unknown (fail open).""" try: cfg = _load_cfg() - # mcp_servers (definitions, what the CLI auto-reload watches) + mcp - # (settings) + tools (enable/disable); omitting mcp_servers meant an - # edited server never bumped mcp_rev and never connected. rev_src = json.dumps( {"mcp": cfg.get("mcp"), "mcp_servers": cfg.get("mcp_servers"), "tools": cfg.get("tools")}, - sort_keys=True, - default=str, + sort_keys=True, default=str, ) return hashlib.sha1(rev_src.encode()).hexdigest()[:12] except Exception: @@ -3629,8 +3497,7 @@ def _compute_mcp_rev() -> str: def _finish_reload(rid, params: dict, *, coalesced: bool) -> dict: - """Shared tail for both reload paths: honor ``always`` (persist the - confirm opt-out) and return the ok payload.""" + """Shared tail for both reload paths: honor ``always`` (persist the confirm opt-out) and return the ok payload.""" if bool(params.get("always", False)): try: from cli import save_config_value as _save_cfg @@ -3652,28 +3519,20 @@ _TUI_EXTRA: list[tuple[str, str, str]] = [ ("/sessions", "Switch between live TUI sessions", "TUI"), ] -# Commands that queue onto _pending_input in the CLI; the slash worker has no -# reader for that queue, so slash.exec routes them to command.dispatch instead. -_PENDING_INPUT_COMMANDS: frozenset[str] = frozenset( - { - "retry", "queue", "q", "steer", "plan", "goal", "loop", "proactive", "moa", "undo", "learn", - "init", "compress", "compact", - } -) +# Commands that queue onto _pending_input in the CLI; the slash worker has no reader for that +# queue, so slash.exec routes them to command.dispatch instead. +_PENDING_INPUT_COMMANDS: frozenset[str] = frozenset({ + "retry", "queue", "q", "steer", "plan", "goal", "loop", "proactive", "moa", "undo", "learn", + "init", "compress", "compact", +}) _WORKER_BLOCKED_COMMANDS: frozenset[str] = frozenset({"snapshot", "snap"}) def _skill_usage_lookup(): - """Build ``(usage, origin)`` callables for the skill-command catalog. - - ``usage(name)`` is the skill's observed activity count (use + view + - patch); ``origin(name)`` is ``"hub"``, ``"bundled"``, or ``"local"`` — the - same classification ``/api/skills`` reports as ``provenance`` (where - "local" is spelled "agent"). Both read sidecar files that are cheap and - already parsed once per catalog build. Any failure degrades to zero usage - and ``"local"`` so a missing/corrupt sidecar can never break the catalog. - """ + """``(usage, origin)`` callables for the skill-command catalog: ``usage(name)`` = observed activity + count (use + view + patch); ``origin(name)`` = "hub" / "bundled" / "local" (what ``/api/skills`` + reports as ``provenance``, "local" spelled "agent"). Any failure degrades to 0 / "local".""" try: from tools.skill_usage import ( _read_bundled_manifest_names, _read_hub_installed_names, activity_count, load_usage, @@ -3703,23 +3562,14 @@ def _skill_usage_lookup(): _SLASH_COMPLETION_LIMIT = 30 -def _rank_slash_completions( - items: list[dict], usage, origin_of, *, browsing: bool, score_of=None, -) -> list[dict]: +def _rank_slash_completions(items: list[dict], usage, origin_of, *, browsing: bool, score_of=None) -> list[dict]: """Rank and bound slash completions the way the menu should read. - ``usage``/``origin_of`` come from :func:`_skill_usage_lookup`. Registry - commands keep their order; only the skill block is reordered: fuzzy - ``score_of`` first (a name match beats a description match), then - most-used, then A-Z. - - The limit is spent PER KIND, not as one flat truncation: commands are - emitted before the first skill, so a flat cut on a large install offered - no skill at all and dropped heavily-used skills for never-opened ones. - - ``browsing`` (bare ``/``) drops bundled skills with no recorded activity - as noise; a typed query is SEARCHING, and a search that hides a match is - broken — nothing is pruned there, only reordered. + Registry commands keep their order; only the skill block is reordered: fuzzy ``score_of`` first + (a name match beats a description match), then most-used, then A-Z. The limit is spent PER KIND: + commands come before the first skill, so a flat cut on a large install offered no skill at all. + ``browsing`` (bare ``/``) drops never-used bundled skills as noise; a typed query is SEARCHING and + a search that hides a match is broken — nothing is pruned there, only reordered. """ def name_of(item: dict) -> str: @@ -3727,11 +3577,7 @@ def _rank_slash_completions( commands = [item for item in items if item.get("kind") != "skill"] skills = [item for item in items if item.get("kind") == "skill"] if browsing: - skills = [ - item - for item in skills - if origin_of(name_of(item)) != "bundled" or usage(name_of(item)) > 0 - ] + skills = [item for item in skills if origin_of(name_of(item)) != "bundled" or usage(name_of(item)) > 0] if score_of is not None: skills.sort(key=lambda item: (score_of(item), -usage(name_of(item)), name_of(item))) else: @@ -3739,20 +3585,21 @@ def _rank_slash_completions( return commands[:_SLASH_COMPLETION_LIMIT] + skills[:_SLASH_COMPLETION_LIMIT] +# argv shapes that must not run headless in the gateway process → user hint. +_CLI_EXEC_BLOCKED = { + ("setup",): "`hermes setup` needs a full terminal — run it outside the TUI", + ("gateway",): "`hermes gateway` is long-running — run it in another terminal", + ("sessions", "browse"): "`hermes sessions browse` is interactive — use /resume here, or run browse in another terminal", + ("config", "edit"): "`hermes config edit` needs $EDITOR in a real terminal", +} + + def _cli_exec_blocked(argv: list[str]) -> str | None: """Return user hint if this argv must not run headless in the gateway process.""" if not argv: return "bare `hermes` is interactive — use `/hermes chat -q …` or run `hermes` in another terminal" - a0 = argv[0].lower() - if a0 == "setup": - return "`hermes setup` needs a full terminal — run it outside the TUI" - if a0 == "gateway": - return "`hermes gateway` is long-running — run it in another terminal" - if a0 == "sessions" and len(argv) > 1 and argv[1].lower() == "browse": - return "`hermes sessions browse` is interactive — use /resume here, or run browse in another terminal" - if a0 == "config" and len(argv) > 1 and argv[1].lower() == "edit": - return "`hermes config edit` needs $EDITOR in a real terminal" - return None + head = tuple(a.lower() for a in argv[:2]) + return _CLI_EXEC_BLOCKED.get(head[:1]) or _CLI_EXEC_BLOCKED.get(head) def _resolve_name(name: str) -> str: @@ -3764,18 +3611,9 @@ def _resolve_name(name: str) -> str: return name -# ── Methods: paste ──────────────────────────────────────────────────── - _paste_counter = 0 -# ── Methods: insights ──────────────────────────────────────────────── - - -# ── Methods: rollback ──────────────────────────────────────────────── - - - # mcp.servers.* handlers (methods_tools) resolve these through this namespace. from .mcp_rpc_helpers import ( # noqa: E402 reset_profile as _mcp_reset_profile, From 65d0be5ff4fd3d1c788833be5a16f428bdc268d0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:57:46 -0700 Subject: [PATCH 5/6] =?UTF-8?q?refactor(tui):=20server.py=20=E2=80=94=20AS?= =?UTF-8?q?T-neutral=20line=20joins,=20docstring/comment=20compaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tui_gateway/server.py | 290 ++++++++++++++---------------------------- 1 file changed, 93 insertions(+), 197 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index cc806b848c..1cb5f69dcd 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -23,8 +23,7 @@ from typing import Any, Callable, NamedTuple, Optional # noqa: F401 (Callable: # namespace (method_ctx.bind_module) — deleting one breaks a handler at call time, not import time. from agent.secret_scope import build_profile_secret_scope, reset_secret_scope, set_secret_scope # noqa: F401 from hermes_constants import ( - get_hermes_home, get_hermes_home_override, reset_hermes_home_override, set_hermes_home_override, -) + get_hermes_home, get_hermes_home_override, reset_hermes_home_override, set_hermes_home_override) from hermes_cli.env_loader import load_hermes_dotenv from utils import is_truthy_value from tools.environments.local import hermes_subprocess_env @@ -36,8 +35,7 @@ from tui_gateway import git_probe # noqa: F401 from tui_gateway._env import env_float, env_int from tui_gateway.turn_marker import clear_turn_marker, read_turn_marker, record_turn_start # noqa: F401 from tui_gateway.transport import ( - StdioTransport, Transport, bind_transport, current_transport, reset_transport, -) + StdioTransport, Transport, bind_transport, current_transport, reset_transport) logger = logging.getLogger(__name__) @@ -92,17 +90,16 @@ _methods: dict[str, callable] = {} _pending: dict[str, tuple[str, threading.Event]] = {} _pending_prompt_payloads: dict[str, tuple[str, dict]] = {} _answers: dict[str, str] = {} -# Batch clarify accumulators: rid → {"qids": [...], "answers": {qid: answer}}. -# Written by clarify.respond (per-question lock, update-in-place), read out by -# _block on resolution/timeout so locked answers survive the deadline. +# Batch clarify accumulators: rid → {"qids": [...], "answers": {qid: answer}}. Written by +# clarify.respond (per-question lock, update-in-place), read out by _block on resolution/timeout +# so locked answers survive the deadline. _batch_clarify: dict[str, dict] = {} _db = None _db_error: str | None = None _stdout_lock = threading.Lock() _cfg_lock = threading.Lock() -# Shared profile UI metadata can be updated concurrently by Desktop, mobile, -# and multiple worker-pool RPCs. Its compare/check/write transaction needs a -# dedicated lock rather than the unrelated process-config cache lock. +# Shared profile UI metadata is updated concurrently by Desktop, mobile and pool RPCs; its +# compare/check/write transaction needs its own lock, not the unrelated config cache lock. _profile_ui_meta_lock = threading.Lock() _sessions_lock = threading.RLock() # reentrant: _close_session_by_id may run under callers that already hold it _prompt_lock = threading.Lock() @@ -130,10 +127,8 @@ def _ws_orphan_setting(env_var: str, cfg_key: str, default: float) -> float: def _resolve_ws_orphan_reap_grace() -> float: """Grace before an orphaned WS session is interrupted/reaped; 0 = park forever. - ws.py parks a disconnected session for a quick reattach, but a browser refresh - mints a NEW sid and never reattaches the old one (leaking its slash worker). - """ + mints a NEW sid and never reattaches the old one (leaking its slash worker).""" return _ws_orphan_setting("HERMES_TUI_WS_ORPHAN_REAP_GRACE_S", "ws_orphan_reap_grace_s", 20.0) @@ -142,8 +137,7 @@ _WS_ORPHAN_REAP_GRACE_S = _resolve_ws_orphan_reap_grace() # clock (API waits, stream tokens, tool heartbeats) has idled this long; 600s matches # the turn-liveness watchdog so "wedged" means the same on both paths. 0 disables the gate. _WS_ORPHAN_ACTIVITY_STALE_S = _ws_orphan_setting( - "HERMES_TUI_WS_ORPHAN_ACTIVITY_STALE_S", "ws_orphan_activity_stale_s", 600.0 -) + "HERMES_TUI_WS_ORPHAN_ACTIVITY_STALE_S", "ws_orphan_activity_stale_s", 600.0) _WS_ORPHAN_INTERRUPT_REAP_POLL_S = 1.0 # Budget for the interrupt-then-reap poll chain: an interrupted turn that never # settles (thread hung in a syscall) would reschedule the 1s poll forever. After @@ -189,15 +183,13 @@ atexit.register(lambda: _pool.shutdown(wait=False, cancel_futures=True)) # Exact in-memory session generation executing on the current turn thread. # Unlike a public session id, this object identity cannot be supplied by RPC. _current_runtime_session_record: contextvars.ContextVar[dict | None] = contextvars.ContextVar( - "hermes_gateway_runtime_session_record", default=None -) + "hermes_gateway_runtime_session_record", default=None) # JSON-RPC method being dispatched on this thread/task. Diagnostic only (names WHICH client # poll is looping in the 4001 warning); never authorization — the method string is client-supplied. _current_rpc_method: contextvars.ContextVar[str] = contextvars.ContextVar("hermes_gateway_rpc_method", default="") -# Reserve real stdout for JSON-RPC only; redirect Python's stdout to stderr -# so stray print() from libraries/tools becomes harmless gateway.stderr instead -# of corrupting the JSON protocol. +# Reserve real stdout for JSON-RPC only; redirect Python's stdout to stderr so stray print() from +# libraries/tools becomes harmless gateway.stderr instead of corrupting the JSON protocol. _real_stdout = sys.stdout sys.stdout = sys.stderr @@ -212,14 +204,12 @@ class _DropTransport: return None -# Module-level stdio transport — fallback sink when no transport is bound via -# contextvar or session. Stream resolved through a lambda so runtime monkey- -# patches of `_real_stdout` (used extensively in tests) still land correctly. +# Module-level stdio transport — fallback sink when no transport is bound via contextvar or session. +# Stream resolved through a lambda so test monkey-patches of `_real_stdout` still land. _stdio_transport = StdioTransport(lambda: _real_stdout, _stdout_lock) -# Detached websocket sessions use a drop sink instead of stdio. Desktop embeds -# the gateway in-process and captures stdout into logs, so stale JSON-RPC frames -# must not fall through there while the session waits for resume or reap. +# Detached websocket sessions use a drop sink instead of stdio: Desktop embeds the gateway in-process +# and captures stdout into logs, so stale frames must not fall through while a session awaits resume/reap. _detached_ws_transport = _DropTransport() @@ -257,16 +247,14 @@ class _SlashWorker: # home via `extra` (applied last, always wins); the base already carries the HOME contract. env = _prepend_tool_paths(build_subprocess_env( hermes_subprocess_env(inherit_credentials=True), scrub_secrets=False, - inherit_profile_home=False, extra={"HERMES_HOME": str(profile_home)} if profile_home else None, - )) + inherit_profile_home=False, extra={"HERMES_HOME": str(profile_home)} if profile_home else None)) # start_new_session: otherwise the worker inherits the gateway's pgid and mcp_tool's orphan # sweep, racing the spawn, killpg()s the TUI parent itself. errors="replace": bytes invalid # in the system locale (GBK Windows) must not raise UnicodeDecodeError in the drain threads. self.proc = subprocess.Popen( argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", bufsize=1, cwd=os.getcwd(), env=env, - creationflags=windows_hide_flags(), start_new_session=True, - ) + creationflags=windows_hide_flags(), start_new_session=True) threading.Thread(target=self._drain_stdout, daemon=True).start() threading.Thread(target=self._drain_stderr, daemon=True).start() @@ -361,14 +349,13 @@ def _shutdown_sessions() -> None: _close_session_by_id(sid, end_reason="tui_shutdown") -# Session reaping / flushing knobs (implementation: session_reaper.py). -# TTL is the last-resort net for disconnect paths that slip past the WS finally; -# hours-scale because last_active freezes during a long turn and on passive -# viewing — running/pending/starting/live-transport are hard exemptions instead. +# Session reaping / flushing knobs (implementation: session_reaper.py). TTL is the last-resort +# net for disconnect paths that slip past the WS finally; hours-scale because last_active freezes +# during a long turn and on passive viewing — running/pending/starting/live-transport are hard exemptions. _SESSION_TTL_S = max(0.0, env_float("HERMES_TUI_SESSION_TTL_S", float(6 * 3600))) _REAPER_SCAN_S = 300.0 -# Flush-on-kill budget + periodic incremental flush (piggybacks the reaper scan) -# so a SIGTERM/SIGKILL mid-update loses at most one flush interval of session state. +# Flush-on-kill budget + periodic incremental flush (piggybacks the reaper scan) so a +# SIGTERM/SIGKILL mid-update loses at most one flush interval of session state. _EXIT_FLUSH_BUDGET_S = max(0.0, env_float("HERMES_TUI_EXIT_FLUSH_BUDGET_S", 5.0)) _INCREMENTAL_FLUSH_INTERVAL_S = max(0.0, env_float("HERMES_TUI_SESSION_FLUSH_INTERVAL_S", _REAPER_SCAN_S)) @@ -398,9 +385,7 @@ def _get_db(): _db_error = None except Exception as exc: _db_error = str(exc) - logger.warning( - "TUI session store unavailable — continuing without state.db features: %s", exc, - ) + logger.warning("TUI session store unavailable — continuing without state.db features: %s", exc) return None return _db @@ -422,12 +407,10 @@ def _db_for_profile(profile: str | None = None): def _transfer_db_to_agent(agent, db) -> bool: """Hand a DEDICATED profile ``state.db`` handle to *agent* (``AIAgent.close()`` then releases it). - True only when the transfer happened; False (agent not holding *this* handle — build failed before ``_make_agent`` or it got a different db) tells the caller the handle is still its own to close. The shared launch handle never transfers: it outlives every agent, and ownership - would let session.close() tear down the process-wide database. - """ + would let session.close() tear down the process-wide database.""" if agent is None or db is None: return False try: @@ -436,8 +419,7 @@ def _transfer_db_to_agent(agent, db) -> bool: if db is _get_db(): logger.warning( "Refused transfer of the shared launch SessionDB to a session " - "agent — the caller's owns_db gate should have prevented this." - ) + "agent — the caller's owns_db gate should have prevented this.") return False agent._owns_session_db = True return True @@ -447,11 +429,9 @@ def _transfer_db_to_agent(agent, db) -> bool: def _open_profile_session_db(profile_home): """Open a DEDICATED handle on ``profile_home``'s ``state.db`` — FAIL CLOSED. - A silent fallback to the launch ``state.db`` would bleed the session's rows into the wrong profile's store exactly when the profile store is briefly unopenable (locked, mid-restore), so - callers let the raised error abort the agent build (deferred builds → ``agent_error``). - """ + callers let the raised error abort the agent build (deferred builds → ``agent_error``).""" from hermes_state import get_shared_session_db db_path = Path(profile_home) / "state.db" try: @@ -528,9 +508,8 @@ def _profile_scoped(handler): return wrapper -# Placeholder ``terminal.cwd`` values that don't name a real directory — the -# gateway resolves these to the home dir at runtime, so they must NOT be treated -# as an explicit workspace (mirrors gateway/run.py's config bridge). +# Placeholder ``terminal.cwd`` values that don't name a real directory — resolved to the home dir at +# runtime, so they must NOT be treated as an explicit workspace (mirrors gateway/run.py's config bridge). _CWD_PLACEHOLDERS = {".", "auto", "cwd"} @@ -546,7 +525,6 @@ def _configured_cwd_from_cfg(cfg: dict | None) -> str | None: def _profile_configured_cwd(profile_home: Path | None) -> str | None: """A non-launch profile's ``terminal.cwd`` from ITS config.yaml (fail-open → None). - The process-global ``TERMINAL_CWD`` belongs to the *launch* profile; a session bound to another profile must take its workspace from that profile's config, not the stale env var. load_config() resolves the ACTIVE profile, so read the file directly + the _load_cfg pipeline. @@ -579,13 +557,11 @@ def _default_session_cwd() -> str: def write_json(obj: dict) -> bool: """Emit one JSON frame via the most-specific transport. - Precedence: (1) event frames with a session id → that session's transport, so async events reach the owning client even from threads with no contextvar binding; (2) the transport bound on the current context (:func:`dispatch`); (3) the module stdio transport (historical behaviour; tests monkey-patch ``_real_stdout``). Every event frame gets a per-session - monotonic ``seq`` + replay-ring entry (event_replay) so ``session.events.since`` can resume. - """ + monotonic ``seq`` + replay-ring entry (event_replay) so ``session.events.since`` can resume.""" from tui_gateway.event_replay import _stamp_event _stamp_event(obj) if obj.get("method") == "event": @@ -666,12 +642,10 @@ def _approval_request_payload(data: dict | None) -> dict: def _pending_clarify_request_payload(sid: str) -> dict | None: """Read-only snapshot of the clarify prompt still blocking a session, if any. - A client whose transport was detached when `clarify.request` was emitted would otherwise never see the question (the agent thread stays parked until timeout). Same replay contract as `pending_approval`: the registry stays authoritative; `clarify.respond` with the embedded - request_id resolves it. - """ + request_id resolves it.""" with _prompt_lock: for rid, (owner_sid, _ev) in _pending.items(): if owner_sid != sid: @@ -807,8 +781,7 @@ def _current_session_steer_authority(session_id: str) -> tuple[Transport | None, if ( session is None or (expected_session is not None and session is not expected_session) - or session.get("transport") is not transport - ): + or session.get("transport") is not transport): return None, None return transport, session @@ -850,8 +823,8 @@ def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None: return _err(rid, 5032, err) if err else None -# The deferred prompt path waits in short slices so a cancel is honored -# promptly and a slow build can be reported to the client exactly once. +# The deferred prompt path waits in short slices so a cancel is honored promptly and a slow +# build is reported to the client exactly once. _AGENT_BUILD_WAIT_SLICE = 5.0 _AGENT_BUILD_SLOW_NOTICE_AFTER = 30.0 _AGENT_BUILD_SLOW_NOTICE_KEY = "agent-build-slow" @@ -869,14 +842,12 @@ def _agent_build_wait_cap() -> float: def _wait_agent_for_prompt(session: dict, rid: str, sid: str) -> dict | None: """Patient ``_wait_agent`` for the deferred prompt.submit path. - prompt.submit already answered ``{"status": "streaming"}`` and the user's first message IS the turn in flight, while a cold deferred build routinely outlives the flat 30s ceiling — timing out here silently discarded that message. So: wait in short slices (cancel honored promptly), notify the client once (keyed) past ``_AGENT_BUILD_SLOW_NOTICE_AFTER``, and fail only when the build thread died without signalling ready or the bounded cap expired on a hung build. - Returns None on success OR cancel mid-wait (the caller's cancel branch owns that messaging). - """ + Returns None on success OR cancel mid-wait (the caller's cancel branch owns that messaging).""" ready = session.get("agent_ready") if ready is None: return None @@ -893,8 +864,7 @@ def _wait_agent_for_prompt(session: dict, rid: str, sid: str) -> dict | None: return _err( rid, 5032, f"agent initialization timed out after {int(waited)}s — " - "your message was not sent; retry once the session is ready", - ) + "your message was not sent; retry once the session is ready") build_thread = session.get("_agent_build_thread") if build_thread is not None and not build_thread.is_alive() and not ready.is_set(): # _build's finally guarantees ready.set(); dead thread + unset ready = died hard. @@ -905,8 +875,7 @@ def _wait_agent_for_prompt(session: dict, rid: str, sid: str) -> dict | None: "text": ( "Still starting the agent (tool discovery / model " "setup) — your message will be sent as soon as it's " - "ready." - ), + "ready."), "level": "info", "kind": "agent", "ttl_ms": None, "key": _AGENT_BUILD_SLOW_NOTICE_KEY, "id": _AGENT_BUILD_SLOW_NOTICE_KEY, }) @@ -918,11 +887,9 @@ def _wait_agent_for_prompt(session: dict, rid: str, sid: str) -> dict | None: def _bind_build_profile_scopes(profile_home: str) -> "_TurnScopes": """Bind a session profile's HERMES_HOME / secret / terminal scopes for an agent build. - Fail-open per scope (the build must not die on a scope helper); the terminal installer itself fails closed (malformed policy → refusal scope) so _make_agent's terminal probing / cwd hints - resolve the routed profile, never the launch process. - """ + resolve the routed profile, never the launch process.""" scopes = _TurnScopes() scopes.home = set_hermes_home_override(profile_home) with contextlib.suppress(Exception): @@ -951,7 +918,6 @@ def _release_build_profile_scopes(scopes: "_TurnScopes") -> None: def _deferred_build_agent_kwargs(current: dict, session_db) -> dict: """_make_agent kwargs for a deferred (first-prompt) build. - A lazy-resumed (watch) session carries the stored conversation id so the upgrade continues it. A cold deferred resume restores the full persisted runtime identity (as the eager resume's _stored_session_runtime_overrides splat does) so the build can't drop the provider ("No LLM @@ -977,10 +943,8 @@ def _deferred_build_agent_kwargs(current: dict, session_db) -> dict: def _wire_session_agent(sid: str, key: str, agent) -> bool: """Post-build wiring for a session agent; returns whether the approval notify got registered. - Approval prompts route to the client; the self-improvement review's "💾 …" summary is emitted - as review.summary (the TUI has no print surface), honoring display.memory_notifications. - """ + as review.summary (the TUI has no print surface), honoring display.memory_notifications.""" notify_registered = False with contextlib.suppress(Exception): from tools.approval import load_permanent_allowlist, register_gateway_notify @@ -1068,11 +1032,9 @@ def _finish_agent_build(sid: str, key: str, current: dict, *, notify_registered: def _start_agent_build(sid: str, session: dict) -> None: """Start building the real AIAgent for a TUI session, once. - Deferred until the first prompt (or any command that needs the agent) so the composer is responsive instead of blocked on tool discovery / model metadata; the ready/error event - contract for the frontend is unchanged. - """ + contract for the frontend is unchanged.""" ready = session.get("agent_ready") if ready is None: return @@ -1129,8 +1091,7 @@ def _start_agent_build(sid: str, session: dict) -> None: _emit("error", sid, {"message": f"agent init failed: {e}"}) finally: _finish_agent_build( - sid, key, current, notify_registered=notify_registered, scopes=scopes, session_db=session_db, - ) + sid, key, current, notify_registered=notify_registered, scopes=scopes, session_db=session_db) ready.set() build_thread = threading.Thread(target=_build, daemon=True) @@ -1150,8 +1111,7 @@ def _sess_nowait(params, rid): logger.warning( "session-scoped RPC rejected: method=%s session_id=%r not in memory " "(detached/reaped runtime; client should resume the stored session), rid=%r", - _current_rpc_method.get() or "?", sid, rid, - ) + _current_rpc_method.get() or "?", sid, rid) return (None, _err(rid, 4001, "session not found")) @@ -1164,7 +1124,6 @@ def _sess(params, rid): def _sess_building(params, rid): """Resolve a session and warm its agent build WITHOUT waiting for it. - For the attach RPCs (image/file/pdf attach, clipboard.paste, image.detach), which only touch record fields populated at creation. They run inline on the socket reader thread, so waiting on a cold build there stalled the paste AND every RPC behind it ("text is instant, images hang"). @@ -1218,7 +1177,6 @@ def _active_config_path() -> Path: def _load_cfg_raw() -> dict: """The active profile's config.yaml EXACTLY as written — the write-back primitive. - ONLY for read→mutate→``_save_cfg`` round-trips and raw inspection: defaults, the managed overlay or ``${VAR}`` expansion applied here would be persisted into the user's file on the next save. Behavioral reads use :func:`_load_cfg`. Cache keyed on the resolved path so profiles don't clobber. @@ -1253,11 +1211,9 @@ def _expand_cfg(cfg: dict) -> dict: def _load_cfg() -> dict: """Behavioral config read: raw user file + managed overlay + ${VAR} expansion. - Same read-side pipeline as ``hermes_cli.config.load_config_readonly`` minus the DEFAULT_CONFIG merge (callers treat a missing key as "unset"; merging would break ``_load_cfg() == {}`` - sentinels). Never pass the result to ``_save_cfg`` — use ``_load_cfg_raw()`` for write-back. - """ + sentinels). Never pass the result to ``_save_cfg`` — use ``_load_cfg_raw()`` for write-back.""" cfg = _apply_managed(_load_cfg_raw()) with contextlib.suppress(Exception): cfg = _expand_cfg(cfg) @@ -1327,8 +1283,7 @@ def _set_session_context(session_key: str, cwd: str | None = None, *, ui_session session_key=session_key, session_id=session_id, source=source, browser_control_principal=browser_control_principal, browser_control_transport_family=browser_control_transport_family, cwd=resolved, - ui_session_id=ui_session_id, cron_session="", - ) + ui_session_id=ui_session_id, cron_session="") except Exception: return [] @@ -1421,12 +1376,10 @@ def _clarify_timeout_seconds() -> float | None: def _clarify_block(sid: str, q, c, multi_select=False, questions=None) -> str: """Bridge the clarify tool callback onto _block. - Single-question payloads keep their exact historical shape (``multi_select`` only when True, so older renderers never see a new field). Batch calls emit one clarify.request with only the wire fields (qid/question/choices/multi_select) — the tool-side entries also carry - result-assembly keys (id, choices_offered) the renderer must not see. - """ + result-assembly keys (id, choices_offered) the renderer must not see.""" if questions: wire = [ {"qid": e["qid"], "question": e["question"], "choices": e["choices"], "multi_select": bool(e["multi_select"])} @@ -1434,8 +1387,7 @@ def _clarify_block(sid: str, q, c, multi_select=False, questions=None) -> str: ] return _block( "clarify.request", sid, {"questions": wire}, timeout=_clarify_timeout_seconds(), - batch_qids=[entry["qid"] for entry in questions], - ) + batch_qids=[entry["qid"] for entry in questions]) payload = {"question": q, "choices": c, "multi_select": True} if multi_select else {"question": q, "choices": c} return _block("clarify.request", sid, payload, timeout=_clarify_timeout_seconds()) @@ -1455,29 +1407,24 @@ _TOUR_BRIDGE_UNAVAILABLE = json.dumps( "driven by the desktop app's renderer, which updates separately " "from this backend, so an app build older than the tour tool has " "nothing listening. Update the Hermes Desktop app and start a new " - "session. Do not retry tour in this session." - ), - } -) + "session. Do not retry tour in this session."), + }) def _tour_request(sid: str, payload: dict) -> str: """Bridge the tour tool callback onto _block without paying for a client that cannot answer. - Renderer and backend update on different clocks: against an older app nobody calls ``tour.respond`` and each action would block the full deadline, stacking per turn. So the session's first action gets the short probe deadline; an unanswered probe marks the bridge unavailable for that session; once a client has answered, actions get the full deadline. The - verdict lives on the session record, so a new session re-probes. - """ + verdict lives on the session record, so a new session re-probes.""" session = _sessions.get(sid) or {} # detached caller: throwaway record, plain bridge, unprobed state = session.get("tour_bridge") if state == "unanswered": return _TOUR_BRIDGE_UNAVAILABLE answer = _block( "tour.request", sid, dict(payload), - timeout=_TOUR_TIMEOUT_S if state == "answered" else _TOUR_PROBE_TIMEOUT_S, - ) + timeout=_TOUR_TIMEOUT_S if state == "answered" else _TOUR_PROBE_TIMEOUT_S) if answer: session["tour_bridge"] = "answered" elif state != "answered": @@ -1540,7 +1487,6 @@ def _resolve_agent_platform(source: str | None) -> str: def _config_model_target() -> tuple[str, str]: """(model, provider) selected by config.yaml — and ONLY config. - Never reads HERMES_MODEL / HERMES_INFERENCE_MODEL: that launch-scoped seed fed into the per-turn sync would be replayed as a /model switch and persisted globally, or pin the session so dashboard/CLI model changes never reach an open chat. Empty model = "no preference" → no-op sync. @@ -1565,8 +1511,7 @@ def _resolve_startup_runtime() -> tuple[str, str | None]: current_provider = ( (str(cfg.get("provider") or "").strip().lower() if isinstance(cfg, dict) else "") or os.environ.get("HERMES_INFERENCE_PROVIDER", "").strip().lower() - or "auto" - ) + or "auto") if detected := detect_static_provider_for_model(explicit_model, current_provider): provider, detected_model = detected return detected_model, provider @@ -1615,14 +1560,12 @@ def _parse_model_config(raw, *, quiet: bool = False) -> dict: def _stored_session_runtime_overrides(row: dict | None) -> dict: """Runtime fields persisted with a stored session (model column, ``billing_provider``, JSON ``model_config``). - ``session.resume`` is session-scoped: reopening an older chat restores the model/provider/ reasoning THAT chat used, not the global model most recently selected elsewhere. Plugin-owned Bot-Mode sessions are exempt and always rebuild from the member profile's CURRENT config (a stale provider pin left room bots failing "out of Nous credits" after a profile switch). Signals, in order: explicit ``room_plumbing`` / ``follow_profile_config`` markers; - the legacy hidden + "Group:" title shape; the title exactly "Bot Chat" (UNIQUE(title)). - """ + the legacy hidden + "Group:" title shape; the title exactly "Bot Chat" (UNIQUE(title)).""" if not row: return {} model_config = _parse_model_config(row.get("model_config"), quiet=True) @@ -1631,8 +1574,7 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict: model_config.get("room_plumbing") or (row.get("hidden") and _row_title.startswith("Group:")) or model_config.get("follow_profile_config") - or _row_title == "Bot Chat" - ): + or _row_title == "Bot Chat"): return {} overrides: dict = {} model = str(row.get("model") or model_config.get("model") or "").strip() @@ -1683,12 +1625,10 @@ def _stored_session_runtime_overrides(row: dict | None) -> dict: def _runtime_model_config(agent, existing: dict | None = None) -> dict: """Merge the agent's CURRENT runtime identity onto the row's persisted ``model_config``. - Falsy agent attributes DELETE the key rather than skip the write, so a stale value can never survive the merge: ``_persist_live_session_runtime`` writes the model column separately and resume reads provider/endpoint from this JSON — a stale provider would silently route the - resumed chat to the wrong endpoint while the model column claimed the new one. - """ + resumed chat to the wrong endpoint while the model column claimed the new one.""" config = dict(existing or {}) model = str(getattr(agent, "model", "") or "").strip() provider = str(getattr(agent, "provider", "") or "").strip() @@ -1771,16 +1711,14 @@ def _persist_live_session_system_prompt(session: dict | None) -> None: except Exception: logger.warning( "failed to persist live session system prompt for session %s", session_key, - exc_info=True, - ) + exc_info=True) finally: _clear_session_context(session_tokens) if home_token is not None: reset_hermes_home_override(home_token) -# Stable leading text of the model-switch marker (builder + dedup). Only the -# newest marker is meaningful; stale ones would be re-sent every turn. +# Stable leading text of the model-switch marker (builder + dedup); only the newest marker is meaningful. _MODEL_SWITCH_MARKER_PREFIX = "[System: The active model for this chat has changed to " @@ -1801,11 +1739,9 @@ def _is_pivot_marker(entry: Any) -> bool: def _append_model_switch_marker(session: dict | None, *, model: str, provider: str) -> None: """Record a real system-history pivot after a live model switch. - Only the newest marker is kept: each switch strips prior model-switch markers first, so N switches leave one marker rather than N stale ones re-sent on every API call; self-healing - across resumes because the next switch collapses whatever a reload brought back. - """ + across resumes because the next switch collapses whatever a reload brought back.""" if not session: return session_key = str(session.get("session_key") or "").strip() @@ -1813,10 +1749,8 @@ def _append_model_switch_marker(session: dict | None, *, model: str, provider: s return provider_part = f" via provider {provider}" if provider else "" marker = ( - f"{_MODEL_SWITCH_MARKER_PREFIX}" - f"{model}{provider_part}. From this point forward, use this runtime " - "metadata when answering questions about what model/provider is active.]" - ) + f"{_MODEL_SWITCH_MARKER_PREFIX}{model}{provider_part}. From this point forward, use this runtime " + "metadata when answering questions about what model/provider is active.]") # A user message, not system: strict OpenAI-compatible providers (vLLM, Qwen) reject # non-leading system messages. entry = {"role": "user", "content": marker, "display_kind": "model_switch"} @@ -2005,8 +1939,7 @@ def _resolve_explicit_toolsets(explicit: list[str], validate_toolset) -> list[st if ignored := [name for name in explicit if name not in {"all", "*"}]: _tui_notice( "[tui] HERMES_TUI_TOOLSETS=all enables every toolset; " - f"ignoring additional entries: {', '.join(ignored)}" - ) + f"ignoring additional entries: {', '.join(ignored)}") return None if not unresolved: return built_in @@ -2020,19 +1953,16 @@ def _resolve_explicit_toolsets(explicit: list[str], validate_toolset) -> list[st _tui_notice( "[tui] ignoring disabled MCP servers in HERMES_TUI_TOOLSETS " "(set enabled: true in config.yaml to use): " - f"{', '.join(disabled)}" - ) + f"{', '.join(disabled)}") return (built_in + mcp_valid) or False def _load_enabled_toolsets(platform: str | None = None) -> list[str] | None: """The agent's toolsets for this desktop/TUI session (None = all). - Order: an explicit HERMES_TUI_TOOLSETS pin; else the coding posture (agent/coding_context.py collapses to the coding toolset + enabled MCP servers inside a code workspace); else the configured CLI toolsets. The client-surface (pane/project) toolsets are folded in here because - this resolver runs only on the surface that can answer them. - """ + this resolver runs only on the surface that can answer them.""" session_platform = platform or _resolve_session_platform() explicit = [item.strip() for item in os.environ.get("HERMES_TUI_TOOLSETS", "").split(",") if item.strip()] fallback_notice = None @@ -2096,8 +2026,7 @@ def _restart_slash_worker(sid: str, session: dict): try: new_worker = _SlashWorker( session["session_key"], getattr(session.get("agent"), "model", _resolve_model()), - profile_home=session.get("profile_home"), - ) + profile_home=session.get("profile_home")) except Exception: session["slash_worker"] = None return @@ -2184,10 +2113,8 @@ def _probe_config_health(cfg: dict) -> str: if null_keys := sorted(k for k, v in cfg.items() if v is None): keys = ", ".join(f"`{k}`" for k in null_keys) warnings.append( - f"config.yaml has empty section(s): {keys}. " - f"Remove the line(s) or set them to `{{}}` — " - f"empty sections silently drop nested settings." - ) + f"config.yaml has empty section(s): {keys}. Remove the line(s) or set them to `{{}}` — " + f"empty sections silently drop nested settings.") display_cfg = cfg.get("display") if isinstance(display_cfg, dict): personality = str(display_cfg.get("personality", "") or "").strip().lower() @@ -2198,8 +2125,7 @@ def _probe_config_health(cfg: dict) -> str: warnings.append( f"`display.personality: {personality}` does not match any " "built-in or `agent.personalities` entry; personality " - "overlay will be skipped." - ) + "overlay will be skipped.") return " ".join(warnings).strip() @@ -2316,8 +2242,7 @@ def _session_info(agent, session: dict | None = None) -> dict: "profile_name": ( _response_profile_name(Path(session["profile_home"]).name) if isinstance(session, dict) and session.get("profile_home") - else _current_profile_name() - ), + else _current_profile_name()), } with contextlib.suppress(Exception): from hermes_cli import __version__, __release_date__ @@ -2384,13 +2309,11 @@ def broadcast_session_info() -> None: def _schedule_mcp_late_refresh(sid: str, agent) -> None: """Refresh a session's tool snapshot when MCP discovery lands late. - ``_make_agent`` waits only a bounded ``mcp_discovery_timeout``, so a slow server (HTTP MCP on first connect) lands after the build and its tools are missing all session. A daemon joins discovery, then does the same rebuild ``/reload-mcp`` performs and re-emits ``session.info``. Cache safety: only while pre-first-turn (nothing cached to invalidate); afterwards the snapshot - stays frozen and late tools need an explicit, consent-gated ``/reload-mcp``. - """ + stays frozen and late tools need an explicit, consent-gated ``/reload-mcp``.""" try: from tui_gateway.entry import mcp_discovery_in_flight, join_mcp_discovery except Exception: @@ -2429,11 +2352,9 @@ class _RuntimeFallbackResolution(NamedTuple): def _resolve_runtime_with_fallback(resolve_kwargs: dict | None = None) -> _RuntimeFallbackResolution: """Resolve the primary runtime or one complete provider/model fallback. - Setup-time auth fallback only accepts entries with both fields: provider-only entries are skipped so the unavailable primary model can never leak into a different runtime. - ``used_fallback`` stays explicit rather than overloading a nullable model as control flow. - """ + ``used_fallback`` stays explicit rather than overloading a nullable model as control flow.""" from hermes_cli.auth import AuthError from hermes_cli.runtime_provider import resolve_runtime_provider try: @@ -2455,8 +2376,7 @@ def _resolve_runtime_with_fallback(resolve_kwargs: dict | None = None) -> _Runti fb_kwargs["explicit_api_key"] = fb_api_key runtime = resolve_runtime_provider(**fb_kwargs) logging.getLogger(__name__).warning( - "Primary auth failed (%s), falling back to %s model %s", primary_exc, fb_provider, fb_model, - ) + "Primary auth failed (%s), falling back to %s model %s", primary_exc, fb_provider, fb_model) return _RuntimeFallbackResolution(runtime, fb_model, True) except Exception: continue @@ -2523,11 +2443,9 @@ def _startup_system_prompt(cfg: dict, task_id: str) -> str: if not loaded_skills: raise ValueError(f"Unknown skill(s): {missing_display}") logger.warning( - "Unknown skill(s) requested, skipping: %s. " - "Continuing with: %s. " + "Unknown skill(s) requested, skipping: %s. Continuing with: %s. " "List available skills with `hermes skills list`.", - missing_display, ", ".join(loaded_skills), - ) + missing_display, ", ".join(loaded_skills)) if skills_prompt: system_prompt = "\n\n".join(part for part in (system_prompt, skills_prompt) if part).strip() return system_prompt @@ -2537,8 +2455,7 @@ def _make_agent( sid: str, key: str, session_id: str | None = None, session_db=None, model_override: dict | str | None = None, provider_override: str | None = None, reasoning_config_override: dict | None = None, service_tier_override: str | None = None, - platform_override: str | None = None, context_cwd_is_launch_artifact: bool | None = None, -): + platform_override: str | None = None, context_cwd_is_launch_artifact: bool | None = None): # AC-4 test seam: dead unless armed by the isolated certify harness. from tui_gateway.synthetic_turn import maybe_build_synthetic_agent synthetic = maybe_build_synthetic_agent(session_id or key, model_override) @@ -2590,8 +2507,7 @@ def _make_agent( skip_context_files=ignore_rules, skip_memory=ignore_rules, fallback_model=_load_fallback_model(), - **_agent_cbs(sid), - ) + **_agent_cbs(sid)) if context_cwd_is_launch_artifact is None: with _sessions_lock: context_session = _sessions.get(sid) @@ -2616,8 +2532,7 @@ def _hydrate_session_cwd(sid: str, key: str, session_db, profile_home: str | Non logger.warning( "profile session store unavailable for %s — skipping cwd " "hydration instead of touching the launch state.db", - profile_home, exc_info=True, - ) + profile_home, exc_info=True) db = None else: db = _get_db() @@ -2644,8 +2559,7 @@ def _hydrate_session_cwd(sid: str, key: str, session_db, profile_home: str | Non def _init_session( sid: str, key: str, agent, history: list, cols: int = 80, cwd: str | None = None, session_db=None, source: str | None = None, profile_home: str | None = None, - explicit_cwd: bool = False, -): + explicit_cwd: bool = False): now = time.time() with _sessions_lock: _sessions[sid] = { @@ -2713,8 +2627,7 @@ def _deferred_session_record( close_on_disconnect: bool = False, display_history_prefix: list | None = None, profile_home: Path | None = None, lazy: bool = False, model_override=None, resume_runtime_overrides: dict | None = None, todo_state: dict | None = None, - explicit_cwd: bool = False, -) -> dict: + explicit_cwd: bool = False) -> dict: """A live-session record whose AIAgent is built later (lazy watch / cold resume) — _init_session's shape minus the agent.""" now = time.time() return { @@ -2821,10 +2734,8 @@ def _schedule_agent_build(sid: str, delay: float = 0.05) -> None: def _load_resume_transcript(db, stored_id: str) -> tuple[list, list, list]: """(raw_history, display_history, ancestor_prefix) for a cold resume. - The ancestor prefix is an in-memory convenience (the transcript is REST-paginated): the full - lineage is materialized only while it fits sessions.max_resume_messages, else the tip alone. - """ + lineage is materialized only while it fits sessions.max_resume_messages, else the tip alone.""" from hermes_state import SessionResumeTooLargeError prefix_fits = True guard = getattr(db, "assert_resume_safe", None) @@ -2834,10 +2745,8 @@ def _load_resume_transcript(db, stored_id: str) -> tuple[list, list, list]: except SessionResumeTooLargeError as exc: prefix_fits = False logger.info( - "resume %s: compression lineage exceeds the resume " - "limit (%s); hydrating the tip segment only", - stored_id, exc, - ) + "resume %s: compression lineage exceeds the resume limit (%s); hydrating the tip segment only", + stored_id, exc) except Exception: logger.debug("resume lineage guard failed; loading full lineage", exc_info=True) if prefix_fits: @@ -2874,8 +2783,7 @@ def _schedule_resume_hydration(sid: str, stored_id: str, db, *, close_db: bool = session["resume_history_ready"].set() _emit( "session.resume_progress", sid, - {"message_count": len(display_history), "phase": "history", "status": "complete"}, - ) + {"message_count": len(display_history), "phase": "history", "status": "complete"}) _maybe_schedule_auto_continue(sid, session, stored_id) _start_agent_build(sid, session) except Exception as exc: @@ -2993,14 +2901,12 @@ def _fallback_session_info(session: dict) -> dict: def _reconcile_display_with_live(db_display: list[dict], in_memory: list[dict]) -> list[dict]: """Merge the persisted DISPLAY lineage with the in-memory live history. - ``db_display`` is verbatim and candidate-inclusive (verification rows the model history collapses out via ``repair_message_sequence``) but can lag the newest turn by a flush; ``in_memory`` (``display_history_prefix + session["history"]``) is the recency authority but the collapsed *model* projection. Keep the DB display as base and append only the in-memory tail past the last DB row's ``(role, text)`` anchor, so the verification answer survives a warm switch AND - a not-yet-flushed live turn is not dropped. - """ + a not-yet-flushed live turn is not dropped.""" if not db_display: return in_memory if not in_memory: @@ -3028,8 +2934,7 @@ def _live_visible_history(session: dict, db, in_memory_fallback: list[dict]) -> # include_compacted: a compacted session's archived turns are still the user's # conversation; without them a warm switch repainted the chat as summary + tail only. display = db.get_messages_as_conversation( - key, include_ancestors=True, include_row_ids=True, include_compacted=True, - ) + key, include_ancestors=True, include_row_ids=True, include_compacted=True) return _reconcile_display_with_live(display, in_memory_fallback) except Exception: logger.debug("live display projection read failed", exc_info=True) @@ -3038,8 +2943,7 @@ def _live_visible_history(session: dict, db, in_memory_fallback: list[dict]) -> def _live_session_payload( sid: str, session: dict, *, cols: int | None = None, touch: bool = False, - transport: Transport | None = None, omit_messages: bool = False, -) -> dict: + transport: Transport | None = None, omit_messages: bool = False) -> dict: with session["history_lock"]: if cols is not None: session["cols"] = cols @@ -3489,8 +3393,7 @@ def _compute_mcp_rev() -> str: cfg = _load_cfg() rev_src = json.dumps( {"mcp": cfg.get("mcp"), "mcp_servers": cfg.get("mcp_servers"), "tools": cfg.get("tools")}, - sort_keys=True, default=str, - ) + sort_keys=True, default=str) return hashlib.sha1(rev_src.encode()).hexdigest()[:12] except Exception: return "" @@ -3535,8 +3438,7 @@ def _skill_usage_lookup(): reports as ``provenance``, "local" spelled "agent"). Any failure degrades to 0 / "local".""" try: from tools.skill_usage import ( - _read_bundled_manifest_names, _read_hub_installed_names, activity_count, load_usage, - ) + _read_bundled_manifest_names, _read_hub_installed_names, activity_count, load_usage) records = load_usage() bundled = _read_bundled_manifest_names() hub = _read_hub_installed_names() @@ -3564,13 +3466,11 @@ _SLASH_COMPLETION_LIMIT = 30 def _rank_slash_completions(items: list[dict], usage, origin_of, *, browsing: bool, score_of=None) -> list[dict]: """Rank and bound slash completions the way the menu should read. - Registry commands keep their order; only the skill block is reordered: fuzzy ``score_of`` first (a name match beats a description match), then most-used, then A-Z. The limit is spent PER KIND: commands come before the first skill, so a flat cut on a large install offered no skill at all. ``browsing`` (bare ``/``) drops never-used bundled skills as noise; a typed query is SEARCHING and - a search that hides a match is broken — nothing is pruned there, only reordered. - """ + a search that hides a match is broken — nothing is pruned there, only reordered.""" def name_of(item: dict) -> str: return str(item.get("text", "")).strip().lstrip("/").lower() @@ -3614,16 +3514,14 @@ def _resolve_name(name: str) -> str: _paste_counter = 0 -# mcp.servers.* handlers (methods_tools) resolve these through this namespace. -from .mcp_rpc_helpers import ( # noqa: E402 +# mcp.servers.* handlers (methods_tools) resolve these BARE through this namespace. +from .mcp_rpc_helpers import ( # noqa: E402, F401 reset_profile as _mcp_reset_profile, - summarize_server as _mcp_summarize_server, -) + summarize_server as _mcp_summarize_server) # ── Split @method handler modules (see method_ctx.py) ──────────────── -# Imported at the end of this module so every global the handlers close -# over already exists; register() rebinds them onto this namespace. +# Imported last so every global the handlers close over exists; register() rebinds them onto this namespace. from . import ( # noqa: E402 methods_voice as _methods_voice, methods_browser as _methods_browser, @@ -3654,8 +3552,7 @@ from . import ( # noqa: E402 methods_tools as _methods_tools, prompt_turn as _prompt_turn, billing_view as _billing_view, - methods_projects as _methods_projects, -) + methods_projects as _methods_projects) for _m in ( _session_reaper, _session_lifecycle, _session_workdir, _compute_host_bridge, _model_switch, @@ -3664,7 +3561,6 @@ for _m in ( _methods_complete_helpers, _methods_slash, _methods_voice, _methods_browser, _methods_browser_control, _methods_session, _methods_prompt, _methods_config, _methods_config_set, _methods_complete, _methods_tools, _methods_profiles, _methods_images, - _methods_bot_relay, _prompt_turn, _billing_view, _methods_projects, -): + _methods_bot_relay, _prompt_turn, _billing_view, _methods_projects): _m.register(sys.modules[__name__]) del _m From e4012388e56d4e040aea6802d2ae0c7031d1ae01 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:03:59 -0700 Subject: [PATCH 6/6] =?UTF-8?q?refactor(tui):=20server.py=20=E2=80=94=20co?= =?UTF-8?q?llapse=20small=20defensive=20ladders=20(approval=20choices,=20m?= =?UTF-8?q?cp=20names,=20config=20key=20write,=20drains)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tui_gateway/server.py | 97 +++++++++++++++---------------------------- 1 file changed, 34 insertions(+), 63 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 1cb5f69dcd..7d3f6e01a2 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -201,7 +201,7 @@ class _DropTransport: return False def close(self) -> None: - return None + pass # Module-level stdio transport — fallback sink when no transport is bound via contextvar or session. @@ -260,10 +260,8 @@ class _SlashWorker: def _drain_stdout(self): for line in self.proc.stdout or []: - try: + with contextlib.suppress(json.JSONDecodeError): self.stdout_queue.put(json.loads(line)) - except json.JSONDecodeError: - continue self.stdout_queue.put(None) def _drain_stderr(self): @@ -381,8 +379,7 @@ def _get_db(): if _db is None: from hermes_state import get_shared_session_db try: - _db = get_shared_session_db() - _db_error = None + _db, _db_error = get_shared_session_db(), None except Exception as exc: _db_error = str(exc) logger.warning("TUI session store unavailable — continuing without state.db features: %s", exc) @@ -624,16 +621,12 @@ def _approval_request_payload(data: dict | None) -> dict: """Build the client-safe representation of a pending approval.""" payload = dict(data or {}) if "choices" not in payload: - if payload.get("smart_denied"): - payload["choices"] = ["once", "deny"] - else: - choices = ["once"] - if payload.get("allow_session") is not False: - choices.append("session") - if payload.get("allow_permanent") is not False: - choices.append("always") - choices.append("deny") - payload["choices"] = choices + choices = ["once"] + if not payload.get("smart_denied") and payload.get("allow_session") is not False: + choices.append("session") + if payload.get("allow_permanent") is not False: + choices.append("always") + payload["choices"] = choices + ["deny"] if "command" in payload: from gateway.run import _redact_approval_command payload["command"] = _redact_approval_command(payload.get("command")) @@ -713,9 +706,7 @@ def _image_meta(path: Path) -> dict: from PIL import Image with Image.open(path) as img: width, height = img.size - meta["width"] = int(width) - meta["height"] = int(height) - meta["token_estimate"] = _estimate_image_tokens(int(width), int(height)) + meta.update(width=int(width), height=int(height), token_estimate=_estimate_image_tokens(int(width), int(height))) return meta @@ -741,8 +732,7 @@ def _normalize_request(req: Any) -> tuple[Any, str, dict] | dict: """Validate a JSON-RPC request enough for safe local dispatch.""" if not isinstance(req, dict): return _err(None, -32600, "invalid request: expected an object") - rid = req.get("id") - method = req.get("method") + rid, method = req.get("id"), req.get("method") if not isinstance(method, str) or not method: return _err(rid, -32600, "invalid request: method must be a non-empty string") params = req.get("params", {}) @@ -1117,9 +1107,7 @@ def _sess_nowait(params, rid): def _sess(params, rid): s, err = _sess_building(params, rid) - if err: - return (None, err) - return (s, _wait_agent(s, rid)) + return (None, err) if err else (s, _wait_agent(s, rid)) def _sess_building(params, rid): @@ -1239,8 +1227,7 @@ def _save_cfg(cfg: dict): # configs); fails closed on an unreadable existing config.yaml like atomic_config_write. atomic_roundtrip_yaml_save(path, cfg) with _cfg_lock: - _cfg_cache = copy.deepcopy(cfg) - _cfg_path = path + _cfg_cache, _cfg_path = copy.deepcopy(cfg), path try: _cfg_mtime = path.stat().st_mtime except Exception: @@ -1776,14 +1763,13 @@ def _append_model_switch_marker(session: dict | None, *, model: str, provider: s def _write_config_key(key_path: str, value): # Write-back round-trip: raw read is mandatory — saving the managed-overlaid / env-expanded # view would persist those values into the file. - cfg = _load_cfg_raw() - current = cfg - keys = key_path.split(".") - for key in keys[:-1]: - if key not in current or not isinstance(current.get(key), dict): + cfg = current = _load_cfg_raw() + *parents, leaf = key_path.split(".") + for key in parents: + if not isinstance(current.get(key), dict): current[key] = {} current = current[key] - current[keys[-1]] = value + current[leaf] = value _save_cfg(cfg) @@ -1810,9 +1796,7 @@ def _load_approval_mode() -> str: def _coerce_statusbar(raw) -> str: if raw is False: return "off" - if isinstance(raw, str) and (s := raw.strip().lower()) in _STATUSBAR_MODES: - return s - return "top" + return s if isinstance(raw, str) and (s := raw.strip().lower()) in _STATUSBAR_MODES else "top" _MOUSE_TRACKING_ALIASES = { @@ -1906,12 +1890,9 @@ def _enabled_mcp_server_names() -> tuple[set[str], set[str]]: mcp_servers = raw_cfg.get("mcp_servers") if isinstance(raw_cfg.get("mcp_servers"), dict) else {} enabled, disabled = set(), set() for name, server_cfg in mcp_servers.items(): - if not isinstance(server_cfg, dict): - continue - if _parse_enabled_flag(server_cfg.get("enabled", True), default=True): - enabled.add(str(name)) - else: - disabled.add(str(name)) + if isinstance(server_cfg, dict): + on = _parse_enabled_flag(server_cfg.get("enabled", True), default=True) + (enabled if on else disabled).add(str(name)) return enabled, disabled except Exception: return set(), set() @@ -2291,10 +2272,9 @@ def _tool_ctx(name: str, args: dict) -> str: def _emit_session_info_for_session(sid: str, session: dict) -> None: agent = session.get("agent") - if agent is None and not _metadata_mirror(session): - return - with contextlib.suppress(Exception): - _emit("session.info", sid, _session_info(agent, session)) + if agent is not None or _metadata_mirror(session): + with contextlib.suppress(Exception): + _emit("session.info", sid, _session_info(agent, session)) def broadcast_session_info() -> None: @@ -3052,17 +3032,16 @@ def _pet_row_frame_counts(spritesheet) -> dict: from agent.pet import constants, render with Image.open(spritesheet) as opened: image = opened.convert("RGBA") - cols = max(1, image.width // constants.FRAME_W) - row_count = max(1, image.height // constants.FRAME_H) + W, H = constants.FRAME_W, constants.FRAME_H + cols = max(1, image.width // W) + row_count = max(1, image.height // H) rows = constants.state_rows_for_grid(row_count) out: dict[str, int] = {} for row_idx, name in enumerate(rows[:row_count]): - top = row_idx * constants.FRAME_H + top = row_idx * H count = 0 for col in range(cols): - left = col * constants.FRAME_W - frame = image.crop((left, top, left + constants.FRAME_W, top + constants.FRAME_H)) - if render._frame_is_blank(frame): + if render._frame_is_blank(image.crop((col * W, top, col * W + W, top + H))): break count += 1 out[name] = count @@ -3167,8 +3146,7 @@ def _pet_gen_sweep(root, *, max_age_s: float = 3600.0) -> None: def _pet_png_data_uri(path, *, max_px: int = 160) -> str: """Downscaled PNG data URI for a draft image (small preview payload).""" - import base64 - import io + import base64, io from PIL import Image with Image.open(path) as opened: img = opened.convert("RGBA") @@ -3191,8 +3169,7 @@ except (TypeError, ValueError): def _pet_reference_images_from_data_url(ref_raw: str, stage) -> list: """Decode + validate a reference-image data URL into the stage dir.""" - import base64 - import binascii + import base64, binascii import re as _re match = _re.match(r"^data:image/([a-zA-Z0-9.+-]+);base64,(.*)$", ref_raw, _re.DOTALL) if not match: @@ -3439,9 +3416,7 @@ def _skill_usage_lookup(): try: from tools.skill_usage import ( _read_bundled_manifest_names, _read_hub_installed_names, activity_count, load_usage) - records = load_usage() - bundled = _read_bundled_manifest_names() - hub = _read_hub_installed_names() + records, bundled, hub = load_usage(), _read_bundled_manifest_names(), _read_hub_installed_names() except Exception as e: logger.debug("skill usage lookup unavailable: %s", e) return (lambda _name: 0), (lambda _name: "local") @@ -3453,11 +3428,7 @@ def _skill_usage_lookup(): return 0 def origin(name: str) -> str: - if name in hub: - return "hub" - if name in bundled: - return "bundled" - return "local" + return "hub" if name in hub else "bundled" if name in bundled else "local" return usage, origin