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
188 changes: 188 additions & 0 deletions tests/tools/test_delegate.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,194 @@ def capture_and_return(user_message, task_id=None):
self.assertEqual(captured["saved"], expected_tools)


class TestDelegateModelArg(unittest.TestCase):
"""Per-call ``model`` parameter must reach ``_build_child_agent``.

Regression for #23467: ``delegate_task`` accepted no ``model`` kwarg and
the schema did not expose it, so a model emitting
``delegate_task(model="X", goal="...")`` had its override silently
discarded β€” the child inherited the parent's model. The fix adds a
top-level ``model`` parameter plus a per-task ``model`` override,
layered on top of ``delegation.model`` from config.
"""

def test_schema_advertises_top_level_model(self):
props = DELEGATE_TASK_SCHEMA["parameters"]["properties"]
self.assertIn("model", props)
self.assertEqual(props["model"]["type"], "string")

def test_schema_advertises_per_task_model(self):
task_props = DELEGATE_TASK_SCHEMA["parameters"]["properties"]["tasks"]["items"]["properties"]
self.assertIn("model", task_props)
self.assertEqual(task_props["model"]["type"], "string")

def test_registry_handler_forwards_model_arg(self):
"""The registry-level handler must extract `model` from tool args and
pass it to delegate_task β€” otherwise the schema field is decorative
and the override is silently dropped at the dispatch boundary."""
from tools.registry import registry

parent = _make_mock_parent(depth=0)
captured = {}

def fake_delegate_task(**kwargs):
captured.update(kwargs)
return json.dumps({"results": []})

with patch("tools.delegate_tool.delegate_task", side_effect=fake_delegate_task):
registry.dispatch(
"delegate_task",
{"goal": "test", "model": "claude-sonnet-4.6"},
parent_agent=parent,
)

self.assertEqual(captured.get("model"), "claude-sonnet-4.6")

@patch("tools.delegate_tool._run_single_child")
@patch("tools.delegate_tool._build_child_agent")
def test_top_level_model_reaches_child_build(self, mock_build, mock_run):
"""A top-level ``model=`` on ``delegate_task`` must be the model
passed to ``_build_child_agent`` for every task in the call."""
mock_run.return_value = {
"task_index": 0, "status": "completed",
"summary": "ok", "api_calls": 1, "duration_seconds": 0.1,
}
mock_child = MagicMock()
mock_child._delegate_saved_tool_names = []
mock_build.return_value = mock_child

parent = _make_mock_parent(depth=0)
delegate_task(goal="x", model="claude-sonnet-4.6", parent_agent=parent)

mock_build.assert_called_once()
_, kwargs = mock_build.call_args
self.assertEqual(kwargs["model"], "claude-sonnet-4.6")

@patch("tools.delegate_tool._run_single_child")
@patch("tools.delegate_tool._build_child_agent")
def test_per_task_model_wins_over_top_level(self, mock_build, mock_run):
"""Per-task ``model`` must override the top-level ``model`` for that
task only (parity with how per-task ``role`` overrides top-level
role today)."""
mock_run.return_value = {
"task_index": 0, "status": "completed",
"summary": "ok", "api_calls": 1, "duration_seconds": 0.1,
}
mock_child = MagicMock()
mock_child._delegate_saved_tool_names = []
mock_build.return_value = mock_child

parent = _make_mock_parent(depth=0)
delegate_task(
tasks=[
{"goal": "a", "model": "anthropic/claude-haiku-4-5"},
{"goal": "b"},
],
model="anthropic/claude-sonnet-4.6",
parent_agent=parent,
)

# Two _build_child_agent calls β€” first uses per-task model,
# second falls back to top-level.
self.assertEqual(mock_build.call_count, 2)
first_kwargs = mock_build.call_args_list[0].kwargs
second_kwargs = mock_build.call_args_list[1].kwargs
self.assertEqual(first_kwargs["model"], "anthropic/claude-haiku-4-5")
self.assertEqual(second_kwargs["model"], "anthropic/claude-sonnet-4.6")

@patch("tools.delegate_tool._run_single_child")
@patch("tools.delegate_tool._build_child_agent")
def test_per_call_model_drives_runtime_resolution(self, mock_build, mock_run):
"""For providers whose transport depends on target_model (Azure
Foundry, openai-codex, OpenCode), a per-call ``model`` must drive
``_resolve_delegation_credentials`` so the child runs over the right
api_mode/base_url β€” not the transport resolved for ``delegation.model``
from config.

Without per-task re-resolution, ``delegate_task(model="gpt-5.3-codex")``
with ``delegation.model=gpt-5.3`` would inherit api_mode from the
config model and run the requested codex model over chat_completions
instead of codex_responses.
"""
mock_run.return_value = {
"task_index": 0, "status": "completed",
"summary": "ok", "api_calls": 1, "duration_seconds": 0.1,
}
mock_child = MagicMock()
mock_child._delegate_saved_tool_names = []
mock_build.return_value = mock_child

parent = _make_mock_parent(depth=0)

# Simulate Azure-Foundry-style resolution: api_mode depends on
# target_model. gpt-5 β†’ chat_completions; gpt-5-codex β†’ responses.
def fake_resolve(_cfg, _parent, override_model=None):
model_arg = override_model or _cfg.get("model")
if model_arg and "codex" in model_arg:
return {
"model": model_arg,
"provider": "azure-foundry",
"base_url": "https://example.azure.com/openai/v1/",
"api_key": "key",
"api_mode": "responses",
}
return {
"model": model_arg,
"provider": "azure-foundry",
"base_url": "https://example.azure.com/openai/v1/",
"api_key": "key",
"api_mode": "chat_completions",
}

cfg_with_default_model = {"provider": "azure-foundry", "model": "gpt-5"}
with patch("tools.delegate_tool._load_config", return_value=cfg_with_default_model):
with patch(
"tools.delegate_tool._resolve_delegation_credentials",
side_effect=fake_resolve,
):
delegate_task(
goal="x", model="gpt-5-codex", parent_agent=parent,
)

_, kwargs = mock_build.call_args
# The override_api_mode passed to the child must come from the
# re-resolution that used the per-call model, NOT from the initial
# pre-loop resolution that used delegation.model.
self.assertEqual(kwargs["model"], "gpt-5-codex")
self.assertEqual(kwargs["override_api_mode"], "responses")

@patch("tools.delegate_tool._run_single_child")
@patch("tools.delegate_tool._build_child_agent")
def test_delegation_config_model_used_when_no_per_call_override(self, mock_build, mock_run):
"""When neither top-level nor per-task ``model`` is given, the
``delegation.model`` config value (resolved via creds["model"])
should still flow through β€” i.e. the new precedence layering does
not regress the existing config path."""
mock_run.return_value = {
"task_index": 0, "status": "completed",
"summary": "ok", "api_calls": 1, "duration_seconds": 0.1,
}
mock_child = MagicMock()
mock_child._delegate_saved_tool_names = []
mock_build.return_value = mock_child

parent = _make_mock_parent(depth=0)
with patch(
"tools.delegate_tool._resolve_delegation_credentials",
return_value={
"model": "delegated/from-config",
"provider": None,
"base_url": None,
"api_key": None,
"api_mode": None,
},
):
delegate_task(goal="x", parent_agent=parent)

_, kwargs = mock_build.call_args
self.assertEqual(kwargs["model"], "delegated/from-config")


class TestDelegateObservability(unittest.TestCase):
"""Tests for enriched metadata returned by _run_single_child."""

Expand Down
77 changes: 67 additions & 10 deletions tools/delegate_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1924,6 +1924,7 @@ def delegate_task(
acp_command: Optional[str] = None,
acp_args: Optional[List[str]] = None,
role: Optional[str] = None,
model: Optional[str] = None,
parent_agent=None,
) -> str:
"""
Expand Down Expand Up @@ -2017,7 +2018,13 @@ 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": model,
}
]
else:
return tool_error("Provide either 'goal' (single task) or 'tasks' (batch).")
Expand Down Expand Up @@ -2058,26 +2065,44 @@ 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)
# Model precedence: per-task model > top-level model > delegation.model
# (creds["model"]). A None at any level falls through to the next.
task_model = t.get("model") or model or creds["model"]
# When task_model differs from the model used to compute the
# outer creds, re-resolve runtime so provider transport selection
# (Azure Foundry api_mode, openai-codex codex_responses vs
# chat_completions, OpenCode routes) uses the per-task target_model
# rather than delegation.model from config. Skip the re-resolve
# when the model is unchanged to keep the common path cheap.
if task_model != creds["model"]:
try:
task_creds = _resolve_delegation_credentials(
cfg, parent_agent, override_model=task_model
)
except ValueError as exc:
return tool_error(str(exc))
else:
task_creds = creds
child = _build_child_agent(
task_index=i,
goal=t["goal"],
context=t.get("context"),
toolsets=t.get("toolsets") or toolsets,
model=creds["model"],
model=task_model,
max_iterations=effective_max_iter,
task_count=n_tasks,
parent_agent=parent_agent,
override_provider=creds["provider"],
override_base_url=creds["base_url"],
override_api_key=creds["api_key"],
override_api_mode=creds["api_mode"],
override_provider=task_creds["provider"],
override_base_url=task_creds["base_url"],
override_api_key=task_creds["api_key"],
override_api_mode=task_creds["api_mode"],
override_acp_command=t.get("acp_command")
or acp_command
or creds.get("command"),
or task_creds.get("command"),
override_acp_args=(
task_acp_args
if task_acp_args is not None
else (acp_args if acp_args is not None else creds.get("args"))
else (acp_args if acp_args is not None else task_creds.get("args"))
),
role=effective_role,
)
Expand Down Expand Up @@ -2342,7 +2367,9 @@ def _resolve_child_credential_pool(effective_provider: Optional[str], parent_age
return None


def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
def _resolve_delegation_credentials(
cfg: dict, parent_agent, override_model: Optional[str] = None
) -> dict:
"""Resolve credentials for subagent delegation.

If ``delegation.base_url`` is configured, subagents use that direct
Expand All @@ -2361,9 +2388,20 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
If neither base_url nor provider is configured, returns None values so the
child inherits everything from the parent agent.

``override_model`` lets a per-call or per-task ``model`` value drive
runtime resolution instead of ``delegation.model`` from config. This
matters for providers whose transport (api_mode / base_url) depends on
target_model β€” Azure Foundry routes gpt-5.x / codex / o-series to
Responses API vs Chat Completions, and openai-codex picks codex_responses
vs chat_completions based on the model slug. Without this, a caller
saying ``delegate_task(model="gpt-5.3-codex")`` would inherit api_mode
resolved against the configured default model and the child would run
over the wrong transport.

Raises ValueError with a user-friendly message on credential failure.
"""
configured_model = str(cfg.get("model") or "").strip() or None
effective_model_raw = override_model if override_model is not None else cfg.get("model")
configured_model = str(effective_model_raw or "").strip() or None
configured_provider = str(cfg.get("provider") or "").strip() or None
configured_base_url = str(cfg.get("base_url") or "").strip() or None
configured_api_key = str(cfg.get("api_key") or "").strip() or None
Expand Down Expand Up @@ -2703,6 +2741,17 @@ def _build_dynamic_schema_overrides() -> dict:
"['terminal', 'file', 'web'] for full-stack tasks."
),
},
"model": {
"type": "string",
"description": (
"Override the model used by every subagent in this call. "
"Falls back to delegation.model from config, then to the "
"parent agent's model. Use the same slug you would pass "
"to /model (e.g. 'claude-sonnet-4.6'). Leave unset unless "
"the user explicitly asked you to route this delegation "
"to a different model."
),
},
"tasks": {
"type": "array",
"items": {
Expand Down Expand Up @@ -2736,6 +2785,13 @@ def _build_dynamic_schema_overrides() -> dict:
"enum": ["leaf", "orchestrator"],
"description": "Per-task role override. See top-level 'role' for semantics.",
},
"model": {
"type": "string",
"description": (
"Per-task model override. Wins over the "
"top-level 'model' parameter for this task only."
),
},
},
"required": ["goal"],
},
Expand Down Expand Up @@ -2793,6 +2849,7 @@ def _build_dynamic_schema_overrides() -> dict:
acp_command=args.get("acp_command"),
acp_args=args.get("acp_args"),
role=args.get("role"),
model=args.get("model"),
parent_agent=kw.get("parent_agent"),
),
check_fn=check_delegate_requirements,
Expand Down
Loading