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_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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue