Skip to content

afm_adaptive_xml parser appends </function to tool names on zero-parameter XML tool calls #80

@scouzi1966

Description

@scouzi1966

Bug

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
  • Confirmed in promptfoo agentic eval suite: tests Add Qwen3.5-397B MoE support #20 and Fix Jinja crash on nullable tool schemas (#32) #33 (todoread tool) fail on adaptive-xml and adaptive-xml-grammar profiles but pass on default profile
  • Direct curl tests often return the correct name

Root 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 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:

  1. Vendor ToolCallProcessor (streaming) — produces the corrupted name during token generation
  2. ToolCallStreamingRuntime.normalizedToolCall() (line 203) — converts ToolCallResponseToolCall, passes name through
  3. MLXChatCompletionsController non-streaming path (line 252) — collects chunk.toolCalls from stream, name already corrupted
  4. MLXChatCompletionsController fallback path (line 276) — parseCompletedToolCallsnormalizeParsedToolCalls (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 only
  • ToolCallStreamingRuntime.normalizedToolCall() — streaming path
  • MLXChatCompletionsController post-stream-loop — non-streaming controller

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:

  1. 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.

  2. Earlier sanitization — strip </ from tool names at the point where StreamChunk.toolCalls is constructed inside MLXModelService.generateStreaming(), before it reaches the controller.

  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions