Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions python/sglang/srt/function_call/glm47_moe_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,28 @@ def _process_xml_to_json_streaming(
) and closing_tag.startswith(self._xml_tag_buffer)

if not is_potential_closing:
content = self._xml_tag_buffer
# The buffer is not a prefix of "</arg_value>", but a
# proper suffix of it might still be. Slide forward to
# the smallest k > 0 such that buffer[k:] is a prefix
# of the closing tag and keep that suffix buffered;
# everything before it is real value content. Without
# this, a value ending with "<" (e.g. ".<") followed
# immediately by "</arg_value>" would yield buffer
# "<<", get ejected wholesale, and the actual closing
# tag would never match — leaking "</arg_value>" into
# the emitted JSON arguments.
kept_suffix = ""
for k in range(1, len(self._xml_tag_buffer)):
candidate = self._xml_tag_buffer[k:]
if closing_tag.startswith(candidate):
kept_suffix = candidate
break
content = (
self._xml_tag_buffer[: -len(kept_suffix)]
if kept_suffix
else self._xml_tag_buffer
)

# Use cached value type for consistency
value_type = self._cached_value_type or "string"

Expand All @@ -425,22 +446,21 @@ def _process_xml_to_json_streaming(
1:-1
]
self._current_value += content
self._xml_tag_buffer = ""
elif value_type == "number":
if content:
if not self._value_started:
self._value_started = True
json_output += content
self._current_value += content
self._xml_tag_buffer = ""
else:
# For object/array types, output as-is
if content:
if not self._value_started:
self._value_started = True
json_output += content
self._current_value += content
self._xml_tag_buffer = ""

self._xml_tag_buffer = kept_suffix

return json_output

Expand Down
71 changes: 71 additions & 0 deletions test/registered/unit/function_call/test_function_call_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2618,6 +2618,77 @@ def test_whitespace_preserved_in_arg_values(self):
self.assertEqual(params["old_string"], " indented code")
self.assertEqual(params["new_string"], " also indented")

def test_streaming_value_ending_with_less_than(self):
"""Streaming: a value whose last character overlaps the start of </arg_value>.

When the value ends with '<', the next character emitted by the model
is the '<' that opens the closing tag. Without correct prefix-suffix
sliding, the buffer becomes '<<', fails the prefix check, and the
entire buffer is ejected as content — at which point the actual
closing tag is never matched and "</arg_value>" leaks into the
emitted JSON arguments.
"""

def _stream(chunks):
detector = Glm47MoeDetector()
emitted = ""
for chunk in chunks:
result = detector.parse_streaming_increment(chunk, self.tools)
for call in result.calls:
if call.parameters:
emitted += call.parameters
return emitted

# Single chunk, value ending in '<'.
text = (
"<tool_call>get_weather"
"<arg_key>city</arg_key><arg_value>.<</arg_value>"
"<arg_key>date</arg_key><arg_value>2024-06-27</arg_value>"
"</tool_call>"
)
emitted = _stream([text])
self.assertEqual(json.loads(emitted), {"city": ".<", "date": "2024-06-27"})

# Realistic chunking: value and closing tag arrive in separate chunks.
emitted = _stream(
[
"<tool_call>get_weather<arg_key>city</arg_key><arg_value>",
".<",
"</arg_value><arg_key>date</arg_key>"
"<arg_value>2024-06-27</arg_value></tool_call>",
]
)
self.assertEqual(json.loads(emitted), {"city": ".<", "date": "2024-06-27"})

# Pathological chunking: every character on its own.
emitted = _stream(list(text))
self.assertEqual(json.loads(emitted), {"city": ".<", "date": "2024-06-27"})

def test_streaming_value_ending_with_closing_tag_prefix(self):
"""Streaming: a value ending with a longer prefix of </arg_value>.

Same class of bug as the trailing-'<' case, but with a multi-char
overlap. The state machine must drop the smallest leading chunk such
that what remains is a prefix of "</arg_value>" — not eject the whole
buffer.
"""
detector = Glm47MoeDetector()
text = (
"<tool_call>get_weather"
"<arg_key>city</arg_key><arg_value></arg_va</arg_value>"
"<arg_key>date</arg_key><arg_value>2024-06-27</arg_value>"
"</tool_call>"
)
emitted = ""
for chunk in list(text):
result = detector.parse_streaming_increment(chunk, self.tools)
for call in result.calls:
if call.parameters:
emitted += call.parameters
self.assertEqual(
json.loads(emitted), {"city": "</arg_va", "date": "2024-06-27"}
)


class TestJsonArrayParser(unittest.TestCase):
def setUp(self):
Expand Down
Loading