Three Tools in One tool_call Is No Longer an Error: Hermes Splits the Batch

You have three MCP servers wired into Hermes: one reads files, one queries git, one files tickets in your company’s tracker. So you ask it to “skim these files and log a ticket,” and it does what any reasonable caller would do — it puts three entries in one tool_call. You expect three results. What you get is a correction message and a wasted round trip, because the agent has to think it all through again and re-issue the calls one at a time.
PR #131249 closes that gap. The agent now splits the batch into individual calls before the turn is persisted. Here is the mechanism, the boundaries, and what actually changes for you.
Where the rejection came from
To understand the error, you first need to know that Hermes loads MCP and plugin tools deferred. They are not in the always-present tool list — the model finds the exact name with tool_search, then invokes it through tool_call. It is a token-saving design, described in our tool search guide.
The calls argument of tool_call is an array, so a natural question follows: if it is an array, why not put three entries in it?
Before this fix, the answer was no. The old contract was “connectors may batch, local tools are one per call.” A model that put two or more local tools in one batch got the whole thing returned with a correction:
tool_call takes exactly one entry for local tools; you sent 2.
Retry with only: {"calls":[{"name":"mcp__files__read","arguments":{...}}]}...
The message was accurate, but it was not free. The model had to re-plan and re-issue. The PR author measured it on real sessions: the same two-entry local batch cost 9 messages and 33 seconds before the fix (rejected, retried as singletons) and 7 messages and 16 seconds after (split and executed). Almost all of the saving is model re-reasoning time.
The fix: split before persistence
The idea is unglamorous. Instead of having the dispatcher reject a batch, split it earlier.
The implementation is a new file, agent/tool_call_batches.py, with the entry point expand_local_tool_batches(), called once from agent/turn_tool_round.py. The placement is the important part: before the assistant message is persisted. Only the unpersisted turn is touched, so cached history is never rewritten.
After the split, each entry takes the ordinary tool path rather than going through the bridge:
- scope check, schema validation, argument parsing;
pre_tool_callhooks and guardrails;- approvals settle per entry — one denial does not drag the others down;
- the underlying tool’s own concurrency policy applies, so parallel-capable servers still run in parallel;
- each entry gets its own paired result and its own incremental write.
Two details matter more than they look:
Child call ids are deterministic. The first entry reuses the parent call; the rest get ids from deterministic_call_id(), derived from the parent id, the arguments and the index. Replays therefore produce stable ids instead of a session that looks like it called a different set of tools on every retry.
Native provider block lists are rewritten in place. Anthropic and Bedrock replay ordered content blocks instead of rebuilding history from tool_calls, so the block list is edited in position: signed thinking blocks stay byte-identical and keep their order. That matters for any model with encrypted reasoning — alter the signature and the provider rejects the whole replayed history.
What still gets rejected
The split only applies to batches that can and should be split. The rest is unchanged:
| Case | Behavior |
|---|---|
A batch of only connectors__ names |
Still one gateway dispatch, same shape as before |
| Malformed batches (unparseable, entries that are not objects) | Left to the dispatcher’s error |
| More than 10 entries | Left to the dispatcher’s error (MAX_CALLS_PER_DISPATCH = 10) |
| A single entry | Contract unchanged |
So the old error string did not disappear from the codebase — it still lives in tools/tool_search_validation.py. It now serves callers that bypass the split path; a normal conversational turn will not hit it. Keeping the old path while routing the common case around it is the safer shape: nothing that used to work stops working.
Four things you will notice
The contract text changed. The tool_call tool description used to say local deferred tools are one per tool_call; it now says local entries are split into individual calls by the agent, each following its own concurrency policy. The official tool-search docs were updated to match. A model cannot violate a rule it no longer knows about.
Hooks fire per entry. If you wrote a pre_tool_call plugin that blocks writes, the old behavior was all-or-nothing for a batch. Now the hook fires once per entry with the underlying tool name and that entry’s own call id, so a write can be blocked while a read in the same batch still runs.
Results come back in input order. The three results splice back into the original order with recomputed counts, so a split does not reshuffle what the model sees.
Interrupting is cleaner. Press /stop mid-batch and the entries that have not started are never sent, so nothing dangles.
How to get it
The fix merged to the main branch on October 2, 2026. The latest tagged release is v0.21.5 (tag v2026.9.24), so this change is main-only for now — a main-tracking install picks it up on its next hermes update, and release users get it in the next tag.
There is nothing to configure; it is a correction of default behavior, not a switch. The one thing worth doing is re-reading your pre_tool_call hooks. If you wrote them loosely on the assumption that a batch would just be rejected, per-entry firing is the moment to tighten write blocking. Deferred-tool configuration lives in the MCP command reference, and the config surface around MCP servers and context variables is covered in this guide.
The takeaway
One line sums it up: a model can now ask for several tools at once, because the responsibility for splitting moved from “a rule the model must remember” to “a path the agent always takes.”
It is not a flashy change, but it buys back a round trip in real conversations — especially on setups with several MCP servers and a smaller model, where one rejection can derail an entire turn. While you are here, it is also worth grepping your skills and system prompts: any instruction telling the model to call one tool at a time is now obsolete.