diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 20b54b7887ba..21eef24bbeed 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -749,6 +749,31 @@ delegation: # provider: "openrouter" # Override provider for subagents (empty = inherit parent) # # Resolves full credentials (base_url, api_key) automatically. # # Supported: openrouter, nous, zai, kimi-coding, minimax + # reasoning_effort: "low" # Flat override for all delegated children + # default_tier: "heavy" # Fallback tier when no explicit tier is chosen + # auto_tier_selection: false # Heuristic auto-router when no explicit tier is provided + # tiers: + # light: + # model: "gpt-5.4-mini" + # reasoning_effort: "low" + # max_iterations: 25 + # heavy: + # model: "gpt-5.4" + # reasoning_effort: "medium" + # max_iterations: 50 + # review: + # model: "gpt-5.4" + # reasoning_effort: "high" # Floor: review >= high + # max_iterations: 60 + # planning: + # model: "xiaomi/mimo-v2-pro" + # provider: "nous" + # reasoning_effort: "high" # Floor: planning >= high + # max_iterations: 60 + # research: + # model: "gpt-5.4" + # reasoning_effort: "high" # Floor: research >= medium + # max_iterations: 60 # ============================================================================= # Honcho Integration (Cross-Session User Modeling) diff --git a/tests/tools/test_delegate_tier_core.py b/tests/tools/test_delegate_tier_core.py new file mode 100644 index 000000000000..faf7444a640a --- /dev/null +++ b/tests/tools/test_delegate_tier_core.py @@ -0,0 +1,465 @@ +#!/usr/bin/env python3 + +import json +import threading +import unittest +from unittest.mock import MagicMock, patch + +from tools.delegate_tool import DELEGATE_TASK_SCHEMA, _build_child_agent, delegate_task + + +def _make_mock_parent(depth=0): + parent = MagicMock() + parent.base_url = "https://openrouter.ai/api/v1" + parent.api_key = "parent-key" + parent.provider = "openrouter" + parent.api_mode = "chat_completions" + parent.model = "anthropic/claude-sonnet-4" + parent.platform = "cli" + parent.providers_allowed = None + parent.providers_ignored = None + parent.providers_order = None + parent.provider_sort = None + parent._session_db = None + parent._delegate_depth = depth + parent._active_children = [] + parent._active_children_lock = threading.Lock() + parent._print_fn = None + parent.tool_progress_callback = None + parent.thinking_callback = None + parent.max_tokens = None + parent.reasoning_config = {"enabled": True, "effort": "xhigh"} + return parent + + +class TestDelegateTierProfiles(unittest.TestCase): + def test_schema_allows_user_defined_tiers(self): + """Schema no longer restricts tier to a fixed enum; user-defined tiers are allowed.""" + props = DELEGATE_TASK_SCHEMA["parameters"]["properties"] + + # tier field should exist and be a string type + self.assertEqual(props["tier"]["type"], "string") + + # enum should NOT be present - any string is allowed + self.assertNotIn("enum", props["tier"]) + + # Check task-level tier as well + task_tier = props["tasks"]["items"]["properties"]["tier"] + self.assertEqual(task_tier["type"], "string") + self.assertNotIn("enum", task_tier) + + def test_resolve_tier_config_merges_default_and_applies_reasoning_floor(self): + from tools.delegate_tool import resolve_tier_config + + cfg = { + "model": "gpt-5.4-mini", + "reasoning_effort": "low", + "max_iterations": 25, + "default_tier": "review", + "tiers": { + "review": { + "model": "gpt-5.4", + "reasoning_effort": "low", + "max_iterations": 60, + } + }, + } + + result = resolve_tier_config(cfg) + self.assertEqual(result["model"], "gpt-5.4") + self.assertEqual(result["max_iterations"], 60) + self.assertEqual(result["reasoning_effort"], "high") + self.assertNotIn("tiers", result) + self.assertNotIn("default_tier", result) + + def test_resolve_tier_config_unknown_explicit_and_default_fall_back_cleanly(self): + from tools.delegate_tool import resolve_tier_config + + cfg = { + "model": "gpt-5.4-mini", + "reasoning_effort": "low", + "default_tier": "bogus", + "tiers": {"review": {"model": "gpt-5.4", "reasoning_effort": "high"}}, + } + + with self.assertLogs("tools.delegate_tool", level="WARNING") as default_logs: + default_result = resolve_tier_config(cfg) + self.assertEqual(default_result["model"], "gpt-5.4-mini") + self.assertNotIn("tiers", default_result) + self.assertTrue(any("unknown default_tier" in msg for msg in default_logs.output)) + + with self.assertLogs("tools.delegate_tool", level="WARNING") as explicit_logs: + explicit_result = resolve_tier_config(cfg, tier="unknown") + self.assertEqual(explicit_result["model"], "gpt-5.4-mini") + self.assertTrue(any("unknown delegation tier" in msg for msg in explicit_logs.output)) + + @patch("tools.delegate_tool._load_config") + @patch("run_agent.AIAgent") + def test_build_child_agent_override_reasoning_effort_beats_delegation_config(self, MockAgent, mock_cfg): + mock_cfg.return_value = {"reasoning_effort": "low"} + MockAgent.return_value = MagicMock() + parent = _make_mock_parent() + + _build_child_agent( + task_index=0, + goal="review the patch", + context=None, + toolsets=None, + model=None, + max_iterations=50, + task_count=1, + parent_agent=parent, + override_reasoning_effort="high", + ) + + call_kwargs = MockAgent.call_args[1] + self.assertEqual(call_kwargs["reasoning_config"], {"enabled": True, "effort": "high"}) + + @patch("tools.delegate_tool._run_single_child") + @patch("tools.delegate_tool._build_child_agent") + @patch("tools.delegate_tool._resolve_delegation_credentials") + @patch("tools.delegate_tool._load_config") + @patch("tools.delegate_tool._get_max_concurrent_children") + def test_batch_per_task_tier_overrides_top_level_tier( + self, + mock_max_children, + mock_load_config, + mock_resolve_creds, + mock_build_child, + mock_run_child, + ): + mock_max_children.return_value = 3 + mock_load_config.return_value = { + "model": "gpt-5.4-mini", + "reasoning_effort": "low", + "max_iterations": 25, + "tiers": { + "heavy": {"reasoning_effort": "medium", "max_iterations": 50}, + "light": {"reasoning_effort": "low", "max_iterations": 10}, + "review": {"reasoning_effort": "low", "max_iterations": 60}, + }, + } + mock_resolve_creds.return_value = { + "model": "gpt-5.4-mini", + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + } + mock_build_child.side_effect = [MagicMock(), MagicMock()] + mock_run_child.side_effect = [ + {"task_index": 0, "status": "completed", "summary": "light done", "api_calls": 1, "duration_seconds": 0.1}, + {"task_index": 1, "status": "completed", "summary": "review done", "api_calls": 1, "duration_seconds": 0.1}, + ] + parent = _make_mock_parent() + + result = json.loads( + delegate_task( + tier="heavy", + tasks=[ + {"goal": "list the files", "tier": "light"}, + {"goal": "review this diff", "tier": "review"}, + ], + parent_agent=parent, + ) + ) + + self.assertEqual(len(result["results"]), 2) + first_kwargs = mock_build_child.call_args_list[0].kwargs + second_kwargs = mock_build_child.call_args_list[1].kwargs + self.assertEqual(first_kwargs["max_iterations"], 10) + self.assertEqual(first_kwargs["override_reasoning_effort"], "low") + self.assertEqual(second_kwargs["max_iterations"], 60) + self.assertEqual(second_kwargs["override_reasoning_effort"], "high") + + def test_heuristic_auto_router_only_uses_supported_real_tiers(self): + from tools.delegate_tool import _infer_delegate_tier, _resolve_effective_tier + + self.assertEqual(_infer_delegate_tier("please review this patch", "", ["file"], {}), "review") + self.assertEqual(_infer_delegate_tier("plan the architecture", "", ["file"], {}), "planning") + self.assertEqual(_infer_delegate_tier("research the options", "", ["web"], {}), "research") + self.assertEqual(_infer_delegate_tier("count the files", "", ["file"], {}), "light") + self.assertIsNone(_infer_delegate_tier("implement", "", ["terminal", "file"], {})) + + cfg = {"auto_tier_selection": True, "default_tier": "heavy"} + self.assertEqual(_resolve_effective_tier(None, "review this patch", "", ["file"], cfg), "review") + self.assertEqual(_resolve_effective_tier("planning", "review this patch", "", ["file"], cfg), "planning") + self.assertIsNone(_resolve_effective_tier(None, "implement", "", ["terminal", "file"], {"auto_tier_selection": False})) + + @patch("tools.delegate_tool._run_single_child") + @patch("tools.delegate_tool._build_child_agent") + @patch("tools.delegate_tool._resolve_delegation_credentials") + @patch("tools.delegate_tool._load_config") + @patch("tools.delegate_tool._get_max_concurrent_children") + def test_single_task_uses_heuristic_tier_when_enabled_and_no_explicit_tier( + self, + mock_max_children, + mock_load_config, + mock_resolve_creds, + mock_build_child, + mock_run_child, + ): + mock_max_children.return_value = 3 + mock_load_config.return_value = { + "model": "gpt-5.4-mini", + "max_iterations": 25, + "auto_tier_selection": True, + "default_tier": "heavy", + "tiers": { + "review": {"reasoning_effort": "low", "max_iterations": 60}, + "heavy": {"reasoning_effort": "medium", "max_iterations": 50}, + }, + } + mock_resolve_creds.return_value = { + "model": "gpt-5.4-mini", + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + } + mock_build_child.return_value = MagicMock() + mock_run_child.return_value = {"task_index": 0, "status": "completed", "summary": "ok", "api_calls": 1, "duration_seconds": 0.1} + + result = json.loads(delegate_task(goal="review this patch", parent_agent=_make_mock_parent())) + + self.assertEqual(result["results"][0]["status"], "completed") + kwargs = mock_build_child.call_args.kwargs + self.assertEqual(kwargs["max_iterations"], 60) + self.assertEqual(kwargs["override_reasoning_effort"], "high") + + @patch("tools.delegate_tool._run_single_child") + @patch("tools.delegate_tool._build_child_agent") + @patch("tools.delegate_tool._resolve_delegation_credentials") + @patch("tools.delegate_tool._load_config") + @patch("tools.delegate_tool._get_max_concurrent_children") + def test_explicit_tier_bypasses_heuristic( + self, + mock_max_children, + mock_load_config, + mock_resolve_creds, + mock_build_child, + mock_run_child, + ): + mock_max_children.return_value = 3 + mock_load_config.return_value = { + "model": "gpt-5.4-mini", + "max_iterations": 25, + "auto_tier_selection": True, + "tiers": { + "planning": {"reasoning_effort": "low", "max_iterations": 40}, + "review": {"reasoning_effort": "low", "max_iterations": 60}, + }, + } + mock_resolve_creds.return_value = { + "model": "gpt-5.4-mini", + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + } + mock_build_child.return_value = MagicMock() + mock_run_child.return_value = {"task_index": 0, "status": "completed", "summary": "ok", "api_calls": 1, "duration_seconds": 0.1} + + with patch("tools.delegate_tool._infer_delegate_tier", side_effect=AssertionError("heuristic should not run")): + result = json.loads(delegate_task(goal="review this patch", tier="planning", parent_agent=_make_mock_parent())) + + self.assertEqual(result["results"][0]["status"], "completed") + kwargs = mock_build_child.call_args.kwargs + self.assertEqual(kwargs["max_iterations"], 40) + self.assertEqual(kwargs["override_reasoning_effort"], "high") + + @patch("tools.delegate_tool._run_single_child") + @patch("tools.delegate_tool._build_child_agent") + @patch("tools.delegate_tool._resolve_delegation_credentials") + @patch("tools.delegate_tool._load_config") + @patch("tools.delegate_tool._get_max_concurrent_children") + def test_single_task_falls_back_to_default_tier_when_auto_inconclusive( + self, + mock_max_children, + mock_load_config, + mock_resolve_creds, + mock_build_child, + mock_run_child, + ): + mock_max_children.return_value = 3 + mock_load_config.return_value = { + "model": "gpt-5.4-mini", + "max_iterations": 25, + "auto_tier_selection": True, + "default_tier": "heavy", + "tiers": { + "heavy": {"reasoning_effort": "low", "max_iterations": 50}, + }, + } + mock_resolve_creds.return_value = { + "model": "gpt-5.4-mini", + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + } + mock_build_child.return_value = MagicMock() + mock_run_child.return_value = {"task_index": 0, "status": "completed", "summary": "ok", "api_calls": 1, "duration_seconds": 0.1} + + with patch("tools.delegate_tool._infer_delegate_tier", return_value=None): + result = json.loads(delegate_task(goal="implement", parent_agent=_make_mock_parent())) + + self.assertEqual(result["results"][0]["status"], "completed") + kwargs = mock_build_child.call_args.kwargs + self.assertEqual(kwargs["max_iterations"], 50) + self.assertEqual(kwargs["override_reasoning_effort"], "medium") + + @patch("tools.delegate_tool._run_single_child") + @patch("tools.delegate_tool._build_child_agent") + @patch("tools.delegate_tool._resolve_delegation_credentials") + @patch("tools.delegate_tool._load_config") + @patch("tools.delegate_tool._get_max_concurrent_children") + def test_batch_task_without_own_tier_inherits_top_level_tier( + self, + mock_max_children, + mock_load_config, + mock_resolve_creds, + mock_build_child, + mock_run_child, + ): + mock_max_children.return_value = 3 + mock_load_config.return_value = { + "model": "gpt-5.4-mini", + "max_iterations": 25, + "tiers": { + "heavy": {"reasoning_effort": "low", "max_iterations": 50}, + "light": {"reasoning_effort": "low", "max_iterations": 10}, + }, + } + mock_resolve_creds.return_value = { + "model": "gpt-5.4-mini", + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + } + mock_build_child.side_effect = [MagicMock(), MagicMock()] + mock_run_child.side_effect = [ + {"task_index": 0, "status": "completed", "summary": "a", "api_calls": 1, "duration_seconds": 0.1}, + {"task_index": 1, "status": "completed", "summary": "b", "api_calls": 1, "duration_seconds": 0.1}, + ] + + result = json.loads( + delegate_task( + tier="heavy", + tasks=[ + {"goal": "implement feature"}, + {"goal": "list files", "tier": "light"}, + ], + parent_agent=_make_mock_parent(), + ) + ) + + self.assertEqual(len(result["results"]), 2) + first_kwargs = mock_build_child.call_args_list[0].kwargs + second_kwargs = mock_build_child.call_args_list[1].kwargs + self.assertEqual(first_kwargs["max_iterations"], 50) + self.assertEqual(first_kwargs["override_reasoning_effort"], "medium") + self.assertEqual(second_kwargs["max_iterations"], 10) + self.assertEqual(second_kwargs["override_reasoning_effort"], "low") + + @patch("tools.delegate_tool._run_single_child") + @patch("tools.delegate_tool._build_child_agent") + @patch("tools.delegate_tool._resolve_delegation_credentials") + @patch("tools.delegate_tool._load_config") + @patch("tools.delegate_tool._get_max_concurrent_children") + def test_user_defined_tier_accepted_with_warning( + self, + mock_max_children, + mock_load_config, + mock_resolve_creds, + mock_build_child, + mock_run_child, + ): + """User-defined tiers should be accepted (not rejected) but warn if not in config.""" + mock_max_children.return_value = 1 + mock_load_config.return_value = { + "model": "gpt-5.4-mini", + "reasoning_effort": "low", + "max_iterations": 25, + "tiers": { + "heavy": {"reasoning_effort": "medium", "max_iterations": 50}, + }, + } + mock_resolve_creds.return_value = { + "model": "gpt-5.4-mini", + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + } + mock_build_child.return_value = MagicMock() + mock_run_child.return_value = {"task_index": 0, "status": "completed", "summary": "done", "api_calls": 1, "duration_seconds": 0.1} + + # User-defined tier not in config should fall back to flat config with warning + result = json.loads( + delegate_task( + tier="my_custom_tier", + goal="test custom tier", + parent_agent=_make_mock_parent(), + ) + ) + + # Should complete with default config (tier not in config, so falls back) + self.assertIn("results", result) + self.assertEqual(len(result["results"]), 1) + self.assertEqual(result["results"][0]["status"], "completed") + + @patch("tools.delegate_tool._run_single_child") + @patch("tools.delegate_tool._build_child_agent") + @patch("tools.delegate_tool._resolve_delegation_credentials") + @patch("tools.delegate_tool._load_config") + @patch("tools.delegate_tool._get_max_concurrent_children") + def test_user_defined_tier_with_config( + self, + mock_max_children, + mock_load_config, + mock_resolve_creds, + mock_build_child, + mock_run_child, + ): + """User-defined tier with proper config should work like built-in tiers.""" + mock_max_children.return_value = 1 + mock_load_config.return_value = { + "model": "gpt-5.4-mini", + "reasoning_effort": "low", + "max_iterations": 25, + "tiers": { + "my_custom_tier": {"reasoning_effort": "high", "max_iterations": 100}, + }, + } + mock_resolve_creds.return_value = { + "model": "gpt-5.4-mini", + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + } + mock_build_child.return_value = MagicMock() + mock_run_child.return_value = {"task_index": 0, "status": "completed", "summary": "done", "api_calls": 1, "duration_seconds": 0.1} + + result = json.loads( + delegate_task( + tier="my_custom_tier", + goal="test custom tier with config", + parent_agent=_make_mock_parent(), + ) + ) + + # Should complete with custom tier config applied + self.assertIn("results", result) + self.assertEqual(len(result["results"]), 1) + self.assertEqual(result["results"][0]["status"], "completed") + # Verify the custom tier config was applied + call_kwargs = mock_build_child.call_args.kwargs + self.assertEqual(call_kwargs["max_iterations"], 100) + self.assertEqual(call_kwargs["override_reasoning_effort"], "high") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 22b132f2c443..51357eee677c 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -20,6 +20,7 @@ import logging logger = logging.getLogger(__name__) import os +import re import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed @@ -52,6 +53,112 @@ _DEFAULT_MAX_CONCURRENT_CHILDREN = 3 MAX_DEPTH = 2 # parent (0) -> child (1) -> grandchild rejected (2) +# --------------------------------------------------------------------------- +# Task-tier profiles: named delegation presets for task shape/routing/effort +# --------------------------------------------------------------------------- +SUPPORTED_TIERS = frozenset({"light", "heavy", "review", "planning", "research"}) +_REVIEW_SIGNALS = { + "review", "audit", "critique", "inspect", "find bugs", "find bug", + "security check", "validate", "verify", "look for issues", "diff", +} +_PLANNING_SIGNALS = { + "plan", "design", "architect", "architecture", "roadmap", "strategy", + "how should we", "how should i", "migration plan", "outline", + "decompose", "break down", +} +_RESEARCH_SIGNALS = { + "research", "investigate", "compare options", "survey", "look up", + "look into", "gather", "summarize options", "benchmark", +} +_LIGHT_SIGNALS = { + "count", "list", "show", "display", "format", "read", "how many", + "simple lookup", +} +_TIER_REASONING_FLOORS = { + "heavy": "medium", + "research": "medium", + "planning": "high", + "review": "high", +} +_REASONING_ORDER = { + "none": 0, + "minimal": 1, + "low": 2, + "medium": 3, + "high": 4, + "xhigh": 5, +} + + +def resolve_tier_config(cfg: dict, tier: Optional[str] = None) -> dict: + """Resolve a delegation tier into an effective config dict. + + Always returns a shallow copy with ``tiers`` and ``default_tier`` removed. + Applies tier overrides when available and enforces reasoning floor guardrails. + """ + merged = dict(cfg or {}) + tiers = merged.pop("tiers", None) + default_tier = merged.pop("default_tier", None) + + if not isinstance(tiers, dict) or not tiers: + return merged + + if tier is not None and str(tier).strip(): + effective_tier = str(tier).strip().lower() + if effective_tier not in SUPPORTED_TIERS: + logger.warning("unknown delegation tier '%s'; proceeding with user-defined tier", tier) + else: + effective_tier = str(default_tier or "").strip().lower() or None + if not effective_tier: + return merged + if effective_tier not in SUPPORTED_TIERS: + logger.warning("unknown default_tier '%s'; proceeding with user-defined tier", default_tier) + + # Check if tier (built-in or user-defined) exists in config; if not, fall back to flat config + tier_cfg = tiers.get(effective_tier) + if not isinstance(tier_cfg, dict): + logger.warning("tier '%s' not defined in tiers config; falling back to flat config", effective_tier) + return merged + + merged.update(tier_cfg) + floor = _TIER_REASONING_FLOORS.get(effective_tier) + if floor: + current = str(merged.get("reasoning_effort") or "").strip().lower() + if _REASONING_ORDER.get(current, 0) < _REASONING_ORDER[floor]: + merged["reasoning_effort"] = floor + return merged + + +def _contains_signal(text: str, signal: str) -> bool: + if " " in signal: + return signal in text + return re.search(rf"\b{re.escape(signal)}\b", text) is not None + + +def _infer_delegate_tier(goal, context, toolsets, cfg) -> Optional[str]: + text = f"{goal or ''} {context or ''}".strip().lower() + if len(text) < 10: + return None + + if any(_contains_signal(text, sig) for sig in _REVIEW_SIGNALS): + return "review" + if any(_contains_signal(text, sig) for sig in _PLANNING_SIGNALS): + return "planning" + if any(_contains_signal(text, sig) for sig in _RESEARCH_SIGNALS): + return "research" + if any(_contains_signal(text, sig) for sig in _LIGHT_SIGNALS): + return "light" + return None + + +def _resolve_effective_tier(tier, goal, context, toolsets, cfg) -> Optional[str]: + raw_tier = str(tier or "").strip().lower() or None + if raw_tier: + return raw_tier + if cfg.get("auto_tier_selection") is True: + return _infer_delegate_tier(goal, context, toolsets, cfg) + return None + def _get_max_concurrent_children() -> int: """Read delegation.max_concurrent_children from config, falling back to @@ -276,6 +383,7 @@ def _build_child_agent( override_base_url: Optional[str] = None, override_api_key: Optional[str] = None, override_api_mode: Optional[str] = None, + override_reasoning_effort: Optional[str] = None, # ACP transport overrides — lets a non-ACP parent spawn ACP child agents override_acp_command: Optional[str] = None, override_acp_args: Optional[List[str]] = None, @@ -354,24 +462,34 @@ def _child_thinking(text: str) -> None: 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 [])) - # Resolve reasoning config: delegation override > parent inherit + # Resolve reasoning config: explicit override > delegation override > parent inherit 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 - 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) + from hermes_constants import parse_reasoning_effort + if override_reasoning_effort is not None and str(override_reasoning_effort).strip(): + parsed = parse_reasoning_effort(str(override_reasoning_effort).strip().lower()) + if parsed is not None: + child_reasoning = parsed + else: + logger.warning( + "Unknown override reasoning_effort '%s', inheriting parent level", + override_reasoning_effort, + ) + else: + try: + delegation_cfg = _load_config() + delegation_effort = str(delegation_cfg.get("reasoning_effort") or "").strip() + if delegation_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) child = AIAgent( base_url=effective_base_url, @@ -683,6 +801,7 @@ def delegate_task( toolsets: Optional[List[str]] = None, tasks: Optional[List[Dict[str, Any]]] = None, max_iterations: Optional[int] = None, + tier: Optional[str] = None, acp_command: Optional[str] = None, acp_args: Optional[List[str]] = None, parent_agent=None, @@ -702,27 +821,13 @@ def delegate_task( # Depth limit depth = getattr(parent_agent, '_delegate_depth', 0) if depth >= MAX_DEPTH: - return json.dumps({ - "error": ( - f"Delegation depth limit reached ({MAX_DEPTH}). " - "Subagents cannot spawn further subagents." - ) - }) + return tool_error( + f"Delegation depth limit reached ({MAX_DEPTH}). " + "Subagents cannot spawn further subagents." + ) # Load config - cfg = _load_config() - default_max_iter = cfg.get("max_iterations", DEFAULT_MAX_ITERATIONS) - effective_max_iter = max_iterations or default_max_iter - - # Resolve delegation credentials (provider:model pair). - # When delegation.provider is configured, this resolves the full credential - # bundle (base_url, api_key, api_mode) via the same runtime provider system - # used by CLI/gateway startup. When unconfigured, returns None values so - # children inherit from the parent. - try: - creds = _resolve_delegation_credentials(cfg, parent_agent) - except ValueError as exc: - return tool_error(str(exc)) + raw_cfg = _load_config() # Normalize to task list max_children = _get_max_concurrent_children() @@ -737,7 +842,7 @@ def delegate_task( ) task_list = tasks elif goal and isinstance(goal, str) and goal.strip(): - task_list = [{"goal": goal, "context": context, "toolsets": toolsets}] + task_list = [{"goal": goal, "context": context, "toolsets": toolsets, "tier": tier}] else: return tool_error("Provide either 'goal' (single task) or 'tasks' (batch).") @@ -768,13 +873,27 @@ def delegate_task( children = [] try: for i, t in enumerate(task_list): + task_tier = _resolve_effective_tier( + t.get("tier") if t.get("tier") is not None else tier, + t["goal"], + t.get("context"), + t.get("toolsets") or toolsets, + raw_cfg, + ) + task_cfg = resolve_tier_config(raw_cfg, tier=task_tier) + task_max_iter = max_iterations if max_iterations is not None else task_cfg.get("max_iterations", DEFAULT_MAX_ITERATIONS) + try: + task_creds = _resolve_delegation_credentials(task_cfg, parent_agent) + except ValueError as exc: + return tool_error(str(exc)) child = _build_child_agent( task_index=i, goal=t["goal"], context=t.get("context"), - toolsets=t.get("toolsets") or toolsets, model=creds["model"], - max_iterations=effective_max_iter, task_count=n_tasks, parent_agent=parent_agent, - override_provider=creds["provider"], override_base_url=creds["base_url"], - override_api_key=creds["api_key"], - override_api_mode=creds["api_mode"], + toolsets=t.get("toolsets") or toolsets, model=task_creds["model"], + max_iterations=task_max_iter, task_count=n_tasks, parent_agent=parent_agent, + override_provider=task_creds["provider"], override_base_url=task_creds["base_url"], + override_api_key=task_creds["api_key"], + override_api_mode=task_creds["api_mode"], + override_reasoning_effort=task_cfg.get("reasoning_effort"), override_acp_command=t.get("acp_command") or acp_command, override_acp_args=t.get("acp_args") or acp_args, ) @@ -1128,6 +1247,10 @@ def _load_config() -> dict: "items": {"type": "string"}, "description": f"Toolsets for this specific task. Available: {_TOOLSET_LIST_STR}. Use 'web' for network access, 'terminal' for shell, 'browser' for web interaction.", }, +"tier": { + "type": "string", + "description": "Per-task complexity tier. Overrides the top-level tier for this task only. Can be a built-in tier (light, heavy, review, planning, research) or a user-defined tier from config.", +}, "acp_command": { "type": "string", "description": "Per-task ACP command override (e.g. 'claude'). Overrides the top-level acp_command for this task only.", @@ -1156,6 +1279,15 @@ def _load_config() -> dict: "Only set lower for simple tasks." ), }, +"tier": { + "type": "string", + "description": ( + "Task complexity tier. Can be a built-in tier (light, heavy, review, planning, research) " + "or a user-defined tier from config. Built-in tiers have predefined reasoning floors; " + "user-defined tiers require explicit config in the 'tiers' section. " + "Per-task tiers in tasks[] override this top-level tier." + ), +}, "acp_command": { "type": "string", "description": ( @@ -1192,6 +1324,7 @@ def _load_config() -> dict: toolsets=args.get("toolsets"), tasks=args.get("tasks"), max_iterations=args.get("max_iterations"), + tier=args.get("tier"), acp_command=args.get("acp_command"), acp_args=args.get("acp_args"), parent_agent=kw.get("parent_agent")),