[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