mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(delegate): make MCP toolset inheritance configurable
This commit is contained in:
parent
98e1396b15
commit
3e96c87f37
4 changed files with 100 additions and 6 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue