diff --git a/crates/rustyclaw-core/src/gateway/protocol/types.rs b/crates/rustyclaw-core/src/gateway/protocol/types.rs index 9c6a0947..3643ed4d 100644 --- a/crates/rustyclaw-core/src/gateway/protocol/types.rs +++ b/crates/rustyclaw-core/src/gateway/protocol/types.rs @@ -253,4 +253,9 @@ pub struct ModelResponse { /// Token counts reported by the provider (when available). pub prompt_tokens: Option, pub completion_tokens: Option, + /// The model's reasoning/thinking text, when the provider emits it. + /// Providers in "thinking mode" (DeepSeek, Kimi, …) require it to be + /// passed back on later assistant messages in the same conversation. + #[serde(default)] + pub reasoning: String, } diff --git a/crates/rustyclaw-core/src/providers/genai_backend.rs b/crates/rustyclaw-core/src/providers/genai_backend.rs index d836905c..4f89aa21 100644 --- a/crates/rustyclaw-core/src/providers/genai_backend.rs +++ b/crates/rustyclaw-core/src/providers/genai_backend.rs @@ -96,12 +96,33 @@ pub fn encode_assistant_message(model_resp: &ModelResponse) -> String { }) .collect(); - json!({ + let mut envelope = json!({ "__rustyclaw_kind": "assistant_tools", "text": model_resp.text, "tool_calls": tool_calls, - }) - .to_string() + }); + // Thinking-mode providers (DeepSeek, Kimi, …) require the reasoning + // content of earlier assistant turns to be passed back verbatim; keep + // it in the canonical envelope so it round-trips through the history. + if !model_resp.reasoning.is_empty() { + envelope["reasoning"] = json!(model_resp.reasoning); + } + envelope.to_string() +} + +/// Content for an assistant message in the *request* stream: the canonical +/// envelope when the turn carries tool calls or reasoning (so the API gets +/// both back on the next round), plain text otherwise. +/// +/// Unlike [`encode_assistant_message`], this never wraps a bare text turn — +/// the plain-text shape is what request builders and history reconstruction +/// expect for ordinary assistant replies. +pub fn assistant_content(model_resp: &ModelResponse) -> String { + if model_resp.tool_calls.is_empty() && model_resp.reasoning.is_empty() { + model_resp.text.clone() + } else { + encode_assistant_message(model_resp) + } } /// Encode a single tool result into the canonical `tool_result` envelope, @@ -232,6 +253,9 @@ async fn consume_stream( server::send_thinking_start(writer).await.ignore(); thinking_started = true; } + // Keep the reasoning for the turn: thinking-mode providers + // require it to be echoed back on later assistant messages. + result.reasoning.push_str(&chunk.content); server::send_thinking_delta(writer, &chunk.content) .await .ignore(); @@ -282,6 +306,9 @@ impl From for ModelResponse { let mut result = ModelResponse { prompt_tokens: resp.usage.prompt_tokens.map(|t| t.max(0) as u64), completion_tokens: resp.usage.completion_tokens.map(|t| t.max(0) as u64), + // The OpenAI adapter reports reasoning in a dedicated field, not + // as a content part — read it before consuming the content. + reasoning: resp.reasoning_content.clone().unwrap_or_default(), ..Default::default() }; @@ -294,6 +321,15 @@ impl From for ModelResponse { result.text.push_str(&t); } ContentPart::ToolCall(tc) => result.tool_calls.push(tc.into()), + // Fallback for adapters that surface reasoning as a part + // (the OpenAI adapter already provided it via the + // `reasoning_content` field above). + ContentPart::ReasoningContent(r) => { + if !result.reasoning.is_empty() { + result.reasoning.push('\n'); + } + result.reasoning.push_str(&r); + } _ => {} } } @@ -483,6 +519,15 @@ fn deduplicate_tool_ids( fn decode_assistant(content: &str) -> GenChatMessage { if let Some(env) = parse_canonical(content, "assistant_tools") { let mut parts: Vec = Vec::new(); + // Reasoning first: the genai adapters hoist ReasoningContent parts + // into the sibling `reasoning_content` / `thinking` field, so + // thinking-mode providers receive it back (they reject the request + // otherwise). + if let Some(reasoning) = env.get("reasoning").and_then(|v| v.as_str()) { + if !reasoning.trim().is_empty() { + parts.push(ContentPart::ReasoningContent(reasoning.to_string())); + } + } if let Some(text) = env.get("text").and_then(|v| v.as_str()) { if !text.trim().is_empty() { parts.push(ContentPart::from_text(text.to_string())); @@ -832,6 +877,62 @@ mod tests { assert_eq!(calls[0].fn_arguments["path"], "a.rs"); } + #[test] + fn reasoning_round_trips_through_the_envelope() { + // Thinking-mode providers require the reasoning content of earlier + // assistant turns to be passed back; the canonical envelope must + // carry it and decode back into a genai ReasoningContent part. + let model_resp = ModelResponse { + text: "answer".to_string(), + reasoning: "I should look up the weather first.".to_string(), + ..Default::default() + }; + let encoded = encode_assistant_message(&model_resp); + let msg = decode_assistant(&encoded); + assert_eq!(msg.role, ChatRole::Assistant); + assert_eq!(msg.content.first_text(), Some("answer")); + let reasoning = msg.content.reasoning_contents(); + assert_eq!( + reasoning, + vec!["I should look up the weather first."], + "reasoning must decode back into a ReasoningContent part" + ); + // A turn without reasoning stays reasoning-free. + let plain = ModelResponse { + text: "hi".to_string(), + ..Default::default() + }; + let msg = decode_assistant(&assistant_content(&plain)); + assert_eq!(msg.content.reasoning_contents().len(), 0); + // assistant_content keeps bare text turns as plain text. + assert_eq!(assistant_content(&plain), "hi"); + // ... but wraps a turn that carries reasoning so the API gets it back. + assert!(assistant_content(&model_resp).contains("reasoning")); + } + + #[test] + fn batch_response_captures_reasoning_from_the_dedicated_field() { + // The OpenAI adapter reports reasoning in ChatResponse::reasoning_content + // (not as a content part), and scheduled/internal turns take the + // non-streaming path — the reasoning must still reach ModelResponse. + let resp = genai::chat::ChatResponse { + content: genai::chat::MessageContent::from_text("answer"), + reasoning_content: Some("the reasoning".to_string()), + model_iden: genai::ModelIden::new(genai::adapter::AdapterKind::OpenAI, "m"), + provider_model_iden: genai::ModelIden::new(genai::adapter::AdapterKind::OpenAI, "m"), + stop_reason: None, + usage: genai::chat::Usage::default(), + captured_raw_body: None, + response_id: None, + }; + let converted: ModelResponse = resp.into(); + assert_eq!(converted.text, "answer"); + assert_eq!( + converted.reasoning, "the reasoning", + "batch reasoning must come from ChatResponse::reasoning_content" + ); + } + #[test] fn tool_result_round_trip() { let result = ToolCallResult { diff --git a/crates/rustyclaw-core/src/providers/mod.rs b/crates/rustyclaw-core/src/providers/mod.rs index fea79f1b..f897dbe8 100644 --- a/crates/rustyclaw-core/src/providers/mod.rs +++ b/crates/rustyclaw-core/src/providers/mod.rs @@ -482,7 +482,7 @@ mod models; pub use custom::*; pub use device_flow::*; pub use genai_backend::{ - call_anthropic_with_tools, call_google_with_tools, call_openai_with_tools, + assistant_content, call_anthropic_with_tools, call_google_with_tools, call_openai_with_tools, encode_assistant_message, encode_tool_result, }; pub use models::*; diff --git a/crates/rustyclaw-core/src/threads/manager.rs b/crates/rustyclaw-core/src/threads/manager.rs index 46b4e50a..801a6ed6 100644 --- a/crates/rustyclaw-core/src/threads/manager.rs +++ b/crates/rustyclaw-core/src/threads/manager.rs @@ -556,6 +556,28 @@ impl ThreadManager { }); } + /// Add an assistant message whose response carried reasoning/thinking + /// text (thinking-mode providers require it back on later turns). + pub fn add_message_with_reasoning( + &mut self, + id: ThreadId, + role: MessageRole, + content: impl Into, + reasoning: Option, + ) { + let message_count = if let Some(thread) = self.threads.get_mut(&id) { + thread.add_message_with_reasoning(role, content, reasoning); + thread.messages.len() + } else { + return; + }; + + self.emit(ThreadEvent::MessageAdded { + thread_id: id, + message_count, + }); + } + /// Add a message to the foreground thread. pub fn add_foreground_message(&mut self, role: MessageRole, content: impl Into) { if let Some(id) = self.foreground_id { diff --git a/crates/rustyclaw-core/src/threads/model.rs b/crates/rustyclaw-core/src/threads/model.rs index e7dcf506..092ac886 100644 --- a/crates/rustyclaw-core/src/threads/model.rs +++ b/crates/rustyclaw-core/src/threads/model.rs @@ -215,6 +215,13 @@ pub struct ThreadMessage { /// `threads.json` files. #[serde(default, skip_serializing_if = "Option::is_none")] pub media: Option>, + /// The model's reasoning/thinking text for an assistant turn, when the + /// provider emitted one. Thinking-mode providers (DeepSeek, Kimi, …) + /// require it to be passed back verbatim on later assistant messages, + /// so it is persisted alongside the turn. Optional and skipped when + /// absent for backward compatibility with existing `threads.json` files. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, } /// Message role. @@ -572,6 +579,29 @@ impl AgentThread { tool_calls: None, tool_call_id: None, media: None, + reasoning: None, + }); + } + + /// Add an assistant message whose response carried reasoning/thinking + /// text. Thinking-mode providers (DeepSeek, Kimi, …) require the + /// reasoning content to be passed back on later turns, so it is stored + /// with the message for history reconstruction. + pub fn add_message_with_reasoning( + &mut self, + role: MessageRole, + content: impl Into, + reasoning: Option, + ) { + self.push_message(ThreadMessage { + id: None, + role, + content: content.into(), + timestamp: SystemTime::now(), + tool_calls: None, + tool_call_id: None, + media: None, + reasoning, }); } @@ -591,6 +621,7 @@ impl AgentThread { tool_calls: None, tool_call_id: None, media: None, + reasoning: None, }); } @@ -613,6 +644,7 @@ impl AgentThread { tool_calls: None, tool_call_id: None, media: Some(media), + reasoning: None, }); } @@ -688,11 +720,14 @@ impl AgentThread { /// Add an assistant turn that issued tool calls. `text` may be empty /// when the model produced only tool calls. `tool_calls` is the - /// normalized JSON form (`Vec<{id, name, arguments}>`). + /// normalized JSON form (`Vec<{id, name, arguments}>`). `reasoning` + /// carries the turn's thinking text (thinking-mode providers require + /// it back on later turns). pub fn add_assistant_with_tool_calls( &mut self, text: impl Into, tool_calls: serde_json::Value, + reasoning: Option, ) { self.push_message(ThreadMessage { id: None, @@ -702,6 +737,7 @@ impl AgentThread { tool_calls: Some(tool_calls), tool_call_id: None, media: None, + reasoning, }); } @@ -715,6 +751,7 @@ impl AgentThread { tool_calls: None, tool_call_id: Some(tool_call_id.into()), media: None, + reasoning: None, }); } diff --git a/crates/rustyclaw-gateway/src/cron_runtime.rs b/crates/rustyclaw-gateway/src/cron_runtime.rs index f46f48fd..a28f9774 100644 --- a/crates/rustyclaw-gateway/src/cron_runtime.rs +++ b/crates/rustyclaw-gateway/src/cron_runtime.rs @@ -473,6 +473,13 @@ async fn run_agent_turn( let http = rustyclaw_core::providers::http_client(); let session_key = format!("cron:{}", job.job_id); let mut final_response = String::new(); + // The final turn's reasoning (thinking-mode providers require it to + // be echoed back if this thread is ever continued with a chat turn). + // The persisted assistant message is one merged turn, so only the + // last round's reasoning is attached — concatenating every round + // would attribute R1R2R3… to a single message, which is not what + // the API returned for any one turn. + let mut final_reasoning = String::new(); for _round in 0..MAX_TOOL_ROUNDS { let model_resp = tokio::time::timeout( @@ -490,6 +497,11 @@ async fn run_agent_turn( if !model_resp.text.is_empty() { final_response.push_str(&model_resp.text); } + if !model_resp.reasoning.is_empty() { + // Assign, not append: only the last round's reasoning + // matches the single persisted assistant message. + final_reasoning = model_resp.reasoning.clone(); + } if model_resp.tool_calls.is_empty() { break; } @@ -615,16 +627,26 @@ async fn run_agent_turn( ); } - anyhow::Ok(final_response) + anyhow::Ok((final_response, final_reasoning)) } .await; // Close the turn on every path; only success lands a response message. let mut tm = thread_mgr.lock().await; match &result { - Ok(response) => { + Ok((response, reasoning_text)) => { if !response.is_empty() { - tm.add_message(thread, MessageRole::Assistant, response.clone()); + let reasoning = if reasoning_text.is_empty() { + None + } else { + Some(reasoning_text.clone()) + }; + tm.add_message_with_reasoning( + thread, + MessageRole::Assistant, + response.clone(), + reasoning, + ); } tm.end_turn(thread, true); } @@ -640,7 +662,7 @@ async fn run_agent_turn( crate::helpers::persist_threads(&mut tm, threads_path); drop(tm); - result.map(|response| { + result.map(|(response, _)| { warn_if_silent(&response, job_label); }) } diff --git a/crates/rustyclaw-gateway/src/dispatch.rs b/crates/rustyclaw-gateway/src/dispatch.rs index e676b941..812ed04c 100644 --- a/crates/rustyclaw-gateway/src/dispatch.rs +++ b/crates/rustyclaw-gateway/src/dispatch.rs @@ -988,9 +988,10 @@ pub(crate) async fn dispatch_text_message( if flush_pending_resume && tool_executor::is_flush_acknowledgement(&model_resp.text) { flush_pending_resume = false; - resolved - .messages - .push(ChatMessage::text("assistant", &model_resp.text)); + resolved.messages.push(ChatMessage::text( + "assistant", + &providers::assistant_content(&model_resp), + )); resolved.messages.push(ChatMessage::text( "user", "Memory flush noted. Now continue with my original request above.", @@ -1024,9 +1025,10 @@ pub(crate) async fn dispatch_text_message( // cause the TUI to show the same text twice. // Append assistant message and continuation prompt - resolved - .messages - .push(ChatMessage::text("assistant", &model_resp.text)); + resolved.messages.push(ChatMessage::text( + "assistant", + &providers::assistant_content(&model_resp), + )); resolved.messages.push(ChatMessage::text( "user", "Continue. Execute the action you described.", @@ -1044,9 +1046,18 @@ pub(crate) async fn dispatch_text_message( let mut tm = thread_mgr.lock().await; if let Some(thread) = turn_thread.and_then(|id| tm.get_mut(id)) { updated_thread_id = Some(thread.id); - thread.add_message( + // Thinking-mode providers require the reasoning + // content to be passed back on later turns, so + // persist it with the turn. + let reasoning = if model_resp.reasoning.is_empty() { + None + } else { + Some(model_resp.reasoning.clone()) + }; + thread.add_message_with_reasoning( rustyclaw_core::threads::MessageRole::Assistant, &model_resp.text, + reasoning, ); } // Persist the final assistant turn so reconnecting @@ -1546,6 +1557,11 @@ pub(crate) async fn dispatch_text_message( thread.add_assistant_with_tool_calls( model_resp.text.clone(), serde_json::Value::Array(normalized), + if model_resp.reasoning.is_empty() { + None + } else { + Some(model_resp.reasoning.clone()) + }, ); for tr in &tool_results { thread.add_tool_result(tr.id.clone(), tr.output.clone()); diff --git a/crates/rustyclaw-gateway/src/providers/mod.rs b/crates/rustyclaw-gateway/src/providers/mod.rs index 9b243d23..d47b3d0f 100644 --- a/crates/rustyclaw-gateway/src/providers/mod.rs +++ b/crates/rustyclaw-gateway/src/providers/mod.rs @@ -301,8 +301,17 @@ pub fn thread_history_to_chat_messages( .unwrap_or_default(); if tool_calls.is_empty() { - // Plain assistant text turn. - out.push(ChatMessage::text("assistant", &m.content)); + // Plain assistant text turn. When the turn carried + // reasoning (thinking-mode providers require it back), + // the request-side content is the canonical envelope so + // the reasoning is echoed; otherwise plain text. + let model_resp = ModelResponse { + text: m.content.clone(), + reasoning: m.reasoning.clone().unwrap_or_default(), + ..Default::default() + }; + let content = providers::assistant_content(&model_resp); + out.push(ChatMessage::text("assistant", &content)); i += 1; continue; } @@ -379,6 +388,7 @@ pub fn thread_history_to_chat_messages( let model_resp = ModelResponse { text: m.content.clone(), tool_calls, + reasoning: m.reasoning.clone().unwrap_or_default(), ..Default::default() }; let assistant_content = format_assistant_message(provider, &model_resp); @@ -807,3 +817,6 @@ fn format_probe_error(err: &anyhow_tracing::Error) -> String { // and client crates share one genai instance. Re-export the single dispatch // entry point so call sites use `providers::call_with_tools`. pub use rustyclaw_core::providers::call_with_tools; +// Assistant-content encoding helpers (reasoning echo for thinking-mode +// providers) shared with dispatch. +pub use rustyclaw_core::providers::assistant_content; diff --git a/crates/rustyclaw-gateway/src/server.rs b/crates/rustyclaw-gateway/src/server.rs index 0a418d9d..4a43377d 100644 --- a/crates/rustyclaw-gateway/src/server.rs +++ b/crates/rustyclaw-gateway/src/server.rs @@ -4972,6 +4972,7 @@ mod tests { "name": tool, "arguments": {"path": "src/main.rs"} }]), + None, ); thread.add_tool_result("call_1", "ok"); } diff --git a/crates/rustyclaw-gateway/src/thread_handler.rs b/crates/rustyclaw-gateway/src/thread_handler.rs index c43286ec..8a7687dd 100644 --- a/crates/rustyclaw-gateway/src/thread_handler.rs +++ b/crates/rustyclaw-gateway/src/thread_handler.rs @@ -1360,6 +1360,7 @@ mod tests { tool_calls: None, tool_call_id: None, media: None, + reasoning: None, }); thread .messages @@ -1371,6 +1372,7 @@ mod tests { tool_calls: None, tool_call_id: None, media: None, + reasoning: None, }); } let mut writer = CapturingWriter { frames: Vec::new() };