agent_runtime — il pianificatore turno per turno{{stepN.field}}Immagina di scrivere a Metnos: «leggi il file ~/notes/diario.md e dimmi le ultime tre righe». Da quel momento parte un turno. Il pianificatore — il modulo che descriviamo qui — riceve la frase, decide cosa fare, mette in piedi i passi, e produce la risposta.
Per capire cosa significa "decidere cosa fare", seguiamo un esempio concreto. La richiesta è quella sopra. Il pianificatore non ha logica codificata che dica "se l'utente dice 'leggi un file' chiama fs_read"; l'idea è un'altra:
La cosa importante: il comportamento utile emerge dalla composizione, non da regole codificate. Se domani l'utente chiederà "scarica una pagina, salvala in un file, e dimmi quanti byte hai scritto", il pianificatore comporrà web_fetch + fs_write + final_answer senza bisogno di alcun caso particolare. La stessa pipeline, una richiesta diversa, una sequenza di passi diversa.
Ora vediamo lo stesso percorso in modo più preciso. Un turno ha sempre questa struttura:
executors/), per ognuno verifica che il manifest sia firmato dalla chiave dell'autore e che il digest del codice corrisponda. Quelli che falliscono la verifica vengono scartati con un motivo. Il catalogo è in memoria.turn_id univoco per identificare questo turno nei log.Il pre-filter ranka il catalogo per pertinenza alla query utente e ne seleziona un sotto-insieme: i top-K. Senza pre-filter, dovremmo passare all'LLM tutti gli executor (potenzialmente decine o centinaia), e la qualità della scelta degraderebbe insieme alla latenza. Il pre-filter risolve questo problema in modo banale ma efficace (vedi cap. 5).
A questo punto il turno entra in un ciclo. Per ogni passo:
{{stepN.field}}, li sostituisce col valore reale (vedi cap. 7).{ok, content?, metadata?, error?}) torna al pianificatore.Il turno termina in uno dei seguenti modi:
final_kind = "answer" — l'LLM ha prodotto la risposta finale.final_kind = "cap_steps" — superato il limite di passi (default 5).final_kind = "cap_same_executor" — lo stesso executor è stato chiamato troppe volte (default cap 2).final_kind = "error" — un componente esterno irraggiungibile (es. Ollama giù), oppure il catalogo è vuoto, ecc.
In ogni caso, il pianificatore scrive un record JSONL completo del turno (vedi cap. 11) e restituisce (log, final_message).
Query: «scarica https://httpbin.org/uuid e dimmi solo l'UUID».
Step 1 — LLM propone web_fetch(url=https://httpbin.org/uuid). Esecuzione: ritorna JSON {"uuid":"7c089d54-..."}.
Step 2 — LLM legge la storia, capisce che ha quel che serve, produce final_answer: "7c089d54-...".
Latenza totale: 3.3 secondi (Qwen3:8b locale). Nessun pre-codifica per "estrai uuid": il LLM ragiona sul JSON e estrae il campo da solo.
Il pianificatore lavora in tre modi, scelti via configurazione. La differenza principale fra i tre è quanti viaggi all'LLM servono per completare un turno e dove gira l'LLM.
| Mode | Loop | LLM tipico | Quando ha senso |
|---|---|---|---|
local | multistep ReAct (un viaggio per passo) | locale (Ollama) | Default. Privacy massima, costo zero, latenza decente. |
online | single-shot (un viaggio per piano completo) | frontier (Anthropic, OpenAI) | Task complessi che valgono i soldi spesi. Frontier riesce in una chiamata sola dove il locale ne avrebbe richieste cinque. |
hybrid | local di default; escalation a online per task critici | misto | Equilibrio fra costo e qualità. Per Metnos casalinga. |
Il principio: la forma del loop segue dal costo per chiamata. Un LLM locale gratuito si può permettere di iterare; un LLM frontier costoso comprime tutto in una chiamata. Stesso pianificatore, comportamento adattato al costo.
In v1.1 abbiamo testato il mode local; online è predisposto come scaffold (provider Anthropic stub) ma non esercitato; hybrid ha il ModeRouter presente che in PoC ritorna sempre "local". Estensioni richiederanno: configurare un provider online + abilitare le regole di routing in [runtime.hybrid].
Indipendentemente dal mode (che dice dove gira l'LLM), il runtime espone tre tier: tre puntatori a LLM con caratteristiche diverse. Ogni componente del runtime sceglie il tier che gli serve.
| Tier | Caratteristica | Candidato |
|---|---|---|
| fast & furious | Piccolo, veloce, sempre LOCALE, sempre disponibile. | qwen3:8b con think=false |
| middle & trustable | Capacità di ragionamento intermedia. | gemma3:12b locale, oppure claude-haiku-4-5 online. |
| slow & wise | Massima capacità di ragionamento, accetta latenza/costo. | claude-sonnet-4-6 online, oppure qwen3:32b con think=true in locale potente. |
Il pianificatore standard usa fast. Il vaglio costituzionale (quando reale) userà middle. Il sintetizzatore di nuovi executor (synt) userà wise. Quando una sezione manca dalla configurazione, il puntatore aliasa al tier inferiore: middle → fast, wise → middle → fast. Mai un crash, mai un'eccezione: la presenza del solo fast garantisce funzionamento.
think=true e chiede riflessione profonda. Stesso modello, ruoli diversi, output diversi. L'utente ottiene tre punti di vista anche con un solo cervello.
Schema config minimale:
[runtime.llm.fast] provider = "ollama" model = "qwen3:8b" think = false [runtime.llm.middle] provider = "ollama" model = "gemma3:12b" [runtime.llm.wise] provider = "anthropic" model = "claude-sonnet-4-6"
In v1.1 implementato la struttura ma non ancora la differenziazione completa: i tre puntatori in PoC sono identici (qwen3:8b) e il prompt è uno solo. La differenziazione di prompt per tier è rimandata a quando avremo un secondo backend o un caso d'uso che la richiede.
Con un catalogo di 30+ executor, passare tutti come "tools" all'LLM esplode il prompt e dilava l'attenzione. Il pre-filter ranka per pertinenza e ne passa al LLM solo i più promettenti.
In v1.1 il ranking è bag-of-words: tokenizza la query utente, tokenizza l'affinity dichiarato in ogni manifest, somma i match (i match sull'affinity contano doppio rispetto a quelli sulla descrizione). Estrarre i token significa: minuscolo, parole alfanumeriche, niente accenti.
Query: «scarica https://httpbin.org/get». Token: {scarica, httpbin, org, get, https}.
Match con affinity di web_fetch: web, http, url, fetch, scarica, leggi, pagina, api, rest. Match: scarica. Score: 2.
Match con affinity di fs_read: fs, read, leggi, lettura, file, ... Nessun match. Score: 0.
web_fetch vince con confidenza alta.
K (numero di executor da passare al LLM) non è fisso: dipende dalla confidenza con cui il pre-filter distingue il top-1 dagli altri.
k_min=5). Inutile passare candidati a score zero.k_max=40). Lascia che sia l'LLM a decidere.Misurato nel POC: pre-filter sub-millisecondo fino a 300+ executor; LLM Qwen3:8b sceglie correttamente fino a K=200; latenza cresce linearmente con K oltre 40. Sweet spot pratico: K=20-40 (~3 secondi di latenza LLM).
La forma con embedding semantico (modello MiniLM locale, ~100 MB) è rimandata: il bag-of-words si è dimostrato sufficiente per i casi reali del POC.
Gli LLM moderni (Anthropic, OpenAI da sempre; Ollama per Qwen 2.5/3, Llama 3.1+, Mistral, Gemma) supportano un protocollo nativo di tool-calling. Il runtime dichiara i tools disponibili nella API, ognuno con il suo JSON Schema preso dal manifest. Quando l'LLM decide di chiamare un tool, restituisce un campo strutturato:
{
"tool_calls": [
{
"id": "call_abc123",
"function": {
"name": "fs_read",
"arguments": {"path": "/tmp/note.txt", "tail_bytes": 100}
}
}
]
}
È già un dict Python, parsato dal protocollo HTTP. Niente regex sul testo, niente blocchi markdown da estrarre, niente edge case di "l'LLM ha scordato di chiudere le parentesi". Quando l'LLM è pronto per la risposta finale, semplicemente non chiama nessun tool e produce solo testo: il pianificatore riconosce questo come final_answer.
[args]) e quel JSON Schema viene passato direttamente al provider come parameters del tool. Una sola fonte di verità sulla forma degli argomenti, zero traduzione. Vedi executor.html per i dettagli del manifest.
{{stepN.field}}
In multistep, l'LLM al passo N+1 ha bisogno di riferirsi all'output del passo N. Esempio classico: "scarica X e salvalo in Y" — al passo 2, fs_write deve ricevere come content il body restituito da web_fetch al passo 1.
La sintassi è {{stepN.field}}:
N è il numero del passo (1-indexed: step1 è il primo).field è la chiave dentro l'observation di quel passo. Può essere annidata: {{step1.metadata.path}}, {{step2.content}}.Il runtime intercetta gli args prima di invocare l'executor, scopre i placeholder, recupera il valore reale dalla history del turno, sostituisce.
// Passo 1
{ "tool": "web_fetch", "args": { "url": "https://httpbin.org/get" } }
// observation: {ok: true, content: "", metadata: {...}}
// Passo 2 (proposto dal LLM)
{ "tool": "fs_write", "args": {
"path": "/tmp/out.txt",
"content": "{{step1.content}}"
} }
// Il runtime sostituisce e invoca:
{ "tool": "fs_write", "args": {
"path": "/tmp/out.txt",
"content": ""
} }
{{step1.bytes_written}}. Il prompt del pianificatore istruisce esplicitamente l'LLM su questo limite. Una violazione di questa regola è stato uno dei bug catturati dal POC.
Il riferimento dev'essere il solo valore dell'arg, non interpolato in stringhe più lunghe (limite v1.1; estensione futura potrebbe permettere "prefisso {{step1.content}} suffisso").
È il risultato di una scelta architettonica. Quando il POC è partito senza alcun meccanismo di data piping, il LLM ha provato a inventarsi una sintassi shell-style ($(web_fetch(...)['content'])) che il runtime non sapeva interpretare: il file finiva per contenere quella stringa letterale invece dei dati. Era una tensione architetturale aperta: serviva un meccanismo, fra cinque alternative possibili (verbatim copy, named variables, pipe primitives, defer multistep). La sintassi {{stepN.field}} è la più semplice che funziona: 30 righe di template substitution nel runtime, una breve istruzione nel prompt, e l'LLM Qwen3:8b la segue zero-shot.
Quando un executor restituisce molto contenuto (un file di 100 KB, una pagina HTML lunga, un body API verboso), passarlo per intero nella history dell'LLM esplode il context. Tagliare a 1500 caratteri perde informazione utile.
La soluzione del v1.1: scratchpad. Quando un'observation supera la soglia (4 KB di JSON serializzato), il runtime la salva in uno SQLite locale (~/.local/share/metnos/scratchpad.db) e mette nella history un'observation sintetica:
{
"ok": true,
"scratchpad_id": "eae04122bd704636",
"size_bytes": 14144,
"kind": "text",
"summary": "ciao questa è una nota di test\n\n[... 13900 caratteri omessi ...]\n\nINFO 2026-04-26 23:59:59 ULTIMO_EVENTO_CRITICO\n",
"metadata": {"path": "/tmp/big_log.txt", "bytes": 14144, ...}
}
Il summary è uno smart truncation: i primi 500 caratteri + un placeholder con il numero di caratteri omessi + gli ultimi 500. Cosí l'LLM vede inizio e fine del contenuto.
L'LLM al passo successivo, vedendo il summary, decide:
scratchpad_read con mode:
full: il contenuto intero (sconsigliato se è molto grande).head: i primi N caratteri (default 2000).tail: gli ultimi N caratteri.range: un intervallo [start, end).
scratchpad_read è un builtin: vive nel runtime, non ha manifest su disco, viene aggiunto al catalogo dei tool dinamicamente quando ci sono entries scratchpad attive nel turno corrente.
Dettagli completi nel doc dedicato: scratchpad.html.
Il vaglio è il valutatore costituzionale: prima che un tool_call diventi azione, verifica se è conforme alle 4 Leggi della costituzione. In multistep gira fra uno step e l'altro; in single-shot gira post-hoc sull'intero piano (un'unica chiamata di vaglio per tutta la catena proposta dall'LLM frontier).
In v1.1 il vaglio è uno stub always-approve: logga ogni decisione su JSONL ma non rifiuta nulla. La versione probabilistica (LLM-judge sulle 4 Leggi) entrerà quando la constitution.html v1.1 sarà scritta. Importante: la forma della chiamata e del logging è già quella corretta — l'unica cosa che cambierà quando passeremo al vaglio reale è l'implementazione interna.
Vedi vaglio.html per dettagli.
Il pianificatore ha tre meccanismi di safety contro loop e azioni mal poste. Sono apparsi nel POC come risposta a comportamenti reali del LLM, non come timore astratto.
| Meccanismo | Cosa fa | Default |
|---|---|---|
cap_steps | Limite massimo di passi per turno. | 5 |
cap_same_executor | Limite di chiamate dello stesso executor nel turno. | 2 |
| guard duplicate read | Se l'LLM richiama fs_read/fs_write/web_fetch con lo stesso path/url di un passo precedente, il runtime non ri-esegue: restituisce un'observation che dice "hai già questo dato al passo X, formula la final_answer". | attivo |
Il guard duplicate read è emerso dal POC: senza, gli LLM piccoli (Qwen3:8b in particolare) tendevano a rileggere lo stesso file con args leggermente diversi sperando in un risultato migliore, finendo nel cap_same. Il guard intercetta prima e sblocca la formulazione.
Eccezione: scratchpad_read non è soggetto al guard, perché chiamarlo più volte con mode/range diversi sullo stesso scratchpad_id è il caso d'uso normale.
Per ogni turno il pianificatore scrive una riga JSONL in ~/.local/share/metnos/turns/YYYY-MM-DD.jsonl con:
turn_id — uuid del turno.ts_start, ts_end — timestamp Unix.user_query — testo originale dell'utente.mode — mode scelto (local/online/hybrid).candidates — nomi degli executor passati al LLM dopo pre-filter.steps — lista di step con: numero, llm in/out tokens, latenza, tool chiamato, args raw e risolti, esito di validation/sandbox/vaglio, result.final_message — testo finale all'utente.final_kind — uno di answer | error | cap_steps | cap_same_executor.JSONL append-only, un file per giorno. Niente rotazione automatica in v1.1 (con uso normale ~3 MB/mese, trascurabile).
Il POC ha verificato cinque modi tipici di rottura. Sono diventati test cases permanenti del framework di test.
| Cosa si rompe | Cosa succede |
|---|---|
| Ollama non raggiungibile | Il provider solleva ProviderError; il pianificatore conclude il turno con final_kind=error e messaggio chiaro. |
| Executor crasha (eccezione Python non catturata) | Il subprocess esce con stderr, il runtime restituisce {ok: false, error: "non-JSON output: …; stderr: …"}. |
| Codice executor modificato dopo la firma | Il loader rifiuta in fase di caricamento (digest mismatch). L'executor non entra mai nel catalogo. |
| Executor restituisce stdout non-JSON | Il runtime rileva e restituisce {ok: false, error: "non-JSON output"}. |
| Catalog vuoto | Il turno termina pulito con final_kind=error, message "(catalogo vuoto)". |
Il principio: non far cadere il sistema; restituire sempre una risposta strutturata, anche quando è un errore. L'utente capisce, l'LLM nel passo successivo (in multistep) può correggere, gli executor a valle non vedono input strani.
| Funzionalità | Quando entra |
|---|---|
Mode online reale | Quando si configura un provider Anthropic con API key. |
Mode hybrid con regole di escalation reali | Quando si decide il vocabolario delle critical_capabilities e si vuole l'auto-routing. |
| Vaglio probabilistico (LLM-judge) | Quando constitution.html v1.1 esiste come riferimento. |
| Auto-escalation di tier (fast → middle → wise se i passi falliscono) | Quando esistono almeno due tier diversi configurati e si ha un caso d'uso che lo motiva. |
| Differenziazione del prompt per tier (fast/middle/wise) anche con stesso modello | Quando il caso d'uso richiede più punti di vista sulla stessa situazione. |
| Parallel-tool-call intra-turno | Quando un caso reale dimostra latency win significativo. Vincolo binding v1.2. |
| Async approval (esecuzione differita con grant per_target durevoli) | Quando channel + scheduler richiedono interazione asincrona. |
| Mnestoma history-driven nel pre-filter (boost dalla storia) | Quando il mnestoma operativo esiste. |
| Embedding (MiniLM locale) nel pre-filter | Quando il bag-of-words mostra limiti pratici (non emersi nel POC). |
| Replay automatico turni orfani da crash | Quando gli executor saranno garantiti idempotenti. |
| Rotazione automatica JSONL | Quando il volume diventa rilevante. |
Questo documento descrive cosa il pianificatore fa oggi, validato da test. Le sezioni "rimandato a v1.2" descrivono cosa ci aspettiamo, non promesse. Quando un'estensione passa, questo doc verrà aggiornato esattamente come il POC v1.1 ha aggiornato la versione precedente: piuttosto che speculare su come sarà, scriveremo cosa funziona.