Skip to content

Commit a1ecd79

Browse files
committed
fix(stream-parser): Strip orphaned </tool_call> tags from streamed output
When Qwen on Albert/vLLM emits `<function=...>...</function></tool_call>` without an opening `<tool_call>` wrapper, the parser closed on `</function>` and emitted the trailing `</tool_call>` as visible text. Same leak when the closing tag arrived in a later SSE chunk, or split as `</to` + `ol_call>`. - Add `_ORPHANED_CLOSE_RE` to scrub stray `</tool_call>` and `</function>` from safe content before emission, in `_drain` and `flush`. - Add `_CLOSE_PARTIAL_RE` so a trailing partial closing tag is held back until the rest arrives. - After extracting a bare `<function>...</function>` block, consume any immediately-following `</tool_call>` silently. - 5 new tests (`TestXmlToolCallStreamParserOrphanedClose`) covering same chunk, separate chunk, split-tag, flush leftover, and trailing text.
1 parent a96c6bb commit a1ecd79

2 files changed

Lines changed: 98 additions & 7 deletions

File tree

‎albert_code/core/llm/backend/generic.py‎

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,17 @@ def parse_response(
268268
_FN_OPEN_PARTIAL_RE = re.compile(
269269
r"<(?:f(?:u(?:n(?:c(?:t(?:i(?:o(?:n(?:=\w*)?)?)?)?)?)?)?)?)?$"
270270
)
271+
# Orphaned closing tags left over after the parser grabs a bare
272+
# <function=...>...</function> block without its <tool_call> wrapper.
273+
_ORPHANED_CLOSE_RE = re.compile(r"\s*</(?:tool_call|function)>")
274+
# Trailing fragment that could be the start of a closing tag once more text
275+
# arrives. Matches `</`, `</t`, ..., `</tool_call`, `</f`, ..., `</function`.
276+
_CLOSE_PARTIAL_RE = re.compile(
277+
r"</"
278+
r"(?:t(?:o(?:o(?:l(?:_(?:c(?:a(?:l(?:l)?)?)?)?)?)?)?)?|"
279+
r"f(?:u(?:n(?:c(?:t(?:i(?:o(?:n)?)?)?)?)?)?)?)?"
280+
r"$"
281+
)
271282

272283

273284
class XmlToolCallStreamParser:
@@ -308,8 +319,8 @@ def flush(self) -> tuple[str, list[ToolCall]]:
308319
leftover, start_index=self._next_tool_index
309320
)
310321
self._next_tool_index += len(tool_calls)
311-
return cleaned, tool_calls
312-
return leftover, []
322+
return _ORPHANED_CLOSE_RE.sub("", cleaned), tool_calls
323+
return _ORPHANED_CLOSE_RE.sub("", leftover), []
313324

314325
def _find_open(self) -> tuple[int, str]:
315326
"""Earliest opening marker as (index, kind). Returns (-1, '') if none.
@@ -345,14 +356,20 @@ def _find_close(self, open_kind: str, after: int) -> tuple[int, int]:
345356
candidates.append((tc_close_idx, len(_TOOL_CALL_CLOSE)))
346357
return min(candidates) if candidates else (-1, 0)
347358

348-
def _trailing_partial_open_length(self) -> int:
349-
"""Length of the trailing fragment that could be the start of an opener."""
359+
def _trailing_partial_tag_length(self) -> int:
360+
"""Length of the trailing fragment that could be the start of any tag."""
350361
tc_partial = _partial_tag_suffix_length(self._buffer, _TOOL_CALL_OPEN)
351362
fn_match = _FN_OPEN_PARTIAL_RE.search(self._buffer)
352363
fn_partial = (
353364
len(self._buffer) - fn_match.start() if fn_match and fn_match.group() else 0
354365
)
355-
return max(tc_partial, fn_partial)
366+
close_match = _CLOSE_PARTIAL_RE.search(self._buffer)
367+
close_partial = (
368+
len(self._buffer) - close_match.start()
369+
if close_match and close_match.group()
370+
else 0
371+
)
372+
return max(tc_partial, fn_partial, close_partial)
356373

357374
def _drain(self) -> tuple[str, list[ToolCall]]:
358375
safe_parts: list[str] = []
@@ -361,7 +378,7 @@ def _drain(self) -> tuple[str, list[ToolCall]]:
361378
open_idx, open_kind = self._find_open()
362379

363380
if open_idx < 0:
364-
partial = self._trailing_partial_open_length()
381+
partial = self._trailing_partial_tag_length()
365382
if partial == 0:
366383
safe_parts.append(self._buffer)
367384
self._buffer = ""
@@ -386,9 +403,15 @@ def _drain(self) -> tuple[str, list[ToolCall]]:
386403
self._next_tool_index += len(parsed)
387404
new_calls.extend(parsed)
388405
self._buffer = self._buffer[end:]
406+
# After extracting a bare <function>…</function>, an orphaned
407+
# </tool_call> wrapper may remain; consume it silently.
408+
stripped = self._buffer.lstrip()
409+
if stripped.startswith(_TOOL_CALL_CLOSE):
410+
self._buffer = stripped[len(_TOOL_CALL_CLOSE) :]
389411
continue
390412

391-
return "".join(safe_parts), new_calls
413+
safe_text = _ORPHANED_CLOSE_RE.sub("", "".join(safe_parts))
414+
return safe_text, new_calls
392415

393416

394417
def _partial_tag_suffix_length(text: str, tag: str) -> int:

‎tests/backend/test_xml_stream_parser.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,71 @@ def test_partial_function_open_released_when_no_match(self) -> None:
217217
safe, calls = parser.feed("tioning")
218218
assert calls == []
219219
assert safe == "<functioning"
220+
221+
222+
class TestXmlToolCallStreamParserOrphanedClose:
223+
"""Coverage for orphaned closing tags that leak into visible output.
224+
225+
When the model emits <function=...>...</function></tool_call>, the parser
226+
grabs the inner <function> block first. The leftover </tool_call> must
227+
be stripped rather than displayed to the user.
228+
"""
229+
230+
def test_orphaned_close_after_bare_function_same_chunk(self) -> None:
231+
parser = _make_parser()
232+
text = (
233+
"<function=read_file>"
234+
"<parameter=path>README.md</parameter>"
235+
"</function>"
236+
"\n</tool_call>"
237+
)
238+
safe, calls = parser.feed(text)
239+
assert "</tool_call>" not in safe
240+
assert safe.strip() == ""
241+
assert len(calls) == 1
242+
assert calls[0].function.name == "read_file"
243+
244+
def test_orphaned_close_arrives_in_separate_chunk(self) -> None:
245+
parser = _make_parser()
246+
s1, c1 = parser.feed(
247+
"<function=read_file><parameter=path>README.md</parameter></function>"
248+
)
249+
s2, c2 = parser.feed("\n</tool_call>")
250+
assert len(c1) == 1
251+
assert "</tool_call>" not in s1
252+
assert "</tool_call>" not in s2
253+
254+
def test_orphaned_close_split_across_chunks(self) -> None:
255+
"""</tool_call> arrives as </to then ol_call>."""
256+
parser = _make_parser()
257+
parser.feed(
258+
"<function=read_file><parameter=path>README.md</parameter></function>"
259+
)
260+
s1, _ = parser.feed("</to")
261+
s2, _ = parser.feed("ol_call>")
262+
combined = s1 + s2
263+
assert "</tool_call>" not in combined
264+
assert combined.strip() == ""
265+
266+
def test_orphaned_close_in_flush(self) -> None:
267+
parser = _make_parser()
268+
parser.feed(
269+
"<function=read_file><parameter=path>README.md</parameter></function>"
270+
)
271+
parser.feed("\n</tool_call>")
272+
leftover_safe, leftover_calls = parser.flush()
273+
assert "</tool_call>" not in leftover_safe
274+
275+
def test_text_after_orphaned_close_is_preserved(self) -> None:
276+
parser = _make_parser()
277+
text = (
278+
"<function=read_file>"
279+
"<parameter=path>README.md</parameter>"
280+
"</function>"
281+
"</tool_call>"
282+
"Now I summarize."
283+
)
284+
safe, calls = parser.feed(text)
285+
assert "Now I summarize." in safe
286+
assert "</tool_call>" not in safe
287+
assert len(calls) == 1

0 commit comments

Comments
 (0)