diff --git a/agent/subagent_lifecycle.py b/agent/subagent_lifecycle.py index 658654717f8b0..f091ab3a0b7bb 100644 --- a/agent/subagent_lifecycle.py +++ b/agent/subagent_lifecycle.py @@ -274,7 +274,7 @@ class SubagentLifecycleService: def __init__(self, parent_agent_resolver: Callable[[], Any]) -> None: self._parent_agent_resolver = parent_agent_resolver - def launch(self, request: SubagentLaunchRequest | Mapping[str, Any]) -> SubagentHandle: + def launch(self, request: SubagentLaunchRequest | Mapping[str, Any], *, tier: Optional[str] = None) -> SubagentHandle: parent = self._parent_agent_resolver() if parent is None: raise SubagentLifecycleError("No active Hermes parent session is available.") @@ -290,12 +290,21 @@ def launch(self, request: SubagentLaunchRequest | Mapping[str, Any]) -> Subagent raise SubagentLifecycleError("Duplicate correlation_id for this parent session.") # Lazy: delegate construction stays internal, plugins never import private delegation helpers. from tools.delegate_tool import ( - _build_child_preserving_parent_tools, _credential_overrides, _load_config, _resolve_delegation_credentials, - DEFAULT_MAX_ITERATIONS, + _build_child_preserving_parent_tools, _credential_overrides, _load_config, + _resolve_delegation_credentials, _resolve_delegation_tier_routing_config, DEFAULT_MAX_ITERATIONS, ) - # Routing precedence: request.provider > delegation config > parent. - routing_cfg = {"provider": request.provider, "model": request.model or ""} if request.provider is not None else _load_config() + config = _load_config() + routing_cfg = config + if config.get("tiers") is not None and tier is not None and ( + request.provider is not None or request.model is not None + ): + raise SubagentLifecycleError("tier cannot be combined with a per-launch model or provider override.") try: + routing_cfg = _resolve_delegation_tier_routing_config( + routing_cfg, tier, tier_configs=config.get("tiers"), enforce_tier=tier is not None, + ) + if request.provider is not None: + routing_cfg = {"provider": request.provider, "model": request.model or ""} creds = _resolve_delegation_credentials(routing_cfg, parent) except ValueError as exc: raise SubagentLifecycleError(str(exc)) from exc diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 0a1fe3d98c19c..87103204c04ab 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1852,6 +1852,14 @@ delegation: # # delegation.model to an inexpensive one — children carry the # # vast majority of tokens, so this is where spend is cut while # # planning quality stays with the frontier parent. + # Optional per-call model routes. If present, delegate_task requires tier: easy, medium, or hard. + # Each tier is a full route spec. Base model/provider/endpoint/credentials/request overrides/fallback providers are cleared first. + # A tier with only model inherits the parent runtime provider and endpoint, not delegation.provider. + # A supplied tier is ignored when no tiers map is configured. + # tiers: + # easy: {model: "google/gemini-3-flash-preview", provider: "openrouter"} + # medium: {model: "anthropic/claude-sonnet-4", provider: "openrouter"} + # hard: {model: "anthropic/claude-opus-4", provider: "openrouter"} # fallback_providers: # Fallback chain for delegated children (same entry format as the # - provider: "openrouter" # top-level list). Pinned children use only a declared child chain; # model: "deepseek/deepseek-chat" # unpinned children inherit when unset. [] disables fallback (#65038). diff --git a/run_agent.py b/run_agent.py index 6ed2df0eb42d1..596db11952ef8 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1342,6 +1342,7 @@ def _dispatch_delegate_task(self, function_args: dict) -> str: goal=function_args.get("goal"), context=function_args.get("context"), tasks=_strip_model_hidden_task_fields(function_args.get("tasks")), max_iterations=function_args.get("max_iterations"), role=function_args.get("role"), + tier=function_args.get("tier"), enforce_tier=True, background=not (getattr(self, "_delegate_depth", 0) > 0), images=function_args.get("images"), action=function_args.get("action"), subagent_id=function_args.get("subagent_id"), message=function_args.get("message"), parent_agent=self, diff --git a/tests/agent/test_review_engine.py b/tests/agent/test_review_engine.py index bb388f10ef741..00cebcac538e2 100644 --- a/tests/agent/test_review_engine.py +++ b/tests/agent/test_review_engine.py @@ -244,6 +244,37 @@ def fake_build(**kw): assert evt is not None and evt["type"] == "async_delegation" assert evt["results"][0]["summary"] == "REVIEW: looks good" + + +def test_start_review_is_exempt_from_configured_delegation_tiers(monkeypatch): + import tools.delegate_tool as dt + + captured = {} + + def fake_resolve(config, _parent): + captured["routing_cfg"] = config + return { + "model": "parent-model", "provider": None, "base_url": None, + "api_key": None, "api_mode": None, "request_overrides": None, + "command": None, "args": None, + } + + monkeypatch.setattr(dt, "_load_config", lambda: { + "model": "delegation-model", + "tiers": {tier: {"model": f"tier-{tier}"} for tier in ("easy", "medium", "hard")}, + }) + monkeypatch.setattr(dt, "_resolve_delegation_credentials", fake_resolve) + monkeypatch.setattr(dt, "_build_child_preserving_parent_tools", lambda **_kwargs: MagicMock(_delegate_role="leaf")) + monkeypatch.setattr(dt, "_run_batch", lambda _batch, _background: '{"status": "dispatched"}') + monkeypatch.setattr(re_mod, "_load_review_credentials_cfg", lambda: None) + + parent = _fake_parent() + parent._delegate_depth = 0 + result = start_review(parent, [{"role": "user", "content": "review"}], "") + + assert result["status"] == "dispatched" + assert "tiers" not in captured["routing_cfg"] + assert captured["routing_cfg"]["model"] == "delegation-model" def test_start_review_rejects_empty_conversation(): with pytest.raises(ValueError, match="empty"): start_review(_fake_parent(), [], "") diff --git a/tests/agent/test_subagent_lifecycle.py b/tests/agent/test_subagent_lifecycle.py index ee4c49f93ccbb..ccaf622f0b5b6 100644 --- a/tests/agent/test_subagent_lifecycle.py +++ b/tests/agent/test_subagent_lifecycle.py @@ -285,6 +285,67 @@ def test_routing_delegation_provider_bundle(routing_lifecycle): assert build.call_args.kwargs["model"] == "request-model" + +def test_lifecycle_launch_without_tier_remains_exempt_when_tiers_configured(routing_lifecycle): + service, build, config, resolve = routing_lifecycle + config.return_value = { + "provider": "openrouter", "model": "delegated-model", + "base_url": "https://delegation.example/v1", + "tiers": {"hard": {"provider": "openrouter", "model": "tier-model"}}, + } + + handle = service.launch(SubagentLaunchRequest(goal="route")) + + assert service.wait(handle, timeout_seconds=2).state is SubagentState.SUCCEEDED + assert build.call_args.kwargs["model"] == "delegated-model" + assert build.call_args.kwargs["routing_cfg"]["model"] == "delegated-model" + resolve.assert_called_once_with(requested="openrouter", target_model="delegated-model") + + +def test_lifecycle_launch_resolves_explicit_tier(routing_lifecycle): + service, build, config, resolve = routing_lifecycle + config.return_value = { + "provider": "nous", "model": "delegated-model", + "base_url": "https://delegation.example/v1", "api_key": "delegation-fixture-key", + "api_mode": "anthropic_messages", + "tiers": {"hard": {"provider": "openrouter", "model": "tier-model"}}, + } + + handle = service.launch(SubagentLaunchRequest(goal="route"), tier="hard") + + assert service.wait(handle, timeout_seconds=2).state is SubagentState.SUCCEEDED + routing_cfg = build.call_args.kwargs["routing_cfg"] + assert build.call_args.kwargs["model"] == "tier-model" + assert routing_cfg["provider"] == "openrouter" + assert routing_cfg["model"] == "tier-model" + assert not {"base_url", "api_key", "api_mode"} & routing_cfg.keys() + resolve.assert_called_once_with(requested="openrouter", target_model="tier-model") + + +def test_lifecycle_ignores_tier_when_tiers_are_unconfigured(routing_lifecycle): + service, build, config, resolve = routing_lifecycle + config.return_value = {"provider": "openrouter", "model": "delegated-model"} + + handle = service.launch(SubagentLaunchRequest(goal="route"), tier="hard") + + assert service.wait(handle, timeout_seconds=2).state is SubagentState.SUCCEEDED + assert build.call_args.kwargs["model"] == "delegated-model" + assert build.call_args.kwargs["routing_cfg"] == config.return_value + resolve.assert_called_once_with(requested="openrouter", target_model="delegated-model") + + +@pytest.mark.parametrize("override", [{"provider": "openrouter"}, {"model": "request-model"}]) +def test_lifecycle_tier_rejects_per_launch_route_overrides(routing_lifecycle, override): + service, build, config, resolve = routing_lifecycle + config.return_value = {"tiers": {"hard": {"model": "tier-model"}}} + + with pytest.raises(SubagentLifecycleError, match="tier cannot be combined"): + service.launch({"goal": "route", **override}, tier="hard") + + build.assert_not_called() + resolve.assert_not_called() + + @pytest.mark.parametrize("wire", [False, True]) def test_routing_request_provider_wins_without_mixing_bundles(routing_lifecycle, wire): from hermes_cli.plugin_host_wire import decode, encode diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index b9b95062bdfa6..6710c28381743 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -1557,6 +1557,25 @@ def fake_delegate_task(**kwargs): self.assertNotIn("acp_command", captured["tasks"][0]) self.assertNotIn("acp_args", captured["tasks"][0]) + def test_tier_is_forwarded_to_delegate_task(self): + import run_agent + + captured = {} + + def fake_delegate_task(**kwargs): + captured.update(kwargs) + return "{}" + + parent = _make_mock_parent(depth=0) + with patch("tools.delegate_tool.delegate_task", fake_delegate_task): + run_agent.AIAgent._dispatch_delegate_task( + parent, + {"goal": "test", "tier": "hard"}, + ) + + self.assertEqual(captured["tier"], "hard") + self.assertTrue(captured["enforce_tier"]) + class TestConcurrencyDefaults(unittest.TestCase): diff --git a/tests/tools/test_delegate_tiers.py b/tests/tools/test_delegate_tiers.py new file mode 100644 index 0000000000000..8e723bf9d19e0 --- /dev/null +++ b/tests/tools/test_delegate_tiers.py @@ -0,0 +1,250 @@ +"""Per-call delegation model tiers.""" + +import json +from contextlib import nullcontext +from unittest.mock import MagicMock, patch + +import pytest + +from tools import delegate_tool +from tools.registry import registry + + +TIER_MODELS = { + "easy": "fixture/easy", + "medium": "fixture/medium", + "hard": "fixture/hard", +} + + +def _parent(): + from tests.tools.test_delegate import _make_mock_parent + + return _make_mock_parent(depth=0) + + +def _config(*, tiers=True): + config = { + "model": "fixture/default", + "base_url": "https://tiers.example/v1", + "api_key": "fixture-key", + } + if tiers: + config["tiers"] = { + tier: { + "model": model, + "base_url": "https://tiers.example/v1", + "api_key": "fixture-key", + } + for tier, model in TIER_MODELS.items() + } + return config + + +def _dispatch(args, config, monkeypatch, tmp_path, *, resolve_credentials=None): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + child = MagicMock() + resolver_patch = ( + patch.object(delegate_tool, "_resolve_delegation_credentials", side_effect=resolve_credentials) + if resolve_credentials is not None else nullcontext() + ) + with ( + patch.object(delegate_tool, "_load_config", return_value=config), + patch.object(delegate_tool, "_resolve_workspace_hint", return_value=str(tmp_path)), + patch.object(delegate_tool, "_model_background_value", return_value=False), + patch.object(delegate_tool, "_run_batch", return_value='{"ok": true}'), + patch("run_agent.AIAgent", return_value=child) as agent_class, + resolver_patch, + ): + result = json.loads(registry.dispatch("delegate_task", args, parent_agent=_parent())) + return result, agent_class + + +def test_non_mapping_tiers_config_is_rejected(monkeypatch, tmp_path): + result, agent_class = _dispatch( + {"goal": "Inspect fixture", "tier": "easy"}, + {**_config(), "tiers": ["easy"]}, monkeypatch, tmp_path, + ) + + assert "error" in result + assert "expected a mapping" in result["error"] + agent_class.assert_not_called() + + +def test_tier_provider_does_not_inherit_base_endpoint(monkeypatch, tmp_path): + config = _config() + config["tiers"]["hard"] = {"model": "openrouter/model", "provider": "openrouter"} + resolved = {} + + def resolve_credentials(routing_cfg, _parent): + resolved.update(routing_cfg) + return {"model": "openrouter/model", "provider": "openrouter", "base_url": None, + "api_key": "fixture-key", "api_mode": None, "request_overrides": None, + "command": None, "args": None} + + _dispatch( + {"goal": "Inspect fixture", "tier": "hard"}, config, monkeypatch, tmp_path, + resolve_credentials=resolve_credentials, + ) + + assert resolved["model"] == "openrouter/model" + assert resolved["provider"] == "openrouter" + assert not {"base_url", "api_key", "api_mode"} & resolved.keys() + assert "fixture/default" not in resolved.values() + + +def test_selected_tier_drops_base_route_options_and_inherits_parent_provider(monkeypatch, tmp_path): + config = _config() + config.update({ + "provider": "nous", + "request_overrides": {"temperature": 0.7}, + "fallback_providers": [{"provider": "nous", "model": "fallback-model"}], + "max_iterations": 123, + }) + config["tiers"]["hard"] = {"model": TIER_MODELS["hard"]} + resolved = {} + + def resolve_credentials(routing_cfg, _parent): + resolved.update(routing_cfg) + return { + "model": TIER_MODELS["hard"], "provider": None, "base_url": None, + "api_key": None, "api_mode": None, "request_overrides": None, + "command": None, "args": None, + } + + result, agent_class = _dispatch( + {"goal": "Inspect fixture", "tier": "hard"}, config, monkeypatch, tmp_path, + resolve_credentials=resolve_credentials, + ) + + assert result == {"ok": True} + assert resolved == {"max_iterations": 123, "model": TIER_MODELS["hard"]} + assert not {"provider", "base_url", "api_key", "api_mode", "request_overrides", "fallback_providers"} & resolved.keys() + child_kwargs = agent_class.call_args.kwargs + assert child_kwargs["provider"] == "openrouter" + assert child_kwargs["base_url"] == "https://openrouter.ai/api/v1" + + +def test_tier_is_ignored_when_tiers_are_unconfigured(monkeypatch, tmp_path): + result, agent_class = _dispatch( + {"goal": "Inspect fixture", "tier": "hard"}, + _config(tiers=False), monkeypatch, tmp_path, + ) + + assert result == {"ok": True} + assert agent_class.call_args.kwargs["model"] == "fixture/default" + assert agent_class.call_args.kwargs["provider"] == "custom" + + +@pytest.mark.parametrize("tier", [None, "unknown"]) +def test_configured_tiers_refuse_missing_or_unknown_tier_without_spawning( + tier, monkeypatch, tmp_path, +): + args = {"goal": "Inspect fixture"} + if tier is not None: + args["tier"] = tier + + result, agent_class = _dispatch(args, _config(), monkeypatch, tmp_path) + + assert "error" in result + assert all(name in result["error"] for name in ("easy", "medium", "hard")) + agent_class.assert_not_called() + + +def test_control_calls_need_no_tier(monkeypatch, tmp_path): + result, agent_class = _dispatch( + {"action": "list"}, _config(), monkeypatch, tmp_path, + ) + + assert result["action"] == "list" + assert "error" not in result + agent_class.assert_not_called() + + +@pytest.mark.parametrize("tier,model", TIER_MODELS.items()) +def test_each_tier_resolves_its_model_and_provider_for_child( + tier, model, monkeypatch, tmp_path, +): + result, agent_class = _dispatch( + {"goal": "Inspect fixture", "tier": tier}, _config(), monkeypatch, tmp_path, + ) + + assert result == {"ok": True} + child_kwargs = agent_class.call_args.kwargs + assert child_kwargs["model"] == model + assert child_kwargs["provider"] == "custom" + assert child_kwargs["base_url"] == "https://tiers.example/v1" + assert child_kwargs["api_key"] == "fixture-key" + + +def test_batch_uses_selected_tier_for_every_task(monkeypatch, tmp_path): + result, agent_class = _dispatch( + { + "tasks": [{"goal": "Inspect fixture A"}, {"goal": "Inspect fixture B"}], + "tier": "hard", + }, + _config(), + monkeypatch, + tmp_path, + ) + + assert result == {"ok": True} + assert agent_class.call_count == 2 + assert [call.kwargs["model"] for call in agent_class.call_args_list] == [ + TIER_MODELS["hard"], TIER_MODELS["hard"], + ] + + +def test_batch_without_tier_is_refused_before_spawning(monkeypatch, tmp_path): + result, agent_class = _dispatch( + {"tasks": [{"goal": "Inspect fixture A"}, {"goal": "Inspect fixture B"}]}, + _config(), + monkeypatch, + tmp_path, + ) + + assert "error" in result + assert all(name in result["error"] for name in ("easy", "medium", "hard")) + agent_class.assert_not_called() + + +def test_missing_requested_tier_is_a_config_error(monkeypatch, tmp_path): + config = _config() + del config["tiers"]["hard"] + + result, agent_class = _dispatch( + {"goal": "Inspect fixture", "tier": "hard"}, config, monkeypatch, tmp_path, + ) + + assert "error" in result + assert "hard" in result["error"] + assert "delegation.tiers" in result["error"] + agent_class.assert_not_called() + + +def test_without_tiers_legacy_call_uses_delegation_model(monkeypatch, tmp_path): + result, agent_class = _dispatch( + {"goal": "Inspect fixture"}, _config(tiers=False), monkeypatch, tmp_path, + ) + + assert result == {"ok": True} + child_kwargs = agent_class.call_args.kwargs + assert child_kwargs["model"] == "fixture/default" + assert child_kwargs["provider"] == "custom" + + +def test_schema_requires_tier_only_when_tiers_are_configured(monkeypatch): + for enabled in (True, False): + config = _config(tiers=enabled) + monkeypatch.setattr(delegate_tool, "_load_config", lambda config=config: config) + definition = registry.get_definitions({"delegate_task"})[0]["function"] + parameters = definition["parameters"] + if enabled: + assert "tier" in parameters["required"] + assert parameters["properties"]["tier"]["enum"] == ["easy", "medium", "hard"] + assert all( + label in parameters["properties"]["tier"]["description"] + for label in ("easy", "medium", "hard") + ) + else: + assert "tier" not in parameters["required"] diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 486acbab10667..556207e6b028f 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -417,6 +417,37 @@ def _credential_overrides(creds: dict[str, Any], routing_cfg: dict[str, Any]) -> } +_TIER_ROUTE_KEYS = frozenset({ + "model", "provider", "base_url", "api_key", "api_mode", "request_overrides", "fallback_providers", +}) +_DELEGATION_TIERS = ("easy", "medium", "hard") + + +def _resolve_delegation_tier_routing_config( + routing_cfg: dict[str, Any], tier: Optional[str], *, tier_configs: Any, enforce_tier: bool = False, +) -> dict[str, Any]: + """Resolve a tier as a complete route, independent of base delegation routing settings.""" + if tier_configs is None: + return {key: value for key, value in routing_cfg.items() if key != "tiers"} + if tier is None and not enforce_tier: + return {key: value for key, value in routing_cfg.items() if key != "tiers"} + if not isinstance(tier_configs, dict): + raise ValueError("Invalid delegation.tiers config: expected a mapping for easy, medium, and hard.") + if tier not in _DELEGATION_TIERS: + raise ValueError( + "A delegation tier is required when delegation.tiers is configured. " + f"Allowed tiers: {', '.join(_DELEGATION_TIERS)}." + ) + tier_cfg = tier_configs.get(tier) + if not isinstance(tier_cfg, dict): + raise ValueError( + f"Missing or invalid delegation.tiers.{tier} config; provide a model spec for this tier." + ) + resolved_cfg = {key: value for key, value in routing_cfg.items() if key not in _TIER_ROUTE_KEYS | {"tiers"}} + resolved_cfg.update(tier_cfg) + return resolved_cfg + + def _build_children( task_list: list[dict[str, Any]], task_schemas: list[Optional[dict[str, Any]]], creds: dict[str, Any], *, top_role: str, max_iterations: int, parent_agent, routing_cfg: dict[str, Any], @@ -490,7 +521,7 @@ def delegate_task( max_iterations: Optional[int] = None, role: Optional[str] = None, background: Optional[bool] = None, output_schema: Optional[dict[str, Any]] = None, images: Optional[list[str]] = None, action: Optional[str] = None, subagent_id: Optional[str] = None, message: Optional[str] = None, parent_agent=None, - credentials_cfg: Optional[dict[str, Any]] = None, + credentials_cfg: Optional[dict[str, Any]] = None, tier: Optional[str] = None, enforce_tier: bool = False, ) -> str: """Spawn child agents (single ``goal`` or ``tasks=[...]`` batch) or control running ones. ``action`` list/steer/stop run synchronously and bypass the pause gate, depth limit and async dispatch. ``role`` is legacy @@ -498,13 +529,22 @@ def delegate_task( dispatch handle when running in the background.""" if parent_agent is None: return tool_error("delegate_task requires a parent agent context.") - + cfg = _load_config() normalized_action = (action or "").strip().lower() if normalized_action in _CONTROL_ACTIONS: return _handle_control_action(normalized_action, subagent_id, message, parent_agent) if normalized_action and normalized_action != "spawn": return tool_error(f"Unknown action '{action}'. Use spawn (default), list, steer, or stop.") + try: + routing_cfg = _resolve_delegation_tier_routing_config( + credentials_cfg if credentials_cfg is not None else cfg, + tier, + tier_configs=cfg.get("tiers"), + enforce_tier=enforce_tier, + ) + except ValueError as exc: + return tool_error(str(exc)) # Operator kill switch (TUI / delegation.pause RPC): blocks NEW spawns only. if is_spawn_paused(): return tool_error( @@ -517,6 +557,15 @@ def delegate_task( # that joins on every child and re-enters as a single consolidated message. background = is_truthy_value(background, default=False) if background is not None else False + default_max_iter = cfg.get("max_iterations", DEFAULT_MAX_ITERATIONS) + # Caller-supplied max_iterations is ignored: the config value is authoritative + # so budgets stay predictable (kwarg kept for internal callers/tests). + if max_iterations is not None and max_iterations != default_max_iter: + logger.debug( + "delegate_task: ignoring caller-supplied max_iterations=%s; using delegation.max_iterations=%s from config", + max_iterations, default_max_iter, + ) + depth = getattr(parent_agent, "_delegate_depth", 0) max_spawn = _get_max_spawn_depth() if depth >= max_spawn: @@ -526,19 +575,9 @@ def delegate_task( f"multiplies API cost)." ) - cfg = _load_config() - default_max_iter = cfg.get("max_iterations", DEFAULT_MAX_ITERATIONS) - # Caller-supplied max_iterations is ignored: the config value is authoritative - # so budgets stay predictable (kwarg kept for internal callers/tests). - if max_iterations is not None and max_iterations != default_max_iter: - logger.debug( - "delegate_task: ignoring caller-supplied max_iterations=%s; using delegation.max_iterations=%s from config", - max_iterations, default_max_iter, - ) # credentials_cfg (internal callers only, e.g. /review → auxiliary.review) is # a per-call routing owner shaped like the delegation config section. Keep # the route and its fallback policy together through child construction. - routing_cfg = credentials_cfg if credentials_cfg is not None else cfg try: creds = _resolve_delegation_credentials(routing_cfg, parent_agent) except ValueError as exc: @@ -673,11 +712,13 @@ def _build_dynamic_schema_overrides() -> dict: get_definitions() pass rewrites the descriptions to the user's actual limits.""" from tools.delegate_tool_config import _get_independent_completions + tiers_enabled = _load_config().get("tiers") is not None independent_completions = _get_independent_completions() overrides_params = {**DELEGATE_TASK_SCHEMA["parameters"]} # Copy properties so the static schema dict is never mutated. 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["required"] = ["tier"] if tiers_enabled else [] if not independent_completions: tasks = overrides_params["properties"]["tasks"] @@ -709,6 +750,14 @@ def _p(type_: str, description: str, **extra) -> dict: # wrapped into a one-entry batch at dispatch, and a per-task `role` (legacy, ignored: capability is # depth-derived). Both unadvertised on purpose (old transcripts only); do not re-add. No maxItems — the # runtime limit (delegation.max_concurrent_children) is enforced with a clear error in delegate_task(). + "tier": _p( + "string", + "easy = routine lookups or mechanical edits\n" + "medium = ordinary multi-step work\n" + "hard = difficult reasoning or design.\n" + "Required to spawn; ignored for control actions.", + enum=["easy", "medium", "hard"], + ), "tasks": { "type": "array", "minItems": 1, @@ -802,7 +851,7 @@ def _strip_model_hidden_task_fields(tasks: Any) -> Any: schema=DELEGATE_TASK_SCHEMA, handler=lambda args, **kw: delegate_task( goal=args.get("goal"), context=args.get("context"), tasks=_strip_model_hidden_task_fields(args.get("tasks")), - max_iterations=args.get("max_iterations"), role=args.get("role"), + max_iterations=args.get("max_iterations"), role=args.get("role"), tier=args.get("tier"), enforce_tier=True, background=_model_background_value(args, kw.get("parent_agent")), output_schema=args.get("output_schema"), images=args.get("images"), action=args.get("action"), subagent_id=args.get("subagent_id"), message=args.get("message"), parent_agent=kw.get("parent_agent"), diff --git a/website/docs/developer-guide/subagent-lifecycle-api.md b/website/docs/developer-guide/subagent-lifecycle-api.md index a2bd44613dde7..6149ff9501125 100644 --- a/website/docs/developer-guide/subagent-lifecycle-api.md +++ b/website/docs/developer-guide/subagent-lifecycle-api.md @@ -14,7 +14,8 @@ The facade also works when a plugin runs under `plugins.isolation: host`. Requests accept `goal`, `context`, `role`, `model`, `provider`, `allowed_toolsets`, `blocked_tools`, `working_directory`, `parent_session_id`, `correlation_id`, -`metadata`, and `timeout_seconds`, subject to the restrictions below. +`metadata`, and `timeout_seconds`, subject to the restrictions below. `launch()` +also accepts an optional keyword-only `tier`. ```python from agent.subagent_lifecycle import SubagentLaunchRequest @@ -72,8 +73,6 @@ timeouts are explicitly rejected until Hermes can support them without weakening isolation. Use `allowed_toolsets` to narrow a child; Hermes's existing unsafe-tool block remains enforced. -## Routing - Children follow the `delegation` config (`provider`, `model`, `base_url`, `api_key`, `api_mode`, `request_overrides`) exactly as `delegate_task` children do. The request's `model` overrides the configured model. Its optional `provider` @@ -82,3 +81,9 @@ delegation config > parent; the selected provider supplies its own credential bundle. An unresolvable provider raises `SubagentLifecycleError` before launch. Core does not expose `provider` to the model: the plugin chooses it, for example from operator config. + +When `delegation.tiers` is configured, model-facing `delegate_task` calls must +name a tier. Plugin lifecycle launches remain exempt unless they pass +`tier="easy"`, `tier="medium"`, or `tier="hard"` to `launch()`. A supplied +tier selects its own model-routing bundle instead of inheriting the base +delegation model, provider, endpoint, key, or API mode. diff --git a/website/docs/user-guide/features/delegation.md b/website/docs/user-guide/features/delegation.md index 1efe1662a818b..b876da88009e8 100644 --- a/website/docs/user-guide/features/delegation.md +++ b/website/docs/user-guide/features/delegation.md @@ -657,6 +657,21 @@ error. ## Configuration +To select a child model per model-facing delegation call, configure `delegation.tiers`. Each tier accepts the +same model-routing keys as `delegation` (`model`, `provider`, `base_url`, `api_key`, and optional +`api_mode`). When this map is present, every model-facing `delegate_task` call must name one tier. +Control actions such as `action: list` ignore the tier, and one tier applies to every task in a batch. + +Tier specs replace base routing fields, including request overrides and fallback providers. A tier with only `model` keeps the parent's runtime provider and endpoint; it does not inherit `delegation.provider`. If `delegation.tiers` is unset, a supplied tier is ignored. + +```yaml +delegation: + tiers: + easy: {model: "google/gemini-3-flash-preview", provider: "openrouter"} + medium: {model: "anthropic/claude-sonnet-4", provider: "openrouter"} + hard: {model: "anthropic/claude-opus-4", provider: "openrouter"} +``` + ```yaml # In ~/.hermes/config.yaml delegation: