[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