Progettare agenti AI che sanno quando passare la mano: ad altri agenti, alle regole, alle persone
Un’architettura pratica per agenti AI con handover sicuro: passaggi tra agenti, motore di regole deterministico per decisioni critiche, tool in sandbox ed eval.
Un agente AI sa quando passare la mano se l’handover è un’azione tipizzata nel suo loop, non qualcosa che ci si aspetta che il modello dica a parole. L’agente può trasferire la conversazione a un altro agente, delegare una decisione a un motore di regole deterministico o passarla a una persona, e ognuna di queste azioni è una chiamata a un tool con un contratto definito, che l’harness esegue, registra e può valutare. Il modello decide che bisogna passare la mano; il codice decide cosa succede dopo. In Turn.io sono responsabile dell’harness agentico che i clienti usano per far girare agenti AI su WhatsApp: uso dei tool, orchestrazione, guardrail e handover tra agenti e verso le persone. In questo articolo descrivo l’architettura che consiglierei per qualsiasi agente che parla con utenti reali di cose importanti.
In breve
- L’harness governa il loop: contesto, esecuzione dei tool, limiti, guardrail e handover. Il modello propone, l’harness dispone.
- Modella ogni handover come un tool con uno schema: destinazioni ammesse, un motivo e un riepilogo strutturato.
- Tieni esplicito il grafo degli handover. Ogni agente può trasferire la conversazione solo agli agenti che elenchi, e i trasferimenti per conversazione hanno un tetto.
- Affida le decisioni ad alto rischio a un motore di regole deterministico. L’LLM raccoglie gli input e spiega l’esito, ma non è lui a decidere.
- Esegui i tool in una sandbox, con privilegi minimi, limiti e output trattati come dati non fidati.
- Organizza i guardrail a strati, così i controlli deterministici ed economici girano per primi e alcune situazioni scavalcano del tutto il modello.
- Valuta l’handover come un classificatore: handover mancati, handover inutili, destinazioni sbagliate e qualità di ciò che viene passato.
Il loop dell’agente, e chi governa cosa
Tolti i framework, un agente è un loop: costruisci il contesto, chiami il modello con i tool disponibili, esegui i tool che richiede, aggiungi i risultati e ripeti finché non produce una risposta per l’utente o non passa la mano. Quello che lo rende pronto per la produzione è tutto ciò che l’harness impone attorno a quel loop.
Uno schizzo in Elixir (i moduli LLM, Tools e Guardrails stanno al posto dei tuoi):
defmodule Agents.Loop do
@max_steps 8
def run(agent, conversation, step \\ 0)
def run(_agent, conversation, @max_steps),
do: {:handover, %{target: "human", reason: "step_limit"}, conversation}
def run(agent, conversation, step) do
case LLM.complete(agent.model, agent.instructions, conversation, agent.tools) do
{:reply, text} ->
case Guardrails.check_output(agent, conversation, text) do
:ok -> {:reply, text, conversation}
{:violation, reason} -> {:handover, %{target: "human", reason: reason}, conversation}
end
{:tool_calls, calls} ->
case Enum.find(calls, &(&1.name == "transfer")) do
nil ->
results = Enum.map(calls, &Tools.execute(agent, &1))
run(agent, Conversation.append_tool_results(conversation, calls, results), step + 1)
transfer ->
{:handover, transfer.input, conversation}
end
end
end
end
Nota cosa il modello non controlla. Non può superare il limite di step, non può inviare una risposta che non supera i controlli sull’output e, quando chiede un trasferimento, il loop si ferma e subentra l’harness. È poi il chiamante a decidere cosa significa un handover: avviare un altro agente, eseguire il motore di regole o mettere la conversazione nella coda di un operatore.
L’handover come tool con un contratto
Un errore frequente nella progettazione degli agenti è definire l’handover solo nel prompt: “se l’utente chiede della fatturazione, digli che lo trasferirai”. Il modello allora dice che sta trasferendo e non succede nulla, oppure trasferisce in un modo che nessuno può misurare. Rendilo un tool:
{
"name": "transfer",
"description": "Hand the conversation to another agent or to a human. Use it when the request is outside your scope, when the user asks for a person, or when you cannot proceed safely.",
"input_schema": {
"type": "object",
"properties": {
"target": { "type": "string", "enum": ["appointments_agent", "human_nurse"] },
"reason": { "type": "string", "enum": ["out_of_scope", "user_requested_human", "unsafe_to_continue", "tool_failure"] },
"summary": { "type": "string", "description": "What the user needs and what has been established so far." }
},
"required": ["target", "reason", "summary"],
"additionalProperties": false
}
}
L’enum su target è il grafo degli handover di questo agente: può andare solo dove hai tracciato un arco. Un reason strutturato ti dà qualcosa da aggregare e su cui impostare alert. Il summary è la prima cosa che legge l’agente o la persona successiva.
Passare la mano ad altri agenti
Un setup multi-agente giustifica la sua complessità quando parti diverse di un servizio hanno bisogno di istruzioni, tool o modelli diversi: un agente di accoglienza, un agente per gli appuntamenti con accesso a un’API di prenotazione, un agente informativo con RAG su una knowledge base. Gli schemi che secondo me funzionano:
- Un router davanti, gli specialisti dietro. Il router è piccolo ed economico e ha un solo compito: capire cosa vuole l’utente e trasferire la conversazione. Gli specialisti non devono sapere nulla l’uno dell’altro, a meno che tu non tracci quell’arco.
- Trasferisci la conversazione, non solo un riepilogo. L’agente che riceve deve vedere la trascrizione più il riepilogo strutturato. I riepiloghi perdono dettagli, e gli utenti detestano ripetersi.
- Metti un tetto ai trasferimenti. Due agenti convinti ciascuno che la responsabilità sia dell’altro si rimbalzeranno la conversazione all’infinito. Dopo un piccolo numero di trasferimenti nella stessa conversazione, l’harness la instrada a una persona, qualunque cosa voglia il modello.
- Rendi l’agente attivo uno stato esplicito. Memorizza quale agente ha in carico la conversazione. Quando il messaggio WhatsApp successivo arriva un’ora dopo, deve andare direttamente a quell’agente, non ripassare dal router.
Passare la mano alle regole: decisioni deterministiche per i casi ad alto rischio
Per alcune decisioni non vuoi affatto una risposta probabilistica. Nel lavoro sul triage con AI ho progettato architetture a due agenti con un motore di regole deterministico per le decisioni ad alto rischio. La forma generale è questa: il primo agente ha il compito di raccogliere reperti strutturati da uno scambio conversazionale e disordinato. Il motore di regole decide l’esito a partire da quei reperti. Il secondo agente spiega l’esito e i passi successivi in linguaggio semplice, e non può cambiare la decisione.
Le regole sono normale codice, versionato e revisionato dalle persone che rispondono dell’esito. Uno schizzo a scopo illustrativo (non è un’indicazione clinica):
defmodule Triage.Rules do
@required [:age_months, :danger_signs, :symptom_days, :fever]
def decide(findings) do
case Enum.reject(@required, &Map.has_key?(findings, &1)) do
[] -> classify(findings)
missing -> {:need_more_info, missing}
end
end
defp classify(%{danger_signs: [_ | _] = signs}), do: {:emergency, {:danger_signs, signs}}
defp classify(%{age_months: age, fever: true}) when age < 3, do: {:emergency, :young_infant_fever}
defp classify(%{symptom_days: days}) when days > 14, do: {:see_clinic, :persistent_symptoms}
defp classify(_findings), do: {:self_care, :no_rule_matched}
end
Due proprietà fanno funzionare questo schema. La prima: {:need_more_info, missing} torna all’agente di accoglienza come risultato di un tool, quindi sono le regole a stabilire quali domande vengono poste. Il modello non può saltare una domanda obbligatoria e arrivare comunque a un esito, e questo colpisce direttamente gli errori di omissione. La seconda: le regole si possono testare in modo esaustivo ed economico su un dataset di vignette cliniche revisionate da clinici, senza alcun LLM nel loop. La valutazione dell’LLM si riduce così a una domanda molto più semplice: l’agente di accoglienza ha estratto correttamente i reperti dalla conversazione?
Eseguire i tool in sandbox
I tool sono il punto in cui gli agenti toccano il mondo reale, quindi richiedono la stessa cura di qualsiasi altro codice eseguito per conto di un utente, anzi di più, perché chi li chiama è un modello che può essere manipolato. In Turn.io ho collegato il nostro app engine Lua all’Agent block, così gli agenti eseguono tool e codice personalizzato dentro una sandbox Lua; è così che i clienti collegano in sicurezza gli agenti a cartelle cliniche elettroniche e API esterne. Qualunque runtime tu usi, i principi sono gli stessi:
- Nessuna autorità implicita. Il tool riceve solo le credenziali di cui ha bisogno, iniettate dall’harness per quella chiamata. Il modello non vede mai i segreti.
- Limiti rigidi. Tempo di CPU, memoria, timeout sul tempo reale, dimensione della risposta, host in allowlist.
- Input validati. Gli argomenti vengono verificati rispetto allo schema del tool prima dell’esecuzione. Una validazione fallita torna al modello come errore, non viene eseguita alla bell’e meglio.
- Output non fidati. La risposta di un’API esterna è un dato, non un’istruzione. Un testo al suo interno che dice “ignora le istruzioni precedenti” è un tentativo di prompt injection, e i risultati dei tool devono essere chiaramente delimitati come dati nel contesto.
- Conferma degli effetti collaterali. Le letture possono essere automatiche. Tutto ciò che è irreversibile (prenotare, annullare, inviare) deve essere idempotente e, a seconda della posta in gioco, confermato prima con l’utente o con una persona.
Guardrail a strati
I guardrail sono più di una chiamata di moderazione sull’output. Io li penso su tre livelli:
- Prima del modello. Controlli deterministici sull’input: frasi di crisi note, richieste esplicite di parlare con una persona, messaggi da numeri segnalati come abusivi. Alcuni di questi casi dovrebbero finire a una persona o a una risposta di sicurezza fissa senza chiamare affatto il modello. Se qualcuno dice di essere in pericolo, non vuoi che la cosa venga gestita da quello che il prompt dice in quel momento.
- Attorno al modello. Limiti di step, limiti di trasferimento, budget di costo e latenza per conversazione, e un fallback definito per quando il provider del modello è giù o lento. Su WhatsApp, un fallback che dice “stiamo avendo qualche problema, una persona ti risponderà” è molto meglio del silenzio.
- Dopo il modello. Controlli sull’output: niente diagnosi dove il servizio non è autorizzato a farle, niente dati personali di altri record, niente promesse che il servizio non può mantenere. Prima i controlli deterministici ed economici, poi un modello classificatore per ciò che non si può esprimere come regola.
Un guardrail che scatta deve produrre un handover o una risposta sicura, e deve essere registrato con un motivo, esattamente come un trasferimento avviato dal modello.
Passare la mano alle persone
L’handover verso le persone è il punto più debole di molti progetti di agenti, perché in parte è un problema operativo. Cose da decidere in anticipo:
- Cosa vede l’operatore. Il motivo, il riepilogo strutturato, i fatti chiave già raccolti e la trascrizione completa a portata di clic. Un’infermiera non dovrebbe dover richiedere al paziente la sua età.
- Cosa vede l’utente. Digli che risponderà una persona e dagli aspettative realistiche sui tempi. Poi assicurati che l’agente smetta di rispondere.
- Comportamento fuori orario. Se non c’è nessuno disponibile fino al mattino, dillo, e prevedi un piano per i casi urgenti che non dipenda dalla coda.
- La finestra di WhatsApp. Se l’operatore risponde più di 24 ore dopo l’ultimo messaggio dell’utente, una risposta libera verrà rifiutata e ti servirà un template approvato per riaprire il contatto.
- Restituire la conversazione. Dopo che l’operatore ha risolto il problema, l’agente può riprendere? Se sì, i messaggi dell’operatore devono entrare nel contesto dell’agente, e lo stato di handover deve essere azzerato in modo esplicito.
Valutare la correttezza dell’handover
L’handover è una decisione, quindi valutalo come un classificatore. Per ogni conversazione vuoi sapere se l’agente avrebbe dovuto passare la mano, se l’ha fatto, a chi e quando. Da qui ottieni:
- Handover mancati: avrebbe dovuto passare la mano e non l’ha fatto. Nei servizi ad alto rischio, è il primo numero da abbassare.
- Handover inutili: ha passato la mano quando avrebbe potuto gestire la richiesta. Costano tempo al personale e indeboliscono le ragioni per avere un agente.
- Destinazione sbagliata: la decisione di trasferire era giusta, la destinazione no.
- Tempistica: quanti turni ci sono voluti. Un handover al nono turno, dopo che al secondo l’utente aveva scritto “voglio parlare con una persona”, è un fallimento anche se alla fine è avvenuto.
- Qualità del contesto: se il riepilogo contiene i fatti di cui chi riceve ha bisogno. È un buon lavoro per un giudice LLM, con un criterio per ogni fatto richiesto.
La fonte migliore di casi di test sono le eval con simulazioni: scenari in cui un LLM interpreta l’utente, etichettati con il comportamento di handover atteso. Includi utenti che chiedono una persona in modo indiretto, utenti arrabbiati che però non ne hanno bisogno e utenti la cui richiesta a metà strada esce dal perimetro. Testa il motore di regole separatamente con unit test, poi applica gli stessi criteri di handover a un campione di conversazioni di produzione, e trasforma ogni mancato handover confermato in un nuovo scenario.
Costruire agenti di cui le persone si possano fidare
Un agente che non passa mai la mano o sta facendo qualcosa di banale, o sta facendo qualcosa di pericoloso. Gestire bene l’handover è soprattutto una questione di architettura: contratti espliciti, percorsi deterministici per le decisioni che contano, tool in sandbox ed eval che misurano se tutto funziona. Se stai progettando un agente per un servizio in cui gli errori hanno conseguenze, aiuto i team a costruire agenti AI e le eval che dimostrano che funzionano.