diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 30e8fa0c36..22d9f553e5 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -25,9 +25,8 @@ from hermes_cli.toolset_scope import ( logger = logging.getLogger(__name__) -# Platforms already warned about an all-invalid platform_toolsets list, so the -# runtime check in _get_platform_tools warns once per platform instead of on -# every tool resolution for a persistently-corrupt config (#38798). +# Platforms already warned about an all-invalid platform_toolsets list: warn once per platform, +# not on every tool resolution for a persistently-corrupt config. _warned_invalid_platform_toolsets: Set[str] = set() PROJECT_ROOT = Path(__file__).parent.parent.resolve() @@ -147,9 +146,7 @@ from hermes_cli.tools_config_providers import ( # noqa: F401 — re-exported fo # ─── Toolset Registry ───────────────────────────────────────────────────────── -# Toolsets shown in the configurator, grouped for display. -# Each entry: (toolset_name, label, description) -# These map to keys in toolsets.py TOOLSETS dict. +# Toolsets shown in the configurator: (toolset key in toolsets.py TOOLSETS, label, description). CONFIGURABLE_TOOLSETS = [ ("web", "🔍 Web Search & Scraping", "web_search, web_extract"), ("browser", "🌐 Browser Automation", "navigate, click, type, scroll"), @@ -195,39 +192,25 @@ def gui_toolset_label(label: str) -> str: return text -# Toolsets that are OFF by default for new installs. -# They're still in _HERMES_CORE_TOOLS (available at runtime if enabled), -# but the setup checklist won't pre-select them for first-time users. -# -# Video gen is off by default — it's a niche, paid, slow feature. Users -# who want it opt in via `hermes tools` → Video Generation, which walks -# them through provider + model selection. -# -# X search is off by default for users without xAI credentials, but -# auto-enables when SuperGrok OAuth tokens are stored OR XAI_API_KEY is -# set — mirroring the HASS_TOKEN → homeassistant auto-enable below. The -# `hermes tools` → X (Twitter) Search setup walks users through credential -# setup. The tool's check_fn means the schema still won't appear to the -# model if the credential later goes missing or expires. +# OFF by default for new installs (still in _HERMES_CORE_TOOLS; the checklist just won't pre-select +# them). video_gen is niche/paid/slow. x_search auto-enables when xAI credentials exist (SuperGrok +# OAuth or XAI_API_KEY), mirroring HASS_TOKEN → homeassistant; its check_fn still hides the schema +# if the credential later expires. _DEFAULT_OFF_TOOLSETS = {"homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen", "x_search", "a2a"} -# Config-only capabilities: they appear in `hermes tools` for provider/API-key -# configuration (TOOL_CATEGORIES) but are NOT model toolsets — they ship zero -# tool schemas and their on/off switch lives in their own config section -# (e.g. ``stt.enabled``), not ``platform_toolsets``. Excluded from the -# per-platform enable/disable checklist; configured via the "Reconfigure an -# existing tool" flow and the GUI provider matrix instead. +# Config-only capabilities: in `hermes tools` for provider/API-key setup (TOOL_CATEGORIES) but NOT +# model toolsets — zero schemas, on/off switch in their own section (``stt.enabled``), not +# ``platform_toolsets``. Excluded from the per-platform checklist; configured via "Reconfigure an +# existing tool" and the GUI provider matrix. _CONFIG_ONLY_TOOLSETS = {"stt"} def _xai_credentials_present() -> bool: - """Cheap, side-effect-free check for usable xAI credentials. + """Cheap, offline check for usable xAI credentials (auth store + env only). - Does NOT hit the network — only inspects the local auth store and environment. The tool's - runtime ``check_fn`` still gates schema registration if creds later expire or get revoked. Also - reused by ``provider_readiness_status`` for ``post_setup: "xai_grok"`` picker rows (xAI TTS, - Grok OAuth x_search). + The tool's runtime ``check_fn`` still gates schema registration if creds later expire. Also used + by ``provider_readiness_status`` for ``post_setup: "xai_grok"`` rows. """ try: from hermes_cli.auth import _read_xai_oauth_tokens @@ -260,11 +243,10 @@ def _homeassistant_credentials_present() -> bool: return False def _toolset_configuration_platform(ts_key: str, default: str = "cli") -> str: - """Return the platform a platform-less configuration UI should target. + """Platform a platform-less configuration UI should target. - Most configurable toolsets retain the historical desktop/CLI target. A toolset restricted away - from that platform must instead be configured on one of its supported platforms; otherwise the - shared save helper correctly drops it and the UI reports a successful no-op. + A toolset restricted away from the default (CLI) must be configured on one of its supported + platforms; otherwise the save helper correctly drops it and the UI reports a successful no-op. """ allowed = _TOOLSET_PLATFORM_RESTRICTIONS.get(ts_key) if not allowed or default in allowed: @@ -273,12 +255,8 @@ def _toolset_configuration_platform(ts_key: str, default: str = "cli") -> str: def _get_effective_configurable_toolsets(): - """Return CONFIGURABLE_TOOLSETS + any plugin-provided toolsets. - - Plugin toolsets are appended at the end so they appear after the built-in toolsets in the TUI - checklist. A plugin whose toolset key already appears in ``CONFIGURABLE_TOOLSETS`` is skipped — - bundled plugins (e.g. - """ + """CONFIGURABLE_TOOLSETS + plugin toolsets (appended, so they render after built-ins; a plugin + whose key is already built-in is skipped).""" result = list(CONFIGURABLE_TOOLSETS) seen = {ts_key for ts_key, _, _ in result} try: @@ -297,28 +275,19 @@ def _get_effective_configurable_toolsets(): def _get_plugin_toolset_keys() -> set: """Return the set of toolset keys provided by plugins.""" try: + # Non-blocking on the CLI startup path: while background discovery is still importing, + # this serves last launch's persisted key set instead of joining the discovery thread. from hermes_cli.plugins import get_plugin_toolset_keys_nowait - # Non-blocking on the CLI startup path: while background plugin - # discovery is still importing modules, this serves last launch's - # persisted key set (used only to exclude plugin toolsets from - # composite expansion) instead of joining the discovery thread. return get_plugin_toolset_keys_nowait() except Exception: return set() def _checklist_toolset_keys(platform: str) -> Set[str]: - """Return the toolset keys the ``hermes tools`` checklist actually offers for ``platform``. - - This mirrors exactly what ``_prompt_toolset_checklist`` renders: - ``_get_effective_configurable_toolsets()`` (built-in + plugin toolsets), filtered by - ``_toolset_allowed_for_platform``. The checklist's returned selection can therefore only ever be - a subset of this universe. - - Non-configurable toolsets that ``_get_platform_tools`` resolves at read time — ``kanban`` and - other check_fn-gated toolsets, recovered platform composites, MCP server names — are NOT in this - set because the checklist never shows them. - """ + """Toolset keys the ``hermes tools`` checklist offers for ``platform`` (mirrors + ``_prompt_toolset_checklist``). Non-configurable toolsets ``_get_platform_tools`` resolves at + read time (``kanban``, recovered composites, MCP names) are NOT here — the checklist never + shows them.""" return { ts_key for ts_key, _, _ in _get_effective_configurable_toolsets() @@ -326,9 +295,8 @@ def _checklist_toolset_keys(platform: str) -> Set[str]: and ts_key not in _CONFIG_ONLY_TOOLSETS } -# Platform display config — derived from the canonical registry so every -# module shares the same data. Kept as dict-of-dicts for backward -# compatibility with existing ``PLATFORMS[key]["label"]`` access patterns. +# Platform display config derived from the canonical registry; dict-of-dicts for the existing +# ``PLATFORMS[key]["label"]`` access pattern. from hermes_cli.platforms import PLATFORMS as _PLATFORMS_REGISTRY PLATFORMS = { @@ -363,9 +331,8 @@ def _toolset_label(ts_key: str) -> str: # ─── Tool Categories (provider-aware configuration) ────────────────────────── -# Maps toolset keys to their provider options. When a toolset is newly enabled, -# we use this to show provider selection and prompt for the right API keys. -# Toolsets not in this map either need no config or use the simple fallback. +# toolset key -> provider options shown when the toolset is newly enabled. Toolsets not in this +# map either need no config or use the TOOLSET_ENV_REQUIREMENTS fallback. TOOL_CATEGORIES = { "tts": { @@ -639,16 +606,10 @@ TOOL_CATEGORIES = { }, } -# Simple env-var requirements for toolsets NOT in TOOL_CATEGORIES. -# Used as a fallback for toolsets that just need an API key. -# -# `vision` is listed here only so it registers as a *configurable* toolset -# (the value gates the reconfigure menu + the "[no API key]" suffix). Its -# actual setup runs through `_configure_vision_backend()` — a full -# provider+model picker like `hermes model` — NOT this single-key prompt, so -# users are never forced onto OpenRouter. `_toolset_has_keys("vision")` -# resolves via `resolve_vision_provider_client()`, so the tuple below is never -# prompted or read for vision; it's purely a presence marker. +# Env-var fallback for toolsets NOT in TOOL_CATEGORIES. `vision` is listed only as a presence marker +# (registers it as configurable: reconfigure menu + "[no API key]" suffix); its setup runs through +# `_configure_vision_backend()` (full provider+model picker, never forcing OpenRouter) and +# `_toolset_has_keys("vision")` resolves via `resolve_vision_provider_client()`. TOOLSET_ENV_REQUIREMENTS = { "vision": [("OPENROUTER_API_KEY", "https://openrouter.ai/keys")], } @@ -668,26 +629,14 @@ _PLATFORM_ENABLE_ENV_VARS = ( def _get_enabled_platforms() -> List[str]: """Return platform keys that are configured (have tokens or are CLI).""" - return ["cli"] + [ - platform - for platform, env_var in _PLATFORM_ENABLE_ENV_VARS - if get_env_value(env_var) - ] + return ["cli"] + [platform for platform, env_var in _PLATFORM_ENABLE_ENV_VARS if get_env_value(env_var)] def _platform_toolset_summary(config: dict, platforms: Optional[List[str]] = None) -> Dict[str, Set[str]]: - """Return a summary of enabled toolsets per platform. - - When ``platforms`` is None, this uses ``_get_enabled_platforms`` to auto-detect platforms. Tests - can pass an explicit list to avoid relying on environment variables. - """ + """Enabled toolsets per platform (``platforms`` defaults to ``_get_enabled_platforms()``).""" if platforms is None: platforms = _get_enabled_platforms() - - summary: Dict[str, Set[str]] = {} - for pkey in platforms: - summary[pkey] = _get_platform_tools(config, pkey) - return summary + return {pkey: _get_platform_tools(config, pkey) for pkey in platforms} def _parse_enabled_flag(value, default: bool = True) -> bool: @@ -723,9 +672,7 @@ def enabled_mcp_server_names(config: dict) -> Set[str]: and _parse_enabled_flag(server_cfg.get("enabled", True), default=True) } try: - from hermes_cli.plugins import ( - get_portable_mcp_server_names_nowait, - ) + from hermes_cli.plugins import get_portable_mcp_server_names_nowait portable = get_portable_mcp_server_names_nowait() # Native config wins on a name collision (mirrors _load_mcp_config). @@ -753,27 +700,16 @@ def _exempt_explicit_platform_native( default_off.discard(ts) -#: Toolsets young enough that absence from a saved ``platform_toolsets`` list -#: means "never offered" rather than "declined". +#: Toolsets young enough that absence from a saved ``platform_toolsets`` list means "never offered" +#: rather than "declined". Saving ``hermes tools`` freezes a platform's composite into an explicit +#: list that nothing ever adds to, so a toolset shipped later stays off forever for picker users +#: while ``[hermes-cli]`` users inherit it; listing it here restores parity. #: -#: Saving ``hermes tools`` (or one toggle in the desktop Toolsets UI) replaces -#: a platform's composite with a frozen explicit list, and nothing ever adds to -#: that list — so a toolset shipped afterwards stays off forever for anyone who -#: has touched the picker, while everyone still on ``[hermes-cli]`` inherits it -#: on upgrade. Listing it here restores that parity. -#: -#: MUST ship in the same release as the toolset it names, and be emptied in the -#: next one. The inference only holds while no released build has put the -#: toolset on a checklist: once one has, a user who unchecks it writes a config -#: byte-identical to one saved before the toolset existed (the record below is -#: only written from that point on), and this rule turns their opt-out back on. -#: Landing late — or leaving an entry here for a second release — converts a -#: back-fill into a stuck checkbox. -#: -#: A ``check_fn``-gated toolset costs nothing here for users who cannot call -#: it: an enabled toolset still ships zero schemas when its check fails — the -#: same split Home Assistant uses. Probing a remote service from this path -#: would put a network call on every CLI start, gateway session and cron tick. +#: MUST ship in the same release as the toolset and be emptied in the next: once a released build +#: has put the toolset on a checklist, an unchecking user writes a config byte-identical to one +#: saved before it existed and this rule would turn their opt-out back on (stuck checkbox). +#: A ``check_fn``-gated toolset costs nothing here (zero schemas when its check fails); never probe +#: a remote service from this path — it runs on every CLI start, gateway session and cron tick. _RECENTLY_SHIPPED_TOOLSETS: frozenset = frozenset() @@ -800,8 +736,7 @@ def _enable_recently_shipped_toolsets( continue if not _toolset_allowed_for_platform(ts_key, platform): continue - # Parity is the whole justification, so only enable the toolset where - # staying on the composite would have enabled it anyway. Deliberately + # Only enable where staying on the composite would have enabled it anyway; deliberately # narrow composites (hermes-acp, hermes-webhook) stay narrow. ts_tools = set(resolve_toolset(ts_key, include_registry=False)) if composite_tools is None: @@ -815,8 +750,8 @@ def _configurable_subset_of(tool_names: Set[str], platform: str) -> Set[str]: """Configurable toolsets whose STATIC membership is contained in ``tool_names``. Compares ``resolve_toolset(ts, include_registry=False)``: a tool registered into a toolset at - runtime (e.g. delegate_cli -> delegation, desktop-only read_terminal -> terminal) that the - composite never listed must not drop the whole toolset (issue #49622). + runtime (delegate_cli -> delegation, desktop-only read_terminal -> terminal) that the composite + never listed must not drop the whole toolset. """ from toolsets import resolve_toolset @@ -833,24 +768,99 @@ def _configurable_subset_of(tool_names: Set[str], platform: str) -> Set[str]: def _default_off_toolsets(platform: str, explicitly_configured: bool) -> Set[str]: """Toolsets to strip from an implicit (composite-derived) enable set for ``platform``. - Legacy safety: a platform whose own name matches a default-off toolset (``homeassistant``) - keeps that toolset on first install — except platform-restricted toolsets, which stay opt-in - even on their own platform (``discord`` + ``discord`` stays OFF). Home Assistant is runtime- - gated by its check_fn (needs HASS_TOKEN), so a configured token is an explicit opt-in and must - not be stripped here — otherwise HA silently vanished from platforms like cron that resolve - without a saved toolset list (regression after #14798). + A platform whose own name is a default-off toolset (``homeassistant``) keeps it on first install, + except platform-restricted toolsets, which stay opt-in even on their own platform (``discord`` on + discord stays OFF). A configured HASS_TOKEN is an explicit opt-in (the check_fn gates at runtime) + and must not be stripped, or HA silently vanishes from platforms like cron that resolve without + a saved list. """ default_off = set(_DEFAULT_OFF_TOOLSETS) if platform in default_off and platform not in _TOOLSET_PLATFORM_RESTRICTIONS: default_off.remove(platform) if "homeassistant" in default_off and _homeassistant_credentials_present(): default_off.remove("homeassistant") - _exempt_explicit_platform_native( - default_off, platform, explicitly_configured=explicitly_configured - ) + _exempt_explicit_platform_native(default_off, platform, explicitly_configured=explicitly_configured) return default_off +def _configurable_keys() -> Set[str]: + return {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} + + +def _platform_default_keys() -> Set[str]: + return {p["default_toolset"] for p in PLATFORMS.values()} + + +def _explicit_toolsets( + toolset_names: List[str], explicit_known_keys: Set[str], config: dict, platform: str, + explicitly_configured: bool, +) -> Set[str]: + """Enabled set when the saved list names configurable/plugin keys directly. + + Direct membership avoids the subset-inference bug where composites like ``hermes-cli`` (all + _HERMES_CORE_TOOLS) made disabled toolsets re-appear. A mixed list (``[hermes-cli, spotify]`` + after enabling Spotify via ``hermes tools``) still expands the composite, otherwise sessions + keep only the opt-ins and lose every native tool; _DEFAULT_OFF_TOOLSETS applies to that + implicit expansion only — anything explicitly listed survives. + """ + from toolsets import resolve_toolset, TOOLSETS + + enabled = { + ts for ts in toolset_names + if ts in explicit_known_keys and _toolset_allowed_for_platform(ts, platform) + } + composite_tools: Set[str] = set() + for ts_name in toolset_names: + if ts_name not in explicit_known_keys and ts_name in TOOLSETS: + composite_tools.update(resolve_toolset(ts_name)) + if composite_tools: + enabled |= _configurable_subset_of(composite_tools, platform) - _default_off_toolsets( + platform, explicitly_configured + ) + _enable_recently_shipped_toolsets(enabled, config, platform) + return enabled + + +def _composite_toolsets(toolset_names: List[str], platform: str, explicitly_configured: bool) -> Set[str]: + """Enabled set inferred from composite names (``hermes-cli``) by reverse-mapping tool names. + + ``x_search`` is its own one-tool toolset the composite does NOT include, so the subset loop + never picks it up; inject it when xAI credentials exist (mirroring HASS_TOKEN → homeassistant) + and carve it out of the default-off subtraction. Only runs while no explicit list is saved — + once saved, that list is authoritative. + """ + from toolsets import resolve_toolset + + all_tool_names: Set[str] = set() + for ts_name in toolset_names: + all_tool_names.update(resolve_toolset(ts_name)) + enabled = _configurable_subset_of(all_tool_names, platform) + default_off = _default_off_toolsets(platform, explicitly_configured) + if _toolset_allowed_for_platform("x_search", platform) and _xai_credentials_present(): + enabled.add("x_search") + default_off.discard("x_search") + return enabled - default_off + + +def _enabled_plugin_toolsets(config: dict, platform: str, toolset_names: List[str], plugin_ts_keys: Set[str]) -> Set[str]: + """Plugin toolsets: on by default unless in _DEFAULT_OFF_TOOLSETS (e.g. bundled spotify — opt-in + so we don't ship 7 schemas to non-users) or "known" for this platform (``known_plugin_toolsets`` + is written on every ``hermes tools`` save) and absent from the saved list.""" + known_for_platform = set((config.get("known_plugin_toolsets", {}) or {}).get(platform, []) or []) + return { + pts for pts in plugin_ts_keys + if pts in toolset_names or (pts not in _DEFAULT_OFF_TOOLSETS and pts not in known_for_platform) + } + + +def _context_engine_active(config: dict) -> bool: + context_cfg = config.get("context") or {} + if not isinstance(context_cfg, dict): + context_cfg = {} + name = str(context_cfg.get("engine") or "compressor").strip().lower() + return bool(name) and name != "compressor" + + def _get_platform_tools( config: dict, platform: str, @@ -858,129 +868,42 @@ 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, TOOLSETS - platform_toolsets = config.get("platform_toolsets") or {} toolset_names = platform_toolsets.get(platform) - # Track whether the user explicitly saved a toolset list for this platform - # (vs. falling back to the platform default). An explicit composite (e.g. - # ``hermes-discord``) is an opt-in to the platform's native default-off - # toolsets — see _exempt_explicit_platform_native (#35527). + # An explicitly saved list (even a composite like ``hermes-discord``) is an opt-in to the + # platform's native default-off toolsets — see _exempt_explicit_platform_native. explicitly_configured = isinstance(toolset_names, list) - if not explicitly_configured: toolset_names = [_platform_default_toolset(platform)] - - # YAML may parse bare numeric names (e.g. ``12306:``) as int. - # Normalise to str so downstream sorted() never mixes types. + # YAML may parse bare numeric names (``12306:``) as int; normalise so sorted() never mixes types. toolset_names = [str(ts) for ts in toolset_names] - configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} + configurable_keys = _configurable_keys() plugin_ts_keys = _get_plugin_toolset_keys() - platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()} - # Plugin-provided toolsets are first-class on a platform-toolsets list — - # explicit config like ``[hermes-cli, a2a]`` must survive filtering just - # like a built-in configurable toolset would. See issue #81163. + platform_default_keys = _platform_default_keys() + # Plugin toolsets are first-class on a saved list: ``[hermes-cli, a2a]`` must survive filtering. explicit_known_keys = configurable_keys | plugin_ts_keys - # 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 explicit_known_keys for ts in toolset_names) - - if has_explicit_config: - enabled_toolsets = { - ts for ts in toolset_names - if ts in explicit_known_keys and _toolset_allowed_for_platform(ts, platform) - } - # Mixed config: composite toolset alongside configurables (e.g. - # ``[hermes-cli, spotify]`` after enabling Spotify via ``hermes - # tools``). Without expansion the composite name is silently dropped, - # leaving sessions with only the configurable opt-ins and no native - # tools. Mirror the else-branch's subset inference, but apply - # _DEFAULT_OFF_TOOLSETS only to the implicit expansion — anything the - # user explicitly listed (e.g. ``spotify``) must survive. - composite_tools = set() - for ts_name in toolset_names: - if ts_name not in explicit_known_keys and ts_name in TOOLSETS: - composite_tools.update(resolve_toolset(ts_name)) - - if composite_tools: - enabled_toolsets |= _configurable_subset_of(composite_tools, platform) - _default_off_toolsets( - platform, explicitly_configured - ) - - _enable_recently_shipped_toolsets(enabled_toolsets, config, platform) - else: - # 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)) - enabled_toolsets = _configurable_subset_of(all_tool_names, platform) - - # Auto-enable ``x_search`` when xAI credentials are configured. - # Unlike ``homeassistant`` (whose ``ha_*`` tools live inside the - # platform composite and thus pass the subset check above), - # ``x_search`` is its own one-tool toolset that the composite does - # NOT include, so the subset loop never picks it up. Inject it - # directly here, mirroring the HASS_TOKEN → ``homeassistant`` rule - # below: once you have working creds, you don't have to also click - # through ``hermes tools`` to flip the toolset on. Only fires when - # the user has not yet saved an explicit toolset list — once they - # do, the saved list is authoritative. - x_search_auto_enabled = ( - _toolset_allowed_for_platform("x_search", platform) - and _xai_credentials_present() + if any(ts in explicit_known_keys for ts in toolset_names): + enabled_toolsets = _explicit_toolsets( + toolset_names, explicit_known_keys, config, platform, explicitly_configured ) - if x_search_auto_enabled: - enabled_toolsets.add("x_search") + else: + enabled_toolsets = _composite_toolsets(toolset_names, platform, explicitly_configured) - default_off = _default_off_toolsets(platform, explicitly_configured) - # Symmetric carve-out for x_search auto-enable (see the inject - # block above). Without this, the default_off subtraction would - # strip the entry we just added. - if x_search_auto_enabled: - default_off.discard("x_search") - enabled_toolsets -= default_off - - _recover_platform_native_toolsets(enabled_toolsets, platform, skip=configurable_keys | plugin_ts_keys | platform_default_keys) - - # Plugin toolsets: enabled by default unless explicitly disabled, or - # unless the toolset is in _DEFAULT_OFF_TOOLSETS (e.g. spotify — - # shipped as a bundled plugin but user must opt in via `hermes tools` - # so we don't ship 7 Spotify tool schemas to users who don't use it). - # 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. + _recover_platform_native_toolsets( + enabled_toolsets, platform, skip=configurable_keys | plugin_ts_keys | platform_default_keys + ) if plugin_ts_keys: - known_map = config.get("known_plugin_toolsets", {}) or {} - known_for_platform = set(known_map.get(platform, []) or []) - for pts in plugin_ts_keys: - if pts in toolset_names or ( - pts not in _DEFAULT_OFF_TOOLSETS and pts not in known_for_platform - ): - enabled_toolsets.add(pts) + enabled_toolsets |= _enabled_plugin_toolsets(config, platform, toolset_names, plugin_ts_keys) - # Context-engine tools are runtime-provided by the active engine, so they - # are not part of any static platform composite. When a non-default engine - # is selected, keep its recovery/status tools available even after a user - # saves an explicit platform toolset list. Preserve the explicit empty-list - # contract: selecting no configurable tools means no context-engine tools - # either unless the user adds ``context_engine`` manually later. - context_cfg = config.get("context") or {} - if not isinstance(context_cfg, dict): - context_cfg = {} - context_engine_name = str(context_cfg.get("engine") or "compressor").strip().lower() - if context_engine_name and context_engine_name != "compressor" and not ( - explicitly_configured and not toolset_names - ): + # Context-engine tools are runtime-provided, not in any static composite: keep them for a + # non-default engine even after an explicit save. An explicit EMPTY list means no + # context-engine tools either, unless the user adds ``context_engine`` by hand. + if _context_engine_active(config) and not (explicitly_configured and not toolset_names): enabled_toolsets.add("context_engine") - # Preserve any explicit non-configurable toolset entries (for example, - # custom toolsets or MCP server names saved in platform_toolsets). + # Explicit non-configurable entries (custom toolsets, MCP server names) pass through. explicit_passthrough = { ts for ts in toolset_names if ts not in explicit_known_keys and ts not in platform_default_keys @@ -989,14 +912,10 @@ def _get_platform_tools( config, toolset_names, explicit_passthrough, include_default_mcp_servers ) - # Honor agent.disabled_toolsets from config.yaml — allows users to - # globally suppress specific toolsets (e.g. "memory") across all - # platforms without per-platform toolset configuration. This runs - # last so it overrides everything above. The value may arrive as a - # JSON-array string (e.g. "['memory']") from `hermes config set` or a - # JSON-mode editor save; parse it so the list is not silently dead (#86661). - agent_cfg = config.get("agent") or {} - disabled_toolsets = agent_cfg.get("disabled_toolsets") or [] + # agent.disabled_toolsets is a global suppression list and runs LAST so it overrides everything + # above. It may arrive as a JSON-array string ("['memory']") from `hermes config set` or a + # JSON-mode editor save; parse it so the list is not silently dead. + disabled_toolsets = (config.get("agent") or {}).get("disabled_toolsets") or [] if disabled_toolsets: from agent.skill_utils import parse_config_string_list @@ -1006,7 +925,6 @@ def _get_platform_tools( if explicitly_configured and toolset_names: _warn_all_invalid_platform_toolsets(platform, platform_toolsets[platform]) - return enabled_toolsets @@ -1030,13 +948,12 @@ def _recover_platform_native_toolsets(enabled_toolsets: Set[str], platform: str, skip = skip | {k for k in TOOLSETS if k.startswith("hermes-")} skip |= set(_DEFAULT_OFF_TOOLSETS) - {platform} for ts_key, ts_def in TOOLSETS.items(): - # Posture toolsets (e.g. ``coding``) are session-level selections made - # by agent/coding_context.py — not per-platform capabilities to recover. + # Posture toolsets (``coding``) are session-level selections made by agent/coding_context.py, + # not per-platform capabilities to recover. if ts_key in skip or ts_def.get("includes") or ts_def.get("posture"): continue - # Static membership (see #49622): a registry-added tool absent from the - # platform composite must not block recovery of a non-configurable - # toolset whose authored tools the composite does list. + # Static membership: a registry-added tool absent from the platform composite must not + # block recovery of a non-configurable toolset whose authored tools the composite lists. ts_tools = set(resolve_toolset(ts_key, include_registry=False)) if not ts_tools or not ts_tools.issubset(platform_tool_universe): continue @@ -1070,7 +987,7 @@ def _merge_mcp_servers( def _warn_all_invalid_platform_toolsets(platform: str, explicit: list) -> None: - """#38798: warn once when an explicitly configured platform has only invalid toolset names. + """Warn once when an explicitly configured platform has only invalid toolset names. A migration or hand-edit that left ``hermes`` instead of ``hermes-cli`` makes resolve_toolset() return [] for every entry and the platform silently ends up with no native tools. Surface it @@ -1098,74 +1015,46 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[ """Save the selected toolset keys for a platform to config.""" config.setdefault("platform_toolsets", {}) - # Drop platform-scoped toolsets that don't apply here. Prevents the - # "Configure all platforms" checklist (or a hand-edited config.yaml) - # from turning on, say, the `discord` toolset for Telegram. - enabled_toolset_keys = { - ts for ts in enabled_toolset_keys - if _toolset_allowed_for_platform(ts, platform) - } + # Drop platform-scoped toolsets that don't apply here, so the "Configure all platforms" + # checklist (or a hand-edited config.yaml) can't turn on `discord` for Telegram. + enabled_toolset_keys = {ts for ts in enabled_toolset_keys if _toolset_allowed_for_platform(ts, platform)} - # Get the set of all configurable toolset keys (built-in + plugin) - configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} plugin_keys = _get_plugin_toolset_keys() - configurable_keys |= plugin_keys + configurable_keys = _configurable_keys() | plugin_keys + # Platform defaults (hermes-cli, ...) resolve to ALL tools; preserving one would silently + # override the user's unchecked selections on the next read. + platform_default_keys = _platform_default_keys() - # Also exclude platform default toolsets (hermes-cli, hermes-telegram, etc.) - # These are "super" toolsets that resolve to ALL tools, so preserving them - # would silently override the user's unchecked selections on the next read. - platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()} - - # Get existing toolsets for this platform existing_toolsets = cfg_get(config, "platform_toolsets", platform, default=[]) if not isinstance(existing_toolsets, list): existing_toolsets = [] - existing_toolsets = [str(ts) for ts in existing_toolsets] - - # Preserve any entries that are NOT configurable toolsets and NOT platform - # defaults (i.e. only MCP server names should be preserved) + # Preserve only entries that are neither configurable nor platform defaults (MCP server names). preserved_entries = { - entry for entry in existing_toolsets - if entry not in configurable_keys and entry not in platform_default_keys + str(entry) for entry in existing_toolsets + if str(entry) not in configurable_keys and str(entry) not in platform_default_keys } - # Opening `hermes tools` is the user's opt-in to reconfigure tools, so treat - # saving from the picker as consent to clear the "no_mcp" sentinel. The - # picker has no checkbox for no_mcp, so without this users who once set it - # by hand could never re-enable MCP servers through the UI. + # Saving from the picker is consent to clear the "no_mcp" sentinel: the picker has no checkbox + # for it, so users who once set it by hand could otherwise never re-enable MCP via the UI. preserved_entries.discard("no_mcp") - # Merge preserved entries with new enabled toolsets config["platform_toolsets"][platform] = sorted(enabled_toolset_keys | preserved_entries) - # Track which plugin toolsets are "known" for this platform so we can - # distinguish "new plugin, default enabled" from "user disabled it". - # _cfg_section normalizes a present-but-null key ("known_plugin_toolsets:" - # in config.yaml parses to None) that setdefault alone would not replace. + # Record which plugin toolsets this platform "knows" (distinguishes "new plugin, default + # enabled" from "user disabled it"). _cfg_section normalizes a present-but-null key that + # setdefault alone would not replace. if plugin_keys: _cfg_section(config, "known_plugin_toolsets")[platform] = sorted(plugin_keys) - # Same record for builtin toolsets: which ones this platform's checklist - # has actually put in front of the user. Without it, a toolset the user - # unchecks here is indistinguishable from one that shipped after they - # saved, and _enable_recently_shipped_toolsets would turn it straight back - # on. Recorded from the full catalog, since that is what the picker showed. - _cfg_section(config, "known_builtin_toolsets")[platform] = sorted( - ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS - ) + # Same record for builtin toolsets the checklist actually offered; without it an unchecked + # toolset is indistinguishable from one shipped after the save and + # _enable_recently_shipped_toolsets would turn it straight back on. + _cfg_section(config, "known_builtin_toolsets")[platform] = sorted(_configurable_keys()) - # Reconcile with agent.disabled_toolsets. _get_platform_tools() applies - # that list as a final override AFTER reading platform_toolsets., - # so a toolset listed there stays permanently OFF no matter what this - # function writes — the toggle "saves" but silently can't ever take - # effect. Blank Slate installs pre-populate this list with ~27 toolsets, - # making most of the desktop Toolsets UI unusable for re-enabling - # anything (issue #49995). - # - # Only toolsets the user just explicitly enabled FOR THIS PLATFORM are - # cleared from the global disabled list — toolsets the user did not - # touch (still unchecked) or that remain disabled on other platforms - # are left alone, so agent.disabled_toolsets keeps working as a - # cross-platform suppression list for anything not actively re-enabled. + # Reconcile with agent.disabled_toolsets, which _get_platform_tools applies as a final override: + # a toolset listed there stays OFF no matter what this function writes (Blank Slate installs + # pre-populate ~27 entries, making the desktop Toolsets UI unable to re-enable anything). Only + # toolsets the user just explicitly enabled FOR THIS PLATFORM are cleared, so the list keeps + # working as a cross-platform suppression list for everything else. agent_cfg = config.get("agent") newly_enabled = enabled_toolset_keys - preserved_entries if isinstance(agent_cfg, dict) and agent_cfg.get("disabled_toolsets") and newly_enabled: @@ -1206,27 +1095,19 @@ def _toolset_has_keys( if ts_key in {"web", "image_gen", "video_gen", "tts", "stt", "browser"}: if features is None: - features = get_nous_subscription_features( - config, force_fresh=force_fresh - ) + features = get_nous_subscription_features(config, force_fresh=force_fresh) feature = features.features.get(ts_key) if feature and (feature.available or feature.managed_by_nous): return True - # Check TOOL_CATEGORIES first (provider-aware). A no-key provider - # (Local Browser, Edge TTS) counts as configured. + # Provider-aware categories first: a no-key provider (Local Browser, Edge TTS) counts as configured. cat = TOOL_CATEGORIES.get(ts_key) if cat: return any( _provider_env_ready(provider) for provider in _visible_providers(cat, config, force_fresh=force_fresh, features=features) ) - - # Fallback to simple requirements - requirements = TOOLSET_ENV_REQUIREMENTS.get(ts_key, []) - if not requirements: - return True - return all(get_env_value(var) for var, _ in requirements) + return all(get_env_value(var) for var, _ in TOOLSET_ENV_REQUIREMENTS.get(ts_key, [])) # ─── Menu Helpers ─────────────────────────────────────────────────────────────