grammar — constrained generation per il tool_callIl PLANNER di Metnos sceglie quale executor invocare a ogni
step. E’ un LLM locale (Gemma 4 26B). Quando la query e’ complessa — tipo
«proponi 3 orari per un appuntamento con Silvia di una ora la mattina settimana
prossima, dopo la scelta mandami una email con la scelta» — l’LLM a volte
entra in un loop di ragionamento e finisce per emettere prosa al posto di un
tool_call strutturato. Il sistema non sa cosa eseguire. Game over.
Si chiama “thinking loop”. Sintomi osservati:
{"name":"get_inputs","arguments":
{"kind":"choice","from_step":1,...}} — il nome di un tool con
gli argomenti di un altro tool.request_new_executor
(cioe’ «sintetizzati un nuovo verbo») anche quando c’e’ gia’ un
executor canonico ovvio. O request_location_from_user per la query
«crea cartella /tmp/x» (no, non mi serve la tua posizione GPS).Il bench prima di questa decisione: 50–70% convergenza su 10 query rappresentative. La pipeline propose+notify dell’ADR 0129 non chiudeva. Inaccettabile per un assistente personale.
llama.cpp (il motore che esegue Gemma) supporta una feature chiamata GBNF (Grammar Backus-Naur Form). E’ un linguaggio per scrivere grammatiche formali, tipo:
root ::= "{" ws "\"name\":" name ws "}"
name ::= "\"get_now\"" | "\"find_files\"" | "\"send_messages\""
ws ::= [ \t\n]*
Quando passi una grammar al server, l’LLM fisicamente non puo’ emettere token che la violino. Ogni token candidato a essere generato viene filtrato contro la grammar prima della scelta. E’ un binario, non una linea guida.
L’idea di ADR 0133 e’: a ogni step del PLANNER, generiamo una grammar specifica a partire dai tools del pool dello step, e la passiamo al server. Il modello e’ libero di scegliere quale tool chiamare e quali argomenti passargli, ma SOLO entro quello che gli schemi dichiarano valido.
runtime/tool_grammar.py::generate_tool_grammar)Riceve la lista di tools del pool e ritorna una stringa GBNF. Il core della grammar e’ una discriminated union:
root ::= "{" ws "\"name\":" (pairGetInputs | pairSendMessages | ...) ws "}"
pairGetInputs ::= "\"get_inputs\"" sep "\"arguments\":" colon (argsGetInputs)
pairSendMessages ::= "\"send_messages\"" sep "\"arguments\":" colon (argsSendMessages)
...
La parola chiave e’ discriminated: se l’LLM sceglie il ramo
pairGetInputs, gli args saranno per forza conformi a
argsGetInputs. Non puo’ mescolare il nome di un tool con gli args di un
altro — ed era il bug live che ci ha bloccato per mezza giornata.
Per gli args, il generator legge il JSON Schema dichiarato nel manifest TOML dell’executor e lo traduce in regole GBNF. Per i casi nested (array di object, object dentro object) il generator e’ ricorsivo:
argsGetInputs ::= "{" ws propGetInputsTitle sep propGetInputsDialog
(sep (propGetInputsFromStep | ...))* ws "}"
propGetInputsDialog ::= "\"dialog\":" colon (
"[" ws (GetInputsObjD1I0 (sep GetInputsObjD1I0)*)? ws "]"
)
GetInputsObjD1I0 ::= "{" ws propGetInputsObjD1I0Var sep propGetInputsObjD1I0Prompt sep
propGetInputsObjD1I0Schema (sep (...))* ws "}"
propGetInputsObjD1I0Schema ::= "\"schema\":" colon (GetInputsObjD3I2)
GetInputsObjD3I2 ::= "{" ws propGetInputsObjD3I2Kind (sep (...))* ws "}"
propGetInputsObjD3I2Kind ::= "\"kind\":" colon (
"\"text\"" | "\"credentials\"" | "\"yes_no\"" | "\"choice\"" |
"\"choice_with_preview\"" | "\"multi_choice\"" | "\"number\"" |
"\"date\"" | "\"file_path\"" | "\"location\""
)
Esempio reale per get_inputs: lo schema dichiara dialog
come array di object, ognuno con {var, prompt, schema}, dove
schema.kind e’ un enum di 10 valori. Tutto questo finisce nella grammar:
il modello non puo’ emettere kind: "banana", e
non puo’ dimenticarsi var.
items = {type: object} senza properties, la grammar fa
fallback su jsonObject generico, e il vincolo si perde. Per questo,
quando aggiungi un executor con strutture nested, devi dichiararle nel
manifest — la grammar lo protegge solo se lo schema e’ completo.
runtime/tool_grammar.py::filter_pool_for_grammar)Anche con la grammar perfetta, c’era un problema: certi tool sono escape-hatch — iniettati sempre nel pool per uso speciale — e con la grammar attiva il modello li sceglie a sproposito.
| Tool | Quando va escluso |
|---|---|
request_new_executor | se ci sono ≥3 tool canonical (e’ un fallback, non un default) |
request_location_from_user | se la query NON contiene marker prossimita’ (“vicino a me”, “qui”, “nearby”) |
undo_last_turn | se la query NON contiene marker undo (“annulla”, “ripristina”) |
*_google_workspace | se la query NON menziona “google”, “drive”, “gmail”... |
Il filter usa word-boundary regex (\bmarker\b) per
evitare i falsi positivi tipo «qua» ⊂ «qualcosa» che ci ha fatto
perdere un altro giro di bench. La logica e’ una funzione pura: la testi con 12
unit-test, garantisci determinismo (§7.9).
runtime/tool_grammar.py::validate_tool_call)La grammar e’ tight, ma non e’ perfetta — alcuni vincoli (es. mutua esclusivita’ tra due property) non si esprimono naturalmente in GBNF. Per quelli c’e’ un validator post-decode:
tool_call = {"name": "get_inputs", "arguments": {"display_template": "x"}}
ok, err = validate_tool_call(tool_call, pool)
# ok=False, err="missing required args ['title', 'dialog'] for tool 'get_inputs'"
Se il validator dice no, l’executor NON viene chiamato. L’errore viene
iniettato nel history dell’LLM come messaggio role=tool, e
allo step successivo il modello vede «mi mancano title e dialog»
e corregge. Un contatore consecutive_blocked previene loop infiniti.
name.kind ha 10 valori, il modello
ne sceglie SOLO uno tra quei 10.title: "": e’ una stringa. Toccaall’executor controllare._MAX_RECURSION_DEPTH = 4: oltre, fallback su
jsonObject generico. Casi rari, da monitorare.llama.cpp e’ software vivo, e la sua implementazione GBNF ha un paio di quirk’. Tutti scoperti in convergence test durante la sessione.
b540-5755a100c ignora silenziosamente le rule names che
contengono underscore. Tipo args_get_now ::= ... non viene
applicata. Workaround: rule names sempre camelCase. Test:
test_no_underscore_in_rule_names.
_PRIMITIVE_DEPS), emettiamo SOLO le primitives effettivamente
referenziate. Test: test_primitives_only_referenced_emitted.
grammar + tools insieme — HTTP 400.
llama-server rifiuta payload che hanno entrambi i campi. Workaround: in
grammar-mode bypassiamo tools e generiamo direttamente il JSON
{"name":..., "arguments":...}. Il chat-template di Gemma che
emette <|tool_call> markers viene anch’esso bypassato.
Sono workaround stabili, ma sono workaround. Se in futuro llama.cpp li risolve, possiamo semplificare. Sono presidiati da test.
Bench n=10 query rappresentative (simple/medium/complex/ambiguous), n=1 run per query:
| Configurazione | Convergenza | first_tool_match | Mean latency |
|---|---|---|---|
| baseline (no grammar) | 50% | n/a | 70s |
| grammar v1 (B2 base) | 80% | 89% | 25s |
| grammar v4 (discriminated) | 80% | 100% | 35s |
| grammar v6 (pool filter) | 100% | 100% | 42s |
| grammar v7 (refactor estratto) | 100% | 100% | 41s |
Tre cose vale la pena notare:
final_answer sensato, invece di scegliere un escape-hatch
a caso.La generazione della grammar e’ deterministica e veloce: per un pool tipico di 8 tool produce ~80 regole in ~2 ms. La grammar «costa» un po’ al server durante la generazione (filtering token candidati), ma il guadagno netto e’ enorme perche’ eliminiamo:
Aggiungere un nuovo provider qualifier (es. _notion o
_telegram_bot) costa una riga:
_PROVIDER_SUFFIX_MARKERS = {
"_google_workspace": ("google", "drive", "gmail", ...),
"_notion": ("notion", "notion.so", "page"), # ← aggiunta
}
Aggiungere un nuovo executor con schema nested complesso costa zero: il generator legge il JSON Schema dichiarato nel manifest e produce la grammar automaticamente, ricorsivamente, fino al cap di depth.
43 unit-test in runtime/tests/test_tool_grammar.py verdi.
Coprono:
args_complexity, is_complex).get_inputs.dialog
emette sub-rule).Bench end-to-end: runtime/bench_grammar.py:
METNOS_GRAMMAR=1 python3 runtime/bench_grammar.py --label grammar_v8 # 10 query × 1 run, ~7 minuti totali # output: /tmp/bench_grammar_grammar_v8_<ts>.json # stampa tabella aggregata + per-complexity + detail riga-per-riga
Per attivare grammar mode in sviluppo:
echo '[Service] Environment=METNOS_GRAMMAR=1' | sudo tee /etc/systemd/system/metnos-http.service.d/grammar.conf systemctl --user daemon-reload && systemctl --user restart metnos-http.service
La constrained generation non e’ un’invenzione di Metnos. Ecco una mappa veloce di chi fa cosa nell’ecosistema LLM/agent — serve per capire dove si colloca la scelta di ADR 0133 e cosa abbiamo guadagnato (o rinunciato) rispetto alle alternative.
| Piattaforma | Meccanismo | Hard / soft | Schema source | Note |
|---|---|---|---|---|
| OpenAI function calling / tools | Modello fine-tuned su tool_call. Server emette JSON conforme al
JSON Schema della function dichiarata. Con tool_choice="required"
forza l’uso di un tool. |
Misto: hard sul fatto che un tool sia chiamato; soft sulla struttura interna degli args (modello-trained, non hard-rail). | JSON Schema (subset OpenAPI) | Eccellente fluidita’, but black-box: niente controllo se il modello inventa un campo non dichiarato. Latency rete + costo $. |
| Anthropic Claude tool use | Stesso pattern OpenAI, con tool definito in input. Sonnet/Opus seguono schema strettamente, ma niente garanzia formale. | Soft (model-trained) | JSON Schema | Robustezza superiore al baseline locale, ma stesse riserve di non-localita’ e costo. Sonnet 4.6 ~$0.30/turn vs Gemma locale ~$0.001. |
| LangChain / LlamaIndex | Orchestrator: adatta function calling di provider diversi (OpenAI, Anthropic, ...) sotto un’interfaccia unica. Pydantic schema in input. | Eredita dal provider sottostante (soft) | Pydantic / JSON Schema | Layer di astrazione, non aggiunge robustezza. Utile per portability; non risolve thinking-loop. |
| llama.cpp GBNF scelta Metnos | Grammatica formale Backus-Naur. Filtra i token candidati a ogni step di decoding. Nativo nel server. | Hard (sampler-level) | Manuale o generata da JSON Schema | Massimo controllo, zero dipendenze esterne, deterministico. Quirks bug-server (vedi §5). Locale, no costi $. |
| Outlines / Instructor | Libreria Python che costruisce un FSM (finite state machine) dal JSON Schema/Pydantic e lo applica al decoding (token-level mask). | Hard (sampler-level) | Pydantic / JSON Schema | Stesso paradigma di GBNF ma library-side. Dipendenza nuova, supporto modelli limitato (HuggingFace + vllm). Per Metnos = piu’ peso, stesso risultato. |
| xgrammar (vllm) | Libreria C++ che compila JSON Schema in maschere di token. Integrata in vllm e a breve in altri serving stack. | Hard (sampler-level) | JSON Schema | Equivalente a GBNF, performance superiore in alcuni scenari. Non ancora in llama.cpp stable. |
| JSON Mode (OpenAI/Anthropic) | Garantisce JSON valido sintatticamente, niente vincolo su schema. | Hard sulla sintassi JSON, soft sullo schema | Nessuno | Utile come fallback, non basta per tool_call strutturato. |
La scelta GBNF di ADR 0133 e’ allineata con la frontiera tecnica: i serving stack moderni stanno tutti convergendo su sampler-level constraints (Outlines, xgrammar, llama.cpp GBNF). E’ il pattern state of the art per tool calling locale.
La differenza rispetto a OpenAI/Anthropic e’ che noi scriviamo la grammar invece di delegarla a un modello fine-tuned. Vantaggi:
Svantaggi:
| Trigger | Direzione |
|---|---|
| llama.cpp introduce xgrammar nativo | Switch a xgrammar (stessa semantica, performance migliori) |
| Multi-modello cross-provider (us. switch Gemma ↔ Sonnet) | Aggiungere adapter Outlines per provider non-llama.cpp |
| Query corpus convergenza scende sotto 95% | Investigare se è il modello o il filter; eventuale fine-tune locale |
| Bug llama-server irrisolvibili | Fork generator a 2 dialetti (GBNF + JSON Schema FSM) |
runtime/tool_grammar.pyruntime/llm_provider.py::LlamaCppProvider.chat_with_toolsruntime/bench_grammar.pyruntime/tests/test_tool_grammar.py