diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index e89f96178..7546d3039 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -549,7 +549,7 @@ def _get_platform_tools( include_default_mcp_servers: bool = True, ) -> Set[str]: """Resolve which individual toolset names are enabled for a platform.""" - from toolsets import resolve_toolset + from toolsets import resolve_toolset, TOOLSETS platform_toolsets = config.get("platform_toolsets") or {} toolset_names = platform_toolsets.get(platform) @@ -563,6 +563,8 @@ def _get_platform_tools( toolset_names = [str(ts) for ts in toolset_names] configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} + plugin_ts_keys = _get_plugin_toolset_keys() + platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()} # If the saved list contains any configurable keys directly, the user # has explicitly configured this platform — use direct membership. @@ -585,16 +587,46 @@ def _get_platform_tools( ts_tools = set(resolve_toolset(ts_key)) if ts_tools and ts_tools.issubset(all_tool_names): enabled_toolsets.add(ts_key) + default_off = set(_DEFAULT_OFF_TOOLSETS) if platform in default_off: default_off.remove(platform) enabled_toolsets -= default_off + # Recover non-configurable platform toolsets (e.g. discord, feishu_doc, + # feishu_drive). These are part of the platform's default composite but + # absent from CONFIGURABLE_TOOLSETS, so they can't appear in the TUI + # checklist or in a user-saved config. Must run in BOTH branches — + # otherwise saving via `hermes tools` (which flips has_explicit_config + # to True) silently drops them. + platform_tool_universe = set(resolve_toolset(PLATFORMS[platform]["default_toolset"])) + configurable_tool_universe = set() + for ck in configurable_keys: + configurable_tool_universe.update(resolve_toolset(ck)) + claimed = set() + for ts_key in enabled_toolsets: + claimed.update(resolve_toolset(ts_key)) + skip = configurable_keys | plugin_ts_keys | platform_default_keys + skip |= {k for k in TOOLSETS if k.startswith("hermes-")} + skip |= set(_DEFAULT_OFF_TOOLSETS) - {platform} + for ts_key, ts_def in TOOLSETS.items(): + if ts_key in skip: + continue + if ts_def.get("includes"): + continue + ts_tools = set(resolve_toolset(ts_key)) + if not ts_tools or not ts_tools.issubset(platform_tool_universe): + continue + if ts_tools.issubset(configurable_tool_universe): + continue + if not ts_tools.issubset(claimed): + enabled_toolsets.add(ts_key) + claimed.update(ts_tools) + # Plugin toolsets: enabled by default unless explicitly disabled. # A plugin toolset is "known" for a platform once `hermes tools` # has been saved for that platform (tracked via known_plugin_toolsets). # Unknown plugins default to enabled; known-but-absent = disabled. - plugin_ts_keys = _get_plugin_toolset_keys() if plugin_ts_keys: known_map = config.get("known_plugin_toolsets", {}) known_for_platform = set(known_map.get(platform, [])) @@ -609,7 +641,6 @@ def _get_platform_tools( # Preserve any explicit non-configurable toolset entries (for example, # custom toolsets or MCP server names saved in platform_toolsets). - platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()} explicit_passthrough = { ts for ts in toolset_names diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index b134fc98b..b4ea337d1 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -601,3 +601,45 @@ class TestImagegenModelPicker: _configure_imagegen_model("fal", config) assert isinstance(config["image_gen"], dict) assert config["image_gen"]["model"] == "fal-ai/flux-2/klein/9b" + + +def test_get_platform_tools_recovers_non_configurable_toolsets_from_composite(): + """Non-configurable toolsets whose tools are in the composite but not in + CONFIGURABLE_TOOLSETS should still appear in the result. + """ + from toolsets import TOOLSETS + from hermes_cli.tools_config import PLATFORMS + from unittest.mock import patch as mock_patch + + fake_toolsets = dict(TOOLSETS) + fake_toolsets["_test_platform_tool"] = { + "description": "test", + "tools": ["_test_special_tool"], + "includes": [], + } + fake_toolsets["hermes-_test_platform"] = { + "description": "test composite", + "tools": ["web_search", "web_extract", "terminal", "process", "_test_special_tool"], + "includes": [], + } + + test_platforms = { + "_test_platform": {"label": "Test", "default_toolset": "hermes-_test_platform"}, + } + + with mock_patch("hermes_cli.tools_config.PLATFORMS", {**PLATFORMS, **test_platforms}): + with mock_patch("toolsets.TOOLSETS", fake_toolsets): + enabled = _get_platform_tools({}, "_test_platform") + + assert "_test_platform_tool" in enabled + assert "web" in enabled + assert "terminal" in enabled + + +def test_get_platform_tools_second_pass_skips_fully_claimed_toolsets(): + """Toolsets whose tools are fully covered by configurable keys should NOT + be added by the second pass (prevents 'search', 'hermes-acp' noise). + """ + enabled = _get_platform_tools({}, "cli") + + assert "search" not in enabled