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: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 17 additions & 4 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions tests/run_agent/test_run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
68 changes: 68 additions & 0 deletions tests/tools/test_delegate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
35 changes: 34 additions & 1 deletion tools/delegate_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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."
)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions website/docs/guides/delegation-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
3 changes: 3 additions & 0 deletions website/docs/user-guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 3 additions & 0 deletions website/docs/user-guide/features/delegation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
:::