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

When a platform-bundle name (e.g. `hermes-yuanbao`, or any `hermes-*`) lands
in `agent.disabled_toolsets`, the shared tool-assembly path
(`model_tools._compute_tool_definitions`, used by the gateway, cron, AND the
CLI) subtracted the WHOLE bundle from the enabled set. Because every platform
bundle is defined as `_HERMES_CORE_TOOLS + [platform extras]`, and core tools
are shared by every other enabled toolset, the subtraction emptied the tool
list entirely — the model received `tools: []` / `tool_choice: null` and
started replying "I cannot execute shell commands" with no error, no warning,
and `hermes tools list` / `hermes doctor` still green. For unattended cron
jobs this fails silently for days. (#33924)

(The original report framed this as gateway-only; it actually affects every
caller of `_compute_tool_definitions`, including the CLI — the reporter's
follow-up confirms this. Fixing the shared chokepoint covers all paths.)

Fix: for a `hermes-*` bundle in `disabled_toolsets`, subtract only its
*non-core delta* (its platform-specific tools plus those of any `includes`),
leaving `_HERMES_CORE_TOOLS` intact. Disabling a bundle now removes its
platform tools (e.g. the `yb_*` tools for `hermes-yuanbao`) while terminal,
read_file, web, etc. survive. A `logger.warning` notes that core tools are
preserved and that bundle names usually belong in `toolsets:`, not
`disabled_toolsets` — informative, not destructive (the subtraction still
behaves sensibly).

Salvaged from #33941 by @liuhao1024 (authorship preserved). Extracted the
inline bundle-resolution into a module-level `_bundle_non_core_tools` helper
(was re-importing `toolsets` inside the disable loop), and added the
informative warning folding in the UX intent of #34073 (@ousiaresearch)
without its hard "ignore the bundle name" behavior — which would have undone
this fix's sensible-subtraction.

Verified empirically: disabling `hermes-yuanbao` from a gateway-style enabled
set keeps all core tools (18→18) and would remove only the 5 `yb_*` tools;
disabling `hermes-discord` removes only `discord`/`discord_admin`.

Fixes #33924

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
This commit is contained in:
liuhao1024 2026-06-21 16:22:55 +05:30
parent 7b9a0b315b
commit dd042fc4df
3 changed files with 134 additions and 2 deletions

View file

@ -457,3 +457,82 @@ class TestCoerceNumberInfNan:
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()