Skip to content

fix(tool_call_parsers): recover from truncated/unbalanced JSON in hermes and longcat parsers - #5315

Closed
trevorgordon981 wants to merge 1 commit into
NousResearch:mainfrom
trevorgordon981:fix/tool-call-parser-unbalanced-json
Closed

fix(tool_call_parsers): recover from truncated/unbalanced JSON in hermes and longcat parsers#5315
trevorgordon981 wants to merge 1 commit into
NousResearch:mainfrom
trevorgordon981:fix/tool-call-parser-unbalanced-json

Conversation

@trevorgordon981

Copy link
Copy Markdown
Contributor

Problem

Local-model tool-call emissions (qwen3.5-122b, hermes-family, longcat) occasionally produce unbalanced JSON inside <tool_call>...</tool_call> tags: the model stops after closing the inner arguments object but before closing the outer function-call object, leaving one or more trailing } missing. Similarly, argument values containing file contents carry literal newlines inside JSON strings, which strict JSON forbids.

Both cases cause json.loads to raise, and the bare except Exception: return text, None in hermes_parser.py and longcat_parser.py silently drops the tool call, returning the raw text to the caller.

Concrete repro

from environments.tool_call_parsers import get_parser
parser = get_parser("hermes")
# Inner `arguments` closes, outer `}` missing (finish_reason=stop fires early)
text = '<tool_call>{"name": "terminal", "arguments": {"command": "ls -la"}</tool_call>'
content, tool_calls = parser.parse(text)
# Before this PR: tool_calls is None, tool call silently dropped
# After this PR:  tool_calls is [ChatCompletionMessageToolCall(name="terminal", ...)]

Impact

  • Phase 2 training (VLLM /generate): silent tool-call drops poison reward signals. The model takes an action, parser returns None, loop sees no tool call, reward is miscomputed.
  • Deployment: surfaces to users as the model "replying with a tool-call as prose" (the raw <tool_call>... text gets sent downstream).

Both are failure modes I hit with qwen3.5-122b-a10b-4bit today on a separate proxy that had the same class of bug, which is what prompted this upstream contribution.

Changes

New helper robust_json_loads() in environments/tool_call_parsers/__init__.py:

  • Uses json.JSONDecoder(strict=False).raw_decode() to tolerate literal \n/\t inside string values
  • On decode failure, appends 1-3 trailing } and retries (recovers truncated outer objects)
  • Logs at debug level when recovery fires or the payload is unrecoverable
  • Returns None on unrecoverable input or non-dict top-level JSON

Parsers updated (hermes_parser.py, longcat_parser.py):

  • Replace json.loads(raw_json) with robust_json_loads(raw_json)
  • Skip tool calls whose JSON parses but is missing the name field
  • Replace bare except Exception: return text, None with typed handling + debug logs
  • Add docstring note about the new recovery behavior

Tests

16 new test cases, all passing:

TestRobustJsonLoads (9 tests):

  • well-formed JSON, missing 1/2 close braces, literal newlines in strings
  • combined newline+truncation, empty/None inputs, unrecoverable garbage, non-dict top-level

TestHermesParserRobustness (5 tests):

  • unbalanced inner JSON recovered, multi-line content argument, unclosed-tag + unbalanced-JSON
  • missing-name skipped, unrecoverable garbage returns text

TestLongcatParserRobustness (2 tests):

  • unbalanced inner JSON recovered, multi-line content argument
$ pytest tests/test_tool_call_parsers.py -q
43 passed in 5.28s

Coverage notes

QwenToolCallParser inherits from HermesToolCallParser, so Qwen 2.5 benefits automatically. Other parsers (deepseek_v3, deepseek_v3_1, kimi_k2, glm45, glm47, mistral) use similar json.loads + bare-except patterns and may want the same treatment in follow-up PRs — out of scope here to keep the diff focused.

Backwards compatibility

  • Well-formed tool calls: identical behavior (parsed by the same raw_decode on first attempt).
  • Malformed tool calls that were previously dropped: some are now recovered (the whole point of the PR). Callers that depended on the previous silent-drop behavior may see new tool calls surface, but this is a correctness improvement, not a regression.
  • No API surface changes.

…mes and longcat parsers

Local-model tool-call emissions (qwen3.5-122b, hermes-family, longcat) occasionally produce
unbalanced JSON inside <tool_call>...</tool_call> tags: the model stops after closing the
inner `arguments` object but before closing the outer function-call object, leaving one or
more trailing `}` missing. Similarly, argument values containing file contents carry literal
newlines inside JSON strings (strict JSON forbids this).

Both cases caused `json.loads` to raise, and the bare `except Exception: return text, None`
in hermes_parser and longcat_parser silently dropped the tool call, returning the raw text
to the caller. In Phase 2 training loops this poisons reward signals; in deployment it
surfaces to users as the model "replying with a tool-call as prose".

Changes:
- Add `robust_json_loads()` helper in `environments/tool_call_parsers/__init__.py` that uses
  `json.JSONDecoder(strict=False).raw_decode()` (tolerates literal control chars in strings)
  and appends 1-3 trailing `}` when the initial decode fails (recovers truncated objects).
- Switch `hermes_parser` and `longcat_parser` to call the helper, drop the bare
  `except Exception`, and emit debug log lines instead of silently swallowing errors.
- Add 16 regression tests covering: well-formed JSON, missing 1/2 close braces, literal
  newlines in strings, combined newline+truncation, empty/None inputs, unrecoverable
  garbage, non-dict top-level results, multi-line content arguments, and the unclosed-tag
  + unbalanced-JSON combined case.

Qwen 2.5 parser inherits from hermes and benefits automatically. Other parsers
(deepseek, kimi, glm, mistral) use similar patterns and may want the same treatment
in follow-up PRs.
@trevorgordon981
trevorgordon981 force-pushed the fix/tool-call-parser-unbalanced-json branch from 51549a9 to 772ec41 Compare April 8, 2026 20:38
@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 1, 2026
@trevorgordon981

Copy link
Copy Markdown
Contributor Author

Closing as obsolete.

The environments/tool_call_parsers/ module this PR patched was removed wholesale in #26106 (Atropos RL environments cleanup), so the changes no longer apply.

More importantly, the bug is already fixed — and more robustly — in the current production path: tool-call argument recovery now lives in agent/message_sanitization.py via _repair_tool_call_arguments() (called from the streaming completion accumulator, the conversation loop, and run_agent.py). It already handles both failure modes this PR addressed:

  • Control chars in argument stringsjson.loads(..., strict=False) fast path plus a char-walking _escape_invalid_chars_in_json_strings() fallback.
  • Truncated / unbalanced JSON — exact {/} and [/] deficit counting (vs this PR's blind append of up to three }), plus excess-closer stripping.

...and a few cases this PR did not cover (trailing commas, Python None literals, a graceful {} last resort). Every tool-call JSON site now routes through the repair helper, so there is no naive json.loads left to harden.

No port needed. Thanks!

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.

2 participants