diff --git a/gateway/run.py b/gateway/run.py index 95473874..3f47dafb 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2498,8 +2498,22 @@ class GatewayRunner: # Parse provider:model syntax target_provider, new_model = parse_model_input(args, current_provider) + + # Detect custom/local provider — skip auto-detection to prevent + # silently accepting an OpenRouter model name on a localhost endpoint. + # Users must use explicit provider:model syntax to switch away. + _resolved_base = "" + try: + from hermes_cli.runtime_provider import resolve_runtime_provider as _rtp + _resolved_base = _rtp(requested=current_provider).get("base_url", "") + except Exception: + pass + is_custom = current_provider == "custom" or ( + "localhost" in _resolved_base or "127.0.0.1" in _resolved_base + ) + # Auto-detect provider when no explicit provider:model syntax was used - if target_provider == current_provider: + if target_provider == current_provider and not is_custom: from hermes_cli.models import detect_provider_for_model detected = detect_provider_for_model(new_model, current_provider) if detected: @@ -2580,7 +2594,18 @@ class GatewayRunner: # Clear fallback state since user explicitly chose a model self._effective_model = None self._effective_provider = None - return f"🤖 Model changed to `{new_model}` ({persist_note}){provider_note}{warning}\n_(takes effect on next message)_" + + # Helpful hint when staying on a custom/local endpoint + custom_hint = "" + if is_custom and not provider_changed: + endpoint = _resolved_base or "custom endpoint" + custom_hint = ( + f"\n**Endpoint:** `{endpoint}`" + "\n_To switch providers, use_ `/model provider:model`" + "\n_e.g._ `/model openrouter:anthropic/claude-sonnet-4`" + ) + + return f"🤖 Model changed to `{new_model}` ({persist_note}){provider_note}{warning}{custom_hint}\n_(takes effect on next message)_" async def _handle_provider_command(self, event: MessageEvent) -> str: """Handle /provider command - show available providers.""" diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 1d6783a2..2d623fbd 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -367,13 +367,24 @@ def _get_platform_tools(config: dict, platform: str) -> Set[str]: default_ts = PLATFORMS[platform]["default_toolset"] toolset_names = [default_ts] - # Resolve to individual tool names, then map back to which - # configurable toolsets are covered + configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} + + # If the saved list contains any configurable keys directly, the user + # has explicitly configured this platform — use direct membership. + # This avoids the subset-inference bug where composite toolsets like + # "hermes-cli" (which include all _HERMES_CORE_TOOLS) cause disabled + # toolsets to re-appear as enabled. + has_explicit_config = any(ts in configurable_keys for ts in toolset_names) + + if has_explicit_config: + return {ts for ts in toolset_names if ts in configurable_keys} + + # No explicit config — fall back to resolving composite toolset names + # (e.g. "hermes-cli") to individual tool names and reverse-mapping. all_tool_names = set() for ts_name in toolset_names: all_tool_names.update(resolve_toolset(ts_name)) - # Map individual tool names back to configurable toolset keys enabled_toolsets = set() for ts_key, _, _ in CONFIGURABLE_TOOLSETS: ts_tools = set(resolve_toolset(ts_key)) @@ -386,23 +397,37 @@ def _get_platform_tools(config: dict, platform: str) -> Set[str]: def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[str]): """Save the selected toolset keys for a platform to config. - Preserves any non-configurable toolset entries (like MCP server names) - that were already in the config for this platform. + Preserves any non-configurable, non-composite entries (like MCP server + names) that were already in the config for this platform. + + Composite platform toolsets (hermes-cli, hermes-telegram, etc.) are + dropped once the user has explicitly configured individual toolsets — + keeping them would override the user's selections because they include + all tools via _HERMES_CORE_TOOLS. """ + from toolsets import TOOLSETS + config.setdefault("platform_toolsets", {}) - # Get the set of all configurable toolset keys + # Keys the user can toggle in the checklist UI configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} + # Keys that are known composite/individual toolsets in toolsets.py + # (hermes-cli, hermes-telegram, homeassistant, web, terminal, etc.) + known_toolset_keys = set(TOOLSETS.keys()) + # Get existing toolsets for this platform existing_toolsets = config.get("platform_toolsets", {}).get(platform, []) if not isinstance(existing_toolsets, list): existing_toolsets = [] - # Preserve any entries that are NOT configurable toolsets (i.e. MCP server names) + # Preserve entries that are neither configurable toolsets nor known + # composite toolsets — this keeps MCP server names and other custom + # entries while dropping composites like "hermes-cli" that would + # silently re-enable everything the user just disabled. preserved_entries = { entry for entry in existing_toolsets - if entry not in configurable_keys + if entry not in configurable_keys and entry not in known_toolset_keys } # Merge preserved entries with new enabled toolsets diff --git a/tests/tools/test_session_search.py b/tests/tools/test_session_search.py index 0d741476..e998a58b 100644 --- a/tests/tools/test_session_search.py +++ b/tests/tools/test_session_search.py @@ -214,3 +214,61 @@ class TestSessionSearch: # Current session should be skipped, only other_sid should appear assert result["sessions_searched"] == 1 assert current_sid not in [r.get("session_id") for r in result.get("results", [])] + + def test_current_child_session_excludes_parent_lineage(self): + """Compression/delegation parents should be excluded for the active child session.""" + from unittest.mock import MagicMock + from tools.session_search_tool import session_search + + mock_db = MagicMock() + mock_db.search_messages.return_value = [ + {"session_id": "parent_sid", "content": "match", "source": "cli", + "session_started": 1709500000, "model": "test"}, + ] + + def _get_session(session_id): + if session_id == "child_sid": + return {"parent_session_id": "parent_sid"} + if session_id == "parent_sid": + return {"parent_session_id": None} + return None + + mock_db.get_session.side_effect = _get_session + + result = json.loads(session_search( + query="test", db=mock_db, current_session_id="child_sid", + )) + + assert result["success"] is True + assert result["count"] == 0 + assert result["results"] == [] + assert result["sessions_searched"] == 0 + + def test_current_root_session_excludes_child_lineage(self): + """Delegation child hits should be excluded when they resolve to the current root session.""" + from unittest.mock import MagicMock + from tools.session_search_tool import session_search + + mock_db = MagicMock() + mock_db.search_messages.return_value = [ + {"session_id": "child_sid", "content": "match", "source": "cli", + "session_started": 1709500000, "model": "test"}, + ] + + def _get_session(session_id): + if session_id == "root_sid": + return {"parent_session_id": None} + if session_id == "child_sid": + return {"parent_session_id": "root_sid"} + return None + + mock_db.get_session.side_effect = _get_session + + result = json.loads(session_search( + query="test", db=mock_db, current_session_id="root_sid", + )) + + assert result["success"] is True + assert result["count"] == 0 + assert result["results"] == [] + assert result["sessions_searched"] == 0 diff --git a/tools/session_search_tool.py b/tools/session_search_tool.py index 13356ec9..7f5332c5 100644 --- a/tools/session_search_tool.py +++ b/tools/session_search_tool.py @@ -251,13 +251,20 @@ def session_search( break return sid - # Group by resolved (parent) session_id, dedup, skip current session + current_lineage_root = ( + _resolve_to_parent(current_session_id) if current_session_id else None + ) + + # Group by resolved (parent) session_id, dedup, skip the current + # session lineage. Compression and delegation create child sessions + # that still belong to the same active conversation. seen_sessions = {} for result in raw_results: raw_sid = result["session_id"] resolved_sid = _resolve_to_parent(raw_sid) - # Skip the current session — the agent already has that context - if current_session_id and resolved_sid == current_session_id: + # Skip the current session lineage — the agent already has that + # context, even if older turns live in parent fragments. + if current_lineage_root and resolved_sid == current_lineage_root: continue if current_session_id and raw_sid == current_session_id: continue