Mistral AI agent circuit breaker: stopping loop failures in Mistral Large and Mixtral function-calling agents
Mistral AI’s models — Mistral Large 2, Mixtral 8x22B, and Mistral 7B — support native function calling via the /v1/chat/completions API. Building an agentic loop on top of Mistral is straightforward: define tools, call the API, execute the returned function calls, append results, repeat. What’s missing is a runtime safety layer. If a Mistral agent enters a tool-call loop — calling the same function with the same arguments on every turn because the result never advances the agent’s goal — the loop runs until your billing quota is exhausted or you manually kill the process. RunGuard adds a circuit breaker at the model-call boundary: loop detection fires on the third repetition of any pattern up to length 4, and a dollar-based budget cap fires before each call if accumulated spend exceeds your threshold.
Why Mistral agents loop: the function-call failure modes
- Ambiguous function selection. Mistral’s function-calling models select among available tools based on the conversation context. If two tools have similar descriptions —
search_webandsearch_database— and the model’s choice fails to advance the task, it may oscillate between them: callsearch_web(insufficient result), callsearch_database(insufficient result), callsearch_webagain. This period-2 oscillation pattern is the most common loop type in Mistral agents because Mistral Large 2’s function-selection behavior is particularly sensitive to tool description overlap. - Error-string tool results. A tool that returns a string like
"Error: database connection failed"instead of raising an exception signals to the model that the call “succeeded” but returned a bad result. The model’s response is to call the same tool again, hoping for a better result. This is the single most common source of runaway loops across all function-calling models, not just Mistral. The fix is to raise typed exceptions from tools; the model interprets exceptions as permanent failures that require a different approach, not transient failures to retry. - Parallel tool calls with correlated failures. Mistral Large 2 supports parallel tool calls — the model can call multiple functions in the same response. If all parallel calls fail for the same underlying reason (network partition, missing API key, rate limit), the model may call all of them again in the next response. RunGuard’s pattern detector sees parallel calls as a set and tracks which combinations repeat, so a set of three correlated parallel-call failures still trips the breaker on the third repetition.
- Mistral’s 128k context limit filling from repeated results. Mistral Large 2 has a 128k context window. An agent that appends 2k-token error responses from repeated tool calls fills this window in roughly 64 iterations. The model then throws a context-length error. By the time the context is full of error messages, the entire call history is polluted and the run cannot be salvaged. Pre-call loop detection trips at iteration 3, not iteration 64.
Adding a circuit breaker to a Mistral agent in Python
- The interception point: the API call. Mistral’s Python client (
mistralai) uses anasync_client.chat.complete_async()or synchronousclient.chat.complete()pattern. RunGuard wraps the inner async function that calls the Mistral API and tracks the tool-call signature stream and accumulated spend. - Python: full agent loop with RunGuard circuit breaker.
import asyncio from mistralai import Mistral from runguard import guard_async, LoopDetectedError, BudgetExceededError client = Mistral(api_key="YOUR_MISTRAL_API_KEY") TOOLS = [ { "type": "function", "function": { "name": "search_database", "description": "Search the product database for records matching a query.", "parameters": { "type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "summarize_results", "description": "Summarize a list of database records into a human-readable report.", "parameters": { "type": "object", "properties": {"records": {"type": "array", "items": {"type": "string"}}}, "required": ["records"], }, }, }, ] async def _call_mistral(messages: list, **kwargs) -> dict: response = await client.chat.complete_async( model="mistral-large-latest", messages=messages, tools=TOOLS, tool_choice="auto", **kwargs, ) choice = response.choices[0] usage = response.usage # Mistral Large 2 pricing: $2/$6 per 1M input/output tokens input_tokens = usage.prompt_tokens if usage else 0 output_tokens = usage.completion_tokens if usage else 0 usd = (input_tokens * 2.0 + output_tokens * 6.0) / 1_000_000 # Signature: first tool-call name, or "end_turn" tool_calls = getattr(choice.message, "tool_calls", None) or [] sig = tool_calls[0].function.name if tool_calls else "end_turn" return {"choice": choice, "usd": usd, "sig": sig} # Wrap once — reuse across iterations of the agent loop guarded_mistral = guard_async( _call_mistral, budget={"max_usd": 2.0}, loop={"repeats": 3, "max_cycle_len": 4}, ) async def run_mistral_agent(user_query: str) -> str: messages = [ {"role": "system", "content": "You are a research assistant. Use the provided tools to answer questions."}, {"role": "user", "content": user_query}, ] while True: try: result = await guarded_mistral(messages) except LoopDetectedError as e: return f"Loop detected after {len(messages)} messages. Pattern: {e.pattern!r}. Aborting." except BudgetExceededError as e: return f"Budget cap reached (${e.spent:.4f}). Aborting after {len(messages)} messages." choice = result["choice"] messages.append(choice.message) tool_calls = getattr(choice.message, "tool_calls", None) or [] if not tool_calls: # No more tool calls — return final answer return choice.message.content or "" for tc in tool_calls: fn_name = tc.function.name fn_args = tc.function.arguments # JSON string from Mistral try: tool_result = await dispatch_tool(fn_name, fn_args) except Exception as e: # Raise exceptions as tool results, never return error strings tool_result = f"__tool_error__: {type(e).__name__}: {e}" messages.append({ "role": "tool", "tool_call_id": tc.id, "content": str(tool_result), }) async def dispatch_tool(name: str, args_json: str) -> str: import json args = json.loads(args_json) if name == "search_database": return await search_database(args["query"]) elif name == "summarize_results": return await summarize_results(args["records"]) raise ValueError(f"Unknown tool: {name}") # Stub implementations async def search_database(query: str) -> str: ... async def summarize_results(records: list) -> str: ... - Signature strategy for parallel tool calls. When Mistral returns multiple tool calls in a single response, the loop detector sees the first tool call’s name as the signature by default. For more precise detection of parallel-call cycles, pass the full sorted set of tool-call names as the signature:
sig = ",".join(sorted(tc.function.name for tc in tool_calls)) if tool_calls else "end_turn". This treats a parallel call of (search_database,summarize_results) as a single compound signature, distinct from calling either tool alone.
Mistral-specific considerations for production agents
- Tool description quality is higher leverage than the circuit breaker. Mistral Large 2 is sensitive to tool description quality. Overlapping descriptions increase the probability of oscillation loops. Before adding a circuit breaker, review your tool descriptions for: (1) ambiguous scope overlap, (2) examples that could apply to multiple tools, (3) parameter descriptions that don’t constrain the valid input clearly. A well-described tool set reduces loop frequency; the circuit breaker handles the residual cases that slip through.
- Mistral’s
tool_choiceparameter. Settingtool_choice: "any"forces the model to call a tool on every turn. This is appropriate for deterministic pipelines but dangerous in open-ended agents because it removes the model’s option to answer directly when it has enough information. If your agent runs withtool_choice: "any", set a lowermax_usdbudget cap because the model will never produce a terminal non-tool response on its own — the loop will always require either the circuit breaker or an explicit termination tool call. - Mixtral 8x22B vs. Mistral Large 2 for function calling. Mixtral 8x22B is a mixture-of-experts model with stronger instruction following for structured data tasks but slightly less reliable function-call selection compared to Mistral Large 2. If your agent uses Mixtral 8x22B, set a slightly lower
max_cycle_len(2 instead of 4) because Mixtral tends to produce shorter oscillation cycles that a period-2 detector catches faster. Mistral Large 2 produces more varied patterns that benefit from the full period-4 detection window.
Mistral API defaults vs. RunGuard
| Control | Mistral API default | RunGuard |
|---|---|---|
| Tool-call loop detection | Not supported | loop: repeats=3 fires on 3rd repeat of same pattern |
| Per-run cost cap | Not supported | budget: max_usd fires before each API call |
| Parallel-call cycle detection | Not supported | Compound signature (sorted tool names) covers parallel calls |
| Context-window guard | 400 error after request sent | Pre-call ContextOverflowError before request sent |
| Max turns | Not supported (host-side only) | Implicit via loop + budget caps |
| Slack/PagerDuty alert on trip | Not supported | alerts: slack_webhook or pagerduty_key |