diff --git a/gateway/run.py b/gateway/run.py index c6e33028b..13043b97c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1982,6 +1982,12 @@ class GatewayRunner: f"Use /resume to browse and restore a previous session.\n" f"Adjust reset timing in config.yaml under session_reset." ) + try: + session_info = self._format_session_info() + if session_info: + notice = f"{notice}\n\n{session_info}" + except Exception: + pass await adapter.send( source.chat_id, notice, metadata=getattr(event, 'metadata', None), @@ -2749,6 +2755,85 @@ class GatewayRunner: # Clear session env self._clear_session_env() + def _format_session_info(self) -> str: + """Resolve current model config and return a formatted info block. + + Surfaces model, provider, context length, and endpoint so gateway + users can immediately see if context detection went wrong (e.g. + local models falling to the 128K default). + """ + from agent.model_metadata import get_model_context_length, DEFAULT_FALLBACK_CONTEXT + + model = _resolve_gateway_model() + config_context_length = None + provider = None + base_url = None + api_key = None + + try: + cfg_path = _hermes_home / "config.yaml" + if cfg_path.exists(): + import yaml as _info_yaml + with open(cfg_path, encoding="utf-8") as f: + data = _info_yaml.safe_load(f) or {} + model_cfg = data.get("model", {}) + if isinstance(model_cfg, dict): + raw_ctx = model_cfg.get("context_length") + if raw_ctx is not None: + try: + config_context_length = int(raw_ctx) + except (TypeError, ValueError): + pass + provider = model_cfg.get("provider") or None + base_url = model_cfg.get("base_url") or None + except Exception: + pass + + # Resolve runtime credentials for probing + try: + runtime = _resolve_runtime_agent_kwargs() + provider = provider or runtime.get("provider") + base_url = base_url or runtime.get("base_url") + api_key = runtime.get("api_key") + except Exception: + pass + + context_length = get_model_context_length( + model, + base_url=base_url or "", + api_key=api_key or "", + config_context_length=config_context_length, + provider=provider or "", + ) + + # Format context source hint + if config_context_length is not None: + ctx_source = "config" + elif context_length == DEFAULT_FALLBACK_CONTEXT: + ctx_source = "default — set model.context_length in config to override" + else: + ctx_source = "detected" + + # Format context length for display + if context_length >= 1_000_000: + ctx_display = f"{context_length / 1_000_000:.1f}M" + elif context_length >= 1_000: + ctx_display = f"{context_length // 1_000}K" + else: + ctx_display = str(context_length) + + lines = [ + f"◆ Model: `{model}`", + f"◆ Provider: {provider or 'openrouter'}", + f"◆ Context: {ctx_display} tokens ({ctx_source})", + ] + + # Show endpoint for local/custom setups + if base_url and ("localhost" in base_url or "127.0.0.1" in base_url or "0.0.0.0" in base_url): + lines.append(f"◆ Endpoint: {base_url}") + + return "\n".join(lines) + async def _handle_reset_command(self, event: MessageEvent) -> str: """Handle /new or /reset command.""" source = event.source @@ -2789,12 +2874,22 @@ class GatewayRunner: "session_key": session_key, }) + # Resolve session config info to surface to the user + try: + session_info = self._format_session_info() + except Exception: + session_info = "" + if new_entry: - return "✨ Session reset! I've started fresh with no memory of our previous conversation." + header = "✨ Session reset! Starting fresh." else: # No existing session, just create one self.session_store.get_or_create_session(source, force_new=True) - return "✨ New session started!" + header = "✨ New session started!" + + if session_info: + return f"{header}\n\n{session_info}" + return header async def _handle_status_command(self, event: MessageEvent) -> str: """Handle /status command.""" diff --git a/tests/gateway/test_session_info.py b/tests/gateway/test_session_info.py new file mode 100644 index 000000000..5f04b1a48 --- /dev/null +++ b/tests/gateway/test_session_info.py @@ -0,0 +1,110 @@ +"""Tests for GatewayRunner._format_session_info — session config surfacing.""" + +import pytest +from unittest.mock import patch, MagicMock +from pathlib import Path + +from gateway.run import GatewayRunner + + +@pytest.fixture() +def runner(): + """Create a bare GatewayRunner without __init__.""" + return GatewayRunner.__new__(GatewayRunner) + + +def _patch_info(tmp_path, config_yaml, model, runtime): + """Return a context-manager stack that patches _format_session_info deps.""" + cfg_path = tmp_path / "config.yaml" + if config_yaml is not None: + cfg_path.write_text(config_yaml) + return ( + patch("gateway.run._hermes_home", tmp_path), + patch("gateway.run._resolve_gateway_model", return_value=model), + patch("gateway.run._resolve_runtime_agent_kwargs", return_value=runtime), + ) + + +class TestFormatSessionInfo: + + def test_includes_model_name(self, runner, tmp_path): + p1, p2, p3 = _patch_info(tmp_path, "model:\n default: anthropic/claude-opus-4.6\n provider: openrouter\n", + "anthropic/claude-opus-4.6", + {"provider": "openrouter", "base_url": "https://openrouter.ai/api/v1", "api_key": "k"}) + with p1, p2, p3: + info = runner._format_session_info() + assert "claude-opus-4.6" in info + + def test_includes_provider(self, runner, tmp_path): + p1, p2, p3 = _patch_info(tmp_path, "model:\n default: test-model\n provider: openrouter\n", + "test-model", + {"provider": "openrouter", "base_url": "", "api_key": ""}) + with p1, p2, p3: + info = runner._format_session_info() + assert "openrouter" in info + + def test_config_context_length(self, runner, tmp_path): + p1, p2, p3 = _patch_info(tmp_path, "model:\n default: test-model\n context_length: 32768\n", + "test-model", + {"provider": "custom", "base_url": "", "api_key": ""}) + with p1, p2, p3: + info = runner._format_session_info() + assert "32K" in info + assert "config" in info + + def test_default_fallback_hint(self, runner, tmp_path): + p1, p2, p3 = _patch_info(tmp_path, "model:\n default: unknown-model-xyz\n", + "unknown-model-xyz", + {"provider": "", "base_url": "", "api_key": ""}) + with p1, p2, p3: + info = runner._format_session_info() + assert "128K" in info + assert "model.context_length" in info + + def test_local_endpoint_shown(self, runner, tmp_path): + p1, p2, p3 = _patch_info( + tmp_path, + "model:\n default: qwen3:8b\n provider: custom\n base_url: http://localhost:11434/v1\n context_length: 8192\n", + "qwen3:8b", + {"provider": "custom", "base_url": "http://localhost:11434/v1", "api_key": ""}) + with p1, p2, p3: + info = runner._format_session_info() + assert "localhost:11434" in info + assert "8K" in info + + def test_cloud_endpoint_hidden(self, runner, tmp_path): + p1, p2, p3 = _patch_info(tmp_path, "model:\n default: test-model\n provider: openrouter\n", + "test-model", + {"provider": "openrouter", "base_url": "https://openrouter.ai/api/v1", "api_key": "k"}) + with p1, p2, p3: + info = runner._format_session_info() + assert "Endpoint" not in info + + def test_million_context_format(self, runner, tmp_path): + p1, p2, p3 = _patch_info(tmp_path, "model:\n default: test-model\n context_length: 1000000\n", + "test-model", + {"provider": "", "base_url": "", "api_key": ""}) + with p1, p2, p3: + info = runner._format_session_info() + assert "1.0M" in info + + def test_missing_config(self, runner, tmp_path): + """No config.yaml should not crash.""" + p1, p2, p3 = _patch_info(tmp_path, None, # don't create config + "anthropic/claude-sonnet-4.6", + {"provider": "openrouter", "base_url": "", "api_key": ""}) + with p1, p2, p3: + info = runner._format_session_info() + assert "Model" in info + assert "Context" in info + + def test_runtime_resolution_failure_doesnt_crash(self, runner, tmp_path): + """If runtime resolution raises, should still produce output.""" + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text("model:\n default: test-model\n context_length: 4096\n") + with patch("gateway.run._hermes_home", tmp_path), \ + patch("gateway.run._resolve_gateway_model", return_value="test-model"), \ + patch("gateway.run._resolve_runtime_agent_kwargs", side_effect=RuntimeError("no creds")): + info = runner._format_session_info() + assert "4K" in info + assert "config" in info