fix(delegate): make MCP toolset inheritance configurable

This commit is contained in:
helix4u 2026-04-22 18:18:50 -06:00 committed by Teknium
parent 98e1396b15
commit 3e96c87f37
4 changed files with 100 additions and 6 deletions

View file

@ -776,6 +776,7 @@ delegation:
# max_concurrent_children: 3 # Max parallel child agents (default: 3) # max_concurrent_children: 3 # Max parallel child agents (default: 3)
# max_spawn_depth: 1 # Tree depth cap (1-3, default: 1 = flat). Raise to 2 or 3 to allow orchestrator children to spawn their own workers. # max_spawn_depth: 1 # Tree depth cap (1-3, default: 1 = flat). Raise to 2 or 3 to allow orchestrator children to spawn their own workers.
# orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true). # orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true).
# inherit_mcp_toolsets: true # When explicit child toolsets are narrowed, also keep the parent's MCP toolsets (default: false).
# model: "google/gemini-3-flash-preview" # Override model for subagents (empty = inherit parent) # model: "google/gemini-3-flash-preview" # Override model for subagents (empty = inherit parent)
# provider: "openrouter" # Override provider for subagents (empty = inherit parent) # provider: "openrouter" # Override provider for subagents (empty = inherit parent)
# # Resolves full credentials (base_url, api_key) automatically. # # Resolves full credentials (base_url, api_key) automatically.

View file

@ -712,6 +712,10 @@ DEFAULT_CONFIG = {
"provider": "", # e.g. "openrouter" (empty = inherit parent provider + credentials) "provider": "", # e.g. "openrouter" (empty = inherit parent provider + credentials)
"base_url": "", # direct OpenAI-compatible endpoint for subagents "base_url": "", # direct OpenAI-compatible endpoint for subagents
"api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY) "api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY)
# When delegate_task narrows child toolsets explicitly, preserve any
# MCP toolsets the parent already has enabled. Off by default so
# narrowed child toolsets stay strict unless the operator opts in.
"inherit_mcp_toolsets": False,
"max_iterations": 50, # per-subagent iteration cap (each subagent gets its own budget, "max_iterations": 50, # per-subagent iteration cap (each subagent gets its own budget,
# independent of the parent's max_iterations) # independent of the parent's max_iterations)
"reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium", "reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium",
@ -923,7 +927,7 @@ DEFAULT_CONFIG = {
}, },
# Config schema version - bump this when adding new required fields # Config schema version - bump this when adding new required fields
"_config_version": 22, "_config_version": 23,
} }
# ============================================================================= # =============================================================================

View file

@ -1058,6 +1058,59 @@ class TestChildCredentialPoolResolution(unittest.TestCase):
self.assertEqual(mock_child._credential_pool, mock_pool) self.assertEqual(mock_child._credential_pool, mock_pool)
@patch("tools.delegate_tool._load_config", return_value={})
def test_build_child_agent_does_not_preserve_mcp_toolsets_by_default(self, mock_cfg):
parent = _make_mock_parent()
parent.enabled_toolsets = ["web", "browser", "mcp-MiniMax"]
with patch("run_agent.AIAgent") as MockAgent:
mock_child = MagicMock()
MockAgent.return_value = mock_child
_build_child_agent(
task_index=0,
goal="Test narrowed toolsets",
context=None,
toolsets=["web", "browser"],
model=None,
max_iterations=10,
parent_agent=parent,
task_count=1,
)
self.assertEqual(
MockAgent.call_args[1]["enabled_toolsets"],
["web", "browser"],
)
@patch(
"tools.delegate_tool._load_config",
return_value={"inherit_mcp_toolsets": True},
)
def test_build_child_agent_can_preserve_parent_mcp_toolsets(self, mock_cfg):
parent = _make_mock_parent()
parent.enabled_toolsets = ["web", "browser", "mcp-MiniMax"]
with patch("run_agent.AIAgent") as MockAgent:
mock_child = MagicMock()
MockAgent.return_value = mock_child
_build_child_agent(
task_index=0,
goal="Test narrowed toolsets",
context=None,
toolsets=["web", "browser"],
model=None,
max_iterations=10,
parent_agent=parent,
task_count=1,
)
self.assertEqual(
MockAgent.call_args[1]["enabled_toolsets"],
["web", "browser", "mcp-MiniMax"],
)
class TestChildCredentialLeasing(unittest.TestCase): class TestChildCredentialLeasing(unittest.TestCase):
def test_run_single_child_acquires_and_releases_lease(self): def test_run_single_child_acquires_and_releases_lease(self):

View file

@ -33,7 +33,7 @@ from typing import Any, Dict, List, Optional
from toolsets import TOOLSETS from toolsets import TOOLSETS
from tools import file_state from tools import file_state
from utils import base_url_hostname from utils import base_url_hostname, is_truthy_value
# Tools that children must never have access to # Tools that children must never have access to
@ -376,6 +376,38 @@ def _get_orchestrator_enabled() -> bool:
return True return True
def _get_inherit_mcp_toolsets() -> bool:
"""Whether narrowed child toolsets should keep the parent's MCP toolsets."""
cfg = _load_config()
return is_truthy_value(cfg.get("inherit_mcp_toolsets"), default=False)
def _is_mcp_toolset_name(name: str) -> bool:
"""Return True for canonical MCP toolsets and their registered aliases."""
if not name:
return False
if str(name).startswith("mcp-"):
return True
try:
from tools.registry import registry
target = registry.get_toolset_alias_target(str(name))
except Exception:
target = None
return bool(target and str(target).startswith("mcp-"))
def _preserve_parent_mcp_toolsets(
child_toolsets: List[str], parent_toolsets: set[str]
) -> List[str]:
"""Append any parent MCP toolsets that are missing from a narrowed child."""
preserved = list(child_toolsets)
for toolset_name in sorted(parent_toolsets):
if _is_mcp_toolset_name(toolset_name) and toolset_name not in preserved:
preserved.append(toolset_name)
return preserved
DEFAULT_MAX_ITERATIONS = 50 DEFAULT_MAX_ITERATIONS = 50
DEFAULT_CHILD_TIMEOUT = 300 # seconds before a child agent is considered stuck DEFAULT_CHILD_TIMEOUT = 300 # seconds before a child agent is considered stuck
_HEARTBEAT_INTERVAL = 30 # seconds between parent activity heartbeats during delegation _HEARTBEAT_INTERVAL = 30 # seconds between parent activity heartbeats during delegation
@ -782,6 +814,8 @@ def _build_child_agent(
parent_subagent_id = getattr(parent_agent, "_subagent_id", None) parent_subagent_id = getattr(parent_agent, "_subagent_id", None)
tui_depth = max(0, child_depth - 1) # 0 = first-level child for the UI tui_depth = max(0, child_depth - 1) # 0 = first-level child for the UI
delegation_cfg = _load_config()
# When no explicit toolsets given, inherit from parent's enabled toolsets # When no explicit toolsets given, inherit from parent's enabled toolsets
# so disabled tools (e.g. web) don't leak to subagents. # so disabled tools (e.g. web) don't leak to subagents.
# Note: enabled_toolsets=None means "all tools enabled" (the default), # Note: enabled_toolsets=None means "all tools enabled" (the default),
@ -803,9 +837,12 @@ def _build_child_agent(
if toolsets: if toolsets:
# Intersect with parent — subagent must not gain tools the parent lacks # Intersect with parent — subagent must not gain tools the parent lacks
child_toolsets = _strip_blocked_tools( child_toolsets = [t for t in toolsets if t in parent_toolsets]
[t for t in toolsets if t in parent_toolsets] if _get_inherit_mcp_toolsets():
child_toolsets = _preserve_parent_mcp_toolsets(
child_toolsets, parent_toolsets
) )
child_toolsets = _strip_blocked_tools(child_toolsets)
elif parent_agent and parent_enabled is not None: elif parent_agent and parent_enabled is not None:
child_toolsets = _strip_blocked_tools(parent_enabled) child_toolsets = _strip_blocked_tools(parent_enabled)
elif parent_toolsets: elif parent_toolsets:
@ -889,7 +926,6 @@ def _build_child_agent(
parent_reasoning = getattr(parent_agent, "reasoning_config", None) parent_reasoning = getattr(parent_agent, "reasoning_config", None)
child_reasoning = parent_reasoning child_reasoning = parent_reasoning
try: try:
delegation_cfg = _load_config()
delegation_effort = str(delegation_cfg.get("reasoning_effort") or "").strip() delegation_effort = str(delegation_cfg.get("reasoning_effort") or "").strip()
if delegation_effort: if delegation_effort:
from hermes_constants import parse_reasoning_effort from hermes_constants import parse_reasoning_effort