Discourse AI: llamadas de herramientas en paralelo se descartan silenciosamente con Mistral (streaming)

Resumen: cuando un modelo de Mistral solicita varias herramientas a la vez (por ejemplo, tres búsquedas), Discourse AI solo ejecuta la primera. El agente luego responde a partir de resultados parciales, y nada avisa al administrador ni al usuario.

Cuando un LLM devuelve varias llamadas a herramientas en un único chunk transmitido, Discourse AI conserva la primera y descarta silenciosamente las demás. Mistral hace exactamente esto cuando realiza llamadas a herramientas en paralelo, por lo que, con un LLM de Mistral configurado, un agente solicita varias herramientas, pero solo una de ellas se ejecuta realmente. No se genera ni registra ningún error, y la respuesta final parece completa.

Probado en 2026.10.0-latest (67bc74d0d, cabecera actual de main, latest y tests-passed a fecha de 2026-10-04), proveedor mistral, modelo mistral-large-2512, herramientas nativas, streaming (el valor predeterminado). Primero lo observé con mistral-medium-2508.

Reproducción mínima (sin red, sin clave API)

# frozen_string_literal: true
# Reproducción sin red. Ejecutar 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: ambas llamadas a herramientas en UN SOLO chunk, como las envía 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}"

# Control: las mismas llamadas a herramientas como una respuesta no transmitida
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}"

Salida:

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

Mismo resultado con 3 llamadas (1 de 3), con partial_tool_calls: true, y cuando el SSE crudo pasa por Endpoints::Mistral#decode_chunk.

Lo que Mistral transmite realmente (chunk crudo)

Llamada directa de streaming a la API de Mistral, dos herramientas de prueba, prompt “What is the current weather in Paris and the local time in Tokyo?”. Cada llamada es completa, todas en un solo chunk, con finish_reason en ese mismo 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 hizo esto en 6 de 6 intentos (2 y 3 llamadas). Con parallel_tool_calls: false en la solicitud, devuelve una llamada por respuesta (5 de 5).

Causa

process_streamed_message solo lee el elemento 0 del array tool_calls, y el procesador rastrea un único @tool:

Las múltiples llamadas se manejan solo cuando llegan una por chunk: un nuevo id en un chunk posterior cierra la llamada actual. Así es como OpenAI las transmite, y es lo que cubre la especificación “properly handles multiple tool calls”. Cuando varias llamadas comparten un solo chunk, los elementos 1+ nunca se leen, y finish cierra la única llamada que conoce.

La ruta no transmitida (process_message) itera sobre todo el array, por lo que el control en la reproducción obtiene todo.

Impacto

  • Con un agente real (herramientas de búsqueda y lectura, Mistral, streaming), en una ejecución de 6 respuestas de LLM: la primera respuesta contenía 3 búsquedas en un solo chunk, y solo se ejecutó 1. Las siguientes 4 respuestas contenían una llamada cada una, y todas se ejecutaron. La última fue la respuesta final. En total, se solicitaron 7 llamadas a herramientas y se ejecutaron 5. Dos búsquedas nunca se ejecutaron, y el agente respondió como si tuviera todos los resultados.
  • Las llamadas descartadas se registran en el propio AiApiAuditLog de Discourse AI (raw_response_payload), por lo que es fácil verificarlas en cualquier sitio afectado. Nada llega a los registros, Logster o al usuario.
  • bot.rb no está involucrado: ejecuta cada ToolCall que recibe. Las descartadas nunca llegan a él.
  • El único control en la interfaz de usuario es disable_native_tools, que cambia a herramientas XML y renuncia a las llamadas nativas a herramientas (no probado aquí). El proveedor mistral no expone disable_streaming, y Discourse AI nunca envía parallel_tool_calls.

Alcance: el procesador es compartido por cada punto final compatible con OpenAI (OpenAI, Azure, Groq, Mistral, OpenRouter, vLLM). Solo he confirmado el problema con Mistral. Otros se verían afectados siempre que agrupen varias llamadas en un solo chunk, lo cual no he probado.

Solución temporal

Utilizo un pequeño parche de plugin que le pide a Mistral una llamada a herramienta por respuesta. Ha funcionado desde principios de septiembre. Sin embargo, depende de un método privado, y no corrige el decodificador.

Parche de solución temporal
# Solución temporal: OpenAiMessageProcessor#process_streamed_message solo lee
# tool_calls[0] de cada chunk transmitido. Mistral puede devolver varias llamadas a herramientas
# en el mismo chunk (parallel_tool_calls predeterminado a true), por lo que todas menos la
# primera se descartan silenciosamente. Pídele a Mistral una llamada a herramienta por respuesta.
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 de after_initialize
reloadable_patch do
  if defined?(::DiscourseAi::Completions::Endpoints::Mistral)
    ::DiscourseAi::Completions::Endpoints::Mistral.prepend(MistralSingleToolCallPerResponse)
  end
end

Posible corrección

  • Leer cada elemento de tool_calls, manteniendo un estado por index (con id como respaldo) en lugar del único @tool, y permitir que process_streamed_message devuelva varios objetos (decode_chunk ya aplanan).
  • Hacer que finish cierre todas las llamadas abiertas.
  • Con partial_tool_calls, usar un analizador de streaming por llamada.
  • Añadir una especificación para varias llamadas a herramientas en un solo chunk.

Una mitigación rápida podría ser enviar parallel_tool_calls: false para Mistral, o exponer disable_streaming para ese proveedor.

Estoy dispuesto a ayudar a probar una corrección, o a abrir una PR si eso es útil.

2 Me gusta