Quando arriva una richiesta, Metnos non chiama più il pianificatore cinque o sei volte di seguito. Per prima cosa prova le scorciatoie che già conosce; se non bastano, chiede al modello linguistico locale di proporre l’intero piano in una sola chiamata; poi lo esegue in modo deterministico, e se qualcosa va storto tenta un recupero mirato o spiega onestamente cosa manca. Quattro strati, un solo modello, mai il cloud.
Il motore attuale parte da due intuizioni semplici.
Prima intuizione: una sola proposta. Invece di interrogare il modello a ogni passo, lo si interroga una volta sola e gli si chiede l’intero piano in un colpo: la lista completa degli step, con i collegamenti fra loro e il messaggio finale. Il modello vede tutto il problema in una volta, costruisce coerenza interna (sa che lo step 4 ha bisogno dell’output dello step 3) e produce un piano molto più stabile. L’esecuzione che segue è pura meccanica deterministica: niente più dadi.
Seconda intuizione: ricordare ciò che funziona. Quando un piano arriva in fondo bene, il sistema lo conserva, indicizzato per «significato» della richiesta. La volta dopo che arriva una richiesta dello stesso tipo, il piano è già pronto: parte in pochi millisecondi, senza disturbare il modello. Il sistema diventa il proprio pianificatore — impara a non chiedere più.
Queste due intuizioni si traducono in quattro strati, provati in cascata. La distinzione che conta è fra ciò che ricorda e ciò che ragiona: i primi due strati (L0 e L1) sono memoria con stato — un archivio che cresce con l’uso e risponde senza disturbare il modello quando riconosce la richiesta. Gli altri due (L2 e L3) sono senza stato: non ricordano nulla da un turno all’altro, ma ricontrollano il piano e, se serve, lo costruiscono daccapo. Il terzo è un controllo, il quarto è il motore vero e proprio che propone, esegue, recupera e — se non c’è via d’uscita — ammette onestamente il limite.
Ogni turno attraversa gli strati dall’alto verso il basso. Appena uno strato risolve, il turno termina: gli strati sotto non vengono nemmeno toccati. I primi due (L0 e L1) sono i due strati con stato, la memoria che impara: sono velocissimi e non usano il modello linguistico. Gli altri due (L2 e L3) sono senza stato — non conservano nulla fra un turno e l’altro — e solo il quarto, solo quando serve, chiama davvero il modello.
File: runtime/engine/fastpath.py · archivio fastpaths.sqlite.
Cosa fa: è il primo dei due strati con stato. Riconosce le richieste già risolte con successo dal piano pieno: la scorciatoia si auto-produce a ogni turno riuscito (valvole: rimozione dalla console di amministrazione e invecchiamento). Il riconoscimento avviene in due tempi: prima un confronto esatto per impronta (hash, sotto i 5 ms, zero modello), poi un confronto per significato con gli embedding BGE-M3 (coseno, sotto i 150 ms). Se trova un riscontro, esegue subito il piano salvato.
Quando vince: sempre, se c’è un match. Una scorciatoia auto-appresa ha la precedenza su tutto il resto.
File: runtime/engine/autopath.py · archivio autopath.sqlite.
Cosa fa: è il secondo strato con stato, la memoria che cresce da sola. Conserva i piani che hanno funzionato, indicizzati per significato della richiesta. Cerca prima per somiglianza semantica (cluster di richieste affini) e poi per corrispondenza esatta dell’intento. Se riconosce la richiesta, esegue il piano già collaudato senza chiamare il modello.
Quando interviene: dopo Fastpath, solo se l’intento è completo (verbo + oggetto riconosciuti). Registra inoltre ogni turno per imparare in futuro.
File: runtime/engine/validator.py.
Cosa fa: è un controllo deterministico del piano prima di eseguirlo. Non conserva nulla fra un turno e l’altro: è il primo dei due strati senza stato. Verifica che i tool citati esistano, che gli argomenti abbiano forma valida, che i riferimenti fra step puntino a qualcosa di reale. Se trova errori, chiede al motore di riproporre il piano una volta. Così un errore banale viene corretto senza nemmeno avviare l’esecuzione.
Quando interviene: fra la proposta e l’esecuzione. Attivo per impostazione predefinita.
File: runtime/engine/{proposer,executor,recovery,terminator}.py.
Cosa fa: è il cuore del motore, quattro componenti senza stato che lavorano in sequenza. Il Proposer chiede al modello l’intero piano in una sola chiamata. L’Executor lo realizza passo per passo, in modo completamente deterministico. La Recovery entra in gioco se uno step fallisce: classifica l’errore e tenta un’alternativa. Il Terminator è l’ultima istanza onesta: se non c’è via d’uscita, spiega all’utente cosa manca.
Quando interviene: solo se né Fastpath né Autopath hanno riconosciuto la richiesta.
runtime/engine/dispatch.py,
che espone una sola funzione di turno e annota quale strato ha risposto
(fastpath, autopath, engine,
recovery o terminator). Il dispatcher non sa nulla
dei domini applicativi: orchestra soltanto gli strati. Aggiungere un nuovo
motore o una nuova strategia di recupero non richiede di toccare gli altri
strati.
Ecco il cammino completo, dal messaggio dell’utente alla risposta finale. Si legge dall’alto verso il basso: ogni livello si tenta solo se quello sopra non ha già risolto.
UTENTE: "cerca le mail spam e mettile in cestino"
|
v
+-------------------+
| fast_path (pre) | regex su pattern banalissimi ("che ora e'", "dove sono")
| (zero LLM) | --> match? rispondi in ~50ms e termina
+-------------------+
| (no match)
v
+-------------------+
| intent_extractor | LLM fast tier ~370ms
| verbo + oggetto + | --> (verbo="move", oggetto="messages",
| parole chiave | keywords=["spam","cestino"])
+-------------------+
|
v
+--------------------------------------------------------------+
| MOTORE (engine/dispatch.run_turn) |
| |
| +----------------+ |
| | L0 Fastpath | hash (<5ms) + coseno BGE-M3 (<150ms) |
| | lookup | --> scorciatoia auto-appresa ? esegui |
| +----------------+ |
| | (miss) |
| v |
| +----------------+ |
| | L1 Autopath | match semantico + intent_hash |
| | lookup | --> autopath appreso ? esegui (no LLM) |
| +----------------+ |
| | (miss) |
| v |
| +----------------+ |
| | L3 Proposer | LLM wise 1-shot |
| | propose | --> piano JSON {steps, fillers, |
| | (Qwen 35B-A3B) | final_message} |
| +----------------+ |
| | |
| v |
| +----------------+ |
| | L2 Validator | typecheck piano; errore? --> riproponi |
| +----------------+ |
| | |
| v |
| +----------------+ |
| | Executor | per ogni step: |
| | (deterministico)| - resolve from_step + FILLER + RUNTIME |
| +----------------+ - invoke_executor + vaglio + accumula |
| | |
| v |
| +----------------+ |
| | render final | template "Trovate {N} mail, spostate" |
| +----------------+ |
+--------------------------------------------------------------+
|
v (ok? si' --> Autopath registra + risponde a utente)
|
| (errore? si' --> entra Recovery)
v
+-------------------+
| Recovery | classifica errore: wrong_tool / wrong_args /
| classify + retry | missing_input
| | --> riproponi escludendo il tool fallito
| | --> esegui di nuovo
+-------------------+
|
v (ok? si' --> risposta)
|
| (out_of_scope o recovery fallita? --> Terminator)
v
+-------------------+
| Terminator | spiega onestamente: causa + azione suggerita
| honest dead-end | --> registra la lacuna in terminator_log.sqlite
| | --> "Non posso risolvere: X. Per procedere: Y."
+-------------------+
|
v
UTENTE: messaggio finale (risposta o richiesta di azione)
Il Proposer non produce testo libero: produce un oggetto strutturato che chiamiamo framework (il piano). Ha tre parti:
{
"steps": [
{"tool": "find_messages",
"args": {"folder": "INBOX", "query": "is:unread"}},
{"tool": "classify_entries",
"args": {"from_step": 1, "dimension": "spam"}},
{"tool": "filter_entries",
"args": {"from_step": 2, "where_field": "spam", "where_value": "spam"}},
{"tool": "move_messages",
"args": {"from_step": 3, "dst_folder": "${FILLER:cestino_folder}"}}
],
"fillers": {
"cestino_folder": {
"prompt": "Come si chiama la cartella cestino per questo account?",
"default": "Trash",
"tier": "fast"
}
},
"final_message": "Spostate ${step4.ok_count} mail in cestino."
}
Dentro gli argomenti compaiono quattro tipi di segnaposto, che l’Executor risolve in modo deterministico al momento giusto:
| Segnaposto | Significato |
|---|---|
from_step: N | Prendi le entries prodotte dallo step N (numerazione da 1) e passale a questo step. |
${stepN.field} | Estrai un campo dal risultato dello step N (supporta percorsi annidati e proiezioni). Usato soprattutto nel messaggio finale. |
${FILLER:nome} | Slot riempito al volo da una piccola chiamata al modello fast tier (con cache), o dal valore di default. |
${RUNTIME:chiave} | Valore di contesto del turno: actor (chi sta parlando), lang, channel. |
Il Fastpath è la corsia preferenziale. Ogni turno completato con successo dal piano pieno crea da solo la scorciatoia — le catene sono executor già vagliati e testati, nessuna approvazione —: da quel momento quella richiesta (e le sue varianti vicine) saltano tutto il resto e vengono servite direttamente. Le scorciatoie invecchiano da sole (mai riusate, stantie, tetto totale), muoiono quando un executor le rimpiazza o sparisce, e si rimuovono a mano dalla console admin.
Il riconoscimento avviene in due tempi. Prima un confronto esatto per impronta della frase (deterministico, sotto i 5 ms, senza alcun modello). Se non basta, un confronto per significato: la richiesta viene trasformata in un vettore con BGE-M3 e confrontata per coseno con le scorciatoie salvate (sotto i 150 ms). Una scorciatoia auto-appresa vince sempre, anche su un autopath appreso automaticamente.
L’Autopath è la memoria che cresce da sola, senza che nessuno scriva regole a mano. Conserva tre tabelle nel proprio archivio sqlite:
| Tabella | Cosa contiene | Quando viene scritta |
|---|---|---|
autopaths |
Piani che hanno funzionato, indicizzati per significato della richiesta e raggruppati per cluster semantico. Per ognuno: il piano, i contatori d’uso, un punteggio composito, lo stato (campione/sfidante). | Promossi automaticamente quando un piano si dimostra valido sullo stesso tipo di richiesta. |
anti_autopaths |
Piani che hanno fallito ripetutamente. Per ognuno: il motivo e una scadenza (circa 30 giorni). | Quando un piano fallisce ripetutamente sullo stesso intento. Alla scadenza, il sistema riprova. |
observations |
Registro di ogni turno: intento, piano eseguito, latenza, vettore semantico. In sola aggiunta. | Sempre, a fine turno. È la fonte di verità per promuovere o ritirare gli autopath. |
La ricerca procede in due tempi: prima per somiglianza semantica (la richiesta cade nel cluster di richieste affini già viste e si serve il piano «campione» di quel cluster), poi, se serve, per corrispondenza esatta dell’intento. Quando più piani competono per lo stesso cluster, vige un meccanismo campione/sfidante: lo sfidante deve dimostrarsi migliore prima di prendere il posto del campione.
Prima di eseguire un piano appena proposto, il Validator lo passa al setaccio, in modo puramente deterministico e senza modello:
from_step, ${stepN.…}) puntano a step che esistono?Se trova anche un solo errore, non avvia l’esecuzione: chiede al Proposer di riproporre il piano una volta, escludendo quello sbagliato. Così un refuso o un argomento storto vengono corretti a costo zero, senza sprecare una chiamata di recupero più pesante. È attivo per impostazione predefinita.
Il Proposer è l’unico componente che parla davvero con il modello linguistico. Riceve la richiesta e l’intento estratto e produce, in una sola chiamata, il piano intero. Niente ragionamento passo-passo, niente tool-call iterativi: un solo oggetto strutturato.
Esistono varianti selezionabili (impostazione METNOS_ENGINE):
L’Executor è il componente più rigoroso: zero modello
linguistico nel loop principale, solo Python deterministico. Per ogni step:
risolve i riferimenti agli step precedenti (from_step), riempie
gli slot ${FILLER:…} (una piccola chiamata fast
con cache) e i valori di contesto ${RUNTIME:…}, valida gli
argomenti, chiama l’executor del tool, passa il risultato al
Vaglio (il giudice di sicurezza) e accumula. Alla
fine compone il messaggio finale dal modello con i campi
${stepN.field}.
Se uno step fallisce, entra la Recovery. Il suo primo lavoro è capire che tipo di errore è, leggendo la classe d’errore strutturata che l’executor restituisce (niente analisi del testo multilingua):
| Classe | Quando | Strategia |
|---|---|---|
| wrong_tool | Il tool scelto non era adatto: ha fallito o è semanticamente sbagliato. | Riproponi un piano diverso, escludendo quel tool. |
| wrong_args | Il tool era giusto ma gli argomenti erano malformati: pipeline a vuoto, limite di passi raggiunto, un riferimento che punta nel nulla. | Riproponi con argomenti canonici e riferimenti espliciti. |
| missing_input | Manca un presupposto: un indice non costruito, una cartella inesistente, un filtro che produce zero risultati. | Riproponi un piano alternativo; se serve l’utente, cedi al Terminator. |
| out_of_scope | Errore non recuperabile: serve un’azione fisica dell’utente (per esempio condividere la posizione) o manca del tutto una capacità. | La Recovery non interviene: cede subito al Terminator. |
La Recovery tenta una sola alternativa, escludendo sia il piano fallito sia il tool che ha causato il problema. Niente loop infiniti: se l’alternativa non riesce, la parola passa al Terminator.
Il Terminator è il pezzo più sottile del sistema. Non è
un «gestore di errori»: è un riconoscimento onesto del
limite. Quando arriva il suo turno, vuol dire che tutto il resto ha tentato
e fallito. Il Terminator non finge un successo: spiega all’utente la
causa e suggerisce un’azione concreta, e registra la lacuna in un
archivio (terminator_log.sqlite) con un contatore di quante volte
si è ripresentata.
I messaggi di causa e azione non sono stringhe fisse nel codice: passano dal dizionario multilingua, così l’utente li legge nella propria lingua. Il turno si chiude sempre con una risposta leggibile, mai con un silenzio o un errore grezzo.
Sotto a ogni risposta in chat, l’utente vede dei piccoli pulsanti di riscontro:
Non è obbligatorio premere niente: se una pipeline arriva in fondo senza errori, l’Autopath la considera comunque un’osservazione positiva. Il feedback esplicito accelera e rifinisce, ma il sistema impara anche dal silenzio. Più lo si usa, più spesso le richieste vengono servite dalla memoria invece che dal modello — e quindi più in fretta.
Anche al primo colpo, il motore non interroga il pianificatore passo-passo ma fa una sola proposta. La sensazione è quella di un sistema che «ci pensa un attimo» invece di «si blocca per un minuto».
Dopo qualche turno della stessa famiglia di richieste, l'autopath è appreso e i turni successivi partono dalla memoria. La risposta arriva in una frazione di secondo: è la differenza fra «assistente» e «reazione immediata».
Quando qualcosa non si può fare, il Terminator dice esattamente cosa serve. Niente messaggi vaghi del tipo «errore generico», niente loop di tentativi inutili: una frase chiara, un suggerimento operativo e, se è il caso, un dialogo per risolvere la mancanza sul momento.
I framework per «workflow di agenti LLM» (LangGraph e affini) coprono in parte lo stesso problema: ridurre la varianza del modello nel loop dando struttura ai task. Ma hanno limiti che questo motore non ha:
| Aspetto | Framework LLM-in-loop | Motore di Metnos |
|---|---|---|
| Origine dei workflow | scritti a mano, manutenzione manuale | seed opzionale, ma crescono da soli dal feedback ✓ ✗ ↺ |
| Apprendimento | no | sì: memoria che si popola dai turni andati bene |
| Anti-errore | no | tabella anti_autopaths che esclude i cammini falliti |
| Recupero strutturato | retry generici | 4 classi d’errore ortogonali + riproposta mirata |
| Vicolo cieco onesto | retry infinito o crash | Terminator: classifica + suggerisce un’azione |
| Tracciabilità | nessuna, o log testuali | registro observations + pannello di amministrazione |
| Coerenza dei nomi | nomi liberi | vocabolario chiuso (verbo + oggetto + qualificatore) garantito |
I framework LLM-in-loop tradizionali sono come artisti con tela bianca: ogni turno reinventano la composizione. Bella creatività, ma il rischio di sbagliare strumento è reale e ogni opera costa.
Il motore di Metnos è come un artigiano con quaderno di schizzi: il modello schizza il piano una volta (creatività concentrata e vincolata), e da quel momento l’artigiano riapre il quaderno alla pagina giusta. La creatività del modello c’è ancora, ma è limitata al momento dell’invenzione: l’esecuzione è riproduzione fedele dello schizzo. Così si evitano le allucinazioni in esecuzione, si guadagna velocità e si arriva a costo zero quando il quaderno è già ricco.
Su un campione di richieste reali (mail, file, web, posizione, ora, dialoghi multi-turno, scheduling, contatti), confrontando il pianificatore iterativo sul cloud con il motore locale a singola proposta:
| Modalità | Copertura | Latenza media | Modello | Costo |
|---|---|---|---|---|
| Pianificatore iterativo (cloud, passo-passo) | equivalente | ~76 s | cloud frontier | a pagamento |
| Motore locale (singola proposta + memoria) | equivalente | ~12 s a freddo, <1 s da memoria | Qwen 3.6 35B-A3B locale | 0 |
Diverse volte più veloce, copertura equivalente, costo zero (il modello gira in locale su hardware a memoria unificata). E la latenza scende ancora man mano che la memoria si popola: le richieste ricorrenti partono dalla cache, sotto il secondo.