Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions omlx/api/tool_calling.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@

logger = logging.getLogger(__name__)

# Pre-compiled patterns for the naked <function=...> fallback (see parse_tool_calls).
# Hoisted to module scope to avoid re-compilation on every request.
_NAKED_FUNCTION_RE = re.compile(r"<function=([^\s>]+)>(.*?)</function>", re.DOTALL)
_NAKED_PARAMETER_RE = re.compile(r"<parameter=([^\s>]+)>\s*(.*?)\s*</parameter>", re.DOTALL)
_STRAY_TOOL_CALL_RE = re.compile(r"</?tool_call>")


def _serialize_tool_call_arguments(arguments: Any) -> str:
"""Serialize parser output to a JSON-object arguments string.
Expand Down Expand Up @@ -536,6 +542,39 @@ def parse_tool_calls(
if "<tool_call>" in cleaned_text:
return _parse_xml_tool_calls(cleaned_text)

# Fallback: naked <function=name>...</function> emitted by Qwen3-Coder
# when the model skips the outer <tool_call> wrapper it was trained on.
# Upstream parser (mlx_lm.tool_parsers.qwen3_coder) requires the wrapper;
# this branch recovers the structured call when the wrapper is absent.
if "<function=" in cleaned_text and "</function>" in cleaned_text:
naked_tool_calls: List[ToolCall] = []
for match in _NAKED_FUNCTION_RE.finditer(cleaned_text):
func_name = match.group(1)
body = match.group(2)
arguments: Dict[str, Any] = {}
for pm in _NAKED_PARAMETER_RE.finditer(body):
key = pm.group(1)
val = pm.group(2).strip()
try:
arguments[key] = json.loads(val)
except (json.JSONDecodeError, ValueError):
arguments[key] = val
naked_tool_calls.append(
ToolCall(
id=f"call_{uuid.uuid4().hex[:8]}",
type="function",
function=FunctionCall(
name=func_name,
arguments=json.dumps(arguments, ensure_ascii=False),
),
)
)
if naked_tool_calls:
cleaned = _NAKED_FUNCTION_RE.sub("", cleaned_text)
# Strip any stray wrapper fragments the model emitted without pairs
cleaned = _STRAY_TOOL_CALL_RE.sub("", cleaned).strip()
return cleaned, naked_tool_calls

# Fallback: namespaced tool_call tags (e.g. <minimax:tool_call>)
ns_match = re.search(r"<([A-Za-z_][\w.-]*):tool_call>", cleaned_text)
if ns_match:
Expand Down
51 changes: 51 additions & 0 deletions tests/test_tool_calling.py
Original file line number Diff line number Diff line change
Expand Up @@ -1203,6 +1203,57 @@ def failing_parser(text, tools):
assert tool_calls is None or len(tool_calls) == 0


class TestParseNakedQwenFunctionCalls:
"""Regression coverage for Qwen-Coder omitting the outer <tool_call> tag."""

def _qwen_tok(self):
tok = MagicMock(spec=[])
tok.has_tool_calling = True
tok.tool_call_start = "<tool_call>"
tok.tool_call_end = "</tool_call>"
tok.tool_parser = None
return tok

def test_naked_function_call_is_recovered(self):
tok = self._qwen_tok()

text = (
"I'll inspect it.\n"
"<function=read_file>\n"
"<parameter=path>/tmp/example.py</parameter>\n"
"<parameter=limit>20</parameter>\n"
"</function>\n"
"</tool_call>"
)

cleaned, tool_calls = parse_tool_calls(text, tok)

assert cleaned == "I'll inspect it."
assert tool_calls is not None
assert len(tool_calls) == 1
assert tool_calls[0].function.name == "read_file"
assert json.loads(tool_calls[0].function.arguments) == {
"path": "/tmp/example.py",
"limit": 20,
}

def test_multiple_naked_function_calls_are_recovered(self):
tok = self._qwen_tok()

text = (
"<function=first><parameter=value>true</parameter></function>"
"<function=second><parameter=name>alpha</parameter></function>"
)

cleaned, tool_calls = parse_tool_calls(text, tok)

assert cleaned == ""
assert tool_calls is not None
assert [tc.function.name for tc in tool_calls] == ["first", "second"]
assert json.loads(tool_calls[0].function.arguments) == {"value": True}
assert json.loads(tool_calls[1].function.arguments) == {"name": "alpha"}


class TestParseBracketToolCalls:
"""Tests for bracket-style tool call parsing (issue #159)."""

Expand Down