feat(engine): schema/grammar-constrained tool-call argument decoding (#374) - #375
Conversation
…374) Add opt-in constrained decoding that forces a tool call's *arguments* to satisfy the supplied JSON Schema, expressed in each architecture's native call syntax. Default off → byte-identical to today; enabled via SHARPI_TOOL_GRAMMAR=1 or the SharpInferenceServerOptions.ToolGrammar flag. Scoped to Gemma 4 end-to-end. Motivation (measured on gemma-4-12b-it-qat-q4_0, temp 0): the call envelope is always correct but arguments are wrong deterministically — get_weather drops the required `location` (emits `{}`), web_search invents a `{queries:[...]}` array instead of the schema's `query` string. This is a model prior, not an engine bug; constrained decoding makes the bad token sequences unreachable. Approach - Core grammar engine (SharpInference.Core/Grammar/): - ToolSchema: JSON-Schema → typed model (types/required/enum/array/nested object). - GrammarVocabulary: lazy per-token byte table + structural special-token lookup, built once per model, shared across requests. - ITokenConstraint: per-request decode constraint (IsConstraining/Filter/Accept/Reset). - GemmaToolArgumentConstraint: a byte-level pushdown automaton over Gemma's `<|tool_call>call:NAME{...}` wire format. It watches the token stream and, inside the argument object, masks logits so only schema-conformant tokens are sampled — only declared keys, every required key once, value shape per type, enums limited to the declared set; free string content stays unconstrained inside the `<|"|>` quotes. The quote / `{ } : , [ ]` resolve to real vocab tokens; multi-token keys and merged delimiters (`:[`, `]}`) are handled because matching is byte-level. Engages the instant the tool name completes (before `{`) so a merged `{}` token can't drop required args. Allocation-free hot path (reused mask/scratch buffers, first-byte pruning over the 262k-vocab). - IToolCallAdapter.BuildArgumentConstraint hook (default null; Gemma4 override) so each family owns its syntax (follow-up: Qwen/Llama JSON grammars). - Engine: SamplingParams.Constraint; the InferenceEngine decode loop masks logits while constraining and advances the constraint per token (disables the device-side argmax fast path and MTP for constrained requests). ContinuousBatchingEngine warns once and ignores it (not yet wired). - Server: ToolGrammar option + SHARPI_TOOL_GRAMMAR env; OpenAI/Anthropic endpoints build the constraint from req.Tools via the adapter on tool-active requests (OpenAI honors tool_choice:"none"). Verification (gemma-4-12b-it-qat-q4_0, CUDA, temp 0): before → after weather "in Berlin" {} → {"location":"Berlin"} web_search France {"queries":["..."]} → {"query":"..."} enum (celsius) {} → {"location":"Berlin","unit":"celsius"} array tags n/a → {"tags":["urgent","bug"]} plain / already-valid / tool_choice:none: byte-identical to default-off. Per-token mask overhead negligible (only the ~10 argument tokens, first-byte-pruned; warm constrained request ~0.33s). Tests: ToolSchemaParseTests (schema derivation), ToolGrammarMockTests (decode-time conformance via a fake Gemma tokenizer, CI-runnable), ToolGrammarConstraintTests (model-gated, real GGUF). 190 Tests.Core + 125 Tests.Server pass; build clean under TreatWarningsAsErrors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7nUrJ5qBQ1wpiPYeWwtGs
There was a problem hiding this comment.
Code Review
This pull request introduces schema- and grammar-constrained decoding for tool-call arguments, specifically supporting Gemma 4's native wire format. It adds a state machine constraint (GemmaToolArgumentConstraint), JSON-Schema parsing to ToolSchema, and integrates these constraints into the single-user InferenceEngine and server endpoints, while providing a warning in the ContinuousBatchingEngine. The review feedback highlights several critical performance optimizations on the hot path of token generation—such as optimizing large vocabulary loops, replacing Array.Copy with manual loops for shallow stacks, and avoiding HashSet.Contains lookups—as well as preventing potential stack overflows during recursive schema compilation by enforcing a depth limit.
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.
| private static CompiledObject? TryCompileObject(ToolSchemaObject obj) | ||
| { | ||
| if (obj.Open || obj.Properties.Count is 0 or > 64) return null; | ||
| var keys = new byte[obj.Properties.Count][]; | ||
| var values = new CompiledNode[obj.Properties.Count]; | ||
| ulong reqMask = 0; | ||
| for (int i = 0; i < obj.Properties.Count; i++) | ||
| { | ||
| var p = obj.Properties[i]; | ||
| if (p.Name.Length == 0) return null; | ||
| keys[i] = ToolSchema.Utf8(p.Name); | ||
| var node = TryCompileNode(p.Value); | ||
| if (node is null) return null; // a non-constrainable value → skip the tool | ||
| values[i] = node; | ||
| if (p.Required) reqMask |= 1UL << i; | ||
| } | ||
| return new CompiledObject { KeyBytes = keys, Values = values, RequiredMask = reqMask }; | ||
| } |
There was a problem hiding this comment.
To prevent potential StackOverflowException when compiling maliciously crafted or extremely deeply nested schemas, we should enforce a compilation depth limit. Since MaxDepth is already defined as 32 for the runtime frame stack, we can pass a depth parameter through the compilation methods and return null if it is exceeded.
private static CompiledObject? TryCompileObject(ToolSchemaObject obj, int depth = 0)
{
if (depth >= MaxDepth) return null;
if (obj.Open || obj.Properties.Count is 0 or > 64) return null;
var keys = new byte[obj.Properties.Count][];
var values = new CompiledNode[obj.Properties.Count];
ulong reqMask = 0;
for (int i = 0; i < obj.Properties.Count; i++)
{
var p = obj.Properties[i];
if (p.Name.Length == 0) return null;
keys[i] = ToolSchema.Utf8(p.Name);
var node = TryCompileNode(p.Value, depth + 1);
if (node is null) return null; // a non-constrainable value → skip the tool
values[i] = node;
if (p.Required) reqMask |= 1UL << i;
}
return new CompiledObject { KeyBytes = keys, Values = values, RequiredMask = reqMask };
}| private static CompiledNode? TryCompileNode(ToolSchemaNode node) | ||
| { | ||
| switch (node.Kind) | ||
| { | ||
| case JsonSchemaKind.String: | ||
| byte[][]? strEnum = node.EnumValues is { } se ? Encode(se) : null; | ||
| return new CompiledNode { Kind = JsonSchemaKind.String, Literals = strEnum, QuotedLiterals = strEnum is not null }; | ||
|
|
||
| case JsonSchemaKind.Number: | ||
| case JsonSchemaKind.Integer: | ||
| byte[][]? numEnum = node.EnumValues is { } ne ? Encode(ne) : null; | ||
| return new CompiledNode | ||
| { | ||
| Kind = node.Kind, | ||
| Literals = numEnum, | ||
| QuotedLiterals = false, | ||
| IntegerOnly = node.Kind == JsonSchemaKind.Integer, | ||
| }; | ||
|
|
||
| case JsonSchemaKind.Boolean: | ||
| return new CompiledNode { Kind = JsonSchemaKind.Boolean, Literals = s_boolLiterals }; | ||
|
|
||
| case JsonSchemaKind.Null: | ||
| return new CompiledNode { Kind = JsonSchemaKind.Null, Literals = s_nullLiterals }; | ||
|
|
||
| case JsonSchemaKind.Array: | ||
| // An untyped array (no items) isn't fully constrainable — skip the tool. | ||
| if (node.Items is null) return null; | ||
| var item = TryCompileNode(node.Items); | ||
| return item is null ? null : new CompiledNode { Kind = JsonSchemaKind.Array, Items = item }; | ||
|
|
||
| case JsonSchemaKind.Object: | ||
| if (node.Object is null) return null; | ||
| var obj = TryCompileObject(node.Object); | ||
| return obj is null ? null : new CompiledNode { Kind = JsonSchemaKind.Object, Object = obj }; | ||
|
|
||
| default: | ||
| return null; // Any / unknown — not constrainable | ||
| } | ||
| } |
There was a problem hiding this comment.
Enforce the compilation depth limit in TryCompileNode as well to prevent stack overflow during recursive schema compilation.
private static CompiledNode? TryCompileNode(ToolSchemaNode node, int depth = 0)
{
if (depth >= MaxDepth) return null;
switch (node.Kind)
{
case JsonSchemaKind.String:
byte[][]? strEnum = node.EnumValues is { } se ? Encode(se) : null;
return new CompiledNode { Kind = JsonSchemaKind.String, Literals = strEnum, QuotedLiterals = strEnum is not null };
case JsonSchemaKind.Number:
case JsonSchemaKind.Integer:
byte[][]? numEnum = node.EnumValues is { } ne ? Encode(ne) : null;
return new CompiledNode
{
Kind = node.Kind,
Literals = numEnum,
QuotedLiterals = false,
IntegerOnly = node.Kind == JsonSchemaKind.Integer,
};
case JsonSchemaKind.Boolean:
return new CompiledNode { Kind = JsonSchemaKind.Boolean, Literals = s_boolLiterals };
case JsonSchemaKind.Null:
return new CompiledNode { Kind = JsonSchemaKind.Null, Literals = s_nullLiterals };
case JsonSchemaKind.Array:
// An untyped array (no items) isn't fully constrainable — skip the tool.
if (node.Items is null) return null;
var item = TryCompileNode(node.Items, depth + 1);
return item is null ? null : new CompiledNode { Kind = JsonSchemaKind.Array, Items = item };
case JsonSchemaKind.Object:
if (node.Object is null) return null;
var obj = TryCompileObject(node.Object, depth + 1);
return obj is null ? null : new CompiledNode { Kind = JsonSchemaKind.Object, Object = obj };
default:
return null; // Any / unknown — not constrainable
}
}| if (top.Kind == FK.Str && top.State == SContent) | ||
| { | ||
| // Any non-forbidden token stays in content; the quote token closes. Forbid only EOG. | ||
| for (int id = 0; id < buf.Length; id++) | ||
| { | ||
| if (_forbidden.Contains(id)) { buf[id] = float.NegativeInfinity; } | ||
| else kept++; | ||
| } | ||
| return kept; | ||
| } |
There was a problem hiding this comment.
In the performance-critical hot path of token generation, calling _forbidden.Contains(id) inside a loop of size 262,144 (the vocabulary size) for every generated token introduces significant overhead. Since _forbidden typically contains only a few EOG token IDs, we can set those specific IDs to -inf first, and then use a simple, extremely fast float comparison loop to count kept tokens, completely avoiding HashSet.Contains overhead inside the 262k loop.
if (top.Kind == FK.Str && top.State == SContent)
{
// Any non-forbidden token stays in content; the quote token closes. Forbid only EOG.
foreach (int id in _forbidden)
{
if (id >= 0 && id < buf.Length)
{
buf[id] = float.NegativeInfinity;
}
}
for (int id = 0; id < buf.Length; id++)
{
if (!float.IsNegativeInfinity(buf[id]))
kept++;
}
return kept;
}| if ((top.Kind == FK.Str && top.State == SExpectOpen) | ||
| || (top.Kind == FK.StrEnum && top.State == SeExpectOpen)) | ||
| { | ||
| for (int id = 0; id < buf.Length; id++) | ||
| { | ||
| if (id == _quoteId) kept++; | ||
| else buf[id] = float.NegativeInfinity; | ||
| } | ||
| return kept; | ||
| } |
There was a problem hiding this comment.
Instead of looping 262,144 times with a conditional branch to set everything except _quoteId to -inf, we can use Array.Fill to set the entire buffer to -inf and then restore the original logit of _quoteId. This is orders of magnitude faster and avoids the overhead of a large loop with branches.
if ((top.Kind == FK.Str && top.State == SExpectOpen)
|| (top.Kind == FK.StrEnum && top.State == SeExpectOpen))
{
float originalQuoteLogit = buf[_quoteId];
Array.Fill(buf, float.NegativeInfinity);
if (!float.IsNegativeInfinity(originalQuoteLogit))
{
buf[_quoteId] = originalQuoteLogit;
return 1;
}
return 0;
}| private bool SimulateToken(int token) | ||
| { | ||
| int savedDepth = _depth; | ||
| Array.Copy(_stack, _scratch, _depth); | ||
| bool ok = RunToken(token); | ||
| Array.Copy(_scratch, _stack, savedDepth); | ||
| _depth = savedDepth; | ||
| return ok; | ||
| } |
There was a problem hiding this comment.
Since SimulateToken is called for every candidate token in the vocabulary (which can be thousands of candidates per generated token), using Array.Copy twice to save and restore the stack introduces noticeable overhead. Since _depth is typically very small (usually 1 to 3), a manual loop to copy the active frames is significantly faster because it avoids native transition, runtime type checking, and bounds checking overhead.
private bool SimulateToken(int token)
{
int savedDepth = _depth;
for (int i = 0; i < savedDepth; i++) _scratch[i] = _stack[i];
bool ok = RunToken(token);
for (int i = 0; i < savedDepth; i++) _stack[i] = _scratch[i];
_depth = savedDepth;
return ok;
}Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7nUrJ5qBQ1wpiPYeWwtGs
…review) Address review findings on PR #375. The constraint's fallbacks are correctly placed (an opt-in feature must never wedge generation) but were silent, so a genuine grammar bug was indistinguishable from benign degradation and "enabled but inert" passed as success. Add one deduped diagnostic per distinct event (mirrors JinjaChatTemplate.WarnUnsupportedOnce — Console.Error, deduped so a recurring condition never spams the decode loop): - Accept: a sampled token permitted by the mask but rejected on replay is a Filter/Accept invariant violation (the tripwire for any mask-vs-simulate bug), now flagged — distinct from the normal end-of-object transition. - Filter: a dead state (zero legal tokens) warns instead of silently returning unmasked logits. - Constructor: warns once when Gemma's structural tokens don't resolve (wrong / mistokenized model) so the inert constraint is discoverable. - StartConstraintBody: warns when trailing-byte replay after '{' fails. - Host: warns at boot when SHARPI_TOOL_GRAMMAR is set alongside SHARPI_MAX_BATCH (the constraint is not applied under continuous batching). Robustness + perf: - ToolSchema parsing now caps recursion at depth 32 (degrades to Any/open), defense-in-depth against an adversarially deep request schema (a .NET StackOverflow is uncatchable; System.Text.Json's own depth limit bounds it too). - The free-string-content mask masks the handful of EOG ids directly instead of testing all 262k tokens against the forbidden set. No behavior change on the happy path (GPU-reverified: weather/search/enum still correct, no spurious diagnostics). 191 Tests.Core + 125 Tests.Server pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7nUrJ5qBQ1wpiPYeWwtGs
…masks - TryCompileObject/TryCompileNode are now depth-capped (MaxDepth): defense-in-depth against an in-memory deeply-nested ToolSchema (the records are public and bypass the parser's own depth cap) — an uncatchable StackOverflow otherwise. - String-open mask uses Array.Fill(-inf) + restore the quote logit instead of a branchy per-id loop over the 262k vocab. - SimulateToken saves/restores the (tiny, 1-3 deep) frame stack with a manual loop instead of Array.Copy, avoiding the per-call overhead on the masking hot path. (The free-string-content mask was already EOG-direct as of the prior commit.) 22 grammar/schema tests pass; build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7nUrJ5qBQ1wpiPYeWwtGs
|
Thanks @gemini-code-assist — addressed in ca28054:
22 grammar/schema tests pass; build clean under TreatWarningsAsErrors. |
|
Thanks for the update, @pekkah. The depth-capping in |
Closes #374.
What
Opt-in schema/grammar-constrained decoding that forces a tool call's arguments to satisfy the supplied JSON Schema, expressed in each architecture's native call syntax. Default off → byte-identical to today. Enabled via
SHARPI_TOOL_GRAMMAR=1orSharpInferenceServerOptions.ToolGrammar. Scoped to Gemma 4 end-to-end; the adapter hook generalizes to other families (follow-up for Qwen/Llama JSON).Why
On
gemma-4-12b-it-qat-q4_0(temp 0) the call envelope is always correct but arguments are wrong deterministically — a model prior, not an engine bug:locationrequired){}{"location":"Berlin"}query: string){"queries":["…"]}{"query":"…"}unit∈{celsius,fahrenheit}{}{"location":"Berlin","unit":"celsius"}tags: array<string>{"tags":["urgent","bug"]}Constrained decoding makes the bad token sequences unreachable instead of hoping the model conforms.
Approach
Per-adapter grammar.
IToolCallAdapter.BuildArgumentConstraint(tools, vocab)(defaultnull; Gemma4 override) so each family owns its wire format.The hard part — terminal→token mapping. A byte-level pushdown automaton over Gemma's
<|tool_call>call:NAME{key:<|"|>val<|"|>,n:3}<tool_call|>syntax. A candidate token is allowed iff replaying its bytes keeps the FSM alive. Matching is byte-level for the structural skeleton (so multi-token keys and merged delimiters like:[/]}work) and token-level for the<|"|>quote special token and free string content. The grammar enforces: only declared keys, every required key exactly once, value shape per declared type (strings quoted with free content; numbers/bool/null bare; arrays[…]; nested objects{…}), andenumrestriction. It engages the instant the tool name completes — before{— so Gemma's merged{}empty-args token can't slip required arguments past the mask. Allocation-free hot path (reused mask/scratch buffers + first-byte pruning over the 262k vocab; warm constrained request ~0.33s, overhead negligible).Engine.
SamplingParams.Constraint; theInferenceEnginedecode loop masks logits while constraining and advances the constraint per token (disables the device-side argmax fast path + MTP for constrained requests).ContinuousBatchingEnginewarns once and ignores it (not yet wired — noted as follow-up).Server.
ToolGrammaroption +SHARPI_TOOL_GRAMMARenv; OpenAI/Anthropic endpoints build the constraint fromreq.Toolson tool-active requests (OpenAI honorstool_choice:"none").Verification (CUDA, gemma-4-12b-it-qat-q4_0, temp 0)
All failure cases above flip to correct; plain text, already-valid calls, and
tool_choice:"none"are byte-identical to default-off; streaming honors the constraint.Tests
ToolSchemaParseTests— schema derivation (types/required/enum/array/nested).ToolGrammarMockTests— decode-time conformance via a fake Gemma tokenizer (CI-runnable, no GGUF): required-key, foreign-key rejection, string/enum/number/array/nested-object value shapes, early-engage, reset.ToolGrammarConstraintTests— model-gated conformance against the real GGUF.190 Tests.Core + 125 Tests.Server pass; full Release build clean under
TreatWarningsAsErrors(trim/AOT analyzers + InvariantGlobalization on).Follow-ups (filed)
ContinuousBatchingEngine(today it warns + ignores).Any-typed value / open object currently left unconstrained).🤖 Generated with Claude Code