Skip to content

Commit 092338b

Browse files
author
Eric Hartford
committed
Parser engine: a delimiter spelled with ordinary tokens is structure inside an open tool call
ROOT CAUSE of the GLM-5.3 tool-call leaks. Once token IDs are available the engine treats a terminal configured as a token ID as structure only when it arrives as that token, so a model discussing "<tool_call>" in prose cannot open a call. That protection also applied INSIDE an open call, where there is no prose to protect. GLM-5.3 sometimes spells a mid-call delimiter with ordinary tokens - most often right after an array-valued argument, where the closing "]" shifts the distribution away from the special token - and the machine then dropped out of the call: the argument value held back by _safe_arg_prefix, the remaining delimiters and every later call all arrived as raw XML in message content with no tool_calls at all. That is exactly what HexisPro measured (~10% of real 43-tool agent turns, both APIs, streamed and not, an array argument in every captured case) and what reproduces here: arguments stop at '"sources": ' and the content carries '["history", "memory"]</arg_value></tool_call><tool_call>...'. Inside a tool state the textual spelling is now accepted, so both spellings produce identical calls; outside one, prose still cannot open a call. 3 tests pin both directions; 491 pass across tests/parser, tests/tool_parsers and tests/tool_use.
1 parent 5751dbd commit 092338b

2 files changed

Lines changed: 128 additions & 1 deletion

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""A mid-call delimiter spelled with ordinary tokens is still structure.
3+
4+
Once token IDs are available, a terminal configured as a token ID is only
5+
structure when it arrives as that token, so a model discussing "<tool_call>"
6+
in prose cannot open a call. That protection used to apply inside an open
7+
call too, where there is no prose to protect: GLM-5.3 sometimes spells a
8+
mid-call delimiter with ordinary tokens - most often right after an
9+
array-valued argument - and the machine then dropped out of the call, so the
10+
held-back argument value, the remaining delimiters and every later call
11+
arrived as raw XML in message content with no tool_calls at all (HexisPro:
12+
~10% of real agent turns, 2026-10-15).
13+
"""
14+
import pytest
15+
16+
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
17+
from vllm.tool_parsers.glm47_moe_tool_parser import Glm47MoeModelToolParser
18+
19+
TOOLS = [
20+
{"type": "function", "function": {"name": "search_history", "parameters": {"type": "object",
21+
"properties": {"query": {"type": "string"},
22+
"sources": {"type": "array", "items": {"type": "string"}}},
23+
"required": ["query"]}}},
24+
{"type": "function", "function": {"name": "open_document_chunk", "parameters": {"type": "object",
25+
"properties": {"chunk_ids": {"type": "array", "items": {"type": "string"}}},
26+
"required": ["chunk_ids"]}}},
27+
]
28+
EXPECTED_ARGS = '{"query": "Q3 budget", "sources": ["history", "memory"]}{"chunk_ids": ["644cca64"]}'
29+
30+
31+
def _request():
32+
req = ChatCompletionRequest.model_validate(
33+
{"model": "zai-org/GLM-4.7", "messages": [{"role": "user", "content": "x"}],
34+
"tools": TOOLS, "tool_choice": "auto", "stream": True})
35+
req.include_reasoning = True
36+
return req
37+
38+
39+
def _stream(tokenizer, segments):
40+
"""Feed one (text, spelling) script token by token, as the server does."""
41+
req = _request()
42+
parser = Glm47MoeModelToolParser(tokenizer, tools=req.tools)
43+
44+
def ids_for(text, spelling):
45+
if spelling != "ordinary":
46+
return tokenizer.encode(text, add_special_tokens=False)
47+
opening = "</" if text.startswith("</") else "<"
48+
pieces = (opening, text.strip("</>"), ">")
49+
return [t for piece in pieces for t in tokenizer.encode(piece, add_special_tokens=False)]
50+
51+
deltas = [(tokenizer.decode([t]), t)
52+
for text, spelling in segments for t in ids_for(text, spelling)]
53+
prev, content, args, names, seen = "", "", "", [], []
54+
for piece, tid in deltas:
55+
delta = parser.extract_tool_calls_streaming(
56+
previous_text=prev, current_text=prev + piece, delta_text=piece,
57+
previous_token_ids=list(seen), current_token_ids=list(seen) + [tid],
58+
delta_token_ids=[tid], request=req)
59+
prev += piece
60+
seen.append(tid)
61+
if delta is None:
62+
continue
63+
content += delta.content or ""
64+
for call in (delta.tool_calls or []):
65+
if call.function and call.function.name:
66+
names.append(call.function.name)
67+
if call.function and call.function.arguments:
68+
args += call.function.arguments
69+
return names, args, content
70+
71+
72+
def _script(closing_spelling):
73+
"""Two calls; the first one's closing delimiters use `closing_spelling`."""
74+
return (
75+
[("I'll search.", "text")]
76+
+ [("<tool_call>", "special"), ("search_history", "text"),
77+
("<arg_key>", "special"), ("query", "text"), ("</arg_key>", "special"),
78+
("<arg_value>", "special"), ("Q3 budget", "text"), ("</arg_value>", "special"),
79+
("<arg_key>", "special"), ("sources", "text"), ("</arg_key>", "special"),
80+
("<arg_value>", "special"), ('["history", "memory"]', "text")]
81+
+ [("</arg_value>", closing_spelling), ("</tool_call>", closing_spelling)]
82+
+ [("<tool_call>", "special"), ("open_document_chunk", "text"),
83+
("<arg_key>", "special"), ("chunk_ids", "text"), ("</arg_key>", "special"),
84+
("<arg_value>", "special"), ('["644cca64"]', "text"),
85+
("</arg_value>", "special"), ("</tool_call>", "special")]
86+
)
87+
88+
89+
@pytest.mark.usefixtures("glm53_tool_profile")
90+
@pytest.mark.parametrize("spelling", ["special", "ordinary"])
91+
def test_both_spellings_yield_the_same_calls(glm47_tokenizer, spelling):
92+
names, args, content = _stream(glm47_tokenizer, _script(spelling))
93+
assert names == ["search_history", "open_document_chunk"]
94+
# The array argument survives whole, and nothing structural reaches text.
95+
assert args == EXPECTED_ARGS
96+
assert content == "I'll search."
97+
98+
99+
@pytest.mark.usefixtures("glm53_tool_profile")
100+
def test_prose_outside_a_call_still_cannot_open_one(glm47_tokenizer):
101+
# The protection this rule exists for: a model quoting the markup in
102+
# ordinary tokens, with no call ever opened by its special token.
103+
names, args, content = _stream(glm47_tokenizer, [
104+
("To call it, emit ", "text"), ("<tool_call>", "ordinary"),
105+
("search_history", "text"), ("</tool_call>", "ordinary"),
106+
])
107+
assert names == [] and args == ""
108+
assert "<tool_call>" in content

‎vllm/parser/engine/streaming_parser_engine.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,26 @@ def _process_lex_tokens(self, tokens: list[LexToken]) -> list[SemanticEvent]:
284284
events: list[SemanticEvent] = []
285285
strict = self._token_id_terminal_names if self._ever_had_token_ids else None
286286
for tok in tokens:
287-
if tok.terminal == CONTENT_TERMINAL or (strict and tok.terminal in strict):
287+
force_content = (
288+
bool(strict)
289+
and tok.terminal in strict
290+
# Once token IDs are available, a terminal configured as a
291+
# token ID is normally only structure when it ARRIVES as that
292+
# token: a model discussing "<tool_call>" in prose must not
293+
# open a call. That protection costs real calls once the
294+
# machine is already inside one. GLM-5.3 sometimes spells a
295+
# mid-call delimiter with ordinary tokens - most often right
296+
# after an array-valued argument, where "]" shifts the
297+
# distribution away from the special token - and forcing that
298+
# spelling to content dropped the machine out of the call: the
299+
# held-back argument value, the remaining delimiters and every
300+
# later call all arrived as raw XML in the message text, with
301+
# no tool_calls at all (HexisPro: ~10% of real agent turns,
302+
# 2026-10-15). Inside a tool there is no prose to protect, so
303+
# the textual spelling is structure there.
304+
and self.state not in self._TOOL_STATES
305+
)
306+
if tok.terminal == CONTENT_TERMINAL or force_content:
288307
events.extend(self._on_content(tok.value))
289308
else:
290309
events.extend(self._on_terminal(tok.terminal, tok.value))

0 commit comments

Comments
 (0)