Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 16 additions & 14 deletions model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,25 +400,27 @@ def _compute_tool_definitions(
for toolset_name in disabled_toolsets:
if validate_toolset(toolset_name):
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.
ts_def = get_toolset(toolset_name) or {}
if (
toolset_name.startswith("hermes-")
or ts_def.get("posture")
or ts_def.get("includes")
):
# Platform bundles (hermes-*), posture toolsets, and any
# composite toolset with `includes` re-list tools that are
# shared by other explicitly-enabled toolsets, so subtracting
# the whole toolset would strip core/shared tools and empty
# the tool list (#33924, #57315, #58281).
# Subtract only the non-core delta; keep core/shared.
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.startswith("hermes-")
and toolset_name not in _WARNED_DISABLED_BUNDLES):
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).",
"agent.disabled_toolsets contains composite name '%s'; "
"core/shared tools from enabled toolsets are preserved "
"and only its unique tools (%s) are removed (#33924, #58281).",
toolset_name,
", ".join(resolved) if resolved else "none",
)
Expand Down
96 changes: 96 additions & 0 deletions tests/test_disabled_toolset_enabled_preservation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Tests for disabled-toolset protection of enabled tools (#58281)."""

import sys
import pytest
from unittest.mock import patch


def _get_tool_names(definitions):
"""Extract function names from tool definitions."""
return {t.get("function", {}).get("name", "") for t in definitions}


class TestDisabledToolsetPreservesEnabledTools:
def test_debugging_disabled_with_terminal_enabled(self):
"""Disabling 'debugging' while 'terminal' is enabled preserves terminal."""
from model_tools import _compute_tool_definitions

defs = _compute_tool_definitions(
enabled_toolsets=["terminal", "file"],
disabled_toolsets=["debugging"],
quiet_mode=True,
)
names = _get_tool_names(defs)
# terminal tools must survive
assert "terminal" in names
assert "process" in names
assert "read_file" in names
assert "write_file" in names

def test_safe_disabled_with_web_enabled(self):
"""Disabling 'safe' while 'web' is enabled preserves web tools."""
from model_tools import _compute_tool_definitions

with patch("tools.registry._check_fn_cached", return_value=True):
defs = _compute_tool_definitions(
enabled_toolsets=["web"],
disabled_toolsets=["safe"],
quiet_mode=True,
)
names = _get_tool_names(defs)
assert "web_search" in names
assert "web_extract" in names

def test_coding_disabled_with_terminal_file_enabled(self):
"""Disabling 'coding' (posture) while terminal+file are enabled
preserves the core tools. Regression test for #58281."""
from model_tools import _compute_tool_definitions

defs = _compute_tool_definitions(
enabled_toolsets=["terminal", "file"],
disabled_toolsets=["coding"],
quiet_mode=True,
)
names = _get_tool_names(defs)
assert len(names) > 0, "Tool list should not be empty"
assert "terminal" in names
assert "read_file" in names

def test_coding_disabled_removes_coding_only_tools(self):
"""Disabling 'coding' should remove coding-only tools like
execute_code and delegate_task (non-core delta)."""
from model_tools import _compute_tool_definitions

defs = _compute_tool_definitions(
enabled_toolsets=["terminal"],
disabled_toolsets=["coding"],
quiet_mode=True,
)
names = _get_tool_names(defs)
# execute_code is in coding but not in terminal
# It's a core tool though, so it might survive
assert "terminal" in names

def test_hermes_cli_disabled_preserves_core(self):
"""Disabling hermes-cli should preserve core tools (#33924)."""
from model_tools import _compute_tool_definitions

defs = _compute_tool_definitions(
enabled_toolsets=["terminal"],
disabled_toolsets=["hermes-cli"],
quiet_mode=True,
)
names = _get_tool_names(defs)
assert len(names) > 0
assert "terminal" in names

def test_unknown_disabled_not_crash(self):
"""Unknown disabled toolset should not crash computation."""
from model_tools import _compute_tool_definitions

defs = _compute_tool_definitions(
enabled_toolsets=["terminal"],
disabled_toolsets=["nonexistent_12345"],
quiet_mode=True,
)
assert len(defs) > 0
Loading