refactor(delegate): extract toolset resolution + task validation modules, move runtime resolution to config

This commit is contained in:
Teknium
2026-09-02 17:44:45 -07:00
parent 70ef2d349f
commit d77505c007
4 changed files with 431 additions and 395 deletions

View File

@@ -13,13 +13,11 @@ tool calls or reasoning.
import json
import logging
import re
import time
import weakref
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from toolsets import TOOLSETS
from tools.terminal_tool import set_approval_callback as _set_subagent_approval_cb # noqa: F401 (used via _await_child)
from utils import is_truthy_value
@@ -41,7 +39,7 @@ from tools.delegate_tool_config import ( # noqa: F401
_get_max_async_children, _get_max_concurrent_children, _get_max_spawn_depth,
_get_orchestrator_enabled, _get_subagent_approval_callback, _get_worktree_isolation,
_inherit_parent_base_url, _inherit_parent_capabilities, _load_config, _merge_request_overrides,
_resolve_child_credential_pool, _require_pinned_command, _resolve_delegation_credentials,
_resolve_child_credential_pool, _require_pinned_command, _resolve_child_runtime, _resolve_delegation_credentials,
_subagent_auto_approve, _subagent_auto_deny,
)
from tools.delegate_tool_dispatch import ( # noqa: F401
@@ -58,21 +56,16 @@ from tools.delegate_tool_registry import ( # noqa: F401
_register_subagent, _unregister_subagent, get_subagent_attribution, interrupt_subagent,
is_spawn_paused, list_active_subagents, set_spawn_paused, steer_subagent,
)
from tools.delegate_tool_tasks import _coerce_task_schemas, _normalize_task_list # noqa: F401
from tools.delegate_tool_toolsets import ( # noqa: F401
DEFAULT_TOOLSETS, DELEGATE_BLOCKED_TOOLS, _expand_parent_toolsets, _resolve_child_toolsets,
_strip_blocked_tools,
)
from tools.delegate_tool_results import ( # noqa: F401
_apply_summary_budget, _build_child_preserving_parent_tools, _finalize_child_results,
_run_child_lifecycle, _summarize_tool_arguments,
)
# 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
]
)
# Nested delegation is granted by depth/role in _build_child_agent, never by the
# model naming toolsets (there is no model-facing toolsets argument).
@@ -86,51 +79,6 @@ def _normalize_role(r: Optional[str]) -> str:
logger.warning("Unknown delegate_task role=%r, coercing to 'leaf'", r)
return "leaf"
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_MAX_ITERATIONS = 250
_HEARTBEAT_INTERVAL = 30 # seconds between parent activity heartbeats during delegation
@@ -141,209 +89,11 @@ _HEARTBEAT_INTERVAL = 30 # seconds between parent activity heartbeats during de
# is much higher so legitimately long tools can finish.
_HEARTBEAT_STALE_CYCLES_IDLE = 15 # 450s idle between turns → stale
_HEARTBEAT_STALE_CYCLES_IN_TOOL = 40 # 1200s stuck on same tool → stale
DEFAULT_TOOLSETS = ["terminal", "file", "web"]
def check_delegate_requirements() -> bool:
"""Delegation has no external requirements -- always available."""
return True
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
# OpenRouter routing filters: inherited from the parent, but reset to these
# defaults under a pinned provider — parent filters (e.g. only=["Anthropic"])
# would silently force the child back onto the parent's provider.
# openrouter_min_coding_score stays inherited: model-gated, no-op elsewhere.
_ROUTING_FILTER_DEFAULTS = (
("providers_allowed", None),
("providers_ignored", None),
("providers_order", None),
("provider_sort", None),
("provider_require_parameters", False),
("provider_data_collection", ""),
)
_NOUS_PROVIDERS = frozenset({"nous", "nous-portal", "nousresearch"})
def _resolve_child_runtime(
parent_agent,
delegation_cfg: dict,
parent_api_key: Any,
*,
model: Optional[str],
override_provider: Optional[str],
override_base_url: Optional[str],
override_api_key: Optional[str],
override_api_mode: Optional[str],
override_max_tokens: Optional[int],
override_acp_command: Optional[str],
override_acp_args: Optional[List[str]],
) -> Dict[str, Any]:
"""Resolve the child's credentials, transport and routing (config override >
parent inherit) as ``AIAgent`` keyword arguments.
Rules that are easy to break: api_mode is re-derived (not inherited) when
the child's provider differs from the parent's or is Nous Portal (dual-wire);
a pinned ``delegation.command`` must exist on PATH or the spawn fails loudly;
``override_provider`` clears the parent's ACP transport, fallback chain and
OpenRouter routing filters so the pinned provider is actually honoured.
"""
effective_model = model or parent_agent.model
effective_provider = override_provider or getattr(parent_agent, "provider", None)
effective_base_url = override_base_url or _inherit_parent_base_url(parent_agent, parent_agent.base_url)
# api_mode: each provider has its own wire, so a different provider re-derives
# (None) instead of inheriting (404s otherwise). Nous Portal is dual-wire
# within one provider (anthropic/* → Messages, else chat_completions), so
# same-provider inheritance would pin the child on the wrong wire — re-derive.
_parent_provider = getattr(parent_agent, "provider", None) or ""
if override_api_mode is not None:
effective_api_mode = override_api_mode
elif (effective_provider or "").strip().lower() in _NOUS_PROVIDERS:
from hermes_cli.providers import nous_api_mode
effective_api_mode = nous_api_mode(effective_model)
elif effective_provider != _parent_provider:
effective_api_mode = None # force re-derivation from provider's defaults
else:
effective_api_mode = getattr(parent_agent, "api_mode", None)
# A pinned transport that cannot run must fail the spawn loudly, never fall
# back silently (delegate_task pre-validates; this covers direct callers).
_require_pinned_command(
override_acp_command,
f"Pinned delegation command '{override_acp_command}' was not "
f"found on PATH. Install it or remove delegation.command from "
f"config.yaml.",
)
effective_acp_command = override_acp_command or getattr(parent_agent, "acp_command", None)
effective_acp_args = list(
override_acp_args if override_acp_args is not None else (getattr(parent_agent, "acp_args", []) or [])
)
# A pinned provider must use direct API calls; inheriting the parent's ACP
# transport would bypass the override credentials entirely.
if override_provider and not override_acp_command:
effective_acp_command, effective_acp_args = None, []
if override_acp_command:
# Forced ACP transport requires provider copilot-acp for run_agent to init the client.
effective_provider, effective_api_mode = "copilot-acp", "chat_completions"
# Reasoning: delegation.reasoning_effort > parent. Keep the raw value — a
# YAML ``false`` must disable thinking, not coerce to "" and inherit.
child_reasoning = getattr(parent_agent, "reasoning_config", None)
try:
delegation_effort = delegation_cfg.get("reasoning_effort")
if delegation_effort or delegation_effort is False:
from hermes_constants import parse_reasoning_effort
parsed = parse_reasoning_effort(delegation_effort)
if parsed is not None:
child_reasoning = parsed
else:
logger.warning("Unknown delegation.reasoning_effort '%s', inheriting parent level", delegation_effort)
except Exception as exc:
logger.debug("Could not load delegation reasoning_effort: %s", exc)
kwargs: Dict[str, Any] = {
"base_url": effective_base_url,
"api_key": override_api_key or parent_api_key,
"model": effective_model,
"provider": effective_provider,
"capabilities": _inherit_parent_capabilities(parent_agent, override_provider, override_base_url),
"api_mode": effective_api_mode,
"acp_command": effective_acp_command,
"acp_args": effective_acp_args,
"reasoning_config": child_reasoning,
# Inherit the parent's fallback chain EXCEPT under a pinned provider: a
# mid-run 429/auth failure must not silently reroute the quiet child onto
# the parent's fallbacks. Predictability > liveness for explicit pins.
"fallback_model": None if override_provider else (getattr(parent_agent, "_fallback_chain", None) or None),
"openrouter_min_coding_score": getattr(parent_agent, "openrouter_min_coding_score", None),
}
for attr, pinned_default in _ROUTING_FILTER_DEFAULTS:
kwargs[attr] = pinned_default if override_provider else getattr(parent_agent, attr, pinned_default)
if not override_provider:
kwargs["provider_data_collection"] = kwargs["provider_data_collection"] or ""
child_max_tokens = (
override_max_tokens if override_max_tokens is not None else getattr(parent_agent, "max_tokens", None)
)
if isinstance(child_max_tokens, int):
kwargs["max_tokens"] = child_max_tokens
return kwargs
def _open_child_session_db(parent_agent) -> Any:
"""DEDICATED SessionDB handle for the child, or None.
@@ -690,77 +440,6 @@ def _run_single_child(
close_deferred=_child_close_deferred,
)
def _recover_tasks_from_json_string(tasks: Any) -> tuple[Optional[List[Dict[str, Any]]], Optional[str]]:
if not isinstance(tasks, str):
return None, None
raw = tasks.strip()
if not raw:
return None, "Provide either 'goal' (single task) or 'tasks' (batch)."
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
return None, (
"tasks must be a JSON array of task objects; received a string "
f"that could not be parsed as JSON ({exc.msg})."
)
if not isinstance(parsed, list):
return None, (f"tasks must be a JSON array of task objects; parsed " f"{type(parsed).__name__} instead.")
return parsed, None
# Placeholder shapes for batch goal validation: bare 'TODO' / 'task N' labels,
# or unexpanded template markers. The marker regex is deliberately NARROW —
# only snake_case / space-separated placeholder identifiers (`<feature_name>`,
# `{file path}`, `<FEATURE-NAME>`), the shape LLM templates leave behind. Bare
# single-word brackets must never be rejected: legitimate goals are full of
# generics (`Vec<T>`), HTML tags (`<div>`), dict snippets (`{"key": 1}`), glob
# braces (`{a,b}`) and f-string style (`{i}`).
_PLACEHOLDER_GOAL_RE = re.compile(r"^(todo|task\s*\d+)$", re.IGNORECASE)
_TEMPLATE_MARKER_RE = re.compile(
r"<[A-Za-z][A-Za-z0-9]*(?:[ _-][A-Za-z0-9]+)+>"
r"|\{[A-Za-z][A-Za-z0-9]*(?:[ _-][A-Za-z0-9]+)+\}"
)
_MIN_BATCH_GOAL_LEN = 10
def _validate_batch_tasks(task_list: List[Dict[str, Any]]) -> Optional[str]:
"""Validate a tasks=[...] batch beyond per-task goal presence; actionable
error string or None.
No minimum count: a one-entry array is the canonical single-task shape
(legacy top-level `goal` is wrapped into one). Duplicate goals are
deliberately NOT rejected — identical-goal fan-outs (best-of-N / ensemble
sampling) are legitimate and blocking them broke real workflows.
"""
for i, task in enumerate(task_list):
goal = str(task.get("goal", "")).strip()
normalized = " ".join(goal.lower().split())
if _PLACEHOLDER_GOAL_RE.match(normalized):
return (
f"Task {i} has a placeholder goal ({goal!r}). Replace it "
"with a specific, self-contained description of what the "
"subagent should accomplish."
)
marker = _TEMPLATE_MARKER_RE.search(goal)
if marker:
return (
f"Task {i} goal contains an unexpanded template marker "
f"({marker.group(0)!r}). Substitute the real value before "
"calling delegate_task — subagents cannot resolve "
"placeholders."
)
if len(goal) < _MIN_BATCH_GOAL_LEN and len(task_list) >= 2:
# Multi-task fan-outs with terse goals are usually unexpanded
# templates; a SINGLE task legitimately uses short goals
# ("Fix the tests"), so one-entry arrays keep the historical
# single-`goal` exemption.
return (
f"Task {i} goal is too short ({goal!r}). Write a specific, "
"self-contained goal of at least "
f"{_MIN_BATCH_GOAL_LEN} characters so the subagent knows "
"exactly what to do."
)
return None
@dataclass
class _Batch:
@@ -780,73 +459,6 @@ class _Batch:
origin_owner_session_record: Any
overall_start: float
def _normalize_task_list(
goal, context, tasks, output_schema, top_role: str, max_children: int
) -> tuple[Optional[List[Dict[str, Any]]], Optional[str]]:
"""``(task_list, None)`` from ``tasks=[...]`` or the legacy single ``goal``, else ``(None, error)``."""
recovered_tasks, tasks_error = _recover_tasks_from_json_string(tasks)
if tasks_error:
return None, tasks_error
if recovered_tasks is not None:
tasks = recovered_tasks
# Small models emit tasks=[] alongside a single goal: treat as "no batch".
if isinstance(tasks, list) and not tasks:
tasks = None
if tasks and isinstance(tasks, list):
if len(tasks) > max_children:
return None, (
f"Too many tasks: {len(tasks)} provided, but "
f"max_concurrent_children is {max_children}. "
f"Either reduce the task count, split into multiple "
f"delegate_task calls, or increase "
f"delegation.max_concurrent_children in config.yaml."
)
task_list = tasks
elif goal and isinstance(goal, str) and goal.strip():
single_task: Dict[str, Any] = {"goal": goal, "context": context, "role": top_role}
if output_schema is not None:
single_task["output_schema"] = output_schema
task_list = [single_task]
else:
return None, (
"No tasks provided. Pass tasks=[{goal: '...', context: '...'}, "
"...] — one entry per subagent (a single task is a one-entry "
"array)."
)
for i, task in enumerate(task_list):
if not isinstance(task, dict):
return None, f"Task {i} must be an object, got {type(task).__name__}."
if not task.get("goal", "").strip():
return None, f"Task {i} is missing a 'goal'."
# Batch-only quality gate (placeholders, template markers); the single-goal
# form is exempt because short goals are valid there.
if tasks is not None and isinstance(tasks, list):
batch_error = _validate_batch_tasks(task_list)
if batch_error:
return None, batch_error
return task_list, None
def _coerce_task_schemas(
task_list: List[Dict[str, Any]], output_schema: Optional[Dict[str, Any]]
) -> tuple[List[Optional[Dict[str, Any]]], Optional[str]]:
"""Per-task coerced output schemas. A malformed output_schema fails the whole
call before any child spawns; schema-less tasks resolve to None and take no
new code paths downstream."""
from tools.delegation_output_schema import coerce_output_schema
task_schemas: List[Optional[Dict[str, Any]]] = []
for i, task in enumerate(task_list):
raw_schema = task.get("output_schema")
if raw_schema is None and len(task_list) == 1 and output_schema is not None:
raw_schema = output_schema
coerced_schema, schema_err = coerce_output_schema(raw_schema)
if schema_err:
return [], f"Task {i} output_schema invalid: {schema_err}"
task_schemas.append(coerced_schema)
return task_schemas, None
def _announce_batch(parent_agent, n_tasks: int, live_deleg_id: Optional[str]) -> None:
"""Announce the batch tag once so interleaved ``[tag n/N]`` lines are attributable."""

View File

@@ -7,7 +7,7 @@ from __future__ import annotations
import logging
import os
from typing import Any, Optional
from typing import Any, Dict, List, Optional
from utils import base_url_hostname, is_truthy_value
# Log-record parity with the origin module.
@@ -486,3 +486,122 @@ def _load_config() -> dict:
return cfg if isinstance(cfg, dict) else {}
except Exception:
return {}
# OpenRouter routing filters: inherited from the parent, but reset to these
# defaults under a pinned provider — parent filters (e.g. only=["Anthropic"])
# would silently force the child back onto the parent's provider.
# openrouter_min_coding_score stays inherited: model-gated, no-op elsewhere.
_ROUTING_FILTER_DEFAULTS = (
("providers_allowed", None),
("providers_ignored", None),
("providers_order", None),
("provider_sort", None),
("provider_require_parameters", False),
("provider_data_collection", ""),
)
_NOUS_PROVIDERS = frozenset({"nous", "nous-portal", "nousresearch"})
def _resolve_child_runtime(
parent_agent,
delegation_cfg: dict,
parent_api_key: Any,
*,
model: Optional[str],
override_provider: Optional[str],
override_base_url: Optional[str],
override_api_key: Optional[str],
override_api_mode: Optional[str],
override_max_tokens: Optional[int],
override_acp_command: Optional[str],
override_acp_args: Optional[List[str]],
) -> Dict[str, Any]:
"""Resolve the child's credentials, transport and routing (config override >
parent inherit) as ``AIAgent`` keyword arguments.
Rules that are easy to break: api_mode is re-derived (not inherited) when
the child's provider differs from the parent's or is Nous Portal (dual-wire);
a pinned ``delegation.command`` must exist on PATH or the spawn fails loudly;
``override_provider`` clears the parent's ACP transport, fallback chain and
OpenRouter routing filters so the pinned provider is actually honoured.
"""
effective_model = model or parent_agent.model
effective_provider = override_provider or getattr(parent_agent, "provider", None)
effective_base_url = override_base_url or _inherit_parent_base_url(parent_agent, parent_agent.base_url)
# api_mode: each provider has its own wire, so a different provider re-derives
# (None) instead of inheriting (404s otherwise). Nous Portal is dual-wire
# within one provider (anthropic/* → Messages, else chat_completions), so
# same-provider inheritance would pin the child on the wrong wire — re-derive.
_parent_provider = getattr(parent_agent, "provider", None) or ""
if override_api_mode is not None:
effective_api_mode = override_api_mode
elif (effective_provider or "").strip().lower() in _NOUS_PROVIDERS:
from hermes_cli.providers import nous_api_mode
effective_api_mode = nous_api_mode(effective_model)
elif effective_provider != _parent_provider:
effective_api_mode = None # force re-derivation from provider's defaults
else:
effective_api_mode = getattr(parent_agent, "api_mode", None)
# A pinned transport that cannot run must fail the spawn loudly, never fall
# back silently (delegate_task pre-validates; this covers direct callers).
_require_pinned_command(
override_acp_command,
f"Pinned delegation command '{override_acp_command}' was not "
f"found on PATH. Install it or remove delegation.command from "
f"config.yaml.",
)
effective_acp_command = override_acp_command or getattr(parent_agent, "acp_command", None)
effective_acp_args = list(
override_acp_args if override_acp_args is not None else (getattr(parent_agent, "acp_args", []) or [])
)
# A pinned provider must use direct API calls; inheriting the parent's ACP
# transport would bypass the override credentials entirely.
if override_provider and not override_acp_command:
effective_acp_command, effective_acp_args = None, []
if override_acp_command:
# Forced ACP transport requires provider copilot-acp for run_agent to init the client.
effective_provider, effective_api_mode = "copilot-acp", "chat_completions"
# Reasoning: delegation.reasoning_effort > parent. Keep the raw value — a
# YAML ``false`` must disable thinking, not coerce to "" and inherit.
child_reasoning = getattr(parent_agent, "reasoning_config", None)
try:
delegation_effort = delegation_cfg.get("reasoning_effort")
if delegation_effort or delegation_effort is False:
from hermes_constants import parse_reasoning_effort
parsed = parse_reasoning_effort(delegation_effort)
if parsed is not None:
child_reasoning = parsed
else:
logger.warning("Unknown delegation.reasoning_effort '%s', inheriting parent level", delegation_effort)
except Exception as exc:
logger.debug("Could not load delegation reasoning_effort: %s", exc)
kwargs: Dict[str, Any] = {
"base_url": effective_base_url,
"api_key": override_api_key or parent_api_key,
"model": effective_model,
"provider": effective_provider,
"capabilities": _inherit_parent_capabilities(parent_agent, override_provider, override_base_url),
"api_mode": effective_api_mode,
"acp_command": effective_acp_command,
"acp_args": effective_acp_args,
"reasoning_config": child_reasoning,
# Inherit the parent's fallback chain EXCEPT under a pinned provider: a
# mid-run 429/auth failure must not silently reroute the quiet child onto
# the parent's fallbacks. Predictability > liveness for explicit pins.
"fallback_model": None if override_provider else (getattr(parent_agent, "_fallback_chain", None) or None),
"openrouter_min_coding_score": getattr(parent_agent, "openrouter_min_coding_score", None),
}
for attr, pinned_default in _ROUTING_FILTER_DEFAULTS:
kwargs[attr] = pinned_default if override_provider else getattr(parent_agent, attr, pinned_default)
if not override_provider:
kwargs["provider_data_collection"] = kwargs["provider_data_collection"] or ""
child_max_tokens = (
override_max_tokens if override_max_tokens is not None else getattr(parent_agent, "max_tokens", None)
)
if isinstance(child_max_tokens, int):
kwargs["max_tokens"] = child_max_tokens
return kwargs

View File

@@ -0,0 +1,151 @@
"""delegate_task input validation: tasks=[...] / legacy goal normalisation and per-task output schemas.
Split out of ``tools/delegate_tool.py``, which re-imports every name (patch targets stay valid).
"""
from __future__ import annotations
import json
import re
from typing import Any, Dict, List, Optional
def _recover_tasks_from_json_string(tasks: Any) -> tuple[Optional[List[Dict[str, Any]]], Optional[str]]:
if not isinstance(tasks, str):
return None, None
raw = tasks.strip()
if not raw:
return None, "Provide either 'goal' (single task) or 'tasks' (batch)."
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
return None, (
"tasks must be a JSON array of task objects; received a string "
f"that could not be parsed as JSON ({exc.msg})."
)
if not isinstance(parsed, list):
return None, (f"tasks must be a JSON array of task objects; parsed " f"{type(parsed).__name__} instead.")
return parsed, None
# Placeholder shapes for batch goal validation: bare 'TODO' / 'task N' labels,
# or unexpanded template markers. The marker regex is deliberately NARROW —
# only snake_case / space-separated placeholder identifiers (`<feature_name>`,
# `{file path}`, `<FEATURE-NAME>`), the shape LLM templates leave behind. Bare
# single-word brackets must never be rejected: legitimate goals are full of
# generics (`Vec<T>`), HTML tags (`<div>`), dict snippets (`{"key": 1}`), glob
# braces (`{a,b}`) and f-string style (`{i}`).
_PLACEHOLDER_GOAL_RE = re.compile(r"^(todo|task\s*\d+)$", re.IGNORECASE)
_TEMPLATE_MARKER_RE = re.compile(
r"<[A-Za-z][A-Za-z0-9]*(?:[ _-][A-Za-z0-9]+)+>"
r"|\{[A-Za-z][A-Za-z0-9]*(?:[ _-][A-Za-z0-9]+)+\}"
)
_MIN_BATCH_GOAL_LEN = 10
def _validate_batch_tasks(task_list: List[Dict[str, Any]]) -> Optional[str]:
"""Validate a tasks=[...] batch beyond per-task goal presence; actionable
error string or None.
No minimum count: a one-entry array is the canonical single-task shape
(legacy top-level `goal` is wrapped into one). Duplicate goals are
deliberately NOT rejected — identical-goal fan-outs (best-of-N / ensemble
sampling) are legitimate and blocking them broke real workflows.
"""
for i, task in enumerate(task_list):
goal = str(task.get("goal", "")).strip()
normalized = " ".join(goal.lower().split())
if _PLACEHOLDER_GOAL_RE.match(normalized):
return (
f"Task {i} has a placeholder goal ({goal!r}). Replace it "
"with a specific, self-contained description of what the "
"subagent should accomplish."
)
marker = _TEMPLATE_MARKER_RE.search(goal)
if marker:
return (
f"Task {i} goal contains an unexpanded template marker "
f"({marker.group(0)!r}). Substitute the real value before "
"calling delegate_task — subagents cannot resolve "
"placeholders."
)
if len(goal) < _MIN_BATCH_GOAL_LEN and len(task_list) >= 2:
# Multi-task fan-outs with terse goals are usually unexpanded
# templates; a SINGLE task legitimately uses short goals
# ("Fix the tests"), so one-entry arrays keep the historical
# single-`goal` exemption.
return (
f"Task {i} goal is too short ({goal!r}). Write a specific, "
"self-contained goal of at least "
f"{_MIN_BATCH_GOAL_LEN} characters so the subagent knows "
"exactly what to do."
)
return None
def _normalize_task_list(
goal, context, tasks, output_schema, top_role: str, max_children: int
) -> tuple[Optional[List[Dict[str, Any]]], Optional[str]]:
"""``(task_list, None)`` from ``tasks=[...]`` or the legacy single ``goal``, else ``(None, error)``."""
recovered_tasks, tasks_error = _recover_tasks_from_json_string(tasks)
if tasks_error:
return None, tasks_error
if recovered_tasks is not None:
tasks = recovered_tasks
# Small models emit tasks=[] alongside a single goal: treat as "no batch".
if isinstance(tasks, list) and not tasks:
tasks = None
if tasks and isinstance(tasks, list):
if len(tasks) > max_children:
return None, (
f"Too many tasks: {len(tasks)} provided, but "
f"max_concurrent_children is {max_children}. "
f"Either reduce the task count, split into multiple "
f"delegate_task calls, or increase "
f"delegation.max_concurrent_children in config.yaml."
)
task_list = tasks
elif goal and isinstance(goal, str) and goal.strip():
single_task: Dict[str, Any] = {"goal": goal, "context": context, "role": top_role}
if output_schema is not None:
single_task["output_schema"] = output_schema
task_list = [single_task]
else:
return None, (
"No tasks provided. Pass tasks=[{goal: '...', context: '...'}, "
"...] — one entry per subagent (a single task is a one-entry "
"array)."
)
for i, task in enumerate(task_list):
if not isinstance(task, dict):
return None, f"Task {i} must be an object, got {type(task).__name__}."
if not task.get("goal", "").strip():
return None, f"Task {i} is missing a 'goal'."
# Batch-only quality gate (placeholders, template markers); the single-goal
# form is exempt because short goals are valid there.
if tasks is not None and isinstance(tasks, list):
batch_error = _validate_batch_tasks(task_list)
if batch_error:
return None, batch_error
return task_list, None
def _coerce_task_schemas(
task_list: List[Dict[str, Any]], output_schema: Optional[Dict[str, Any]]
) -> tuple[List[Optional[Dict[str, Any]]], Optional[str]]:
"""Per-task coerced output schemas. A malformed output_schema fails the whole
call before any child spawns; schema-less tasks resolve to None and take no
new code paths downstream."""
from tools.delegation_output_schema import coerce_output_schema
task_schemas: List[Optional[Dict[str, Any]]] = []
for i, task in enumerate(task_list):
raw_schema = task.get("output_schema")
if raw_schema is None and len(task_list) == 1 and output_schema is not None:
raw_schema = output_schema
coerced_schema, schema_err = coerce_output_schema(raw_schema)
if schema_err:
return [], f"Task {i} output_schema invalid: {schema_err}"
task_schemas.append(coerced_schema)
return task_schemas, None

View File

@@ -0,0 +1,154 @@
"""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