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 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
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_messageenvoie les messages avecsend_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.followerse 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'idde chaque événement pour reprendre, après un redémarrage, à partir du dernier événement traité.on_clickrépond par identifiant, quel que soit le processus appelé par Slack. Lorsque deux personnes cliquent en même temps, toutes deux reçoiventAnswered, avec la réponse qui a été retenue.on_stopinterrompt 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())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
SessionNotFoundErrorpour 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'
idde chaque événement après l'avoir traité.
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.