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

@ -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.