[Bugfix] Fix lfm2 tool parser dropping calls with brackets or newline… (#48171)

Signed-off-by: Zetian Li <804561096@qq.com> Co-authored-by: Claude (Anthropic) <noreply@anthropic.com>

Zetian Li - ikun committed Aug 10, 2026 at 00:10 UTC 7303c66f68ea87573cf29d9a678748a73c8e8c6d
4 files changed +1186 -24
tests/tool_parsers/test_lfm2_tool_parser.py
+198
@@ -54,6 +54,34 @@ EMPTY_LIST_FUNCTION_CALL = FunctionCall(
54 name="do_something_cool",
55 arguments='{"steps": []}',
56 )
57 +SET_ARG_FUNCTION_OUTPUT = "label(tags={'urgent', 'bug'})"
58 +SET_ARG_FUNCTION_CALL = FunctionCall(
59 + name="label",
60 + arguments='{"tags": ["urgent", "bug"]}',
61 +)
62 +LEADING_ZERO_FUNCTION_OUTPUT = "set_date(month=07, day=05)"
63 +LEADING_ZERO_FUNCTION_CALL = FunctionCall(
64 + name="set_date",
65 + arguments='{"month": 7, "day": 5}',
66 +)
67 +NESTED_QUOTE_FUNCTION_OUTPUT = (
68 + "bash(command='sed -n '360,450p' /testbed/sympy/matrices/common.py')"
69 +)
70 +NESTED_QUOTE_FUNCTION_CALL = FunctionCall(
71 + name="bash",
72 + arguments=('{"command": "sed -n \'360,450p\' /testbed/sympy/matrices/common.py"}'),
73 +)
74 +NESTED_PYTHON_C_FUNCTION_OUTPUT = (
75 + "bash(command='cd /testbed && python3 -c \"from sympy import latex\n"
76 + "print(latex(3*x, mul_symbol='\\,'))\"')"
77 +)
78 +NESTED_PYTHON_C_FUNCTION_CALL = FunctionCall(
79 + name="bash",
80 + arguments=(
81 + '{"command": "cd /testbed && python3 -c \\"from sympy import latex\\n'
82 + "print(latex(3*x, mul_symbol='\\\\,'))\\\"\"}"
83 + ),
84 +)
85 ESCAPED_STRING_FUNCTION_OUTPUT = (
86 r"get_weather(city='Martha\'s Vineyard', metric='\"cool units\"')"
87 )
@@ -74,6 +102,29 @@ DOTTED_NAME_FUNCTION_CALL = FunctionCall(
102 '"deliveryAddress": "845 Willow Lane, Springfield, IL 62704"}'
103 ),
104 )
105 +# Agentic shell commands: a bracket inside a string argument must not corrupt
106 +# the streaming bracket tracker.
107 +BRACKET_IN_STRING_FUNCTION_OUTPUT = "exec(command='grep -F \"]\" log.txt')"
108 +BRACKET_IN_STRING_FUNCTION_CALL = FunctionCall(
109 + name="exec",
110 + arguments='{"command": "grep -F \\"]\\" log.txt"}',
111 +)
112 +# A raw newline inside a string argument (multi-line shell command) is invalid
113 +# Python; the parser must recover the full value instead of dropping the call.
114 +MULTILINE_FUNCTION_OUTPUT = (
115 + "exec(command='cat > f.py << EOF\nimport csv\nprint(1)\nEOF')"
116 +)
117 +MULTILINE_FUNCTION_CALL = FunctionCall(
118 + name="exec",
119 + arguments='{"command": "cat > f.py << EOF\\nimport csv\\nprint(1)\\nEOF"}',
120 +)
121 +# A NUL byte inside a string argument makes ast.parse raise ValueError (not
122 +# SyntaxError) for the whole source; the escape path must recover the call.
123 +NUL_BYTE_FUNCTION_OUTPUT = "exec(command='printf a\x00b')"
124 +NUL_BYTE_FUNCTION_CALL = FunctionCall(
125 + name="exec",
126 + arguments='{"command": "printf a\\u0000b"}',
127 +)
128
129
130 @pytest.fixture(scope="module")
@@ -227,6 +278,112 @@ TEST_CASES = [
278 None,
279 id="dotted_name_nonstreaming",
280 ),
281 + # Messy agentic shell commands: literal bracket inside a string argument
282 + pytest.param(
283 + True,
284 + _wrap(BRACKET_IN_STRING_FUNCTION_OUTPUT),
285 + [BRACKET_IN_STRING_FUNCTION_CALL],
286 + None,
287 + id="bracket_in_string_streaming",
288 + ),
289 + pytest.param(
290 + False,
291 + _wrap(BRACKET_IN_STRING_FUNCTION_OUTPUT),
292 + [BRACKET_IN_STRING_FUNCTION_CALL],
293 + None,
294 + id="bracket_in_string_nonstreaming",
295 + ),
296 + # Multi-line string argument (raw newlines in a shell command)
297 + pytest.param(
298 + True,
299 + _wrap(MULTILINE_FUNCTION_OUTPUT),
300 + [MULTILINE_FUNCTION_CALL],
301 + None,
302 + id="multiline_string_arg_streaming",
303 + ),
304 + pytest.param(
305 + False,
306 + _wrap(MULTILINE_FUNCTION_OUTPUT),
307 + [MULTILINE_FUNCTION_CALL],
308 + None,
309 + id="multiline_string_arg_nonstreaming",
310 + ),
311 + # Set argument decoded as a list (JSON has no set type)
312 + pytest.param(
313 + True,
314 + _wrap(SET_ARG_FUNCTION_OUTPUT),
315 + [SET_ARG_FUNCTION_CALL],
316 + None,
317 + id="set_arg_streaming",
318 + ),
319 + pytest.param(
320 + False,
321 + _wrap(SET_ARG_FUNCTION_OUTPUT),
322 + [SET_ARG_FUNCTION_CALL],
323 + None,
324 + id="set_arg_nonstreaming",
325 + ),
326 + # Unescaped same-style quotes nested in a shell command argument
327 + pytest.param(
328 + True,
329 + _wrap(NESTED_QUOTE_FUNCTION_OUTPUT),
330 + [NESTED_QUOTE_FUNCTION_CALL],
331 + None,
332 + id="nested_quote_streaming",
333 + ),
334 + pytest.param(
335 + False,
336 + _wrap(NESTED_QUOTE_FUNCTION_OUTPUT),
337 + [NESTED_QUOTE_FUNCTION_CALL],
338 + None,
339 + id="nested_quote_nonstreaming",
340 + ),
341 + # Doubly nested: multi-line python -c payload with its own inner quotes
342 + pytest.param(
343 + True,
344 + _wrap(NESTED_PYTHON_C_FUNCTION_OUTPUT),
345 + [NESTED_PYTHON_C_FUNCTION_CALL],
346 + None,
347 + id="nested_python_c_streaming",
348 + ),
349 + pytest.param(
350 + False,
351 + _wrap(NESTED_PYTHON_C_FUNCTION_OUTPUT),
352 + [NESTED_PYTHON_C_FUNCTION_CALL],
353 + None,
354 + id="nested_python_c_nonstreaming",
355 + ),
356 + # Zero-padded integer arguments (SyntaxError: leading zeros)
357 + pytest.param(
358 + True,
359 + _wrap(LEADING_ZERO_FUNCTION_OUTPUT),
360 + [LEADING_ZERO_FUNCTION_CALL],
361 + None,
362 + id="leading_zero_int_streaming",
363 + ),
364 + pytest.param(
365 + False,
366 + _wrap(LEADING_ZERO_FUNCTION_OUTPUT),
367 + [LEADING_ZERO_FUNCTION_CALL],
368 + None,
369 + id="leading_zero_int_nonstreaming",
370 + ),
371 + # NUL byte in a string argument (ValueError from ast.parse, not
372 + # SyntaxError)
373 + pytest.param(
374 + True,
375 + _wrap(NUL_BYTE_FUNCTION_OUTPUT),
376 + [NUL_BYTE_FUNCTION_CALL],
377 + None,
378 + id="nul_byte_string_arg_streaming",
379 + ),
380 + pytest.param(
381 + False,
382 + _wrap(NUL_BYTE_FUNCTION_OUTPUT),
383 + [NUL_BYTE_FUNCTION_CALL],
384 + None,
385 + id="nul_byte_string_arg_nonstreaming",
386 + ),
387 ]
388
389
@@ -255,6 +412,47 @@ def test_tool_call(
412 assert actual.function == expected
413
414
415 +@pytest.mark.parametrize("body", ["", " "])
416 +def test_empty_tool_call_block(body: str, lfm2_tokenizer: TokenizerLike):
417 + """An empty block ([] or [ ]) means the model called no tools; it must
418 + not report tools_called=True with zero tool calls (the invariant
419 + run_tool_extraction asserts)."""
420 + cls = ToolParserManager.get_tool_parser("lfm2")
421 + model_output = f"{TOOL_CALL_START}[{body}]{TOOL_CALL_END}"
422 +
423 + content, tool_calls = run_tool_extraction(
424 + cls(lfm2_tokenizer), model_output, streaming=False
425 + )
426 + assert len(tool_calls) == 0
427 +
428 + reconstructor = run_tool_extraction_streaming(
429 + cls(lfm2_tokenizer), [model_output], assert_one_tool_per_delta=False
430 + )
431 + assert len(reconstructor.tool_calls) == 0
432 +
433 +
434 +def test_whitespace_after_start_token(lfm2_tokenizer: TokenizerLike):
435 + """Whitespace between <|tool_call_start|> and the opening bracket must
436 + not break parsing. The streaming path used to feed the indented text to
437 + ast.parse verbatim, so every completion candidate raised
438 + IndentationError and the call was silently dropped (non-streaming
439 + stripped it and succeeded)."""
440 + cls = ToolParserManager.get_tool_parser("lfm2")
441 + model_output = f"{TOOL_CALL_START} [{SIMPLE_FUNCTION_OUTPUT}]{TOOL_CALL_END}"
442 +
443 + content, tool_calls = run_tool_extraction(
444 + cls(lfm2_tokenizer), model_output, streaming=False
445 + )
446 + assert len(tool_calls) == 1
447 + assert tool_calls[0].function == SIMPLE_FUNCTION_CALL
448 +
449 + reconstructor = run_tool_extraction_streaming(
450 + cls(lfm2_tokenizer), [model_output], assert_one_tool_per_delta=False
451 + )
452 + assert len(reconstructor.tool_calls) == 1
453 + assert reconstructor.tool_calls[0].function == SIMPLE_FUNCTION_CALL
454 +
455 +
456 def test_streaming_tool_call_with_large_steps(lfm2_tokenizer: TokenizerLike):
457 tool_parser: ToolParser = ToolParserManager.get_tool_parser("lfm2")(lfm2_tokenizer)
458 model_output_deltas = [
tests/tool_parsers/test_utils.py
+485
@@ -1,13 +1,24 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +import ast
5 import json
6
7 import pytest
8
9 from vllm.tool_parsers.utils import (
10 + UnexpectedAstError,
11 coerce_to_schema_type,
12 + contains_broken_string_literal,
13 + escape_ctrl_chars_in_strings,
14 + escape_nested_quotes_in_strings,
15 extract_types_from_schema,
16 + get_parameter_value,
17 + handle_single_tool,
18 + make_valid_python,
19 + normalize_leading_zero_ints,
20 + rename_reserved_kwargs,
21 + restore_reserved_kwarg_names,
22 )
23
24
@@ -279,3 +290,477 @@ class TestExtractTypesFromSchema:
290 }
291 result = set(extract_types_from_schema(schema))
292 assert result == {"integer", "null", "string"}
293 +
294 +
295 +def _value_of(expr: str):
296 + """Parse a single Python expression and run get_parameter_value on it."""
297 + return get_parameter_value(ast.parse(expr, mode="eval").body)
298 +
299 +
300 +def _first_call(text: str) -> ast.Call:
301 + """Parse ``[foo(...)]`` and return the single ast.Call node."""
302 + statement = ast.parse(text).body[0]
303 + assert isinstance(statement, ast.Expr)
304 + assert isinstance(statement.value, ast.List)
305 + call = statement.value.elts[0]
306 + assert isinstance(call, ast.Call)
307 + return call
308 +
309 +
310 +def _bare_call(text: str) -> ast.Call:
311 + """Parse ``foo(...)`` (no list wrapper) and return the ast.Call node."""
312 + statement = ast.parse(text).body[0]
313 + assert isinstance(statement, ast.Expr)
314 + assert isinstance(statement.value, ast.Call)
315 + return statement.value
316 +
317 +
318 +def _kwarg_constant(call: ast.Call, index: int = 0):
319 + """Return the constant value of the call's ``index``-th keyword arg."""
320 + value = call.keywords[index].value
321 + assert isinstance(value, ast.Constant)
322 + return value.value
323 +
324 +
325 +class TestMakeValidPythonStringLiterals:
326 + def test_bracket_inside_string_is_literal(self):
327 + # A bracket inside a string argument must not be counted as a
328 + # structural bracket. Regression: `]` inside the string popped the
329 + # bracket stack and the whole call raised as mismatched.
330 + text = "[exec(command='grep -F \"]\" log.txt')]"
331 + assert make_valid_python(text) == (text, "")
332 +
333 + def test_open_bracket_inside_string_is_literal(self):
334 + # An unclosed `[` inside a string must not leave a phantom open
335 + # bracket on the stack.
336 + text = "[exec(command='grep [abc log.txt')]"
337 + assert make_valid_python(text) == (text, "")
338 +
339 + def test_partial_string_with_bracket_completes(self):
340 + # Streaming prefix ending mid-string after a literal bracket closes
341 + # with quote + paren + bracket.
342 + result = make_valid_python('[exec(command=\'grep -F "]" lo')
343 + assert result is not None
344 + completed, added = result
345 + assert added == "')]"
346 + assert completed == "[exec(command='grep -F \"]\" lo')]"
347 +
348 + def test_real_mismatched_bracket_still_raises(self):
349 + with pytest.raises(UnexpectedAstError):
350 + make_valid_python("[exec(command=data])")
351 +
352 + def test_multiline_string_argument_recovers_after_escape(self):
353 + # A raw newline inside a string argument is invalid Python, so
354 + # make_valid_python alone returns None; callers pre-escape control
355 + # chars (as the lfm2 parser does) and the escaped value must evaluate
356 + # back to the original.
357 + text = "[exec(command='line1\nline2')]"
358 + assert make_valid_python(text) is None
359 + result = make_valid_python(escape_ctrl_chars_in_strings(text))
360 + assert result is not None
361 + completed, added = result
362 + assert added == ""
363 + assert _kwarg_constant(_first_call(completed)) == "line1\nline2"
364 +
365 + def test_value_ending_in_backslash_recovers(self):
366 + # A string value ending in a literal backslash: the closing quote follows
367 + # an escaped backslash (an *even* run), so it closes the string. Checking
368 + # only the single preceding char misread it as an escaped quote, left the
369 + # string open, and make_valid_python returned None — dropping calls whose
370 + # last argument ends in a backslash (common in regex like r'\b').
371 + text = "[write(path='x', content='pattern \\\\')]"
372 + assert make_valid_python(text) == (text, "")
373 +
374 + def test_escaped_quote_odd_backslashes_stays_open(self):
375 + # An escaped quote (an *odd* backslash run) must NOT close the string;
376 + # only the final unescaped quote does. Value round-trips to it's fine.
377 + text = "[say(msg='it\\'s fine')]"
378 + assert make_valid_python(text) == (text, "")
379 + assert _kwarg_constant(_first_call(text)) == "it's fine"
380 +
381 +
382 +class TestEscapeCtrlCharsInStrings:
383 + def test_newline_inside_string_escaped(self):
384 + assert escape_ctrl_chars_in_strings("f(cmd='a\nb')") == "f(cmd='a\\nb')"
385 +
386 + def test_ctrl_chars_outside_strings_untouched(self):
387 + assert escape_ctrl_chars_in_strings("f(a=1,\nb=2)") == "f(a=1,\nb=2)"
388 +
389 + def test_existing_escapes_pass_through(self):
390 + text = "f(cmd='a\\nb')"
391 + assert escape_ctrl_chars_in_strings(text) == text
392 +
393 + def test_escaped_quote_does_not_close_string(self):
394 + assert escape_ctrl_chars_in_strings("f(cmd='a\\'\nb')") == "f(cmd='a\\'\\nb')"
395 +
396 + def test_value_preserved_through_ast(self):
397 + # The escaped text parses and evaluates back to the original value.
398 + raw = "cat > f.py << EOF\nimport csv\nEOF\techo done"
399 + escaped = escape_ctrl_chars_in_strings(f"f(cmd='{raw}')")
400 + assert _kwarg_constant(_bare_call(escaped)) == raw
401 +
402 + def test_nul_byte_inside_string_escaped(self):
403 + # ast.parse raises ValueError (not SyntaxError) on NUL anywhere in
404 + # the source, so an unescaped NUL in a string arg is unrecoverable.
405 + raw = "printf a\x00b"
406 + escaped = escape_ctrl_chars_in_strings(f"f(cmd='{raw}')")
407 + assert "\x00" not in escaped
408 + assert _kwarg_constant(_bare_call(escaped)) == raw
409 +
410 + def test_nul_byte_outside_strings_untouched(self):
411 + text = "f(a=1,\x00b=2)"
412 + assert escape_ctrl_chars_in_strings(text) == text
413 +
414 +
415 +class TestGetParameterValueNegativeNumbers:
416 + # A negative number is parsed by Python as UnaryOp(USub, Constant(n)), not
417 + # a plain Constant. Without explicit handling the entire tool call is
418 + # dropped. Negative longitudes/deltas/offsets are extremely common tool
419 + # arguments (e.g. every Western-hemisphere coordinate).
420 + def test_negative_int(self):
421 + assert _value_of("-1") == -1
422 +
423 + def test_negative_float(self):
424 + assert _value_of("-3.5") == -3.5
425 +
426 + def test_explicit_positive_int(self):
427 + assert _value_of("+7") == 7
428 +
429 + def test_negative_longitude(self):
430 + assert _value_of("-74.0046539") == -74.0046539
431 +
432 + def test_negative_in_list(self):
433 + assert _value_of("[-1, 2, -3]") == [-1, 2, -3]
434 +
435 + def test_negative_in_dict(self):
436 + assert _value_of('{"min": -5, "max": 5}') == {"min": -5, "max": 5}
437 +
438 + def test_nested_negative(self):
439 + assert _value_of('{"bbox": [-74.0, 40.7, -73.9]}') == {
440 + "bbox": [-74.0, 40.7, -73.9]
441 + }
442 +
443 + def test_non_numeric_unary_still_raises(self):
444 + # ``not x`` / ``~x`` are not literals and must still be rejected.
445 + with pytest.raises(UnexpectedAstError):
446 + _value_of("~5")
447 + with pytest.raises(UnexpectedAstError):
448 + _value_of("not True")
449 +
450 +
451 +class TestHandleSingleToolNegativeNumbers:
452 + def test_negative_arg_end_to_end(self):
453 + call = _first_call("[searchWeather(latitude=40.84, longitude=-74.0046539)]")
454 + tool = handle_single_tool(call)
455 + assert tool.function.name == "searchWeather"
456 + assert json.loads(tool.function.arguments) == {
457 + "latitude": 40.84,
458 + "longitude": -74.0046539,
459 + }
460 +
461 + def test_negative_delta_end_to_end(self):
462 + call = _first_call("[updateInventory(quantity_delta=-20)]")
463 + tool = handle_single_tool(call)
464 + assert json.loads(tool.function.arguments) == {"quantity_delta": -20}
465 +
466 +
467 +class TestGetParameterValueSet:
468 + # JSON has no set type; a set argument is decoded as a list (preserving
469 + # source order) instead of dropping the whole call, mirroring the tuple
470 + # handling.
471 + def test_set_becomes_list(self):
472 + assert _value_of("{'a', 'b'}") == ["a", "b"]
473 +
474 + def test_set_of_numbers(self):
475 + assert _value_of("{1, 2, 3}") == [1, 2, 3]
476 +
477 + def test_set_nested_in_dict(self):
478 + assert _value_of("{'tags': {'x', 'y'}}") == {"tags": ["x", "y"]}
479 +
480 + def test_set_end_to_end(self):
481 + call = _first_call("[label(tags={'urgent', 'bug'})]")
482 + tool = handle_single_tool(call)
483 + assert json.loads(tool.function.arguments) == {"tags": ["urgent", "bug"]}
484 +
485 +
486 +class TestMakeValidPythonSets:
487 + def test_complete_set_in_model_text_accepted(self):
488 + # A set the model wrote and closed itself is a genuine argument.
489 + text = "[label(tags={'urgent', 'bug'})]"
490 + assert make_valid_python(text) == (text, "")
491 +
492 + def test_truncated_dict_completed_to_set_still_rejected(self):
493 + # `{"k` closes to `{"k"}` — a truncated dict, not a set the model
494 + # wrote. The completion added the `}`, so it must keep waiting.
495 + assert make_valid_python('[f(x={"k') is None
496 +
497 +
498 +class TestGetParameterValueFString:
499 + # A placeholder-free f-string is a plain string constant, but ast parses
500 + # it as JoinedStr; it must not drop the call. Real placeholders are not
501 + # literals and must still be rejected.
502 + def test_constant_fstring(self):
503 + assert _value_of("f'hello world'") == "hello world"
504 +
505 + def test_empty_fstring(self):
506 + assert _value_of("f''") == ""
507 +
508 + def test_constant_fstring_in_list(self):
509 + assert _value_of("[f'a', 'b']") == ["a", "b"]
510 +
511 + def test_fstring_with_placeholder_still_raises(self):
512 + with pytest.raises(UnexpectedAstError):
513 + _value_of("f'{x}'")
514 +
515 + def test_constant_fstring_end_to_end(self):
516 + call = _first_call("[send(msg=f'hello')]")
517 + tool = handle_single_tool(call)
518 + assert json.loads(tool.function.arguments) == {"msg": "hello"}
519 +
520 +
521 +class TestGetParameterValueTuple:
522 + # JSON has no tuple type, so a tuple argument is decoded as a list rather
523 + # than dropping the whole call.
524 + def test_tuple_becomes_list(self):
525 + assert _value_of("(800, 600)") == [800, 600]
526 +
527 + def test_nested_tuple(self):
528 + assert _value_of("[(1, 2), (3, 4)]") == [[1, 2], [3, 4]]
529 +
530 + def test_tuple_with_negative(self):
531 + assert _value_of("(-74.0, 40.7)") == [-74.0, 40.7]
532 +
533 + def test_tuple_end_to_end(self):
534 + call = _first_call("[resize(size=(800, 600))]")
535 + tool = handle_single_tool(call)
536 + assert json.loads(tool.function.arguments) == {"size": [800, 600]}
537 +
538 +
539 +class TestEscapeNestedQuotesInStrings:
540 + # Unescaped same-style quotes nested in a string argument (shell
541 + # commands like sed -n '1,9p') are Python juxtaposition errors. When
542 + # exactly one quote can syntactically close the string, close there and
543 + # escape the interior quotes; anything ambiguous is left unchanged.
544 + def test_sed_command_recovers_exact_value(self):
545 + text = "[bash(command='sed -n '360,450p' /testbed/common.py')]"
546 + rewritten, changed = escape_nested_quotes_in_strings(text)
547 + assert changed
548 + assert _kwarg_constant(_first_call(rewritten)) == (
549 + "sed -n '360,450p' /testbed/common.py"
550 + )
551 +
552 + def test_double_quote_variant(self):
553 + text = '[bash(command="grep "foo" log.txt")]'
554 + rewritten, changed = escape_nested_quotes_in_strings(text)
555 + assert changed
556 + assert _kwarg_constant(_first_call(rewritten)) == 'grep "foo" log.txt'
557 +
558 + def test_mixed_with_already_escaped_quote(self):
559 + text = "[bash(command='it\\'s 'x' end')]"
560 + rewritten, changed = escape_nested_quotes_in_strings(text)
561 + assert changed
562 + assert _kwarg_constant(_first_call(rewritten)) == "it's 'x' end"
563 +
564 + def test_doubly_nested_python_c_payload(self):
565 + # command='python3 -c "...latex(x, mul_symbol='\,')..."' — two false
566 + # closers both followed by ')', but only the real one yields text
567 + # that parses; ast-validation disambiguates.
568 + text = "[bash(command='python3 -c \"print(latex(x, mul_symbol='\\,'))\"')]"
569 + rewritten, changed = escape_nested_quotes_in_strings(text)
570 + assert changed
571 + assert _kwarg_constant(_first_call(rewritten)) == (
572 + "python3 -c \"print(latex(x, mul_symbol='\\,'))\""
573 + )
574 +
575 + def test_multiline_python_c_with_inner_string(self):
576 + # Raw newlines beyond the phantom close plus a single-quoted inner
577 + # string; recovery must survive both (caller re-escapes ctrl chars).
578 + inner = "python3 -c \"\ntest_str = 'is <b>fine</b>'\nprint(test_str)\n\""
579 + text = f"[bash(command='{inner}')]"
580 + rewritten, changed = escape_nested_quotes_in_strings(text)
581 + assert changed
582 + recovered = escape_ctrl_chars_in_strings(rewritten)
583 + assert _kwarg_constant(_first_call(recovered)) == inner
584 +
585 + def test_nested_quotes_before_non_string_argument(self):
586 + # A later NON-string argument adds no candidate closer, so the
587 + # nested string is still unambiguous.
588 + text = "[run(cmd='awk '{print}' f', count=2)]"
589 + rewritten, changed = escape_nested_quotes_in_strings(text)
590 + assert changed
591 + call = _first_call(rewritten)
592 + assert _kwarg_constant(call, 0) == "awk '{print}' f"
593 +
594 + @pytest.mark.parametrize(
595 + "text",
596 + [
597 + "[f(a='x')]",
598 + "[f(a='x', b='y')]",
599 + "[f(a={'k': 'v'})]",
600 + "[f(a='it\\'s fine')]",
601 + "[a(x='p'), b(y='q')]",
602 + ],
603 + )
604 + def test_valid_text_unchanged(self, text):
605 + rewritten, changed = escape_nested_quotes_in_strings(text)
606 + assert not changed
607 + assert rewritten == text
608 +
609 + @pytest.mark.parametrize(
610 + "text",
611 + [
612 + "[f(a='echo 'hi', b='x')]",
613 + "[run(cmd='awk '{print}' f', mode='fast')]",
614 + ],
615 + )
616 + def test_ambiguous_nesting_left_unchanged(self, text):
617 + # A later string argument's closing quote is itself a plausible
618 + # closer, making the nesting formally ambiguous; no rewrite is
619 + # attempted rather than guessing.
620 + rewritten, changed = escape_nested_quotes_in_strings(text)
621 + assert not changed
622 + assert rewritten == text
623 +
624 +
625 +class TestContainsBrokenStringLiteral:
626 + @pytest.mark.parametrize(
627 + "text, broken",
628 + [
629 + ("[bash(command='sed -n '360,450p' /x')]", True),
630 + ("[f(a='echo 'hi', b='x')]", True),
631 + # Closing quote at the very end of partial text: the follower
632 + # is unknown until the next chunk arrives, so hold.
633 + ("[f(a='x'", True),
634 + ("[f(a='x', b='y')]", False),
635 + # Mid-string (no closing quote yet) is normal streaming.
636 + ('[f(a=\'grep -F "]" log', False),
637 + ("[f(a={'k': 'v'})]", False),
638 + ("[f(a='it\\'s fine')]", False),
639 + ],
640 + )
641 + def test_detection(self, text, broken):
642 + assert contains_broken_string_literal(text) is broken
643 +
644 +
645 +class TestGetParameterValueNonJsonConstants:
646 + # bytes/Ellipsis/complex are ast.Constant but have no JSON form; they
647 + # must raise UnexpectedAstError (like other unsupported nodes) instead
648 + # of surfacing later as a TypeError inside json.dumps.
649 + @pytest.mark.parametrize("expr", ["b'abc'", "...", "1j"])
650 + def test_non_json_constant_raises(self, expr):
651 + with pytest.raises(UnexpectedAstError):
652 + _value_of(expr)
653 +
654 + def test_handle_single_tool_raises_ast_error_not_type_error(self):
655 + call = _first_call("[f(x=b'abc')]")
656 + with pytest.raises(UnexpectedAstError):
657 + handle_single_tool(call)
658 +
659 + @pytest.mark.parametrize(
660 + "expr, expected",
661 + [("'s'", "s"), ("1", 1), ("1.5", 1.5), ("True", True), ("None", None)],
662 + )
663 + def test_json_constants_still_pass(self, expr, expected):
664 + assert _value_of(expr) == expected
665 +
666 +
667 +class TestNormalizeLeadingZeroInts:
668 + # Zero-padded ints (month=07) are a SyntaxError no other recovery path
669 + # handles; the rewrite must strip the padding without touching tokens
670 + # that are already valid Python.
671 + @pytest.mark.parametrize(
672 + "text, expected",
673 + [
674 + ("[f(month=07)]", "[f(month=7)]"),
675 + ("[f(x=007, y=05)]", "[f(x=7, y=5)]"),
676 + ("[f(x=0_7)]", "[f(x=7)]"),
677 + ("[f(x=-07)]", "[f(x=-7)]"),
678 + ],
679 + )
680 + def test_leading_zeros_stripped(self, text, expected):
681 + assert normalize_leading_zero_ints(text) == expected
682 + assert ast.parse(expected)
683 +
684 + @pytest.mark.parametrize(
685 + "text",
686 + [
687 + "[f(x=0)]",
688 + "[f(x=00)]",
689 + "[f(x=0.5)]",
690 + "[f(x=07.5)]",
691 + "[f(x=1.07)]",
692 + "[f(x=1e07)]",
693 + "[f(x=0x1F)]",
694 + "[f(x=0o17)]",
695 + "[f(x=0b101)]",
696 + "[f(s='id 007')]",
697 + '[f(s="v0.07")]',
698 + ],
699 + )
700 + def test_valid_tokens_and_strings_untouched(self, text):
701 + assert normalize_leading_zero_ints(text) == text
702 +
703 + def test_end_to_end(self):
704 + normalized = normalize_leading_zero_ints("[set_date(month=07, day=05)]")
705 + tool = handle_single_tool(_first_call(normalized))
706 + assert json.loads(tool.function.arguments) == {"month": 7, "day": 5}
707 +
708 +
709 +class TestRenameReservedKwargs:
710 + # A parameter named after a Python keyword (`from=1`) is a SyntaxError
711 + # that no escape/retry can recover; rename_reserved_kwargs rewrites it to
712 + # a parseable name and restore_reserved_kwarg_names is its exact inverse.
713 + def test_reserved_kwarg_renamed(self):
714 + text, changed = rename_reserved_kwargs("[memory_get(from=1)]")
715 + assert changed
716 + assert text == "[memory_get(from_pyreservedkw_=1)]"
717 + assert ast.parse(text)
718 +
719 + def test_round_trip_restores_original_name(self):
720 + renamed, _ = rename_reserved_kwargs("[memory_get(path='M.md', from=1)]")
721 + tool = handle_single_tool(_first_call(renamed))
722 + restored = restore_reserved_kwarg_names(json.loads(tool.function.arguments))
723 + assert restored == {"path": "M.md", "from": 1}
724 +
725 + def test_multiple_reserved_kwargs(self):
726 + text, changed = rename_reserved_kwargs('[search(in="docs/", from=0)]')
727 + assert changed
728 + args = json.loads(handle_single_tool(_first_call(text)).function.arguments)
729 + assert restore_reserved_kwarg_names(args) == {"in": "docs/", "from": 0}
730 +
731 + def test_keyword_inside_string_untouched(self):
732 + text, changed = rename_reserved_kwargs('[f(cmd="import x from y")]')
733 + assert not changed
734 + assert text == '[f(cmd="import x from y")]'
735 +
736 + def test_keyword_with_from_eq_inside_string_untouched(self):
737 + text, changed = rename_reserved_kwargs('[f(cmd="SELECT from=1")]')
738 + assert not changed
739 +
740 + def test_keyword_value_untouched(self):
741 + # `x=True` has a keyword as *value*, not parameter name.
742 + text, changed = rename_reserved_kwargs("[f(x=True, y=None)]")
743 + assert not changed
744 +
745 + def test_double_equals_untouched(self):
746 + text, changed = rename_reserved_kwargs('[f(expr="a", cond=1)]')
747 + assert not changed
748 + # `in ==` comparison-like text is not kwarg position anyway, but the
749 + # `==` guard also protects string-free edge text.
750 + text, changed = rename_reserved_kwargs("[f(x=1)]")
751 + assert not changed
752 +
753 + def test_non_keyword_names_untouched(self):
754 + text, changed = rename_reserved_kwargs("[f(fromage=1, classic=2)]")
755 + assert not changed
756 +
757 + def test_spaces_around_equals(self):
758 + text, changed = rename_reserved_kwargs("[f( from = 1 )]")
759 + assert changed
760 + assert ast.parse(text)
761 +
762 + def test_restore_leaves_normal_names_alone(self):
763 + assert restore_reserved_kwarg_names({"path": "x", "from": 1}) == {
764 + "path": "x",
765 + "from": 1,
766 + }
vllm/tool_parsers/lfm2_tool_parser.py
+99 -7
@@ -2,6 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import ast
5 +import json
6 from collections.abc import Sequence
7
8 import regex as re
@@ -24,8 +25,14 @@ from vllm.tool_parsers.abstract_tool_parser import (
25 from vllm.tool_parsers.utils import (
26 UnexpectedAstError,
27 compute_tool_delta,
28 + contains_broken_string_literal,
29 + escape_ctrl_chars_in_strings,
30 + escape_nested_quotes_in_strings,
31 handle_single_tool,
32 make_valid_python,
33 + normalize_leading_zero_ints,
34 + rename_reserved_kwargs,
35 + restore_reserved_kwarg_names,
36 )
37
38 logger = init_logger(__name__)
@@ -94,6 +101,15 @@ class Lfm2ToolParser(ToolParser):
101 def current_tool_index(self, value: int) -> None:
102 self.current_tool_id = value
103
104 + @staticmethod
105 + def _restore_reserved(tool_call):
106 + """Restore parameter names renamed by ``rename_reserved_kwargs``."""
107 + arguments = json.loads(tool_call.function.arguments)
108 + restored = restore_reserved_kwarg_names(arguments)
109 + if restored != arguments:
110 + tool_call.function.arguments = json.dumps(restored, ensure_ascii=False)
111 + return tool_call
112 +
113 @staticmethod
114 def _strip_echo(raw_after: str) -> str:
115 """Drop any orphan <|tool_call_end|> (and the preceding text) from
@@ -171,17 +187,53 @@ class Lfm2ToolParser(ToolParser):
187 )
188
189 try:
174 - module = ast.parse(tool_text)
190 + kw_renamed = False
191 + try:
192 + module = ast.parse(tool_text)
193 + except (SyntaxError, ValueError):
194 + # Progressive rewrites, each a no-op on already-valid text:
195 + # escape raw control chars / NUL bytes inside string literals,
196 + # strip leading zeros from int literals (month=07), close
197 + # unambiguous nested quotes (command='sed -n '1,9p' f.py'),
198 + # and rename reserved-keyword parameters (from=1; restored
199 + # below). The first rewrite whose result parses wins.
200 + escaped = escape_ctrl_chars_in_strings(
201 + normalize_leading_zero_ints(tool_text)
202 + )
203 + candidates = [escaped]
204 + requoted, requote_changed = escape_nested_quotes_in_strings(escaped)
205 + if requote_changed:
206 + # Requoting can move raw control chars (newlines beyond
207 + # the phantom close) inside the string; escape again.
208 + candidates.append(escape_ctrl_chars_in_strings(requoted))
209 + renamed, kw_renamed = rename_reserved_kwargs(candidates[-1])
210 + if kw_renamed:
211 + candidates.append(renamed)
212 + for candidate in candidates:
213 + try:
214 + module = ast.parse(candidate)
215 + break
216 + except (SyntaxError, ValueError):
217 + continue
218 + else:
219 + raise
220 parsed = getattr(module.body[0], "value", None)
176 - if isinstance(parsed, ast.List) and all(
177 - isinstance(e, ast.Call) for e in parsed.elts
221 + # An empty block ([]) must not report tools_called=True with zero
222 + # calls, so require at least one element.
223 + if (
224 + isinstance(parsed, ast.List)
225 + and parsed.elts
226 + and all(isinstance(e, ast.Call) for e in parsed.elts)
227 ):
228 + tool_calls = [
229 + handle_single_tool(e) # type: ignore
230 + for e in parsed.elts
231 + ]
232 + if kw_renamed:
233 + tool_calls = [self._restore_reserved(tc) for tc in tool_calls]
234 return ExtractedToolCallInformation(
235 tools_called=True,
181 - tool_calls=[
182 - handle_single_tool(e) # type: ignore
183 - for e in parsed.elts
184 - ],
236 + tool_calls=tool_calls,
237 content=content,
238 )
239 else:
@@ -261,6 +313,9 @@ class Lfm2ToolParser(ToolParser):
313 # Strip the end token if present (entire call arrived at once).
314 if TOOL_CALL_END in tool_text:
315 tool_text = tool_text.split(TOOL_CALL_END, 1)[0]
316 + # Leading whitespace after the start token would make every completion
317 + # candidate an IndentationError (the non-streaming path strips it too).
318 + tool_text = tool_text.lstrip()
319
320 def _content_only_or_none() -> DeltaMessage | None:
321 """Return a content-only delta if any content arrived in this
@@ -271,6 +326,41 @@ class Lfm2ToolParser(ToolParser):
326 return DeltaMessage(content=combined) if combined else None
327
328 try:
329 + # A raw control char inside a string argument would make every
330 + # completion candidate a SyntaxError; escape them here rather than
331 + # inside make_valid_python so the shared helper keeps its upstream
332 + # behavior for the other pythonic parsers. Leading zeros in int
333 + # literals (month=07) and parameters named after Python keywords
334 + # (`from=1`) can never parse; rewrite those too. All rewrites are
335 + # deterministic, so successive chunks stay consistent; keyword
336 + # names are restored after decoding.
337 + tool_text = escape_ctrl_chars_in_strings(
338 + normalize_leading_zero_ints(tool_text)
339 + )
340 + if has_end_in_current:
341 + # Nested-quote recovery needs the final text (which quote
342 + # closes a partial string is not stable across chunks) and
343 + # must never touch text that already parses.
344 + try:
345 + ast.parse(tool_text)
346 + except (SyntaxError, ValueError):
347 + requoted_text, requote_changed = escape_nested_quotes_in_strings(
348 + tool_text
349 + )
350 + if requote_changed:
351 + tool_text = escape_ctrl_chars_in_strings(requoted_text)
352 + renamed_tool_text, kw_renamed = rename_reserved_kwargs(tool_text)
353 + if kw_renamed:
354 + tool_text = renamed_tool_text
355 +
356 + # A broken string (its first closing quote cannot close it —
357 + # nested-quote text mid-arrival) makes every completion-based
358 + # partial parse an implicit-concatenation misreading whose
359 + # streamed prefix could never be retracted. Withhold deltas
360 + # until the requote recovery above has produced sane text.
361 + if contains_broken_string_literal(tool_text):
362 + return _content_only_or_none()
363 +
364 valid_and_added_text = make_valid_python(tool_text)
365 if valid_and_added_text is None:
366 return _content_only_or_none()
@@ -286,6 +376,8 @@ class Lfm2ToolParser(ToolParser):
376 handle_single_tool(e) # type: ignore
377 for e in parsed.elts
378 ]
379 + if kw_renamed:
380 + tool_calls = [self._restore_reserved(tc) for tc in tool_calls]
381
382 tool_deltas = []
383 for index, new_call in enumerate(tool_calls):
vllm/tool_parsers/utils.py
+404 -17
@@ -3,6 +3,7 @@
3
4 import ast
5 import json
6 +import keyword as _python_keyword
7 import math
8 import warnings
9 from dataclasses import dataclass
@@ -451,7 +452,16 @@ def get_parameter_value(val: ast.expr) -> Any:
452 UnexpectedAstError: If the AST node is not a supported literal type.
453 """
454 if isinstance(val, ast.Constant):
454 - return val.value
455 + if val.value is None or isinstance(val.value, (str, int, float)):
456 + return val.value
457 + # bytes/Ellipsis/complex constants have no JSON representation and
458 + # would otherwise surface as a TypeError deep inside json.dumps;
459 + # reject them explicitly like other unsupported nodes.
460 + logger.warning(
461 + "Non-JSON-representable constant in tool call arguments: %s",
462 + ast.dump(val),
463 + )
464 + raise UnexpectedAstError("Tool call arguments must be JSON values")
465 elif isinstance(val, ast.Dict):
466 if not all(isinstance(k, ast.Constant) for k in val.keys):
467 logger.warning(
@@ -465,8 +475,44 @@ def get_parameter_value(val: ast.expr) -> Any:
475 }
476 elif isinstance(val, ast.List):
477 return [get_parameter_value(v) for v in val.elts]
478 + elif isinstance(val, ast.Tuple):
479 + # JSON has no tuple type; a tuple argument (e.g. ``size=(800, 600)``)
480 + # is treated as a list so it round-trips through ``json.dumps``.
481 + # Without this the whole call is dropped.
482 + return [get_parameter_value(v) for v in val.elts]
483 + elif isinstance(val, ast.Set):
484 + # JSON has no set type either; a set argument (e.g.
485 + # ``tags={'a', 'b'}``) is treated as a list, preserving source order.
486 + return [get_parameter_value(v) for v in val.elts]
487 + elif isinstance(val, ast.JoinedStr) and all(
488 + isinstance(part, ast.Constant) for part in val.values
489 + ):
490 + # An f-string without placeholders (``f'hello'``) is a plain string
491 + # constant, but Python parses it as JoinedStr rather than Constant;
492 + # without this branch the whole call is dropped. F-strings with real
493 + # placeholders still fall through to the raise below.
494 + return "".join(
495 + str(part.value) # type: ignore
496 + for part in val.values
497 + )
498 elif isinstance(val, ast.Name) and val.id in _JSON_NAME_LITERALS:
499 return _JSON_NAME_LITERALS[val.id]
500 + elif isinstance(val, ast.UnaryOp) and isinstance(val.op, (ast.USub, ast.UAdd)):
501 + # A negative (or explicitly positive) number is parsed by Python as a
502 + # unary operation over a numeric constant, e.g. ``-1`` becomes
503 + # ``UnaryOp(USub, Constant(1))`` rather than a plain ``Constant(-1)``.
504 + # These are extremely common tool arguments (negative longitudes,
505 + # offsets, deltas); without this branch the whole call is dropped.
506 + # Restrict to numeric operands so ``not``/``~`` and other expressions
507 + # still raise below.
508 + operand = get_parameter_value(val.operand)
509 + if isinstance(operand, (int, float)) and not isinstance(operand, bool):
510 + return -operand if isinstance(val.op, ast.USub) else operand
511 + logger.warning(
512 + "Unsupported unary operand in tool call arguments: %s",
513 + ast.dump(val),
514 + )
515 + raise UnexpectedAstError("Tool call arguments must be literals")
516 else:
517 logger.warning(
518 "Unsupported AST node type in tool call arguments: %s",
@@ -524,6 +570,343 @@ def handle_single_tool(call: ast.Call) -> ToolCall:
570 )
571
572
573 +def escape_ctrl_chars_in_strings(text: str) -> str:
574 + """Escape literal control chars inside string literals of pythonic text.
575 +
576 + Models emitting pythonic tool calls frequently place raw newlines inside a
577 + string argument (e.g. ``exec(command='line1\\nline2')`` written with a real
578 + line break). That is invalid Python — ``ast.parse`` fails with "unterminated
579 + string literal" — so the call would be dropped even though the intent is
580 + unambiguous. A NUL byte is worse: ``ast.parse`` rejects it anywhere in the
581 + source with ``ValueError``. Escaping ``\\n``/``\\r``/``\\t``/``\\x00`` only
582 + *inside* string literals makes the text parseable while preserving the
583 + argument value exactly (the escape sequences evaluate back to the original
584 + control chars).
585 +
586 + Text outside string literals is returned unchanged.
587 + """
588 + out: list[str] = []
589 + quote: str | None = None
590 + index, length = 0, len(text)
591 + while index < length:
592 + char = text[index]
593 + if quote is None:
594 + if char in {"'", '"'}:
595 + quote = char
596 + out.append(char)
597 + elif char == "\\" and index + 1 < length:
598 + out.append(char)
599 + out.append(text[index + 1])
600 + index += 2
601 + continue
602 + elif char == quote:
603 + quote = None
604 + out.append(char)
605 + elif char == "\n":
606 + out.append("\\n")
607 + elif char == "\r":
608 + out.append("\\r")
609 + elif char == "\t":
610 + out.append("\\t")
611 + elif char == "\x00":
612 + out.append("\\x00")
613 + else:
614 + out.append(char)
615 + index += 1
616 + return "".join(out)
617 +
618 +
619 +_RESERVED_KW_SUFFIX = "_pyreservedkw_"
620 +
621 +
622 +def rename_reserved_kwargs(text: str) -> tuple[str, bool]:
623 + """Rename Python-keyword parameter names so pythonic tool text parses.
624 +
625 + Tools legitimately name parameters ``from``, ``in``, ``class`` — but
626 + ``memory_get(from=1)`` is a Python ``SyntaxError``, so the whole call
627 + would be dropped. Rename ``from=`` to ``from_pyreservedkw_=`` (outside
628 + string literals only, and only in keyword-argument position: preceded by
629 + ``(`` or ``,`` and followed by a single ``=``), parse, then restore the
630 + original name with :func:`restore_reserved_kwarg_names`.
631 +
632 + Returns (rewritten_text, changed). Keyword *values* (``x=True``) and
633 + keywords inside string arguments are never touched.
634 + """
635 + out: list[str] = []
636 + quote: str | None = None
637 + changed = False
638 + last_sig = ""
639 + index, length = 0, len(text)
640 + while index < length:
641 + char = text[index]
642 + if quote is not None:
643 + out.append(char)
644 + if char == "\\" and index + 1 < length:
645 + out.append(text[index + 1])
646 + index += 2
647 + continue
648 + if char == quote:
649 + quote = None
650 + index += 1
651 + continue
652 + if char in {"'", '"'}:
653 + quote = char
654 + out.append(char)
655 + last_sig = char
656 + index += 1
657 + continue
658 + if char.isalpha() or char == "_":
659 + end = index
660 + while end < length and (text[end].isalnum() or text[end] == "_"):
661 + end += 1
662 + name = text[index:end]
663 + look = end
664 + while look < length and text[look].isspace():
665 + look += 1
666 + if (
667 + _python_keyword.iskeyword(name)
668 + and look < length
669 + and text[look] == "="
670 + and (look + 1 >= length or text[look + 1] != "=")
671 + and last_sig in {"(", ","}
672 + ):
673 + out.append(name + _RESERVED_KW_SUFFIX)
674 + changed = True
675 + else:
676 + out.append(name)
677 + last_sig = name[-1]
678 + index = end
679 + continue
680 + out.append(char)
681 + if not char.isspace():
682 + last_sig = char
683 + index += 1
684 + return "".join(out), changed
685 +
686 +
687 +def restore_reserved_kwarg_names(arguments: dict) -> dict:
688 + """Undo :func:`rename_reserved_kwargs` on a decoded arguments dict.
689 +
690 + Only keys that carry the rename suffix *and* whose stem is a Python
691 + keyword are restored, making this an exact inverse of the rename.
692 + """
693 + restored = {}
694 + for key, value in arguments.items():
695 + if (
696 + isinstance(key, str)
697 + and key.endswith(_RESERVED_KW_SUFFIX)
698 + and _python_keyword.iskeyword(key[: -len(_RESERVED_KW_SUFFIX)])
699 + ):
700 + restored[key[: -len(_RESERVED_KW_SUFFIX)]] = value
701 + else:
702 + restored[key] = value
703 + return restored
704 +
705 +
706 +def normalize_leading_zero_ints(text: str) -> str:
707 + """Strip leading zeros from decimal integer literals so the text parses.
708 +
709 + Models emit zero-padded integers (``month=07``), which Python rejects
710 + ("leading zeros in decimal integer literals are not permitted"), so the
711 + whole call would be dropped. Rewrite ``07`` to ``7`` outside string
712 + literals only. Tokens that are already valid Python are left alone:
713 + all-zero literals (``00``), floats and fractional parts (``07.5``,
714 + ``1.07``), exponents (``1e07``, consumed as a name run), and ``0x``/
715 + ``0o``/``0b`` prefixes (the digit run stops at the prefix letter).
716 + """
717 + out: list[str] = []
718 + quote: str | None = None
719 + index, length = 0, len(text)
720 + while index < length:
721 + char = text[index]
722 + if quote is not None:
723 + out.append(char)
724 + if char == "\\" and index + 1 < length:
725 + out.append(text[index + 1])
726 + index += 2
727 + continue
728 + if char == quote:
729 + quote = None
730 + index += 1
731 + continue
732 + if char in {"'", '"'}:
733 + quote = char
734 + out.append(char)
735 + index += 1
736 + continue
737 + if char.isalpha() or char == "_":
738 + end = index
739 + while end < length and (text[end].isalnum() or text[end] == "_"):
740 + end += 1
741 + out.append(text[index:end])
742 + index = end
743 + continue
744 + if char.isdigit():
745 + end = index
746 + while end < length and (text[end].isdigit() or text[end] == "_"):
747 + end += 1
748 + token = text[index:end]
749 + digits = token.replace("_", "")
750 + follower = text[end] if end < length else ""
751 + preceded_by_dot = index > 0 and text[index - 1] == "."
752 + if (
753 + digits[0] == "0"
754 + and digits.strip("0")
755 + and not preceded_by_dot
756 + and follower not in {".", "e", "E", "j", "J"}
757 + ):
758 + out.append(str(int(digits)))
759 + else:
760 + out.append(token)
761 + index = end
762 + continue
763 + out.append(char)
764 + index += 1
765 + return "".join(out)
766 +
767 +
768 +_QUOTE_FOLLOWERS = {",", ")", "]", "}", ":"}
769 +
770 +
771 +def escape_nested_quotes_in_strings(text: str) -> tuple[str, bool]:
772 + """Close a broken string literal at the only closing quote that works.
773 +
774 + Models emitting shell commands frequently nest unescaped same-style
775 + quotes inside a string argument — ``command='sed -n '360,450p' f.py'``,
776 + or a quoted ``python3 -c`` payload that itself contains quoted strings —
777 + which Python reads as juxtaposed garbage, so the call is dropped even
778 + though the intent is unambiguous. A string is treated as broken when
779 + its first unescaped quote cannot syntactically close it (what follows
780 + is none of ``,``, ``)``, ``]``, ``}``, ``:``). For a broken string,
781 + every syntactically plausible closing quote is tried: interior quotes
782 + escaped, the rest of the text kept verbatim, and the result (with
783 + control chars escaped) validated with ``ast.parse``. Exactly one
784 + candidate parsing means recovery — the decoded value is exactly the
785 + text the model wrote. Zero or several parsing candidates means the
786 + nesting is genuinely ambiguous and the text is returned unchanged
787 + rather than guessed at.
788 +
789 + Returns (rewritten_text, changed); run the result through
790 + escape_ctrl_chars_in_strings before parsing — quotes chosen here can
791 + move raw control chars inside the string.
792 + """
793 +
794 + def unescaped_quotes(start: int, quote: str) -> list[int]:
795 + positions = []
796 + j = start
797 + while j < len(text):
798 + if text[j] == "\\":
799 + j += 2
800 + continue
801 + if text[j] == quote:
802 + positions.append(j)
803 + j += 1
804 + return positions
805 +
806 + def is_closer(pos: int) -> bool:
807 + k = pos + 1
808 + while k < len(text) and text[k].isspace():
809 + k += 1
810 + return k < len(text) and text[k] in _QUOTE_FOLLOWERS
811 +
812 + prefix: list[str] = []
813 + index = 0
814 + while index < len(text):
815 + char = text[index]
816 + if char not in {"'", '"'}:
817 + prefix.append(char)
818 + index += 1
819 + continue
820 + quotes = unescaped_quotes(index + 1, char)
821 + if not quotes:
822 + return text, False
823 + if is_closer(quotes[0]):
824 + # The normal reading closes this string; move past it.
825 + prefix.append(text[index : quotes[0] + 1])
826 + index = quotes[0] + 1
827 + continue
828 + winners = []
829 + for close in (j for j in quotes if is_closer(j)):
830 + interior: list[str] = []
831 + for j in range(index + 1, close):
832 + if text[j] == char and not _is_escaped(text, j):
833 + interior.append("\\")
834 + interior.append(text[j])
835 + candidate = "".join(
836 + ["".join(prefix), char, "".join(interior), char, text[close + 1 :]]
837 + )
838 + with warnings.catch_warnings():
839 + warnings.simplefilter("ignore", SyntaxWarning)
840 + try:
841 + ast.parse(escape_ctrl_chars_in_strings(candidate))
842 + except (SyntaxError, ValueError):
843 + continue
844 + winners.append(candidate)
845 + if len(winners) == 1:
846 + return winners[0], True
847 + return text, False
848 + return text, False
849 +
850 +
851 +def contains_broken_string_literal(text: str) -> bool:
852 + """Whether some string literal's first closing quote cannot close it.
853 +
854 + Streaming guard companion to escape_nested_quotes_in_strings:
855 + completion-based partial parses of nested-quote text read the string as
856 + implicit concatenation and stream argument prefixes that can never be
857 + retracted. Callers should withhold tool deltas while this returns True
858 + and let the requote recovery run on the final text instead. A string
859 + whose closing quote has not arrived yet is NOT broken (normal
860 + streaming); a closing quote at the very end of the text counts as
861 + broken only because its follower is still unknown.
862 + """
863 + index = 0
864 + while index < len(text):
865 + char = text[index]
866 + if char == "\\":
867 + index += 2
868 + continue
869 + if char not in {"'", '"'}:
870 + index += 1
871 + continue
872 + j = index + 1
873 + close = -1
874 + while j < len(text):
875 + if text[j] == "\\":
876 + j += 2
877 + continue
878 + if text[j] == char:
879 + close = j
880 + break
881 + j += 1
882 + if close == -1:
883 + return False
884 + k = close + 1
885 + while k < len(text) and text[k].isspace():
886 + k += 1
887 + if k >= len(text) or text[k] not in _QUOTE_FOLLOWERS:
888 + return True
889 + index = close + 1
890 + return False
891 +
892 +
893 +def _is_escaped(text: str, index: int) -> bool:
894 + """Whether the character at ``index`` is backslash-escaped.
895 +
896 + A character is escaped iff it is preceded by an *odd* number of consecutive
897 + backslashes. Checking only the single preceding character is wrong for even
898 + runs: in ``'ab\\'`` the closing quote follows an escaped backslash (``\\\\``)
899 + and is therefore NOT escaped — it closes the string. Common in regex/code
900 + arguments such as ``r'\\\\b'``.
901 + """
902 + backslashes = 0
903 + j = index - 1
904 + while j >= 0 and text[j] == "\\":
905 + backslashes += 1
906 + j -= 1
907 + return backslashes % 2 == 1
908 +
909 +
910 def make_valid_python(text: str) -> tuple[str, str] | None:
911 """Attempt to close all open brackets/quotes to make partial Python valid.
912
@@ -541,6 +924,14 @@ def make_valid_python(text: str) -> tuple[str, str] | None:
924 """
925 bracket_stack: list[str] = []
926 for index, char in enumerate(text):
927 + # Inside a string literal only an unescaped matching quote is
928 + # significant; brackets are literal text. Without this guard a bracket
929 + # in a string argument (e.g. `cmd='grep -F "]"'`) corrupts the bracket
930 + # stack and the whole tool call is rejected as mismatched.
931 + if bracket_stack and bracket_stack[-1] in {"'", '"'}:
932 + if char == bracket_stack[-1] and not _is_escaped(text, index):
933 + bracket_stack.pop()
934 + continue
935 if char in {"[", "(", "{"}:
936 bracket_stack.append(char)
937 elif char == "]":
@@ -553,15 +944,7 @@ def make_valid_python(text: str) -> tuple[str, str] | None:
944 if not bracket_stack or bracket_stack.pop() != "{":
945 raise UnexpectedAstError("Mismatched curly braces")
946 elif char in {"'", '"'}:
556 - if bracket_stack and bracket_stack[-1] == char:
557 - if index > 0 and text[index - 1] == "\\":
558 - pass
559 - else:
560 - bracket_stack.pop()
561 - elif bracket_stack and bracket_stack[-1] in {"'", '"'}:
562 - pass
563 - else:
564 - bracket_stack.append(char)
947 + bracket_stack.append(char)
948
949 text = text.rstrip()
950 if text.endswith("=") or text.endswith(":"):
@@ -600,19 +983,23 @@ def make_valid_python(text: str) -> tuple[str, str] | None:
983 # 1. Mid-key inside a dict (`..., "k`) closes to `..., "k"}` — a
984 # syntactically invalid mixed dict/set.
985 # 2. A bare string inside a dict (`{"k`) closes to `{"k"}` — valid
603 - # Python but a *set* literal, which downstream tool-call AST
604 - # handling rejects.
605 - # Validate the candidate parses, has a body, and contains no Set
606 - # nodes (pythonic tool calls always use dicts for `{...}`).
986 + # Python but a *set* literal that is really a truncated dict.
987 + # Validate the candidate parses and has a body, and treat Set nodes as
988 + # incomplete — but only when this completion added a `}` itself; a set
989 + # already closed in the model text is a genuine set argument, not an
990 + # artifact. Callers whose models emit raw control chars inside string
991 + # arguments must escape them (see escape_ctrl_chars_in_strings) before
992 + # calling.
993 try:
994 module = ast.parse(candidate)
995 except SyntaxError:
996 return None
997 if not module.body:
998 return None
613 - for node in ast.walk(module):
614 - if isinstance(node, ast.Set):
615 - return None
999 + if "}" in added_text:
1000 + for node in ast.walk(module):
1001 + if isinstance(node, ast.Set):
1002 + return None
1003
1004 return candidate, added_text
1005