fix(tool-calls): repair concatenated JSON, extract from noise, scrub nonfinite, coerce booleans, port content-channel promotion - #38042
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.
cda1285 to
f847aa9
Compare
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the detailed investigation and regression coverage. The integer precision issue is real on current main: model_tools.py:936-947 still takes integer-schema strings through float() before int().
Problems
agent/message_sanitization.py:302-313detects concatenated objects but returns onlysplit[0]. Current streaming assembly emits one call per accumulated slot (agent/chat_completion_helpers.py:2473-2503), and this PR does not add the required fan-out path, so later calls remain lost.agent/conversation_loop.py:3503-3521adds shared-loop parsing/execution of calls from free-textcontent. Maintainer review on PR #35129 explicitly rejected this compatibility layer in core; unsupported wire formats must be handled at provider/capability level.- The new JSON split/extract/scrub stages are model-output reconstruction, which conflicts with the repository's standing policy against adding repair passes for malformed model output.
Suggested changes
- Salvage the int-first integer coercion change and regression tests as a focused PR.
- Keep any boolean-coercion proposal separate.
- Remove the content-field promotion and new generic reconstruction passes.
Automated hermes-sweeper review.
| "Concatenated tool_call arguments for %s — keeping first of %d", | ||
| tool_name, len(split), | ||
| ) | ||
| return split[0] |
There was a problem hiding this comment.
This only returns the first decoded object. The actual streaming builder still emits exactly one call per tool_calls_acc slot (agent/chat_completion_helpers.py:2473-2503), and this PR does not consume the rest of split, so the claimed parallel-call recovery still drops every later object. Either implement fan-out at the assembly layer with end-to-end coverage or remove this repair.
| extract_content_tool_calls, | ||
| ) | ||
| valid = getattr(agent, "valid_tool_names", None) or set() | ||
| promoted, residual = extract_content_tool_calls( |
There was a problem hiding this comment.
Maintainer review on #35129 explicitly rejected shared-loop promotion of JSON/XML-like calls from free-text content; unsupported model wire formats must be handled by a provider/capability integration. Please remove this core promotion seam rather than adding another content parser.
Summary
Six pre-existing tool-call argument failure modes in the Hermes dispatch pipeline, surfaced by adversarial probing and cross-referenced against open upstream PRs. 241/241 tests passing in the repair+coerce+transport surface.
This work is the local-install complement to three open upstream PRs (the design mirrors their contracts so a future merge is near-zero-conflict):
content(Ollama/Kimi/MiniMax/Gemma) #35129 — content-channel tool calls (names MiniMax by name; this is the production failure mode from Hermes issue [Bug]: Minimax tool calls being sent to telegram instead of executing #12090)What's fixed
Concatenated streamed tool-call args (
{"a":1}{"b":2}) —{}fallback was dropping every call. Added_split_concatenated_tool_call_argumentsusingjson.JSONDecoder().raw_decode(pos=...)to walk left-to-right and peel complete top-level dicts. Matches the upstream PR fix(run_agent): split concatenated streamed tool-call args #25346 contract verbatim.Valid JSON buried in noise (
{"a":1}garbage,{"a":1}\u2028{"b":2}, BOM markers, model preamble/postscript) —{}fallback. Added_extract_first_json_objectwhich usesraw_decodefrom every{position and rescues the first complete dict.NaN / Infinity pass-through —
json.loads(strict=False)accepts the literal tokens; re-emits asNaN/Infinityliterals; strict-validating providers (Anthropic, AWS Bedrock, Google Vertex) reject with HTTP 400. Added_scrub_nonfinite_numberswhich recursively replaces withNone.Integer overflow silent corruption in
_coerce_number—'99999999999999999999'was goingfloat(value) → int(f), producing100000000000000000000(off by ~9 orders of magnitude). Fixed by tryingint(value)first forinteger_onlyfields; the float path is the fallback for decimal strings like"3.0"._coerce_booleanaccepted only"true"/"false"while M3, DeepSeek, Qwen, and GLM routinely emit"1"/"0"/"yes"/"no"/"on"/"off". Extended the match set. Two pre-existing tests asserted the old behaviour; updated with comments explaining the change.Content-channel tool calls (M3 / MiniMax, Kimi K2, Ollama qwen2.5-coder, GLM, Gemma) — models emit tool calls in
contentas<invoke name="…">…</invoke>or<tool_call>{…}</tool_call>instead of the structuredtool_callsfield, causing the call to leak as chat text. Ported the leaf parser from PR feat(transports): execute tool calls models emit incontent(Ollama/Kimi/MiniMax/Gemma) #35129 into a new moduleagent/transports/content_tool_calls.py(309 lines, 6 parsers, exact-name gate, fail-closed, env kill-switchHERMES_PROMOTE_TOOLCALLS) and added a single promotion seam inagent/conversation_loop.py. The seam is a strict no-op when structuredtool_callsalready exist, so native tool-calling paths are untouched.Design decisions
{"a":1}{"b":2}split MUST run before the "valid + garbage" extraction, otherwise the extractor returns{"a":1}and the second call is lost."null"in non-nullable string field (passes through correctly, the_schema_allows_nullcheck handles the nullable case), lone surrogate pair (Python'sjsonhandles emoji round-trips correctly), empty dict{}for array field (logged and falls through gracefully), hex numbers (correctly rejected).Tests
test_repair_tool_call_arguments.py(53 tests) for the split/extract/scrub helpers and end-to-end coverage of the new repair stages.test_tool_arg_coercion.py(8 tests) for integer overflow precision.test_tool_arg_coercion.pyupdated to assert the new correct boolean coercion behaviour (with comments explaining the change).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.Test totals: 241/241 passing across
test_content_tool_calls.py(17) +test_transport.py(23) +test_chat_completions.py(75) +test_tool_arg_coercion.py(65) +test_repair_tool_call_arguments.py(61).Files changed
6 modified, 2 new. +1035 / -13.
agent/message_sanitization.py— three new helpers, two new repair stagesrun_agent.py— re-exportsmodel_tools.py—_coerce_numberint-first,_coerce_booleanextendedagent/transports/content_tool_calls.py(NEW, 309 lines) — port from PR feat(transports): execute tool calls models emit incontent(Ollama/Kimi/MiniMax/Gemma) #35129agent/conversation_loop.py— promotion seamtests/run_agent/test_repair_tool_call_arguments.py— 53 new teststests/run_agent/test_tool_arg_coercion.py— 8 new tests + 2 updatedtests/agent/transports/test_content_tool_calls.py(NEW, 17 tests)Links
content(Ollama/Kimi/MiniMax/Gemma) #35129 (content-channel, still open): feat(transports): execute tool calls models emit incontent(Ollama/Kimi/MiniMax/Gemma) #35129Test plan