Résumé : lorsqu’un modèle Mistral demande plusieurs outils simultanément (par exemple, trois recherches), Discourse AI n’exécute que le premier. L’agent répond ensuite à partir de résultats partiels, sans avertir l’administrateur ni l’utilisateur.
Lorsqu’un LLM renvoie plusieurs appels d’outils dans un seul chunk en streaming, Discourse AI conserve le premier et supprime silencieusement les autres. Mistral fait exactement cela lorsqu’il effectue des appels d’outils parallèles, si bien qu’avec un LLM Mistral configuré, un agent demande plusieurs outils, mais seul l’un d’entre eux est réellement exécuté. Aucune erreur n’est levée ni journalisée, et la réponse finale semble complète.
Testé sur 2026.10.0-latest (67bc74d0d, tête actuelle de main, latest et tests-passed au 4 octobre 2026), fournisseur mistral, modèle mistral-large-2512, outils natifs, streaming (par défaut). Je l’ai d’abord observé avec mistral-medium-2508.
Reproduction minimale (sans réseau, sans clé API)
# frozen_string_literal: true
# Network-free repro. Run with: bin/rails runner repro.rb
Processor = DiscourseAi::Completions::OpenAiMessageProcessor
calls = [
{ index: 0, id: "call_1", type: "function", function: { name: "echo", arguments: '{"text":"one"}' } },
{ index: 1, id: "call_2", type: "function", function: { name: "echo", arguments: '{"text":"two"}' } },
]
# Streaming: both tool calls in ONE chunk, as Mistral sends them
processor = Processor.new
chunk = { choices: [{ index: 0, delta: { tool_calls: calls }, finish_reason: "tool_calls" }] }
streamed = [processor.process_streamed_message(chunk), *processor.finish].compact
puts "streamed: #{streamed.size} of #{calls.size} -> #{streamed.map(&:id).inspect}"
# Control: the same tool calls as a non-streamed response
message = { choices: [{ index: 0, message: { role: "assistant", tool_calls: calls }, finish_reason: "tool_calls" }] }
non_streamed = Processor.new.process_message(message)
puts "non-streamed: #{non_streamed.size} of #{calls.size} -> #{non_streamed.map(&:id).inspect}"
Sortie :
streamed: 1 of 2 -> ["call_1"]
non-streamed: 2 of 2 -> ["call_1", "call_2"]
Même résultat avec 3 appels (1 sur 3), avec partial_tool_calls: true, et lorsque le SSE brut passe par Endpoints::Mistral#decode_chunk.
Ce que Mistral stream réellement (chunk brut)
Appel de streaming direct à l’API Mistral, deux outils factices, invite “What is the current weather in Paris and the local time in Tokyo?”. Chaque appel est complet, tous dans un seul chunk, avec finish_reason dans ce même chunk :
data: {"id":"5a2166a42dd14374a54944726c759667","object":"chat.completion.chunk","created":1791036485,"model":"mistral-large-2512","choices":[{"index":0,"delta":{"tool_calls":[{"id":"bM0Xl0KSh","type":"function","function":{"name":"get_weather","arguments":"{\"city\": \"Paris\"}"},"index":0},{"id":"kgOYVL46d","type":"function","function":{"name":"get_local_time","arguments":"{\"city\": \"Tokyo\"}"},"index":1}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":172,"total_tokens":195,"completion_tokens":23,"prompt_tokens_details":{"cached_tokens":0},"service_tier":"standard"},"p":"abcdefghijklmnopqrstuvwxyz"}
Mistral a fait cela dans 6 cas sur 6 (2 et 3 appels). Avec parallel_tool_calls: false dans la requête, il renvoie un appel par réponse (5 sur 5).
Cause
process_streamed_message ne lit que l’élément 0 du tableau tool_calls, et le processeur suit un seul @tool :
Plusieurs appels ne sont gérés que s’ils arrivent un par chunk : un nouvel id dans un chunk ultérieur ferme l’appel en cours. C’est ainsi qu’OpenAI les stream, et c’est ce que couvre la spécification “properly handles multiple tool calls”. Lorsque plusieurs appels partagent un seul chunk, les éléments 1+ ne sont jamais lus, et finish ferme l’unique appel dont il a connaissance.
Le chemin non streamé (process_message) itère sur tout le tableau, c’est pourquoi le contrôle dans la reproduction obtient tout.
Impact
- Avec un agent réel (outils de recherche et de lecture, Mistral, streaming), sur une exécution de 6 réponses LLM : la première réponse contenait 3 recherches dans un seul chunk, et seule 1 a été exécutée. Les 4 réponses suivantes contenaient chacune un appel, et tous ont été exécutés. La dernière était la réponse finale. Au total, 7 appels d’outils ont été demandés et 5 ont été exécutés. Deux recherches n’ont jamais été exécutées, et l’agent a répondu comme s’il avait tous les résultats.
- Les appels supprimés sont enregistrés dans le
AiApiAuditLogde Discourse AI lui-même (raw_response_payload), ils sont donc faciles à vérifier sur tout site affecté. Rien n’atteint les journaux, Logster ou l’utilisateur. bot.rbn’est pas impliqué : il exécute chaqueToolCallqu’il reçoit. Les appels supprimés ne l’atteignent jamais.- La seule option dans l’interface est
disable_native_tools, qui bascule vers les outils XML et abandonne l’appel d’outils natif (non testé ici). Le fournisseurmistraln’expose pasdisable_streaming, et Discourse AI n’envoie jamaisparallel_tool_calls.
Portée : le processeur est partagé par tous les points de terminaison compatibles OpenAI (OpenAI, Azure, Groq, Mistral, OpenRouter, vLLM). J’ai uniquement confirmé le problème avec Mistral. D’autres seraient affectés s’ils groupent plusieurs appels dans un seul chunk, ce que je n’ai pas testé.
Contournement
J’utilise un petit correctif de plugin qui demande à Mistral un seul appel d’outil par réponse. Cela fonctionne depuis début septembre. Cela repose toutefois sur une méthode privée, et ne corrige pas le décodeur.
Correctif de contournement
# Workaround: OpenAiMessageProcessor#process_streamed_message only reads
# tool_calls[0] of each streamed chunk. Mistral may return several tool calls
# in the same chunk (parallel_tool_calls defaults to true), so all but the
# first are silently dropped. Ask Mistral for one tool call per response.
module MistralSingleToolCallPerResponse
private
def prepare_payload(prompt, model_params, dialect)
payload = super
payload[:parallel_tool_calls] = false if payload[:tools].present?
payload
end
end
# plugin.rb, inside after_initialize
reloadable_patch do
if defined?(::DiscourseAi::Completions::Endpoints::Mistral)
::DiscourseAi::Completions::Endpoints::Mistral.prepend(MistralSingleToolCallPerResponse)
end
end
Correction possible
- Lire chaque élément de
tool_calls, en conservant un état parindex(en repli surid) au lieu du seul@tool, et laisserprocess_streamed_messagerenvoyer plusieurs objets (decode_chunkaplatit déjà). - Faire en sorte que
finishferme tous les appels ouverts. - Avec
partial_tool_calls, utiliser un analyseur de streaming par appel. - Ajouter une spécification pour plusieurs appels d’outils dans un seul chunk.
Une mitigation rapide pourrait être d’envoyer parallel_tool_calls: false pour Mistral, ou d’exposer disable_streaming pour ce fournisseur.
Je suis heureux d’aider à tester une correction, ou d’ouvrir une PR si cela est utile.