From e5636da5d868c088cb2c8dadd2218665cd624671 Mon Sep 17 00:00:00 2001 From: Brett Bonner <1772563+bbopen@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:24:43 -0400 Subject: [PATCH] fix(toolsets): preserve core tools when a posture toolset is in disabled_toolsets (#57315) The disabled_toolsets subtraction loop in _compute_tool_definitions preserved shared core tools only for hermes-* platform bundles (#33924), subtracting bundle_non_core_tools(); every other name took the else branch and got a full resolve_toolset() subtraction. The `coding` toolset is a posture toolset (posture: True) that re-lists the shared _HERMES_CORE_TOOLS it does not own, so disabled_toolsets=["coding"] stripped those core tools from the whole schema (34 tools collapsed to a handful; terminal/read_file/write_file/web_search/execute_code gone). Extend the core-preserving branch to also match posture toolsets, so they subtract only the non-core delta. Only `coding` carries posture: True, so atomic toolsets stay fully removable. The bundle-misconfiguration info log is gated to hermes-* names, since its wording is bundle-specific and disabled_toolsets=["coding"] is a legitimate config written by older `hermes setup` runs. Adds a regression test (TestDisabledToolsetsPostureToolset) alongside the existing #33924 bundle tests. --- model_tools.py | 17 +++++++------ tests/test_model_tools.py | 51 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/model_tools.py b/model_tools.py index c3c3a11a66f..7aed8acd589 100644 --- a/model_tools.py +++ b/model_tools.py @@ -399,16 +399,19 @@ def _compute_tool_definitions( if disabled_toolsets: for toolset_name in disabled_toolsets: if validate_toolset(toolset_name): - 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 + from toolsets import bundle_non_core_tools, get_toolset + if toolset_name.startswith("hermes-") or (get_toolset(toolset_name) or {}).get("posture"): + # Platform bundles (hermes-*) include _HERMES_CORE_TOOLS, and + # posture toolsets (`posture: True`, e.g. `coding`) re-list + # those same core tools without owning them, so subtracting + # the whole toolset would strip core tools shared by other + # enabled toolsets and empty the tool list (#33924, #57315). + # Subtract only the non-core delta; keep core. 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: + if (not quiet_mode and toolset_name.startswith("hermes-") + and toolset_name not in _WARNED_DISABLED_BUNDLES): _WARNED_DISABLED_BUNDLES.add(toolset_name) logger.info( "agent.disabled_toolsets contains platform-bundle " diff --git a/tests/test_model_tools.py b/tests/test_model_tools.py index 9eff5c5b2d3..469b8a6921e 100644 --- a/tests/test_model_tools.py +++ b/tests/test_model_tools.py @@ -536,3 +536,54 @@ class TestDisabledToolsetsPlatformBundle: 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() + + +class TestDisabledToolsetsPostureToolset: + """Regression test for #57315: disabling a posture toolset (`coding`, + posture: True) must preserve the shared core tools it re-lists but does + not own -- same non-core-delta subtraction as hermes-* bundles (#33924) -- + while atomic toolsets stay fully removable.""" + + def test_disabling_coding_preserves_core_but_atomic_disables_still_remove(self): + from model_tools import get_tool_definitions + + # web_search is check_fn-gated (needs an API key); probe only the core + # tools actually present in baseline so gating cannot mask the fix. + core_probe = {"terminal", "read_file", "write_file", "web_search", "execute_code"} + + baseline = { + t["function"]["name"] + for t in get_tool_definitions(quiet_mode=True) + } + present_core = core_probe & baseline + # Sanity: at least some probed core tools are available in this env. + assert present_core, "no probed core tools present in baseline" + + no_coding = { + t["function"]["name"] + for t in get_tool_definitions( + disabled_toolsets=["coding"], quiet_mode=True + ) + } + # Previously the full resolve_toolset("coding") subtraction stripped + # these shared core tools, collapsing the schema to a handful (#57315). + assert present_core <= no_coding, ( + f"Core tools stripped by disabling 'coding': {present_core - no_coding}" + ) + + # Atomic (non-posture) toolsets must still be fully removable. + no_terminal = { + t["function"]["name"] + for t in get_tool_definitions( + disabled_toolsets=["terminal"], quiet_mode=True + ) + } + assert "terminal" not in no_terminal + + no_file = { + t["function"]["name"] + for t in get_tool_definitions( + disabled_toolsets=["file"], quiet_mode=True + ) + } + assert "write_file" not in no_file