From 0e94990b0852437b4926708e324e5913567bf581 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Sun, 24 May 2026 06:08:38 +0000 Subject: [PATCH] feat: allow per-call delegation model overrides --- tests/tools/test_delegate.py | 59 +++++++++++++++++++++++++++ tools/delegate_tool.py | 78 +++++++++++++++++++++++++++++++++--- 2 files changed, 132 insertions(+), 5 deletions(-) diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 72c4c67f570ed..ba92147fbb665 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -69,6 +69,11 @@ def test_schema_valid(self): self.assertIn("tasks", props) self.assertIn("context", props) self.assertIn("toolsets", props) + self.assertIn("model", props) + self.assertIn("provider", props) + task_props = props["tasks"]["items"]["properties"] + self.assertIn("model", task_props) + self.assertIn("provider", task_props) # max_iterations is intentionally NOT exposed to the model — it's # config-authoritative via delegation.max_iterations so users get # predictable budgets. @@ -198,6 +203,60 @@ def test_single_task_mode(self, mock_run): self.assertEqual(result["results"][0]["summary"], "Done!") mock_run.assert_called_once() + @patch("tools.delegate_tool._run_single_child") + def test_single_task_accepts_per_call_model_override(self, mock_run): + mock_run.return_value = { + "task_index": 0, + "status": "completed", + "summary": "Done!", + "api_calls": 1, + "duration_seconds": 1.0, + } + parent = _make_mock_parent() + + delegate_task( + goal="Use a one-off model", + model="gpt-5.4-mini", + parent_agent=parent, + ) + + mock_run.assert_called_once() + child = mock_run.call_args.kwargs.get("child") + if child is None: + child = mock_run.call_args.args[2] + self.assertEqual(child.model, "gpt-5.4-mini") + # The persistent parent default remains untouched. + self.assertEqual(parent.model, "anthropic/claude-sonnet-4") + + @patch("tools.delegate_tool._run_single_child") + def test_batch_task_model_overrides_top_level_model(self, mock_run): + seen_models = [] + + def _fake_run(*args, **kwargs): + child = kwargs.get("child") or args[2] + seen_models.append(child.model) + return { + "task_index": len(seen_models) - 1, + "status": "completed", + "summary": "Done!", + "api_calls": 1, + "duration_seconds": 1.0, + } + + mock_run.side_effect = _fake_run + parent = _make_mock_parent() + + delegate_task( + model="gpt-5.4-mini", + tasks=[ + {"goal": "inherit top-level override"}, + {"goal": "use task-specific override", "model": "gpt-5.5"}, + ], + parent_agent=parent, + ) + + self.assertEqual(sorted(seen_models), ["gpt-5.4-mini", "gpt-5.5"]) + @patch("tools.delegate_tool._run_single_child") def test_batch_mode(self, mock_run): mock_run.side_effect = [ diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 86dcd0715cc9c..67ad82853a75a 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1921,6 +1921,8 @@ def delegate_task( toolsets: Optional[List[str]] = None, tasks: Optional[List[Dict[str, Any]]] = None, max_iterations: Optional[int] = None, + model: Optional[str] = None, + provider: Optional[str] = None, acp_command: Optional[str] = None, acp_args: Optional[List[str]] = None, role: Optional[str] = None, @@ -1933,6 +1935,12 @@ def delegate_task( - Single: provide goal (+ optional context, toolsets, role) - Batch: provide tasks array [{goal, context, toolsets, role}, ...] + ``model`` / ``provider`` are per-call overrides layered on top of + delegation config. They are intentionally non-persistent: use them when a + single delegation fan-out should run on a different model without changing + the user's default delegation route. In batch mode, per-task + ``model``/``provider`` values override the top-level values for that child. + The 'role' parameter controls whether a child can further delegate: 'leaf' (default) cannot; 'orchestrator' retains the delegation toolset and can spawn its own workers, bounded by @@ -1992,10 +2000,20 @@ def delegate_task( # 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)) + call_model = str(model or "").strip() or None + call_provider = str(provider or "").strip() or None + + def _cfg_with_call_overrides( + *, task_model: Optional[str] = None, task_provider: Optional[str] = None + ) -> dict: + merged = dict(cfg) + effective_model_override = str(task_model or call_model or "").strip() + effective_provider_override = str(task_provider or call_provider or "").strip() + if effective_model_override: + merged["model"] = effective_model_override + if effective_provider_override: + merged["provider"] = effective_provider_override + return merged # Normalize to task list max_children = _get_max_concurrent_children() @@ -2017,7 +2035,14 @@ def delegate_task( task_list = tasks elif goal and isinstance(goal, str) and goal.strip(): task_list = [ - {"goal": goal, "context": context, "toolsets": toolsets, "role": top_role} + { + "goal": goal, + "context": context, + "toolsets": toolsets, + "role": top_role, + "model": call_model, + "provider": call_provider, + } ] else: return tool_error("Provide either 'goal' (single task) or 'tasks' (batch).") @@ -2058,6 +2083,16 @@ 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) + try: + creds = _resolve_delegation_credentials( + _cfg_with_call_overrides( + task_model=t.get("model"), + task_provider=t.get("provider"), + ), + parent_agent, + ) + except ValueError as exc: + return tool_error(str(exc)) child = _build_child_agent( task_index=i, goal=t["goal"], @@ -2718,6 +2753,21 @@ def _build_dynamic_schema_overrides() -> 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.", }, + "model": { + "type": "string", + "description": ( + "Per-task model override for this child only. " + "Leave empty to inherit the top-level delegate_task model override, " + "delegation config, or the parent agent model." + ), + }, + "provider": { + "type": "string", + "description": ( + "Per-task provider override for this child only (e.g. 'openai-codex', 'openrouter'). " + "Leave empty unless this task must use a different configured provider." + ), + }, "acp_command": { "type": "string", "description": ( @@ -2749,6 +2799,22 @@ def _build_dynamic_schema_overrides() -> dict: "enum": ["leaf", "orchestrator"], "description": "(rebuilt at get_definitions() time)", }, + "model": { + "type": "string", + "description": ( + "Optional per-call model override for this delegation only. " + "Does not persist to config. Leave empty to inherit delegation.model " + "or the parent agent model." + ), + }, + "provider": { + "type": "string", + "description": ( + "Optional per-call provider override for this delegation only (e.g. 'openai-codex', 'openrouter'). " + "Does not persist to config. Leave empty to inherit delegation.provider " + "or the parent agent provider." + ), + }, "acp_command": { "type": "string", "description": ( @@ -2790,6 +2856,8 @@ def _build_dynamic_schema_overrides() -> dict: toolsets=args.get("toolsets"), tasks=args.get("tasks"), max_iterations=args.get("max_iterations"), + model=args.get("model"), + provider=args.get("provider"), acp_command=args.get("acp_command"), acp_args=args.get("acp_args"), role=args.get("role"),