Fork for thermals request add-json-schema-dpeq
0

Configure Feed

Select the types of activity you want to include in your feed.

Strip orphan tool-call closer tags from the visible stream (#115)

Live ZAYA / Gemma-4 AppleScript rows emit stray
</parameter></function></zyphra_tool_call> runs with no matching opener
after several agent-loop steps (MODEL_ISSUES_TRIAGE Issue 3). The
tool-call state machine only enters collection on an OPEN tag, so those
protocol closers streamed to the user as literal text.

Adds an opt-in orphanStripTags registry to ToolCallParser (default
empty, no behavior change for other formats) and teaches
ToolCallProcessor to strip registered orphan closers — including runs,
chunk-split partials, and closers embedded mid-buffer — while
unregistered tag-looking prose like </div> still flushes verbatim.
XMLFunctionParser and Gemma4ToolCallParser register their closers.

author
tpae
committer
GitHub
date (Jul 3, 2026, 1:52 AM -0700) commit 8dffa0a8 parent cee0f8e2
+290
+8
Libraries/MLXLMCommon/Tool/Parsers/Gemma4ToolCallParser.swift
··· 47 47 native.endTagAliases + zyphra.endTagAliases 48 48 } 49 49 50 + /// Both transports' closers strip when orphaned: Gemma-4 AppleScript 51 + /// fine-tunes emit the Zyphra XML envelope (and its stray-closer leak 52 + /// shape), and an orphan native `<tool_call|>` is the same protocol 53 + /// residue class. 54 + public var orphanStripTags: [String] { 55 + native.endTagAliases + zyphra.orphanStripTags 56 + } 57 + 50 58 public func parse(content: String, tools: [[String: any Sendable]]?) -> ToolCall? { 51 59 native.parse(content: content, tools: tools) 52 60 ?? zyphra.parse(content: content, tools: tools)
+11
Libraries/MLXLMCommon/Tool/Parsers/XMLFunctionParser.swift
··· 22 22 self.unwrapJSONQuotedStringParameters = unwrapJSONQuotedStringParameters 23 23 } 24 24 25 + /// The XML-function transport's closers are protocol control markers even 26 + /// when orphaned (no matching opener): live ZAYA rows emit stray 27 + /// `</parameter></function></zyphra_tool_call>` runs mid-conversation 28 + /// (MODEL_ISSUES_TRIAGE Issue 3 — the wrapper tags are dedicated special 29 + /// tokens for that family). Register the wrapper closer plus the body 30 + /// closers so the streaming processor strips an orphan run instead of 31 + /// surfacing it as visible assistant text. 32 + public var orphanStripTags: [String] { 33 + endTagAliases + ["</function>", "</parameter>"] 34 + } 35 + 25 36 public func parse(content: String, tools: [[String: any Sendable]]?) -> ToolCall? { 26 37 // Pattern: <function=(content)</function> — [\s\S] matches newlines 27 38 guard
+15
Libraries/MLXLMCommon/Tool/ToolCallFormat.swift
··· 36 36 /// Prefixes for dynamic end tags matching ``startTagPrefixes``. 37 37 var endTagPrefixes: [String] { get } 38 38 39 + /// Exact protocol CLOSER tags that must be stripped from the visible 40 + /// stream even when they appear WITHOUT a matching opener ("orphan 41 + /// closers"). Live rows of some families (ZAYA / Gemma-4 AppleScript 42 + /// fine-tunes) emit stray `</parameter></function></zyphra_tool_call>` 43 + /// runs after several agent-loop steps; the state machine only enters 44 + /// collection on an OPEN tag, so without this registry the orphan closer 45 + /// would stream to the user as literal protocol text. Scoped to the 46 + /// format's OWN registered closers so ordinary tag-looking prose 47 + /// (`</div>`) is never touched — the same robustness class as the 48 + /// `ReasoningParser` stray-tag strip. Default: empty (no behavior 49 + /// change for formats that have not opted in). 50 + var orphanStripTags: [String] { get } 51 + 39 52 /// Parse the content into a `ToolCall`. 40 53 /// - Parameters: 41 54 /// - content: The text content to parse (may include tags) ··· 90 103 public var startTagPrefixes: [String] { [] } 91 104 92 105 public var endTagPrefixes: [String] { [] } 106 + 107 + public var orphanStripTags: [String] { [] } 93 108 94 109 public func isValidPartialContent(_ toolCallBuffer: String) -> Bool { 95 110 true
+62
Libraries/MLXLMCommon/Tool/ToolCallProcessor.swift
··· 722 722 let tags = 723 723 parser.startTagAliases + parser.endTagAliases 724 724 + parser.startTagPrefixes + parser.endTagPrefixes 725 + + parser.orphanStripTags 725 726 726 727 if tags.contains(where: { trimmed.hasPrefix($0) }) { 727 728 return true ··· 1673 1674 let combined = visible + inline 1674 1675 return combined.isEmpty ? nil : combined 1675 1676 } 1677 + 1678 + // The buffer is not a start tag. Before flushing it as 1679 + // visible text, screen it against the format's registered 1680 + // ORPHAN closers: live rows (ZAYA / Gemma-4 AppleScript) emit 1681 + // stray `</parameter></function></zyphra_tool_call>` runs 1682 + // with no matching opener, and those protocol markers must 1683 + // strip rather than stream to the user. Only the format's 1684 + // own registered tags are touched — unregistered tag-looking 1685 + // prose (`</div>`) still flushes verbatim. 1686 + let orphanTags = parser.orphanStripTags 1687 + if !orphanTags.isEmpty { 1688 + var remainder = toolCallBuffer 1689 + var strippedOrphan = false 1690 + while let tag = orphanTags.first(where: { remainder.hasPrefix($0) }) { 1691 + remainder.removeFirst(tag.count) 1692 + strippedOrphan = true 1693 + } 1694 + if strippedOrphan { 1695 + state = .normal 1696 + toolCallBuffer = "" 1697 + let leading = leadingTextBeforeToolCall 1698 + leadingTextBeforeToolCall = "" 1699 + // Whatever follows the orphan run goes back through 1700 + // the machine — it may hold prose, another marker, or 1701 + // a real envelope. 1702 + let trailing = 1703 + remainder.isEmpty 1704 + ? nil 1705 + : processTaggedChunk( 1706 + remainder, allowInlineFallback: allowInlineFallback) 1707 + let visible = leading + (trailing ?? "") 1708 + return visible.isEmpty ? nil : visible 1709 + } 1710 + // A strict prefix of an orphan closer may still complete 1711 + // on the next chunk — keep buffering instead of leaking 1712 + // the partial marker. 1713 + if orphanTags.contains(where: { $0.hasPrefix(toolCallBuffer) }) { 1714 + return nil 1715 + } 1716 + // A closer can also sit EMBEDDED later in this same 1717 + // non-matching buffer (one big chunk: `<zyx</function>`). 1718 + // Emit the non-matching head and re-scan the tail so the 1719 + // embedded marker still strips; each level drops at least 1720 + // one character, so this terminates. 1721 + if toolCallBuffer.count > 1, 1722 + toolCallBuffer.dropFirst().contains(startChar) 1723 + { 1724 + state = .normal 1725 + let head = String(toolCallBuffer.prefix(1)) 1726 + let tail = String(toolCallBuffer.dropFirst()) 1727 + toolCallBuffer = "" 1728 + let leading = leadingTextBeforeToolCall 1729 + leadingTextBeforeToolCall = "" 1730 + let trailing = 1731 + processTaggedChunk(tail, allowInlineFallback: allowInlineFallback) 1732 + ?? "" 1733 + let visible = leading + head + trailing 1734 + return visible.isEmpty ? nil : visible 1735 + } 1736 + } 1737 + 1676 1738 // Otherwise, return the collected text and reset the state 1677 1739 state = .normal 1678 1740 let buffer = toolCallBuffer
+194
Tests/MLXLMCommonToolParserFocusedTests/OrphanToolCloserStripTests.swift
··· 1 + // Copyright © 2026 Osaurus AI. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + import Testing 5 + @testable import MLXLMCommon 6 + 7 + /// Orphan tool-call CLOSER tags must never leak as visible text. 8 + /// 9 + /// Live Zaya/AppleScript rows (MODEL_ISSUES_TRIAGE Issue 3) emit orphan 10 + /// closing tags — `</parameter></function></zyphra_tool_call>` with no 11 + /// matching opener — after several agent-loop steps. The streaming 12 + /// `ToolCallProcessor` state machine only entered tool-call collection after 13 + /// matching an OPEN tag, so an orphan closer fell through the 14 + /// `.potentialToolCall` flush and streamed to the user as literal protocol 15 + /// text. These are protocol control markers for the format (for ZAYA the 16 + /// wrapper tags are dedicated special tokens 101/102), the same robustness 17 + /// class as the Gemma `<channel|>` stray-tag strip in `ReasoningParser`. 18 + /// 19 + /// The strip is scoped to the format's OWN registered closers 20 + /// (`orphanStripTags`) — arbitrary tag-looking prose (`</div>`) must still 21 + /// pass through untouched, and real envelopes must keep parsing. 22 + @Suite("Orphan tool-call closer strip") 23 + struct OrphanToolCloserStripFocusedTests { 24 + 25 + // MARK: - The Issue 3 leak shape (zayaXml) 26 + 27 + @Test("zayaXml: an orphan closer run streams invisible, char-by-char") 28 + func zayaOrphanCloserRunStrippedCharByChar() { 29 + let output = "Volume set to 30.\n</parameter>\n</function>\n</zyphra_tool_call>" 30 + let processor = ToolCallProcessor(format: .zayaXml, tools: [lineCountToolSpec()]) 31 + var visible = "" 32 + for ch in output { 33 + visible += processor.processChunk(String(ch)) ?? "" 34 + } 35 + visible += processor.processEOS() ?? "" 36 + 37 + #expect(visible.trimmingCharacters(in: .whitespacesAndNewlines) == "Volume set to 30.") 38 + #expect(!visible.contains("</parameter>")) 39 + #expect(!visible.contains("</function>")) 40 + #expect(!visible.contains("</zyphra_tool_call>")) 41 + #expect(processor.toolCalls.isEmpty) 42 + } 43 + 44 + @Test("zayaXml: back-to-back orphan closers in one chunk strip, prose around them survives") 45 + func zayaOrphanClosersSingleChunk() { 46 + let processor = ToolCallProcessor(format: .zayaXml, tools: [lineCountToolSpec()]) 47 + var visible = "" 48 + visible += processor.processChunk("Done.</parameter></function></zyphra_tool_call> All set.") ?? "" 49 + visible += processor.processEOS() ?? "" 50 + 51 + #expect(visible == "Done. All set.") 52 + #expect(processor.toolCalls.isEmpty) 53 + } 54 + 55 + @Test("zayaXml: an orphan closer split across chunk boundaries still strips") 56 + func zayaOrphanCloserSplitAcrossChunks() { 57 + let processor = ToolCallProcessor(format: .zayaXml, tools: [lineCountToolSpec()]) 58 + var visible = "" 59 + for chunk in ["ok </zy", "phra_tool", "_call>", " done"] { 60 + visible += processor.processChunk(chunk) ?? "" 61 + } 62 + visible += processor.processEOS() ?? "" 63 + 64 + #expect(visible == "ok done") 65 + #expect(processor.toolCalls.isEmpty) 66 + } 67 + 68 + @Test("zayaXml: a partial orphan closer at EOS is suppressed, not leaked") 69 + func zayaPartialOrphanCloserAtEOS() { 70 + let processor = ToolCallProcessor(format: .zayaXml, tools: [lineCountToolSpec()]) 71 + var visible = "" 72 + visible += processor.processChunk("answer 42 ") ?? "" 73 + visible += processor.processChunk("</functi") ?? "" 74 + visible += processor.processEOS() ?? "" 75 + 76 + #expect(visible == "answer 42 ") 77 + #expect(processor.toolCalls.isEmpty) 78 + } 79 + 80 + // MARK: - No over-stripping 81 + 82 + @Test("zayaXml: tag-looking prose that is NOT a registered closer passes through") 83 + func zayaUnregisteredTagsPassThrough() { 84 + let processor = ToolCallProcessor(format: .zayaXml, tools: [lineCountToolSpec()]) 85 + var visible = "" 86 + for ch in "HTML uses </div> and </p> to close elements." { 87 + visible += processor.processChunk(String(ch)) ?? "" 88 + } 89 + visible += processor.processEOS() ?? "" 90 + 91 + #expect(visible == "HTML uses </div> and </p> to close elements.") 92 + #expect(processor.toolCalls.isEmpty) 93 + } 94 + 95 + @Test("zayaXml: a real complete envelope still parses to a tool call with no leak") 96 + func zayaRealEnvelopeStillParses() { 97 + let output = """ 98 + <zyphra_tool_call> 99 + <function=line_count> 100 + <parameter=text> 101 + red 102 + green 103 + </parameter> 104 + </function> 105 + </zyphra_tool_call> 106 + """ 107 + let processor = ToolCallProcessor(format: .zayaXml, tools: [lineCountToolSpec()]) 108 + var visible = "" 109 + for ch in output { 110 + visible += processor.processChunk(String(ch)) ?? "" 111 + } 112 + visible += processor.processEOS() ?? "" 113 + 114 + #expect(visible.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) 115 + #expect(processor.toolCalls.count == 1) 116 + #expect(processor.toolCalls.first?.function.name == "line_count") 117 + #expect(processor.toolCalls.first?.function.arguments["text"] == .string("red\ngreen")) 118 + } 119 + 120 + @Test("zayaXml: orphan closers AFTER a real envelope strip while the call still parses") 121 + func zayaOrphanClosersAfterRealEnvelope() { 122 + let output = "<zyphra_tool_call>\n<function=line_count>\n<parameter=text>\nhi\n</parameter>\n</function>\n</zyphra_tool_call>\n</function>\n</zyphra_tool_call>" 123 + let processor = ToolCallProcessor(format: .zayaXml, tools: [lineCountToolSpec()]) 124 + var visible = "" 125 + for ch in output { 126 + visible += processor.processChunk(String(ch)) ?? "" 127 + } 128 + visible += processor.processEOS() ?? "" 129 + 130 + #expect(visible.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) 131 + #expect(processor.toolCalls.count == 1) 132 + #expect(processor.toolCalls.first?.function.name == "line_count") 133 + } 134 + 135 + // MARK: - gemma4 (the shipped AppleScript 16B transport, zyphra-aliased) 136 + 137 + @Test("gemma4: orphan zyphra closers strip on the Gemma4 parser path") 138 + func gemma4OrphanZyphraClosersStrip() { 139 + let processor = ToolCallProcessor(format: .gemma4, tools: [lineCountToolSpec()]) 140 + var visible = "" 141 + for ch in "Saved the note.</parameter></function></zyphra_tool_call>" { 142 + visible += processor.processChunk(String(ch)) ?? "" 143 + } 144 + visible += processor.processEOS() ?? "" 145 + 146 + #expect(visible == "Saved the note.") 147 + #expect(processor.toolCalls.isEmpty) 148 + } 149 + 150 + @Test("gemma4: an orphan native closer strips too") 151 + func gemma4OrphanNativeCloserStrips() { 152 + let processor = ToolCallProcessor(format: .gemma4, tools: [lineCountToolSpec()]) 153 + var visible = "" 154 + for ch in "Done.<tool_call|> next" { 155 + visible += processor.processChunk(String(ch)) ?? "" 156 + } 157 + visible += processor.processEOS() ?? "" 158 + 159 + #expect(visible == "Done. next") 160 + #expect(processor.toolCalls.isEmpty) 161 + } 162 + 163 + // MARK: - Strip-only mode (no tools offered) 164 + 165 + @Test("zayaXml strip-only: orphan closers still strip and no calls are recorded") 166 + func zayaStripOnlyOrphanClosers() { 167 + let processor = ToolCallProcessor(format: .zayaXml, tools: nil, stripOnly: true) 168 + var visible = "" 169 + for ch in "hello </zyphra_tool_call> world" { 170 + visible += processor.processChunk(String(ch)) ?? "" 171 + } 172 + visible += processor.processEOS() ?? "" 173 + 174 + #expect(visible == "hello world") 175 + #expect(processor.toolCalls.isEmpty) 176 + } 177 + 178 + private func lineCountToolSpec() -> [String: any Sendable] { 179 + [ 180 + "type": "function", 181 + "function": [ 182 + "name": "line_count", 183 + "parameters": [ 184 + "type": "object", 185 + "properties": [ 186 + "text": ["type": "string"] as [String: any Sendable], 187 + ] as [String: any Sendable], 188 + "required": ["text"], 189 + "additionalProperties": false, 190 + ] as [String: any Sendable], 191 + ] as [String: any Sendable], 192 + ] 193 + } 194 + }