[Bugfix][ToolParser] Handle braces in required tool streaming strings (#45389)
Signed-off-by: Ting Sun <suntcrick@gmail.com> Co-authored-by: Flora Feng <4florafeng@gmail.com>
Ting SUN committed
Jun 24, 2026 at 03:29 UTC
899d72a58c118035be5e9420a15cfa1110c64f47
2 files changed
+78
-44
tests/tool_use/test_tool_choice_required.py
+35
-33
@@ -280,15 +280,7 @@ def test_structured_outputs_json_without_parameters(
280
)
281
282
283
-@pytest.mark.parametrize("output", VALID_TOOLS)
284
-@pytest.mark.parametrize("empty_params", [False, True])
285
-@pytest.mark.parametrize("delta_len", [1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
286
-def test_streaming_output_valid(output, empty_params, delta_len):
287
- output = deepcopy(output)
288
- if empty_params:
289
- output = [{"name": o["name"], "parameters": {}} for o in output]
290
- output_json = json.dumps(output)
291
-
283
+def _collect_required_tool_streaming_json(output_json: str, delta_len: int) -> str:
284
previous_text = ""
285
function_name_returned = False
286
messages = []
@@ -327,37 +319,47 @@ def test_streaming_output_valid(output, empty_params, delta_len):
319
else:
320
combined_messages += message.tool_calls[0].function.arguments
321
combined_messages += "}]"
330
- assert json.loads(combined_messages) == output
331
- assert json.dumps(json.loads(combined_messages)) == output_json
322
+ return combined_messages
323
324
334
-def test_streaming_output_valid_with_trailing_extra_data():
335
- output = [{"name": "get_current_weather", "parameters": {"city": "Vienna"}}]
336
- output_json = json.dumps(output) + "\nDONE"
325
+@pytest.mark.parametrize("output", VALID_TOOLS)
326
+@pytest.mark.parametrize("empty_params", [False, True])
327
+@pytest.mark.parametrize("delta_len", [1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
328
+def test_streaming_output_valid(output, empty_params, delta_len):
329
+ output = deepcopy(output)
330
+ if empty_params:
331
+ output = [{"name": o["name"], "parameters": {}} for o in output]
332
+ output_json = json.dumps(output)
333
338
- previous_text = ""
339
- function_name_returned = False
340
- messages = []
341
- delta_len = 3
342
- for i in range(0, len(output_json), delta_len):
343
- delta_text = output_json[i : i + delta_len]
344
- current_text = previous_text + delta_text
334
+ combined_messages = _collect_required_tool_streaming_json(output_json, delta_len)
335
+ assert json.loads(combined_messages) == output
336
+ assert json.dumps(json.loads(combined_messages)) == output_json
337
346
- delta_message, function_name_returned = extract_required_tool_call_streaming(
347
- previous_text=previous_text,
348
- current_text=current_text,
349
- delta_text=delta_text,
350
- function_name_returned=function_name_returned,
351
- tool_call_idx=None,
352
- tool_call_id_type="random",
353
- )
338
355
- if delta_message:
356
- messages.append(delta_message)
339
+@pytest.mark.parametrize(
340
+ "city",
341
+ [
342
+ "a { b",
343
+ "a } b",
344
+ "a }} b",
345
+ 'a " } b',
346
+ r"a \ } b",
347
+ ],
348
+)
349
+@pytest.mark.parametrize("delta_len", [1, 2, 3, 8, 9999])
350
+def test_streaming_output_valid_with_braces_in_string(city, delta_len):
351
+ output = [{"name": "get_current_weather", "parameters": {"city": city}}]
352
+ output_json = json.dumps(output)
353
+ combined_messages = _collect_required_tool_streaming_json(output_json, delta_len)
354
+ assert json.loads(combined_messages) == output
355
+ assert json.dumps(json.loads(combined_messages)) == output_json
356
358
- previous_text = current_text
357
360
- assert len(messages) > 0
358
+def test_streaming_output_valid_with_trailing_extra_data():
359
+ output = [{"name": "get_current_weather", "parameters": {"city": "Vienna"}}]
360
+ output_json = json.dumps(output) + "\nDONE"
361
+ combined_messages = _collect_required_tool_streaming_json(output_json, delta_len=3)
362
+ assert json.loads(combined_messages) == output
363
364
365
FUNCTION_TOOL = FunctionTool(
vllm/tool_parsers/streaming.py
+43
-11
@@ -23,14 +23,33 @@ else:
23
TokenizerLike = object
24
25
26
-def _bracket_level(s: str, opening: str = "{", closing: str = "}") -> int:
27
- """Calculate the current level of nested brackets in a string."""
26
+def _bracket_level_state(
27
+ s: str, opening: str = "{", closing: str = "}"
28
+) -> tuple[int, bool, bool]:
29
level = 0
30
+ in_string = False
31
+ escaped = False
32
for char in s:
30
- if char == opening:
31
- level += 1
32
- elif char == closing:
33
- level -= 1
33
+ if escaped:
34
+ escaped = False
35
+ continue
36
+ if in_string and char == "\\":
37
+ escaped = True
38
+ continue
39
+ if char == '"':
40
+ in_string = not in_string
41
+ continue
42
+ if not in_string:
43
+ if char == opening:
44
+ level += 1
45
+ elif char == closing:
46
+ level -= 1
47
+ return level, in_string, escaped
48
+
49
+
50
+def _bracket_level(s: str, opening: str = "{", closing: str = "}") -> int:
51
+ """Calculate the current level of nested brackets in a string."""
52
+ level, _, _ = _bracket_level_state(s, opening, closing)
53
return level
54
55
@@ -39,11 +58,20 @@ def filter_delta_text(
58
previous_text: str,
59
) -> tuple[str, bool]:
60
"""Trim trailing tool-list delimiters from required-tool streaming text."""
42
- bracket_level = _bracket_level(previous_text)
61
+ bracket_level, in_string, escaped = _bracket_level_state(previous_text)
62
updated_delta = ""
63
passed_zero = False
64
for char in delta_text:
46
- if char == "{":
65
+ if escaped:
66
+ escaped = False
67
+ elif in_string:
68
+ if char == "\\":
69
+ escaped = True
70
+ elif char == '"':
71
+ in_string = False
72
+ elif char == '"':
73
+ in_string = True
74
+ elif char == "{":
75
bracket_level += 1
76
passed_zero = bracket_level == 0
77
elif char == "}":
@@ -53,7 +81,7 @@ def filter_delta_text(
81
if bracket_level != 0:
82
updated_delta += char
83
else:
56
- if char == ",":
84
+ if not in_string and char == ",":
85
break
86
return updated_delta, passed_zero
87
@@ -146,8 +174,12 @@ def extract_required_tool_call_streaming(
174
param_match = re.search(
175
r'.*"parameters":\s*(.*)', current_text, re.DOTALL
176
)
149
- arguments = param_match.group(1) if param_match else ""
150
- arguments, _ = filter_delta_text(arguments, previous_text)
177
+ if param_match:
178
+ arguments = param_match.group(1)
179
+ arguments_prefix = current_text[: param_match.start(1)]
180
+ arguments, _ = filter_delta_text(arguments, arguments_prefix)
181
+ else:
182
+ arguments = ""
183
184
# if this iteration finishes a previous tool call but a
185
# new incomplete tool is already generated, take the