diff --git a/cli-config.yaml.example b/cli-config.yaml.example index e8e3d30af..f1858e9b7 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -776,6 +776,7 @@ delegation: # 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. # 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) # provider: "openrouter" # Override provider for subagents (empty = inherit parent) # # Resolves full credentials (base_url, api_key) automatically. diff --git a/hermes_cli/config.py b/hermes_cli/config.py index ce77af7ca..14bfb205e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -712,6 +712,10 @@ DEFAULT_CONFIG = { "provider": "", # e.g. "openrouter" (empty = inherit parent provider + credentials) "base_url": "", # direct OpenAI-compatible endpoint for subagents "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, # independent of the parent's max_iterations) "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_version": 22, + "_config_version": 23, } # ============================================================================= diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index f53da7e55..f5720fff0 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -1058,6 +1058,59 @@ class TestChildCredentialPoolResolution(unittest.TestCase): 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): def test_run_single_child_acquires_and_releases_lease(self): diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index f25085613..72c76773f 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -33,7 +33,7 @@ from typing import Any, Dict, List, Optional from toolsets import TOOLSETS 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 @@ -376,6 +376,38 @@ def _get_orchestrator_enabled() -> bool: 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_CHILD_TIMEOUT = 300 # seconds before a child agent is considered stuck _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) 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 # so disabled tools (e.g. web) don't leak to subagents. # Note: enabled_toolsets=None means "all tools enabled" (the default), @@ -803,9 +837,12 @@ def _build_child_agent( if toolsets: # Intersect with parent — subagent must not gain tools the parent lacks - child_toolsets = _strip_blocked_tools( - [t for t in toolsets if t in parent_toolsets] - ) + child_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: child_toolsets = _strip_blocked_tools(parent_enabled) elif parent_toolsets: @@ -889,7 +926,6 @@ def _build_child_agent( parent_reasoning = getattr(parent_agent, "reasoning_config", None) child_reasoning = parent_reasoning try: - delegation_cfg = _load_config() delegation_effort = str(delegation_cfg.get("reasoning_effort") or "").strip() if delegation_effort: from hermes_constants import parse_reasoning_effort