From 4463fa5db9f961517fe5a27f8a4a362bdeea9d25 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 18 Mar 2026 16:32:39 +0000 Subject: [PATCH] Stop tool-call XML from leaking into chat UI Two fixes for raw XML appearing in chat bubbles: 1. Non-streaming tool loop: llama-server can return BOTH structured tool_calls AND raw XML in the content field at the same time. Previously the XML stripping only ran in the fallback path (when no structured tool_calls were found). Now it always strips tool-call XML from content_text when any tool calls are present, regardless of whether they came from the structured field or the XML fallback parser. 2. Final streaming pass: add "" and " blocks since they - # can contain arbitrary content including code. - import re - - # Strip ... blocks (greedy inside) - content_text = re.sub( - r".*?", - "", - content_text, - flags = re.DOTALL, - ) - # Strip unterminated ... to end - content_text = re.sub( - r".*$", - "", - content_text, - flags = re.DOTALL, - ) - # Strip bare ... blocks - content_text = re.sub( - r".*?", - "", - content_text, - flags = re.DOTALL, - ) - # Strip unterminated bare to end - content_text = re.sub( - r".*$", - "", - content_text, - flags = re.DOTALL, - ).strip() logger.info( f"Parsed {len(tool_calls)} tool call(s) from content text" ) + # Always strip tool-call XML from content_text when any tool + # calls are present. llama-server may return structured + # tool_calls AND also leave XML in the content + # field, which would leak into the chat UI and conversation. + if ( + auto_heal_tool_calls + and tool_calls + and ("" in content_text or ".*?", + "", + content_text, + flags = re.DOTALL, + ) + content_text = re.sub( + r".*$", + "", + content_text, + flags = re.DOTALL, + ) + content_text = re.sub( + r".*?", + "", + content_text, + flags = re.DOTALL, + ) + content_text = re.sub( + r".*$", + "", + content_text, + flags = re.DOTALL, + ).strip() + if finish_reason == "tool_calls" or (tool_calls and len(tool_calls) > 0): # Append the assistant message with tool_calls to conversation assistant_msg = {"role": "assistant", "content": content_text} @@ -1815,7 +1818,11 @@ class LlamaCppBackend: # Clear status yield {"type": "status", "text": ""} - # Final streaming pass with the full conversation context + # Final streaming pass with the full conversation context. + # Add stop sequences so the model cannot emit tool-call XML -- + # the non-streaming loop above already handled all tool + # iterations. If the model tries to call tools here it will + # simply stop, and we yield whatever text came before. stream_payload = { "messages": conversation, "stream": True, @@ -1832,19 +1839,16 @@ class LlamaCppBackend: } if max_tokens is not None: stream_payload["max_tokens"] = max_tokens - if stop: - stream_payload["stop"] = stop + _stop = list(stop) if stop else [] + if auto_heal_tool_calls: + _stop += ["", ".*?", _re_final.DOTALL), _re_final.compile(r".*?", _re_final.DOTALL), - ] - # Open-ended patterns strip from an opening tag to end-of-string. - # Only applied on the final flush to avoid non-monotonic shrinking. - _TOOL_ALL_PATTERNS = _TOOL_CLOSED_PATTERNS + [ _re_final.compile(r".*$", _re_final.DOTALL), _re_final.compile(r".*$", _re_final.DOTALL), ] @@ -1852,8 +1856,7 @@ class LlamaCppBackend: def _strip_tool_markup(text: str, *, final: bool = False) -> str: if not auto_heal_tool_calls: return text - patterns = _TOOL_ALL_PATTERNS if final else _TOOL_CLOSED_PATTERNS - for pat in patterns: + for pat in _TOOL_PATTERNS: text = pat.sub("", text) return text.strip() if final else text