Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 5 additions & 55 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
# ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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

Expand Down
9 changes: 7 additions & 2 deletions hermes_cli/active_sessions.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
163 changes: 154 additions & 9 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Loading
Loading