Il problema: il modello propone, il tuo codice dispone
Quando un agente «usa uno strumento», il modello non esegue niente: produce un messaggio con il nome dello strumento e gli argomenti, e sei tu a eseguirlo e a rimandargli il risultato. Nel formato documentato da Ollama la chiamata è un elemento tool_calls con type: function, il nome e gli argomenti; la risposta torna in un messaggio con ruolo tool e il campo tool_name. Il protocollo MCP lo chiama tools/call, l'API di Anthropic usa blocchi tool_result.
Cambiano le etichette, la struttura è la stessa, ed è il motivo per cui questo articolo parla del tuo codice e non di un fornitore: tutto ciò che sta tra la proposta del modello e l'effetto nel mondo (lo schema, la validazione, i permessi, la gestione degli errori) è tuo. Il formato del messaggio l'abbiamo visto in Fondamenti: OODA Loop, ReAct e Tool Calling; qui si va oltre, verso strumenti che toccano API e database.
I listati sono eseguiti in locale con Python 3.14.4. Cinque usano solo la libreria standard; il secondo usa jsonschema 4.26.0, installato in un ambiente virtuale fuori dal repository. Nessun modello è stato chiamato, nessun servizio raggiunto: gli «argomenti prodotti dal modello» sono dizionari scritti a mano, e dove una cosa richiederebbe un servizio vero lo dico.
Definire lo strumento: lo schema è il contratto
Uno strumento ha un nome, una descrizione e uno schema dei parametri in JSON Schema: è la forma che prendono tutte le definizioni citate sopra (nel protocollo MCP il campo si chiama inputSchema). Il modello vede solo questo testo, quindi la descrizione è l'unico posto in cui può imparare quando usare lo strumento.
La documentazione di Anthropic è esplicita: fornire descrizioni estremamente dettagliate è, nelle sue parole, il fattore più importante per le prestazioni, e una buona descrizione spiega che cosa fa lo strumento, quando va usato (e quando no), che cosa significa ogni parametro e quali sono i limiti, per esempio che cosa non restituisce.
Scrivere lo schema a mano per cinquanta funzioni è noioso e si disallinea dal codice. Per questo l'SDK Python di Ollama, nella sua documentazione, accetta direttamente una funzione e ne ricava la definizione dalla docstring. Il listato fa la stessa cosa in piccolo: un decoratore legge firma, tipi e valori predefiniti con inspect, costruisce la definizione e la registra; i parametri senza valore predefinito diventano required e additionalProperties: false vieta i parametri inventati.
import inspect, json
TIPI = {str: "string", int: "integer", float: "number", bool: "boolean"}
REGISTRO = {}
def strumento(funzione):
"""Costruisce la definizione dalla funzione: nome, docstring, tipi e valori predefiniti."""
firma = inspect.signature(funzione)
proprieta, obbligatori = {}, []
for nome, p in firma.parameters.items():
proprieta[nome] = {"type": TIPI[p.annotation]}
if p.default is inspect.Parameter.empty:
obbligatori.append(nome)
else:
proprieta[nome]["default"] = p.default
definizione = {"type": "function", "function": {
"name": funzione.__name__,
"description": inspect.getdoc(funzione),
"parameters": {"type": "object", "properties": proprieta,
"required": obbligatori, "additionalProperties": False}}}
REGISTRO[funzione.__name__] = (funzione, definizione)
return funzione
@strumento
def cerca_ordini(cliente: str, stato: str = "aperto", limite: int = 5):
"""Cerca gli ordini di un cliente per stato. Non modifica nulla: per annullare
un ordine usa annulla_ordine. Restituisce al massimo `limite` ordini."""
return []
definizione = REGISTRO["cerca_ordini"][1]["function"]
print(definizione["name"])
print(definizione["description"].replace("\n", " "))
print(json.dumps(definizione["parameters"], ensure_ascii=False))
# Output:
# cerca_ordini
# Cerca gli ordini di un cliente per stato. Non modifica nulla: per annullare un ordine usa annulla_ordine. Restituisce al massimo `limite` ordini.
# {"type": "object", "properties": {"cliente": {"type": "string"}, "stato": {"type": "string", "default": "aperto"}, "limite": {"type": "integer", "default": 5}}, "required": ["cliente"], "additionalProperties": false}
L'uscita mostra che cosa si ottiene gratis e che cosa no. Nome, descrizione, tipi e parametri obbligatori vengono dal codice; stato e limite portano il loro valore predefinito. Mancano i vincoli che i tipi di Python non esprimono: nulla dice che limite non possa essere 5000 o che stato sia solo «aperto» o «chiuso».
Quei vincoli vanno aggiunti allo schema (o a una seconda copia di validazione), ed è il lavoro della prossima sezione. Un'altra conseguenza: la descrizione è la docstring, quindi rivedere la docstring significa cambiare il comportamento dell'agente, e va trattata come codice, con revisione e test (come si fa in Testing e Evaluation).
Una descrizione efficace risponde a quattro domande, che sono quelle della documentazione citata:
- che cosa fa lo strumento, e che cosa non fa («non modifica nulla»);
- quando usarlo e quando preferire un altro (nel listato, «per annullare un ordine usa annulla_ordine»);
- che cosa significa ogni parametro e che cosa cambia scegliendo un valore o un altro;
- i limiti: quanti risultati restituisce, che cosa manca nella risposta.
Le quattro voci non hanno un ordine, ma la seconda è la più trascurata: un modello con due strumenti dai nomi vicini sceglie male se la descrizione non dice dove finisce uno e comincia l'altro, e l'errore non si vede nello schema, si vede nelle scelte dell'agente. Per questo conviene avere casi di prova in cui lo strumento giusto non è quello più ovvio.
Validare gli argomenti prima di eseguire
Gli argomenti li ha prodotti un modello, e non c'è garanzia che rispettino lo schema: possono avere il tipo sbagliato, valori fuori scala o parametri che non esistono. La specifica MCP, nelle considerazioni di sicurezza, dice che i server DEVONO validare tutti gli input degli strumenti, applicare controlli di accesso, limitare la frequenza delle invocazioni e ripulire gli output. Il listato usa jsonschema con uno schema più stretto di quello generato prima, e prova cinque chiamate come potrebbe produrle un modello.
from jsonschema import Draft202012Validator
SCHEMA = {"type": "object",
"properties": {"cliente": {"type": "string", "minLength": 1, "maxLength": 40},
"stato": {"enum": ["aperto", "chiuso"]},
"limite": {"type": "integer", "minimum": 1, "maximum": 50}},
"required": ["cliente"], "additionalProperties": False}
validatore = Draft202012Validator(SCHEMA)
CHIAMATE = [ # come potrebbe produrle un modello: nessuna garanzia sulla forma
{"cliente": "Rossi", "stato": "aperto", "limite": 5},
{"cliente": "Rossi", "limite": "cinque"},
{"cliente": "Rossi", "limite": 5000, "stato": "sospeso"},
{"stato": "aperto"},
{"cliente": "Rossi", "ordina_per": "prezzo"},
]
for numero, argomenti in enumerate(CHIAMATE, 1):
errori = sorted(validatore.iter_errors(argomenti), key=lambda e: list(e.path))
if not errori:
print(numero, "ok")
for e in errori:
print(numero, "/".join(map(str, e.path)) or "(radice)", "->", e.message)
# Output:
# 1 ok
# 2 limite -> 'cinque' is not of type 'integer'
# 3 limite -> 5000 is greater than the maximum of 50
# 3 stato -> 'sospeso' is not one of ['aperto', 'chiuso']
# 4 (radice) -> 'cliente' is a required property
# 5 (radice) -> Additional properties are not allowed ('ordina_per' was unexpected)
La chiamata 1 passa. La 2 passa una stringa dove serve un intero; la 3 sbaglia due campi insieme e il validatore li riporta entrambi (iter_errors raccoglie tutti gli errori e non si ferma al primo, il che è prezioso, perché il modello può correggerli in un colpo solo); la 4 dimentica il campo obbligatorio; la 5 inventa ordina_per, e la proprietà viene respinta perché lo schema ha additionalProperties: false.
Un dettaglio che ho verificato: il validatore non applica i valori predefiniti. Con {"type": "integer", "default": 5} e un'istanza vuota non segnala errori e non inserisce il 5: nello schema, default è un'annotazione, quindi i valori predefiniti li applica il tuo codice dopo la validazione.
Il database come strumento: parametri, elenchi consentiti, sola lettura
I database sono lo strumento che più rende e più rischia. Il rischio concreto non è che il modello «diventi cattivo»: è che il testo di un utente, o di un documento che l'agente ha letto, finisca nei parametri e da lì in una query.
L'OWASP, nella sua guida alla prevenzione della SQL injection, indica come difese preferite le query parametrizzate per i dati, e per le parti strutturali che non si possono parametrizzare (nomi di tabella e colonna, direzione dell'ordinamento) una lista chiusa di valori fissi, decisi dall'applicazione; sconsiglia di affidarsi all'escaping manuale. A questo si aggiunge il minimo privilegio: l'identità che apre il database non deve poter fare più di ciò che serve. La lista OWASP sugli agenti con «Excessive Agency» elenca proprio funzioni, permessi e autonomia eccessivi tra le cause di danni.
Il listato crea un database SQLite temporaneo e lo riapre in sola lettura con l'URI ?mode=ro, il modo documentato da Python. Poi confronta una query parametrizzata con una costruita a stringa usando lo stesso input ostile, x' OR '1'='1, e mostra come si difende una colonna scelta dal modello (non parametrizzabile) con un elenco ammesso.
import os, shutil, sqlite3, tempfile
cartella = tempfile.mkdtemp()
percorso = os.path.join(cartella, "negozio.db")
scrittura = sqlite3.connect(percorso)
scrittura.execute("CREATE TABLE ordini (id INTEGER, cliente TEXT, totale REAL)")
scrittura.executemany("INSERT INTO ordini VALUES (?, ?, ?)",
[(1, "Rossi", 40.0), (2, "Bianchi", 75.5), (3, "Rossi", 12.0)])
scrittura.commit()
scrittura.close()
# Il tool apre il database in sola lettura: anche una query sbagliata non puo' scrivere
db = sqlite3.connect(f"file:{percorso}?mode=ro", uri=True)
TABELLE_AMMESSE = {"ordini": ["id", "cliente", "totale"]}
LIMITE_MASSIMO = 50
def ordini_di(cliente, limite=5):
limite = min(int(limite), LIMITE_MASSIMO)
return db.execute("SELECT id, totale FROM ordini WHERE cliente = ? ORDER BY id LIMIT ?",
(cliente, limite)).fetchall()
def ordini_di_sbagliato(cliente): # NON farlo: concatena il testo del modello
return db.execute(f"SELECT id, totale FROM ordini WHERE cliente = '{cliente}'").fetchall()
malevolo = "x' OR '1'='1"
print("parametrizzata, input normale :", ordini_di("Rossi"))
print("parametrizzata, input ostile :", ordini_di(malevolo))
print("concatenata, input ostile :", ordini_di_sbagliato(malevolo))
def ordina_per(colonna): # i nomi di colonna non si parametrizzano
if colonna not in TABELLE_AMMESSE["ordini"]:
raise ValueError(f"colonna non ammessa: {colonna!r}")
return db.execute(f"SELECT id FROM ordini ORDER BY {colonna} DESC LIMIT 1").fetchall()
for tentativo in ("totale", "totale; DROP TABLE ordini"):
try:
print("colonna ammessa :", tentativo, "->", ordina_per(tentativo))
except ValueError as errore:
print("colonna rifiutata :", errore)
try:
db.execute("DELETE FROM ordini")
except sqlite3.OperationalError as errore:
print("scrittura in sola lettura :", errore)
db.close()
shutil.rmtree(cartella)
# Output:
# parametrizzata, input normale : [(1, 40.0), (3, 12.0)]
# parametrizzata, input ostile : []
# concatenata, input ostile : [(1, 40.0), (2, 75.5), (3, 12.0)]
# colonna ammessa : totale -> [(2,)]
# colonna rifiutata : colonna non ammessa: 'totale; DROP TABLE ordini'
# scrittura in sola lettura : attempt to write a readonly database
Con il parametro legato, l'input ostile viene trattato come un nome cliente che non esiste e restituisce zero righe. Con la stringa concatenata diventa parte della query e restituisce tutti gli ordini, anche quelli di clienti che non c'entrano: è una fuga di dati, non solo un errore. La colonna per l'ordinamento passa solo se è nell'elenco, e il tentativo con ; DROP TABLE viene rifiutato prima di arrivare al database.
Infine il DELETE fallisce con attempt to write a readonly database, che è la protezione di ultima istanza: se tutto il resto sbaglia, la connessione non ha il permesso di scrivere. Un'avvertenza sul listato: in SQLite un execute esegue una sola istruzione, quindi l'attacco con ; DROP avrebbe comunque fallito; con altri database e altri driver non è garantito, e la difesa non deve dipendere da questa proprietà.
Il resto della sicurezza degli agenti (injection indiretta da documenti, conferma umana per azioni distruttive, limiti di frequenza) è nell'articolo Security e Safety. Qui basta il principio: un'operazione irreversibile non deve poter partire da un solo messaggio del modello.
Che cosa rimandare al modello: l'esito, non l'eccezione
Quando uno strumento fallisce, il modello deve poterlo capire. La specifica MCP distingue due casi: gli errori di protocollo (strumento sconosciuto, richiesta malformata), restituiti come errori JSON-RPC, e gli errori di esecuzione (un'API che non risponde, un input non valido, una regola di business violata), riportati nel risultato con isError: true e un testo azionabile, per esempio «la data di partenza deve essere nel futuro». Il testo della specifica dice che i secondi permettono al modello di correggersi e riprovare, e che i client dovrebbero passarglieli.
Il listato adotta la stessa idea con un involucro minimo (ok, e in caso di errore tipo, messaggio e ritentabile). Aggiunge due cose che la specifica non decide per te. Primo, il segreto: il token non è un parametro dello strumento, quindi il modello non lo vede e non può scriverlo in un prompt; lo aggiunge il runtime al momento della chiamata e il log lo maschera. Secondo, la distinzione tra errori temporanei (si ritenta) e errori degli argomenti (ritentare non serve, si rimanda l'errore al modello).
SEGRETI = {"API_ORDINI": "tok_di_prova_12345"} # in produzione: gestore di segreti, mai nel prompt
LOG, TENTATIVI_FALLITI = [], {"stato_ordine": 0}
class ErroreTemporaneo(Exception):
pass
def stato_ordine(id_ordine: int, token: str):
"""Il modello non vede `token`: lo aggiunge il runtime."""
if TENTATIVI_FALLITI["stato_ordine"] < 2: # fallisce due volte, poi funziona
TENTATIVI_FALLITI["stato_ordine"] += 1
raise ErroreTemporaneo("503 dal servizio ordini")
if id_ordine <= 0:
raise ValueError("id_ordine deve essere positivo")
return {"id": id_ordine, "stato": "spedito"}
def esito_ok(dati):
return {"ok": True, "dati": dati}
def esito_errore(tipo, messaggio, ritentabile):
return {"ok": False, "errore": {"tipo": tipo, "messaggio": messaggio, "ritentabile": ritentabile}}
def chiama(funzione, argomenti, massimo=3):
for tentativo in range(1, massimo + 1):
richiesta = f"{funzione.__name__}({argomenti}, token={SEGRETI['API_ORDINI']})"
LOG.append(richiesta.replace(SEGRETI["API_ORDINI"], "***")) # il log non contiene il segreto
try:
return esito_ok(funzione(**argomenti, token=SEGRETI["API_ORDINI"]))
except ErroreTemporaneo as e:
if tentativo == massimo:
return esito_errore("temporaneo", str(e), True)
except ValueError as e: # errore di chi chiama: ritentare non serve
return esito_errore("argomento", str(e), False)
print(chiama(stato_ordine, {"id_ordine": 7}))
print(chiama(stato_ordine, {"id_ordine": -1}))
print(len(LOG), "righe di log; la prima:", LOG[0])
print("segreto nel log:", any(SEGRETI["API_ORDINI"] in riga for riga in LOG))
# Output:
# {'ok': True, 'dati': {'id': 7, 'stato': 'spedito'}}
# {'ok': False, 'errore': {'tipo': 'argomento', 'messaggio': 'id_ordine deve essere positivo', 'ritentabile': False}}
# 4 righe di log; la prima: stato_ordine({'id_ordine': 7}, token=***)
# segreto nel log: False
La prima chiamata fallisce due volte con un errore temporaneo e riesce alla terza; l'agente vede solo l'esito finale. La seconda ha un argomento sbagliato: nessun nuovo tentativo, e il modello riceve ritentabile: false con il motivo. Le quattro righe di log sono tre tentativi più uno, e nessuna contiene il token. Non c'è attesa tra i tentativi, apposta: backoff con jitter e interruttore sono nel listato di Orchestrazione Multi-Agente, e aggiungerli qui avrebbe solo allungato il codice. Per i segreti, il perimetro è questo: fuori dal prompt, fuori dai log e fuori dai risultati restituiti al modello.
Dalle API agli strumenti: generare le definizioni da OpenAPI
Se un servizio pubblica una descrizione OpenAPI, le definizioni degli strumenti si possono ricavare dal documento. La specifica OpenAPI 3.1 dice che operationId è una stringa che identifica l'operazione, che deve essere unica tra tutte le operazioni descritte e che «gli strumenti e le librerie possono usarla per identificare in modo univoco un'operazione»: è il nome naturale dello strumento. Il listato percorre i paths, trasforma parametri di percorso e di query in proprietà dello schema e, per scelta di prudenza, importa soltanto i metodi get: il resto viene elencato come saltato.
import json
OPENAPI = {"openapi": "3.1.0", "info": {"title": "Ordini", "version": "1.0"},
"paths": {
"/ordini/{id}": {
"get": {"operationId": "leggiOrdine", "summary": "Legge un ordine",
"parameters": [{"name": "id", "in": "path", "required": True,
"schema": {"type": "integer"}}]},
"delete": {"operationId": "eliminaOrdine", "summary": "Elimina un ordine",
"parameters": [{"name": "id", "in": "path", "required": True,
"schema": {"type": "integer"}}]}},
"/ordini": {
"get": {"operationId": "elencaOrdini", "summary": "Elenca gli ordini",
"parameters": [{"name": "stato", "in": "query", "schema": {"type": "string"}},
{"name": "limite", "in": "query", "schema": {"type": "integer"}}]},
"post": {"summary": "Crea un ordine",
"requestBody": {"content": {"application/json": {"schema": {
"type": "object", "required": ["cliente"],
"properties": {"cliente": {"type": "string"}}}}}}}}}}
def da_openapi(doc, metodi_ammessi=("get",)):
strumenti, saltati = [], []
for percorso, operazioni in doc["paths"].items():
for metodo, op in operazioni.items():
nome = op.get("operationId")
if metodo not in metodi_ammessi:
saltati.append(f"{metodo.upper()} {percorso} (metodo non ammesso)")
continue
if not nome:
saltati.append(f"{metodo.upper()} {percorso} (senza operationId)")
continue
proprieta = {p["name"]: p["schema"] for p in op.get("parameters", [])}
obbligatori = [p["name"] for p in op.get("parameters", []) if p.get("required")]
strumenti.append({"name": nome, "description": op["summary"],
"parameters": {"type": "object", "properties": proprieta,
"required": obbligatori}})
return strumenti, saltati
strumenti, saltati = da_openapi(OPENAPI)
for s in strumenti:
print(s["name"], "|", s["description"], "|", json.dumps(s["parameters"]["properties"]),
"| obbligatori:", s["parameters"]["required"])
print("saltati:", saltati)
print("con POST ammesso, saltati:", da_openapi(OPENAPI, ("get", "post"))[1])
# Output:
# leggiOrdine | Legge un ordine | {"id": {"type": "integer"}} | obbligatori: ['id']
# elencaOrdini | Elenca gli ordini | {"stato": {"type": "string"}, "limite": {"type": "integer"}} | obbligatori: []
# saltati: ['DELETE /ordini/{id} (metodo non ammesso)', 'POST /ordini (metodo non ammesso)']
# con POST ammesso, saltati: ['DELETE /ordini/{id} (metodo non ammesso)', 'POST /ordini (senza operationId)']
Le due operazioni di lettura diventano strumenti. La cancellazione e la creazione non entrano: la regola è quella di minimizzare le funzioni a disposizione dell'agente, che l'OWASP indica come prima misura contro l'«Excessive Agency». Il secondo calcolo, con il post ammesso, mostra un altro limite: la creazione non ha un operationId, quindi non ha un nome stabile e va saltata o nominata a mano.
Il listato non gestisce il corpo delle richieste, i riferimenti $ref, l'autenticazione definita nello schema di sicurezza né altri formati come GraphQL, che non ho provato. Una definizione generata resta una bozza: la descrizione di summary è scritta per un umano che legge una documentazione, non per un modello che deve scegliere, e va quasi sempre riscritta.
Lavori lunghi e troppi strumenti
Alcune operazioni non finiscono in un turno: analisi di un archivio, una compilazione, un report. Lo schema che regge è quello del lavoro con un identificativo: lo strumento di avvio risponde subito con un id, un secondo strumento ne legge lo stato, un terzo lo annulla. Il listato simula il lavoro con un generatore, uno strumento di avvio e un controllo con un tetto: dopo un certo numero di controlli l'agente rinuncia e lo dice, invece di aspettare per sempre. Il lavoro vero girerebbe in un processo separato; qui ogni chiamata a lavora fa avanzare di un passo.
def analisi_lunga(file):
"""Un lavoro lungo come generatore: ogni yield e' un passo, con percentuale e messaggio."""
for passo, messaggio in enumerate(("lettura", "analisi", "report"), 1):
yield round(100 * passo / 3), f"{messaggio} di {file}"
class Lavori:
def __init__(self):
self.lavori, self.stato = {}, {}
def avvia(self, generatore): # il tool risponde subito con un identificativo
id_lavoro = f"job-{len(self.lavori) + 1}"
self.lavori[id_lavoro] = generatore
self.stato[id_lavoro] = {"stato": "in_corso", "percentuale": 0, "messaggio": "in coda"}
return id_lavoro
def lavora(self, id_lavoro): # in produzione: un processo a parte
if self.stato[id_lavoro]["stato"] != "in_corso":
return
try:
percentuale, messaggio = next(self.lavori[id_lavoro])
self.stato[id_lavoro] = {"stato": "in_corso", "percentuale": percentuale, "messaggio": messaggio}
except StopIteration:
self.stato[id_lavoro]["stato"] = "finito"
def annulla(self, id_lavoro):
self.stato[id_lavoro] = {"stato": "annullato", "percentuale": self.stato[id_lavoro]["percentuale"],
"messaggio": "annullato dall'utente"}
def attendi(lavori, id_lavoro, controlli_massimi):
"""L'agente interroga lo stato, ma con un tetto: dopo N controlli rinuncia e lo dice."""
for n in range(1, controlli_massimi + 1):
lavori.lavora(id_lavoro)
stato = lavori.stato[id_lavoro]
print(f" controllo {n}: {stato['stato']}, {stato['percentuale']}%, {stato['messaggio']}")
if stato["stato"] != "in_corso":
return stato["stato"]
return "ancora_in_corso"
lavori = Lavori()
primo = lavori.avvia(analisi_lunga("log.txt"))
print(primo, "->", attendi(lavori, primo, controlli_massimi=6))
secondo = lavori.avvia(analisi_lunga("db.sql"))
print(secondo, "->", attendi(lavori, secondo, controlli_massimi=2))
lavori.annulla(secondo)
print(secondo, "dopo l'annullamento:", lavori.stato[secondo])
# Output:
# controllo 1: in_corso, 33%, lettura di log.txt
# controllo 2: in_corso, 67%, analisi di log.txt
# controllo 3: in_corso, 100%, report di log.txt
# controllo 4: finito, 100%, report di log.txt
# job-1 -> finito
# controllo 1: in_corso, 33%, lettura di db.sql
# controllo 2: in_corso, 67%, analisi di db.sql
# job-2 -> ancora_in_corso
# job-2 dopo l'annullamento: {'stato': 'annullato', 'percentuale': 67, 'messaggio': "annullato dall'utente"}
Il primo lavoro arriva a 100% al terzo controllo e al quarto risulta finito; il secondo, con un tetto di due controlli, resta «ancora_in_corso» e viene annullato. Il dettaglio di progetto che conta è chi fa i controlli: se li fa il modello, ognuno è un turno di conversazione che costa token e latenza, quindi conviene che sia il tuo codice a interrogare lo stato a intervalli e a riportare al modello solo l'esito o un aggiornamento significativo. Non ho misurato quanto costi, perché non ho chiamato modelli.
L'ultimo problema è il numero. Con decine di strumenti, descriverli tutti in ogni prompt costa token e rende più difficile la scelta; la guida di Microsoft sui pattern di orchestrazione, citata nell'articolo precedente, include il «tool overload» tra i motivi per cui un singolo agente può non bastare.
Una risposta è selezionare, per ogni richiesta, solo gli strumenti pertinenti cercandoli per somiglianza tra la richiesta e la loro descrizione, con la stessa tecnica del recupero descritta in Memory Systems, soglia compresa. Non l'ho eseguita qui, e va misurata sul tuo insieme di strumenti: la selezione sbagliata toglie al modello proprio lo strumento che serviva.
Domande frequenti
Chi esegue davvero uno strumento in un agente?
Il tuo codice. Il modello produce soltanto una richiesta con il nome e gli argomenti; sta a te validarla, eseguirla e rimandargli il risultato, ed è per questo che validazione, permessi e gestione degli errori sono responsabilità dell'applicazione e non del modello.
Devo validare gli argomenti se lo schema è già nel prompt?
Sì. Lo schema nel prompt guida il modello ma non lo vincola: la specifica MCP prescrive ai server di validare tutti gli input. Il listato mostra tipi sbagliati, valori fuori scala e parametri inventati che solo il validatore ferma.
Come impedisco che un agente cancelli dati con una query?
Con più livelli: query parametrizzate, elenchi chiusi per ciò che non si parametrizza, connessione con i soli privilegi necessari (nel listato, sola lettura) e conferma esplicita per le azioni irreversibili. Nessun livello da solo basta.
Che cosa sai fare adesso
Sai ricavare una definizione di strumento dal codice sapendo che cosa manca (i vincoli) e scrivere una descrizione che dice anche quando non usarlo. Sai validare gli argomenti prodotti da un modello e applicare tu i valori predefiniti, aprire un database con i permessi minimi e distinguere i dati dalla struttura di una query. Sai restituire al modello un esito che gli permette di correggersi, tenere i segreti fuori dal prompt e dai log, generare bozze di strumenti da una descrizione OpenAPI limitandoti alla lettura, e gestire un lavoro lungo con un identificativo e un tetto di controlli.
I lavori lunghi con identificativo trovano il loro seguito nel deployment in produzione, dove un compito interrotto da un arresto riprende; e ogni chiamata a uno strumento ha un prezzo, che l'articolo sul FinOps porta a un budget per compito.
Fonti
- Tool calling (documentazione Ollama) - formato di
tool_calls, messaggio con ruolotooletool_name, definizione ricavata dalla docstring nell'SDK Python; consultata il 2026-11-12 - Tools (specifica del Model Context Protocol) -
inputSchema,tools/call, errori di protocollo e di esecuzione conisError, considerazioni di sicurezza; consultata il 2026-11-12 - How to implement tool use (documentazione Anthropic) - blocchi
tool_resulte indicazioni sulle descrizioni degli strumenti; consultata il 2026-11-12 - SQL Injection Prevention Cheat Sheet (OWASP) - query parametrizzate, elenchi chiusi per le parti strutturali, minimo privilegio; consultata il 2026-11-12
- LLM06:2025 Excessive Agency (OWASP GenAI Security Project) - funzioni, permessi e autonomia eccessivi come cause di danno; consultata il 2026-11-12
- sqlite3 - DB-API 2.0 interface for SQLite databases (documentazione Python) - segnaposto
?e apertura in sola lettura conmode=ro; consultata il 2026-11-12 - OpenAPI Specification v3.1.1 - definizione di
operationId; consultata il 2026-11-12 - jsonschema su PyPI - versione 4.26.0 installata in un ambiente virtuale fuori dal repository; il comportamento sui valori predefiniti e sui messaggi d'errore è osservato eseguendo i listati, non letto dalla documentazione

Commenti
Caricamento commenti...
Accedi per commentare