Repository navigation
Conversation
…es parser The Hermes parser located the end of a tool call with a non-greedy regex (non-streaming) and a plain `str.find` (streaming), so a literal `</tool_call>` inside a JSON string argument terminated the tool call early and the whole call was dropped with a JSONDecodeError. Scan the JSON body with string/escape awareness instead, matching what the Rust frontend's Hermes parser already does, and only withhold a partial closing tag in streaming when it is not inside a string literal. Fixes vllm-project#45167 Signed-off-by: RickyYii <237135932+RickyYii@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose
Fixes vllm-project#45167.
Hermes2ProToolParserlocated the end of a<tool_call>block with a non-greedy regex (<tool_call>(.*?)</tool_call>) in the non-streaming path and with a plainstr.find("</tool_call>")in the streaming path. When a JSON string argument contains the literal text</tool_call>(for example a file-editing tool whosecontentargument quotes the tag), the block was cut at the first occurrence,json.loadsfailed withUnterminated string, and the whole tool call was silently dropped (tools_called=False, raw markup returned as content). In streaming the argument stream was truncated at the same point.This PR scans the tool-call body with JSON string/escape awareness instead:
vllm/tool_parsers/utils.py: newfind_tag_outside_json_strings(text, tag, start)helper (next topartial_tag_overlap). It jumps between"characters withstr.find, tracks string/escape state (reusing the existing_is_escapedbackslash-parity check), and returns the first tag that is outside a string literal, plus whether the scan ended inside an unterminated string.vllm/tool_parsers/hermes_tool_parser.py: one region scanner_iter_tool_call_bodiesshared byextract_tool_callsand_extract_tool_call_jsons. A region without an end tag still extends to end-of-text exactly as before; invalid JSON still falls through to the raw-content fallback. In streaming, a partial</tool_call>suffix is only withheld when the scan is not inside a string literal (inside a string those characters are argument data). The now-unusedtool_call_regexattribute is removed.vllm/tool_parsers/longcat_tool_parser.py: drops itstool_call_regexoverride (it subclasses the Hermes parser and now works purely offtool_call_start_token/tool_call_end_token).The start tag is deliberately still located with plain
str.find: prose before a tool call may contain unbalanced quotes, and a start tag inside an already-terminated body is skipped because scanning resumes after the end tag.This matches what the Rust frontend's Hermes parser already does (
take_json_objectinrust/src/parser/src/utils.rstracks string/escape state), so Python and Rust now agree on this input.Duplicate check: no open PR references vllm-project#45167; the only related open PR is vllm-project#51937 (migrating the Hermes parser to the streaming parser engine), which does not address literal end tags inside strings (the engine lexer also matches
</tool_call>irrespective of JSON string state), so this fix is independent of it.Test Plan
New regression tests in
tests/tool_parsers/test_hermes_tool_parser.py(all fail onmainwithJSONDecodeError: Unterminated string):</tool_call>inside a Korean string argument);</tool_call>in the first of two consecutive tool calls;\"hi\") right before the literal tag;content;<tool_call>must not open a second tool call.Test Result
tests/tool_parsers/test_hermes_tool_parser.py: 35 passed, 1 skipped (30 passed, 1 skipped before; 5 new tests).test_hermes_tool_parser.py+test_utils.py+test_longcat_tool_parser.py: 225 passed, 1 skipped, 1 xfailed.tests/tool_parsersrun before/after the source change: identical pass/fail sets (no behaviour change outside the fixed case).str.find-based rather than a per-character Python loop: ~0.27 ms per scan on a 25 KBedit_file-style body versus ~1.65 ms for a character loop.pre-commit(ruff, ruff-format, typos, mypy 3.10, SPDX and the other repo hooks) passes;mypy-3.12manual hook passes.AI assistance was used while developing this change (drafting and test scaffolding); I reviewed every line and ran the tests above myself.
Generated by Claude Code