TESTED Microprogettazione v1.1 — testata il 26 aprile 2026. La componente è stata realizzata e testata a livello di modulo e di cluster nel POC v1.1 (test framework: cluster agent_runtime 109/109 verde, totale sistema 135/135). Riferimento di implementazione: /opt/myclaw/runtime/agent_runtime.py.
Stato nella sequenza dei microdesign: under approvalapprovedtestedimplemented (quando l'intero sistema sarà pronto in produzione). Sostituisce la versione 1.0 del 24/4/2026, ormai obsoleta.
← Indice documentazione Microprogettazione › agent_runtime

Metnos

agent_runtime — il pianificatore turno per turno
Microprogettazione v1.1 — stato TESTED (26 aprile 2026)
Validato dal POC v1.1 con test framework SQLite a 3 livelli
Pubblico: chi legge per capire come funziona Metnos dentro;
chi implementa o estende un componente del runtime.

Lettura: 18 minuti.

Indice

  1. Cosa fa: una storia di una richiesta
  2. Il loop di un turno, passo per passo
  3. Modi: local, online, hybrid
  4. Tre tier di LLM: fast, middle, wise
  5. Il pre-filter: scegliere il sotto-catalogo
  6. Tool-use nativo (no JSON parsing)
  7. Data piping fra passi: {{stepN.field}}
  8. Scratchpad per observation grandi
  9. Vaglio del piano
  10. Cap di sicurezza e guard runtime
  11. Cosa scrive nei log
  12. Quando le cose vanno male
  13. Cosa è rimandato a v1.2+

1. Cosa fa: una storia di una richiesta

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:

  1. Il pianificatore guarda il catalogo degli executor disponibili (programmi piccoli e firmati che sanno fare una cosa sola: leggere un file, scriverlo, fare una chiamata HTTP, leggere l'orologio, ecc.).
  2. Sceglie un sotto-insieme rilevante per la richiesta (qui i candidati saranno fs_read, fs_write, web_fetch).
  3. Passa quei candidati a un LLM (un modello di linguaggio) come "tools" che può usare.
  4. L'LLM legge la richiesta e propone il prossimo passo: "chiama fs_read con path=~/notes/diario.md e tail_bytes=N (N abbastanza grande da contenere 3 righe)".
  5. Il pianificatore valida la proposta (sandbox, args, vaglio costituzionale), invoca l'executor, raccoglie l'output.
  6. Passa di nuovo all'LLM con: la richiesta originale + l'output del passo precedente.
  7. L'LLM dice: "ho tutto, la risposta è: ecco le ultime tre righe del tuo diario".
  8. Il pianificatore consegna quella risposta all'utente, scrive il log, finisce il turno.

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.

Cosa non fa il pianificatore. Non risolve mai direttamente le richieste degli utenti. Non legge file, non chiama URL, non scrive nulla. Tutto questo lo fanno gli executor (programmi specializzati). Il pianificatore decide solo chi chiamare e con quali argomenti, poi raccoglie i risultati.

2. Il loop di un turno, passo per passo

Ora vediamo lo stesso percorso in modo più preciso. Un turno ha sempre questa struttura:

User query Loader → Catalog Mode + Tier Pre-filter top-K LLMtool-use nativo resolve refs + validatesandbox + vaglio + guard execute (subprocess) observation→ scratchpad se >4KB history update cicla fino a final_answer final_answertesto per l'utente cap raggiunto / errorefinal_kind = error / cap_* JSONL log del turno~/.local/share/metnos/turns/YYYY-MM-DD.jsonl
Flusso di un turno: dalla query utente al log finale, con il ciclo multistep al centro.

Fase 0 — preparazione

Fase 1 — pre-filter

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).

Fase 2 — ciclo dei passi

A questo punto il turno entra in un ciclo. Per ogni passo:

  1. Si chiama l'LLM passando: la query utente, il sotto-catalogo (come "tools" nativi), e la storia delle observation dei passi precedenti.
  2. L'LLM risponde in uno di due modi:
  3. Se è un tool_call, il pianificatore ne fa una serie di controlli:
    1. Resolve references: se gli args contengono {{stepN.field}}, li sostituisce col valore reale (vedi cap. 7).
    2. Validate: gli args rispettano il JSON Schema dichiarato nel manifest dell'executor?
    3. Sandbox check: il path/host richiesto è dentro lo scope dichiarato nell'hint dell'executor?
    4. Vaglio: il valutatore costituzionale dà il via libera?
    5. Guard duplicate read: stiamo rileggendo lo stesso path/url di un passo precedente? Se sí, intercetta e suggerisci all'LLM di formulare la final_answer.
  4. Se tutti i controlli passano, l'executor viene eseguito come subprocess. L'observation (un JSON con {ok, content?, metadata?, error?}) torna al pianificatore.
  5. Se l'observation è più grande di una soglia (4 KB di JSON), viene salvata in scratchpad e nella history dell'LLM ci va una versione sintetica con id + summary (vedi cap. 8).
  6. La history del turno cresce di un passo. Il ciclo riprende dal punto 1.

Fase 3 — chiusura del turno

Il turno termina in uno dei seguenti modi:

In ogni caso, il pianificatore scrive un record JSONL completo del turno (vedi cap. 11) e restituisce (log, final_message).

Esempio reale dal POC

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.

3. Modi: local, online, hybrid

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.

ModeLoopLLM tipicoQuando ha senso
localmultistep ReAct (un viaggio per passo)locale (Ollama)Default. Privacy massima, costo zero, latenza decente.
onlinesingle-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.
hybridlocal di default; escalation a online per task criticimistoEquilibrio 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].

4. Tre tier di LLM: fast, middle, wise

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.

TierCaratteristicaCandidato
fast & furiousPiccolo, veloce, sempre LOCALE, sempre disponibile.qwen3:8b con think=false
middle & trustableCapacità di ragionamento intermedia.gemma3:12b locale, oppure claude-haiku-4-5 online.
slow & wiseMassima 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.

Tre tier, lo stesso runtime fast & furious qwen3:8b · think=false ~700ms/chiamata sempre LOCALE sempre disponibile usa: pianificatore summarizer scratchpad middle & trustable gemma3:12b o haiku-4-5 ~2-5s/chiamata locale o online ragionamento intermedio usa: vaglio reale synt.compose slow & wise claude-sonnet o qwen3:32b+think 5-30s/chiamata accetta latenza/$ riflessione profonda usa: synt.generate decisioni delicate Quando un tier non e' configurato, aliasa al tier inferiore (wise→middle→fast). Caso peggiore: tutti puntano a fast.
I tre tier coesistono nel runtime; ogni componente sceglie quello che gli serve.
Specializzazione anche con un solo modello. Se l'hardware permette un solo LLM locale, i tre tier puntano allo stesso (es. tutti a qwen3:8b). Stesso modello ma il system prompt cambia per tier: fast chiede azione diretta, middle chiede di considerare alternative, wise attiva 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.

5. Il pre-filter: scegliere il sotto-catalogo

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.

Come ranka

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.

Esempio

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 adattivo

K (numero di executor da passare al LLM) non è fisso: dipende dalla confidenza con cui il pre-filter distingue il top-1 dagli altri.

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.

6. Tool-use nativo (no JSON parsing)

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.

Conseguenza per i manifest. Il manifest di un executor dichiara gli args in JSON Schema (sezione [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.

7. Data piping fra passi: {{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}}:

Il runtime intercetta gli args prima di invocare l'executor, scopre i placeholder, recupera il valore reale dalla history del turno, sostituisce.

Esempio: scarica e salva
// 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": ""
} }
La sintassi vale SOLO negli args. Nel testo della final_answer scrivere i valori reali (es. "Ho scritto 173 byte"); MAI {{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").

Da dove viene questa sintassi

È 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.

8. Scratchpad per observation grandi

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 è 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.

9. Vaglio del piano

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.

10. Cap di sicurezza e guard runtime

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.

MeccanismoCosa faDefault
cap_stepsLimite massimo di passi per turno.5
cap_same_executorLimite di chiamate dello stesso executor nel turno.2
guard duplicate readSe 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.

11. Cosa scrive nei log

Per ogni turno il pianificatore scrive una riga JSONL in ~/.local/share/metnos/turns/YYYY-MM-DD.jsonl con:

JSONL append-only, un file per giorno. Niente rotazione automatica in v1.1 (con uso normale ~3 MB/mese, trascurabile).

12. Quando le cose vanno male

Il POC ha verificato cinque modi tipici di rottura. Sono diventati test cases permanenti del framework di test.

Cosa si rompeCosa succede
Ollama non raggiungibileIl 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 firmaIl loader rifiuta in fase di caricamento (digest mismatch). L'executor non entra mai nel catalogo.
Executor restituisce stdout non-JSONIl runtime rileva e restituisce {ok: false, error: "non-JSON output"}.
Catalog vuotoIl 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.

13. Cosa è rimandato a v1.2+

FunzionalitàQuando entra
Mode online realeQuando si configura un provider Anthropic con API key.
Mode hybrid con regole di escalation realiQuando 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 modelloQuando il caso d'uso richiede più punti di vista sulla stessa situazione.
Parallel-tool-call intra-turnoQuando 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-filterQuando il bag-of-words mostra limiti pratici (non emersi nel POC).
Replay automatico turni orfani da crashQuando gli executor saranno garantiti idempotenti.
Rotazione automatica JSONLQuando il volume diventa rilevante.

Note finali

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.