Skip to content

Commit 738f5f6

Browse files
authored
feat: reasoning_content in API for reasoning models (#6322)
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
1 parent d1aa571 commit 738f5f6

14 files changed

Lines changed: 276 additions & 35 deletions

File tree

crates/goose-server/src/openapi.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ use goose::config::declarative_providers::{
2121
};
2222
use goose::conversation::message::{
2323
ActionRequired, ActionRequiredData, FrontendToolRequest, Message, MessageContent,
24-
MessageMetadata, RedactedThinkingContent, SystemNotificationContent, SystemNotificationType,
25-
ThinkingContent, TokenState, ToolConfirmationRequest, ToolRequest, ToolResponse,
24+
MessageMetadata, ReasoningContent, RedactedThinkingContent, SystemNotificationContent,
25+
SystemNotificationType, ThinkingContent, TokenState, ToolConfirmationRequest, ToolRequest,
26+
ToolResponse,
2627
};
2728

2829
use crate::routes::recipe_utils::RecipeManifest;
@@ -480,6 +481,7 @@ derive_utoipa!(Icon as IconSchema);
480481
ActionRequiredData,
481482
ThinkingContent,
482483
RedactedThinkingContent,
484+
ReasoningContent,
483485
FrontendToolRequest,
484486
ResourceContentsSchema,
485487
SystemNotificationType,

crates/goose/src/context_mgmt/mod.rs

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -336,19 +336,19 @@ fn format_message_for_compacting(msg: &Message) -> String {
336336
let content_parts: Vec<String> = msg
337337
.content
338338
.iter()
339-
.map(|content| match content {
340-
MessageContent::Text(text) => text.text.clone(),
341-
MessageContent::Image(img) => format!("[image: {}]", img.mime_type),
339+
.filter_map(|content| match content {
340+
MessageContent::Text(text) => Some(text.text.clone()),
341+
MessageContent::Image(img) => Some(format!("[image: {}]", img.mime_type)),
342342
MessageContent::ToolRequest(req) => {
343343
if let Ok(call) = &req.tool_call {
344-
format!(
344+
Some(format!(
345345
"tool_request({}): {}",
346346
call.name,
347347
serde_json::to_string(&call.arguments)
348348
.unwrap_or_else(|_| "<<invalid json>>".to_string())
349-
)
349+
))
350350
} else {
351-
"tool_request: [error]".to_string()
351+
Some("tool_request: [error]".to_string())
352352
}
353353
}
354354
MessageContent::ToolResponse(res) => {
@@ -362,40 +362,41 @@ fn format_message_for_compacting(msg: &Message) -> String {
362362
.collect();
363363

364364
if !text_items.is_empty() {
365-
format!("tool_response: {}", text_items.join("\n"))
365+
Some(format!("tool_response: {}", text_items.join("\n")))
366366
} else {
367-
"tool_response: [non-text content]".to_string()
367+
Some("tool_response: [non-text content]".to_string())
368368
}
369369
} else {
370-
"tool_response: [error]".to_string()
370+
Some("tool_response: [error]".to_string())
371371
}
372372
}
373373
MessageContent::ToolConfirmationRequest(req) => {
374-
format!("tool_confirmation_request: {}", req.tool_name)
374+
Some(format!("tool_confirmation_request: {}", req.tool_name))
375375
}
376376
MessageContent::ActionRequired(action) => match &action.data {
377377
ActionRequiredData::ToolConfirmation { tool_name, .. } => {
378-
format!("action_required(tool_confirmation): {}", tool_name)
378+
Some(format!("action_required(tool_confirmation): {}", tool_name))
379379
}
380380
ActionRequiredData::Elicitation { message, .. } => {
381-
format!("action_required(elicitation): {}", message)
381+
Some(format!("action_required(elicitation): {}", message))
382382
}
383383
ActionRequiredData::ElicitationResponse { id, .. } => {
384-
format!("action_required(elicitation_response): {}", id)
384+
Some(format!("action_required(elicitation_response): {}", id))
385385
}
386386
},
387387
MessageContent::FrontendToolRequest(req) => {
388388
if let Ok(call) = &req.tool_call {
389-
format!("frontend_tool_request: {}", call.name)
389+
Some(format!("frontend_tool_request: {}", call.name))
390390
} else {
391-
"frontend_tool_request: [error]".to_string()
391+
Some("frontend_tool_request: [error]".to_string())
392392
}
393393
}
394-
MessageContent::Thinking(_) => "thinking".to_string(),
395-
MessageContent::RedactedThinking(_) => "redacted_thinking".to_string(),
394+
MessageContent::Thinking(_) => None,
395+
MessageContent::RedactedThinking(_) => None,
396396
MessageContent::SystemNotification(notification) => {
397-
format!("system_notification: {}", notification.msg)
397+
Some(format!("system_notification: {}", notification.msg))
398398
}
399+
MessageContent::Reasoning(_) => None,
399400
})
400401
.collect();
401402

crates/goose/src/conversation/message.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,11 @@ pub struct SystemNotificationContent {
175175
pub data: Option<serde_json::Value>,
176176
}
177177

178+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
179+
pub struct ReasoningContent {
180+
pub text: String,
181+
}
182+
178183
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
179184
/// Content passed inside a message, which can be both simple content and tool content
180185
#[serde(tag = "type", rename_all = "camelCase")]
@@ -189,6 +194,7 @@ pub enum MessageContent {
189194
Thinking(ThinkingContent),
190195
RedactedThinking(RedactedThinkingContent),
191196
SystemNotification(SystemNotificationContent),
197+
Reasoning(ReasoningContent),
192198
}
193199

194200
impl fmt::Display for MessageContent {
@@ -230,6 +236,7 @@ impl fmt::Display for MessageContent {
230236
MessageContent::SystemNotification(r) => {
231237
write!(f, "[SystemNotification: {}]", r.msg)
232238
}
239+
MessageContent::Reasoning(r) => write!(f, "[Reasoning: {}]", r.text),
233240
}
234241
}
235242
}
@@ -443,6 +450,10 @@ impl MessageContent {
443450
})
444451
}
445452

453+
pub fn reasoning<S: Into<String>>(text: S) -> Self {
454+
MessageContent::Reasoning(ReasoningContent { text: text.into() })
455+
}
456+
446457
pub fn as_system_notification(&self) -> Option<&SystemNotificationContent> {
447458
if let MessageContent::SystemNotification(ref notification) = self {
448459
Some(notification)
@@ -514,6 +525,14 @@ impl MessageContent {
514525
_ => None,
515526
}
516527
}
528+
529+
/// Get the reasoning content if this is a ReasoningContent variant
530+
pub fn as_reasoning(&self) -> Option<&ReasoningContent> {
531+
match self {
532+
MessageContent::Reasoning(reasoning) => Some(reasoning),
533+
_ => None,
534+
}
535+
}
517536
}
518537

519538
impl From<Content> for MessageContent {

crates/goose/src/providers/formats/anthropic.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
124124
}));
125125
}
126126
}
127+
MessageContent::Reasoning(_reasoning) => {
128+
// Reasoning content is for OpenAI-compatible APIs (e.g., DeepSeek)
129+
// Anthropic doesn't use this format, so skip it
130+
}
127131
}
128132
}
129133

crates/goose/src/providers/formats/bedrock.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@ pub fn to_bedrock_message_content(content: &MessageContent) -> Result<bedrock::C
113113
.build()?,
114114
)
115115
}
116+
MessageContent::Reasoning(_reasoning) => {
117+
// Reasoning content is for OpenAI-compatible APIs (e.g., DeepSeek)
118+
// Bedrock doesn't use this format, so skip
119+
bedrock::ContentBlock::Text("".to_string())
120+
}
116121
})
117122
}
118123

crates/goose/src/providers/formats/databricks.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,10 @@ fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<Data
205205
MessageContent::SystemNotification(_)
206206
| MessageContent::ToolConfirmationRequest(_)
207207
| MessageContent::ActionRequired(_) => {}
208+
MessageContent::Reasoning(_reasoning) => {
209+
// Reasoning content is for OpenAI-compatible APIs (e.g., DeepSeek)
210+
// Databricks doesn't use this format, so skip
211+
}
208212
}
209213
}
210214

crates/goose/src/providers/formats/openai.rs

Lines changed: 138 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ struct Delta {
5252
role: Option<String>,
5353
tool_calls: Option<Vec<DeltaToolCall>>,
5454
reasoning_details: Option<Vec<Value>>,
55+
reasoning_content: Option<String>,
5556
}
5657

5758
#[derive(Serialize, Deserialize, Debug)]
@@ -80,6 +81,7 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<
8081
let mut output = Vec::new();
8182
let mut content_array = Vec::new();
8283
let mut text_array = Vec::new();
84+
let mut reasoning_text: Option<String> = None;
8385

8486
for content in &message.content {
8587
match content {
@@ -112,6 +114,9 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<
112114
MessageContent::SystemNotification(_) => {
113115
continue;
114116
}
117+
MessageContent::Reasoning(r) => {
118+
reasoning_text = Some(r.text.clone());
119+
}
115120
MessageContent::ToolRequest(request) => match &request.tool_call {
116121
Ok(tool_call) => {
117122
let sanitized_name = sanitize_function_name(&tool_call.name);
@@ -277,6 +282,17 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<
277282
converted["content"] = json!(null);
278283
}
279284

285+
// DeepSeek requires reasoning_content field when tool_calls are present
286+
// Set it to the captured reasoning text, or empty string if not present
287+
if converted.get("tool_calls").is_some() {
288+
let reasoning = reasoning_text.unwrap_or_default();
289+
converted["reasoning_content"] = json!(reasoning);
290+
} else if let Some(reasoning) = reasoning_text {
291+
if !reasoning.is_empty() {
292+
converted["reasoning_content"] = json!(reasoning);
293+
}
294+
}
295+
280296
if converted.get("content").is_some() || converted.get("tool_calls").is_some() {
281297
output.insert(0, converted);
282298
}
@@ -330,6 +346,15 @@ pub fn response_to_message(response: &Value) -> anyhow::Result<Message> {
330346

331347
let mut content = Vec::new();
332348

349+
// Capture reasoning_content if present (for DeepSeek reasoning models)
350+
if let Some(reasoning_content) = original.get("reasoning_content") {
351+
if let Some(reasoning_str) = reasoning_content.as_str() {
352+
if !reasoning_str.is_empty() {
353+
content.push(MessageContent::reasoning(reasoning_str));
354+
}
355+
}
356+
}
357+
333358
if let Some(text) = original.get("content") {
334359
if let Some(text_str) = text.as_str() {
335360
content.push(MessageContent::text(text_str));
@@ -678,23 +703,44 @@ where
678703
Some(msg),
679704
usage,
680705
)
681-
} else if chunk.choices[0].delta.content.is_some() {
682-
let text = chunk.choices[0].delta.content.as_ref().unwrap();
683-
let mut msg = Message::new(
684-
Role::Assistant,
685-
chrono::Utc::now().timestamp(),
686-
vec![MessageContent::text(text)],
687-
);
706+
} else if chunk.choices[0].delta.content.is_some() || chunk.choices[0].delta.reasoning_content.is_some() {
707+
let mut content = Vec::new();
688708

689-
// Add ID if present
690-
if let Some(id) = chunk.id {
691-
msg = msg.with_id(id);
709+
if let Some(reasoning) = &chunk.choices[0].delta.reasoning_content {
710+
if !reasoning.is_empty() {
711+
content.push(MessageContent::reasoning(reasoning));
712+
}
692713
}
693714

694-
yield (
695-
Some(msg),
696-
usage,
697-
)
715+
if let Some(text) = &chunk.choices[0].delta.content {
716+
if !text.is_empty() {
717+
content.push(MessageContent::text(text));
718+
}
719+
}
720+
721+
if !content.is_empty() {
722+
let mut msg = Message::new(
723+
Role::Assistant,
724+
chrono::Utc::now().timestamp(),
725+
content,
726+
);
727+
728+
// Add ID if present
729+
if let Some(id) = chunk.id {
730+
msg = msg.with_id(id);
731+
}
732+
733+
yield (
734+
Some(msg),
735+
if chunk.choices[0].finish_reason.is_some() {
736+
usage
737+
} else {
738+
None
739+
},
740+
)
741+
} else if usage.is_some() {
742+
yield (None, usage)
743+
}
698744
} else if usage.is_some() {
699745
yield (None, usage)
700746
}
@@ -1834,4 +1880,82 @@ data: [DONE]"#;
18341880

18351881
panic!("Expected tool call message with nested extra_content metadata");
18361882
}
1883+
1884+
#[test]
1885+
fn test_response_to_message_with_reasoning_content() -> anyhow::Result<()> {
1886+
// Test capturing reasoning_content from DeepSeek reasoning models
1887+
let response = json!({
1888+
"choices": [{
1889+
"role": "assistant",
1890+
"message": {
1891+
"reasoning_content": "Let me think about this step by step...",
1892+
"content": "The answer is 9.11 is greater than 9.8"
1893+
}
1894+
}],
1895+
"usage": {
1896+
"input_tokens": 10,
1897+
"output_tokens": 25,
1898+
"total_tokens": 35
1899+
}
1900+
});
1901+
1902+
let message = response_to_message(&response)?;
1903+
assert_eq!(message.content.len(), 2);
1904+
1905+
// First should be reasoning content
1906+
if let MessageContent::Reasoning(reasoning) = &message.content[0] {
1907+
assert_eq!(reasoning.text, "Let me think about this step by step...");
1908+
} else {
1909+
panic!("Expected Reasoning content");
1910+
}
1911+
1912+
// Second should be text content
1913+
if let MessageContent::Text(text) = &message.content[1] {
1914+
assert_eq!(text.text, "The answer is 9.11 is greater than 9.8");
1915+
} else {
1916+
panic!("Expected Text content");
1917+
}
1918+
1919+
Ok(())
1920+
}
1921+
1922+
#[test]
1923+
fn test_format_messages_with_reasoning_content() -> anyhow::Result<()> {
1924+
// Test that reasoning_content is properly included in formatted messages
1925+
let mut message = Message::assistant()
1926+
.with_content(MessageContent::reasoning("Thinking through the problem..."))
1927+
.with_text("The result is 42");
1928+
1929+
// Add a tool call to test that reasoning_content works with tool calls
1930+
message = message.with_tool_request(
1931+
"tool1",
1932+
Ok(rmcp::model::CallToolRequestParams {
1933+
meta: None,
1934+
task: None,
1935+
name: "test_tool".into(),
1936+
arguments: Some(rmcp::object!({"param": "value"})),
1937+
}),
1938+
);
1939+
1940+
let spec = format_messages(&[message], &ImageFormat::OpenAi);
1941+
1942+
assert_eq!(spec.len(), 1);
1943+
assert_eq!(spec[0]["role"], "assistant");
1944+
1945+
// Should have reasoning_content field
1946+
assert!(spec[0].get("reasoning_content").is_some());
1947+
assert_eq!(
1948+
spec[0]["reasoning_content"],
1949+
"Thinking through the problem..."
1950+
);
1951+
1952+
// Should have content
1953+
assert_eq!(spec[0]["content"], "The result is 42");
1954+
1955+
// Should have tool_calls
1956+
assert!(spec[0]["tool_calls"].is_array());
1957+
assert_eq!(spec[0]["tool_calls"][0]["function"]["name"], "test_tool");
1958+
1959+
Ok(())
1960+
}
18371961
}

0 commit comments

Comments
 (0)