Salta al contenuto principale

85 - Tool: function calling, schemi JSON e errori che l'agente deve poter leggere

Un tool è una funzione con un contratto leggibile dal modello. Scrivi schemi stretti, errori utili e tool sicuri, e scopri cosa arriva davvero al modello.

Cosa ne pensi di questo articolo?

85 - Tool: function calling, schemi JSON e errori che l'agente deve poter leggere
Machine Learning Engineerfedericocalo.dev

In Breve

Un tool è una funzione con un contratto leggibile dal modello. Scrivi schemi stretti, errori utili e tool sicuri, e scopri cosa arriva davvero al modello.

Un tool è una funzione più un contratto leggibile dal modello: nome, descrizione e schema JSON degli argomenti. Il modello non esegue nulla — produce un oggetto che dice «chiama leggi_giacenza con {"sku": "SKU-11"}» e sta al tuo codice decidere se fidarsi. Da questa asimmetria discende tutto il mestiere: lo schema è un prompt, gli argomenti sono input non fidati, e l'errore di un tool non è un'eccezione da propagare ma un'informazione da restituire.

Versione usata e che cosa ho eseguito. Codice provato con openai-agents 0.24.0 (installato senza vincolo di versione in un ambiente virtuale temporaneo il 2026-11-12), pydantic 2.14.3 e Python 3.14.4. Fissa la versione: su questo SDK i nomi si spostano fra minor. Per eseguire i tool attraverso l'SDK vero senza modello uso lo stesso «Ollama finto» della lezione precedente (un trasporto HTTP simulato, nessuna rete). La funzione chiama(tool, argomenti) che compare nelle prove fa eseguire UN tool al finto modello e restituisce il testo che il modello riceverebbe; non è riportata per intero, ma è la stessa costruzione di quella lezione. Dove un blocco non è stato eseguito, il testo lo dice.

L'analogia: il modulo da compilare allo sportello

Immagina uno sportello con un cartello: «Per richiedere un certificato, compila il modulo A: campo 1 codice fiscale (16 caratteri), campo 2 tipo di certificato (nascita / residenza / matrimonio)». Il cartello è la descrizione, il modulo è lo schema, e l'impiegato che controlla il modulo prima di accettarlo è la validazione. Un cartello vago («chiedi quello che ti serve») produce moduli compilati a caso; un modulo con un campo libero dove serviva una scelta fra tre produce dati che non puoi elaborare. E l'impiegato serve comunque, perché c'è sempre qualcuno che scrive la data nel campo del nome.

Il viaggio di un argomento, in un disegno

Un argomento di tool fa cinque tappe. Vale la pena vederle tutte perché il controllo di sicurezza non sta dove la gente crede: lo schema è alla tappa 2, ma la validazione che conta è alla tappa 5, dentro la tua funzione.

Dalla firma Python all'esecuzione: le cinque tappe di un argomento 1. firma Python type hint + docstring (scritta da te) 2. schema JSON generato dall'SDK, inviato nel prompt 3. il modello emette argomenti zona NON fidata 4. deserializza stringa JSON → dict può fallire 5. la funzione valida, POI esegue qui il controllo vero Lo schema è un vincolo che il modello LEGGE, non un controllo che qualcuno APPLICA. Fra la tappa 3 e la tappa 5 può arrivare qualunque cosa. Il percorso di ritorno, in caso di problema: errore sui DATI (sku inesistente) → RESTITUISCI un dizionario d'errore → il modello si corregge al giro dopo errore di SISTEMA (database giù) → SOLLEVA un'eccezione → la run si ferma, nessun retry inutile
Le tappe 1 e 2 sono comunicazione; le tappe 4 e 5 sono difesa. Confonderle è l'errore di sicurezza più comune con i tool.

Il meccanismo: da type hint a schema

Quando decori una funzione, l'SDK ispeziona la firma e genera uno schema JSON. Lo guardo su un tool con un Literal, un default e un intero, stampando ciò che l'SDK espone: nome, descrizione e schema dei parametri.

from lib import *

@function_tool
def cerca_ordini(
    cliente: str,
    stato: Literal["aperto", "spedito", "annullato"] = "aperto",
    max_risultati: int = 10,
) -> dict:
    """Cerca gli ordini di un cliente.

    Usa questo tool quando l'utente chiede lo stato di uno o più ordini.
    Non usarlo per i resi: per quelli esiste cerca_resi.
    """
    return {"cliente": cliente, "stato": stato, "max": max_risultati}

for t in [cerca_ordini]:
    print("nome       :", t.name)
    print("descrizione:", t.description)
    print("parametri  :", json.dumps(t.params_json_schema, indent=2, ensure_ascii=False))
# nome       : cerca_ordini
# descrizione: Cerca gli ordini di un cliente.
# 
# Usa questo tool quando l'utente chiede lo stato di uno o più ordini.
# Non usarlo per i resi: per quelli esiste cerca_resi.
# parametri  : {
#   "properties": {
#     "cliente": {
#       "title": "Cliente",
#       "type": "string"
#     },
#     "stato": {
#       "default": "aperto",
#       "enum": [
#         "aperto",
#         "spedito",
#         "annullato"
#       ],
#       "title": "Stato",
#       "type": "string"
#     },
#     "max_risultati": {
#       "default": 10,
#       "title": "Max Risultati",
#       "type": "integer"
#     }
#   },
#   "required": [
#     "cliente",
#     "stato",
#     "max_risultati"
#   ],
#   "title": "cerca_ordini_args",
#   "type": "object",
#   "additionalProperties": false
# }

Tre lezioni da leggere in questo pezzo di JSON, e una di esse smentisce un'idea diffusa. Primo: Literal diventa enum, ed è lo strumento più efficace che hai, perché chiude lo spazio delle risposte possibili: un argomento stato: str ti arriverà come «in corso», «OPEN», «aperto?», un Literal no. Secondo: la descrizione viene dalla docstring e conserva il quando non usarlo; la riga «non usarlo per i resi» vale più di un paragrafo di istruzioni nel system prompt, perché sta dove il modello guarda mentre decide. Terzo, la sorpresa: in required compaiono tutti e tre i parametri, anche stato e max_risultati che hanno un default. Con le impostazioni predefinite dell'SDK (schema «strict», lo stesso che nella lezione precedente avevamo visto con "strict": true) i default restano nello schema ma i campi diventano obbligatori. Non è vero, quindi, che «i default rendono il campo opzionale» per il modello: gli rendono ovvio il valore da usare, ma deve comunque emetterlo. Se ti serve un campo davvero facoltativo, controlla la documentazione della tua versione su come dichiararlo.

Perché la docstring è codice di produzione

Una cosa che vale la pena dire esplicitamente, perché contraddice un'abitudine radicata: nella programmazione normale la docstring è per gli esseri umani, e se è vaga o assente il programma funziona comunque. Qui no. La docstring finisce nel prompt e viene letta dal modello nel momento in cui decide se chiamarti: è l'unica informazione su cui si basa quella decisione, insieme al nome e allo schema.

La conseguenza pratica è che una docstring va scritta con criteri diversi da quelli abituali. Non descrive l'implementazione, descrive quando usare il tool e quando no. Confronta queste due versioni della stessa funzione.

# Docstring "normale": corretta per un umano, inutile per il modello
@function_tool
def cerca_resi(cliente: str, dal: str) -> dict:
    """Interroga la tabella resi filtrando per cliente e data, con join su ordini."""
    ...

# Docstring come prompt: dice quando, quando no, e cosa aspettarsi
@function_tool
def cerca_resi(cliente: str, dal: str) -> dict:
    """Elenca le richieste di RESO di un cliente a partire da una data.

    Usa questo tool quando l'utente parla di merce da restituire, resi,
    sostituzioni o pacchi rifiutati.
    NON usarlo per i rimborsi su ordini consegnati: per quelli esiste cerca_ordini.
    Il nome del cliente deve essere esatto: se non lo conosci, prima chiama
    cerca_cliente. La data va in formato 2026-10-09.
    """
    ...

La seconda versione contiene quattro informazioni che la prima non dà e che il modello usa: l'intenzione dell'utente che la attiva, il confine con un tool vicino, la dipendenza da un altro tool, e il formato di un argomento. Nessuna di queste è documentazione dell'implementazione; tutte e quattro riducono il numero di giri sbagliati.

Ci sono anche due cose che non vanno nella docstring, e sono simmetriche agli errori che si vedono più spesso. Non vanno i dettagli interni («fa un join su ordini»): sono contesto pagato a ogni giro per un'informazione che non cambia nessuna decisione del modello. E non vanno le minacce («NON chiamare questo tool più di una volta, è importante!»): il modello non ha un meccanismo di conteggio su cui fare affidamento, e se la chiamata ripetuta è un problema la difesa è l'idempotenza nel codice, non l'enfasi nel testo.

Un piccolo esercizio che chiarisce tutto il punto, ed è il blocco di stampa che ti ho mostrato sopra: prendi il tuo catalogo, stampa per ogni tool soltanto nome, descrizione e schema (params_json_schema) — cioè esattamente ciò che vede il modello — e leggili come se fossero la pagina di aiuto di un programma che non conosci. In questa versione dell'SDK i tool decorati espongono name, description e params_json_schema; i nomi sono quelli che ho verificato e potrebbero spostarsi.

Il primo giro di questa lettura trova sempre qualcosa: una descrizione rimasta vuota, due tool che sembrano la stessa cosa, un argomento chiamato val, un enum che era rimasto una stringa libera. Sono dieci minuti, e sono i dieci minuti con il miglior rapporto fra fatica e miglioramento di tutto il lavoro su un agente.

Argomenti complessi: usa un modello Pydantic

Per input non banali, dichiara un tipo invece di cinque parametri sciolti. Guadagni vincoli espressi nello schema e un oggetto già validato dentro la funzione. Ma c'è una cosa che ho scoperto eseguendo il blocco e che cambia come lo usi: guarda che cosa riceve il modello quando gli argomenti violano un vincolo.

from pydantic import BaseModel, Field
class RichiestaRiordino(BaseModel):
    sku: str = Field(pattern=r"^SKU-\d{2,6}$", description="Codice prodotto, es. SKU-11")
    quantita: int = Field(ge=1, le=1000, description="Pezzi da ordinare")
    urgenza: Literal["standard", "urgente"] = "standard"

@function_tool
def crea_riordino(richiesta: RichiestaRiordino) -> dict:
    """Crea una proposta di riordino. Non invia nulla al fornitore."""
    return {"stato": "proposta_creata", "sku": richiesta.sku,
            "quantita": richiesta.quantita, "urgenza": richiesta.urgenza}

print(json.dumps(crea_riordino.params_json_schema["$defs"]["RichiestaRiordino"]["properties"], ensure_ascii=False))

async def main():
    print(await chiama(crea_riordino, {"richiesta": {"sku": "SKU-11", "quantita": 5}}))
    print(await chiama(crea_riordino, {"richiesta": {"sku": "SKU-11", "quantita": 0}}))
    print(await chiama(crea_riordino, {"richiesta": {"sku": "sku-11", "quantita": 5}}))
asyncio.run(main())
# {"sku": {"description": "Codice prodotto, es. SKU-11", "pattern": "^SKU-\\d{2,6}$", "title": "Sku", "type": "string"}, "quantita": {"description": "Pezzi da ordinare", "maximum": 1000, "minimum": 1, "title": "Quantita", "type": "integer"}, "urgenza": {"default": "standard", "enum": ["standard", "urgente"], "title": "Urgenza", "type": "string"}}
# {'stato': 'proposta_creata', 'sku': 'SKU-11', 'quantita': 5, 'urgenza': 'standard'}
# An error occurred while running the tool. Please try again.
# An error occurred while running the tool. Please try again.

Le tre uscite dicono tre cose. Lo schema contiene davvero i vincoli (pattern, minimum, maximum) e le descrizioni dei campi: il modello li legge e tende a rispettarli. La chiamata valida passa. Le due non valide non producono alcun messaggio utile per il modello: con il comportamento predefinito di questa versione ricevono soltanto «An error occurred while running the tool. Please try again.», senza dire quale vincolo è stato violato. Il modello può quindi riprovare allo stesso modo. Sostituendo la gestione degli errori (sezione seguente) ho ottenuto «Invalid JSON input for tool crea_riordino», ancora senza il dettaglio del campo. In breve: i vincoli Pydantic sono una prima barriera e una guida per il modello, ma non comunicano l'errore; se vuoi che il modello si corregga, i controlli che contano vanno fatti dentro la funzione, restituendo un dizionario d'errore che nomina il campo.

Errori: restituiscili, non sollevarli

Questa è la parte che distingue un agente che recupera da uno che muore. Un valore di ritorno che descrive il problema entra nella cronologia e il modello lo legge al giro successivo. cerca_cliente lo fa: se il nome non c'è, restituisce suggerimenti e un'istruzione su come rimediare. L'ho scritto come funzione pura (_impl) con un involucro sottile, così si testa senza SDK.

CLIENTI = {"ACME": 1, "Brambilla SRL": 2}
def cerca_cliente_impl(nome: str) -> dict:
    if nome in CLIENTI:
        return {"ok": True, "id": CLIENTI[nome]}
    simili = [c for c in CLIENTI if nome.lower() in c.lower() or c.lower() in nome.lower()]
    return {"ok": False, "errore": "cliente_non_trovato",
            "suggerimenti": simili or sorted(CLIENTI)[:5],
            "come_rimediare": "Richiama il tool con uno dei nomi suggeriti."}

print(cerca_cliente_impl("Brambilla"))
# {'ok': False, 'errore': 'cliente_non_trovato', 'suggerimenti': ['Brambilla SRL'], 'come_rimediare': 'Richiama il tool con uno dei nomi suggeriti.'}

Il campo come_rimediare non è decorativo: è un'istruzione al modello, consegnata nel momento esatto in cui serve. Con un errore così, un agente che ha cercato «Brambilla» trova il suggerimento «Brambilla SRL» e si corregge in un giro (la correzione del modello non è provata qui: la funzione sì).

Resta un confine da tenere: non tutti gli errori sono recuperabili. L'errore sui dati si restituisce (il modello può rimediare); l'errore sul sistema si solleva, perché il modello non può farci niente e riprovare costa soldi. Ma l'SDK, per impostazione predefinita, non propaga le eccezioni: le trasforma in un messaggio generico per il modello, e la run continua. Per far fermare davvero la run su un errore di infrastruttura serve decidere tu la regola, con il parametro failure_error_function di function_tool. Il blocco seguente ne definisce una che converte gli errori di dati in testo e rilancia quelli di infrastruttura.

from lib import *
from agents import function_tool

class ErroreInfrastruttura(Exception):
    """Non recuperabile dal modello: l'agente deve fermarsi."""

class RichiestaRiordino(BaseModel):
    sku: str = Field(pattern=r"^SKU-\d{2,6}$", description="Codice prodotto, es. SKU-11")
    quantita: int = Field(ge=1, le=1000)

def errore_leggibile(contesto, errore: Exception) -> str:
    """Trasforma l'eccezione in un messaggio che il modello può usare per correggersi."""
    if isinstance(errore, ErroreInfrastruttura):
        raise errore                      # non recuperabile: la run si ferma
    causa = errore.__cause__ or errore       # per gli argomenti non validi, il dettaglio sta qui
    righe = [e["msg"] + " (" + ".".join(str(p) for p in e["loc"]) + ")"
             for e in causa.errors()] if isinstance(causa, ValidationError) else [str(causa)]
    return "Argomenti o esecuzione non validi: " + "; ".join(righe)[:300]

@function_tool(failure_error_function=errore_leggibile)
def crea_riordino(richiesta: RichiestaRiordino) -> dict:
    """Crea una proposta di riordino. Non invia nulla al fornitore."""
    return {"stato": "proposta_creata", "quantita": richiesta.quantita}

@function_tool(failure_error_function=errore_leggibile)
async def leggi_fattura(numero: str) -> dict:
    """Legge una fattura dal gestionale."""
    if numero == "giu":
        raise ErroreInfrastruttura("gestionale non raggiungibile")
    return {"ok": True}

@function_tool(failure_error_function=None)
def fragile(numero: str) -> dict:
    """Solleva sempre."""
    raise ValueError("boom")

async def main():
    for t, a in ((leggi_fattura, {"numero": "giu"}), (fragile, {"numero": "x"})):
        try:
            print(await chiama(t, a))
        except Exception as e:
            print(f"{t.name}: la run è morta con {type(e).__name__}: {e}")
asyncio.run(main())
# leggi_fattura: la run è morta con UserError: Error running tool leggi_fattura: gestionale non raggiungibile
# fragile: la run è morta con UserError: Error running tool fragile: boom

Due righe, due modi di fermare la run. Con la funzione personalizzata che rilancia, l'ErroreInfrastruttura arriva al tuo codice come UserError e la run si ferma: è il comportamento voluto per un database giù. Con failure_error_function=None qualunque eccezione fa lo stesso. Il modello, invece, non vede mai l'eccezione. Il rischio opposto è usare la conversione in messaggio per tutti gli errori, che è il modo per non accorgersi mai che il database è giù: il modello riprova, la run continua, e il guasto sparisce dai tuoi log. Il messaggio generico del comportamento predefinito è ancora peggio, perché non dice nemmeno che cosa riprovare.

Il tool è un confine di sistema: dove sta davvero la sicurezza

Il diagramma delle cinque tappe dice che il controllo vero è alla tappa 5, dentro la funzione. Vale la pena insistere, perché è la cosa che si capisce tardi e costa caro: gli argomenti di un tool sono input non fidato, nello stesso senso in cui lo è un campo di un form web. Non perché il modello sia malevolo, ma perché gli argomenti che genera dipendono dal testo che ha letto — e quel testo può venire da un utente, da una pagina web, dalla mail di un cliente, dal risultato di un altro tool.

La conseguenza è che ogni vulnerabilità classica delle applicazioni si ripresenta identica, con un passaggio in più. Tre esempi concreti, perché sono quelli che si vedono davvero.

Il tool che esegue SQL. Un tool esegui_query(sql: str) è comodissimo e è una SQL injection con passaggi aggiuntivi: stai dando a un generatore di testo la facoltà di scrivere comandi arbitrari sul tuo database. Non si risolve con un prompt («esegui solo SELECT») perché il prompt non è un controllo. Si risolve cambiando la forma del tool: invece di accettare SQL, accetta parametri e costruisci tu la query.

Per mostrare la forma corretta ho eseguito il tool con un database finto che registra la query e i parametri (al posto di un driver come asyncpg, la cui sintassi con $1 uso qui; non ho chiamato un database vero). Il contesto di sessione è un oggetto tuo, passato a Runner.run(..., context=...) e invisibile al modello.

from dataclasses import dataclass
from lib import *

@dataclass
class Sessione:
    id_run: str
    cliente_id: int

class FiltroOrdini(BaseModel):
    cliente_id: int = Field(ge=1)
    stato: Literal["aperto", "spedito", "annullato"] | None = None
    dal: str | None = Field(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$")
    limite: int = Field(default=20, ge=1, le=100)

class DbFinto:
    """Al posto di asyncpg: registra la query e i parametri, restituisce due righe."""
    chiamate = []
    async def fetch(self, query, *parametri):
        self.chiamate.append((query, parametri))
        return [{"numero": "A-1", "stato": "aperto"}, {"numero": "A-2", "stato": "aperto"}]
db = DbFinto()

@function_tool
async def cerca_ordini(contesto: RunContextWrapper[Sessione], filtro: FiltroOrdini) -> dict:
    """Cerca ordini di un cliente. Restituisce al massimo 100 righe."""
    if filtro.cliente_id != contesto.context.cliente_id:
        return {"ok": False, "errore": "non_autorizzato",
                "come_rimediare": "Puoi consultare solo gli ordini del cliente corrente."}
    righe = await db.fetch(
        "SELECT numero, stato FROM ordini WHERE cliente_id = $1 "
        "AND ($2::text IS NULL OR stato = $2) ORDER BY data DESC LIMIT $3",
        filtro.cliente_id, filtro.stato, filtro.limite)
    return {"ok": True, "ordini": righe, "quante": len(righe)}

async def main():
    ctx = Sessione(id_run="run-1", cliente_id=7)
    print(await chiama(cerca_ordini, {"filtro": {"cliente_id": 7}}, ctx))
    print(await chiama(cerca_ordini, {"filtro": {"cliente_id": 8}}, ctx))
    print(db.chiamate[0])
asyncio.run(main())
# {'ok': True, 'ordini': [{'numero': 'A-1', 'stato': 'aperto'}, {'numero': 'A-2', 'stato': 'aperto'}], 'quante': 2}
# {'ok': False, 'errore': 'non_autorizzato', 'come_rimediare': 'Puoi consultare solo gli ordini del cliente corrente.'}
# ('SELECT numero, stato FROM ordini WHERE cliente_id = $1 AND ($2::text IS NULL OR stato = $2) ORDER BY data DESC LIMIT $3', (7, None, 20))

Il dettaglio che conta più di tutti è la riga sul cliente_id. Il modello può metterci un numero qualsiasi, perché è un campo dello schema: l'unica difesa è confrontarlo con l'identità che arriva dalla sessione autenticata, che vive nel contesto della run e non è visibile al modello. L'uscita mostra le due strade: il cliente 7, quello della sessione, ottiene le righe; il cliente 8 riceve «non_autorizzato». Nella terza riga si vede la query con i parametri separati dal testo SQL: nessuna stringa del modello viene concatenata nella query. Questa è la forma generale: i campi che determinano l'autorizzazione non devono stare nello schema, e se per comodità ci stanno, vanno verificati contro una fonte che il modello non controlla.

Il tool che legge file. Un leggi_file(percorso: str) senza normalizzazione è una path traversal: basta un ../../etc/passwd o un percorso assoluto. La difesa è risolvere il percorso e verificare che resti dentro la radice consentita, dopo la risoluzione dei collegamenti simbolici. L'ho provato in una cartella temporanea con un file lecito, un CSV, e un collegamento simbolico che punta fuori dalla radice.

import os, tempfile
from pathlib import Path
from lib import *

cartella = tempfile.mkdtemp()
RADICE = Path(cartella).resolve()
(RADICE / "nota.md").write_text("ciao", encoding="utf-8")
(RADICE / "dati.csv").write_text("a,b", encoding="utf-8")
os.symlink("/etc/hostname", RADICE / "fuori.md")           # collegamento verso l'esterno

@function_tool
def leggi_documento(nome: str) -> dict:
    """Legge un documento dall'archivio. Solo file .md o .txt dell'archivio."""
    candidato = (RADICE / nome).resolve()                 # risolve .. e symlink
    if not candidato.is_relative_to(RADICE):              # Python 3.9+
        return {"ok": False, "errore": "percorso_fuori_archivio"}
    if candidato.suffix not in {".md", ".txt"}:
        return {"ok": False, "errore": "estensione_non_consentita"}
    if not candidato.is_file():
        return {"ok": False, "errore": "non_trovato", "nome": nome}
    testo = candidato.read_text(encoding="utf-8", errors="replace")[:20_000]
    return {"ok": True, "nome": nome, "testo": testo,
            "troncato": candidato.stat().st_size > 20_000}

async def main():
    for nome in ("nota.md", "../etc/passwd", "dati.csv", "fuori.md", "manca.md"):
        print(f"{nome:14s}", await chiama(leggi_documento, {"nome": nome}))
asyncio.run(main())
# nota.md        {'ok': True, 'nome': 'nota.md', 'testo': 'ciao', 'troncato': False}
# ../etc/passwd  {'ok': False, 'errore': 'percorso_fuori_archivio'}
# dati.csv       {'ok': False, 'errore': 'estensione_non_consentita'}
# fuori.md       {'ok': False, 'errore': 'percorso_fuori_archivio'}
# manca.md       {'ok': False, 'errore': 'non_trovato', 'nome': 'manca.md'}

Cinque casi, cinque esiti. Il file lecito si legge. Il percorso con .. e il collegamento simbolico verso l'esterno finiscono entrambi in «percorso_fuori_archivio», perché resolve() segue il collegamento prima del controllo is_relative_to: se avessi controllato il nome prima di risolvere, il collegamento sarebbe passato. Il CSV è respinto per estensione, e il file inesistente dà un errore specifico invece di un'eccezione.

Il tool che fa una richiesta HTTP. Uno scarica(url: str) è una Server-Side Request Forgery: un modello può essere indotto a chiedere http://169.254.169.254/ o un indirizzo della tua rete interna, e il tuo server, che ha accesso a cose a cui l'utente non ha accesso, la esegue. La difesa è una lista di domini consentiti, non una lista di indirizzi vietati, perché le liste di divieti si aggirano con un DNS che risolve dove vuole chi lo controlla. Non ho scritto né eseguito un esempio di questo tool.

Il filo comune delle tre difese è un principio che vale la pena enunciare perché orienta ogni decisione successiva: minimo privilegio applicato alla forma del tool. Non «dai al tool i permessi minimi» (che è giusto ma generico), ma: progetta il tool in modo che la cosa pericolosa non sia esprimibile. Un tool che accetta SQL può fare qualunque cosa; un tool che accetta un filtro tipizzato non può. La differenza non è quanta validazione scrivi: è cosa hai messo nello schema.

Progettare il catalogo: il test dei due tool

La granularità dei tool è la decisione di progetto con più effetto sul numero di giri e sul tasso di errore, e si prende con un test che si fa in trenta secondi. Prendi due tool del tuo catalogo, leggi solo nome e descrizione (non il codice), e chiediti: io, in questa situazione, saprei quale scegliere? Se esiti, esiterà anche il modello, e la sua esitazione costa un giro o una scelta sbagliata.

Alcuni casi ricorrenti, con la cura.

Tool quasi gemelli. cerca_cliente_per_nome, cerca_cliente_per_email, cerca_cliente_per_partita_iva: tre tool che fanno la stessa cosa con una chiave diversa. Ogni descrizione occupa contesto a ogni giro, e il modello deve fare una scelta che non aggiunge informazione. Si fondono in un tool con un parametro tipizzato: cerca_cliente(valore: str, tipo: Literal["nome","email","piva"]).

Un tool che fa due cose. gestisci_ordine(azione: Literal["leggi","annulla","modifica"], ...) accorpa una lettura innocua e due scritture irreversibili sotto lo stesso nome. È comodo e sbagliato per due ragioni indipendenti: il modello sceglie l'azione con la stessa leggerezza con cui riempie qualunque campo, e tu non puoi dare permessi diversi a operazioni diverse. Le letture e le scritture vanno in tool separati, sempre.

Un tool troppo generico. esegui_operazione(nome: str, parametri: dict) è un modo di non progettare il catalogo: lo schema non dice nulla, il modello non sa cosa può chiedere, e la validazione finisce tutta a tempo d'esecuzione. Lo si vede nei progetti che vogliono «un tool solo per tutto», e produce agenti che sbagliano in modi impossibili da diagnosticare.

Una regola pratica che funziona: un tool per intenzione dell'utente, non per endpoint del tuo backend. Se il tuo gestionale espone sei chiamate per sapere se un ordine è spedibile, il tool ne espone una, che internamente ne fa sei. Il modello non deve conoscere la tua architettura — e ogni dettaglio della tua architettura che gli fai conoscere è contesto che paghi e superficie su cui può sbagliare.

Il valore di ritorno è contesto, e il contesto si paga n volte

Questa è la parte del progetto di un tool che viene ignorata più spesso, e l'unica dove un intervento di dieci minuti produce un risparmio. Il motivo è il meccanismo visto nella lezione sull'anatomia: ciò che un tool restituisce entra nello stato, e lo stato viene rimandato al modello a ogni giro successivo. Un tool che restituisce 3000 token al primo di quattro giri compare quindi in quattro chiamate: 3000 token generati e tre rinvii, per 12.000 token attribuibili a quel tool.

Un conto d'esempio, con numeri stimati, non misurati con un tokenizer: un tool che cerca ordini e restituisce la riga completa del database, venti colonne di cui il modello ne usa quattro, su venti ordini.

Versione "SELECT *":  20 ordini x 20 colonne ~ 1600 token (stima)
  giro 1 il tool restituisce, giri 2-4 ri-inviato: 1600 x 4 = 6400

Versione "campi utili": 20 ordini x 4 colonne ~ 320 token (stima, 1/5)
  stesso conto: 320 x 4 = 1280

Il rapporto è quello delle colonne, cinque a uno, e vale a prescindere dai valori assoluti, che dipendono dal contenuto e dal tokenizer. Il risparmio non è solo economico: meno materiale irrilevante nel contesto significa meno occasioni di confondere il modello.

Da qui tre regole, con la ragione tecnica accanto.

Restituisci i campi che servono, mai la riga intera. Il criterio non è «cosa potrebbe essere utile» ma «cosa serve per la decisione che questo agente deve prendere». Se l'agente decide se riordinare, non gli serve il nome del magazziniere che ha fatto l'ultimo inventario.

Pagina, e dichiara che stai paginando. È la regola che evita una categoria di bugie in perfetta buona fede. Se il tool restituisce dieci risultati su quattrocentoventotto e non lo dice, il modello conclude che ce ne sono dieci e lo riferisce all'utente con sicurezza. Il campo che risolve è banale:

return {"ordini": righe[:10],
        "mostrati": len(righe[:10]),
        "totale_disponibili": totale,
        "nota": ("Mostrati i primi 10 di "
                 f"{totale}. Se ti serve un sottoinsieme preciso, affina i filtri.")}

Quella nota in linguaggio naturale non è ridondante rispetto ai numeri: è l'istruzione su cosa fare, consegnata nel punto esatto in cui serve, e nella pratica è ciò che fa la differenza fra un modello che affina la ricerca e un modello che si accontenta.

Chiavi parlanti e unità esplicite. {"peso": 3.2} è ambiguo — chili, grammi, libbre? — e il modello risolverà l'ambiguità indovinando. {"peso_kg": 3.2} non lascia spazio. Vale per le valute (importo_eur), per le date (preferibilmente in ISO 8601, che è l'unico formato che non si può fraintendere fra giorno e mese) e per i booleani, dove {"spedibile": false, "motivo_non_spedibile": "indirizzo incompleto"} dice al modello cosa fare e {"stato": 0} no.

Un ultimo accorgimento che costa niente e salva tempo: restituisci sempre lo stesso schema, anche in caso di errore. Un tool che in caso di successo restituisce una lista e in caso di errore una stringa obbliga il modello a trattare due forme diverse. Un tool che restituisce sempre {"ok": bool, ...} gli dà un unico punto da guardare. È la stessa logica dell'inviluppo di risposta che si usa per le API verso l'esterno, e vale per le stesse ragioni.

I tool che scrivono: idempotenza e conferma

Un tool che legge può essere chiamato dieci volte senza danni. Un tool che scrive — invia un ordine, manda una mail, cancella un record — chiamato due volte fa il danno due volte. E il modello ripeterà delle chiamate: perché il risultato non gli è chiaro, perché un timeout ha fatto scattare un retry, perché la cronologia è stata troncata e non vede di aver già agito.

La difesa non è un prompt («non chiamare questo tool due volte»): è l'idempotenza. Un'operazione è idempotente quando eseguirla N volte ha lo stesso effetto che eseguirla una volta. Il modo standard di ottenerla è una chiave di deduplicazione decisa da chi chiama.

Il blocco seguente definisce invia_ordine con una chiave di deduplicazione e lo chiama due volte con gli stessi dati attraverso l'SDK, con una sessione che porta l'identificativo della run.

import hashlib
from dataclasses import dataclass
from lib import *

@dataclass
class Sessione:
    id_run: str
    cliente_id: int

ORDINI_INVIATI: dict[str, dict] = {}

class Ordine(BaseModel):
    sku: str = Field(pattern=r"^SKU-\d{2,6}$")
    quantita: int = Field(ge=1, le=1000)
    fornitore: str

def chiave_operazione(id_run: str, o: Ordine) -> str:
    grezzo = f"{id_run}|{o.sku}|{o.quantita}|{o.fornitore}"
    return hashlib.sha256(grezzo.encode()).hexdigest()[:16]

@function_tool
def invia_ordine(contesto: RunContextWrapper[Sessione], ordine: Ordine) -> dict:
    """Invia un ordine al fornitore. ATTENZIONE: operazione non reversibile.

    Chiamare questo tool due volte con gli stessi dati NON crea due ordini.
    """
    chiave = chiave_operazione(contesto.context.id_run, ordine)
    if chiave in ORDINI_INVIATI:                       # già fatto: esito identico
        return {"ok": True, "duplicato": True, **ORDINI_INVIATI[chiave]}
    esito = {"numero_ordine": f"ORD-{chiave}", "sku": ordine.sku, "quantita": ordine.quantita}
    ORDINI_INVIATI[chiave] = esito
    return {"ok": True, "duplicato": False, **esito}

async def main():
    ctx = Sessione(id_run="run-1", cliente_id=7)
    o = {"ordine": {"sku": "SKU-11", "quantita": 5, "fornitore": "ACME"}}
    print(await chiama(invia_ordine, o, ctx))
    print(await chiama(invia_ordine, o, ctx))
    print(len(ORDINI_INVIATI), "ordine/i registrato/i")
asyncio.run(main())
# {'ok': True, 'duplicato': False, 'numero_ordine': 'ORD-433e96f6500c31a1', 'sku': 'SKU-11', 'quantita': 5}
# {'ok': True, 'duplicato': True, 'numero_ordine': 'ORD-433e96f6500c31a1', 'sku': 'SKU-11', 'quantita': 5}
# 1 ordine/i registrato/i

Due chiamate, un solo ordine registrato: la seconda restituisce lo stesso numero_ordine con duplicato: True. Due dettagli. Il primo: RunContextWrapper è il modo in cui l'SDK ti passa un oggetto tuo (qui con l'identificativo della run) senza esporlo al modello: non compare nello schema, quindi il modello non può inventarlo né manometterlo; è il posto giusto per utente autenticato, identificativo di sessione, permessi. Il secondo: restituire duplicato: True invece di un errore è deliberato, perché il modello legge «ok», non riprova, e non hai mandato due ordini. Il dizionario in memoria vale per l'esempio; in produzione serve una tabella con vincolo univoco.

Per le operazioni davvero costose l'idempotenza non basta: serve una conferma umana. Il modo pulito è spezzare in due tool, uno che prepara e uno che esegue; _invia_davvero è qui un segnaposto che restituisce un dizionario, non invia nulla.

# (stesse definizioni di Ordine e chiave_operazione del blocco precedente)
BOZZE: dict[str, Ordine] = {}

def _invia_davvero(ordine: Ordine, approvato_da: str) -> dict:
    return {"ok": True, "inviato": ordine.sku, "approvato_da": approvato_da}

@function_tool
def prepara_ordine(ordine: Ordine) -> dict:
    """Prepara un ordine e restituisce un token di conferma. NON invia nulla."""
    token = chiave_operazione("bozza", ordine)
    BOZZE[token] = ordine
    return {"token": token, "riepilogo": f"{ordine.quantita} x {ordine.sku}",
            "nota": "Mostra il riepilogo all'utente e chiedi conferma. "
                    "Chiama conferma_ordine SOLO se l'utente approva esplicitamente."}

@function_tool
def conferma_ordine(token: str, approvato_da: str) -> dict:
    """Invia un ordine preparato. Richiede il token di prepara_ordine."""
    ordine = BOZZE.pop(token, None)
    if ordine is None:
        return {"ok": False, "errore": "token_scaduto_o_inesistente",
                "come_rimediare": "Richiama prepara_ordine."}
    return _invia_davvero(ordine, approvato_da)

async def main():
    o = {"ordine": {"sku": "SKU-11", "quantita": 5, "fornitore": "ACME"}}
    await chiama(prepara_ordine, o)
    token = list(BOZZE)[0]
    print(await chiama(conferma_ordine, {"token": "inventato", "approvato_da": "mario"}))
    print(await chiama(conferma_ordine, {"token": token, "approvato_da": "mario"}))
asyncio.run(main())
# {'ok': False, 'errore': 'token_scaduto_o_inesistente', 'come_rimediare': 'Richiama prepara_ordine.'}
# {'ok': True, 'inviato': 'SKU-11', 'approvato_da': 'mario'}

Un token inventato dal modello viene rifiutato; quello prodotto da prepara_ordine funziona, e solo una volta, perché pop lo consuma. Il punto strutturale: il secondo tool richiede un token che solo il primo può produrre. Il modello non può saltare il passaggio, perché non conosce il token. È un vincolo di protocollo, non un invito nel prompt, e la differenza fra i due è tutta la differenza fra un sistema sicuro e un sistema che si fida. Resta un limite: il token impedisce il salto, non garantisce che l'utente abbia davvero approvato; quella garanzia sta nell'interfaccia che raccoglie la conferma, non nel modello.

Testare un tool (è codice normale, e questo è il punto)

I tool sono la parte deterministica di un agente: funzioni pure o quasi, con input e output definiti, che si testano senza modello e senza spendere niente. Tieni la logica in una funzione normale (leggi_giacenza_impl) e applica @function_tool a un involucro sottile, così i test non dipendono da come l'SDK incapsula le funzioni, che è una delle cose che cambia fra versioni. Il blocco usa pytest nella forma consueta; io ho eseguito le stesse asserzioni come funzioni semplici, senza pytest, e riporto quell'uscita.

import asyncio, json
from lib import *

CLIENTI = {"ACME": 1, "Brambilla SRL": 2}
MAGAZZINO = {"SKU-11": {"giacenza": 4, "soglia": 10}}

def leggi_giacenza_impl(sku: str) -> dict:
    riga = MAGAZZINO.get(sku)
    if riga is None:
        return {"errore": "sku_inesistente", "sku": sku, "noti": sorted(MAGAZZINO)}
    return {"sku": sku, **riga}

@function_tool
def leggi_giacenza(sku: str) -> dict:
    """Giacenza attuale e soglia di riordino di uno SKU."""
    return leggi_giacenza_impl(sku)           # involucro sottile: la logica sta nella _impl

def cerca_cliente_impl(nome: str) -> dict:
    if nome in CLIENTI:
        return {"ok": True, "id": CLIENTI[nome]}
    simili = [c for c in CLIENTI if nome.lower() in c.lower() or c.lower() in nome.lower()]
    return {"ok": False, "errore": "cliente_non_trovato",
            "suggerimenti": simili or sorted(CLIENTI)[:5],
            "come_rimediare": "Richiama il tool con uno dei nomi suggeriti."}

class Ordine(BaseModel):
    sku: str = Field(pattern=r"^SKU-\d{2,6}$")
    quantita: int = Field(ge=1, le=1000)
    fornitore: str

def test_sku_inesistente_restituisce_errore_non_solleva():
    esito = leggi_giacenza_impl("SKU-NON-ESISTE")
    assert esito["errore"] == "sku_inesistente"
    assert "noti" in esito                     # il suggerimento c'è

def test_cliente_parziale_ha_suggerimento():
    assert cerca_cliente_impl("Brambilla")["suggerimenti"] == ["Brambilla SRL"]

def test_schema_rifiuta_sku_malformati():
    for cattivo in ["", "11", "sku-11", "SKU-", "SKU-1234567"]:
        try:
            Ordine(sku=cattivo, quantita=1, fornitore="ACME")
        except ValidationError:
            continue
        raise AssertionError(f"accettato: {cattivo!r}")

for t in (test_sku_inesistente_restituisce_errore_non_solleva,
          test_cliente_parziale_ha_suggerimento, test_schema_rifiuta_sku_malformati):
    t(); print("ok:", t.__name__)
# ok: test_sku_inesistente_restituisce_errore_non_solleva
# ok: test_cliente_parziale_ha_suggerimento
# ok: test_schema_rifiuta_sku_malformati

Nota la prima asserzione, che è una scelta di progetto resa esecutabile: il test verifica che il tool non solleva e che l'errore contiene il suggerimento. Se qualcuno domani «sistema» quella funzione trasformando il dizionario in un'eccezione, il test diventa rosso e spiega perché. Il terzo test prova i vincoli del modello Pydantic, cioè la barriera di cui sopra: essa rifiuta le cinque forme errate di SKU, anche se, come hai visto, il modello non ne riceverebbe il motivo.

Trade-off

Quattro scelte tirano in direzioni opposte. Pochi tool generici contro molti tool specifici: pochi tool riducono gli errori di scelta e il contesto occupato, ma ogni tool diventa una funzione con molti rami da validare; molti tool specifici sono semplici da scrivere e difficili da scegliere bene. Schemi stretti contro schemi permissivi: stretti (enum, pattern, range) significano più rifiuti, più giri e meno danni; permissivi, meno attriti e più spazzatura che entra nel sistema; per i tool che scrivono, stringi sempre.

Descrizioni lunghe contro contesto: la descrizione di ogni tool viene inviata a ogni giro, quindi dieci tool con cinque righe di docstring sono un costo fisso ricorrente; vale la riga che evita una chiamata sbagliata, non quella che ripete l'ovvio. Tool sincroni contro asincroni: i sincroni sono più semplici ma bloccano l'event loop, gli asincroni sono corretti sotto carico e richiedono client async in tutta la catena.

Le trappole in cui si cade davvero

  1. Trattare gli argomenti come dati fidati. Li genera un modello sulla base di testo che può arrivare da un utente o da una pagina web. Un tool esegui_sql(query: str) è una SQL injection con passaggi aggiuntivi; un tool leggi_file(percorso: str) senza normalizzazione è una path traversal. Lo schema non è un controllo di sicurezza: il controllo lo scrivi dentro la funzione.
  2. Tool non idempotenti senza chiave di deduplicazione. Il modello può ripetere una chiamata (perché il risultato non gli è chiaro, perché c'è stato un retry). Se il tool invia un ordine, l'ordine va due volte. Soluzione: accetta un identificativo di operazione e scarta i duplicati.
  3. Nomi sovrapposti. cerca_ordine e trova_ordine nello stesso catalogo garantiscono scelte casuali. Un'intenzione, un tool, un nome.
  4. Mettere segreti nello schema. Un parametro api_key: str verrà inventato dal modello. Le credenziali si leggono dall'ambiente dentro la funzione.
  5. Restituire testo libero al posto di dati. «Ho trovato tre ordini, il più recente è di martedì» costringe il modello a riparsare prosa. Restituisci strutture; la prosa la scrive lui alla fine.
  6. Dimenticare che il ritorno del tool è testo non fidato. Se un tool legge una pagina web o la mail di un cliente, quel contenuto entra nel contesto e può contenere istruzioni. È il tema della lezione sulla prompt injection, e si risolve a livello di architettura, non di schema.

Le sei trappole hanno due radici. Le prime quattro nascono dal dimenticare che il modello è un autore di input: argomenti non fidati, chiamate ripetute, nomi ambigui, segreti che inventa. Le ultime due nascono dal dimenticare che il tool è anche un produttore di contesto: testo libero che va riparsato, contenuto esterno che può portare istruzioni. In entrambi i casi la difesa sta nella forma del tool e nel codice che lo circonda, non nel prompt.

Che cosa sai fare adesso

Sai leggere lo schema che l'SDK genera dai type hint e dalla docstring, sai che i default restano obbligatori nello schema strict e che gli errori di validazione Pydantic non arrivano al modello con il dettaglio, sai scrivere tool che restituiscono errori utili e tool che si fermano sugli errori di sistema, e sai chiudere la superficie di un tool pericoloso (query, file, richieste HTTP) e renderlo idempotente. Nella prossima lezione, Output strutturato, giriamo la freccia: non più dati che entrano nel tool, ma dati tipizzati che escono dall'agente.

Fonti

Eseguiti due volte con uscita identica (Python 3.14.4, openai-agents 0.24.0, pydantic 2.14.3) contro il finto Ollama: schema, validazione, gestione errori, autorizzazione, path traversal, idempotenza, conferma, asserzioni dei test. Non eseguiti: i due esempi di docstring a confronto (frammenti), il frammento di paginazione, qualunque tool con un database o una rete veri, e pytest. Le stime di token sono stime.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti