From b121a0241666d3704a3dcbc3adb0d79bae0f2445 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:43:27 -0700 Subject: [PATCH] fix(delegate): composite parents can grant their included toolsets to children _expand_parent_toolsets built the parent's tool surface from each toolset's declared `tools` only, so a composite parent's `includes` were invisible: a child of a `debugging` parent (terminal/process_manage + includes web/file) asking for `file` or `web` was refused, and `safe` / `hermes-gateway` parents could grant nothing but their own name. Same root cause as the `_strip_blocked_tools` fix in the previous commit (#111700, "Related" section). Both sides of the subset check now use the resolved static surface (`resolve_toolset(name, include_registry=False)`), so a child may request any toolset whose real tools the parent genuinely holds, and still never gains a tool the parent lacks. Candidates that resolve to nothing are not expanded into (they cannot be a meaningful subset). Co-authored-by: DresvyanskiyDenis --- tests/tools/test_delegate_composite_toolsets.py | 8 ++++++++ tools/delegate_tool_toolsets.py | 14 ++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/tools/test_delegate_composite_toolsets.py b/tests/tools/test_delegate_composite_toolsets.py index fbb984ea4b..246d3aae6d 100644 --- a/tests/tools/test_delegate_composite_toolsets.py +++ b/tests/tools/test_delegate_composite_toolsets.py @@ -26,6 +26,14 @@ class TestExpandParentToolsets(unittest.TestCase): child_toolsets = [t for t in toolsets if t in expanded] self.assertEqual(child_toolsets, ["web"]) + def test_included_toolsets_of_composite_parent_are_grantable(self): + """A composite parent holds its ``includes`` too (#111700): ``debugging`` = terminal/process_manage + + includes web/file, so a child may request ``web``/``file`` — but never a toolset the parent lacks.""" + expanded = _expand_parent_toolsets({"debugging"}) + self.assertTrue({"debugging", "terminal", "web", "file"} <= expanded) + self.assertNotIn("browser", expanded) + self.assertNotIn("hermes-cli", expanded) + def test_composites_with_allowed_included_tools_are_not_stripped(self): toolsets = ["safe", "hermes-gateway", "hermes-cli", "delegation", "kanban"] diff --git a/tools/delegate_tool_toolsets.py b/tools/delegate_tool_toolsets.py index 0252dd9b65..ead1979a39 100644 --- a/tools/delegate_tool_toolsets.py +++ b/tools/delegate_tool_toolsets.py @@ -37,13 +37,19 @@ def _is_mcp_toolset_name(name: str) -> bool: def _expand_parent_toolsets(parent_toolsets: set) -> set: """Add every toolset whose tools are a subset of the parent's tools: a parent on a composite like ``hermes-cli`` - must still let a child request ``web``/``terminal``; bare name intersection would reject them.""" - parent_tool_names = {t for ts_name in parent_toolsets for t in (TOOLSETS.get(ts_name) or {}).get("tools", [])} + must still let a child request ``web``/``terminal``; bare name intersection would reject them. Both sides use + the RESOLVED static surface: a composite's ``includes`` (``debugging`` -> ``web``/``file``, ``safe``) are tools + the parent genuinely holds, and the child never gains a tool the parent lacks.""" + parent_tool_names = { + t for ts_name in parent_toolsets if ts_name in TOOLSETS for t in resolve_toolset(ts_name, include_registry=False) + } expanded = set(parent_toolsets) if parent_tool_names: expanded.update( - ts_name for ts_name, ts_def in TOOLSETS.items() - if ts_name not in expanded and ts_def.get("tools") and set(ts_def["tools"]).issubset(parent_tool_names) + ts_name for ts_name in TOOLSETS + if ts_name not in expanded + and (resolved := resolve_toolset(ts_name, include_registry=False)) + and set(resolved).issubset(parent_tool_names) ) return expanded