diff --git a/src/SharpInference.Core/Grammar/GemmaToolArgumentConstraint.cs b/src/SharpInference.Core/Grammar/GemmaToolArgumentConstraint.cs
index 3b19baa..02477c1 100644
--- a/src/SharpInference.Core/Grammar/GemmaToolArgumentConstraint.cs
+++ b/src/SharpInference.Core/Grammar/GemmaToolArgumentConstraint.cs
@@ -114,7 +114,7 @@ public void Reset()
// ── Frame stack ───────────────────────────────────────────────────────────
- private enum FK : byte { Object, Array, Str, StrEnum, Num, Lit }
+ private enum FK : byte { Object, Array, Str, StrEnum, Num, Lit, Free }
private struct Frame
{
@@ -125,6 +125,7 @@ private struct Frame
public ulong Emitted; // Object: keys emitted
public ulong Cand; // Object key-match candidates / StrEnum / Lit candidates
public int MatchLen; // chars into current key / literal
+ public int FreeDepth; // Free: nesting balance of {}/[] in a free value
public bool SeenDigit; // Num
public bool SeenDot; // Num
public bool SeenSign; // Num
@@ -160,6 +161,14 @@ private struct Frame
// Lit sub-state: single matching state.
private const int LMatch = 0;
+ // Free-value sub-states (issue #378): a permissive value of unknown type, balanced to completion.
+ // Strings are token-level (the <|"|> quote, via HandleQuote); structure is byte-level.
+ private const int FrStart = 0; // value not yet started
+ private const int FrBare = 1; // a bare scalar — ends at a top-level delimiter
+ private const int FrBalanced = 2; // inside {…}/[…], FreeDepth ≥ 1, not in a string
+ private const int FrStr = 3; // a top-level <|"|>…<|"|> string value (token-level content)
+ private const int FrBalancedStr = 4; // a <|"|>…<|"|> string inside a balanced free value
+
private void PushObject(CompiledObject obj)
{
ref var f = ref _stack[_depth++];
@@ -198,6 +207,9 @@ private bool PushValue(CompiledNode node)
case JsonSchemaKind.Array:
f.Kind = FK.Array; f.State = AExpectOpen;
break;
+ case JsonSchemaKind.Any: // free value (issue #378)
+ f.Kind = FK.Free; f.State = FrStart;
+ break;
default: // Number / Integer / Boolean / Null
if (node.Literals is not null) { f.Kind = FK.Lit; f.State = LMatch; f.Cand = AllBits(node.Literals.Length); }
else { f.Kind = FK.Num; f.State = NStart; }
@@ -331,8 +343,10 @@ private int ComputeMask(float[] buf)
int kept = 0;
// Token-level tops: free string content and the open/close quote. Handle without a
- // per-token byte simulation for speed.
- if (top.Kind == FK.Str && top.State == SContent)
+ // per-token byte simulation for speed. Free-value string content (issue #378) behaves
+ // identically — any non-EOG token stays, the quote token closes.
+ if ((top.Kind == FK.Str && top.State == SContent)
+ || (top.Kind == FK.Free && top.State is FrStr or FrBalancedStr))
{
// Any non-forbidden token stays in content; the quote token closes. Forbid only EOG —
// a tiny set, so mask those ids directly rather than testing all 262k tokens against it.
@@ -360,6 +374,12 @@ private int ComputeMask(float[] buf)
Array.Clear(_firstByteOk);
bool quoteAllowed = CollectFirstBytes(_depth - 1, _firstByteOk) || IsQuoteAccepted();
+ // A free value's non-string states (issue #378) mark all first-bytes, so without a shortcut
+ // every step would simulate the whole vocabulary. A non-quote token carrying none of the
+ // bytes that can balance / delimit a free value is pure content that keeps it alive — admit it
+ // without the per-token replay (the analogue of the token-level free-content path above).
+ bool fastFree = top.Kind == FK.Free && top.State is FrStart or FrBare or FrBalanced;
+
for (int id = 0; id < buf.Length; id++)
{
var bytes = _vocab.TokenBytes(id);
@@ -368,14 +388,33 @@ private int ComputeMask(float[] buf)
else if (bytes.Length == 0) candidate = false;
else candidate = _firstByteOk[bytes[0]];
- if (candidate && SimulateToken(id))
- kept++;
- else
- buf[id] = float.NegativeInfinity;
+ bool ok = candidate
+ && ((fastFree && id != _quoteId && !ContainsFreeStructural(bytes)) || SimulateToken(id));
+ if (ok) kept++;
+ else buf[id] = float.NegativeInfinity;
}
+
+ // Belt-and-suspenders: forbid every EOG id regardless of its bytes. The fastFree shortcut
+ // admits content tokens without simulation, so a tokenizer whose EOS decodes to ordinary
+ // (non-structural) text could otherwise pass first-byte pruning inside a free value and
+ // truncate the call mid-object. (Mirrors the JSON constraint's sweep.)
+ foreach (int id in _forbidden)
+ if ((uint)id < (uint)buf.Length && !float.IsNegativeInfinity(buf[id]))
+ { buf[id] = float.NegativeInfinity; kept--; }
+
return kept;
}
+ /// Whether a token carries any byte that can balance or delimit a free value (and so
+ /// must be simulated rather than fast-pathed as pure content). Gemma strings are the <|"|>
+ /// token, so the quote is not a structural byte here.
+ private static bool ContainsFreeStructural(ReadOnlySpan bytes)
+ {
+ for (int i = 0; i < bytes.Length; i++)
+ if (bytes[i] is (byte)'{' or (byte)'}' or (byte)'[' or (byte)']' or (byte)',') return true;
+ return false;
+ }
+
private bool SimulateToken(int token)
{
// Save/restore the active frames around a trial replay. _depth is tiny (1–3 in practice), and
@@ -409,6 +448,7 @@ private bool RunToken(int tokenId)
case FK.StrEnum when top.State == SeExpectOpen:
return false;
case FK.Str when top.State == SContent: // free content: any non-EOG token stays
+ case FK.Free when top.State is FrStr or FrBalancedStr: // free-value string content
return !_forbidden.Contains(tokenId);
case FK.StrEnum when top.State == SeMatch:
break; // enum content → byte-walk below
@@ -436,15 +476,29 @@ private bool HandleQuote()
case FK.Array when top.State is AExpectItemOrClose or AExpectItem:
{
var item = top.Node!.Items!;
- if (item.Kind != JsonSchemaKind.String) return false; // only a string item opens on a quote
+ // A string item — or a FREE item (issue #378) — opens on the quote token (other item
+ // kinds open on a structural byte instead).
+ if (item.Kind is not (JsonSchemaKind.String or JsonSchemaKind.Any)) return false;
top.State = AExpectCommaOrClose; // resume here after the item
if (_depth >= MaxDepth) return false;
ref var f = ref _stack[_depth++];
f = default; f.Node = item;
- if (item.Literals is not null) { f.Kind = FK.StrEnum; f.State = SeMatch; f.Cand = AllBits(item.Literals.Length); }
+ if (item.Kind == JsonSchemaKind.Any) { f.Kind = FK.Free; f.State = FrStr; } // free string item
+ else if (item.Literals is not null) { f.Kind = FK.StrEnum; f.State = SeMatch; f.Cand = AllBits(item.Literals.Length); }
else { f.Kind = FK.Str; f.State = SContent; }
return true;
}
+ case FK.Free:
+ // A free value's strings are <|"|>…<|"|>: open one at the value start or inside a
+ // balanced object/array; close the one currently open.
+ switch (top.State)
+ {
+ case FrStart: top.State = FrStr; return true; // open top-level string value
+ case FrStr: _depth--; return PostValue(); // close → value done
+ case FrBalanced: top.State = FrBalancedStr; return true; // open string inside {}/[]
+ case FrBalancedStr: top.State = FrBalanced; return true; // close inner string
+ default: return false; // FrBare: a quote isn't legal
+ }
default:
return false; // quote not legal here
}
@@ -461,7 +515,10 @@ private bool IsQuoteAccepted()
FK.StrEnum => top.State == SeExpectOpen
|| (top.State == SeMatch && AnyComplete(top.Node!.Literals!, top.Cand, top.MatchLen)),
FK.Array => top.State is AExpectItemOrClose or AExpectItem
- && top.Node!.Items!.Kind == JsonSchemaKind.String,
+ && top.Node!.Items!.Kind is JsonSchemaKind.String or JsonSchemaKind.Any,
+ // A free value can open a string at its start or inside a balanced object/array; the close
+ // of an open free string is handled by the free-content mask path, not here.
+ FK.Free => top.State is FrStart or FrBalanced,
_ => false,
};
}
@@ -492,7 +549,8 @@ private bool FeedRawBytes(ReadOnlySpan bytes)
}
private static bool IsTokenLevel(in Frame f) =>
- (f.Kind == FK.Str) || (f.Kind == FK.StrEnum && f.State == SeExpectOpen);
+ (f.Kind == FK.Str) || (f.Kind == FK.StrEnum && f.State == SeExpectOpen)
+ || (f.Kind == FK.Free && f.State is FrStr or FrBalancedStr);
private enum Step { Consume, Retry, Reject }
@@ -505,10 +563,43 @@ private Step StepByte(ref Frame top, byte b)
case FK.Num: return StepNum(ref top, b);
case FK.Lit: return StepLit(ref top, b);
case FK.StrEnum: return StepStrEnum(ref top, b); // SeMatch only reaches here
+ case FK.Free: return StepFree(ref top, b);
default: return Step.Reject;
}
}
+ /// Free-value (issue #378) byte handling: balance a bare scalar / object / array to
+ /// completion, then pop so the enclosing object resumes enforcing its declared/required keys.
+ /// Strings (<|"|>…<|"|>) are opened/closed by the quote token in HandleQuote,
+ /// never byte-walked here.
+ private Step StepFree(ref Frame f, byte b)
+ {
+ switch (f.State)
+ {
+ case FrStart:
+ if (IsWs(b)) return Step.Consume;
+ if (b is (byte)'{' or (byte)'[') { f.FreeDepth = 1; f.State = FrBalanced; return Step.Consume; }
+ if (b is (byte)',' or (byte)'}' or (byte)']') return Step.Reject; // a value can't be empty
+ f.State = FrBare; return Step.Consume; // bare scalar start
+
+ case FrBare:
+ if (b is (byte)',' or (byte)'}' or (byte)']') { _depth--; return PostValueRetry(); }
+ return Step.Consume;
+
+ case FrBalanced:
+ if (b is (byte)'{' or (byte)'[') { f.FreeDepth++; return Step.Consume; }
+ if (b is (byte)'}' or (byte)']')
+ {
+ if (--f.FreeDepth == 0) { _depth--; return PostValueOrDone(); }
+ return Step.Consume;
+ }
+ return Step.Consume; // bare keys / ':' / ',' / scalars are content
+
+ default:
+ return Step.Reject; // FrStr / FrBalancedStr are token-level
+ }
+ }
+
private static bool IsWs(byte b) => b is (byte)' ' or (byte)'\t' or (byte)'\n' or (byte)'\r';
// After a value frame pops, resume the parent (object/array) at its post-value state.
@@ -708,6 +799,10 @@ private bool CollectFirstBytes(int d, bool[] set)
CollectStrEnum(ref f, set, ref quote);
popThrough = false;
break;
+ case FK.Free:
+ CollectFree(ref f, set); // quote handled by IsQuoteAccepted
+ popThrough = false;
+ break;
}
if (!popThrough) break;
d--; // bare value can end here — parent's bytes also start a token
@@ -715,6 +810,15 @@ private bool CollectFirstBytes(int d, bool[] set)
return quote;
}
+ private static void CollectFree(ref Frame f, bool[] set)
+ {
+ // A free value admits almost anything — mark broadly and let the (fast-pathed) simulate pass
+ // decide. The only restriction is that the value can't START with a parent delimiter (empty
+ // value). FrStr/FrBalancedStr never reach here (token-level free content is masked separately).
+ for (int i = 0; i < 256; i++) set[i] = true;
+ if (f.State == FrStart) { set[','] = false; set['}'] = false; set[']'] = false; }
+ }
+
private static bool CollectObject(ref Frame f, bool[] set)
{
var obj = f.Obj!;
@@ -816,6 +920,15 @@ private static void MarkValueFirstBytes(CompiledNode node, bool[] set)
case JsonSchemaKind.Array: set['['] = true; break;
case JsonSchemaKind.Object: set['{'] = true; break;
case JsonSchemaKind.String: break; // opens with the quote token, not a byte
+ case JsonSchemaKind.Any:
+ // A free array item (issue #378) can be any value — mark every value-START byte. A
+ // string item opens on the <|"|> quote token (admitted by IsQuoteAccepted, not a byte),
+ // so the quote is not marked here. (Don't touch ']'; the array frame marks it for the
+ // empty-array close, and unsetting it here would wrongly forbid closing.)
+ set['{'] = true; set['['] = true; set['-'] = true;
+ MarkDigits(set);
+ set['t'] = true; set['f'] = true; set['n'] = true; // true / false / null
+ break;
default:
if (node.Literals is { } lits)
foreach (var l in lits) { if (l.Length > 0) set[l[0]] = true; }
diff --git a/src/SharpInference.Core/Grammar/JsonToolArgumentConstraint.cs b/src/SharpInference.Core/Grammar/JsonToolArgumentConstraint.cs
index f9115f6..f14014f 100644
--- a/src/SharpInference.Core/Grammar/JsonToolArgumentConstraint.cs
+++ b/src/SharpInference.Core/Grammar/JsonToolArgumentConstraint.cs
@@ -153,7 +153,7 @@ private void ResetPreamble()
// ── Frame stack ───────────────────────────────────────────────────────────
- private enum FK : byte { Object, Array, Str, StrEnum, Num, Lit }
+ private enum FK : byte { Object, Array, Str, StrEnum, Num, Lit, Free }
private struct Frame
{
@@ -165,10 +165,11 @@ private struct Frame
public ulong Cand; // Object key-match / StrEnum / Lit candidates
public int MatchLen; // chars into current key / literal
public int PendingKey; // Object: key index whose value to push at ':'
+ public int FreeDepth; // Free: nesting balance of {}/[] in a free value
public bool SeenDigit; // Num
public bool SeenDot; // Num
public bool SeenSign; // Num
- public bool Escaped; // Str content: previous byte was '\'
+ public bool Escaped; // Str / Free content: previous byte was '\'
}
// Object sub-states.
@@ -202,6 +203,13 @@ private struct Frame
// Lit sub-state.
private const int LMatch = 0;
+ // Free-value sub-states (issue #378): a permissive value of unknown type, balanced to completion.
+ private const int FrStart = 0; // value not yet started
+ private const int FrStr = 1; // a top-level string value
+ private const int FrBare = 2; // a bare scalar — ends at a top-level delimiter
+ private const int FrBalanced = 3; // inside {…}/[…], FreeDepth ≥ 1
+ private const int FrBalancedStr = 4; // a string inside a balanced free value
+
// Preamble (watching) sub-states.
private const int WStart = 0; // before envelope '{' (NameValueObject) / scanning name (NameThenSeparator)
private const int WKeyExpect = 1; // '"' opens a key, '}' gives up
@@ -240,6 +248,9 @@ private bool PushValue(CompiledNode node)
case JsonSchemaKind.Array:
f.Kind = FK.Array; f.State = AExpectOpen;
break;
+ case JsonSchemaKind.Any: // free value (issue #378)
+ f.Kind = FK.Free; f.State = FrStart;
+ break;
default: // Number / Integer / Boolean / Null
if (node.Literals is not null) { f.Kind = FK.Lit; f.State = LMatch; f.Cand = AllBits(node.Literals.Length); }
else { f.Kind = FK.Num; f.State = NStart; }
@@ -356,10 +367,14 @@ private int ComputeMask(float[] buf)
// or escape need the full replay. Disabled mid-escape (the next byte is consumed literally), so
// those tokens fall back to SimulateToken. This is the byte-level analogue of the Gemma sibling's
// token-level free-content shortcut.
- bool fastStrContent;
+ bool fastStrContent, fastFree;
{
ref var top = ref _stack[_depth - 1];
fastStrContent = top.Kind == FK.Str && top.State == SContent && !top.Escaped;
+ // A free value (issue #378) marks all 256 first-bytes too; a token carrying none of the
+ // structural bytes that can balance / delimit / quote it is pure content valid in any free
+ // state, so it skips SimulateToken — same shape as the string-content fast-path.
+ fastFree = top.Kind == FK.Free && !top.Escaped;
}
int kept = 0;
@@ -369,7 +384,9 @@ private int ComputeMask(float[] buf)
// Empty-byte tokens (EOG / control) never advance the structure — forbidding them keeps
// an end-of-generation token from truncating the call mid-object.
if (bytes.Length == 0 || !_firstByteOk[bytes[0]]) { buf[id] = float.NegativeInfinity; continue; }
- bool ok = (fastStrContent && !ContainsQuoteOrBackslash(bytes)) || SimulateToken(id);
+ bool ok = (fastStrContent && !ContainsQuoteOrBackslash(bytes))
+ || (fastFree && !ContainsFreeStructural(bytes))
+ || SimulateToken(id);
if (ok) kept++;
else buf[id] = float.NegativeInfinity;
}
@@ -390,6 +407,16 @@ private static bool ContainsQuoteOrBackslash(ReadOnlySpan bytes)
return false;
}
+ /// Whether a token carries any byte that could balance, delimit, quote, or escape a free
+ /// value (and so must be fully simulated rather than fast-pathed as pure content).
+ private static bool ContainsFreeStructural(ReadOnlySpan bytes)
+ {
+ for (int i = 0; i < bytes.Length; i++)
+ if (bytes[i] is (byte)'{' or (byte)'}' or (byte)'[' or (byte)']'
+ or (byte)'"' or (byte)'\\' or (byte)',') return true;
+ return false;
+ }
+
private bool SimulateToken(int token)
{
int savedDepth = _depth;
@@ -435,10 +462,56 @@ private Step StepByte(ref Frame top, byte b)
case FK.StrEnum: return StepStrEnum(ref top, b);
case FK.Num: return StepNum(ref top, b);
case FK.Lit: return StepLit(ref top, b);
+ case FK.Free: return StepFree(ref top, b);
default: return Step.Reject;
}
}
+ /// Free-value (issue #378): accept any single well-formed JSON value — a string, a bare
+ /// scalar, or a balanced {…}/[…] — and pop when it completes, so the enclosing object resumes and
+ /// keeps enforcing its declared/required keys. The value's contents are unconstrained.
+ private Step StepFree(ref Frame f, byte b)
+ {
+ switch (f.State)
+ {
+ case FrStart:
+ if (IsWs(b)) return Step.Consume;
+ if (b is (byte)'{' or (byte)'[') { f.FreeDepth = 1; f.State = FrBalanced; return Step.Consume; }
+ if (b == (byte)'"') { f.State = FrStr; return Step.Consume; }
+ if (b is (byte)',' or (byte)'}' or (byte)']') return Step.Reject; // a value can't be empty
+ f.State = FrBare; return Step.Consume; // bare scalar start
+
+ case FrStr:
+ if (f.Escaped) { f.Escaped = false; return Step.Consume; }
+ if (b == (byte)'\\') { f.Escaped = true; return Step.Consume; }
+ if (b == (byte)'"') { _depth--; return PostValueOrDone(); } // string closes the value
+ return Step.Consume;
+
+ case FrBare:
+ if (b is (byte)',' or (byte)'}' or (byte)']') { _depth--; return PostValueRetry(); }
+ return Step.Consume;
+
+ case FrBalanced:
+ if (b == (byte)'"') { f.State = FrBalancedStr; return Step.Consume; }
+ if (b is (byte)'{' or (byte)'[') { f.FreeDepth++; return Step.Consume; }
+ if (b is (byte)'}' or (byte)']')
+ {
+ if (--f.FreeDepth == 0) { _depth--; return PostValueOrDone(); }
+ return Step.Consume;
+ }
+ return Step.Consume;
+
+ case FrBalancedStr:
+ if (f.Escaped) { f.Escaped = false; return Step.Consume; }
+ if (b == (byte)'\\') { f.Escaped = true; return Step.Consume; }
+ if (b == (byte)'"') { f.State = FrBalanced; return Step.Consume; }
+ return Step.Consume;
+
+ default:
+ return Step.Reject;
+ }
+ }
+
private static bool IsWs(byte b) => b is (byte)' ' or (byte)'\t' or (byte)'\n' or (byte)'\r';
// After a value frame pops, resume the parent (object/array) at its post-value state.
@@ -817,6 +890,7 @@ private void CollectFirstBytes(int d, bool[] set)
case FK.StrEnum: CollectStrEnum(ref f, set); break;
case FK.Num: popThrough = CollectNum(ref f, set); break;
case FK.Lit: popThrough = CollectLit(ref f, set); break;
+ case FK.Free: CollectFree(ref f, set); break;
}
if (!popThrough) break;
d--; // bare value can end here — parent bytes also start a token
@@ -874,6 +948,15 @@ private static void CollectStr(ref Frame f, bool[] set)
for (int i = 0; i < 256; i++) set[i] = true;
}
+ private static void CollectFree(ref Frame f, bool[] set)
+ {
+ // A free value admits almost anything — mark broadly and let the (fast-pathed) simulate pass
+ // decide. The only structural restriction is that the value can't START with a parent
+ // delimiter (that would be an empty value).
+ for (int i = 0; i < 256; i++) set[i] = true;
+ if (f.State == FrStart) { set[','] = false; set['}'] = false; set[']'] = false; }
+ }
+
private static void CollectStrEnum(ref Frame f, bool[] set)
{
if (f.State == SeExpectOpen) { MarkWs(set); set['"'] = true; return; }
@@ -923,6 +1006,14 @@ private static void MarkValueFirstBytes(CompiledNode node, bool[] set)
case JsonSchemaKind.Array: set['['] = true; break;
case JsonSchemaKind.Object: set['{'] = true; break;
case JsonSchemaKind.String: set['"'] = true; break; // JSON strings open with a '"' byte
+ case JsonSchemaKind.Any:
+ // A free array item (issue #378) can be any JSON value — mark every value-START byte.
+ // (Don't touch ']'; the array frame marks it for the empty-array close, and unsetting
+ // it here would wrongly forbid closing.)
+ set['"'] = true; set['{'] = true; set['['] = true; set['-'] = true;
+ MarkDigits(set);
+ set['t'] = true; set['f'] = true; set['n'] = true; // true / false / null
+ break;
default:
if (node.Literals is { } lits)
foreach (var l in lits) { if (l.Length > 0) set[l[0]] = true; }
diff --git a/src/SharpInference.Core/Grammar/ToolSchemaCompiler.cs b/src/SharpInference.Core/Grammar/ToolSchemaCompiler.cs
index c0d729d..34d49a0 100644
--- a/src/SharpInference.Core/Grammar/ToolSchemaCompiler.cs
+++ b/src/SharpInference.Core/Grammar/ToolSchemaCompiler.cs
@@ -7,10 +7,10 @@ namespace SharpInference.Core.Grammar;
// wire-format-agnostic — both the Gemma constraint (GemmaToolArgumentConstraint, bespoke
// <|"|>-quoted syntax) and the JSON constraint (JsonToolArgumentConstraint, standard JSON for
// Qwen/Llama/DeepSeek) consume the same CompiledObject/CompiledNode and only differ in how they
-// walk the structural bytes. Only tools whose schema is FULLY constrainable (every value is a
-// concrete type / typed array / nested typed object — no Any-typed value, no open object) are
-// compiled; a tool that isn't is left out of the constraint and generates its arguments
-// unconstrained, so the constraint never blocks generation.
+// walk the structural bytes. A loosely-typed VALUE no longer disqualifies its tool (issue #378): it
+// compiles to a FreeValue node the constraints accept as any well-formed value, so the surrounding
+// structure stays enforced. A tool is left unconstrained only when its argument OBJECT itself is open
+// (no declared properties) — the constraint then never blocks generation.
/// Compiled value-type descriptor (see ).
internal sealed class CompiledNode
@@ -40,12 +40,24 @@ internal sealed class CompiledObject
///
/// Compiles a parsed into the match tables the argument-grammar
/// state machines drive. Shared by every architecture's constraint so the "which schemas are
-/// constrainable" rule lives in exactly one place. Returns null for any schema that isn't
-/// fully constrainable (open body, an Any-typed value, an untyped array, …) — the caller
-/// then leaves that tool unconstrained.
+/// constrainable" rule lives in exactly one place.
+///
+///
+/// A loosely-typed VALUE (an Any-typed value with no type, an open object, or an
+/// untyped array) no longer disqualifies the whole tool (issue #378): it compiles to the
+/// node (), which the constraints accept as
+/// any single well-formed value while still enforcing the object's structure — declared key
+/// names, required-once, and the typed siblings. returns null
+/// only when the object body ITSELF is open (no declared properties to enforce), so a partially-typed
+/// tool stays constrained on its typed/required parts instead of being dropped wholesale.
+///
///
internal static class ToolSchemaCompiler
{
+ /// A fully-free value: any single well-formed value (string, scalar, array, object) the
+ /// constraints balance to completion without restricting its contents. Shared singleton.
+ public static CompiledNode FreeValue { get; } = new() { Kind = JsonSchemaKind.Any };
+
// Depth-capped so compilation can't recurse unbounded — the parser already caps nesting, but the
// ToolSchema records are public, so a caller that builds a deeply-nested schema in-memory (not via
// the parser) would otherwise risk an uncatchable StackOverflow. Past the cap the tool is simply
@@ -68,17 +80,20 @@ internal static class ToolSchemaCompiler
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;
+ // A loosely-typed value compiles to FreeValue (issue #378) rather than disqualifying the
+ // tool — the key/required structure stays enforced, only the value is left free.
+ values[i] = CompileNode(p.Value, depth + 1);
if (p.Required) reqMask |= 1UL << i;
}
return new CompiledObject { KeyBytes = keys, Values = values, RequiredMask = reqMask };
}
- private static CompiledNode? TryCompileNode(ToolSchemaNode node, int depth)
+ /// Compiles one value node, degrading any loosely-typed value (Any / untyped array /
+ /// open or too-deep object) to rather than null — so a partially-typed
+ /// object still constrains its typed siblings (issue #378).
+ private static CompiledNode CompileNode(ToolSchemaNode node, int depth)
{
- if (depth >= MaxDepth) return null;
+ if (depth >= MaxDepth) return FreeValue; // too deep to constrain → free
switch (node.Kind)
{
case JsonSchemaKind.String:
@@ -103,18 +118,17 @@ internal static class ToolSchemaCompiler
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 };
+ // An untyped array (no item shape) is left free; a typed array constrains its items.
+ if (node.Items is null) return FreeValue;
+ return new CompiledNode { Kind = JsonSchemaKind.Array, Items = CompileNode(node.Items, depth + 1) };
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 };
+ // An open / too-deep nested object is left free; a typed nested object recurses.
+ var obj = node.Object is null ? null : TryCompileObject(node.Object, depth + 1);
+ return obj is null ? FreeValue : new CompiledNode { Kind = JsonSchemaKind.Object, Object = obj };
default:
- return null; // Any / unknown — not constrainable
+ return FreeValue; // Any / unknown — free value
}
}
diff --git a/tests/SharpInference.Tests.Core/JsonToolGrammarMockTests.cs b/tests/SharpInference.Tests.Core/JsonToolGrammarMockTests.cs
index ad8da20..1c254f9 100644
--- a/tests/SharpInference.Tests.Core/JsonToolGrammarMockTests.cs
+++ b/tests/SharpInference.Tests.Core/JsonToolGrammarMockTests.cs
@@ -261,6 +261,78 @@ public void DeepSeek_NameThenSeparatorEnvelope()
Assert.False(Allowed(c, vocab, tok.Merged("{}"))); // required key still enforced
}
+ [Fact]
+ public void PartiallyTyped_FreeValue_StillEnforcesTypedRequiredKey()
+ {
+ // 'context' is an open object (no properties) → a free value; 'location' stays a required
+ // string. Before #378 the whole tool was dropped (Build would return null); now it compiles.
+ var (c, tok, vocab) = Build(
+ """{"type":"object","properties":{"location":{"type":"string"},"context":{"type":"object"}},"required":["location"]}""",
+ "get_weather");
+
+ Feed(c, tok, QwenPreamble + "{");
+ Assert.True(c.IsConstraining);
+ Assert.False(Allowed(c, vocab, tok.Char('}'))); // required 'location' still missing
+
+ // Emit the loosely-typed 'context' first — its value may be an object, string, or bare scalar.
+ Feed(c, tok, "\"context\":");
+ Assert.True(Allowed(c, vocab, tok.Char('{')));
+ Assert.True(Allowed(c, vocab, tok.Char('"')));
+ Assert.True(Allowed(c, vocab, tok.Char('5')));
+ Assert.False(Allowed(c, vocab, tok.Char(','))); // a value can't be empty
+
+ // A free object with arbitrary inner keys/nesting is accepted whole.
+ Feed(c, tok, "{\"anything\":42,\"nested\":{\"x\":[1,2]}}");
+ Assert.True(c.IsConstraining);
+ Assert.False(Allowed(c, vocab, tok.Char('}'))); // 'location' STILL required after the free value
+
+ Feed(c, tok, ",\"location\":\"Paris\"");
+ Assert.True(Allowed(c, vocab, tok.Char('}'))); // required satisfied → may close
+ Feed(c, tok, "}");
+ Assert.False(c.IsConstraining);
+ }
+
+ [Fact]
+ public void PartiallyTyped_AnyValue_AndUntypedArray_AreFree()
+ {
+ // 'meta' has no type (Any) and 'tags' is an untyped array — both free; 'id' stays required int.
+ var (c, tok, vocab) = Build(
+ """{"type":"object","properties":{"id":{"type":"integer"},"meta":{},"tags":{"type":"array"}},"required":["id"]}""",
+ "save");
+
+ Feed(c, tok, "{\"name\":\"save\",\"arguments\":{");
+ Assert.True(c.IsConstraining);
+
+ Feed(c, tok, "\"meta\":");
+ Assert.True(Allowed(c, vocab, tok.Char('"'))); // Any → free: string ok
+ Assert.True(Allowed(c, vocab, tok.Char('['))); // …or array
+ Feed(c, tok, "\"x\",\"tags\":[1,\"a\",{\"k\":2}]"); // free string, then free untyped array
+ Assert.False(Allowed(c, vocab, tok.Char('}'))); // required 'id' still missing
+ Feed(c, tok, ",\"id\":7");
+ Assert.True(Allowed(c, vocab, tok.Char('}')));
+ }
+
+ [Fact]
+ public void TypedArray_OfFreeItems_AcceptsAnyItemShape()
+ {
+ // A typed array whose ITEM type is loose ({}) — the array structure is enforced, each item is
+ // free. Regression: the first-byte prune must admit non-numeric free items (string/object), not
+ // only numbers.
+ var (c, tok, vocab) = Build(
+ """{"type":"object","properties":{"items":{"type":"array","items":{}}},"required":["items"]}""",
+ "save");
+
+ Feed(c, tok, "{\"name\":\"save\",\"arguments\":{\"items\":[");
+ Assert.True(c.IsConstraining);
+ Assert.True(Allowed(c, vocab, tok.Char('"'))); // string item
+ Assert.True(Allowed(c, vocab, tok.Char('{'))); // object item
+ Assert.True(Allowed(c, vocab, tok.Char('5'))); // number item
+ Assert.True(Allowed(c, vocab, tok.Char(']'))); // or close (empty)
+
+ Feed(c, tok, "1,\"a\",{\"k\":2}]"); // mixed free items
+ Assert.True(Allowed(c, vocab, tok.Char('}'))); // array done, 'items' satisfied
+ }
+
[Fact]
public void NonConstrainableTool_BuildsNoConstraint()
{
diff --git a/tests/SharpInference.Tests.Core/ToolGrammarConstraintTests.cs b/tests/SharpInference.Tests.Core/ToolGrammarConstraintTests.cs
index 4106ff0..44527da 100644
--- a/tests/SharpInference.Tests.Core/ToolGrammarConstraintTests.cs
+++ b/tests/SharpInference.Tests.Core/ToolGrammarConstraintTests.cs
@@ -150,6 +150,28 @@ public void EnumValue_RestrictedToDeclaredSet()
Assert.True(Allowed(c, tok, vocab.VocabSize, quote)); // completed value → close quote allowed
}
+ [Fact]
+ public void PartiallyTyped_RequiredTypedKey_Enforced_LooseValueFree()
+ {
+ var tok = Tok();
+ if (tok is null) { output.WriteLine("missing model — skip"); return; }
+ var vocab = new GrammarVocabulary(tok);
+
+ // 'location' is a required string; 'context' is an open object (free value). Issue #378: the
+ // tool is now constrained on its typed/required parts instead of being dropped wholesale.
+ var schema = Schema("get_weather",
+ """{"type":"object","properties":{"location":{"type":"string"},"context":{"type":"object"}},"required":["location"]}""");
+ var c = new Gemma4ToolCallAdapter().BuildArgumentConstraint([schema], vocab);
+ Assert.NotNull(c);
+
+ Feed(c!, tok, "<|tool_call>call:get_weather{");
+ Assert.True(c!.IsConstraining);
+ // Required 'location' not yet emitted → may not close; both declared keys reachable.
+ Assert.False(Allowed(c, tok, vocab.VocabSize, Id(tok, "}")));
+ Assert.True(Allowed(c, tok, vocab.VocabSize, tok.Encode("location")[0]));
+ Assert.True(Allowed(c, tok, vocab.VocabSize, tok.Encode("context")[0]));
+ }
+
[Fact]
public void NonGemmaAdapter_BuildsNoConstraint()
{
diff --git a/tests/SharpInference.Tests.Core/ToolGrammarMockTests.cs b/tests/SharpInference.Tests.Core/ToolGrammarMockTests.cs
index b919a80..7ffaf3c 100644
--- a/tests/SharpInference.Tests.Core/ToolGrammarMockTests.cs
+++ b/tests/SharpInference.Tests.Core/ToolGrammarMockTests.cs
@@ -213,4 +213,60 @@ public void Reset_ReturnsToWatching()
c.Reset();
Assert.False(c.IsConstraining);
}
+
+ [Fact]
+ public void TypedArray_OfFreeItems_AcceptsStringAndObjectItems()
+ {
+ // A typed array of loose items ({}): a free string item opens on the <|"|> quote, a free
+ // object on '{', a scalar bare. Regression for the array-item first-byte / quote path.
+ var (c, tok, vocab) = Build(
+ """{"type":"object","properties":{"items":{"type":"array","items":{}}},"required":["items"]}""",
+ "save");
+
+ Feed(c, tok, "<|tool_call>call:save{items:[");
+ Assert.True(c.IsConstraining);
+ Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Quote)); // free string item opens on the quote
+ Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Char('{'))); // free object item
+ Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Char('5'))); // bare scalar item
+ Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Char(']'))); // or close (empty)
+
+ c.Accept(FakeGemmaTokenizer.Quote); Feed(c, tok, "a"); c.Accept(FakeGemmaTokenizer.Quote);
+ Feed(c, tok, ",{k:3}]");
+ Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Char('}')));
+ }
+
+ [Fact]
+ public void PartiallyTyped_FreeValue_StillEnforcesTypedRequiredKey()
+ {
+ // 'context' is an open object → a free value; 'location' stays a required string. Before #378
+ // the whole tool was dropped (Build would return null); now it compiles and enforces the
+ // typed/required parts while leaving 'context' free.
+ var (c, tok, vocab) = Build(
+ """{"type":"object","properties":{"location":{"type":"string"},"context":{"type":"object"}},"required":["location"]}""",
+ "get_weather");
+
+ Feed(c, tok, "<|tool_call>call:get_weather{");
+ Assert.True(c.IsConstraining);
+ Assert.False(Allowed(c, vocab, FakeGemmaTokenizer.Char('}'))); // required 'location' missing
+
+ // Emit the loosely-typed 'context' first — its value may be a string, object, array, or scalar.
+ Feed(c, tok, "context:");
+ Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Quote)); // <|"|> string
+ Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Char('{'))); // object
+ Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Char('['))); // array
+ Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Char('3'))); // bare scalar
+
+ // A free object with a <|"|>-string value and a nested array is balanced whole.
+ Feed(c, tok, "{a:");
+ c.Accept(FakeGemmaTokenizer.Quote); Feed(c, tok, "x"); c.Accept(FakeGemmaTokenizer.Quote);
+ Feed(c, tok, ",b:[1,2]}");
+ Assert.True(c.IsConstraining);
+ Assert.False(Allowed(c, vocab, FakeGemmaTokenizer.Char('}'))); // 'location' STILL required
+
+ Feed(c, tok, ",location:");
+ c.Accept(FakeGemmaTokenizer.Quote); Feed(c, tok, "Paris"); c.Accept(FakeGemmaTokenizer.Quote);
+ Assert.True(Allowed(c, vocab, FakeGemmaTokenizer.Char('}'))); // required satisfied
+ c.Accept(FakeGemmaTokenizer.Char('}'));
+ Assert.False(c.IsConstraining); // clean close
+ }
}