Discourse AI : les appels d'outils parallèles sont silencieusement ignorés avec Mistral (streaming)

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 AiApiAuditLog de 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.rb n’est pas impliqué : il exécute chaque ToolCall qu’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 fournisseur mistral n’expose pas disable_streaming, et Discourse AI n’envoie jamais parallel_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 par index (en repli sur id) au lieu du seul @tool, et laisser process_streamed_message renvoyer plusieurs objets (decode_chunk aplatit déjà).
  • Faire en sorte que finish ferme 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.

3 « J'aime »

si tu en as envie, n’hésite pas à ouvrir une PR :+1: je la relirai

Ça a l’air bien, je vais ouvrir la PR. J’attends ton retour.

non, pas de souci, c’est presque nettoyé.

Notez que ce n’est pas une forme standard ; vllm, par exemple, n’est pas sujet au problème auquel Open AI est confronté ces jours-ci en matière de réponses, de toute façon.

Bref… la PR devrait être disponible bientôt, merci de l’avoir signalé.

Sera corrigi selon :

1 « J'aime »

Merci Sam, c’était rapide ! Je vais mettre à niveau et vérifier avec Mistral.

1 « J'aime »

Merci beaucoup @sam pour la correction rapide, et @zogstrip pour la réponse prompte

Tout fonctionne comme prévu :+1: et le résultat est excellent : maintenant que l’agent peut effectuer plusieurs lectures en une seule réponse, mon scénario de test RAG utilise 43 % d’appels LLM en moins, a passé 40 % de temps en moins à chercher et lire, et a coûté 33 % moins cher.

Je l’ai vérifié sur 2026.10.0-latest (f18a1985b, incluant #44270) avec Mistral mistral-large-2512 (après avoir retiré mon contournement parallel_tool_calls: false) :

  • La reproduction sans réseau de mon premier message renvoie désormais tous les appels : 2 sur 2 et 3 sur 3 dans un seul bloc, même avec partial_tool_calls et via Endpoints::Mistral#decode_chunk. Un appel par bloc fonctionne toujours. (Le script a désormais besoin d’un .flatten, car process_streamed_message peut retourner un tableau.)
  • Avec un agent RAG réel et sans contournement, Mistral a envoyé 4 lectures parallèles dans un seul bloc, et les 4 ont été exécutées. Sur 14 exécutions de l’agent, chaque appel d’outil demandé a été exécuté.

Merci encore !

1 « J'aime »