Discourse AI: le chiamate parallelhe agli strumenti vengono ignorate con Mistral (streaming)

Riepilogo: quando un modello Mistral richiede più strumenti contemporaneamente (ad esempio tre ricerche), Discourse AI esegue solo il primo. L’agente risponde quindi in base a risultati parziali e nessun avviso viene mostrato all’amministratore o all’utente.

Quando un LLM restituisce più chiamate di strumento in un singolo chunk in streaming, Discourse AI mantiene la prima e scarta silenziosamente le altre. Mistral fa esattamente questo quando esegue chiamate di strumento parallele, quindi, con un LLM Mistral configurato, un agente richiede più strumenti ma ne viene effettivamente eseguito solo uno. Nessun errore viene sollevato o registrato e la risposta finale sembra completa.

Testato su 2026.10.0-latest (67bc74d0d, testa corrente di main, latest e tests-passed al 2026-10-04), provider mistral, modello mistral-large-2512, strumenti nativi, streaming (impostazione predefinita). L’ho visto per la prima volta con mistral-medium-2508.

Riproduzione minima (senza rete, senza chiave API)

# frozen_string_literal: true
# Riproduzione senza rete. Esegui con: 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: entrambe le chiamate di strumento in UN SOLO chunk, come le invia Mistral
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}"

# Controllo: le stesse chiamate di strumento come risposta non in streaming
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}"

Output:

streamed:     1 of 2 -> ["call_1"]
non-streamed: 2 of 2 -> ["call_1", "call_2"]

Risultato identico con 3 chiamate (1 su 3), con partial_tool_calls: true e quando le SSE grezze passano attraverso Endpoints::Mistral#decode_chunk.

Cosa trasmette effettivamente Mistral (chunk grezzo)

Chiamata di streaming diretta all’API Mistral, due strumenti fittizi, prompt “What is the current weather in Paris and the local time in Tokyo?”. Ogni chiamata è completa, tutte in un unico chunk, con finish_reason nello stesso 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 ha fatto questo in 6 tentativi su 6 (con 2 e 3 chiamate). Con parallel_tool_calls: false nella richiesta, restituisce una chiamata per risposta (5 su 5).

Causa

process_streamed_message legge solo l’elemento 0 dell’array tool_calls e il processore traccia un singolo @tool:

Le chiamate multiple vengono gestite solo quando arrivano una per chunk: un nuovo id in un chunk successivo chiude la chiamata corrente. È così che OpenAI le trasmette in streaming e ciò che la specifica “properly handles multiple tool calls” copre. Quando più chiamate condividono un unico chunk, gli elementi 1+ non vengono mai letti e finish chiude l’unica chiamata di cui è a conoscenza.

Il percorso non in streaming (process_message) itera sull’intero array, motivo per cui il controllo nella riproduzione ottiene tutto.

Impatto

  • Con un agente reale (strumenti di ricerca e lettura, Mistral, streaming), su un’esecuzione di 6 risposte LLM: la prima risposta conteneva 3 ricerche in un unico chunk e ne è stata eseguita solo 1. Le successive 4 risposte contenevano una chiamata ciascuna e tutte sono state eseguite. L’ultima era la risposta finale. In totale, sono state richieste 7 chiamate di strumento e ne sono state eseguite 5. Due ricerche non sono mai state eseguite e l’agente ha risposto come se avesse tutti i risultati.
  • Le chiamate scartate sono registrate nel AiApiAuditLog di Discourse AI stesso (raw_response_payload), quindi è facile verificarle su qualsiasi sito interessato. Nulla raggiunge i log, Logster o l’utente.
  • bot.rb non è coinvolto: esegue ogni ToolCall che riceve. Quelle scartate non lo raggiungono mai.
  • L’unica leva nell’interfaccia utente è disable_native_tools, che passa agli strumenti XML e rinuncia alle chiamate di strumento native (non testato qui). Il provider mistral non espone disable_streaming e Discourse AI non invia mai parallel_tool_calls.

Ambito: il processore è condiviso da ogni endpoint compatibile con OpenAI (OpenAI, Azure, Groq, Mistral, OpenRouter, vLLM). Ho confermato il problema solo con Mistral. Altri sarebbero interessati ogni volta che raggruppano più chiamate in un unico chunk, cosa che non ho testato.

Soluzione temporanea

Utilizzo un piccolo patch di plugin che chiede a Mistral una chiamata di strumento per risposta. Funziona da inizio settembre. Si basa però su un metodo privato e non corregge il decoder.

Patch di soluzione temporanea
# Soluzione temporanea: OpenAiMessageProcessor#process_streamed_message legge solo
# tool_calls[0] di ogni chunk in streaming. Mistral può restituire più chiamate di strumento
# nello stesso chunk (parallel_tool_calls è true di default), quindi tutte tranne la
# prima vengono scartate silenziosamente. Chiedi a Mistral una chiamata di strumento per risposta.
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, dentro after_initialize
reloadable_patch do
  if defined?(::DiscourseAi::Completions::Endpoints::Mistral)
    ::DiscourseAi::Completions::Endpoints::Mistral.prepend(MistralSingleToolCallPerResponse)
  end
end

Possibile correzione

  • Leggere ogni elemento di tool_calls, mantenendo uno stato per index (con fallback su id) invece del singolo @tool, e far sì che process_streamed_message restituisca più oggetti (decode_chunk appiattisce già).
  • Far sì che finish chiuda ogni chiamata aperta.
  • Con partial_tool_calls, utilizzare un parser di streaming per ogni chiamata.
  • Aggiungere una specifica per più chiamate di strumento in un unico chunk.

Una mitigazione rapida potrebbe essere inviare parallel_tool_calls: false per Mistral, oppure esporre disable_streaming per quel provider.

Sono felice di aiutare a testare una correzione o di aprire una PR se fosse utile.

2 Mi Piace