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 run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -5186,6 +5186,8 @@ def _dispatch_delegate_task(self, function_args: dict) -> str:
goal=function_args.get("goal"),
context=function_args.get("context"),
toolsets=function_args.get("toolsets"),
model=function_args.get("model"),
reasoning_effort=function_args.get("reasoning_effort"),
tasks=function_args.get("tasks"),
max_iterations=function_args.get("max_iterations"),
acp_command=function_args.get("acp_command"),
Expand Down
77 changes: 77 additions & 0 deletions tests/tools/test_delegate.py
Original file line number Diff line number Diff line change
Expand Up @@ -1996,6 +1996,23 @@ def test_invalid_reasoning_effort_falls_back_to_parent(self, MockAgent, mock_cfg
call_kwargs = MockAgent.call_args[1]
self.assertEqual(call_kwargs["reasoning_config"], {"enabled": True, "effort": "medium"})

@patch("tools.delegate_tool._load_config")
@patch("run_agent.AIAgent")
def test_per_task_override_reasoning_effort_beats_config(self, MockAgent, mock_cfg):
"""Per-task override_reasoning_effort beats delegation.reasoning_effort."""
mock_cfg.return_value = {"max_iterations": 50, "reasoning_effort": "low"}
MockAgent.return_value = MagicMock()
parent = _make_mock_parent()
parent.reasoning_config = {"enabled": True, "effort": "xhigh"}

_build_child_agent(
task_index=0, goal="test", context=None, toolsets=None,
model=None, max_iterations=50, parent_agent=parent,
task_count=1, override_reasoning_effort="high",
)
call_kwargs = MockAgent.call_args[1]
self.assertEqual(call_kwargs["reasoning_config"], {"enabled": True, "effort": "high"})


# =========================================================================
# Dispatch helper, progress events, concurrency
Expand Down Expand Up @@ -2036,6 +2053,56 @@ def test_acp_args_forwarded(self, mock_creds, mock_cfg):
self.assertEqual(kwargs["override_acp_command"], "claude")
self.assertEqual(kwargs["override_acp_args"], ["--acp", "--stdio"])

@patch("tools.delegate_tool._load_config", return_value={})
@patch("tools.delegate_tool._resolve_delegation_credentials")
def test_model_and_reasoning_effort_forwarded_to_build_child_agent(
self, mock_creds, mock_cfg,
):
"""Top-level model and reasoning_effort reach _build_child_agent."""
mock_creds.return_value = {
"provider": None, "base_url": None,
"api_key": None, "api_mode": None, "model": "delegation-model",
}
parent = _make_mock_parent(depth=0)
with patch("tools.delegate_tool._build_child_agent") as mock_build:
mock_child = MagicMock()
mock_child.run_conversation.return_value = {
"final_response": "done", "completed": True,
"api_calls": 1, "messages": [],
}
mock_child._delegate_saved_tool_names = []
mock_child._credential_pool = None
mock_child.session_prompt_tokens = 0
mock_child.session_completion_tokens = 0
mock_child.model = "test"
mock_build.return_value = mock_child

delegate_task(
goal="test",
model="claude-sonnet-4-6",
reasoning_effort="medium",
parent_agent=parent,
)
_, kwargs = mock_build.call_args
self.assertEqual(kwargs["model"], "claude-sonnet-4-6")
self.assertEqual(kwargs["override_reasoning_effort"], "medium")

@patch("tools.delegate_tool.delegate_task")
def test_model_and_reasoning_effort_forwarded(self, mock_delegate):
"""Top-level model and reasoning_effort reach delegate_task via the helper."""
mock_delegate.return_value = '{"results": []}'
from run_agent import AIAgent

parent = _make_mock_parent(depth=0)
AIAgent._dispatch_delegate_task(parent, {
"goal": "test",
"model": "claude-sonnet-4-6",
"reasoning_effort": "medium",
})
_, kwargs = mock_delegate.call_args
self.assertEqual(kwargs["model"], "claude-sonnet-4-6")
self.assertEqual(kwargs["reasoning_effort"], "medium")

class TestDelegateEventEnum(unittest.TestCase):
"""Tests for DelegateEvent enum and back-compat aliases."""

Expand Down Expand Up @@ -2300,6 +2367,16 @@ def test_schema_has_role_top_level_and_per_task(self):
self.assertIn("role", task_props)
self.assertEqual(task_props["role"]["enum"], ["leaf", "orchestrator"])

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

def test_acp_command_description_has_do_not_set_guidance(self):
# acp_command/acp_args descriptions must NOT bias the model toward
# assuming an ACP CLI (Claude, Copilot, etc.) is installed. They must
Expand Down
76 changes: 66 additions & 10 deletions tools/delegate_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,8 @@ def _build_child_agent(
# ACP transport overrides — lets a non-ACP parent spawn ACP child agents
override_acp_command: Optional[str] = None,
override_acp_args: Optional[List[str]] = None,
# Per-task reasoning effort override (beats delegation.reasoning_effort).
override_reasoning_effort: Optional[str] = None,
# Per-call role controlling whether the child can further delegate.
# 'leaf' (default) cannot; 'orchestrator' retains the delegation
# toolset subject to depth/kill-switch bounds applied below.
Expand Down Expand Up @@ -1161,21 +1163,24 @@ def _child_thinking(text: str) -> None:
effective_provider = "copilot-acp"
effective_api_mode = "chat_completions"

# Resolve reasoning config: delegation override > parent inherit
# Resolve reasoning config: per-task override > delegation override > parent inherit
parent_reasoning = getattr(parent_agent, "reasoning_config", None)
child_reasoning = parent_reasoning
try:
delegation_effort = str(delegation_cfg.get("reasoning_effort") or "").strip()
if delegation_effort:
from hermes_constants import parse_reasoning_effort
from hermes_constants import parse_reasoning_effort

parsed = parse_reasoning_effort(delegation_effort)
effort_source = (
str(override_reasoning_effort or "").strip()
or str(delegation_cfg.get("reasoning_effort") or "").strip()
)
if effort_source:
parsed = parse_reasoning_effort(effort_source)
if parsed is not None:
child_reasoning = parsed
else:
logger.warning(
"Unknown delegation.reasoning_effort '%s', inheriting parent level",
delegation_effort,
"Unknown delegation reasoning_effort '%s', inheriting parent level",
effort_source,
)
except Exception as exc:
logger.debug("Could not load delegation reasoning_effort: %s", exc)
Expand Down Expand Up @@ -2067,6 +2072,8 @@ def delegate_task(
context: Optional[str] = None,
toolsets: Optional[List[str]] = None,
tasks: Optional[List[Dict[str, Any]]] = None,
model: Optional[str] = None,
reasoning_effort: Optional[str] = None,
max_iterations: Optional[int] = None,
acp_command: Optional[str] = None,
acp_args: Optional[List[str]] = None,
Expand Down Expand Up @@ -2179,7 +2186,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": model,
"reasoning_effort": reasoning_effort,
}
]
else:
return tool_error("Provide either 'goal' (single task) or 'tasks' (batch).")
Expand Down Expand Up @@ -2220,12 +2234,18 @@ 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 = (
str(t.get("model") or model or creds.get("model") or "").strip() or None
)
task_effort = (
str(t.get("reasoning_effort") or reasoning_effort or "").strip() or None
)
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,
Expand All @@ -2241,6 +2261,7 @@ def delegate_task(
if task_acp_args is not None
else (acp_args if acp_args is not None else creds.get("args"))
),
override_reasoning_effort=task_effort,
role=effective_role,
)
# Override with correct parent tool names (before child construction mutated global)
Expand Down Expand Up @@ -2886,7 +2907,10 @@ def _build_top_level_description() -> str:
f"user and can be disabled globally via "
"delegation.orchestrator_enabled=false.\n"
"- Each subagent gets its own terminal session (separate working directory and state).\n"
"- Results are always returned as an array, one entry per task."
"- Results are always returned as an array, one entry per task.\n"
"- Per-task `model` and `reasoning_effort` override the parent and "
"delegation config for that child only (e.g. route coding to a stronger "
"model while the parent stays on its default)."
)


Expand Down Expand Up @@ -3009,6 +3033,22 @@ def _build_dynamic_schema_overrides() -> dict:
"['terminal', 'file', 'web'] for full-stack tasks."
),
},
"model": {
"type": "string",
"description": (
"Optional model id for the subagent (single-task mode). "
"Overrides delegation.model and parent model. Examples: "
"'claude-sonnet-4-6', 'gpt-5.4', 'deepseek-v3'."
),
},
"reasoning_effort": {
"type": "string",
"description": (
"Optional reasoning/thinking effort for the subagent "
"(single-task mode). Examples: 'low', 'medium', 'high', "
"'xhigh'. Overrides delegation.reasoning_effort."
),
},
"tasks": {
"type": "array",
"items": {
Expand Down Expand Up @@ -3042,6 +3082,20 @@ 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 id. Overrides parent and "
"delegation.model for this task only."
),
},
"reasoning_effort": {
"type": "string",
"description": (
"Per-task reasoning effort (low/medium/high/xhigh). "
"Overrides delegation.reasoning_effort for this task."
),
},
},
"required": ["goal"],
},
Expand Down Expand Up @@ -3113,6 +3167,8 @@ def _build_dynamic_schema_overrides() -> dict:
context=args.get("context"),
toolsets=args.get("toolsets"),
tasks=args.get("tasks"),
model=args.get("model"),
reasoning_effort=args.get("reasoning_effort"),
max_iterations=args.get("max_iterations"),
acp_command=args.get("acp_command"),
acp_args=args.get("acp_args"),
Expand Down