The model sent an array as a string — so why do only some tools complain?

You ask the agent to use clarify and ask five questions at once. It cheerfully calls the tool and comes back with a type error. You retry with a different model, phrase it slightly differently, and it works. What makes it worse: switch to another tool, repeat the exact same mistake — an array sent as a JSON string — and nothing ever breaks.
That “sometimes it works” feel is usually not the model. It is two different entry points for tool calls, one of which quietly repairs arguments and one of which does not. A patch merged 2026-09-29 (PR #127781) lined them up. Here is what went wrong, what changed, and what still behaves as before.
The shared entry point already repaired arguments for you
Every registry tool call in Hermes goes through one place:
# model_tools.handle_function_call
args = coerce_tool_args(tool_name, args)
The logic in tools/arg_coercion.py is the codebase’s one spot for repairing argument types. It works against the tool’s own JSON Schema and fixes:
"42"into42(string to number)"true"intotrue(string to boolean)- a JSON-encoded string into an array or object, including when nested inside a container
- a single value into a one-item list, when the schema asks for an array
It is deliberately conservative: it only changes a value when there is exactly one clear answer, and keeps the original when the fix is ambiguous. That is also why you never notice it — nobody mentions a repair that worked, only a call that failed.
The problem: inline tools never take that road
A set of tools is “inline”: implemented by the agent itself and registered in the INLINE_TOOL_EXECUTORS table in agent/inline_tool_executors.py. That includes clarify, todo_list, memory, session_search, read_terminal, drive_preview, delegate_task, and more.
The catch is that both dispatch paths look that table up directly instead of going through handle_function_call:
- sequential path:
_resolve_sequential_dispatchinagent/tool_executor.py - concurrent path:
invoke_toolinagent/agent_runtime_helpers.py
So those tools receive raw arguments, never repaired:
clarifyrejected aquestionsarray that arrived as a JSON string;todo_listhit the same mistake and survived because it carried its ownjson.loadsblock;memoryandsession_searchdid nothing about it at all.
One tool fixed by the shared layer, one with a hand-written patch, the rest with nothing — that is the answer to “why do only some tools report argument errors”.
model tool call
├─ registry tool → handle_function_call → coerce_tool_args → tool
└─ inline tool → INLINE_TOOL_EXECUTORS → tool gets raw args ← the gap
The fix: wrap the one table both paths read
The patch is restrained — it does not touch either dispatch path. It wraps the table they both consult:
# agent/inline_tool_executors.py
def _coerced(name: str, executor: InlineToolExecutor) -> InlineToolExecutor:
def _exec(agent, args: dict, ctx: InlineToolContext) -> Any:
return executor(agent, coerce_tool_args(name, args), ctx)
return _exec
INLINE_TOOL_EXECUTORS: Dict[str, InlineToolExecutor] = {
name: _coerced(name, executor) for name, executor in _RAW_INLINE_TOOL_EXECUTORS.items()
}
Because both dispatch paths read this one table, a single wrapper covers sequential and concurrent execution alike. tools/todo_tool.py then dropped its hand-rolled json.loads: its schema already declares todos as an array, so the shared repair covers it — and keeping a second implementation just meant a second source of truth.
Worth stating what the fix does not cover: memory-provider tools on the concurrent path have no registry schema, so coerce_tool_args has no type information to act on. That is unchanged from before — not a regression. The patch only stopped tools with a schema from living by two different standards.
An easy detail to miss: field names have to line up first
The first thing coerce_tool_args actually does is map argument names back to the registry’s wire names:
from tools.schema_sanitizer import unrename_tool_args
args = unrename_tool_args(schema.get("parameters"), args)
The schema the model sees is sanitised: property keys that a provider refuses are renamed. So the model’s key names and the registry’s internal ones are not quite identical. Repair has to restore the names using the registry’s own schema before it types anything by key — get the order wrong and you are repairing a field that does not exist.
How to check it works
The patch ships a verification command (needs a repo checkout):
scripts/run_tests.sh tests/tools/test_todo_tool_type_coercion.py tests/agent/ tests/tools/test_clarify_tool.py
In day-to-day use, the observable difference is that the same phrasing now behaves the same across tools. Previously only registry tools forgave a JSON string where an array was declared; now clarify, todo_list, memory, and session_search do too. The easiest way to reproduce it yourself is to have a session ask several questions at once through clarify — the exact scenario that exposed the gap.
Three practical takeaways:
- Stop reading type errors as a model-capability problem. When a tool reports a bad argument type, check whether it received an array as a JSON string; after this patch that class of noise should drop noticeably.
- When writing skills or custom prompts, describe parameters the way the schema does. Repair is a safety net, not the design goal — telling the model that
questionsis an array of strings is steadier than hoping it gets it right every time. - Keep declaring schemas on tools you write yourself. Repair depends entirely on the schema; parameters without one (such as the memory-provider tools above) will never be repaired automatically.
This continues a line from two earlier write-ups: the batch-clarify piece covers how much back-and-forth asking several questions at once saves, and the tool-schema diet covers shrinking schemas to save tokens. This patch is the third brick: make argument repair consistent before a tool ever sees the arguments. For the todo side of it, see the nested subtasks write-up.
The short version
- Argument repair happens only in
handle_function_call→coerce_tool_args; inline tools are looked up directly by two dispatch paths and never passed through it. - So the same “array sent as a JSON string” mistake made
clarifyfail,todo_listpaper over it withjson.loads, andmemoryplussession_searchdo nothing. - Now the whole
INLINE_TOOL_EXECUTORStable is wrapped once, covering both sequential and concurrent paths; the hand-rolled parse intodo_listis gone. - Repair needs a schema and must first map sanitised field names back to registry names; provider tools without a schema keep their previous behaviour.
- Treat type errors as a mechanism problem, describe parameters per schema in your prompts, and keep declaring schemas on your own tools.
Related reading: the tools and toolsets documentation, and hermes config on this site.