synt — come nasce e matura il pool degli executorhandle_synth_request) verifica
prima se l'intento è già coperto dal catalogo o re-indirizzabile
a un executor canonico (es. list_processes →
get_processes), risparmiando l'intera pipeline. Un garbage
collector sposta in un'area temporanea gli executor sintetizzati che
collidono con quelli scritti a mano, senza cancellare.
Questo documento definisce cosa fa synt: il processo che, di fronte a una richiesta utente o a un pattern emerso nel mnestoma, fa crescere e maturare il pool degli executor di Metnos. Non è un singolo loop: è una cascata di strategie ordinate per costo crescente, che onora il telos «coltivare gli strumenti» senza sprecare chiamate frontier e senza inquinare la libreria.
Il documento copre:
Non copre, e demanda altrove:
approval_ux.html (da riscrivere);policy.html (da riscrivere).synt è il processo (non un agente, non un oggetto) che ha un solo compito: far esistere ciò che il pool non sa ancora fare. Non scrive da zero ogni volta, non scrive solo da zero, e a volte non scrive affatto. La sua intelligenza sta nel sapere quando applicare quale strategia.
synt entra in azione in due modi distinti:
| Modo | Innesco | Tempo |
|---|---|---|
| Reattivo | Il gateway, durante un turno utente, registra un proto-mnest verso un executor che non esiste; oppure il pianificatore non trova nessun executor che soddisfi la richiesta. | Sincrono al turno (l'utente sta aspettando una risposta). |
| Introvertivo | L'ager nottetempo scorre il mnestoma e il pool firmato; trova proto-mnest ricorrenti, executor con tracce sovrapposte, famiglie di specializzati con stessa forma. | Asincrono, in omeostasi (notte, pause, basso carico). |
Le due modalità condividono la stessa cascata di strategie ma si differenziano nei tempi (sincrono vs asincrono) e nel tono (rispondere vs proporre).
multi_tool_promote legge le pipeline memoizzate
nel fast-path L2 e, quando una sequenza
supera i 50 utilizzi, crea un proto-mnest in mnestoma con la firma
desiderata (catena di executor + segnaposti + canonical query). Da
quel momento la pipeline diventa candidata alla sintesi vera e
propria: un nuovo executor unificato che incorpora tutta la sequenza,
chiamato di solito <verbo_ultimo>_<oggetto_primo>.
Il bridge non duplica lavoro: L2 cattura il volume basso (3–50
utilizzi, replay deterministico), L3 prende il sopravvento quando il
pattern è stabilmente ricorrente.
| # | Strategia | Tempo | Cosa fa, in una riga | Costo frontier |
|---|---|---|---|---|
| 1 | Comporre | reattivo | Cerca una catena di executor attivi che chiude il proto-mnest. | 0 |
| 2 | Generare | reattivo | Pipeline multistage a cinque stadi LLM (naming, signature, test, description, code) che produce un nuovo executor firmato (vedi §4.2.3). | €0 (locale) |
| 3 | Fondere | introvertivo | Unisce due executor con tracce sovrapposte e profili compatibili. | ~0.5 € |
| 4 | Generalizzare | introvertivo | Da N specializzati con stessa forma deriva un executor parametrico. | ~1 € |
| 5 | Specializzare | introvertivo | Da uno generale deriva una versione mirata a un caso caldo. | ~0.5 € |
La strategia di base. Quando un proto-mnest indica un buco operativo, synt prima guarda nel pool firmato. La domanda è: esiste una catena A → B → C che, se eseguita in sequenza, copre il bisogno? Se sì, synt non scrive codice nuovo: propone l'orchestrato.
Il mnestoma è un grafo diretto fra executor. Trovare una catena che collega l'input richiesto all'output desiderato è una visita guidata sul grafo. Tre criteri ordinano le candidate:
Quando synt risolve via composizione, registra nel mnestoma un proto-mnest verso la composizione stessa: un nodo virtuale che dice «ho appena usato A→B→C come fosse un singolo executor di nome X». Se questo proto-mnest ricorre, diventa candidato di generalizzazione (cap. 5): un executor unico che incorpora la catena. La composizione è quindi una palestra di generalizzazione: ogni catena ripetuta è un suggerimento per il futuro.
Quando comporre non basta — perché non esiste catena, perché è troppo lunga, perché un anello mancante è cruciale — synt entra nella pipeline multistage a cinque stadi descritta in §4.2.3.
Generare costa: tipicamente 1–2 chiamate al tier wise + il tempo umano di approvazione. Il fatto che venga dopo comporre non è estetica: è risparmio reale.
runtime/llm_router.py) come quality floor: se l'utente non
ha un wise locale di livello, deve configurare un provider esterno (Anthropic,
OpenAI, ecc.). Errore esplicito al boot, mai degradazione silenziosa al fast.
Sintesi end-to-end via tier wise = Qwen 3.6 35B-A3B locale (Q4_K_M
su llama-server). Caso: format_json (formatta una stringa JSON con
indentazione leggibile).
| Stadio | Tempo | Note |
|---|---|---|
| 1 Pattern detect | ~0 (caller) | chi chiama react ha già il proto-mnest in mano. |
| 2+3 Spec + Scheletro | ~36s | una sola chiamata LLM tier=wise via tool-use propose_executor; ~640 token in, ~1700 token out. |
| 4 Profilo | ~ms | derivato da AST + import whitelist; profilo "pure" se nessun I/O esterno. |
| 5 Birth-test livello 2 | ~28s + ~1s/test | seconda chiamata LLM produce 3-5 test dichiarativi; runner esegue ognuno via subprocess. |
| 6 Approval | umano | oggi CLI (synt approve|reject <id>); UX HTML rimandata. |
| 7 Firma + installazione | ~ms | Ed25519 via runtime/sign.py, executor copiato in executors/<name>/. |
Latenza wall totale dello stadio LLM: ~64s per il primo executor non banale. È tempo che il dialog manager comunica all'utente come "sto costruendo un nuovo componente per estendere le mie capacità": una volta firmato, ogni successivo riuso e' pure code execution (~ms).
propose_executor. Il doc canonico li tiene separati perché
in futuro introdurremo la UX di amend tra Specifica e Scheletro: Roberto
potrà rivedere/correggere la spec prima della generazione del codice. Il
tool unificato del POC e' una semplificazione esplicita, non una rinuncia al
disegno.
Ogni famiglia di modelli ha sue idiosincrasie scoperte solo con l'uso. Synt
mantiene un repertorio di addendum di prompt applicati automaticamente
agli stadi 2/3 quando il chiamante segnala for_code=True. Tre
regole di stile, ratificate dopo i primi confronti reali:
Il repertorio vive in runtime/prompts.toml (default bundled) ed
e' sovrascrivibile da ~/.config/metnos/prompts.toml (override
utente). Schema TOML:
[[hint]]
provider = "anthropic"
model_pattern = "claude-*" # glob su prov.model
use_case = "code_gen"
text = "\\n\\nVincoli: codice fedele alla spec. Regex semplice. Niente lookbehind/lookahead."
[[hint]]
provider = "llamacpp"
model_pattern = "qwen*"
use_case = "code_gen"
text = "\\n\\nVincoli: raw string r'...' con UN backslash. Niente triple-quote docstring."
[[hint]]
provider = "openai"
model_pattern = "gpt-*"
use_case = "code_gen"
text = "\\n\\nVincoli: compila python_code per intero (def invoke + def main). Mai vuoto."
Il primo match per (provider, model_pattern, use_case) vince. Quando arriva un nuovo modello (Gemma 5, Claude Opus 4.7, GPT-5, …) si aggiunge una entry sulla base di quello che si osserva nelle prime sintesi. Il system prompt di base e gli executor non cambiano: l'unica leva che si tocca e' il repertorio.
La risposta architetturale è multistage: cinque stadi piccoli, ognuno con prompt focalizzato sul proprio compito, ordinati dal piu' procedurale (lookup nel vocabolario chiuso) al piu' creativo (codice). Lo skeleton del manifest si riempie progressivamente, e ogni stadio vede SOLO la fetta che gli serve — niente blob cumulativo.
| Stadio | Tipo | Tier LLM | Output |
|---|---|---|---|
| 1. Naming + classification | procedurale | middle (Qwen 3.6 35B-A3B think=true) | nome {azione}_{oggetto}[_qualifier] dal vocabolario chiuso (23 azioni, 22 oggetti) + revertible/critical/target_kind. Se nessuna combinazione calza: rejection esplicita con motivo. |
| 2. Signature | procedurale | middle | args_schema (JSON Schema), capabilities (set chiuso), reverse_pattern dal catalogo deterministico (runtime/reverse_patterns.py). |
| 3. Birth tests | procedurale | middle | 4-6 test in formato fisso (setup/input/expect/teardown), almeno: caso felice, lista vuota, args invalidi, edge dominio. |
| 4. Description + affinity | creativo | middle | description LLM-readable strutturata in quattro capitoli (SCOPO: / PATTERN: / NON: / OUT:). Il proposer mostra al planner solo la testa (fino a OUT: escluso). + 6-10 keyword di affinity per il composer. |
| 5. Codice | creativo + procedurale | wise (Qwen 3.6 35B-A3B think=true) | <name>.py Python con def invoke, conformità alle convenzioni del runtime (runtime/messages.py, runtime/platform_policy.py, helpers). |
Il vocabolario chiuso compare SOLO nel prompt di stage 1; gli stadi
successivi ricevono il name già deciso e lavorano nei loro confini. La
descrizione del manifest non la scrive il developer ma l'LLM dello stage
4 dedicato, nel formato a quattro capitoli: SCOPO: (cosa fa), PATTERN: (forma di chiamata canonica), NON: (anti-pattern e disambiguazione), OUT: (forma output). Il proposer tronca la description alla testa (fino a OUT:) per risparmiare budget di contesto.
Risultati misurati sui 35 query del dataset di stress: stadio 1 (naming + classification) raggiunge l'88.6 % (31/35) sul prompt bilingue. Le 4 escalation residue (resize, query SQL, validate YAML, crack) sono rejection esplicite per verbi semanticamente fuori vocabolario chiuso, non errori di sintesi: il vocabolario va eventualmente esteso solo se quei pattern ricorrono in casi reali («synonyms before vocabulary»).
Le strategie introvertive non rispondono a una richiesta puntuale: rispondono al bisogno strutturale di tenere piccolo, coerente e riusabile il pool. Girano nottetempo come parte dell'omeostasi dell'ager (vedi Architettura cap. 10), con basso uso di CPU e LLM economici (tier local-fast).
Quando due executor hanno tracce sovrapposte (peso dei mnest che li collegano > soglia) e profili di sandbox compatibili, synt propone una fusione: un nuovo executor che li copre entrambi. Lo stato fuso nel lifecycle dell'executor (cap. 6) è previsto proprio per questo: i due originali restano caricati finché ci sono mnest residui, poi vengono archiviati.
Innesco tipico: due executor che fanno cose simili sotto nomi diversi
(archive_pdf e store_pdf), magari nati in momenti
diversi senza che synt li avesse correlati alla nascita.
Quando N executor specializzati hanno la stessa forma (stesso schema I/O salvo una dimensione, stesso profilo di sandbox salvo un parametro), e quando i proto-mnest puntano alla loro famiglia su dimensioni nuove, synt propone un executor parametrico. La nuova versione prende come argomento esplicito quello che prima era ricodificato N volte.
Esempio canonico: order_image_file, order_audio_file,
order_doc_file — tutti riordinano file per data — vengono
proposti come order_file con argomento file_kind. Una
volta firmato il generalizzato, i tre specializzati vanno in stato
superseded e progressivamente in archiviato.
La strategia inversa, ed è la più rara. Da un executor generale, quando una sua invocazione su un caso specifico ricorre con altissima frequenza e ha un profilo di costo o latenza scomodo, synt propone una versione specializzata. La specializzazione vive accanto al generale, non lo sostituisce: il routing diventa «se argomento X ≡ caso caldo, usa lo specializzato; altrimenti il generale».
Si applica solo se c'è un beneficio misurato: riduzione di latenza osservata, riduzione di costo, oppure semplificazione del profilo di sandbox. Non si specializza per ottimizzazione preventiva.
| Strategia | Direzione | Effetto sul pool | Trigger |
|---|---|---|---|
| Fondere | N → 1 | riduce il numero di executor | tracce sovrapposte, profili compatibili |
| Generalizzare | N → 1 (parametrico) | riduce e copre nuove dimensioni | specializzati con shape coerente + proto-mnest sulla famiglia |
| Specializzare | 1 → 2 (gen + spec) | aggiunge un executor | caso caldo con beneficio misurato |
Ogni proposta di sintesi — qualunque strategia — produce un
punteggio R ∈ [0, 1] che synt calcola sui dati osservati e che
Roberto vede nel dossier di approvazione. La formula include una componente strategy_cost che premia le
strategie più economiche:
R = 0.35 · det_pass_rate # frazione test deterministici verdi
+ 0.20 · judge_score # LLM-as-judge (local-fast) su rubrica costituzionale
+ 0.15 · cost_ratio # clip(1 - cost_effettivo / stima, 0, 1)
− 0.10 · similarity_penalty # cosine-sim embedding con pool esistente > 0.85
+ 0.10 · coverage_bonus # bonus se copre proto-mnest non coperti
+ 0.10 · strategy_cost_bonus # 1 per comporre, 0.6 per fondere/specializzare,
# 0.4 per generalizzare, 0 per generare
gate_threshold = 0.65 # DECISIONE v1
Il strategy_cost_bonus è la novità: spinge synt verso le strategie meno costose a parità di qualità. Una composizione che chiude un proto-mnest con punteggio simile a una generazione vince per il bonus di strategia, com'è corretto.
La cascata non è solo disciplina di costo: è il meccanismo che onora il telos «coltivare gli strumenti» (Architettura cap. 11): finché una richiesta utente è dentro la costituzione e dentro il budget, synt esaurisce le strategie disponibili prima di rispondere «non posso».
Il telos vive in TELOS.md nel workspace, accanto agli altri:
5. Coltivare gli strumenti: se ti chiedo qualcosa che il tuo pool non sa
ancora fare, esaurisci le strategie di sintesi (comporre, generare,
chiedere) prima di rispondere "non posso". Il fallimento è lecito,
la rinuncia silenziosa no.
La differenza fra fallimento e rinuncia è tutta qui. Synt può arrivare alla conclusione che la richiesta non si può soddisfare nel budget, ma deve farlo dopo aver tentato la cascata, e deve spiegarlo: «ho provato a comporre con questi N executor, ho provato a generare con questa specifica, il test di nascita falliva sul caso X». Un fallimento motivato è un dato per il futuro; una rinuncia silenziosa è un buco nel mnestoma.
Indipendentemente dalla strategia, ogni proposta di synt che produce o modifica
un executor passa per il gate umano (vedi approval_ux.html, da
riscrivere). Il principio è quello del terzo dei sei principi
dell'Architettura: niente sintesi senza filtro umano, in nessun livello
di autonomy.
Una proposta di composizione è un caso a parte: synt non crea nessun nuovo artefatto firmato, solo orchestra l'esecuzione. Il gate umano in questo caso si applica al primo uso (Roberto vede «sto per chiamare A → B → C, va bene?») e poi viene rilassato in modo simmetrico al livello di autonomy: in Supervised ogni invocazione della catena chiede conferma; in Full, dopo le prime 5 esecuzioni pulite, la conferma viene saltata.
| Tetto | Default | Comportamento al supero |
|---|---|---|
| Soft per richiesta | 2 € | synt avvisa Roberto e chiede se proseguire. |
| Hard per richiesta | 5 € | synt si ferma, registra abbandono per budget. |
| Hard al giorno | 20 € | cap costituzionale (non superabile via config). |
| Stato | Significato | Transizioni |
|---|---|---|
composing | Cerca catena nel pool. | → composed (catena trovata) oppure generating. |
composed | Catena proposta, in attesa del gate utente. | → delivered oppure generating se Roberto rifiuta. |
generating | Pipeline multistage a 5 stadi in corso. | → born (executor firmato) oppure abandoned. |
born | Executor firmato, in pool. | terminale. |
abandoned | Tutte le strategie esaurite, fallimento motivato. | terminale; lock di 24h sullo stesso scopo. |
proposed | Strategia introvertiva, in attesa di batch utente. | → born/fused/generalized/specialized oppure rejected. |
rejected | Roberto ha respinto la proposta introvertiva. | terminale; lock di 30 giorni sulla stessa direzione. |
from typing import Protocol, Literal
from dataclasses import dataclass
Strategy = Literal[
"compose", "generate",
"merge", "generalize", "specialize",
]
ProposalState = Literal[
"composing", "composed",
"generating", "born", "abandoned",
"proposed", "rejected",
"fused", "generalized", "specialized",
]
@dataclass(frozen=True)
class SynthRequest:
"""Una richiesta di sintesi, reattiva o introvertiva."""
request_id: str
mode: Literal["reactive", "introspective"]
proto_mnest: str | None # id del proto-mnest che innesca (reattivo)
target_intent: str # NL: cosa serve fare
budget_cents: int # tetto frontier consumabile
capability_hint: list[str] # capability già supposte
@dataclass(frozen=True)
class RewardBreakdown:
det_pass_rate: float # [0,1]
judge_score: float # [0,1]
judge_reasoning: str
cost_ratio: float # [0,1]
similarity_penalty: float # [0,1] (col segno negativo nella formula)
coverage_bonus: float # [0,1]
strategy_cost_bonus: float # [0,1] dipende da Strategy
total: float # R aggregato
@dataclass
class SynthProposal:
"""Una proposta concreta che synt presenta all'utente."""
request_id: str
strategy: Strategy
state: ProposalState
artefact: dict # catena (compose) o executor candidato (generate/…)
reward: RewardBreakdown
cost_cents: int # consumo finora
rationale: str # 2-3 righe NL: perché questa strategia, qui
class Synt(Protocol):
async def react(self, req: SynthRequest) -> SynthProposal:
"""Cascata reattiva: prima compone, poi genera, poi abbandona."""...
async def homeostasis(self, lookback_days: int = 30) -> list[SynthProposal]:
"""Scorre mnestoma e pool; ritorna 0..N proposte introvertive in batch."""...
async def revise(
self, request_id: str, feedback: str,
target_strategy: Strategy | None = None,
) -> SynthProposal:
"""Riprende una proposta dopo feedback umano."""...
# Errori (registrati e trasformati in stato, non propagati)
class StrategyExhaustedError(Exception):...
class BudgetExceededError(Exception):...
class PolicyVetoError(Exception):...
class ConstitutionViolationError(Exception):...
Ogni passo della cascata produce una riga JSONL in
workspace/.audit/synt/YYYY-MM-DD.jsonl. Esempio per una composizione
riuscita:
{
"ts": "2026-04-25T22:14:33Z",
"request_id": "01HX...",
"mode": "reactive",
"proto_mnest": "mnest_01HW...",
"strategy": "compose",
"state": "composed",
"chain": ["read_files", "read_files_pdf", "classify_entries", "move_files"],
"reward": {"total": 0.78, "det_pass_rate": 0.95, "…": "…"},
"cost_cents": 0,
"duration_ms": 142
}
Tre invarianti dell'audit di synt, che il loader e il runtime garantiscono:
request_id esiste l'intera sequenza di stati attraversati, fino al terminale.rationale NL leggibile da Roberto a posteriori.cost_cents di una richiesta non supera mai il budget_cents dichiarato; la riga finale lo certifica.| Alternativa | Perché scartata (o rimandata) |
|---|---|
| Solo generazione, niente cascata | Default del primo disegno. Costoso e inquina il pool: ogni proto-mnest produce un nuovo executor anche quando una catena di executor esistenti basterebbe. Sostituito dalla cascata. |
| Cascata estesa (anche cap. 5 reattivo) | Fondere/generalizzare/specializzare nel turno utente moltiplica il rischio: tre proposte concorrenti in parallelo, gate umano sotto pressione. Restano introvertivi. |
| Generazione con N modelli in parallelo | 3× il costo per piccola riduzione di varianza. Eventuale in v2 se l'hit-rate della pipeline scende sotto il 60%. |
| Reward learning (RLHF) sui pesi di R | Richiede dataset di feedback umano firmato; sovradimensionato per uso domestico. La taratura manuale + revisione semestrale è più trasparente. |
| Composizione con LLM (planner) | Sostituisce la visita di grafo con una chiamata frontier che inventa la catena. Costoso e fragile: la visita è deterministica e ispezionabile, l'LLM no. |
| Auto-merge senza gate | Una fusione che cambia profili di sandbox è un atto di sicurezza. Resta gated. |
| Invariante | Test |
|---|---|
| La cascata reattiva tenta sempre comporre prima di generare | Iniezione di un proto-mnest risolvibile da catena nota → strategy = compose nel proposal, cost_cents = 0 in chiamate frontier. |
| Generare scatta solo se comporre fallisce | Proto-mnest senza catena possibile → transizione composing → generating in audit; compose non emesso come strategia finale. |
| Catena max 5 hop | Forzare 6 hop → visita rifiuta la catena, fallback a generare. |
| Gate umano non bypassabile | Mock di approval_ux che non viene chiamato → nessuno stato born/fused/… raggiunto. |
| Budget hard rispettato | Richiesta con budget 0.10 € → abbandono prima della seconda chiamata frontier; cost effettivo ≤ 0.10 €. |
| R reproducibile | Replay con stesso seed → stesso R ± 0.01 (judge_score con tolleranza). |
| Strategy cost bonus monotono | Per stessa qualità (det_pass_rate, judge), R(compose) > R(merge) > R(specialize) > R(generalize) > R(generate). |
| Lock 24h post-abbandono | Stesso target_intent entro 24h → SynthRequest rifiutata con stato abandoned immediato. |
| Lock 30 giorni post-rejected interno | Direzione introvertiva rifiutata da Roberto → nessuna nuova proposta sulla stessa direzione per 30 giorni. |
| Audit completo | Per ogni request_id con stato terminale, esistono in audit la riga iniziale e quella terminale, e tutti gli stati intermedi. |
| Coerenza profili in fusione | Tentativo di fondere due executor con profili sandbox incompatibili (es. uno scrive su ~/Pictures/, l'altro no) → proposta sospesa con motivazione. |
synt.html, oppure migrano in un nuovo consolidator.html? La separazione potrebbe semplificare i contratti (synt = reattivo, consolidator = introvertivo) ma duplicherebbe parti di pesatura. Oggi: tutto qui. Aperta.metnos-server singolo utente la notte basta; per scenari multi-sender va rivisto. Aperta.born dopo n invocazioni, è un'archiviazione o una quarantena? Lifecycle dell'executor (cap. 6) ammette entrambi; criterio non ancora fissato.metnos-server o può spawnare composizioni che attraversano server e laptop? Aperta; default conservativo: tutto su metnos-server, executor remoti chiamati come ogni altro.Metnos — synt, microprogettazione canonica.