Discourse AI: chamadas de ferramentas paralelas descartadas silenciosamente com Mistral (streaming)

Resumo: quando um modelo Mistral solicita várias ferramentas de uma vez (por exemplo, três buscas), o Discourse AI executa apenas a primeira. O agente então responde com base em resultados parciais, e nada alerta o administrador ou o usuário.

Quando um LLM retorna várias chamadas de ferramentas em um único chunk transmitido em streaming, o Discourse AI retém a primeira e descarta silenciosamente as demais. O Mistral faz exatamente isso quando realiza chamadas de ferramentas paralelas, portanto, com um LLM Mistral configurado, um agente solicita várias ferramentas, mas apenas uma delas é efetivamente executada. Nenhum erro é lançado ou registrado, e a resposta final parece completa.

Testado na versão 2026.10.0-latest (67bc74d0d, ponta atual de main, latest e tests-passed em 2026-10-04), provedor mistral, modelo mistral-large-2512, ferramentas nativas, streaming (o padrão). Eu o observei pela primeira vez com mistral-medium-2508.

Reprodução mínima (sem rede, sem chave de API)

# frozen_string_literal: true
# Reprodução sem rede. Execute com: 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 as chamadas de ferramentas em UM único chunk, como o Mistral as envia
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}"

# Controle: as mesmas chamadas de ferramentas como uma resposta não transmitida em 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}"

Saída:

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

O mesmo resultado com 3 chamadas (1 de 3), com partial_tool_calls: true, e quando o SSE bruto passa por Endpoints::Mistral#decode_chunk.

O que o Mistral realmente transmite em streaming (chunk bruto)

Chamada direta em streaming à API do Mistral, duas ferramentas fictícias, prompt “What is the current weather in Paris and the local time in Tokyo?”. Cada chamada está completa, todas em um único chunk, com finish_reason naquele mesmo 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"}

O Mistral fez isso em 6 de 6 tentativas (2 e 3 chamadas). Com parallel_tool_calls: false na requisição, ele retorna uma chamada por resposta (5 de 5).

Causa

process_streamed_message apenas lê o elemento 0 do array tool_calls, e o processador rastreia um único @tool:

Múltiplas chamadas são tratadas apenas quando chegam uma por chunk: um novo id em um chunk posterior fecha a chamada atual. É assim que o OpenAI as transmite em streaming, e é o que a especificação “properly handles multiple tool calls” cobre. Quando várias chamadas compartilham um único chunk, os elementos 1+ nunca são lidos, e finish fecha a única chamada que ele conhece.

O caminho não transmitido em streaming (process_message) itera pelo array inteiro, motivo pelo qual o controle na reprodução obtém tudo.

Impacto

  • Com um agente real (ferramentas de busca e leitura, Mistral, streaming), em uma execução com 6 respostas do LLM: a primeira resposta continha 3 buscas em um único chunk, e apenas 1 foi executada. As próximas 4 respostas continham uma chamada cada, e todas foram executadas. A última foi a resposta final. No total, 7 chamadas de ferramentas foram solicitadas e 5 foram executadas. Duas buscas nunca foram executadas, e o agente respondeu como se tivesse todos os resultados.
  • As chamadas descartadas são registradas no próprio AiApiAuditLog do Discourse AI (raw_response_payload), portanto, são fáceis de verificar em qualquer site afetado. Nada chega aos logs, ao Logster ou ao usuário.
  • bot.rb não está envolvido: ele executa cada ToolCall que recebe. As descartadas nunca chegam até ele.
  • O único controle na interface é disable_native_tools, que alterna para ferramentas XML e abre mão do chamamento nativo de ferramentas (não testado aqui). O provedor mistral não expõe disable_streaming, e o Discourse AI nunca envia parallel_tool_calls.

Escopo: o processador é compartilhado por todos os endpoints compatíveis com OpenAI (OpenAI, Azure, Groq, Mistral, OpenRouter, vLLM). Confirmei o problema apenas com o Mistral. Outros seriam afetados sempre que agruparem várias chamadas em um único chunk, o que não testei.

Solução temporária

Uso um pequeno patch de plugin que pede ao Mistral para retornar uma chamada de ferramenta por resposta. Funciona desde o início de setembro. No entanto, ele depende de um método privado e não corrige o decodificador.

Patch da solução temporária
# Solução temporária: OpenAiMessageProcessor#process_streamed_message apenas lê
# tool_calls[0] de cada chunk transmitido em streaming. O Mistral pode retornar várias chamadas
# de ferramentas no mesmo chunk (parallel_tool_calls padrão é true), portanto, todas as
# exceto a primeira são silenciosamente descartadas. Pede ao Mistral para retornar uma
# chamada de ferramenta por resposta.
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

Possível correção

  • Ler cada elemento de tool_calls, mantendo um estado por index (com fallback para id) em vez do único @tool, e permitir que process_streamed_message retorne vários objetos (decode_chunk já achata).
  • Fazer finish fechar todas as chamadas abertas.
  • Com partial_tool_calls, usar um analisador de streaming por chamada.
  • Adicionar uma especificação para várias chamadas de ferramentas em um único chunk.

Uma mitigação rápida poderia ser enviar parallel_tool_calls: false para o Mistral, ou expor disable_streaming para esse provedor.

Ficarei feliz em ajudar a testar uma correção, ou abrir um PR se isso for útil.

2 curtidas