Lab 12e: Persistance d’etat de session - une conversation qui se souvient

Navigation : Lab 12 << | README du track

Ce lab porte le contrat comportemental C1b du corpus EPITA (#14058) : l’historique d’un tour doit atteindre le tour suivant, au sein d’une meme conversation.

Dans les labs precedents, chaque appel au runtime (run_agent_turn) creait une session fraiche : l’agent repondait a chaque tour comme s’il decouvrait la conversation. Nous allons d’abord mesurer ce non-portage avec le LLM reel, puis porter le contrat au-dessus d’ADK avec ConversationRunner (utils/adk_conversation.py) : un seul service de session et un seul identifiant de session partages par tous les tours d’une conversation.

Ce que vous saurez faire a la fin : - demontrer pourquoi deux tours isoles ne partagent aucun etat ; - maintenir une conversation multi-tours persistante sur le runtime ADK reel ; - prouver mecaniquement (par le contenu de la session) que la memoire existe ; - garder l’isolation entre conversations distinctes (contrat C1) tout en portant C1b.

1. Configuration

Le track charge son provider LLM depuis la configuration (ACTIVE_PROVIDER dans le .env du track). Le runtime ADK reste le meme qu’aux labs 11-12 : agent ADK reel, runner reel, session ADK reelle. Aucune reponse n’est simulee.

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-mini
Endpoint externe : True

Lecture : Verbatim : « Provider actif : openrouter », « Modele : openrouter/openai/gpt-4.1-mini », « Endpoint externe : True » — un modèle léger via passerelle pour ce lab. Le choix du modèle ne change rien à la question de la persistance de session ; seul le compte de jetons (Lab12d) en dépend.

Le provider actif est celui du .env du track (vLLM local, OpenRouter ou Gemini selon la machine). Le lab ne depend pas du modele choisi : le contrat C1b teste la mecanique de session du runtime, pas la qualite des reponses du LLM.

2. Le probleme : deux tours isoles ne partagent rien

Mesurons le comportement par defaut du runtime. Nous posons deux questions separees a l’agent : la premiere etablit un contexte metier (un dataset precis), la seconde demande un rappel de ce contexte – sans jamais le repeter dans la question.

from utils.adk_runtime import run_agent_turn, build_data_agent

agent = build_data_agent()

# Tour 1 : le contexte metier est pose.
tour1 = await run_agent_turn(
    agent,
    "Je travaille sur le dataset 'patients-2026' : 240 lignes et 12 colonnes. "
    "Appelle dataset_profile pour ce dataset et resume le resultat.",
    timeout_seconds=90,
)
print("TOUR 1 -- reponse :")
print(tour1.response_text)
TOUR 1 -- reponse :
Le dataset 'patients-2026' contient 240 lignes et 12 colonnes, ce qui donne un total de 2880 cellules. Chaque colonne a en moyenne 20 valeurs distinctes. Cela suggère une diversité de données suffisante dans chaque colonne, adaptée pour des analyses statistiques ou d'apprentissage machine. Si vous avez besoin d'une analyse plus détaillée ou des statistiques spécifiques, n'hésitez pas à me le dire.

Lecture : Réponse du tour 1, verbatim : « Le dataset ‘patients-2026’ contient 240 lignes et 12 colonnes, ce qui donne un total de 2880 cellules. Chaque colonne a en moyenne 20 valeurs distinctes. Cela suggère une diversité de données suffisante dans chaque colonne, adaptée pour des analyses statistiques ou d’apprentissage machine. » Le détail à retenir : l’agent ne recopie pas l’énoncé — il a appelé dataset_profile et calculé la multiplication (240 × 12 = 2880) et le profil par colonne (20 valeurs distinctes en moyenne). Le contexte posé au tour 1 est plus riche que la question : c’est ce contenu-là que le tour 2 devra retrouver.

# Tour 2 : rappel demande SANS repeter le contexte.
tour2 = await run_agent_turn(
    agent,
    "Combien de lignes et de colonnes avait mon dataset, et comment s'appelait-il ?",
    timeout_seconds=90,
)
print("TOUR 2 -- reponse :")
print(tour2.response_text)
TOUR 2 -- reponse :
Pour répondre à cette question, j'ai besoin que tu me fournisses les dimensions (nombre de lignes et de colonnes) ou un extrait de ton dataset pour que je puisse en analyser la forme. Pourrais-tu me transmettre ces informations ?

Lecture du resultat

La reponse du tour 2 est celle d’un agent amnesique : « Pour répondre à cette question, j’ai besoin que tu me fournisses les dimensions (nombre de lignes et de colonnes) ou un extrait de ton dataset » – il ne connait ni le nom patients-2026, ni ses 240 lignes, il n’a jamais recu cette information. Ce n’est pas un defaut du modele : c’est le runtime qui, a chaque appel a run_agent_turn, instancie un InMemorySessionService neuf et une session neuve. Le message du tour 1 n’existe simplement plus au tour 2.

C’est la mesure du non-portage du contrat C1b – le meme constat que celui consigne par le test test_state_does_not_persist_across_calls dans utils/test_adk_runtime_contracts.py.

3. La solution au-dessus d’ADK : ConversationRunner

ADK porte nativement la mecanique qu’il nous faut : une session est concue pour durer, et le runner y accumule l’historique a chaque run_async. Le non-portage venait de l’assemblage (tout recreer par appel), pas du moteur.

utils/adk_conversation.py fait l’assemblage durable : un seul service de session, un seul session_id, un runner pour toute la conversation. Aucune logique propre – uniquement les primitives publiques d’ADK.

from utils.adk_conversation import ConversationRunner

print(ConversationRunner.__doc__.strip()[:600])
print("...")
print("Deux attributs font tout le contrat :")
print("  - self.session_service : un seul InMemorySessionService pour TOUS les tours")
print("  - self.session_id      : identifiant stable, jamais regenere entre les tours")
Execute plusieurs tours d'un agent dans la MEME session ADK.

La conversation detient un ``InMemorySessionService`` et un ``session_id``
uniques : chaque tour herite de l'historique des tours precedents, ce qui
porte le contrat C1b (persistance d'etat entre tours). Le cloisonnement
reste celui du contrat C1 : deux ``ConversationRunner`` sont deux sessions
distinctes, isolees l'une de l'autre.
...
Deux attributs font tout le contrat :
  - self.session_service : un seul InMemorySessionService pour TOUS les tours
  - self.session_id      : identifiant stable, jamais regenere entre les tours

Lecture : Le docstring porte les deux contrats d’une phrase : « Execute plusieurs tours d’un agent dans la MEME session ADK » (C1b) et « Le cloisonnement reste celui du contrat C1 : deux ConversationRunner sont deux sessions distinctes, isolees l’une de l’autre ». Les deux attributs imprimés font tout le travail : « self.session_service : un seul InMemorySessionService pour TOUS les tours », « self.session_id : identifiant stable, jamais regenere entre les tours ». Aucune logique propre — l’assemblage réutilise les primitives publiques d’ADK ; ce qui manquait au tour isolé de la section 2, c’était uniquement la durée de vie de ces deux objets.

La conversation va maintenant jouer trois tours sur la meme session : le tour 1 pose le contexte, le tour 2 demande le rappel, le tour 3 demande un rappel plus profond (une valeur calculee au tour 1 par l’outil, pas seulement recitee).

conv_agent = build_data_agent()

async with ConversationRunner(conv_agent) as conv:
    t1 = await conv.turn(
        "Je travaille sur le dataset 'patients-2026' : 240 lignes et 12 colonnes. "
        "Appelle dataset_profile et resume le resultat.",
        timeout_seconds=90,
    )
    t2 = await conv.turn(
        "Combien de lignes et de colonnes avait mon dataset, et comment s'appelait-il ?",
        timeout_seconds=90,
    )
    t3 = await conv.turn(
        "Et combien de cellules au total (recalcule si besoin) ?",
        timeout_seconds=90,
    )
    history = await conv.history()
print("TOUR 2 -- reponse (rappel du contexte) :")
print(t2.response_text)
print()
print("TOUR 3 -- reponse (rappel de la valeur calculee) :")
print(t3.response_text)
TOUR 2 -- reponse (rappel du contexte) :
Ton dataset s'appelait "patients-2026". Il contenait 240 lignes et 12 colonnes.

TOUR 3 -- reponse (rappel de la valeur calculee) :
Le dataset "patients-2026" ayant 240 lignes et 12 colonnes contient au total 240 × 12 = 2880 cellules.

Lecture du resultat

Le tour 2 rappelle le nom et la forme du dataset ; le tour 3 retrouve la valeur calculee (2 880 cellules = 240 x 12). L’agent n’a jamais recu ces informations au tour 2 ou 3 : elles lui parviennent par l’historique de session qu’ADK attache a chaque requete LLM. Le contrat C1b est porte – par l’assemblage durable des primitives ADK, pas par une restauration du moteur SK (#14058).

4. Preuve mecanique : la session cumule les tours

La memoire ne doit pas etre un effet de bord du modele : elle doit etre observable dans la session. Un evenement ADK par message – apres trois tours d’une conversation, la session doit contenir l’historique complet (questions et reponses, appels d’outil inclus).

print(f"Evenements persistes dans la session apres 3 tours : {len(history)}")
print()
print("Chronologie de la conversation (role : extrait) :")
for i, event in enumerate(history, start=1):
    if event.content and event.content.parts:
        texte = "".join(p.text or "" for p in event.content.parts).strip()
        if texte:
            print(f"  {i:2d}. [{event.author or '?':>8}] {texte[:95]}")
        elif event.get_function_calls():
            print(f"  {i:2d}. [{event.author or '?':>8}] <appel d'outil>")
Evenements persistes dans la session apres 3 tours : 8

Chronologie de la conversation (role : extrait) :
   1. [    user] Je travaille sur le dataset 'patients-2026' : 240 lignes et 12 colonnes. Appelle dataset_profil
   2. [dataset_profile_agent] <appel d'outil>
   4. [dataset_profile_agent] Le dataset "patients-2026" contient 240 lignes (exemples) et 12 colonnes (variables). Il y a do
   5. [    user] Combien de lignes et de colonnes avait mon dataset, et comment s'appelait-il ?
   6. [dataset_profile_agent] Ton dataset s'appelait "patients-2026". Il contenait 240 lignes et 12 colonnes.
   7. [    user] Et combien de cellules au total (recalcule si besoin) ?
   8. [dataset_profile_agent] Le dataset "patients-2026" ayant 240 lignes et 12 colonnes contient au total 240 × 12 = 2880 ce

Lecture du resultat

La session detient la conversation complete : les trois questions utilisateur, les reponses du modele et les allers-retours d’outil. La chronologie imprimee le montre evenement par evenement – question en 1, appel d’outil en 2, profil en 4, rappel du nom en 6, calcul en 8 (l’evenement 3, masque par le filtre d’impression, compte dans les 8). C’est exactement ce contenu qu’ADK renvoie au LLM comme contexte au tour suivant – la preuve mecanique demande par le critere 4 de #14058 : le test test_c1b_history_reaches_next_turn verifie que le marqueur du tour 1 figure dans la requete LLM du tour 2, et echoue si la persistance est retiree.

5. Persistance ET isolation : C1b ne casse pas C1

Porter la memoire intra-conversation ne doit pas creer de fuite inter-conversations. Le contrat C1 (isolation) reste porte : deux ConversationRunner sont deux sessions ADK distinctes. Verifions qu’un contexte pose dans la conversation A est invisible depuis la conversation B.

async def deux_conversations():
    # Conversation A : contexte exclusif.
    agent_a = build_data_agent()
    async with ConversationRunner(agent_a) as conv_a:
        await conv_a.turn(
            "Retiens : le dataset 'secret-alpha' fait 88 lignes et 4 colonnes.",
            timeout_seconds=90,
        )
        hist_a = await conv_a.history()
    # Conversation B : question SANS le nom -- le marqueur ne peut arriver
    # dans l'historique de B que par une fuite de session.
    agent_b = build_data_agent()
    async with ConversationRunner(agent_b) as conv_b:
        t = await conv_b.turn(
            "Combien de lignes avait MON dataset, et comment s'appelait-il ?",
            timeout_seconds=90,
        )
        hist_b = await conv_b.history()
    return hist_a, hist_b, t

hist_a, hist_b, reponse_b = await deux_conversations()
textes_a = "".join(
    (p.text or "") for e in hist_a for p in ((e.content and e.content.parts) or []))
textes_b = "".join(
    (p.text or "") for e in hist_b for p in ((e.content and e.content.parts) or []))
print("A contient 'secret-alpha'          :", "secret-alpha" in textes_a)
print("B contient 'secret-alpha' (fuite ?):", "secret-alpha" in textes_b)
print()
print("Reponse de B (attendue : aucun contexte recu) :")
print(reponse_b.response_text[:300])
A contient 'secret-alpha'          : True
B contient 'secret-alpha' (fuite ?): False

Reponse de B (attendue : aucun contexte recu) :
Pour vous répondre précisément, j'aurais besoin que vous me fournissiez les informations sur votre dataset, notamment le nombre de lignes et de colonnes si vous les connaissez, ou bien que vous me donniez accès à ce dataset pour analyser sa forme (nombre de lignes et colonnes). Pouvez-vous me fourni

Lecture du resultat

La conversation B ne contient mecaniquement aucune trace du contexte de A : sa reponse, « Pour vous répondre précisément, j’aurais besoin que vous me fournissiez les informations sur votre dataset, notamment le nombre de lignes et de colonnes », redemande les dimensions du dataset – l’isolation C1 tient. Les deux contrats ensemble dessinent le perimetre exact de l’etat partage : partage au sein d’une conversation, cloisonnement entre conversations. C’est la paire C1 + C1b, verifiee par les tests test_c1b_history_reaches_next_turn et test_c1b_conversations_still_isolate.

Le test croise le confirme ligne a ligne : « A contient ‘secret-alpha’ : True », « B contient ‘secret-alpha’ (fuite ?): False ».

6. Exercices

Trois exercices pour vous approprier le contrat. Le notebook doit rester executable de bout en bout : les stubs ne levent jamais d’erreur.

Exercice 1 – Amnesie controlee

Certaines conversations doivent oublier : donnees sensibles, changement de client, test isole. Ecrivez oublie_et_redemarre qui ferme la conversation courante et en rouvre une neuve, puis verifie que le nouveau tour ignore le contexte anterieur.

Indice : il suffit de creer un nouveau ConversationRunner – l’isolation C1 fait le reste. Comparez les history() avant et apres.

# Exercice 1 : a completer
# Etape 1 : poser un contexte marqueur dans une conversation.
# Etape 2 : rouvrir une conversation neuve avec le meme agent.
# Etape 3 : demander le rappel du marqueur et afficher la reponse.

async def oublie_et_redemarre(agent, marqueur):
    """Renvoie (historique_avant, reponse_apres_oubli).

    La conversation initiale pose le marqueur ; une conversation neuve
    tente de le rappeler. L'historique de la premiere ne doit jamais
    contaminer la seconde.
    """
    # TODO etudiant : implementer avec deux ConversationRunner
    print("Exercice a completer")
    return None, None

# resultats = await oublie_et_redemarre(build_data_agent(), "memoire-vive-42")
# print(resultats)

Exercice 2 – Compteur d’evenements par tour

Ecrivez evenements_par_tour qui joue N tours sur une meme conversation et renvoie la liste des nombres d’evenements persistes apres chaque tour. La sequence attendue est croissante : chaque tour ajoute ses evenements a l’historique.

Indice : appelez history() entre les tours, sans fermer la conversation.

# Exercice 2 : a completer
# Etape 1 : ouvrir une ConversationRunner.
# Etape 2 : apres chaque tour, relever len(history()).
# Etape 3 : renvoyer la liste croissante.

async def evenements_par_tour(agent, invites):
    """Renvoie [len(history) apres le tour 1, apres le tour 2, ...]."""
    # TODO etudiant : implementer
    print("Exercice a completer")
    return None

# compteurs = await evenements_par_tour(build_data_agent(), ["tour un", "tour deux"])
# print("Compteurs :", compteurs)

Exercice 3 – Detecteur de fuite

Ecrivez fuite_entre_conversations qui pose un marqueur dans la conversation A, fait parler la conversation B, et renvoie True si le marqueur apparait mecaniquement dans l’historique de B (une fuite), False sinon. C’est le garde du contrat C1.

Indice : la verification est mecanique (parcourir history() de B), pas interpretative – ne demandez pas au LLM si il se souvient, lisez la session.

# Exercice 3 : a completer
# Etape 1 : conversation A pose un marqueur unique.
# Etape 2 : conversation B joue un tour quelconque.
# Etape 3 : chercher le marqueur dans history() de B et renvoyer le booleen.

async def fuite_entre_conversations(agent_a, agent_b, marqueur):
    """True si le marqueur de A atteint mecaniquement l'historique de B."""
    # TODO etudiant : implementer
    print("Exercice a completer")
    return False

# fuite = await fuite_entre_conversations(build_data_agent(), build_data_agent(), "fuite-cle-77")
# print("Fuite detectee :", fuite)  # attendu : False

7. Conclusion

Idee clee Ou la voir
Deux tours isoles ne partagent aucun etat section 2, reponse amnesique du tour 2
La memoire vit dans la session ADK, pas dans le modele section 4, history() cumule
Porter C1b = assembler durablement les primitives ADK utils/adk_conversation.py
C1b ne casse pas C1 : conversations toujours isolees section 5
Le contrat est garde par un test qui echoue au retrait test_c1b_history_reaches_next_turn

Ou va le track ensuite : les contrats C4 (designation), C5 (handoff) et C6 (budgets de jetons) restent non portes par le runtime du depot – chacun est un grain ouvert (#14058), mesuré dans utils/test_adk_runtime_contracts.py. Le transfer natif d’ADK (TransferToAgentTool) et l’ordonnanceur dynamique (workflow) sont les primitives candidates pour les tranches suivantes.

Retour au sommet