Skip to content

fix(run_agent): split concatenated streamed tool-call args (#25333) - #36039

Closed
hbentel wants to merge 1 commit into
NousResearch:mainfrom
hbentel:hermes/fix-25333-gemini-parallel-tool-calls
Closed

fix(run_agent): split concatenated streamed tool-call args (#25333)#36039
hbentel wants to merge 1 commit into
NousResearch:mainfrom
hbentel:hermes/fix-25333-gemini-parallel-tool-calls

Conversation

@hbentel

@hbentel hbentel commented May 31, 2026

Copy link
Copy Markdown
Contributor

Problem

Some providers (including gemini-3-flash-preview) emit multiple parallel tool calls in a single streaming chunk as concatenated top-level JSON objects with no delimiter — e.g. {"entity":"A"}{"entity":"B"}. Hermes logs 'Unrepairable tool_call arguments' and drops all calls in the blob.

Fix

Adds _split_concatenated_tool_call_arguments(raw_args) which uses json.JSONDecoder.raw_decode() to walk the string left-to-right, peeling off complete top-level dict objects. Only fires when the entire string decodes losslessly into 2+ dicts — any partial tail returns None and existing repair logic stays in control.

When a split is detected, synthetic SimpleNamespace tool-call objects are created for each segment (same function name, sequential IDs: call_xyz, call_xyz-split-2, ...) and appended in place of the unsplit entry.

Tests

  • test_split_concatenated_tool_call_arguments: happy path, two objects split correctly
  • test_split_concatenated_tool_call_arguments_rejects_partial_tail: truncated input returns None
  • test_concatenated_json_arguments_split_into_parallel_tool_calls: end-to-end streaming integration test

349 tests/run_agent/ tests pass.

Closes #25333

Generated with Claude Code

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint labels May 31, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Duplicate of #25346 — same json.JSONDecoder.raw_decode() split approach for concatenated }{ tool-call arguments from Gemini parallel streaming. Both fix #25333.

benclawbot pushed a commit to benclawbot/hermes-agent that referenced this pull request Jun 3, 2026
…nonfinite, coerce booleans, port content-channel promotion

Six pre-existing failure modes in the tool-call argument pipeline, all
surfaced by adversarial probing and online research into upstream
Hermes PRs. 241/241 tests passing in the repair+coerce+transport surface.

A. Concatenated streamed tool-call args — Gemini-3-flash-preview and
   some Ollama routes emit ``{"a":1}{"b":2}`` with no delimiter. The
   repair layer was falling through to ``{}`` and losing every call.
   Fixed by adding ``_split_concatenated_tool_call_arguments`` (matches
   upstream PR NousResearch#25346/NousResearch#36039 verbatim so a future merge is conflict-
   free), which uses ``json.JSONDecoder().raw_decode(pos=...)`` to
   walk left-to-right and peel off complete top-level dicts.

B. JSON buried in surrounding noise — U+2028/U+2029 line separators,
   BOM markers, model preamble/postscript, extra ``true``/``null``/
   ``"excess"`` after valid JSON, etc. All made ``json.loads`` reject
   the whole payload. Fixed by adding ``_extract_first_json_object``,
   which uses ``raw_decode`` from every ``{`` position in the string
   and rescues the first complete dict it finds.

C. NaN / Infinity pass-through — ``json.loads(strict=False)`` accepts
   the literal tokens, but re-serialising produces ``NaN``/``Infinity``
   literals that strict-validating providers (Anthropic, AWS Bedrock,
   Google Vertex) reject with HTTP 400. Fixed by adding
   ``_scrub_nonfinite_numbers`` which recursively replaces them with
   ``None``.

D. Integer overflow silent corruption in ``_coerce_number`` —
   ``'99999999999999999999'`` was being routed through ``float()`` then
   ``int()``, producing ``100000000000000000000`` (off by ~9 orders of
   magnitude). Python's ``int()`` has arbitrary precision, so for
   ``integer_only`` fields we now try ``int(value)`` first; the float
   path is the fallback for decimal strings like ``"3.0"``.

E. ``_coerce_boolean`` accepted only ``"true"`` / ``"false"`` while
   M3, DeepSeek, Qwen, and GLM routinely emit ``"1"`` / ``"0"`` /
   ``"yes"`` / ``"no"`` / ``"on"`` / ``"off"`` for boolean fields.
   Extended the match set. Two pre-existing tests asserted the old
   wrong behaviour; updated with comments explaining the change.

F. Content-channel tool calls (M3 / MiniMax, Kimi K2, Ollama
   qwen2.5-coder, GLM, Gemma) — models emit tool calls in the
   response ``content`` field as ``<invoke name="…">…</invoke>`` or
   ``<tool_call>{…}</tool_call>`` instead of the structured
   ``tool_calls`` field, causing the call to leak as chat text. Ported
   the leaf parser from upstream Hermes PR NousResearch#35129 into a new module
   ``agent/transports/content_tool_calls.py`` (309 lines, 6 parsers,
   exact-name gate, fail-closed, env kill-switch
   ``HERMES_PROMOTE_TOOLCALLS``) and added a single promotion seam
   in ``agent/conversation_loop.py`` at the post-normalization point
   in the response flow. The seam is a strict no-op when structured
   ``tool_calls`` already exist, so native tool-calling paths are
   untouched.

Tests:
- 4 new classes in test_repair_tool_call_arguments.py (53 tests) for
  the split/extract/scrub helpers and end-to-end coverage of the new
  repair stages.
- 1 new class in test_tool_arg_coercion.py (8 tests) for integer
  overflow precision.
- 2 pre-existing tests in test_tool_arg_coercion.py updated to assert
  the new correct boolean coercion behaviour.
- New file tests/agent/transports/test_content_tool_calls.py (17 tests)
  covering all 6 content-channel parsers, dedup, env gates, and
  fail-closed behaviour on unknown tool names.

Files changed: 6 modified, 2 new. +506/-13.

Upstream: PRs NousResearch#25346, NousResearch#36039, NousResearch#35129 are still open. If the user
wants to push these locally, the diffs are designed to merge near-zero-
conflict with those PRs.
@teknium1

Copy link
Copy Markdown
Contributor

Closing after maintainer direction on the product boundary.

This PR tries to recover malformed streamed tool-call arguments by parsing assistant/tool-call output that the provider/model failed to emit in the structured tool-call shape. We do not want Hermes to infer, split, or repair tool calls from malformed/plain-text/provider-broken output. If the provider/engine fails to emit valid structured tool calls, or the model is unreliable at tool calling, Hermes should surface that failure rather than adding parser/remedy logic.

That makes this approach out of scope even though the bug report is understandable. Thanks for the contribution — we're closing rather than salvaging this class of fix.

Related maintainer decision also closed the bounded to=functions.* leak-scan salvage (#45835) for the same reason.

@donbowman

Copy link
Copy Markdown

this makes no sense. its not malformed, its legal to have the {json}{json}, just as if you added a newline. once you have parsed the first json, the pointer is valid to the next one.

it also means that quite a few popular models, including gemma, gemini, just don't work with hermes.

a network stream of json is a valid output. that is what is happening here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

run_agent: 'Unrepairable tool_call arguments' when Gemini-3-Flash-Preview emits parallel tool calls as }{ — JSON parser drops them

5 participants