Files
hermes-agent/tui_gateway/server.py
Austin Pickett a6b65df5bd fix(tui): fall back to checkpoints config when HERMES_TUI_CHECKPOINTS unset (#79625)
Desktop sessions (via 'hermes serve' -> TUI gateway) never set
HERMES_TUI_CHECKPOINTS, so agent creation gated filesystem checkpoints on
that env var alone: checkpoints.enabled: true in config.yaml was silently
ignored on the desktop surface while the CLI and messaging gateway honored
it. _make_agent now resolves enablement via the new topical sibling
tui_gateway/checkpoints.py: the env var (explicit '--tui --checkpoints'
opt-in) wins when present, otherwise fall back to the checkpoints config
section with DEFAULT_CONFIG defaults, mirroring gateway/run.py
_checkpoint_agent_kwargs. The pure _resolve_checkpoint_hash moves to the
same sibling to offset the facade's line cap.

Reimplements https://github.com/NousResearch/hermes-agent/pull/79743 by
@thatssoheil (its branch pre-dates current main and no longer applies);
test coverage carried over from that PR.
2026-10-06 14:51:15 -04:00

3673 lines
194 KiB
Python

import atexit
import concurrent.futures
import contextlib
import contextvars
import copy
import hashlib
import importlib
import inspect # noqa: F401 (split modules)
import json
import logging
import os
import queue
import re
import subprocess
import sys
import threading
import time
import uuid
from datetime import datetime, timezone # noqa: F401 (timezone: split modules)
from pathlib import Path
from typing import Any, Callable, NamedTuple, Optional # noqa: F401 (Callable: split modules)
# Several of these look unused here but are resolved BARE by split-module bodies rebound onto this
# 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, get_process_hermes_home, profile_name_for_home,
reset_hermes_home_override, set_hermes_home_override)
from hermes_cli.env_loader import load_hermes_dotenv
from utils import file_signature, is_truthy_value
from hermes_state_ids import new_session_id
from tools.environments.local import hermes_subprocess_env
from agent.fast_mode import STATIC_TIERS
from agent.replay_cleanup import canonicalize_replay_history
from agent.reasoning_effort import clamp_effort, route_supported_efforts
from agent.compaction_display import project_compaction_message_for_display # noqa: F401
from agent.skill_commands import describe_skill_invocation # noqa: F401
from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX # noqa: F401
from tui_gateway import git_probe
from tui_gateway.checkpoints import (_load_checkpoints_enabled, _resolve_checkpoint_hash,
resolve_checkpoints_enabled as _resolve_checkpoints_enabled) # noqa: F401
from tui_gateway._env import env_float, env_int
from tui_gateway.turn_marker import clear_turn_marker, marker_writer_state, read_turn_marker, record_turn_start # noqa: F401
from tui_gateway.contracts import registry as _contracts
# User-facing copy shared with the split method modules (they close over this namespace).
from tui_gateway.user_messages import ( # noqa: F401
AGENT_BUILD_ABANDONED, AGENT_MISSING_FOR_TURN, AGENT_STILL_STARTING, agent_init_failed_message, busy_message,
resume_failed_message, turn_error_text)
from tui_gateway.transport import (FanoutTransport, StdioTransport, Transport, bind_transport,
current_transport, reset_transport)
logger = logging.getLogger(__name__)
_hermes_home = _HERMES_HOME_AT_IMPORT = get_hermes_home()
load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).parent.parent / ".env")
# ── Panic logger: crashes otherwise leave no forensics (stdout is the JSON-RPC pipe, stderr doesn't
# flush before exit) → append every unhandled exception to the crash log + one-line stderr summary.
_CRASH_LOG = os.path.join(_hermes_home, "logs", "tui_gateway_crash.log")
def _record_crash(kind: str, exc_type, exc_value, exc_tb, *, thread_name: str | None = None) -> None:
import traceback
trace = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
suffix = f" · thread={thread_name}" if thread_name is not None else ""
with contextlib.suppress(Exception):
os.makedirs(os.path.dirname(_CRASH_LOG), exist_ok=True)
with open(_CRASH_LOG, "a", encoding="utf-8") as f:
f.write(f"\n=== {kind} · {time.strftime('%Y-%m-%d %H:%M:%S')}{suffix} ===\n")
f.write(trace)
# The first line is what the user sees (gateway.stderr Activity line); the rest stays in the log.
first = str(exc_value).strip().splitlines()[0] if str(exc_value).strip() else exc_type.__name__
who = f"thread {thread_name} raised " if thread_name is not None else ""
print(f"[gateway-crash] {who}{exc_type.__name__}: {first}", file=sys.stderr, flush=True)
def _panic_hook(exc_type, exc_value, exc_tb):
_record_crash("unhandled exception", exc_type, exc_value, exc_tb)
sys.__excepthook__(exc_type, exc_value, exc_tb) # chain so the process still terminates normally
sys.excepthook = _panic_hook
threading.excepthook = lambda args: _record_crash(
"thread exception", args.exc_type, args.exc_value, args.exc_traceback, thread_name=args.thread.name)
with contextlib.suppress(Exception):
from hermes_cli.banner import prefetch_update_check
prefetch_update_check()
from tui_gateway.render import make_stream_renderer, render_diff, render_message # noqa: F401
_sessions: dict[str, dict] = {}
_methods: dict[str, callable] = {}
_db = None
_db_error: str | None = None
_stdout_lock = threading.Lock()
_cfg_lock = threading.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
_cfg_cache: dict | None = None
_cfg_sig: tuple | None = None
_cfg_path = None
# Idempotency registry for session.create: maps client-supplied key → sid so a
# retried create (e.g. response lost in transit) returns the same session
# instead of spawning a duplicate child. Entries expire with the session.
_idempotency_keys: dict[str, tuple[str, float]] = {}
_IDEMPOTENCY_KEY_TTL = 300.0 # 5 min: longer than any realistic retry window
_session_resume_lock = threading.Lock()
_SLASH_WORKER_TIMEOUT_S = max(5.0, env_float("HERMES_TUI_SLASH_TIMEOUT_S", 45.0))
def _ws_orphan_setting(env_var: str, cfg_key: str, default: float) -> float:
"""``dashboard.<cfg_key>`` seconds; the env var is an internal override that wins when set."""
raw = os.environ.get(env_var)
if raw is None or not str(raw).strip():
raw = None
with contextlib.suppress(Exception):
from hermes_cli.config import load_config
raw = (load_config().get("dashboard") or {}).get(cfg_key)
with contextlib.suppress(ValueError, TypeError):
return max(0.0, float(raw) if raw is not None else default)
return max(0.0, default)
# When a WebSocket client (the dashboard's embedded-chat tab / desktop app) disconnects, ``tui_gateway.ws``
# detaches the transport but intentionally leaves the session parked so a quick reconnect can reattach it
# (see ws.py). That park is unbounded, though: a browser refresh spins up a brand-new ``session.create``
# (new sid + a fresh _SlashWorker via _deferred_build) and never reattaches the OLD sid, so the old
# session's slash-worker subprocess lingers forever — one leaked python process per refresh (#38591
# fallout). After this grace window, an orphaned WS session is interrupted if it is still running, then
# reaped once the normal turn-finalization path settles. Set to 0 to disable (park forever, pre-fix
# behaviour).
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)."""
return _ws_orphan_setting("HERMES_TUI_WS_ORPHAN_REAP_GRACE_S", "ws_orphan_reap_grace_s", 20.0)
_WS_ORPHAN_REAP_GRACE_S = _resolve_ws_orphan_reap_grace()
# A reap Timer whose wall-clock wait outlasted its awake-time (monotonic) wait by more than
# this fired early because the host slept through it (#44183); re-arm for the remaining
# awake grace instead of reaping. Large enough to ignore timer jitter and NTP slew, small
# relative to any real sleep.
_WS_ORPHAN_REAP_SLEEP_SLACK_S = 0.5
# A detached RUNNING turn is interrupted only once its activity clock (API waits, stream tokens, tool
# heartbeats) idled this long; 600s = the turn-liveness watchdog so "wedged" means the same. 0 disables.
_WS_ORPHAN_ACTIVITY_STALE_S = _ws_orphan_setting("HERMES_TUI_WS_ORPHAN_ACTIVITY_STALE_S", "ws_orphan_activity_stale_s", 600.0)
_WS_ORPHAN_INTERRUPT_REAP_POLL_S = 1.0
# Interrupt-then-reap poll budget: a turn that never settles (thread hung in a syscall) would
# reschedule the 1s poll forever; after this many polls, log loudly and force-reap.
# If an interrupted turn never settles (agent thread hung in a syscall, supervisor lost), each 1s poll would
# otherwise reschedule forever — trading the old leak-one-worker bug for leak-one-session-plus-timer-chain
# (review finding, PR #90373). After this many polls we log loudly and force-reap, mirroring the
# pre-existing stuck-`running` safety net's role of breaking the deadlock.
_WS_ORPHAN_INTERRUPT_REAP_MAX_POLLS = 60
_TURN_SETTLE_BEFORE_CLOSE_SECONDS = 5.0
_DETAIL_SECTION_NAMES = ("thinking", "tools", "subagents", "activity")
_DETAIL_MODES = frozenset({"hidden", "collapsed", "expanded"})
# ── Async RPC dispatch: slow handlers (seconds to minutes) would leave approval.respond and
# session.interrupt unread in the stdin pipe, so only THESE go to a small thread pool; everything else
# stays inline so fast-path ordering stays sane (write_json is _stdout_lock-guarded). Why each is slow:
# billing/subscription/usage = blocking portal (+Stripe) round-trips; complete.* = git ls-files /
# prompt_toolkit import + skill scan; model.options = credential pool + pricing + provider probe;
# pet.* = network or PNG decode (generate = several image-model round-trips); reload.mcp /
# mcp.servers.* = rediscovery, cold npx spawn, ~30s OAuth wait; profiles.* = skill-tree walk + state.db
# open; bot_relay.* = a FULL one-turn agent conversation (600s); setup.* / session.active_list =
# Desktop-polled and under GIL pressure block the WS read loop (false "needs setup", stalled
# interrupts); voice.*/wake.* = SYNCHRONOUS faster-whisper install (300s); session.workspace.move =
# git subprocess probes on an arbitrary (maybe slow) mount; session.save = a full stored-session read + JSON
# render (up to sessions.max_export_messages rows, ~0.8s at the default cap).
_LONG_HANDLERS = frozenset({
"session.foreign.list", "session.foreign.preview", "session.foreign.import",
"billing.state", "subscription.state", "subscription.preview", "subscription.change",
"subscription.resume", "subscription.upgrade", "usage.bars", "session.usage", "billing.step_up",
"browser.manage", "cli.exec", "complete.path", "complete.slash", "llm.oneshot", "model.options",
"pet.cells", "pet.gallery", "pet.generate", "pet.hatch", "pet.info", "pet.select", "pet.thumb",
"learning.frames", "plugins.manage", "reload.mcp", "mcp.servers.test", "mcp.servers.oauth.start",
"process.list", "profiles.configure", "profiles.create", "profiles.describe", "profiles.get_asset",
"profiles.list", "profiles.set_asset", "bot_relay.roster.sync", "bot_relay.outbox.drain",
"bot_relay.deliver", "bot_relay.reply", "image.generate", "projects.discover_repos",
"projects.record_repos", "projects.for_cwd", "projects.tree", "projects.project_sessions",
"setup.runtime_check", "setup.status", "free_tier.provision", "voice.toggle", "voice.record", "voice.tts", "wake.start",
"wake.status", "session.active_list", "session.branch", "session.compress", "session.list",
"session.resume", "session.save", "session.workspace.move", "shell.exec", "skills.manage", "slash.exec",
"command.dispatch", # /goal draft invokes the auxiliary model; never block the RPC reader
"shared_metrics.set", # consent reconcile waits on the metrics store's write lock
})
_rpc_pool_workers = max(2, env_int("HERMES_TUI_RPC_POOL_WORKERS", 8))
_pool = concurrent.futures.ThreadPoolExecutor(max_workers=_rpc_pool_workers, thread_name_prefix="tui-rpc")
atexit.register(lambda: _pool.shutdown(wait=False, cancel_futures=True))
# Exact in-memory session record 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)
# 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.
_real_stdout = sys.stdout
sys.stdout = sys.stderr
class _DropTransport:
"""Detached WS sink: keep sessions resumable without writing stale frames."""
def write(self, obj: dict) -> bool:
return False
def close(self) -> None:
pass
# 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 frames must not fall through while a session awaits resume/reap.
_detached_ws_transport = _DropTransport()
def _prepend_tool_paths(env: dict[str, str]) -> dict[str, str]:
"""Prepend managed bin (managed-first policy for the Browser Use CLI), venv bin and
~/.local/bin to PATH so slash_worker children resolve Hermes-managed CLIs under the Desktop's minimal PATH.
The PM store dirs go in front of all of them: a user's node/uv in ~/.local/bin never wins."""
managed_bin = ""
with contextlib.suppress(Exception):
managed_bin = str(Path(get_hermes_home()) / "bin")
venv_bin = str(Path(sys.executable).parent) # <venv>/bin (POSIX) or <venv>/Scripts (Windows)
parts = [p for p in (managed_bin, venv_bin, str(Path.home() / ".local" / "bin"), env.get("PATH") or "") if p]
env["PATH"] = os.pathsep.join(parts)
with contextlib.suppress(Exception):
import pm
env["PATH"] = pm.store_first_path(env["PATH"])
return env
class _SlashWorker:
"""Persistent HermesCLI subprocess for slash commands."""
def __init__(self, session_key: str, model: str, profile_home: str | None = None,
provider: str | None = None):
self._lock = threading.Lock()
self._seq = 0
self.stderr_tail: list[str] = []
self.stdout_queue: queue.Queue[dict | None] = queue.Queue()
# ``--provider`` pins the child to the parent agent's virtual provider: without it the
# worker re-resolves provider from config, so a MoA session (provider=moa, model=<preset>)
# dispatched its preset NAME to the configured real provider and 402/503'd (#57283).
argv = [sys.executable, "-m", "tui_gateway.slash_worker", "--session-key", session_key,
"--parent-pid", str(os.getpid())] \
+ (["--model", model] if model else []) \
+ (["--provider", provider] if provider else [])
self._closed = False
from hermes_cli._subprocess_compat import windows_hide_flags
# slash_worker runs the Hermes agent → needs provider credentials. Tier-1 secrets
# (gateway/GitHub/infra) are still stripped (#29157). Global-remote / multi-profile sessions: the
# worker must resolve config/skills/state against the session's profile home, not the gateway's
# launch HERMES_HOME (#40677).
from tools.environments.local import served_profile_child_env
from agent.secret_scope import is_multiplex_active
# The worker runs the agent → needs provider credentials; tier-1 secrets (gateway/GitHub/
# infra) are still stripped. A served profile's worker gets THAT profile's home + secrets and
# none of the launch profile's .env / TERMINAL_* residue, exactly what a standalone
# `hermes -p X` would load itself. The launch profile is a profile too: once the process hosts
# a second home (multiplex flipped), its worker must name its own home or the fail-closed
# no-target/no-scope path raises UnscopedSecretError (#115427).
env = _prepend_tool_paths(served_profile_child_env(
target_home=profile_home or (_hermes_home if is_multiplex_active() else None),
inherit_credentials=True))
# Internal slash workers must import the same checkout as their parent.
module_root = str(Path(__file__).resolve().parent.parent)
env["PYTHONPATH"] = os.pathsep.join(
part for part in (module_root, env.get("PYTHONPATH", "")) if part
)
# 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.
# Prepend the Hermes venv bin dir and the user-local bin dir to PATH so slash_worker child processes
# can resolve Hermes-managed CLIs (browser-use, uvx) even when the parent gateway was launched with
# a minimal PATH (e.g. by the Desktop/Dashboard app). See #83845.
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)
threading.Thread(target=self._drain_stdout, daemon=True).start()
threading.Thread(target=self._drain_stderr, daemon=True).start()
def _drain_stdout(self):
for line in self.proc.stdout or []:
with contextlib.suppress(json.JSONDecodeError):
self.stdout_queue.put(json.loads(line))
self.stdout_queue.put(None)
def _drain_stderr(self):
for line in self.proc.stderr or []:
if text := line.rstrip("\n"):
self.stderr_tail = (self.stderr_tail + [text])[-80:]
def run(self, command: str) -> str:
"""Run one command; return its output text.
A command like /prompt may also have parked a next-turn prompt (a "seed")
on the worker CLI; it rides back on the reply's ``seed`` field and is
retrieved separately via ``pop_seed()``.
"""
if self.proc.poll() is not None:
raise RuntimeError("slash worker exited")
with self._lock:
self._seq += 1
rid = self._seq
self.proc.stdin.write(json.dumps({"id": rid, "command": command}) + "\n")
self.proc.stdin.flush()
while True:
try:
msg = self.stdout_queue.get(timeout=_SLASH_WORKER_TIMEOUT_S)
except queue.Empty:
raise RuntimeError("slash worker timed out")
if msg is None:
break
if msg.get("id") != rid:
continue
if not msg.get("ok"):
raise RuntimeError(msg.get("error", "slash worker failed"))
self._last_seed = str(msg.get("seed", "") or "")
return str(msg.get("output", "")).rstrip()
raise RuntimeError(
f"slash worker closed pipe{': ' + chr(10).join(self.stderr_tail[-8:]) if self.stderr_tail else ''}")
def pop_seed(self) -> str:
"""Return and clear the seed from the last ``run()`` (empty when none)."""
seed, self._last_seed = getattr(self, "_last_seed", ""), ""
return seed
def close(self):
if getattr(self, "_closed", False):
return
self._closed = True
proc = self.proc
try:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=1)
except Exception:
proc.kill()
with contextlib.suppress(Exception):
proc.wait(timeout=1) # reap the zombie SIGKILL leaves behind
except Exception:
with contextlib.suppress(Exception):
proc.kill()
proc.wait(timeout=1)
finally:
for stream in (proc.stdin, proc.stdout, proc.stderr):
with contextlib.suppress(Exception):
stream.close()
def _display_cfg() -> dict:
"""``display`` section of the behavioral config, or ``{}`` when absent/malformed."""
display = _load_cfg().get("display")
return display if isinstance(display, dict) else {}
def _load_busy_input_mode() -> str:
raw = str(_display_cfg().get("busy_input_mode", "") or "").strip().lower()
return raw if raw in {"queue", "steer", "interrupt"} else "interrupt"
def _load_interim_assistant_messages() -> bool:
"""``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 transcripts (bounded budget) BEFORE the slow teardown so a supervisor SIGKILL can't lose them.
for step in (_flush_sessions_before_exit, _release_gateway_wake_owner, _stop_turns_before_exit):
with contextlib.suppress(Exception):
step()
with _sessions_lock:
sids = list(_sessions)
for sid in sids:
_close_session_by_id(sid, end_reason="tui_shutdown")
# Session reaping / flushing knobs (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): 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))
def _start_idle_reaper() -> None:
def _loop():
while True:
time.sleep(_REAPER_SCAN_S)
with contextlib.suppress(Exception):
_reap_idle_sessions()
threading.Thread(target=_loop, daemon=True).start()
atexit.register(_shutdown_sessions)
_start_idle_reaper()
# ── Plumbing ──────────────────────────────────────────────────────────
def _launch_home() -> Path:
"""The launch profile's home at call time: the patched ``_hermes_home`` when a test changed
it, else the live process home — resolved through :func:`get_process_hermes_home`, which honours
``HERMES_HOME`` but ignores the context-local override. The desktop multiplex cron ticker sets
that override per profile at startup, and a first touch inside a foreign window would bind
process-wide launch state (the shared ``state.db`` handle, the launch ``.env`` secrets) to
another profile (#102526). Resolving here rather than at import time lets a harness that
redirects ``HERMES_HOME`` after import be honoured (#112692)."""
home = _hermes_home if _hermes_home != _HERMES_HOME_AT_IMPORT else get_process_hermes_home()
return Path(home)
def _launch_state_db_path() -> Path:
"""Launch profile's ``state.db`` (see :func:`_launch_home`)."""
return _launch_home() / "state.db"
def _get_db():
global _db, _db_error
if _db is None:
from hermes_state_registry import acquire
try:
# Launch home, never the context-local override (#102526); resolved at first
# use, not import time (#112692). See _launch_state_db_path.
_db, _db_error = acquire(_launch_state_db_path()), None
except Exception as exc:
_db_error = str(exc)
logger.warning("TUI session store unavailable — continuing without state.db features: %s", exc)
return None
return _db
def _transfer_db_to_agent(agent, db) -> bool:
"""Hand a DEDICATED profile ``state.db`` handle to *agent* (``AIAgent.close()`` then releases it).
False = agent not holding *this* handle (build failed before ``_make_agent`` or got a different db):
the caller still owns it. The shared launch handle never transfers — it outlives every agent, and
ownership would let session.close() tear down the process-wide database."""
with contextlib.suppress(Exception):
if agent is None or db is None or getattr(agent, "_session_db", None) is not db:
return False
# Defense in depth (#91610): the shared launch handle must never transfer. Identity alone passes for
# it — a launch-profile agent IS holding that handle — and ownership would make session.close() tear
# down the process-wide database every other session shares. Refuse it explicitly even if a caller
# invokes the transfer incorrectly; the caller's own `owns_db` gate is the first line of defense.
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.")
return False
agent._owns_session_db = True
return True
return False
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 rows into the wrong profile's store exactly when the profile store is
briefly unopenable (locked, mid-restore); callers let the error abort the build (→ ``agent_error``)."""
from hermes_state_registry import acquire
db_path = Path(profile_home) / "state.db"
try:
return acquire(db_path)
except Exception as exc:
raise RuntimeError(f"profile session store unavailable: {db_path}: {exc}") from exc
@contextlib.contextmanager
def _profile_db(params: dict | None = None, *, writer: bool = False):
"""Yield the SessionDB for ``params['profile']`` (None when unavailable); closes dedicated
profile handles, leaves the launch-profile shared handle open.
Foreign-profile handles are read-only unless ``writer=True``: that store belongs to ITS
gateway/dashboard, and a writer here would take its write lock per RPC. Mirrors
hermes_cli.web_routers.profiles._read_profile_db."""
profile = (params.get("profile") or "").strip() or None if isinstance(params, dict) else None
# Launch/own profile → the shared _get_db() handle (left open); another profile → a dedicated
# handle closed below (app-global remote mode). db is None when unavailable.
if (profile_home := _profile_home(profile)) is None:
db, owns = _get_db(), False
else:
try:
if writer:
from hermes_state_registry import acquire
db = acquire(Path(profile_home) / "state.db")
else:
from hermes_cli.web_server_sessions import _open_session_db_at_path
db = _open_session_db_at_path(Path(profile_home) / "state.db", read_only=True)
owns = True
except Exception as exc:
logger.warning("TUI profile session store unavailable for %s: %s", profile, exc)
db, owns = None, False
try:
yield db
finally:
if owns and db is not None:
with contextlib.suppress(Exception):
db.close()
def _canonical_profile_request(name: str) -> str:
"""Canonicalize profile basenames emitted by older session-info payloads.
``Path(default_home).name`` was historically sent as a profile id. Those basenames are
installation details — unless a real named profile of that name exists (``hermes`` is a legal
id), in which case it wins; other unknown names keep failing closed in ``_profile_home``.
"""
if name.casefold() in {".hermes", "hermes"}:
from hermes_cli import profiles as profiles_mod
# Check the profiles root directly: get_profile_dir rejects "hermes" as a
# reserved name, but a pre-reserved-list install may still carry that dir.
if not (profiles_mod._get_profiles_root() / profiles_mod.normalize_profile_name(name)).is_dir():
return "default"
return name
def _response_profile_name(profile: str | None = None) -> str:
"""Profile name for session.* payloads: the requested real non-launch profile, else the launch one."""
name = _canonical_profile_request((profile or "").strip())
if not name:
return _current_profile_name()
try:
return name if _profile_home(name) is not None else _current_profile_name()
except ProfileUnavailableError:
return _current_profile_name()
def _db_unavailable_error(rid, *, code: int):
from hermes_state_user_copy import describe_storage_failure, storage_failure_details
failure = describe_storage_failure(_db_error)
return _err(
rid, code,
f"Session storage is unavailable: {failure.gloss}. {failure.action}",
data={"code": failure.code, "cause": failure.cause, "details": storage_failure_details(_db_error)})
# ── Per-session profile scoping: 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) so config/skills/model/persistence resolve to it. Omitted/own profile → launch profile.
class ProfileUnavailableError(FileNotFoundError):
"""An explicit ``profile`` param names no live profile on this host. Raised out of the method
(never a silent fall-back to the launch profile); ``handle_request`` turns it into JSON-RPC 4064
so a client holding a deleted profile gets a typed error instead of a ws dispatch crash (#107829)."""
def _profile_home(profile: str | None) -> Path | None:
"""Resolve a named profile's home on THIS host, or None for the launch profile."""
if not (name := _canonical_profile_request((profile or "").strip())):
return None
from hermes_cli import profiles as profiles_mod
try:
home = Path(profiles_mod.get_profile_dir(name))
except ValueError:
home = None
if home is None or not home.is_dir():
raise ProfileUnavailableError(f"Profile '{name}' does not exist.")
if home.resolve() == Path(_hermes_home).resolve():
return None # already the launch profile (no override needed)
if home not in _served_profile_homes:
# This process now hosts a second profile home: freeze the launch env as the launch
# profile's own and flip get_secret() to fail closed, so an unscoped read for a
# secondary raises instead of returning the launch profile's os.environ value
# (tui_gateway/launch_profile_policy.py). Must run before any secondary code.
from tui_gateway.launch_profile_policy import activate_multi_profile_hosting
activate_multi_profile_hosting()
_served_profile_homes.add(home) # the change watcher must stat every served sibling store too
return home
# 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 full runtime scope (HERMES_HOME + secrets + terminal policy) around a
handler, so config.yaml ``${VAR}`` refs, provider credential checks and ``.env`` writes resolve to
THAT profile (app-global remote mode hits the focused profile). Home alone left ``get_secret`` on the
launch process's ``os.environ``: ``config.get full`` for a secondary shipped the default profile's
expanded secrets and ``config.set`` published a secondary's ``.env`` edit into the shared process env.
Launch profile: unscoped while this is a single-profile process (legacy ``os.environ`` precedence,
systemd / ``op run`` injection); once multiplexing is active it binds its own scope from the env
frozen at activation (``_session_profile_runtime_scope``), never ambient state a secondary context
might have poisoned (#107422).
No ``profile`` param but a ``session_id`` naming a live session: that session's ``profile_home``
is the scope. The TUI and the Desktop's ambient dispatcher send session-bound RPCs with the
session id alone, so a ``config.set`` from a focused worker session otherwise persisted into the
LAUNCH profile's config.yaml while the worker's stayed unchanged (#85669).
"""
def wrapper(rid, params):
p = params if isinstance(params, dict) else {}
if str(p.get("profile") or "").strip():
home = _profile_home(p.get("profile"))
profile_home = str(home) if home else None
else:
session = _sessions.get(str(p.get("session_id") or ""))
profile_home = session.get("profile_home") if isinstance(session, dict) else None
with _session_profile_runtime_scope({"profile_home": profile_home or None}):
return handler(rid, params)
return wrapper
# Placeholder ``terminal.cwd`` values (resolved to the home dir at runtime) — never an explicit
# workspace (mirrors gateway/run.py's config bridge).
_CWD_PLACEHOLDERS = {".", "auto", "cwd"}
def _configured_cwd_from_cfg(cfg: dict | None) -> str | None:
"""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))
return resolved if os.path.isdir(resolved) else None
def _profile_configured_cwd(profile_home: Path | None) -> str | None:
"""A profile's ``terminal.cwd`` from ITS config.yaml (fail-open → None): the process-global
``TERMINAL_CWD`` belongs to the *launch* profile, and load_config() resolves the ACTIVE profile, so
read the requested file through the same effective-config pipeline as ``_load_cfg``.
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.
"""
if profile_home is None:
return None
with contextlib.suppress(Exception):
from hermes_cli.config_effective import load_user_config_effective
p = Path(profile_home) / "config.yaml"
return _configured_cwd_from_cfg(load_user_config_effective(p)) if p.exists() else None
return None
def _launch_configured_cwd() -> str | None:
"""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 otherwise start in ``os.getcwd()``."""
# Read the launch file by path. ``_load_cfg`` follows the active HERMES_HOME
# override, which may belong to a different profile-scoped RPC.
return _profile_configured_cwd(Path(_hermes_home))
def _default_session_cwd() -> str:
"""Fallback cwd when no explicit / stored / profile cwd (mirrors :func:`_completion_cwd`'s tail 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 via the most-specific transport: (1) event frames with a session id → that
session's transport (async events reach the owner even from threads with no contextvar binding);
(2) the context-bound transport (:func:`dispatch`); (3) module stdio (tests monkey-patch ``_real_stdout``).
Every event frame gets a per-session monotonic ``seq`` + replay-ring entry so ``session.events.since`` can resume."""
from tui_gateway.event_replay import _stamp_event
from tui_gateway.hosted_room_member_activity import project_room_member_activity
_stamp_event(obj)
params = obj.get("params")
if obj.get("method") == "event" or (isinstance(obj.get("id"), str) and "method" in obj):
# Event notifications AND server→client requests carry ``params.session_id``; both route to the
# owning session's transport. A room member's hidden session has no transport: its frames would
# die at stdio below.
project_room_member_activity(obj, _sessions)
sid = ((params or {}).get("session_id")) if isinstance(params, dict) else ""
if sid and (t := (_sessions.get(sid) or {}).get("transport")) is not None:
return t.write(obj)
return (current_transport() or _stdio_transport).write(obj)
def _event_frame(event: str, sid: str, payload: dict | None = None) -> dict:
_contracts.check_payload(event, payload)
params: dict = {"type": event, "session_id": sid, **({"payload": payload} if payload is not None else {})}
return {"jsonrpc": "2.0", "method": "event", "params": params}
def _emit(event: str, sid: str, payload: dict | None = None) -> bool:
from agent.notification_presentation import event_presentation_muted
if event_presentation_muted(event, sid):
return False
return write_json(_event_frame(event, sid, payload))
from tui_gateway import server_requests as _server_requests # noqa: E402
_server_requests.bind_sinks(lambda frame: write_json(frame), lambda event, sid, payload: _emit(event, sid, payload),
lambda sid: _session_client_answers_requests(sid),
lambda sid: _session_answering_clients(sid))
# 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()
# True only when real stdout IS the JSON-RPC client channel (``tui_gateway.entry.main``, the stdio TUI).
# `hermes serve` / dashboard processes speak JSON-RPC over WS only: their stdout is captured into
# desktop.log, so a peer-less global broadcast (the change watcher keeps ticking after the last WS client
# leaves) must be dropped there, not printed.
_stdio_is_rpc_channel = False
def register_live_transport(transport: Transport | None) -> None:
"""Track a connected client transport for global broadcasts. Idempotent."""
if transport is not None:
with _live_transports_lock:
_live_transports.add(transport)
def unregister_live_transport(transport: Transport | None) -> None:
"""Stop tracking a transport (call on disconnect). Idempotent."""
with _live_transports_lock:
_live_transports.discard(transport)
_server_requests.forget(transport)
def _broadcast_global_event(event: str, payload: dict | None = None) -> None:
"""Fan a session-less, surface-global event (``skin.changed``) to every connected client — background
emitters bottom out at stdio in ``write_json``'s ladder. No registered transports → ``_emit`` when stdout is the
stdio TUI's JSON-RPC channel, else dropped (nobody is listening; stdout is a log sink)."""
with _live_transports_lock:
targets = list(_live_transports)
if not targets:
if _stdio_is_rpc_channel:
return _emit(event, "", payload)
logger.debug("global-event broadcast dropped (no connected client) type=%s", event)
return None
frame = _event_frame(event, "", payload)
for transport in targets:
try:
transport.write(frame)
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)
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:
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"))
return payload
def _open_requests(sid: str) -> list[dict]:
"""Server→client requests still waiting on *sid*'s renderer, for reconnect snapshots (``session.resume`` /
``session.activate`` / ``session.events.since``). A client detached when the request frame was written would
otherwise never see it (agent parked until timeout). Under turn isolation the compute-host child owns the
request; the parent mirrors it from the relayed frame (compute_host_bridge)."""
from tui_gateway import server_requests
reqs = server_requests.open_requests(sid)
if reqs:
return reqs
if (session := _sessions.get(sid)) is not None:
with session.get("history_lock", threading.Lock()):
mirrored = session.get("_compute_host_open_request")
return [dict(mirrored)] if isinstance(mirrored, dict) else []
return []
def _pending_connection_request_payload(sid: str) -> dict | None:
"""The open connection operation on *sid* as its ``connection.request`` payload, so a client
that missed the event (or restarted) restores the card with the server's deadline."""
from tools.connectors import live
session = _sessions.get(sid)
operation = (live.current(str(session.get("session_key") or ""), profile_home=session.get("profile_home"))
if session else None)
return operation.request_payload() if operation is not None else None
def _pending_approval_request_payload(session_key: str) -> dict | None:
"""Read the oldest unresolved approval in a session, if there is one."""
try:
from tools.approval import get_pending_gateway_approval
approval = get_pending_gateway_approval(session_key)
except Exception:
logger.debug("failed to read pending approval for %s", session_key, exc_info=True)
return None
return _approval_request_payload(approval) if approval else None
def _emit_approval_request(sid: str, data: dict | None) -> None:
"""Send an ``approval`` server request with the command redacted: a credential-shaped value Tirith flagged
would otherwise echo verbatim to the TUI (third egress alongside chat platforms and the SSE/API stream).
See #48456, #50767.
The wait is owned by ``tools.approval``'s queue (its own timeout, ``/approve all``, coalescing), so the request
is queue-backed: the response resolves the queue entry, and the entry's own resolution (any surface, timeout,
interrupt) withdraws the request with ``request.cancel``."""
from tui_gateway import server_requests
from tools import approval as _approval
payload = _approval_request_payload(data)
request_id = str(payload.get("request_id") or "")
session_key = str((_sessions.get(sid) or {}).get("session_key") or "")
def on_result(result: dict | None) -> None:
if result is None:
# No client can answer this prompt: the request was never sent (the only attached client predates
# server→client requests) or the client answered -32601 (no handler). Without withdrawing the
# queue entry the agent would idle for the whole approvals.timeout with no prompt anywhere
# (#112548). A withdrawal, not a deny: nobody refused the command.
if request_id:
_approval.withdraw_gateway_approval(session_key, request_id,
"the attached client cannot answer approval requests "
"(update the Hermes app)")
return
choice = str(result.get("choice") or "deny")
_approval.resolve_gateway_approval(session_key, choice, resolve_all=bool(result.get("all")),
request_id=request_id or None)
settle = server_requests.send_async("approval", sid, payload, on_result)
# The wait can end between the frame going out and the hook attaching (the client answered by RPC, another
# surface resolved it): withdraw now, or the request stays in ``open_requests`` for every later resume.
if request_id and not _approval.register_gateway_settle(session_key, request_id, settle):
settle("resolved")
def _status_update(sid: str, kind: str, text: str | None = None):
if not (body := (text if text is not None else kind).strip()):
return
out_kind = kind if text is not None else "status"
# 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.
# See #97239.
if out_kind == "lifecycle":
from agent.conversation_compression import is_compaction_progress_status
if is_compaction_progress_status(body):
out_kind = "compacting"
_emit("status.update", sid, {"kind": out_kind, "text": body})
def _image_meta(path: Path) -> dict:
meta = {"name": path.name}
with contextlib.suppress(Exception):
from PIL import Image
with Image.open(path) as img:
width, height = (int(v) for v in img.size)
# Rough attachment-display token estimate: 512px tiles at ~85 tokens/tile (cross-provider hint).
tiles = max(1, (width + 511) // 512) * max(1, (height + 511) // 512) if width > 0 and height > 0 else 0
meta.update(width=width, height=height, token_estimate=tiles * 85)
return meta
def _ok(rid, result: dict) -> dict:
return {"jsonrpc": "2.0", "id": rid, "result": result}
def _err(rid, code: int, msg: str, data=None) -> dict:
error = {"code": code, "message": msg, **({"data": data} if data is not None else {})}
return {"jsonrpc": "2.0", "id": rid, "error": error}
def register_method(name: str, fn) -> None:
"""The ONE registration seam (``@method`` here and ``HandlerRegistry.install`` for the split
modules). ``tests/tui_gateway/contracts/test_generated.py::test_every_method_has_a_contract`` and the
generator's ``assert_complete`` fail when a registered name has no contract."""
_methods[name] = fn
def method(name: str):
def dec(fn):
register_method(name, fn)
return fn
return dec
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, 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", {})
if params is not None and not isinstance(params, dict):
return _err(rid, -32602, "invalid params: expected an object")
return rid, method, params if params is not None else {}
def _current_session_steer_authority(session_id: str) -> tuple[Transport | None, dict | None]:
"""Unforgeable steering authority for this RPC context: the public session id is only a lookup
hint; authority requires the ContextVar-bound transport to be ATTACHED to the live in-memory record
under that id, so transport detachment, session removal or id reuse invalidates an earlier generation."""
transport = current_transport()
if transport is None or not session_id:
return None, None
expected_session = _current_runtime_session_record.get()
with _sessions_lock:
session = _sessions.get(session_id)
# Membership, not slot identity: a mirrored session stores a FanoutTransport in the slot, so slot
# identity alone would reject every client, the peer that commissioned the subagent included.
# Authority is membership in the slot, which also grants it to any client attached to mirror the
# session (see tests/tui_gateway/test_multi_client_fanout.py).
if (session is None or (expected_session is not None and session is not expected_session)
or not _session_transport_contains(session, transport)):
return None, None
return transport, session
def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None:
ready = session.get("agent_ready")
if ready is not None and not ready.wait(timeout=timeout):
return _err(rid, 5032, AGENT_STILL_STARTING)
return _err(rid, 5032, err) if (err := session.get("agent_error")) else None
# 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"
def _agent_build_wait_cap() -> float:
"""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):
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 ``_wait_agent`` for deferred prompt.submit: the client already got ``streaming`` and the
first message IS the turn, while a cold build routinely outlives the flat 30s ceiling (timing out
silently discarded it). Waits in short slices (cancel honored promptly), notifies once (keyed) past
``_AGENT_BUILD_SLOW_NOTICE_AFTER``, fails only on a dead build thread or the bounded cap.
Returns None on success OR cancel mid-wait (the caller's cancel branch owns that messaging).
The flat 30s ``_wait_agent`` ceiling was a message-eating cliff (#63078): ``prompt.submit`` has already
returned ``{"status": "streaming"}``, the user's first message IS the turn in flight, and the deferred
agent build (MCP discovery with per-server retry backoff, synchronous model-metadata HTTP, skills
scanning) routinely outlives 30 seconds on cold starts. On timeout the old path emitted an error EVENT
and returned without ever calling ``_run_prompt_submit`` — the first message was permanently discarded
while the build finished successfully in the background, leaving the blank first session.
"""
ready = session.get("agent_ready")
if ready is None:
return None
start, cap, notified_slow = time.monotonic(), _agent_build_wait_cap(), False
while not ready.wait(timeout=_AGENT_BUILD_WAIT_SLICE):
with session["history_lock"]:
cancelled = session.get("_turn_cancel_requested") or not session.get("running")
if cancelled:
return None
waited = time.monotonic() - start
if waited >= cap:
return _err(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 = 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:
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})
return _err(rid, 5032, err) if (err := session.get("agent_error")) else None
def _bind_build_profile_scopes(profile_home: "str | None") -> "_TurnScopes | None":
"""Bind a session profile's HERMES_HOME / secret / terminal scopes for an agent build. ``None`` is the
launch profile: its own launch-env secret scope (live env while single-profile, frozen once
multiplexing is active — a hosted-room turn for a default member otherwise died at build with
``UnscopedSecretError`` because the launch profile was treated as "no scope"). 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."""
scopes = _TurnScopes()
with contextlib.suppress(Exception):
return _profile_runtime_scope_tokens(profile_home)
if profile_home: # secret/terminal helper failed: keep at least the home + terminal refusal scope
scopes.home = set_hermes_home_override(profile_home)
with contextlib.suppress(Exception):
from tools.terminal_scope import install_profile_terminal_scope
scopes.terminal = install_profile_terminal_scope(Path(profile_home))
return scopes
def _release_build_profile_scopes(scopes: "_TurnScopes | None") -> None:
with contextlib.suppress(Exception):
_release_profile_runtime_scope_tokens(scopes)
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 (like the eager resume's overrides splat) so the build can't drop the provider. No
stored runtime, or an unroutable provider → this session's picked model/effort/tier, else the default."""
kw = {"session_db": session_db, "context_cwd_is_launch_artifact": _context_cwd_is_launch_artifact(current),
"platform_override": _session_source(current), "cwd_override": _session_cwd(current)}
if resume_sid := current.get("resume_session_id"):
kw["session_id"] = resume_sid
resume_overrides = current.get("resume_runtime_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"):
kw["model_override"] = override
kw.update({k: v for k, v in (("reasoning_config_override", current.get("create_reasoning_override")),
("service_tier_override", current.get("create_service_tier_override")))
if v is not None})
return kw
def _wire_session_agent(sid: str, key: str, agent) -> bool:
"""Post-build wiring; returns whether the approval notify got registered. Approval prompts route to the
client; the self-improvement "💾 …" summary is emitted as review.summary (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)
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()
return notify_registered
def _start_session_services(sid: str, key: str, current: dict) -> None:
"""Start the notification poller and fire the session-reset boundary hook."""
with _sessions_lock:
if (rec := _sessions.get(sid)) is not None:
rec["_notif_stop"] = _start_notification_poller(sid, rec)
_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(sid: str, current: dict, agent) -> bool:
"""Attach a freshly built agent to its live record (session DB row deferred to first run_conversation()).
False when ``session.close`` popped this record mid-build: teardown saw ``agent=None`` and closed
nothing, so the caller owns closing the orphan (#49852)."""
# Bot Mode gate hint: the DB title lands post-first-turn but the system prompt builds at turn START.
if _title_hint := str(current.get("pending_title") or "").strip():
agent._session_title_hint = _title_hint
# Under the same lock session.close takes to pop the record: no window between "still live" and "attached".
with _sessions_lock:
if _sessions.get(sid) is not current:
return False
current["agent"] = agent
# A workspace move can land while construction is still in flight.
_register_session_cwd(current)
_session_todo_state(current)
# Baseline for the per-turn config sync (profile home override still active).
current["config_model_seen"] = _config_model_target()
return True
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 (notice_callback already 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)
_schedule_mcp_late_refresh(sid, agent) # servers slower than the bounded discovery wait land here
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)
# Reaped after the agent was attached: _attach_worker closed the worker; only a 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 be torn down, else close it (build
# failed, or `replaced`: this agent is discarded and _teardown_session never reaches it).
if session_db is not None and not _transfer_db_to_agent(None if replaced else current.get("agent"), 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 needing the agent) so the composer isn't blocked on tool discovery / model metadata;
the ready/error event contract 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 (it bails once agent is set); incidental RPCs via _sess() would upgrade it mid-stream.
if session.get("lazy") and _child_run_active(
str(session.get("session_key") or ""), session.get("profile_home") or None):
return
with session.setdefault("agent_build_lock", threading.Lock()):
if ready.is_set() or session.get("agent_build_started"):
return
session["agent_build_started"] = True
session.pop("lazy", None) # now genuinely mid-construction: restore the "still starting" eviction exemption
key = session["session_key"]
def _build() -> None:
with _sessions_lock:
current = _sessions.get(sid)
if current is None:
# Closed/reaped before the build started: nothing will ever attach an agent to this
# record, yet ``agent_ready`` must be set so a prompt waiting on it fails instead of hanging.
session["agent_error"] = AGENT_BUILD_ABANDONED
ready.set()
return
notify_registered, scopes, session_db = False, None, None
profile_home = current.get("profile_home")
try:
if not _await_resume_history(sid, current):
# Replaced mid-build: the finally still sets ``agent_ready`` with ``agent`` None, so record
# why — a turn admitted against this record refuses with the real reason (#111531).
current["agent_error"] = AGENT_BUILD_ABANDONED
return
tokens = _set_session_context(key, cwd=_session_cwd(current))
# Global-remote: bind the session profile's HERMES_HOME and hand the agent that profile's db —
# DEDICATED and ours until _transfer_db_to_agent in the finally; FAIL CLOSED rather than
# binding the launch DB and bleeding rows into the wrong state.db.
scopes = _bind_build_profile_scopes(profile_home)
if profile_home:
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)
# Attach atomically against session teardown: ``session.close`` may have popped this
# session while the expensive build was in flight, in which case teardown could not close
# an agent that did not exist yet. Release the orphan immediately and do not keep wiring
# workers/callbacks for a dead session (#49852).
if not _attach_built_agent(sid, current, agent):
# Same contract as the replaced-before-attach exit above: a turn admitted against
# this record must refuse with the real reason rather than a generic missing agent.
current["agent_error"] = AGENT_BUILD_ABANDONED
with contextlib.suppress(Exception):
if hasattr(agent, "close"):
agent.close()
return
# 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)
_announce_built_agent(sid, key, current, agent)
except Exception as e:
from agent.auxiliary_unavailable import ProviderNotConfiguredError
current["agent_error"] = str(e)
# A client can route "no provider is set up" to its setup flow instead of a dead-end
# error toast — but only if it can tell. The sentence is for the reader, the code is
# for the client; older clients keep matching the text.
_emit("error", sid, {
"message": agent_init_failed_message(e),
**({"code": "provider_not_configured"} if isinstance(e, ProviderNotConfiguredError) else {})})
finally:
_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)
# _wait_agent_for_prompt handle: dead thread + unset agent_ready = died hard; waiters must not sit out the cap.
session["_agent_build_thread"] = build_thread
build_thread.start()
def _sess_nowait(params, rid):
sid = params.get("session_id") or ""
s = _sessions.get(sid)
if s:
return (s, None)
# Stale runtime id (reaped/evicted/TTL): 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 "
# A session-scoped RPC hit a runtime id the gateway no longer holds (detached on WS
# disconnect and orphan-reaped, LRU-evicted, or torn down after an idle TTL). The client
# is expected to recover via session.resume on the STORED session id, but a plain
# stale-id send leaves no trace anywhere when the resume never fires — every RPC in this
# class returned a silent 4001. Log it so a "message vanished" report is diagnosable as
# "request arrived and was rejected" instead of "request never arrived" (see #90428).
"(detached/reaped runtime; client should resume the stored session), rid=%r",
_current_rpc_method.get() or "?", sid, rid)
return (None, _err(rid, 4001, "session not found"))
def _sess(params, rid):
s, err = _sess_building(params, rid)
return (None, err) if err else (s, _wait_agent(s, rid))
def _sess_building(params, rid):
"""Resolve a session and warm its agent build WITHOUT waiting — for the attach RPCs (image/file/pdf,
clipboard.paste, image.detach), which only touch creation-time fields and run inline on the socket
reader thread, where waiting on a cold build stalled every RPC behind it ("text is instant, images hang")."""
s, err = _sess_nowait(params, rid)
if not err:
_start_agent_build(params.get("session_id") or "", s)
return (None, err) if err else (s, None)
# ── Config I/O ────────────────────────────────────────────────────────
_DASHBOARD_TURN_ISOLATION_DEFAULT = False
_DASHBOARD_COMPUTE_HOST_HEARTBEAT_SECS_DEFAULT = 15
_DASHBOARD_COMPUTE_HOST_RESPAWN_MAX_DEFAULT = 3
def _coerce_int_config_value(value: Any, default: int, *, min_value: int) -> int:
try:
coerced = int(value)
except (TypeError, ValueError):
return default
return coerced if coerced >= min_value else default
def _load_dashboard_process_isolation_config(cfg: dict | None = None) -> dict[str, Any]:
"""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
dash = root.get("dashboard") if isinstance(root, dict) else {}
dash = dash if isinstance(dash, dict) else {}
return {
"turn_isolation": is_truthy_value(dash.get("turn_isolation"), default=_DASHBOARD_TURN_ISOLATION_DEFAULT),
"compute_host_heartbeat_secs": _coerce_int_config_value(
dash.get("compute_host_heartbeat_secs"), _DASHBOARD_COMPUTE_HOST_HEARTBEAT_SECS_DEFAULT, min_value=1),
"compute_host_respawn_max": _coerce_int_config_value(
dash.get("compute_host_respawn_max"), _DASHBOARD_COMPUTE_HOST_RESPAWN_MAX_DEFAULT, min_value=0),
}
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"
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 / managed overlay / ``${VAR}``
expansion applied here would be persisted 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_sig, _cfg_path
from hermes_cli.config import read_user_config_raw
from hermes_cli.config_read_errors import FailedConfigRead
try:
p = _active_config_path()
sig = file_signature(p.stat()) if p.exists() else None
with _cfg_lock:
if _cfg_cache is not None and _cfg_sig == sig and _cfg_path == p:
return copy.deepcopy(_cfg_cache)
data = read_user_config_raw(p) if p.exists() else {}
except Exception as exc:
return FailedConfigRead(error=exc) # readable as {}, refused by _save_cfg
with _cfg_lock: # cache the RAW config: _save_cfg writes _cfg_cache back to disk
_cfg_cache, _cfg_sig, _cfg_path = copy.deepcopy(data), sig, p
return data
def _load_cfg() -> dict:
"""Behavioral config read: the effective USER config (managed overlay, ``${VAR}`` expansion, model-key
canon) minus the DEFAULT_CONFIG merge — callers treat a missing key as "unset", so merging would break
``_load_cfg() == {}`` sentinels. Fail-open to ``{}``. Never pass the result to ``_save_cfg`` (use
``_load_cfg_raw()``)."""
with contextlib.suppress(Exception):
from hermes_cli.config_effective import load_user_config_effective
return load_user_config_effective(_active_config_path())
return {}
def _save_cfg(cfg: dict):
global _cfg_cache, _cfg_sig, _cfg_path
from hermes_cli.config import atomic_config_replace
path = _active_config_path()
atomic_config_replace(path, cfg)
with _cfg_lock:
_cfg_cache, _cfg_path = copy.deepcopy(cfg), path
try:
_cfg_sig = file_signature(path.stat())
except Exception:
_cfg_sig = None
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:
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:
with contextlib.suppress(Exception):
from gateway.session_context import set_session_vars
sess = _session_for_key(session_key) if session_key else None
# 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 (str(sess.get("cwd") or "") if sess is not None else "")
source = _resolve_session_platform()
profile = _current_profile_name()
browser_control_principal = browser_control_transport_family = ""
# Live conversation id for subprocess HERMES_SESSION_ID: an explicitly empty contextvar is authoritative
# (no os.environ fallback), so never leave it "" — agent's durable session_id, then session_key.
session_id = session_key
if sess is not None:
source = _session_source(sess)
# App-global backends multiplex profiles: prefer the live session's own home.
profile = profile_name_for_home(sess.get("profile_home")) or profile
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,
profile=profile,
browser_control_principal=browser_control_principal,
browser_control_transport_family=browser_control_transport_family, cwd=resolved,
ui_session_id=ui_session_id, cron_session="")
return []
def _clear_session_context(tokens: list) -> None:
if tokens:
with contextlib.suppress(Exception):
from gateway.session_context import clear_session_vars
clear_session_vars(tokens)
def _enable_gateway_prompts() -> None:
"""Route approvals through gateway callbacks instead of CLI input()."""
os.environ.update(HERMES_GATEWAY_SESSION="1", HERMES_EXEC_ASK="1", HERMES_INTERACTIVE="1")
# ── Blocking prompt factory ──────────────────────────────────────────
def _ask(method: str, sid: str, params: dict, timeout: float | None = 300) -> str:
"""Server→client request whose answer is one string under ``value`` (sudo, secret, vault prompts, GUI reads,
MCP setup). Empty string when the renderer skipped, timed out, or was cancelled."""
from tui_gateway import server_requests
result = server_requests.send(method, sid, params, timeout=timeout)
value = (result or {}).get("value", "")
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def _clarify_timeout_seconds() -> float | None:
"""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)."""
with contextlib.suppress(Exception):
from tools.clarify_gateway import get_clarify_timeout
timeout = get_clarify_timeout()
return timeout if timeout > 0 else None
return 300
def _clarify_block(sid: str, questions: list[dict]) -> dict:
"""Bridge the clarify tool callback onto one ``clarify`` server request carrying only the wire fields
(tool-side entries carry result-assembly keys too). Answers lock one at a time through ``clarify.lock``
(``null`` = skipped); the tool gets ``{"answers", "outcome"}`` — ``undelivered`` when no client took it."""
from tui_gateway import server_requests
wire = [{"qid": e["qid"], "question": e["question"], "choices": e["choices"], "multi_select": bool(e["multi_select"])}
for e in questions]
result = server_requests.send("clarify", sid, {"questions": wire}, timeout=_clarify_timeout_seconds(),
qids=[e["qid"] for e in questions])
return result or {"answers": {}, "outcome": "undelivered"}
# A tour action is a DOM op the renderer answers in ms; 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.
_TOUR_PROBE_TIMEOUT_S = 10
_TOUR_BRIDGE_UNAVAILABLE = json.dumps({
"success": False,
"error": ("No Hermes Desktop window answered the tour request. The tour is 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.")})
def _tour_request(sid: str, payload: dict) -> str:
"""Bridge the tour tool callback onto a ``tour`` server request without paying for a client that cannot answer: against
an older app nobody answers ``tour`` and each action would block the full deadline, stacking per
turn. First action per session gets the short probe deadline; unanswered → bridge marked unavailable
for that session; once answered, the full deadline. Verdict lives on the record, so a new session re-probes.
The renderer's ``tour`` handler ships in the desktop bundle, but the tool is offered by this
backend — and the two update on different clocks. The model then does what the schema tells it to and
tries the next action, so a single "give me a tour" turn stacks those waits (the timeouts reported
against #89620).
"""
session = _sessions.get(sid)
if session is None: # detached caller: throwaway record, plain bridge, unprobed ({} is falsy but a REAL record)
session = {}
state = session.get("tour_bridge")
if state == "unanswered":
return _TOUR_BRIDGE_UNAVAILABLE
answer = _ask("tour", sid, dict(payload),
timeout=_TOUR_TIMEOUT_S if state == "answered" else _TOUR_PROBE_TIMEOUT_S)
if answer:
session["tour_bridge"] = "answered"
elif state != "answered":
session["tour_bridge"] = "unanswered"
return answer or _TOUR_BRIDGE_UNAVAILABLE
_PREVIEW_ACTION_TIMEOUT_S = 45
# Until a session's client has proven it answers preview.act at all, hold it to a
# deadline a working renderer cannot miss (same ladder as the tour probe).
_PREVIEW_ACTION_PROBE_TIMEOUT_S = 10
# An unanswered probe condemns the bridge only until the cooldown expires: the
# renderer may attach late (app launched after the turn started). One caller at
# a time re-probes; concurrent siblings fail fast instead of stacking waits.
_PREVIEW_ACTION_REPROBE_COOLDOWN_S = 30
_PREVIEW_ACTION_BRIDGE_UNAVAILABLE = json.dumps({
"success": False,
"error": ("No Hermes Desktop window answered the preview action request. The drive_preview / "
"annotate_preview bridge is served by the desktop app's renderer, which updates "
"separately from this backend, so an app build older than the tool has nothing "
"listening. Update the Hermes Desktop app, open a page with open_preview, and try "
"again in this session after a short cooldown.")})
# One in-flight cooldown-expiry reprobe per session: concurrent callers fail fast.
_preview_action_reprobe: dict[str, object] = {}
_preview_action_reprobe_lock = threading.Lock()
def _preview_action_request(sid: str, payload: dict) -> str:
"""Bridge the drive_preview / annotate_preview callback onto a ``preview.act`` server request
without paying for a client that cannot answer: against an older app (or a session no window
hosts, #94272 / #119333) nobody answers ``preview.act`` and each action would block the full
deadline, stacking per turn exactly like the tour timeouts (#89620). First action per session
gets the short probe deadline; unanswered → bridge marked unavailable for that session with a
cooldown-gated reprobe; once answered, the full deadline. The verdict lives on the session
record, so a new session re-probes. Interrupt ≠ timeout: a cancelled wait (Stop, session close)
returns without poisoning the state, because ``send()``'s None conflates the two and only the
cooldown-reprobe token distinguishes an in-flight probe — so state flips only through it.
"""
with _sessions_lock:
session = _sessions.get(sid)
if session is None:
# detached caller: throwaway record, plain bridge, unprobed ({} is falsy but a REAL record)
session = {}
state = session.get("preview_action_bridge")
now = time.monotonic()
if state == "unanswered" and now < session.get("preview_action_bridge_retry_at", 0):
return _PREVIEW_ACTION_BRIDGE_UNAVAILABLE
if state == "unanswered":
with _preview_action_reprobe_lock:
if _preview_action_reprobe.get(sid) is not None:
return _PREVIEW_ACTION_BRIDGE_UNAVAILABLE
_preview_action_reprobe[sid] = object()
session["preview_action_bridge_retry_at"] = now + _PREVIEW_ACTION_REPROBE_COOLDOWN_S
try:
answer = _ask("preview.act", sid, dict(payload),
timeout=_PREVIEW_ACTION_TIMEOUT_S if state == "answered" else _PREVIEW_ACTION_PROBE_TIMEOUT_S)
finally:
if state == "unanswered":
with _preview_action_reprobe_lock:
_preview_action_reprobe.pop(sid, None)
with _sessions_lock:
if answer:
session["preview_action_bridge"] = "answered"
elif session.get("preview_action_bridge") != "answered":
session["preview_action_bridge"] = "unanswered"
session["preview_action_bridge_retry_at"] = time.monotonic() + _PREVIEW_ACTION_REPROBE_COOLDOWN_S
return answer or _PREVIEW_ACTION_BRIDGE_UNAVAILABLE
def _clear_pending(sid: str | None = None) -> None:
"""Withdraw open server→client requests: only *sid*'s (session.interrupt must not cancel other sessions'
prompts), or every one when *sid* is None (process exit). Each one gets a ``request.cancel``."""
from tui_gateway import server_requests
server_requests.cancel(sid, reason="interrupted" if sid else "shutdown")
# ── 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:
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 / config preference: the cost-safe silent default (cache-only read), never an unpicked flagship.
with contextlib.suppress(Exception):
from hermes_cli.models import get_preferred_silent_default_model
return get_preferred_silent_default_model()
return "z-ai/glm-5.2"
def _resolve_session_platform() -> str:
"""``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:
"""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:
return _resolve_session_source(source)
def _config_model_target() -> tuple[str, str]:
"""(model, provider) selected by config.yaml — and ONLY config: the HERMES_MODEL launch 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")
if isinstance(cfg_model, dict):
provider = str(cfg_model.get("provider") or "").strip()
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()
if explicit_provider := os.environ.get("HERMES_TUI_PROVIDER", "").strip():
return model, explicit_provider
if not (explicit_model := _env_model_seed()):
return model, None
with contextlib.suppress(Exception):
from hermes_cli.model_switch import resolve_startup_model_route
from hermes_cli.models import detect_static_provider_for_model
full_cfg = _load_cfg()
cfg = full_cfg.get("model") or {}
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")
# Same owner as HermesCLI/oneshot: ``custom:<name>:<model>`` selects that provider (#73943).
if route := resolve_startup_model_route(
explicit_model, current_provider=current_provider,
user_providers=full_cfg.get("providers"), custom_providers=full_cfg.get("custom_providers")):
return route.model, route.provider
if detected := detect_static_provider_for_model(explicit_model, current_provider):
provider, detected_model = detected
return detected_model, provider
return model, None
def _is_routable_provider(provider: str) -> bool:
with contextlib.suppress(Exception):
from hermes_cli.runtime_provider import is_routable_provider
return is_routable_provider(provider)
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 = NOT routable, so the caller falls back to the session's picked model."""
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)
return parsed if isinstance(parsed, dict) else {}
except Exception:
if not quiet:
raise
logger.debug("failed to parse stored session model_config", exc_info=True)
return {}
def _row_follows_profile(row: dict | None) -> bool:
"""Whether a stored row is a canonical Bot Chat whose runtime follows the member profile's config.
Identity is the persisted ``follow_profile_config`` marker; the bare title compare stays ONLY here as
the legacy fallback for rows written before the marker existed."""
if not row:
return False
model_config = _parse_model_config(row.get("model_config"), quiet=True)
return bool(model_config.get("follow_profile_config") or str(row.get("title") or "").strip() == "Bot Chat")
def _stored_session_runtime_overrides(row: dict | None) -> dict:
"""Runtime fields persisted with a stored session (model column, ``billing_provider``, JSON ``model_config``):
resume restores the model/provider/reasoning THAT chat used, not the global pick. Plugin-owned Bot-Mode
sessions normally rebuild from the member profile's CURRENT config (a stale provider pin left room bots
"out of Nous credits" after a profile switch). A canonical Bot Chat may instead restore an explicit
composer pick while the profile model it diverged from remains unchanged."""
if not row:
return {}
model_config = _parse_model_config(row.get("model_config"), quiet=True)
_row_title = str(row.get("title") or "").strip()
room_plumbing = model_config.get("room_plumbing") or (row.get("hidden") and _row_title.startswith("Group:"))
composer_profile = model_config.get("composer_override_profile")
composer_profile_matches = isinstance(composer_profile, dict) and (
str(composer_profile.get("model") or "").strip(),
str(composer_profile.get("provider") or "").strip(),
) == _config_model_target()
if room_plumbing or (_row_follows_profile(row) and not composer_profile_matches):
return {}
overrides: dict = {}
model = str(row.get("model") or model_config.get("model") or "").strip()
# Canonical route reader shared with CLI --resume: nested ``gateway_runtime`` (the route the messaging
# gateway last ran) before the TUI's top-level keys, then a routable ``billing_provider`` (#125942).
from hermes_state import SessionDB
route = SessionDB.session_gateway_runtime(row)
provider, base_url, api_mode = (str(route.get(k) or "").strip() for k in ("provider", "base_url", "api_mode"))
service_tier = str(model_config.get("service_tier") or "").strip()
reasoning_config = model_config.get("reasoning_config")
from hermes_cli.runtime_provider import is_foreign_provider_endpoint
if is_foreign_provider_endpoint(provider, base_url):
# The endpoint and its wire belong to the provider this chat left; resolve the stored one's own.
base_url = api_mode = ""
# Heal a stale provider persisted by an older build (renamed/removed custom provider → "Unknown provider"):
# recover ``custom:<name>`` from the stored base_url, then from the entry serving the model; else drop it.
if provider and not _is_routable_provider(provider):
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
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 never persisted/restored.
overrides["model_override"] = {
"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: # None = "inherit the profile" at _make_agent; "" = real override "no priority tier"
overrides["service_tier_override"] = "" if service_tier.lower() == "normal" else service_tier
return overrides
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: resume reads provider/endpoint from this JSON
(model column written separately), so a stale provider would route the resumed chat to the wrong endpoint."""
config = dict(existing or {})
attr = lambda k: str(getattr(agent, k, "") or "").strip()
model, provider, base_url = attr("model"), attr("provider"), attr("base_url")
if provider.lower() == "custom":
# ``agent.provider`` resolves every named custom entry to the literal "custom", losing the entry
# identity (api_key is never persisted): recover ``custom:<name>`` from the endpoint URL.
try:
from hermes_cli.runtime_provider import canonical_custom_identity
provider = canonical_custom_identity(base_url=base_url, model=model or None) or provider
except Exception:
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, "api_mode": attr("api_mode"),
# An empty dict is still a real (present) reasoning config.
"reasoning_config": reasoning_config if isinstance(reasoning_config, dict) else None,
"service_tier": getattr(agent, "service_tier", None),
}
for key, value in live.items():
if value or isinstance(value, dict):
config[key] = value
else:
config.pop(key, None)
return config
def _persist_live_session_runtime(session: dict | None) -> None:
"""Persist active session runtime so future resumes restore the same footer."""
live = _live_session_agent_db(session)
if live is None:
return
agent, session_key, db = live
try:
row = db.get_session(session_key) or {}
model_config = _runtime_model_config(agent, _parse_model_config(row.get("model_config")))
if isinstance(composer_profile := session.get("composer_override_profile"), dict):
model_config["composer_override_profile"] = composer_profile
elif "composer_override_profile" in session:
model_config.pop("composer_override_profile", None)
if (tier_override := session.get("create_service_tier_override")) is not None:
# agent.service_tier is None for explicit normal; without this the distinction is erased on every persist.
model_config["service_tier"] = tier_override or "normal"
model = str(getattr(agent, "model", "") or "").strip()
if hasattr(db, "update_session_meta"):
db.update_session_meta(session_key, json.dumps(model_config), model or None)
elif model and hasattr(db, "update_session_model"):
db.update_session_model(session_key, model)
except Exception:
logger.debug("failed to persist live session runtime", exc_info=True)
def _live_session_agent_db(session: dict | None):
"""(agent, session_key, db) for a live record, or None when any of them is missing."""
agent = (session or {}).get("agent")
session_key = str((session or {}).get("session_key") or "").strip()
if agent is None or not session_key:
return None
db = getattr(agent, "_session_db", None) or _get_db()
return None if db is None else (agent, session_key, db)
def _persist_live_session_system_prompt(session: dict | None) -> None:
"""Refresh the stored system prompt after a live runtime identity change."""
live = _live_session_agent_db(session)
if live is None or not hasattr(live[0], "_build_system_prompt") or not hasattr(live[2], "update_system_prompt"):
return
agent, session_key, db = live
# Re-bind the session's profile runtime scope (the build's finally reset it → root profile's SOUL.md/skills,
# #50233) and session context (on the RPC thread _SESSION_CWD is unset → the process TERMINAL_CWD would
# persist). The full scope, not HERMES_HOME alone: the external memory provider's system_prompt_block()
# reads its credential through get_secret, which fails closed once this process multiplexes (#112927).
session_tokens = _set_session_context(session_key, cwd=_session_cwd(session))
try:
with _session_profile_runtime_scope(session):
prompt = agent._cached_system_prompt = agent._build_system_prompt(None)
db.update_system_prompt(getattr(agent, "session_id", None) or session_key, prompt)
except Exception:
logger.warning("failed to persist live session system prompt for session %s", session_key, exc_info=True)
finally:
_clear_session_context(session_tokens)
# Stable leading text of the model-switch marker (builder + dedup); only the newest marker is meaningful.
# Only the newest marker is meaningful (it names the *currently* active model); older ones are stale and
# would otherwise be re-sent to the provider on every turn (#65891).
_MODEL_SWITCH_MARKER_PREFIX = "[System: The active model for this chat has changed to "
def _is_model_switch_marker(entry: Any) -> bool:
"""Whether a history entry is a (self-replacing) model-switch marker."""
if not isinstance(entry, dict):
return False
content = entry.get("content")
return isinstance(content, str) and content.startswith(_MODEL_SWITCH_MARKER_PREFIX)
def _is_pivot_marker(entry: Any) -> bool:
"""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."""
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 newest marker is kept (each
switch strips prior ones, so N switches leave one marker, not N re-sent every API call; self-healing
across resumes because the next switch collapses whatever a reload brought back).
See #65891.
"""
session_key = str((session or {}).get("session_key") or "").strip()
if not session_key:
return
provider_part = f" via provider {provider}" if provider else ""
marker = (
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.
# See #48338.
entry: dict[str, Any] = {"role": "user", "content": marker, "display_kind": "model_switch"}
with session.get("history_lock") or contextlib.nullcontext():
history = session.setdefault("history", [])
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
try:
agent = session.get("agent")
db = getattr(agent, "_session_db", None) if agent is not None else None
if db is None:
_ensure_session_db_row(session)
with (contextlib.nullcontext(db) if db is not None else _session_db(session)) as db:
if db is not None:
from agent.context_compressor import _DB_PERSISTED_MARKER
# Same stale-key hazard as the submit row: this durable pivot must land in the session the
# live agent writes to, or a model switch between turns on a rotated session files the notice
# under a parent the conversation no longer reads from (#123545).
target = _submit_row_target_key(session)
# The in-memory strip above keeps one marker; the durable rows need the same invariant or N
# switches leave N active rows that all replay on resume (#65891 kept it in memory only).
db.deactivate_messages_by_display_kind(target, "model_switch")
from agent.message_metadata import stamp_message_uid
entry["_row_id"] = db.append_message(
session_id=target, role="user", content=marker, display_kind="model_switch",
message_uid=stamp_message_uid(entry))
entry[_DB_PERSISTED_MARKER] = True
except Exception:
# warning, not debug: filing the pivot in the LIVE session (#123545) means this write can now hit
# CompressionSessionClosedError on a closed parent, which the old session_key target could not.
# Swallowed at debug, a model switch silently loses its durable notice — the next resume replays
# without it and nothing lands in errors.log. Matches _persist_live_session_system_prompt above.
logger.warning("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 overlaid/expanded view would persist it.
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[leaf] = value
_save_cfg(cfg)
_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`). `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,
}
def _load_approval_mode() -> str:
"""Effective ``approvals.mode`` via the gate's own ``_get_approval_mode`` (a raw re-read missed the
managed overlay and ``${VAR}`` expansion)."""
from tools.approval_context import _get_approval_mode
mode = _get_approval_mode()
return mode if mode in _APPROVAL_MODES else "manual"
def _coerce_statusbar(raw) -> str:
if raw is False:
return "off"
return s if isinstance(raw, str) and (s := raw.strip().lower()) in _STATUSBAR_MODES else "top"
_MOUSE_TRACKING_ALIASES = {
"0": "off", "1": "all", "all": "all", "any": "all", "button": "buttons", "buttons": "buttons",
"click": "buttons", "false": "off", "full": "all", "no": "off", "off": "off", "on": "all",
"scroll": "wheel", "true": "all", "wheel": "wheel", "yes": "all",
}
def _display_mouse_tracking(display: dict) -> str:
"""display.mouse_tracking → ``off|wheel|buttons|all`` (bools: True → all, False → off); ``wheel`` (DEC
1000+1006) is the tmux-friendly subset without hover events. Legacy ``tui_mouse`` only when ``mouse_tracking`` is absent."""
if not isinstance(display, dict):
return "all"
raw = display.get("mouse_tracking") if "mouse_tracking" in display else display.get("tui_mouse", True)
if isinstance(raw, str):
return _MOUSE_TRACKING_ALIASES.get(raw.strip().lower(), "all")
return "off" if raw is False or raw == 0 else "all"
def _load_reasoning_config(model: str = "") -> dict | None:
"""Via the shared chokepoint :func:`hermes_constants.resolve_reasoning_config` (per-model override >
global ``agent.reasoning_effort``; YAML False = disabled).
Closes #21256.
"""
from hermes_constants import resolve_reasoning_config
return resolve_reasoning_config(_load_cfg(), model)
def _load_service_tier() -> str | None:
from agent.fast_mode import parse_service_tier
return parse_service_tier((_load_cfg().get("agent") or {}).get("service_tier", ""))
def _load_provider_routing() -> dict:
"""OpenRouter ``provider_routing`` prefs (gateway/CLI parity — without them OpenRouter picks an effectively random provider)."""
with contextlib.suppress(Exception):
return _load_cfg().get("provider_routing", {}) or {}
return {}
def _load_show_reasoning() -> bool:
# 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:
"""``display.memory_notifications`` (``off`` / ``on`` default / ``verbose``; bool normalized) — gates the
"💾 Self-improvement review" summary (gateway/CLI parity)."""
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 _TOOL_PROGRESS_MODES:
return env
raw = _display_cfg().get("tool_progress", "all")
if isinstance(raw, bool):
return "all" if raw else "off"
mode = str(raw or "all").strip().lower()
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 (both off ``_HERMES_CORE_TOOLS``; this is the one gate).
``platform`` is the SESSION's source, never a process env var: the desktop may drive a URL/cloud
backend where ``HERMES_DESKTOP`` is unset (AGENTS.md surface rule)."""
from toolsets import CLIENT_SURFACE_TOOLSETS
return set(CLIENT_SURFACE_TOOLSETS) if platform == "desktop" else {"project"}
def _with_session_toolsets(selection, platform: str | None) -> list[str]:
"""*selection* plus what the session carries whatever its config says (the client surface's
toolsets when *platform* is given; the ones its PROFILE's role reserves, from the backend-written
profile.yaml under the session's home override), minus toolsets reserved for another role.
The fold-in happens after ``_get_platform_tools`` already subtracted ``agent.disabled_toolsets``,
so the same subtraction is applied to the fold-in itself — otherwise ``disabled_toolsets:
[project]`` is a no-op on desktop/TUI, the only surfaces where the client toolsets exist
(#54433). ``desktop_ui`` is kept regardless: it is the client's own control surface, not a
model toolset."""
from toolsets import profile_role_toolsets
granted, denied = profile_role_toolsets()
surface = _gui_surface_toolsets(platform) if platform is not None else set()
kept = [name for name in selection if name not in denied]
fold_in = (surface | granted) - set(kept)
disabled = set(_load_disabled_toolsets() or [])
if disabled:
fold_in -= disabled - {"desktop_ui"}
return [*kept, *sorted(fold_in)]
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)]
unresolved = [name for name in explicit if name not in built_in]
if unresolved:
try:
from hermes_cli.plugins import discover_plugins
discover_plugins()
plugin_valid = [name for name in unresolved if validate_toolset(name)]
except Exception:
plugin_valid = []
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):
if ignored := [name for name in explicit if name not in {"all", "*"}]:
_tui_notice(f"[tui] HERMES_TUI_TOOLSETS=all enables every toolset; ignoring additional entries: {', '.join(ignored)}")
return None
if not unresolved:
return built_in
try: # (enabled, disabled) MCP server names from raw config; both empty on any failure
from hermes_cli.config import read_raw_config
from tools.mcp_tool_common import mcp_server_enabled
raw_cfg = read_raw_config()
mcp_servers = raw_cfg.get("mcp_servers") if isinstance(raw_cfg.get("mcp_servers"), dict) else {}
mcp_names, mcp_disabled = set(), set()
for name, server_cfg in mcp_servers.items():
if isinstance(server_cfg, dict):
on = mcp_server_enabled(server_cfg)
(mcp_names if on else mcp_disabled).add(str(name))
except Exception:
mcp_names, mcp_disabled = set(), set()
mcp_valid = [name for name in unresolved if name in mcp_names]
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:
_tui_notice(f"[tui] ignoring unknown HERMES_TUI_TOOLSETS entries: {', '.join(unknown)}")
if disabled:
_tui_notice("[tui] ignoring disabled MCP servers in HERMES_TUI_TOOLSETS "
f"(set enabled: true in config.yaml to use): {', '.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 session (None = all): an explicit HERMES_TUI_TOOLSETS pin; else the
coding posture (coding_context collapses to coding toolset + enabled MCP servers in a code workspace);
else the configured CLI toolsets. Client-surface toolsets fold in here — only this surface 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
if not explicit:
with contextlib.suppress(Exception):
from agent.coding_context import coding_selection
selection = coding_selection(platform=session_platform)
if selection is not None:
return sorted(_with_session_toolsets(selection, session_platform))
try:
from toolsets import validate_toolset
except Exception:
validate_toolset = None
if explicit and validate_toolset is not None:
resolved = _resolve_explicit_toolsets(explicit, validate_toolset)
if resolved is not False:
# An operator pin replaces the surface fold-in but never strips the profile's own role toolsets.
return resolved if resolved is None else _with_session_toolsets(resolved, None)
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); the config-editing variant would silently drop MCP tools from the TUI.
# Passing ``False`` here is the config-editing variant — used when we need to persist a toolset list
# without baking in implicit MCP defaults. Using the wrong variant at agent creation time makes MCP
# tools silently missing from the TUI. See PR #3252 for the original design split.
enabled = _get_platform_tools(cfg, "cli", include_default_mcp_servers=True)
if fallback_notice is not None:
_tui_notice(fallback_notice)
return sorted(_with_session_toolsets(enabled, session_platform)) if enabled else None
except Exception:
if fallback_notice is not None:
_tui_notice("[tui] no valid HERMES_TUI_TOOLSETS entries and configured CLI toolsets could not be loaded; enabling all toolsets")
return None
def _load_disabled_toolsets() -> list[str] | None:
"""``agent.disabled_toolsets`` from config.yaml, or ``None``.
The classic CLI (``cli_init_mixin``) and the messaging gateway both forward this list to
AIAgent, where ``get_tool_definitions`` strips the named toolsets even out of composite
defaults like ``hermes-cli`` (#17309). The desktop/TUI gateway historically dropped it, so
e.g. ``disabled_toolsets: [browser]`` silently had no effect on Desktop — the only consumer
was ``_get_platform_tools``'s name-level subtraction, which can't reach inside a composite
default toolset (#44499).
"""
try:
from agent.skill_utils import parse_config_string_list
from hermes_cli.config import load_config
agent_cfg = load_config().get("agent") or {}
disabled = parse_config_string_list(agent_cfg.get("disabled_toolsets"))
return [str(ts) for ts in disabled] or None
except Exception:
return None
def _session_tool_progress_mode(sid: str) -> str:
return str(_sessions.get(sid, {}).get("tool_progress_mode", "all") or "all")
def _session_verbose(sid: str) -> bool:
return _session_tool_progress_mode(sid) == "verbose"
def _session_show_reasoning(sid: str) -> bool:
"""Session display flag. Missing means the config default, not hidden."""
session = _sessions.get(sid) or {}
if "show_reasoning" in session:
return bool(session["show_reasoning"])
return _load_show_reasoning()
def _tool_progress_enabled(sid: str) -> bool:
return _session_tool_progress_mode(sid) != "off"
# Names whose lifecycle a UI renders as a card even with display.tool_progress off. `isCardTool` /
# `isFileEditTool` in apps/desktop/src/lib/tool-render-class.ts must stay in sync with this
# set (test_gateway_lifecycle_set_covers_desktop_card_tools pins the direction that matters).
_TOOL_LIFECYCLE_UI_TOOLS = frozenset({
"clarify", "manage_connections", "setup_mcp",
"image_generate", "manage_catalog", "delegate_task",
# File edits are the turn's deliverable — the diff card the user reviews.
"edit_file", "patch", "write_file",
})
def _tool_lifecycle_required_for_ui(name: str) -> bool:
"""Interactive UI / card surfaces, not optional chrome.
Desktop renders these from the tool-call part itself, so suppressing the
lifecycle hides the turn's deliverable entirely (`isCardTool` /
`isFileEditTool` in apps/desktop/src/lib/tool-render-class.ts must stay in
sync with `_TOOL_LIFECYCLE_UI_TOOLS`): clarify / connection cards are
consent surfaces, image_generate / manage_catalog / delegate_task draw the
thing the user asked for, and file edits are the diff the user reviews.
The start and complete guards both consult this set, so a card's
`tool.complete` can never arrive without its `tool.start`.
"""
return name in _TOOL_LIFECYCLE_UI_TOOLS
def _restart_slash_worker(sid: str, session: dict):
# Close the slash-worker subprocess as part of finalize itself, not just in the callers.
# Defense-in-depth: every session-end path goes through _finalize_session (it's the single
# ``_finalized``-guarded chokepoint), so folding worker cleanup in here means a future code path that
# calls _finalize_session directly — without the surrounding _teardown_session / _shutdown_sessions
# worker.close() — can't reintroduce the #38095 leak. Idempotent: _SlashWorker.close() is
# poll()-guarded, so the explicit close() still in those callers is harmless.
worker = session.get("slash_worker")
if worker is None:
return # never spawned one; spawning here would fork the per-worker MCP fleet for nothing
with contextlib.suppress(Exception):
worker.close()
try:
new_worker = _SlashWorker(session["session_key"], getattr(session.get("agent"), "model", _resolve_model()),
profile_home=session.get("profile_home"),
provider=getattr(session.get("agent"), "provider", None) or None)
except Exception:
session["slash_worker"] = None
return
# Store-iff-still-mapped: the post-turn restart races a close_on_disconnect reap (a bare store would orphan it).
_attach_worker(sid, session, new_worker)
def _get_usage(agent) -> dict:
g = lambda k, fb=None: getattr(agent, k, 0) or (getattr(agent, fb, 0) if fb else 0)
usage = {
"model": getattr(agent, "model", "") or "",
"input": g("session_input_tokens", "session_prompt_tokens"),
"output": g("session_output_tokens", "session_completion_tokens"),
"reasoning": g("session_reasoning_tokens"), "prompt": g("session_prompt_tokens"),
"completion": g("session_completion_tokens"), "total": g("session_total_tokens"),
"calls": g("session_api_calls"),
}
comp = getattr(agent, "context_compressor", None)
if comp:
from agent.context_breakdown import context_usage_fields
usage.update(context_usage_fields(comp))
usage["compressions"] = getattr(comp, "compression_count", 0) or 0
# Cache-hit ratio + rolling latency/tps (CLI status-bar parity). 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):
# Mirrors the classic CLI bar (cli.py _get_status_bar_snapshot / PR #98250): hit =
# session_cache_read_tokens / session_prompt_tokens (CanonicalUsage.prompt_tokens = input +
# cache_read + cache_write) latency/tps read the deque(maxlen=10) history maintained per API call in
# agent/conversation_loop.py.
_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)))
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 [])
if _n := min(len(_lhist), len(_ohist)):
_total_lat = sum(_lhist[-_n:])
_avg_vel = (sum(_ohist[-_n:]) / _total_lat) if _total_lat > 0 else None
for _key, _val in (("avg_latency_s", _total_lat / _n), ("avg_tps", _avg_vel)):
if _val is not None and _val == _val and 0 < _val < 1e6: # guard NaN/negative/absurd provider timings
usage[_key] = round(float(_val), 1)
# 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, 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()
if spent is not None:
usage["dev_credits_spent_micros"] = int(spent)
return usage
def _probe_credentials(agent) -> str:
"""Warning or '' (``no-key-required`` is a valid sentinel for keyless custom providers)."""
with contextlib.suppress(Exception):
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:
"""Warn on bare YAML keys (`agent:` → None, silently dropping nested settings) and an unknown ``display.personality``."""
if not isinstance(cfg, dict):
return ""
warnings: list[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}. 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()
if personality and personality not in {"default", "none", "neutral"}:
with contextlib.suppress(Exception):
from hermes_cli.personality import available_personalities
if personality not in available_personalities(cfg):
warnings.append(f"`display.personality: {personality}` does not match any built-in or "
"`agent.personalities` entry; personality overlay will be skipped.")
return " ".join(warnings).strip()
def _current_profile_name() -> str:
with contextlib.suppress(Exception):
from hermes_cli.profiles import get_active_profile_name
return get_active_profile_name() or "default"
return "default"
# Monotonic GUI<->backend contract version: the desktop refuses a backend reporting less (or none) with a
# one-click "update to align" prompt. The desktop also warns in the reverse direction: a backend reporting
# MORE than the GUI's required value means the GUI build predates this backend (e.g. a long-running app
# across a backend update) and should be updated. Bump whenever the desktop's backend contract changes.
# v2 file.attach; v3 approvals.mode RPCs + session.info reconciliation; v4 session.create fast=false = explicit normal tier;
# v5 ws_max_size >16 MiB file.attach frames; v6 plugins.manage rows carry the canonical registry key;
# v7 blocking prompts are JSON-RPC server->client requests (`srq-<n>` frames, `open_requests` replay) — a v6
# backend still emits `<kind>.request` notifications the renderer no longer listens for.
DESKTOP_BACKEND_CONTRACT = 8
def _session_usage_snapshot(session: dict | None) -> dict:
sess = session or {}
mirror_usage = _metadata_mirror(session).get("usage")
if sess.get("agent") is not None and not (sess.get("_compute_host_active") and isinstance(mirror_usage, dict)):
return _get_usage(sess["agent"])
return dict(mirror_usage) if isinstance(mirror_usage, dict) else {}
def _project_info_for_cwd(cwd: str) -> dict | None:
"""The first-class Project owning ``cwd`` (per-profile projects.db) so TUI status, desktop status bar and
``/status`` name the workspace identically. Only explicit named projects resolve."""
if not str(cwd or "").strip():
return None
try:
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as conn:
project = pdb.project_for_path(conn, cwd)
return None if project is None else {
"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 (desktop keeps the elapsed timer across switches)."""
inflight = (session or {}).get("inflight_turn")
return float(inflight["started_at"]) if isinstance(inflight, dict) and inflight.get("started_at") else None
def _live_session_identity(session: dict) -> tuple[str, str]:
"""``(model, provider)`` the live session actually runs — the same precedence ``_session_info`` reports:
a switch queued mid-turn, the metadata mirror, the built agent, the composer override a deferred record
carries. The profile default is the LAST resort, never the answer for a chat that made its own pick."""
pending = session.get("pending_model_switch") or {}
mirror = _metadata_mirror(session)
agent = session.get("agent")
override = session.get("model_override") or {}
model = (str(pending.get("display_model") or "").strip() or mirror.get("model")
or getattr(agent, "model", "") or override.get("model") or _session_default_model(session))
provider = (str(pending.get("display_provider") or "").strip() or mirror.get("provider")
or getattr(agent, "provider", "") or override.get("provider") or "")
return str(model), str(provider or "")
def _fast_tier_applies(agent, model: str, provider: str, *, route_known: bool, tier: str | None = None) -> bool:
"""Whether a priority tier reaches this session's route. Every request builder asks the same gate, so a
profile-wide ``service_tier: fast`` sends nothing to a local server or a proxy, and the session must not
report Fast there either. ``route_known`` is False while a switch is pending: the agent's base URL still
belongs to the old route."""
from hermes_cli.models import resolve_fast_mode_overrides
base_url = None
if route_known and agent is not None:
if getattr(agent, "api_mode", None) == "anthropic_messages":
base_url = getattr(agent, "_anthropic_base_url", None)
base_url = base_url or getattr(agent, "base_url", None)
try:
return resolve_fast_mode_overrides(model, provider=provider or None, base_url=base_url, tier=tier) is not None
except Exception:
return False
def _session_info(agent, session: dict | None = None) -> dict:
if session is None:
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(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):
# Disabled must differ from unset ("" = provider default) or the desktop loses "thinking off" after turn 1.
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 ""
# yolo ORs the same three sources check_all_command_guards() does (approvals.mode=off, the process
# --yolo env, the per-session flag): the session flag alone would show "off" while config auto-approves.
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, approval_mode = False, "manual"
# A switch queued mid-turn applies at next turn start (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()
provider = mirror.get("provider", getattr(agent, "provider", ""))
if provider == "custom" and "provider" not in mirror and agent is not None:
# Clients reuse this identity for new chats without carrying the endpoint or key.
# Broadcast/resume callers need not be bound to this session's profile.
with _profile_build_scope(sess.get("profile_home") or _hermes_home):
provider = _runtime_model_config(agent).get("provider", provider)
model = pending_model or mirror.get("model", getattr(agent, "model", ""))
# The level the route's entry clamp actually sends (== reasoning_effort when verbatim), so the
# Desktop can say "ultra sends max on this route" like `/reasoning` does instead of presenting a
# Hermes-internal step (#61634) as a wire level the route does not have.
reasoning_effort_wire = ""
if reasoning_effort and reasoning_effort != "none":
reasoning_effort_wire = str(clamp_effort(reasoning_effort, route_supported_efforts(
pending_provider or provider, model, getattr(agent, "api_mode", None))) or "")
info: dict = {
"model": model,
"provider": pending_provider or provider,
"reasoning_effort": reasoning_effort, "reasoning_effort_wire": reasoning_effort_wire,
"service_tier": service_tier,
"fast": service_tier in STATIC_TIERS and _fast_tier_applies(agent, model, pending_provider or provider,
route_known=not pending_provider, tier=service_tier),
"yolo": yolo, "approval_mode": approval_mode,
"tools": dict(mirror.get("tools") or {}) if isinstance(mirror.get("tools"), dict) else {},
"skills": dict(mirror.get("skills") or {}) if isinstance(mirror.get("skills"), dict) else {},
"cwd": cwd, "branch": git_probe.branch(cwd), "project": _project_info_for_cwd(cwd),
"terminal_backend": _effective_terminal_backend(), "personality": str(personality or ""),
"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": "", "release_date": "", "update_behind": None, "update_command": "",
"usage": _session_usage_snapshot(session),
"profile_name": profile_name_for_home(sess.get("profile_home")) or _current_profile_name(),
}
with contextlib.suppress(Exception):
from hermes_cli import __release_date__
from hermes_cli.version_info import get_version_info
info.update(version=get_version_info().base_version, release_date=__release_date__)
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
info["tools"] = {}
for t in getattr(agent, "tools", []) or []:
name = t["function"]["name"]
info["tools"].setdefault(get_toolset_for_tool(name) or "other", []).append(name)
with contextlib.suppress(Exception):
from hermes_cli.banner import get_available_skills
info["skills"] = get_available_skills()
info["mcp_servers"] = []
with contextlib.suppress(Exception):
from tools.mcp_tool_discovery import get_mcp_status
info["mcp_servers"] = get_mcp_status()
with contextlib.suppress(Exception):
info["system_prompt"] = (
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
from hermes_cli.config import recommended_update_command
# Two assignments (not one info.update): if recommended_update_command() raises,
# update_behind must still be reported, as on main.
info["update_behind"] = get_update_result(timeout=0.5)
info["update_command"] = recommended_update_command()
if live_agent and (warn := _probe_credentials(agent)):
info["credential_warning"] = warn
return info
def _tool_ctx(name: str, args: dict) -> str:
"""Argument preview for a tool row — never a phrased label: clients own their phrasing, so
``build_tool_label`` here would stutter ("Running Running …") and leak into the desktop's ``args.context``."""
with contextlib.suppress(Exception):
from agent.display import build_tool_preview
return build_tool_preview(name, args, max_len=80) or ""
return ""
def _emit_session_info_for_session(sid: str, session: dict) -> None:
agent = session.get("agent")
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:
"""Re-emit ``session.info`` to every live session — for approvals-config writers that bypass the
self-re-emitting ``config.set`` RPC. Only THIS process; a spawned 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)
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's tools would be missing all session): a daemon joins discovery,
rebuilds like ``/reload-mcp`` and re-emits ``session.info``. Only pre-first-turn (nothing cached to
invalidate); afterwards late tools need an explicit, consent-gated ``/reload-mcp``."""
try:
from tui_gateway.entry import mcp_discovery_in_flight, join_mcp_discovery
except Exception:
return
if not mcp_discovery_in_flight():
return
def _wait_then_refresh() -> None:
if not join_mcp_discovery(timeout=30.0): # a server still not connected after this is genuinely slow/dead
return
with _sessions_lock:
session = _sessions.get(sid)
if session is None or session.get("agent") is not agent:
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_agent 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)
return
if not added:
return # discovery added nothing → don't churn the client
info = _session_info(agent, session)
_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):
runtime: dict
selected_model: str | None
used_fallback: bool
def _resolve_runtime_with_fallback(resolve_kwargs: dict | None = None) -> _RuntimeFallbackResolution:
"""Resolve the primary runtime or one complete provider/model fallback. Provider-only fallback entries
are skipped so the unavailable primary model can never leak into a different runtime."""
from hermes_cli.auth import AuthError
from hermes_cli.runtime_provider import resolve_runtime_provider
try:
return _RuntimeFallbackResolution(resolve_runtime_provider(**(resolve_kwargs or {})), None, False)
except AuthError as primary_exc:
for entry in _load_fallback_model() or []:
fb_provider = str(entry.get("provider") or "").strip() if isinstance(entry, dict) else ""
fb_model = str(entry.get("model") or "").strip() if isinstance(entry, dict) else ""
if not fb_provider or not fb_model:
continue
try:
from hermes_cli.fallback_config import effective_runtime_provider, resolve_entry_api_key
fb_kwargs: dict = {"requested": fb_provider, "target_model": fb_model,
**({"explicit_base_url": entry["base_url"]} if entry.get("base_url") else {})}
if fb_api_key := resolve_entry_api_key(entry):
fb_kwargs["explicit_api_key"] = fb_api_key
runtime = resolve_runtime_provider(**fb_kwargs)
# Named custom entries resolve to the bare "custom" billing class; keep the configured
# identity so the session/UI shows the provider name, matching the manual-switch path (#98739).
runtime["provider"] = effective_runtime_provider(entry, runtime)
from hermes_cli.auth import primary_failure_wording
logging.getLogger(__name__).warning(
"Primary %s (%s), falling back to %s model %s",
primary_failure_wording(primary_exc)[0], primary_exc, fb_provider, fb_model)
return _RuntimeFallbackResolution(runtime, fb_model, True)
except Exception:
continue
raise
def _resolve_agent_model_runtime(model_override, provider_override) -> tuple[str, dict]:
"""(model, runtime) for a new agent; a per-session override (/model switch or a resumed row's persisted
runtime) wins over global config/env. Older rows stored the resolved provider "custom" (no named entry
matches) — recover the identity from the persisted base_url or the rebuild fails "No LLM provider
configured". Persisted base_url/api_key/api_mode are honored only for the original runtime, never a fallback."""
if isinstance(model_override, dict) and model_override.get("model"):
model = str(model_override.get("model") or "")
requested_provider = model_override.get("provider") or provider_override or None
override_base_url = model_override.get("base_url")
resolve_kwargs = {}
if str(requested_provider or "").strip().lower() == "custom":
from hermes_cli.runtime_provider import canonical_custom_identity
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 base_url to the direct-alias branch so pool/env credentials resolve.
resolve_kwargs["explicit_base_url"] = override_base_url
resolve_kwargs.update(requested=requested_provider, target_model=model or None)
overrides = {k: model_override.get(k) for k in ("base_url", "api_key", "api_mode")}
else:
model, requested_provider = _resolve_startup_runtime()
if isinstance(model_override, str) and model_override:
model = model_override
if provider_override:
requested_provider = provider_override
resolve_kwargs = {"requested": requested_provider, "target_model": model or None}
overrides = {}
resolution = _resolve_runtime_with_fallback(resolve_kwargs)
if resolution.used_fallback:
if not resolution.selected_model:
raise RuntimeError("Auth fallback resolved without a model")
# Same pre-agent switch the messaging gateway surfaces (#74349); _make_agent pops it onto the
# agent's one-shot notice so the TUI/Desktop user sees which provider actually answered.
from hermes_cli.fallback_config import pre_agent_fallback_notice
# requested_provider=None means resolve_runtime_provider read the persisted config provider;
# ``model: <id>`` (string shorthand) names no provider.
cfg_model = _load_cfg().get("model")
primary_provider = requested_provider or (cfg_model.get("provider") if isinstance(cfg_model, dict) else None)
resolution.runtime["_fallback_notice"] = pre_agent_fallback_notice(
primary_provider, model, resolution.runtime.get("provider"), resolution.selected_model)
return resolution.selected_model, resolution.runtime
if resolution.runtime.get("source") == "local-runtime":
# Live supervisor beat any persisted loopback URL for this identity.
overrides.pop("base_url", None)
resolution.runtime.update({k: v for k, v in overrides.items() if v})
if any(overrides.values()):
_rederive_per_model_route(model, resolution.runtime)
return model, resolution.runtime
def _rederive_per_model_route(model: str, runtime: dict) -> None:
"""A row's persisted api_mode/base_url were written for whichever model the session last ran. Providers
that pick the wire per model (OpenCode Zen/Go, Copilot, Nous) must re-derive both from the target model,
or a resumed opencode-go session keeps a MiniMax-era anthropic_messages route (and its /v1-stripped or
other-family relay URL) for a chat_completions model like deepseek-v4-flash-vision-exp (#96066)."""
from hermes_cli.model_switch import model_derived_api_mode
from hermes_cli.models import normalize_opencode_base_url
provider = str(runtime.get("requested_provider") or runtime.get("provider") or "")
api_mode = model_derived_api_mode(provider, model)
if api_mode is None:
return
runtime["api_mode"] = api_mode
runtime["base_url"] = normalize_opencode_base_url(provider, api_mode, runtime.get("base_url"))
def _startup_system_prompt(cfg: dict, task_id: str) -> str:
"""Config ephemeral system prompt + 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, format_missing_skills
skills_prompt, loaded_skills, missing_skills = build_preloaded_skills_prompt(startup_skills, task_id=task_id)
if missing_skills:
if not loaded_skills:
raise ValueError(format_missing_skills(missing_skills))
logger.warning("Skipping %s. Continuing with: %s. List available skills with `hermes skills list`.",
format_missing_skills(missing_skills), ", ".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 _transport_auth_user_id(transport) -> str | None:
"""``<provider>:<user id>`` the WS-upgrade credential authenticated for ``transport``, or None for the legacy
token, stdio and the PTY child's server-internal credential. The prefix keeps a basic-auth ``alice`` and an
OIDC ``alice`` apart."""
identity = getattr(transport, "auth_identity", None)
if _methods_browser_control._is_authenticated_identity(identity):
return f"{str(identity['provider']).strip()}:{str(identity['user_id']).strip()}"
return None
def _session_auth_user_id(session: dict | None) -> str | None:
"""The login ``session`` was created under, stamped on the record as ``auth_user_id``. A second window turns
the transport slot into a FanoutTransport, which names no login, so only a record without the slot reads
its transport."""
session = session or {}
if "auth_user_id" in session:
return session["auth_user_id"]
return _transport_auth_user_id(session.get("transport"))
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,
cwd_override: str | None = None, auth_user_id: str | 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)
if synthetic is not None:
return synthetic
from run_agent import AIAgent
# MCP discovery runs in a daemon thread (a dead server can't freeze the shell); the agent snapshots its tool
# list once, so briefly wait for in-flight discovery. Dashboard /api/ws uses mcp_startup; TUI stdio uses entry.
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()
# Load hooks alongside the same profile config used to construct this agent.
from agent.shell_hooks import register_from_config
register_from_config(cfg)
system_prompt = _startup_system_prompt(cfg, session_id or key)
model, runtime = _resolve_agent_model_runtime(model_override, provider_override)
fallback_notice = runtime.pop("_fallback_notice", None)
_pr = _load_provider_routing()
platform = _resolve_agent_platform(platform_override)
ignore_rules = is_truthy_value(os.environ.get("HERMES_IGNORE_RULES"))
with _sessions_lock:
session = _sessions.get(sid)
agent = AIAgent(
model=model, max_iterations=_cfg_max_turns(cfg, 500), provider=runtime.get("provider"),
requested_provider=runtime.get("requested_provider"),
base_url=runtime.get("base_url"), api_key=runtime.get("api_key"), api_mode=runtime.get("api_mode"),
acp_command=runtime.get("command"), acp_args=runtime.get("args"),
credential_pool=runtime.get("credential_pool"), 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 ""))),
service_tier=service_tier_override if service_tier_override is not None else _load_service_tier(),
enabled_toolsets=_load_enabled_toolsets(platform),
disabled_toolsets=_load_disabled_toolsets(),
# OpenRouter provider_routing prefs (gateway + CLI parity).
providers_allowed=_pr.get("only"), providers_ignored=_pr.get("ignore"), providers_order=_pr.get("order"),
provider_sort=_pr.get("sort"), provider_require_parameters=_pr.get("require_parameters", False),
provider_data_collection=_pr.get("data_collection"), platform=platform, session_id=session_id or key,
cwd=cwd_override,
# The dashboard login identity reaches memory providers as the runtime user, like a gateway user id.
# Builds that run before the record exists (branch, eager resume, compute host) pass it explicitly.
user_id=auth_user_id if auth_user_id is not None else _session_auth_user_id(session),
session_db=session_db if session_db is not None else _get_db(), ephemeral_system_prompt=system_prompt or None,
checkpoints_enabled=_resolve_checkpoints_enabled(cfg),
pass_session_id=is_truthy_value(os.environ.get("HERMES_TUI_PASS_SESSION_ID")),
skip_context_files=ignore_rules, skip_memory=ignore_rules, fallback_model=_load_fallback_model(),
# The resolved provider's request body (a custom entry's extra_body), as the CLI/cron/gateway pass it.
request_overrides=runtime.get("request_overrides"),
prefill_messages=_load_prefill_messages() or None, **_agent_cbs(sid))
if context_cwd_is_launch_artifact is None:
context_cwd_is_launch_artifact = _context_cwd_is_launch_artifact(session)
agent._context_cwd_is_launch_artifact = bool(context_cwd_is_launch_artifact)
if fallback_notice:
# Emitted once on the first successful reply via _emit_pending_fallback_notice -> status_callback.
agent._pending_fallback_notice = fallback_notice
return agent
def _hydrate_session_cwd(sid: str, key: str, session_db, profile_home: str | None) -> None:
"""Adopt the stored row's cwd and fill missing Git metadata, or persist a fresh cwd."""
owns_db, db = False, session_db
if db is None and not profile_home:
db = _get_db()
elif db is None:
try:
db = _open_profile_session_db(profile_home)
owns_db = True
except Exception:
# FAIL CLOSED (as the deferred-build bind): a named-profile session must never touch the launch
# state.db — skip 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)
try:
if db is not None:
row = db.get_session(key) if hasattr(db, "get_session") else None
if row and _resumable_stored_cwd(row.get("cwd"), profile_home):
# An ssh session's stored cwd is its workspace: explicit, so the remote terminal uses it instead of
# the profile's ~. Other backends keep main's semantics (resolved outside the sessions lock: I/O).
remote = _cwd_is_remote(profile_home)
with _sessions_lock:
if sid in _sessions:
_sessions[sid]["cwd"] = row["cwd"]
if remote:
_sessions[sid]["explicit_cwd"] = True
# Lazy desktop rows already carry their explicitly chosen cwd, so they never reach the fresh-cwd
# branch below. Claim a generation before probing to keep an older probe from overwriting a later
# workspace move; complete rows do not need another probe on every resume.
if (not row.get("git_branch") or not row.get("git_repo_root")) and hasattr(
db, "update_session_cwd"
):
try:
_persist_session_cwd_and_schedule_git_meta(_sessions[sid], row["cwd"], db=db)
except Exception:
logger.debug("failed to enrich resumed session git metadata", exc_info=True)
elif not (row and row.get("cwd")) and hasattr(db, "update_session_cwd") and not _is_remote_launch_cwd(
_sessions.get(sid)
):
# A stored cwd that was set aside (Hermes's own host tree) stays as stored: only an empty row is filled.
try:
_persist_session_cwd_and_schedule_git_meta(_sessions[sid], _sessions[sid]["cwd"], db=db)
except Exception:
logger.debug("failed to persist resumed session cwd", exc_info=True)
finally:
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); SessionBranch copies the parent's (same state.db).
"profile_home": profile_home,
# In-session /model switch, honored on rebuild (/new, resume) — never leaks to siblings via 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,
"auth_user_id": _transport_auth_user_id(current_transport()),
}
_session_todo_state(_sessions[sid])
_hydrate_session_cwd(sid, key, session_db, profile_home)
_register_session_cwd(_sessions[sid])
_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)
def _new_session_key() -> str:
return new_session_id()
# Server-minted session keys are ``%Y%m%d_%H%M%S_`` + 6 hex chars (see
# ``_new_session_key``). session.resume uses this shape as the fail-closed gate
# for materializing a row for a minted-but-never-persisted key: only keys the
# server itself could have produced qualify — arbitrary strings and 8-hex
# runtime session ids (``uuid4().hex[:8]``) are rejected.
_MINTED_SESSION_KEY_RE = re.compile(r"^\d{8}_\d{6}_[0-9a-f]{6}$")
def _is_server_minted_key(value: str | None) -> bool:
return bool(value and _MINTED_SESSION_KEY_RE.fullmatch(value))
def _any_live_session_claims_key(target: str) -> bool:
"""True if any live registry record claims this stored key (any profile).
Fail-closed gate for minted-key materialization: a key claimed by a live
session — even one scoped to a different profile — is owned, so an
unscoped resume must not mint a phantom row in the launch store (#93296
cross-profile rule: routing guesses are forbidden).
"""
for record in list(_sessions.values()):
if not isinstance(record, dict):
continue
if str(record.get("session_key") or "") == target:
return True
return False
def _with_checkpoints(session, fn):
return fn(session["agent"]._checkpoint_mgr, _session_cwd(session))
# ── 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 (session.create's shape); tools/skills land with the deferred build."""
return {
"cwd": cwd, "branch": git_probe.branch(cwd), "project": _project_info_for_cwd(cwd),
"model": model or _session_default_model({"profile_home": _profile_home(profile)}),
"tools": {}, "skills": {}, "lazy": True,
"desktop_contract": DESKTOP_BACKEND_CONTRACT, "profile_name": _response_profile_name(profile),
**({"provider": provider} if provider else {}),
}
def _deferred_session_record(
session_key: str, *, cols: int, cwd: str, history: list, lease, source: str = "tui",
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:
"""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": [],
"close_on_disconnect": close_on_disconnect, "active_session_lease": lease, "cols": cols,
"created_at": now, "cwd": cwd, "display_history_prefix": display_history_prefix or [],
"edit_snapshots": {}, "explicit_cwd": bool(explicit_cwd), "history": history,
"history_lock": threading.Lock(), "history_version": 0, "image_counter": 0,
"inflight_turn": None, "last_active": now, "lazy": lazy, "model_override": model_override,
"pending_title": None,
"profile_home": str(profile_home) if profile_home is not None else None,
"resume_runtime_overrides": resume_runtime_overrides, "resume_session_id": session_key,
"running": False, "session_key": session_key, "show_reasoning": _load_show_reasoning(),
"slash_worker": None, "source": source, "tool_progress_mode": _load_tool_progress_mode(),
"tool_started_at": {}, "todo_state": todo_state,
"transport": current_transport() or _stdio_transport,
"auth_user_id": _transport_auth_user_id(current_transport()),
}
_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; a record with no
``profile_home`` is the launch profile's). ``_ANY_PROFILE`` disables the check."""
if profile_home is _ANY_PROFILE:
return True
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."""
# A live runtime of the same stored id under ANOTHER profile is not a winner to reuse.
# See #100029.
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 reap is cancelled by the guarded reuse (_reattach_refusal), not here: a rejected
# reattach must leave an in-flight orphan interrupt polling.
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 (storm).
_cancel_ws_orphan_reap(sid)
stale = _claim_parked_runtimes(session_key, keep_sid=sid, profile_home=profile_home)
_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: cancel their orphan-reap
Timer and pop them here (under the caller's _session_resume_lock); the caller finalizes after release."""
stale: list[tuple[str, dict]] = []
with _sessions_lock:
candidates = [
(old_sid, old) for old_sid, old in list(_sessions.items())
if old_sid != keep_sid and not old.get("_finalized")
and _session_lookup_key(old, fallback=old_sid) == session_key
and _live_profile_matches(old, profile_home) and old.get("transport") is _detached_ws_transport]
for old_sid, _old in candidates:
_cancel_ws_orphan_reap(old_sid)
if (popped := _pop_session_by_id(old_sid)) is not None:
stale.append((old_sid, popped))
return stale
def _finalize_superseded_runtimes(stale: list[tuple[str, dict]]) -> None:
"""end_reason ``superseded_by_resume`` is deliberately NOT in _RECLAIM_END_REASONS (no ``session.reclaimed``
broadcast → no reap->broadcast->resume loop) but IN _RECOVERABLE_END_REASONS (Bot Chat resurrection applies)."""
for old_sid, popped in stale:
try:
_teardown_popped_session(popped, end_reason="superseded_by_resume")
except Exception:
logger.exception("superseded runtime teardown failed sid=%s", old_sid)
def _schedule_agent_build(sid: str, delay: float = 0.05) -> None:
"""Pre-warm a deferred session's agent off the response path (session.create + cold resume; _sess() also builds on demand)."""
def _run():
if (session := _sessions.get(sid)) is not None:
_start_agent_build(sid, session)
timer = threading.Timer(delay, _run)
timer.daemon = True
timer.start()
def _load_resume_transcript(db, stored_id: str, *, model_history_only: bool = False) -> tuple[list, list, list]:
"""(raw_history, display_history, ancestor_prefix) for a cold resume. The full lineage is materialized
only while it fits sessions.max_resume_messages (the transcript is REST-paginated), else the tip alone."""
from hermes_state import SessionResumeTooLargeError
if model_history_only:
raw_history = db.get_messages_as_conversation(
stored_id, repair_alternation=True, include_row_ids=True)
return raw_history, [], []
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,
model_history_only: bool = False) -> None:
"""Load a cold resume's transcript off the JSON-RPC response path."""
def _run() -> None:
session = _sessions.get(sid)
try:
if session is None:
return
_emit("session.resume_progress", sid, {"phase": "history", "status": "loading"})
# Read-only mount (#85303): hydration is a read; an ended row stays ended —
# the first real turn (prompt.submit) reopens it. But a restart discarded the
# busy-queue, so retire never-drained accept rows (#125577) before the read —
# with #128508 the reopen (and its retire) no longer runs on this path.
# Best-effort like the resume guard: a handle without the method skips it.
retire = getattr(db, "retire_undrained_queue_rows", None)
if callable(retire):
retire(stored_id)
raw_history, display_history, prefix = _load_resume_transcript(
db, stored_id, model_history_only=model_history_only)
# Display keeps the full transcript; the model-fed history uses the
# same canonicalization as gateway resume and the send path.
history = canonicalize_replay_history(raw_history)
if _sessions.get(sid) is not session:
return
with session["history_lock"]:
session.update(history=history, display_history_prefix=prefix, resume_hydrating=False)
if not model_history_only:
session["resume_message_count"] = len(display_history)
# Deferred resumes answered before the transcript existed; cache the derived todo snapshot now.
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
session["resume_history_ready"].set()
_emit("session.resume_progress", sid,
{"message_count": session["resume_message_count"], "phase": "history", "status": "complete"})
_maybe_schedule_auto_continue(sid, session, stored_id)
_start_agent_build(sid, session)
except Exception as exc:
if _sessions.get(sid) is not session:
return
message = resume_failed_message(exc)
session.update(resume_hydrating=False, resume_history_error=message, agent_error=message)
session["resume_history_ready"].set()
session["agent_ready"].set()
_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
if (lease := (discarded or {}).get("active_session_lease")) is not None:
lease.release()
finally:
if close_db and hasattr(db, "close"):
try:
db.close()
except Exception:
logger.debug("failed to close resume db for %s", sid, exc_info=True)
threading.Thread(target=_run, daemon=True).start()
def _session_pending_kind(sid: str) -> str:
"""Method of the server→client request *sid* is blocked on ("" when none)."""
from tui_gateway import server_requests
return server_requests.pending_kind(sid)
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 idling, not one stuck mid-construction.
if ready is not None and not ready.is_set() and session.get("agent_build_started"):
return "starting"
return "working" if session.get("running") else "idle"
def _session_live_title(session: dict, key: str) -> str:
title = str(session.get("pending_title") or "").strip()
with contextlib.suppress(Exception), _session_db(session) as db:
title = str(db.get_session_title(key) or title or "").strip() if db is not None else title
return title
def _session_live_item(sid: str, session: dict, current_sid: str = "") -> dict:
key = _session_lookup_key(session, fallback=sid)
agent = session.get("agent")
history = list(session.get("history") or [])
status = _session_live_status(sid, session)
inflight = _inflight_snapshot(session)
queued = _queued_prompt_snapshot(session)
preview = next((" ".join(text.split())[:160] for msg in reversed(history)
if (text := _content_display_text(msg.get("content", msg.get("text", ""))).strip())), "")
if queued:
preview = " ".join(str(queued.get("user") or preview).split())[:160]
elif inflight:
preview = " ".join(str(inflight.get("assistant") or inflight.get("user") or preview).split())[:160]
now = time.time()
return {
"current": sid == current_sid, "id": sid,
"last_active": float(session.get("last_active") or session.get("created_at") or now),
"message_count": len(history),
"model": str(getattr(agent, "model", "") or _resolve_model()), "preview": preview,
"session_key": key, "started_at": float(session.get("created_at") or now), "status": status,
"title": _session_live_title(session, key),
}
def _session_lookup_key(session: dict, *, fallback: str = "") -> str:
return str(getattr(session.get("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, key).
# Profile-aware callers pass the home they resolved; the match must then be on (profile_home,
# session_key). See #100029.
for sid, session in list(_sessions.items()):
if (not session.get("_finalized") and _session_lookup_key(session, fallback=sid) == session_key
and _live_profile_matches(session, profile_home)):
return sid, session
return None
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 launch dir (wrong project in the desktop Files pane). `branch` is
# always emitted ("" outside git) so a stale label clears; `desktop_contract` missing reads as "out of date".
# Reporting `_default_session_cwd()` here told a lazily-resumed session's client that its workspace was
# wherever the gateway process happened to start, so the desktop Files pane painted the wrong project
# even after the renderer rebound correctly (#71254). `branch` is always emitted ("" outside a git repo)
# so a client can clear a stale label instead of retaining it — the same contract `_lazy_session_info`
# above already follows.
cwd = _session_cwd(session)
return {
"cwd": cwd, "branch": git_probe.branch(cwd), "project": _project_info_for_cwd(cwd), "lazy": True,
"model": _session_default_model(session), "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: ``db_display`` is verbatim and
candidate-inclusive (verification rows the model history collapses out) but can lag by a flush;
``in_memory`` is the recency authority but the collapsed *model* projection. Keep the DB display as base,
append only the in-memory tail past the last DB row's ``(role, text)`` anchor — the verification answer
survives a warm switch AND a not-yet-flushed live turn is kept."""
if not db_display:
return in_memory
if not in_memory:
return db_display
def _key(msg: dict) -> tuple:
return (msg.get("role"), _coerce_message_text(msg.get("content")))
anchor = _key(db_display[-1])
last_shared = max((idx for idx, msg in enumerate(in_memory) if isinstance(msg, dict) and _key(msg) == anchor), default=-1)
if last_shared == -1:
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]:
"""User-visible DISPLAY projection for a live/warm session: the persisted display lineage (same read as
resume/REST so the payloads agree) reconciled with the in-memory tail; in-memory when the DB 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, include_compacted=True)
# See #92080.
return _reconcile_display_with_live(display, in_memory_fallback)
except Exception:
logger.debug("live display projection read failed", exc_info=True)
return in_memory_fallback
def _live_session_payload(
sid: str, session: dict, *, cols: int | None = None, touch: bool = False,
transport: Transport | None = None, omit_messages: bool = False, inline_images: bool = True) -> dict:
with session["history_lock"]:
if cols is not None:
session["cols"] = cols
if transport is not None:
_rebind_live_transport(sid, session, transport)
if touch:
# #84417: do not re-fire the live turn's original user text from a stale server-queue
# self-duplicate after settle.
session["last_active"] = time.time()
in_memory_history = list(session.get("display_history_prefix") or []) + list(session.get("history") or [])
inflight, queued = _inflight_snapshot(session), _queued_prompt_snapshot(session)
running, turn_started_at = bool(session.get("running")), _turn_started_at(session)
# Persisted display lineage via the session's profile-aware DB (not the launch ``_get_db()``), read
# outside the history lock (the DB has its own). ``omit_messages`` skips the read (fast path).
if omit_messages:
history = in_memory_history
else:
with _session_db(session) as db:
history = _live_visible_history(session, db, in_memory_history)
# message_count follows _resume_response: the stored size when messages are omitted, else the wire count
# (a hidden seed row is in ``history`` but never on the wire).
messages = ([] if omit_messages else
_history_to_messages(history, profile_home=session.get("profile_home"), image_urls=inline_images))
payload = {
"info": _fallback_session_info(session), "message_count": len(history) if omit_messages else len(messages),
"messages": messages,
"messages_omitted": omit_messages, "running": running, "turn_started_at": turn_started_at,
"session_id": sid, "session_key": _session_lookup_key(session, fallback=sid),
"started_at": float(session.get("created_at") or time.time()),
"status": _session_live_status(sid, session),
}
for key, value in (("inflight", inflight), ("queued", queued),
("pending_approval", _pending_approval_request_payload(str(session.get("session_key") or ""))),
("open_requests", _open_requests(sid)),
("pending_connection", _pending_connection_request_payload(sid))):
if value:
payload[key] = value
return _attach_todo_state(payload, session)
def _main_runtime_from_agent(agent) -> dict | None:
"""Aux-client main_runtime override from a live agent, so a one-shot inherits the session's runtime."""
if agent is None:
return None
runtime: dict = {}
# ``session_id`` rides along so a session-bound ``llm.oneshot`` (title, approval) on an OpenCode
# route sends the conversation's ``x-opencode-session`` like the main turn does (#112717).
for field in ("provider", "model", "base_url", "api_key", "api_mode", "auth_mode", "session_id"):
value = getattr(agent, field, None)
if isinstance(value, str) and value.strip():
runtime[field] = value.strip()
elif field == "api_key" and callable(value):
runtime[field] = value
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.
_pet_payload_cache_lock = threading.Lock()
_pet_payload_cache: dict[tuple, dict] = {}
def _pet_sheet_revision(spritesheet) -> str:
"""Stable revision id for one spritesheet file."""
with contextlib.suppress(Exception):
stat = spritesheet.stat()
return f"{stat.st_mtime_ns}:{stat.st_size}"
return "0:0"
def _clone_pet_payload(payload: dict) -> dict:
"""Shallow-clone cached payloads so callers can't mutate shared state."""
out = dict(payload)
for key, kind in (("framesByState", dict), ("framesByRow", dict), ("stateRows", list)):
if isinstance(payload.get(key), kind):
out[key] = kind(payload[key])
return out
def _pet_row_frame_counts(spritesheet) -> dict:
"""Real frame count per concrete spritesheet row name."""
with contextlib.suppress(Exception):
from PIL import Image
from agent.pet import constants, render
with Image.open(spritesheet) as opened:
image = opened.convert("RGBA")
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 * H
blank = lambda col: render._frame_is_blank(image.crop((col * W, top, col * W + W, top + H)))
out[name] = next((col for col in range(cols) if blank(col)), cols) # frames before the first blank cell
return out
return {}
def _pet_cfg() -> dict:
"""``display.pet`` from the canonical config ({} on any failure)."""
with contextlib.suppress(Exception):
from hermes_cli.config import load_config
display = load_config().get("display")
pet = display.get("pet") if isinstance(display, dict) else None
return pet if isinstance(pet, dict) else {}
return {}
def _pet_config_scale() -> float:
"""Configured ``display.pet.scale`` (or the engine default), never raises."""
from agent.pet import constants
with contextlib.suppress(Exception):
return float(_pet_cfg().get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
return constants.DEFAULT_SCALE
def _pet_sprite_payload(pet, *, scale: float) -> dict:
"""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
try:
stat = pet.spritesheet.stat()
cache_key = (str(pet.spritesheet), stat.st_mtime_ns, stat.st_size, pet.slug, pet.display_name, round(scale, 4))
except Exception: # noqa: BLE001
cache_key = None
if cache_key is not None:
with _pet_payload_cache_lock:
cached = _pet_payload_cache.get(cache_key)
if cached is not None:
return _clone_pet_payload(cached)
try: # real (padding-trimmed) frame count per state; {} → the canvas uses the static framesPerState
from agent.pet import render
frames_by_state = render.state_frame_counts(str(pet.spritesheet))
except Exception: # noqa: BLE001
frames_by_state = {}
raw = pet.spritesheet.read_bytes()
mime = "image/png" if pet.spritesheet.suffix.lower() == ".png" else "image/webp"
payload = {
"slug": pet.slug, "displayName": pet.display_name, "mime": mime,
"spritesheetBase64": base64.standard_b64encode(raw).decode("ascii"),
"spritesheetRevision": _pet_sheet_revision(pet.spritesheet), "frameW": constants.FRAME_W,
"frameH": constants.FRAME_H, "framesPerState": constants.FRAMES_PER_STATE,
"framesByState": frames_by_state,
"framesByRow": _pet_row_frame_counts(pet.spritesheet), "loopMs": constants.LOOP_MS,
"scale": scale, "stateRows": _pet_state_rows(pet.spritesheet),
}
if cache_key is not None:
with _pet_payload_cache_lock:
_pet_payload_cache[cache_key] = payload
while len(_pet_payload_cache) > 8:
_pet_payload_cache.pop(next(iter(_pet_payload_cache)))
return _clone_pet_payload(payload)
def _pet_active_selection():
"""Resolve configured active pet + scale from config."""
from agent.pet import constants, store
pet_cfg = _pet_cfg()
enabled = is_truthy_value(pet_cfg.get("enabled"), default=False)
pet = store.resolve_active_pet(str(pet_cfg.get("slug", "") or "")) if enabled else None
return enabled, pet, float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
def _pet_state_rows(spritesheet) -> list[str]:
"""Row taxonomy for the concrete sheet (legacy 8-row or current 9-row atlas), in the renderer's `PetState` names."""
from agent.pet import constants
with contextlib.suppress(Exception):
from PIL import Image
with Image.open(spritesheet) as image:
row_count = max(1, image.height // constants.FRAME_H)
return list(constants.state_rows_for_grid(row_count))
return list(constants.STATE_ROWS)
def _pet_gen_root():
"""Profile-scoped staging dir for in-progress generation drafts."""
root = get_hermes_home() / "cache" / "pet-gen"
root.mkdir(parents=True, exist_ok=True)
return 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
try:
now = time.time()
for child in (c for c in root.iterdir() if c.is_dir() and now - c.stat().st_mtime > max_age_s):
shutil.rmtree(child, ignore_errors=True)
except Exception as exc: # noqa: BLE001 - cleanup is best-effort
logger.debug("pet-gen sweep failed: %s", exc)
def _pet_png_data_uri(path, *, max_px: int = 160) -> str:
"""Downscaled PNG data URI for a draft image (small preview payload)."""
import base64, io
from PIL import Image
with Image.open(path) as opened:
img = opened.convert("RGBA")
img.thumbnail((max_px, max_px), Image.LANCZOS)
img.save(buf := io.BytesIO(), format="PNG")
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).
_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)))
except (TypeError, ValueError):
_PET_REFERENCE_MAX_BYTES = 16 * 1024 * 1024
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, binascii
import re as _re
match = _re.match(r"^data:image/([a-zA-Z0-9.+-]+);base64,(.*)$", ref_raw, _re.DOTALL)
if not match:
raise ValueError("invalid reference image format")
if (ext := _PET_REFERENCE_MIME_EXT.get(match.group(1).lower())) is None:
raise ValueError("unsupported reference image type")
payload = "".join(match.group(2).split())
if (len(payload) * 3) // 4 > _PET_REFERENCE_MAX_BYTES:
raise ValueError("reference image too large")
try:
raw = base64.b64decode(payload, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("invalid reference image data") from exc
if len(raw) > _PET_REFERENCE_MAX_BYTES:
raise ValueError("reference image too large")
(ref_path := stage / f"reference.{ext}").write_bytes(raw)
return [ref_path]
def _pet_cancel_arm(token: str) -> None:
"""Clear a stale cancel flag at the start of a generate/hatch run."""
with _pet_cancel_lock:
_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)
def _pet_is_cancelled(token: str) -> bool:
with _pet_cancel_lock:
return token in _pet_cancelled
# ── Spawn-tree snapshots: the TUI owns subagent state (/agents overlay; registry in tools/delegate_tool), posts
# the final tree on turn-complete and /replay fetches by session_id + filename. Layout: spawn-trees/<sid>/<ts>.json
def _spawn_trees_root():
root = get_hermes_home() / "spawn-trees"
root.mkdir(parents=True, exist_ok=True)
return root
def _spawn_tree_session_dir(session_id: str):
d = _spawn_trees_root() / ("".join(c if c.isalnum() or c in "-_" else "_" for c in session_id) or "unknown")
d.mkdir(parents=True, exist_ok=True)
return d
# Per-session append-only JSONL index so `spawn_tree.list` needn't read every snapshot; a cache — a lost
# line just means list() falls back to a directory scan.
# Read by `spawn_tree.list` so scanning doesn't require reading every full snapshot file (Copilot review on
# #14045). One JSON object per line.
_SPAWN_TREE_INDEX = "_index.jsonl"
def _append_spawn_tree_index(session_dir, entry: dict) -> None:
try:
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:
logger.debug("spawn_tree index append failed: %s", exc) # never block the save
def _read_spawn_tree_index(session_dir) -> list[dict]:
out: list[dict] = []
try:
with (session_dir / _SPAWN_TREE_INDEX).open("r", encoding="utf-8-sig") as f:
for line in f:
if line := line.strip():
with contextlib.suppress(json.JSONDecodeError):
out.append(json.loads(line))
except OSError:
return []
return out
# ── Methods: prompt ──────────────────────────────────────────────────
_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 the 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. The caller must set the
Event AND join the thread before ``message.complete``: a late tick would roll the final usage back."""
stop = threading.Event()
# Dedup baseline sampled BEFORE the thread starts (the client has the turn-start values); a late-scheduled
# thread would otherwise absorb the first counter growth and never emit it.
baseline: dict | None = None
with contextlib.suppress(Exception):
baseline = _get_usage(agent)
def _loop() -> None:
last = baseline
while not stop.wait(interval):
with contextlib.suppress(Exception):
usage = _get_usage(agent)
if usage == last:
continue # counters frozen (one long API call in flight): don't re-render the status bar
last = usage
if stop.is_set():
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
# ── Methods: tools & system ──────────────────────────────────────────
def _session_processes(session: dict) -> list:
"""Background processes owned by this session (registry session_key match)."""
# Drain completion notifications that arrived during this turn. The background poller handles
# between-turn delivery; this is the safety net for events that arrived mid-turn. Ownership filter
# (#42674, #35652): a turn finishing in session B must not consume an event that belongs to session A.
# The registry requeues every addressed event this session cannot positively claim; the poller then
# delivers it to a live owner or drops an orphan.
from tools.process_registry import process_registry
key = str(session.get("session_key") or "")
owned = []
for entry in process_registry.list_sessions():
proc = process_registry.get(entry["session_id"])
if proc is not None and str(getattr(proc, "session_key", "") or "") == key:
entry["output_tail"] = (proc.output_buffer or "")[-4000:] # the 200-char list preview is too thin for the viewer
owned.append(entry)
return owned
# 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 → the 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.
_mcp_reload_loaded_rev = ""
# Bounded convergence for a config edit racing a slow reload: the leader re-hashes until the hash is stable.
_MCP_RELOAD_MAX_PASSES = 3
def _compute_mcp_rev() -> str:
"""Hash of mcp_servers (definitions — omitting it meant an edited server never bumped the rev) + mcp +
tools. ``config.get mtime`` ships it so cosmetic writes don't reload; ``reload.mcp`` coalesces on it. "" = unknown."""
with contextlib.suppress(Exception):
cfg = _load_cfg()
rev_src = json.dumps({k: cfg.get(k) for k in ("mcp", "mcp_servers", "tools")}, sort_keys=True, default=str)
return hashlib.sha1(rev_src.encode()).hexdigest()[:12]
return ""
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."""
if bool(params.get("always", False)):
try:
from cli import save_config_value
save_config_value("approvals.mcp_reload_confirm", False)
except Exception as _exc:
logger.warning("Failed to persist mcp_reload_confirm=false: %s", _exc)
return _ok(rid, {"status": "reloaded", "loaded_rev": _mcp_reload_loaded_rev, **({"coalesced": True} if coalesced else {})})
_TUI_HIDDEN: frozenset[str] = frozenset({"sethome", "set-home", "commands", "approve", "deny"})
_TUI_EXTRA: list[tuple[str, str, str]] = [
("/density", "Toggle compact display mode", "TUI"),
("/logs", "Show recent gateway log lines", "TUI"),
("/mouse", "Set mouse tracking preset [on|off|toggle|wheel|buttons|all]", "TUI"),
("/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",
})
_WORKER_BLOCKED_COMMANDS: frozenset[str] = frozenset({"snapshot", "snap"})
def _skill_usage_lookup():
"""``(usage, origin)`` callables for the skill catalog: activity count (use + view + patch) and
"hub" / "bundled" / "local" (``/api/skills`` ``provenance``, "local" spelled "agent"). Failure → 0 / "local"."""
try:
from tools.skill_usage import (
_read_bundled_names, _read_hub_installed_names, activity_count, load_usage)
records, bundled, hub = load_usage(), _read_bundled_names(), _read_hub_installed_names()
except Exception as e:
logger.debug("skill usage lookup unavailable: %s", e)
return (lambda _name: 0), (lambda _name: "local")
def usage(name: str) -> int:
with contextlib.suppress(Exception):
return activity_count(records.get(name) or {})
return 0
def origin(name: str) -> str:
return "hub" if name in hub else "bundled" if name in bundled else "local"
return usage, origin
_SLASH_COMPLETION_LIMIT = 30
def _rank_slash_completions(items: list[dict], usage, origin_of, *, browsing: bool, score_of=None,
registry_command_names: frozenset[str] | None = None) -> list[dict]:
"""Registry commands keep their order; only skills reorder: fuzzy ``score_of`` first, then most-used, then
A-Z. The limit is spent PER KIND (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 — nothing pruned, only reordered.
While browsing, only names in ``registry_command_names`` (default ``GATEWAY_KNOWN_COMMANDS``) skip the cap:
plugin-registered commands are also ``kind != "skill"`` but unbounded, so they stay capped like skills."""
def name_of(item: dict) -> str:
return str(item.get("text", "")).strip().lstrip("/").lower()
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.sort(key=lambda item: (
*(() if score_of is None else (score_of(item),)), -usage(name_of(item)), name_of(item)))
if browsing:
if registry_command_names is None:
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS
registry_command_names = GATEWAY_KNOWN_COMMANDS
fixed = [c for c in commands if name_of(c) in registry_command_names]
other = [c for c in commands if name_of(c) not in registry_command_names]
ranked_commands = fixed + other[:_SLASH_COMPLETION_LIMIT]
else:
ranked_commands = commands[:_SLASH_COMPLETION_LIMIT]
return ranked_commands + 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"
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:
with contextlib.suppress(Exception):
from hermes_cli.commands import resolve_command
return r.name if (r := resolve_command(name)) else name
return name
_paste_counter = 0
# mcp.servers.* handlers (methods_tools) resolve this BARE through this namespace.
from .mcp_rpc_helpers import summarize_server as _mcp_summarize_server # noqa: E402, F401
# ── Split @method handler modules (see method_ctx.py): 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, methods_slash as _methods_slash,
methods_complete_helpers as _methods_complete_helpers, session_auto_continue as _session_auto_continue,
plugin_inject as _plugin_inject,
rpc_dispatch as _rpc_dispatch,
agent_callbacks as _agent_callbacks, session_history as _session_history,
prompt_attachments as _prompt_attachments, session_notifications as _session_notifications,
tool_progress as _tool_progress, change_watcher as _change_watcher,
session_compression as _session_compression, model_switch as _model_switch,
compute_host_bridge as _compute_host_bridge, session_workdir as _session_workdir,
session_lifecycle as _session_lifecycle, session_reaper as _session_reaper,
session_transports as _session_transports,
methods_browser_control as _methods_browser_control, methods_bot_relay as _methods_bot_relay,
methods_complete as _methods_complete, methods_config as _methods_config,
methods_config_set as _methods_config_set, methods_images as _methods_images,
methods_profiles as _methods_profiles, methods_prompt as _methods_prompt, methods_session as _methods_session,
methods_tools as _methods_tools, prompt_turn as _prompt_turn, billing_view as _billing_view,
methods_projects as _methods_projects, methods_session_foreign as _methods_session_foreign,
methods_session_control as _methods_session_control, methods_subagents as _methods_subagents,
methods_vault as _methods_vault, methods_free_tier as _methods_free_tier,
methods_connectors as _methods_connectors, methods_connectors_account as _methods_connectors_account,
methods_display as _methods_display, methods_display_watch as _methods_display_watch,
methods_onboarding as _methods_onboarding, methods_i18n as _methods_i18n,
methods_shared_metrics as _methods_shared_metrics)
for _m in (
_session_transports, _session_reaper, _session_lifecycle, _session_workdir, _compute_host_bridge, _model_switch,
_session_compression, _change_watcher, _tool_progress, _session_notifications,
_prompt_attachments, _session_history, _agent_callbacks, _session_auto_continue, _plugin_inject, _rpc_dispatch,
_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_session_foreign,
_methods_session_control, _methods_subagents, _methods_vault, _methods_free_tier, _methods_connectors,
_methods_connectors_account, _methods_display, _methods_display_watch, _methods_onboarding,
_methods_i18n, _methods_shared_metrics):
_m.register(sys.modules[__name__])
del _m