Lab 12c : Handoff entre agents — le contrat C5, le transfert natif câblé et observable
Le cinquième contrat EPITA du registre (#14058) : un agent peut transférer la main à un autre agent, et le transfert est observable. La mesure initiale de la tranche 3 disait : ADK 2.8 porte le transfert natif (TransferToAgentTool), mais rien ne l’exerçait — aucun agent du dépôt ne déclarait de hiérarchie. Ce lab travaille sur le câblage livré par #14686 : build_agent accepte sub_agents, et chaque tour rend qui a parlé (agent_hands) et quels transferts ont eu lieu (handoffs).
LLM réel (OpenRouter) : les décisions de transfert ci-dessous sont prises par le modèle, jamais scriptées.
Lire la configuration attestée : le même moteur que la chaîne, pour comparer
Comme dans le Lab 12b, la sortie d’import atteste le paramètre d’expérience : Provider actif : openrouter, Modele : openrouter/openai/gpt-4.1, Endpoint externe : True. Ce n’est pas une répétition gratuite : le Lab 12b (désignation C4) et celui-ci (handoff C5) comparent deux contrats sur le même moteur — c’est ce qui rend la comparaison honnête. Quand le §4 montrera la même intention réalisée par deux mécanismes distincts, la seule variable sera le contrat, pas le modèle.
2. L’arbre d’agents : un codeur assisté par un vérificateur
La hiérarchie se déclare : build_agent(..., sub_agents=(...)). Dès qu’un agent porte des sous-agents, ADK injecte nativement l’outil transfer_to_agent (avec une contrainte d’enum sur les noms valides — le modèle ne peut pas inventer une cible). Rien n’est à écrire côté outillage : câbler la hiérarchie suffit à rendre le handoff exerçable.
from utils.adk_runtime import build_agentverificateur = build_agent( name="verificateur", description="Verificateur de l'atelier : relit du code Python et rend un verdict.", instruction=("Tu es le verificateur de l'atelier. On te transfere du code Python ""a relire. Reponds en trois lignes : VERDICT (OK ou ECART), la regle ""de code verifiee, puis une suggestion d'amelioration concrete." ),)coder = build_agent( name="coder", description="Codeur Python de l'atelier, assiste par un verificateur.", instruction=("Tu es le codeur de l'atelier. Quand on te demande d'ecrire du code, ""produis une fonction Python courte et commentee. DES QUE la demande ""mentionne une verification, un controle ou le verificateur, tu ""dois OBLIGATOIREMENT transferer la main au verificateur avec l'outil ""transfer_to_agent, puis laisser la reponse finale au verificateur." ), sub_agents=(verificateur,),)print(f"Racine de l'arbre : {coder.name}")print(f"Sous-agents declares : {[s.name for s in coder.sub_agents]}")print("L'outil natif transfer_to_agent est injecte par ADK des que la ""hierarchie existe.")
Racine de l'arbre : coder
Sous-agents declares : ['verificateur']
L'outil natif transfer_to_agent est injecte par ADK des que la hierarchie existe.
Lire l’injection native : l’outil de transfert que nous n’avons pas écrit
Trois lignes : Racine de l'arbre : coder, Sous-agents declares : ['verificateur'] — exactement un sous-agent —, puis la phrase clé : L'outil natif transfer_to_agent est injecte par ADK des que la hierarchie existe. La lecture importante est dans « injecté » : la cellule n’a écrit aucun code de transfert. Déclarer un sous-agent dans l’arbre suffit à ce que le runtime ADK ajoute l’outil transfer_to_agent à l’ensemble d’outils du parent — le handoff du §3 sera déclenché par un appel d’outil dont l’existence même est un fait de la déclaration, pas de la programmation. C’est la différence entre câbler un mécanisme et le laisser émerger de la structure. L’arbre est fait de vrais Agent ADK, jamais d’une restauration du moteur SK (#14058) : la décision actée reste ADK comme runtime.
3. Le handoff en action : la main passe au vérificateur
Un seul tour de conversation : la demande couvre deux rôles (écrire, puis faire vérifier). Observez tool_calls — le transfert y apparaît comme un appel d’outil — puis agent_hands, la trace mécanique de qui a tenu la main.
import asynciofrom utils.adk_conversation import ConversationRunnerasyncdef demonstration_handoff():asyncwith ConversationRunner(coder) as conversation:returnawait conversation.turn("Ecris une fonction mediane(liste) qui rend la mediane d'une ""liste de nombres, puis fais verifier ton code par le ""verificateur.", timeout_seconds=180, )resultat =await demonstration_handoff()print(f"Appels d'outils du tour : {resultat.tool_calls}")print(f"Mains du tour (ordre de passage) : {resultat.agent_hands}")print(f"Handoffs observes : {resultat.handoffs}")print(f"Agent final : {resultat.final_agent}")
Appels d'outils du tour : ('transfer_to_agent',)
Mains du tour (ordre de passage) : ('coder', 'verificateur')
Handoffs observes : (('coder', 'verificateur'),)
Agent final : verificateur
Lire le tour de handoff : un transfert décidé par le LLM, et son coût caché
Les quatre compteurs de fin de sortie portent le résultat. Appels d'outils du tour : ('transfer_to_agent',) et Mains du tour (ordre de passage) : ('coder', 'verificateur') : la main passe de coder à verificateur par un appel d’outil. C’est le LLM qui a décidé du transfert, au milieu du tour — le handoff n’est ni ordonnancé ni simulé par l’appelant. Agent final : verificateur : la réponse finale vient du vérificateur.
Avant ces compteurs, la sortie porte un avertissement du runtime qui mérite sa propre lecture : can transfer between agents but has no context_cache_config. Every transfer swaps the system instruction and the tool set, so the request prefix changes and the whole prompt is re-sent uncached after each transfer. Traduit : chaque handoff change l’instruction système et l’ensemble d’outils de l’agent courant — le préfixe de requête change, et l’intégralité du prompt est re-soumise sans cache. Le handoff C5 est souple (décidé pendant le tour) mais paie ce transport : c’est le coût structurel que la désignation C4 du Lab 12b n’a pas (chaque étape est une conversation dont le périmètre est posé d’avance). Le second avertissement (JSON_SCHEMA EXPÉRIMENTAL, avec le filtre de la cellule de configuration) est bénin ; il témoigne simplement que l’outil de transfert passe par la même machinerie de déclaration que les autres outils.
4. Handoff C5 vs désignation C4 : deux contrats distincts
Le registre distingue deux mécaniques souvent confondues :
C5 — handoff (ce lab) : décision de l’agent, en cours de tour. Le LLM choisit de transférer ; l’observateur ne fait que constater.
C4 — désignation (grain ouvert #14685) : stratégie explicite de l’orchestrateur, décidée avant le tour — qui parle ensuite est une règle, pas un choix du modèle.
La cellule suivante rejoue « coder puis vérifier » en mode C4 : l’ordre vit dans notre code Python, le runtime n’a rien choisi.
from utils.adk_runtime import build_agentverificateur_autonome = build_agent( name="verificateur_autonome", description="Verificateur autonome, appele directement par l'appelant.", instruction=("Tu es un verificateur autonome. On te soumet du code Python : ""reponds en une ligne par VERDICT (OK ou ECART) et la regle verifiee." ),)asyncdef designation_explicite():# Contrat C4 (designation) : l'ORDRE vit dans le code de l'appelant.# Le runtime ne choisit rien -- nous enchainons nous-memes deux# conversations mono-agent, dans un ordre ecrit ici, en Python. traces = []asyncwith ConversationRunner(coder) as conversation: tour =await conversation.turn("Ecris une fonction est_pair(n). Ne demande aucune verification.", timeout_seconds=180, ) traces.append(("etape 1 (code, decidee par l'appelant)", tour.agent_hands))asyncwith ConversationRunner(verificateur_autonome) as conversation: tour =await conversation.turn("Verifie cette fonction : def est_pair(n): return n % 2 == 0", timeout_seconds=180, ) traces.append(("etape 2 (verif, decidee par l'appelant)", tour.agent_hands))return tracesfor etiquette, mains inawait designation_explicite():print(f"{etiquette} -> mains : {mains}")print("Cote C4, la sequence est dans NOTRE liste Python ; cote C5, elle ""etait apparue DANS un seul tour, decidee par le LLM.")
etape 1 (code, decidee par l'appelant) -> mains : ('coder',)
etape 2 (verif, decidee par l'appelant) -> mains : ('verificateur_autonome',)
Cote C4, la sequence est dans NOTRE liste Python ; cote C5, elle etait apparue DANS un seul tour, decidee par le LLM.
Lire la séquence côté appelant : deux conversations mono-main
La sortie aligne les deux étapes : etape 1 (code, decidee par l'appelant) -> mains : ('coder',) puis etape 2 (verif, decidee par l'appelant) -> mains : ('verificateur_autonome',). Deux observations. Un agent par conversation : ici coder et verificateur_autonome sont deux agents SÉPARÉS (pas un arbre racine/sous-agent) — la séquence existe seulement dans la liste traces de notre code, chaque tour reste mono-main. Le contraste avec le §3 : la même intention (« écris puis fais vérifier ») s’y était réalisée dans UN tour, la main passant à l’intérieur par décision du modèle — ici elle passe entre deux tours, par décision du code appelant. Le commentaire final imprimé le pose : C4 met la séquence dans nos données, C5 la laisse apparaître dans le tour.
5. Le transfert, tour à tour
Une même conversation, trois tours : coder seul, puis handoff demandé, puis retour au résumé. Chaque tour rend ses propres mains.
asyncdef mains_tour_par_tour():asyncwith ConversationRunner(coder) as conversation: empreintes = [] questions = ("Ecris une fonction moyenne(liste). Ne verifie rien pour l'instant.","Fais maintenant verifier moyenne par le verificateur.","Merci. Resume en une ligne ce que l'atelier a produit.", )for numero, question inenumerate(questions, start=1): tour =await conversation.turn(question, timeout_seconds=180) empreintes.append((numero, tour.agent_hands, tour.handoffs))return empreintesfor numero, mains, handoffs inawait mains_tour_par_tour():print(f"tour {numero} : mains = {mains} | handoffs = {handoffs}")
Lire les trois tours : un handoff durable, pas un écart d’un tour
Le tour 1 reste mono-main (coder), le tour 2 porte le handoff observable ((('coder', 'verificateur'),) dans handoffs). Le tour 3 révèle un comportement précieux : la conversation ne repart pas de la racine — la main est restée au vérificateur (mains = ('verificateur',), aucun handoff). ADK mémorise l’agent courant au-delà du tour : le handoff est un transfert durable du point d’entrée, pas un écart d’un seul tour. L’historique persiste (contrat C1b) : le résumé du tour 3 s’appuie sur tout ce qui précède.
6. Exercices
Exercice 1 — Détecteur de handoff manquant
Écris une fonction handoff_attendu(resultat) qui rend True si un handoff est observable dans un AdkRunResult, False sinon. Puis sers-t’en pour vérifier que le tour 2 du §5 a bien transféré (et que le tour 1, non).
# Exercice 1 : a completerprint("Exercice a completer")
Exercice a completer
Exercice 2 — Chaîne à trois agents
Déclare un troisième agent documenteur en sous-agent du vérificateur (pas du coder), demande « écris, fais vérifier, puis fais documenter » et observe : combien de handoffs dans le tour ? La chaîne descend-elle jusqu’au documenteur ?
# Exercice 2 : a completerprint("Exercice a completer")
Exercice a completer
Exercice 3 — Isolement des hiérarchies
Construis deuxConversationRunner sur le même arbre. Déclenche un handoff dans le premier. Vérifie ensuite dans l’historique du second (await conversation.history()) que le transfert du premier n’y apparaît pas — la contre-garde C1 : les sessions s’isolent, hiérarchies ou pas.
# Exercice 3 : a completerprint("Exercice a completer")
Exercice a completer
Lire les trois exercices : détecter, étendre, isoler
Les trois cellules d’exercice sont des stubs C.1 suivant le patron canonique (invite « Exercice à compléter » imprimée par chaque stub) — contrat d’exécution bout-en-bout, validation par l’étudiant. L’échelle des énoncés : Exercice 1 construit un détecteur de handoff manquant — un agent qui devait transférer vers le vérificateur et répond lui-même : comment l’observer depuis les compteurs handoffs/mains ? (le §3 a montré la main passée ; l’exercice demande de reconnaître l’absence) ; Exercice 2 étend l’arbre à trois agents — le transfert devient-il une chaîne, et que devient l’ensemble d’outils de chaque niveau ? ; Exercice 3 vérifie l’isolement des hiérarchies — deux conversations racines distinctes doivent garder leurs mains séparées, même quand les sous-agents portent le même nom. Trois niveaux : instrumenter l’absence, croître la structure, borner la portée.
Comme dans le Lab 12b, noter la forme : chaque stub imprime l’invite canonique « Exercice à compléter » — l’output atteste l’exécution, la solution reste celle de l’étudiant. Le reste du notebook a établi ses observables un à un — l’arbre déclaré, l’outil injecté, le coût d’avertissement du transfert, les mains tour par tour — et l’exercice prend le relais exactement là où l’observable devient celui de l’étudiant. La conclusion ci-dessous referme le contrat C5 : le handoff est le seul des deux mécanismes où le modèle choisit le prochain locuteur pendant le tour — avec le coût de transport que le runtime a imprimé au §3, et la persistance de la main que le §5 a mesurée sur trois tours.
7. Conclusion
C5 est câblé au-dessus d’ADK, pas réécrit : build_agent transmet la hiérarchie, ADK injecte transfer_to_agent, le Runner exécute le transfert natif.
Le handoff est observable : agent_hands (qui a parlé), handoffs (les passages de main), final_agent (qui conclut) — la preuve mécanique vit dans l’AdkRunResult, testée au retrait (critère 4 du registre).
C4 reste distinct et ouvert (#14685) : la désignation est une stratégie d’orchestrateur, le handoff une décision d’agent — les deux contrats cohabiteront sans se recouvrir.
Registre vivant : #14058 — jamais une restauration SK : ADK reste le runtime.