From ba83fd98faa2f2845d5c20c954a352100d4eb92b Mon Sep 17 00:00:00 2001 From: Zetian Li <804561096@qq.com> Date: Thu, 9 Jul 2026 20:42:20 +0000 Subject: [PATCH 01/16] [Bugfix] Fix lfm2 tool parser dropping calls with brackets or newlines in string args Two failure modes in pythonic tool-call parsing, both hit by agentic models emitting shell commands as string arguments: 1. make_valid_python counted brackets inside string literals, so a bracket in a quoted argument (e.g. exec(command='grep -F "]" log.txt')) corrupted the bracket stack and the streaming parse raised UnexpectedAstError, dropping the call. Skip brackets while inside a string literal; only an unescaped matching quote closes it. 2. A raw newline inside a string argument (multi-line shell command / heredoc) is invalid Python, so ast.parse failed with 'unterminated string literal' and the whole call was dropped, both in extract_tool_calls and in the streaming path's final parse. Add escape_ctrl_chars_in_strings, which escapes \n/\r/\t only inside string literals, and retry the parse with the escaped text; the argument value round-trips exactly. On LiveClawBench with LFM2.5-based agents these two together dropped ~8% of tool calls (every multi-line command). Co-authored-by: Claude (Anthropic) Signed-off-by: Zetian Li <804561096@qq.com> --- tests/tool_parsers/test_lfm2_tool_parser.py | 46 +++++++++++++ tests/tool_parsers/test_utils.py | 67 +++++++++++++++++++ vllm/tool_parsers/lfm2_tool_parser.py | 9 ++- vllm/tool_parsers/utils.py | 72 +++++++++++++++++---- 4 files changed, 182 insertions(+), 12 deletions(-) diff --git a/tests/tool_parsers/test_lfm2_tool_parser.py b/tests/tool_parsers/test_lfm2_tool_parser.py index 9cb5b195f1a7..995b07149b3d 100644 --- a/tests/tool_parsers/test_lfm2_tool_parser.py +++ b/tests/tool_parsers/test_lfm2_tool_parser.py @@ -74,6 +74,22 @@ '"deliveryAddress": "845 Willow Lane, Springfield, IL 62704"}' ), ) +# Agentic shell commands: a bracket inside a string argument must not corrupt +# the streaming bracket tracker. +BRACKET_IN_STRING_FUNCTION_OUTPUT = "exec(command='grep -F \"]\" log.txt')" +BRACKET_IN_STRING_FUNCTION_CALL = FunctionCall( + name="exec", + arguments='{"command": "grep -F \\"]\\" log.txt"}', +) +# A raw newline inside a string argument (multi-line shell command) is invalid +# Python; the parser must recover the full value instead of dropping the call. +MULTILINE_FUNCTION_OUTPUT = ( + "exec(command='cat > f.py << EOF\nimport csv\nprint(1)\nEOF')" +) +MULTILINE_FUNCTION_CALL = FunctionCall( + name="exec", + arguments='{"command": "cat > f.py << EOF\\nimport csv\\nprint(1)\\nEOF"}', +) @pytest.fixture(scope="module") @@ -227,6 +243,36 @@ def test_no_tool_call(streaming: bool, lfm2_tokenizer: TokenizerLike): None, id="dotted_name_nonstreaming", ), + # Messy agentic shell commands: literal bracket inside a string argument + pytest.param( + True, + _wrap(BRACKET_IN_STRING_FUNCTION_OUTPUT), + [BRACKET_IN_STRING_FUNCTION_CALL], + None, + id="bracket_in_string_streaming", + ), + pytest.param( + False, + _wrap(BRACKET_IN_STRING_FUNCTION_OUTPUT), + [BRACKET_IN_STRING_FUNCTION_CALL], + None, + id="bracket_in_string_nonstreaming", + ), + # Multi-line string argument (raw newlines in a shell command) + pytest.param( + True, + _wrap(MULTILINE_FUNCTION_OUTPUT), + [MULTILINE_FUNCTION_CALL], + None, + id="multiline_string_arg_streaming", + ), + pytest.param( + False, + _wrap(MULTILINE_FUNCTION_OUTPUT), + [MULTILINE_FUNCTION_CALL], + None, + id="multiline_string_arg_nonstreaming", + ), ] diff --git a/tests/tool_parsers/test_utils.py b/tests/tool_parsers/test_utils.py index 3276fa9ddd25..5a7a1eec3117 100644 --- a/tests/tool_parsers/test_utils.py +++ b/tests/tool_parsers/test_utils.py @@ -1,13 +1,17 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import ast import json import pytest from vllm.tool_parsers.utils import ( + UnexpectedAstError, coerce_to_schema_type, + escape_ctrl_chars_in_strings, extract_types_from_schema, + make_valid_python, ) @@ -279,3 +283,66 @@ def test_nested_anyof(self): } result = set(extract_types_from_schema(schema)) assert result == {"integer", "null", "string"} + + +class TestMakeValidPythonStringLiterals: + def test_bracket_inside_string_is_literal(self): + # A bracket inside a string argument must not be counted as a + # structural bracket. Regression: `]` inside the string popped the + # bracket stack and the whole call raised as mismatched. + text = "[exec(command='grep -F \"]\" log.txt')]" + assert make_valid_python(text) == (text, "") + + def test_open_bracket_inside_string_is_literal(self): + # An unclosed `[` inside a string must not leave a phantom open + # bracket on the stack. + text = "[exec(command='grep [abc log.txt')]" + assert make_valid_python(text) == (text, "") + + def test_partial_string_with_bracket_completes(self): + # Streaming prefix ending mid-string after a literal bracket closes + # with quote + paren + bracket. + result = make_valid_python('[exec(command=\'grep -F "]" lo') + assert result is not None + completed, added = result + assert added == "')]" + assert completed == "[exec(command='grep -F \"]\" lo')]" + + def test_real_mismatched_bracket_still_raises(self): + with pytest.raises(UnexpectedAstError): + make_valid_python("[exec(command=data])") + + def test_multiline_string_argument_recovers(self): + # A raw newline inside a string argument is invalid Python; the + # escaped-retry path must recover the call instead of returning None, + # and the escaped value must evaluate back to the original. + text = "[exec(command='line1\nline2')]" + result = make_valid_python(text) + assert result is not None + completed, added = result + assert added == "" + module = ast.parse(completed) + call = module.body[0].value.elts[0] + assert call.keywords[0].value.value == "line1\nline2" + + +class TestEscapeCtrlCharsInStrings: + def test_newline_inside_string_escaped(self): + assert escape_ctrl_chars_in_strings("f(cmd='a\nb')") == "f(cmd='a\\nb')" + + def test_ctrl_chars_outside_strings_untouched(self): + assert escape_ctrl_chars_in_strings("f(a=1,\nb=2)") == "f(a=1,\nb=2)" + + def test_existing_escapes_pass_through(self): + text = "f(cmd='a\\nb')" + assert escape_ctrl_chars_in_strings(text) == text + + def test_escaped_quote_does_not_close_string(self): + assert escape_ctrl_chars_in_strings("f(cmd='a\\'\nb')") == "f(cmd='a\\'\\nb')" + + def test_value_preserved_through_ast(self): + # The escaped text parses and evaluates back to the original value. + raw = "cat > f.py << EOF\nimport csv\nEOF\techo done" + escaped = escape_ctrl_chars_in_strings(f"f(cmd='{raw}')") + call = ast.parse(escaped).body[0].value + assert call.keywords[0].value.value == raw diff --git a/vllm/tool_parsers/lfm2_tool_parser.py b/vllm/tool_parsers/lfm2_tool_parser.py index ee92d060fbea..ef9efab1f0fe 100644 --- a/vllm/tool_parsers/lfm2_tool_parser.py +++ b/vllm/tool_parsers/lfm2_tool_parser.py @@ -24,6 +24,7 @@ from vllm.tool_parsers.utils import ( UnexpectedAstError, compute_tool_delta, + escape_ctrl_chars_in_strings, handle_single_tool, make_valid_python, ) @@ -171,7 +172,13 @@ def extract_tool_calls( ) try: - module = ast.parse(tool_text) + try: + module = ast.parse(tool_text) + except SyntaxError: + # A raw newline/tab inside a string argument (e.g. a multi-line + # shell command) is invalid Python; escape control chars inside + # string literals and retry instead of dropping the call. + module = ast.parse(escape_ctrl_chars_in_strings(tool_text)) parsed = getattr(module.body[0], "value", None) if isinstance(parsed, ast.List) and all( isinstance(e, ast.Call) for e in parsed.elts diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index a11d4a9eec7a..3c1bbee39f22 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -524,6 +524,48 @@ def handle_single_tool(call: ast.Call) -> ToolCall: ) +def escape_ctrl_chars_in_strings(text: str) -> str: + """Escape literal control chars inside string literals of pythonic text. + + Models emitting pythonic tool calls frequently place raw newlines inside a + string argument (e.g. ``exec(command='line1\\nline2')`` written with a real + line break). That is invalid Python — ``ast.parse`` fails with "unterminated + string literal" — so the call would be dropped even though the intent is + unambiguous. Escaping ``\\n``/``\\r``/``\\t`` only *inside* string literals + makes the text parseable while preserving the argument value exactly + (the escape sequences evaluate back to the original control chars). + + Text outside string literals is returned unchanged. + """ + out: list[str] = [] + quote: str | None = None + index, length = 0, len(text) + while index < length: + char = text[index] + if quote is None: + if char in {"'", '"'}: + quote = char + out.append(char) + elif char == "\\" and index + 1 < length: + out.append(char) + out.append(text[index + 1]) + index += 2 + continue + elif char == quote: + quote = None + out.append(char) + elif char == "\n": + out.append("\\n") + elif char == "\r": + out.append("\\r") + elif char == "\t": + out.append("\\t") + else: + out.append(char) + index += 1 + return "".join(out) + + def make_valid_python(text: str) -> tuple[str, str] | None: """Attempt to close all open brackets/quotes to make partial Python valid. @@ -541,6 +583,16 @@ def make_valid_python(text: str) -> tuple[str, str] | None: """ bracket_stack: list[str] = [] for index, char in enumerate(text): + # Inside a string literal only an unescaped matching quote is + # significant; brackets are literal text. Without this guard a bracket + # in a string argument (e.g. `cmd='grep -F "]"'`) corrupts the bracket + # stack and the whole tool call is rejected as mismatched. + if bracket_stack and bracket_stack[-1] in {"'", '"'}: + if char == bracket_stack[-1] and not ( + index > 0 and text[index - 1] == "\\" + ): + bracket_stack.pop() + continue if char in {"[", "(", "{"}: bracket_stack.append(char) elif char == "]": @@ -553,15 +605,7 @@ def make_valid_python(text: str) -> tuple[str, str] | None: if not bracket_stack or bracket_stack.pop() != "{": raise UnexpectedAstError("Mismatched curly braces") elif char in {"'", '"'}: - if bracket_stack and bracket_stack[-1] == char: - if index > 0 and text[index - 1] == "\\": - pass - else: - bracket_stack.pop() - elif bracket_stack and bracket_stack[-1] in {"'", '"'}: - pass - else: - bracket_stack.append(char) + bracket_stack.append(char) text = text.rstrip() if text.endswith("=") or text.endswith(":"): @@ -603,11 +647,17 @@ def make_valid_python(text: str) -> tuple[str, str] | None: # Python but a *set* literal, which downstream tool-call AST # handling rejects. # Validate the candidate parses, has a body, and contains no Set - # nodes (pythonic tool calls always use dicts for `{...}`). + # nodes (pythonic tool calls always use dicts for `{...}`). A raw + # newline inside a string argument is recovered by escaping control + # chars in string literals before giving up. try: module = ast.parse(candidate) except SyntaxError: - return None + candidate = escape_ctrl_chars_in_strings(candidate) + try: + module = ast.parse(candidate) + except SyntaxError: + return None if not module.body: return None for node in ast.walk(module): From 81546d74f9c355bdca020506b7539a88facf93c3 Mon Sep 17 00:00:00 2001 From: Zetian Li <804561096@qq.com> Date: Fri, 17 Jul 2026 09:43:58 +0000 Subject: [PATCH 02/16] [Bugfix] lfm2 tool parser: close string args ending in a backslash make_valid_python decided whether a quote closed its enclosing string by inspecting only the single preceding character for a backslash. That is wrong for an even run of backslashes: in `content='...\\'` the closing quote follows an escaped backslash (`\\`), so it DOES close the string -- but the single-char check read it as an escaped quote, left the string open, and returned None, silently dropping the tool call. Hits code/regex arguments (e.g. `r'\b'`) whose value ends in a backslash. Decide escaping by backslash parity via a small `_is_escaped` helper: a character is escaped iff preceded by an odd number of backslashes. Odd runs (a genuinely escaped quote) still keep the string open; even runs close it. On our internal agentic trace corpus this removed the streaming-only residual (16 -> 2 dropped tool calls across 1.15M), bringing the streaming path to parity with the non-streaming path. Co-authored-by: Claude (Anthropic) Signed-off-by: Zetian Li <804561096@qq.com> --- tests/tool_parsers/test_utils.py | 17 +++++++++++++++++ vllm/tool_parsers/utils.py | 21 ++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/tests/tool_parsers/test_utils.py b/tests/tool_parsers/test_utils.py index 5a7a1eec3117..59c0c304a3f2 100644 --- a/tests/tool_parsers/test_utils.py +++ b/tests/tool_parsers/test_utils.py @@ -325,6 +325,23 @@ def test_multiline_string_argument_recovers(self): call = module.body[0].value.elts[0] assert call.keywords[0].value.value == "line1\nline2" + def test_value_ending_in_backslash_recovers(self): + # A string value ending in a literal backslash: the closing quote follows + # an escaped backslash (an *even* run), so it closes the string. Checking + # only the single preceding char misread it as an escaped quote, left the + # string open, and make_valid_python returned None — dropping calls whose + # last argument ends in a backslash (common in regex like r'\b'). + text = "[write(path='x', content='pattern \\\\')]" + assert make_valid_python(text) == (text, "") + + def test_escaped_quote_odd_backslashes_stays_open(self): + # An escaped quote (an *odd* backslash run) must NOT close the string; + # only the final unescaped quote does. Value round-trips to it's fine. + text = "[say(msg='it\\'s fine')]" + assert make_valid_python(text) == (text, "") + module = ast.parse(text) + assert module.body[0].value.elts[0].keywords[0].value.value == "it's fine" + class TestEscapeCtrlCharsInStrings: def test_newline_inside_string_escaped(self): diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index 3c1bbee39f22..c5dd40e4ccfa 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -566,6 +566,23 @@ def escape_ctrl_chars_in_strings(text: str) -> str: return "".join(out) +def _is_escaped(text: str, index: int) -> bool: + """Whether the character at ``index`` is backslash-escaped. + + A character is escaped iff it is preceded by an *odd* number of consecutive + backslashes. Checking only the single preceding character is wrong for even + runs: in ``'ab\\'`` the closing quote follows an escaped backslash (``\\\\``) + and is therefore NOT escaped — it closes the string. Common in regex/code + arguments such as ``r'\\\\b'``. + """ + backslashes = 0 + j = index - 1 + while j >= 0 and text[j] == "\\": + backslashes += 1 + j -= 1 + return backslashes % 2 == 1 + + def make_valid_python(text: str) -> tuple[str, str] | None: """Attempt to close all open brackets/quotes to make partial Python valid. @@ -588,9 +605,7 @@ def make_valid_python(text: str) -> tuple[str, str] | None: # in a string argument (e.g. `cmd='grep -F "]"'`) corrupts the bracket # stack and the whole tool call is rejected as mismatched. if bracket_stack and bracket_stack[-1] in {"'", '"'}: - if char == bracket_stack[-1] and not ( - index > 0 and text[index - 1] == "\\" - ): + if char == bracket_stack[-1] and not _is_escaped(text, index): bracket_stack.pop() continue if char in {"[", "(", "{"}: From 9020bdd27dc3fe7bcd747622ea715e25e3e92fe3 Mon Sep 17 00:00:00 2001 From: Zetian Li <804561096@qq.com> Date: Fri, 17 Jul 2026 18:25:21 +0000 Subject: [PATCH 03/16] [Bugfix] lfm2 tool parser: accept negative number arguments A negative number is parsed by Python as ast.UnaryOp(USub, Constant(n)) rather than a plain Constant, so get_parameter_value rejected it and the whole tool-call list was dropped. Negative longitudes, deltas, and offsets are common tool arguments; a scan of ~5.9M markers across four datasets found 30,217 dropped calls, all caused by this (up to 34.6% of rows in one set). Add a branch that unwraps unary +/- over a numeric constant, at any nesting depth, restricted to numeric operands so genuine non-literals still raise. Purely additive: no input that parsed before changes behavior. Co-authored-by: Claude (Anthropic) Signed-off-by: Zetian Li <804561096@qq.com> --- tests/tool_parsers/test_utils.py | 64 ++++++++++++++++++++++++++++++++ vllm/tool_parsers/utils.py | 16 ++++++++ 2 files changed, 80 insertions(+) diff --git a/tests/tool_parsers/test_utils.py b/tests/tool_parsers/test_utils.py index 59c0c304a3f2..ab71678af40d 100644 --- a/tests/tool_parsers/test_utils.py +++ b/tests/tool_parsers/test_utils.py @@ -11,6 +11,8 @@ coerce_to_schema_type, escape_ctrl_chars_in_strings, extract_types_from_schema, + get_parameter_value, + handle_single_tool, make_valid_python, ) @@ -363,3 +365,65 @@ def test_value_preserved_through_ast(self): escaped = escape_ctrl_chars_in_strings(f"f(cmd='{raw}')") call = ast.parse(escaped).body[0].value assert call.keywords[0].value.value == raw + + +def _value_of(expr: str): + """Parse a single Python expression and run get_parameter_value on it.""" + return get_parameter_value(ast.parse(expr, mode="eval").body) + + +def _first_call(text: str) -> ast.Call: + """Parse ``[foo(...)]`` and return the single ast.Call node.""" + return ast.parse(text).body[0].value.elts[0] + + +class TestGetParameterValueNegativeNumbers: + # A negative number is parsed by Python as UnaryOp(USub, Constant(n)), not + # a plain Constant. Without explicit handling the entire tool call is + # dropped. Negative longitudes/deltas/offsets are extremely common tool + # arguments (e.g. every Western-hemisphere coordinate). + def test_negative_int(self): + assert _value_of("-1") == -1 + + def test_negative_float(self): + assert _value_of("-3.5") == -3.5 + + def test_explicit_positive_int(self): + assert _value_of("+7") == 7 + + def test_negative_longitude(self): + assert _value_of("-74.0046539") == -74.0046539 + + def test_negative_in_list(self): + assert _value_of("[-1, 2, -3]") == [-1, 2, -3] + + def test_negative_in_dict(self): + assert _value_of('{"min": -5, "max": 5}') == {"min": -5, "max": 5} + + def test_nested_negative(self): + assert _value_of('{"bbox": [-74.0, 40.7, -73.9]}') == { + "bbox": [-74.0, 40.7, -73.9] + } + + def test_non_numeric_unary_still_raises(self): + # ``not x`` / ``~x`` are not literals and must still be rejected. + with pytest.raises(UnexpectedAstError): + _value_of("~5") + with pytest.raises(UnexpectedAstError): + _value_of("not True") + + +class TestHandleSingleToolNegativeNumbers: + def test_negative_arg_end_to_end(self): + call = _first_call("[searchWeather(latitude=40.84, longitude=-74.0046539)]") + tool = handle_single_tool(call) + assert tool.function.name == "searchWeather" + assert json.loads(tool.function.arguments) == { + "latitude": 40.84, + "longitude": -74.0046539, + } + + def test_negative_delta_end_to_end(self): + call = _first_call("[updateInventory(quantity_delta=-20)]") + tool = handle_single_tool(call) + assert json.loads(tool.function.arguments) == {"quantity_delta": -20} diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index c5dd40e4ccfa..7d5eddfc1401 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -467,6 +467,22 @@ def get_parameter_value(val: ast.expr) -> Any: return [get_parameter_value(v) for v in val.elts] elif isinstance(val, ast.Name) and val.id in _JSON_NAME_LITERALS: return _JSON_NAME_LITERALS[val.id] + elif isinstance(val, ast.UnaryOp) and isinstance(val.op, (ast.USub, ast.UAdd)): + # A negative (or explicitly positive) number is parsed by Python as a + # unary operation over a numeric constant, e.g. ``-1`` becomes + # ``UnaryOp(USub, Constant(1))`` rather than a plain ``Constant(-1)``. + # These are extremely common tool arguments (negative longitudes, + # offsets, deltas); without this branch the whole call is dropped. + # Restrict to numeric operands so ``not``/``~`` and other expressions + # still raise below. + operand = get_parameter_value(val.operand) + if isinstance(operand, (int, float)) and not isinstance(operand, bool): + return -operand if isinstance(val.op, ast.USub) else operand + logger.warning( + "Unsupported unary operand in tool call arguments: %s", + ast.dump(val), + ) + raise UnexpectedAstError("Tool call arguments must be literals") else: logger.warning( "Unsupported AST node type in tool call arguments: %s", From 4a6392d7ddacce9cb280618b7c762db263445d56 Mon Sep 17 00:00:00 2001 From: Zetian Li <804561096@qq.com> Date: Fri, 17 Jul 2026 18:43:29 +0000 Subject: [PATCH 04/16] [Bugfix] lfm2 tool parser: accept tuple arguments A tuple argument (e.g. size=(800, 600)) parsed as ast.Tuple, which get_parameter_value did not handle, so the whole tool-call list was dropped. JSON has no tuple type; decode it as a list so it round-trips through json.dumps. Matches the behavior of the BFCL liquid_api handler. Co-authored-by: Claude (Anthropic) Signed-off-by: Zetian Li <804561096@qq.com> --- tests/tool_parsers/test_utils.py | 18 ++++++++++++++++++ vllm/tool_parsers/utils.py | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/tests/tool_parsers/test_utils.py b/tests/tool_parsers/test_utils.py index ab71678af40d..de2e0ccff538 100644 --- a/tests/tool_parsers/test_utils.py +++ b/tests/tool_parsers/test_utils.py @@ -427,3 +427,21 @@ def test_negative_delta_end_to_end(self): call = _first_call("[updateInventory(quantity_delta=-20)]") tool = handle_single_tool(call) assert json.loads(tool.function.arguments) == {"quantity_delta": -20} + + +class TestGetParameterValueTuple: + # JSON has no tuple type, so a tuple argument is decoded as a list rather + # than dropping the whole call. + def test_tuple_becomes_list(self): + assert _value_of("(800, 600)") == [800, 600] + + def test_nested_tuple(self): + assert _value_of("[(1, 2), (3, 4)]") == [[1, 2], [3, 4]] + + def test_tuple_with_negative(self): + assert _value_of("(-74.0, 40.7)") == [-74.0, 40.7] + + def test_tuple_end_to_end(self): + call = _first_call("[resize(size=(800, 600))]") + tool = handle_single_tool(call) + assert json.loads(tool.function.arguments) == {"size": [800, 600]} diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index 7d5eddfc1401..a706c666b4e0 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -465,6 +465,11 @@ def get_parameter_value(val: ast.expr) -> Any: } elif isinstance(val, ast.List): return [get_parameter_value(v) for v in val.elts] + elif isinstance(val, ast.Tuple): + # JSON has no tuple type; a tuple argument (e.g. ``size=(800, 600)``) + # is treated as a list so it round-trips through ``json.dumps``. + # Without this the whole call is dropped. + return [get_parameter_value(v) for v in val.elts] elif isinstance(val, ast.Name) and val.id in _JSON_NAME_LITERALS: return _JSON_NAME_LITERALS[val.id] elif isinstance(val, ast.UnaryOp) and isinstance(val.op, (ast.USub, ast.UAdd)): From 68ec5d88da6b32e94bb2dacb2f8ffa8acc2145fe Mon Sep 17 00:00:00 2001 From: Zetian Li <804561096@qq.com> Date: Fri, 17 Jul 2026 20:31:46 +0000 Subject: [PATCH 05/16] [Bugfix] lfm2 tool parser: accept reserved-keyword parameter names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tools legitimately name parameters `from`, `in`, `class` — but `memory_get(from=1)` is a Python SyntaxError no escape retry can recover, so the whole tool-call list was dropped (observed in real traces). Add rename_reserved_kwargs, a string-aware scanner that rewrites `from=` to `from_pyreservedkw_=` only outside string literals and only in keyword-argument position (preceded by `(` or `,`, followed by a single `=`), plus restore_reserved_kwarg_names as its exact inverse applied to the decoded arguments. Wired into both lfm2 paths: non-streaming as a third recovery attempt after the control-char escape retry, streaming as a deterministic pre-rewrite so successive chunks stay consistent. Inputs that parsed before take an identical code path. Co-authored-by: Claude (Anthropic) Signed-off-by: Zetian Li <804561096@qq.com> --- tests/tool_parsers/test_utils.py | 66 ++++++++++++++++++++ vllm/tool_parsers/lfm2_tool_parser.py | 45 ++++++++++++-- vllm/tool_parsers/utils.py | 88 +++++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 5 deletions(-) diff --git a/tests/tool_parsers/test_utils.py b/tests/tool_parsers/test_utils.py index de2e0ccff538..81175dcb12e5 100644 --- a/tests/tool_parsers/test_utils.py +++ b/tests/tool_parsers/test_utils.py @@ -14,6 +14,8 @@ get_parameter_value, handle_single_tool, make_valid_python, + rename_reserved_kwargs, + restore_reserved_kwarg_names, ) @@ -445,3 +447,67 @@ def test_tuple_end_to_end(self): call = _first_call("[resize(size=(800, 600))]") tool = handle_single_tool(call) assert json.loads(tool.function.arguments) == {"size": [800, 600]} + + +class TestRenameReservedKwargs: + # A parameter named after a Python keyword (`from=1`) is a SyntaxError + # that no escape/retry can recover; rename_reserved_kwargs rewrites it to + # a parseable name and restore_reserved_kwarg_names is its exact inverse. + def test_reserved_kwarg_renamed(self): + text, changed = rename_reserved_kwargs("[memory_get(from=1)]") + assert changed + assert text == "[memory_get(from_pyreservedkw_=1)]" + assert ast.parse(text) + + def test_round_trip_restores_original_name(self): + renamed, _ = rename_reserved_kwargs("[memory_get(path='M.md', from=1)]") + call = ast.parse(renamed).body[0].value.elts[0] + tool = handle_single_tool(call) + restored = restore_reserved_kwarg_names(json.loads(tool.function.arguments)) + assert restored == {"path": "M.md", "from": 1} + + def test_multiple_reserved_kwargs(self): + text, changed = rename_reserved_kwargs('[search(in="docs/", from=0)]') + assert changed + args = json.loads( + handle_single_tool(ast.parse(text).body[0].value.elts[0]) + .function.arguments + ) + assert restore_reserved_kwarg_names(args) == {"in": "docs/", "from": 0} + + def test_keyword_inside_string_untouched(self): + text, changed = rename_reserved_kwargs('[f(cmd="import x from y")]') + assert not changed + assert text == '[f(cmd="import x from y")]' + + def test_keyword_with_from_eq_inside_string_untouched(self): + text, changed = rename_reserved_kwargs('[f(cmd="SELECT from=1")]') + assert not changed + + def test_keyword_value_untouched(self): + # `x=True` has a keyword as *value*, not parameter name. + text, changed = rename_reserved_kwargs("[f(x=True, y=None)]") + assert not changed + + def test_double_equals_untouched(self): + text, changed = rename_reserved_kwargs('[f(expr="a", cond=1)]') + assert not changed + # `in ==` comparison-like text is not kwarg position anyway, but the + # `==` guard also protects string-free edge text. + text, changed = rename_reserved_kwargs("[f(x=1)]") + assert not changed + + def test_non_keyword_names_untouched(self): + text, changed = rename_reserved_kwargs("[f(fromage=1, classic=2)]") + assert not changed + + def test_spaces_around_equals(self): + text, changed = rename_reserved_kwargs("[f( from = 1 )]") + assert changed + assert ast.parse(text) + + def test_restore_leaves_normal_names_alone(self): + assert restore_reserved_kwarg_names({"path": "x", "from": 1}) == { + "path": "x", + "from": 1, + } diff --git a/vllm/tool_parsers/lfm2_tool_parser.py b/vllm/tool_parsers/lfm2_tool_parser.py index ef9efab1f0fe..6cd09d0cbe6f 100644 --- a/vllm/tool_parsers/lfm2_tool_parser.py +++ b/vllm/tool_parsers/lfm2_tool_parser.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import ast +import json from collections.abc import Sequence import regex as re @@ -27,6 +28,8 @@ escape_ctrl_chars_in_strings, handle_single_tool, make_valid_python, + rename_reserved_kwargs, + restore_reserved_kwarg_names, ) logger = init_logger(__name__) @@ -95,6 +98,15 @@ def current_tool_index(self) -> int: def current_tool_index(self, value: int) -> None: self.current_tool_id = value + @staticmethod + def _restore_reserved(tool_call): + """Restore parameter names renamed by ``rename_reserved_kwargs``.""" + arguments = json.loads(tool_call.function.arguments) + restored = restore_reserved_kwarg_names(arguments) + if restored != arguments: + tool_call.function.arguments = json.dumps(restored, ensure_ascii=False) + return tool_call + @staticmethod def _strip_echo(raw_after: str) -> str: """Drop any orphan <|tool_call_end|> (and the preceding text) from @@ -172,23 +184,36 @@ def extract_tool_calls( ) try: + kw_renamed = False try: module = ast.parse(tool_text) except SyntaxError: # A raw newline/tab inside a string argument (e.g. a multi-line # shell command) is invalid Python; escape control chars inside # string literals and retry instead of dropping the call. - module = ast.parse(escape_ctrl_chars_in_strings(tool_text)) + escaped = escape_ctrl_chars_in_strings(tool_text) + try: + module = ast.parse(escaped) + except SyntaxError: + # A parameter named after a Python keyword (`from=1`) is + # also a SyntaxError; rename it, parse, restore below. + renamed, kw_renamed = rename_reserved_kwargs(escaped) + if not kw_renamed: + raise + module = ast.parse(renamed) parsed = getattr(module.body[0], "value", None) if isinstance(parsed, ast.List) and all( isinstance(e, ast.Call) for e in parsed.elts ): + tool_calls = [ + handle_single_tool(e) # type: ignore + for e in parsed.elts + ] + if kw_renamed: + tool_calls = [self._restore_reserved(tc) for tc in tool_calls] return ExtractedToolCallInformation( tools_called=True, - tool_calls=[ - handle_single_tool(e) # type: ignore - for e in parsed.elts - ], + tool_calls=tool_calls, content=content, ) else: @@ -278,6 +303,14 @@ def _content_only_or_none() -> DeltaMessage | None: return DeltaMessage(content=combined) if combined else None try: + # A parameter named after a Python keyword (`from=1`) can never + # parse; rename complete `keyword=` tokens up front (a deterministic + # rewrite, so successive chunks stay consistent) and restore the + # original names after decoding. + renamed_tool_text, kw_renamed = rename_reserved_kwargs(tool_text) + if kw_renamed: + tool_text = renamed_tool_text + valid_and_added_text = make_valid_python(tool_text) if valid_and_added_text is None: return _content_only_or_none() @@ -293,6 +326,8 @@ def _content_only_or_none() -> DeltaMessage | None: handle_single_tool(e) # type: ignore for e in parsed.elts ] + if kw_renamed: + tool_calls = [self._restore_reserved(tc) for tc in tool_calls] tool_deltas = [] for index, new_call in enumerate(tool_calls): diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index a706c666b4e0..58333852cf9f 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -3,6 +3,7 @@ import ast import json +import keyword as _python_keyword import math import warnings from dataclasses import dataclass @@ -587,6 +588,93 @@ def escape_ctrl_chars_in_strings(text: str) -> str: return "".join(out) +_RESERVED_KW_SUFFIX = "_pyreservedkw_" + + +def rename_reserved_kwargs(text: str) -> tuple[str, bool]: + """Rename Python-keyword parameter names so pythonic tool text parses. + + Tools legitimately name parameters ``from``, ``in``, ``class`` — but + ``memory_get(from=1)`` is a Python ``SyntaxError``, so the whole call + would be dropped. Rename ``from=`` to ``from_pyreservedkw_=`` (outside + string literals only, and only in keyword-argument position: preceded by + ``(`` or ``,`` and followed by a single ``=``), parse, then restore the + original name with :func:`restore_reserved_kwarg_names`. + + Returns (rewritten_text, changed). Keyword *values* (``x=True``) and + keywords inside string arguments are never touched. + """ + out: list[str] = [] + quote: str | None = None + changed = False + last_sig = "" + index, length = 0, len(text) + while index < length: + char = text[index] + if quote is not None: + out.append(char) + if char == "\\" and index + 1 < length: + out.append(text[index + 1]) + index += 2 + continue + if char == quote: + quote = None + index += 1 + continue + if char in {"'", '"'}: + quote = char + out.append(char) + last_sig = char + index += 1 + continue + if char.isalpha() or char == "_": + end = index + while end < length and (text[end].isalnum() or text[end] == "_"): + end += 1 + name = text[index:end] + look = end + while look < length and text[look] in " \t": + look += 1 + if ( + _python_keyword.iskeyword(name) + and look < length + and text[look] == "=" + and (look + 1 >= length or text[look + 1] != "=") + and last_sig in {"(", ","} + ): + out.append(name + _RESERVED_KW_SUFFIX) + changed = True + else: + out.append(name) + last_sig = name[-1] + index = end + continue + out.append(char) + if not char.isspace(): + last_sig = char + index += 1 + return "".join(out), changed + + +def restore_reserved_kwarg_names(arguments: dict) -> dict: + """Undo :func:`rename_reserved_kwargs` on a decoded arguments dict. + + Only keys that carry the rename suffix *and* whose stem is a Python + keyword are restored, making this an exact inverse of the rename. + """ + restored = {} + for key, value in arguments.items(): + if ( + isinstance(key, str) + and key.endswith(_RESERVED_KW_SUFFIX) + and _python_keyword.iskeyword(key[: -len(_RESERVED_KW_SUFFIX)]) + ): + restored[key[: -len(_RESERVED_KW_SUFFIX)]] = value + else: + restored[key] = value + return restored + + def _is_escaped(text: str, index: int) -> bool: """Whether the character at ``index`` is backslash-escaped. From df0b66d68f10ed093e695e00d57c9690667d4f2f Mon Sep 17 00:00:00 2001 From: Zetian Li <804561096@qq.com> Date: Sat, 18 Jul 2026 08:43:32 +0000 Subject: [PATCH 06/16] [Bugfix] lfm2 tool parser: allow newline before `=` in reserved-kwarg rename Python permits a newline between a keyword-argument name and its `=` inside parens (`memory_get(from\n=1)`), but rename_reserved_kwargs skipped only spaces/tabs in its lookahead, so the rename never fired and the call was still dropped. Skip all whitespace; the `(`/`,` position guard and `==` exclusion are unchanged. Co-authored-by: Claude (Anthropic) Signed-off-by: Zetian Li <804561096@qq.com> --- vllm/tool_parsers/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index 58333852cf9f..cde04aaf6b69 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -633,7 +633,7 @@ def rename_reserved_kwargs(text: str) -> tuple[str, bool]: end += 1 name = text[index:end] look = end - while look < length and text[look] in " \t": + while look < length and text[look].isspace(): look += 1 if ( _python_keyword.iskeyword(name) From a8c59f415fafb5fae2be4edc292de1a314a856df Mon Sep 17 00:00:00 2001 From: Zetian Li <804561096@qq.com> Date: Tue, 4 Aug 2026 09:51:58 +0000 Subject: [PATCH 07/16] [Bugfix] lfm2 tool parser: confine ctrl-char escape recovery to the lfm2 parser The escaped-retry inside make_valid_python changed streaming behavior for every consumer of the shared helper (pythonic, llama4_pythonic, olmo3), making their streaming paths accept raw newlines that their non-streaming paths still reject. Move the escape to the lfm2 streaming call site, next to the existing reserved-keyword rewrite, so the shared helper keeps its upstream semantics and the recovery stays scoped to the parser whose models are known to emit raw control chars in string arguments. Co-authored-by: Claude Signed-off-by: Zetian Li <804561096@qq.com> --- tests/tool_parsers/test_utils.py | 12 +++++++----- vllm/tool_parsers/lfm2_tool_parser.py | 13 +++++++++---- vllm/tool_parsers/utils.py | 12 ++++-------- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/tests/tool_parsers/test_utils.py b/tests/tool_parsers/test_utils.py index 81175dcb12e5..83edaf09b859 100644 --- a/tests/tool_parsers/test_utils.py +++ b/tests/tool_parsers/test_utils.py @@ -316,12 +316,14 @@ def test_real_mismatched_bracket_still_raises(self): with pytest.raises(UnexpectedAstError): make_valid_python("[exec(command=data])") - def test_multiline_string_argument_recovers(self): - # A raw newline inside a string argument is invalid Python; the - # escaped-retry path must recover the call instead of returning None, - # and the escaped value must evaluate back to the original. + def test_multiline_string_argument_recovers_after_escape(self): + # A raw newline inside a string argument is invalid Python, so + # make_valid_python alone returns None; callers pre-escape control + # chars (as the lfm2 parser does) and the escaped value must evaluate + # back to the original. text = "[exec(command='line1\nline2')]" - result = make_valid_python(text) + assert make_valid_python(text) is None + result = make_valid_python(escape_ctrl_chars_in_strings(text)) assert result is not None completed, added = result assert added == "" diff --git a/vllm/tool_parsers/lfm2_tool_parser.py b/vllm/tool_parsers/lfm2_tool_parser.py index 6cd09d0cbe6f..1afdc93d87b2 100644 --- a/vllm/tool_parsers/lfm2_tool_parser.py +++ b/vllm/tool_parsers/lfm2_tool_parser.py @@ -303,10 +303,15 @@ def _content_only_or_none() -> DeltaMessage | None: return DeltaMessage(content=combined) if combined else None try: - # A parameter named after a Python keyword (`from=1`) can never - # parse; rename complete `keyword=` tokens up front (a deterministic - # rewrite, so successive chunks stay consistent) and restore the - # original names after decoding. + # A raw control char inside a string argument would make every + # completion candidate a SyntaxError; escape them here rather than + # inside make_valid_python so the shared helper keeps its upstream + # behavior for the other pythonic parsers. A parameter named after + # a Python keyword (`from=1`) can never parse; rename complete + # `keyword=` tokens as well. Both rewrites are deterministic, so + # successive chunks stay consistent; names are restored after + # decoding. + tool_text = escape_ctrl_chars_in_strings(tool_text) renamed_tool_text, kw_renamed = rename_reserved_kwargs(tool_text) if kw_renamed: tool_text = renamed_tool_text diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index cde04aaf6b69..ac28a5e1f4aa 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -771,17 +771,13 @@ def make_valid_python(text: str) -> tuple[str, str] | None: # Python but a *set* literal, which downstream tool-call AST # handling rejects. # Validate the candidate parses, has a body, and contains no Set - # nodes (pythonic tool calls always use dicts for `{...}`). A raw - # newline inside a string argument is recovered by escaping control - # chars in string literals before giving up. + # nodes (pythonic tool calls always use dicts for `{...}`). Callers + # whose models emit raw control chars inside string arguments must + # escape them (see escape_ctrl_chars_in_strings) before calling. try: module = ast.parse(candidate) except SyntaxError: - candidate = escape_ctrl_chars_in_strings(candidate) - try: - module = ast.parse(candidate) - except SyntaxError: - return None + return None if not module.body: return None for node in ast.walk(module): From 475c523fbbd0cc0208edcf5052b13eec68459bfc Mon Sep 17 00:00:00 2001 From: Zetian Li <804561096@qq.com> Date: Tue, 4 Aug 2026 09:54:26 +0000 Subject: [PATCH 08/16] [Bugfix] lfm2 tool parser: strip leading whitespace in the streaming path Whitespace between <|tool_call_start|> and the opening bracket made every streaming completion candidate an IndentationError, so the call was silently dropped while the non-streaming path (which strips the tool text) parsed it fine. Co-authored-by: Claude Signed-off-by: Zetian Li <804561096@qq.com> --- tests/tool_parsers/test_lfm2_tool_parser.py | 22 +++++++++++++++++++++ vllm/tool_parsers/lfm2_tool_parser.py | 3 +++ 2 files changed, 25 insertions(+) diff --git a/tests/tool_parsers/test_lfm2_tool_parser.py b/tests/tool_parsers/test_lfm2_tool_parser.py index 995b07149b3d..1af9841d1a6c 100644 --- a/tests/tool_parsers/test_lfm2_tool_parser.py +++ b/tests/tool_parsers/test_lfm2_tool_parser.py @@ -301,6 +301,28 @@ def test_tool_call( assert actual.function == expected +def test_whitespace_after_start_token(lfm2_tokenizer: TokenizerLike): + """Whitespace between <|tool_call_start|> and the opening bracket must + not break parsing. The streaming path used to feed the indented text to + ast.parse verbatim, so every completion candidate raised + IndentationError and the call was silently dropped (non-streaming + stripped it and succeeded).""" + cls = ToolParserManager.get_tool_parser("lfm2") + model_output = f"{TOOL_CALL_START} [{SIMPLE_FUNCTION_OUTPUT}]{TOOL_CALL_END}" + + content, tool_calls = run_tool_extraction( + cls(lfm2_tokenizer), model_output, streaming=False + ) + assert len(tool_calls) == 1 + assert tool_calls[0].function == SIMPLE_FUNCTION_CALL + + reconstructor = run_tool_extraction_streaming( + cls(lfm2_tokenizer), [model_output], assert_one_tool_per_delta=False + ) + assert len(reconstructor.tool_calls) == 1 + assert reconstructor.tool_calls[0].function == SIMPLE_FUNCTION_CALL + + def test_streaming_tool_call_with_large_steps(lfm2_tokenizer: TokenizerLike): tool_parser: ToolParser = ToolParserManager.get_tool_parser("lfm2")(lfm2_tokenizer) model_output_deltas = [ diff --git a/vllm/tool_parsers/lfm2_tool_parser.py b/vllm/tool_parsers/lfm2_tool_parser.py index 1afdc93d87b2..0b8167e989e5 100644 --- a/vllm/tool_parsers/lfm2_tool_parser.py +++ b/vllm/tool_parsers/lfm2_tool_parser.py @@ -293,6 +293,9 @@ def extract_tool_calls_streaming( # Strip the end token if present (entire call arrived at once). if TOOL_CALL_END in tool_text: tool_text = tool_text.split(TOOL_CALL_END, 1)[0] + # Leading whitespace after the start token would make every completion + # candidate an IndentationError (the non-streaming path strips it too). + tool_text = tool_text.lstrip() def _content_only_or_none() -> DeltaMessage | None: """Return a content-only delta if any content arrived in this From c48965039e16680a17c7de41a1618de575cd3e5b Mon Sep 17 00:00:00 2001 From: Zetian Li <804561096@qq.com> Date: Tue, 4 Aug 2026 10:04:04 +0000 Subject: [PATCH 09/16] [Bugfix] lfm2 tool parser: recover string arguments containing NUL bytes ast.parse rejects a NUL byte anywhere in the source with ValueError, not SyntaxError, so the escape-retry chain never fired: the call was dropped in non-streaming mode and streaming emitted truncated arguments. Escape NUL inside string literals alongside the other control chars and widen the retry to catch ValueError. Observed in production: LFM2 emitting printf/shell commands with embedded NULs. Co-authored-by: Claude Signed-off-by: Zetian Li <804561096@qq.com> --- tests/tool_parsers/test_lfm2_tool_parser.py | 23 +++++++++++++++++++++ tests/tool_parsers/test_utils.py | 13 ++++++++++++ vllm/tool_parsers/lfm2_tool_parser.py | 9 ++++---- vllm/tool_parsers/utils.py | 10 ++++++--- 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/tests/tool_parsers/test_lfm2_tool_parser.py b/tests/tool_parsers/test_lfm2_tool_parser.py index 1af9841d1a6c..0514ddd71622 100644 --- a/tests/tool_parsers/test_lfm2_tool_parser.py +++ b/tests/tool_parsers/test_lfm2_tool_parser.py @@ -90,6 +90,13 @@ name="exec", arguments='{"command": "cat > f.py << EOF\\nimport csv\\nprint(1)\\nEOF"}', ) +# A NUL byte inside a string argument makes ast.parse raise ValueError (not +# SyntaxError) for the whole source; the escape path must recover the call. +NUL_BYTE_FUNCTION_OUTPUT = "exec(command='printf a\x00b')" +NUL_BYTE_FUNCTION_CALL = FunctionCall( + name="exec", + arguments='{"command": "printf a\\u0000b"}', +) @pytest.fixture(scope="module") @@ -273,6 +280,22 @@ def test_no_tool_call(streaming: bool, lfm2_tokenizer: TokenizerLike): None, id="multiline_string_arg_nonstreaming", ), + # NUL byte in a string argument (ValueError from ast.parse, not + # SyntaxError) + pytest.param( + True, + _wrap(NUL_BYTE_FUNCTION_OUTPUT), + [NUL_BYTE_FUNCTION_CALL], + None, + id="nul_byte_string_arg_streaming", + ), + pytest.param( + False, + _wrap(NUL_BYTE_FUNCTION_OUTPUT), + [NUL_BYTE_FUNCTION_CALL], + None, + id="nul_byte_string_arg_nonstreaming", + ), ] diff --git a/tests/tool_parsers/test_utils.py b/tests/tool_parsers/test_utils.py index 83edaf09b859..133d1543379b 100644 --- a/tests/tool_parsers/test_utils.py +++ b/tests/tool_parsers/test_utils.py @@ -370,6 +370,19 @@ def test_value_preserved_through_ast(self): call = ast.parse(escaped).body[0].value assert call.keywords[0].value.value == raw + def test_nul_byte_inside_string_escaped(self): + # ast.parse raises ValueError (not SyntaxError) on NUL anywhere in + # the source, so an unescaped NUL in a string arg is unrecoverable. + raw = "printf a\x00b" + escaped = escape_ctrl_chars_in_strings(f"f(cmd='{raw}')") + assert "\x00" not in escaped + call = ast.parse(escaped).body[0].value + assert call.keywords[0].value.value == raw + + def test_nul_byte_outside_strings_untouched(self): + text = "f(a=1,\x00b=2)" + assert escape_ctrl_chars_in_strings(text) == text + def _value_of(expr: str): """Parse a single Python expression and run get_parameter_value on it.""" diff --git a/vllm/tool_parsers/lfm2_tool_parser.py b/vllm/tool_parsers/lfm2_tool_parser.py index 0b8167e989e5..af748906b05b 100644 --- a/vllm/tool_parsers/lfm2_tool_parser.py +++ b/vllm/tool_parsers/lfm2_tool_parser.py @@ -187,14 +187,15 @@ def extract_tool_calls( kw_renamed = False try: module = ast.parse(tool_text) - except SyntaxError: + except (SyntaxError, ValueError): # A raw newline/tab inside a string argument (e.g. a multi-line - # shell command) is invalid Python; escape control chars inside - # string literals and retry instead of dropping the call. + # shell command) is invalid Python, and a NUL byte anywhere is + # a ValueError; escape control chars inside string literals and + # retry instead of dropping the call. escaped = escape_ctrl_chars_in_strings(tool_text) try: module = ast.parse(escaped) - except SyntaxError: + except (SyntaxError, ValueError): # A parameter named after a Python keyword (`from=1`) is # also a SyntaxError; rename it, parse, restore below. renamed, kw_renamed = rename_reserved_kwargs(escaped) diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index ac28a5e1f4aa..1a6aeecb4234 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -553,9 +553,11 @@ def escape_ctrl_chars_in_strings(text: str) -> str: string argument (e.g. ``exec(command='line1\\nline2')`` written with a real line break). That is invalid Python — ``ast.parse`` fails with "unterminated string literal" — so the call would be dropped even though the intent is - unambiguous. Escaping ``\\n``/``\\r``/``\\t`` only *inside* string literals - makes the text parseable while preserving the argument value exactly - (the escape sequences evaluate back to the original control chars). + unambiguous. A NUL byte is worse: ``ast.parse`` rejects it anywhere in the + source with ``ValueError``. Escaping ``\\n``/``\\r``/``\\t``/``\\x00`` only + *inside* string literals makes the text parseable while preserving the + argument value exactly (the escape sequences evaluate back to the original + control chars). Text outside string literals is returned unchanged. """ @@ -582,6 +584,8 @@ def escape_ctrl_chars_in_strings(text: str) -> str: out.append("\\r") elif char == "\t": out.append("\\t") + elif char == "\x00": + out.append("\\x00") else: out.append(char) index += 1 From 301b3cbb1b4300b080514105a11f700d2f50eb83 Mon Sep 17 00:00:00 2001 From: Zetian Li <804561096@qq.com> Date: Tue, 4 Aug 2026 10:05:05 +0000 Subject: [PATCH 10/16] [Bugfix] lfm2 tool parser: accept placeholder-free f-string arguments Python parses f'hello' as JoinedStr, not Constant, so get_parameter_value rejected it and the whole call was dropped even though the value is a plain string constant. Fold all-Constant JoinedStr parts; f-strings with real placeholders still raise. Co-authored-by: Claude Signed-off-by: Zetian Li <804561096@qq.com> --- tests/tool_parsers/test_utils.py | 23 +++++++++++++++++++++++ vllm/tool_parsers/utils.py | 11 +++++++++++ 2 files changed, 34 insertions(+) diff --git a/tests/tool_parsers/test_utils.py b/tests/tool_parsers/test_utils.py index 133d1543379b..fe57fb99d9f8 100644 --- a/tests/tool_parsers/test_utils.py +++ b/tests/tool_parsers/test_utils.py @@ -446,6 +446,29 @@ def test_negative_delta_end_to_end(self): assert json.loads(tool.function.arguments) == {"quantity_delta": -20} +class TestGetParameterValueFString: + # A placeholder-free f-string is a plain string constant, but ast parses + # it as JoinedStr; it must not drop the call. Real placeholders are not + # literals and must still be rejected. + def test_constant_fstring(self): + assert _value_of("f'hello world'") == "hello world" + + def test_empty_fstring(self): + assert _value_of("f''") == "" + + def test_constant_fstring_in_list(self): + assert _value_of("[f'a', 'b']") == ["a", "b"] + + def test_fstring_with_placeholder_still_raises(self): + with pytest.raises(UnexpectedAstError): + _value_of("f'{x}'") + + def test_constant_fstring_end_to_end(self): + call = _first_call("[send(msg=f'hello')]") + tool = handle_single_tool(call) + assert json.loads(tool.function.arguments) == {"msg": "hello"} + + class TestGetParameterValueTuple: # JSON has no tuple type, so a tuple argument is decoded as a list rather # than dropping the whole call. diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index 1a6aeecb4234..04b99d4b28fc 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -471,6 +471,17 @@ def get_parameter_value(val: ast.expr) -> Any: # is treated as a list so it round-trips through ``json.dumps``. # Without this the whole call is dropped. return [get_parameter_value(v) for v in val.elts] + elif isinstance(val, ast.JoinedStr) and all( + isinstance(part, ast.Constant) for part in val.values + ): + # An f-string without placeholders (``f'hello'``) is a plain string + # constant, but Python parses it as JoinedStr rather than Constant; + # without this branch the whole call is dropped. F-strings with real + # placeholders still fall through to the raise below. + return "".join( + str(part.value) # type: ignore + for part in val.values + ) elif isinstance(val, ast.Name) and val.id in _JSON_NAME_LITERALS: return _JSON_NAME_LITERALS[val.id] elif isinstance(val, ast.UnaryOp) and isinstance(val.op, (ast.USub, ast.UAdd)): From f2966021c21e531a4ff6c2de5e1b0b8a29d9fa91 Mon Sep 17 00:00:00 2001 From: Zetian Li <804561096@qq.com> Date: Tue, 4 Aug 2026 10:07:51 +0000 Subject: [PATCH 11/16] [Bugfix] lfm2 tool parser: accept set literal arguments JSON has no set type; decode a set argument (tags={'urgent', 'bug'}) as a list in source order, mirroring the tuple handling, instead of dropping the whole call. make_valid_python keeps rejecting Set nodes only when its own completion added the closing brace (the truncated-dict artifact it was guarding against); a set the model closed itself now parses. Co-authored-by: Claude Signed-off-by: Zetian Li <804561096@qq.com> --- tests/tool_parsers/test_lfm2_tool_parser.py | 20 +++++++++++++ tests/tool_parsers/test_utils.py | 31 +++++++++++++++++++++ vllm/tool_parsers/utils.py | 24 ++++++++++------ 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/tests/tool_parsers/test_lfm2_tool_parser.py b/tests/tool_parsers/test_lfm2_tool_parser.py index 0514ddd71622..8da216ba425f 100644 --- a/tests/tool_parsers/test_lfm2_tool_parser.py +++ b/tests/tool_parsers/test_lfm2_tool_parser.py @@ -54,6 +54,11 @@ name="do_something_cool", arguments='{"steps": []}', ) +SET_ARG_FUNCTION_OUTPUT = "label(tags={'urgent', 'bug'})" +SET_ARG_FUNCTION_CALL = FunctionCall( + name="label", + arguments='{"tags": ["urgent", "bug"]}', +) ESCAPED_STRING_FUNCTION_OUTPUT = ( r"get_weather(city='Martha\'s Vineyard', metric='\"cool units\"')" ) @@ -280,6 +285,21 @@ def test_no_tool_call(streaming: bool, lfm2_tokenizer: TokenizerLike): None, id="multiline_string_arg_nonstreaming", ), + # Set argument decoded as a list (JSON has no set type) + pytest.param( + True, + _wrap(SET_ARG_FUNCTION_OUTPUT), + [SET_ARG_FUNCTION_CALL], + None, + id="set_arg_streaming", + ), + pytest.param( + False, + _wrap(SET_ARG_FUNCTION_OUTPUT), + [SET_ARG_FUNCTION_CALL], + None, + id="set_arg_nonstreaming", + ), # NUL byte in a string argument (ValueError from ast.parse, not # SyntaxError) pytest.param( diff --git a/tests/tool_parsers/test_utils.py b/tests/tool_parsers/test_utils.py index fe57fb99d9f8..43b09c48498f 100644 --- a/tests/tool_parsers/test_utils.py +++ b/tests/tool_parsers/test_utils.py @@ -446,6 +446,37 @@ def test_negative_delta_end_to_end(self): assert json.loads(tool.function.arguments) == {"quantity_delta": -20} +class TestGetParameterValueSet: + # JSON has no set type; a set argument is decoded as a list (preserving + # source order) instead of dropping the whole call, mirroring the tuple + # handling. + def test_set_becomes_list(self): + assert _value_of("{'a', 'b'}") == ["a", "b"] + + def test_set_of_numbers(self): + assert _value_of("{1, 2, 3}") == [1, 2, 3] + + def test_set_nested_in_dict(self): + assert _value_of("{'tags': {'x', 'y'}}") == {"tags": ["x", "y"]} + + def test_set_end_to_end(self): + call = _first_call("[label(tags={'urgent', 'bug'})]") + tool = handle_single_tool(call) + assert json.loads(tool.function.arguments) == {"tags": ["urgent", "bug"]} + + +class TestMakeValidPythonSets: + def test_complete_set_in_model_text_accepted(self): + # A set the model wrote and closed itself is a genuine argument. + text = "[label(tags={'urgent', 'bug'})]" + assert make_valid_python(text) == (text, "") + + def test_truncated_dict_completed_to_set_still_rejected(self): + # `{"k` closes to `{"k"}` — a truncated dict, not a set the model + # wrote. The completion added the `}`, so it must keep waiting. + assert make_valid_python('[f(x={"k') is None + + class TestGetParameterValueFString: # A placeholder-free f-string is a plain string constant, but ast parses # it as JoinedStr; it must not drop the call. Real placeholders are not diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index 04b99d4b28fc..9b8c718ccf10 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -471,6 +471,10 @@ def get_parameter_value(val: ast.expr) -> Any: # is treated as a list so it round-trips through ``json.dumps``. # Without this the whole call is dropped. return [get_parameter_value(v) for v in val.elts] + elif isinstance(val, ast.Set): + # JSON has no set type either; a set argument (e.g. + # ``tags={'a', 'b'}``) is treated as a list, preserving source order. + return [get_parameter_value(v) for v in val.elts] elif isinstance(val, ast.JoinedStr) and all( isinstance(part, ast.Constant) for part in val.values ): @@ -783,21 +787,23 @@ def make_valid_python(text: str) -> tuple[str, str] | None: # 1. Mid-key inside a dict (`..., "k`) closes to `..., "k"}` — a # syntactically invalid mixed dict/set. # 2. A bare string inside a dict (`{"k`) closes to `{"k"}` — valid - # Python but a *set* literal, which downstream tool-call AST - # handling rejects. - # Validate the candidate parses, has a body, and contains no Set - # nodes (pythonic tool calls always use dicts for `{...}`). Callers - # whose models emit raw control chars inside string arguments must - # escape them (see escape_ctrl_chars_in_strings) before calling. + # Python but a *set* literal that is really a truncated dict. + # Validate the candidate parses and has a body, and treat Set nodes as + # incomplete — but only when this completion added a `}` itself; a set + # already closed in the model text is a genuine set argument, not an + # artifact. Callers whose models emit raw control chars inside string + # arguments must escape them (see escape_ctrl_chars_in_strings) before + # calling. try: module = ast.parse(candidate) except SyntaxError: return None if not module.body: return None - for node in ast.walk(module): - if isinstance(node, ast.Set): - return None + if "}" in added_text: + for node in ast.walk(module): + if isinstance(node, ast.Set): + return None return candidate, added_text From 880492304827ba2ecf455bf6a9862f33dc96ec6d Mon Sep 17 00:00:00 2001 From: Zetian Li <804561096@qq.com> Date: Tue, 4 Aug 2026 10:09:03 +0000 Subject: [PATCH 12/16] [Bugfix] lfm2 tool parser: empty tool block no longer reports tools_called An empty block ([]) passed the all()-over-elts check vacuously and returned tools_called=True with zero tool calls, breaking the tools_called == bool(tool_calls) invariant the test helpers assert. Require at least one element; an empty block now falls back to content. Co-authored-by: Claude Signed-off-by: Zetian Li <804561096@qq.com> --- tests/tool_parsers/test_lfm2_tool_parser.py | 19 +++++++++++++++++++ vllm/tool_parsers/lfm2_tool_parser.py | 8 ++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/tool_parsers/test_lfm2_tool_parser.py b/tests/tool_parsers/test_lfm2_tool_parser.py index 8da216ba425f..f4d34f8f7119 100644 --- a/tests/tool_parsers/test_lfm2_tool_parser.py +++ b/tests/tool_parsers/test_lfm2_tool_parser.py @@ -344,6 +344,25 @@ def test_tool_call( assert actual.function == expected +@pytest.mark.parametrize("body", ["", " "]) +def test_empty_tool_call_block(body: str, lfm2_tokenizer: TokenizerLike): + """An empty block ([] or [ ]) means the model called no tools; it must + not report tools_called=True with zero tool calls (the invariant + run_tool_extraction asserts).""" + cls = ToolParserManager.get_tool_parser("lfm2") + model_output = f"{TOOL_CALL_START}[{body}]{TOOL_CALL_END}" + + content, tool_calls = run_tool_extraction( + cls(lfm2_tokenizer), model_output, streaming=False + ) + assert len(tool_calls) == 0 + + reconstructor = run_tool_extraction_streaming( + cls(lfm2_tokenizer), [model_output], assert_one_tool_per_delta=False + ) + assert len(reconstructor.tool_calls) == 0 + + def test_whitespace_after_start_token(lfm2_tokenizer: TokenizerLike): """Whitespace between <|tool_call_start|> and the opening bracket must not break parsing. The streaming path used to feed the indented text to diff --git a/vllm/tool_parsers/lfm2_tool_parser.py b/vllm/tool_parsers/lfm2_tool_parser.py index af748906b05b..1611c0ae0cd4 100644 --- a/vllm/tool_parsers/lfm2_tool_parser.py +++ b/vllm/tool_parsers/lfm2_tool_parser.py @@ -203,8 +203,12 @@ def extract_tool_calls( raise module = ast.parse(renamed) parsed = getattr(module.body[0], "value", None) - if isinstance(parsed, ast.List) and all( - isinstance(e, ast.Call) for e in parsed.elts + # An empty block ([]) must not report tools_called=True with zero + # calls, so require at least one element. + if ( + isinstance(parsed, ast.List) + and parsed.elts + and all(isinstance(e, ast.Call) for e in parsed.elts) ): tool_calls = [ handle_single_tool(e) # type: ignore From ac35edccc6fe2b00246b9b841dab074cb14f6f71 Mon Sep 17 00:00:00 2001 From: Zetian Li <804561096@qq.com> Date: Tue, 4 Aug 2026 10:11:47 +0000 Subject: [PATCH 13/16] [Bugfix] lfm2 tool parser: accept zero-padded integer arguments month=07 is a SyntaxError (leading zeros in decimal integer literals) that neither the escape nor the rename retry can recover, so the whole call was dropped. Add a quote-aware rewrite that strips leading zeros from decimal int literals outside string literals, leaving already-valid tokens (0x/0o/0b, floats, exponents, all-zero literals, fractional parts) untouched. Co-authored-by: Claude Signed-off-by: Zetian Li <804561096@qq.com> --- tests/tool_parsers/test_lfm2_tool_parser.py | 20 +++++++ tests/tool_parsers/test_utils.py | 44 +++++++++++++++ vllm/tool_parsers/lfm2_tool_parser.py | 22 +++++--- vllm/tool_parsers/utils.py | 62 +++++++++++++++++++++ 4 files changed, 140 insertions(+), 8 deletions(-) diff --git a/tests/tool_parsers/test_lfm2_tool_parser.py b/tests/tool_parsers/test_lfm2_tool_parser.py index f4d34f8f7119..6e9d4b8f09ee 100644 --- a/tests/tool_parsers/test_lfm2_tool_parser.py +++ b/tests/tool_parsers/test_lfm2_tool_parser.py @@ -59,6 +59,11 @@ name="label", arguments='{"tags": ["urgent", "bug"]}', ) +LEADING_ZERO_FUNCTION_OUTPUT = "set_date(month=07, day=05)" +LEADING_ZERO_FUNCTION_CALL = FunctionCall( + name="set_date", + arguments='{"month": 7, "day": 5}', +) ESCAPED_STRING_FUNCTION_OUTPUT = ( r"get_weather(city='Martha\'s Vineyard', metric='\"cool units\"')" ) @@ -300,6 +305,21 @@ def test_no_tool_call(streaming: bool, lfm2_tokenizer: TokenizerLike): None, id="set_arg_nonstreaming", ), + # Zero-padded integer arguments (SyntaxError: leading zeros) + pytest.param( + True, + _wrap(LEADING_ZERO_FUNCTION_OUTPUT), + [LEADING_ZERO_FUNCTION_CALL], + None, + id="leading_zero_int_streaming", + ), + pytest.param( + False, + _wrap(LEADING_ZERO_FUNCTION_OUTPUT), + [LEADING_ZERO_FUNCTION_CALL], + None, + id="leading_zero_int_nonstreaming", + ), # NUL byte in a string argument (ValueError from ast.parse, not # SyntaxError) pytest.param( diff --git a/tests/tool_parsers/test_utils.py b/tests/tool_parsers/test_utils.py index 43b09c48498f..4408e7dc8462 100644 --- a/tests/tool_parsers/test_utils.py +++ b/tests/tool_parsers/test_utils.py @@ -14,6 +14,7 @@ get_parameter_value, handle_single_tool, make_valid_python, + normalize_leading_zero_ints, rename_reserved_kwargs, restore_reserved_kwarg_names, ) @@ -518,6 +519,49 @@ def test_tuple_end_to_end(self): assert json.loads(tool.function.arguments) == {"size": [800, 600]} +class TestNormalizeLeadingZeroInts: + # Zero-padded ints (month=07) are a SyntaxError no other recovery path + # handles; the rewrite must strip the padding without touching tokens + # that are already valid Python. + @pytest.mark.parametrize( + "text, expected", + [ + ("[f(month=07)]", "[f(month=7)]"), + ("[f(x=007, y=05)]", "[f(x=7, y=5)]"), + ("[f(x=0_7)]", "[f(x=7)]"), + ("[f(x=-07)]", "[f(x=-7)]"), + ], + ) + def test_leading_zeros_stripped(self, text, expected): + assert normalize_leading_zero_ints(text) == expected + assert ast.parse(expected) + + @pytest.mark.parametrize( + "text", + [ + "[f(x=0)]", + "[f(x=00)]", + "[f(x=0.5)]", + "[f(x=07.5)]", + "[f(x=1.07)]", + "[f(x=1e07)]", + "[f(x=0x1F)]", + "[f(x=0o17)]", + "[f(x=0b101)]", + "[f(s='id 007')]", + '[f(s="v0.07")]', + ], + ) + def test_valid_tokens_and_strings_untouched(self, text): + assert normalize_leading_zero_ints(text) == text + + def test_end_to_end(self): + normalized = normalize_leading_zero_ints("[set_date(month=07, day=05)]") + call = ast.parse(normalized).body[0].value.elts[0] + tool = handle_single_tool(call) + assert json.loads(tool.function.arguments) == {"month": 7, "day": 5} + + class TestRenameReservedKwargs: # A parameter named after a Python keyword (`from=1`) is a SyntaxError # that no escape/retry can recover; rename_reserved_kwargs rewrites it to diff --git a/vllm/tool_parsers/lfm2_tool_parser.py b/vllm/tool_parsers/lfm2_tool_parser.py index 1611c0ae0cd4..7d037a20d496 100644 --- a/vllm/tool_parsers/lfm2_tool_parser.py +++ b/vllm/tool_parsers/lfm2_tool_parser.py @@ -28,6 +28,7 @@ escape_ctrl_chars_in_strings, handle_single_tool, make_valid_python, + normalize_leading_zero_ints, rename_reserved_kwargs, restore_reserved_kwarg_names, ) @@ -191,8 +192,11 @@ def extract_tool_calls( # A raw newline/tab inside a string argument (e.g. a multi-line # shell command) is invalid Python, and a NUL byte anywhere is # a ValueError; escape control chars inside string literals and - # retry instead of dropping the call. - escaped = escape_ctrl_chars_in_strings(tool_text) + # strip leading zeros from int literals (month=07), then retry + # instead of dropping the call. + escaped = escape_ctrl_chars_in_strings( + normalize_leading_zero_ints(tool_text) + ) try: module = ast.parse(escaped) except (SyntaxError, ValueError): @@ -314,12 +318,14 @@ def _content_only_or_none() -> DeltaMessage | None: # A raw control char inside a string argument would make every # completion candidate a SyntaxError; escape them here rather than # inside make_valid_python so the shared helper keeps its upstream - # behavior for the other pythonic parsers. A parameter named after - # a Python keyword (`from=1`) can never parse; rename complete - # `keyword=` tokens as well. Both rewrites are deterministic, so - # successive chunks stay consistent; names are restored after - # decoding. - tool_text = escape_ctrl_chars_in_strings(tool_text) + # behavior for the other pythonic parsers. Leading zeros in int + # literals (month=07) and parameters named after Python keywords + # (`from=1`) can never parse; rewrite those too. All rewrites are + # deterministic, so successive chunks stay consistent; keyword + # names are restored after decoding. + tool_text = escape_ctrl_chars_in_strings( + normalize_leading_zero_ints(tool_text) + ) renamed_tool_text, kw_renamed = rename_reserved_kwargs(tool_text) if kw_renamed: tool_text = renamed_tool_text diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index 9b8c718ccf10..ce000344581c 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -694,6 +694,68 @@ def restore_reserved_kwarg_names(arguments: dict) -> dict: return restored +def normalize_leading_zero_ints(text: str) -> str: + """Strip leading zeros from decimal integer literals so the text parses. + + Models emit zero-padded integers (``month=07``), which Python rejects + ("leading zeros in decimal integer literals are not permitted"), so the + whole call would be dropped. Rewrite ``07`` to ``7`` outside string + literals only. Tokens that are already valid Python are left alone: + all-zero literals (``00``), floats and fractional parts (``07.5``, + ``1.07``), exponents (``1e07``, consumed as a name run), and ``0x``/ + ``0o``/``0b`` prefixes (the digit run stops at the prefix letter). + """ + out: list[str] = [] + quote: str | None = None + index, length = 0, len(text) + while index < length: + char = text[index] + if quote is not None: + out.append(char) + if char == "\\" and index + 1 < length: + out.append(text[index + 1]) + index += 2 + continue + if char == quote: + quote = None + index += 1 + continue + if char in {"'", '"'}: + quote = char + out.append(char) + index += 1 + continue + if char.isalpha() or char == "_": + end = index + while end < length and (text[end].isalnum() or text[end] == "_"): + end += 1 + out.append(text[index:end]) + index = end + continue + if char.isdigit(): + end = index + while end < length and (text[end].isdigit() or text[end] == "_"): + end += 1 + token = text[index:end] + digits = token.replace("_", "") + follower = text[end] if end < length else "" + preceded_by_dot = index > 0 and text[index - 1] == "." + if ( + digits[0] == "0" + and digits.strip("0") + and not preceded_by_dot + and follower not in {".", "e", "E", "j", "J"} + ): + out.append(str(int(digits))) + else: + out.append(token) + index = end + continue + out.append(char) + index += 1 + return "".join(out) + + def _is_escaped(text: str, index: int) -> bool: """Whether the character at ``index`` is backslash-escaped. From 9b47e839cb22541874ec689abcf4dec760700730 Mon Sep 17 00:00:00 2001 From: Zetian Li <804561096@qq.com> Date: Tue, 4 Aug 2026 10:13:41 +0000 Subject: [PATCH 14/16] [Bugfix] lfm2 tool parser: reject non-JSON constants explicitly bytes/Ellipsis/complex are ast.Constant nodes, so they passed get_parameter_value and only failed later as a TypeError inside json.dumps. Restrict the Constant branch to JSON-representable types and raise UnexpectedAstError with a warning like the other unsupported nodes. Co-authored-by: Claude Signed-off-by: Zetian Li <804561096@qq.com> --- tests/tool_parsers/test_utils.py | 25 +++++++++++++++++++++++-- vllm/tool_parsers/utils.py | 11 ++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/tests/tool_parsers/test_utils.py b/tests/tool_parsers/test_utils.py index 4408e7dc8462..0a840eec347a 100644 --- a/tests/tool_parsers/test_utils.py +++ b/tests/tool_parsers/test_utils.py @@ -519,6 +519,28 @@ def test_tuple_end_to_end(self): assert json.loads(tool.function.arguments) == {"size": [800, 600]} +class TestGetParameterValueNonJsonConstants: + # bytes/Ellipsis/complex are ast.Constant but have no JSON form; they + # must raise UnexpectedAstError (like other unsupported nodes) instead + # of surfacing later as a TypeError inside json.dumps. + @pytest.mark.parametrize("expr", ["b'abc'", "...", "1j"]) + def test_non_json_constant_raises(self, expr): + with pytest.raises(UnexpectedAstError): + _value_of(expr) + + def test_handle_single_tool_raises_ast_error_not_type_error(self): + call = _first_call("[f(x=b'abc')]") + with pytest.raises(UnexpectedAstError): + handle_single_tool(call) + + @pytest.mark.parametrize( + "expr, expected", + [("'s'", "s"), ("1", 1), ("1.5", 1.5), ("True", True), ("None", None)], + ) + def test_json_constants_still_pass(self, expr, expected): + assert _value_of(expr) == expected + + class TestNormalizeLeadingZeroInts: # Zero-padded ints (month=07) are a SyntaxError no other recovery path # handles; the rewrite must strip the padding without touching tokens @@ -583,8 +605,7 @@ def test_multiple_reserved_kwargs(self): text, changed = rename_reserved_kwargs('[search(in="docs/", from=0)]') assert changed args = json.loads( - handle_single_tool(ast.parse(text).body[0].value.elts[0]) - .function.arguments + handle_single_tool(ast.parse(text).body[0].value.elts[0]).function.arguments ) assert restore_reserved_kwarg_names(args) == {"in": "docs/", "from": 0} diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index ce000344581c..2bdc698f1fac 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -452,7 +452,16 @@ def get_parameter_value(val: ast.expr) -> Any: UnexpectedAstError: If the AST node is not a supported literal type. """ if isinstance(val, ast.Constant): - return val.value + if val.value is None or isinstance(val.value, (str, int, float)): + return val.value + # bytes/Ellipsis/complex constants have no JSON representation and + # would otherwise surface as a TypeError deep inside json.dumps; + # reject them explicitly like other unsupported nodes. + logger.warning( + "Non-JSON-representable constant in tool call arguments: %s", + ast.dump(val), + ) + raise UnexpectedAstError("Tool call arguments must be JSON values") elif isinstance(val, ast.Dict): if not all(isinstance(k, ast.Constant) for k in val.keys): logger.warning( From 2ef61e99f8f0df3c328c8c56a81794555be55a7f Mon Sep 17 00:00:00 2001 From: Zetian Li <804561096@qq.com> Date: Tue, 4 Aug 2026 10:53:48 +0000 Subject: [PATCH 15/16] [Bugfix] lfm2 tool parser: type-narrow ast helpers in parser tests mypy (pinned 1.20.2, repo config) flagged the ast.parse(...).body[0].value chains as attr-defined errors on ast.stmt. Replace them with shared _first_call/_bare_call/_kwarg_constant helpers that narrow via isinstance, deduplicating the inline chains. Test behavior unchanged. Co-authored-by: Claude Signed-off-by: Zetian Li <804561096@qq.com> --- tests/tool_parsers/test_utils.py | 63 +++++++++++++++++++------------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/tests/tool_parsers/test_utils.py b/tests/tool_parsers/test_utils.py index 0a840eec347a..71eb2d2aaac4 100644 --- a/tests/tool_parsers/test_utils.py +++ b/tests/tool_parsers/test_utils.py @@ -290,6 +290,36 @@ def test_nested_anyof(self): assert result == {"integer", "null", "string"} +def _value_of(expr: str): + """Parse a single Python expression and run get_parameter_value on it.""" + return get_parameter_value(ast.parse(expr, mode="eval").body) + + +def _first_call(text: str) -> ast.Call: + """Parse ``[foo(...)]`` and return the single ast.Call node.""" + statement = ast.parse(text).body[0] + assert isinstance(statement, ast.Expr) + assert isinstance(statement.value, ast.List) + call = statement.value.elts[0] + assert isinstance(call, ast.Call) + return call + + +def _bare_call(text: str) -> ast.Call: + """Parse ``foo(...)`` (no list wrapper) and return the ast.Call node.""" + statement = ast.parse(text).body[0] + assert isinstance(statement, ast.Expr) + assert isinstance(statement.value, ast.Call) + return statement.value + + +def _kwarg_constant(call: ast.Call, index: int = 0): + """Return the constant value of the call's ``index``-th keyword arg.""" + value = call.keywords[index].value + assert isinstance(value, ast.Constant) + return value.value + + class TestMakeValidPythonStringLiterals: def test_bracket_inside_string_is_literal(self): # A bracket inside a string argument must not be counted as a @@ -328,9 +358,7 @@ def test_multiline_string_argument_recovers_after_escape(self): assert result is not None completed, added = result assert added == "" - module = ast.parse(completed) - call = module.body[0].value.elts[0] - assert call.keywords[0].value.value == "line1\nline2" + assert _kwarg_constant(_first_call(completed)) == "line1\nline2" def test_value_ending_in_backslash_recovers(self): # A string value ending in a literal backslash: the closing quote follows @@ -346,8 +374,7 @@ def test_escaped_quote_odd_backslashes_stays_open(self): # only the final unescaped quote does. Value round-trips to it's fine. text = "[say(msg='it\\'s fine')]" assert make_valid_python(text) == (text, "") - module = ast.parse(text) - assert module.body[0].value.elts[0].keywords[0].value.value == "it's fine" + assert _kwarg_constant(_first_call(text)) == "it's fine" class TestEscapeCtrlCharsInStrings: @@ -368,8 +395,7 @@ def test_value_preserved_through_ast(self): # The escaped text parses and evaluates back to the original value. raw = "cat > f.py << EOF\nimport csv\nEOF\techo done" escaped = escape_ctrl_chars_in_strings(f"f(cmd='{raw}')") - call = ast.parse(escaped).body[0].value - assert call.keywords[0].value.value == raw + assert _kwarg_constant(_bare_call(escaped)) == raw def test_nul_byte_inside_string_escaped(self): # ast.parse raises ValueError (not SyntaxError) on NUL anywhere in @@ -377,24 +403,13 @@ def test_nul_byte_inside_string_escaped(self): raw = "printf a\x00b" escaped = escape_ctrl_chars_in_strings(f"f(cmd='{raw}')") assert "\x00" not in escaped - call = ast.parse(escaped).body[0].value - assert call.keywords[0].value.value == raw + assert _kwarg_constant(_bare_call(escaped)) == raw def test_nul_byte_outside_strings_untouched(self): text = "f(a=1,\x00b=2)" assert escape_ctrl_chars_in_strings(text) == text -def _value_of(expr: str): - """Parse a single Python expression and run get_parameter_value on it.""" - return get_parameter_value(ast.parse(expr, mode="eval").body) - - -def _first_call(text: str) -> ast.Call: - """Parse ``[foo(...)]`` and return the single ast.Call node.""" - return ast.parse(text).body[0].value.elts[0] - - class TestGetParameterValueNegativeNumbers: # A negative number is parsed by Python as UnaryOp(USub, Constant(n)), not # a plain Constant. Without explicit handling the entire tool call is @@ -579,8 +594,7 @@ def test_valid_tokens_and_strings_untouched(self, text): def test_end_to_end(self): normalized = normalize_leading_zero_ints("[set_date(month=07, day=05)]") - call = ast.parse(normalized).body[0].value.elts[0] - tool = handle_single_tool(call) + tool = handle_single_tool(_first_call(normalized)) assert json.loads(tool.function.arguments) == {"month": 7, "day": 5} @@ -596,17 +610,14 @@ def test_reserved_kwarg_renamed(self): def test_round_trip_restores_original_name(self): renamed, _ = rename_reserved_kwargs("[memory_get(path='M.md', from=1)]") - call = ast.parse(renamed).body[0].value.elts[0] - tool = handle_single_tool(call) + tool = handle_single_tool(_first_call(renamed)) restored = restore_reserved_kwarg_names(json.loads(tool.function.arguments)) assert restored == {"path": "M.md", "from": 1} def test_multiple_reserved_kwargs(self): text, changed = rename_reserved_kwargs('[search(in="docs/", from=0)]') assert changed - args = json.loads( - handle_single_tool(ast.parse(text).body[0].value.elts[0]).function.arguments - ) + args = json.loads(handle_single_tool(_first_call(text)).function.arguments) assert restore_reserved_kwarg_names(args) == {"in": "docs/", "from": 0} def test_keyword_inside_string_untouched(self): From b1c3fadd69431008eba0f6132b028a04e20c5ec2 Mon Sep 17 00:00:00 2001 From: Zetian Li <804561096@qq.com> Date: Tue, 4 Aug 2026 23:43:54 +0000 Subject: [PATCH 16/16] [Bugfix] lfm2 tool parser: recover strings with unambiguous nested quotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell commands nest unescaped same-style quotes inside string arguments — command='sed -n '360,450p' f.py', or a quoted python3 -c payload whose code contains its own quoted strings. Python reads these as juxtaposed garbage, so the call was dropped (non-streaming) or streamed as truncated or corrupted arguments — high-frequency patterns in SWE-agent traces. A string is treated as broken when its first unescaped quote cannot syntactically close it. For a broken string, every syntactically plausible closing quote is tried (interior quotes escaped, rest of the text verbatim) and validated with ast.parse; exactly one parsing candidate means recovery with the exact value the model wrote, anything else is left unchanged rather than guessed at. Applied as a last-resort rewrite after the existing recoveries, only to text that failed to parse. Streaming additionally withholds tool deltas while the partial text contains a broken string (contains_broken_string_literal): any completion-based parse of such text is an implicit-concatenation misreading whose streamed prefix could never be retracted. The recovery then runs once the end sentinel arrives, on final text. Co-authored-by: Claude Signed-off-by: Zetian Li <804561096@qq.com> --- tests/tool_parsers/test_lfm2_tool_parser.py | 48 ++++++++ tests/tool_parsers/test_utils.py | 108 +++++++++++++++++ vllm/tool_parsers/lfm2_tool_parser.py | 59 ++++++--- vllm/tool_parsers/utils.py | 125 ++++++++++++++++++++ 4 files changed, 326 insertions(+), 14 deletions(-) diff --git a/tests/tool_parsers/test_lfm2_tool_parser.py b/tests/tool_parsers/test_lfm2_tool_parser.py index 6e9d4b8f09ee..0cb865fef5bc 100644 --- a/tests/tool_parsers/test_lfm2_tool_parser.py +++ b/tests/tool_parsers/test_lfm2_tool_parser.py @@ -64,6 +64,24 @@ name="set_date", arguments='{"month": 7, "day": 5}', ) +NESTED_QUOTE_FUNCTION_OUTPUT = ( + "bash(command='sed -n '360,450p' /testbed/sympy/matrices/common.py')" +) +NESTED_QUOTE_FUNCTION_CALL = FunctionCall( + name="bash", + arguments=('{"command": "sed -n \'360,450p\' /testbed/sympy/matrices/common.py"}'), +) +NESTED_PYTHON_C_FUNCTION_OUTPUT = ( + "bash(command='cd /testbed && python3 -c \"from sympy import latex\n" + "print(latex(3*x, mul_symbol='\\,'))\"')" +) +NESTED_PYTHON_C_FUNCTION_CALL = FunctionCall( + name="bash", + arguments=( + '{"command": "cd /testbed && python3 -c \\"from sympy import latex\\n' + "print(latex(3*x, mul_symbol='\\\\,'))\\\"\"}" + ), +) ESCAPED_STRING_FUNCTION_OUTPUT = ( r"get_weather(city='Martha\'s Vineyard', metric='\"cool units\"')" ) @@ -305,6 +323,36 @@ def test_no_tool_call(streaming: bool, lfm2_tokenizer: TokenizerLike): None, id="set_arg_nonstreaming", ), + # Unescaped same-style quotes nested in a shell command argument + pytest.param( + True, + _wrap(NESTED_QUOTE_FUNCTION_OUTPUT), + [NESTED_QUOTE_FUNCTION_CALL], + None, + id="nested_quote_streaming", + ), + pytest.param( + False, + _wrap(NESTED_QUOTE_FUNCTION_OUTPUT), + [NESTED_QUOTE_FUNCTION_CALL], + None, + id="nested_quote_nonstreaming", + ), + # Doubly nested: multi-line python -c payload with its own inner quotes + pytest.param( + True, + _wrap(NESTED_PYTHON_C_FUNCTION_OUTPUT), + [NESTED_PYTHON_C_FUNCTION_CALL], + None, + id="nested_python_c_streaming", + ), + pytest.param( + False, + _wrap(NESTED_PYTHON_C_FUNCTION_OUTPUT), + [NESTED_PYTHON_C_FUNCTION_CALL], + None, + id="nested_python_c_nonstreaming", + ), # Zero-padded integer arguments (SyntaxError: leading zeros) pytest.param( True, diff --git a/tests/tool_parsers/test_utils.py b/tests/tool_parsers/test_utils.py index 71eb2d2aaac4..f8890c2d350e 100644 --- a/tests/tool_parsers/test_utils.py +++ b/tests/tool_parsers/test_utils.py @@ -9,7 +9,9 @@ from vllm.tool_parsers.utils import ( UnexpectedAstError, coerce_to_schema_type, + contains_broken_string_literal, escape_ctrl_chars_in_strings, + escape_nested_quotes_in_strings, extract_types_from_schema, get_parameter_value, handle_single_tool, @@ -534,6 +536,112 @@ def test_tuple_end_to_end(self): assert json.loads(tool.function.arguments) == {"size": [800, 600]} +class TestEscapeNestedQuotesInStrings: + # Unescaped same-style quotes nested in a string argument (shell + # commands like sed -n '1,9p') are Python juxtaposition errors. When + # exactly one quote can syntactically close the string, close there and + # escape the interior quotes; anything ambiguous is left unchanged. + def test_sed_command_recovers_exact_value(self): + text = "[bash(command='sed -n '360,450p' /testbed/common.py')]" + rewritten, changed = escape_nested_quotes_in_strings(text) + assert changed + assert _kwarg_constant(_first_call(rewritten)) == ( + "sed -n '360,450p' /testbed/common.py" + ) + + def test_double_quote_variant(self): + text = '[bash(command="grep "foo" log.txt")]' + rewritten, changed = escape_nested_quotes_in_strings(text) + assert changed + assert _kwarg_constant(_first_call(rewritten)) == 'grep "foo" log.txt' + + def test_mixed_with_already_escaped_quote(self): + text = "[bash(command='it\\'s 'x' end')]" + rewritten, changed = escape_nested_quotes_in_strings(text) + assert changed + assert _kwarg_constant(_first_call(rewritten)) == "it's 'x' end" + + def test_doubly_nested_python_c_payload(self): + # command='python3 -c "...latex(x, mul_symbol='\,')..."' — two false + # closers both followed by ')', but only the real one yields text + # that parses; ast-validation disambiguates. + text = "[bash(command='python3 -c \"print(latex(x, mul_symbol='\\,'))\"')]" + rewritten, changed = escape_nested_quotes_in_strings(text) + assert changed + assert _kwarg_constant(_first_call(rewritten)) == ( + "python3 -c \"print(latex(x, mul_symbol='\\,'))\"" + ) + + def test_multiline_python_c_with_inner_string(self): + # Raw newlines beyond the phantom close plus a single-quoted inner + # string; recovery must survive both (caller re-escapes ctrl chars). + inner = "python3 -c \"\ntest_str = 'is fine'\nprint(test_str)\n\"" + text = f"[bash(command='{inner}')]" + rewritten, changed = escape_nested_quotes_in_strings(text) + assert changed + recovered = escape_ctrl_chars_in_strings(rewritten) + assert _kwarg_constant(_first_call(recovered)) == inner + + def test_nested_quotes_before_non_string_argument(self): + # A later NON-string argument adds no candidate closer, so the + # nested string is still unambiguous. + text = "[run(cmd='awk '{print}' f', count=2)]" + rewritten, changed = escape_nested_quotes_in_strings(text) + assert changed + call = _first_call(rewritten) + assert _kwarg_constant(call, 0) == "awk '{print}' f" + + @pytest.mark.parametrize( + "text", + [ + "[f(a='x')]", + "[f(a='x', b='y')]", + "[f(a={'k': 'v'})]", + "[f(a='it\\'s fine')]", + "[a(x='p'), b(y='q')]", + ], + ) + def test_valid_text_unchanged(self, text): + rewritten, changed = escape_nested_quotes_in_strings(text) + assert not changed + assert rewritten == text + + @pytest.mark.parametrize( + "text", + [ + "[f(a='echo 'hi', b='x')]", + "[run(cmd='awk '{print}' f', mode='fast')]", + ], + ) + def test_ambiguous_nesting_left_unchanged(self, text): + # A later string argument's closing quote is itself a plausible + # closer, making the nesting formally ambiguous; no rewrite is + # attempted rather than guessing. + rewritten, changed = escape_nested_quotes_in_strings(text) + assert not changed + assert rewritten == text + + +class TestContainsBrokenStringLiteral: + @pytest.mark.parametrize( + "text, broken", + [ + ("[bash(command='sed -n '360,450p' /x')]", True), + ("[f(a='echo 'hi', b='x')]", True), + # Closing quote at the very end of partial text: the follower + # is unknown until the next chunk arrives, so hold. + ("[f(a='x'", True), + ("[f(a='x', b='y')]", False), + # Mid-string (no closing quote yet) is normal streaming. + ('[f(a=\'grep -F "]" log', False), + ("[f(a={'k': 'v'})]", False), + ("[f(a='it\\'s fine')]", False), + ], + ) + def test_detection(self, text, broken): + assert contains_broken_string_literal(text) is broken + + class TestGetParameterValueNonJsonConstants: # bytes/Ellipsis/complex are ast.Constant but have no JSON form; they # must raise UnexpectedAstError (like other unsupported nodes) instead diff --git a/vllm/tool_parsers/lfm2_tool_parser.py b/vllm/tool_parsers/lfm2_tool_parser.py index 7d037a20d496..3e4c15d9942b 100644 --- a/vllm/tool_parsers/lfm2_tool_parser.py +++ b/vllm/tool_parsers/lfm2_tool_parser.py @@ -25,7 +25,9 @@ from vllm.tool_parsers.utils import ( UnexpectedAstError, compute_tool_delta, + contains_broken_string_literal, escape_ctrl_chars_in_strings, + escape_nested_quotes_in_strings, handle_single_tool, make_valid_python, normalize_leading_zero_ints, @@ -189,23 +191,32 @@ def extract_tool_calls( try: module = ast.parse(tool_text) except (SyntaxError, ValueError): - # A raw newline/tab inside a string argument (e.g. a multi-line - # shell command) is invalid Python, and a NUL byte anywhere is - # a ValueError; escape control chars inside string literals and - # strip leading zeros from int literals (month=07), then retry - # instead of dropping the call. + # Progressive rewrites, each a no-op on already-valid text: + # escape raw control chars / NUL bytes inside string literals, + # strip leading zeros from int literals (month=07), close + # unambiguous nested quotes (command='sed -n '1,9p' f.py'), + # and rename reserved-keyword parameters (from=1; restored + # below). The first rewrite whose result parses wins. escaped = escape_ctrl_chars_in_strings( normalize_leading_zero_ints(tool_text) ) - try: - module = ast.parse(escaped) - except (SyntaxError, ValueError): - # A parameter named after a Python keyword (`from=1`) is - # also a SyntaxError; rename it, parse, restore below. - renamed, kw_renamed = rename_reserved_kwargs(escaped) - if not kw_renamed: - raise - module = ast.parse(renamed) + candidates = [escaped] + requoted, requote_changed = escape_nested_quotes_in_strings(escaped) + if requote_changed: + # Requoting can move raw control chars (newlines beyond + # the phantom close) inside the string; escape again. + candidates.append(escape_ctrl_chars_in_strings(requoted)) + renamed, kw_renamed = rename_reserved_kwargs(candidates[-1]) + if kw_renamed: + candidates.append(renamed) + for candidate in candidates: + try: + module = ast.parse(candidate) + break + except (SyntaxError, ValueError): + continue + else: + raise parsed = getattr(module.body[0], "value", None) # An empty block ([]) must not report tools_called=True with zero # calls, so require at least one element. @@ -326,10 +337,30 @@ def _content_only_or_none() -> DeltaMessage | None: tool_text = escape_ctrl_chars_in_strings( normalize_leading_zero_ints(tool_text) ) + if has_end_in_current: + # Nested-quote recovery needs the final text (which quote + # closes a partial string is not stable across chunks) and + # must never touch text that already parses. + try: + ast.parse(tool_text) + except (SyntaxError, ValueError): + requoted_text, requote_changed = escape_nested_quotes_in_strings( + tool_text + ) + if requote_changed: + tool_text = escape_ctrl_chars_in_strings(requoted_text) renamed_tool_text, kw_renamed = rename_reserved_kwargs(tool_text) if kw_renamed: tool_text = renamed_tool_text + # A broken string (its first closing quote cannot close it — + # nested-quote text mid-arrival) makes every completion-based + # partial parse an implicit-concatenation misreading whose + # streamed prefix could never be retracted. Withhold deltas + # until the requote recovery above has produced sane text. + if contains_broken_string_literal(tool_text): + return _content_only_or_none() + valid_and_added_text = make_valid_python(tool_text) if valid_and_added_text is None: return _content_only_or_none() diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index 2bdc698f1fac..98ea9cc19549 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -765,6 +765,131 @@ def normalize_leading_zero_ints(text: str) -> str: return "".join(out) +_QUOTE_FOLLOWERS = {",", ")", "]", "}", ":"} + + +def escape_nested_quotes_in_strings(text: str) -> tuple[str, bool]: + """Close a broken string literal at the only closing quote that works. + + Models emitting shell commands frequently nest unescaped same-style + quotes inside a string argument — ``command='sed -n '360,450p' f.py'``, + or a quoted ``python3 -c`` payload that itself contains quoted strings — + which Python reads as juxtaposed garbage, so the call is dropped even + though the intent is unambiguous. A string is treated as broken when + its first unescaped quote cannot syntactically close it (what follows + is none of ``,``, ``)``, ``]``, ``}``, ``:``). For a broken string, + every syntactically plausible closing quote is tried: interior quotes + escaped, the rest of the text kept verbatim, and the result (with + control chars escaped) validated with ``ast.parse``. Exactly one + candidate parsing means recovery — the decoded value is exactly the + text the model wrote. Zero or several parsing candidates means the + nesting is genuinely ambiguous and the text is returned unchanged + rather than guessed at. + + Returns (rewritten_text, changed); run the result through + escape_ctrl_chars_in_strings before parsing — quotes chosen here can + move raw control chars inside the string. + """ + + def unescaped_quotes(start: int, quote: str) -> list[int]: + positions = [] + j = start + while j < len(text): + if text[j] == "\\": + j += 2 + continue + if text[j] == quote: + positions.append(j) + j += 1 + return positions + + def is_closer(pos: int) -> bool: + k = pos + 1 + while k < len(text) and text[k].isspace(): + k += 1 + return k < len(text) and text[k] in _QUOTE_FOLLOWERS + + prefix: list[str] = [] + index = 0 + while index < len(text): + char = text[index] + if char not in {"'", '"'}: + prefix.append(char) + index += 1 + continue + quotes = unescaped_quotes(index + 1, char) + if not quotes: + return text, False + if is_closer(quotes[0]): + # The normal reading closes this string; move past it. + prefix.append(text[index : quotes[0] + 1]) + index = quotes[0] + 1 + continue + winners = [] + for close in (j for j in quotes if is_closer(j)): + interior: list[str] = [] + for j in range(index + 1, close): + if text[j] == char and not _is_escaped(text, j): + interior.append("\\") + interior.append(text[j]) + candidate = "".join( + ["".join(prefix), char, "".join(interior), char, text[close + 1 :]] + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", SyntaxWarning) + try: + ast.parse(escape_ctrl_chars_in_strings(candidate)) + except (SyntaxError, ValueError): + continue + winners.append(candidate) + if len(winners) == 1: + return winners[0], True + return text, False + return text, False + + +def contains_broken_string_literal(text: str) -> bool: + """Whether some string literal's first closing quote cannot close it. + + Streaming guard companion to escape_nested_quotes_in_strings: + completion-based partial parses of nested-quote text read the string as + implicit concatenation and stream argument prefixes that can never be + retracted. Callers should withhold tool deltas while this returns True + and let the requote recovery run on the final text instead. A string + whose closing quote has not arrived yet is NOT broken (normal + streaming); a closing quote at the very end of the text counts as + broken only because its follower is still unknown. + """ + index = 0 + while index < len(text): + char = text[index] + if char == "\\": + index += 2 + continue + if char not in {"'", '"'}: + index += 1 + continue + j = index + 1 + close = -1 + while j < len(text): + if text[j] == "\\": + j += 2 + continue + if text[j] == char: + close = j + break + j += 1 + if close == -1: + return False + k = close + 1 + while k < len(text) and text[k].isspace(): + k += 1 + if k >= len(text) or text[k] not in _QUOTE_FOLLOWERS: + return True + index = close + 1 + return False + + def _is_escaped(text: str, index: int) -> bool: """Whether the character at ``index`` is backslash-escaped.