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
2 changes: 2 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@
"jeremy@geocaching.com": "outdoorsea",
"leone.parise@gmail.com": "leoneparise",
"mr@shu.io": "mrshu",
"chuang.guo@hopechart.com": "郭闯",
"wuyefeima9@gmail.com": "wuwuzhijing",
"adam.manning@gmail.com": "am423",
"buraysandro9@gmail.com": "ygd58",
"108427749+buntingszn@users.noreply.github.com": "buntingszn",
Expand Down
203 changes: 203 additions & 0 deletions tests/tools/test_delegate.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ def test_schema_valid(self):
self.assertIn("tasks", props)
self.assertIn("context", props)
self.assertIn("toolsets", props)
# Per-call model routing lets a single delegate_task invocation assign
# different providers/models to different children without changing the
# global delegation config.
for key in ("model", "provider", "base_url", "api_key"):
self.assertIn(key, props)
self.assertIn(key, 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.
Expand Down Expand Up @@ -1109,6 +1115,203 @@ def test_direct_endpoint_credentials_reach_child_agent(self, mock_creds, mock_cf
self.assertEqual(kwargs["api_key"], "local-key")
self.assertEqual(kwargs["api_mode"], "chat_completions")

@patch("tools.delegate_tool._load_config")
@patch("tools.delegate_tool._resolve_delegation_credentials")
def test_per_task_model_routing_overrides_global_config(self, mock_creds, mock_cfg):
"""Each batch task can override model/provider/base_url/api_key independently."""
mock_cfg.return_value = {
"max_iterations": 45,
"model": "global-model",
"provider": "global-provider",
"base_url": "https://global.example/v1",
"api_key": "global-key",
}
mock_creds.side_effect = [
{
"model": "global-model",
"provider": "global-provider",
"base_url": "https://global.example/v1",
"api_key": "global-key",
"api_mode": "chat_completions",
},
{
"model": "deepseek-chat",
"provider": "deepseek",
"base_url": "https://api.deepseek.com/v1",
"api_key": "deepseek-key",
"api_mode": "chat_completions",
},
{
"model": "glm-4.5",
"provider": "zai",
"base_url": "https://open.bigmodel.cn/api/paas/v4",
"api_key": "glm-key",
"api_mode": "chat_completions",
},
]
parent = _make_mock_parent(depth=0)

with patch("tools.delegate_tool._build_child_agent") as mock_build, \
patch("tools.delegate_tool._run_single_child") as mock_run:
mock_child_a = MagicMock()
mock_child_b = MagicMock()
mock_build.side_effect = [mock_child_a, mock_child_b]
mock_run.side_effect = [
{
"task_index": 0,
"status": "completed",
"summary": "A",
"api_calls": 1,
"duration_seconds": 1.0,
},
{
"task_index": 1,
"status": "completed",
"summary": "B",
"api_calls": 1,
"duration_seconds": 1.0,
},
]

tasks = [
{
"goal": "DeepSeek review",
"model": "deepseek-chat",
"provider": "deepseek",
"base_url": "https://api.deepseek.com/v1",
"api_key": "deepseek-key",
},
{
"goal": "GLM review",
"model": "glm-4.5",
"provider": "zai",
"base_url": "https://open.bigmodel.cn/api/paas/v4",
"api_key": "glm-key",
},
]
delegate_task(tasks=tasks, parent_agent=parent)

self.assertEqual(mock_build.call_count, 2)
first = mock_build.call_args_list[0].kwargs
second = mock_build.call_args_list[1].kwargs
self.assertEqual(first["model"], "deepseek-chat")
self.assertEqual(first["override_provider"], "deepseek")
self.assertEqual(first["override_base_url"], "https://api.deepseek.com/v1")
self.assertEqual(first["override_api_key"], "deepseek-key")
self.assertEqual(second["model"], "glm-4.5")
self.assertEqual(second["override_provider"], "zai")
self.assertEqual(second["override_base_url"], "https://open.bigmodel.cn/api/paas/v4")
self.assertEqual(second["override_api_key"], "glm-key")

@patch("tools.delegate_tool._load_config")
@patch("tools.delegate_tool._resolve_delegation_credentials")
def test_toplevel_model_routing_overrides_global_config(self, mock_creds, mock_cfg):
"""Top-level model routing applies to single-task mode."""
mock_cfg.return_value = {
"max_iterations": 45,
"model": "global-model",
"provider": "global-provider",
"base_url": "https://global.example/v1",
"api_key": "global-key",
}
mock_creds.side_effect = [
{
"model": "global-model",
"provider": "global-provider",
"base_url": "https://global.example/v1",
"api_key": "global-key",
"api_mode": "chat_completions",
},
{
"model": "kimi-k2-0905-preview",
"provider": "moonshot",
"base_url": "https://api.moonshot.cn/v1",
"api_key": "kimi-key",
"api_mode": "chat_completions",
},
]
parent = _make_mock_parent(depth=0)

with patch("tools.delegate_tool._build_child_agent") as mock_build, \
patch("tools.delegate_tool._run_single_child") as mock_run:
mock_build.return_value = MagicMock()
mock_run.return_value = {
"task_index": 0,
"status": "completed",
"summary": "Done",
"api_calls": 1,
"duration_seconds": 1.0,
}

delegate_task(
goal="KIMI efficiency review",
model="kimi-k2-0905-preview",
provider="moonshot",
base_url="https://api.moonshot.cn/v1",
api_key="kimi-key",
parent_agent=parent,
)

_, kwargs = mock_build.call_args
self.assertEqual(kwargs["model"], "kimi-k2-0905-preview")
self.assertEqual(kwargs["override_provider"], "moonshot")
self.assertEqual(kwargs["override_base_url"], "https://api.moonshot.cn/v1")
self.assertEqual(kwargs["override_api_key"], "kimi-key")

@patch("tools.delegate_tool._load_config")
@patch("tools.delegate_tool._resolve_delegation_credentials")
def test_per_task_provider_resolved_when_only_provider_and_model_given(self, mock_creds, mock_cfg):
"""Per-task provider/model must use provider resolution, not raw parent credentials."""
mock_cfg.return_value = {"max_iterations": 45}
mock_creds.side_effect = [
{
"model": None,
"provider": None,
"base_url": None,
"api_key": None,
"api_mode": None,
},
{
"model": "glm-4.5",
"provider": "zai",
"base_url": "https://open.bigmodel.cn/api/paas/v4",
"api_key": "resolved-glm-key",
"api_mode": "chat_completions",
"command": "glm-acp",
"args": ["--stdio"],
},
]
parent = _make_mock_parent(depth=0)

with patch("tools.delegate_tool._build_child_agent") as mock_build, \
patch("tools.delegate_tool._run_single_child") as mock_run:
mock_build.return_value = MagicMock()
mock_run.return_value = {
"task_index": 0,
"status": "completed",
"summary": "Done",
"api_calls": 1,
"duration_seconds": 1.0,
}

delegate_task(
tasks=[{"goal": "GLM review", "model": "glm-4.5", "provider": "zai"}],
parent_agent=parent,
)

self.assertEqual(mock_creds.call_count, 2)
resolved_cfg = mock_creds.call_args_list[1].args[0]
self.assertEqual(resolved_cfg["model"], "glm-4.5")
self.assertEqual(resolved_cfg["provider"], "zai")
_, kwargs = mock_build.call_args
self.assertEqual(kwargs["model"], "glm-4.5")
self.assertEqual(kwargs["override_provider"], "zai")
self.assertEqual(kwargs["override_base_url"], "https://open.bigmodel.cn/api/paas/v4")
self.assertEqual(kwargs["override_api_key"], "resolved-glm-key")
self.assertEqual(kwargs["override_api_mode"], "chat_completions")
self.assertEqual(kwargs["override_acp_command"], "glm-acp")
self.assertEqual(kwargs["override_acp_args"], ["--stdio"])

@patch("tools.delegate_tool._load_config")
@patch("tools.delegate_tool._resolve_delegation_credentials")
def test_empty_config_inherits_parent(self, mock_creds, mock_cfg):
Expand Down
100 changes: 92 additions & 8 deletions tools/delegate_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1918,6 +1918,10 @@ def delegate_task(
max_iterations: Optional[int] = None,
acp_command: Optional[str] = None,
acp_args: Optional[List[str]] = None,
model: Optional[str] = None,
provider: Optional[str] = None,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
role: Optional[str] = None,
parent_agent=None,
) -> str:
Expand Down Expand Up @@ -1993,6 +1997,10 @@ def delegate_task(
return tool_error(str(exc))

# Normalize to task list
per_call_model = str(model or "").strip() or None
per_call_provider = str(provider or "").strip() or None
per_call_base_url = str(base_url or "").strip() or None
per_call_api_key = str(api_key or "").strip() or None
max_children = _get_max_concurrent_children()
recovered_tasks, tasks_error = _recover_tasks_from_json_string(tasks)
if tasks_error:
Expand All @@ -2012,7 +2020,16 @@ 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": per_call_model,
"provider": per_call_provider,
"base_url": per_call_base_url,
"api_key": per_call_api_key,
}
]
else:
return tool_error("Provide either 'goal' (single task) or 'tasks' (batch).")
Expand Down Expand Up @@ -2053,26 +2070,57 @@ 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)
task_model_override = str(t.get("model") or "").strip() or per_call_model
task_provider_override = str(t.get("provider") or "").strip() or per_call_provider
task_base_url_override = str(t.get("base_url") or "").strip() or per_call_base_url
task_api_key_override = str(t.get("api_key") or "").strip() or per_call_api_key
if any(
(
task_model_override,
task_provider_override,
task_base_url_override,
task_api_key_override,
)
):
task_cfg = dict(cfg)
if task_model_override:
task_cfg["model"] = task_model_override
if task_provider_override:
task_cfg["provider"] = task_provider_override
if task_base_url_override:
task_cfg["base_url"] = task_base_url_override
if task_api_key_override:
task_cfg["api_key"] = task_api_key_override
try:
task_creds = _resolve_delegation_credentials(task_cfg, parent_agent)
except ValueError as exc:
return tool_error(f"Task {i} credential resolution failed: {exc}")
else:
task_creds = creds
task_model = task_model_override or task_creds["model"]
task_provider = task_creds["provider"]
task_base_url = task_base_url_override or task_creds["base_url"]
task_api_key = task_api_key_override or task_creds["api_key"]
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_provider,
override_base_url=task_base_url,
override_api_key=task_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 @@ -2699,6 +2747,22 @@ 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. Overrides top-level and delegation config model for this task only.",
},
"provider": {
"type": "string",
"description": "Per-task provider override (e.g. openrouter, anthropic, zai, moonshot, custom:name). Overrides delegation config provider for this task only.",
},
"base_url": {
"type": "string",
"description": "Per-task direct OpenAI-compatible base URL override. Use with api_key/model when the provider is not configured globally.",
},
"api_key": {
"type": "string",
"description": "Per-task API key override. Only use when the user explicitly provided or configured a key for this task; never invent keys.",
},
"acp_command": {
"type": "string",
"description": (
Expand Down Expand Up @@ -2730,6 +2794,22 @@ def _build_dynamic_schema_overrides() -> dict:
"enum": ["leaf", "orchestrator"],
"description": "(rebuilt at get_definitions() time)",
},
"model": {
"type": "string",
"description": "Model override for this delegate_task call. In batch mode, per-task model fields override this value.",
},
"provider": {
"type": "string",
"description": "Provider override for this delegate_task call (e.g. openrouter, anthropic, zai, moonshot, custom:name). In batch mode, per-task provider fields override this value.",
},
"base_url": {
"type": "string",
"description": "Direct OpenAI-compatible base URL override for this delegate_task call. In batch mode, per-task base_url fields override this value.",
},
"api_key": {
"type": "string",
"description": "API key override for this delegate_task call. Only use keys explicitly provided/configured by the user; per-task api_key fields override this value.",
},
"acp_command": {
"type": "string",
"description": (
Expand Down Expand Up @@ -2773,6 +2853,10 @@ def _build_dynamic_schema_overrides() -> dict:
max_iterations=args.get("max_iterations"),
acp_command=args.get("acp_command"),
acp_args=args.get("acp_args"),
model=args.get("model"),
provider=args.get("provider"),
base_url=args.get("base_url"),
api_key=args.get("api_key"),
role=args.get("role"),
parent_agent=kw.get("parent_agent"),
),
Expand Down
Loading