gglib_core/request_pipeline/constrain.rs
1//! Stage 6: decode-time enforcement of dialect tool calls.
2//!
3//! **Tier A — Compensation** ([ADR 0001]). This stage originates a grammar
4//! because llama.cpp builds none for dialect models. It is the clearest
5//! deletion candidate in the tree, because upstream has the machinery
6//! already: `json_schema_to_grammar` converts tool schemas to GBNF, and
7//! lazily-triggered grammars exist for the `auto` case gglib cannot cover.
8//!
9//! *Deletion criterion:* llama.cpp constrains dialect tool calls under both
10//! `tool_choice: "required"` and `"auto"`, with arguments conforming to the
11//! tool's own JSON Schema rather than merely being well-formed JSON. Note
12//! that this stage's grammar is *weaker* than that today — it constrains the
13//! envelope, the function name, and JSON well-formedness, but admits
14//! `{"path": 42}` against a schema demanding a string. So the criterion is
15//! not "upstream matches this stage" but "upstream exceeds it", and meeting
16//! it deletes this stage and obviates the schema-constraint work it would
17//! otherwise need.
18//!
19//! Measured by `scripts/experiments/lazy_grammar_conformance.py`, whose
20//! result is recorded as its own ADR rather than assumed from
21//! [`RuntimeFlags::PEG_NATIVE_TOOL_CALLS`].
22//!
23//! **Criterion met, stage retained** ([ADR 0002]). On `b10327` against
24//! Qwen3.5-4B, upstream held 60/60 across `auto` and `required` under prompts
25//! written to break types, enums, required fields and `additionalProperties`.
26//! It exceeds this stage on the measured path, and the schema-constraint work
27//! this stage would otherwise have needed is dropped rather than deferred.
28//!
29//! The stage stays anyway, and the reason is the scope of the evidence: one
30//! model, one build, one schema. Deleting a stage that also serves dialects
31//! nobody has measured would trade a known cost for an unmeasured risk — the
32//! same asymmetry [`RuntimeCapabilities::unknown`] encodes. What remains
33//! before removal is a second dialect family measured to the same standard.
34//!
35//! [ADR 0001]: https://github.com/mmogr/gglib/blob/main/docs/adr/0001-runtime-capability-tiers.md
36//! [ADR 0002]: https://github.com/mmogr/gglib/blob/main/docs/adr/0002-defer-tool-call-constraint-to-llama-cpp.md
37//! [`RuntimeFlags::PEG_NATIVE_TOOL_CALLS`]: crate::domain::RuntimeFlags::PEG_NATIVE_TOOL_CALLS
38//! [`RuntimeCapabilities::unknown`]: crate::domain::RuntimeCapabilities::unknown
39//!
40//! For models with a resolved [`DialectSpec`], tool calls are free text —
41//! the model *chooses* to emit `OPEN{json}CLOSE` markup and the proxy
42//! parses it after the fact. Post-hoc parsing can rescue a well-formed call,
43//! but it cannot stop a small model from producing a malformed one. This
44//! stage can: when the client *demands* a tool call (`tool_choice:
45//! "required"` or a named function), it originates a GBNF `grammar` that
46//! llama-server enforces at decode time, making an invalid envelope,
47//! invalid JSON, or an invented tool name unrepresentable. The grammar is
48//! generated from the same spec the parser reads, so enforcement and
49//! parsing cannot drift.
50//!
51//! # Why only dialect models
52//!
53//! Models whose chat template does native tool handling are already
54//! constrained: llama.cpp builds its own grammar from the template (eager
55//! under `required`, lazily-triggered under `auto`) — and its `OpenAI`
56//! endpoint *rejects* a request that combines a custom `grammar` with
57//! `tools` ("Cannot use custom grammar constraints with tools") unless
58//! `tool_choice` is `"none"`. Dialect models are exactly the ones that
59//! machinery does not cover, so they are exactly where the proxy steps in.
60//!
61//! # Why `tool_choice` is rewritten to `"none"`
62//!
63//! That same upstream rejection is the reason the stage rewrites
64//! `tool_choice` to `"none"` when it installs a grammar: it is the one
65//! escape hatch llama-server leaves open for grammar + tools. The template
66//! still renders the tool schemas into the prompt (templates never see
67//! `tool_choice`), and the requirement the client expressed now lives in
68//! the grammar itself — which is *stronger* than what `tool_choice` could
69//! ask for on a model llama-server has no tool handling for anyway.
70//!
71//! # Why `auto` is left alone
72//!
73//! A grammar constrains from the first token, so under `tool_choice:
74//! "auto"` it would forbid the plain-text answers `auto` exists to permit.
75//! llama.cpp solves this internally with lazily-triggered grammars, but
76//! does not expose lazy triggers as request fields — so `auto` keeps
77//! today's behaviour: unconstrained decode, post-hoc parsing.
78
79use serde_json::Value;
80use tracing::{debug, info};
81
82use super::ModelContext;
83use crate::domain::agent::config::MAX_PARALLEL_TOOLS_CEILING;
84use crate::domain::dialect::DialectSpec;
85
86/// Environment kill switch. Truthy values (case-insensitive `1`, `true`,
87/// `yes`, `on`) disable grammar origination entirely — the same contract as
88/// `GGLIB_DISABLE_MTP` and `GGLIB_DISABLE_CACHE_REUSE`.
89pub const DISABLE_GRAMMAR_ENV: &str = "GGLIB_DISABLE_GRAMMAR";
90
91/// Whether [`DISABLE_GRAMMAR_ENV`] is set to a truthy value.
92fn grammar_disabled_via_env() -> bool {
93 crate::debug_switches::enabled(DISABLE_GRAMMAR_ENV)
94}
95
96/// Originate a decode-time grammar for a demanded dialect tool call.
97///
98/// Engages only when *all* of the following hold, and is a no-op otherwise:
99///
100/// - `GGLIB_DISABLE_GRAMMAR` is not set to a truthy value;
101/// - the model resolved from the catalog with a dialect spec whose codec
102/// list includes JSON (see module docs for why the native path is
103/// excluded — and a grammar can only originate JSON-shaped bodies);
104/// - the request carries a non-empty `tools` array whose function names are
105/// expressible in a GBNF literal;
106/// - the client sent none of `grammar` / `json_schema` / `response_format`
107/// — a client that constrains its own decode is always respected;
108/// - `tool_choice` demands a call: `"required"`, or a named function.
109///
110/// Returns `true` when a grammar was installed.
111pub fn constrain_tool_calls(body: &mut Value, ctx: &ModelContext) -> bool {
112 if grammar_disabled_via_env() {
113 return false;
114 }
115 constrain_tool_calls_inner(body, ctx)
116}
117
118/// [`constrain_tool_calls`] without the environment check, for tests.
119fn constrain_tool_calls_inner(body: &mut Value, ctx: &ModelContext) -> bool {
120 if !ctx.catalog_resolved {
121 return false;
122 }
123 let Some(spec) = ctx.dialect.as_ref().filter(|s| s.supports_json_body()) else {
124 return false;
125 };
126 if body.get("grammar").is_some()
127 || body.get("json_schema").is_some()
128 || body.get("response_format").is_some()
129 {
130 debug!("client sent its own decode constraint; not originating a grammar");
131 return false;
132 }
133
134 let Some(all_names) = tool_names(body) else {
135 return false;
136 };
137
138 let allowed: Vec<String> = match demanded_names(body.get("tool_choice"), &all_names) {
139 Some(names) => names,
140 None => return false,
141 };
142 if allowed.is_empty() || !allowed.iter().all(|n| gbnf_literal_safe(n)) {
143 debug!("tool names not expressible in a GBNF literal; not constraining");
144 return false;
145 }
146
147 let Some(grammar) = tool_call_grammar(spec, &allowed, grammar_call_limit()) else {
148 debug!("dialect markers not expressible in a GBNF literal; not constraining");
149 return false;
150 };
151 body["grammar"] = Value::String(grammar);
152 // The one combination llama-server accepts alongside a custom grammar —
153 // see the module docs. The demand now lives in the grammar.
154 body["tool_choice"] = Value::String("none".into());
155
156 info!(
157 tools = allowed.len(),
158 dialect = %spec.id,
159 "originated decode-time grammar for a demanded dialect tool call"
160 );
161 true
162}
163
164/// The advertised function names, or `None` when `tools` is absent, empty,
165/// or not in the `OpenAI` function-tool shape.
166fn tool_names(body: &Value) -> Option<Vec<String>> {
167 let tools = body.get("tools")?.as_array()?;
168 if tools.is_empty() {
169 return None;
170 }
171 let names: Vec<String> = tools
172 .iter()
173 .filter_map(|t| t.get("function")?.get("name")?.as_str())
174 .map(str::to_owned)
175 .collect();
176 (names.len() == tools.len()).then_some(names)
177}
178
179/// Which names the client's `tool_choice` demands a call from.
180///
181/// `Some(names)` means "a call is demanded, constrain to these"; `None`
182/// means "no demand" (`auto`, absent, `"none"`, or an unrecognized shape)
183/// and the stage stays out of the way.
184fn demanded_names(tool_choice: Option<&Value>, all_names: &[String]) -> Option<Vec<String>> {
185 match tool_choice {
186 Some(Value::String(s)) if s == "required" => Some(all_names.to_vec()),
187 Some(Value::Object(_)) => {
188 let named = tool_choice?
189 .get("function")?
190 .get("name")?
191 .as_str()?
192 .to_owned();
193 // A demand for a tool that is not advertised is the client's
194 // inconsistency to surface, not ours to paper over with a
195 // grammar for a name the model has never seen.
196 all_names.contains(&named).then(|| vec![named])
197 }
198 _ => None,
199 }
200}
201
202/// Whether `name` can be embedded in a GBNF double-quoted literal verbatim.
203///
204/// Function names are identifier-like in practice; anything that would need
205/// escaping (quotes, backslashes, control bytes, non-ASCII) makes the whole
206/// request fall back to unconstrained decode rather than risk emitting a
207/// grammar llama-server cannot compile.
208fn gbnf_literal_safe(name: &str) -> bool {
209 !name.is_empty()
210 && name
211 .chars()
212 .all(|c| c.is_ascii_graphic() && c != '"' && c != '\\')
213}
214
215/// Build the GBNF grammar for one or more dialect tool calls, from the
216/// same [`DialectSpec`] the parser reads.
217///
218/// The envelope is exactly what the [`DelimitedToolCallParser`] prefers to
219/// parse — the JSON body codec, `{"name": …, "arguments": {…}}` in that
220/// key order (the order the models were trained on) — wrapped in the
221/// spec's markers with the spec's emission newlines. `name` is an enum of
222/// the demanded tools; `arguments` is constrained to well-formed JSON.
223/// Malformed envelopes, truncated JSON, and invented tool names all become
224/// unrepresentable at decode time.
225///
226/// Returns `None` when a marker cannot be embedded in a GBNF literal
227/// ([`gbnf_string_literal`]) — the caller falls back to unconstrained
228/// decode rather than risk a grammar llama-server cannot compile.
229///
230/// [`DelimitedToolCallParser`]: crate::normalize::parsers::delimited::DelimitedToolCallParser
231/// Overrides the grammar's tool-call bound. Numeric; clamped to at least 1.
232const MAX_GRAMMAR_TOOL_CALLS_ENV: &str = "GGLIB_MAX_GRAMMAR_TOOL_CALLS";
233
234/// How many tool calls the originated grammar may express in one response.
235///
236/// # Why bound it at all
237///
238/// The rule was `root ::= sp call (sp call)* sp`. Nothing in `*` says stop,
239/// and nothing else did either: `tool_choice` must be `"none"` beside a custom
240/// grammar (llama-server accepts no other combination), so the model's own
241/// trained stop behaviour is not in play, and a request carrying no
242/// `max_tokens` has no ceiling below the context window. Measured 2026-08-29:
243/// 606 calls in one response for a task expecting one, 853s against 6s
244/// unconstrained, scored 1.0 either way because extra calls cost nothing.
245///
246/// # Why the ceiling
247///
248/// Calls past [`MAX_PARALLEL_TOOLS_CEILING`] can never be executed — no
249/// configured limit may exceed it — so generating them is waste the loop then
250/// pays to discard. The grammar should not be able to express what the runtime
251/// will certainly reject.
252///
253/// This is the *ceiling*, not the user's configured `max_parallel_tools`,
254/// which the request pipeline cannot see: `ModelContext` carries per-model
255/// facts, not agent settings. Threading the live setting through would tighten
256/// this further and is the natural follow-up. The env override exists so the
257/// bound can be tested against a real model without a rebuild.
258fn grammar_call_limit() -> usize {
259 std::env::var(MAX_GRAMMAR_TOOL_CALLS_ENV)
260 .ok()
261 .and_then(|v| v.trim().parse::<usize>().ok())
262 .map_or(MAX_PARALLEL_TOOLS_CEILING, |n| n.max(1))
263}
264
265fn tool_call_grammar(spec: &DialectSpec, names: &[String], limit: usize) -> Option<String> {
266 let open = gbnf_string_literal(&spec.tool_open)?;
267 let close = gbnf_string_literal(&spec.tool_close)?;
268 let after_open = if spec.emission.newline_after_open {
269 " nl"
270 } else {
271 ""
272 };
273 let before_close = if spec.emission.newline_before_close {
274 " nl"
275 } else {
276 ""
277 };
278
279 let name_alternatives = names
280 .iter()
281 .map(|n| format!("\"\\\"{n}\\\"\""))
282 .collect::<Vec<_>>()
283 .join(" | ");
284
285 // `(sp call)*` — unbounded — is what let a 4B model emit 606 calls for a
286 // one-call task on 2026-08-29, stopping only when it exhausted a 32,768
287 // context, while the same model unconstrained emitted one call in 6s.
288 // Expanded as explicit optionals rather than `{{0,n}}`, which older GBNF
289 // parsers do not accept; the grammar is built once per request.
290 let repeats = " (sp call)?".repeat(limit.saturating_sub(1));
291
292 Some(format!(
293 r#"root ::= sp call{repeats} sp
294call ::= {open}{after_open} "{{" sp "\"name\"" sp ":" sp name sp "," sp "\"arguments\"" sp ":" sp object sp "}}"{before_close} {close}
295name ::= {name_alternatives}
296object ::= "{{" sp ( member ( sp "," sp member )* )? sp "}}"
297member ::= string sp ":" sp value
298value ::= object | array | string | number | "true" | "false" | "null"
299array ::= "[" sp ( value ( sp "," sp value )* )? sp "]"
300string ::= "\"" char* "\""
301char ::= [^"\\\x7F\x00-\x1F] | "\\" (["\\bfnrt/] | "u" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F])
302number ::= "-"? ("0" | [1-9] [0-9]*) ("." [0-9]+)? ([eE] [-+]? [0-9]+)?
303sp ::= [ \t\r\n]*
304nl ::= "\n"
305"#
306 ))
307}
308
309/// Render `s` as a GBNF double-quoted literal, escaping `"` and `\`.
310///
311/// Returns `None` for an empty string or one containing control bytes —
312/// markers a grammar cannot express verbatim make the caller fall back to
313/// unconstrained decode.
314fn gbnf_string_literal(s: &str) -> Option<String> {
315 if s.is_empty() || s.chars().any(char::is_control) {
316 return None;
317 }
318 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
319 Some(format!("\"{escaped}\""))
320}
321
322#[cfg(test)]
323#[path = "constrain_tests.rs"]
324mod constrain_tests;