← Indice documentazione Microprogettazione › grammar

Metnos

grammar — constrained generation per il tool_call
Microprogettazione v1.0 — 14 maggio 2026
Allineata con ADR 0133
e con i moduli runtime/tool_grammar.py, runtime/llm_provider.py, runtime/agent_runtime.py.

Pubblico: chi vuole capire perche' Metnos non “chiacchiera” durante il planning
e cosa succede tra l’LLM e l’executor.
Lettura: 12 minuti, zero matematica.

Indice

  1. Il problema: l’LLM “ci pensa su” e si perde
  2. L’idea: la grammatica come binario
  3. I tre pezzi: generator, filter, validator
  4. Cosa garantisce e cosa no
  5. Perche’ tre “bug llama-server” ci hanno fatto perdere mezza giornata
  6. Robustezza, velocita’, estensibilita’
  7. Come si testa
  8. Confronto con altre piattaforme
  9. Riferimenti

1. Il problema: l’LLM “ci pensa su” e si perde

Il 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:

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.

2. L’idea: la grammatica come binario

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.

Analogia. Un soft constraint via prompt e’ come dire “per favore guida nella corsia destra”. Una grammar e’ come mettere il guard-rail. Il primo si puo’ ignorare, il secondo no.

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.

3. I tre pezzi: generator, filter, validator

3.1 Generator (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.

Schema = source of truth. Se il manifest dichiara 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.

3.2 Pool filter (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.

ToolQuando va escluso
request_new_executorse ci sono ≥3 tool canonical (e’ un fallback, non un default)
request_location_from_userse la query NON contiene marker prossimita’ (“vicino a me”, “qui”, “nearby”)
undo_last_turnse la query NON contiene marker undo (“annulla”, “ripristina”)
*_google_workspacese 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).

3.3 Validator (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.

4. Cosa garantisce e cosa no

Garantisce

Non garantisce

5. Perche’ tre “bug llama-server” ci hanno fatto perdere mezza giornata

llama.cpp e’ software vivo, e la sua implementazione GBNF ha un paio di quirk’. Tutti scoperti in convergence test durante la sessione.

1. Rule names con underscore — ignorate. La versione 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.
2. Rule unused interferiscono. Anche se una rule non e’ referenziata da nessuna altra, la sua presenza puo’ alterare il matching. Workaround: dependency closure (_PRIMITIVE_DEPS), emettiamo SOLO le primitives effettivamente referenziate. Test: test_primitives_only_referenced_emitted.
3. 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.

6. Robustezza, velocita’, estensibilita’

Robustezza

Bench n=10 query rappresentative (simple/medium/complex/ambiguous), n=1 run per query:

ConfigurazioneConvergenzafirst_tool_matchMean latency
baseline (no grammar)50%n/a70s
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:

Velocita’

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:

Estensibilita’

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.

7. Come si testa

43 unit-test in runtime/tests/test_tool_grammar.py verdi. Coprono:

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

8. Confronto con altre piattaforme

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.

Dove si colloca Metnos

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:

Quando avrebbe senso cambiare

TriggerDirezione
llama.cpp introduce xgrammar nativoSwitch 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 irrisolvibiliFork generator a 2 dialetti (GBNF + JSON Schema FSM)

9. Riferimenti