Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/rustyclaw-core/src/gateway/protocol/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,4 +253,9 @@ pub struct ModelResponse {
/// Token counts reported by the provider (when available).
pub prompt_tokens: Option<u64>,
pub completion_tokens: Option<u64>,
/// 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,
}
107 changes: 104 additions & 3 deletions crates/rustyclaw-core/src/providers/genai_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -282,6 +306,9 @@ impl From<genai::chat::ChatResponse> 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()
};

Expand All @@ -294,6 +321,15 @@ impl From<genai::chat::ChatResponse> 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);
}
_ => {}
}
}
Expand Down Expand Up @@ -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<ContentPart> = 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()));
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion crates/rustyclaw-core/src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
22 changes: 22 additions & 0 deletions crates/rustyclaw-core/src/threads/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
reasoning: Option<String>,
) {
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<String>) {
if let Some(id) = self.foreground_id {
Expand Down
39 changes: 38 additions & 1 deletion crates/rustyclaw-core/src/threads/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,13 @@ pub struct ThreadMessage {
/// `threads.json` files.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub media: Option<Vec<crate::gateway::protocol::types::MediaRef>>,
/// 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<String>,
}

/// Message role.
Expand Down Expand Up @@ -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<String>,
reasoning: Option<String>,
) {
self.push_message(ThreadMessage {
id: None,
role,
content: content.into(),
timestamp: SystemTime::now(),
tool_calls: None,
tool_call_id: None,
media: None,
reasoning,
});
}

Expand All @@ -591,6 +621,7 @@ impl AgentThread {
tool_calls: None,
tool_call_id: None,
media: None,
reasoning: None,
});
}

Expand All @@ -613,6 +644,7 @@ impl AgentThread {
tool_calls: None,
tool_call_id: None,
media: Some(media),
reasoning: None,
});
}

Expand Down Expand Up @@ -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<String>,
tool_calls: serde_json::Value,
reasoning: Option<String>,
) {
self.push_message(ThreadMessage {
id: None,
Expand All @@ -702,6 +737,7 @@ impl AgentThread {
tool_calls: Some(tool_calls),
tool_call_id: None,
media: None,
reasoning,
});
}

Expand All @@ -715,6 +751,7 @@ impl AgentThread {
tool_calls: None,
tool_call_id: Some(tool_call_id.into()),
media: None,
reasoning: None,
});
}

Expand Down
30 changes: 26 additions & 4 deletions crates/rustyclaw-gateway/src/cron_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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();
}
Comment thread
rexlunae marked this conversation as resolved.
if model_resp.tool_calls.is_empty() {
break;
}
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
})
}
Expand Down
Loading
Loading