Merge pull request #2268 from NousResearch/hermes/hermes-5d6932ba

fix(tools): disabled toolsets re-enable themselves after hermes tools
This commit is contained in:
Teknium 2026-03-20 21:57:39 -07:00 committed by GitHub
commit 52adc8873b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 128 additions and 13 deletions

View file

@ -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."""

View file

@ -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

View file

@ -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

View file

@ -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