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.

1. Configuration

import sys
sys.path.insert(0, '..')

import warnings

warnings.filterwarnings(
    "ignore",
    message=(
        r"\[EXPERIMENTAL\] feature "
        r"FeatureName\.JSON_SCHEMA_FOR_FUNC_DECL is enabled\."
    ),
    category=UserWarning,
    module=r"google\.adk\.models\.llm_request",
)

from config.providers import get_settings, get_provider_config, get_litellm_model

settings = get_settings()
provider = get_provider_config(settings)
print(f"Provider actif : {provider.provider.value}")
print(f"Modele : {get_litellm_model(provider)}")
print(f"Endpoint externe : {bool(provider.base_url)}")
Provider actif : openrouter
Modele : openrouter/openai/gpt-4.1
Endpoint externe : True

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_agent

verificateur = 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 asyncio
from utils.adk_conversation import ConversationRunner

async def demonstration_handoff():
    async with ConversationRunner(coder) as conversation:
        return await 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_agent

verificateur_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."
    ),
)

async def 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 = []
    async with 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))
    async with 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 traces

for etiquette, mains in await 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.

async def mains_tour_par_tour():
    async with 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 in enumerate(questions, start=1):
            tour = await conversation.turn(question, timeout_seconds=180)
            empreintes.append((numero, tour.agent_hands, tour.handoffs))
        return empreintes

for numero, mains, handoffs in await mains_tour_par_tour():
    print(f"tour {numero} : mains = {mains} | handoffs = {handoffs}")
tour 1 : mains = ('coder',) | handoffs = ()
tour 2 : mains = ('coder', 'verificateur') | handoffs = (('coder', 'verificateur'),)
tour 3 : mains = ('verificateur',) | 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 completer
print("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 completer
print("Exercice a completer")
Exercice a completer

Exercice 3 — Isolement des hiérarchies

Construis deux ConversationRunner 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 completer
print("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.
Retour au sommet