fix(providers): Buffer thinking across stream chunks for DeepSeek/Kimi tool calls (#9704)
Signed-off-by: thepetk <thepetk@gmail.com>
This commit is contained in:
@@ -182,12 +182,37 @@ pub fn format_messages_with_options(
|
||||
) -> Vec<Value> {
|
||||
let mut messages_spec = Vec::new();
|
||||
let mut pending_assistant_reasoning = String::new();
|
||||
// Reasoning to propagate across consecutive tool-call messages in the same turn.
|
||||
// DeepSeek/Kimi require reasoning_content on every assistant tool-call message.
|
||||
let mut tool_call_turn_reasoning = String::new();
|
||||
let mut saw_tool_response = false;
|
||||
|
||||
for message in messages {
|
||||
if options.preserve_thinking_context && message.role != Role::Assistant {
|
||||
pending_assistant_reasoning.clear();
|
||||
}
|
||||
|
||||
if options.preserve_thinking_context && message.role == Role::User {
|
||||
if message
|
||||
.content
|
||||
.iter()
|
||||
.any(|c| matches!(c, MessageContent::ToolResponse(_)))
|
||||
{
|
||||
saw_tool_response = true;
|
||||
} else {
|
||||
tool_call_turn_reasoning.clear();
|
||||
saw_tool_response = false;
|
||||
}
|
||||
}
|
||||
|
||||
// A new assistant message after tool results creates a new turn.
|
||||
// Prevents reasoning from the previous turn leaking into the new one.
|
||||
if options.preserve_thinking_context && message.role == Role::Assistant && saw_tool_response
|
||||
{
|
||||
tool_call_turn_reasoning.clear();
|
||||
saw_tool_response = false;
|
||||
}
|
||||
|
||||
let mut converted = json!({
|
||||
"role": message.role
|
||||
});
|
||||
@@ -408,6 +433,25 @@ pub fn format_messages_with_options(
|
||||
merge_reasoning_text(&pending_assistant_reasoning, &reasoning_text);
|
||||
pending_assistant_reasoning.clear();
|
||||
}
|
||||
|
||||
let has_tool_calls = converted
|
||||
.get("tool_calls")
|
||||
.and_then(|tc| tc.as_array())
|
||||
.is_some_and(|a| !a.is_empty());
|
||||
|
||||
if has_tool_calls {
|
||||
if reasoning_text.is_empty() {
|
||||
reasoning_text = tool_call_turn_reasoning.clone();
|
||||
} else {
|
||||
tool_call_turn_reasoning = reasoning_text.clone();
|
||||
}
|
||||
} else {
|
||||
// Carry reasoning forward even through non-tool assistant messages
|
||||
// (e.g., a visible text chunk that's is sent before a tool-call chunk
|
||||
// in the same streaming turn). An empty reasoning_text is equivalent
|
||||
// to clear.
|
||||
tool_call_turn_reasoning = reasoning_text.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// Include reasoning_content only when non-empty. Kimi rejects empty
|
||||
@@ -3135,6 +3179,180 @@ data: [DONE]"#;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_messages_carries_reasoning_through_text_only_chunks() -> anyhow::Result<()> {
|
||||
// Scenario B from the streaming bug: thinking arrives first, then multiple
|
||||
// text-only assistant messages, then a tool call with thinking re-attached
|
||||
// by agent.rs (via the earlier-chunk lookback).
|
||||
// Text-only messages set tool_call_turn_reasoning="" (line 453 else-branch),
|
||||
// but the TC's own Thinking content must repopulate it.
|
||||
let messages = vec![
|
||||
Message::assistant().with_content(MessageContent::thinking("reason", "")),
|
||||
Message::assistant().with_text("partial answer"),
|
||||
Message::assistant().with_text("more text"),
|
||||
// agent.rs attaches the earlier thinking to the TC message
|
||||
Message::assistant()
|
||||
.with_content(MessageContent::thinking("reason", ""))
|
||||
.with_tool_request(
|
||||
"tool1",
|
||||
Ok(CallToolRequestParams::new("test_tool").with_arguments(object!({}))),
|
||||
),
|
||||
];
|
||||
|
||||
let spec = format_messages_with_options(
|
||||
&messages,
|
||||
&ImageFormat::OpenAi,
|
||||
OpenAiFormatOptions {
|
||||
preserve_thinking_context: true,
|
||||
},
|
||||
);
|
||||
|
||||
let tool_call_msgs: Vec<_> = spec
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.get("tool_calls")
|
||||
.and_then(|tc| tc.as_array())
|
||||
.is_some_and(|a| !a.is_empty())
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(tool_call_msgs.len(), 1);
|
||||
assert_eq!(
|
||||
tool_call_msgs[0]["reasoning_content"], "reason",
|
||||
"reasoning_content must survive text-only chunks between thinking and tool call"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_messages_carries_reasoning_to_all_split_tool_calls() -> anyhow::Result<()> {
|
||||
// Simulates DeepSeek/Kimi streaming: a thinking-only chunk arrives first,
|
||||
// then the agent splits two tool calls into separate messages, each with
|
||||
// the same reasoning attached (as agent.rs does via response_thinking).
|
||||
// The formatter must keep reasoning_content on both so that
|
||||
// merge_split_tool_call_messages can reunite them into one assistant message.
|
||||
let tool_result1 = Message::user().with_tool_response(
|
||||
"tool1",
|
||||
Ok(rmcp::model::CallToolResult::success(vec![
|
||||
rmcp::model::Content::text("result1"),
|
||||
])),
|
||||
);
|
||||
let messages = vec![
|
||||
// Standalone thinking message (created by agent.rs alongside request_msgs)
|
||||
Message::assistant().with_content(MessageContent::thinking("reasoning", "")),
|
||||
// Each request_msg has thinking explicitly attached (agent.rs behaviour)
|
||||
Message::assistant()
|
||||
.with_content(MessageContent::thinking("reasoning", ""))
|
||||
.with_tool_request(
|
||||
"tool1",
|
||||
Ok(CallToolRequestParams::new("tool_a").with_arguments(object!({}))),
|
||||
),
|
||||
tool_result1,
|
||||
Message::assistant()
|
||||
.with_content(MessageContent::thinking("reasoning", ""))
|
||||
.with_tool_request(
|
||||
"tool2",
|
||||
Ok(CallToolRequestParams::new("tool_b").with_arguments(object!({}))),
|
||||
),
|
||||
];
|
||||
|
||||
let spec = format_messages_with_options(
|
||||
&messages,
|
||||
&ImageFormat::OpenAi,
|
||||
OpenAiFormatOptions {
|
||||
preserve_thinking_context: true,
|
||||
},
|
||||
);
|
||||
|
||||
// After merge: one assistant message with both tool calls
|
||||
let assistant_msgs: Vec<_> = spec
|
||||
.iter()
|
||||
.filter(|m| m.get("role") == Some(&json!("assistant")))
|
||||
.collect();
|
||||
assert_eq!(assistant_msgs.len(), 1);
|
||||
assert_eq!(assistant_msgs[0]["reasoning_content"], "reasoning");
|
||||
let tool_calls = assistant_msgs[0]["tool_calls"].as_array().unwrap();
|
||||
assert_eq!(tool_calls.len(), 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sequential_tool_calls_not_merged() -> anyhow::Result<()> {
|
||||
// Verifies that two tool calls from *different* turns are never merged,
|
||||
// even when the second call carries no fresh reasoning (the previous
|
||||
// turn's reasoning must not leak into it).
|
||||
let tool_result1 = Message::user().with_tool_response(
|
||||
"tool1",
|
||||
Ok(rmcp::model::CallToolResult::success(vec![
|
||||
rmcp::model::Content::text("result1"),
|
||||
])),
|
||||
);
|
||||
let messages = vec![
|
||||
// Turn 1: thinking then tool call
|
||||
Message::assistant().with_content(MessageContent::thinking("turn1_reasoning", "")),
|
||||
Message::assistant().with_tool_request(
|
||||
"tool1",
|
||||
Ok(CallToolRequestParams::new("tool_a").with_arguments(object!({}))),
|
||||
),
|
||||
tool_result1,
|
||||
// Turn 2: new tool call, no fresh thinking
|
||||
Message::assistant().with_tool_request(
|
||||
"tool2",
|
||||
Ok(CallToolRequestParams::new("tool_b").with_arguments(object!({}))),
|
||||
),
|
||||
];
|
||||
|
||||
let spec = format_messages_with_options(
|
||||
&messages,
|
||||
&ImageFormat::OpenAi,
|
||||
OpenAiFormatOptions {
|
||||
preserve_thinking_context: true,
|
||||
},
|
||||
);
|
||||
|
||||
let assistant_msgs: Vec<_> = spec
|
||||
.iter()
|
||||
.filter(|m| m.get("role") == Some(&json!("assistant")))
|
||||
.collect();
|
||||
|
||||
// Must remain two separate assistant messages — not merged across turns.
|
||||
assert_eq!(
|
||||
assistant_msgs.len(),
|
||||
2,
|
||||
"sequential tool calls must not be merged"
|
||||
);
|
||||
|
||||
// Turn 1 carries reasoning; turn 2 must not inherit it.
|
||||
assert_eq!(assistant_msgs[0]["reasoning_content"], "turn1_reasoning");
|
||||
assert!(
|
||||
assistant_msgs[1].get("reasoning_content").is_none()
|
||||
|| assistant_msgs[1]["reasoning_content"].is_null(),
|
||||
"turn 2 must not inherit stale reasoning from turn 1"
|
||||
);
|
||||
|
||||
// The tool result must appear between the two assistant messages.
|
||||
let tool_idx = spec
|
||||
.iter()
|
||||
.position(|m| m.get("role") == Some(&json!("tool")))
|
||||
.expect("tool result must be present");
|
||||
let asst1_idx = spec
|
||||
.iter()
|
||||
.position(|m| m.get("role") == Some(&json!("assistant")))
|
||||
.unwrap();
|
||||
let asst2_idx = spec
|
||||
.iter()
|
||||
.rposition(|m| m.get("role") == Some(&json!("assistant")))
|
||||
.unwrap();
|
||||
assert!(
|
||||
asst1_idx < tool_idx && tool_idx < asst2_idx,
|
||||
"tool result must sit between the two assistant messages"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case(
|
||||
"data: {\"error\":{\"message\":\"Internal server error\",\"type\":\"server_error\",\"code\":500}}\ndata: [DONE]",
|
||||
"Internal server error";
|
||||
|
||||
@@ -2239,27 +2239,54 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
// 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(_)))
|
||||
// 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 direct_thinking: Vec<MessageContent> = response
|
||||
.content
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
matches!(
|
||||
c,
|
||||
MessageContent::Thinking(_)
|
||||
| MessageContent::RedactedThinking(_)
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if !thinking_content.is_empty() {
|
||||
if !direct_thinking.is_empty() {
|
||||
let thinking_msg = Message::new(
|
||||
response.role.clone(),
|
||||
response.created,
|
||||
thinking_content,
|
||||
).with_id(format!("msg_{}", Uuid::new_v4()));
|
||||
direct_thinking.clone(),
|
||||
)
|
||||
.with_id(format!("msg_{}", Uuid::new_v4()));
|
||||
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::Thinking(_)))
|
||||
.cloned()
|
||||
.collect();
|
||||
// When thinking arrived in an earlier stream chunk (stored as a
|
||||
// thinking-only message) and this chunk has only tool calls,
|
||||
// reuse that thinking so each split request_msg carries it.
|
||||
let response_thinking = if direct_thinking.is_empty() {
|
||||
messages_to_add
|
||||
.messages()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| {
|
||||
m.role == response.role
|
||||
&& !m.content.is_empty()
|
||||
&& m.content.iter().all(|c| {
|
||||
matches!(
|
||||
c,
|
||||
MessageContent::Thinking(_)
|
||||
| MessageContent::RedactedThinking(_)
|
||||
)
|
||||
})
|
||||
})
|
||||
.map(|m| m.content.clone())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
direct_thinking
|
||||
};
|
||||
|
||||
for request in frontend_requests.iter().chain(remaining_requests.iter()) {
|
||||
if let Err(err) = &request.tool_call {
|
||||
@@ -2274,10 +2301,8 @@ impl Agent {
|
||||
let mut request_msg = Message::assistant()
|
||||
.with_id(format!("msg_{}", Uuid::new_v4()));
|
||||
|
||||
// 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());
|
||||
for thinking in &response_thinking {
|
||||
request_msg = request_msg.with_content(thinking.clone());
|
||||
}
|
||||
|
||||
request_msg = request_msg
|
||||
|
||||
@@ -1122,6 +1122,566 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod thinking_preservation_tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use goose::agents::{AgentConfig, SessionConfig};
|
||||
use goose::config::permission::PermissionManager;
|
||||
use goose::config::GooseMode;
|
||||
use goose::conversation::message::{Message, MessageContent};
|
||||
use goose::providers::base::{MessageStream, Provider, ProviderDef, ProviderMetadata};
|
||||
use goose::session::session_manager::SessionType;
|
||||
use goose::session::SessionManager;
|
||||
use goose_providers::conversation::token_usage::{ProviderUsage, Usage};
|
||||
use goose_providers::errors::ProviderError;
|
||||
use goose_providers::model::ModelConfig;
|
||||
use rmcp::model::{CallToolRequestParams, Tool};
|
||||
use rmcp::object;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// Simulates DeepSeek/Kimi streaming: reasoning_content arrives in an early
|
||||
/// chunk, the tool call arrives in a later chunk with no reasoning_content.
|
||||
struct ThinkingStreamProvider {
|
||||
call_count: AtomicUsize,
|
||||
name: &'static str,
|
||||
}
|
||||
|
||||
impl ThinkingStreamProvider {
|
||||
fn new(name: &'static str) -> Self {
|
||||
Self {
|
||||
call_count: AtomicUsize::new(0),
|
||||
name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl goose::providers::base::ProviderDescriptor for ThinkingStreamProvider {
|
||||
fn metadata() -> ProviderMetadata {
|
||||
ProviderMetadata {
|
||||
name: "thinking-stream-mock".to_string(),
|
||||
display_name: "Thinking Stream Mock".to_string(),
|
||||
description: "Mock for thinking preservation tests".to_string(),
|
||||
default_model: "mock-model".to_string(),
|
||||
known_models: vec![],
|
||||
model_doc_link: "".to_string(),
|
||||
config_keys: vec![],
|
||||
setup_steps: vec![],
|
||||
model_selection_hint: None,
|
||||
fast_model: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderDef for ThinkingStreamProvider {
|
||||
type Provider = Self;
|
||||
|
||||
fn from_env(
|
||||
_extensions: Vec<goose::config::ExtensionConfig>,
|
||||
_tls_config: Option<goose::providers::api_client::TlsConfig>,
|
||||
) -> futures::future::BoxFuture<'static, anyhow::Result<Self>> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ThinkingStreamProvider {
|
||||
async fn stream(
|
||||
&self,
|
||||
_model_config: &ModelConfig,
|
||||
_session_id: &str,
|
||||
_system_prompt: &str,
|
||||
_messages: &[Message],
|
||||
_tools: &[Tool],
|
||||
) -> Result<MessageStream, ProviderError> {
|
||||
let call = self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||
let usage = ProviderUsage::new(
|
||||
"mock-model".to_string(),
|
||||
Usage::new(Some(10), Some(20), Some(30)),
|
||||
);
|
||||
match call {
|
||||
0 => {
|
||||
// Chunk 1: reasoning_content only (no tool call)
|
||||
let thinking =
|
||||
Message::assistant().with_thinking("I should call test_tool", "sig_0");
|
||||
// Chunk 2: tool call only (no reasoning_content) — the bug scenario
|
||||
let tool_call = CallToolRequestParams::new("test_tool")
|
||||
.with_arguments(object!({"param": "value"}));
|
||||
let tool_msg =
|
||||
Message::assistant().with_tool_request("call_1", Ok(tool_call));
|
||||
let stream = futures::stream::iter(vec![
|
||||
Ok((Some(thinking), None)),
|
||||
Ok((Some(tool_msg), Some(usage))),
|
||||
]);
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
_ => {
|
||||
let msg = Message::assistant().with_text("Done.");
|
||||
Ok(Box::pin(futures::stream::once(async move {
|
||||
Ok((Some(msg), Some(usage)))
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_name(&self) -> &str {
|
||||
self.name
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_and_collect(provider_name: &'static str) -> Result<Vec<Message>> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf()));
|
||||
let config = AgentConfig::new(
|
||||
session_manager.clone(),
|
||||
PermissionManager::instance(),
|
||||
None,
|
||||
GooseMode::Auto,
|
||||
true,
|
||||
GoosePlatform::GooseCli,
|
||||
);
|
||||
let agent = Agent::with_config(config);
|
||||
let provider = Arc::new(ThinkingStreamProvider::new(provider_name));
|
||||
|
||||
let session = session_manager
|
||||
.create_session(
|
||||
PathBuf::default(),
|
||||
format!("{provider_name}-thinking-test"),
|
||||
SessionType::Hidden,
|
||||
GooseMode::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let session_id = session.id.clone();
|
||||
agent
|
||||
.update_provider(provider, ModelConfig::new("mock-model"), &session_id)
|
||||
.await?;
|
||||
|
||||
let session_config = SessionConfig {
|
||||
id: session_id.clone(),
|
||||
schedule_id: None,
|
||||
max_turns: Some(2),
|
||||
retry_config: None,
|
||||
};
|
||||
|
||||
let reply_stream = agent
|
||||
.reply(
|
||||
Message::user().with_text("Use the test tool"),
|
||||
session_config,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tokio::pin!(reply_stream);
|
||||
|
||||
while let Some(event) = reply_stream.next().await {
|
||||
event?;
|
||||
}
|
||||
|
||||
let reloaded = session_manager.get_session(&session_id, true).await?;
|
||||
Ok(reloaded
|
||||
.conversation
|
||||
.expect("should have conversation")
|
||||
.messages()
|
||||
.to_vec())
|
||||
}
|
||||
|
||||
fn assert_formatter_adds_reasoning_to_tool_calls(messages: &[Message], provider: &str) {
|
||||
use goose_providers::formats::openai::{
|
||||
format_messages_with_options, OpenAiFormatOptions,
|
||||
};
|
||||
use goose_providers::images::ImageFormat;
|
||||
|
||||
assert!(
|
||||
messages.iter().any(|m| m
|
||||
.content
|
||||
.iter()
|
||||
.any(|c| matches!(c, MessageContent::Thinking(_)))),
|
||||
"{provider}: conversation must contain at least one Thinking message"
|
||||
);
|
||||
assert!(
|
||||
messages.iter().any(|m| m
|
||||
.content
|
||||
.iter()
|
||||
.any(|c| matches!(c, MessageContent::ToolRequest(_)))),
|
||||
"{provider}: conversation must contain at least one tool-call message"
|
||||
);
|
||||
|
||||
let spec = format_messages_with_options(
|
||||
messages,
|
||||
&ImageFormat::OpenAi,
|
||||
OpenAiFormatOptions {
|
||||
preserve_thinking_context: true,
|
||||
},
|
||||
);
|
||||
let has_reasoning_on_tool_call = spec.iter().any(|m| {
|
||||
m.get("tool_calls")
|
||||
.and_then(|tc| tc.as_array())
|
||||
.is_some_and(|a| !a.is_empty())
|
||||
&& m.get("reasoning_content").is_some()
|
||||
});
|
||||
assert!(
|
||||
has_reasoning_on_tool_call,
|
||||
"{provider}: formatter must produce reasoning_content on assistant tool-call \
|
||||
messages — {provider} returns HTTP 400 when it is absent on the next turn"
|
||||
);
|
||||
}
|
||||
|
||||
/// DeepSeek streams reasoning_content before the tool-call chunk. The formatter
|
||||
/// must attach it to the tool-call message so the next turn is accepted.
|
||||
#[tokio::test]
|
||||
async fn test_deepseek_thinking_preserved_in_tool_call_message() -> Result<()> {
|
||||
let messages = run_and_collect("deepseek-mock").await?;
|
||||
assert_formatter_adds_reasoning_to_tool_calls(&messages, "DeepSeek");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Kimi has the same streaming behaviour as DeepSeek.
|
||||
#[tokio::test]
|
||||
async fn test_kimi_thinking_preserved_in_tool_call_message() -> Result<()> {
|
||||
let messages = run_and_collect("kimi-mock").await?;
|
||||
assert_formatter_adds_reasoning_to_tool_calls(&messages, "Kimi");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Simulates a provider that emits reasoning and a tool call in the same
|
||||
/// streamed message (no prior thinking-only chunk).
|
||||
struct CombinedThinkingToolProvider {
|
||||
call_count: AtomicUsize,
|
||||
}
|
||||
|
||||
impl CombinedThinkingToolProvider {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
call_count: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl goose::providers::base::ProviderDescriptor for CombinedThinkingToolProvider {
|
||||
fn metadata() -> ProviderMetadata {
|
||||
ProviderMetadata {
|
||||
name: "combined-thinking-tool-mock".to_string(),
|
||||
display_name: "Combined Thinking+Tool Mock".to_string(),
|
||||
description: "Mock for combined thinking+tool call in one chunk".to_string(),
|
||||
default_model: "mock-model".to_string(),
|
||||
known_models: vec![],
|
||||
model_doc_link: "".to_string(),
|
||||
config_keys: vec![],
|
||||
setup_steps: vec![],
|
||||
model_selection_hint: None,
|
||||
fast_model: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderDef for CombinedThinkingToolProvider {
|
||||
type Provider = Self;
|
||||
|
||||
fn from_env(
|
||||
_extensions: Vec<goose::config::ExtensionConfig>,
|
||||
_tls_config: Option<goose::providers::api_client::TlsConfig>,
|
||||
) -> futures::future::BoxFuture<'static, anyhow::Result<Self>> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for CombinedThinkingToolProvider {
|
||||
async fn stream(
|
||||
&self,
|
||||
_model_config: &ModelConfig,
|
||||
_session_id: &str,
|
||||
_system_prompt: &str,
|
||||
_messages: &[Message],
|
||||
_tools: &[Tool],
|
||||
) -> Result<MessageStream, ProviderError> {
|
||||
let call = self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||
let usage = ProviderUsage::new(
|
||||
"mock-model".to_string(),
|
||||
Usage::new(Some(10), Some(20), Some(30)),
|
||||
);
|
||||
match call {
|
||||
0 => {
|
||||
// Single chunk: reasoning_content AND tool call together
|
||||
let tool_call = CallToolRequestParams::new("test_tool")
|
||||
.with_arguments(object!({"param": "value"}));
|
||||
let combined = Message::assistant()
|
||||
.with_thinking("I should call test_tool", "sig_0")
|
||||
.with_tool_request("call_1", Ok(tool_call));
|
||||
Ok(Box::pin(futures::stream::once(async move {
|
||||
Ok((Some(combined), Some(usage)))
|
||||
})))
|
||||
}
|
||||
_ => {
|
||||
let msg = Message::assistant().with_text("Done.");
|
||||
Ok(Box::pin(futures::stream::once(async move {
|
||||
Ok((Some(msg), Some(usage)))
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_name(&self) -> &str {
|
||||
"combined-thinking-tool-mock"
|
||||
}
|
||||
}
|
||||
|
||||
/// When reasoning arrives in the same chunk as the tool call (no prior
|
||||
/// thinking-only message), the agent must attach it to the persisted
|
||||
/// request_msg so the formatter can emit reasoning_content on the next turn.
|
||||
#[tokio::test]
|
||||
async fn test_reasoning_preserved_when_combined_with_tool_call() -> Result<()> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf()));
|
||||
let config = AgentConfig::new(
|
||||
session_manager.clone(),
|
||||
PermissionManager::instance(),
|
||||
None,
|
||||
GooseMode::Auto,
|
||||
true,
|
||||
GoosePlatform::GooseCli,
|
||||
);
|
||||
let agent = Agent::with_config(config);
|
||||
let provider = Arc::new(CombinedThinkingToolProvider::new());
|
||||
|
||||
let session = session_manager
|
||||
.create_session(
|
||||
PathBuf::default(),
|
||||
"combined-thinking-tool-test".to_string(),
|
||||
SessionType::Hidden,
|
||||
GooseMode::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let session_id = session.id.clone();
|
||||
agent
|
||||
.update_provider(provider, ModelConfig::new("mock-model"), &session_id)
|
||||
.await?;
|
||||
|
||||
let session_config = SessionConfig {
|
||||
id: session_id.clone(),
|
||||
schedule_id: None,
|
||||
max_turns: Some(2),
|
||||
retry_config: None,
|
||||
};
|
||||
|
||||
let reply_stream = agent
|
||||
.reply(
|
||||
Message::user().with_text("Use the test tool"),
|
||||
session_config,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tokio::pin!(reply_stream);
|
||||
while let Some(event) = reply_stream.next().await {
|
||||
match event {
|
||||
Ok(_) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
let reloaded = session_manager.get_session(&session_id, true).await?;
|
||||
let messages = reloaded
|
||||
.conversation
|
||||
.expect("should have conversation")
|
||||
.messages()
|
||||
.to_vec();
|
||||
|
||||
assert_formatter_adds_reasoning_to_tool_calls(&messages, "combined-thinking-tool");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Simulates the DeepSeek/Kimi multi-tool-call case: thinking arrives as a
|
||||
/// separate stream chunk, then both tool calls arrive together in a second
|
||||
/// chunk with no thinking. Before the fix, the second tool-call message
|
||||
/// (asst(TC2)) received no reasoning_content because lines 210-213 in
|
||||
/// format_messages_with_options cleared tool_call_turn_reasoning after the
|
||||
/// first tool result.
|
||||
struct MultiToolThinkingProvider {
|
||||
call_count: AtomicUsize,
|
||||
}
|
||||
|
||||
impl MultiToolThinkingProvider {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
call_count: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl goose::providers::base::ProviderDescriptor for MultiToolThinkingProvider {
|
||||
fn metadata() -> ProviderMetadata {
|
||||
ProviderMetadata {
|
||||
name: "multi-tool-thinking-mock".to_string(),
|
||||
display_name: "Multi Tool Thinking Mock".to_string(),
|
||||
description: "Mock for multi-tool thinking preservation".to_string(),
|
||||
default_model: "mock-model".to_string(),
|
||||
known_models: vec![],
|
||||
model_doc_link: "".to_string(),
|
||||
config_keys: vec![],
|
||||
setup_steps: vec![],
|
||||
model_selection_hint: None,
|
||||
fast_model: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderDef for MultiToolThinkingProvider {
|
||||
type Provider = Self;
|
||||
|
||||
fn from_env(
|
||||
_extensions: Vec<goose::config::ExtensionConfig>,
|
||||
_tls_config: Option<goose::providers::api_client::TlsConfig>,
|
||||
) -> futures::future::BoxFuture<'static, anyhow::Result<Self>> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for MultiToolThinkingProvider {
|
||||
async fn stream(
|
||||
&self,
|
||||
_model_config: &ModelConfig,
|
||||
_session_id: &str,
|
||||
_system_prompt: &str,
|
||||
_messages: &[Message],
|
||||
_tools: &[Tool],
|
||||
) -> Result<MessageStream, ProviderError> {
|
||||
let call = self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||
let usage = ProviderUsage::new(
|
||||
"mock-model".to_string(),
|
||||
Usage::new(Some(10), Some(20), Some(30)),
|
||||
);
|
||||
match call {
|
||||
0 => {
|
||||
// Chunk 1: reasoning only (no tool calls)
|
||||
let thinking =
|
||||
Message::assistant().with_thinking("multi-tool reasoning", "sig_0");
|
||||
// Chunk 2: two tool calls, no reasoning — the multi-tool bug scenario
|
||||
let tc1 = CallToolRequestParams::new("tool_a")
|
||||
.with_arguments(object!({"p": "1"}));
|
||||
let tc2 = CallToolRequestParams::new("tool_b")
|
||||
.with_arguments(object!({"p": "2"}));
|
||||
let tool_msg = Message::assistant()
|
||||
.with_tool_request("call_1", Ok(tc1))
|
||||
.with_tool_request("call_2", Ok(tc2));
|
||||
let stream = futures::stream::iter(vec![
|
||||
Ok((Some(thinking), None)),
|
||||
Ok((Some(tool_msg), Some(usage))),
|
||||
]);
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
_ => {
|
||||
let msg = Message::assistant().with_text("Done.");
|
||||
Ok(Box::pin(futures::stream::once(async move {
|
||||
Ok((Some(msg), Some(usage)))
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_name(&self) -> &str {
|
||||
"multi-tool-thinking-mock"
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reasoning_preserved_on_all_tool_calls_when_thinking_in_separate_chunk(
|
||||
) -> Result<()> {
|
||||
use goose_providers::formats::openai::{
|
||||
format_messages_with_options, OpenAiFormatOptions,
|
||||
};
|
||||
use goose_providers::images::ImageFormat;
|
||||
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf()));
|
||||
let config = AgentConfig::new(
|
||||
session_manager.clone(),
|
||||
PermissionManager::instance(),
|
||||
None,
|
||||
GooseMode::Auto,
|
||||
true,
|
||||
GoosePlatform::GooseCli,
|
||||
);
|
||||
let agent = Agent::with_config(config);
|
||||
let provider = Arc::new(MultiToolThinkingProvider::new());
|
||||
|
||||
let session = session_manager
|
||||
.create_session(
|
||||
PathBuf::default(),
|
||||
"multi-tool-thinking-test".to_string(),
|
||||
SessionType::Hidden,
|
||||
GooseMode::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let session_id = session.id.clone();
|
||||
agent
|
||||
.update_provider(provider, ModelConfig::new("mock-model"), &session_id)
|
||||
.await?;
|
||||
|
||||
let session_config = SessionConfig {
|
||||
id: session_id.clone(),
|
||||
schedule_id: None,
|
||||
max_turns: Some(2),
|
||||
retry_config: None,
|
||||
};
|
||||
|
||||
let reply_stream = agent
|
||||
.reply(
|
||||
Message::user().with_text("Use both tools"),
|
||||
session_config,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tokio::pin!(reply_stream);
|
||||
while let Some(event) = reply_stream.next().await {
|
||||
event?;
|
||||
}
|
||||
|
||||
let reloaded = session_manager.get_session(&session_id, true).await?;
|
||||
let messages = reloaded
|
||||
.conversation
|
||||
.expect("should have conversation")
|
||||
.messages()
|
||||
.to_vec();
|
||||
|
||||
let spec = format_messages_with_options(
|
||||
&messages,
|
||||
&ImageFormat::OpenAi,
|
||||
OpenAiFormatOptions {
|
||||
preserve_thinking_context: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Both tool calls must end up in one merged assistant message with reasoning_content.
|
||||
let assistant_msgs: Vec<_> = spec
|
||||
.iter()
|
||||
.filter(|m| m.get("role") == Some(&serde_json::json!("assistant")))
|
||||
.filter(|m| {
|
||||
m.get("tool_calls")
|
||||
.and_then(|tc| tc.as_array())
|
||||
.is_some_and(|a| !a.is_empty())
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
assistant_msgs.len(),
|
||||
1,
|
||||
"both tool calls must be merged into one assistant message"
|
||||
);
|
||||
assert_eq!(
|
||||
assistant_msgs[0]["reasoning_content"], "multi-tool reasoning",
|
||||
"merged message must carry reasoning_content"
|
||||
);
|
||||
let tool_calls = assistant_msgs[0]["tool_calls"].as_array().unwrap();
|
||||
assert_eq!(tool_calls.len(), 2, "both tool calls must be present");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod goal_checking_tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user