Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions tests/tools/test_delegate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 = [
Expand Down
78 changes: 73 additions & 5 deletions tools/delegate_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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).")
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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": (
Expand Down Expand Up @@ -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": (
Expand Down Expand Up @@ -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"),
Expand Down