Skip to content

Commit f3bb10c

Browse files
authored
Fix Gemma4 tool parser: support hyphenated names and braces in strings (#1150)
1 parent e1c24b3 commit f3bb10c

3 files changed

Lines changed: 41 additions & 4 deletions

File tree

mlx_lm/server.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,14 @@ def __call__(self, tool_calls):
7878

7979
result = []
8080
for tool_text in tool_calls:
81-
parsed = self._tool_parser(tool_text, self._tools)
81+
try:
82+
parsed = self._tool_parser(tool_text, self._tools)
83+
except (ValueError, json.JSONDecodeError) as e:
84+
logging.warning(
85+
f"Failed to parse tool call ({type(e).__name__}: {e}) — "
86+
f"tool text was likely truncated mid-generation."
87+
)
88+
continue
8289
if not isinstance(parsed, list):
8390
parsed = [parsed]
8491
result.extend(self._format(tc) for tc in parsed)

mlx_lm/tool_parsers/gemma4.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,19 @@
55

66
import regex as re
77

8+
# Matches <|"|>...<|"|> string literals (Gemma 4's string delimiter).
9+
_GEMMA4_STR = r'<\|"\|>(?:(?!<\|"\|>)[\s\S])*?<\|"\|>'
10+
811
# Matches call:name{...} with balanced braces via the regex module's
9-
# recursive (?R)-style support. (\{(?:[^{}]|(?2))*\}) recurses on the
10-
# second capture group so nested objects like {a:{b:1}} are captured whole.
11-
_tool_call_regex = re.compile(r"call:(\w+)(\{(?:[^{}]|(?2))*\})", re.DOTALL)
12+
# recursive (?R)-style support. The inner alternatives handle:
13+
# [^{}<] – any char that is not a brace or start of <|"|>
14+
# <(?!\|"\|>) – a lone '<' that is NOT the start of <|"|>
15+
# <|"|>...<|"|> – a complete string literal (braces inside are ignored)
16+
# (?2) – recursively balanced nested brace group
17+
_tool_call_regex = re.compile(
18+
r"call:([\w-]+)(\{(?:[^{}<]|<(?!\|\"\|>)|" + _GEMMA4_STR + r"|(?2))*\})",
19+
re.DOTALL,
20+
)
1221

1322

1423
def _gemma4_args_to_json(text: str) -> str:

tests/test_tool_parsing.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,27 @@ def test_gemma4(self):
254254
{"settings": {"enabled": True, "name": "test"}},
255255
)
256256

257+
# Hyphenated function name (e.g. manim-video)
258+
test_case = (
259+
'call:manim-video{mode:<|"|>plan<|"|>,prompt:<|"|>explain KV caching<|"|>}'
260+
)
261+
tool_call = gemma4.parse_tool_call(test_case, None)
262+
self.assertEqual(tool_call["name"], "manim-video")
263+
self.assertEqual(
264+
tool_call["arguments"],
265+
{"mode": "plan", "prompt": "explain KV caching"},
266+
)
267+
268+
# Braces inside a string argument (e.g. code snippets or markdown in content)
269+
test_case = (
270+
'call:skill_manage{action:<|"|>create<|"|>,'
271+
'content:<|"|>use a dict like {key: value} in your code<|"|>}'
272+
)
273+
tool_call = gemma4.parse_tool_call(test_case, None)
274+
self.assertEqual(tool_call["name"], "skill_manage")
275+
self.assertEqual(tool_call["arguments"]["action"], "create")
276+
self.assertIn("{", tool_call["arguments"]["content"])
277+
257278
def test_kimi_k2(self):
258279
# Single tool call
259280
test_case = (

0 commit comments

Comments
 (0)