NousResearch/hermes-agent

disabled_toolsets composite bundles stripping explicitly enabled tools

Cluster 33924 · agent. Data as of

1 open item. Fix merged in #59150 on 5 Jul 2026. Check it covers your case.

The short answer

0open issues
1open PR
14items grouped
Known?
Yes. 14 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #59150. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PRs #50109, #59150. That is separate from the fix event.
Still open
0 issues and 1 PR.

Most members concern how disabling a composite or platform toolset bundle (e.g. coding, hermes-yuanbao) in disabled_toolsets may also remove overlapping tools that were explicitly enabled, leaving the model with few or no tools. They include bug reports and several proposed changes that preserve enabled tools, resolve bundle names, or reject platform-bundle names.

Open issues0

No open issues.

Open PRs1

  • PR#60399fix(tools): prevent composite disabled_toolsets from stripping explicitly enabled tools

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #50109 fix(tools): preserve core tools when a platform bundle is disabled

    merged to main

    files: model_tools.py, tests/test_model_tools.py, toolsets.py

    Show the diff
    • model_tools.py
      @@ -34,6 +34,10 @@
       
       logger = logging.getLogger(__name__)
       
      +# Tracks platform-bundle names already flagged in disabled_toolsets so the
      +# advisory (#33924) is logged once per name, not on every tool recompute.
      +_WARNED_DISABLED_BUNDLES: set = set()
      +
       
       # =============================================================================
       # Async Bridging  (single source of truth -- used by registry.dispatch too)
      @@ -392,8 +396,29 @@ def _compute_tool_definitions(
           if disabled_toolsets:
               for toolset_name in disabled_toolsets:
                   if validate_toolset(toolset_name):
      -                resolved = resolve_toolset(toolset_name)
      -                tools_to_include.difference_update(resolved)
      +                if toolset_name.startswith("hermes-"):
      +                    # Platform bundles (hermes-*) include _HERMES_CORE_TOOLS, so
      +                    # subtracting the whole bundle would strip core tools shared
      +                    # by other enabled toolsets and empty the tool list (#33924).
      +                    # Subtract only the bundle's non-core delta; keep core.
      +                    from toolsets import bundle_non_core_tools
      +                    to_remove = bundle_non_core_tools(toolset_name)
      +                    tools_to_include.difference_update(to_remove)
      +                    resolved = sorted(to_remove)
      +                    if not quiet_mode and toolset_name not in _WARNED_DISABLED_BUNDLES:
      +                        _WARNED_DISABLED_BUNDLES.add(toolset_name)
      +                        logger.info(
      +                            "agent.disabled_toolsets contains platform-bundle "
      +                            "name '%s'; core tools are preserved and only its "
      +                            "platform-specific tools (%s) are removed. Bundle "
      +                            "names usually belong in `toolsets:`, not "
      +                            "`disabled_toolsets` (#33924).",
      +                            toolset_name,
      +                            ", ".join(resolved) if resolved else "none",
      +                        )
      +                else:
      +                    resolved = resolve_toolset(toolset_name)
      +                    tools_to_include.difference_update(resolved)
                       if not quiet_mode:
                           print(f"🚫 Disabled toolset '{toolset_name}': {', '.join(resolved) if resolved else 'no tools'}")
                   elif toolset_name in _LEGACY_TOOLSET_MAP:
    • tests/test_model_tools.py
      @@ -457,3 +457,82 @@ def test_normal_numbers_still_coerce(self):
               assert _coerce_number("42") == 42
               assert _coerce_number("3.14") == 3.14
               assert _coerce_number("1e3") == 1000
      +
      +class TestDisabledToolsetsPlatformBundle:
      +    """Regression test for #33924: disabling a platform bundle (hermes-*)
      +    must not remove core tools from other enabled toolsets."""
      +
      +    def test_disabling_platform_bundle_preserves_core_tools(self):
      +        """Disabling hermes-yuanbao should not strip core tools from hermes-telegram."""
      +        from model_tools import get_tool_definitions
      +
      +        tools_telegram = get_tool_definitions(
      +            enabled_toolsets=["hermes-telegram"],
      +            quiet_mode=True,
      +        )
      +        tools_telegram_no_yuanbao = get_tool_definitions(
      +            enabled_toolsets=["hermes-telegram"],
      +            disabled_toolsets=["hermes-yuanbao"],
      +            quiet_mode=True,
      +        )
      +        names_telegram = {t["function"]["name"] for t in tools_telegram}
      +        names_no_yuanbao = {t["function"]["name"] for t in tools_telegram_no_yuanbao}
      +
      +        # Disabling a *different* platform bundle must not remove any tools
      +        assert names_telegram == names_no_yuanbao, (
      +            f"Tools lost after disabling hermes-yuanbao: "
      +            f"{names_telegram - names_no_yuanbao}"
      +        )
      +
      +    def test_disabling_platform_bundle_removes_own_tools(self):
      +        """Disabling hermes-discord should remove discord-specific tools."""
      +        from model_tools import get_tool_definitions
      +
      +        tools = get_tool_definitions(
      +            enabled_toolsets=["hermes-discord"],
      +            disabled_toolsets=["hermes-discord"],
      +            quiet_mode=True,
      +        )
      +        names = {t["function"]["name"] for t in tools}
      +        assert "discord" not in names
      +
      +    def test_disabling_non_platform_toolset_still_works(self):
      +        """Disabling a regular (non-hermes-) toolset still subtracts all tools."""
      +        from model_tools import get_tool_definitions
      +
      +        tools_normal = get_tool_definitions(
      +            enabled_toolsets=["hermes-telegram"],
      +            quiet_mode=True,
      +        )
      +        tools_no_web = get_tool_definitions(
      +            enabled_toolsets=["hermes-telegram"],
      +            disabled_toolsets=["web"],
      +            quiet_mode=True,
      +        )
      +        names_normal = {t["function"]["name"] for t in tools_normal}
      +        names_no_web = {t["function"]["name"] for t in tools_no_web}
      +
      +        web_tools = {"web_search", "web_extract"}
      +        removed = names_normal - names_no_web
      +        # web tools should be removed (if they were present)
      +        present_web = web_tools & names_normal
      +        assert present_web <= removed, (
      +            f"Web tools not removed: {present_web - removed}"
      +        )
      +
      +
      +    def test_disabling_bundle_removes_platform_tools_but_keeps_core(self):
      +        """Disabling hermes-discord (when enabled) removes discord/discord_admin
      +        from the resolved delta but keeps core tools — via bundle_non_core_tools."""
      +        from toolsets import bundle_non_core_tools, _HERMES_CORE_TOOLS
      +
      +        delta = bundle_non_core_tools("hermes-yuanbao")
      +        # The delta is the bundle's platform-specific tools, NOT core.
      +        assert "yb_send_dm" in delta
      +        assert not (delta & set(_HERMES_CORE_TOOLS)), "core tools must not be in the removal delta"
      +
      +    def test_bundle_non_core_tools_unknown_falls_back(self):
      +        """An unknown/garbage bundle name falls back to full resolution (best effort)."""
      +        from toolsets import bundle_non_core_tools
      +        # A non-existent bundle resolves to an empty set (no tools), not a crash.
      +        assert bundle_non_core_tools("hermes-does-not-exist") == set()
    • toolsets.py
      @@ -627,6 +627,34 @@ def get_toolset(name: str) -> Optional[Dict[str, Any]]:
           }
       
       
      +def bundle_non_core_tools(toolset_name: str) -> Set[str]:
      +    """Return a ``hermes-*`` bundle's platform-specific tools, excluding core.
      +
      +    Platform bundles are defined as ``_HERMES_CORE_TOOLS + [platform extras]``.
      +    When a bundle name appears in ``disabled_toolsets``, subtracting the whole
      +    bundle would strip core tools (terminal, read_file, …) shared by every
      +    other enabled toolset, emptying the model's tool list (#33924). This
      +    returns only the bundle's non-core delta (its own extras plus those of any
      +    one-level ``includes``), so disabling a bundle removes its platform tools
      +    while leaving core intact.
      +
      +    Bundle nesting is one level deep in practice (only ``hermes-gateway``
      +    includes other bundles, and those leaves don't nest further), so a single
      +    ``includes`` pass is sufficient. Unknown/garbage names fall back to the
      +    full resolution minus core — never re-introducing the core wipe.
      +    """
      +    core = set(_HERMES_CORE_TOOLS)
      +    ts_def = get_toolset(toolset_name)
      +    if not (ts_def and "tools" in ts_def):
      +        return set(resolve_toolset(toolset_name)) - core
      +    to_remove = set(ts_def["tools"]) - core
      +    for inc in ts_def.get("includes", []):
      +        inc_def = get_toolset(inc)
      +        if inc_def and "tools" in inc_def:
      +            to_remove.update(set(inc_def["tools"]) - core)
      +    return to_remove
      +
      +
       def resolve_toolset(name: str, visited: Set[str] = None) -> List[str]:
           """
           Recursively resolve a toolset to get all tool names.
  • #59150 fix(setup+toolsets): blank slate no longer strips its own core tools via the coding posture bundle

    merged to main

    files: hermes_cli/setup.py, model_tools.py, tests/hermes_cli/test_setup_blank_slate.py, tests/test_model_tools.py

9 PRs closed without merging

From the close comments: done elsewhere 4 · superseded by another PR 2 · merged via another change 1 · other 2

  • #33941 fix(tools): preserve core tools when disabling platform bundles in disabled_toolsets · other → #50109
  • #34033 fix(tools): resolve bundle names in disabled_toolsets before subtraction · superseded by another PR → #50109
  • #34073 fix(tools_config): reject platform-bundle names in disabled_toolsets (#33924) · superseded by another PR → #50109
  • #58310 fix: preserve explicit leaf tools when disabling coding · merged via another change → e5636da, #59150
  • #58496 fix(model_tools): preserve enabled toolsets when disabling composite toolsets · done elsewhere → #58281, #59150, #17309, #33924
Show 4 more
  • #58654 fix: preserve explicitly-enabled tools when disabling overlapping composite toolset · done elsewhere → #58281, #59150, #17309, #33924
  • #58800 fix(model_tools): preserve explicitly-enabled tools when disabling a composite toolset · done elsewhere → #58281, #59150, #17309, #33924
  • #59299 fix(tools): preserve explicitly-enabled tools when disabling composite toolsets · done elsewhere → e5636da, #58281, #57315
  • #60273 fix(tools): prevent composite disabled_toolsets from stripping explicitly enabled tools · other
Closed issues · 2
  • Closed#33924Bug: Plattform-Bundle name `hermes-yuanbao` in `agent.disabled_toolsets` silently kills ALL tools in gateway path (Telegram + cron), CLI unaffected
  • Closed#58281[Bug]: [Bug]: Disabling the `coding` toolset silently strips explicitly-enabled `terminal`/`file` tools — model receives Tools: 0
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.14: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#50109, #59150
Closed issues
2
Attempts
9 PRs closed without merging
Fix link checked
5 Jul 2026

For your agent

The same facts, typed.