From 6bf68f9393b2844b5993b961e2ef27124bc2d953 Mon Sep 17 00:00:00 2001 From: Erica Stith Date: Tue, 18 Aug 2026 10:09:52 -0700 Subject: [PATCH 1/7] fix(providers): echo reasoning content back for thinking-mode models DeepSeek (and other thinking-mode OpenAI-compatible providers) reject a follow-up request whose conversation includes an assistant message that previously carried reasoning_content but is sent back without it: "The reasoning_content in the thinking mode must be passed back to the API." RustyClaw captured the reasoning only for the live thinking display and dropped it from the turn, so tool-loop rounds and history replay both failed with a 400 on the next request. - ModelResponse gains a reasoning field; the streaming path accumulates ReasoningChunk content and the batch path captures ContentPart::ReasoningContent. - The canonical assistant_tools envelope carries the reasoning, and decode_assistant emits a genai ReasoningContent part, which the genai OpenAI adapter serializes back as the sibling reasoning_content field (the Anthropic adapter echoes it as a thinking block, which its multi-turn API also requires). - assistant_content() picks plain text vs the envelope so bare text turns are unchanged while turns with reasoning/tool calls are echoed. - ThreadMessage persists the reasoning (serde-defaulted, skipped when absent), the gateway stores it on final assistant turns (dispatch, cron) and thread_history_to_chat_messages replays it on the next request, so follow-up turns and new sessions keep working. --- .../src/gateway/protocol/types.rs | 5 ++ .../src/providers/genai_backend.rs | 80 ++++++++++++++++++- crates/rustyclaw-core/src/providers/mod.rs | 2 +- crates/rustyclaw-core/src/threads/manager.rs | 22 +++++ crates/rustyclaw-core/src/threads/model.rs | 34 ++++++++ crates/rustyclaw-gateway/src/cron_runtime.rs | 24 +++++- crates/rustyclaw-gateway/src/dispatch.rs | 25 ++++-- crates/rustyclaw-gateway/src/providers/mod.rs | 17 +++- .../rustyclaw-gateway/src/thread_handler.rs | 2 + 9 files changed, 194 insertions(+), 17 deletions(-) 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..bfad4afd 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(); @@ -294,6 +318,14 @@ impl From for ModelResponse { result.text.push_str(&t); } ContentPart::ToolCall(tc) => result.tool_calls.push(tc.into()), + // Thinking-mode providers require the reasoning content to + // be echoed back on later assistant messages. + ContentPart::ReasoningContent(r) => { + if !result.reasoning.is_empty() { + result.reasoning.push('\n'); + } + result.reasoning.push_str(&r); + } _ => {} } } @@ -483,6 +515,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 +873,39 @@ 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 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..482d721d 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, }); } @@ -702,6 +734,7 @@ impl AgentThread { tool_calls: Some(tool_calls), tool_call_id: None, media: None, + reasoning: None, }); } @@ -715,6 +748,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..14624b11 100644 --- a/crates/rustyclaw-gateway/src/cron_runtime.rs +++ b/crates/rustyclaw-gateway/src/cron_runtime.rs @@ -473,6 +473,9 @@ 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). + let mut final_reasoning = String::new(); for _round in 0..MAX_TOOL_ROUNDS { let model_resp = tokio::time::timeout( @@ -490,6 +493,9 @@ async fn run_agent_turn( if !model_resp.text.is_empty() { final_response.push_str(&model_resp.text); } + if !model_resp.reasoning.is_empty() { + final_reasoning.push_str(&model_resp.reasoning); + } if model_resp.tool_calls.is_empty() { break; } @@ -615,16 +621,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 +656,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..9b8984d8 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 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/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() }; From af5d4841da5140dc6ee124ace440efe66b6d35a6 Mon Sep 17 00:00:00 2001 From: Erica Stith Date: Tue, 18 Aug 2026 10:51:01 -0700 Subject: [PATCH 2/7] ci: retrigger after a hung runner step From 6019b540defda2821d0d6f8ed37029748e2baeab Mon Sep 17 00:00:00 2001 From: Erica Stith Date: Tue, 18 Aug 2026 11:41:46 -0700 Subject: [PATCH 3/7] fix(threads): persist reasoning on tool-call turns too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add_assistant_with_tool_calls hardcoded reasoning: None, so a tool-call turn persisted to history lost its thinking text and the next user message (rebuilt from history) sent it back reasoning-less — the same 400 thinking-mode providers reject. The method now takes the reasoning and dispatch passes model_resp.reasoning alongside the tool calls. --- crates/rustyclaw-core/src/threads/model.rs | 7 +++++-- crates/rustyclaw-gateway/src/dispatch.rs | 5 +++++ crates/rustyclaw-gateway/src/server.rs | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/rustyclaw-core/src/threads/model.rs b/crates/rustyclaw-core/src/threads/model.rs index 482d721d..092ac886 100644 --- a/crates/rustyclaw-core/src/threads/model.rs +++ b/crates/rustyclaw-core/src/threads/model.rs @@ -720,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, @@ -734,7 +737,7 @@ impl AgentThread { tool_calls: Some(tool_calls), tool_call_id: None, media: None, - reasoning: None, + reasoning, }); } diff --git a/crates/rustyclaw-gateway/src/dispatch.rs b/crates/rustyclaw-gateway/src/dispatch.rs index 9b8984d8..812ed04c 100644 --- a/crates/rustyclaw-gateway/src/dispatch.rs +++ b/crates/rustyclaw-gateway/src/dispatch.rs @@ -1557,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/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"); } From 8f1b7ac3a6fdc909e4c0c2080d7a01ad97e794c9 Mon Sep 17 00:00:00 2001 From: Erica Stith Date: Tue, 18 Aug 2026 12:09:26 -0700 Subject: [PATCH 4/7] ci: retrigger after another hung runner step From 883251c62ce668d23ce79a1f63f5aed2a514a96c Mon Sep 17 00:00:00 2001 From: Erica Stith Date: Tue, 18 Aug 2026 12:56:19 -0700 Subject: [PATCH 5/7] fix(providers): capture reasoning on the non-streaming path too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAI adapter reports reasoning in ChatResponse::reasoning_content (a dedicated field), not as a content part, so the batch conversion never saw it — scheduled turns, Google calls and compaction/summary calls all take that path. Read reasoning_content into ModelResponse (keeping the part-based arm as a fallback) and cover it with a test. --- .../src/providers/genai_backend.rs | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/rustyclaw-core/src/providers/genai_backend.rs b/crates/rustyclaw-core/src/providers/genai_backend.rs index bfad4afd..4f89aa21 100644 --- a/crates/rustyclaw-core/src/providers/genai_backend.rs +++ b/crates/rustyclaw-core/src/providers/genai_backend.rs @@ -306,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() }; @@ -318,8 +321,9 @@ impl From for ModelResponse { result.text.push_str(&t); } ContentPart::ToolCall(tc) => result.tool_calls.push(tc.into()), - // Thinking-mode providers require the reasoning content to - // be echoed back on later assistant messages. + // 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'); @@ -906,6 +910,29 @@ mod tests { 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 { From 210ba258ed8980fc86bc394b400e859544a6f7f3 Mon Sep 17 00:00:00 2001 From: Erica Stith Date: Tue, 18 Aug 2026 13:30:16 -0700 Subject: [PATCH 6/7] fix(cron): persist only the last round's reasoning for a scheduled turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool loop's per-round reasoning was concatenated (R1R2R3…) and attached to the single persisted assistant message, which is not what the API returned for any one turn; assign the last round's reasoning instead. --- crates/rustyclaw-gateway/src/cron_runtime.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/rustyclaw-gateway/src/cron_runtime.rs b/crates/rustyclaw-gateway/src/cron_runtime.rs index 14624b11..a28f9774 100644 --- a/crates/rustyclaw-gateway/src/cron_runtime.rs +++ b/crates/rustyclaw-gateway/src/cron_runtime.rs @@ -475,6 +475,10 @@ async fn run_agent_turn( 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 { @@ -494,7 +498,9 @@ async fn run_agent_turn( final_response.push_str(&model_resp.text); } if !model_resp.reasoning.is_empty() { - final_reasoning.push_str(&model_resp.reasoning); + // 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; From 50be31915045c01fda667a4485c20cf3c35428ba Mon Sep 17 00:00:00 2001 From: Erica Stith Date: Tue, 18 Aug 2026 14:14:00 -0700 Subject: [PATCH 7/7] ci: retrigger after repeated runner dependency-step hang