From fe4729e18ef2cc371b7118baa8d45adc5073c7e6 Mon Sep 17 00:00:00 2001 From: stefan-mcf <73107236+stefan-mcf@users.noreply.github.com> Date: Thu, 7 May 2026 12:52:49 +1000 Subject: [PATCH] fix(delegation): tune child output budgets --- AGENTS.md | 2 +- run_agent.py | 21 ++++-- tests/run_agent/test_run_agent.py | 6 ++ tests/tools/test_delegate.py | 68 +++++++++++++++++++ tools/delegate_tool.py | 35 +++++++++- website/docs/guides/delegation-patterns.md | 1 + website/docs/user-guide/configuration.md | 3 + .../docs/user-guide/features/delegation.md | 3 + 8 files changed, 133 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0c8550d459d89..4ab034d289079 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -622,7 +622,7 @@ Roles: Key config knobs (under `delegation:` in `config.yaml`): `max_concurrent_children`, `max_spawn_depth`, `child_timeout_seconds`, `orchestrator_enabled`, `subagent_auto_approve`, `inherit_mcp_toolsets`, -`max_iterations`. +`max_iterations`, `max_tokens`. Synchronicity rule: delegate_task is **not** durable. For long-running work that must outlive the current turn, use `cronjob` or diff --git a/run_agent.py b/run_agent.py index 919a5875b65ad..26b302c2e3ed8 100644 --- a/run_agent.py +++ b/run_agent.py @@ -11803,10 +11803,23 @@ def _stop_spinner(): f"{self.log_prefix}⚠️ Truncated tool call detected — retrying API call...", force=True, ) - # Don't append the broken response to messages; - # just re-run the same API call from the current - # message state, giving the model another chance. - continue + messages.append( + { + "role": "user", + "content": ( + "Your previous tool call was truncated before the arguments formed valid JSON. " + "Retry with a smaller valid tool call: use small targeted patches or writes, " + "split large content into repo-local artifacts, and avoid giant tool-call payloads." + ), + } + ) + # Don't append the broken assistant response to + # messages. Instead, steer the retry toward a + # smaller valid tool call and route it through the + # length-continuation path so the retry also gets + # the existing output-token budget boost. + restart_with_length_continuation = True + break self._vprint( f"{self.log_prefix}⚠️ Truncated tool call response detected again — refusing to execute incomplete tool arguments.", force=True, diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 42f1902db8613..e284702303073 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3228,6 +3228,12 @@ def test_truncated_tool_call_retries_once_before_refusing(self, agent): # Tool was executed on the retry (good_resp) mock_hfc.assert_called_once() assert result["final_response"] == "Done!" + retry_messages = agent.client.chat.completions.create.call_args_list[1].kwargs[ + "messages" + ] + assert retry_messages[-1]["role"] == "user" + assert "previous tool call was truncated" in retry_messages[-1]["content"] + assert "smaller valid tool call" in retry_messages[-1]["content"] def test_truncated_tool_args_detected_when_finish_reason_not_length(self, agent): """When a router rewrites finish_reason from 'length' to 'tool_calls', diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index c45de2a581f92..66859de8385ac 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -93,6 +93,12 @@ def test_empty_context_ignored(self): prompt = _build_child_system_prompt("Do something", " ") self.assertNotIn("CONTEXT", prompt) + def test_prompt_tells_subagents_to_avoid_oversized_tool_calls(self): + prompt = _build_child_system_prompt("Generate a report") + self.assertIn("Avoid oversized tool calls", prompt) + self.assertIn("small targeted patches", prompt) + self.assertIn("repo-local artifacts", prompt) + class TestStripBlockedTools(unittest.TestCase): def test_removes_blocked_toolsets(self): @@ -1084,6 +1090,68 @@ def test_empty_config_inherits_parent(self, mock_creds, mock_cfg): self.assertEqual(kwargs["provider"], parent.provider) self.assertEqual(kwargs["base_url"], parent.base_url) + @patch("tools.delegate_tool._load_config") + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_delegation_max_tokens_overrides_parent_for_child_agent( + self, mock_creds, mock_cfg + ): + """delegation.max_tokens can raise child output budget above parent.""" + mock_cfg.return_value = {"max_iterations": 45, "max_tokens": 64000} + mock_creds.return_value = { + "model": None, + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + } + parent = _make_mock_parent(depth=0) + parent.max_tokens = 8000 + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.run_conversation.return_value = { + "final_response": "done", + "completed": True, + "api_calls": 1, + } + MockAgent.return_value = mock_child + + delegate_task(goal="Test max token override", parent_agent=parent) + + _, kwargs = MockAgent.call_args + self.assertEqual(kwargs["max_tokens"], 64000) + + @patch("tools.delegate_tool._load_config") + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_invalid_delegation_max_tokens_falls_back_to_parent( + self, mock_creds, mock_cfg + ): + """Invalid delegation.max_tokens should not break child creation.""" + mock_cfg.return_value = {"max_iterations": 45, "max_tokens": "not-a-number"} + mock_creds.return_value = { + "model": None, + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + } + parent = _make_mock_parent(depth=0) + parent.max_tokens = 12000 + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.run_conversation.return_value = { + "final_response": "done", + "completed": True, + "api_calls": 1, + } + MockAgent.return_value = mock_child + + delegate_task(goal="Test invalid max token override", parent_agent=parent) + + _, kwargs = MockAgent.call_args + self.assertEqual(kwargs["max_tokens"], 12000) + @patch("tools.delegate_tool._load_config") @patch("tools.delegate_tool._resolve_delegation_credentials") def test_credential_error_returns_json_error(self, mock_creds, mock_cfg): diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 5c7c431b253ac..6caf59484943f 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -447,6 +447,35 @@ def _get_inherit_mcp_toolsets() -> bool: return is_truthy_value(cfg.get("inherit_mcp_toolsets"), default=True) +def _resolve_child_max_tokens(parent_agent: Any) -> Optional[int]: + """Resolve the output-token budget for delegated child agents. + + Defaults to the parent's max_tokens for backwards compatibility. Operators + can set ``delegation.max_tokens`` to give subagents more room for valid tool + calls and final summaries without raising the parent agent's output budget. + """ + parent_max_tokens = getattr(parent_agent, "max_tokens", None) + cfg = _load_config() + raw_value = cfg.get("max_tokens") + if raw_value is None or raw_value == "": + return parent_max_tokens + try: + value = int(raw_value) + except (TypeError, ValueError): + logger.warning( + "Invalid delegation.max_tokens value %r; inheriting parent max_tokens", + raw_value, + ) + return parent_max_tokens + if value <= 0: + logger.warning( + "Invalid delegation.max_tokens value %r; inheriting parent max_tokens", + raw_value, + ) + return parent_max_tokens + return value + + def _is_mcp_toolset_name(name: str) -> bool: """Return True for canonical MCP toolsets and their registered aliases.""" if not name: @@ -569,6 +598,8 @@ def _build_child_system_prompt( "- Any issues encountered\n\n" "Important workspace rule: Never assume a repository lives at /workspace/... or any other container-style path unless the task/context explicitly gives that path. " "If no exact local path is provided, discover it first before issuing git/workdir-specific commands.\n\n" + "Avoid oversized tool calls: prefer small targeted patches, file-backed artifacts, and incremental writes over giant patch/write_file payloads. " + "For large generated content, create or update repo-local artifacts in manageable chunks, then summarize the artifact paths.\n\n" "Be thorough but concise -- your response is returned to the " "parent agent as a summary." ) @@ -1049,6 +1080,8 @@ def _child_thinking(text: str) -> None: child_providers_order = None child_provider_sort = None + child_max_tokens = _resolve_child_max_tokens(parent_agent) + child = AIAgent( base_url=effective_base_url, api_key=effective_api_key, @@ -1058,7 +1091,7 @@ def _child_thinking(text: str) -> None: acp_command=effective_acp_command, acp_args=effective_acp_args, max_iterations=max_iterations, - max_tokens=getattr(parent_agent, "max_tokens", None), + max_tokens=child_max_tokens, reasoning_config=child_reasoning, prefill_messages=getattr(parent_agent, "prefill_messages", None), fallback_model=parent_fallback, diff --git a/website/docs/guides/delegation-patterns.md b/website/docs/guides/delegation-patterns.md index 0564690bc3391..277d8182ac793 100644 --- a/website/docs/guides/delegation-patterns.md +++ b/website/docs/guides/delegation-patterns.md @@ -226,6 +226,7 @@ Restricting toolsets keeps the subagent focused and prevents accidental side eff |--------|---------|-------|--------| | `max_concurrent_children` | 3 | >=1 | Parallel batch size per `delegate_task` call | | `max_spawn_depth` | 1 | 1-3 | How many delegation levels can spawn further | +| `max_tokens` | parent value | >0 | Optional child output-token budget override for long summaries or tool-call-heavy work | Example: running 30 parallel workers with nested subagents: diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 8cec37ccc87a6..c3e572312272a 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -67,8 +67,11 @@ auxiliary: delegation: api_key: ${DELEGATION_KEY} + max_tokens: 64000 ``` +`delegation.max_tokens` sets a child-specific output-token budget for `delegate_task` subagents. Use it when subagents doing code edits or large structured tool calls are hitting `finish_reason="length"` and truncating tool arguments. It does not increase the parent agent's output budget. + Multiple references in a single value work: `url: "${HOST}:${PORT}"`. If a referenced variable is not set, the placeholder is kept verbatim (`${UNDEFINED_VAR}` stays as-is). Only the `${VAR}` syntax is supported — bare `$VAR` is not expanded. For AI provider setup (OpenRouter, Anthropic, Copilot, custom endpoints, self-hosted LLMs, fallback models, etc.), see [AI Providers](/docs/integrations/providers). diff --git a/website/docs/user-guide/features/delegation.md b/website/docs/user-guide/features/delegation.md index ec09d148f94f3..8632ab463dde9 100644 --- a/website/docs/user-guide/features/delegation.md +++ b/website/docs/user-guide/features/delegation.md @@ -263,6 +263,7 @@ For **durable long-running work** that must survive interrupts or outlive the cu # In ~/.hermes/config.yaml delegation: max_iterations: 50 # Max turns per child (default: 50) + # max_tokens: 64000 # Optional child output-token budget; defaults to parent max_tokens # max_concurrent_children: 3 # Parallel children per batch (default: 3) # max_spawn_depth: 1 # Tree depth (1-3, default 1 = flat). Raise to 2 to allow orchestrator children to spawn leaves; 3 for three levels. # orchestrator_enabled: true # Disable to force all children to leaf role. @@ -276,6 +277,8 @@ delegation: api_key: "local-key" ``` +Installations that use profile-specific config should set `delegation.max_tokens` in each profile that spawns subagents. + :::tip The agent handles delegation automatically based on the task complexity. You don't need to explicitly ask it to delegate — it will do so when it makes sense. :::