Skip to content
Closed
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
22 changes: 19 additions & 3 deletions crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1346,8 +1346,9 @@ impl Agent {
}
}

// Preserve thinking content from the original response
// Preserve thinking/reasoning content from the original response
// Gemini (and other thinking models) require thinking to be echoed back
// Kimi/DeepSeek require reasoning_content on assistant tool call messages
let thinking_content: Vec<MessageContent> = response.content.iter()
.filter(|c| matches!(c, MessageContent::Thinking(_)))
.cloned()
Expand All @@ -1361,10 +1362,25 @@ impl Agent {
messages_to_add.push(thinking_msg);
}

// Collect reasoning content to attach to tool request messages
let reasoning_content: Vec<MessageContent> = response.content.iter()
.filter(|c| matches!(c, MessageContent::Reasoning(_)))
.cloned()
.collect();

for (idx, request) in frontend_requests.iter().chain(remaining_requests.iter()).enumerate() {
if request.tool_call.is_ok() {
let request_msg = Message::assistant()
.with_id(format!("msg_{}", Uuid::new_v4()))
let mut request_msg = Message::assistant()
.with_id(format!("msg_{}", Uuid::new_v4()));

// Attach reasoning content to EVERY split tool request message.
// Providers like Kimi require reasoning_content on all assistant
// messages with tool_calls when thinking mode is enabled.
for rc in &reasoning_content {
request_msg = request_msg.with_content(rc.clone());
}

request_msg = request_msg
.with_tool_request_with_metadata(
request.id.clone(),
request.tool_call.clone(),
Expand Down
22 changes: 20 additions & 2 deletions crates/goose/src/agents/platform_extensions/apps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,17 @@ impl AppsManagerClient {
let messages = vec![Message::user().with_text(&user_prompt)];
let tools = vec![Self::create_app_content_tool()];

let mut model_config = provider.get_model_config();
model_config.max_tokens = Some(16384);

let (response, _usage) = provider
.complete(session_id, &system_prompt, &messages, &tools)
.complete_with_model(
Some(session_id),
&model_config,
&system_prompt,
&messages,
&tools,
)
.await
.map_err(|e| format!("LLM call failed: {}", e))?;

Expand Down Expand Up @@ -321,8 +330,17 @@ impl AppsManagerClient {
let messages = vec![Message::user().with_text(&user_prompt)];
let tools = vec![Self::update_app_content_tool()];

let mut model_config = provider.get_model_config();
model_config.max_tokens = Some(16384);

let (response, _usage) = provider
.complete(session_id, &system_prompt, &messages, &tools)
.complete_with_model(
Some(session_id),
&model_config,
&system_prompt,
&messages,
&tools,
)
.await
.map_err(|e| format!("LLM call failed: {}", e))?;

Expand Down
29 changes: 18 additions & 11 deletions crates/goose/src/providers/formats/openai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<
let mut output = Vec::new();
let mut content_array = Vec::new();
let mut text_array = Vec::new();
let mut reasoning_text: Option<String> = None;
let mut reasoning_text = String::new();

for content in &message.content {
match content {
Expand Down Expand Up @@ -116,7 +116,7 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<
continue;
}
MessageContent::Reasoning(r) => {
reasoning_text = Some(r.text.clone());
reasoning_text.push_str(&r.text);
}
MessageContent::ToolRequest(request) => match &request.tool_call {
Ok(tool_call) => {
Expand Down Expand Up @@ -278,15 +278,11 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<
converted["content"] = json!(null);
}

// DeepSeek requires reasoning_content field when tool_calls are present
// Set it to the captured reasoning text, or empty string if not present
if converted.get("tool_calls").is_some() {
let reasoning = reasoning_text.unwrap_or_default();
converted["reasoning_content"] = json!(reasoning);
} else if let Some(reasoning) = reasoning_text {
if !reasoning.is_empty() {
converted["reasoning_content"] = json!(reasoning);
}
// Include reasoning_content only when non-empty.
// Kimi rejects empty reasoning_content (""), so we must omit it entirely
// when there's no reasoning to send.
if !reasoning_text.is_empty() {
converted["reasoning_content"] = json!(reasoning_text);
}

if converted.get("content").is_some() || converted.get("tool_calls").is_some() {
Expand Down Expand Up @@ -542,6 +538,7 @@ where
use futures::StreamExt;

let mut accumulated_reasoning: Vec<Value> = Vec::new();
let mut accumulated_reasoning_content = String::new();

'outer: while let Some(response) = stream.next().await {
if response.as_ref().is_ok_and(|s| s == "data: [DONE]") {
Expand All @@ -562,6 +559,9 @@ where
if let Some(details) = &chunk.choices[0].delta.reasoning_details {
accumulated_reasoning.extend(details.iter().cloned());
}
if let Some(rc) = &chunk.choices[0].delta.reasoning_content {
accumulated_reasoning_content.push_str(rc);
}
}

let mut usage = extract_usage_with_output_tokens(&chunk);
Expand Down Expand Up @@ -602,6 +602,9 @@ where
if let Some(details) = &tool_chunk.choices[0].delta.reasoning_details {
accumulated_reasoning.extend(details.iter().cloned());
}
if let Some(rc) = &tool_chunk.choices[0].delta.reasoning_content {
accumulated_reasoning_content.push_str(rc);
}
if let Some(delta_tool_calls) = &tool_chunk.choices[0].delta.tool_calls {
for delta_call in delta_tool_calls {
if let Some(index) = delta_call.index {
Expand Down Expand Up @@ -642,6 +645,10 @@ where
};

let mut contents = Vec::new();
if !accumulated_reasoning_content.is_empty() {
contents.push(MessageContent::reasoning(&accumulated_reasoning_content));
accumulated_reasoning_content.clear();
}
let mut sorted_indices: Vec<_> = tool_call_data.keys().cloned().collect();
sorted_indices.sort();

Expand Down
61 changes: 56 additions & 5 deletions crates/goose/src/providers/gemini_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,18 @@ impl GeminiCliProvider {

fn parse_stream_json_response(events: &[Value]) -> Result<(Message, Usage), ProviderError> {
let mut all_text_content = Vec::new();
let mut all_thinking_content = Vec::new();
let mut usage = Usage::default();

for parsed in events {
match parsed.get("type").and_then(|t| t.as_str()) {
Some("thinking") => {
if let Some(content) = parsed.get("content").and_then(|c| c.as_str()) {
if !content.is_empty() {
all_thinking_content.push(content.to_string());
}
}
}
Some("message") => {
if parsed.get("role").and_then(|r| r.as_str()) == Some("assistant") {
if let Some(content) = parsed.get("content").and_then(|c| c.as_str()) {
Expand Down Expand Up @@ -253,11 +261,16 @@ impl GeminiCliProvider {
));
}

let message = Message::new(
Role::Assistant,
chrono::Utc::now().timestamp(),
vec![MessageContent::text(combined_text)],
);
let mut content = Vec::new();

let combined_thinking = all_thinking_content.join("");
if !combined_thinking.is_empty() {
content.push(MessageContent::thinking(combined_thinking, String::new()));
}

content.push(MessageContent::text(combined_text));

let message = Message::new(Role::Assistant, chrono::Utc::now().timestamp(), content);

Ok((message, usage))
}
Expand Down Expand Up @@ -395,6 +408,44 @@ mod tests {
assert!(GeminiCliProvider::parse_stream_json_response(&empty).is_err());
}

#[test]
fn test_parse_thinking_blocks() {
let events = vec![
json!({"type":"init","session_id":"abc","model":"gemini-2.5-pro"}),
json!({"type":"thinking","content":"Let me reason about this...","delta":true}),
json!({"type":"thinking","content":" Step 1: analyze the problem.","delta":true}),
json!({"type":"message","role":"assistant","content":"Here is the answer.","delta":true}),
json!({"type":"result","status":"success","stats":{"input_tokens":30,"output_tokens":15,"total_tokens":45}}),
];
let (message, usage) = GeminiCliProvider::parse_stream_json_response(&events).unwrap();
assert_eq!(message.role, Role::Assistant);

// Should have thinking content followed by text content
assert_eq!(message.content.len(), 2);
let thinking = message.content[0]
.as_thinking()
.expect("first content should be thinking");
assert_eq!(
thinking.thinking,
"Let me reason about this... Step 1: analyze the problem."
);
assert_eq!(message.as_concat_text(), "Here is the answer.");
assert_eq!(usage.input_tokens, Some(30));
assert_eq!(usage.output_tokens, Some(15));
}

#[test]
fn test_parse_no_thinking_blocks() {
// When there's no thinking, message should only have text content
let events = vec![
json!({"type":"message","role":"assistant","content":"Direct answer.","delta":true}),
json!({"type":"result","status":"success","stats":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}),
];
let (message, _usage) = GeminiCliProvider::parse_stream_json_response(&events).unwrap();
assert_eq!(message.content.len(), 1);
assert_eq!(message.as_concat_text(), "Direct answer.");
}

#[test]
fn test_build_prompt_first_and_resume() {
let provider = make_provider();
Expand Down
14 changes: 8 additions & 6 deletions crates/goose/tests/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,15 +179,17 @@ impl ProviderTester {
.complete(session_id, "You are a helpful assistant.", &[message], &[])
.await?;

assert_eq!(
response.content.len(),
1,
"Expected single content item in response"
assert!(
!response.content.is_empty(),
"Expected at least one content item in response"
);

assert!(
matches!(response.content[0], MessageContent::Text(_)),
"Expected text response"
response
.content
.iter()
.any(|c| matches!(c, MessageContent::Text(_))),
"Expected at least one text content item in response"
);

println!(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,15 @@ export default function ProviderConfigurationModal({

const toSubmit = Object.fromEntries(
Object.entries(configValues)
.filter(([_k, entry]) => !!entry.value)
.map(([k, entry]) => [k, entry.value || ''])
.filter(
([_k, entry]) =>
!!entry.value ||
(entry.serverValue != null && typeof entry.serverValue === 'string')
)
.map(([k, entry]) => [
k,
entry.value ?? (typeof entry.serverValue === 'string' ? entry.serverValue : ''),
])
);

try {
Expand Down
Loading