fix: stream Qwen3 tool call string arguments (#46351)
Signed-off-by: Rui Yin <2260891073@qq.com> Co-authored-by: abinggo <107740309+abinggo@users.noreply.github.com>
Rui Yin committed
Jun 23, 2026 at 10:26 UTC
8db12169a474c0bdbd9be55d6a749cad4ab16caa
4 files changed
+177
-9
tests/parser/engine/test_parser_engine.py
+40
-1
@@ -1339,6 +1339,42 @@ class TestArgDeltaWithConverter:
1339
assert parsed == {"count": 5, "name": "test"}
1340
assert isinstance(parsed["count"], int)
1341
1342
+ def test_streamable_string_keys_cached_after_name_delta(self, monkeypatch):
1343
+ tool = _make_tool(
1344
+ "f",
1345
+ {
1346
+ "name": {"type": "string"},
1347
+ "count": {"type": "integer"},
1348
+ },
1349
+ )
1350
+ engine = _make_engine(_converter_config(), tools=[tool])
1351
+
1352
+ original = ParserEngine._streamable_string_keys
1353
+ calls: list[dict] = []
1354
+
1355
+ def wrapped(properties: dict) -> set[str] | None:
1356
+ calls.append(properties)
1357
+ return original(properties)
1358
+
1359
+ monkeypatch.setattr(
1360
+ ParserEngine,
1361
+ "_streamable_string_keys",
1362
+ staticmethod(wrapped),
1363
+ )
1364
+
1365
+ _run_streaming_tool(
1366
+ engine,
1367
+ "f",
1368
+ ["name=alice ", "count=4", "2"],
1369
+ )
1370
+
1371
+ assert calls == [
1372
+ {
1373
+ "name": {"type": "string"},
1374
+ "count": {"type": "integer"},
1375
+ }
1376
+ ]
1377
+
1378
1379
# ── TestSafeArgPrefix ────────────────────────────────────────────
1380
@@ -1351,7 +1387,7 @@ class TestSafeArgPrefix:
1387
[
1388
('{"a": 1}', '{"a": '),
1389
('{"a": 1, "b": 2}', '{"a": 1, "b": '),
1354
- ('{"a": "hello", "b": "world"}', '{"a": "hello", "b": '),
1390
+ ('{"a": "hello", "b": "world"}', '{"a": "hello", "b": "world'),
1391
('{"obj": {"x": 1}, "b": 2}', '{"obj": {"x": 1}, "b": '),
1392
('{"url": "http://x:80", "b": 1}', '{"url": "http://x:80", "b": '),
1393
('{"a": 1', '{"a": '),
@@ -1360,6 +1396,9 @@ class TestSafeArgPrefix:
1396
("", ""),
1397
('{"k":1}', '{"k":'),
1398
('{"k": 1, "v":2}', '{"k": 1, "v":'),
1399
+ ('{"k":"value"}', '{"k":"value'),
1400
+ ('{"k":"unterminated', '{"k":"unterminated'),
1401
+ (r'{"k":"escaped \" quote"}', r'{"k":"escaped \" quote'),
1402
],
1403
)
1404
def test_safe_arg_prefix(self, json_str, expected):
tests/parser/engine/test_qwen3.py
+65
-1
@@ -346,6 +346,49 @@ class TestStreaming:
346
parsed = json.loads(concatenated)
347
assert parsed == {"city": "Tokyo", "unit": "celsius", "days": "5"}
348
349
+ def test_streaming_long_string_arg_before_parameter_end(self, parser, mock_request):
350
+ """Long string arguments should stream before the closing parameter tag."""
351
+ chunks = [
352
+ "<tool_call>\n",
353
+ "<function=write_report>\n",
354
+ "<parameter=content>",
355
+ "Artificial intelligence has rapidly transformed the way ",
356
+ "developers build dynamic applications with external tools.",
357
+ "</param",
358
+ "eter>\n",
359
+ "</function>\n",
360
+ "</tool_call>",
361
+ ]
362
+
363
+ results = simulate_tool_streaming(parser, mock_request, chunks)
364
+
365
+ pre_close_arg_deltas: list[str] = []
366
+ all_arg_deltas: list[str] = []
367
+ for idx, (delta, _) in enumerate(results):
368
+ if delta and delta.tool_calls:
369
+ for tc in delta.tool_calls:
370
+ if tc.function and tc.function.arguments:
371
+ all_arg_deltas.append(tc.function.arguments)
372
+ if idx < 5:
373
+ pre_close_arg_deltas.append(tc.function.arguments)
374
+
375
+ assert len(pre_close_arg_deltas) > 1, (
376
+ "Expected long string arguments to stream incrementally before "
377
+ f"</parameter>, got {pre_close_arg_deltas}"
378
+ )
379
+ partial_args = "".join(pre_close_arg_deltas)
380
+ assert partial_args.startswith('{"content": "Artificial intelligence')
381
+ assert partial_args.endswith("external tools.")
382
+ assert not partial_args.endswith('"}')
383
+
384
+ all_args = "".join(all_arg_deltas)
385
+ assert json.loads(all_args) == {
386
+ "content": (
387
+ "Artificial intelligence has rapidly transformed the way "
388
+ "developers build dynamic applications with external tools."
389
+ )
390
+ }
391
+
392
def test_streaming_text_before_tool(self, parser, mock_request):
393
chunks = [
394
"Let me check ",
@@ -395,6 +438,27 @@ class TestStreaming:
438
parsed = json.loads(args_text)
439
assert parsed["name"] == "Alice"
440
441
+ def test_streaming_split_next_parameter_tag_is_buffered(self, parser, mock_request):
442
+ """A split opening parameter tag must not leak into previous value."""
443
+ chunks = [
444
+ "<tool_call>\n",
445
+ "<function=search>\n",
446
+ "<parameter=query>hello ",
447
+ "<param",
448
+ "eter=limit>10</parameter>\n",
449
+ "</function>\n",
450
+ "</tool_call>",
451
+ ]
452
+
453
+ results = simulate_tool_streaming(parser, mock_request, chunks)
454
+
455
+ args_after_partial_tag = collect_tool_arguments(results[:4])
456
+ assert "<param" not in args_after_partial_tag
457
+ assert args_after_partial_tag == '{"query": "hello'
458
+
459
+ args_text = collect_tool_arguments(results)
460
+ assert json.loads(args_text) == {"query": "hello", "limit": "10"}
461
+
462
def test_streaming_numeric_values(self, parser, mock_request):
463
chunks = [
464
"<tool_call>\n",
@@ -613,7 +677,7 @@ class TestArgConverter:
677
raw = "<parameter=command>\nls -la</parameter>\n<parameter=desc>\npartial value"
678
result = json.loads(_qwen3_arg_converter(raw, partial=True))
679
assert result["command"] == "ls -la"
616
- assert result["desc"] == "\npartial value"
680
+ assert result["desc"] == "partial value"
681
682
def test_partial_value_with_angle_bracket(self):
683
from vllm.parser.qwen3 import (
vllm/parser/engine/parser_engine.py
+59
-6
@@ -52,6 +52,7 @@ class ToolCallSlot:
52
"_args_parts",
53
"_args_joined",
54
"name_sent",
55
+ "string_keys",
56
"streamed_json",
57
)
58
@@ -61,6 +62,7 @@ class ToolCallSlot:
62
self._args_parts: list[str] = []
63
self._args_joined: str | None = ""
64
self.name_sent: bool = False
65
+ self.string_keys: set[str] | None = None
66
self.streamed_json: str = ""
67
68
@property
@@ -264,17 +266,23 @@ class ParserEngine(Parser):
266
return args, changed
267
268
@staticmethod
267
- def _safe_arg_prefix(json_str: str) -> str:
269
+ def _safe_arg_prefix(json_str: str, string_keys: set[str] | None = None) -> str:
270
"""Return the prefix of *json_str* up to the last top-level value.
271
272
Middle values (followed by a comma) are stable across streaming
271
- ticks and included. The trailing value is excluded because type
272
- coercion may change its serialised form between ticks, which
273
- would violate the ``startswith(prev)`` prefix invariant.
273
+ ticks and included. The trailing value is excluded for non-string
274
+ values because type coercion may change its serialised form between
275
+ ticks, which would violate the ``startswith(prev)`` prefix invariant.
276
+ String values for keys in ``string_keys`` are prefix-stable, so stream
277
+ their unterminated content instead of buffering long arguments until
278
+ the closing tag arrives.
279
"""
280
last_colon = -1
281
+ last_key: str | None = None
282
+ pending_key: str | None = None
283
in_string = False
284
escape = False
285
+ string_start = -1
286
depth = 0
287
for i, c in enumerate(json_str):
288
if escape:
@@ -285,21 +293,60 @@ class ParserEngine(Parser):
293
escape = True
294
elif c == '"':
295
in_string = False
296
+ if depth == 1 and string_start >= 0:
297
+ pending_key = json_str[string_start + 1 : i]
298
continue
299
if c == '"':
300
in_string = True
301
+ string_start = i
302
elif c in ("{", "["):
303
depth += 1
304
elif c in ("}", "]"):
305
depth -= 1
306
elif c == ":" and depth == 1:
307
last_colon = i
308
+ last_key = pending_key
309
+ pending_key = None
310
if last_colon < 0:
311
return ""
312
end = last_colon + 1
313
while end < len(json_str) and json_str[end] in (" ", "\t", "\n", "\r"):
314
end += 1
302
- return json_str[:end]
315
+ if end >= len(json_str) or json_str[end] != '"':
316
+ return json_str[:end]
317
+ if string_keys is not None and last_key not in string_keys:
318
+ return json_str[:end]
319
+
320
+ escape = False
321
+ for i in range(end + 1, len(json_str)):
322
+ c = json_str[i]
323
+ if escape:
324
+ escape = False
325
+ continue
326
+ if c == "\\":
327
+ escape = True
328
+ continue
329
+ if c == '"':
330
+ return json_str[:i]
331
+ return json_str
332
+
333
+ @staticmethod
334
+ def _streamable_string_keys(properties: dict) -> set[str] | None:
335
+ """Return keys whose trailing string values can safely stream.
336
+
337
+ ``None`` means there is no schema, so all string values keep their
338
+ JSON representation as strings. With a schema, only fields that can
339
+ remain strings are safe to emit before the value is closed; fields
340
+ coerced to bool/number/null/object/array may serialize differently.
341
+ """
342
+ if not properties:
343
+ return None
344
+
345
+ streamable: set[str] = set()
346
+ for key, schema in properties.items():
347
+ if set(extract_types_from_schema(schema)) == {"string"}:
348
+ streamable.add(key)
349
+ return streamable
350
351
def _fix_arg_types(self, args_json: str, func_name: str) -> str:
352
"""Correct parameter types using the tool schema.
@@ -736,6 +783,9 @@ class ParserEngine(Parser):
783
slot = self._tool_slots[idx]
784
slot.name = name
785
slot.name_sent = True
786
+ slot.string_keys = self._streamable_string_keys(
787
+ find_tool_properties(self._tools, name)
788
+ )
789
self._ensure_tool_id(slot, name)
790
deltas.append(
791
DeltaToolCall(
@@ -791,6 +841,9 @@ class ParserEngine(Parser):
841
if name and self._is_valid_tool_name(name):
842
slot.name = name
843
slot.name_sent = True
844
+ slot.string_keys = self._streamable_string_keys(
845
+ find_tool_properties(self._tools, name)
846
+ )
847
self._ensure_tool_id(slot, name)
848
deltas.append(
849
DeltaToolCall(
@@ -873,7 +926,7 @@ class ParserEngine(Parser):
926
current_json = self._fix_arg_types(current_json, slot.name)
927
928
prev = slot.streamed_json
876
- safe_json = self._safe_arg_prefix(current_json)
929
+ safe_json = self._safe_arg_prefix(current_json, slot.string_keys)
930
931
if not safe_json or safe_json == prev:
932
return None
vllm/parser/qwen3.py
+13
-1
@@ -42,6 +42,8 @@ TOOL_CALL_START = "<tool_call>"
42
TOOL_CALL_END = "</tool_call>"
43
FUNC_PREFIX = "<function="
44
FUNC_END = "</function>"
45
+PARAM_START = "<parameter="
46
+PARAM_END = "</parameter>"
47
48
_PARAM_RE = re.compile(
49
r"<\s*parameter\s*=\s*([^>]*)>"
@@ -67,7 +69,7 @@ def _qwen3_arg_converter(raw_args: str, partial: bool) -> str:
69
name = m.group(1)
70
value = m.group(2)
71
if name:
70
- params[name] = value
72
+ params[name] = value.strip()
73
74
return json.dumps(params, ensure_ascii=False)
75
@@ -86,6 +88,8 @@ def qwen3_config(thinking: bool = True) -> ParserEngineConfig:
88
"TOOL_END": TOOL_CALL_END,
89
"FUNC_PREFIX": FUNC_PREFIX,
90
"FUNC_END": FUNC_END,
91
+ "PARAM_START": PARAM_START,
92
+ "PARAM_END": PARAM_END,
93
"CLOSE_ANGLE": ">",
94
},
95
token_id_terminals={
@@ -146,6 +150,14 @@ def qwen3_config(thinking: bool = True) -> ParserEngineConfig:
150
ParserState.TOOL_BETWEEN,
151
(EventType.TOOL_CALL_END,),
152
),
153
+ (ParserState.TOOL_ARGS, "PARAM_START"): Transition(
154
+ ParserState.TOOL_ARGS,
155
+ (EventType.ARG_VALUE_CHUNK,),
156
+ ),
157
+ (ParserState.TOOL_ARGS, "PARAM_END"): Transition(
158
+ ParserState.TOOL_ARGS,
159
+ (EventType.ARG_VALUE_CHUNK,),
160
+ ),
161
(ParserState.TOOL_BETWEEN, "TOOL_END"): Transition(
162
ParserState.CONTENT,
163
(),