[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