Come Metnos riceve una richiesta, decide cosa fare, esegue e risponde. Il runtime dell'agente è il cuore che orchestra ogni turno: estrae l'intento, instrada la richiesta al motore cognitivo, esegue gli step e compone la risposta, con telemetria fine per ogni sotto-fase (intent_ms, prefilter_ms, vaglio_ms, exec_ms, rerank_ms). Riferimento di implementazione: runtime/agent_runtime.py.
← Indice documentazione Microprogettazione › agent_runtime

Metnos

agent_runtime — il runtime turno per turno
Microprogettazione — il runtime dell'agente.
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. I tier di LLM: fast, middle, wise (locali) + frontier
  5. Forma del pipeline: invariante E+ (F | A)?
  6. Auto-remediation registry
  7. Il pre-filter: scegliere il sotto-catalogo
  8. Tool-use nativo (no JSON parsing)
  9. Data piping fra passi: from_step e {{stepN.field}}
  10. Scratchpad per observation grandi
  11. Vaglio del piano
  12. Cap di sicurezza e guard runtime
  13. Cosa scrive nei log
  14. Quando le cose vanno male
  15. Cosa è rimandato

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 read_files"; 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 read_files, write_files, get_urls).
  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 read_files con paths=["~/notes/diario.md"] (leggendo le ultime 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à get_urls + write_files + 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.

Spesso il pianificatore non viene nemmeno scomodato. Prima di chiamare il modello, il runtime attraversa il fast-path introvertivo: tre livelli di memoizzazione che riconoscono le richieste già viste e rieseguono la sequenza giusta in pochi millisecondi. Solo quando tutti e tre i livelli mancano, il pianificatore Qwen 3.6 35B-A3B entra nel turno. La struttura descritta qui sotto si applica integralmente a quel caso; il caso veloce e' descritto nella pagina dedicata.

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.
Il loop è l'ultimo strato, non il primo. Prima di arrivare al ciclo descritto qui, la richiesta attraversa la cascata del motore cognitivo: una scorciatoia approvata (fastpath) o una skill già appresa (autopath) possono rispondere in pochi millisecondi senza alcuna chiamata al modello. Il ciclo passo-passo descritto in questa sezione è lo strato più profondo (l'engine): si attiva solo quando né le scorciatoie né la memoria riconoscono la richiesta. In quel caso il motore propone l'intero piano in una sola chiamata e l'esecuzione che segue è quella deterministica descritta qui sotto.

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 from_step: se gli args contengono from_step: N (riferimento a una lista prodotta da uno step precedente), il runtime recupera la lista dallo scratchpad e la inietta come entries (vedi cap. 7).
    2. Resolve references: se gli args contengono {{stepN.field}} (per arg non-lista, es. content, dst_template), li sostituisce col valore reale (vedi cap. 7).
    3. Validate: gli args rispettano il JSON Schema dichiarato nel manifest dell'executor?
    4. Sandbox check: il path/host richiesto è dentro lo scope dichiarato nell'hint dell'executor?
    5. Vaglio: il valutatore costituzionale dà il via libera sull'uso specifico dell'executor? Le firme degli executor sono già state verificate al load (fase 0); il vaglio non ri-verifica componenti, controlla la singola chiamata: questa delete_files(paths=...), con questi args, in questo contesto, è ammessa? Due fasi: guardia binaria (tocca path forbidden? Viola una delle 4 Leggi?) e giudice graduato (allineamento ai telos). Vedi vaglio.html.
    6. 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 get_urls(urls=["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-...".

Nessuna pre-codifica per "estrai uuid": il LLM locale ragiona sul JSON ed 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 (llama-server)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.

Il mode predefinito è local: tutto gira sul modello locale, a costo zero e con privacy massima. Il mode online e hybrid instradano i task più complessi verso un modello frontier; richiedono di configurare un provider online e le regole di routing in [runtime.hybrid].

4. I tier di LLM: fast, middle, wise (locali) + frontier

Indipendentemente dal mode (che dice dove gira l'LLM), il runtime espone quattro tier. I tre tier locali puntano tutti allo stesso modello (Qwen 3.6 35B‑A3B su llama-server:8080, con MTP self-speculative): la differenza non è il modello ma i parametri per chiamata (ragionamento attivo o no, budget di token). Il quarto tier, frontier, è un modello del cloud, opt-in.

TierCaratteristicaModello
fastRisposte dirette, ragionamento disattivato, budget di token basso.Qwen 3.6 35B‑A3B locale, think=false
middleRagionamento intermedio. È il tier dell'intent extractor e del vaglio.Qwen 3.6 35B‑A3B locale
wiseMassima riflessione locale, ragionamento attivo, budget di token alto. È il tier che propone i piani.Qwen 3.6 35B‑A3B locale, think=true
frontierModello del cloud, opt-in, per i casi che lo valgono. Accetta latenza e costo.Anthropic Opus 4.7 (online)

Il proposer del motore usa wise. L'intent extractor e il vaglio usano middle. I riempitivi al volo (${FILLER}) usano fast. Il frontier si invoca solo esplicitamente, come ultima risorsa. La verità canonica dei tier è in runtime/llm_router.py.

I tier, lo stesso runtime fast & furious Qwen 3.6 35B-A3B · think=false ~700ms/chiamata sempre LOCALE sempre disponibile usa: pianificatore summarizer scratchpad middle & trustable Qwen 3.6 35B-A3B (locale) ~2-5s/chiamata locale o online ragionamento intermedio usa: vaglio reale synt.compose slow & wise Qwen 3.6 35B-A3B · think=true 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 con un solo modello. I tre tier locali puntano per progetto allo stesso modello (Qwen 3.6 35B‑A3B). A cambiare sono i parametri per chiamata: fast chiede azione diretta senza ragionamento, middle un ragionamento intermedio, wise attiva think=true con budget di token alto. Stesso modello, ruoli diversi, output diversi. L'utente ottiene più punti di vista da un solo cervello.

Schema config minimale:

[runtime.llm.fast]
provider = "llamacpp"
model = "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf"
think = false

[runtime.llm.middle]
provider = "llamacpp"
model = "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf"

[runtime.llm.wise]
provider = "llamacpp"
model = "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf"
think = true

[runtime.llm.frontier]
provider = "anthropic"
model = "claude-opus-4-7"

I tre tier locali condividono per progetto lo stesso modello e lo stesso backend: la differenziazione è nei parametri per chiamata (ragionamento, budget di token) e nel system prompt specifico di ogni ruolo. Il tier frontier è l'unico che esce dalla macchina, e solo se invocato esplicitamente.

4-bis. Forma del pipeline: invariante E+ (F | A)?

Il pianificatore puo' emettere step in ordine illegale rispetto al data-flow. Per evitare fallimenti silenziosi (un consumer senza dati upstream, un'azione senza target, uno step dopo un terminatore), il runtime impone una regola di forma universale, deterministica, derivata dal vocabolario chiuso §2.2. Ogni turno deve matchare la regex:

E+ (F | A)? seguita opzionalmente da final_answer (sempre lecito)

Tre categorie, classificate dal verb prefix (mai dal nome dell'executor):

CatVerbiRuolo
Eread, find, list, get, filter, sort, group, classify, compute, compare, extractEmette entries riusabili a valle (producer-out).
Fdescribe, renderPresentazione user-facing. Chiude il pipeline.
Amove, delete, send, share, write, set, create, change, order, compressMutazione di stato, output = metadata di esito. Chiude il pipeline.

I cinque system pseudo-verbi (final_answer, undo_last_turn, request_new_executor, admin, request_disambiguation_from_user) bypassano la FSM: sono meta-operazioni del runtime, fuori dal data-flow.

FSM deterministico a tre stati implementato in runtime/pipeline_shape.py:

 E E F | A
START ─────► CHAIN ─────► CHAIN ─────► TERMINAL
 │ │ │
 │ F | A │ │ *
 ▼ ▼ ▼
ERROR VALID ERROR
(no source) (post-F|A) (post-terminator)

API minimale: compute_state(history) → str ricostruisce lo stato accumulato; next_state(state, name, args) → (new_state, error_class) simula la prossima transizione. La funzione has_literal_source(args) riconosce from_step o qualunque list-arg non vuoto come producer implicito, per cui delete_files(paths=["/x"]) e' una pipeline valida 1-step (literal e' E implicito).

Tre transizioni illegali, tre error_class canonici:

ErroreCausaStrategia
needs_data_sourceF o consumer-E senza sorgente upstreamauto-recoverable: cascade intent.object → OBJECT_PRIMARY_TOOLS → find_urls
needs_action_targetA senza target (no from_step, no literal)fail-fast: dialog get_inputs, MAI cascade esterna (un target di mutazione non si inventa)
pipeline_already_closedstep dopo terminatore F/Aforce final_answer immediato, log warning

Il check vive in un solo punto in agent_runtime.py, subito dopo che il planner ha emesso chosen_name. La FSM e' safety net: il prompt del planner (regola 0-PRE in _core.j2) insegna gia' il pattern upstream, cosi' la remediation runtime resta raramente attivata.

4-ter. Auto-remediation registry

La FSM segnala violazioni con error_class canonici; un registry centralizzato in runtime/auto_remediation.py li mappa a piani di rimedio. Stesso pattern di install_on_demand per binari mancanti, generalizzato a qualunque prerequisito sintetizzabile al volo.

@dataclass(frozen=True)
class RemediationPlan:
 prereq_tool: Any # str | Callable[[obs], str] ← dinamico
 hint_field: Optional[str] # None = passa intera obs
 arg_builder: Callable[[Any], dict]
 merge_field: str = "entries"
 merge_source: str = "entries"
 skip_retry: bool = False # fail-fast (dialog get_inputs)

Registry attuale, append-only:

error_classPrereqHint / Strategy
needs_content_fetchread_urls_htmlfetch top-5 URL da hint needs_urls_html, retry executor originale con entries arricchite
needs_data_sourcechooser dinamicocascade intent.object → OBJECT_PRIMARY_TOOLS[obj][0] → find_urls; retry con entries
needs_action_targetget_inputsdialog free_text con verb + object; skip_retry=True, turno termina via needs_inputs orchestrator

Estendere il pattern e' una riga in REMEDIATIONS. Esempi futuri possibili: needs_ocrchange_files_ocr; needs_embeddingcreate_<dom>_indices; needs_voicemailread_messages_voice.

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 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 get_urls: web, http, url, fetch, scarica, leggi, pagina, api, rest. Match: scarica. Score: 2.

Match con affinity di read_files: read, leggi, lettura, file,... Nessun match. Score: 0.

get_urls 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: pre-filter sub-millisecondo fino a 300+ executor; il LLM locale sceglie correttamente anche con K grandi; la latenza cresce linearmente con K oltre 40. Sweet spot pratico: K=20-40.

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; llama-server e altri server compatibili OpenAI per modelli locali tipo 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": "read_files",
 "arguments": {"paths": ["/tmp/note.txt"]}
 }
 }
 ]
}

È 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: from_step e {{stepN.field}}

In multistep, l'LLM al passo N+1 ha bisogno di riferirsi all'output del passo N. Due meccanismi distinti, scelti in base al tipo di dato.

7.1 Liste: from_step: N

Per gli arg che ricevono una lista di entries (mail, file, web_results, ecc.) prodotta da uno step precedente, il runtime espone un arg dedicato from_step: integer. L'LLM passa il numero dello step che ha prodotto la lista; il runtime recupera dallo scratchpad e inietta automaticamente entries nei kwargs prima di invocare l'executor.

Esempio: pipeline classify+filter+describe
// Passo 1
{ "tool": "read_messages", "args": { "account": "knowcastle", "time_window": "today" } }
// observation handle: {ok: true, scratchpad_id, count: 12, list_field: "entries", schema: [...]}

// Passo 2 (proposto dal LLM)
{ "tool": "classify_entries", "args": {
 "from_step": 1,
 "dimension": "relevance",
 "pre_filter": true
} }

// Il runtime recupera entries da scratchpad[step1] e invoca:
classify_entries(entries=[12 mail], dimension="relevance", pre_filter=true)

Lo schema dei tool che ricevono liste dichiara solo from_step: integer required. entries: array non è più esposto al LLM: il decoder schema-guidato emette un intero, non un array di dict, e il problema dell'invenzione di dati inline (LLM che fabricca dict plausibili invece di consultare lo scratchpad) sparisce per costruzione.

7.2 Valori singoli: {{stepN.field}}

Per gli arg di tipo NON-lista (stringhe, content, path), la sintassi rimane il placeholder template:

Esempio: scarica e salva
// Passo 1
{ "tool": "get_urls", "args": { "urls": ["https://httpbin.org/get"] } }
// observation: {ok: true, content: "", metadata: {...}}

// Passo 2 (proposto dal LLM)
{ "tool": "write_files", "args": {
 "path": "/tmp/out.txt",
 "content": "{{step1.content}}"
} }

// Il runtime sostituisce e invoca:
write_files(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).

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: lo 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, decide se è lecita. In multistep gira fra uno step e l'altro; in single-shot gira post-hoc sull'intero piano. Dal 27/4 il vaglio e' reale (non piu' stub) e funziona in due fasi distinte come da cap. 11 dell'Architettura.

9.1 Guardia (binaria)

Blocca le violazioni delle 4 Leggi. In le regole codificate sono:

La lista non si rilassa per livello di autonomia: e' il "nucleo non negoziabile" del cap. 5. Se la guardia ferma, niente score: il verdetto e' blocked_by="guard".

9.2 Giudice (graduato)

Se la guardia lascia passare, il giudice misura l'allineamento dell'azione ai telos dell'utente in [0, 1]. Sotto la soglia METNOS_JUDGE_THRESHOLD (default 0.30, configurabile via env) l'azione e' negata con blocked_by="judge". Sopra, e' approvata.

In il giudice e' rule-based: heuristiche locali, microsecondi, costo zero. Score base 0.7, bonus se l'intent menziona il nome dell'executor (segnale di intento esplicito), penalita' per .. in path (possibile path traversal), penalita' per chiavi args con caratteri non alfanumerici (anomalia). Il giudice LLM (tier middle, contesto separato dal proponente per evitare auto-conferma) e' rimandato: richiede tier middle configurato + budget esplicito. La separazione deontologia/teleologia e' gia' al posto giusto, l'implementazione del giudice puo' evolvere senza toccare la guardia.

Il Verdict esposto dal modulo vaglio contiene {approved, reason, score, blocked_by, judge_kind, ts}. La log JSONL su ~/.local/share/metnos/vaglio/YYYY-MM.jsonl riporta solo le chiavi di args (non i valori), per privacy.

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.30
cap_same_executorLimite di chiamate dello stesso executor nel turno.10 (2 per gli executor vettoriali)
guard duplicate readSe l'LLM richiama read_files/write_files/get_urls 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 evita uno spreco comune: senza, l'LLM tende 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 (con uso normale ~3 MB/mese, trascurabile).

11.1 Scrittura nel mnestoma

In parallelo al log JSONL, il pianificatore aggiorna il mnestoma (SQLite singolo file). Due hook semplici, attivati solo quando esiste piping osservato fra step:

Senza piping niente mnest: la chiamata isolata di un singolo executor non rappresenta un «passaggio fra A e B» nel senso del cap. 2 di mnest. La scrittura e' fail-safe: un errore sul mnestoma viene loggato (in modo verbose) ma non interrompe il turno.

11.1.1 Synt-on-the-fly: suggerimento immediato al pianificatore

Quando un proto-mnest viene appena registrato (caso di sopra: tool inesistente con piping da step precedente), il pianificatore prova subito una sintesi reattiva compose-only chiamando Synt.react(req) con router=None (il modo generate resta riservato allo scheduler notturno e ai cicli introvertivi del cap. 11.2). L'esito di questa chiamata, se positivo, viene aggiunto all'observation come campo synt:

Costo: una chiamata a Composer.find_chain (BFS sul mnestoma, millisecondi) e un'eventuale lock-check. Niente LLM, niente budget. La chiamata e' fail-safe: qualunque eccezione di synt non propaga e l'observation rimane quella di default ("executor inesistente: X"). Vedi runtime/agent_runtime.py:_try_synt_compose.

11.2 Scheduler builtin

Lo scheduler e' un builtin del runtime, non un executor: non ha capability, non e' firmato, non ha sandbox. E' un loop cron-style che esegue task ricorrenti del sistema senza input utente. Tre supporti di schedule:

Stato persistito in workspace/.scheduler/state.sqlite (tabella tasks con last_run_at + last_status, tabella runs append-only). Il check di due-time e' idempotente: due tick nello stesso slot non eseguono il task due volte.

Task built-in registrati di default (definiti in runtime/scheduler_v2/builtin_callbacks.py::_BUILTIN_JOBS, auto-installati al primo boot del daemon HTTP via install_default_jobs(scheduler)):

NomeScheduleCosa fa
i18n_translate_pendingdaily@02:00Traduce 20 chiavi i18n.sqlite con needs_translation=1 (cap throttling GPU). Tier wise default. Vedi multilang.
images_index_refreshdaily@03:00Refresh incrementale indice immagini unificato: walk + stat ~11s, pipeline EXIF + ArcFace + VLM + BGE su nuove/modificate.
apply_executor_agerdaily@03:30Decay degli executor inattivi: activedeprecated dopo 30g di inattivita'; deprecatedarchived dopo altri 14g.
apply_agerdaily@04:00Chiama Mnestoma.apply_ager: decay + demote + proto purge sul mnestoma.
synt_suggestdaily@04:30Per ciascun proto-mnest ricorrente (uses≥3, weight≥0.30) chiama Synt.react in compose-only e logga l'esito.
multi_tool_maintenancedaily@04:30Housekeeping fast-path L2: expire stale (TTL N giorni di attivita' effettiva, default 30) + promote pipelines mature (uses≥K_synth, default 50) a proto-mnest in mnestoma per synth_request.
proposals_eta_aggregatedaily@04:30Aggregator latenze per path_shape: scansiona turn JSONL ultimi 7g, calcola p50/p95 in proposals_eta.sqlite.
promoterdaily@04:45Promoter daemon: valuta + promuove synth proposals via proposal_evaluator (6 killer + 7 signal → verdict).
introvertiva_proposedaily@05:00Cascata introvertiva: produce proposte dedupe / generalize / specialize sul corpus accumulato (no auto-apply, audit JSONL).
introvertiva_applydaily@05:30Auto-apply specialize ad altissima confidenza (dominanza ≥ 0,9, uses ≥ 30, finestra ≤ 14g).
proposals_cleanupdaily@06:00Manutenzione lifecycle backlog: archive aged synt_proposals, dedupe candidates, auto-decay legacy_orphan. NIENTE delete: solo move + UPDATE.
lifecycle_summarydaily@06:30Aggregatore READ-ONLY dei 4 ager: produce un sunto giornaliero del lifecycle del mnestoma.
skill_sandbox_watchdogdaily@06:35Controlla soglia trigger per sandbox per-skill (≥ 5 skill third-party OR ≥ 1 guest paired); notifica admin via Telegram.
promoter_digestdaily@07:00Digest Telegram delle proposte in promoted_grace non ancora approvate dall'utente.
manutenzione GitHubrun_user_queryLa manutenzione GitHub gira tramite gli executor canonici (find/read/write_issues) guidati da comandi utente schedulati; il watcher sempre-attivo è stato ritirato.
Lifecycle delle schedule entries. Le righe vivono in ~/.local/state/metnos/scheduler_v2.sqlite (tabella schedule_entries). Tre garanzie:

Il loop daemon (scheduler daemon) e' un singolo processo che fa tick ogni 60s e va in idle. Niente concorrenza, niente locking fra processi: lo scheduler in e' un singleton sul metnos-server. La policy di errore e' "non far cadere il loop": ogni eccezione del task viene catturata, segnata in last_status='error' con traceback in last_output, e il tick continua sui task successivi.

Il design dello scheduler come builtin e' coerente con la decisione del Dialogo sugli executor: la manutenzione del sistema (decadimento, sintesi notturna) e' parte del runtime, non un executor che il sistema "decide di chiamare". Vedi anche la memoria builtin executors proposals per la triade builtin futura (scheduler, ager, snapshot).

11.2.1 Recurring tasks utente

Sopra lo scheduler builtin gira runtime/recurring_tasks.py: il registro dei task ricorrenti definiti dall'utente via canale conversazionale («ogni cinque minuti verifica le mail importanti»). Sette tool builtin sono callable dal PLANNER: schedule_recurring, cancel_recurring, list_recurring, show_recurring, toggle_recurring, history_recurring, run_now_recurring.

La risoluzione del callback è per chiave string: il task persiste il nome simbolico del callback, il dispatcher lo risolve a runtime contro un registro centralizzato. Niente serializzazione di callable, niente stale references al riavvio del daemon.

Semantica fine: times regola il ciclo di vita del task (1 = one-shot, N = max esecuzioni, NULL = forever) con auto-cancel al raggiungimento; grace_window_minutes abilita il recover-missed con created_at come discriminante (un task creato dopo l'orario schedulato non scatta retroattivamente); is_due applica la logica «done=false && time>scheduled && !new_post_target». Il loop daemon ha re-entrancy lock + timeout cooperativo + try/except per task, ed e' supervisionato da metnos-scheduler.service (systemd user, Restart=always). Predisposizione async-ready (refactor locale non globale): _run_with_timeout standalone, _acquire_lock/_release_lock astratti, dispatch_callback con detection coroutine + bridge sync→async.

11.2.2 Location request UX (§2-quater PLANNER)

Quando get_location torna ok:false in risposta a una query location-relative (es. «la farmacia più vicina»), il PLANNER attiva il tool builtin request_location_from_user: un dialog atomico che chiede la posizione all'utente sul canale e riprende il piano alla ricezione. Il pattern è analogo a §2-ter (undo atomico): un tool che ferma e riprende il turno senza step intermedi che inquinino la storia. -0065 codificano la famiglia di pattern «atomic dialog tool».

11.2.3 Geo provider hybrid

runtime/geo_provider.py introduce la strategia ibrida Google Places primary + Photon fallback: la copertura POI di Google è superiore a OSM nei casi reali osservati (caso live: una farmacia non indicizzata in OSM, presente in Google), ma Photon resta per i casi in cui Google non risponde o la chiave non è configurata. La policy «OSS-first» resta invariata per le altre capacità: per i POI il vantaggio di copertura giustifica l'eccezione.

11.2.4 Multi-user step 1: actor resolver

runtime/actor_resolver.py implementa lo step 1 di (host + guest model): ogni pairing espone un campo actor; nuovi pairing ricevono auto-assign host (al primo) o guest_<id6> (ai successivi). L'actor è propagato end-to-end nel turno (log, scratchpad, vaglio, scheduler) ed è il discriminante delle decisioni per-utente (telos, autonomy, quote di scheduler). Il router di approvazione resta single-channel nello step 1; la separazione canale-per-utente entrerà nello step 2.

11.2.5 i18n DB centralizzato

runtime/i18n.py + il daemon timer runtime/i18n_translator.py centralizzano i testi user-facing e i prompt LLM-targeted in un unico DB SQLite (~/.local/share/metnos/i18n.sqlite). Auto-invalidation per source_hash: un testo cambia → tutte le traduzioni vengono rigenerate. Due template di prompt: uno per testi user-facing (tier middle, batch), uno per prompt LLM-targeted (tier wise, 1-per-call per qualità). CLI admin in runtime/admin/i18n_cli.py. Tutti i moduli runtime caricano le stringhe via i18n.t(code, lang, **kwargs); sostituisce le tabelle locali in runtime/messages.py (che resta per i template interni non user-facing).

11.2.7 Multilinguismo (tre layer + latest-wins)

Il sottosistema multilingua di Metnos vive su tre layer distinti: (1) prompt LLM in runtime/prompts/<lang>/<role>.j2 (MiniJinja, 26 ruoli, ); (2) description executor nei manifest TOML (tabella [description].<lang> + companion manifest.lang_state.json); (3) messaggi user-facing in i18n.sqlite (118 chiavi standard + 79 description migrate). La disciplina di allineamento e' latest-wins: nessuna lingua è canonica per costruzione, vince chi viene editato per ultimo (version_hash + source_hash per risorsa). Il daemon notturno i18n_translator.run_loop rigenera candidati in _pending/; metnos-prompts review mostra il diff, mark-synced promuove. Aggiungere una nuova lingua è un comando: metnos-prompts add-language fr. Vedi documento canonico multilang.

11.2.6 config.py + logging_setup centralizzati

runtime/config.py espone 24 costanti tunable e 11 path con override via env (METNOS_INSTALL_ROOT, METNOS_USER_DATA, METNOS_LOG_LEVEL, ecc.). runtime/logging_setup.py configura un logger root metnos.* con stdout + file rotating. Bonifica accompagnata: 54 occorrenze di except: pass sostituite da log.warning + pass in 17 moduli. Resta nel TODO la migrazione dei 19 path legacy e 9 costanti residue verso config.C.

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
llama-server 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

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 la constitution 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.
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» descrivono cosa ci aspettiamo, non promesse. Quando un'estensione passa, questo doc verrà aggiornato: piuttosto che speculare su come sarà, scriviamo cosa funziona.

run_turn — cascata a quattro strati L0 fast-path hash + coseno L1 autopath cluster BGE-M3 L2 validator ri-proposta 1× L3 engine proponi+esegui+recupera < 5 ms match cluster guardia pre-engine loop ReAct completo risolvi e fermati il primo strato che risolve vince

I quattro strati, dall'alto:

  1. L0 — fast-path: match esatto su hash (< 5 ms) piu' una ricerca per coseno BGE-M3. Approvato dalla chat col bottone «approva fast-path»; vince su autopath.
  2. L1 — autopath: match semantico di cluster (BGE-M3) indicizzato sull'hash di intento, con punteggio champion/challenger e TTL di 30 giorni sugli anti-skill.
  3. L2 — validator: una guardia che puo' ri-proporre una volta prima che giri l'engine (attiva di default).
  4. L3 — engine: il loop ReAct completo — proposer, executor, recovery, terminator. Il selettore METNOS_ENGINE sceglie simple|metis|frontier.