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