[Bugfix][Frontend] Enforce parallel_tool_calls=false in the required-tool grammar (#50502)
Signed-off-by: ostring <ostring@qq.com> Signed-off-by: sfeng33 <4florafeng@gmail.com> Co-authored-by: Flora Feng <4florafeng@gmail.com>
Ostring24 committed
Sep 30, 2026 at 05:33 UTC
4e7af3b0de4cae4f70056da590f2f3d676223978
8 files changed
+261
-38
tests/tool_parsers/test_functiongemma_tool_parser.py
+1
@@ -27,6 +27,7 @@ def mock_request():
27
request = MagicMock(spec=ChatCompletionRequest)
28
request.tools = []
29
request.tool_choice = "auto"
30
+ request.parallel_tool_calls = True
31
return request
32
33
tests/tool_parsers/test_structural_tag_registry.py
+139
-13
@@ -1317,27 +1317,153 @@ def test_mimo_strict_compact_xml(policy):
1317
)
1318
1319
1320
-@pytest.mark.parametrize("policy", ["auto", "required"])
1321
-def test_mimo_request_limits_parallel_calls(sample_tools_strict, policy):
1320
+_DS_CALLS_BEGIN = "<\uff5ctool\u2581calls\u2581begin\uff5c>"
1321
+_DS_CALL_BEGIN = "<\uff5ctool\u2581call\u2581begin\uff5c>"
1322
+_DS_SEP = "<\uff5ctool\u2581sep\uff5c>"
1323
+_DS_CALL_END = "<\uff5ctool\u2581call\u2581end\uff5c>"
1324
+_DS_CALLS_END = "<\uff5ctool\u2581calls\u2581end\uff5c>"
1325
+
1326
+# One entry per tool-call list shape: top-level triggered tags (mimo, qwen3),
1327
+# tags with a separator (hermes required), and a calls block wrapping the list
1328
+# (deepseek). Each is (tool_parser, list prefix, one call, list suffix).
1329
+_SINGLE_CALL_CASES = [
1330
+ (
1331
+ "mimo",
1332
+ "",
1333
+ "<tool_call><function=get_weather><parameter=city>Paris</parameter>"
1334
+ "</function></tool_call>",
1335
+ "",
1336
+ ),
1337
+ (
1338
+ "hermes",
1339
+ "",
1340
+ '<tool_call>\n{"name": "get_weather", "arguments": {"city": "Paris"}}'
1341
+ "\n</tool_call>",
1342
+ "",
1343
+ ),
1344
+ (
1345
+ "qwen3_xml",
1346
+ "",
1347
+ "<tool_call>\n<function=get_weather>\n<parameter=city>\nParis\n"
1348
+ "</parameter>\n</function>\n</tool_call>",
1349
+ "",
1350
+ ),
1351
+ (
1352
+ "deepseek_v31",
1353
+ _DS_CALLS_BEGIN,
1354
+ f'{_DS_CALL_BEGIN}get_weather{_DS_SEP}{{"city": "Paris"}}{_DS_CALL_END}',
1355
+ _DS_CALLS_END,
1356
+ ),
1357
+]
1358
+
1359
+_SINGLE_CALL_IDS = [case[0] for case in _SINGLE_CALL_CASES]
1360
+
1361
+
1362
+def _tool_calling_grammar(
1363
+ tool_parser: str,
1364
+ tools: list[ChatCompletionToolsParam],
1365
+ request: ChatCompletionRequest | ResponsesRequest,
1366
+) -> Grammar | None:
1367
from vllm.parser.parser_manager import ParserManager
1368
1369
+ parser_cls = ParserManager.get_parser(
1370
+ tool_parser_name=tool_parser, enable_auto_tools=True
1371
+ )
1372
+ adjusted = parser_cls(MagicMock(), tools=tools).adjust_request(request)
1373
+ structured_outputs = adjusted.structured_outputs
1374
+ if structured_outputs is None or structured_outputs.structural_tag is None:
1375
+ return None
1376
+ return Grammar.from_structural_tag(structured_outputs.structural_tag)
1377
+
1378
+
1379
+@pytest.mark.parametrize("policy", ["auto", "required"])
1380
+@pytest.mark.parametrize(
1381
+ "tool_parser,prefix,call,suffix", _SINGLE_CALL_CASES, ids=_SINGLE_CALL_IDS
1382
+)
1383
+def test_parallel_tool_calls_false_limits_grammar_to_one_call(
1384
+ sample_tools, tool_parser, prefix, call, suffix, policy
1385
+):
1386
+ # Tools are not strict, so auto gets a grammar only because of the limit.
1387
request = ChatCompletionRequest(
1325
- model="mimo",
1388
+ model="m",
1389
messages=[],
1327
- tools=[t.model_dump() for t in sample_tools_strict],
1390
+ tools=[t.model_dump() for t in sample_tools],
1391
tool_choice=policy,
1392
parallel_tool_calls=False,
1393
)
1331
- parser_cls = ParserManager.get_parser(
1332
- tool_parser_name="mimo", enable_auto_tools=True
1394
+
1395
+ grammar = _tool_calling_grammar(tool_parser, sample_tools, request)
1396
+
1397
+ assert grammar is not None
1398
+ assert _is_grammar_accept_string(grammar, prefix + call + suffix)
1399
+ assert not _is_grammar_accept_string(grammar, prefix + call * 2 + suffix)
1400
+ assert not _is_grammar_accept_string(grammar, prefix + call + suffix + "text")
1401
+
1402
+
1403
+@pytest.mark.parametrize(
1404
+ "tool_parser,prefix,call,suffix", _SINGLE_CALL_CASES, ids=_SINGLE_CALL_IDS
1405
+)
1406
+def test_parallel_tool_calls_default_keeps_grammar_unlimited(
1407
+ sample_tools, tool_parser, prefix, call, suffix
1408
+):
1409
+ def request(policy: str) -> ChatCompletionRequest:
1410
+ return ChatCompletionRequest(
1411
+ model="m",
1412
+ messages=[],
1413
+ tools=[t.model_dump() for t in sample_tools],
1414
+ tool_choice=policy,
1415
+ )
1416
+
1417
+ assert _tool_calling_grammar(tool_parser, sample_tools, request("auto")) is None
1418
+ grammar = _tool_calling_grammar(tool_parser, sample_tools, request("required"))
1419
+ assert grammar is not None
1420
+ assert _is_grammar_accept_string(grammar, prefix + call * 2 + suffix)
1421
+
1422
+
1423
+def test_parallel_tool_calls_false_does_not_enable_calls_with_response_format(
1424
+ sample_tools,
1425
+):
1426
+ # With the flag unset, auto + response_format + non-strict tools is
1427
+ # format-only. Setting the flag to false must not make tool calls possible.
1428
+ request = ChatCompletionRequest(
1429
+ model="m",
1430
+ messages=[],
1431
+ tools=[t.model_dump() for t in sample_tools],
1432
+ tool_choice="auto",
1433
+ parallel_tool_calls=False,
1434
+ response_format={
1435
+ "type": "json_schema",
1436
+ "json_schema": {"name": "answer", "schema": {"type": "object"}},
1437
+ },
1438
)
1334
- parser = parser_cls(MagicMock(), tools=sample_tools_strict)
1335
- adjusted = parser.adjust_request(request)
1336
- grammar = Grammar.from_structural_tag(adjusted.structured_outputs.structural_tag)
1337
- call = (
1338
- "<tool_call><function=get_weather><parameter=city>北京</parameter>"
1339
- "</function></tool_call>"
1439
+
1440
+ assert _tool_calling_grammar("hermes", sample_tools, request) is None
1441
+
1442
+
1443
+def test_responses_parallel_tool_calls_false_limits_grammar_to_one_call():
1444
+ # The Responses API has no post-hoc filter, so the grammar is the only limit.
1445
+ request = ResponsesRequest.model_validate(
1446
+ {
1447
+ "input": "hi",
1448
+ "tools": [
1449
+ {
1450
+ "type": "function",
1451
+ "name": "get_weather",
1452
+ "parameters": {
1453
+ "type": "object",
1454
+ "properties": {"city": {"type": "string"}},
1455
+ "required": ["city"],
1456
+ },
1457
+ }
1458
+ ],
1459
+ "tool_choice": "required",
1460
+ "parallel_tool_calls": False,
1461
+ }
1462
)
1463
+ _, _, call, _ = _SINGLE_CALL_CASES[1]
1464
+
1465
+ grammar = _tool_calling_grammar("hermes", request.tools, request)
1466
+
1467
+ assert grammar is not None
1468
assert _is_grammar_accept_string(grammar, call)
1469
assert not _is_grammar_accept_string(grammar, call * 2)
1343
- assert not _is_grammar_accept_string(grammar, call + "extra text")
tests/tool_use/test_tool_choice_required.py
+65
-2
@@ -9,6 +9,8 @@ from openai.types.responses import FunctionTool, WebSearchTool
9
from pydantic import TypeAdapter
10
11
from vllm.entrypoints.openai.chat_completion.protocol import (
12
+ ChatCompletionNamedFunction,
13
+ ChatCompletionNamedToolChoiceParam,
14
ChatCompletionToolsParam,
15
)
16
from vllm.tool_parsers.streaming import extract_required_tool_call_streaming
@@ -68,11 +70,18 @@ EXAMPLE_TOOLS = [
70
71
72
def _compile_and_check(
71
- tools: list[ChatCompletionToolsParam], sample_output, should_match: bool
73
+ tools: list[ChatCompletionToolsParam],
74
+ sample_output,
75
+ should_match: bool,
76
+ parallel_tool_calls: bool | None = None,
77
):
78
# self = MagicMock(tool_choice="required", tools=tools)
79
# schema = ChatCompletionRequest._get_json_schema_from_tool(self)
75
- schema = get_json_schema_from_tools(tools=tools, tool_choice="required")
80
+ schema = get_json_schema_from_tools(
81
+ tools=tools,
82
+ tool_choice="required",
83
+ parallel_tool_calls=parallel_tool_calls,
84
+ )
85
assert isinstance(schema, dict)
86
87
# use build_regex_from_schema used in JSONLogitsProcessor to create Guide
@@ -393,3 +402,57 @@ class TestNonFunctionToolsSkipped:
402
any_of = schema["items"]["anyOf"]
403
assert len(any_of) == 1
404
assert any_of[0]["properties"]["name"]["enum"] == ["get_weather"]
405
+
406
+
407
+class TestParallelToolCallsConstraint:
408
+ """`parallel_tool_calls=false` must be enforced by the decoding grammar.
409
+
410
+ Without a `maxItems` bound the model is free to emit an unbounded run of
411
+ tool calls that are only discarded afterwards, wasting the token budget and
412
+ risking truncation of the one call the client actually receives."""
413
+
414
+ TOOLS = TypeAdapter(list[ChatCompletionToolsParam]).validate_python(EXAMPLE_TOOLS)
415
+ ONE_CALL = [{"name": "get_current_weather", "parameters": {"city": "Vienna"}}]
416
+ TWO_CALLS = [
417
+ {"name": "get_current_weather", "parameters": {"city": "Vienna"}},
418
+ {"name": "get_current_weather", "parameters": {"city": "Berlin"}},
419
+ ]
420
+
421
+ def test_disabled_rejects_a_second_tool_call(self):
422
+ _compile_and_check(self.TOOLS, self.ONE_CALL, True, parallel_tool_calls=False)
423
+ _compile_and_check(self.TOOLS, self.TWO_CALLS, False, parallel_tool_calls=False)
424
+
425
+ @pytest.mark.parametrize("parallel_tool_calls", [True, None])
426
+ def test_enabled_or_unset_still_allows_multiple(self, parallel_tool_calls):
427
+ _compile_and_check(
428
+ self.TOOLS,
429
+ self.TWO_CALLS,
430
+ True,
431
+ parallel_tool_calls=parallel_tool_calls,
432
+ )
433
+
434
+ @pytest.mark.parametrize(
435
+ "parallel_tool_calls,expected",
436
+ [(False, 1), (True, None), (None, None)],
437
+ )
438
+ def test_max_items_bound(self, parallel_tool_calls, expected):
439
+ schema = get_json_schema_from_tools(
440
+ tools=self.TOOLS,
441
+ tool_choice="required",
442
+ parallel_tool_calls=parallel_tool_calls,
443
+ )
444
+ assert isinstance(schema, dict)
445
+ assert schema["minItems"] == 1
446
+ assert schema.get("maxItems") == expected
447
+
448
+ def test_forced_named_tool_is_unaffected(self):
449
+ # Named tool choice yields a bare parameters object, never an array.
450
+ schema = get_json_schema_from_tools(
451
+ tools=self.TOOLS,
452
+ tool_choice=ChatCompletionNamedToolChoiceParam(
453
+ function=ChatCompletionNamedFunction(name="get_current_weather")
454
+ ),
455
+ parallel_tool_calls=False,
456
+ )
457
+ assert isinstance(schema, dict)
458
+ assert "maxItems" not in schema
vllm/parser/abstract_parser.py
+15
-3
@@ -49,7 +49,10 @@ from vllm.tool_parsers.streaming import (
49
extract_named_tool_call_streaming,
50
extract_required_tool_call_streaming,
51
)
52
-from vllm.tool_parsers.structural_tag_registry import resolve_tool_strictness
52
+from vllm.tool_parsers.structural_tag_registry import (
53
+ limit_to_single_tool_call,
54
+ resolve_tool_strictness,
55
+)
56
from vllm.tool_parsers.tool_strict_level import ToolStrictLevel
57
58
logger = init_logger(__name__)
@@ -496,13 +499,20 @@ class DelegatingParser(Parser):
499
500
structured_outputs = request.extract_structured_outputs()
501
is_auto = request.tool_choice == "auto"
502
+ single_call = request.parallel_tool_calls is False
503
+ strict_level = self.tool_strict_level
504
+ if single_call and not (is_auto and structured_outputs is not None):
505
+ # Limiting the call count needs the call envelope in the grammar.
506
+ # Auto with structured outputs keeps its format-only grammar, so the
507
+ # flag never makes calls possible that are impossible without it.
508
+ strict_level = max(strict_level, ToolStrictLevel.FUNCTION)
509
510
resolved_tools = None
511
if tool_parser.structural_tag_model is not None:
512
resolved_tools = resolve_tool_strictness(
513
request.tools,
514
request.tool_choice,
505
- self.tool_strict_level,
515
+ strict_level,
516
)
517
518
output_format = None
@@ -529,7 +539,7 @@ class DelegatingParser(Parser):
539
tools_structural_tag = tool_parser.get_structural_tag(
540
tag_request,
541
reasoning=False,
532
- strict_level=self.tool_strict_level,
542
+ strict_level=strict_level,
543
)
544
else:
545
tools_structural_tag = None
@@ -544,6 +554,8 @@ class DelegatingParser(Parser):
554
)
555
return request
556
557
+ if single_call:
558
+ limit_to_single_tool_call(tools_structural_tag)
559
structural_tag = tools_structural_tag
560
if output_format is not None:
561
structural_tag = StructuralTag(
vllm/tool_parsers/abstract_tool_parser.py
+3
-1
@@ -141,7 +141,9 @@ class ToolParser:
141
return request
142
143
json_schema_from_tool = get_json_schema_from_tools(
144
- tool_choice=request.tool_choice, tools=request.tools
144
+ tool_choice=request.tool_choice,
145
+ tools=request.tools,
146
+ parallel_tool_calls=request.parallel_tool_calls,
147
)
148
# Set structured output params for tool calling
149
if json_schema_from_tool is not None:
vllm/tool_parsers/mimo_tool_parser.py
-17
@@ -1,25 +1,8 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
-from xgrammar.structural_tag import TriggeredTagsFormat
5
-
4
from vllm.parser.engine.registered_adapters import MiMoParserToolAdapter
7
-from vllm.tool_parsers.tool_strict_level import ToolStrictLevel
5
6
7
class MiMoToolParser(MiMoParserToolAdapter): # type: ignore[valid-type, misc]
8
structural_tag_model = "mimo"
12
-
13
- def get_structural_tag(
14
- self, request, *, reasoning=False, strict_level=ToolStrictLevel.AUTO
15
- ):
16
- tag = super().get_structural_tag(
17
- request, reasoning=reasoning, strict_level=strict_level
18
- )
19
- if (
20
- tag is not None
21
- and request.parallel_tool_calls is False
22
- and isinstance(tag.format, TriggeredTagsFormat)
23
- ):
24
- tag.format.stop_after_first = True
25
- return tag
vllm/tool_parsers/structural_tag_registry.py
+30
@@ -9,6 +9,7 @@ from openai.types.responses.response import ToolChoice as ResponsesToolChoice
9
from openai.types.responses.tool import Tool as ResponsesTool
10
from openai.types.responses.tool_choice_allowed import ToolChoiceAllowed
11
from openai.types.responses.tool_choice_function import ToolChoiceFunction
12
+from pydantic import BaseModel
13
from xgrammar import StructuralTag, normalize_tool_choice
14
from xgrammar import get_model_structural_tag as get_xgrammar_model_structural_tag
15
from xgrammar.openai_tool_call_schema import (
@@ -27,6 +28,7 @@ from xgrammar.structural_tag import (
28
StarFormat,
29
TagFormat,
30
TagsWithSeparatorFormat,
31
+ TokenTriggeredTagsFormat,
32
TriggeredTagsFormat,
33
)
34
@@ -197,6 +199,34 @@ def get_model_structural_tag(
199
)
200
201
202
+_TOOL_CALL_LIST_FORMATS = (
203
+ TriggeredTagsFormat,
204
+ TokenTriggeredTagsFormat,
205
+ TagsWithSeparatorFormat,
206
+)
207
+
208
+
209
+def limit_to_single_tool_call(tag: StructuralTag) -> StructuralTag:
210
+ """Stop every tool-call list in ``tag`` after its first call, in place.
211
+
212
+ Enforces ``parallel_tool_calls=false`` in the grammar. In every registered
213
+ tag, ``_TOOL_CALL_LIST_FORMATS`` repeat tool calls, never arguments.
214
+ """
215
+
216
+ def visit(node: Any) -> None:
217
+ if isinstance(node, BaseModel):
218
+ if isinstance(node, _TOOL_CALL_LIST_FORMATS):
219
+ node.stop_after_first = True
220
+ for name in type(node).model_fields:
221
+ visit(getattr(node, name))
222
+ elif isinstance(node, (list, tuple)):
223
+ for item in node:
224
+ visit(item)
225
+
226
+ visit(tag.format)
227
+ return tag
228
+
229
+
230
def _dump_tool_for_xgrammar(
231
tool: ChatCompletionToolsParam | ResponsesTool,
232
) -> dict[str, Any]:
vllm/tool_parsers/utils.py
+8
-2
@@ -347,6 +347,7 @@ def _get_tool_schema_defs(
347
348
def _get_json_schema_from_tools(
349
tools: list[Tool],
350
+ parallel_tool_calls: bool | None = None,
351
) -> dict:
352
fn_tool_schemas: list[dict[str, Any]] = []
353
fn_tools: list[Tool] = []
@@ -361,7 +362,7 @@ def _get_json_schema_from_tools(
362
elif _is_function_tool(tool):
363
fn_tool_schemas.append(_get_tool_schema_from_tool(tool))
364
fn_tools.append(tool)
364
- json_schema = {
365
+ json_schema: dict[str, Any] = {
366
"type": "array",
367
"minItems": 1,
368
"items": {
@@ -369,6 +370,10 @@ def _get_json_schema_from_tools(
370
"anyOf": fn_tool_schemas,
371
},
372
}
373
+ # Only an explicit `false` opts out of parallel tool calls; `None` means the
374
+ # field was never set and the default (true) applies.
375
+ if parallel_tool_calls is False:
376
+ json_schema["maxItems"] = 1
377
json_schema_defs = _get_tool_schema_defs(fn_tools)
378
if json_schema_defs:
379
json_schema["$defs"] = json_schema_defs
@@ -378,6 +383,7 @@ def _get_json_schema_from_tools(
383
def get_json_schema_from_tools(
384
tool_choice: str | ToolChoiceFunction | ChatCompletionNamedToolChoiceParam,
385
tools: list[Tool] | None,
386
+ parallel_tool_calls: bool | None = None,
387
) -> str | dict | None:
388
# tool_choice: "none"
389
if tool_choice in ("none", None) or tools is None:
@@ -413,7 +419,7 @@ def get_json_schema_from_tools(
419
return chat_tool_map[tool_name].function.parameters
420
# tool_choice: "required"
421
if tool_choice == "required":
416
- return _get_json_schema_from_tools(tools)
422
+ return _get_json_schema_from_tools(tools, parallel_tool_calls)
423
# tool_choice: "auto"
424
return None
425