Vue d’ensemble : un duel d’agents Père Fouras vs Laurent Jalabert

# Parameters
BATCH_MODE = True

Ce notebook implémente une simulation multi-agent du jeu télévisé Fort Boyard : le Père Fouras pose des énigmes à un personnage célèbre (ici, le cycliste Laurent Jalabert), et l’agent doit résoudre l’énigme en mobilisant un mot à deviner (par défaut "anticonstitutionnellement", l’un des plus longs mots français). L’orchestration repose sur un AgentGroupChat Semantic Kernel avec une stratégie de renoncement (l’agent abandonne après N tentatives) et un système de score en fin de partie. Le notebook couvre les patterns : kernel partagé, ChatCompletionAgent, persona engineering via prompts structurés, et termination strategy custom. C’est un cas d’usage type pour les game-theoretic agent simulations où deux LLM agents s’affrontent dans un dialogue structuré. L’architecture du notebook — kernel partagé, deux agents à prompts asymétriques, groupe de chat avec stratégies custom — est volontairement minimale pour rester lisible : un étudiant peut suivre la dynamique du duel sans se perdre dans une forêt de plugins. C’est l’opposé des cas industriels à 10+ agents où l’orchestration devient illisible. Pattern reproductible.

Adapté d’une production étudiante EPF (#890) — groupe d’origine non préservé par le refactor.

Jeu de devinette : Père Fouras vs Laurent Jalabert

Navigation : Index

Dans ce notebook, nous allons simuler le duel légendaire entre le Père Fouras et Laurent Jalabert en utilisant Semantic Kernel avec des agents conversationnels.

# Import guards - availability flags for external dependencies

try:
    from dotenv import load_dotenv
    DOTENV_AVAILABLE = True
except ImportError:
    DOTENV_AVAILABLE = False
    print(f'  dotenv non disponible - certaines fonctionnalites seront limitees')

try:
    import semantic_kernel
    SEMANTIC_KERNEL_AVAILABLE = True
except ImportError:
    SEMANTIC_KERNEL_AVAILABLE = False
    print(f'  semantic_kernel non disponible - certaines fonctionnalites seront limitees')

# Bloc 1 - Installation et imports

import os
import logging
from dotenv import load_dotenv
from semantic_kernel import Kernel
from semantic_kernel.agents import ChatCompletionAgent, AgentGroupChat
from semantic_kernel.agents.strategies import KernelFunctionTerminationStrategy
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
from semantic_kernel.contents import ChatHistory
from semantic_kernel.functions import KernelArguments

# Configuration des logs
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    handlers=[logging.StreamHandler()]
)
logger = logging.getLogger('FortBoyard')

# Chargement des variables d'environnement
env_path = os.path.join(os.path.dirname(os.path.abspath(".")), ".env")
if os.path.exists(env_path):
    load_dotenv(env_path)
else:
    load_dotenv()

Configuration des agents

Le Kernel est le cœur de Semantic Kernel : un conteneur d’injection de dépendances qui regroupe les services d’IA et les fonctions (plugins) qu’un agent peut invoquer. Avant de créer un agent, il faut donc lui brancher un service de complétion de chat — ici OpenAIChatCompletion, paramétré par le modèle et la clé API lus depuis l’environnement (jamais codés en dur, cf. règle secrets-hygiene).

Cette cellule fixe aussi MOT_A_DEVINER, le mot secret que le Père Fouras fera deviner à Laurent Jalabert tout au long de la partie.

# Bloc 2 - Création du kernel
MOT_A_DEVINER = "anticonstitutionnellement"

def create_kernel():
    kernel = Kernel()
    model_id = os.getenv("OPENAI_CHAT_MODEL_ID", "gpt-5-mini")
    kernel.add_service(OpenAIChatCompletion(
        service_id="openai",
        ai_model_id=model_id,
        api_key=os.getenv("OPENAI_API_KEY")
    ))
    return kernel

Lecture du résultat : pourquoi ce choix de mot est pédagogique

Le mot "anticonstitutionnellement" (25 lettres, Grand Robert) cumule plusieurs difficultés : longueur (force des indices progressifs), composition morphologique (anti-+constitution+nelle+ment — l’agent peut deviner par décomposition), et rareté (vocabulaire juridique peu courant). Pour un jeu d’énigmes LLM, c’est un excellent stress-test : si l’agent devine ce mot en moins de 10 essais, le prompt Père Fouras est bien calibré. L’étudiant peut mesurer le taux de réussite sur 100 parties avec evaluer_partie() puis comparer avec un mot plus simple (ex. "kayak", 5 lettres) pour isoler l’effet longueur du mot. Le contrat doit être testé. Référence Grand Robert. Référence.

Lecture du résultat : kernel Semantic Kernel instancié

La cellule 4 crée le kernel et stocke MOT_A_DEVINER = "anticonstitutionnellement" (25 lettres, l’un des plus longs mots français standards). Cette constante est ce que l’agent va devoir deviner au cours du duel via une stratégie d’énigmes posée par le Père Fouras. Le choix du mot est pédagogique : sa longueur force l’agent à mobiliser des stratégies d’indice indirect (rime, contexte, longueur) plutôt qu’une description directe. L’étudiant peut substituer un mot plus court pour des parties plus rapides ("kayak" pour un palindrome). Force la progression. Plus. Progression.

Définition des prompts

Chaque agent conversationnel est piloté par un prompt système (ses instructions) qui définit son rôle, sa personnalité et les règles qu’il doit respecter. Ces instructions conditionnent tout le comportement de l’agent au fil du dialogue : un même Kernel peut produire deux interlocuteurs radicalement différents selon le prompt qu’on lui donne.

Ici, le prompt du Père Fouras injecte le mot secret via une f-string ({MOT_A_DEVINER}) et lui interdit de le révéler directement : il doit guider par des charades. Celui de Laurent Jalabert le contraint à poser des questions fermées (Oui/Non), ce qui transforme la devinette en un problème de réduction d’incertitude tour par tour.

# Bloc 3 - Prompts des agents
PERE_FOURAS_PROMPT = f"""
Tu es le Père Fouras de Fort Boyard. 
Tu dois faire deviner le mot '{MOT_A_DEVINER}'. 
Utilise des charades et réponses énigmatiques. 
Ne révèle jamais directement le mot !
"""

LAURENT_JALABERT_PROMPT = """
Tu es Laurent Jalabert. 
Tu dois deviner le mot en posant des questions fermées (Oui/Non).
Sois perspicace et stratégique dans tes questions.
"""

Lecture du résultat : prompts Père Fouras et cycliste configurés

La cellule 6 définit deux PERE_FOURAS_PROMPT et CYCLISTE_PROMPT structurés via f"""...""". Le prompt du Père Fouras encode la persona + la mécanique : pose énigme, attend réponse, valide, propose indice suivant. Le prompt du cycliste encode la stratégie de devinette : extraire lettres, longueur, contexte, demander indice. Cette séparation persona/mécanique est cruciale — sans persona explicite, le LLM répondrait en mode Q&A générique sans le cadre narratif Fort Boyard. L’exercice 1 invite l’étudiant à ajouter un 3ᵉ personnage (Détective, Témoin) — extension classique du multi-agent cast. Le format f"""...""" permet l’interpolation de variables runtime (mot à deviner, score actuel).

Exercice 1 : Personnaliser les personnages du duel

Les prompts actuels definissent le Pere Fouras comme enigmatique et Laurent Jalabert comme perspicace. L’objectif est de créer une variante du jeu avec deux personnages différents : un Detective (qui pose des questions deductives) et un Temoin (qui repond de maniere evasive sans reveler le mot).

Objectif : ecrire les instructions système pour ces deux nouveaux personnages et configurer les agents correspondants.

Indices : - # Étape 1 : Rediger le prompt du Temoin (rôle, règles, interdictions) - # Étape 2 : Rediger le prompt du Detective (stratégie de questionnement) - # Indice : le Temoin ne doit jamais reveler le mot directement, mais peut donner des indices indirects

# Exercice 1 : Personnaliser les personnages du duel
# TODO etudiant : definir les prompts et creer les agents

TEMoin_PROMPT = ""  # Etape 1 : instructions du Temoin (evasif, indices indirects)
DETECTIVE_PROMPT = ""  # Etape 2 : instructions du Detective (questions deductives)

# Indice : creer les agents avec ChatCompletionAgent(kernel=..., name=..., instructions=...)
temoin = None    # TODO etudiant : creer l'agent Temoin
detective = None  # TODO etudiant : creer l'agent Detective

print("Exercice a completer : personnages Detective et Temoin")
Exercice a completer : personnages Detective et Temoin

Création des agents avec stratégies personnalisées

Un ChatCompletionAgent concrétise l’association entre un Kernel (le moteur), un name (son identité dans le dialogue) et des instructions (son prompt système). Une fois instanciés, les deux agents peuvent produire des messages, mais ils ne savent pas encore quand parler ni quand s’arrêter — c’est le groupe de discussion qui le décidera (voir plus bas).

Notez que chaque agent reçoit son propre Kernel via create_kernel() : ils partagent le même service OpenAI mais restent des entités indépendantes, ce qui laisse la porte ouverte à les brancher plus tard sur des modèles ou des plugins différents.

# Bloc 4 - Définition des agents
pere_fouras = ChatCompletionAgent(
    kernel=create_kernel(),
    name="Pere_Fouras",
    instructions=PERE_FOURAS_PROMPT,
)

laurent_jalabert = ChatCompletionAgent(
    kernel=create_kernel(),
    name="Laurent_Jalabert",
    instructions=LAURENT_JALABERT_PROMPT,
)

Lecture du résultat : agents conversationnels créés

La cellule 10 instancie pere_fouras et jalabert via ChatCompletionAgent(kernel=..., name=..., instructions=..., description=...). Le paramètre description est souvent sous-exploité : il sert au selection_strategy pour choisir l’agent actif quand le groupe de chat doit décider qui parle. Sans description explicite, l’orchestrateur risquerait d’alterner arbitrairement. La cellule 12 (après agents) importe TerminationStrategy — base class pour les stratégies de fin custom. L’étudiant doit observer la différence entre agents et kernel : les agents utilisent le kernel mais ne le possèdent pas, et chaque agent peut avoir un prompt différent. C’est un paramètre.

Stratégie de terminaison personnalisée

Dans un dialogue multi-agent, il faut décider quand s’arrêter. Semantic Kernel délègue cette décision à une TerminationStrategy : après chaque tour, la méthode asynchrone should_agent_terminate est invoquée et renvoie un booléen qui détermine si la boucle se clôt.

Notre stratégie métier est simple : la partie se termine dès que le mot secret apparaît dans le dernier message — c’est-à-dire quand Laurent Jalabert a deviné correctement. On ne teste la terminaison que sur l’agent concerné (le devineur), pas sur le Père Fouras qui n’a pas vocation à révéler la solution.

from semantic_kernel.agents.strategies.termination.termination_strategy import TerminationStrategy
from semantic_kernel.contents.chat_message_content import ChatMessageContent

# Bloc 5 - Logique de terminaison
class FortBoyardTerminationStrategy(TerminationStrategy):
    """Arrête la partie si le mot est deviné"""
    
    async def should_agent_terminate(
        self, 
        agent: ChatCompletionAgent, 
        history: list[ChatMessageContent], 
        cancellation_token = None
    ) -> bool:
        if not history:
            return False
        
        last_message = str(history[-1].content).lower()
        return MOT_A_DEVINER in last_message

Exercice 2 : Terminaison avec detection de renoncement

La stratégie actuelle arrete la partie uniquement si le mot est devine. Mais Laurent Jalabert pourrait aussi abandonner. L’objectif est de créer une stratégie hybride qui arrete la partie si le mot est devine ou si Laurent exprime un renoncement (“je donne ma langue au chat”, “j’abandonne”).

Objectif : implementer RenoncementTerminationStrategy qui combine la detection du mot secret avec la detection de phrases d’abandon.

Indices : - # Étape 1 : Définir une liste de phrases d’abandon typiques - # Étape 2 : Verifier si le dernier message contient une de ces phrases (en minuscules) - # Indice : combiner les deux conditions avec or dans should_agent_terminate

from semantic_kernel.agents.strategies.termination.termination_strategy import TerminationStrategy
from semantic_kernel.contents.chat_message_content import ChatMessageContent

class RenoncementTerminationStrategy(TerminationStrategy):
    # TODO etudiant : implementer la strategie de terminaison hybride
    PHRASES_ABANDON: list[str] = []  # Etape 1 : phrases de renoncement
    
    async def should_agent_terminate(self, agent, history: list[ChatMessageContent], cancellation_token=None) -> bool:
        # Etape 2 : verifier mot devine OU abandon detecte
        result = False  # TODO etudiant : remplacer par la logique
        return result

print("Exercice a completer : RenoncementTerminationStrategy")
Exercice a completer : RenoncementTerminationStrategy

Configuration du groupe de discussion

AgentGroupChat est l’orchestrateur multi-agent : il fait tourner les agents à tour de rôle selon une stratégie de sélection (quel agent parle ensuite) et s’arrête selon une stratégie de terminaison (quand arrêter). Par défaut, la sélection alterne simplement les agents dans l’ordre de la liste.

On lui passe ici les deux agents et notre FortBoyardTerminationStrategy, bornée à maximum_iterations=20 : c’est le filet de sécurité qui empêche une partie de boucler indéfiniment si le mot n’est jamais trouvé. La stratégie de terminaison ne s’applique qu’à laurent_jalabert — c’est lui le devineur, c’est donc son tour qui peut clore la partie.

# Bloc 6 - Configuration corrigée
chat = AgentGroupChat(
    agents=[pere_fouras, laurent_jalabert],
    termination_strategy=FortBoyardTerminationStrategy(
        agents=[laurent_jalabert],  # Définit explicitement les agents
        maximum_iterations=20       # Définit le nombre max d'itérations
    )
)

Lecture du résultat : groupe de chat configuré

La cellule 16 construit AgentGroupChat(agents=[pere_fouras, jalabert], termination_strategy=..., selection_strategy=...). C’est l’objet central qui orchestre le duel : à chaque tour, le selection_strategy choisit qui parle (par défaut round-robin), le termination_strategy détecte la fin (renoncement ou score). Sans ce wrapper, les deux agents parleraient en parallèle. La spécificité Fort Boyard est que la stratégie de renoncement (cellule 14, exercice) doit laisser l’agent abandonner après N tentatives infructueuses — émulation du stress télévisé.

Lancement de la partie !

Le groupe est configuré, on peut lancer la partie. On sème le dialogue avec un message utilisateur initial (add_chat_message) — sinon les agents n’ont rien à quoi réagir — puis on itère asynchronement sur chat.invoke(), qui produit chaque message tour à tour.

L’itération s’arrêtera automatiquement soit quand Laurent aura trouvé le mot (terminaison métier), soit au bout de 20 itérations (garde-fou). await est obligatoire car les appels au LLM sont asynchrones : chaque tour effectue une véritable requête HTTP vers l’API OpenAI, dont on voit la latence dans les logs (plusieurs secondes par réponse).

from semantic_kernel.contents import AuthorRole, ChatMessageContent

# Bloc 7 - Exécution du jeu
async def jouer_partie():
    logger.info("Depart du duel Pere Fouras vs Laurent Jalabert !")
    logger.info(f"Mot a deviner : {MOT_A_DEVINER.upper()}")
    
    # Message initial pour lancer la conversation
    await chat.add_chat_message(
        ChatMessageContent(role=AuthorRole.USER, content="Pere Fouras, donne-moi un indice !")
    )
    
    async for message in chat.invoke():
        role = message.role
        logger.info(f"[{role}] : {message.content}")
    
    logger.info("Partie terminee !")

await jouer_partie()

Lecture du résultat : début du duel Père Fouras vs Jalabert

Le log "Depart du duel Pere Fouras vs Laurent Jalabert" marque l’entrée dans la boucle asynchrone. À partir de cette cellule, l’étudiant observe : (1) l’alternance Père Fouras (énigme) ↔︎ Jalabert (réponse), (2) la convergence vers un mot deviné ou un renoncement, (3) l’évaluation finale par evaluer_partie(). Le logging structuré (timestamp + niveau INFO + préfixe événement) est essentiel dans un chat asynchrone multi-agent : sans lui, l’étudiant ne peut pas distinguer qui parle dans la sortie, ni quand une étape se termine. C’est ce logging qui rend le notebook pédagogique — rejouable.

Lecture du résultat : sélection d’agent par description

Quand le selection_strategy du AgentGroupChat doit choisir le prochain locuteur, il lit la description de chaque agent (paramètre description="Le Père Fouras pose des énigmes..." cellule 10) et compare au contexte du dernier message. C’est ce mécanisme qui décide si Jalabert pose une question (turn cycliste) ou si Père Fouras valide (turn maître du jeu). Sans description explicite, l’orchestrateur risque de tomber dans un pattern round-robin rigide qui ne s’adapte pas au flux conversationnel. Le pattern role-based selection est un raffinement important pour des simulations multi-agent crédibles.

Exercice 3 : Système de score pour la partie

Actuellement, la partie s’arrete simplement quand le mot est devine ou après 20 itérations. L’objectif est d’ajouter un système de score qui compte le nombre de questions posees par Laurent Jalabert et affiche un score final.

Objectif : ecrire une fonction evaluer_partie qui prend l’historique des messages et retourne un dictionnaire contenant le nombre de questions posees, si le mot a ete trouve, et un score (moins de questions = meilleur score).

Indices : - # Étape 1 : Compter les messages ou le rôle est celui de Laurent Jalabert - # Étape 2 : Verifier si le mot secret apparait dans le dernier message - # Indice : le score peut etre max(0, 100 - nb_questions * 10)

def evaluer_partie(history_messages: list) -> dict:
    # TODO etudiant : implementer le systeme de score
    nb_questions = 0     # Etape 1 : compter les questions de Laurent
    mot_trouve = False   # Etape 2 : verifier la presence du mot
    
    # Indice : parcourir history_messages et compter les messages de Laurent
    score = None  # TODO etudiant : calculer le score
    return {"nb_questions": nb_questions, "mot_trouve": mot_trouve, "score": score}

print("Exercice a completer : systeme de score")
Exercice a completer : systeme de score

Lecture du résultat : système de score en fin de partie

La cellule 20 expose evaluer_partie(history_messages: list) -> dict — l’étudiant doit implémenter cette fonction (exercice 4). Le contrat typique : compter les tentatives, attribuer un score basé sur la longueur du mot deviné vs nombre d’essais, et retourner un dict sérialisable. Ce scoring est ce qui transforme le duel en jeu avec issue mesurable — sans score, le notebook serait une simple conversation sans fin. C’est aussi ce qui ouvre la porte à des expériences reproductibles : l’étudiant peut comparer deux configurations de prompts en mesurant le score moyen sur 100 parties.

Retour au sommet