153 lines
6.2 KiB
Python
153 lines
6.2 KiB
Python
"""Child toolset resolution for delegate_task: what a child may and may never use.
|
|
|
|
Split out of ``tools/delegate_tool.py``, which re-imports every name (patch targets stay valid).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import List, Optional
|
|
|
|
from toolsets import TOOLSETS
|
|
from tools.delegate_tool_config import _get_inherit_mcp_toolsets
|
|
|
|
# Log-record parity with the origin module.
|
|
logger = logging.getLogger("tools.delegate_tool")
|
|
|
|
# Tools that children must never have access to
|
|
DELEGATE_BLOCKED_TOOLS = frozenset(
|
|
[
|
|
"delegate_task", # no recursive delegation
|
|
"clarify", # no user interaction
|
|
"memory", # no writes to shared MEMORY.md
|
|
"send_message", # no cross-platform side effects
|
|
"cronjob_manage", # no scheduling more work in the parent's name
|
|
]
|
|
)
|
|
|
|
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 _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: set = set()
|
|
for ts_name in parent_toolsets:
|
|
ts_def = TOOLSETS.get(ts_name)
|
|
if ts_def:
|
|
parent_tool_names.update(ts_def.get("tools", []))
|
|
|
|
if not parent_tool_names:
|
|
return set(parent_toolsets)
|
|
|
|
expanded = set(parent_toolsets)
|
|
for ts_name, ts_def in TOOLSETS.items():
|
|
if ts_name in expanded:
|
|
continue
|
|
ts_tools = ts_def.get("tools", [])
|
|
if ts_tools and set(ts_tools).issubset(parent_tool_names):
|
|
expanded.add(ts_name)
|
|
return expanded
|
|
|
|
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_TOOLSETS = ["terminal", "file", "web"]
|
|
|
|
def _strip_blocked_tools(toolsets: List[str]) -> List[str]:
|
|
"""Remove toolsets whose tools are ALL blocked (derived from DELEGATE_BLOCKED_TOOLS
|
|
so the two can't drift) plus composite toolsets children must never get."""
|
|
_COMPOSITE_BLOCKED_TOOLSETS = frozenset({"delegation"})
|
|
blocked_toolset_names = {
|
|
name
|
|
for name, defn in TOOLSETS.items()
|
|
if name in _COMPOSITE_BLOCKED_TOOLSETS
|
|
or all(t in DELEGATE_BLOCKED_TOOLS for t in defn.get("tools", []))
|
|
}
|
|
blocked_toolset_names.add("kanban")
|
|
return [t for t in toolsets if t not in blocked_toolset_names]
|
|
|
|
def _blocked_toolsets_for_role(role: str) -> List[str]:
|
|
"""One-tool deny toolsets for the role; passed as ``disabled_toolsets`` so
|
|
blocked names inside mixed bundles are subtracted AFTER composite expansion."""
|
|
blocked_names = set(DELEGATE_BLOCKED_TOOLS)
|
|
if role == "orchestrator":
|
|
blocked_names.discard("delegate_task")
|
|
return sorted(
|
|
name
|
|
for name, defn in TOOLSETS.items()
|
|
if defn.get("tools")
|
|
and set(defn.get("tools", ())).issubset(blocked_names)
|
|
)
|
|
|
|
def _resolve_child_toolsets(
|
|
parent_agent, toolsets: Optional[List[str]], effective_role: str
|
|
) -> tuple[List[str], List[str]]:
|
|
"""Return ``(enabled_toolsets, disabled_toolsets)`` for a child.
|
|
|
|
Children never gain tools the parent lacks: explicit ``toolsets`` are
|
|
intersected with the parent's (composite-expanded) set, else the parent's
|
|
enabled set is inherited. Blocked tools are stripped twice — whole blocked
|
|
toolsets here, and exact one-tool deny toolsets via ``disabled_toolsets`` so
|
|
blocked names inside mixed bundles (hermes-cli) are subtracted AFTER
|
|
composite expansion and survive registry refreshes. Orchestrators get
|
|
``delegation`` re-added unconditionally (role-granted, not inherited).
|
|
"""
|
|
# enabled_toolsets=None means "all tools", so derive from loaded tool names.
|
|
parent_enabled = getattr(parent_agent, "enabled_toolsets", None)
|
|
if parent_enabled is not None:
|
|
parent_toolsets = set(parent_enabled)
|
|
elif parent_agent and hasattr(parent_agent, "valid_tool_names"):
|
|
import model_tools
|
|
parent_toolsets = {
|
|
ts
|
|
for name in parent_agent.valid_tool_names
|
|
if (ts := model_tools.get_toolset_for_tool(name)) is not None
|
|
}
|
|
else:
|
|
parent_toolsets = set(DEFAULT_TOOLSETS)
|
|
|
|
if toolsets:
|
|
expanded_parent = _expand_parent_toolsets(parent_toolsets)
|
|
child_toolsets = [t for t in toolsets if t in expanded_parent]
|
|
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:
|
|
child_toolsets = _strip_blocked_tools(sorted(parent_toolsets))
|
|
else:
|
|
child_toolsets = _strip_blocked_tools(DEFAULT_TOOLSETS)
|
|
|
|
raw_parent_disabled = getattr(parent_agent, "disabled_toolsets", None)
|
|
inherited_disabled = (
|
|
[str(name) for name in raw_parent_disabled] if isinstance(raw_parent_disabled, (list, tuple, set)) else []
|
|
)
|
|
if effective_role == "orchestrator":
|
|
inherited_disabled = [name for name in inherited_disabled if name != "delegation"]
|
|
if "delegation" not in child_toolsets:
|
|
child_toolsets.append("delegation")
|
|
child_disabled_toolsets = list(
|
|
dict.fromkeys(inherited_disabled + _blocked_toolsets_for_role(effective_role) + ["kanban"])
|
|
)
|
|
return child_toolsets, child_disabled_toolsets
|