You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The afm_adaptive_xml tool call parser intermittently appends </function to the tool name when parsing zero-parameter XML tool calls. The tool name todoread becomes todoread</function in the HTTP response.
Reproduction
# Start server with adaptive-xml parser
afm mlx -m mlx-community/Qwen3.5-35B-A3B-4bit --tool-call-parser afm_adaptive_xml
# Send a zero-parameter tool call request
curl -s http://127.0.0.1:9999/v1/chat/completions -H "Content-Type: application/json" -d '{ "model": "m", "stream": false, "max_tokens": 200, "messages": [{"role": "user", "content": "Check what tasks are pending."}], "tools": [{"type":"function","function":{"name":"todoread","description":"Read todo list","parameters":{"type":"object","properties":{},"required":[]}}}]}'
Expected:"name": "todoread" in tool_calls[0].function Actual (intermittent):"name": "todoread</function" — the XML closing tag is appended to the name
Non-deterministic
Happens intermittently, not on every request
At temperature 0 with the same prompt, it may or may not reproduce depending on token boundary alignment
The model generates: <tool_call><function=todoread></function></tool_call>
This is a zero-parameter tool call — no <parameter> tags between the opening and closing function tags.
The vendor's ToolCallProcessor (streaming inline XML parser in mlx-swift-lm) processes tokens incrementally. For zero-parameter calls, the token boundaries can cause todoread></function to be delivered as a single chunk. The inline parser's name capture doesn't always stop cleanly at > when the closing tag immediately follows, resulting in todoread</function as the captured function name.
Why only afm_adaptive_xml?
The default profile uses the vendor's ToolCallProcessor with the auto-detected xmlFunction format and a different code path that handles this correctly. The afm_adaptive_xml profile takes a branch in ToolCallStreamingRuntime.parseCompletedToolCalls() that processes the vendor's output differently.
Why only zero-parameter tools?
Tools WITH parameters have content between > and </function> (e.g., <parameter=key>value</parameter>), so the parser naturally stops the name capture at >. With no parameters, > and </function> are adjacent with no intervening content, creating the edge case.
Code Locations
The corrupted name flows through multiple paths:
Vendor ToolCallProcessor (streaming) — produces the corrupted name during token generation
ToolCallStreamingRuntime.normalizedToolCall() (line 203) — converts ToolCall → ResponseToolCall, passes name through
MLXChatCompletionsController non-streaming path (line 252) — collects chunk.toolCalls from stream, name already corrupted
MLXChatCompletionsController fallback path (line 276) — parseCompletedToolCalls → normalizeParsedToolCalls (separate path, may or may not be reached)
These catch the corruption when the name passes through these specific code points. However, the vendor's ToolCallProcessor can emit the corrupted name through chunk.toolCalls BEFORE these sanitization points are reached, depending on which streaming code path is taken.
Proper Fix
The root cause is in the vendor's ToolCallProcessor inline XML parsing. Options:
Vendor patch (Scripts/patches/ for the ToolCallProcessor) — fix the name capture regex to stop at > regardless of what follows. This is the proper fix but requires understanding the vendor's streaming parser internals.
Earlier sanitization — strip </ from tool names at the point where StreamChunk.toolCalls is constructed inside MLXModelService.generateStreaming(), before it reaches the controller.
StreamChunk post-processing — add a sanitization step immediately after chunk.toolCalls is set, before any downstream code uses it.
Affected Models
Any model producing XML tool call format (xmlFunction) with zero-parameter tools when used with --tool-call-parser afm_adaptive_xml:
mlx-community/Qwen3.5-35B-A3B-4bit
Other Qwen3.5 / Qwen3-Coder models
Evidence
From promptfoo agentic eval suite (opencode-adaptive-xml profile):
Test #20: prompt="Check what tasks are already pending before adding new ones."
tool_calls[0].function.name = "todoread</function"
tool_calls[0].function.arguments = {}
finish_reason = "tool_calls"
completion_tokens = 16
Test #33: prompt="Before you add more tasks, check the current todo list."
tool_calls[0].function.name = "todoread</function"
tool_calls[0].function.arguments = {}
finish_reason = "tool_calls"
completion_tokens = 16
Same tests pass on default profile (name = todoread).
Impact
Agent frameworks (OpenCode, Pi) that match tool names exactly will fail to dispatch the tool call
tool_choice: {"function": {"name": "todoread"}} enforcement would also break
Only affects afm_adaptive_xml parser, not the default auto-detected parser
Bug
The
afm_adaptive_xmltool call parser intermittently appends</functionto the tool name when parsing zero-parameter XML tool calls. The tool nametodoreadbecomestodoread</functionin the HTTP response.Reproduction
Expected:
"name": "todoread"intool_calls[0].functionActual (intermittent):
"name": "todoread</function"— the XML closing tag is appended to the nameNon-deterministic
todoreadtool) fail onadaptive-xmlandadaptive-xml-grammarprofiles but pass ondefaultprofileRoot Cause Analysis
The model generates:
<tool_call><function=todoread></function></tool_call>This is a zero-parameter tool call — no
<parameter>tags between the opening and closing function tags.The vendor's
ToolCallProcessor(streaming inline XML parser inmlx-swift-lm) processes tokens incrementally. For zero-parameter calls, the token boundaries can causetodoread></functionto be delivered as a single chunk. The inline parser's name capture doesn't always stop cleanly at>when the closing tag immediately follows, resulting intodoread</functionas the captured function name.Why only
afm_adaptive_xml?The
defaultprofile uses the vendor'sToolCallProcessorwith the auto-detectedxmlFunctionformat and a different code path that handles this correctly. Theafm_adaptive_xmlprofile takes a branch inToolCallStreamingRuntime.parseCompletedToolCalls()that processes the vendor's output differently.Why only zero-parameter tools?
Tools WITH parameters have content between
>and</function>(e.g.,<parameter=key>value</parameter>), so the parser naturally stops the name capture at>. With no parameters,>and</function>are adjacent with no intervening content, creating the edge case.Code Locations
The corrupted name flows through multiple paths:
ToolCallStreamingRuntime.normalizedToolCall()(line 203) — convertsToolCall→ResponseToolCall, passes name throughMLXChatCompletionsControllernon-streaming path (line 252) — collectschunk.toolCallsfrom stream, name already corruptedMLXChatCompletionsControllerfallback path (line 276) —parseCompletedToolCalls→normalizeParsedToolCalls(separate path, may or may not be reached)Existing Partial Fix (commit 0585f06)
Three safety-net locations strip
</suffixes from tool names:ToolCallStreamingRuntime.normalizeParsedToolCalls()— fallback path onlyToolCallStreamingRuntime.normalizedToolCall()— streaming pathMLXChatCompletionsControllerpost-stream-loop — non-streaming controllerThese catch the corruption when the name passes through these specific code points. However, the vendor's
ToolCallProcessorcan emit the corrupted name throughchunk.toolCallsBEFORE these sanitization points are reached, depending on which streaming code path is taken.Proper Fix
The root cause is in the vendor's
ToolCallProcessorinline XML parsing. Options:Vendor patch (
Scripts/patches/for the ToolCallProcessor) — fix the name capture regex to stop at>regardless of what follows. This is the proper fix but requires understanding the vendor's streaming parser internals.Earlier sanitization — strip
</from tool names at the point whereStreamChunk.toolCallsis constructed insideMLXModelService.generateStreaming(), before it reaches the controller.StreamChunk post-processing — add a sanitization step immediately after
chunk.toolCallsis set, before any downstream code uses it.Affected Models
Any model producing XML tool call format (
xmlFunction) with zero-parameter tools when used with--tool-call-parser afm_adaptive_xml:mlx-community/Qwen3.5-35B-A3B-4bitEvidence
From promptfoo agentic eval suite (opencode-adaptive-xml profile):
Same tests pass on
defaultprofile (name =todoread).Impact
tool_choice: {"function": {"name": "todoread"}}enforcement would also breakafm_adaptive_xmlparser, not the default auto-detected parser