diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 731397ba13af..4377224f2697 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2088,6 +2088,11 @@ def _ensure_hermes_home_managed(home: Path): # Flip to true only if you trust delegated work to run dangerous cmds # without human review (cron pipelines, batch automation, etc.). "subagent_auto_approve": False, + # Overrides for model/provider/base_url/api_key/api_mode/reasoning_effort based on tier + # Valid tiers are: small/medium/large + # The tier is determined by the caller of `delegate_task` + # Non-configured tiers fallback to the top-level delegation config + "tiers": {}, }, # Ephemeral prefill messages file — JSON list of {role, content} dicts diff --git a/run_agent.py b/run_agent.py index 125f7dff1192..380ab4515730 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5312,6 +5312,7 @@ def _dispatch_delegate_task(self, function_args: dict) -> str: context=function_args.get("context"), toolsets=function_args.get("toolsets"), tasks=function_args.get("tasks"), + tier=function_args.get("tier"), max_iterations=function_args.get("max_iterations"), acp_command=function_args.get("acp_command"), acp_args=function_args.get("acp_args"), diff --git a/scripts/release.py b/scripts/release.py index c2de7f6701db..f8c36660b6ce 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -285,6 +285,7 @@ "aman@abacus.ai": "Aman113114-IITD", "octavio.turra@gmail.com": "octavioturra", "524706+Twanislas@users.noreply.github.com": "Twanislas", + "loicnico96@gmail.com": "loicnico96", "9592417+adam91holt@users.noreply.github.com": "adam91holt", "kchuang1015@users.noreply.github.com": "kchuang1015", "maheshthedev@gmail.com": "MaheshtheDev", diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index e8505128f464..91c7443842bd 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -706,12 +706,18 @@ here; full developer notes live in `AGENTS.md`, user-facing docs under Spawn a subagent with an isolated context + terminal session. -- **Single:** `delegate_task(goal, context, toolsets)`. -- **Batch:** `delegate_task(tasks=[{goal, ...}, ...])` runs children in +- **Single:** `delegate_task(goal, context, toolsets, tier)`. +- **Batch:** `delegate_task(tasks=[{goal, ...}, ...], tier=...)` runs children in parallel, capped by `delegation.max_concurrent_children` (default 3). - **Background:** `delegate_task(background=true)` returns a handle immediately and keeps the parent loop going; the child's result re-enters the conversation as a new turn when it finishes. +- **Tier routing:** top-level `tier` is bounded to `small`, `medium`, or `large`, + with `medium` being the default. This determines which model is used and which + level of reasoning to prefer. `small` prefers `delegation_small`, `large` + prefers `delegation_large`, and omitted / `medium` uses `delegation` before + falling back to parent inheritance. Batch mode applies one top-level tier to + the whole call; there is no per-task tier inside `tasks[]`. - **Roles:** `leaf` (default; cannot re-delegate) vs `orchestrator` (can spawn its own workers, bounded by `delegation.max_spawn_depth`). - **Not durable.** A backgrounded child is still process-local — if the @@ -719,7 +725,8 @@ Spawn a subagent with an isolated context + terminal session. the process, use `cronjob` or `terminal(background=True, notify_on_complete=True)`. -Config: `delegation.*` in `config.yaml`. +Config: `delegation.*` in `config.yaml`, plus optional `delegation_small.*` +and `delegation_large.*` routing blocks. ### Cron (scheduled jobs) diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 5eef8f3bb2f6..7f97be5c7ff1 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -16,6 +16,8 @@ import unittest from unittest.mock import MagicMock, patch +from run_agent import AIAgent + from tools.delegate_tool import ( DELEGATE_BLOCKED_TOOLS, DELEGATE_TASK_SCHEMA, @@ -33,6 +35,8 @@ _resolve_child_credential_pool, _resolve_delegation_credentials, _inherit_parent_base_url, + _merge_delegation_config_for_tier, + _load_delegation_config_for_tier, ) @@ -70,6 +74,9 @@ def test_schema_valid(self): self.assertIn("tasks", props) self.assertIn("context", props) self.assertIn("toolsets", props) + self.assertIn("tier", props) + self.assertEqual(props["tier"]["enum"], ["small", "medium", "large"]) + self.assertNotIn("tier", props["tasks"]["items"]["properties"]) # max_iterations is intentionally NOT exposed to the model — it's # config-authoritative via delegation.max_iterations so users get # predictable budgets. @@ -211,6 +218,12 @@ def test_empty_goal(self): result = json.loads(delegate_task(goal=" ", parent_agent=parent)) self.assertIn("error", result) + def test_invalid_tier_returns_tool_error(self): + parent = _make_mock_parent() + result = json.loads(delegate_task(goal="test", tier="huge", parent_agent=parent)) + self.assertIn("error", result) + self.assertIn("Invalid delegation tier", result["error"]) + def test_task_missing_goal(self): parent = _make_mock_parent() result = json.loads(delegate_task(tasks=[{"context": "no goal here"}], parent_agent=parent)) @@ -248,6 +261,61 @@ def test_batch_mode(self, mock_run): self.assertEqual(result["results"][1]["summary"], "Result B") self.assertIn("total_duration_seconds", result) + @patch("tools.delegate_tool._run_single_child") + @patch("tools.delegate_tool._resolve_delegation_credentials") + @patch("tools.delegate_tool._load_config") + def test_batch_mode_uses_top_level_tier_once(self, mock_cfg, mock_creds, mock_run): + mock_cfg.return_value = {"max_iterations": 50} + mock_creds.return_value = { + "model": None, + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + } + mock_run.side_effect = [ + {"task_index": 0, "status": "completed", "summary": "Result A", "api_calls": 2, "duration_seconds": 3.0}, + {"task_index": 1, "status": "completed", "summary": "Result B", "api_calls": 4, "duration_seconds": 6.0}, + ] + parent = _make_mock_parent() + + result = json.loads( + delegate_task( + tasks=[{"goal": "Research topic A"}, {"goal": "Research topic B"}], + tier="large", + parent_agent=parent, + ) + ) + + self.assertIn("results", result) + mock_creds.assert_called_once_with(mock_cfg.return_value, parent) + + @patch("tools.delegate_tool._run_single_child") + @patch("tools.delegate_tool._resolve_delegation_credentials") + @patch("tools.delegate_tool._load_config") + def test_blank_tier_behaves_like_medium(self, mock_cfg, mock_creds, mock_run): + mock_cfg.return_value = {"max_iterations": 50} + mock_creds.return_value = { + "model": None, + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + } + mock_run.return_value = { + "task_index": 0, + "status": "completed", + "summary": "Done!", + "api_calls": 1, + "duration_seconds": 1.0, + } + parent = _make_mock_parent() + + result = json.loads(delegate_task(goal="Fix tests", tier=" ", parent_agent=parent)) + + self.assertIn("results", result) + mock_creds.assert_called_once_with(mock_cfg.return_value, parent) + @patch("tools.delegate_tool._run_single_child") def test_batch_mode_accepts_json_string_tasks(self, mock_run): mock_run.side_effect = [ @@ -970,7 +1038,6 @@ def test_model_only_no_provider(self): self.assertIsNone(creds["api_key"]) - def test_direct_endpoint_uses_configured_base_url_and_api_key(self): parent = _make_mock_parent(depth=0) cfg = { @@ -2078,6 +2145,24 @@ def test_invalid_reasoning_effort_falls_back_to_parent(self, MockAgent, mock_cfg class TestDispatchDelegateTask(unittest.TestCase): """Tests for the _dispatch_delegate_task helper and full param forwarding.""" + def test_tier_forwarded(self): + """The agent-loop dispatch helper must pass through the top-level tier.""" + parent = object.__new__(AIAgent) + with patch("tools.delegate_tool.delegate_task", return_value='{"ok":true}') as mock_delegate: + result = AIAgent._dispatch_delegate_task( + parent, + { + "goal": "test", + "toolsets": [], + "tier": "small", + }, + ) + + self.assertEqual(result, '{"ok":true}') + _, kwargs = mock_delegate.call_args + self.assertEqual(kwargs["tier"], "small") + self.assertIs(kwargs["parent_agent"], parent) + @patch("tools.delegate_tool._load_config", return_value={}) @patch("tools.delegate_tool._resolve_delegation_credentials") def test_acp_args_forwarded(self, mock_creds, mock_cfg): @@ -2868,6 +2953,204 @@ def test_child_gets_no_fallback_when_parent_chain_empty(self): _, kwargs = MockAgent.call_args self.assertIsNone(kwargs["fallback_model"]) +class TestDelegationTierConfigMerge(unittest.TestCase): + def test_same_provider_tier_inherits_base_routing_fields(self): + merged = _merge_delegation_config_for_tier( + { + "model": "anthropic/claude-sonnet-4", + "provider": "openrouter", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "base-key", + "api_mode": "chat_completions", + "max_iterations": 50, + }, + { + "model": "google/gemini-3-flash-preview", + "provider": "openrouter", + }, + ) + + self.assertEqual(merged["model"], "google/gemini-3-flash-preview") + self.assertEqual(merged["provider"], "openrouter") + self.assertEqual(merged["base_url"], "https://openrouter.ai/api/v1") + self.assertEqual(merged["api_key"], "base-key") + self.assertEqual(merged["api_mode"], "chat_completions") + self.assertEqual(merged["max_iterations"], 50) + + def test_provider_switch_clears_inherited_routing_bundle(self): + merged = _merge_delegation_config_for_tier( + { + "model": "anthropic/claude-sonnet-4", + "provider": "openrouter", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "base-key", + "api_mode": "chat_completions", + "command": "copilot", + "args": ["--acp", "--stdio"], + "max_iterations": 50, + }, + { + "provider": "minimax", + "max_iterations": 20, + }, + ) + + self.assertEqual(merged["provider"], "minimax") + self.assertNotIn("model", merged) + self.assertNotIn("base_url", merged) + self.assertNotIn("api_key", merged) + self.assertNotIn("api_mode", merged) + self.assertNotIn("command", merged) + self.assertNotIn("args", merged) + self.assertEqual(merged["max_iterations"], 20) + + def test_base_url_switch_clears_inherited_provider_fields(self): + merged = _merge_delegation_config_for_tier( + { + "model": "anthropic/claude-sonnet-4", + "provider": "openrouter", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "base-key", + "api_mode": "chat_completions", + "reasoning_effort": "medium", + }, + { + "base_url": "http://localhost:1234/v1", + "api_mode": "anthropic_messages", + }, + ) + + self.assertEqual(merged["base_url"], "http://localhost:1234/v1") + self.assertEqual(merged["api_mode"], "anthropic_messages") + self.assertNotIn("provider", merged) + self.assertNotIn("model", merged) + self.assertNotIn("api_key", merged) + self.assertEqual(merged["reasoning_effort"], "medium") + + def test_blank_provider_override_is_ignored_and_model_override_still_applies(self): + merged = _merge_delegation_config_for_tier( + { + "provider": "openrouter", + "model": "google/gemini-2.5-pro", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "base-key", + "api_mode": "chat_completions", + }, + { + "provider": "", + "model": "google/gemini-2.5-flash-lite", + }, + ) + + self.assertEqual(merged["provider"], "openrouter") + self.assertEqual(merged["model"], "google/gemini-2.5-flash-lite") + self.assertEqual(merged["base_url"], "https://openrouter.ai/api/v1") + self.assertEqual(merged["api_key"], "base-key") + self.assertEqual(merged["api_mode"], "chat_completions") + + def test_blank_base_url_override_is_ignored(self): + merged = _merge_delegation_config_for_tier( + { + "model": "anthropic/claude-sonnet-4", + "provider": "openrouter", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "base-key", + "api_mode": "chat_completions", + }, + { + "base_url": "", + }, + ) + + self.assertEqual(merged["provider"], "openrouter") + self.assertEqual(merged["model"], "anthropic/claude-sonnet-4") + self.assertEqual(merged["base_url"], "https://openrouter.ai/api/v1") + self.assertEqual(merged["api_key"], "base-key") + self.assertEqual(merged["api_mode"], "chat_completions") + + +class TestDelegationTierConfigLoad(unittest.TestCase): + def test_missing_delegation_block_returns_empty_config(self): + self.assertEqual(_load_delegation_config_for_tier({}, "small"), {}) + self.assertEqual(_load_delegation_config_for_tier(None, "large"), {}) + + def test_without_requested_tier_returns_base_delegation_config(self): + full_cfg = { + "delegation": { + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4", + "tiers": { + "small": {"model": "google/gemini-3-flash-preview"}, + }, + } + } + + loaded = _load_delegation_config_for_tier(full_cfg) + + self.assertEqual(loaded, full_cfg["delegation"]) + + def test_missing_requested_tier_falls_back_to_base_delegation_config(self): + full_cfg = { + "delegation": { + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4", + "tiers": { + "small": {"model": "google/gemini-3-flash-preview"}, + }, + } + } + + loaded = _load_delegation_config_for_tier(full_cfg, "large") + + self.assertEqual(loaded, full_cfg["delegation"]) + + def test_requested_tier_returns_merged_delegation_config(self): + full_cfg = { + "delegation": { + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "base-key", + "tiers": { + "small": { + "model": "google/gemini-3-flash-preview", + }, + }, + } + } + + loaded = _load_delegation_config_for_tier(full_cfg, "small") + + self.assertEqual(loaded["provider"], "openrouter") + self.assertEqual(loaded["model"], "google/gemini-3-flash-preview") + self.assertEqual(loaded["base_url"], "https://openrouter.ai/api/v1") + self.assertEqual(loaded["api_key"], "base-key") + self.assertIn("tiers", loaded) + + def test_requested_tier_ignores_blank_string_route_overrides(self): + full_cfg = { + "delegation": { + "provider": "openrouter", + "model": "google/gemini-2.5-pro", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "base-key", + "tiers": { + "small": { + "provider": "", + "base_url": "", + "model": "google/gemini-2.5-flash-lite", + }, + }, + } + } + + loaded = _load_delegation_config_for_tier(full_cfg, "small") + + self.assertEqual(loaded["provider"], "openrouter") + self.assertEqual(loaded["model"], "google/gemini-2.5-flash-lite") + self.assertEqual(loaded["base_url"], "https://openrouter.ai/api/v1") + self.assertEqual(loaded["api_key"], "base-key") + self.assertIn("tiers", loaded) if __name__ == "__main__": unittest.main() diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 9de91b671b9c..3d20463f1fff 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1047,6 +1047,7 @@ def _build_child_agent( # 'leaf' (default) cannot; 'orchestrator' retains the delegation # toolset subject to depth/kill-switch bounds applied below. role: str = "leaf", + tier: str = "medium", ): """ Build a child AIAgent on the main thread (thread-safe construction). @@ -1080,7 +1081,7 @@ def _build_child_agent( parent_subagent_id = getattr(parent_agent, "_subagent_id", None) tui_depth = max(0, child_depth - 1) # 0 = first-level child for the UI - delegation_cfg = _load_config() + delegation_cfg = _load_config(tier) # When no explicit toolsets given, inherit from parent's enabled toolsets # so disabled tools (e.g. web) don't leak to subagents. @@ -2126,6 +2127,7 @@ def delegate_task( context: Optional[str] = None, toolsets: Optional[List[str]] = None, tasks: Optional[List[Dict[str, Any]]] = None, + tier: Optional[str] = None, max_iterations: Optional[int] = None, acp_command: Optional[str] = None, acp_args: Optional[List[str]] = None, @@ -2186,8 +2188,13 @@ def delegate_task( } ) + try: + tier = _normalize_delegation_tier(tier) + except ValueError as exc: + return tool_error(str(exc)) + # Load config - cfg = _load_config() + cfg = _load_config(tier) default_max_iter = cfg.get("max_iterations", DEFAULT_MAX_ITERATIONS) # Model-supplied max_iterations is ignored — the config value is authoritative # so users get predictable budgets. The kwarg is retained for internal callers @@ -2295,6 +2302,7 @@ def delegate_task( else (acp_args if acp_args is not None else creds.get("args")) ), role=effective_role, + tier=tier, ) # Override with correct parent tool names (before child construction mutated global) child._delegate_saved_tool_names = _parent_tool_names @@ -2744,6 +2752,17 @@ def _resolve_child_credential_pool( return None +def _normalize_delegation_tier(tier: Optional[str]) -> str: + normalized = str(tier or "").strip().lower() + if not normalized: + return "medium" + if normalized not in {"small", "medium", "large"}: + raise ValueError( + f"Invalid delegation tier {tier!r}. Expected one of: small, medium, large." + ) + return normalized + + def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: """Resolve credentials for subagent delegation. @@ -2858,18 +2877,87 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: } -def _load_config() -> dict: +def _merge_delegation_config_for_tier(delegation_cfg: dict, tier_cfg: dict) -> dict: + """Merge a tier override into the base delegation config. + + Most tier fields are ordinary per-tier overrides layered on top of the + base delegation settings. But when a tier switches routing by changing + ``provider`` or ``base_url``, the inherited routing bundle from the base + config must be cleared first so we don't leak incompatible credentials or + transports into the selected tier. + + Blank-string values are treated like absent overrides here. The config CLI + persists unset-like values as ``""`` rather than removing the key, so + merging them literally would accidentally erase the base route and fall + back to parent inheritance. + """ + sanitized_tier_cfg = { + key: value + for key, value in tier_cfg.items() + if not (isinstance(value, str) and not value.strip()) + } + merged = dict(delegation_cfg) + + provider_changed = ( + "provider" in sanitized_tier_cfg + and sanitized_tier_cfg.get("provider") != delegation_cfg.get("provider") + ) + base_url_changed = ( + "base_url" in sanitized_tier_cfg + and sanitized_tier_cfg.get("base_url") != delegation_cfg.get("base_url") + ) + + if provider_changed or base_url_changed: + for key in ( + "model", + "provider", + "base_url", + "api_key", + "api_mode", + "command", + "args", + ): + merged.pop(key, None) + + merged.update(sanitized_tier_cfg) + return merged + + +def _load_delegation_config_for_tier(full_cfg: Optional[dict], tier: Optional[str] = None) -> dict: + if full_cfg is None: + return {} + + delegation_cfg = full_cfg.get("delegation") + if not isinstance(delegation_cfg, dict): + return {} + + if tier is None: + return delegation_cfg + + tiers_cfg = delegation_cfg.get("tiers") + if not isinstance(tiers_cfg, dict): + return delegation_cfg + + tier_cfg = tiers_cfg.get(tier) + if not isinstance(tier_cfg, dict): + return delegation_cfg + + return _merge_delegation_config_for_tier(delegation_cfg, tier_cfg) + + +def _load_config(tier: Optional[str] = None) -> dict: """Load delegation config from CLI_CONFIG or persistent config. - Checks the runtime config (cli.py CLI_CONFIG) first, then falls back - to the persistent config (hermes_cli/config.py load_config()) so that - ``delegation.model`` / ``delegation.provider`` are picked up regardless - of the entry point (CLI, gateway, cron). + Returns the effective ``delegation`` config for the requested tier. When + ``tier`` matches a nested ``delegation.tiers.`` override, that tier + block is merged onto the base ``delegation`` config before normal + credential resolution. """ + try: from cli import CLI_CONFIG - cfg = CLI_CONFIG.get("delegation") or {} + cfg = _load_delegation_config_for_tier(CLI_CONFIG, tier) if cfg: return cfg except Exception: @@ -2877,8 +2965,7 @@ def _load_config() -> dict: try: from hermes_cli.config import load_config - full = load_config() - return full.get("delegation") or {} + return _load_delegation_config_for_tier(load_config(), tier) except Exception: return {} @@ -3110,6 +3197,17 @@ def _build_dynamic_schema_overrides() -> dict: "['terminal', 'file', 'web'] for full-stack tasks." ), }, + "tier": { + "type": "string", + "enum": ["small", "medium", "large"], + "description": ( + "Optional delegation routing tier. Common patterns: 'medium' " + "(the default) for most tasks, 'small' for lighter and quicker " + "tasks (e.g. discovery), 'large' for heavier tasks that require " + "advanced reasoning over speed and cost. Applies to all tasks " + "in a batch." + ), + }, "tasks": { "type": "array", "items": { @@ -3225,6 +3323,7 @@ def _model_background_value(args: dict, parent_agent=None) -> bool: context=args.get("context"), toolsets=args.get("toolsets"), tasks=args.get("tasks"), + tier=args.get("tier"), max_iterations=args.get("max_iterations"), acp_command=args.get("acp_command"), acp_args=args.get("acp_args"), diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 0bcda2138a45..0b906a4b2fa0 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1918,17 +1918,38 @@ delegation: max_concurrent_children: 3 # Parallel children per batch (floor 1, no ceiling). Also via DELEGATION_MAX_CONCURRENT_CHILDREN env var. max_spawn_depth: 1 # Delegation tree depth cap (1-3, clamped). 1 = flat (default): parent spawns leaves that cannot delegate. 2 = orchestrator children can spawn leaf grandchildren. 3 = three levels. orchestrator_enabled: true # Global kill switch. When false, role="orchestrator" is ignored and every child is forced to leaf regardless of max_spawn_depth. + + tiers: + small: # Optional override used by delegate_task(tier="small") + # model: "google/gemini-3-flash-preview" + # provider: "openrouter" + # base_url: "http://localhost:1234/v1" + # api_key: "local-key" + # api_mode: "" + + medium: # Optional override used by delegate_task(tier="medium") + # model: "anthropic/claude-sonnet-4" + # provider: "openrouter" + + large: # Optional override used by delegate_task(tier="large") + # model: "anthropic/claude-opus-4-1" + # provider: "anthropic" + # base_url: "" + # api_key: "" + # api_mode: "" ``` **Subagent provider:model override:** By default, subagents inherit the parent agent's provider and model. Set `delegation.provider` and `delegation.model` to route subagents to a different provider:model pair — e.g., use a cheap/fast model for narrowly-scoped subtasks while your primary agent runs an expensive reasoning model. +**Tier routing:** `delegate_task` accepts an optional top-level `tier` of `small`, `medium`, or `large`. Omitted or blank behaves like `medium`. When configured, `delegation.tiers.small`, `delegation.tiers.medium`, and `delegation.tiers.large` override their respective tiers by merging onto the base `delegation` config. If the requested tier has no nested override, Hermes falls back to the base `delegation` config before inheriting the parent model/provider path. The tier is top-level only — batch mode applies one tier to the whole call, and `tasks[]` does not support per-task tier overrides. + **Direct endpoint override:** If you want the obvious custom-endpoint path, set `delegation.base_url`, `delegation.api_key`, and `delegation.model`. That sends subagents directly to that OpenAI-compatible endpoint and takes precedence over `delegation.provider`. If `delegation.api_key` is omitted, Hermes falls back to `OPENAI_API_KEY` only. **Wire protocol (`api_mode`):** Hermes auto-detects the wire protocol from `delegation.base_url` (e.g. paths ending in `/anthropic` → `anthropic_messages`; Codex / native Anthropic / Kimi-coding hostnames keep their existing detection). For endpoints the heuristic can't classify — for example Azure AI Foundry, MiniMax, Zhipu GLM, or LiteLLM proxies fronting an Anthropic-shaped backend — set `delegation.api_mode` explicitly to one of `chat_completions`, `codex_responses`, or `anthropic_messages`. Leave it empty (the default) to keep auto-detection. The delegation provider uses the same credential resolution as CLI/gateway startup. All configured providers are supported: `openrouter`, `nous`, `copilot`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`. When a provider is set, the system automatically resolves the correct base URL, API key, and API mode — no manual credential wiring needed. -**Precedence:** `delegation.base_url` in config → `delegation.provider` in config → parent provider (inherited). `delegation.model` in config → parent model (inherited). Setting just `model` without `provider` changes only the model name while keeping the parent's credentials (useful for switching models within the same provider like OpenRouter). +**Precedence:** when a requested tier has a matching nested override under `delegation.tiers`, Hermes first merges that tier override onto the base `delegation` config. If no matching tier override is configured, Hermes uses the base `delegation` config. If delegation routing is still effectively unconfigured, subagents inherit the parent model/provider path. **Width and depth:** `max_concurrent_children` caps how many subagents run in parallel per batch (default `3`, floor of 1, no ceiling). Can also be set via the `DELEGATION_MAX_CONCURRENT_CHILDREN` env var. When the model submits a `tasks` array longer than the cap, `delegate_task` returns a tool error explaining the limit rather than silently truncating. `max_spawn_depth` controls the delegation tree depth (clamped to 1-3). At the default `1`, delegation is flat: children cannot spawn grandchildren, and passing `role="orchestrator"` silently degrades to `leaf`. Raise to `2` so orchestrator children can spawn leaf grandchildren; `3` for three-level trees. The agent opts into orchestration per call via `role="orchestrator"`; `orchestrator_enabled: false` forces every child back to leaf regardless. Cost scales multiplicatively — at `max_spawn_depth: 3` with `max_concurrent_children: 3`, the tree can reach 3×3×3 = 27 concurrent leaf agents. See [Subagent Delegation → Depth Limit and Nested Orchestration](features/delegation.md#depth-limit-and-nested-orchestration) for usage patterns. diff --git a/website/docs/user-guide/features/delegation.md b/website/docs/user-guide/features/delegation.md index 037c2e806ae1..fe0995e6fcd8 100644 --- a/website/docs/user-guide/features/delegation.md +++ b/website/docs/user-guide/features/delegation.md @@ -14,22 +14,30 @@ The `delegate_task` tool spawns child AIAgent instances with isolated context, r delegate_task( goal="Debug why tests fail", context="Error: assertion in test_foo.py line 42", - toolsets=["terminal", "file"] + toolsets=["terminal", "file"], + tier="small", ) ``` +Top-level `tier` is optional and bounded to `small`, `medium`, or `large`. Omitted or blank behaves like `medium`. When configured, `delegation.tiers.small`, `delegation.tiers.medium`, and `delegation.tiers.large` override their respective tiers by merging onto the base `delegation` config. If the requested tier has no nested override, Hermes falls back to the base `delegation` config before inheriting the parent model/provider path. + ## Parallel Batch Up to 3 concurrent subagents by default (configurable, no hard ceiling): ```python -delegate_task(tasks=[ - {"goal": "Research topic A", "toolsets": ["web"]}, - {"goal": "Research topic B", "toolsets": ["web"]}, - {"goal": "Fix the build", "toolsets": ["terminal", "file"]} -]) +delegate_task( + tier="large", + tasks=[ + {"goal": "Research topic A", "toolsets": ["web"]}, + {"goal": "Research topic B", "toolsets": ["web"]}, + {"goal": "Fix the build", "toolsets": ["terminal", "file"]}, + ], +) ``` +Batch mode applies one top-level tier to the whole delegation call. `tasks[]` does not support per-task tier overrides. + ## How Subagent Context Works :::warning Critical: Subagents Know Nothing