diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 8b0769ead3a7..02fe18b4f4de 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -85,25 +85,6 @@ model: # # default_headers: # User-Agent: "curl/8.7.1" - # - # extra_headers: accepted as an alias of default_headers (merged, with - # extra_headers winning when both are set) — matches the per-provider - # extra_headers key below. - # - # Per-provider variant: named providers / custom_providers entries accept an - # extra_headers dict scoped to that endpoint only — for reverse proxies, - # gateways, or custom auth (e.g. Cloudflare Access service tokens). - # Merged onto SDK/provider defaults with the entry's values winning. - # Header values are treated as secrets and are never logged. - # - # providers: - # my-proxy: - # base_url: "https://llm.internal.example.com/v1" - # key_env: "MY_PROXY_API_KEY" - # extra_headers: - # CF-Access-Client-Id: "xxxx.access" - # CF-Access-Client-Secret: "${CF_ACCESS_SECRET}" - # X-Client-Name: "hermes-agent" # Named provider overrides (optional) # Use this for per-provider request timeouts, non-stream stale timeouts, @@ -582,6 +563,11 @@ session_reset: # top-level key takes precedence over gateway.max_concurrent_sessions. The cap # is a best-effort single-host/profile runtime guard; Hermes fails open if the # local runtime lease registry cannot be read or locked. +# Counting is surface-aware: CLI processes count while open, messaging turns +# count while in flight, and TUI/desktop tabs count from their first message +# until they go idle (30 min default; HERMES_TUI_LEASE_IDLE_S overrides, +# 0 = hold until the tab closes) — idle tabs release their slot and re-acquire +# it on the next message. max_concurrent_sessions: null # When true, group/channel chats use one session per participant when the platform @@ -590,41 +576,6 @@ max_concurrent_sessions: null # explicitly want one shared "room brain" per group/channel. group_sessions_per_user: true -# ───────────────────────────────────────────────────────────────────────────── -# API Server — per-client model routing -# ───────────────────────────────────────────────────────────────────────────── -# Route different API clients to different models/providers on a single -# Hermes deployment. Clients choose a backend by sending a specific string -# as the OpenAI ``model`` field. Unmapped model values fall back to the -# global model configured in the ``model:`` section above, and an explicit -# session /model override always wins over a route. -# -# Configure via the ``platforms.api_server.extra.model_routes`` gateway -# config block: -# -# platforms: -# api_server: -# enabled: true -# extra: -# key: "your-api-server-secret" -# model_routes: -# # Xiaozhi clients send model="minimax-m2" → routed to MiniMax via OpenRouter -# minimax-m2: -# model: "minimax/minimax-m1" -# provider: "openrouter" # optional — overrides global provider -# # api_key: "sk-..." # optional — per-route UPSTREAM provider -# # key (NOT caller auth; never logged) -# # base_url: "https://..." # optional — per-route base URL -# # GPT clients keep their own alias -# gpt-5: -# model: "openai/gpt-5" -# provider: "openrouter" -# -# Configured aliases are automatically listed by GET /v1/models so clients -# can discover them without manual coordination. Caller authentication is -# unchanged: every request still authenticates with the global API server -# key (``extra.key`` / API_SERVER_KEY). - # ───────────────────────────────────────────────────────────────────────────── # Gateway Streaming # ───────────────────────────────────────────────────────────────────────────── @@ -1076,7 +1027,6 @@ display: # new: Show a tool indicator only when the tool changes (skip repeats) # all: Show every tool call with a short preview (default) # verbose: Full args, results, and debug logs (same as /verbose) - # log: Silent in chat; append every tool call to ~/.hermes/logs/tool_calls.log (gateway only) # Toggle at runtime with /verbose in the CLI tool_progress: all diff --git a/hermes_cli/active_sessions.py b/hermes_cli/active_sessions.py index 7eba80e50242..f45dce5ae35c 100644 --- a/hermes_cli/active_sessions.py +++ b/hermes_cli/active_sessions.py @@ -1,8 +1,13 @@ """Cross-process active chat session leases. The session database records persisted conversations. This module records -currently open chat surfaces, including idle CLI/TUI sessions that have not -written a transcript row yet. +currently active chat surfaces, including CLI sessions that have not written +a transcript row yet. What "active" means is surface-specific: the CLI +holds a lease for the life of the interactive process, the messaging +gateway claims per in-flight turn, and the TUI/desktop gateway claims on a +tab's first turn and hands the slot back after an idle window (so open but +quiet tabs don't pin ``max_concurrent_sessions``; see +``tui_gateway.server._ensure_turn_lease`` / ``_release_idle_session_leases``). """ from __future__ import annotations diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 2487e6b95e46..f1923002dbaf 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -9,12 +9,21 @@ from pathlib import Path from unittest.mock import patch +import pytest + from hermes_constants import reset_hermes_home_override, set_hermes_home_override -from hermes_cli.active_sessions import active_session_registry_snapshot +from hermes_cli.active_sessions import ( + active_session_registry_snapshot, + try_acquire_active_session, +) from tui_gateway import server -def test_session_create_rejects_at_active_session_limit(monkeypatch, tmp_path): +def test_session_create_claims_lazily_and_first_turn_hits_the_cap(monkeypatch, tmp_path): + """Opening a tab is free: the active-session slot is claimed lazily on the + tab's FIRST TURN (_ensure_turn_lease), not at session.create — so idle + tabs can't pin max_concurrent_sessions. The cap is enforced when the tab + actually speaks.""" home = tmp_path / ".hermes" home.mkdir() (home / "config.yaml").write_text("max_concurrent_sessions: 1\n", encoding="utf-8") @@ -33,23 +42,47 @@ def _clear_server_sessions(): monkeypatch.setattr(server, "_start_agent_build", lambda *args, **kwargs: None) monkeypatch.setattr(server, "_completion_cwd", lambda params=None: str(tmp_path)) + # Another surface holds the only slot. + blocker, message = try_acquire_active_session( + session_id="other-surface", + surface="cli", + config={"max_concurrent_sessions": 1}, + ) + assert message is None + assert blocker is not None + + # Opening tabs still succeeds and claims nothing. first = server._methods["session.create"]("r1", {"cols": 80}) assert "result" in first sid = first["result"]["session_id"] - second = server._methods["session.create"]("r2", {"cols": 80}) - assert second["error"]["message"] == ( + assert "result" in second + assert [entry["session_id"] for entry in active_session_registry_snapshot()] == [ + "other-surface" + ] + + # The tab's first turn is what hits the cap... + session = server._sessions[sid] + limit = server._ensure_turn_lease(sid, session) + assert limit == ( "Hermes is at the active session limit (1/1). " "Try again when another session finishes." ) - assert list(server._sessions) == [sid] - + assert session.get("active_session_lease") is None + + # ...and claims (then reuses) the slot once it frees up. + blocker.release() + assert server._ensure_turn_lease(sid, session) is None + lease = session.get("active_session_lease") + assert lease is not None + assert server._ensure_turn_lease(sid, session) is None + assert session.get("active_session_lease") is lease + assert len(active_session_registry_snapshot()) == 1 + + # Closing the tab returns the slot. closed = server._methods["session.close"]("r3", {"session_id": sid}) assert closed["result"]["closed"] is True assert active_session_registry_snapshot() == [] - - third = server._methods["session.create"]("r4", {"cols": 80}) - assert "result" in third finally: _clear_server_sessions() server._cfg_cache = None @@ -2002,6 +2035,60 @@ def test_notification_event_routing_by_session_key(monkeypatch): assert server._notification_event_belongs_elsewhere(mine, {"session_key": "ghost"}) is False +def test_prompt_submit_rejects_negative_truncate_ordinal(monkeypatch): + """A negative truncate_before_user_ordinal must be rejected, not honoured. + + The handler validates the upper bound (`ordinal >= len(user_indices)`) but a + negative ordinal would otherwise slip through and hit Python negative + indexing: `user_indices[-1]` selects the LAST user turn, truncating history + to everything before it and persisting that loss via replace_messages — an + unrecoverable overwrite of the session DB. Reject it on the safe 4018 path + and leave the in-memory history and the DB untouched. + """ + replaced = [] + + class _FakeDB: + def replace_messages(self, key, messages): + replaced.append((key, list(messages))) + + history = [ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": "second"}, + {"role": "assistant", "content": "done"}, + ] + server._sessions["trunc-sid"] = _session(history=list(history)) + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + # If the guard ever lets a negative ordinal through, these would run and the + # session would be marked busy; failing here makes that regression loud. + monkeypatch.setattr( + server, "_start_agent_build", lambda *a, **k: pytest.fail("must not start a turn") + ) + monkeypatch.setattr( + server, "_start_inflight_turn", lambda *a, **k: pytest.fail("must not start a turn") + ) + + try: + resp = server.handle_request( + { + "id": "1", + "method": "prompt.submit", + "params": { + "session_id": "trunc-sid", + "text": "next", + "truncate_before_user_ordinal": -1, + }, + } + ) + assert resp["error"]["code"] == 4018 + # History and the DB are left exactly as they were — no silent loss. + assert server._sessions["trunc-sid"]["history"] == history + assert server._sessions["trunc-sid"]["running"] is False + assert replaced == [] + finally: + server._sessions.pop("trunc-sid", None) + + def test_session_create_does_not_persist_empty_row(monkeypatch): """session.create must NOT eagerly write a DB row. @@ -8474,3 +8561,61 @@ def fake_agent(**kwargs): assert agent.model == "gpt-5.5" assert captured["provider"] == "deepseek" + + +def test_get_usage_does_not_substitute_cumulative_total_for_context_used(): + """An external context engine that does not report last_prompt_tokens must + not have the cumulative lifetime session_total_tokens shown as its current + context occupancy — that substitution produced impossible 1.9m/120k (100%) + status-bar readings (#50421). With no real current occupancy known, + context_used/percent stay unset rather than wrong.""" + agent = types.SimpleNamespace( + model="test-model", + session_total_tokens=1_900_000, + context_compressor=types.SimpleNamespace( + last_prompt_tokens=0, + context_length=120_000, + compression_count=0, + ), + ) + usage = server._get_usage(agent) + assert usage.get("context_used") != 1_900_000 + assert "context_used" not in usage + assert "context_percent" not in usage + + +def test_get_usage_reports_real_current_occupancy(): + """When the compressor reports a real current prompt size, context_used is + that value (not the cumulative total) and the percent is sane.""" + agent = types.SimpleNamespace( + model="test-model", + session_total_tokens=1_900_000, + context_compressor=types.SimpleNamespace( + last_prompt_tokens=60_000, + context_length=120_000, + compression_count=2, + ), + ) + usage = server._get_usage(agent) + assert usage["context_used"] == 60_000 + assert usage["context_max"] == 120_000 + assert usage["context_percent"] == 50 + + +def test_get_usage_clamps_post_compression_sentinel(): + """Right after a compression, last_prompt_tokens is the -1 sentinel + (conversation_compression sets it until the next real usage report). It is + truthy, so `or 0` doesn't neutralize it — the guard must clamp <0 to 0 so + the transitional turn emits no gauge instead of leaking context_used=-1.""" + agent = types.SimpleNamespace( + model="test-model", + session_total_tokens=4_000_000, + context_compressor=types.SimpleNamespace( + last_prompt_tokens=-1, + context_length=1_048_576, + compression_count=6, + ), + ) + usage = server._get_usage(agent) + assert "context_used" not in usage + assert "context_percent" not in usage diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 170e78aec643..950f2e0ee004 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -973,6 +973,162 @@ def test_sync_session_key_after_compress_reanchors_active_session_lease( lease.release() +# ── Lazy turn leases + idle release ────────────────────────────────── + + +def _lease_test_session(key: str = "session-1", **overrides) -> dict: + session = { + "active_session_lease": None, + "agent_ready": None, + "created_at": time.time(), + "history": [], + "history_lock": threading.Lock(), + "last_active": time.time(), + "running": False, + "session_key": key, + } + session.update(overrides) + return session + + +def test_ensure_turn_lease_claims_once_and_reuses(server, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + monkeypatch.setattr(server, "_load_cfg", lambda: {"max_concurrent_sessions": 1}) + from hermes_cli.active_sessions import active_session_registry_snapshot + + session = _lease_test_session() + assert server._ensure_turn_lease("ui-1", session) is None + lease = session["active_session_lease"] + assert lease is not None + assert [e["session_id"] for e in active_session_registry_snapshot()] == ["session-1"] + + # A held lease is reused, not re-claimed. + assert server._ensure_turn_lease("ui-1", session) is None + assert session["active_session_lease"] is lease + assert len(active_session_registry_snapshot()) == 1 + lease.release() + + +def test_ensure_turn_lease_surfaces_limit_message_at_cap(server, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + monkeypatch.setattr(server, "_load_cfg", lambda: {"max_concurrent_sessions": 1}) + from hermes_cli.active_sessions import try_acquire_active_session + + blocker, message = try_acquire_active_session( + session_id="other-surface", + surface="gateway:telegram", + config={"max_concurrent_sessions": 1}, + ) + assert message is None + + session = _lease_test_session() + limit = server._ensure_turn_lease("ui-1", session) + assert limit == ( + "Hermes is at the active session limit (1/1). " + "Try again when another session finishes." + ) + assert session["active_session_lease"] is None + + blocker.release() + assert server._ensure_turn_lease("ui-1", session) is None + assert session["active_session_lease"] is not None + session["active_session_lease"].release() + + +def test_ensure_turn_lease_does_not_store_into_finalized_session( + server, monkeypatch, tmp_path +): + """A tab closed between claim and store must not strand a registry entry: + the freshly claimed lease is handed straight back.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + monkeypatch.setattr(server, "_load_cfg", lambda: {"max_concurrent_sessions": 1}) + from hermes_cli.active_sessions import active_session_registry_snapshot + + session = _lease_test_session(_finalized=True) + assert server._ensure_turn_lease("ui-1", session) is None + assert session["active_session_lease"] is None + assert active_session_registry_snapshot() == [] + + +def test_idle_lease_release_frees_slot_and_next_turn_reacquires( + server, monkeypatch, tmp_path +): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + monkeypatch.setattr(server, "_load_cfg", lambda: {"max_concurrent_sessions": 1}) + from hermes_cli.active_sessions import active_session_registry_snapshot + + now = time.time() + stale = now - server._LEASE_IDLE_RELEASE_S - 60 + session = _lease_test_session(last_active=stale, created_at=stale) + assert server._ensure_turn_lease("ui-1", session) is None + assert session["active_session_lease"] is not None + server._sessions["ui-1"] = session + + # Idle past the window: the LEASE goes back, the session stays live. + server._release_idle_session_leases(now) + assert session.get("active_session_lease") is None + assert active_session_registry_snapshot() == [] + assert server._sessions["ui-1"] is session + + # The next turn transparently re-acquires. + assert server._ensure_turn_lease("ui-1", session) is None + assert session["active_session_lease"] is not None + assert len(active_session_registry_snapshot()) == 1 + session["active_session_lease"].release() + + +def test_idle_lease_release_skips_busy_recent_and_pending_sessions( + server, monkeypatch, tmp_path +): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + monkeypatch.setattr(server, "_load_cfg", lambda: None) + now = time.time() + stale = now - server._LEASE_IDLE_RELEASE_S - 60 + + running = _lease_test_session( + "session-running", last_active=stale, created_at=stale, running=True + ) + recent = _lease_test_session("session-recent") + queued = _lease_test_session( + "session-queued", + last_active=stale, + created_at=stale, + queued_prompt={"text": "next turn"}, + ) + pending = _lease_test_session("session-pending", last_active=stale, created_at=stale) + for sid, session in ( + ("ui-running", running), + ("ui-recent", recent), + ("ui-queued", queued), + ("ui-pending", pending), + ): + session["active_session_lease"] = object.__new__(type("_Sentinel", (), {})) + server._sessions[sid] = session + # An unanswered gateway prompt marks ui-pending as awaiting input. + server._pending["rid-pending"] = ("ui-pending", None) + + try: + server._release_idle_session_leases(now) + assert running["active_session_lease"] is not None + assert recent["active_session_lease"] is not None + assert queued["active_session_lease"] is not None + assert pending["active_session_lease"] is not None + finally: + server._pending.pop("rid-pending", None) + + +def test_idle_lease_release_disabled_with_zero_window(server, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + monkeypatch.setattr(server, "_LEASE_IDLE_RELEASE_S", 0.0) + now = time.time() + session = _lease_test_session(last_active=now - 999999, created_at=now - 999999) + session["active_session_lease"] = object.__new__(type("_Sentinel", (), {})) + server._sessions["ui-1"] = session + + server._release_idle_session_leases(now) + assert session["active_session_lease"] is not None + + def test_session_resume_live_payload_uses_current_history_with_ancestors(server, monkeypatch): """Live resume should not reuse a stale ancestor-inclusive snapshot.""" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 2c057155b587..3e2043304c51 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -284,6 +284,14 @@ def __init__(self, session_key: str, model: str): self._closed = False from hermes_cli._subprocess_compat import windows_hide_flags + # start_new_session=True detaches the slash worker into its own + # process group / session. Without this, the worker inherits the + # gateway's pgid (= TUI parent PID). When mcp_tool's + # _kill_orphaned_mcp_children races with slash_worker spawn and sweeps + # the gateway's child set, it captures the worker PID, records the + # inherited pgid, and killpg() then kills the TUI parent itself. + # See agent/lsp/client.py for the symmetric LSP server fix and + # tools/mcp_tool.py _filter_mcp_children for defense-in-depth. self.proc = subprocess.Popen( argv, stdin=subprocess.PIPE, @@ -296,6 +304,7 @@ def __init__(self, session_key: str, model: str): # Tier-1 secrets (gateway/GitHub/infra) are still stripped (#29157). env=hermes_subprocess_env(inherit_credentials=True), creationflags=windows_hide_flags(), + start_new_session=True, ) threading.Thread(target=self._drain_stdout, daemon=True).start() threading.Thread(target=self._drain_stderr, daemon=True).start() @@ -420,6 +429,52 @@ def _release_active_session_slot(session: dict | None) -> None: logger.debug("Failed to release active session slot", exc_info=True) +def _ensure_turn_lease(sid: str, session: dict) -> str | None: + """Claim the cross-process active-session slot for this session's turn, + unless the session already holds one. + + TUI/desktop sessions acquire their registry lease lazily on the first + turn (not at session.create/resume, which fire for every composer paint + and sidebar switch) and keep it across turns; the idle reaper hands the + slot back after ``_LEASE_IDLE_RELEASE_S`` without conversational + activity (see ``_release_idle_session_leases``). This is the re-acquire + half: mirrors the platform gateway's claim-per-turn in + ``gateway/run.py`` handle_message. + + Returns the limit message when the cap is reached (the caller surfaces + it as the turn's error), None on success or fail-open. + """ + with session["history_lock"]: + if session.get("active_session_lease") is not None: + return None + lease, limit_message = _claim_active_session_slot( + str(session.get("session_key") or ""), + live_session_id=sid, + ) + if limit_message is not None: + return limit_message + if lease is None: + # Fail-open (registry unreadable/locked) — proceed uncounted, same as + # every other surface. The next turn retries the claim. + return None + # Store under the same lock turn-start uses, re-checking both the slot (a + # concurrent claimer may have won — e.g. a stuck-`running` overlap) and + # finalize (the tab may have closed between claim and store; a lease + # stored into a finalized session would leak until process exit). + with session["history_lock"]: + if ( + session.get("active_session_lease") is None + and not session.get("_finalized") + ): + session["active_session_lease"] = lease + return None + try: + lease.release() + except Exception: + logger.debug("Failed to release turn lease after lost store race", exc_info=True) + return None + + def _transfer_active_session_slot( sid: str, session: dict, @@ -762,12 +817,73 @@ def _session_is_evictable(sid: str, session: dict, now: float) -> bool: return (now - last_active) > _SESSION_TTL_S and (now - created_at) > _SESSION_TTL_S +# How long a session may sit without conversational activity before its +# cross-process active-session lease (max_concurrent_sessions slot) is handed +# back. The lease only — the session, its agent, and its transcript stay live; +# the next turn re-acquires through _ensure_turn_lease. Without this, tabs +# sitting open in a CONNECTED desktop app hold their slot forever: the TTL +# reaper above requires a dead transport, so N idle overnight tabs pin the cap +# at N and lock out every other surface. 0 disables idle release. +try: + _LEASE_IDLE_RELEASE_S = float(os.environ.get("HERMES_TUI_LEASE_IDLE_S") or 1800.0) +except (TypeError, ValueError): + _LEASE_IDLE_RELEASE_S = 1800.0 +_LEASE_IDLE_RELEASE_S = max(0.0, _LEASE_IDLE_RELEASE_S) + + +def _release_idle_session_leases(now: float) -> None: + """Release the active-session LEASE (not the session) for idle sessions. + + Runs on the reaper cadence. Applies to live-transport sessions too — that + is the point: connected-but-idle tabs are exactly the ones the TTL reaper + can never free. Skips anything mid-turn, awaiting an input/approval + prompt, holding a queued next-turn prompt, or still building its agent + (imminent turn). The `running` check happens under history_lock, the same + lock every turn-start path sets it under, so a lease can't be pulled out + from under a turn that is about to reuse it. + """ + if _LEASE_IDLE_RELEASE_S <= 0: + return + with _sessions_lock: + candidates = list(_sessions.items()) + for sid, session in candidates: + if session.get("active_session_lease") is None or session.get("_finalized"): + continue + lock = session.get("history_lock") + if lock is None: + continue + with lock: + if session.get("running") or session.get("queued_prompt"): + continue + if _session_pending_kind(sid): + continue + ready = session.get("agent_ready") + if ready is not None and not ready.is_set() and not session.get("lazy"): + continue + last_active = float(session.get("last_active") or 0.0) + created_at = float(session.get("created_at") or 0.0) + reference = max(last_active, created_at) + if (now - reference) <= _LEASE_IDLE_RELEASE_S: + continue + lease = session.pop("active_session_lease", None) + if lease is None: + continue + try: + lease.release() + except Exception: + logger.debug("Failed to release idle active session lease", exc_info=True) + + def _reap_idle_sessions() -> None: now = time.time() with _sessions_lock: victims = [sid for sid, s in _sessions.items() if _session_is_evictable(sid, s, now)] for sid in victims: _close_session_by_id(sid, end_reason="idle_timeout") + try: + _release_idle_session_leases(now) + except Exception: + logger.debug("idle lease release sweep failed", exc_info=True) _enforce_session_cap() @@ -2964,12 +3080,31 @@ def _get_usage(agent) -> dict: } comp = getattr(agent, "context_compressor", None) if comp: - ctx_used = getattr(comp, "last_prompt_tokens", 0) or usage["total"] or 0 + # context_used is the *current-window* occupancy. Do NOT fall back to + # usage["total"] (cumulative lifetime session_total_tokens): for an + # external context engine that doesn't report last_prompt_tokens that + # substitution showed lifetime totals as the live context fill, yielding + # impossible readings such as 1.9m/120k clamped to 100% (#50421). + # + # Per the issue, populate context_used/percent only from a *real* + # current-occupancy value and "leave it unknown otherwise" — so a falsy + # last_prompt_tokens (0 or missing, i.e. an engine that doesn't track + # per-window occupancy) intentionally emits no gauge rather than a + # fabricated 0% or the old cumulative reading. The built-in compressor + # always reports a real last_prompt_tokens once a turn runs, so it is + # unaffected. + # Clamp the -1 "compression just ran, awaiting real usage" sentinel + # (conversation_compression.py) to 0 so the transitional turn reads as + # unknown (no gauge) instead of leaking context_used=-1. Matches the + # CLI status-bar path (cli.py _get_status_bar_snapshot). + last_prompt = getattr(comp, "last_prompt_tokens", 0) or 0 + if last_prompt < 0: + last_prompt = 0 ctx_max = getattr(comp, "context_length", 0) or 0 - if ctx_max: - usage["context_used"] = ctx_used + if ctx_max and last_prompt: + usage["context_used"] = last_prompt usage["context_max"] = ctx_max - usage["context_percent"] = max(0, min(100, round(ctx_used / ctx_max * 100))) + usage["context_percent"] = max(0, min(100, round(last_prompt / ctx_max * 100))) usage["compressions"] = getattr(comp, "compression_count", 0) or 0 # Live count of background/async subagents still running (delegate_task # batches + background single delegations). Mirrors the classic CLI status @@ -4911,10 +5046,13 @@ def _(rid, params: dict) -> dict: ready = threading.Event() now = time.time() - lease, limit_message = _claim_active_session_slot(key, live_session_id=sid) - if limit_message is not None: - return _err(rid, 4090, limit_message) + # No active-session slot is claimed here. Every TUI/desktop launch (and + # every "New agent" / draft) opens a session just to paint the composer — + # like the DB row (see the NOTE below), the cross-process lease is claimed + # lazily on the first turn (_ensure_turn_lease), and idle tabs hand it + # back (_release_idle_session_leases) so open-but-quiet tabs don't pin + # max_concurrent_sessions. with _sessions_lock: _sessions[sid] = { "agent": None, @@ -4922,7 +5060,7 @@ def _(rid, params: dict) -> dict: "agent_ready": ready, "attached_images": [], "close_on_disconnect": is_truthy_value(params.get("close_on_disconnect", False)), - "active_session_lease": lease, + "active_session_lease": None, "cols": cols, "created_at": now, "edit_snapshots": {}, @@ -5337,17 +5475,16 @@ def _reuse_live_payload(sid: str, session: dict) -> dict: # (resume_session_id keeps the upgrade on the stored conversation). if is_truthy_value(params.get("lazy", False)): sid = uuid.uuid4().hex[:8] - lease, limit_message = _claim_active_session_slot(target, live_session_id=sid) - if limit_message is not None: - return _err(rid, 4090, limit_message) + # Active-session lease is claimed lazily on the first turn (see + # _ensure_turn_lease) — reopening a tab never counts against + # max_concurrent_sessions by itself. + lease = None try: db.reopen_session(target) # The child's OWN conversation only — include_ancestors would prepend # the parent's transcript onto the subagent's branch. history = db.get_messages_as_conversation(target) except Exception as e: - if lease is not None: - lease.release() return _err(rid, 5000, f"resume failed: {e}") cwd = profile_resume_cwd or os.getenv("TERMINAL_CWD", os.getcwd()) record = _deferred_session_record( @@ -5398,9 +5535,8 @@ def _reuse_live_payload(sid: str, session: dict) -> dict: # session's persisted runtime identity, and is a real (upgradable) session. if not is_truthy_value(params.get("eager_build", False)): sid = uuid.uuid4().hex[:8] - lease, limit_message = _claim_active_session_slot(target, live_session_id=sid) - if limit_message is not None: - return _err(rid, 4090, limit_message) + # Lease claimed lazily on the first turn (_ensure_turn_lease). + lease = None # Interactive resume routes approvals/clarify through gateway prompts; # the deferred build wires the remaining per-session callbacks. _enable_gateway_prompts() @@ -5409,8 +5545,6 @@ def _reuse_live_payload(sid: str, session: dict) -> dict: raw_history = db.get_messages_as_conversation(target) display_history = db.get_messages_as_conversation(target, include_ancestors=True) except Exception as e: - if lease is not None: - lease.release() return _err(rid, 5000, f"resume failed: {e}") # Display keeps the full transcript; the model-fed history drops a # dangling/interrupted tool-call tail so a session killed mid-loop does @@ -5468,9 +5602,7 @@ def _reuse_live_payload(sid: str, session: dict) -> dict: # _session_resume_lock across it would stall session.close on the main # dispatch thread (it's not a _LONG_HANDLER), blocking fast-path RPCs. sid = uuid.uuid4().hex[:8] - lease, limit_message = _claim_active_session_slot(target, live_session_id=sid) - if limit_message is not None: - return _err(rid, 4090, limit_message) + # Active-session lease is claimed lazily on the first turn (_ensure_turn_lease). _enable_gateway_prompts() home_token = ( set_hermes_home_override(str(profile_home)) if profile_home is not None else None @@ -5511,8 +5643,6 @@ def _reuse_live_payload(sid: str, session: dict) -> dict: finally: _clear_session_context(tokens) except Exception as e: - if lease is not None: - lease.release() return _err(rid, 5000, f"resume failed: {e}") finally: if home_token is not None: @@ -5529,8 +5659,6 @@ def _reuse_live_payload(sid: str, session: dict) -> dict: agent.close() except Exception: pass - if lease is not None: - lease.release() other_sid, other_session = live payload = _live_session_payload( other_sid, @@ -5571,10 +5699,7 @@ def _reuse_live_payload(sid: str, session: dict) -> dict: # skills — must resolve to the resumed profile too). if profile_home is not None: _sessions[sid]["profile_home"] = str(profile_home) - _sessions[sid]["active_session_lease"] = lease except Exception as e: - if lease is not None: - lease.release() return _err(rid, 5000, f"resume failed: {e}") session = _sessions.get(sid) or {} return _ok( @@ -7726,9 +7851,8 @@ def _(rid, params: dict) -> dict: return _err(rid, 4008, "nothing to branch — send a message first") new_key = _new_session_key() new_sid = uuid.uuid4().hex[:8] - lease, limit_message = _claim_active_session_slot(new_key, live_session_id=new_sid) - if limit_message is not None: - return _err(rid, 4090, limit_message) + # Active-session lease is claimed lazily on the branch's first turn + # (_ensure_turn_lease) — opening the branch tab doesn't consume a slot. branch_name = params.get("name", "") try: if branch_name: @@ -7761,8 +7885,6 @@ def _(rid, params: dict) -> dict: ) db.set_session_title(new_key, title) except Exception as e: - if lease is not None: - lease.release() return _err(rid, 5008, f"branch failed: {e}") try: tokens = _set_session_context(new_key) @@ -7773,11 +7895,7 @@ def _(rid, params: dict) -> dict: _init_session( new_sid, new_key, agent, list(history), cols=session.get("cols", 80) ) - if new_sid in _sessions: - _sessions[new_sid]["active_session_lease"] = lease except Exception as e: - if lease is not None: - lease.release() return _err(rid, 5000, f"agent init failed on branch: {e}") return _ok(rid, {"session_id": new_sid, "title": title, "parent": old_key}) @@ -8121,7 +8239,12 @@ def _(rid, params: dict) -> dict: return _err(rid, 4004, "truncate_before_user_ordinal must be an integer") history = session.get("history", []) user_indices = [i for i, m in enumerate(history) if m.get("role") == "user"] - if ordinal >= len(user_indices): + # Reject out-of-range ordinals on BOTH ends. A negative value would + # otherwise sail past the upper-bound check and hit Python's negative + # indexing below (user_indices[-1] -> the LAST user turn), silently + # truncating history to everything before it and persisting that loss + # via replace_messages — an unrecoverable overwrite of the session DB. + if ordinal < 0 or ordinal >= len(user_indices): return _err(rid, 4018, "target user message is no longer in session history") truncated = history[: user_indices[ordinal]] session["history"] = truncated @@ -8438,6 +8561,19 @@ def run(): home_token = None # per-turn HERMES_HOME override for a resumed remote profile goal_followup = None # set by the post-turn goal hook below try: + # Claim (or reuse) the cross-process active-session slot before any + # model work. First turn of a tab and first turn after an idle + # release both land here; every turn entry point (prompt.submit, + # queued drain, goal continuation, notification turns) funnels + # through this body. At cap, surface the standard limit message as + # this turn's error — same message.start→error shape as the + # ctx.blocked path below; the finally clears running/inflight so + # the client returns to idle. + limit_message = _ensure_turn_lease(sid, session) + if limit_message is not None: + _emit("error", sid, {"message": limit_message}) + return + from tools.approval import ( reset_current_session_key, set_current_session_key, @@ -11310,6 +11446,11 @@ def _(rid, params: dict) -> dict: if name in qcmds: qc = qcmds[name] if qc.get("type") == "exec": + # Sanitize env to prevent credential leakage — + # quick commands run in the TUI server process which + # has all API keys in os.environ. + from tools.environments.local import _sanitize_subprocess_env + sanitized_env = _sanitize_subprocess_env(os.environ.copy()) r = subprocess.run( qc.get("command", ""), shell=True, @@ -11317,12 +11458,16 @@ def _(rid, params: dict) -> dict: text=True, timeout=30, stdin=subprocess.DEVNULL, + env=sanitized_env, ) output = ( (r.stdout or "") + ("\n" if r.stdout and r.stderr else "") + (r.stderr or "") ).strip()[:4000] + if output: + from agent.redact import redact_sensitive_text + output = redact_sensitive_text(output) if r.returncode != 0: return _err( rid, diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index bcc77f25e736..7362236d1b64 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -915,20 +915,17 @@ For Claude on **native Anthropic**, **OpenRouter**, and **Nous Portal**, Hermes The Qwen Cloud (Alibaba DashScope) upstream caps cache TTL at 5 minutes, so Hermes uses the 5-minute breakpoint TTL there instead. Other Claude-via-third-party paths (AWS Bedrock, Azure Foundry) fall back to the provider's own caching defaults. xAI Grok uses a separate session-pinned conversation-id mechanism — see [xAI prompt caching](/integrations/providers#xai-grok--responses-api--prompt-caching). -Caching is on by default and saves money even on single-turn conversations because the system prompt alone is a meaningful fraction of the input token count. It can be turned off entirely with the `enabled` knob below when a strict provider rejects `cache_control` markers. +No knob exists to disable this — caching is always-on and saves money even on single-turn conversations because the system prompt alone is a meaningful fraction of the input token count. -The explicit knobs are whether caching runs at all and the cache TTL tier Hermes requests on Anthropic-style breakpoints: +The one explicit knob is the cache TTL tier Hermes requests on Anthropic-style breakpoints: ```yaml prompt_caching: - enabled: true # set false to stop sending cache_control markers entirely cache_ttl: "5m" # "5m" or "1h" (Anthropic-supported tiers); other values are ignored ``` `cache_ttl` selects the breakpoint TTL Hermes attaches for Claude via the native Anthropic API, OpenRouter, and Nous Portal. Only the two Anthropic-supported tiers (`"5m"`, `"1h"`) are honored — any other value is ignored. Providers with their own caps (e.g. Qwen Cloud, which maxes at 5 minutes) still clamp to what the upstream allows. -`enabled` defaults to `true`. Set it to `false` as an escape hatch for strict Anthropic-compatible proxies that inject their own `cache_control` markers server-side — stacking those on top of Hermes' breakpoints can exceed Anthropic's 4-breakpoint limit and return HTTP 400 `"A maximum of 4 blocks with cache_control may be provided"`. Disabling caching on that setup passes requests through without client-side markers so the proxy manages its own. - ## Auxiliary Models Hermes uses "auxiliary" models for side tasks like image analysis, web page summarization, browser screenshot analysis, session-title generation, and context compression. By default (`auxiliary.*.provider: "auto"`), Hermes routes every auxiliary task to your **main chat model** — the same provider/model you picked in `hermes model`. You don't need to configure anything to get started, but be aware that on expensive reasoning models (Opus, MiniMax M2.7, etc.) auxiliary tasks add meaningful cost. If you want cheap-and-fast side tasks regardless of your main model, set `auxiliary..provider` and `auxiliary..model` explicitly (for example, Gemini Flash on OpenRouter for vision and web extraction). @@ -1625,7 +1622,7 @@ The master `streaming.enabled` switch is `false` by default — nothing streams ## Group Chat Session Isolation -Limit how many chat sessions can actively be open across CLI, TUI/dashboard, +Limit how many chat sessions can be active at once across CLI, TUI/dashboard, and messaging gateway: ```yaml @@ -1635,6 +1632,21 @@ max_concurrent_sessions: null # null/0 = unlimited; positive integer = active s When the cap is reached, Hermes returns a direct limit message for new sessions. Existing active sessions keep their normal behavior. +What counts as "active" is surface-specific: + +- **CLI**: an interactive `hermes` process holds a slot for its lifetime. +- **Messaging gateway** (Telegram, Discord, …): a slot is held per in-flight + turn and released when the turn finishes. +- **TUI / desktop / dashboard**: a tab claims its slot on its first message + (opening tabs or switching sessions is free) and keeps it while the + conversation is active. A tab with no activity for 30 minutes hands its + slot back automatically and transparently re-acquires it on the next + message — so open-but-idle tabs don't pin the cap overnight. If the cap is + full at re-acquire time, that message fails with the standard limit + message; retry when a slot frees up. Tune the idle window with the + `HERMES_TUI_LEASE_IDLE_S` environment variable (seconds; `0` keeps slots + held until the tab closes). + The canonical key is top-level `max_concurrent_sessions`. Hermes also accepts `gateway.max_concurrent_sessions` as a fallback, but the top-level key wins when both are set.