diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 2a37a6d63fb9..8c18f7efbdd5 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2460,6 +2460,11 @@ def _ensure_hermes_home_managed(home: Path): # (floor 30s) to enforce a hard cap. "reasoning_effort": "", # subagent effort: "ultra", "max", "xhigh", "high", # "medium", "low", "minimal", "none" (empty = inherit) + # Operator-approved per-task capability lanes. The model can request only + # explore/engineer/review; each configured entry must contain provider, + # model, and reasoning_effort. Raw credentials and transport overrides + # are intentionally rejected from lane entries. + "lanes": {}, "max_concurrent_children": 3, # unified concurrency cap: max parallel children per batch # AND max concurrent background (background=true) # delegation units. New async dispatches beyond the cap diff --git a/run_agent.py b/run_agent.py index b1f0008d8909..aa8458d550c2 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6899,6 +6899,7 @@ def _dispatch_delegate_task(self, function_args: dict) -> str: role=function_args.get("role"), background=(not _is_subagent), parent_agent=self, + lane=function_args.get("lane"), ) def _invoke_tool(self, function_name: str, function_args: dict, effective_task_id: str, diff --git a/tests/tools/test_delegate_lanes.py b/tests/tools/test_delegate_lanes.py new file mode 100644 index 000000000000..55a26a0dbd68 --- /dev/null +++ b/tests/tools/test_delegate_lanes.py @@ -0,0 +1,453 @@ +"""Behavior contract for config-controlled delegate_task capability lanes.""" + +import json +from typing import Any, cast +from unittest.mock import MagicMock, patch + +import pytest + +from tools.delegate_tool import ( + DELEGATE_TASK_SCHEMA, + _build_child_agent, + _build_dynamic_schema_overrides, + _resolve_task_lane_routing, + delegate_task, +) + + +LANE_CONFIG = { + "model": "", + "provider": "", + "reasoning_effort": "", + "lanes": { + "explore": { + "provider": "openrouter", + "model": "x-ai/grok-4.5", + "reasoning_effort": "low", + }, + "engineer": { + "provider": "openai-codex", + "model": "gpt-5.6-luna", + "reasoning_effort": "medium", + }, + }, +} + + +def _parent(): + parent = MagicMock() + parent.base_url = "https://chatgpt.com/backend-api/codex" + parent.api_key = "test-only" + parent.api_mode = "codex_responses" + parent.provider = "openai-codex" + parent.model = "gpt-5.6-sol" + parent.platform = "cli" + parent.enabled_toolsets = [] + parent.disabled_toolsets = [] + parent.providers_allowed = None + parent.providers_ignored = None + parent.providers_order = None + parent.provider_sort = None + parent._session_db = None + parent._delegate_depth = 0 + parent._active_children = [] + return parent + + +def _fake_credentials(config, _parent_agent): + return { + "model": config.get("model") or None, + "provider": config.get("provider") or None, + "base_url": "https://example.invalid/v1", + "api_key": "test-only", + "api_mode": "chat_completions", + "request_overrides": None, + "max_output_tokens": None, + } + + +def test_static_schema_exposes_only_lane_not_raw_routing_fields(): + parameters = cast(dict[str, Any], DELEGATE_TASK_SCHEMA["parameters"]) + props = cast(dict[str, Any], parameters["properties"]) + task_props = cast( + dict[str, Any], props["tasks"]["items"]["properties"] + ) + + assert props["lane"]["enum"] == ["explore", "engineer", "review"] + assert task_props["lane"]["enum"] == ["explore", "engineer", "review"] + for forbidden in ("model", "provider", "reasoning_effort", "base_url", "api_key"): + assert forbidden not in props + assert forbidden not in task_props + + +def test_dynamic_schema_only_advertises_operator_configured_lanes(): + with patch("tools.delegate_tool._load_config", return_value=LANE_CONFIG): + schema = _build_dynamic_schema_overrides()["parameters"] + + props = schema["properties"] + assert props["lane"]["enum"] == ["explore", "engineer"] + assert props["tasks"]["items"]["properties"]["lane"]["enum"] == [ + "explore", + "engineer", + ] + assert "configured lanes: explore, engineer" in props["lane"]["description"] + + +def test_dynamic_schema_hides_lane_when_operator_config_has_none(): + with patch("tools.delegate_tool._load_config", return_value={"lanes": {}}): + schema = _build_dynamic_schema_overrides()["parameters"] + + props = schema["properties"] + assert "lane" not in props + assert "lane" not in props["tasks"]["items"]["properties"] + + +def test_dynamic_schema_excludes_incomplete_lane(): + config = { + "lanes": { + "explore": { + "provider": "openrouter", + "model": "x-ai/grok-4.5", + }, + "engineer": cast(dict[str, Any], LANE_CONFIG["lanes"])["engineer"], + } + } + + with patch("tools.delegate_tool._load_config", return_value=config): + schema = _build_dynamic_schema_overrides()["parameters"] + + props = schema["properties"] + assert props["lane"]["enum"] == ["engineer"] + assert props["tasks"]["items"]["properties"]["lane"]["enum"] == [ + "engineer" + ] + + +def test_mixed_batch_resolves_each_unique_lane_once_and_per_task_wins(): + tasks = [ + {"goal": "research", "lane": "explore"}, + {"goal": "build"}, + {"goal": "review", "lane": "explore"}, + ] + + with patch( + "tools.delegate_tool._resolve_delegation_credentials", + side_effect=_fake_credentials, + ) as resolver: + routes = _resolve_task_lane_routing( + LANE_CONFIG, + tasks, + top_lane="engineer", + parent_agent=_parent(), + ) + + assert [route["lane"] for route in routes] == [ + "explore", + "engineer", + "explore", + ] + assert [route["credentials"]["model"] for route in routes] == [ + "x-ai/grok-4.5", + "gpt-5.6-luna", + "x-ai/grok-4.5", + ] + assert [route["reasoning_effort"] for route in routes] == [ + "low", + "medium", + "low", + ] + assert resolver.call_count == 2 + + +def test_registry_handler_forwards_lane_and_filters_task_routing_fields(): + from tools.registry import registry + + captured = {} + + def fake_delegate_task(**kwargs): + captured.update(kwargs) + return "{}" + + with patch("tools.delegate_tool.delegate_task", fake_delegate_task): + result = registry.dispatch( + "delegate_task", + { + "goal": "task", + "lane": "engineer", + "tasks": [ + { + "goal": "nested", + "lane": "explore", + "model": "must-not-pass", + "provider": "must-not-pass", + "reasoning_effort": "must-not-pass", + "base_url": "must-not-pass", + "api_key": "must-not-pass", + "request_overrides": {"must-not-pass": True}, + "max_output_tokens": 1, + "command": "must-not-pass", + "args": ["must-not-pass"], + } + ], + }, + parent_agent=_parent(), + ) + + assert result == "{}" + assert captured["lane"] == "engineer" + assert captured["tasks"] == [{"goal": "nested", "lane": "explore"}] + + +def test_delegate_task_builds_each_batch_child_from_its_resolved_lane(): + built = [] + child = MagicMock() + run_results = [ + { + "task_index": 0, + "status": "completed", + "summary": "explored", + "api_calls": 1, + "duration_seconds": 0.1, + }, + { + "task_index": 1, + "status": "completed", + "summary": "engineered", + "api_calls": 1, + "duration_seconds": 0.1, + }, + ] + + def fake_build(**kwargs): + built.append(kwargs) + return child + + with ( + patch("tools.delegate_tool._load_config", return_value=LANE_CONFIG), + patch( + "tools.delegate_tool._resolve_delegation_credentials", + side_effect=_fake_credentials, + ), + patch("tools.delegate_tool._build_child_preserving_parent_tools", side_effect=fake_build), + patch("tools.delegate_tool._run_single_child", side_effect=run_results), + patch( + "tools.delegation_live_log.create_live_transcripts", + return_value=("delegation-id", [], []), + ), + patch("tools.delegation_live_log.update_manifest_statuses"), + ): + result = json.loads( + delegate_task( + lane="engineer", + tasks=[ + {"goal": "research", "lane": "explore"}, + {"goal": "build"}, + ], + parent_agent=_parent(), + ) + ) + + assert [kwargs["override_provider"] for kwargs in built] == [ + "openrouter", + "openai-codex", + ] + assert [kwargs["model"] for kwargs in built] == [ + "x-ai/grok-4.5", + "gpt-5.6-luna", + ] + assert [kwargs["override_reasoning_effort"] for kwargs in built] == [ + "low", + "medium", + ] + assert [entry["summary"] for entry in result["results"]] == [ + "explored", + "engineered", + ] + + +def test_no_lane_preserves_legacy_global_override_resolution(): + cfg = { + "provider": "openrouter", + "model": "legacy/model", + "reasoning_effort": "high", + } + tasks = [{"goal": "one"}, {"goal": "two"}] + parent = _parent() + + with patch( + "tools.delegate_tool._resolve_delegation_credentials", + side_effect=_fake_credentials, + ) as resolver: + routes = _resolve_task_lane_routing( + cfg, + tasks, + top_lane=None, + parent_agent=parent, + ) + + assert [route["lane"] for route in routes] == [None, None] + assert [route["credentials"]["model"] for route in routes] == [ + "legacy/model", + "legacy/model", + ] + assert [route["reasoning_effort"] for route in routes] == [None, None] + resolver.assert_called_once_with(cfg, parent) + + +@pytest.mark.parametrize("requested", ["cheap", "gpt-5.6-luna", "EXPLORE", 7]) +def test_unknown_or_non_string_lane_fails_closed(requested): + with pytest.raises(ValueError, match="lane"): + _resolve_task_lane_routing( + LANE_CONFIG, + [{"goal": "task", "lane": requested}], + top_lane=None, + parent_agent=_parent(), + ) + + +def test_unconfigured_lane_fails_closed_before_credentials_are_resolved(): + with patch("tools.delegate_tool._resolve_delegation_credentials") as resolver: + with pytest.raises(ValueError, match="review.*not configured"): + _resolve_task_lane_routing( + LANE_CONFIG, + [{"goal": "task", "lane": "review"}], + top_lane=None, + parent_agent=_parent(), + ) + resolver.assert_not_called() + + +def test_disabled_lane_fails_closed_before_child_construction(): + cfg = { + "lanes": { + "review": { + "enabled": False, + } + } + } + with ( + patch("tools.delegate_tool._load_config", return_value=cfg), + patch("tools.delegate_tool._build_child_preserving_parent_tools") as builder, + ): + response = json.loads( + delegate_task( + goal="must not spawn", + lane="review", + parent_agent=_parent(), + ) + ) + + assert "unsupported fields" in response["error"] + builder.assert_not_called() + + +def test_lane_config_rejects_secret_or_transport_escape_hatches(): + cfg = { + "lanes": { + "explore": { + "provider": "openrouter", + "model": "x-ai/grok-4.5", + "reasoning_effort": "low", + "api_key": "must-not-be-accepted", + } + } + } + with pytest.raises(ValueError, match="unsupported fields.*api_key"): + _resolve_task_lane_routing( + cfg, + [{"goal": "task", "lane": "explore"}], + top_lane=None, + parent_agent=_parent(), + ) + + +def test_lane_config_requires_provider_model_and_valid_effort(): + for spec, error in [ + ({"model": "m", "reasoning_effort": "low"}, "provider"), + ({"provider": "openrouter", "reasoning_effort": "low"}, "model"), + ({"provider": "openrouter", "model": "m"}, "reasoning_effort"), + ( + { + "provider": "openrouter", + "model": "m", + "reasoning_effort": "impossible", + }, + "reasoning_effort", + ), + ]: + with pytest.raises(ValueError, match=error): + _resolve_task_lane_routing( + {"lanes": {"explore": spec}}, + [{"goal": "task", "lane": "explore"}], + top_lane=None, + parent_agent=_parent(), + ) + + +def test_run_agent_fast_path_forwards_lane_and_strips_raw_routing_fields(): + import run_agent + + captured = {} + + def fake_delegate_task(**kwargs): + captured.update(kwargs) + return "{}" + + parent = _parent() + with patch("tools.delegate_tool.delegate_task", fake_delegate_task): + run_agent.AIAgent._dispatch_delegate_task( + parent, + { + "goal": "task", + "lane": "engineer", + "tasks": [ + { + "goal": "nested", + "lane": "review", + "model": "must-not-pass", + "provider": "must-not-pass", + "reasoning_effort": "must-not-pass", + "base_url": "must-not-pass", + "api_key": "must-not-pass", + "request_overrides": {"must-not-pass": True}, + "max_output_tokens": 1, + "command": "must-not-pass", + "args": ["must-not-pass"], + } + ], + }, + ) + + assert captured["lane"] == "engineer" + assert captured["tasks"] == [{"goal": "nested", "lane": "review"}] + + +def test_lane_reasoning_override_beats_global_delegation_effort(): + parent = _parent() + parent.reasoning_config = {"enabled": True, "effort": "xhigh"} + + with ( + patch( + "tools.delegate_tool._load_config", + return_value={"reasoning_effort": "high"}, + ), + patch("run_agent.AIAgent") as agent_cls, + ): + agent_cls.return_value = MagicMock() + _build_child_agent( + task_index=0, + goal="task", + context=None, + toolsets=None, + model="gpt-5.6-luna", + max_iterations=50, + task_count=1, + parent_agent=parent, + override_provider="openai-codex", + override_reasoning_effort="medium", + ) + + assert agent_cls.call_args.kwargs["reasoning_config"] == { + "enabled": True, + "effort": "medium", + } diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 7894a6af9bfc..6069409cdbc2 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -29,7 +29,7 @@ ThreadPoolExecutor, TimeoutError as FuturesTimeoutError, ) -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, cast from urllib.parse import urlsplit, urlunsplit from toolsets import TOOLSETS @@ -38,6 +38,12 @@ # not natively known (named custom providers, third-party aggregators, etc.). # Must match hermes_cli.runtime_provider.RUNTIME_PROVIDER_TYPE_CUSTOM. _RUNTIME_PROVIDER_CUSTOM = "custom" + +# Model-facing capability lanes. The model can select only one of these stable +# task classes; provider/model/effort remain operator-owned in config.yaml. +DELEGATION_LANES = ("explore", "engineer", "review") +_LANE_CONFIG_FIELDS = frozenset({"provider", "model", "reasoning_effort"}) +_REASONING_EFFORT_UNSET = object() from tools import file_state from tools.terminal_tool import set_approval_callback as _set_subagent_approval_cb from utils import base_url_hostname, is_truthy_value @@ -1205,6 +1211,7 @@ def _build_child_agent( override_api_mode: Optional[str] = None, override_request_overrides: Optional[Dict[str, Any]] = None, override_max_tokens: Optional[int] = None, + override_reasoning_effort: Any = _REASONING_EFFORT_UNSET, # ACP transport overrides from trusted delegation config. override_acp_command: Optional[str] = None, override_acp_args: Optional[List[str]] = None, @@ -1432,14 +1439,18 @@ def _child_thinking(text: str) -> None: effective_provider = "copilot-acp" effective_api_mode = "chat_completions" - # Resolve reasoning config: delegation override > parent inherit + # Resolve reasoning config: lane override > global delegation override > parent. parent_reasoning = getattr(parent_agent, "reasoning_config", None) child_reasoning = parent_reasoning try: # Keep the raw value — ``str(x or "")`` would coerce a YAML boolean # False (``reasoning_effort: false``) to "" and inherit the parent # instead of disabling thinking for children. - delegation_effort = delegation_cfg.get("reasoning_effort") + delegation_effort = ( + delegation_cfg.get("reasoning_effort") + if override_reasoning_effort is _REASONING_EFFORT_UNSET + else override_reasoning_effort + ) if delegation_effort or delegation_effort is False: from hermes_constants import parse_reasoning_effort @@ -1448,7 +1459,7 @@ def _child_thinking(text: str) -> None: child_reasoning = parsed else: logger.warning( - "Unknown delegation.reasoning_effort '%s', inheriting parent level", + "Unknown delegation reasoning_effort '%s', inheriting parent level", delegation_effort, ) except Exception as exc: @@ -2279,6 +2290,10 @@ def _run_with_thread_capture(): "exit_reason": "timeout" if is_timeout else "error", "api_calls": child_api_calls, "duration_seconds": duration, + "lane": getattr(child, "_delegate_lane", None), + "reasoning_effort": getattr( + child, "_delegate_lane_reasoning_effort", None + ), "timeout_seconds": child_timeout if is_timeout else None, "timed_out_after_seconds": duration if is_timeout else None, "timeout_phase": ( @@ -2382,6 +2397,10 @@ def _run_with_thread_capture(): "summary": summary, "api_calls": api_calls, "duration_seconds": duration, + "lane": getattr(child, "_delegate_lane", None), + "reasoning_effort": getattr( + child, "_delegate_lane_reasoning_effort", None + ), "model": _model if isinstance(_model, str) else None, "exit_reason": exit_reason, "tokens": { @@ -2762,13 +2781,18 @@ def delegate_task( role: Optional[str] = None, background: Optional[bool] = None, parent_agent=None, + lane: Optional[str] = None, ) -> str: """ Spawn one or more child agents to handle delegated tasks. Supports two modes: - - Single: provide goal (+ optional context and role) - - Batch: provide tasks array [{goal, context, role}, ...] + - Single: provide goal (+ optional context, role, and lane) + - Batch: provide tasks array [{goal, context, role, lane}, ...] + + ``lane`` is a model-facing capability class, never a raw model override. + It resolves provider/model/reasoning_effort exclusively from the operator's + ``delegation.lanes`` config. Per-task lane beats the top-level default. The 'role' parameter controls whether a child can further delegate: 'leaf' (default) cannot; 'orchestrator' retains the delegation @@ -2834,16 +2858,6 @@ def delegate_task( ) effective_max_iter = 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)) - # Normalize to task list max_children = _get_max_concurrent_children() recovered_tasks, tasks_error = _recover_tasks_from_json_string(tasks) @@ -2879,6 +2893,23 @@ def delegate_task( if not task.get("goal", "").strip(): return tool_error(f"Task {i} is missing a 'goal'.") + # Copy caller-owned task dicts, then resolve all lane routing before child + # construction. A missing/malformed lane therefore fails closed without + # partially spawning a batch. Unique lanes share one credential resolution. + task_list = [dict(task) for task in task_list] + try: + task_routes = _resolve_task_lane_routing( + cfg, + task_list, + top_lane=lane, + parent_agent=parent_agent, + ) + except ValueError as exc: + return tool_error(str(exc)) + for task, route in zip(task_list, task_routes): + if route["lane"] is not None: + task["lane"] = route["lane"] + overall_start = time.monotonic() results = [] @@ -2923,6 +2954,13 @@ def delegate_task( # Per-task role beats top-level; normalise again so unknown # per-task values warn and degrade to leaf uniformly. effective_role = _normalize_role(t.get("role") or top_role) + route = task_routes[i] + creds = route["credentials"] + lane_effort = ( + route["reasoning_effort"] + if route["lane"] is not None + else _REASONING_EFFORT_UNSET + ) child = _build_child_preserving_parent_tools( task_index=i, goal=t["goal"], @@ -2940,10 +2978,15 @@ def delegate_task( override_api_mode=creds["api_mode"], override_request_overrides=creds.get("request_overrides"), override_max_tokens=creds.get("max_output_tokens"), + override_reasoning_effort=lane_effort, override_acp_command=creds.get("command"), override_acp_args=creds.get("args"), role=effective_role, ) + child._delegate_lane = route["lane"] + child._delegate_lane_reasoning_effort = ( + route["reasoning_effort"] if route["lane"] is not None else None + ) # Tee the child's progress events into its live transcript log. # wrap_progress_callback preserves the inner callback contract # (including the _flush attribute) and never lets writer failures @@ -3580,6 +3623,129 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: } +def _configured_lane_names(cfg: dict) -> List[str]: + """Return syntactically valid configured lane names in stable enum order.""" + raw_lanes = cfg.get("lanes") if isinstance(cfg, dict) else None + if not isinstance(raw_lanes, dict): + return [] + configured = [] + for name in DELEGATION_LANES: + if not isinstance(raw_lanes.get(name), dict): + continue + try: + _validated_lane_config(cfg, name) + except ValueError: + continue + configured.append(name) + return configured + + +def _normalize_requested_lane(value: Any) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str): + raise ValueError( + "Delegation lane must be one of: " + ", ".join(DELEGATION_LANES) + "." + ) + lane = value.strip() + if lane not in DELEGATION_LANES: + raise ValueError( + f"Unknown delegation lane {value!r}; allowed lanes: " + + ", ".join(DELEGATION_LANES) + + "." + ) + return lane + + +def _validated_lane_config(cfg: dict, lane: str) -> dict: + raw_lanes = cfg.get("lanes") + if not isinstance(raw_lanes, dict): + raise ValueError( + f"Delegation lane '{lane}' is not configured under delegation.lanes.{lane}." + ) + raw_spec = raw_lanes.get(lane) + if not isinstance(raw_spec, dict): + raise ValueError( + f"Delegation lane '{lane}' is not configured under delegation.lanes.{lane}." + ) + + unsupported = sorted(set(raw_spec) - _LANE_CONFIG_FIELDS) + if unsupported: + raise ValueError( + f"Delegation lane '{lane}' has unsupported fields: " + + ", ".join(unsupported) + + ". Only provider, model, and reasoning_effort are allowed." + ) + + provider = raw_spec.get("provider") + model = raw_spec.get("model") + effort = raw_spec.get("reasoning_effort", _REASONING_EFFORT_UNSET) + if not isinstance(provider, str) or not provider.strip(): + raise ValueError(f"Delegation lane '{lane}' requires a non-empty provider.") + if not isinstance(model, str) or not model.strip(): + raise ValueError(f"Delegation lane '{lane}' requires a non-empty model.") + if effort is _REASONING_EFFORT_UNSET: + raise ValueError(f"Delegation lane '{lane}' requires reasoning_effort.") + + from hermes_constants import parse_reasoning_effort + + if parse_reasoning_effort(effort) is None: + raise ValueError( + f"Delegation lane '{lane}' has invalid reasoning_effort {effort!r}." + ) + + return { + "provider": provider.strip(), + "model": model.strip(), + "reasoning_effort": effort, + } + + +def _resolve_task_lane_routing( + cfg: dict, + task_list: List[Dict[str, Any]], + *, + top_lane: Any, + parent_agent, +) -> List[Dict[str, Any]]: + """Resolve operator-owned routing once per unique lane before child build. + + The model supplies only a stable capability lane. Provider, model, and + reasoning effort come exclusively from ``delegation.lanes``. A requested + lane that is missing or malformed fails closed before any child is built. + Calls without a lane preserve the legacy global override/inheritance path. + """ + normalized_top = _normalize_requested_lane(top_lane) + requested_lanes: List[Optional[str]] = [] + for task in task_list: + raw_task_lane = task.get("lane") if "lane" in task else normalized_top + requested_lanes.append( + _normalize_requested_lane(raw_task_lane) + if raw_task_lane is not None + else normalized_top + ) + + cache: Dict[Optional[str], Dict[str, Any]] = {} + routes: List[Dict[str, Any]] = [] + for lane in requested_lanes: + if lane not in cache: + if lane is None: + cache[lane] = { + "lane": None, + "credentials": _resolve_delegation_credentials(cfg, parent_agent), + "reasoning_effort": None, + } + else: + lane_cfg = _validated_lane_config(cfg, lane) + cache[lane] = { + "lane": lane, + "credentials": _resolve_delegation_credentials(lane_cfg, parent_agent), + "reasoning_effort": lane_cfg["reasoning_effort"], + } + routes.append(cache[lane]) + return routes + + def _load_config() -> dict: """Load delegation config from the active Hermes config. @@ -3728,7 +3894,7 @@ def _build_top_level_description() -> str: f"Orchestrators are bounded by max_spawn_depth={max_depth} for this " f"user and can be disabled globally via " "delegation.orchestrator_enabled=false.\n" - "- Subagent model is NOT selectable per call: children inherit the parent model (plus its fallback chain) unless you pin all subagents to a model via delegation.provider / delegation.model in config.yaml.\n" + "- Subagent model is NOT selectable directly per call. Without a lane, children inherit the parent model (plus its fallback chain) unless all subagents are pinned via delegation.provider / delegation.model. When delegation.lanes is configured, the model may select only an operator-approved capability lane; provider, model, and reasoning effort still come exclusively from config.yaml.\n" "- Each subagent gets its own terminal session (separate working directory and state).\n" "- Results are always returned as an array, one entry per task." ) @@ -3788,19 +3954,39 @@ def _build_role_param_description() -> str: def _build_dynamic_schema_overrides() -> dict: """Return per-call schema overrides reflecting current config. - Plugged into ToolEntry.dynamic_schema_overrides so every - get_definitions() pass rewrites the description fields to the user's - actual limits. + Descriptions expose live concurrency/depth limits. Capability-lane fields + are present only when the operator configured at least one valid lane name, + and their enum is narrowed to exactly those configured names. """ - overrides_params = { - **DELEGATE_TASK_SCHEMA["parameters"], - } - # Deep-copy properties so we don't mutate the static schema dict. - overrides_params["properties"] = { - k: dict(v) for k, v in DELEGATE_TASK_SCHEMA["parameters"]["properties"].items() - } - overrides_params["properties"]["tasks"]["description"] = _build_tasks_param_description() - overrides_params["properties"]["role"]["description"] = _build_role_param_description() + import copy + + overrides_params = cast( + Dict[str, Any], copy.deepcopy(DELEGATE_TASK_SCHEMA["parameters"]) + ) + properties = cast(Dict[str, Any], overrides_params["properties"]) + properties["tasks"]["description"] = _build_tasks_param_description() + properties["role"]["description"] = _build_role_param_description() + + configured_lanes = _configured_lane_names(_load_config()) + task_properties = cast( + Dict[str, Any], properties["tasks"]["items"]["properties"] + ) + if configured_lanes: + lane_description = ( + "Operator-controlled capability lane; configured lanes: " + + ", ".join(configured_lanes) + + ". Provider, model, and reasoning effort are resolved only from config." + ) + properties["lane"]["enum"] = configured_lanes + properties["lane"]["description"] = ( + lane_description + + " In batch mode this is the default and an item-level lane may override it." + ) + task_properties["lane"]["enum"] = configured_lanes + task_properties["lane"]["description"] = lane_description + else: + properties.pop("lane", None) + task_properties.pop("lane", None) return { "description": _build_top_level_description(), @@ -3842,6 +4028,15 @@ def _build_dynamic_schema_overrides() -> dict: "specific you are, the better the subagent performs." ), }, + "lane": { + "type": "string", + "enum": list(DELEGATION_LANES), + "description": ( + "Operator-controlled capability lane. The lane selects provider, " + "model, and reasoning effort from config; it never accepts raw " + "model identifiers. In batch mode this is the default lane." + ), + }, "tasks": { "type": "array", "items": { @@ -3852,6 +4047,14 @@ def _build_dynamic_schema_overrides() -> dict: "type": "string", "description": "Task-specific context", }, + "lane": { + "type": "string", + "enum": list(DELEGATION_LANES), + "description": ( + "Per-task capability lane override. Provider, model, " + "and reasoning effort are resolved only from config." + ), + }, "role": { "type": "string", "enum": ["leaf", "orchestrator"], @@ -3909,7 +4112,19 @@ def _model_background_value(args: dict, parent_agent=None) -> bool: return not is_subagent -_MODEL_HIDDEN_TASK_FIELDS = {"acp_command", "acp_args"} +_MODEL_HIDDEN_TASK_FIELDS = { + "acp_command", + "acp_args", + "model", + "provider", + "reasoning_effort", + "base_url", + "api_key", + "request_overrides", + "max_output_tokens", + "command", + "args", +} def _strip_model_hidden_task_fields(tasks: Any) -> Any: @@ -3943,6 +4158,7 @@ def _strip_model_hidden_task_fields(tasks: Any) -> Any: role=args.get("role"), background=_model_background_value(args, kw.get("parent_agent")), parent_agent=kw.get("parent_agent"), + lane=args.get("lane"), ), check_fn=check_delegate_requirements, emoji="🔀", diff --git a/website/docs/user-guide/features/delegation.md b/website/docs/user-guide/features/delegation.md index d40a0dfc107b..44da910ea417 100644 --- a/website/docs/user-guide/features/delegation.md +++ b/website/docs/user-guide/features/delegation.md @@ -140,18 +140,75 @@ process disappears while it is still running is recorded as `unknown`, because Hermes cannot prove whether its external side effects happened. Pending and delivered records are bounded and profile-local. -## Model Override +## Model routing -You can configure a different model for subagents via `config.yaml` — useful for delegating simple tasks to cheaper/faster models: +### One model for every subagent + +Set `delegation.model` and `delegation.provider` to route all subagents through one model: + +```yaml +# In ~/.hermes/config.yaml +delegation: + model: "google/gemini-flash-2.0" + provider: "openrouter" + reasoning_effort: "low" +``` + +Without these keys, subagents inherit the parent's provider, model, reasoning level, and fallback chain. + +### Capability lanes + +Capability lanes let one batch use different operator-approved model presets without exposing raw model identifiers to the LLM. `delegate_task` accepts only three stable classes: `explore`, `engineer`, and `review`. ```yaml # In ~/.hermes/config.yaml delegation: - model: "google/gemini-flash-2.0" # Cheaper model for subagents - provider: "openrouter" # Optional: route subagents to a different provider + lanes: + explore: + provider: "xai" + model: "grok-4.5" + reasoning_effort: "low" + engineer: + provider: "openai-codex" + model: "gpt-5.6-luna" + reasoning_effort: "medium" + review: + provider: "xiaomi" + model: "mimo-v2.5" + reasoning_effort: "low" ``` -If omitted, subagents use the same model as the parent. +Use a lane for one task: + +```python +delegate_task( + goal="Implement the validated API change", + lane="engineer", +) +``` + +Or mix lanes in one batch. An item-level lane overrides the top-level default: + +```python +delegate_task( + lane="engineer", + tasks=[ + {"goal": "Compare three implementation options", "lane": "explore"}, + {"goal": "Implement the selected option"}, + {"goal": "Review the implementation", "lane": "review"}, + ], +) +``` + +Lane routing is fail-closed: + +- The effective schema advertises only lane names present under `delegation.lanes`. +- Each lane must define `provider`, `model`, and `reasoning_effort`. +- Lane entries cannot contain API keys, base URLs, request overrides, commands, or ACP arguments. +- An unknown, unavailable, or malformed lane fails before Hermes constructs any child in the batch. +- Calls without `lane` keep the existing global override and parent-inheritance behavior. + +Each result records the resolved `lane`, `model`, and lane `reasoning_effort`. Credentials never enter the result or transcript. ## Inherited Tool Access