Salta al contenuto principale

88 - Handoff: delegare il controllo a un agente specializzato

Un handoff è un tool che cede il controllo a un altro agente. Come si dichiara, cosa vede chi lo riceve, quanto costa e come si spezza un rimpallo.

Cosa ne pensi di questo articolo?

88 - Handoff: delegare il controllo a un agente specializzato
Machine Learning Engineerfedericocalo.dev

In Breve

Un handoff è un tool che cede il controllo a un altro agente. Come si dichiara, cosa vede chi lo riceve, quanto costa e come si spezza un rimpallo.

Un handoff è un tool particolare: quando il modello lo chiama, non ottiene un risultato, cede il posto. Dal giro successivo è un altro agente, con altre istruzioni e altri tool, a rispondere, e la sua risposta va direttamente all'utente. Il primo agente non la rivede. È il modo più semplice per avere specialisti invece di un unico agente con quaranta tool, e il modo più semplice per costruire, senza accorgersene, due agenti che si rimpallano il controllo per sempre.

Versione provata. openai-agents 0.24.0 su Python 3.14.4, il 2026-11-12, con un modello finto al posto del modello vero (lo spiego più sotto). Gli handoff sono una delle astrazioni più caratterizzanti di questo SDK e i nomi dei parametri possono cambiare fra versioni: fissa la versione nel tuo requirements.txt e rileggi la pagina della documentazione sugli handoff quando la aggiorni.

L'analogia: il centralino e lo sportello

Chiami un'azienda. Risponde il centralino: non sa nulla di fatture né di guasti, sa solo riconoscere di cosa stai parlando e passarti la persona giusta. Quando ti passa l'ufficio amministrazione succedono due cose insieme: l'amministrazione ti parla direttamente (il centralinista non ripete le sue frasi) e il centralinista esce dalla conversazione. Se poi l'amministrazione decide che il tuo è un problema tecnico, ti passa di nuovo, e se il tecnico ti rimanda all'amministrazione sei nel girone infernale che tutti conosciamo.

Questa analogia contiene già i tre temi dell'articolo: chi decide il trasferimento, quanto della conversazione precedente viene passato, e come si evita il rimbalzo infinito.

Perché un handoff, invece di un unico agente con più tool

È la domanda da fare prima di qualunque codice, perché la risposta scontata («così è più ordinato») è quella sbagliata: l'ordine non giustifica da solo una classe di guasti nuova. Ci sono quattro ragioni per cui un handoff fa cose che un singolo agente con un catalogo più grande non può fare, e due di esse sono strutturali.

Ragione 1: istruzioni incompatibili. È la ragione più forte e la meno citata. L'ufficio rimborsi ha un mandato preciso: verifica l'ordine, applica la politica dei 50 euro, chiudi. Il supporto tecnico ne ha un altro: chiedi i dettagli dell'errore, procedi per esclusione, apri un ticket se non risolvi. Queste due istruzioni non si sommano, si diluiscono. Un unico system prompt che le contiene entrambe costringe il modello a tenere in mente due politiche, e il rischio concreto è che ne applichi una a metà: chiede i dettagli dell'errore prima di un rimborso, o rimborsa senza verificare perché «era un problema tecnico». Con due agenti ciascuno vede solo il proprio mandato.

Ragione 2: permessi diversi. Il tool emetti_rimborso muove soldi. In un unico agente quel tool è nel catalogo sempre: durante una conversazione sul firmware di un dispositivo il modello ha comunque la facoltà tecnica di chiamarlo. Puoi scrivere nelle istruzioni che non deve, e sarà vero quasi sempre, ma «quasi sempre» su un'operazione che muove soldi non è una postura di sicurezza. Separando gli agenti, il tool di rimborso non esiste per l'agente tecnico. È la differenza fra un divieto e un'assenza, e questa ragione da sola basta a giustificare la separazione.

Ragione 3: il catalogo che si affolla. Ogni descrizione di tool occupa contesto a ogni singolo giro: più tool ci sono, più caratteri paghi a ogni chiamata (più sotto li conto). C'è anche un effetto sulla scelta, perché tool vicini di significato («cerca ordine», «cerca reso», «cerca spedizione») si prestano a essere confusi. Quanto peggiori la scelta al crescere del catalogo non lo so dire in generale: dipende dal modello e dalle descrizioni, e si misura sul tuo catalogo con la matrice di confusione che costruiamo nella sezione sul routing.

Ragione 4: modelli diversi per compiti diversi. Un agente di triage fa una cosa sola, classificare e instradare, e gira su ogni richiesta; è un compito che si può affidare a un modello più piccolo e veloce. Un agente che deve ragionare su una contraddizione contrattuale merita un modello più capace e gira su una frazione delle richieste. Con un unico agente paghi il modello più costoso su tutto; separando, lo paghi dove serve.

E ora la parte onesta, perché queste quattro ragioni non valgono sempre. Quando un singolo agente è la scelta migliore:

  • I compiti condividono il mandato. Se «cerca ordine», «cerca reso» e «cerca spedizione» rispondono tutti alla stessa politica e agli stessi permessi, separarli in tre agenti aggiunge giri e grafo senza aggiungere niente.
  • Il catalogo è piccolo e con confini netti. Con pochi tool il problema dell'affollamento non esiste, e un handoff costa una chiamata al modello in più per risolvere un problema che non hai.
  • L'utente cambia argomento continuamente. Con un grafo a stella ogni cambio di argomento passa dal router: un utente che alterna rimborsi e problemi tecnici paga un giro di instradamento a ogni passaggio. Un unico agente con entrambi i cataloghi è più fluido, se le ragioni 1 e 2 non si applicano.

Le tre voci hanno la stessa radice: l'handoff si paga con una chiamata in più e con un grafo da mantenere, e il pagamento è giustificato solo se ottieni mandati o permessi separati. Se ottieni soltanto ordine, hai speso latenza per estetica. Di qui il criterio che consiglio come primo filtro: separa quando cambiano il mandato o i permessi, non quando cambia l'argomento. Argomenti diversi con lo stesso mandato sono tool dello stesso agente; lo stesso argomento con mandati incompatibili sono due agenti.

Il meccanismo, in un disegno

Il flusso di un handoff fra agente di triage e agente specializzato utente "voglio un rimborso" agente TRIAGE nessun tool di dominio agente RIMBORSI tool: ordini, rimborsa politica: max 50 EUR agente TECNICO tool: diagnostica, ticket transfer_to_rimborsi() transfer_to_tecnico() la risposta va DIRETTA all'utente il TRIAGE non rientra più Se RIMBORSI può passare a TECNICO e TECNICO può passare a RIMBORSI, il grafo ha un ciclo: serve un limite.
In un handoff il controllo si sposta e non torna. È la differenza principale con l'agent-as-tool della prossima lezione.

Sotto il cofano non c'è nulla di magico. Per ogni agente nella lista handoffs l'SDK genera un tool con un nome convenzionale, transfer_to_ seguito dal nome dell'agente in minuscolo e con gli spazi trasformati in trattini bassi (la documentazione fa l'esempio di «Refund Agent», che diventa transfer_to_refund_agent), e lo aggiunge al catalogo del modello. Quando il modello chiama quel tool il runner non esegue una funzione: cambia l'agente attivo e rifà girare il ciclo. Il resto (descrizioni, schema, scelta) è il normale meccanismo di tool calling visto in Tool: function calling, schemi JSON e errori che l'agente deve poter leggere.

Il codice minimo, provato senza un modello vero

Per eseguire davvero i listati di questo articolo senza chiamare nessun modello ho scritto un modello finto: una classe che l'SDK accetta al posto di un modello (estende agents.Model), risponde con un copione fisso e registra ciò che riceve a ogni chiamata, cioè istruzioni, cronologia, tool e handoff disponibili. Questo ha un limite che devi tenere a mente: ciò che verifico è la meccanica dell'SDK (quale tool viene generato, chi risponde, che cosa vede l'agente che riceve il controllo), non la qualità delle decisioni di un modello vero. Dove una decisione serve, la prende il copione, e lo dico ogni volta. Il file si chiama fake.py e lo importano tutti i blocchi seguenti.

"""Un modello finto e deterministico: risponde con un copione e registra cio' che riceve."""
import itertools
import json

from agents import Model, ModelResponse, Usage
from openai.types.responses import (ResponseFunctionToolCall, ResponseOutputMessage,
                                    ResponseOutputText)

_contatore = itertools.count()


def chiama(nome, **argomenti):
    """Il modello decide di chiamare un tool (o un handoff, che per lui e' un tool)."""
    n = next(_contatore)
    return ResponseFunctionToolCall(type="function_call", name=nome, id=f"fc_{n}",
                                    call_id=f"call_{n}", arguments=json.dumps(argomenti))


def testo(frase):
    """Il modello risponde con un messaggio di testo."""
    return ResponseOutputMessage(id="m", type="message", role="assistant", status="completed",
                                 content=[ResponseOutputText(type="output_text", text=frase,
                                                             annotations=[])])


class ModelloFinto(Model):
    """`copione`: lista di passi, uno per chiamata. Un passo e' un output o una funzione
    input -> output. Se `copione` e' una funzione, risponde a ogni chiamata."""

    def __init__(self, copione, registro=None):
        self.copione = copione
        self.registro = registro if registro is not None else []

    async def get_response(self, system_instructions, input, model_settings, tools, output_schema,
                           handoffs, tracing, *, previous_response_id=None, conversation_id=None,
                           prompt=None):
        schemi = [(t.name, t.description, t.params_json_schema) for t in tools] + \
                 [(h.tool_name, h.tool_description, h.input_json_schema) for h in handoffs]
        self.registro.append({
            "istruzioni": system_instructions, "input": input,
            "tool": [t.name for t in tools], "handoff": [h.tool_name for h in handoffs],
            "descrizioni_handoff": {h.tool_name: h.tool_description for h in handoffs},
            "schemi_handoff": {h.tool_name: h.input_json_schema for h in handoffs},
            "caratteri": len(system_instructions or "") + len(json.dumps(schemi)) + len(json.dumps(input)),
        })
        passo = self.copione(input) if callable(self.copione) else self.copione.pop(0)
        passo = passo(input) if callable(passo) else passo
        return ModelResponse(output=passo if isinstance(passo, list) else [passo],
                             usage=Usage(), response_id=None)

    def stream_response(self, *args, **kwargs):
        raise NotImplementedError("il modello finto non fa streaming")

Le due funzioni chiama e testo costruiscono le due uscite possibili di un modello: una chiamata di tool (un handoff è, per il modello, la chiamata di un tool qualunque) o un messaggio finale. La classe ModelloFinto prende un copione, cioè una lista di passi consumati uno per chiamata, oppure una funzione che risponde a ogni chiamata, e nel registro salva le righe che ci servono: nomi dei tool, nomi e descrizioni degli handoff, cronologia ricevuta e un conteggio dei caratteri inviati. Gli identificatori delle chiamate sono generati da un contatore, perché nelle prove un identificatore ripetuto faceva ripartire il ciclo senza eseguire il trasferimento. Con questo in mano, ecco la dichiarazione minima.

import asyncio
from agents import Agent, Runner, function_tool
from fake import ModelloFinto, chiama, testo

@function_tool
def cerca_ordine(numero: str) -> dict:
    """Dettagli di un ordine dal suo numero."""
    return {"numero": numero, "importo": 42.0, "stato": "consegnato"}

@function_tool
def emetti_rimborso(numero: str, importo: float) -> dict:
    """Emette un rimborso su un ordine. Massimo 50 EUR senza approvazione."""
    if importo > 50:
        return {"ok": False, "errore": "oltre_il_limite",
                "come_rimediare": "Spiega all'utente che serve un'approvazione."}
    return {"ok": True, "rimborsato": importo}

visto = []          # qui il modello finto registra che cosa riceve a ogni giro

agente_rimborsi = Agent(
    name="Rimborsi",
    handoff_description="Gestisce richieste di rimborso e reso su ordini esistenti.",
    instructions=("Sei l'ufficio rimborsi. Verifica sempre l'ordine prima di rimborsare. "
                  "Oltre 50 EUR non puoi procedere: spiegalo e chiudi."),
    tools=[cerca_ordine, emetti_rimborso],
    model=ModelloFinto([chiama("cerca_ordine", numero="A-7781"),
                        testo("Ho rimborsato 42 EUR sull'ordine A-7781.")], visto),
)

agente_tecnico = Agent(
    name="Tecnico",
    handoff_description="Risolve malfunzionamenti, errori e problemi di configurazione.",
    instructions="Sei il supporto tecnico. Chiedi i dettagli dell'errore, poi guida l'utente.",
    model=ModelloFinto([], visto),
)

triage = Agent(
    name="Triage",
    instructions=(
        "Capisci di cosa ha bisogno l'utente e passa all'agente giusto. "
        "Non rispondere tu nel merito: non hai accesso ai dati. "
        "Se la richiesta non riguarda ne' rimborsi ne' problemi tecnici, "
        "dillo chiaramente all'utente e NON passare a nessuno."
    ),
    handoffs=[agente_rimborsi, agente_tecnico],
    model=ModelloFinto([chiama("transfer_to_rimborsi")], visto),
)

async def main():
    esito = await Runner.run(triage, "L'ordine A-7781 è arrivato rotto, voglio i soldi.")
    print("ha risposto:", esito.last_agent.name)
    print(esito.final_output)
    print("tool del triage:", visto[0]["tool"], "handoff:", visto[0]["handoff"])
    print("tool di Rimborsi:", visto[1]["tool"], "handoff:", visto[1]["handoff"])
    print(visto[0]["descrizioni_handoff"]["transfer_to_rimborsi"])

asyncio.run(main())

# Output:
# ha risposto: Rimborsi
# Ho rimborsato 42 EUR sull'ordine A-7781.
# tool del triage: [] handoff: ['transfer_to_rimborsi', 'transfer_to_tecnico']
# tool di Rimborsi: ['cerca_ordine', 'emetti_rimborso'] handoff: []
# Handoff to the Rimborsi agent to handle the request. Gestisce richieste di rimborso e reso su ordini esistenti.

L'uscita risponde alla domanda che conta: il triage non ha tool, ha solo due handoff; l'agente che risponde è Rimborsi, e i suoi tool sono quelli di dominio. Tre righe del codice determinano se il routing funziona.

  • handoff_description. È ciò che finisce nella descrizione del tool di trasferimento: l'ultima riga dell'uscita mostra che l'SDK la accoda a un testo predefinito. Non è documentazione, è il prompt del router. Scriverla male («Agente per varie cose») garantisce instradamenti casuali; scriverla con i confini («rimborso e reso su ordini esistenti») li rende molto più prevedibili.
  • «Non rispondere tu nel merito». Senza questa riga il triage tende a rispondere di testa sua, perché per un modello rispondere è più naturale che delegare. È un comportamento da correggere nelle istruzioni, e da misurare.
  • «Se non riguarda nessuno dei due, NON passare a nessuno». Il caso fuori perimetro va dichiarato, altrimenti il triage sceglie l'agente «meno sbagliato» e l'utente finisce dall'ufficio rimborsi per chiedere l'orario di apertura.

Le ultime due righe sono istruzioni in linguaggio naturale: funzionano finché il modello le rispetta, e per questo vanno verificate con il test di routing più sotto. La prima è diversa, perché è un dato dell'SDK che puoi leggere con certezza. Un dettaglio di progetto vale più di molti prompt: il triage non ha tool di dominio. Non può cercare ordini anche se volesse. Togliere una capacità è un vincolo strutturale; chiederne a parole il non-uso è una speranza.

Quanto costa un handoff

Un handoff non è gratis, e conviene sapere esattamente cosa paghi. Il conto ha due voci: una chiamata al modello in più (il giro del triage) e un testo di ingresso diverso a ogni chiamata. Il blocco seguente confronta un agente unico, con tre domini nello stesso catalogo, con un triage più uno specialista che porta solo i tool del proprio dominio. Ho contato i caratteri inviati al modello (istruzioni, schemi dei tool e degli handoff, cronologia), non i token: non avevo un tokenizzatore sotto mano, e i caratteri sono una misura riproducibile, proporzionale ai token ma non uguale.

import asyncio, json
from agents import Agent, FunctionTool, Runner
from fake import ModelloFinto, chiama, testo

def crea_tool(nome: str, descrizione: str) -> FunctionTool:
    async def esegui(_contesto, _argomenti: str) -> str:
        return json.dumps({"ok": True})
    schema = {"type": "object", "properties": {"numero": {"type": "string"}},
              "required": ["numero"], "additionalProperties": False}
    return FunctionTool(name=nome, description=descrizione, params_json_schema=schema,
                        on_invoke_tool=esegui)

def catalogo(prefisso: str, n: int) -> list[FunctionTool]:
    return [crea_tool(f"{prefisso}_{i:02d}", f"Operazione {i} del dominio {prefisso}: legge o "
                      "modifica un record a partire dal suo numero e restituisce l'esito.")
            for i in range(n)]

RICHIESTA = "L'ordine A-7781 è arrivato rotto, voglio i soldi."
ISTRUZIONI = "Sei un assistente clienti. Verifica l'ordine, poi applica la politica del dominio."

async def caratteri_inviati(agente: Agent, registro: list) -> int:
    await Runner.run(agente, RICHIESTA)
    return sum(v["caratteri"] for v in registro)

async def confronta(per_dominio: int) -> None:
    # Agente unico: tre domini nello stesso catalogo, tre giri (tool, tool, risposta)
    r1 = []
    unico = Agent(name="Unico", instructions=ISTRUZIONI,
                  tools=catalogo("rimborsi", per_dominio) + catalogo("tecnico", per_dominio)
                        + catalogo("spedizioni", per_dominio),
                  model=ModelloFinto([chiama("rimborsi_00", numero="A-7781"),
                                      chiama("rimborsi_00", numero="A-7781"), testo("Fatto.")], r1))
    # Triage + specialista: il triage non ha tool di dominio, lo specialista solo i suoi
    r2 = []
    specialista = Agent(name="Rimborsi", instructions=ISTRUZIONI, tools=catalogo("rimborsi", per_dominio),
                        handoff_description="Rimborsi e resi su ordini esistenti.",
                        model=ModelloFinto([chiama("rimborsi_00", numero="A-7781"),
                                            chiama("rimborsi_00", numero="A-7781"), testo("Fatto.")], r2))
    triage = Agent(name="Triage", instructions="Capisci la richiesta e passa all'agente giusto.",
                   handoffs=[specialista, Agent(name="Tecnico", instructions="x",
                                                handoff_description="Problemi tecnici.")],
                   model=ModelloFinto([chiama("transfer_to_rimborsi")], r2))
    a = await caratteri_inviati(unico, r1)
    b = await caratteri_inviati(triage, r2)
    print(f"{3 * per_dominio:>2} tool in tutto | agente unico: {len(r1)} chiamate, {a:>6} caratteri"
          f" | triage + specialista: {len(r2)} chiamate, {b:>6} caratteri")

async def main():
    for per_dominio in (1, 2, 6):
        await confronta(per_dominio)

asyncio.run(main())

# Output:
#  3 tool in tutto | agente unico: 3 chiamate,   3399 caratteri | triage + specialista: 4 chiamate,   3041 caratteri
#  6 tool in tutto | agente unico: 3 chiamate,   5664 caratteri | triage + specialista: 4 chiamate,   3794 caratteri
# 18 tool in tutto | agente unico: 3 chiamate,  14733 caratteri | triage + specialista: 4 chiamate,   6824 caratteri

Nella mia prova l'handoff non è mai costato di più dell'agente unico, e il vantaggio cresce con il catalogo: la differenza è piccola con tre tool e più che dimezza il totale con diciotto. Il motivo è che il giro del triage è brevissimo (nessuna descrizione di tool di dominio) mentre le chiamate dello specialista portano solo i suoi schemi, che si ripetono a ogni giro. Il numero preciso dipende dalle scelte della prova: descrizioni di circa cento caratteri per tool, istruzioni uguali per i due agenti, due chiamate di tool per conversazione. Con istruzioni dello specialista molto più lunghe di quelle del triage, il pareggio si sposta. Non esiste una soglia «da manuale»: misura sul tuo catalogo.

Resta il costo che il conteggio non mostra: la latenza. Il giro di triage è una chiamata di rete in più, e l'utente la sente; l'uscita lo dice con il numero di chiamate (quattro contro tre). Due mitigazioni funzionano: usare per il triage un modello piccolo, dato che il compito è una classificazione, e annunciare il passaggio nell'interfaccia («ti metto in contatto con l'ufficio rimborsi») invece di lasciare uno schermo vuoto.

Misurare se il routing funziona

Il triage è un classificatore, e come tutti i classificatori va misurato invece di essere valutato a sensazione. Serve un insieme di richieste con l'agente corretto annotato a mano: quaranta richieste prese dai log bastano per cominciare. Qui ne uso cinque, e il triage è una regola a parole chiave invece di un modello: serve a mostrare la forma della matrice, non a dire quanto sia bravo un modello. L'ultimo caso è scelto perché la regola sbagli.

import asyncio
from collections import Counter
from agents import Agent, Runner
from fake import ModelloFinto, chiama, testo

def regola_a_parole_chiave(input_utente):
    """Il 'triage' di questa prova: una regola, non un modello. Serve a mostrare la matrice."""
    ultimo = input_utente if isinstance(input_utente, str) else input_utente[-1]["content"]
    t = ultimo.lower()
    if "rimbors" in t or "soldi" in t:
        return chiama("transfer_to_rimborsi")
    if "app" in t or "errore" in t:
        return chiama("transfer_to_tecnico")
    return testo("Non posso aiutarti su questo.")

rimborsi = Agent(name="Rimborsi", instructions="r", handoff_description="Rimborsi.",
                 model=ModelloFinto(lambda _i: testo("ok")))
tecnico = Agent(name="Tecnico", instructions="t", handoff_description="Problemi tecnici.",
                model=ModelloFinto(lambda _i: testo("ok")))
triage = Agent(name="Triage", instructions="Passa all'agente giusto.", handoffs=[rimborsi, tecnico],
               model=ModelloFinto(regola_a_parole_chiave))

CASI_ROUTING = [
    ("L'ordine A-7781 è arrivato rotto, voglio i soldi",  "Rimborsi"),
    ("L'app si chiude quando apro le impostazioni",        "Tecnico"),
    ("Il rimborso non arriva perché l'app da' errore",     "Rimborsi"),   # ambiguo: l'hai deciso tu
    ("A che ora chiudete?",                                None),         # fuori perimetro
    ("Vorrei i soldi indietro, il tasto Paga da' errore",  "Tecnico"),    # la regola sbaglia
]

async def misura_routing(triage, casi) -> dict:
    confusione = Counter()
    for richiesta, atteso in casi:
        esito = await Runner.run(triage, richiesta, max_turns=4)
        scelto = esito.last_agent.name if esito.last_agent.name != "Triage" else None
        confusione[(atteso, scelto)] += 1
    corretti = sum(v for (a, s), v in confusione.items() if a == s)
    return {"accuratezza": corretti / len(casi), "matrice": dict(confusione)}

async def main():
    risultato = await misura_routing(triage, CASI_ROUTING)
    print("accuratezza:", risultato["accuratezza"])
    for (atteso, scelto), n in sorted(risultato["matrice"].items(), key=str):
        print(f"  atteso={atteso!s:<8} scelto={scelto!s:<8} n={n}")

asyncio.run(main())

# Output:
# accuratezza: 0.8
#   atteso=Rimborsi scelto=Rimborsi n=2
#   atteso=Tecnico  scelto=Rimborsi n=1
#   atteso=Tecnico  scelto=Tecnico  n=1
#   atteso=None     scelto=None     n=1

La matrice di confusione è la parte utile, perché gli errori di routing non hanno tutti lo stesso costo. Mandare una richiesta di rimborso al supporto tecnico è un fastidio: l'utente ripete. Mandare una richiesta tecnica all'ufficio rimborsi, se quell'agente ha la facoltà di rimborsare, è un rischio: potrebbe decidere di risolvere il problema con un rimborso non dovuto. Qui la riga atteso=Tecnico scelto=Rimborsi è proprio questo caso. E non instradare nulla su una richiesta fuori perimetro è il comportamento corretto, non un errore: l'accuratezza aggregata lo conta giusto solo se l'hai annotato bene, con None come atteso.

Le righe da guardare per prime sono quindi due: quante richieste fuori perimetro sono state instradate a qualcuno (sintomo di un triage che non sa dire «no») e quante sono finite nell'agente con i permessi più ampi (sintomo di un rischio, non di un fastidio). Entrambe si correggono nella handoff_description e nella riga delle istruzioni sul caso fuori perimetro, cioè nei due punti visti all'inizio, che adesso hanno una misura a sostegno.

Che cosa vede l'agente che riceve il controllo

Per default l'agente che riceve il controllo vede tutta la conversazione: la documentazione dice che è come se il nuovo agente prendesse il posto del precedente e leggesse l'intera cronologia, messaggi dell'utente e chiamate di tool comprese. Questo è comodo e spesso è sbagliato: se il ricevente legge una chiamata a diagnostica_dispositivo può concludere di avere quel tool a disposizione e provare a usarlo, ottenendo un errore. Il rimedio sono i filtri sull'input, funzioni che ricevono un HandoffInputData e ne restituiscono uno nuovo. L'SDK ne fornisce uno, remove_all_tools; spesso serve qualcosa di più selettivo, per esempio tenere il messaggio dell'utente e aggiungere una nota su perché si è arrivati qui. Il file filtri.py lo contiene.

def messaggi_utente(input_history):
    """Run con una stringa: input_history e' una stringa. Run con una lista: e' una tupla."""
    if isinstance(input_history, str):
        return [{"role": "user", "content": input_history}]
    return [m for m in input_history if isinstance(m, dict) and m.get("role") == "user"]

def solo_richiesta_utente(dati):
    """Passa al ricevente solo i messaggi utente, piu' una nota di contesto."""
    nota = {"role": "user",
            "content": "[Trasferito dal triage: richiesta di rimborso su un ordine.]"}
    return dati.clone(input_history=(*messaggi_utente(dati.input_history), nota),
                      pre_handoff_items=(), new_items=())

C'è una trappola che ho trovato eseguendo il codice: input_history è una stringa quando chiami Runner.run(agente, "testo") e una tupla di messaggi quando passi una lista. Un filtro che itera su input_history credendolo sempre una lista, iterando su una stringa guarda i singoli caratteri, non trova nessun dizionario e butta via il messaggio dell'utente: il ricevente avrebbe visto soltanto la nota. Per questo messaggi_utente normalizza i due casi. Nota anche che il filtro svuota pre_handoff_items e new_items, cioè gli elementi generati durante la run (tool compresi), e lascia solo ciò che costruisce lui. Ora vediamo che cosa riceve il ricevente nei tre casi.

import asyncio
from agents import Agent, Runner, function_tool, handoff
from agents.extensions import handoff_filters
from filtri import solo_richiesta_utente
from fake import ModelloFinto, chiama, testo

@function_tool
def diagnostica_dispositivo(codice: str) -> str:
    """Esegue una diagnosi del dispositivo."""
    return "firmware ok"

def cio_che_vede_il_ricevente(input_filter, richiesta):
    visto = []
    rimborsi = Agent(name="Rimborsi", instructions="Sei l'ufficio rimborsi.",
                     model=ModelloFinto([testo("ok")], visto))
    triage = Agent(name="Triage", instructions="Passa.", tools=[diagnostica_dispositivo],
                   handoffs=[handoff(agent=rimborsi, input_filter=input_filter)],
                   model=ModelloFinto([chiama("diagnostica_dispositivo", codice="x"),
                                       chiama("transfer_to_rimborsi")]))
    asyncio.run(Runner.run(triage, richiesta))
    return [(m.get("role") or m["type"]) + ": " + str(m.get("content") or m.get("name") or m.get("output"))
            for m in visto[0]["input"]]

RICHIESTA = "L'ordine A-7781 è arrivato rotto, voglio i soldi."
for titolo, filtro in [("nessun filtro", None),
                       ("remove_all_tools", handoff_filters.remove_all_tools),
                       ("solo_richiesta_utente", solo_richiesta_utente)]:
    print("==", titolo)
    for riga in cio_che_vede_il_ricevente(filtro, RICHIESTA):
        print("  ", riga)

# Output:
# == nessun filtro
#    user: L'ordine A-7781 è arrivato rotto, voglio i soldi.
#    function_call: diagnostica_dispositivo
#    function_call_output: firmware ok
#    function_call: transfer_to_rimborsi
#    function_call_output: {"assistant": "Rimborsi"}
# == remove_all_tools
#    user: L'ordine A-7781 è arrivato rotto, voglio i soldi.
# == solo_richiesta_utente
#    user: L'ordine A-7781 è arrivato rotto, voglio i soldi.
#    user: [Trasferito dal triage: richiesta di rimborso su un ordine.]

Senza filtro il ricevente legge anche la diagnosi e il trasferimento stesso. Con remove_all_tools resta il solo messaggio dell'utente. Con il filtro personalizzato resta il messaggio più la nota. La nota esplicita fa la differenza nella pratica: senza di essa l'agente ricevente deve dedurre dalla conversazione perché è stato chiamato, e a volte deduce male. Non esagerare nella direzione opposta: se filtri troppo, il ricevente perde il numero d'ordine che l'utente aveva già dato e lo richiede, producendo l'esperienza del «me l'ha già chiesto il centralino». Esiste anche un'opzione dell'SDK, nest_handoff_history, che riassume la cronologia in un messaggio unico; la documentazione la descrive come beta, disattivata per default e ignorata quando imposti un tuo input_filter.

Il contesto che si perde nei passaggi

C'è un guasto degli handoff che non è il rimpallo e che in produzione si vede più spesso: l'informazione che l'utente ha già dato e che l'agente ricevente richiede. È la trasposizione digitale dell'esperienza più irritante del servizio clienti telefonico, e nasce da un filtro sull'input impostato con la mano troppo pesante. In una conversazione lunga il numero d'ordine può essere stato dato molti messaggi prima, e un filtro che tiene «solo qualcosa» può portarselo via; l'agente rimborsi riceve un contesto in cui il numero non c'è, e fa la cosa sensata: lo chiede.

Il rimedio efficace non è «filtrare meno», è estrarre lo stato prima di filtrare. Invece di affidarti a quali messaggi sopravvivono, fai compilare al triage un oggetto strutturato al momento del trasferimento, lo salvi nel contesto dell'applicazione e il filtro lo riporta come nota. Il parametro input_type di handoff() fa proprio questo: trasforma il tool di trasferimento in un tool con argomenti, e on_handoff riceve gli argomenti già validati contro lo schema Pydantic.

import asyncio
from dataclasses import dataclass
from pydantic import BaseModel
from agents import Agent, Runner, RunContextWrapper, handoff
from fake import ModelloFinto, chiama, testo
from filtri import messaggi_utente

class StatoConversazione(BaseModel):
    numero_ordine: str | None = None
    problema: str | None = None
    importo_richiesto: float | None = None

@dataclass
class ContestoApp:
    stato: StatoConversazione | None = None

async def al_trasferimento(contesto: RunContextWrapper[ContestoApp], dati: StatoConversazione) -> None:
    """Lo SDK valida gli argomenti contro lo schema e li passa qui."""
    contesto.context.stato = dati                     # salvato fuori dal modello

def con_nota_di_stato(dati):
    """Filtra la cronologia, ma riporta lo stato strutturato come nota finale."""
    s = dati.run_context.context.stato
    nota = {"role": "user", "content":
            "[Trasferito dal triage. Dati già raccolti: "
            f"ordine={s.numero_ordine or 'non fornito'}, "
            f"problema={s.problema or 'non specificato'}, "
            f"importo={s.importo_richiesto or 'non indicato'}. "
            "NON richiedere all'utente i dati già presenti qui.]"}
    return dati.clone(input_history=(*messaggi_utente(dati.input_history), nota),
                      pre_handoff_items=(), new_items=())

def esegui(argomenti_del_triage: dict):
    visto, contesto = [], ContestoApp()
    rimborsi = Agent(name="Rimborsi", instructions="Sei l'ufficio rimborsi.",
                     model=ModelloFinto([testo("Pratica aperta.")], visto))
    triage = Agent(
        name="Triage", instructions="Passa.",
        handoffs=[handoff(agent=rimborsi, on_handoff=al_trasferimento,
                          input_type=StatoConversazione,      # il triage DEVE compilarlo
                          input_filter=con_nota_di_stato)],
        model=ModelloFinto([chiama("transfer_to_rimborsi", **argomenti_del_triage)], visto))
    asyncio.run(Runner.run(triage, "L'ordine A-7781 è arrivato rotto, voglio i soldi.",
                           context=contesto))
    return visto, contesto

visto, contesto = esegui({"numero_ordine": "A-7781", "problema": "arrivato rotto",
                          "importo_richiesto": 42.0})
print("argomenti del tool di trasferimento:",
      list(visto[0]["schemi_handoff"]["transfer_to_rimborsi"]["properties"]))
print("stato nel contesto:", contesto.stato)
print("il ricevente legge:", visto[1]["input"][-1]["content"])

visto, _ = esegui({"numero_ordine": "A-7718"})      # il triage sbaglia una cifra
print("triage sbagliato:  ", visto[1]["input"][-1]["content"])

# Output:
# argomenti del tool di trasferimento: ['numero_ordine', 'problema', 'importo_richiesto']
# stato nel contesto: numero_ordine='A-7781' problema='arrivato rotto' importo_richiesto=42.0
# il ricevente legge: [Trasferito dal triage. Dati già raccolti: ordine=A-7781, problema=arrivato rotto, importo=42.0. NON richiedere all'utente i dati già presenti qui.]
# triage sbagliato:   [Trasferito dal triage. Dati già raccolti: ordine=A-7718, problema=non specificato, importo=non indicato. NON richiedere all'utente i dati già presenti qui.]

Le prime due righe dell'uscita mostrano che il tool di trasferimento ora ha tre argomenti e che l'oggetto compilato dal triage è finito nel contesto, fuori dal modello. La terza è la nota che il ricevente legge in coda alla cronologia, cioè al bordo del contesto: gli esperimenti di Lost in the Middle mostrano che i modelli usano meglio l'informazione all'inizio e alla fine di un contesto lungo che nel mezzo, ed è una ragione in più per mettere lì i dati che non devono andare persi (se ne parla anche in Memoria conversazionale: sessioni, finestra di contesto e riassunto). La documentazione precisa un punto che qui conta: input_type descrive gli argomenti della chiamata di trasferimento e non sostituisce l'input principale del ricevente, che resta quello deciso dal filtro.

L'ultima riga mostra il caso che fa paura: il triage compila A-7718 invece di A-7781, e il ricevente lavorerà su un ordine che non esiste, scoprendolo solo quando il tool risponde «ordine non trovato». La difesa non è più accuratezza nel triage, è che il campo passato nell'handoff sia trattato come un suggerimento e non come una verità: il tool a valle deve restituire un errore utile, con i suggerimenti, come nel pattern della lezione sui tool. Quando i componenti sono probabilistici, la tolleranza agli errori di chi sta a monte è una proprietà che va progettata.

Il rimpallo infinito: come nasce e come si spezza

Il ciclo nasce dalla combinazione di due cose innocue prese singolarmente: un grafo di handoff con archi in entrambe le direzioni, e due agenti ciascuno convinto che il problema sia dell'altro. L'utente scrive «il rimborso non arriva perché l'app dà errore»: è un problema di rimborsi o tecnico? Entrambi gli agenti hanno una lettura legittima, e si passano la palla. Per provarlo ho scritto due agenti il cui modello finto risponde sempre con un trasferimento verso l'altro, e ho applicato le tre difese che seguono, tutte e tre perché coprono casi diversi.

  1. Strutturale: il grafo è una stella, non una maglia. Solo il triage ha handoff; gli specialisti no. Il ciclo è impossibile per costruzione.
  2. Contatore: se serve una maglia, conta i trasferimenti. L'handoff porta con sé una funzione on_handoff che registra il percorso e solleva un'eccezione al secondo passaggio sullo stesso agente o oltre il terzo trasferimento.
  3. Tetto duro: max_turns. Ogni giro del ciclo, handoff compresi, conta; oltre il limite l'SDK solleva MaxTurnsExceeded. Il valore predefinito è 10, come si legge nel codice dell'SDK.

Le tre difese sono in ordine di preferenza, non di forza. La prima elimina la classe di bug invece di gestirla; la seconda dà un messaggio che spiega perché ti sei fermato, con il percorso; la terza è l'ultima rete, ed è anche la meno informativa, perché dice solo che il limite è stato superato. Nota una cosa del codice: una funzione on_handoff senza input_type deve prendere un solo argomento, il contesto (l'SDK solleva un errore se ne dichiari due), quindi il nome dell'agente di destinazione lo si cattura con una funzione che costruisce l'handoff, con_guardia.

import asyncio
from dataclasses import dataclass, field
from agents import Agent, Runner, RunContextWrapper, handoff
from agents.exceptions import MaxTurnsExceeded
from fake import ModelloFinto, chiama, testo

@dataclass
class ContestoRun:
    id_run: str
    handoff_fatti: int = 0
    agenti_visitati: list[str] = field(default_factory=list)

def con_guardia(agente: Agent, max_handoff: int = 3):
    """Un handoff che registra il percorso e interrompe il ciclo."""
    async def al_trasferimento(contesto: RunContextWrapper[ContestoRun]) -> None:
        c = contesto.context
        c.handoff_fatti += 1
        c.agenti_visitati.append(agente.name)
        print(f"[run {c.id_run}] handoff n.{c.handoff_fatti} verso {agente.name}")
        if c.handoff_fatti > max_handoff:
            raise RuntimeError(f"troppi handoff: {c.agenti_visitati}")
        if c.agenti_visitati[-4:].count(agente.name) >= 2:
            raise RuntimeError(f"ciclo rilevato su {agente.name}: {c.agenti_visitati}")
    return handoff(agent=agente, on_handoff=al_trasferimento)

def due_agenti_che_si_rimbalzano():
    rimborsi = Agent(name="Rimborsi", instructions="r",
                     model=ModelloFinto(lambda _i: chiama("transfer_to_tecnico")))
    tecnico = Agent(name="Tecnico", instructions="t",
                    model=ModelloFinto(lambda _i: chiama("transfer_to_rimborsi")))
    return rimborsi, tecnico

async def main():
    richiesta = "il rimborso non arriva perché l'app dà errore"

    # Difesa 2: maglia con contatore
    rimborsi, tecnico = due_agenti_che_si_rimbalzano()
    rimborsi.handoffs = [con_guardia(tecnico)]
    tecnico.handoffs = [con_guardia(rimborsi)]
    contesto = ContestoRun("r-1")
    try:
        await Runner.run(rimborsi, richiesta, context=contesto, max_turns=10)
    except RuntimeError as errore:
        print("interrotto:", errore)

    # Difesa 3: maglia senza contatore, solo il tetto duro
    rimborsi, tecnico = due_agenti_che_si_rimbalzano()
    rimborsi.handoffs, tecnico.handoffs = [tecnico], [rimborsi]
    try:
        await Runner.run(rimborsi, richiesta, max_turns=6)
    except MaxTurnsExceeded as errore:
        print("tetto duro:", errore)

    # Difesa 1: stella. Solo il triage ha handoff, gli specialisti no.
    visto = []
    rimborsi = Agent(name="Rimborsi", instructions="r", model=ModelloFinto([testo("fatto")], visto))
    tecnico = Agent(name="Tecnico", instructions="t")
    triage = Agent(name="Triage", instructions="x", handoffs=[rimborsi, tecnico],
                   model=ModelloFinto([chiama("transfer_to_rimborsi")], visto))
    await Runner.run(triage, richiesta)
    print("handoff visti dal triage:", visto[0]["handoff"], "| da Rimborsi:", visto[1]["handoff"])

asyncio.run(main())

# Output:
# [run r-1] handoff n.1 verso Tecnico
# [run r-1] handoff n.2 verso Rimborsi
# [run r-1] handoff n.3 verso Tecnico
# interrotto: ciclo rilevato su Tecnico: ['Tecnico', 'Rimborsi', 'Tecnico']
# tetto duro: Max turns (6) exceeded
# handoff visti dal triage: ['transfer_to_rimborsi', 'transfer_to_tecnico'] | da Rimborsi: []

Il contatore ha fermato la corsa al terzo trasferimento, quando Tecnico compariva per la seconda volta nel percorso; senza contatore, il tetto duro ha interrotto la corsa al sesto giro, e la stella ha tolto del tutto la possibilità del ciclo: Rimborsi non vede nessun handoff. La prima difesa è quella che uso quasi sempre. Non è elegante come un sistema in cui ogni agente conosce i colleghi, ma se davvero serve che uno specialista rimandi indietro, la forma pulita è che chiuda con una risposta che spiega il da farsi, non che trasferisca. Lo stesso callback on_handoff è anche il posto dove registrare l'evento per i log: senza l'elenco degli agenti visitati, «a volte l'utente finisce nel posto sbagliato» è irrisolvibile, con quell'elenco è una query su un log.

L'escalation verso un essere umano

Il caso più utile e più sottovalutato è l'agente che riconosce di non poter procedere e passa a una persona. Dal punto di vista del controllo è un handoff, ma nell'SDK non si implementa con handoffs=[...], perché il destinatario non è un agente: si implementa come un function tool che accoda il caso in un sistema di ticket. La coda del blocco è un sostituto in memoria, da rimpiazzare con il tuo sistema; il resto è eseguito così com'è.

import asyncio
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
from agents import Agent, Runner, function_tool
from fake import ModelloFinto, chiama, testo

class CodaUmana:
    """Al posto di un sistema di ticket vero: accoda in memoria e restituisce un numero."""
    def __init__(self):
        self.richieste = []
    async def accoda(self, richiesta: dict) -> str:
        self.richieste.append(richiesta)
        return f"T-{len(self.richieste):04d}"

coda_umana = CodaUmana()

class Escalation(BaseModel):
    motivo: Literal["oltre_i_poteri", "utente_arrabbiato", "caso_ambiguo", "dato_mancante"]
    riassunto: str = Field(max_length=400, description="Riassunto per l'operatore umano")
    urgenza: Literal["normale", "alta"]

@function_tool
async def passa_a_operatore(richiesta: Escalation) -> dict:
    """Passa la conversazione a un operatore umano e CHIUDI il tuo turno.

    Usa questo tool quando: l'importo supera i tuoi poteri, l'utente chiede
    esplicitamente una persona, i dati sono contraddittori, oppure non sai cosa fare.
    Non è una sconfitta: è la scelta corretta in questi casi.
    """
    ticket = await coda_umana.accoda(richiesta.model_dump())
    return {"ok": True, "ticket": ticket,
            "cosa_dire_all_utente": f"Ho passato il caso a un collega "
                                    f"(riferimento {ticket}). Ti risponderanno a breve."}

async def main():
    agente = Agent(name="Rimborsi", instructions="Sei l'ufficio rimborsi.", tools=[passa_a_operatore],
                   model=ModelloFinto([
                       chiama("passa_a_operatore", richiesta={"motivo": "oltre_i_poteri",
                              "riassunto": "Rimborso di 180 EUR sull'ordine A-7781.", "urgenza": "normale"}),
                       testo("Ho passato il caso a un collega (riferimento T-0001). Ti risponderanno a breve.")]))
    esito = await Runner.run(agente, "Voglio 180 euro indietro per l'ordine A-7781.")
    print(esito.final_output)
    print(coda_umana.richieste)
    try:
        Escalation(motivo="caso_ambiguo", riassunto="x" * 401, urgenza="alta")
    except ValidationError as errore:
        print(errore.errors()[0]["type"], "-", errore.errors()[0]["msg"])

asyncio.run(main())

# Output:
# Ho passato il caso a un collega (riferimento T-0001). Ti risponderanno a breve.
# [{'motivo': 'oltre_i_poteri', 'riassunto': "Rimborso di 180 EUR sull'ordine A-7781.", 'urgenza': 'normale'}]
# string_too_long - String should have at most 400 characters

Tre accortezze. Il campo riassunto ha max_length=400 nello schema Pydantic: l'ultima riga dell'uscita mostra che un riassunto di 401 caratteri viene rifiutato prima che il tool giri, e il limite diventa così un vincolo dell'SDK invece di un commento. La riga «non è una sconfitta» nella docstring serve perché i modelli addestrati a essere d'aiuto possono evitare di dichiarare l'impotenza: è una scelta di prompt da verificare con i tuoi casi, non una garanzia. E il campo cosa_dire_all_utente restituito dal tool evita che l'agente improvvisi un messaggio di chiusura, magari promettendo tempi che nessuno ha garantito. Nell'esecuzione il testo finale lo detta il copione, naturalmente; con un modello vero dipende da come il modello usa quel campo.

Tre casi in cui l'handoff è la scelta sbagliata

  • Hai bisogno della risposta dello specialista per continuare. Esempio: un agente che deve consultare il revisore legale e poi riscrivere il contratto tenendo conto del parere. Con l'handoff la risposta del revisore va all'utente e il contratto non viene riscritto. Serve l'agent-as-tool.
  • Ti servono due pareri da confrontare. Un handoff va in un posto solo e non torna; per interrogare tre specialisti e sintetizzare serve un orchestratore che li chiami come tool, possibilmente in parallelo.
  • La decisione di routing è deterministica. Se instradi in base al canale di arrivo, al piano tariffario o a una regola scritta nel contratto, quella è una riga di codice. Chiedere al modello di applicare una regola che conosci con certezza è pagare per introdurre un'incertezza che non avevi.

I primi due casi hanno in comune il bisogno di riprendere il controllo dopo la risposta dello specialista, ed è esattamente ciò che l'handoff non offre; la soluzione è nell'articolo successivo, Agent-as-tool: orchestrazione gerarchica invece della delega. Il terzo è un promemoria più generale: un if è più economico e più prevedibile di un modello ogni volta che la regola è nota.

Vale la pena inquadrare tutto questo in un contesto più ampio. Il problema del coordinamento fra agenti è antecedente agli LLM di decenni, e la ricerca classica sui sistemi multi-agente ha prodotto protocolli formali per la delega e la negoziazione: la serie dedicata ne copre il lato teorico in comunicazione fra agenti: FIPA-ACL e protocolli e negoziazione e aste: Contract Net Protocol. Il confronto è istruttivo: in quei protocolli il trasferimento di un compito è un messaggio tipizzato con una semantica definita e verificabile, mentre qui è una decisione probabilistica di un modello. Si guadagna in flessibilità sul linguaggio naturale, si perde ogni garanzia. Per le architetture a più agenti in produzione il riferimento pratico è orchestrazione multi-agente: architetture per la produzione.

Trade-off

  • Specialisti contro agente unico. Ogni specialista ha istruzioni, politiche e tool propri, e nella prova sopra il totale di caratteri inviati cala. Il prezzo è una chiamata in più (quella spesa a decidere il trasferimento) e un grafo da mantenere.
  • Handoff contro ramo nel codice. Se la classificazione della richiesta è facile e stabile («contiene il numero d'ordine» significa rimborsi), un if costa zero chiamate ed è deterministico. L'handoff serve quando la classificazione richiede comprensione del linguaggio.
  • Cronologia completa contro filtrata. Completa: il ricevente ha tutto il contesto e anche la confusione. Filtrata: parte pulito e può richiedere dati che l'utente aveva già dato. Il compromesso pratico è «messaggi utente più una nota di trasferimento con lo stato».
  • Grafo a stella contro grafo a maglia. Stella: nessun ciclo possibile, ma ogni cambio di argomento passa dal router. Maglia: trasferimenti più diretti, cicli possibili, e un sistema il cui comportamento diventa difficile da prevedere.

Le quattro coppie descrivono un solo asse: quanto controllo strutturale vuoi pagare in flessibilità. Per un'applicazione che muove soldi o dati personali la risposta ragionevole sta dal lato rigido (stella, tool separati, stato esplicito); per un assistente di sola consultazione puoi permetterti più libertà. Parti rigido e allenta solo dove una misura ti dice che serve.

Le trappole in cui si cade davvero

  1. handoff_description mancante o generica. Senza di essa il modello instrada con poche informazioni, e tu passi il pomeriggio a riscrivere le istruzioni del triage quando il problema era una riga nell'agente ricevente.
  2. Dare tool di dominio al triage. Se può cercare l'ordine, lo cercherà e potrà rispondere senza trasferire. I confini si impongono togliendo le capacità.
  3. Dimenticare che l'output finale arriva da un altro agente. Se il triage ha un output_type e l'agente ricevente no, la risposta finale non ha il tipo che il tuo codice si aspetta. Controlla sempre last_agent prima di assumere la forma del risultato.
  4. Non registrare il percorso. Senza l'elenco degli agenti visitati non puoi ricostruire dove è finito un utente.
  5. Grafo a maglia senza contatore. È la ricetta del rimpallo infinito, e il conto lo scopri il giorno dopo.
  6. Filtrare tutto, o filtrare una stringa come fosse una lista. Rimuovere l'intera cronologia fa ripartire la conversazione da zero; il secondo caso è la trappola di input_history vista sopra.
  7. Confondere handoff e agent-as-tool. Se dopo la risposta dello specialista vuoi che il primo agente riprenda il controllo, l'handoff è lo strumento sbagliato.

Le sette trappole si riducono a due errori di fondo. Il primo è trattare il triage come un oggetto che «capisce» invece che come un classificatore da misurare (trappole 1, 2, 4). Il secondo è dimenticare che il controllo, una volta ceduto, non torna da solo (trappole 3, 5, 7). Le altre due, sul filtro, nascono dal non guardare mai che cosa riceve davvero il ricevente: ti basta stampare la cronologia come in questo articolo.

Che cosa sai fare adesso

Sai dichiarare un triage con due handoff, leggere il nome del tool che l'SDK genera e la descrizione che il modello vede, e capire quando l'handoff è la scelta giusta (mandati o permessi diversi) e quando no. Sai decidere che cosa vede l'agente che riceve il controllo con un filtro che regge sia la stringa sia la lista, e portare lo stato strutturato con input_type e on_handoff. Sai spezzare un rimpallo con tre difese diverse, e misurare il routing con una matrice di confusione. Nell'articolo successivo vediamo l'alternativa simmetrica, trasformare un agente in un tool, così chi chiama non perde il controllo.

Nella stessa serie trovi anche Che cos'è un agente AI (e quando non ti serve), Il primo agente funzionante: lo stesso codice su SDK gestito e su Ollama, Guardrail: tripwire in ingresso e in uscita, Le trappole reali: prompt injection dai tool, loop infiniti, costi che esplodono, test inaffidabili e Model Context Protocol: la specifica, e un server collegato a un agente.

Fonti

Tutti i listati sono stati eseguiti due volte con Python 3.14.4 e openai-agents 0.24.0, in un ambiente virtuale temporaneo, con il tracciamento dell'SDK disattivato e senza chiamare nessun modello: le uscite riportate sono identiche nelle due esecuzioni. Le decisioni di routing e il testo delle risposte vengono dal modello finto, quindi i listati provano la meccanica dell'SDK e non la qualità di un modello vero. I conteggi di caratteri dipendono dalle descrizioni inventate per la prova. Gli scenari (rimborsi, supporto tecnico, numeri d'ordine) sono inventati per l'esempio.

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