From 97c6a183af6baf1de3d2ca3ddbb1d2487ee7a24c Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:46:55 -0400 Subject: [PATCH] auto populate flux3 in tools for nous portal users --- hermes_cli/config.py | 1 + hermes_cli/tools_config.py | 73 ++++++++++++++++++ tests/hermes_cli/test_tools_config.py | 106 ++++++++++++++++++++++++++ 3 files changed, 180 insertions(+) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4970c4c7f80..4523f92fd74 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1853,6 +1853,7 @@ _EXTRA_KNOWN_ROOT_KEYS = { "smart_model_routing", # written by the setup wizard (hermes_cli/setup.py) "platform_toolsets", # written by the setup wizard (hermes_cli/setup.py) "known_plugin_toolsets", # written/read by hermes_cli/tools_config.py toolset-save flow + "known_builtin_toolsets", # ditto — which builtin toolsets a platform's checklist has offered "session_reset", # top-level form read by gateway/config.py + setup "group_sessions_per_user", # top-level form bridged by gateway/config.py "thread_sessions_per_user", # top-level form bridged by gateway/config.py diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index a777db1f1e3..162a6427643 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -2132,6 +2132,66 @@ 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". +#: +#: 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. +#: +#: Not gated on a Nous subscription here: the six ``bfl_flux3_*`` tools carry +#: ``check_fn=check_bfl_requirements`` (logged in AND paid), so an enabled +#: toolset still ships zero schemas to a user without paid portal access — the +#: same split Home Assistant uses. Probing the portal from this path would put +#: a network call on every CLI start, gateway session and cron tick. +_RECENTLY_SHIPPED_TOOLSETS = frozenset({"bfl"}) + + +def _enable_recently_shipped_toolsets( + enabled_toolsets: Set[str], config: dict, platform: str +) -> None: + """Turn on toolsets that shipped after this platform's saved list. + + Either way of saying no outlives this: unchecking in ``hermes tools`` + records the toolset in ``known_builtin_toolsets`` so it reads as declined + from then on, and ``agent.disabled_toolsets`` is subtracted after every + rule in :func:`_get_platform_tools`. Mutates ``enabled_toolsets`` in place. + """ + from toolsets import resolve_toolset + + offered = (config.get("known_builtin_toolsets") or {}).get(platform) + declined = {str(ts) for ts in offered} if isinstance(offered, list) else set() + + plat_info = PLATFORMS.get(platform) + default_ts = plat_info["default_toolset"] if plat_info else f"hermes-{platform}" + composite_tools = None + + for ts_key in sorted(_RECENTLY_SHIPPED_TOOLSETS): + if ts_key in enabled_toolsets or ts_key in declined: + 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 + # narrow composites (hermes-acp, hermes-webhook) stay narrow. + ts_tools = set(resolve_toolset(ts_key, include_registry=False)) + if composite_tools is None: + composite_tools = set(resolve_toolset(default_ts)) + if not ts_tools or not ts_tools.issubset(composite_tools): + continue + enabled_toolsets.add(ts_key) + + def _get_platform_tools( config: dict, platform: str, @@ -2217,6 +2277,8 @@ def _get_platform_tools( expanded -= default_off enabled_toolsets |= expanded + + _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. @@ -2484,6 +2546,17 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[ config["known_plugin_toolsets"] = {} 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. + if not isinstance(config.get("known_builtin_toolsets"), dict): + config["known_builtin_toolsets"] = {} + config["known_builtin_toolsets"][platform] = sorted( + ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS + ) + # 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 diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index ad05e6b82bd..18902ebd589 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -9,6 +9,7 @@ import pytest from hermes_cli.nous_account import NousPortalAccountInfo from hermes_cli.tools_config import ( _DEFAULT_OFF_TOOLSETS, + _RECENTLY_SHIPPED_TOOLSETS, _apply_toolset_change, _checklist_toolset_keys, _configure_provider, @@ -553,3 +554,108 @@ def _fake_features(*, logged_in: bool, paid: bool = True): # ("browserbase") only the CLI, and camofox its npm package. +# ── Toolsets that shipped after a platform's last `hermes tools` save ──────── +# +# Saving the picker (or one toggle in the desktop Toolsets UI) replaces a +# platform's composite (``[hermes-cli]``) with a frozen explicit list, and +# nothing ever adds to that list — so a toolset shipped later stays off +# forever, while everyone still on the composite inherits it on upgrade. +# ``_RECENTLY_SHIPPED_TOOLSETS`` closes that gap for toolsets new enough that +# absence from a saved list cannot mean the user declined them. +# +# Every assertion here is a subset test against that set, which passes +# vacuously once it empties out — and empty is the steady state between +# releases. Skip loudly rather than going quietly green. +_requires_recently_shipped = pytest.mark.skipif( + not _RECENTLY_SHIPPED_TOOLSETS, + reason="no toolset is currently inside its first release", +) + + +def _saved_list_from_before(platform="cli"): + """A saved explicit list as it looked before the new toolsets existed.""" + from hermes_cli.tools_config import ( + _CONFIG_ONLY_TOOLSETS, + _toolset_allowed_for_platform, + ) + + return { + "platform_toolsets": { + platform: sorted( + ts_key + for ts_key, _, _ in CONFIGURABLE_TOOLSETS + if ts_key not in _RECENTLY_SHIPPED_TOOLSETS + and ts_key not in _DEFAULT_OFF_TOOLSETS + and ts_key not in _CONFIG_ONLY_TOOLSETS + and _toolset_allowed_for_platform(ts_key, platform) + ) + } + } + + +@_requires_recently_shipped +def test_saved_list_gains_toolsets_that_shipped_after_it_was_written(): + """The bug: a frozen list never gained bfl, so composite users got Nous + Portal video generation on upgrade and picker users silently did not.""" + on_composite = _get_platform_tools( + {"platform_toolsets": {"cli": ["hermes-cli"]}}, + "cli", + include_default_mcp_servers=False, + ) + on_saved_list = _get_platform_tools( + _saved_list_from_before(), "cli", include_default_mcp_servers=False + ) + + assert _RECENTLY_SHIPPED_TOOLSETS <= (on_composite & on_saved_list) + + +@_requires_recently_shipped +def test_unchecking_the_new_toolset_sticks(): + """Saving records it as offered, so the next read reads absence as a + decline instead of turning it back on.""" + config = {"platform_toolsets": {"cli": ["hermes-cli"]}} + enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False) + with patch("hermes_cli.tools_config.save_config"): + _save_platform_tools(config, "cli", enabled - _RECENTLY_SHIPPED_TOOLSETS) + + reread = _get_platform_tools(config, "cli", include_default_mcp_servers=False) + + assert not (_RECENTLY_SHIPPED_TOOLSETS & reread) + + +@_requires_recently_shipped +def test_agent_disabled_toolsets_still_wins(): + """The other way to say no — a global suppression list applied last.""" + config = _saved_list_from_before() + config["agent"] = {"disabled_toolsets": sorted(_RECENTLY_SHIPPED_TOOLSETS)} + + enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False) + + assert not (_RECENTLY_SHIPPED_TOOLSETS & enabled) + + +@_requires_recently_shipped +def test_platforms_whose_composite_excludes_it_are_left_narrow(): + """Parity is the justification, so don't widen a deliberately small + composite (hermes-acp, hermes-webhook) that never carried the toolset.""" + from toolsets import TOOLSETS, resolve_toolset + + narrow = [ + platform + for platform in ("acp", "webhook") + if f"hermes-{platform}" in TOOLSETS + and not any( + set(resolve_toolset(ts, include_registry=False)) + <= set(resolve_toolset(f"hermes-{platform}")) + for ts in _RECENTLY_SHIPPED_TOOLSETS + ) + ] + assert narrow, "expected a composite that excludes the new toolset" + + for platform in narrow: + enabled = _get_platform_tools( + _saved_list_from_before(platform), + platform, + include_default_mcp_servers=False, + ) + assert not (_RECENTLY_SHIPPED_TOOLSETS & enabled), platform