agent_runtime — il runtime turno per turnoE+ (F | A)?from_step e {{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 read_files"; 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à get_urls + write_files + 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. Al boot successivo e ad ogni turno (hot), un check leggero sulla firma di modifica (mtime dei manifest.toml, .py, .sig + DB lifecycle) ritorna il catalogo cached in O(1). Una qualunque modifica (synth-on-the-fly, re-sign manuale, ager demote/archive) invalida la cache automaticamente al turno successivo. Vedi.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:
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).{{stepN.field}} (per arg non-lista, es. content, dst_template), li sostituisce col valore reale (vedi cap. 7).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.{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 = "ask" — serve una scelta o un chiarimento all'utente prima di proseguire.final_kind = "awaiting" — il turno attende un'azione esterna (per esempio la posizione condivisa) prima di completarsi.final_kind = "needs_inputs" — manca un dato richiesto (per esempio una credenziale): si apre un dialogo per raccoglierlo.final_kind = "cap_steps" — superato il limite di passi (default 30).final_kind = "cap_same_executor" — lo stesso executor è stato chiamato troppe volte (default 10; 2 per gli executor vettoriali).final_kind = "loop_break" — rilevato un ciclo (stesso passo ripetuto senza progresso): il turno si interrompe.final_kind = "error" — un componente esterno irraggiungibile (es. il llama-server locale 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 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.
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 (llama-server) | 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.
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].
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.
| Tier | Caratteristica | Modello |
|---|---|---|
| fast | Risposte dirette, ragionamento disattivato, budget di token basso. | Qwen 3.6 35B‑A3B locale, think=false |
| middle | Ragionamento intermedio. È il tier dell'intent extractor e del vaglio. | Qwen 3.6 35B‑A3B locale |
| wise | Massima riflessione locale, ragionamento attivo, budget di token alto. È il tier che propone i piani. | Qwen 3.6 35B‑A3B locale, think=true |
| frontier | Modello 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.
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.
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):
| Cat | Verbi | Ruolo |
|---|---|---|
| E | read, find, list, get, filter, sort, group, classify, compute, compare, extract | Emette entries riusabili a valle (producer-out). |
| F | describe, render | Presentazione user-facing. Chiude il pipeline. |
| A | move, delete, send, share, write, set, create, change, order, compress | Mutazione 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:
| Errore | Causa | Strategia |
|---|---|---|
needs_data_source | F o consumer-E senza sorgente upstream | auto-recoverable: cascade intent.object → OBJECT_PRIMARY_TOOLS → find_urls |
needs_action_target | A senza target (no from_step, no literal) | fail-fast: dialog get_inputs, MAI cascade esterna (un target di mutazione non si inventa) |
pipeline_already_closed | step dopo terminatore F/A | force 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.
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_class | Prereq | Hint / Strategy |
|---|---|---|
needs_content_fetch | read_urls_html | fetch top-5 URL da hint needs_urls_html, retry executor originale con entries arricchite |
needs_data_source | chooser dinamico | cascade intent.object → OBJECT_PRIMARY_TOOLS[obj][0] → find_urls; retry con entries |
needs_action_target | get_inputs | dialog 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_ocr → change_files_ocr; needs_embedding → create_<dom>_indices; needs_voicemail → read_messages_voice.
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 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 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 (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: 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.
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.
[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.
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.
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.
// 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.
{{stepN.field}}Per gli arg di tipo NON-lista (stringhe, content, path), la sintassi rimane il placeholder template:
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}}.// 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="")
{{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).
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 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, 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.
Blocca le violazioni delle 4 Leggi. In le regole codificate sono:
~/.ssh,
/etc/passwd|shadow|sudoers, /root,
/boot, /sys, /proc,
/dev/sd*|nvme*, ~/.aws/credentials,
~/.config/*/credentials.env, ~/.gnupg.
Se anche solo MENZIONATI in un argomento di tool, l'azione e' negata.rm -rf /, rm -rf ~,
mkfs, dd of=/dev/..., fork bomb, chmod 7XX
ricorsivo sulla radice. Match solo per executor con capability:
code:exec.
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".
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.
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. | 30 |
cap_same_executor | Limite di chiamate dello stesso executor nel turno. | 10 (2 per gli executor vettoriali) |
| guard duplicate read | Se 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.
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 | ask | awaiting | needs_inputs | cap_steps | cap_same_executor | loop_break | error.JSONL append-only, un file per giorno. Niente rotazione automatica in (con uso normale ~3 MB/mese, trascurabile).
In parallelo al log JSONL, il pianificatore aggiorna il mnestoma (SQLite singolo file). Due hook semplici, attivati solo quando esiste piping osservato fra step:
obs.ok = true,
se i raw_args contenevano almeno un riferimento {{stepM.field}}
risolto e l'executor M era reale (non un proto, non scratchpad), invoca
Mnestoma.record_passing(src=executor_M, dst=executor_corrente, dst_exists=True).
Il mnest cresce o nasce con peso bootstrap; ogni passaggio futuro lo rinforza.executor_inesistente) e i raw_args
avevano riferimenti a step precedenti, invoca
record_passing(src=executor_M, dst=nome_desiderato, dst_exists=False,
desired_signature=...). La firma desiderata è inferita
in modo conservativo da nome del tool richiesto, args e contesto del turno
(build_desired_signature).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.
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:
state == "composed": il composer ha trovato una catena
di executor firmati che chiude il proto-mnest. L'observation porta
{strategy: "compose", state: "composed", chain: [...], first_hop: "X",
suggestion: "Riprova invocando 'X' come prossimo passo"}.
Il pianificatore non rilancia automaticamente il primo hop: lascia che
sia il LLM a decidere se seguire il suggerimento allo step successivo
(mantiene la disciplina ReAct — il LLM resta padrone della
sequenza).state == "abandoned" o "rejected":
l'observation porta {state: "abandoned",
suggestion: "Non c'e' executor disponibile per questa esigenza,
cerca un'altra via"}. Telos di non-rinuncia: il pianificatore
non si arrende sul primo errore, comunica al LLM che la strada e'
chiusa cosi' che possa cercarne un'altra.
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.
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:
daily@HH:MM — ogni giorno all'ora HH:MM (UTC), una sola volta.every_N_minutes — ogni N minuti dall'ultima esecuzione (o subito se mai).manual — solo via scheduler run-now <task>.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)):
| Nome | Schedule | Cosa fa |
|---|---|---|
i18n_translate_pending | daily@02:00 | Traduce 20 chiavi i18n.sqlite con needs_translation=1 (cap throttling GPU). Tier wise default. Vedi multilang. |
images_index_refresh | daily@03:00 | Refresh incrementale indice immagini unificato: walk + stat ~11s, pipeline EXIF + ArcFace + VLM + BGE su nuove/modificate. |
apply_executor_ager | daily@03:30 | Decay degli executor inattivi: active → deprecated dopo 30g di inattivita'; deprecated → archived dopo altri 14g. |
apply_ager | daily@04:00 | Chiama Mnestoma.apply_ager: decay + demote + proto purge sul mnestoma. |
synt_suggest | daily@04:30 | Per ciascun proto-mnest ricorrente (uses≥3, weight≥0.30) chiama Synt.react in compose-only e logga l'esito. |
multi_tool_maintenance | daily@04:30 | Housekeeping 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_aggregate | daily@04:30 | Aggregator latenze per path_shape: scansiona turn JSONL ultimi 7g, calcola p50/p95 in proposals_eta.sqlite. |
promoter | daily@04:45 | Promoter daemon: valuta + promuove synth proposals via proposal_evaluator (6 killer + 7 signal → verdict). |
introvertiva_propose | daily@05:00 | Cascata introvertiva: produce proposte dedupe / generalize / specialize sul corpus accumulato (no auto-apply, audit JSONL). |
introvertiva_apply | daily@05:30 | Auto-apply specialize ad altissima confidenza (dominanza ≥ 0,9, uses ≥ 30, finestra ≤ 14g). |
proposals_cleanup | daily@06:00 | Manutenzione lifecycle backlog: archive aged synt_proposals, dedupe candidates, auto-decay legacy_orphan. NIENTE delete: solo move + UPDATE. |
lifecycle_summary | daily@06:30 | Aggregatore READ-ONLY dei 4 ager: produce un sunto giornaliero del lifecycle del mnestoma. |
skill_sandbox_watchdog | daily@06:35 | Controlla soglia trigger per sandbox per-skill (≥ 5 skill third-party OR ≥ 1 guest paired); notifica admin via Telegram. |
promoter_digest | daily@07:00 | Digest Telegram delle proposte in promoted_grace non ancora approvate dall'utente. |
| manutenzione GitHub | run_user_query | La manutenzione GitHub gira tramite gli executor canonici (find/read/write_issues) guidati da comandi utente schedulati; il watcher sempre-attivo è stato ritirato. |
~/.local/state/metnos/scheduler_v2.sqlite (tabella
schedule_entries). Tre garanzie:
install_default_jobs e' chiamato
al primo avvio del daemon HTTP (metnos_http_server.py:131) e
inserisce con INSERT-OR-IGNORE i job di _BUILTIN_JOBS —
righe esistenti preservate (mantiene last_run_at,
total_runs...).next_fire_at, fa partire il loop. Eventi mancati durante
il downtime vengono ignorati (next valid window): niente catch-up
caotico.scheduler.stop(timeout=5s) imposta
shutdown_evt, attende che il task corrente termini, poi
pool.shutdown(cancel_futures=True). Job in-flight oltre il
timeout vengono cancellati graziosamente.
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).
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.
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».
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.
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.
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).
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.
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.
Il POC ha verificato cinque modi tipici di rottura. Sono diventati test cases permanenti del framework di test.
| Cosa si rompe | Cosa succede |
|---|---|
| llama-server 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 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 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. |
| 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» descrivono cosa ci aspettiamo, non promesse. Quando un'estensione passa, questo doc verrà aggiornato: piuttosto che speculare su come sarà, scriviamo cosa funziona.
I quattro strati, dall'alto:
METNOS_ENGINE sceglie simple|metis|frontier.