Conversation
Gemma 4 writes every string in a tool call between escape markers,
including strings inside nested objects and arrays:
`{location:{city:<|"|>Paris<|"|>}}`. BareKeyJSONParser (ml-explore#557) only
understands JSON's own double quotes, so a value like this fails to
parse and GemmaFunctionParser falls back to the raw text. A parameter
the schema declares an object then arrives as a string, and a tool that
validates its input rejects the call. The ml-explore#557 review left this as a
follow-up.
Parse the original text first, exactly as before. Only when that fails,
and only if the text contains the marker, rewrite each marker-delimited
span as a JSON string (escaping backslash, double quote, and control
characters) and try again. Because the original text is always tried
first, anything that parsed before still gives the same result. This
matters for FunctionGemma's `<escape>` marker, which can appear inside
an already-quoted JSON string.
Add 20 tests to Tests/MLXLMTests/ToolTests.swift, next to the existing
Gemma tests. 17 fail without the fix: nested objects, arrays of
objects, bare scalars mixed with marker strings, control characters,
quotes and backslashes, empty and backslash-ended strings, non-ASCII
text, marker-quoted keys and a space before a key (both seen in
gemma-4-e2b-it output, though the template writes neither), deep
nesting, a call with no tool schema, FunctionGemma nested `<escape>`
strings, and a streamed call through ToolCallProcessor. Three pin
behavior that does not change for input outside the dialect: markers
inside an already-quoted JSON string, a FunctionGemma value with an
unpaired marker, and a string-typed parameter that keeps its raw text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GemmaFunctionParser trimmed argument values but not keys, so a space
after a comma, as in `{city:<|"|>Paris<|"|>, days:3}`, stored `days`
under the key " days", which the tool schema does not know. The chat
template writes no space there, but gemma-4-e2b-it wrote a space before
a nested key with thinking disabled. Trim the key the same way as the
value, and add a test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| for field in scanner.splitTopLevel(body, separator: ",") { | ||
| guard let colon = scanner.firstTopLevelIndex(of: ":", in: field) else { continue } | ||
| let key = String(field[..<colon]) | ||
| let key = String(field[..<colon].trimmingWhitespace()) |
There was a problem hiding this comment.
This trims the key but doesn't unmark it, so a marker-quoted top-level key still lands in arguments verbatim.
testGemmaMarkerQuotedNestedKeys establishes that the model really does emit marker-quoted keys this PR fixes that shape one level down via quotingMarkedStrings , but the same shape at the top level is still broken...
There was a problem hiding this comment.
Right — the trim covered the space but not the markers, so {<|"|>city<|"|>:<|"|>Paris<|"|>} stored the key as <|"|>city<|"|>. Nested keys were already fine because the rewrite turns them into JSON strings and BareKeyJSONParser takes a quoted key; the top-level key is read directly. Pushed a commit that strips a marker pair around the trimmed key and adds a test for it next to the space case. I have only seen the model do this nested, not at the top level, but it is the same shape, and transformers and mlx-lm read it at every depth.
| } | ||
|
|
||
| /// Parses a brace-form literal, then retries with nested marker strings quoted as JSON. | ||
| private func parseStructured(_ literal: String, marker: String) -> (any Sendable)? { |
There was a problem hiding this comment.
The first structuredValues.parse(literal) is dead work whenever the literal contains a marker.
BareKeyJSONParser can never succeed on such input: <|"|> carries an unescaped " , so tryParseJSON rejects it, and quotingBareKeys passes values through verbatim
and rejects marker-wrapped keys ( quotedKey fails on < ), so the retry string still contains the marker. Checking contains(marker) first is therefore behaviour-preserving, not just an optimisation:
private func parseStructured(_ literal: String, marker: String) -> (any Sendable)? {
guard literal.contains(marker) else { return structuredValues.parse(literal) }
return structuredValues.parse(Self.quotingMarkedStrings(literal, marker: marker))
}
There was a problem hiding this comment.
For <|"|> that's true: its " is never valid JSON in place, so the first parse can't succeed and the guard changes nothing.
It isn't true for FunctionGemma's <escape>, which has no quote in it and can sit inside a JSON-quoted string. {a:"<escape>hi<escape>"} parses on main today through the bare-key retry as {"a": "<escape>hi<escape>"}. With the guard, the rewrite runs first and produces {a:""hi""}, which nothing parses, so the value falls back to the raw string. The two cases in testFunctionGemmaMarkersInsideQuotedString fail with the guard applied.
So I kept the order and added a doc comment on parseStructured saying why the original text goes first. The cost is one failed JSONSerialization on a nested literal that contains <|"|>.
| } | ||
|
|
||
| @Test("Gemma trims a space before a top-level argument key") | ||
| func testGemmaTopLevelKeyAfterSpace() throws { |
There was a problem hiding this comment.
Good test for the trim. When my other comment gets addressed, this is the natural home for a marker-quoted top-level key case.
There was a problem hiding this comment.
Added testGemmaMarkerQuotedTopLevelKey right after it: a marker-quoted key, and a marker-quoted key after a space, so the trim-then-unmark order is pinned too.
|
Thank you for raising this pull request, this was something I had in my TODO list. |
The model sometimes marker-quotes a key, as it does values. Inside a
nested value the rewrite already turns `<|"|>city<|"|>` into a JSON
string, but a top-level key is read directly, so a call such as
`{<|"|>city<|"|>:<|"|>Paris<|"|>}` reached the tool with the key
`<|"|>city<|"|>`. Strip a marker pair around a top-level key after
trimming it, and add a test next to the space case.
Also say in `parseStructured` why the original text is parsed first:
FunctionGemma's `<escape>` can sit inside a JSON-quoted string, which
the rewrite would break.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@aleroot one idea for after this lands, not for this PR. The "which parse goes first" question in |
@Adi2K yes, it sounds good. If you can open the issue so that I will definitely remember next week, as this week I have no time for this project. Thanks. |
|
Opened #628 with the grammar. To be clear, I'll write it myself once this lands. It only needs a review from you, no work on your side. |
Perfetto. |
Proposed changes
Gemma 4 writes every string in a tool call between escape markers (
<|"|>),including strings inside nested objects and arrays. A call with the
arguments
{"location": {"city": "Paris", "country": "FR"}}is written as:Today
GemmaFunctionParserreturns the nested value as a string, even whenthe tool's schema declares
locationan object:A tool that validates its input then rejects the call (for example, an MCP
server answers
string found, object expected). With this change:More examples of what Gemma writes, and what the parser returns with this
change:
{stops:[{city:<|"|>Paris<|"|>},{city:<|"|>Lyon<|"|>}]}becomes
[{"city": "Paris"}, {"city": "Lyon"}](today: a string).{note:{text:<|"|>line one+ newline +line two<|"|>}}becomes{"text": "line one\nline two"}(today: a string).{city:<|"|>Paris<|"|>}already worked and isunchanged.
Why. #557 added
BareKeyJSONParser, which quotes bare object keys andretries JSON parsing. It only knows JSON's own double quotes, so a nested
value that holds marker-quoted strings still falls back to the raw text. The
#557 review noted this: nested values written as
<|"|>…<|"|>are something"this parser still does not rewrite, but that is probably a separate
follow-up." This PR is that follow-up.
This is the shape the model is trained to write. The chat template's
format_argumentmacro wraps every string value in<|"|>at any nestingdepth and inserts the text as is, so line breaks can appear inside
(
google/gemma-4-e2b-itchat_template.jinja, lines 124–155; tool calls useit at line 253). The examples above are rendered by that template. Google's
tokenizer_config.jsonfor the same model tells transformers to read thesestrings (
string_delims: <|"|>,unquoted_keys: true), and mlx-lm'sgemma4tool parser reads them too. The regex in Google's function-callingdocs is a flat demo that does not read nested values, so it is not a useful
reference here.
What changed.
GemmaFunctionParserstill tries the original text first,exactly as before. Only if that fails, and the text contains the marker, it
rewrites each
<|"|>…<|"|>span as a JSON string and tries again. Therewrite escapes backslash, double quote, and every control character below
U+0020. Because the original text is always tried first, anything that
parsed before gives the same result now. This matters for FunctionGemma,
which shares this parser with the
<escape>marker:{a:"<escape>hi<escape>"}still parses as{"a": "<escape>hi<escape>"}.Two key shapes that the template never writes now parse as well, nested and
at the top level: a marker-quoted key, as in
{<|"|>city<|"|>:<|"|>Paris<|"|>}, and a space before a key, as in{city:<|"|>Paris<|"|>, country:<|"|>FR<|"|>}. gemma-4-e2b-it-4bit wroteboth shapes inside a nested value with thinking disabled: the quoted key in
1 of 8 sampled runs, the space in 2 of 8. Nested, the rewrite above turns a
marker-quoted key into a JSON string, which
BareKeyJSONParseraccepts. Atop-level key is read directly, so the parser now trims whitespace around
it and strips a marker pair around it, which transformers and mlx-lm already
do at every depth because they rewrite the whole argument text. Before,
{city:<|"|>Paris<|"|>, days:3}stored
daysunder the key" days"and{<|"|>city<|"|>:<|"|>Paris<|"|>}stored
cityunder<|"|>city<|"|>, neither of which the tool's schemaknows.
How it's tested.
Tests/MLXLMTests/ToolTests.swiftadds 22 tests next tothe existing Gemma tests, five of them parameterized.
19 fail without these changes (31 issues): nested objects, arrays of objects, bare
scalars mixed with marker strings, line breaks, tabs and other control
characters, quotes and backslashes, empty and backslash-ended strings,
non-ASCII text, marker-quoted keys, the two key shapes above, a space before
a top-level key, a marker-quoted top-level key, a marker
string in an array nested several objects deep, a call with no tool schema,
a FunctionGemma nested
<escape>string, a FunctionGemma value where anunpaired marker follows a paired one, and a streamed call through
ToolCallProcessor(format: .gemma4).3 pass both before and after. They use input outside the dialect and pin
existing behavior: JSON-quoted FunctionGemma strings that contain markers
(the original text is read first), a FunctionGemma value with an unpaired
marker, and a string-typed parameter that keeps its raw brace text. The test
with an unpaired marker after a paired one also uses malformed input; it
pins the result this change chooses.
Checklist
Put an
xin the boxes that apply.pre-commit run --all-filesto format my code / installed pre-commit prior to committing changesAI usage
I have read this PR description in full and approve it as my own, and it
accurately describes the code changes.
AI usage disclosure: