From e795b7e3ab1df4dd1998f1eb4f77732396b4a69a Mon Sep 17 00:00:00 2001 From: luyao618 <364939526@qq.com> Date: Mon, 4 May 2026 09:42:13 +0800 Subject: [PATCH] fix(delegate): expand composite toolsets before intersection in delegate_task When the parent agent uses a composite toolset like hermes-cli, calling delegate_task with individual toolsets (e.g. web, terminal) resulted in zero tools because the name-based intersection failed: 'web' != 'hermes-cli'. Add _expand_parent_toolsets() which collects all tool names from parent toolsets, then recognises any individual toolset whose tools are a subset of the parent's available tools. This allows delegate_task(toolsets=['web']) to work correctly when the parent has hermes-cli enabled. Fixes #19447 --- .../tools/test_delegate_composite_toolsets.py | 46 +++++++++++++++++++ tools/delegate_tool.py | 38 ++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 tests/tools/test_delegate_composite_toolsets.py diff --git a/tests/tools/test_delegate_composite_toolsets.py b/tests/tools/test_delegate_composite_toolsets.py new file mode 100644 index 0000000000..8546023994 --- /dev/null +++ b/tests/tools/test_delegate_composite_toolsets.py @@ -0,0 +1,46 @@ +"""Tests for composite toolset expansion in delegate_task intersection.""" + +import unittest +from unittest.mock import patch + +from tools.delegate_tool import _expand_parent_toolsets + + +class TestExpandParentToolsets(unittest.TestCase): + """Verify _expand_parent_toolsets recognises individual toolsets within composites.""" + + def test_composite_hermes_cli_expands_web(self): + """hermes-cli includes web_search/web_extract → 'web' should be in expansion.""" + expanded = _expand_parent_toolsets({"hermes-cli"}) + self.assertIn("web", expanded) + self.assertIn("terminal", expanded) + self.assertIn("browser", expanded) + # Original composite is preserved + self.assertIn("hermes-cli", expanded) + + def test_individual_toolset_unchanged(self): + """When parent already uses individual toolsets, expansion keeps them.""" + expanded = _expand_parent_toolsets({"web", "terminal"}) + self.assertIn("web", expanded) + self.assertIn("terminal", expanded) + + def test_empty_parent_toolsets(self): + expanded = _expand_parent_toolsets(set()) + self.assertEqual(expanded, set()) + + def test_unknown_toolset_passthrough(self): + """Unknown toolset names pass through without error.""" + expanded = _expand_parent_toolsets({"nonexistent-toolset-xyz"}) + self.assertIn("nonexistent-toolset-xyz", expanded) + + def test_intersection_with_expanded_composite(self): + """End-to-end: requesting ['web'] from parent with ['hermes-cli'] yields ['web'].""" + parent_toolsets = {"hermes-cli"} + expanded = _expand_parent_toolsets(parent_toolsets) + toolsets = ["web"] + child_toolsets = [t for t in toolsets if t in expanded] + self.assertEqual(child_toolsets, ["web"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 7b4595cb71..5a1ec534f8 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -462,6 +462,37 @@ def _is_mcp_toolset_name(name: str) -> bool: return bool(target and str(target).startswith("mcp-")) +def _expand_parent_toolsets(parent_toolsets: set) -> set: + """Expand composite toolsets so individual toolset names are recognized. + + When a parent uses a composite toolset like ``hermes-cli`` (which bundles + all core tools), the child may request individual toolsets such as ``web`` + or ``terminal``. A simple name-based intersection would reject them + because ``"web" != "hermes-cli"``. + + This helper collects the tool names from each parent toolset, then adds + the names of any individual toolsets whose tools are a *subset* of the + parent's available tools. The original parent toolset names are preserved. + """ + parent_tool_names: set = set() + for ts_name in parent_toolsets: + ts_def = TOOLSETS.get(ts_name) + if ts_def: + parent_tool_names.update(ts_def.get("tools", [])) + + if not parent_tool_names: + return set(parent_toolsets) + + expanded = set(parent_toolsets) + for ts_name, ts_def in TOOLSETS.items(): + if ts_name in expanded: + continue + ts_tools = ts_def.get("tools", []) + if ts_tools and set(ts_tools).issubset(parent_tool_names): + expanded.add(ts_name) + return expanded + + def _preserve_parent_mcp_toolsets( child_toolsets: List[str], parent_toolsets: set[str] ) -> List[str]: @@ -907,8 +938,11 @@ def _build_child_agent( parent_toolsets = set(DEFAULT_TOOLSETS) if toolsets: - # Intersect with parent — subagent must not gain tools the parent lacks - child_toolsets = [t for t in toolsets if t in parent_toolsets] + # Intersect with parent — subagent must not gain tools the parent lacks. + # Expand composite toolsets (e.g. hermes-cli) so that individual + # toolset names (e.g. web, terminal) are recognised during intersection. + expanded_parent = _expand_parent_toolsets(parent_toolsets) + child_toolsets = [t for t in toolsets if t in expanded_parent] if _get_inherit_mcp_toolsets(): child_toolsets = _preserve_parent_mcp_toolsets( child_toolsets, parent_toolsets