From 1870069f80fe4eae1adebd954bfea9850e4053c9 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 20 Mar 2026 11:56:02 -0700 Subject: [PATCH 1/3] fix(session_search): exclude current session lineage Cherry-picked from PR #2201 by @Gutslabs. session_search resolved hits to parent/root sessions but only excluded the exact current_session_id. If the active session was a child continuation (compression/delegation), its parent could still appear as a 'past' conversation result. Fix: resolve current_session_id to its lineage root before filtering, so the entire active lineage (parent and children) is excluded. --- tests/tools/test_session_search.py | 58 ++++++++++++++++++++++++++++++ tools/session_search_tool.py | 13 +++++-- 2 files changed, 68 insertions(+), 3 deletions(-) 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 From f3b23034280f217fa66b949151d1121ce1016a5a Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 20 Mar 2026 12:47:23 -0700 Subject: [PATCH 2/3] fix(gateway): skip model auto-detection for custom/local providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the CLI fix for the gateway /model handler. When the user is on a custom provider (provider=custom, localhost, or 127.0.0.1 endpoint), /model no longer tries to auto-detect a provider switch. Previously, typing /model openrouter/nvidia/nemotron:free on Telegram while on a localhost endpoint would silently accept the model name on the local server — auto-detection failed to match the free model, so the provider stayed as custom with the localhost base_url. The user saw 'Model changed' but requests still went to localhost, which doesn't serve that model. Now shows the endpoint URL and provider:model syntax tip, matching the CLI behavior. --- gateway/run.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) 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.""" From 173a5c6290761372c300f812114fcb07b703caee Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 20 Mar 2026 21:11:54 -0700 Subject: [PATCH 3/3] fix(tools): disabled toolsets re-enable themselves after hermes tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the save/load roundtrip for platform_toolsets: 1. _save_platform_tools preserved composite toolset entries (hermes-cli, hermes-telegram, etc.) because they weren't in configurable_keys. These composites include ALL _HERMES_CORE_TOOLS, so having hermes-cli in the saved list alongside individual keys negated any disables — the subset check always found the disabled toolset's tools via the composite entry. Fix: also filter out known TOOLSETS keys from preserved entries. Only truly unknown entries (MCP server names, custom entries) are kept. 2. _get_platform_tools used reverse subset inference to determine which configurable toolsets were enabled. This is inherently broken when tools appear in multiple toolsets (e.g. HA tools in both the homeassistant toolset and _HERMES_CORE_TOOLS). Fix: when the saved list contains explicit configurable keys (meaning the user has configured this platform), use direct membership instead of subset inference. The fallback path still handles legacy configs that only have a composite entry like hermes-cli. --- hermes_cli/tools_config.py | 41 ++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) 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