feat(core): JSON-syntax tool-argument grammar for Qwen/Llama/DeepSeek (#376) - #380
Conversation
…#376) Extends schema/grammar-constrained tool-call argument decoding (#374) to the families that emit standard JSON — Qwen (<tool_call>{json}</tool_call>), Llama-3 (<|python_tag|>{json}), and DeepSeek (NAME<|tool_sep|>{json}) — the JSON sibling of GemmaToolArgumentConstraint. - New JsonToolArgumentConstraint: a byte-level pushdown automaton over standard JSON ({"key":"value",...}: double-quoted keys/strings with \-escape handling, bare numbers/booleans/null, [...] arrays, nested {...} objects, enum). Matching is fully byte-level because real BPE merges structural bytes with content (one token carries {", ":, "}}, or the whole {}). - Early-engage at the argument key's colon ("arguments":/"parameters":) so the next token is masked — a merged {} token can't drop the required arguments. - Two envelopes: NameValueObject (Qwen/Llama — name+arguments object, with a preamble parser that captures the tool name and finds the args object) and NameThenSeparator (DeepSeek — bare name up to <|tool_sep|>, then the args object). Wired via QwenToolCallAdapter/LlamaToolCallAdapter/DeepSeekToolCall- Adapter.BuildArgumentConstraint. - Extracted the shared schema-compile logic + CompiledObject/CompiledNode out of GemmaToolArgumentConstraint into ToolSchemaCompiler (Gemma now calls it; all 72 Gemma grammar/adapter tests still green — behavior-preserving). - EOG forbidden inside the region (empty-byte pruning + an explicit EOG mask pass, so an EOS that decodes to text can't slip through a free string). - Default-off byte-identical: inert when no tool is constrainable, the open marker isn't a vocabulary token, or the model emits a non-JSON shape (Qwen3.6 XML <function=> never engages). Tests: FakeJsonTokenizer (single-byte tokens + realistic merged structural pieces) drives 14 CI mock cases (required key, foreign-key rejection, enum, string-with-escapes, number, nested object, array, merged-{} early-engage, and the Qwen/Llama/DeepSeek envelopes). Model-gated JsonToolGrammarConstraintTests asserts the same invariants over a REAL Qwen3.6 BPE vocab (production merges: {}, {", ":). 90 grammar/tool tests green. Qwen3-Coder's <parameter=> XML wrapper is not JSON and is left for a follow-up matcher (no override → unconstrained, never wrong). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U3TksFvj6f3rjpH1kDLtwf
There was a problem hiding this comment.
Code Review
This pull request extracts the tool schema compilation logic into a shared ToolSchemaCompiler class and introduces JsonToolArgumentConstraint to support constrained decoding of standard JSON tool-call arguments for Qwen, Llama-3, and DeepSeek. Corresponding adapters and tests have been added or updated. Feedback highlights a critical performance bottleneck in ComputeMask during free string content generation, where calling SimulateToken for the entire vocabulary on every step will severely degrade performance; a fast-path optimization is suggested to resolve this.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| int kept = 0; | ||
| for (int id = 0; id < buf.Length; id++) | ||
| { | ||
| var bytes = _vocab.TokenBytes(id); | ||
| // Empty-byte tokens (EOG / control) never advance the structure — forbidding them keeps | ||
| // an end-of-generation token from truncating the call mid-object. | ||
| bool candidate = bytes.Length != 0 && _firstByteOk[bytes[0]]; | ||
| if (candidate && SimulateToken(id)) | ||
| kept++; | ||
| else | ||
| buf[id] = float.NegativeInfinity; | ||
| } | ||
|
|
||
| // Belt-and-suspenders: forbid every EOG id regardless of its bytes (a tokenizer whose EOS | ||
| // decodes to ordinary text could otherwise pass first-byte pruning inside a free string). | ||
| foreach (int id in _forbidden) | ||
| if ((uint)id < (uint)buf.Length && !float.IsNegativeInfinity(buf[id])) | ||
| { buf[id] = float.NegativeInfinity; kept--; } | ||
|
|
||
| return kept; |
There was a problem hiding this comment.
Performance Bottleneck in ComputeMask during String Content Generation
When the state machine is in SContent (generating free string content), CollectStr sets all 256 bytes in _firstByteOk to true. This causes candidate to be true for almost the entire vocabulary (typically 150,000+ tokens for Qwen/Llama).
Without an optimization, SimulateToken(id) is called for every single token in the vocabulary on every generated token step inside a string. SimulateToken copies the stack of frames and walks the token's bytes, which introduces a massive
Since the vast majority of tokens do not contain the string delimiter " or the escape character \\, we can fast-path them when !escaped and skip the expensive SimulateToken call entirely.
int kept = 0;
ref var top = ref _stack[_depth - 1];
bool isStrContent = top.Kind == FK.Str && top.State == SContent;
bool escaped = top.Escaped;
for (int id = 0; id < buf.Length; id++)
{
var bytes = _vocab.TokenBytes(id);
// Empty-byte tokens (EOG / control) never advance the structure — forbidding them keeps
// an end-of-generation token from truncating the call mid-object.
bool candidate = bytes.Length != 0 && _firstByteOk[bytes[0]];
if (candidate)
{
bool ok = isStrContent && !escaped && !ContainsQuoteOrSlash(bytes)
? true
: SimulateToken(id);
if (ok)
kept++;
else
buf[id] = float.NegativeInfinity;
}
else
{
buf[id] = float.NegativeInfinity;
}
}
// Belt-and-suspenders: forbid every EOG id regardless of its bytes (a tokenizer whose EOS
// decodes to ordinary text could otherwise pass first-byte pruning inside a free string).
foreach (int id in _forbidden)
if ((uint)id < (uint)buf.Length && !float.IsNegativeInfinity(buf[id]))
{ buf[id] = float.NegativeInfinity; kept--; }
return kept;
static bool ContainsQuoteOrSlash(ReadOnlySpan<byte> span)
{
for (int i = 0; i < span.Length; i++)
{
byte b = span[i];
if (b is (byte)'"' or (byte)'\\') return true;
}
return false;
}Inside a JSON string value, the close/escape are ordinary bytes, so the mask marks all 256 first-bytes and would SimulateToken the whole vocabulary (150k+ tokens on Qwen/Llama) every step — ~seconds/token. A token containing neither '"' nor '\' is pure content that only keeps the string open, so it's legal without the frame-copy + byte-walk; only tokens that could close or escape need the full replay. Disabled mid-escape. The byte-level analogue of the Gemma sibling's token-level free-content shortcut. All 18 JSON tests still green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U3TksFvj6f3rjpH1kDLtwf
|
Addressed the |
Summary
Extends schema/grammar-constrained tool-call argument decoding (#374, PR #375) to the families that emit standard JSON — Qwen (
<tool_call>{json}</tool_call>), Llama-3 (<|python_tag|>{json}), and DeepSeek (NAME<|tool_sep|>{json}) — the JSON sibling ofGemmaToolArgumentConstraint. Closes #376.What changed
JsonToolArgumentConstraint— a byte-level pushdown automaton over standard JSON:{"key":"value",...}with double-quoted keys/strings (\-escape aware), bare numbers/booleans/null,[...]arrays, nested{...}objects, andenumrestriction. Matching is fully byte-level because a real BPE merges structural bytes with content/whitespace — one token carries{", another":, another"}}, and the empty object is a single{}token (verified by probing the real Qwen vocab)."arguments":/"parameters":) pushes the object frame inOExpectOpenBraceso the next token is masked — a merged{}token can't drop the required arguments (the feat(engine): schema/grammar-constrained decoding for tool-call arguments #374 failure mode, in JSON form).NameValueObject(Qwen/Llama): a preamble parser walks{"name":"X","argskey":{to capture the tool name and find the args object;argskeyisarguments(Qwen) orparameters(Llama).NameThenSeparator(DeepSeek): bare name up to<|tool_sep|>, then the args object directly.Wired via
QwenToolCallAdapter/LlamaToolCallAdapter/DeepSeekToolCallAdapter.BuildArgumentConstraint.CompiledObject/CompiledNode+TryCompile*moved out ofGemmaToolArgumentConstraintintoToolSchemaCompiler, used by both constraints (DRY, and sets up Tool-arg grammar: constrain partially-typed tools (Any/open-object values) (#374 follow-up) #378 to modify one place). The Gemma constraint now calls it; all 72 prior Gemma grammar/adapter tests stay green — behavior-preserving.<function=>never engages).Tests
JsonToolGrammarMockTests, 14) —FakeJsonTokenizerprovides single-byte tokens plus realistic merged structural pieces ({",":,{}, …). Covers: required key can't close early, foreign-key rejection, enum, string with\"escape, number, nested object, array, the merged-{}early-engage, and the Qwen/Llama/DeepSeek envelopes.JsonToolGrammarConstraintTests, 4) — drives the constraint over a real Qwen3.6 BPE vocabulary (production merges:{},{",":), asserting it engages at the colon, rejects merged{}, allows{", enforces declared keys, restricts the enum, and stays inert on the XML form.90 grammar/tool tests green; full solution builds clean underTreatWarningsAsErrors+ trim/AOT analyzers.Note on GPU e2e
A live generation A/B (like #377's) isn't included: the on-disk Qwen3.6 emits the XML tool shape (the JSON constraint correctly stays inert on it), and no JSON-emitting Qwen/Llama that fits VRAM was available. The model-gated test instead exercises the byte-level matching against the real production tokenizer.
Follow-up
Qwen3-Coder's
<parameter=…>XML wrapper isn't JSON and is left for a separate matcher (its adapter has no override → arguments generate unconstrained, never wrong) — the issue flagged this as possibly separate.Follow-up to #374 / #375.
🤖 Generated with Claude Code