AIREITER

Le descrizioni degli strumenti degli Agent vanno fuori sincrono: la dichiarazione deve essere l’unica fonte di verità

Ultimo Aggiornamento: 2026-07-31 07:35:05

Aggiungi un tool al tuo Agent, ad esempio per recuperare i post pubblici di una piattaforma. Per due settimane gira in produzione senza il minimo problema. Poi cambi il valore predefinito di limit da 25 a 20 e aggiungi un nuovo valore all’enum sort. Il codice cambia, i test sono verdi, fai merge.

Dopo tre giorni iniziano a comparire errori sporadici in produzione. Il modello ha invocato il tool con un valore enum che avevi eliminato la settimana precedente, la validazione runtime lo ha rifiutato e lo stack trace punta al layer di dispatch. Passi mezz’ora a fissare quel codice. Non c’è una riga sbagliata. Il problema è da tutt’altra parte: hai modificato la firma della funzione, ma non la descrizione del tool letta dal modello. Il modello sta ancora usando il vecchio schema, quindi genera chiamate per la vecchia struttura, che ovviamente non corrispondono più.

Questo è il drift delle descrizioni dei tool. È la categoria di bug più comune e più difficile da diagnosticare nell’engineering degli Agent, per una ragione precisa: errore e causa radice vivono in punti diversi. L’errore emerge nel layer di esecuzione; la causa sta in un file JSON che nessuno pensa di aprire. Qui vediamo come eliminare strutturalmente quella classe di bug. Non con il solito “ricordatevi di tenerli sincronizzati”, ma progettando il sistema in modo che non esista una seconda copia destinata a divergere.

Da dove nasce il drift

Alla base del drift ci sono sempre due fonti di verità da mantenere.

La prima è il codice che viene davvero eseguito: firma della funzione, validazione degli argomenti, valori predefiniti, vincoli enum. È la parte rigida. Se è sbagliata, fallisce in modo evidente.

L’altra è la descrizione del tool che il modello legge: name, description e lo schema JSON parameters. È una parte più permissiva. Se sbagli qui, non esplode nulla nell’immediato: il modello genera semplicemente una chiamata errata, che fallisce più avanti nel layer di esecuzione.

Finché spetta a una persona mantenere allineate queste due cose, il drift non è una possibilità ma una questione di tempo. Modifichi un argomento nel codice e dimentichi la descrizione. Oppure aggiorni la descrizione e non il codice. O ancora, cambi entrambi ma il loro significato non coincide più. Nessuno di questi casi si manifesta nel momento della modifica: resta nascosto finché il modello non produce una chiamata che tocca proprio quella differenza, quando ormai hai dimenticato cosa avevi cambiato due settimane prima. La soluzione ha una sola direzione: trasformare due copie in una sola.

Una sola fonte di verità: la dichiarazione è l’interfaccia

Il cambio di prospettiva è semplice: quel JSON con la descrizione del tool non ti serve davvero.

La dichiarazione del parser di una funzione, insieme alla docstring, contiene già tutti i campi necessari a descrivere un tool. Ecco una normale dichiarazione argparse:

subreddit = commands.add_parser("subreddit", help="Query a public board's feed")
subreddit.add_argument("subreddit")
subreddit.add_argument(
    "--sort",
    choices=("hot", "new", "top", "rising", "controversial"),
    default="hot",
)
subreddit.add_argument("--limit", type=int, default=25)

help fornisce la descrizione sintetica del comando. choices definisce il vincolo enum. default indica il valore predefinito. type è il tipo del parametro, mentre l’argomento posizionale è il campo obbligatorio. C’è già tutto ciò che serve al modello per invocare il tool: cosa fa il comando, quali parametri accetta, quali sono richiesti, i valori enum e i default. Inoltre, questa è la stessa dichiarazione usata dal runtime per parsing e validazione: non può divergere dalla logica di esecuzione perché è essa stessa logica di esecuzione.

Quindi smetti di scrivere una seconda descrizione del tool. Il principio corretto è che quel documento non esista affatto. Esiste soltanto il codice e, quando serve una descrizione, la si genera dal codice. La proiezione procede in una sola direzione, dal codice alla descrizione, mai al contrario.

Generare l’intero catalogo dalle dichiarazioni

Una volta accettato che la dichiarazione sia l’interfaccia, le descrizioni dei tool non dovrebbero più essere scritte a mano. Deve produrle un generatore.

Il suo lavoro è meccanico: attraversa ogni contesto della piattaforma, importa il relativo parser e legge l’elenco delle azioni di argparse in tre strutture immutabili: Platform, Command e Parameter. Ogni Parameter include nome, tipo, flag di obbligatorietà, valori enum, default e testo di help. È una read-model dell’interfaccia derivata interamente dal codice.

Una volta ottenuta questa read-model, ogni formato di output diventa un suo derivato. describe --format json emette l’interfaccia completa e machine-readable da usare per la selezione degli strumenti di un Agent. render_skill() genera invece un catalogo delle capacità leggibile da persone e modelli. Il numero di comandi nel catalogo non è una costante inserita manualmente: è sum(len(platform.commands)), calcolato al momento. Oggi il totale è di 22 contesti di piattaforma e 241 comandi, e nessuno di questi è stato digitato a mano nel catalogo.

Il vantaggio è molto concreto. Per aggiungere una piattaforma basta aggiungerne il contesto, e il catalogo acquisisce automaticamente tutti i suoi comandi. Per cambiare un parametro modifichi la dichiarazione del parser, e nel catalogo si aggiornano da soli enum e default corrispondenti. Non ti ritrovi più con “ho scritto un nuovo comando ma ho dimenticato di registrarlo” oppure “ho cambiato un parametro ma il catalogo è vecchio”, perché la registrazione manuale non esiste. Il catalogo si calcola, non si mantiene.

(L’istinto di derivare anziché mantenere è lo stesso che porta a usare operazioni sugli insiemi per stabilire cosa sia stato davvero migrato tra linguaggi, tema affrontato in questo articolo sulle migrazioni cross-language.)

La CI intercetta il drift prima del merge

La derivazione risolve il caso “un nuovo comando finisce automaticamente nel catalogo”, ma resta un varco. Qualcuno modifica una dichiarazione del parser, dimentica di rieseguire il generatore e non committa il catalogo rigenerato. La copia nel repository torna a essere obsoleta e il drift rientra dalla porta di servizio.

L’ultimo controllo va in CI e si riduce a una sola asserzione:

docs-check:
	$(PYTHON) -c 'from pathlib import Path; from reverse.catalog import render_skill; \
	  path = Path("skill/SKILL.md"); \
	  assert path.read_text(encoding="utf-8") == render_skill(), \
	  "skill/SKILL.md is out of sync with the code; run make docs"'

Prende il catalogo committato nel repository e lo confronta byte per byte con quello rigenerato dal codice corrente. Basta un carattere di differenza perché la CI fallisca, con un messaggio che segnala che il catalogo non è sincronizzato e invita a eseguire make docs.

Il valore di quella riga è che sposta il momento in cui il drift viene scoperto. Prima era un fantasma runtime: esplodeva in produzione due settimane dopo, con uno stack trace che indicava il punto sbagliato. Ora è una X rossa al momento del commit. Vieni fermato nella pull request, l’errore ti dice che il catalogo è datato e rigenerarlo risolve il problema. Il drift passa da “il bug più difficile da rintracciare” a un errore di compilazione risolvibile con un comando. È l’intero ciclo dell’interfaccia come codice: la dichiarazione è la sorgente, il catalogo delle capacità è l’artefatto di build e la CI è il type check. Non scriveresti a mano un artefatto di build, né accetteresti che differisse dalla sua sorgente: una descrizione di tool merita lo stesso trattamento.

Cosa fissare nel codice e cosa affidare al modello

Derivazione e CI garantiscono che la descrizione dell’interfaccia sia corretta. Ma prima c’è una decisione più importante: una certa capacità va implementata come codice fisso oppure lasciata al modello, che la orchestra al momento? Se sbagli questa separazione, un’interfaccia accurata non basta a salvarti.

Conviene guardare alle capacità su tre livelli.

Una primitiva di basso livello legge un tipo di dato o esegue un’azione chiara. Ha input stabili, output strutturati e può essere testata isolatamente. Questo livello è puro codice e non richiede ragionamento. Qui rientra la grande maggioranza dei 241 comandi.

Un workflow deterministico è un processo fortemente ordinato all’interno di una piattaforma, con stato condiviso e una condizione di successo chiara. Prendi una pipeline creativa, creative-pipeline, che esegue in sequenza: ricerca delle opportunità, poi Top Ads, poi il matching dei creator, poi un brief creativo, infine un preflight per la generazione. Ordine e dipendenze tra gli step sono fissi. Anche questo livello va congelato nel codice: se la sequenza è già determinata, chiedere al modello di ripianificarla ogni volta è più lento e meno stabile. Per segnalarlo basta una riga: assegnare al comando set_defaults(_command_level="workflow"). È l’unica riga di questo tipo nell’intera codebase, e proprio per questo il catalogo mostra workflow e primitive su due livelli distinti.

L’orchestrazione dell’Agent è il livello della ricerca cross-platform, dei compromessi valutati in tempo reale e del rerouting dopo un fallimento. È quello che va lasciato al modello, perché la prossima query dipende da ciò che ha restituito la precedente e non puoi fissarlo in anticipo.

Il criterio è abbastanza netto. Se una capacità richiede stato stabile degli stadi, contesto condiviso o effetti collaterali di generazione, fissala nel codice. Se riguarda espansione delle query, verifica tra piattaforme o rerouting dopo un errore, lasciala al modello. Entrambi gli errori costano. Codificare un’ipotesi di ricerca nel client significa irrigidire troppo il sistema, e al primo cambiamento della piattaforma torni a modificare codice. Affidare al modello una sequenza fissa da ricomporre ogni volta significa irrigidire troppo poco: risparmi una decisione del modello e introduci molta instabilità.

Far decidere al modello come degradare: sei stati di stadio

Per prendere decisioni nel layer di orchestrazione, il modello deve poter leggere chiaramente ciò che restituisce il livello inferiore. Un booleano opaco di successo o fallimento non basta. Se consegni al modello un success: false, può solo indovinare il passo successivo.

Ogni stadio di un workflow restituisce quindi uno stato, non un booleano, e gli stati sono sei: completed, empty, ready, skipped, unavailable e blocked. L’informazione più utile sta nelle differenze tra quelli che non sono avanzati:

  • skipped indica che l’operatore ha disattivato intenzionalmente quello step, ad esempio impostando a 0 il limite di un percorso di raccolta. Non è un errore e il modello non deve riprovarci.

  • unavailable indica che una dipendenza dello step non è temporaneamente disponibile, ad esempio un’interfaccia che restituisce errore o una sessione mancante. Il modello può saltarlo e proseguire, oppure richiedere una nuova sessione e tornare indietro.

  • blocked indica che manca una precondizione, ad esempio evidenze di ricerca vuote oppure un preflight fallito. Il modello non deve forzare lo step successivo: deve tornare indietro e completare le evidenze.

Prendiamo quella pipeline creativa. Valuta separatamente “platform preflight ready” e “research evidence ready”, con un ready = platform_ready and research_ready finale. Se una delle due condizioni fallisce, lo stadio di generazione restituisce blocked con un elenco blockers che spiega cosa lo sta fermando; se ogni risultato della ricerca commerciale è vuoto, non invia semplicemente il job di generazione.

Perché questo design è utile al modello? Un modello di orchestrazione che legge seedance_generation: blocked insieme a blockers: [research_evidence_empty] sa che deve tornare a raccogliere evidenze, invece di ritentare l’invio. Leggendo organic_discovery: skipped, capisce che è una scelta dell’utente e non un guasto, quindi non interviene. Davanti a uno step unavailable, sa che può aggirarlo degradando il flusso. Quando separi “disattivato di proposito”, “temporaneamente non disponibile” e “precondizione non soddisfatta”, il modello può scegliere la giusta strategia di degradazione. Se riduci tutti e tre a false, anche un modello forte finisce per girare a vuoto.

Un modello diverso per ogni livello

Lo stack descritto richiede capacità molto diverse a seconda del livello. (L’articolo sul reverse engineering in quattro fasi presenta la stessa tabella a quattro livelli in un contesto di reverse engineering; qui viene applicata allo stack di un Agent.) Assegna il modello per livello e smetti di sprecare capacità:

Attività nello stack Agent

Capacità richiesta

Scelta

model id

Caricare nel contesto il json describe di 241 comandi per scegliere un tool

Contesto lungo, lettura dell’intero catalogo in un passaggio

Kimi K3

kimi-k3

Orchestrazione: leggere stati e blocker degli stadi, decidere se degradare, reindirizzare o continuare

Ragionamento solido, decisione corretta in base allo stato

Claude Opus 5

claude-opus-5

Generare in massa testo di descrizione dei tool leggibile dai modelli a partire dalle docstring

Economico, centinaia di chiamate ad alta concorrenza

Claude Sonnet 5

claude-sonnet-5

Attribuzione degli errori nelle tool call: leggere errore e dichiarazione, stabilire se si tratta di drift o di una modifica upstream

Ragionamento intermedio, spiegazione rispetto a campi specifici

GPT-5.6 Sol

gpt-5.6-sol

Vale la pena soffermarsi sul layer di orchestrazione. Leggere blocked e skipped per decidere la mossa successiva è l’unico passaggio di questo flusso in cui il cambio di modello modifica visibilmente il risultato, perché qui si verifica esattamente la capacità di decidere correttamente a partire da uno stato. Un modello più debole tratta skipped come un fallimento e riprova, oppure vede blocked e invia comunque. Un modello con ragionamento forte legge i blockers e reindirizza con precisione. È lo stesso divario che c’è nel capire se una sezione di controevidenze stia davvero argomentando contro se stessa in questo articolo sul fingerprinting: produrre il candidato è alla portata di tutti, la parte difficile è il giudizio.

Non serve fidarti sulla parola: mettilo alla prova.

  1. Prendi una risposta reale di uno dei tuoi workflow, con i relativi stages e blockers, oppure crea una risposta blocked con blockers: [research_evidence_empty].

  2. Invia quella risposta, il tuo catalogo delle capacità, cioè il json describe, e un’istruzione per decidere l’azione successiva separatamente a claude-opus-5 e gpt-5.6-sol.

  3. Osserva un solo aspetto: l’azione successiva proposta dal modello distingue correttamente blocked (tornare alle evidenze), skipped (intenzione dell’utente, non intervenire) e unavailable (ottenere una sessione o aggirare il problema), oppure ritenta skipped come se fosse un errore?

  4. La quota di percorsi di degradazione corretti è il tuo criterio di scelta. Determina se il tuo Agent resterà bloccato durante un errore reale oppure saprà aggirarlo autonomamente.

Il vero ostacolo è il costo del cambio

I quattro modelli arrivano da tre vendor, e nel function calling il costo del passaggio è particolarmente pesante. tools / tool_calls di OpenAI e tool_use / tool_result di Anthropic sono due formati diversi. Se vuoi sostituire il modello di orchestrazione con uno che giudica meglio, devi riscrivere l’intero percorso di tool dispatch e parsing degli errori. È il vero motivo per cui molti finiscono per bloccare un solo modello nel layer di orchestrazione, anche quando quel modello interpreta spesso male gli stati degli stadi.

AIReiter elimina questo strato di complessità. Una chiave, un’interfaccia compatibile con OpenAI, tutti e quattro i modelli dietro le quinte: per cambiare basta modificare il campo model nel body della richiesta.

# Orchestration decision: hand the reasoning tier the catalog plus one blocked workflow response, ask for the next action
curl https://aireiter.com/api/v1/chat/completions \
  -H "Authorization: Bearer $AIREITER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-opus-5",
    "messages": [{"role": "user", "content": "<describe json> + <stages/blockers response> + decide the next action"}]
  }'

# Generate tool-description text in bulk: change the model field, leave the rest
#   "model": "claude-sonnet-5"
# Error attribution:
#   "model": "gpt-5.6-sol"

Per il function calling nativo basta aggiungere un array tools; il protocollo tool di OpenAI attraversa questa interfaccia senza modifiche, quindi cambiare modello resta un intervento su un solo campo. Se usi già l’SDK OpenAI, punta base_url a https://aireiter.com/api/v1 senza toccare altro. Con l’SDK Anthropic, usa POST /api/v1/messages con la stessa chiave.

Sui prezzi, i modelli Claude hanno il 30% di sconto sul listino, i modelli GPT costano la metà e Kimi K3 è disponibile con la stessa chiave. Per questo stack, lo sconto incide proprio dove conta. Ogni passo avanti del layer di orchestrazione richiede un’altra chiamata al tier di ragionamento, che è quindi il tier più frequente e costoso dell’intero Agent, e lo sconto Claude si applica esattamente lì. La generazione in massa delle descrizioni dei tool da 241 docstring è lavoro Sonnet ad alta concorrenza, anch’esso scontato. Queste due voci fanno la maggior parte del costo. Le chiamate GPT-5.6 per l’attribuzione degli errori sono molto meno numerose.

In sintesi

Il drift delle descrizioni dei tool non si risolve con un generico “ricordati di sincronizzare”. Sarebbe solo trasformare un difetto strutturale in una questione di disciplina personale. La soluzione reale è eliminare la struttura a due fonti: dichiarazione del parser e docstring sono l’unica sorgente, il catalogo delle capacità è un artefatto di build derivato da essa e una sola asserzione CI funge da type check. Il drift passa da fantasma runtime a X rossa al momento del commit.

La derivazione, però, garantisce soltanto che la descrizione sia accurata. Non dice nulla sulla correttezza dei livelli. Le capacità da congelare nel codice, quelle da lasciare orchestrare al modello e i sei stati di stadio che permettono al modello di capire se ritentare o degradare sono i due fattori che decidono se il tuo Agent può operare in autonomia. In questo stack il modello svolge due compiti concreti: valuta le alternative nel layer di orchestrazione e attribuisce la causa quando una tool call fallisce. La scelta di fissare una capacità e del percorso di degradazione dipende dagli stati di stadio che progetti e dalla CI che scrivi, non dal modello.

È lo stesso approccio degli articoli sulla riconciliazione delle migrazioni basata sugli insiemi e sul non costruire un Model di risposta unificato: l’AI comprime il tempo di un singolo passaggio, mentre il verdetto resta dentro i vincoli che codifichi. Quando tutto funziona senza attriti, l’unica frizione rimasta è cambiare modello: un problema infrastrutturale che un’unica interfaccia unificata risolve.