Bot Slack : une session par thread

Un bot Slack conserve une session par thread et peut redémarrer à tout moment. Il stocke trois valeurs par thread : l'identifiant de la session, le curseur indiquant jusqu'où il a lu les événements et l'identifiant du dernier tour. Chaque gestionnaire se rattache à la session à partir des données stockées, pour que tout processus appelé par Slack puisse répondre.

Cette page montre comment : se rattacher à une session par son identifiant, suivre ses événements à partir d'un curseur enregistré, répondre à une demande d'approbation par son identifiant, et envoyer un message qui démarre un tour ou réoriente le tour en cours.

Exécution : regroupez les blocs de code intitulés slack_bot.py dans un seul fichier, dans l'ordre. Une fois le SDK installé et MISTRAL_API_KEY défini, exécutez python slack_bot.py.

Pour une application Slack complète, avec une connexion réelle à Slack et des tickets créés dans Linear, consultez le bot Slack de tri des retours. Cette page se limite à la gestion des sessions, avec Slack simulé dans le Terminal.

Pour les agents déployés via Studio (Auto-hébergés ou gérés par Mistral), tout processus peut se rattacher à la session. Dans votre processus, seule la boucle d'événements qui a démarré la session peut le faire. La démonstration s'exécute donc dans un seul programme.

La démonstration conserve les données dans un dictionnaire. Pour un bot qui redémarre, stockez-les dans une base de données : au démarrage, relisez ces données et démarrez un follower par thread enregistré.

L'agent

L'agent

L'agent demande une approbation avant chaque appel d'outil :

import asyncio

from mistralai import agents

@agents.tool
def list_branches() -> list[str]:
    """List the repository's stale branches."""
    return ["fix/typo", "exp/old-cache"]

@agents.tool
def delete_branch(name: str) -> str:
    """Delete a branch."""
    return f"deleted {name}"

agent = agents.Agent(
    model="mistral-large-4",
    instructions="Clean up stale branches with your tools, one call at a time. Answer in one short sentence.",
    tools=[list_branches, delete_branch],
    permission_mode="ask",  # every tool call asks for approval
)
Le Code du bot

Le Code du bot

Un objet factice FakeSlack simule Slack. Il clique sur Autoriser dès que les boutons s'affichent, puis clique à nouveau sur le même bouton :

class FakeSlack:
    """Slack, played in the terminal: replace it with your Slack client's calls."""

    async def post(self, thread: str, text: str) -> None:
        print(f"[{thread}] {text}")

    async def show_buttons(self, thread: str, title: str | None, value: str) -> None:
        print(f"[{thread}] buttons: {title}")
        # In this demo, a person clicks Allow at once, then clicks the same button again.
        await bot.on_click(thread, value, allow=True)
        await bot.on_click(thread, value, allow=True)

    async def close_buttons(self, callback_id: str) -> None:
        print("buttons closed")
class SlackBot:
    """One session per Slack thread. The store maps a thread to [session id, cursor, last turn id]."""

    def __init__(self, agent: agents.Agent, slack: FakeSlack) -> None:
        self.agent = agent
        self.slack = slack
        self.store: dict[str, list[str | None]] = {}
        self.followers: list[asyncio.Task[None]] = []

    async def on_message(self, thread: str, timestamp: str, text: str) -> None:
        if thread not in self.store:
            session = await self.agent.start_session()  # the conversation outlives the bot
            self.store[thread] = [session.id, None, None]
            self.followers.append(asyncio.create_task(self.follower(thread)))
        session = self.agent.session(self.store[thread][0])
        turn = await session.send_message(text, idempotency_key=timestamp)  # starts or steers
        self.store[thread][2] = turn.id  # the turn a stop button stops

    async def on_click(self, thread: str, value: str, allow: bool) -> None:
        turn_id, callback_id = value.split("/")
        answer = agents.AllowApproval() if allow else agents.DenyApproval()
        match await self.agent.session(self.store[thread][0]).turn(turn_id).answer(callback_id, answer):
            case agents.Answered(answer=applied) if applied != answer:
                await self.slack.post(thread, "someone answered first")
            case agents.AlreadySettled():
                await self.slack.post(thread, "someone already answered that")

    async def on_stop(self, thread: str) -> None:
        session_id, _, turn_id = self.store[thread]
        if turn_id is not None:
            await self.agent.session(session_id).turn(turn_id).interrupt()  # TurnConflict: it had ended

    async def follower(self, thread: str) -> None:
        """Resumes from the stored cursor after a restart."""
        session_id, cursor, _ = self.store[thread]
        async for event in self.agent.session(session_id).iter_events(after=cursor):
            match event:
                case agents.TurnEnded(state=agents.Completed(text=text)):
                    await self.slack.post(thread, text)
                case agents.TurnEnded(state=agents.Interrupted()):
                    await self.slack.post(thread, "(stopped)")
                case agents.TurnEnded(state=agents.Failed()):
                    await self.slack.post(thread, "(that failed; ask again)")
                case agents.CallbackOpened(callback=callback):
                    await self.slack.show_buttons(thread, callback.title, value=f"{callback.turn_id}/{callback.id}")
                case agents.CallbackResolved(callback_id=callback_id):
                    await self.slack.close_buttons(callback_id)
            self.store[thread][1] = event.id
        del self.store[thread]  # the stream ends only with the session

bot = SlackBot(agent, FakeSlack())
  • on_message envoie les messages avec send_message() : un message envoyé pendant un tour est ajouté à ce tour. L'horodatage Slack sert de clé d'idempotence : un message transmis deux fois par Slack ne parvient donc qu'une seule fois à la session.
  • follower se contente de suivre les événements. Il publie le texte de chaque tour et affiche des boutons pour chaque demande d'approbation, avec les identifiants du tour et de la demande comme valeur du bouton. Il stocke l'id de chaque événement pour reprendre, après un redémarrage, à partir du dernier événement traité.
  • on_click répond par identifiant, quel que soit le processus appelé par Slack. Lorsque deux personnes cliquent en même temps, toutes deux reçoivent Answered, avec la réponse qui a été retenue.
  • on_stop interrompt le dernier tour.

Aucune référence du bot ne possède de gestionnaire on_approval= : chaque demande d'approbation attend donc un clic.

La démonstration envoie un message et attend la fin du tour. turn.result() renvoie Blocked tant qu'une demande d'approbation est en attente. main attend donc à nouveau au-delà de cet état avec after=state :

async def main() -> None:
    thread = "C042/1730290000.1"
    await bot.on_message(thread, "1730290000.1", "Delete the branch fix/typo.")
    session_id, _, turn_id = bot.store[thread]
    # Wait for the turn's end: each approval returns Blocked, until FakeSlack's clicks answer it.
    turn = agent.session(session_id).turn(turn_id)
    state = await turn.result()
    while isinstance(state, agents.Blocked):
        state = await turn.result(after=state)
    await agent.session(session_id).cancel()
    await asyncio.gather(*bot.followers)

if __name__ == "__main__":
    asyncio.run(main())
Output
[C042/1730290000.1] buttons: Allow tools.client.delete_branch?
[C042/1730290000.1] someone already answered that
buttons closed
[C042/1730290000.1] Branch fix/typo has been deleted.

Le premier clic a autorisé la suppression. Le second clic est arrivé après la réponse et a donc reçu AlreadySettled. Le follower a ensuite lu la réponse et fermé les boutons.

Problèmes courants

Problèmes courants

  • SessionNotFoundError pour une session démarrée par votre processus. Le rattachement a été effectué dans une autre boucle d'événements ou un autre processus. Rendez l'agent accessible via une API ou déployez-le pour pouvoir vous rattacher à la session depuis n'importe quel processus.
  • Le follower publie deux fois le texte d'un tour après un redémarrage. Il a repris à partir d'un curseur plus ancien. Stockez l'id de chaque événement après l'avoir traité.
Aller plus loin

Aller plus loin

  • Sessions : rattachez-vous à une session par son identifiant.
  • Approbations : répondez à une demande d'approbation par son identifiant depuis n'importe quel processus.
  • Exposer via une API : exécutez l'agent dans un environnement où chaque processus peut se rattacher à ses sessions.