fix(run_agent): split concatenated streamed tool-call args (#25333) - #36039
fix(run_agent): split concatenated streamed tool-call args (#25333)#36039hbentel wants to merge 1 commit into
Conversation
…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.
|
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 |
|
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. |
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
349 tests/run_agent/ tests pass.
Closes #25333
Generated with Claude Code