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:
- First:
reasoning_content = Some(...), tool_calls = vec![]
- 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
- Use
deepseek-reasoner with tools enabled
- Trigger a prompt that causes 2+ rounds of tool calls
- Round 2+ fails with 400:
Missing reasoning_content field
Environment
- rig-core: 0.31.0
- Model: deepseek-reasoner (thinking mode + tool use)
Bug Description
In
providers/deepseek.rs, theTryFrom<message::Message> for Vec<Message>implementation splits a single rigMessage::Assistant(containingReasoning+ToolCallcontent) into two separate DeepSeekMessage::Assistantentries:reasoning_content = Some(...),tool_calls = vec![]tool_calls = [...],reasoning_content = NoneThis causes DeepSeek API to return 400 Bad Request:
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_contentfrom the message that carriestool_calls: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::Reasoningback inchat_history. The SDK just needs to faithfully serialize it.The fix is straightforward — put
reasoning_contenton the message that carriestool_calls, since that's what DeepSeek expects:This also makes the merge workaround in
DeepseekCompletionRequest::try_from(lines 506-534) unnecessary — it can be reverted to a simplefull_history.extend(chat_history).Reproduction
deepseek-reasonerwith tools enabledMissing reasoning_content fieldEnvironment