Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/SharpInference.Cli/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ public sealed class Settings : CommandSettings
public string? ToolsPath { get; init; }

[CommandOption("--tool-grammar")]
[Description("Constrain tool-call arguments to the --tools JSON Schemas (issue #374): required keys can't be dropped, only declared keys/enum values appear, value shapes match the declared type. Needs --tools and a model family with constraint support (Gemma 4 today). Default off → byte-identical to unconstrained decoding.")]
[Description("Constrain tool-call arguments to the --tools JSON Schemas (issue #374): required keys can't be dropped, only declared keys/enum values appear, value shapes match the declared type. Needs --tools and a model family with constraint support (Gemma 4, Qwen/Qwen3-Coder, Llama-3, DeepSeek). Default off → byte-identical to unconstrained decoding.")]
[DefaultValue(false)]
public bool ToolGrammar { get; init; }

Expand Down
59 changes: 59 additions & 0 deletions src/SharpInference.Core/Grammar/CompositeToolArgumentConstraint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
namespace SharpInference.Core.Grammar;

/// <summary>
/// A tool-argument constraint that overlays several format-specific sub-constraints and lets whichever
/// one engages drive masking. It exists because a single GGUF <c>general.architecture</c> can host
/// more than one tool-call wire format: a <c>qwen3moe</c> model is Qwen3-MoE (JSON
/// <c>&lt;tool_call&gt;{…}&lt;/tool_call&gt;</c>) OR Qwen3-Coder (XML
/// <c>&lt;tool_call&gt;&lt;function=…&gt;…&lt;/function&gt;&lt;/tool_call&gt;</c>) — the two are only
/// distinguishable by the chat template, which the adapter doesn't see (issue #383).
///
/// <para>
/// Both sub-constraints arm on the same <c>&lt;tool_call&gt;</c> token and then diverge on the first
/// body byte (<c>{</c> for JSON vs <c>&lt;</c> for XML), so at most one ever leaves the watching state
/// for a given call; the other disarms itself. <see cref="Accept"/> feeds every sub, and
/// <see cref="Filter"/> defers to the one currently constraining. When none is constraining the input
/// logits pass through untouched, so a request that never enters a constrained region is byte-identical
/// to the default path.
/// </para>
///
/// <para>The per-token cost is just the extra <see cref="Accept"/> byte-walk in the idle subs (a few
/// bytes each); only the engaged sub allocates a mask buffer / calls <see cref="Filter"/>.</para>
/// </summary>
public sealed class CompositeToolArgumentConstraint : ITokenConstraint
{
private readonly ITokenConstraint[] _inner;

public CompositeToolArgumentConstraint(IReadOnlyList<ITokenConstraint> inner)
{
ArgumentNullException.ThrowIfNull(inner);
if (inner.Count == 0) throw new ArgumentException("at least one inner constraint is required", nameof(inner));
_inner = [.. inner];
}

public bool IsConstraining
{
get
{
foreach (var c in _inner) if (c.IsConstraining) return true;
return false;
}
}

public ReadOnlySpan<float> Filter(ReadOnlySpan<float> logits)
{
// At most one sub is ever constraining (they diverge after the shared open marker); defer to it.
foreach (var c in _inner) if (c.IsConstraining) return c.Filter(logits);
return logits;
}

public void Accept(int token)
{
foreach (var c in _inner) c.Accept(token);
}

public void Reset()
{
foreach (var c in _inner) c.Reset();
}
}
696 changes: 696 additions & 0 deletions src/SharpInference.Core/Grammar/QwenCoderToolArgumentConstraint.cs

Large diffs are not rendered by default.

48 changes: 41 additions & 7 deletions src/SharpInference.Core/ToolCallAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -320,18 +320,33 @@ public sealed class QwenToolCallAdapter(string architecture) : IToolCallAdapter
public int MaxOpenTagLength => OpenMarker.Length;

/// <inheritdoc/>
/// <remarks>Constrains the JSON argument object of Qwen's
/// <c>&lt;tool_call&gt;{"name":"NAME","arguments":{...}}&lt;/tool_call&gt;</c> wire format
/// (issue #376). Inert (returns null) when no supplied tool is constrainable, when
/// <c>&lt;tool_call&gt;</c> isn't a vocabulary token, or when the model emits the Qwen3.6 XML
/// shape instead of JSON (the constraint simply never engages).</remarks>
/// <remarks>Constrains the argument body of Qwen's <c>&lt;tool_call&gt;…&lt;/tool_call&gt;</c> wire
/// format. The same architecture (<c>qwen3moe</c>/<c>qwen2</c>/<c>qwen3</c>) hosts two formats that
/// the GGUF metadata can't tell apart — standard JSON
/// (<c>{"name":"NAME","arguments":{...}}</c>, issue #376) and Qwen3-Coder's XML
/// (<c>&lt;function=NAME&gt;&lt;parameter=K&gt;V&lt;/parameter&gt;…&lt;/function&gt;</c>, issue
/// #383) — and they're only distinguishable by the chat template, which isn't visible here. So we
/// build BOTH and overlay them with a <see cref="CompositeToolArgumentConstraint"/>: both arm on
/// <c>&lt;tool_call&gt;</c> and diverge on the first body byte, so whichever matches the model's
/// actual output engages and the other stays inert. Returns null when no supplied tool is
/// constrainable or <c>&lt;tool_call&gt;</c> isn't a vocabulary token.</remarks>
public ITokenConstraint? BuildArgumentConstraint(IReadOnlyList<ToolSchema> tools, GrammarVocabulary vocab)
{
ArgumentNullException.ThrowIfNull(tools);
ArgumentNullException.ThrowIfNull(vocab);
var c = new JsonToolArgumentConstraint(
var json = new JsonToolArgumentConstraint(
vocab, tools, OpenMarker, JsonToolEnvelope.NameValueObject, argsKeys: ["arguments", "parameters"]);
return c.HasConstrainableTools ? c : null;
var xml = new QwenCoderToolArgumentConstraint(vocab, tools);

var active = new List<ITokenConstraint>(2);
if (json.HasConstrainableTools) active.Add(json);
if (xml.HasConstrainableTools) active.Add(xml);
return active.Count switch
{
0 => null,
1 => active[0],
_ => new CompositeToolArgumentConstraint(active),
};
}

public ToolCallParseResult Parse(string rawOutput)
Expand Down Expand Up @@ -403,10 +418,29 @@ public sealed class QwenCoderToolCallAdapter : IToolCallAdapter
{
public const string OpenMarker = "<function=";
public const string CloseMarker = "</function>";
/// <summary>Qwen's tool-call envelope tokens. Qwen3-Coder wraps each XML call in
/// <c>&lt;tool_call&gt;…&lt;/tool_call&gt;</c>; these are single special tokens used by the
/// argument-grammar constraint as a leak-proof arming gate (the inner <c>&lt;function=&gt;</c> tags
/// are ordinary text). Mirrors <see cref="QwenToolCallAdapter.OpenMarker"/>.</summary>
public const string ArmMarker = "<tool_call>";
public const string ArmCloseMarker = "</tool_call>";

public string Architecture => "qwen3coder";
public int MaxOpenTagLength => OpenMarker.Length;

/// <inheritdoc/>
/// <remarks>Constrains the XML argument body of Qwen3-Coder's
/// <c>&lt;tool_call&gt;&lt;function=NAME&gt;&lt;parameter=KEY&gt;VALUE&lt;/parameter&gt;…&lt;/function&gt;&lt;/tool_call&gt;</c>
/// wire format (issue #383) — the XML sibling of the JSON/Gemma constraints. Inert (returns null)
/// when no supplied tool is constrainable or <c>&lt;tool_call&gt;</c> isn't a vocabulary token.</remarks>
public ITokenConstraint? BuildArgumentConstraint(IReadOnlyList<ToolSchema> tools, GrammarVocabulary vocab)
{
ArgumentNullException.ThrowIfNull(tools);
ArgumentNullException.ThrowIfNull(vocab);
var c = new QwenCoderToolArgumentConstraint(vocab, tools);
return c.HasConstrainableTools ? c : null;
}

public ToolCallParseResult Parse(string rawOutput)
{
var calls = new List<ParsedToolCall>();
Expand Down
10 changes: 5 additions & 5 deletions src/SharpInference.Server/SharpInferenceServerOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ public sealed class SharpInferenceServerOptions

/// <summary>
/// Enable schema/grammar-constrained decoding for tool-call arguments (issue #374). When on and
/// a tool-active request is served by a family with constraint support (Gemma 4 today), the
/// sampler is restricted to tokens that satisfy the supplied JSON Schema in the model's native
/// call syntax — required keys can't be dropped, only declared keys/enum values appear, value
/// shapes match the declared type. Default off → byte-identical to unconstrained decoding. Also
/// turned on by the <c>SHARPI_TOOL_GRAMMAR=1</c> environment variable.
/// a tool-active request is served by a family with constraint support (Gemma 4, Qwen and
/// Qwen3-Coder, Llama-3, DeepSeek), the sampler is restricted to tokens that satisfy the supplied
/// JSON Schema in the model's native call syntax — required keys can't be dropped, only declared
/// keys/enum values appear, value shapes match the declared type. Default off → byte-identical to
/// unconstrained decoding. Also turned on by the <c>SHARPI_TOOL_GRAMMAR=1</c> environment variable.
/// </summary>
public bool ToolGrammar { get; set; }

Expand Down
141 changes: 141 additions & 0 deletions tests/SharpInference.Tests.Core/CoderToolGrammarConstraintTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
using System.Text.Json;
using SharpInference.Core;
using SharpInference.Core.Grammar;
using Xunit.Abstractions;

namespace SharpInference.Tests.Core;

/// <summary>
/// Decode-time conformance for the Qwen3-Coder XML tool-argument grammar constraint (issue #383)
/// against a REAL Qwen3-Coder vocabulary, so the byte-level matching is exercised over the actual BPE
/// merges (where a single token can carry a whole <c>&lt;parameter=</c> tag, or close one parameter
/// and open the next). Model-gated — skips when the GGUF is absent. The grammar logic itself is covered
/// model-free by <see cref="CoderToolGrammarMockTests"/>; this asserts the same invariants survive a
/// production tokenizer.
/// </summary>
public sealed class CoderToolGrammarConstraintTests(ITestOutputHelper output)
{
private static readonly string[] s_modelPaths =
[
@"E:\models\Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
];

private static GgufTokenizer? Tok()
{
foreach (var p in s_modelPaths)
if (File.Exists(p))
{
var m = GgufModel.Open(p);
return GgufTokenizer.FromGgufModel(m);
}
return null;
}

private static ToolSchema Schema(string name, string parametersJson)
{
using var doc = JsonDocument.Parse(parametersJson);
return ToolSchema.FromOpenAiFunction(name, doc.RootElement.Clone());
}

private static void Feed(ITokenConstraint c, GgufTokenizer tok, string text)
{
foreach (int id in tok.Encode(text)) c.Accept(id);
}

private static bool Allowed(ITokenConstraint c, int vocab, int tokenId)
{
Span<float> logits = new float[vocab];
var masked = c.Filter(logits);
return !float.IsNegativeInfinity(masked[tokenId]);
}

// The first token that emits exactly `s` (handles multi-token strings: returns the leading token).
private static int First(GgufTokenizer tok, string s) => tok.Encode(s)[0];

private const string Weather =
"""{"type":"object","properties":{"location":{"type":"string"},"unit":{"type":"string","enum":["celsius","fahrenheit"]},"days":{"type":"integer"}},"required":["location"]}""";

private QwenCoderToolArgumentConstraint? Constraint(GgufTokenizer tok, out GrammarVocabulary vocab)
{
vocab = new GrammarVocabulary(tok);
return (QwenCoderToolArgumentConstraint?)
new QwenCoderToolCallAdapter().BuildArgumentConstraint([Schema("get_weather", Weather)], vocab);
}

[Fact]
public void EngagesAtFunctionTag_BlocksImmediateClose()
{
var tok = Tok();
if (tok is null) { output.WriteLine("missing model — skip"); return; }
var c = Constraint(tok, out var vocab);
Assert.NotNull(c); // <tool_call> resolved as a special token → constrainable

Feed(c!, tok, "<tool_call>\n<function=get_weather>");
Assert.True(c!.IsConstraining); // engaged at the '>' closing the function tag

// At the body root, a '<' (tag open) and whitespace are legal; bare text is not.
Assert.True(Allowed(c, vocab.VocabSize, First(tok, "<")));
Assert.False(Allowed(c, vocab.VocabSize, First(tok, "x")));

Feed(c, tok, "<");
// Only <parameter= may continue; </function> ('/') is forbidden — required 'location' missing.
Assert.True(Allowed(c, vocab.VocabSize, First(tok, "p")));
Assert.False(Allowed(c, vocab.VocabSize, First(tok, "/")));
}

[Fact]
public void RequiredKey_CannotBeOmitted_ForeignKeyRejected()
{
var tok = Tok();
if (tok is null) { output.WriteLine("missing model — skip"); return; }
var c = Constraint(tok, out var vocab)!;

Feed(c, tok, "<tool_call>\n<function=get_weather>\n<parameter=");
// Now matching a parameter key: declared names are reachable; a foreign letter is not.
Assert.True(Allowed(c, vocab.VocabSize, First(tok, "location")));
Assert.True(Allowed(c, vocab.VocabSize, First(tok, "unit")));
Assert.False(Allowed(c, vocab.VocabSize, First(tok, "zzz")));
}

[Fact]
public void EnumValue_RestrictedToDeclaredSet_BareNotQuoted()
{
var tok = Tok();
if (tok is null) { output.WriteLine("missing model — skip"); return; }
var c = Constraint(tok, out var vocab)!;

Feed(c, tok, "<tool_call>\n<function=get_weather>\n<parameter=unit>\n");
Assert.True(c.IsConstraining);
// Coder enum values are bare text — a quote is not part of the value.
Assert.False(Allowed(c, vocab.VocabSize, First(tok, "\"")));
// 'c' (celsius) / 'f' (fahrenheit) are reachable; 'x' is not.
Assert.True(Allowed(c, vocab.VocabSize, First(tok, "c")));
Assert.True(Allowed(c, vocab.VocabSize, First(tok, "f")));
Assert.False(Allowed(c, vocab.VocabSize, First(tok, "x")));
}

[Fact]
public void NumberValue_RejectsNonDigit()
{
var tok = Tok();
if (tok is null) { output.WriteLine("missing model — skip"); return; }
var c = Constraint(tok, out var vocab)!;

Feed(c, tok, "<tool_call>\n<function=get_weather>\n<parameter=days>\n");
Assert.True(Allowed(c, vocab.VocabSize, First(tok, "3")));
Assert.False(Allowed(c, vocab.VocabSize, First(tok, "x"))); // a non-numeric value is illegal
}

[Fact]
public void JsonOutput_DoesNotEngage()
{
var tok = Tok();
if (tok is null) { output.WriteLine("missing model — skip"); return; }
var c = Constraint(tok, out _)!;

// If the model emitted a JSON envelope instead of the XML <function=…> shape, the Coder
// constraint must stay inert (it arms on <tool_call> but only engages on <function=NAME>).
Feed(c, tok, "<tool_call>\n{\"name\": \"get_weather\"}");
Assert.False(c.IsConstraining);
}
}
Loading