Skip to content

bug(deepseek): reasoning_content dropped from assistant messages with tool_calls, breaking multi-turn tool loops #1434

@kawayiYokami

Description

@kawayiYokami

Bug Description

In providers/deepseek.rs, the TryFrom<message::Message> for Vec<Message> implementation splits a single rig Message::Assistant (containing Reasoning + ToolCall content) into two separate DeepSeek Message::Assistant entries:

  1. First: reasoning_content = Some(...), tool_calls = vec![]
  2. Second: tool_calls = [...], reasoning_content = None

This causes DeepSeek API to return 400 Bad Request:

Missing reasoning_content field in the assistant message at message index ...

PR #1333 added a workaround that merges the last reasoning into the last assistant message during request construction. But this only fixes the last assistant message — in multi-turn tool loops, intermediate messages still lack reasoning_content.

Root Cause

The core issue is that the conversion layer discards the reasoning_content from the message that carries tool_calls:

// line 339-345: tool_calls message always gets reasoning_content: None
if !tool_calls.is_empty() {
    messages.push(Message::Assistant {
        content: "".to_string(),
        tool_calls,
        reasoning_content: None,  // ← always None, reasoning is lost
    });
}

Suggested Fix

The SDK should not try to cache or manage reasoning content itself. The caller (application code managing the tool loop) is responsible for passing AssistantContent::Reasoning back in chat_history. The SDK just needs to faithfully serialize it.

The fix is straightforward — put reasoning_content on the message that carries tool_calls, since that's what DeepSeek expects:

message::Message::Assistant { content, .. } => {
    let mut text_content = String::new();
    let mut reasoning_content = String::new();
    let mut tool_calls = Vec::new();

    for item in content.iter() {
        match item {
            message::AssistantContent::Text(text) => {
                text_content.push_str(text.text());
            }
            message::AssistantContent::Reasoning(reasoning) => {
                reasoning_content.push_str(&reasoning.display_text());
            }
            message::AssistantContent::ToolCall(tool_call) => {
                tool_calls.push(ToolCall::from(tool_call.clone()));
            }
            _ => {}
        }
    }

    let reasoning = if reasoning_content.is_empty() {
        None
    } else {
        Some(reasoning_content)
    };

    // Single message — reasoning_content stays with tool_calls
    Ok(vec![Message::Assistant {
        content: text_content,
        name: None,
        tool_calls,
        reasoning_content: reasoning,
    }])
}

This also makes the merge workaround in DeepseekCompletionRequest::try_from (lines 506-534) unnecessary — it can be reverted to a simple full_history.extend(chat_history).

Reproduction

  1. Use deepseek-reasoner with tools enabled
  2. Trigger a prompt that causes 2+ rounds of tool calls
  3. Round 2+ fails with 400: Missing reasoning_content field

Environment

  • rig-core: 0.31.0
  • Model: deepseek-reasoner (thinking mode + tool use)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions