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" into 42 (string to number)
  • "true" into true (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_dispatch in agent/tool_executor.py
  • concurrent path: invoke_tool in agent/agent_runtime_helpers.py

So those tools receive raw arguments, never repaired:

  • clarify rejected a questions array that arrived as a JSON string;
  • todo_list hit the same mistake and survived because it carried its own json.loads block;
  • memory and session_search did 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:

  1. 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.
  2. 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 questions is an array of strings is steadier than hoping it gets it right every time.
  3. 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 clarify fail, todo_list paper over it with json.loads, and memory plus session_search do nothing.
  • Now the whole INLINE_TOOL_EXECUTORS table is wrapped once, covering both sequential and concurrent paths; the hand-rolled parse in todo_list is 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.