Lab 12b : Désignation séquentielle — le contrat C4, l’orchestrateur explicite au-dessus d’ADK

Le quatrième contrat EPITA du registre (#14058) : le runtime désigne le prochain agent selon une stratégie explicite. La mesure initiale : ADK 2.8 embarque bien un ordonnanceur dynamique, mais aucune surface du dépôt n’exposait de stratégie — l’ordre Planner→Coder→… de vos chaînes était codé à la main, étape par étape, chez l’appelant. Ce lab travaille sur le portage livré par #14685 : un AdkOrchestrator accepte N spécialistes et exécute la chaîne selon un plan déclaratif posé avant le premier appel LLM.

LLM réel (OpenRouter) : le contenu de chaque étape est produit par le modèle ; l’ordre des étapes, lui, n’est jamais décidé par le modèle.

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)}")

import logging

# Runner par etape + session partagee : chaque runner previent (warning)
# pour les events de specialistes hors de son arbre -- attendu, bruit seul.
# ADK prefixe ses loggers ("google_adk." + __name__) : viser le nom reel.
logging.getLogger("google_adk.google.adk.runners").setLevel(logging.ERROR)
Provider actif : openrouter
Modele : openrouter/openai/gpt-4.1
Endpoint externe : True

Lire la configuration attestée : quel moteur a produit ces sorties

La sortie d’import fixe le paramètre d’expérience du notebook : Provider actif : openrouter, Modele : openrouter/openai/gpt-4.1, Endpoint externe : True. C’est l’attestation que tout ce qui suit — la chaîne de désignation, les réponses des spécialistes, le verdict final — est produit par un vrai moteur LLM externe, pas par un stub ou une réponse codée en dur : la même exécution sur une autre session pourra confronter ses sorties à celles-ci en connaissant le modèle exact. Le reste de la cellule est de la plomberie assumée : l’insertion de .. au sys.path (les utilitaires de l’atelier) et le filtre ciblé d’un avertissement EXPÉRIMENTAL de la bibliothèque ADK — filtré par motif précis, pas en bloquant tous les warnings.

2. La chaîne déclarée : quatre spécialistes, un plan explicite

La désignation C4 se déclare : la flotte d’agents d’un côté (quatre spécialistes construits par build_agent — l’exécuteur porte l’outil réel dataset_profile), le plan de l’autre — un tuple de noms, posé avant toute exécution. L’orchestrateur détient une session ADK unique : chaque spécialiste désigné verra tout ce que ses prédécesseurs ont produit.

from utils.adk_runtime import build_agent, dataset_profile
from utils.adk_orchestrator import AdkOrchestrator

planner = build_agent(
    name="planner",
    description="Planificateur de la chaîne : décompose la demande.",
    instruction=(
        "Tu es le planificateur d'une chaîne d'analyse de données. "
        "Décompose la demande en trois étapes courtes et numérotées. "
        "Ne code rien, ne calcule rien : tu planifies."
    ),
)
coder = build_agent(
    name="coder",
    description="Codeur de la chaîne : écrit la fonction demandée.",
    instruction=(
        "Tu es le codeur de la chaîne. Écris une fonction Python courte "
        "(nommée, commentée) qui réponde à l'étape en cours. Une fonction "
        "seulement, prête à exécuter."
    ),
)
executor = build_agent(
    name="executor",
    description="Exécuteur de la chaîne : mesure avec l'outil réel.",
    instruction=(
        "Tu es l'exécuteur de la chaîne. Quand un profil de dataset est "
        "en jeu, appelle OBLIGATOIREMENT l'outil dataset_profile avec les "
        "dimensions (lignes, colonnes) présentes dans la conversation, "
        "puis restitue ses chiffres."
    ),
    tools=(dataset_profile,),
)
verifier = build_agent(
    name="verifier",
    description="Vérificateur de la chaîne : clôt par un verdict.",
    instruction=(
        "Tu es le vérificateur de la chaîne. Relis ce que tes "
        "prédécesseurs ont produit et conclus en deux lignes : VERDICT "
        "(COHÉRENT ou ÉCART), puis l'argument central."
    ),
)

plan_complet = ("planner", "coder", "executor", "verifier")
chaine = AdkOrchestrator(
    [planner, coder, executor, verifier],
    plan=plan_complet,
)
print(f"Plan declaré AVANT execution : {chaine.plan}")
print("La designation est une donnée posée par l'appelant -- l'ordre "
      "n'est decidé par aucun LLM.")
Plan declaré AVANT execution : ('planner', 'coder', 'executor', 'verifier')
La designation est une donnée posée par l'appelant -- l'ordre n'est decidé par aucun LLM.

Lire le plan imprimé avant l’exécution : la stratégie est une donnée

Le plan est imprimé avant l’exécution : c’est l’observable de la stratégie. Les quatre agents sont de vrais Agent ADK — l’orchestrateur n’assemble que des primitives publiques (un Runner par étape, un InMemorySessionService partagé), jamais une restauration du moteur SK (#14058).

3. La chaîne en action : chaque spécialiste prend le relais

Un seul appel, quatre étapes désignées. La première reçoit le prompt ; chacune des suivantes s’enchaîne sur l’historique de la session partagée, sans nouveau message utilisateur — le spécialiste désigné prend le relais sur ce qui précède. Observez agent_hands (la désignation exécutée) et tool_calls (l’outil réel de l’exécuteur).

import asyncio

async def chaine_complete():
    async with AdkOrchestrator(
        [planner, coder, executor, verifier], plan=plan_complet
    ) as orchestrateur:
        return orchestrateur, await orchestrateur.run_chain(
            "Traite ce dataset de 120 lignes et 8 colonnes : planifie "
            "l'analyse, écris la fonction de profil, exécute le profil, "
            "puis vérifie le résultat.",
            timeout_seconds=240,
        )

orchestrateur, resultat = await chaine_complete()
print(f"Désignation exécutée (mains) : {resultat.agent_hands}")
print(f"Appels d'outils : {resultat.tool_calls}")
print(f"Réponse finale (du dernier désigné, {resultat.final_agent}) :")
print(resultat.response_text[:400])
Désignation exécutée (mains) : ('planner', 'coder', 'executor', 'verifier')
Appels d'outils : ('dataset_profile',)
Réponse finale (du dernier désigné, verifier) :
VERDICT : COHÉRENT  
L’exécution a correctement vérifié les dimensions du dataset et le profilage de base est conforme à la planification et à la fonction définie.

Lire le verdict final : une vérification affichée, pas réimprimée

Premier compteur : Désignation exécutée (mains) : ('planner', 'coder', 'executor', 'verifier') reproduit exactement le plan imprimé avant l’exécution — la chaîne a suivi la désignation déclarée, sans agent sauté ni ajouté.

La réponse finale imprimée commence par VERDICT : COHÉRENT et statue que « l’exécution a correctement vérifié les dimensions du dataset et le profilage de base est conforme à la planification et à la fonction définie » — le verdict ÉVALUE les dimensions sans les ré-énumérer. Les chiffres 120 lignes × 8 colonnes vivent dans la demande posée à la chaîne (source de la cellule : « Traite ce dataset de 120 lignes et 8 colonnes ») ; ce que la sortie prouve, elle, est double : l’exécuteur a invoqué l’outil de profilage (Appels d'outils : ('dataset_profile',)), et la réponse finale vient du dernier désigné, verifier — la parole finale appartient au plan, pas au hasard des tours. Ce que la sortie ne prouve PAS : d’où viennent exactement les mots du verdict — il ne réimprime ni les dimensions ni le profil ; l’affirmation de conformité est celle du vérificateur, lue telle quelle.

4. Désignation C4 vs handoff C5 : l’ordre posé avant, pas décidé pendant

Le point de distinction avec le Lab 12c : là, le transfert était décidé par l’agent au milieu d’un tour (transfer_to_agent) ; ici, l’ordre est une donnée. La preuve par l’expérience : la même flotte d’agents, un plan réduit — le déroulé change sans qu’aucun agent ne soit modifié.

async def plan_reduit():
    # MÊME flotte d'agents, AUTRE stratégie : la désignation est une
    # donnée par chaîne -- on retire coder et executor du plan sans
    # toucher aux agents eux-mêmes.
    async with AdkOrchestrator(
        [planner, coder, executor, verifier],
        plan=("planner", "verifier"),
    ) as orchestrateur:
        return orchestrateur, await orchestrateur.run_chain(
            "Prépare en une étape la vérification directe de ce dataset "
            "de 120 lignes et 8 colonnes.",
            timeout_seconds=240,
        )

orchestrateur_reduit, resultat_reduit = await plan_reduit()
print(f"Plan réduit : {orchestrateur_reduit.plan}")
print(f"Désignation exécutée : {resultat_reduit.agent_hands}")
print(f"Appels d'outils : {resultat_reduit.tool_calls or 'aucun'}")
print("Côté C4, changer l'ordre = changer UNE donnée ; côté C5 "
      "(Lab 12c), le transfert était décidé par le modèle en cours de tour.")
Plan réduit : ('planner', 'verifier')
Désignation exécutée : ('planner', 'verifier')
Appels d'outils : aucun
Côté C4, changer l'ordre = changer UNE donnée ; côté C5 (Lab 12c), le transfert était décidé par le modèle en cours de tour.

Lire le plan réduit en chiffres : deux mains, zéro outil, quatre agents

La sortie compare trois compteurs : Plan réduit : ('planner', 'verifier'), Désignation exécutée : ('planner', 'verifier') — déclarité et exécution coïncident — et Appels d'outils : aucun. La lecture en creux est la plus instructive : la flotte instanciée compte toujours quatre agents (coder et executor existent, avec leurs instructions et leurs outils), mais seuls deux sont désignés — l’exécution constate leur absence par le compteur d’appels vide et le tuple de deux mains. Changer la stratégie d’une chaîne C4 = changer une donnée (le plan), jamais le code ; changer le cours d’un handoff C5 = réécrire l’instruction d’un agent. C’est la démonstration chiffrée du contrat — l’anti-miroir du handoff C5 du Lab 12c, où la séquence était décidée pendant le tour : les deux contrats cohabitent sans se recouvrir.

5. Mémoire commune intra-chaîne, isolation inter-chaînes

La session partagée donne à la chaîne une mémoire commune : l’historique porte les quatre auteurs. La contre-garde C1 exige la symétrie inverse : deux chaînes sont deux sessions isolées — le message confidentiel de la première ne doit jamais atteindre l’historique de la seconde.

async def memoire_et_isolation():
    async with AdkOrchestrator(
        [planner, coder, executor, verifier], plan=plan_complet
    ) as chaine_a:
        await chaine_a.run_chain(
            "Analyse confidentielle : 60 lignes, 4 colonnes.",
            timeout_seconds=240,
        )
        historique_a = await chaine_a.history()
    async with AdkOrchestrator(
        [planner, coder, executor, verifier], plan=("planner",)
    ) as chaine_b:
        await chaine_b.run_chain(
            "Question banale : 10 lignes, 2 colonnes.",
            timeout_seconds=240,
        )
        historique_b = await chaine_b.history()
    return historique_a, historique_b

def textes(historique):
    return [
        "".join(p.text or "" for p in (event.content.parts or []))
        for event in historique if event.content
    ]

historique_a, historique_b = await memoire_et_isolation()
auteurs_a = [event.author for event in historique_a]
print(f"Auteurs dans la chaîne A (mémoire commune) : {auteurs_a}")
fuite = any("confidentielle" in t for t in textes(historique_b))
print(f"Le message confidentiel de A a-t-il fui dans B ? {fuite}")
print("La mémoire est commune À L'INTÉRIEUR d'une chaîne, jamais ENTRE "
      "chaînes (contre-garde C1).")
Auteurs dans la chaîne A (mémoire commune) : ['user', 'planner', 'coder', 'executor', 'executor', 'executor', 'verifier']
Le message confidentiel de A a-t-il fui dans B ? False
La mémoire est commune À L'INTÉRIEUR d'une chaîne, jamais ENTRE chaînes (contre-garde C1).

Lire l’historique : sept messages, un agent qui triple, aucune fuite

La sortie de la mémoire commune mérite un comptage exact : Auteurs dans la chaîne A : ['user', 'planner', 'coder', 'executor', 'executor', 'executor', 'verifier'] — sept messages, dans l’ordre désigné, chacun ayant lu ses prédécesseurs, et l’exécuteur apparaît trois fois : un agent désigné peut prendre plusieurs tours consécutifs dans une même étape du plan, l’ordre désigné n’est pas un plafond de un tour par main. Second point, l’isolation : fuite = False alors que la chaîne B a tourné juste après — et les demandes des deux chaînes sont distinctes par construction (source de la cellule — A : « Analyse confidentielle : 60 lignes, 4 colonnes. » ; B : « Question banale : 10 lignes, 2 colonnes. ») : même en poussant B juste après A, rien de la conversation de A n’a transité. Le garde mesure précisément la fuite du mot « confidentielle » dans les textes de B : le contre-garde C1 est mesuré, pas seulement affirmé. La mémoire est commune à l’intérieur d’une chaîne, jamais entre chaînes — désignation ou pas.

6. Exercices

Exercice 1 — Vérificateur en tête

Déclare la même flotte avec le plan ("verifier", "planner") et exécute-la. Que produit une vérification qui précède le plan ? Observe agent_hands et la cohérence du verdict final.

# Exercice 1 : a completer
print("Exercice a completer")
Exercice a completer

Exercice 2 — Le plan n’appartient pas aux agents

Construis deux AdkOrchestrator sur la MÊME flotte d’agents avec des plans différents, exécute les deux. Montre que le plan est une donnée par chaîne : aucun des deux déroulés ne modifie l’autre ni les agents.

# Exercice 2 : a completer
print("Exercice a completer")
Exercice a completer

Exercice 3 — Compteur de désignation

Écris nb_etapes(resultat) qui compte les mains distinctes d’un AdkRunResult. Vérifie qu’elle rend 4 pour la chaîne complète (§3) et 2 pour le plan réduit (§4).

# Exercice 3 : a completer
print("Exercice a completer")
Exercice a completer

Lire les trois exercices : l’échelle des contre-contrats

Les cellules d’exercice sont des stubs C.1 suivant le patron canonique du dépôt — chaque invite imprime « Exercice à compléter », aucune erreur volontaire — le notebook s’exécute d’un bout à l’autre, la validation est l’étudiant. L’échelle des énoncés prolonge la leçon : Exercice 1 place un vérificateur en tête de plan — la question devient « que vérifie-t-on quand rien n’a encore été produit ? », l’ordre du plan comme décision d’architecture ; Exercice 2 déplace le plan hors des instructions des agents — le même anti-pattern que le notebook vient de démontrer, à refaire tenir par construction ; Exercice 3 demande un compteur de désignation — instrumenter agent_hands pour mesurer qui a pris la parole, l’observable du contrat C4. Trois gestes : inverser, encadrer, instrumenter.

Dernière observation, sur la forme : chaque stub imprime l’invite canonique « Exercice à compléter » — le patron du dépôt pour une cellule d’exercice, qui la rend à la fois exécutable et informative : l’output atteste que la cellule a tourné, la place de la solution reste à l’étudiant. Partout ailleurs, chaque cellule de démonstration imprime son observable (le plan déclaré, les mains, les appels d’outils, l’historique) ; la zone d’exercice prend le relais exactement là où l’observable attendu devient celui de l’étudiant — et la conclusion ci-dessous referme le contrat C4 en rappelant ce que la désignation garantit que le handoff ne garantit pas.

7. Conclusion

  • C4 est porté au-dessus d’ADK : AdkOrchestrator exécute une chaîne selon un plan déclaratif — la stratégie est une donnée observable avant l’exécution (orchestrator.plan) et vérifiée après (agent_hands).
  • La chaîne a une mémoire commune : chaque spécialiste désigné voit tout l’historique ; l’isolement reste scopé par session (contre-garde C1, testée au retrait).
  • C4 et C5 restent distincts : désignation = ordre posé avant le premier appel LLM ; handoff = décision de l’agent au milieu d’un tour. L’ordonnanceur dynamique natif d’ADK reste la primitive candidate pour une désignation adaptée au contenu — une stratégie enrichie du même contrat.
  • Registre vivant : #14058 — jamais une restauration SK : ADK reste le runtime.
Retour au sommet