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 <dresvyanskiydenis@gmail.com>
This commit is contained in:
teknium1
2026-09-15 11:43:27 -07:00
committed by Teknium
parent 3d4c4cc24d
commit b121a02416
2 changed files with 18 additions and 4 deletions

View File

@@ -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"]

View File

@@ -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