Interrompre un agent qui se répète

Certains agents restent bloqués à appeler sans cesse le même outil. Demandez à un agent de suivi de colis de « continuer à vérifier jusqu'à la livraison du colis », et il interroge le transporteur en boucle, alors que la réponse ne changera pas avant qu'il ne vous réponde.

Ce guide pratique ajoute un hook qui compte le nombre d'appels à chaque outil au cours d'un tour :

  • Les deux premiers appels à un outil s'exécutent normalement.
  • À partir du troisième appel, le hook bloque l'appel et indique au modèle de s'arrêter et de répondre.
  • Si le modèle ignore trois de ces messages, le hook met fin au tour.

Le paramètre max_iterations d'un agent limite aussi la durée d'un tour, mais il compte toutes les étapes et termine le tour avec stop_reason="iteration_limit". Ce hook limite les appels à chaque outil séparément et explique pourquoi au modèle, ce qui permet au tour de se terminer par une réponse.

Le hook conserve ses compteurs dans des champs. Le SDK enregistre les champs entre les appels, les compteurs sont donc conservés.

Fonctionnalités présentées : hooks avec état, pre_tool_call et agents.skip, et interruption d'un tour depuis pre_llm_call.

Exécution : placez les blocs de code des étapes 1 à 4 dans un même fichier, loop_breaker.py. Une fois le SDK installé et MISTRAL_API_KEY définie, exécutez python loop_breaker.py.

Fonctionnement

Fonctionnement

turn 1: track_parcel → runs                      calls = 1
        track_parcel → runs                      calls = 2
        track_parcel → blocked, with a message   refusals = 1
        ... two more blocked calls ...           refusals = 3
        next model call → blocked: the turn ends with the hook's message
turn 2: track_parcel → runs: each turn starts a new count
1. Simuler le transporteur

1. Simuler le transporteur

import asyncio

from pydantic import Field

from mistralai import agents

# --- The carrier, faked -------------------------------------------------------------------------
# In production, track_parcel calls the carrier's API. Here the parcel stays in transit, and
# TRACKED records every call that reached the carrier.

TRACKED: list[str] = []

@agents.tool
def track_parcel(tracking_number: str) -> dict[str, str]:
    """The carrier's latest scan of a parcel."""
    print(f"carrier: tracked {tracking_number}")
    TRACKED.append(tracking_number)
    return {"tracking_number": tracking_number, "status": "in transit", "last_scan": "Lyon hub, 06:12"}
2. Écrire le hook d'interruption des boucles

2. Écrire le hook d'interruption des boucles

class StopRepeatedCalls(agents.Hook):
    """Refuse a tool once the turn has called it too often; end the turn if the model keeps trying."""

    max_calls: int = 2  # calls allowed to each tool in one turn
    max_refusals: int = 3  # refusals in one turn before the hook ends it
    calls: dict[str, int] = Field(default_factory=dict)  # this turn: tool id -> times called
    refusals: int = 0  # this turn: calls refused

    async def pre_agent_turn(self, ctx, request):
        self.calls = {}
        self.refusals = 0

    async def pre_tool_call(self, ctx, call):
        self.calls[call.tool_id] = self.calls.get(call.tool_id, 0) + 1
        if self.calls[call.tool_id] <= self.max_calls:
            return None
        self.refusals += 1
        print(f"loop breaker: refused {call.tool_id}, call {self.calls[call.tool_id]} of this turn")
        return agents.skip(
            f"You already called {call.tool_name} {self.max_calls} times in this turn, and its result will not "
            "change before the turn ends. Do not call it again: answer with what you know."
        )

    async def pre_llm_call(self, ctx, invocation):
        if self.refusals < self.max_refusals:
            return None
        print("loop breaker: ended the turn")
        return agents.skip("I stopped checking: the status did not change. Ask me again later.")
  • pre_agent_turn s'exécute au début d'un tour. Il remet les compteurs à zéro, car une nouvelle question peut recevoir une nouvelle réponse.
  • pre_tool_call s'exécute avant chaque appel d'outil. Il compte l'appel et le bloque dès que l'outil a été appelé max_calls fois. Le modèle reçoit le message à la place d'un résultat.
  • pre_llm_call s'exécute avant chaque appel au modèle. Dès que le modèle a ignoré max_refusals messages, il met fin au tour. L'exécution renvoie Completed avec stop_reason="skipped".

Les compteurs sont des champs, et non de simples attributs. Quand l'agent est déployé, chaque appel du hook peut s'exécuter dans un processus différent, et le SDK ne transmet que les champs d'un appel à l'autre.

3. Définir l'agent

3. Définir l'agent

tracker = agents.Agent(
    model="mistral-large-4",
    name="parcel-tracker",
    instructions=(
        "You track parcels with track_parcel. Answer in at most two sentences. You cannot check again later: say so."
    ),
    tools=[track_parcel],
    hooks=[StopRepeatedCalls()],
)
4. Exécuter deux tours

4. Exécuter deux tours

def show(state: agents.TurnState[object]) -> None:
    if isinstance(state, agents.Completed):
        print(f"agent ({state.stop_reason or 'answered'}): {state.text}")
    else:
        print(f"agent: {type(state).__name__}")

async def main() -> None:
    async with agents.local_runtime():  # at exit: every local session of the event loop stops
        session = await tracker.start_session()
        print("--- turn 1")
        show(await session.run("Parcel FR-7731 is due today. Keep checking until it is delivered, then tell me."))
        print("--- turn 2")
        show(await session.run("Check FR-7731 once more."))
        await session.cancel()

    print(f"calls that reached the carrier: {len(TRACKED)}")

if __name__ == "__main__":
    asyncio.run(main())

Une exécution réelle a affiché ceci :

--- turn 1
carrier: tracked FR-7731
carrier: tracked FR-7731
loop breaker: refused client.track_parcel, call 3 of this turn
agent (answered): I can't keep checking — the tracking result for this turn won't change and I have no way to check again later. As of now, parcel FR-7731 is **in transit**, with its last scan at the Lyon hub at 06:12; it has not yet been delivered.
--- turn 2
carrier: tracked FR-7731
agent (answered): Parcel FR-7731 is still **in transit** — last scan at the Lyon hub at 06:12, not yet delivered.
calls that reached the carrier: 3

Le hook a bloqué le troisième appel, et le modèle a répondu avec les informations disponibles. Le deuxième tour a commencé avec un compteur remis à zéro, son appel a donc pu s'exécuter.

Et après

Et après

  • agents.Hook dans la Référence API : champs, attributs privés et limites des valeurs enregistrées.
  • Progression en direct : un hook qui transmet chaque étape à votre application.