chore: use a Conversation type (#3735)

This commit is contained in:
Jack Amadeo
2025-08-06 22:06:13 -04:00
committed by GitHub
parent 17ea675def
commit 5eb01004a4
90 changed files with 994 additions and 790 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use goose::message::Message;
use goose::conversation::Conversation;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
@@ -19,7 +19,7 @@ pub struct BenchAgentError {
pub trait BenchBaseSession: Send + Sync {
async fn headless(&mut self, message: String) -> anyhow::Result<()>;
fn session_file(&self) -> Option<PathBuf>;
fn message_history(&self) -> Vec<Message>;
fn message_history(&self) -> Conversation;
fn get_total_token_usage(&self) -> anyhow::Result<Option<i32>>;
}
// struct for managing agent-session-access. to be passed to evals for benchmarking
@@ -34,7 +34,7 @@ impl BenchAgent {
Self { session, errors }
}
pub(crate) async fn prompt(&mut self, p: String) -> anyhow::Result<Vec<Message>> {
pub(crate) async fn prompt(&mut self, p: String) -> anyhow::Result<Conversation> {
// Clear previous errors
{
let mut errors = self.errors.lock().await;
@@ -8,7 +8,7 @@ use crate::eval_suites::{
};
use crate::register_evaluation;
use async_trait::async_trait;
use goose::message::MessageContent;
use goose::conversation::message::MessageContent;
use rmcp::model::Role;
use serde_json::{self, Value};
@@ -8,7 +8,7 @@ use crate::eval_suites::{
};
use crate::register_evaluation;
use async_trait::async_trait;
use goose::message::MessageContent;
use goose::conversation::message::MessageContent;
use rmcp::model::Role;
use serde_json::{self, Value};
@@ -8,7 +8,7 @@ use crate::eval_suites::{
};
use crate::register_evaluation;
use async_trait::async_trait;
use goose::message::MessageContent;
use goose::conversation::message::MessageContent;
use rmcp::model::Role;
use serde_json::{self, Value};
@@ -6,7 +6,7 @@ use crate::eval_suites::{
};
use crate::register_evaluation;
use async_trait::async_trait;
use goose::message::MessageContent;
use goose::conversation::message::MessageContent;
use rmcp::model::Role;
use serde_json::{self, Value};
@@ -6,7 +6,7 @@ use crate::eval_suites::{
};
use crate::register_evaluation;
use async_trait::async_trait;
use goose::message::MessageContent;
use goose::conversation::message::MessageContent;
use rmcp::model::Role;
use serde_json::{self, Value};
@@ -6,7 +6,7 @@ use crate::eval_suites::{
};
use crate::register_evaluation;
use async_trait::async_trait;
use goose::message::MessageContent;
use goose::conversation::message::MessageContent;
use rmcp::model::Role;
use serde_json::{self, Value};
@@ -8,7 +8,7 @@ use crate::eval_suites::{
};
use crate::register_evaluation;
use async_trait::async_trait;
use goose::message::MessageContent;
use goose::conversation::message::MessageContent;
use rmcp::model::Role;
use serde_json::{self, Value};
@@ -1,6 +1,7 @@
use crate::bench_session::BenchAgent;
use crate::eval_suites::EvalMetricValue;
use goose::message::{Message, MessageContent};
use goose::conversation::message::{Message, MessageContent};
use goose::conversation::Conversation;
use std::collections::HashMap;
use std::time::Instant;
@@ -8,7 +9,7 @@ use std::time::Instant;
pub async fn collect_baseline_metrics(
agent: &mut BenchAgent,
prompt: String,
) -> (Vec<Message>, HashMap<String, EvalMetricValue>) {
) -> (Conversation, HashMap<String, EvalMetricValue>) {
// Initialize metrics map
let mut metrics = HashMap::new();
@@ -23,7 +24,7 @@ pub async fn collect_baseline_metrics(
"prompt_error".to_string(),
EvalMetricValue::String(format!("Error: {}", e)),
);
Vec::new()
Conversation::new_unvalidated(Vec::new())
}
};
@@ -35,7 +36,7 @@ pub async fn collect_baseline_metrics(
);
// Count tool calls
let (total_tool_calls, tool_calls_by_name) = count_tool_calls(&messages);
let (total_tool_calls, tool_calls_by_name) = count_tool_calls(messages.messages());
metrics.insert(
"total_tool_calls".to_string(),
EvalMetricValue::Integer(total_tool_calls),
+1 -1
View File
@@ -1,6 +1,6 @@
use crate::bench_work_dir::BenchmarkWorkDir;
use anyhow::{Context, Result};
use goose::message::Message;
use goose::conversation::message::Message;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
@@ -37,7 +37,7 @@ impl Evaluation for BlogSummary {
// Write response to file and get the text content
let response_text =
match write_response_to_file(&response, run_loc, "blog_summary_output.txt") {
match write_response_to_file(response.messages(), run_loc, "blog_summary_output.txt") {
Ok(text) => text,
Err(e) => {
println!("Warning: Failed to write blog summary output: {}", e);
@@ -59,7 +59,7 @@ impl Evaluation for BlogSummary {
));
// Check if the fetch tool was used
let used_fetch_tool = crate::eval_suites::used_tool(&response, "fetch");
let used_fetch_tool = crate::eval_suites::used_tool(response.messages(), "fetch");
metrics.push((
"used_fetch_tool".to_string(),
EvalMetricValue::Boolean(used_fetch_tool),
@@ -6,7 +6,7 @@ use crate::eval_suites::{
};
use crate::register_evaluation;
use async_trait::async_trait;
use goose::message::MessageContent;
use goose::conversation::message::MessageContent;
use rmcp::model::Role;
use serde_json::{self, Value};
use std::fs;
@@ -6,7 +6,7 @@ use crate::eval_suites::{
};
use crate::register_evaluation;
use async_trait::async_trait;
use goose::message::MessageContent;
use goose::conversation::message::MessageContent;
use rmcp::model::Role;
use serde_json::{self, Value};
use std::fs;
@@ -50,17 +50,20 @@ Present the information in order of significance or quality. Focus specifically
).await;
// Write response to file and get the text content
let response_text =
match write_response_to_file(&response, run_loc, "restaurant_research_output.txt") {
Ok(text) => text,
Err(e) => {
println!("Warning: Failed to write restaurant research output: {}", e);
// If file write fails, still continue with the evaluation
response
.last()
.map_or_else(String::new, |msg| msg.as_concat_text())
}
};
let response_text = match write_response_to_file(
response.messages(),
run_loc,
"restaurant_research_output.txt",
) {
Ok(text) => text,
Err(e) => {
println!("Warning: Failed to write restaurant research output: {}", e);
// If file write fails, still continue with the evaluation
response
.last()
.map_or_else(String::new, |msg| msg.as_concat_text())
}
};
// Convert HashMap to Vec for our metrics
let mut metrics = metrics_hashmap_to_vec(perf_metrics);
@@ -79,7 +82,7 @@ Present the information in order of significance or quality. Focus specifically
));
// Check if the fetch tool was used
let used_fetch_tool = crate::eval_suites::used_tool(&response, "fetch");
let used_fetch_tool = crate::eval_suites::used_tool(response.messages(), "fetch");
metrics.push((
"used_fetch_tool".to_string(),
EvalMetricValue::Boolean(used_fetch_tool),
@@ -6,7 +6,7 @@ use crate::eval_suites::{
};
use crate::register_evaluation;
use async_trait::async_trait;
use goose::message::MessageContent;
use goose::conversation::message::MessageContent;
use rmcp::model::Role;
use serde_json::{self, Value};
+2 -2
View File
@@ -2,7 +2,7 @@ use crate::session::build_session;
use crate::session::SessionBuilderConfig;
use crate::{logging, session, Session};
use async_trait::async_trait;
use goose::message::Message;
use goose::conversation::Conversation;
use goose_bench::bench_session::{BenchAgent, BenchBaseSession};
use goose_bench::eval_suites::ExtensionRequirements;
use std::path::PathBuf;
@@ -18,7 +18,7 @@ impl BenchBaseSession for Session {
fn session_file(&self) -> Option<PathBuf> {
self.session_file()
}
fn message_history(&self) -> Vec<Message> {
fn message_history(&self) -> Conversation {
self.message_history()
}
fn get_total_token_usage(&self) -> anyhow::Result<Option<i32>> {
+2 -2
View File
@@ -13,7 +13,7 @@ use goose::config::{
Config, ConfigError, ExperimentManager, ExtensionConfigManager, ExtensionEntry,
PermissionManager,
};
use goose::message::Message;
use goose::conversation::message::Message;
use goose::providers::{create, providers};
use rmcp::model::{Tool, ToolAnnotations};
use rmcp::object;
@@ -1551,7 +1551,7 @@ pub fn configure_max_turns_dialog() -> Result<(), Box<dyn Error>> {
/// Handle OpenRouter authentication
pub async fn handle_openrouter_auth() -> Result<(), Box<dyn Error>> {
use goose::config::{configure_openrouter, signup_openrouter::OpenRouterAuth};
use goose::message::Message;
use goose::conversation::message::Message;
use goose::providers::create;
// Use the OpenRouter authentication flow
+15 -11
View File
@@ -195,7 +195,8 @@ pub fn handle_session_export(identifier: Identifier, output_path: Option<PathBuf
};
// Generate the markdown content using the export functionality
let markdown = export_session_to_markdown(messages, &session_file_path, None);
let markdown =
export_session_to_markdown(messages.messages().clone(), &session_file_path, None);
// Output the markdown
if let Some(output) = output_path {
@@ -214,7 +215,7 @@ pub fn handle_session_export(identifier: Identifier, output_path: Option<PathBuf
/// This function handles the formatting of a complete session including headers,
/// message organization, and proper tool request/response pairing.
fn export_session_to_markdown(
messages: Vec<goose::message::Message>,
messages: Vec<goose::conversation::message::Message>,
session_file: &Path,
session_name_override: Option<&str>,
) -> String {
@@ -242,10 +243,12 @@ fn export_session_to_markdown(
for message in &messages {
// Check if this is a User message containing only ToolResponses
let is_only_tool_response = message.role == rmcp::model::Role::User
&& message
.content
.iter()
.all(|content| matches!(content, goose::message::MessageContent::ToolResponse(_)));
&& message.content.iter().all(|content| {
matches!(
content,
goose::conversation::message::MessageContent::ToolResponse(_)
)
});
// If the previous message had tool requests and this one is just tool responses,
// don't create a new User section - we'll attach the responses to the tool calls
@@ -274,11 +277,12 @@ fn export_session_to_markdown(
markdown_output.push_str("\n\n---\n\n");
// Check if this message has any tool requests, to handle the next message differently
if message
.content
.iter()
.any(|content| matches!(content, goose::message::MessageContent::ToolRequest(_)))
{
if message.content.iter().any(|content| {
matches!(
content,
goose::conversation::message::MessageContent::ToolRequest(_)
)
}) {
skip_next_if_tool_response = true;
}
}
+14 -10
View File
@@ -10,7 +10,8 @@ use axum::{
};
use futures::{sink::SinkExt, stream::StreamExt};
use goose::agents::{Agent, AgentEvent};
use goose::message::Message as GooseMessage;
use goose::conversation::message::Message as GooseMessage;
use goose::conversation::Conversation;
use goose::session;
use serde::{Deserialize, Serialize};
use std::{net::SocketAddr, sync::Arc};
@@ -18,7 +19,7 @@ use tokio::sync::{Mutex, RwLock};
use tower_http::cors::{Any, CorsLayer};
use tracing::error;
type SessionStore = Arc<RwLock<std::collections::HashMap<String, Arc<Mutex<Vec<GooseMessage>>>>>>;
type SessionStore = Arc<RwLock<std::collections::HashMap<String, Arc<Mutex<Conversation>>>>>;
type CancellationStore = Arc<RwLock<std::collections::HashMap<String, tokio::task::AbortHandle>>>;
#[derive(Clone)]
@@ -319,8 +320,8 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
let mut sessions = state.sessions.write().await;
// Load existing messages from JSONL file if it exists
let existing_messages = session::read_messages(&session_file)
.unwrap_or_else(|_| Vec::new());
let existing_messages =
session::read_messages(&session_file).unwrap_or_default();
let new_session = Arc::new(Mutex::new(existing_messages));
sessions.insert(session_id.clone(), new_session.clone());
@@ -435,21 +436,21 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
async fn process_message_streaming(
agent: &Agent,
session_messages: Arc<Mutex<Vec<GooseMessage>>>,
session_messages: Arc<Mutex<Conversation>>,
session_file: std::path::PathBuf,
content: String,
sender: Arc<Mutex<futures::stream::SplitSink<WebSocket, Message>>>,
) -> Result<()> {
use futures::StreamExt;
use goose::agents::SessionConfig;
use goose::message::MessageContent;
use goose::conversation::message::MessageContent;
use goose::session;
// Create a user message
let user_message = GooseMessage::user().with_text(content.clone());
// Messages will be auto-compacted in agent.reply() if needed
let messages = {
let messages: Conversation = {
let mut session_msgs = session_messages.lock().await;
session_msgs.push(user_message.clone());
session_msgs.clone()
@@ -493,7 +494,10 @@ async fn process_message_streaming(
retry_config: None,
};
match agent.reply(&messages, Some(session_config), None).await {
match agent
.reply(messages.clone(), Some(session_config), None)
.await
{
Ok(mut stream) => {
while let Some(result) = stream.next().await {
match result {
@@ -617,7 +621,7 @@ async fn process_message_streaming(
// For now, auto-summarize in web mode
// TODO: Implement proper UI for context handling
let (summarized_messages, _) =
agent.summarize_context(&messages).await?;
agent.summarize_context(messages.messages()).await?;
{
let mut session_msgs = session_messages.lock().await;
*session_msgs = summarized_messages;
@@ -633,7 +637,7 @@ async fn process_message_streaming(
// Replace the session's message history with the compacted messages
{
let mut session_msgs = session_messages.lock().await;
*session_msgs = new_messages;
*session_msgs = Conversation::new_unvalidated(new_messages);
}
// Persist the updated messages to the JSONL file
@@ -4,7 +4,7 @@
use crate::scenario_tests::scenario_runner::SCENARIO_TESTS_DIR;
use base64::engine::general_purpose;
use base64::Engine;
use goose::message::Message;
use goose::conversation::message::Message;
use goose::providers::base::Provider;
pub type MessageGenerator<'a> = Box<dyn Fn(&dyn Provider) -> Message + 'a>;
@@ -100,7 +100,7 @@ impl McpClientTrait for MockClient {
is_error: None,
structured_content: None,
}),
Err(e) => Err(Error::UnexpectedResponse),
Err(_e) => Err(Error::UnexpectedResponse),
}
} else {
Err(Error::UnexpectedResponse)
@@ -1,4 +1,5 @@
use dotenvy::dotenv;
use goose::conversation::Conversation;
use crate::scenario_tests::message_generator::MessageGenerator;
use crate::scenario_tests::mock_client::weather_client;
@@ -6,7 +7,6 @@ use crate::scenario_tests::provider_configs::{get_provider_configs, ProviderConf
use crate::session::Session;
use anyhow::Result;
use goose::agents::Agent;
use goose::message::Message;
use goose::model::ModelConfig;
use goose::providers::{create, testprovider::TestProvider};
use std::collections::{HashMap, HashSet};
@@ -18,7 +18,7 @@ pub const SCENARIO_TESTS_DIR: &str = "src/scenario_tests";
#[derive(Debug, Clone)]
pub struct ScenarioResult {
pub messages: Vec<Message>,
pub messages: Conversation,
pub error: Option<String>,
}
@@ -214,7 +214,7 @@ where
break;
}
}
let updated_messages = session.message_history().to_vec();
let updated_messages = session.message_history();
if let Some(ref err_msg) = error {
if err_msg.contains("No recorded response found") {
@@ -7,7 +7,7 @@ mod tests {
use crate::scenario_tests::mock_client::WEATHER_TYPE;
use crate::scenario_tests::scenario_runner::run_scenario;
use anyhow::Result;
use goose::message::Message;
use goose::conversation::message::Message;
#[tokio::test]
async fn test_what_is_your_name() -> Result<()> {
+2 -2
View File
@@ -1,4 +1,4 @@
use goose::message::{Message, MessageContent, ToolRequest, ToolResponse};
use goose::conversation::message::{Message, MessageContent, ToolRequest, ToolResponse};
use goose::utils::safe_truncate;
use rmcp::model::{RawContent, ResourceContents, Role};
use serde_json::Value;
@@ -360,7 +360,7 @@ pub fn message_to_markdown(message: &Message, export_all_content: bool) -> Strin
#[cfg(test)]
mod tests {
use super::*;
use goose::message::{Message, ToolRequest, ToolResponse};
use goose::conversation::message::{Message, ToolRequest, ToolResponse};
use mcp_core::tool::ToolCall;
use rmcp::model::{Content, RawTextContent, TextContent};
use serde_json::json;
+28 -20
View File
@@ -10,13 +10,13 @@ mod thinking;
use crate::session::task_execution_display::{
format_task_execution_notification, TASK_EXECUTION_NOTIFICATION_TYPE,
};
use goose::conversation::Conversation;
use std::io::Write;
pub use self::export::message_to_markdown;
pub use builder::{build_session, SessionBuilderConfig, SessionSettings};
use console::Color;
use goose::agents::AgentEvent;
use goose::message::push_message;
use goose::permission::permission_confirmation::PrincipalType;
use goose::permission::Permission;
use goose::permission::PermissionConfirmation;
@@ -31,7 +31,6 @@ use goose::agents::extension::{Envs, ExtensionConfig};
use goose::agents::types::RetryConfig;
use goose::agents::{Agent, SessionConfig};
use goose::config::Config;
use goose::message::{Message, MessageContent};
use goose::providers::pricing::initialize_pricing_cache;
use goose::session;
use input::InputResult;
@@ -39,6 +38,7 @@ use mcp_core::handler::ToolError;
use rmcp::model::PromptMessage;
use rmcp::model::ServerNotification;
use goose::conversation::message::{Message, MessageContent};
use rand::{distributions::Alphanumeric, Rng};
use rustyline::EditMode;
use serde_json::Value;
@@ -56,7 +56,7 @@ pub enum RunMode {
pub struct Session {
agent: Agent,
messages: Vec<Message>,
messages: Conversation,
session_file: Option<PathBuf>,
// Cache for completion data - using std::sync for thread safety without async
completion_cache: Arc<std::sync::RwLock<CompletionCache>>,
@@ -134,11 +134,11 @@ impl Session {
let messages = if let Some(session_file) = &session_file {
session::read_messages(session_file).unwrap_or_else(|e| {
eprintln!("Warning: Failed to load message history: {}", e);
Vec::new()
Conversation::new_unvalidated(Vec::new())
})
} else {
// Don't try to read messages if we're not saving sessions
Vec::new()
Conversation::new_unvalidated(Vec::new())
};
Session {
@@ -157,12 +157,12 @@ impl Session {
/// Helper function to summarize context messages
async fn summarize_context_messages(
messages: &mut Vec<Message>,
messages: &mut Conversation,
agent: &Agent,
message_suffix: &str,
) -> Result<()> {
// Summarize messages to fit within context length
let (summarized_messages, _) = agent.summarize_context(messages).await?;
let (summarized_messages, _) = agent.summarize_context(messages.messages()).await?;
let msg = format!("Context maxed out\n{}\n{}", "-".repeat(50), message_suffix);
output::render_text(&msg, Some(Color::Yellow), true);
*messages = summarized_messages;
@@ -719,8 +719,10 @@ impl Session {
let provider = self.agent.provider().await?;
// Call the summarize_context method which uses the summarize_messages function
let (summarized_messages, _) =
self.agent.summarize_context(&self.messages).await?;
let (summarized_messages, _) = self
.agent
.summarize_context(self.messages.messages())
.await?;
// Update the session messages with the summarized ones
self.messages = summarized_messages;
@@ -771,12 +773,14 @@ impl Session {
async fn plan_with_reasoner_model(
&mut self,
plan_messages: Vec<Message>,
plan_messages: Conversation,
reasoner: Arc<dyn Provider>,
) -> Result<(), anyhow::Error> {
let plan_prompt = self.agent.get_plan_prompt().await?;
output::show_thinking();
let (plan_response, _usage) = reasoner.complete(&plan_prompt, &plan_messages, &[]).await?;
let (plan_response, _usage) = reasoner
.complete(&plan_prompt, plan_messages.messages(), &[])
.await?;
output::render_message(&plan_response, self.debug);
output::hide_thinking();
let planner_response_type =
@@ -875,7 +879,11 @@ impl Session {
});
let mut stream = self
.agent
.reply(&self.messages, session_config.clone(), Some(cancel_token))
.reply(
self.messages.clone(),
session_config.clone(),
Some(cancel_token),
)
.await?;
let mut progress_bars = output::McpSpinners::new();
@@ -921,7 +929,7 @@ impl Session {
confirmation.id.clone(),
Err(ToolError::ExecutionError("Tool call cancelled by user".to_string()))
));
push_message(&mut self.messages, response_message);
self.messages.push(response_message);
if let Some(session_file) = &self.session_file {
let working_dir = std::env::current_dir().ok();
session::persist_messages_with_schedule_id(
@@ -983,7 +991,7 @@ impl Session {
}
"truncate" => {
// Truncate messages to fit within context length
let (truncated_messages, _) = self.agent.truncate_context(&self.messages).await?;
let (truncated_messages, _) = self.agent.truncate_context(self.messages.messages()).await?;
let msg = if context_strategy == "truncate" {
format!("Context maxed out - automatically truncated messages.\n{}\nGoose tried its best to truncate messages for you.", "-".repeat(50))
} else {
@@ -1013,7 +1021,7 @@ impl Session {
stream = self
.agent
.reply(
&self.messages,
self.messages.clone(),
session_config.clone(),
None
)
@@ -1065,7 +1073,7 @@ impl Session {
}
}
push_message(&mut self.messages, message.clone());
self.messages.push(message.clone());
// No need to update description on assistant messages
if let Some(session_file) = &self.session_file {
@@ -1193,7 +1201,7 @@ impl Session {
}
Some(Ok(AgentEvent::HistoryReplaced(new_messages))) => {
// Replace the session's message history with the compacted messages
self.messages = new_messages;
self.messages = Conversation::new_unvalidated(new_messages);
// Persist the updated messages to the session file
if let Some(session_file) = &self.session_file {
@@ -1414,7 +1422,7 @@ impl Session {
cache.last_updated = Instant::now();
}
pub fn message_history(&self) -> Vec<Message> {
pub fn message_history(&self) -> Conversation {
self.messages.clone()
}
@@ -1432,7 +1440,7 @@ impl Session {
);
// Render each message
for message in &self.messages {
for message in self.messages.iter() {
output::render_message(message, self.debug);
}
@@ -1612,7 +1620,7 @@ impl Session {
}
fn push_message(&mut self, message: Message) {
push_message(&mut self.messages, message);
self.messages.push(message);
}
}
+2 -1
View File
@@ -2,7 +2,7 @@ use anstream::println;
use bat::WrappingMode;
use console::{style, Color};
use goose::config::Config;
use goose::message::{Message, MessageContent, ToolRequest, ToolResponse};
use goose::conversation::message::{Message, MessageContent, ToolRequest, ToolResponse};
use goose::providers::pricing::get_model_pricing;
use goose::providers::pricing::parse_model_id;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
@@ -16,6 +16,7 @@ use std::io::{Error, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
// Re-export theme for use in main
#[derive(Clone, Copy)]
pub enum Theme {
+4 -4
View File
@@ -3,10 +3,6 @@ use goose::agents::extension::ToolInfo;
use goose::agents::ExtensionConfig;
use goose::config::permission::PermissionLevel;
use goose::config::ExtensionEntry;
use goose::message::{
ContextLengthExceeded, FrontendToolRequest, Message, MessageContent, RedactedThinkingContent,
SummarizationRequested, ThinkingContent, ToolConfirmationRequest, ToolRequest, ToolResponse,
};
use goose::permission::permission_confirmation::PrincipalType;
use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata};
use goose::session::info::SessionInfo;
@@ -17,6 +13,10 @@ use rmcp::model::{
};
use utoipa::{OpenApi, ToSchema};
use goose::conversation::message::{
ContextLengthExceeded, FrontendToolRequest, Message, MessageContent, RedactedThinkingContent,
SummarizationRequested, ThinkingContent, ToolConfirmationRequest, ToolRequest, ToolResponse,
};
use utoipa::openapi::schema::{
AdditionalProperties, AnyOfBuilder, ArrayBuilder, ObjectBuilder, OneOfBuilder, Schema,
SchemaFormat, SchemaType,
+3 -3
View File
@@ -6,7 +6,7 @@ use axum::{
routing::post,
Json, Router,
};
use goose::message::Message;
use goose::conversation::{message::Message, Conversation};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use utoipa::ToSchema;
@@ -58,7 +58,7 @@ async fn manage_context(
.await
.map_err(|_| StatusCode::PRECONDITION_FAILED)?;
let mut processed_messages: Vec<Message> = vec![];
let mut processed_messages = Conversation::new_unvalidated(vec![]);
let mut token_counts: Vec<usize> = vec![];
if request.manage_action == "truncation" {
@@ -74,7 +74,7 @@ async fn manage_context(
}
Ok(Json(ContextManageResponse {
messages: processed_messages,
messages: processed_messages.messages().clone(),
token_counts,
}))
}
+4 -2
View File
@@ -1,7 +1,7 @@
use std::sync::Arc;
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use goose::message::Message;
use goose::conversation::{message::Message, Conversation};
use goose::recipe::Recipe;
use goose::recipe_deeplink;
use serde::{Deserialize, Serialize};
@@ -83,7 +83,9 @@ async fn create_recipe(
.map_err(|_| (StatusCode::PRECONDITION_FAILED, Json(error_response)))?;
// Create base recipe from agent state and messages
let recipe_result = agent.create_recipe(request.messages).await;
let recipe_result = agent
.create_recipe(Conversation::new_unvalidated(request.messages))
.await;
match recipe_result {
Ok(mut recipe) => {
+11 -11
View File
@@ -9,9 +9,10 @@ use axum::{
};
use bytes::Bytes;
use futures::{stream::StreamExt, Stream};
use goose::conversation::message::{Message, MessageContent};
use goose::conversation::Conversation;
use goose::{
agents::{AgentEvent, SessionConfig},
message::{push_message, Message, MessageContent},
permission::permission_confirmation::PrincipalType,
};
use goose::{
@@ -186,7 +187,7 @@ async fn reply_handler(
let stream = ReceiverStream::new(rx);
let cancel_token = CancellationToken::new();
let messages = request.messages;
let messages = Conversation::new_unvalidated(request.messages);
let session_working_dir = request.session_working_dir.clone();
let session_id = request
@@ -221,12 +222,9 @@ async fn reply_handler(
retry_config: None,
};
// Messages will be auto-compacted in agent.reply() if needed
let messages_to_process = messages.clone();
let mut stream = match agent
.reply(
&messages_to_process,
messages.clone(),
Some(session_config),
Some(task_cancel.clone()),
)
@@ -279,15 +277,15 @@ async fn reply_handler(
match response {
Ok(Some(Ok(AgentEvent::Message(message)))) => {
for content in &message.content {
track_tool_telemetry(content, &all_messages);
}
track_tool_telemetry(content, all_messages.messages());
}
push_message(&mut all_messages, message.clone());
stream_event(MessageEvent::Message { message }, &tx, &cancel_token).await;
all_messages.push(message.clone());
stream_event(MessageEvent::Message { message }, &tx, &cancel_token).await;
}
Ok(Some(Ok(AgentEvent::HistoryReplaced(new_messages)))) => {
// Replace the message history with the compacted messages
all_messages = new_messages;
all_messages = Conversation::new_unvalidated(new_messages);
// Note: We don't send this as a stream event since it's an internal operation
// The client will see the compaction notification message that was sent before this event
}
@@ -518,6 +516,7 @@ pub fn routes(state: Arc<AppState>) -> Router {
#[cfg(test)]
mod tests {
use super::*;
use goose::conversation::message::Message;
use goose::{
agents::Agent,
model::ModelConfig,
@@ -558,6 +557,7 @@ mod tests {
mod integration_tests {
use super::*;
use axum::{body::Body, http::Request};
use goose::conversation::message::Message;
use std::sync::Arc;
use tower::ServiceExt;
+2 -2
View File
@@ -10,7 +10,7 @@ use axum::{
routing::{get, put},
Json, Router,
};
use goose::message::Message;
use goose::conversation::message::Message;
use goose::session;
use goose::session::info::{get_valid_sorted_sessions, SessionInfo, SortOrder};
use goose::session::SessionMetadata;
@@ -137,7 +137,7 @@ async fn get_session_history(
Ok(Json(SessionHistoryResponse {
session_id,
metadata,
messages,
messages: messages.messages().clone(),
}))
}
+6 -4
View File
@@ -4,7 +4,8 @@ use dotenvy::dotenv;
use futures::StreamExt;
use goose::agents::{Agent, AgentEvent, ExtensionConfig};
use goose::config::{DEFAULT_EXTENSION_DESCRIPTION, DEFAULT_EXTENSION_TIMEOUT};
use goose::message::Message;
use goose::conversation::message::Message;
use goose::conversation::Conversation;
use goose::providers::databricks::DatabricksProvider;
#[tokio::main]
@@ -32,10 +33,11 @@ async fn main() {
println!(" {}", extension);
}
let messages = vec![Message::user()
.with_text("can you summarize the readme.md in this dir using just a haiku?")];
let conversation = Conversation::new(vec![Message::user()
.with_text("can you summarize the readme.md in this dir using just a haiku?")])
.unwrap();
let mut stream = agent.reply(&messages, None, None).await.unwrap();
let mut stream = agent.reply(conversation, None, None).await.unwrap();
while let Some(Ok(AgentEvent::Message(message))) = stream.next().await {
println!("{}", serde_json::to_string_pretty(&message).unwrap());
println!("\n");
+4 -6
View File
@@ -1,11 +1,9 @@
use anyhow::Result;
use dotenvy::dotenv;
use goose::{
message::Message,
providers::{
base::{Provider, Usage},
databricks::DatabricksProvider,
},
use goose::conversation::message::Message;
use goose::providers::{
base::{Provider, Usage},
databricks::DatabricksProvider,
};
use tokio_stream::StreamExt;
+3 -3
View File
@@ -1,9 +1,9 @@
use anyhow::Result;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use dotenvy::dotenv;
use goose::{
message::Message,
providers::{bedrock::BedrockProvider, databricks::DatabricksProvider, openai::OpenAiProvider},
use goose::conversation::message::Message;
use goose::providers::{
bedrock::BedrockProvider, databricks::DatabricksProvider, openai::OpenAiProvider,
};
use mcp_core::tool::ToolCall;
use rmcp::model::{Content, Tool};
+28 -23
View File
@@ -34,7 +34,7 @@ use crate::agents::types::SessionConfig;
use crate::agents::types::{FrontendTool, ToolResultReceiver};
use crate::config::{Config, ExtensionConfigManager, PermissionManager};
use crate::context_mgmt::auto_compact;
use crate::message::{push_message, Message, ToolRequest};
use crate::conversation::{debug_conversation_fix, fix_conversation, Conversation};
use crate::permission::permission_judge::{check_tool_permissions, PermissionCheckResult};
use crate::permission::PermissionConfirmation;
use crate::providers::base::Provider;
@@ -56,13 +56,13 @@ use super::final_output_tool::FinalOutputTool;
use super::platform_tools;
use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE};
use crate::agents::subagent_task_config::TaskConfig;
use crate::conversation_fixer::{debug_conversation_fix, ConversationFixer};
use crate::conversation::message::{Message, ToolRequest};
const DEFAULT_MAX_TURNS: u32 = 1000;
/// Context needed for the reply function
pub struct ReplyContext {
pub messages: Vec<Message>,
pub messages: Conversation,
pub tools: Vec<Tool>,
pub toolshim_tools: Vec<Tool>,
pub system_prompt: String,
@@ -199,7 +199,7 @@ impl Agent {
/// Handle retry logic for the agent reply loop
async fn handle_retry_logic(
&self,
messages: &mut Vec<Message>,
messages: &mut Conversation,
session: &Option<SessionConfig>,
initial_messages: &[Message],
) -> Result<bool> {
@@ -218,24 +218,29 @@ impl Agent {
async fn prepare_reply_context(
&self,
unfixed_messages: &[Message],
unfixed_conversation: Conversation,
session: &Option<SessionConfig>,
) -> Result<ReplyContext> {
let (messages, issues) = ConversationFixer::fix_conversation(Vec::from(unfixed_messages));
let unfixed_messages = unfixed_conversation.messages().clone();
let (conversation, issues) = fix_conversation(unfixed_conversation.clone());
if !issues.is_empty() {
tracing::warn!(
"Conversation issue fixed: {}",
debug_conversation_fix(unfixed_messages, &messages, &issues)
debug_conversation_fix(
unfixed_messages.as_slice(),
conversation.messages(),
&issues
)
);
}
let initial_messages = messages.clone();
let initial_messages = conversation.messages().clone();
let config = Config::global();
let (tools, toolshim_tools, system_prompt) = self.prepare_tools_and_prompt().await?;
let goose_mode = Self::determine_goose_mode(session.as_ref(), config);
Ok(ReplyContext {
messages,
messages: conversation,
tools,
toolshim_tools,
system_prompt,
@@ -760,7 +765,7 @@ impl Agent {
&self,
messages: &[Message],
session: &Option<SessionConfig>,
) -> Result<Option<(Vec<Message>, String)>> {
) -> Result<Option<(Conversation, String)>> {
// Try to get session metadata for more accurate token counts
let session_metadata = if let Some(session_config) = session {
match session::storage::get_path(session_config.id.clone()) {
@@ -802,22 +807,22 @@ impl Agent {
Ok(None)
}
#[instrument(skip(self, unfixed_messages, session), fields(user_message))]
#[instrument(skip(self, unfixed_conversation, session), fields(user_message))]
pub async fn reply(
&self,
unfixed_messages: &[Message],
unfixed_conversation: Conversation,
session: Option<SessionConfig>,
cancel_token: Option<CancellationToken>,
) -> Result<BoxStream<'_, Result<AgentEvent>>> {
// Handle auto-compaction before processing
let (messages, compaction_msg) = match self
.handle_auto_compaction(unfixed_messages, &session)
.handle_auto_compaction(unfixed_conversation.messages(), &session)
.await?
{
Some((compacted_messages, msg)) => (compacted_messages, Some(msg)),
None => {
let context = self
.prepare_reply_context(unfixed_messages, &session)
.prepare_reply_context(unfixed_conversation, &session)
.await?;
(context.messages, None)
}
@@ -827,10 +832,10 @@ impl Agent {
if let Some(compaction_msg) = compaction_msg {
return Ok(Box::pin(async_stream::try_stream! {
yield AgentEvent::Message(Message::assistant().with_text(compaction_msg));
yield AgentEvent::HistoryReplaced(messages.clone());
yield AgentEvent::HistoryReplaced(messages.messages().clone());
// Continue with normal reply processing using compacted messages
let mut reply_stream = self.reply_internal(&messages, session, cancel_token).await?;
let mut reply_stream = self.reply_internal(messages, session, cancel_token).await?;
while let Some(event) = reply_stream.next().await {
yield event?;
}
@@ -838,13 +843,13 @@ impl Agent {
}
// No compaction needed, proceed with normal processing
self.reply_internal(&messages, session, cancel_token).await
self.reply_internal(messages, session, cancel_token).await
}
/// Main reply method that handles the actual agent processing
async fn reply_internal(
&self,
messages: &[Message],
messages: Conversation,
session: Option<SessionConfig>,
cancel_token: Option<CancellationToken>,
) -> Result<BoxStream<'_, Result<AgentEvent>>> {
@@ -905,7 +910,7 @@ impl Agent {
let mut stream = Self::stream_response_from_provider(
self.provider().await?,
&system_prompt,
&messages,
messages.messages(),
&tools,
&toolshim_tools,
).await?;
@@ -1074,8 +1079,8 @@ impl Agent {
yield AgentEvent::Message(final_message_tool_resp.clone());
added_message = true;
push_message(&mut messages_to_add, response);
push_message(&mut messages_to_add, final_message_tool_resp);
messages_to_add.push(response);
messages_to_add.push(final_message_tool_resp);
}
}
Err(ProviderError::ContextLengthExceeded(_)) => {
@@ -1244,7 +1249,7 @@ impl Agent {
}
}
pub async fn create_recipe(&self, mut messages: Vec<Message>) -> Result<Recipe> {
pub async fn create_recipe(&self, mut messages: Conversation) -> Result<Recipe> {
let extension_manager = self.extension_manager.read().await;
let extensions_info = extension_manager.get_extensions_info().await;
@@ -1273,7 +1278,7 @@ impl Agent {
.await
.as_ref()
.unwrap()
.complete(&system_prompt, &messages, &tools)
.complete(&system_prompt, messages.messages(), &tools)
.await?;
let content = result.as_concat_text();
+4 -3
View File
@@ -1,6 +1,7 @@
use anyhow::Ok;
use crate::message::Message;
use crate::conversation::message::Message;
use crate::conversation::Conversation;
use crate::token_counter::create_async_token_counter;
use crate::context_mgmt::summarize::summarize_messages_async;
@@ -14,7 +15,7 @@ impl Agent {
pub async fn truncate_context(
&self,
messages: &[Message], // last message is a user msg that led to assistant message with_context_length_exceeded
) -> Result<(Vec<Message>, Vec<usize>), anyhow::Error> {
) -> Result<(Conversation, Vec<usize>), anyhow::Error> {
let provider = self.provider().await?;
let token_counter = create_async_token_counter()
.await
@@ -51,7 +52,7 @@ impl Agent {
pub async fn summarize_context(
&self,
messages: &[Message], // last message is a user msg that led to assistant message with_context_length_exceeded
) -> Result<(Vec<Message>, Vec<usize>), anyhow::Error> {
) -> Result<(Conversation, Vec<usize>), anyhow::Error> {
let provider = self.provider().await?;
let token_counter = create_async_token_counter()
.await
+16 -8
View File
@@ -5,8 +5,10 @@ use std::sync::Arc;
use async_stream::try_stream;
use futures::stream::StreamExt;
use super::super::agents::Agent;
use crate::agents::router_tool_selector::RouterToolSelectionStrategy;
use crate::message::{Message, MessageContent, ToolRequest};
use crate::conversation::message::{Message, MessageContent, ToolRequest};
use crate::conversation::Conversation;
use crate::providers::base::{stream_from_single_message, MessageStream, Provider, ProviderUsage};
use crate::providers::errors::ProviderError;
use crate::providers::toolshim::{
@@ -16,8 +18,6 @@ use crate::providers::toolshim::{
use crate::session;
use rmcp::model::Tool;
use super::super::agents::Agent;
async fn toolshim_postprocess(
response: Message,
toolshim_tools: &[Tool],
@@ -127,12 +127,12 @@ impl Agent {
let messages_for_provider = if config.toolshim {
convert_tool_messages_to_text(messages)
} else {
messages.to_vec()
Conversation::new_unvalidated(messages.to_vec())
};
// Call the provider to get a response
let (mut response, usage) = provider
.complete(system_prompt, &messages_for_provider, tools)
.complete(system_prompt, messages_for_provider.messages(), tools)
.await?;
crate::providers::base::set_current_model(&usage.model);
@@ -159,7 +159,7 @@ impl Agent {
let messages_for_provider = if config.toolshim {
convert_tool_messages_to_text(messages)
} else {
messages.to_vec()
Conversation::new_unvalidated(messages.to_vec())
};
// Clone owned data to move into the async stream
@@ -170,11 +170,19 @@ impl Agent {
let mut stream = if provider.supports_streaming() {
provider
.stream(system_prompt.as_str(), &messages_for_provider, &tools)
.stream(
system_prompt.as_str(),
messages_for_provider.messages(),
&tools,
)
.await?
} else {
let (message, usage) = provider
.complete(system_prompt.as_str(), &messages_for_provider, &tools)
.complete(
system_prompt.as_str(),
messages_for_provider.messages(),
&tools,
)
.await?;
stream_from_single_message(message, usage)
};
+5 -5
View File
@@ -11,7 +11,8 @@ use crate::agents::types::{
RetryConfig, SuccessCheck, DEFAULT_ON_FAILURE_TIMEOUT_SECONDS, DEFAULT_RETRY_TIMEOUT_SECONDS,
};
use crate::config::Config;
use crate::message::Message;
use crate::conversation::message::Message;
use crate::conversation::Conversation;
use crate::tool_monitor::ToolMonitor;
/// Result of a retry logic evaluation
@@ -92,12 +93,11 @@ impl RetryManager {
/// Reset status for retry: clear message history and final output tool state
async fn reset_status_for_retry(
messages: &mut Vec<Message>,
messages: &mut Conversation,
initial_messages: &[Message],
final_output_tool: &Arc<Mutex<Option<crate::agents::final_output_tool::FinalOutputTool>>>,
) {
messages.clear();
messages.extend_from_slice(initial_messages);
*messages = Conversation::new_unvalidated(initial_messages.to_vec());
info!("Reset message history to initial state for retry");
if let Some(final_output_tool) = final_output_tool.lock().await.as_mut() {
@@ -109,7 +109,7 @@ impl RetryManager {
/// Handle retry logic for the agent reply loop
pub async fn handle_retry_logic(
&self,
messages: &mut Vec<Message>,
messages: &mut Conversation,
session: &Option<SessionConfig>,
initial_messages: &[Message],
final_output_tool: &Arc<Mutex<Option<crate::agents::final_output_tool::FinalOutputTool>>>,
@@ -12,7 +12,7 @@ use std::sync::Arc;
use tokio::sync::RwLock;
use crate::agents::tool_vectordb::ToolVectorDB;
use crate::message::Message;
use crate::conversation::message::Message;
use crate::model::ModelConfig;
use crate::providers::{self, base::Provider};
+10 -11
View File
@@ -3,7 +3,6 @@ use crate::{
agents::extension::ExtensionConfig,
agents::{extension_manager::ExtensionManager, Agent, TaskConfig},
config::ExtensionConfigManager,
message::{Message, MessageContent, ToolRequest},
prompt_template::render_global_file,
providers::errors::ProviderError,
};
@@ -13,6 +12,8 @@ use mcp_core::handler::ToolError;
use rmcp::model::Tool;
use serde::{Deserialize, Serialize};
// use serde_json::{self};
use crate::conversation::message::{Message, MessageContent, ToolRequest};
use crate::conversation::Conversation;
use std::{collections::HashMap, sync::Arc};
use tokio::sync::{Mutex, RwLock};
use tokio_util::sync::CancellationToken;
@@ -41,7 +42,7 @@ pub struct SubAgentProgress {
/// A specialized agent that can handle specific tasks independently
pub struct SubAgent {
pub id: String,
pub conversation: Arc<Mutex<Vec<Message>>>,
pub conversation: Arc<Mutex<Conversation>>,
pub status: Arc<RwLock<SubAgentStatus>>,
pub config: TaskConfig,
pub turn_count: Arc<Mutex<usize>>,
@@ -80,7 +81,7 @@ impl SubAgent {
let subagent = Arc::new(SubAgent {
id: task_config.id.clone(),
conversation: Arc::new(Mutex::new(Vec::new())),
conversation: Arc::new(Mutex::new(Conversation::new_unvalidated(Vec::new()))),
status: Arc::new(RwLock::new(SubAgentStatus::Ready)),
config: task_config,
turn_count: Arc::new(Mutex::new(0)),
@@ -107,7 +108,7 @@ impl SubAgent {
&self,
message: String,
task_config: TaskConfig,
) -> Result<Vec<Message>, anyhow::Error> {
) -> Result<Conversation, anyhow::Error> {
debug!("Processing message for subagent {}", self.id);
// Get provider from task config
@@ -128,7 +129,10 @@ impl SubAgent {
}
// Get the current conversation for context
let mut messages = self.get_conversation().await;
let mut messages = {
let conversation = self.conversation.lock().await;
conversation.clone()
};
// Get tools from the subagent's own extension manager
let tools: Vec<Tool> = self
@@ -156,7 +160,7 @@ impl SubAgent {
match Agent::generate_response_from_provider(
Arc::clone(provider),
&system_prompt,
&messages,
messages.messages(),
&tools,
&toolshim_tools,
)
@@ -264,11 +268,6 @@ impl SubAgent {
conversation.push(message);
}
/// Get the full conversation history
async fn get_conversation(&self) -> Vec<Message> {
self.conversation.lock().await.clone()
}
/// Build the system prompt for the subagent using the template
async fn build_system_prompt(&self, available_tools: &[Tool]) -> Result<String, anyhow::Error> {
let mut context = HashMap::new();
+2 -2
View File
@@ -24,10 +24,10 @@ pub async fn run_complete_subagent_task(
.flat_map(|message| {
message.content.iter().filter_map(|content| {
match content {
crate::message::MessageContent::Text(text_content) => {
crate::conversation::message::MessageContent::Text(text_content) => {
Some(text_content.text.clone())
}
crate::message::MessageContent::ToolResponse(tool_response) => {
crate::conversation::message::MessageContent::ToolResponse(tool_response) => {
// Extract text from tool response
if let Ok(contents) = &tool_response.tool_result {
let texts: Vec<String> = contents
+1 -1
View File
@@ -9,7 +9,6 @@ use tokio_util::sync::CancellationToken;
use crate::config::permission::PermissionLevel;
use crate::config::PermissionManager;
use crate::message::{Message, ToolRequest};
use crate::permission::Permission;
use mcp_core::ToolResult;
use rmcp::model::{Content, ServerNotification};
@@ -32,6 +31,7 @@ impl From<ToolResult<Vec<Content>>> for ToolCallResult {
use super::agent::{tool_stream, ToolStream};
use crate::agents::Agent;
use crate::conversation::message::{Message, ToolRequest};
pub const DECLINED_RESPONSE: &str = "The user has declined to run this tool. \
DO NOT attempt to call this tool again. \
@@ -7,7 +7,7 @@ use crate::agents::tool_execution::ToolCallResult;
use crate::agents::tool_router_index_manager::ToolRouterIndexManager;
use crate::agents::tool_vectordb::generate_table_id;
use crate::config::Config;
use crate::message::ToolRequest;
use crate::conversation::message::ToolRequest;
use crate::providers::base::Provider;
use anyhow::{anyhow, Result};
use mcp_core::ToolError;
@@ -1,3 +1,5 @@
use crate::conversation::message::Message;
use crate::conversation::Conversation;
use crate::{
agents::Agent,
config::Config,
@@ -5,7 +7,6 @@ use crate::{
common::{SYSTEM_PROMPT_TOKEN_OVERHEAD, TOOLS_TOKEN_OVERHEAD},
get_messages_token_counts_async,
},
message::Message,
token_counter::create_async_token_counter,
};
use anyhow::Result;
@@ -17,7 +18,7 @@ pub struct AutoCompactResult {
/// Whether compaction was performed
pub compacted: bool,
/// The messages after potential compaction
pub messages: Vec<Message>,
pub messages: Conversation,
/// Token count before compaction (if compaction occurred)
pub tokens_before: Option<usize>,
/// Token count after compaction (if compaction occurred)
@@ -140,7 +141,7 @@ pub async fn check_compaction_needed(
pub async fn perform_compaction(
agent: &Agent,
messages: &[Message],
) -> Result<(Vec<Message>, usize, usize)> {
) -> Result<(Conversation, usize, usize)> {
// Get token counter to measure before/after
let token_counter = create_async_token_counter()
.await
@@ -199,7 +200,7 @@ pub async fn check_and_compact_messages(
);
return Ok(AutoCompactResult {
compacted: false,
messages: messages.to_vec(),
messages: Conversation::new_unvalidated(messages.to_vec()),
tokens_before: None,
tokens_after: None,
});
@@ -243,9 +244,9 @@ pub async fn check_and_compact_messages(
#[cfg(test)]
mod tests {
use super::*;
use crate::conversation::message::{Message, MessageContent};
use crate::{
agents::Agent,
message::{Message, MessageContent},
model::ModelConfig,
providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage},
providers::errors::ProviderError,
+1 -1
View File
@@ -2,8 +2,8 @@ use std::sync::Arc;
use rmcp::model::Tool;
use crate::conversation::message::Message;
use crate::{
message::Message,
providers::base::Provider,
token_counter::{AsyncTokenCounter, TokenCounter},
};
+52 -42
View File
@@ -1,5 +1,7 @@
use super::common::{get_messages_token_counts, get_messages_token_counts_async};
use crate::message::Message;
use super::common::get_messages_token_counts_async;
use crate::context_mgmt::get_messages_token_counts;
use crate::conversation::message::Message;
use crate::conversation::Conversation;
use crate::prompt_template::render_global_file;
use crate::providers::base::Provider;
use crate::token_counter::{AsyncTokenCounter, TokenCounter};
@@ -23,13 +25,15 @@ async fn summarize_combined_messages(
provider: &Arc<dyn Provider>,
accumulated_summary: &[Message],
current_chunk: &[Message],
) -> Result<Vec<Message>, anyhow::Error> {
) -> Result<Conversation, anyhow::Error> {
// Combine the accumulated summary and current chunk into a single batch.
let combined_messages: Vec<Message> = accumulated_summary
.iter()
.cloned()
.chain(current_chunk.iter().cloned())
.collect();
let combined_messages = Conversation::new_unvalidated(
accumulated_summary
.iter()
.cloned()
.chain(current_chunk.iter().cloned())
.collect::<Vec<_>>(),
);
// Format the batch as a summarization request.
let request_text = format!(
@@ -47,7 +51,7 @@ async fn summarize_combined_messages(
response.role = Role::User;
// Return the summary as the new accumulated summary.
Ok(vec![response])
Ok(Conversation::new_unvalidated(vec![response]))
}
// Summarization steps:
@@ -57,10 +61,10 @@ pub async fn summarize_messages_oneshot(
messages: &[Message],
token_counter: &TokenCounter,
_context_limit: usize,
) -> Result<(Vec<Message>, Vec<usize>), anyhow::Error> {
) -> Result<(Conversation, Vec<usize>), anyhow::Error> {
if messages.is_empty() {
// If no messages to summarize, return empty
return Ok((vec![], vec![]));
return Ok((Conversation::empty(), vec![]));
}
// Format all messages as a single string for the summarization prompt
@@ -92,12 +96,10 @@ pub async fn summarize_messages_oneshot(
response.role = Role::User;
// Return just the summary without any tool response preservation
let final_summary = vec![response];
let final_summary = Conversation::new_unvalidated([response].into_iter());
let counts = get_messages_token_counts(token_counter, final_summary.messages());
Ok((
final_summary.clone(),
get_messages_token_counts(token_counter, &final_summary),
))
Ok((final_summary, counts))
}
// Summarization steps:
@@ -111,10 +113,10 @@ pub async fn summarize_messages_chunked(
messages: &[Message],
token_counter: &TokenCounter,
context_limit: usize,
) -> Result<(Vec<Message>, Vec<usize>), anyhow::Error> {
) -> Result<(Conversation, Vec<usize>), anyhow::Error> {
let chunk_size = context_limit / 3; // 33% of the context window.
let summary_prompt_tokens = token_counter.count_tokens(SUMMARY_PROMPT);
let mut accumulated_summary = Vec::new();
let mut accumulated_summary = Conversation::empty();
// Get token counts for each message.
let token_counts = get_messages_token_counts(token_counter, messages);
@@ -126,9 +128,12 @@ pub async fn summarize_messages_chunked(
for (message, message_tokens) in messages.iter().zip(token_counts.iter()) {
if current_chunk_tokens + message_tokens > chunk_size - summary_prompt_tokens {
// Summarize the current chunk with the accumulated summary.
accumulated_summary =
summarize_combined_messages(&provider, &accumulated_summary, &current_chunk)
.await?;
accumulated_summary = summarize_combined_messages(
&provider,
accumulated_summary.messages(),
&current_chunk,
)
.await?;
// Reset for the next chunk.
current_chunk.clear();
@@ -143,13 +148,14 @@ pub async fn summarize_messages_chunked(
// Summarize the final chunk if it exists.
if !current_chunk.is_empty() {
accumulated_summary =
summarize_combined_messages(&provider, &accumulated_summary, &current_chunk).await?;
summarize_combined_messages(&provider, accumulated_summary.messages(), &current_chunk)
.await?;
}
// Return just the summary without any tool response preservation
Ok((
accumulated_summary.clone(),
get_messages_token_counts(token_counter, &accumulated_summary),
get_messages_token_counts(token_counter, accumulated_summary.messages()),
))
}
@@ -164,7 +170,7 @@ pub async fn summarize_messages(
messages: &[Message],
token_counter: &TokenCounter,
context_limit: usize,
) -> Result<(Vec<Message>, Vec<usize>), anyhow::Error> {
) -> Result<(Conversation, Vec<usize>), anyhow::Error> {
// Calculate total tokens in messages
let total_tokens: usize = get_messages_token_counts(token_counter, messages)
.iter()
@@ -207,24 +213,27 @@ pub async fn summarize_messages_async(
messages: &[Message],
token_counter: &AsyncTokenCounter,
context_limit: usize,
) -> Result<(Vec<Message>, Vec<usize>), anyhow::Error> {
) -> Result<(Conversation, Vec<usize>), anyhow::Error> {
let chunk_size = context_limit / 3; // 33% of the context window.
let summary_prompt_tokens = token_counter.count_tokens(SUMMARY_PROMPT);
let mut accumulated_summary = Vec::new();
let mut accumulated_summary = Conversation::empty();
// Get token counts for each message.
let token_counts = get_messages_token_counts_async(token_counter, messages);
// Tokenize and break messages into chunks.
let mut current_chunk: Vec<Message> = Vec::new();
let mut current_chunk = Vec::new();
let mut current_chunk_tokens = 0;
for (message, message_tokens) in messages.iter().zip(token_counts.iter()) {
if current_chunk_tokens + message_tokens > chunk_size - summary_prompt_tokens {
// Summarize the current chunk with the accumulated summary.
accumulated_summary =
summarize_combined_messages(&provider, &accumulated_summary, &current_chunk)
.await?;
accumulated_summary = summarize_combined_messages(
&provider,
accumulated_summary.messages(),
&current_chunk,
)
.await?;
// Reset for the next chunk.
current_chunk.clear();
@@ -239,22 +248,22 @@ pub async fn summarize_messages_async(
// Summarize the final chunk if it exists.
if !current_chunk.is_empty() {
accumulated_summary =
summarize_combined_messages(&provider, &accumulated_summary, &current_chunk).await?;
summarize_combined_messages(&provider, accumulated_summary.messages(), &current_chunk)
.await?;
}
let count = get_messages_token_counts_async(token_counter, accumulated_summary.messages());
// Return just the summary without any tool response preservation
Ok((
accumulated_summary.clone(),
get_messages_token_counts_async(token_counter, &accumulated_summary),
))
Ok((accumulated_summary.clone(), count))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::{Message, MessageContent};
use crate::conversation::message::{Message, MessageContent};
use crate::model::ModelConfig;
use crate::providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage};
use crate::providers::base::{ProviderMetadata, ProviderUsage, Usage};
use crate::providers::errors::ProviderError;
use chrono::Utc;
use rmcp::model::Role;
@@ -343,7 +352,7 @@ mod tests {
"The summary should contain one message."
);
assert_eq!(
summarized_messages[0].role,
summarized_messages.first().unwrap().role,
Role::User,
"The summarized message should be from the user."
);
@@ -379,7 +388,7 @@ mod tests {
"There should be one final summarized message."
);
assert_eq!(
summarized_messages[0].role,
summarized_messages.first().unwrap().role,
Role::User,
"The summarized message should be from the user."
);
@@ -551,7 +560,8 @@ mod tests {
);
// Verify the content comes from the chunked approach
if let MessageContent::Text(text_content) = &summarized_messages[0].content[0] {
if let MessageContent::Text(text_content) = &summarized_messages.first().unwrap().content[0]
{
assert_eq!(text_content.text, "Chunked summary");
} else {
panic!("Expected text content");
@@ -592,7 +602,7 @@ mod tests {
"One-shot should return a single summary message."
);
assert_eq!(
summarized_messages[0].role,
summarized_messages.first().unwrap().role,
Role::User,
"Summary should be from user role for context."
);
@@ -630,7 +640,7 @@ mod tests {
"Chunked should return a single final summary."
);
assert_eq!(
summarized_messages[0].role,
summarized_messages.first().unwrap().role,
Role::User,
"Summary should be from user role for context."
);
+29 -21
View File
@@ -1,4 +1,5 @@
use crate::message::{Message, MessageContent};
use crate::conversation::message::{Message, MessageContent};
use crate::conversation::Conversation;
use crate::utils::safe_truncate;
use anyhow::{anyhow, Result};
use rmcp::model::{RawContent, ResourceContents, Role};
@@ -16,7 +17,7 @@ fn handle_oversized_messages(
token_counts: &[usize],
context_limit: usize,
strategy: &dyn TruncationStrategy,
) -> Result<(Vec<Message>, Vec<usize>), anyhow::Error> {
) -> Result<(Conversation, Vec<usize>), anyhow::Error> {
let mut truncated_messages = Vec::new();
let mut truncated_token_counts = Vec::new();
let mut any_truncated = false;
@@ -67,7 +68,10 @@ fn handle_oversized_messages(
);
}
Ok((truncated_messages, truncated_token_counts))
Ok((
Conversation::new_unvalidated(truncated_messages),
truncated_token_counts,
))
}
/// Truncates the content within a message while preserving its structure
@@ -180,7 +184,7 @@ pub fn truncate_messages(
token_counts: &[usize],
context_limit: usize,
strategy: &dyn TruncationStrategy,
) -> Result<(Vec<Message>, Vec<usize>), anyhow::Error> {
) -> Result<(Conversation, Vec<usize>), anyhow::Error> {
let mut messages = messages.to_owned();
let mut token_counts = token_counts.to_owned();
@@ -221,7 +225,10 @@ pub fn truncate_messages(
}
if total_tokens <= context_limit {
return Ok((messages, token_counts)); // No truncation needed
return Ok((
Conversation::new_unvalidated(messages.to_vec()),
token_counts.to_vec(),
)); // No truncation needed
}
// Step 2: Determine indices to remove based on strategy
@@ -303,7 +310,10 @@ pub fn truncate_messages(
}
debug!("Truncation complete. Total tokens: {}", total_tokens);
Ok((messages, token_counts))
Ok((
Conversation::new_unvalidated(messages.to_vec()),
token_counts.to_vec(),
))
}
/// Trait representing a truncation strategy
@@ -378,7 +388,7 @@ impl TruncationStrategy for OldestFirstTruncation {
#[cfg(test)]
mod tests {
use super::*;
use crate::message::Message;
use crate::conversation::message::Message;
use anyhow::Result;
use mcp_core::tool::ToolCall;
use rmcp::model::Content;
@@ -423,15 +433,13 @@ mod tests {
num_pairs: usize,
tokens: usize,
remove_last: bool,
) -> (Vec<Message>, Vec<usize>) {
let mut messages: Vec<Message> = (0..num_pairs)
.flat_map(|i| {
vec![
user_text(i * 2, tokens).0,
assistant_text((i * 2) + 1, tokens).0,
]
})
.collect();
) -> (Conversation, Vec<usize>) {
let mut messages = Conversation::new_unvalidated((0..num_pairs).flat_map(|i| {
vec![
user_text(i * 2, tokens).0,
assistant_text((i * 2) + 1, tokens).0,
]
}));
if remove_last {
messages.pop();
@@ -498,13 +506,13 @@ mod tests {
let context_limit = 25;
let result = truncate_messages(
&messages,
&messages.messages(),
&token_counts,
context_limit,
&OldestFirstTruncation,
)?;
assert_eq!(result.0, messages);
assert_eq!(result.0.messages(), messages.messages());
assert_eq!(result.1, token_counts);
Ok(())
}
@@ -582,7 +590,7 @@ mod tests {
let context_limit = 100; // Exactly matches total tokens
let result = truncate_messages(
&messages,
&messages.messages(),
&token_counts,
context_limit,
&OldestFirstTruncation,
@@ -597,7 +605,7 @@ mod tests {
token_counts.push(1);
let result = truncate_messages(
&messages,
&messages.messages(),
&token_counts,
context_limit,
&OldestFirstTruncation,
@@ -702,7 +710,7 @@ mod tests {
// Test impossibly small context window
let (messages, token_counts) = create_messages_with_counts(1, 10, false);
let result = truncate_messages(
&messages,
&messages.messages(),
&token_counts,
5, // Impossibly small context
&OldestFirstTruncation,
@@ -1,18 +1,8 @@
/// Messages which represent the content sent back and forth to LLM provider
///
/// We use these messages in the agent code, and interfaces which interact with
/// the agent. That let's us reuse message histories across different interfaces.
///
/// The content of the messages uses MCP types to avoid additional conversions
/// when interacting with MCP servers.
use chrono::Utc;
use mcp_core::handler::ToolResult;
use mcp_core::tool::ToolCall;
use rmcp::model::ResourceContents;
use rmcp::model::Role;
use mcp_core::{ToolCall, ToolResult};
use rmcp::model::{
AnnotateAble, Content, ImageContent, PromptMessage, PromptMessageContent, PromptMessageRole,
RawContent, RawImageContent, RawTextContent, TextContent,
RawContent, RawImageContent, RawTextContent, ResourceContents, Role, TextContent,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -20,7 +10,7 @@ use std::collections::HashSet;
use std::fmt;
use utoipa::ToSchema;
mod tool_result_serde;
use crate::conversation::tool_result_serde;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -371,26 +361,6 @@ impl fmt::Debug for Message {
}
}
pub fn push_message(messages: &mut Vec<Message>, message: Message) {
if let Some(last) = messages
.last_mut()
.filter(|m| m.id.is_some() && m.id == message.id)
{
match (last.content.last_mut(), message.content.last()) {
(Some(MessageContent::Text(ref mut last)), Some(MessageContent::Text(new)))
if message.content.len() == 1 =>
{
last.text.push_str(&new.text);
}
(_, _) => {
last.content.extend(message.content);
}
}
} else {
messages.push(message);
}
}
fn default_created() -> i64 {
0 // old messages do not have timestamps.
}
@@ -585,9 +555,14 @@ impl Message {
#[cfg(test)]
mod tests {
use super::*;
use crate::conversation::message::{Message, MessageContent};
use crate::conversation::*;
use mcp_core::handler::ToolError;
use rmcp::model::{PromptMessage, PromptMessageContent, RawEmbeddedResource, ResourceContents};
use mcp_core::ToolCall;
use rmcp::model::{
AnnotateAble, PromptMessage, PromptMessageContent, PromptMessageRole, RawEmbeddedResource,
RawImageContent, ResourceContents,
};
use serde_json::{json, Value};
#[test]
+534
View File
@@ -0,0 +1,534 @@
use crate::conversation::message::{Message, MessageContent};
use rmcp::model::Role;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use thiserror::Error;
pub mod message;
mod tool_result_serde;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conversation(Vec<Message>);
#[derive(Error, Debug)]
#[error("invalid conversation: {reason}")]
pub struct InvalidConversation {
reason: String,
conversation: Conversation,
}
impl Conversation {
pub fn new<I>(messages: I) -> Result<Self, InvalidConversation>
where
I: IntoIterator<Item = Message>,
{
Self::new_unvalidated(messages).validate()
}
pub fn new_unvalidated<I>(messages: I) -> Self
where
I: IntoIterator<Item = Message>,
{
Self(messages.into_iter().collect())
}
pub fn empty() -> Self {
Self::new_unvalidated([])
}
pub fn messages(&self) -> &Vec<Message> {
&self.0
}
pub fn push(&mut self, message: Message) {
if let Some(last) = self
.0
.last_mut()
.filter(|m| m.id.is_some() && m.id == message.id)
{
match (last.content.last_mut(), message.content.last()) {
(Some(MessageContent::Text(ref mut last)), Some(MessageContent::Text(new)))
if message.content.len() == 1 =>
{
last.text.push_str(&new.text);
}
(_, _) => {
last.content.extend(message.content);
}
}
} else {
self.0.push(message);
}
}
pub fn last(&self) -> Option<&Message> {
self.0.last()
}
pub fn first(&self) -> Option<&Message> {
self.0.first()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn extend<I>(&mut self, iter: I)
where
I: IntoIterator<Item = Message>,
{
for message in iter {
self.push(message);
}
}
pub fn iter(&self) -> std::slice::Iter<Message> {
self.0.iter()
}
pub fn pop(&mut self) -> Option<Message> {
self.0.pop()
}
pub fn truncate(&mut self, len: usize) {
self.0.truncate(len);
}
pub fn clear(&mut self) {
self.0.clear();
}
fn validate(self) -> Result<Self, InvalidConversation> {
let (_messages, issues) = fix_messages(self.0.clone());
if !issues.is_empty() {
let reason = issues.join("\n");
Err(InvalidConversation {
reason,
conversation: self,
})
} else {
Ok(self)
}
}
}
impl Default for Conversation {
fn default() -> Self {
Self::empty()
}
}
/// Fix a conversation that we're about to send to an LLM. So the last and first
/// messages should always be from the user.
pub fn fix_conversation(conversation: Conversation) -> (Conversation, Vec<String>) {
let messages = conversation.messages().clone();
let (messages, issues) = fix_messages(messages);
(Conversation::new_unvalidated(messages), issues)
}
fn fix_messages(messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
let (messages_1, empty_removed) = remove_empty_messages(messages);
let (messages_2, tool_calling_fixed) = fix_tool_calling(messages_1);
let (messages_3, messages_merged) = merge_consecutive_messages(messages_2);
let (messages_4, lead_trail_fixed) = fix_lead_trail(messages_3);
let (messages_5, populated_if_empty) = populate_if_empty(messages_4);
let mut issues = Vec::new();
issues.extend(empty_removed);
issues.extend(tool_calling_fixed);
issues.extend(messages_merged);
issues.extend(lead_trail_fixed);
issues.extend(populated_if_empty);
(messages_5, issues)
}
fn remove_empty_messages(messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
let mut issues = Vec::new();
let filtered_messages = messages
.into_iter()
.filter(|msg| {
if msg.content.is_empty() {
issues.push("Removed empty message".to_string());
false
} else {
true
}
})
.collect();
(filtered_messages, issues)
}
fn fix_tool_calling(mut messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
let mut issues = Vec::new();
let mut pending_tool_requests: HashSet<String> = HashSet::new();
for message in &mut messages {
let mut content_to_remove = Vec::new();
match message.role {
Role::User => {
for (idx, content) in message.content.iter().enumerate() {
match content {
MessageContent::ToolRequest(req) => {
content_to_remove.push(idx);
issues.push(format!(
"Removed tool request '{}' from user message",
req.id
));
}
MessageContent::ToolConfirmationRequest(req) => {
content_to_remove.push(idx);
issues.push(format!(
"Removed tool confirmation request '{}' from user message",
req.id
));
}
MessageContent::Thinking(_) | MessageContent::RedactedThinking(_) => {
content_to_remove.push(idx);
issues.push("Removed thinking content from user message".to_string());
}
MessageContent::ToolResponse(resp) => {
if pending_tool_requests.contains(&resp.id) {
pending_tool_requests.remove(&resp.id);
} else {
content_to_remove.push(idx);
issues
.push(format!("Removed orphaned tool response '{}'", resp.id));
}
}
_ => {}
}
}
}
Role::Assistant => {
for (idx, content) in message.content.iter().enumerate() {
match content {
MessageContent::ToolResponse(resp) => {
content_to_remove.push(idx);
issues.push(format!(
"Removed tool response '{}' from assistant message",
resp.id
));
}
MessageContent::FrontendToolRequest(req) => {
content_to_remove.push(idx);
issues.push(format!(
"Removed frontend tool request '{}' from assistant message",
req.id
));
}
MessageContent::ToolRequest(req) => {
pending_tool_requests.insert(req.id.clone());
}
_ => {}
}
}
}
}
for &idx in content_to_remove.iter().rev() {
message.content.remove(idx);
}
}
for message in &mut messages {
if message.role == Role::Assistant {
let mut content_to_remove = Vec::new();
for (idx, content) in message.content.iter().enumerate() {
if let MessageContent::ToolRequest(req) = content {
if pending_tool_requests.contains(&req.id) {
content_to_remove.push(idx);
issues.push(format!("Removed orphaned tool request '{}'", req.id));
}
}
}
for &idx in content_to_remove.iter().rev() {
message.content.remove(idx);
}
}
}
let (messages, empty_removed) = remove_empty_messages(messages);
issues.extend(empty_removed);
(messages, issues)
}
fn merge_consecutive_messages(messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
let mut issues = Vec::new();
let mut merged_messages: Vec<Message> = Vec::new();
for message in messages {
if let Some(last) = merged_messages.last_mut() {
let effective = effective_role(&message);
if effective_role(last) == effective {
last.content.extend(message.content);
issues.push(format!("Merged consecutive {} messages", effective));
continue;
}
}
merged_messages.push(message);
}
(merged_messages, issues)
}
fn has_tool_response(message: &Message) -> bool {
message
.content
.iter()
.any(|content| matches!(content, MessageContent::ToolResponse(_)))
}
fn effective_role(message: &Message) -> String {
if message.role == Role::User && has_tool_response(message) {
"tool".to_string()
} else {
match message.role {
Role::User => "user".to_string(),
Role::Assistant => "assistant".to_string(),
}
}
}
fn fix_lead_trail(mut messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
let mut issues = Vec::new();
if let Some(first) = messages.first() {
if first.role == Role::Assistant {
messages.remove(0);
issues.push("Removed leading assistant message".to_string());
}
}
if let Some(last) = messages.last() {
if last.role == Role::Assistant {
messages.pop();
issues.push("Removed trailing assistant message".to_string());
}
}
(messages, issues)
}
const PLACEHOLDER_USER_MESSAGE: &str = "Hello";
fn populate_if_empty(mut messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
let mut issues = Vec::new();
if messages.is_empty() {
issues.push("Added placeholder user message to empty conversation".to_string());
messages.push(Message::user().with_text(PLACEHOLDER_USER_MESSAGE));
}
(messages, issues)
}
pub fn debug_conversation_fix(
messages: &[Message],
fixed: &[Message],
issues: &[String],
) -> String {
let mut output = String::new();
output.push_str("=== CONVERSATION FIX DEBUG ===\n\n");
output.push_str("BEFORE:\n");
for (i, msg) in messages.iter().enumerate() {
output.push_str(&format!(" [{}] {}\n", i, msg.debug()));
}
output.push_str("\nISSUES FOUND:\n");
if issues.is_empty() {
output.push_str(" (none)\n");
} else {
for issue in issues {
output.push_str(&format!(" - {}\n", issue));
}
}
output.push_str("\nAFTER:\n");
for (i, msg) in fixed.iter().enumerate() {
output.push_str(&format!(" [{}] {}\n", i, msg.debug()));
}
output.push_str("\n==============================\n");
output
}
#[cfg(test)]
mod tests {
use crate::conversation::message::Message;
use crate::conversation::{debug_conversation_fix, fix_conversation, Conversation};
use mcp_core::tool::ToolCall;
use rmcp::model::Role;
use serde_json::json;
fn run_verify(messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
let (fixed, issues) = fix_conversation(Conversation::new_unvalidated(messages.clone()));
// Uncomment the following line to print the debug report
// let report = debug_conversation_fix(&messages, &fixed, &issues);
// print!("\n{}", report);
let (_fixed, issues_with_fixed) = fix_conversation(fixed.clone());
assert_eq!(
issues_with_fixed.len(),
0,
"Fixed conversation should have no issues, but found: {:?}\n\n{}",
issues_with_fixed,
debug_conversation_fix(&messages, &fixed.messages(), &issues)
);
(fixed.messages().clone(), issues)
}
#[test]
fn test_valid_conversation() {
let all_messages = vec![
Message::user().with_text("Can you help me search for something?"),
Message::assistant()
.with_text("I'll help you search.")
.with_tool_request(
"search_1",
Ok(ToolCall::new(
"web_search",
json!({"query": "rust programming"}),
)),
),
Message::user().with_tool_response("search_1", Ok(vec![])),
Message::assistant().with_text("Based on the search results, here's what I found..."),
];
for i in 1..=all_messages.len() {
let messages = Conversation::new_unvalidated(all_messages[..i].to_vec());
if messages.last().unwrap().role == Role::User {
let (fixed, issues) = fix_conversation(messages.clone());
assert_eq!(
fixed.len(),
messages.len(),
"Step {}: Length should match",
i
);
assert!(
issues.is_empty(),
"Step {}: Should have no issues, but found: {:?}",
i,
issues
);
assert_eq!(
fixed.messages(),
messages.messages(),
"Step {}: Messages should be unchanged",
i
);
}
}
}
#[test]
fn test_role_alternation_and_content_placement_issues() {
let messages = vec![
Message::user().with_text("Hello"),
Message::user().with_text("Another user message"),
Message::assistant()
.with_text("Response")
.with_tool_response("orphan_1", Ok(vec![])), // Wrong role
Message::assistant().with_thinking("Let me think", "sig"),
Message::user()
.with_tool_request("bad_req", Ok(ToolCall::new("search", json!({}))))
.with_text("User with bad tool request"),
];
let (fixed, issues) = run_verify(messages);
assert_eq!(fixed.len(), 3);
assert_eq!(issues.len(), 4);
assert!(issues
.iter()
.any(|i| i.contains("Merged consecutive user messages")));
assert!(issues
.iter()
.any(|i| i.contains("Removed tool response 'orphan_1' from assistant message")));
assert!(issues
.iter()
.any(|i| i.contains("Removed tool request 'bad_req' from user message")));
assert_eq!(fixed[0].role, Role::User);
assert_eq!(fixed[1].role, Role::Assistant);
assert_eq!(fixed[2].role, Role::User);
assert_eq!(fixed[0].content.len(), 2);
}
#[test]
fn test_orphaned_tools_and_empty_messages() {
// This conversation completely collapses. the first user message is invalid
// then we remove the empty user message and the wrong tool response
// then we collapse the assistant messages
// which we then remove because you can't end a conversation with an assistant message
let messages = vec![
Message::assistant()
.with_text("I'll search for you")
.with_tool_request("search_1", Ok(ToolCall::new("search", json!({})))),
Message::user(),
Message::user().with_tool_response("wrong_id", Ok(vec![])),
Message::assistant()
.with_tool_request("search_2", Ok(ToolCall::new("search", json!({})))),
];
let (fixed, issues) = run_verify(messages);
assert_eq!(fixed.len(), 1);
assert!(issues.iter().any(|i| i.contains("Removed empty message")));
assert!(issues
.iter()
.any(|i| i.contains("Removed orphaned tool response 'wrong_id'")));
assert_eq!(fixed[0].role, Role::User);
assert_eq!(fixed[0].as_concat_text(), "Hello");
}
#[test]
fn test_real_world_consecutive_assistant_messages() {
let conversation = Conversation::new_unvalidated(vec![
Message::user().with_text("run ls in the current directory and then run a word count on the smallest file"),
Message::assistant()
.with_text("I'll help you run `ls` in the current directory and then perform a word count on the smallest file. Let me start by listing the directory contents.")
.with_tool_request("toolu_bdrk_018adWbP4X26CfoJU5hkhu3i", Ok(ToolCall::new("developer__shell", json!({"command": "ls -la"})))),
Message::assistant()
.with_text("Now I'll identify the smallest file by size. Looking at the output, I can see that both `slack.yaml` and `subrecipes.yaml` have a size of 0 bytes, making them the smallest files. I'll run a word count on one of them:")
.with_tool_request("toolu_bdrk_01KgDYHs4fAodi22NqxRzmwx", Ok(ToolCall::new("developer__shell", json!({"command": "wc slack.yaml"})))),
Message::user()
.with_tool_response("toolu_bdrk_01KgDYHs4fAodi22NqxRzmwx", Ok(vec![])),
Message::assistant()
.with_text("I ran `ls -la` in the current directory and found several files. Looking at the file sizes, I can see that both `slack.yaml` and `subrecipes.yaml` are 0 bytes (the smallest files). I ran a word count on `slack.yaml` which shows: **0 lines**, **0 words**, **0 characters**"),
Message::user().with_text("thanks!"),
]);
let (fixed, issues) = fix_conversation(conversation);
assert_eq!(fixed.len(), 5);
assert_eq!(issues.len(), 2);
assert!(issues[0].contains("Removed orphaned tool request"));
assert!(issues[1].contains("Merged consecutive assistant messages"));
}
#[test]
fn test_tool_response_effective_role() {
let messages = vec![
Message::user().with_text("Search for something"),
Message::assistant()
.with_text("I'll search for you")
.with_tool_request("search_1", Ok(ToolCall::new("search", json!({})))),
Message::user().with_tool_response("search_1", Ok(vec![])),
Message::user().with_text("Thanks!"),
];
let (_fixed, issues) = run_verify(messages);
assert_eq!(issues.len(), 0);
}
}
-408
View File
@@ -1,408 +0,0 @@
use crate::message::{Message, MessageContent};
use rmcp::model::Role;
use std::collections::HashSet;
pub struct ConversationFixer;
const PLACEHOLDER_USER_MESSAGE: &str = "Hello";
impl ConversationFixer {
/// Fix a conversation that we're about to send to an LLM. So the last and first
/// messages should always be from the user.
pub fn fix_conversation(messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
let (messages_1, empty_removed) = Self::remove_empty_messages(messages);
let (messages_2, tool_calling_fixed) = Self::fix_tool_calling(messages_1);
let (messages_3, messages_merged) = Self::merge_consecutive_messages(messages_2);
let (messages_4, lead_trail_fixed) = Self::fix_lead_trail(messages_3);
let (messages_5, populated_if_empty) = Self::populate_if_empty(messages_4);
let mut issues = Vec::new();
issues.extend(empty_removed);
issues.extend(tool_calling_fixed);
issues.extend(messages_merged);
issues.extend(lead_trail_fixed);
issues.extend(populated_if_empty);
(messages_5, issues)
}
fn remove_empty_messages(messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
let mut issues = Vec::new();
let filtered_messages = messages
.into_iter()
.filter(|msg| {
if msg.content.is_empty() {
issues.push("Removed empty message".to_string());
false
} else {
true
}
})
.collect();
(filtered_messages, issues)
}
fn fix_tool_calling(mut messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
let mut issues = Vec::new();
let mut pending_tool_requests: HashSet<String> = HashSet::new();
for message in &mut messages {
let mut content_to_remove = Vec::new();
match message.role {
Role::User => {
for (idx, content) in message.content.iter().enumerate() {
match content {
MessageContent::ToolRequest(req) => {
content_to_remove.push(idx);
issues.push(format!(
"Removed tool request '{}' from user message",
req.id
));
}
MessageContent::ToolConfirmationRequest(req) => {
content_to_remove.push(idx);
issues.push(format!(
"Removed tool confirmation request '{}' from user message",
req.id
));
}
MessageContent::Thinking(_) | MessageContent::RedactedThinking(_) => {
content_to_remove.push(idx);
issues
.push("Removed thinking content from user message".to_string());
}
MessageContent::ToolResponse(resp) => {
if pending_tool_requests.contains(&resp.id) {
pending_tool_requests.remove(&resp.id);
} else {
content_to_remove.push(idx);
issues.push(format!(
"Removed orphaned tool response '{}'",
resp.id
));
}
}
_ => {}
}
}
}
Role::Assistant => {
for (idx, content) in message.content.iter().enumerate() {
match content {
MessageContent::ToolResponse(resp) => {
content_to_remove.push(idx);
issues.push(format!(
"Removed tool response '{}' from assistant message",
resp.id
));
}
MessageContent::FrontendToolRequest(req) => {
content_to_remove.push(idx);
issues.push(format!(
"Removed frontend tool request '{}' from assistant message",
req.id
));
}
MessageContent::ToolRequest(req) => {
pending_tool_requests.insert(req.id.clone());
}
_ => {}
}
}
}
}
for &idx in content_to_remove.iter().rev() {
message.content.remove(idx);
}
}
for message in &mut messages {
if message.role == Role::Assistant {
let mut content_to_remove = Vec::new();
for (idx, content) in message.content.iter().enumerate() {
if let MessageContent::ToolRequest(req) = content {
if pending_tool_requests.contains(&req.id) {
content_to_remove.push(idx);
issues.push(format!("Removed orphaned tool request '{}'", req.id));
}
}
}
for &idx in content_to_remove.iter().rev() {
message.content.remove(idx);
}
}
}
let (messages, empty_removed) = Self::remove_empty_messages(messages);
issues.extend(empty_removed);
(messages, issues)
}
fn merge_consecutive_messages(messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
let mut issues = Vec::new();
let mut merged_messages: Vec<Message> = Vec::new();
for message in messages {
if let Some(last) = merged_messages.last_mut() {
let effective = Self::effective_role(&message);
if Self::effective_role(last) == effective {
last.content.extend(message.content);
issues.push(format!("Merged consecutive {} messages", effective));
continue;
}
}
merged_messages.push(message);
}
(merged_messages, issues)
}
fn has_tool_response(message: &Message) -> bool {
message
.content
.iter()
.any(|content| matches!(content, MessageContent::ToolResponse(_)))
}
fn effective_role(message: &Message) -> String {
if message.role == Role::User && Self::has_tool_response(message) {
"tool".to_string()
} else {
match message.role {
Role::User => "user".to_string(),
Role::Assistant => "assistant".to_string(),
}
}
}
fn fix_lead_trail(mut messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
let mut issues = Vec::new();
if let Some(first) = messages.first() {
if first.role == Role::Assistant {
messages.remove(0);
issues.push("Removed leading assistant message".to_string());
}
}
if let Some(last) = messages.last() {
if last.role == Role::Assistant {
messages.pop();
issues.push("Removed trailing assistant message".to_string());
}
}
(messages, issues)
}
fn populate_if_empty(mut messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
let mut issues = Vec::new();
if messages.is_empty() {
issues.push("Added placeholder user message to empty conversation".to_string());
messages.push(Message::user().with_text(PLACEHOLDER_USER_MESSAGE));
}
(messages, issues)
}
}
pub fn debug_conversation_fix(
messages: &[Message],
fixed: &[Message],
issues: &[String],
) -> String {
let mut output = String::new();
output.push_str("=== CONVERSATION FIX DEBUG ===\n\n");
output.push_str("BEFORE:\n");
for (i, msg) in messages.iter().enumerate() {
output.push_str(&format!(" [{}] {}\n", i, msg.debug()));
}
output.push_str("\nISSUES FOUND:\n");
if issues.is_empty() {
output.push_str(" (none)\n");
} else {
for issue in issues {
output.push_str(&format!(" - {}\n", issue));
}
}
output.push_str("\nAFTER:\n");
for (i, msg) in fixed.iter().enumerate() {
output.push_str(&format!(" [{}] {}\n", i, msg.debug()));
}
output.push_str("\n==============================\n");
output
}
#[cfg(test)]
mod tests {
use super::*;
use mcp_core::tool::ToolCall;
use serde_json::json;
fn run_verify(messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
let (fixed, issues) = ConversationFixer::fix_conversation(messages.clone());
// Uncomment the following line to print the debug report
// let report = debug_conversation_fix(&messages, &fixed, &issues);
// print!("\n{}", report);
let (_fixed, issues_with_fixed) = ConversationFixer::fix_conversation(fixed.clone());
assert_eq!(
issues_with_fixed.len(),
0,
"Fixed conversation should have no issues, but found: {:?}\n\n{}",
issues_with_fixed,
debug_conversation_fix(&messages, &fixed, &issues)
);
(fixed, issues)
}
#[test]
fn test_valid_conversation() {
let all_messages = vec![
Message::user().with_text("Can you help me search for something?"),
Message::assistant()
.with_text("I'll help you search.")
.with_tool_request(
"search_1",
Ok(ToolCall::new(
"web_search",
json!({"query": "rust programming"}),
)),
),
Message::user().with_tool_response("search_1", Ok(vec![])),
Message::assistant().with_text("Based on the search results, here's what I found..."),
];
for i in 1..=all_messages.len() {
let messages = all_messages[..i].to_vec();
if messages.last().unwrap().role == Role::User {
let (fixed, issues) = ConversationFixer::fix_conversation(messages.clone());
assert_eq!(
fixed.len(),
messages.len(),
"Step {}: Length should match",
i
);
assert!(
issues.is_empty(),
"Step {}: Should have no issues, but found: {:?}",
i,
issues
);
assert_eq!(fixed, messages, "Step {}: Messages should be unchanged", i);
}
}
}
#[test]
fn test_role_alternation_and_content_placement_issues() {
let messages = vec![
Message::user().with_text("Hello"),
Message::user().with_text("Another user message"),
Message::assistant()
.with_text("Response")
.with_tool_response("orphan_1", Ok(vec![])), // Wrong role
Message::assistant().with_thinking("Let me think", "sig"),
Message::user()
.with_tool_request("bad_req", Ok(ToolCall::new("search", json!({}))))
.with_text("User with bad tool request"),
];
let (fixed, issues) = run_verify(messages);
assert_eq!(fixed.len(), 3);
assert_eq!(issues.len(), 4);
assert!(issues
.iter()
.any(|i| i.contains("Merged consecutive user messages")));
assert!(issues
.iter()
.any(|i| i.contains("Removed tool response 'orphan_1' from assistant message")));
assert!(issues
.iter()
.any(|i| i.contains("Removed tool request 'bad_req' from user message")));
assert_eq!(fixed[0].role, Role::User);
assert_eq!(fixed[1].role, Role::Assistant);
assert_eq!(fixed[2].role, Role::User);
assert_eq!(fixed[0].content.len(), 2);
}
#[test]
fn test_orphaned_tools_and_empty_messages() {
// This conversation completely collapses. the first user message is invalid
// then we remove the empty user message and the wrong tool response
// then we collapse the assistant messages
// which we then remove because you can't end a conversation with an assistant message
let messages = vec![
Message::assistant()
.with_text("I'll search for you")
.with_tool_request("search_1", Ok(ToolCall::new("search", json!({})))),
Message::user(),
Message::user().with_tool_response("wrong_id", Ok(vec![])),
Message::assistant()
.with_tool_request("search_2", Ok(ToolCall::new("search", json!({})))),
];
let (fixed, issues) = run_verify(messages);
assert_eq!(fixed.len(), 1);
assert!(issues.iter().any(|i| i.contains("Removed empty message")));
assert!(issues
.iter()
.any(|i| i.contains("Removed orphaned tool response 'wrong_id'")));
assert_eq!(fixed[0].role, Role::User);
assert_eq!(fixed[0].as_concat_text(), "Hello");
}
#[test]
fn test_real_world_consecutive_assistant_messages() {
let messages = vec![
Message::user().with_text("run ls in the current directory and then run a word count on the smallest file"),
Message::assistant()
.with_text("I'll help you run `ls` in the current directory and then perform a word count on the smallest file. Let me start by listing the directory contents.")
.with_tool_request("toolu_bdrk_018adWbP4X26CfoJU5hkhu3i", Ok(ToolCall::new("developer__shell", json!({"command": "ls -la"})))),
Message::assistant()
.with_text("Now I'll identify the smallest file by size. Looking at the output, I can see that both `slack.yaml` and `subrecipes.yaml` have a size of 0 bytes, making them the smallest files. I'll run a word count on one of them:")
.with_tool_request("toolu_bdrk_01KgDYHs4fAodi22NqxRzmwx", Ok(ToolCall::new("developer__shell", json!({"command": "wc slack.yaml"})))),
Message::user()
.with_tool_response("toolu_bdrk_01KgDYHs4fAodi22NqxRzmwx", Ok(vec![])),
Message::assistant()
.with_text("I ran `ls -la` in the current directory and found several files. Looking at the file sizes, I can see that both `slack.yaml` and `subrecipes.yaml` are 0 bytes (the smallest files). I ran a word count on `slack.yaml` which shows: **0 lines**, **0 words**, **0 characters**"),
Message::user().with_text("thanks!"),
];
let (fixed, issues) = ConversationFixer::fix_conversation(messages);
assert_eq!(fixed.len(), 5);
assert_eq!(issues.len(), 2);
assert!(issues[0].contains("Removed orphaned tool request"));
assert!(issues[1].contains("Merged consecutive assistant messages"));
}
#[test]
fn test_tool_response_effective_role() {
let messages = vec![
Message::user().with_text("Search for something"),
Message::assistant()
.with_text("I'll search for you")
.with_tool_request("search_1", Ok(ToolCall::new("search", json!({})))),
Message::user().with_tool_response("search_1", Ok(vec![])),
Message::user().with_text("Thanks!"),
];
let (fixed, issues) = run_verify(messages);
assert_eq!(issues.len(), 0);
}
}
+1 -2
View File
@@ -1,8 +1,7 @@
pub mod agents;
pub mod config;
pub mod context_mgmt;
mod conversation_fixer;
pub mod message;
pub mod conversation;
pub mod model;
pub mod oauth;
pub mod permission;
@@ -1,7 +1,8 @@
use crate::agents::platform_tools::PLATFORM_MANAGE_EXTENSIONS_TOOL_NAME;
use crate::config::permission::PermissionLevel;
use crate::config::PermissionManager;
use crate::message::{Message, MessageContent, ToolRequest};
use crate::conversation::message::{Message, MessageContent, ToolRequest};
use crate::conversation::Conversation;
use crate::providers::base::Provider;
use chrono::Utc;
use indoc::indoc;
@@ -68,7 +69,7 @@ fn create_read_only_tool() -> Tool {
}
/// Builds the message to be sent to the LLM for detecting read-only operations.
fn create_check_messages(tool_requests: Vec<&ToolRequest>) -> Vec<Message> {
fn create_check_messages(tool_requests: Vec<&ToolRequest>) -> Conversation {
let tool_names: Vec<String> = tool_requests
.iter()
.filter_map(|req| {
@@ -93,7 +94,7 @@ fn create_check_messages(tool_requests: Vec<&ToolRequest>) -> Vec<Message> {
tool_names.join(", "),
))],
));
check_messages
Conversation::new_unvalidated(check_messages)
}
/// Processes the response to extract the list of tools with read-only operations.
@@ -135,7 +136,7 @@ pub async fn detect_read_only_tools(
let res = provider
.complete(
"You are a good analyst and can detect operations whether they have read-only operations.",
&check_messages,
check_messages.messages(),
&[tool.clone()],
)
.await;
@@ -260,7 +261,7 @@ pub async fn check_tool_permissions(
#[cfg(test)]
mod tests {
use super::*;
use crate::message::{Message, MessageContent, ToolRequest};
use crate::conversation::message::{Message, MessageContent, ToolRequest};
use crate::model::ModelConfig;
use crate::providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage};
use crate::providers::errors::ProviderError;
@@ -340,7 +341,7 @@ mod tests {
let messages = create_check_messages(vec![&tool_request]);
assert_eq!(messages.len(), 1);
let content = &messages[0].content[0];
let content = &messages.first().unwrap().content[0];
if let MessageContent::Text(text_content) = content {
assert!(text_content
.text
@@ -1,4 +1,4 @@
use crate::message::ToolRequest;
use crate::conversation::message::ToolRequest;
use anyhow::Result;
use blake3::Hasher;
use chrono::Utc;
+1 -1
View File
@@ -15,8 +15,8 @@ use super::formats::anthropic::{
create_request, get_usage, response_to_message, response_to_streaming_message,
};
use super::utils::{emit_debug_trace, get_model, map_http_error_to_provider_error};
use crate::conversation::message::Message;
use crate::impl_provider_default;
use crate::message::Message;
use crate::model::ModelConfig;
use crate::providers::retry::ProviderRetry;
use rmcp::model::Tool;
+1 -1
View File
@@ -10,8 +10,8 @@ use super::errors::ProviderError;
use super::formats::openai::{create_request, get_usage, response_to_message};
use super::retry::ProviderRetry;
use super::utils::{emit_debug_trace, get_model, handle_response_openai_compat, ImageFormat};
use crate::conversation::message::Message;
use crate::impl_provider_default;
use crate::message::Message;
use crate::model::ModelConfig;
use rmcp::model::Tool;
+7 -3
View File
@@ -4,7 +4,8 @@ use serde::{Deserialize, Serialize};
use super::errors::ProviderError;
use super::retry::RetryConfig;
use crate::message::Message;
use crate::conversation::message::Message;
use crate::conversation::Conversation;
use crate::model::ModelConfig;
use crate::utils::safe_truncate;
use rmcp::model::Tool;
@@ -370,7 +371,7 @@ pub trait Provider: Send + Sync {
}
/// Returns the first 3 user messages as strings for session naming
fn get_initial_user_messages(&self, messages: &[Message]) -> Vec<String> {
fn get_initial_user_messages(&self, messages: &Conversation) -> Vec<String> {
messages
.iter()
.filter(|m| m.role == rmcp::model::Role::User)
@@ -381,7 +382,10 @@ pub trait Provider: Send + Sync {
/// Generate a session name/description based on the conversation history
/// Creates a prompt asking for a concise description in 4 words or less.
async fn generate_session_name(&self, messages: &[Message]) -> Result<String, ProviderError> {
async fn generate_session_name(
&self,
messages: &Conversation,
) -> Result<String, ProviderError> {
let context = self.get_initial_user_messages(messages);
let prompt = self.create_session_name_prompt(&context);
let message = Message::user().with_text(&prompt);
+1 -1
View File
@@ -3,8 +3,8 @@ use std::collections::HashMap;
use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage};
use super::errors::ProviderError;
use super::retry::ProviderRetry;
use crate::conversation::message::Message;
use crate::impl_provider_default;
use crate::message::Message;
use crate::model::ModelConfig;
use crate::providers::utils::emit_debug_trace;
use anyhow::Result;
+1 -1
View File
@@ -11,8 +11,8 @@ use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage};
use super::errors::ProviderError;
use super::utils::emit_debug_trace;
use crate::config::Config;
use crate::conversation::message::{Message, MessageContent};
use crate::impl_provider_default;
use crate::message::{Message, MessageContent};
use crate::model::ModelConfig;
use rmcp::model::Tool;
+1 -1
View File
@@ -18,8 +18,8 @@ use super::oauth;
use super::retry::ProviderRetry;
use super::utils::{get_model, handle_response_openai_compat, ImageFormat};
use crate::config::ConfigError;
use crate::conversation::message::Message;
use crate::impl_provider_default;
use crate::message::Message;
use crate::model::ModelConfig;
use crate::providers::formats::openai::{get_usage, response_to_streaming_message};
use crate::providers::retry::{
+1 -1
View File
@@ -174,7 +174,7 @@ fn create_provider(name: &str, model: ModelConfig) -> Result<Arc<dyn Provider>>
#[cfg(test)]
mod tests {
use super::*;
use crate::message::{Message, MessageContent};
use crate::conversation::message::{Message, MessageContent};
use crate::providers::base::{ProviderMetadata, ProviderUsage, Usage};
use chrono::Utc;
use rmcp::model::{AnnotateAble, RawTextContent, Role};
@@ -1,4 +1,4 @@
use crate::message::{Message, MessageContent};
use crate::conversation::message::{Message, MessageContent};
use crate::model::ModelConfig;
use crate::providers::base::Usage;
use crate::providers::errors::ProviderError;
@@ -676,6 +676,7 @@ where
#[cfg(test)]
mod tests {
use super::*;
use crate::conversation::message::Message;
use rmcp::object;
use serde_json::json;
@@ -983,6 +984,7 @@ mod tests {
#[test]
fn test_tool_error_handling_maintains_pairing() {
use crate::conversation::message::Message;
use mcp_core::handler::ToolError;
let messages = vec![
@@ -11,7 +11,7 @@ use rmcp::model::{Content, RawContent, ResourceContents, Role, Tool};
use serde_json::Value;
use super::super::base::Usage;
use crate::message::{Message, MessageContent};
use crate::conversation::message::{Message, MessageContent};
pub fn to_bedrock_message(message: &Message) -> Result<bedrock::Message> {
bedrock::Message::builder()
@@ -1,4 +1,4 @@
use crate::message::{Message, MessageContent};
use crate::conversation::message::{Message, MessageContent};
use crate::model::ModelConfig;
use crate::providers::utils::{
convert_image, detect_image_path, is_valid_function_name, load_image_file, safely_parse_json,
@@ -9,6 +9,7 @@ use mcp_core::{ToolCall, ToolError};
use rmcp::model::{AnnotateAble, Content, RawContent, ResourceContents, Role, Tool};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Serialize)]
struct DatabricksMessage {
content: Value,
@@ -616,6 +617,7 @@ pub fn create_request(
#[cfg(test)]
mod tests {
use super::*;
use crate::conversation::message::Message;
use rmcp::object;
use serde_json::json;
@@ -1,5 +1,5 @@
use super::{anthropic, google};
use crate::message::Message;
use crate::conversation::message::Message;
use crate::model::ModelConfig;
use crate::providers::base::Usage;
use anyhow::{Context, Result};
+2 -1
View File
@@ -1,4 +1,3 @@
use crate::message::{Message, MessageContent};
use crate::model::ModelConfig;
use crate::providers::base::Usage;
use crate::providers::errors::ProviderError;
@@ -8,6 +7,7 @@ use mcp_core::tool::ToolCall;
use rand::{distributions::Alphanumeric, Rng};
use rmcp::model::{AnnotateAble, RawContent, Role, Tool};
use crate::conversation::message::{Message, MessageContent};
use serde_json::{json, Map, Value};
use std::ops::Deref;
@@ -335,6 +335,7 @@ pub fn create_request(
#[cfg(test)]
mod tests {
use super::*;
use crate::conversation::message::Message;
use rmcp::{model::Content, object};
use serde_json::json;
+2 -1
View File
@@ -1,4 +1,4 @@
use crate::message::{Message, MessageContent};
use crate::conversation::message::{Message, MessageContent};
use crate::model::ModelConfig;
use crate::providers::base::{ProviderUsage, Usage};
use crate::providers::utils::{
@@ -641,6 +641,7 @@ pub fn create_request(
#[cfg(test)]
mod tests {
use super::*;
use crate::conversation::message::Message;
use rmcp::object;
use serde_json::json;
use tokio::pin;
@@ -1,4 +1,4 @@
use crate::message::{Message, MessageContent};
use crate::conversation::message::{Message, MessageContent};
use crate::model::ModelConfig;
use crate::providers::base::Usage;
use crate::providers::errors::ProviderError;
@@ -359,6 +359,7 @@ pub fn create_request(
#[cfg(test)]
mod tests {
use super::*;
use crate::conversation::message::Message;
use rmcp::object;
use serde_json::json;
@@ -546,6 +547,7 @@ data: {"id":"a9537c2c-2017-4906-9817-2456168d89fa","model":"claude-3-5-sonnet","
#[test]
fn test_create_request_format() -> Result<()> {
use crate::conversation::message::Message;
use crate::model::ModelConfig;
let model_config = ModelConfig::new_or_fail("claude-3-5-sonnet");
@@ -654,6 +656,7 @@ data: {"id":"a9537c2c-2017-4906-9817-2456168d89fa","model":"claude-3-5-sonnet","
#[test]
fn test_create_request_excludes_tools_for_description() -> Result<()> {
use crate::conversation::message::Message;
use crate::model::ModelConfig;
let model_config = ModelConfig::new_or_fail("claude-3-5-sonnet");
@@ -675,6 +678,7 @@ data: {"id":"a9537c2c-2017-4906-9817-2456168d89fa","model":"claude-3-5-sonnet","
#[test]
fn test_message_formatting_skips_tool_requests() {
use crate::conversation::message::Message;
use mcp_core::tool::ToolCall;
// Create a conversation with text, tool requests, and tool responses
+1 -1
View File
@@ -8,7 +8,7 @@ use serde_json::Value;
use tokio::time::sleep;
use url::Url;
use crate::message::Message;
use crate::conversation::message::Message;
use crate::model::ModelConfig;
use crate::providers::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage};
+1 -1
View File
@@ -9,8 +9,8 @@ use tokio::process::Command;
use super::base::{Provider, ProviderMetadata, ProviderUsage, Usage};
use super::errors::ProviderError;
use super::utils::emit_debug_trace;
use crate::conversation::message::{Message, MessageContent};
use crate::impl_provider_default;
use crate::message::{Message, MessageContent};
use crate::model::ModelConfig;
use rmcp::model::Role;
use rmcp::model::Tool;
+1 -1
View File
@@ -18,8 +18,8 @@ use super::retry::ProviderRetry;
use super::utils::{emit_debug_trace, get_model, handle_response_openai_compat, ImageFormat};
use crate::config::{Config, ConfigError};
use crate::conversation::message::Message;
use crate::impl_provider_default;
use crate::message::Message;
use crate::model::ModelConfig;
use crate::providers::base::ConfigKey;
use rmcp::model::Tool;
+1 -1
View File
@@ -2,8 +2,8 @@ use super::api_client::{ApiClient, AuthMethod};
use super::errors::ProviderError;
use super::retry::ProviderRetry;
use super::utils::{emit_debug_trace, handle_response_google_compat, unescape_json_values};
use crate::conversation::message::Message;
use crate::impl_provider_default;
use crate::message::Message;
use crate::model::ModelConfig;
use crate::providers::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage};
use crate::providers::formats::google::{create_request, get_usage, response_to_message};
+1 -1
View File
@@ -2,8 +2,8 @@ use super::api_client::{ApiClient, AuthMethod};
use super::errors::ProviderError;
use super::retry::ProviderRetry;
use super::utils::{get_model, handle_response_openai_compat};
use crate::conversation::message::Message;
use crate::impl_provider_default;
use crate::message::Message;
use crate::model::ModelConfig;
use crate::providers::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage};
use crate::providers::formats::openai::{create_request, get_usage, response_to_message};
+2 -2
View File
@@ -6,7 +6,7 @@ use tokio::sync::Mutex;
use super::base::{LeadWorkerProviderTrait, Provider, ProviderMetadata, ProviderUsage};
use super::errors::ProviderError;
use crate::message::{Message, MessageContent};
use crate::conversation::message::{Message, MessageContent};
use crate::model::ModelConfig;
use rmcp::model::Tool;
use rmcp::model::{Content, RawContent};
@@ -454,7 +454,7 @@ impl Provider for LeadWorkerProvider {
#[cfg(test)]
mod tests {
use super::*;
use crate::message::MessageContent;
use crate::conversation::message::{Message, MessageContent};
use crate::providers::base::{ProviderMetadata, ProviderUsage, Usage};
use chrono::Utc;
use rmcp::model::{AnnotateAble, RawTextContent, Role};
+1 -1
View File
@@ -9,8 +9,8 @@ use super::embedding::EmbeddingCapable;
use super::errors::ProviderError;
use super::retry::ProviderRetry;
use super::utils::{emit_debug_trace, get_model, handle_response_openai_compat, ImageFormat};
use crate::conversation::message::Message;
use crate::impl_provider_default;
use crate::message::Message;
use crate::model::ModelConfig;
use rmcp::model::Tool;
+6 -2
View File
@@ -3,8 +3,9 @@ use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage};
use super::errors::ProviderError;
use super::retry::ProviderRetry;
use super::utils::{get_model, handle_response_openai_compat};
use crate::conversation::message::Message;
use crate::conversation::Conversation;
use crate::impl_provider_default;
use crate::message::Message;
use crate::model::ModelConfig;
use crate::providers::formats::openai::{create_request, get_usage, response_to_message};
use crate::utils::safe_truncate;
@@ -159,7 +160,10 @@ impl Provider for OllamaProvider {
/// Generate a session name based on the conversation history
/// This override filters out reasoning tokens that some Ollama models produce
async fn generate_session_name(&self, messages: &[Message]) -> Result<String, ProviderError> {
async fn generate_session_name(
&self,
messages: &Conversation,
) -> Result<String, ProviderError> {
let context = self.get_initial_user_messages(messages);
let message = Message::user().with_text(self.create_session_name_prompt(&context));
let result = self
+1 -1
View File
@@ -20,8 +20,8 @@ use super::utils::{
emit_debug_trace, get_model, handle_response_openai_compat, handle_status_openai_compat,
ImageFormat,
};
use crate::conversation::message::Message;
use crate::impl_provider_default;
use crate::message::Message;
use crate::model::ModelConfig;
use crate::providers::base::MessageStream;
use crate::providers::formats::openai::response_to_streaming_message;
+1 -1
View File
@@ -10,8 +10,8 @@ use super::utils::{
emit_debug_trace, get_model, handle_response_google_compat, handle_response_openai_compat,
is_google_model,
};
use crate::conversation::message::Message;
use crate::impl_provider_default;
use crate::message::Message;
use crate::model::ModelConfig;
use crate::providers::formats::openai::{create_request, get_usage, response_to_message};
use rmcp::model::Tool;
+1 -1
View File
@@ -13,8 +13,8 @@ use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage};
use super::errors::ProviderError;
use super::retry::ProviderRetry;
use super::utils::emit_debug_trace;
use crate::conversation::message::{Message, MessageContent};
use crate::impl_provider_default;
use crate::message::{Message, MessageContent};
use crate::model::ModelConfig;
use chrono::Utc;
use rmcp::model::Role;
+1 -1
View File
@@ -10,8 +10,8 @@ use super::formats::snowflake::{create_request, get_usage, response_to_message};
use super::retry::ProviderRetry;
use super::utils::{get_model, map_http_error_to_provider_error, ImageFormat};
use crate::config::ConfigError;
use crate::conversation::message::Message;
use crate::impl_provider_default;
use crate::message::Message;
use crate::model::ModelConfig;
use rmcp::model::Tool;
+2 -2
View File
@@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex};
use super::base::{Provider, ProviderMetadata, ProviderUsage};
use super::errors::ProviderError;
use crate::message::Message;
use crate::conversation::message::Message;
use crate::model::ModelConfig;
use rmcp::model::Tool;
@@ -162,7 +162,7 @@ impl Provider for TestProvider {
#[cfg(test)]
mod tests {
use super::*;
use crate::message::{Message, MessageContent};
use crate::conversation::message::{Message, MessageContent};
use crate::providers::base::{ProviderUsage, Usage};
use chrono::Utc;
use rmcp::model::{RawTextContent, Role, TextContent};
+7 -4
View File
@@ -33,7 +33,8 @@
use super::errors::ProviderError;
use super::ollama::OLLAMA_DEFAULT_PORT;
use super::ollama::OLLAMA_HOST;
use crate::message::{Message, MessageContent};
use crate::conversation::message::{Message, MessageContent};
use crate::conversation::Conversation;
use crate::model::ModelConfig;
use crate::providers::formats::openai::create_request;
use anyhow::Result;
@@ -310,8 +311,8 @@ pub fn format_tool_info(tools: &[Tool]) -> String {
/// Convert messages containing ToolRequest/ToolResponse to text messages for toolshim mode
/// This is necessary because some providers (like Bedrock) validate that tool_use/tool_result
/// blocks can only exist when tools are defined, but in toolshim mode we pass empty tools
pub fn convert_tool_messages_to_text(messages: &[Message]) -> Vec<Message> {
messages
pub fn convert_tool_messages_to_text(messages: &[Message]) -> Conversation {
let converted_messages: Vec<Message> = messages
.iter()
.map(|message| {
let mut new_content = Vec::new();
@@ -366,7 +367,9 @@ pub fn convert_tool_messages_to_text(messages: &[Message]) -> Vec<Message> {
message.clone()
}
})
.collect()
.collect();
Conversation::new_unvalidated(converted_messages)
}
/// Modifies the system prompt to include tool usage instructions when tool interpretation is enabled
+1 -1
View File
@@ -9,8 +9,8 @@ use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage};
use super::errors::ProviderError;
use super::retry::ProviderRetry;
use super::utils::map_http_error_to_provider_error;
use crate::conversation::message::{Message, MessageContent};
use crate::impl_provider_default;
use crate::message::{Message, MessageContent};
use crate::model::ModelConfig;
use mcp_core::{ToolCall, ToolResult};
use rmcp::model::{Role, Tool};
+1 -1
View File
@@ -2,8 +2,8 @@ use super::api_client::{ApiClient, AuthMethod};
use super::errors::ProviderError;
use super::retry::ProviderRetry;
use super::utils::{get_model, handle_response_openai_compat};
use crate::conversation::message::Message;
use crate::impl_provider_default;
use crate::message::Message;
use crate::model::ModelConfig;
use crate::providers::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage};
use crate::providers::formats::openai::{create_request, get_usage, response_to_message};
+15 -8
View File
@@ -15,7 +15,8 @@ use tokio_cron_scheduler::{job::JobId, Job, JobScheduler as TokioJobScheduler};
use crate::agents::AgentEvent;
use crate::agents::{Agent, SessionConfig};
use crate::config::{self, Config};
use crate::message::Message;
use crate::conversation::message::Message;
use crate::conversation::Conversation;
use crate::providers::base::Provider as GooseProvider; // Alias to avoid conflict in test section
use crate::providers::create;
use crate::recipe::Recipe;
@@ -1201,8 +1202,8 @@ async fn run_scheduled_job_internal(
};
if let Some(prompt_text) = recipe.prompt {
let mut all_session_messages: Vec<Message> =
vec![Message::user().with_text(prompt_text.clone())];
let mut all_session_messages =
Conversation::new_unvalidated(vec![Message::user().with_text(prompt_text.clone())]);
let current_dir = match std::env::current_dir() {
Ok(cd) => cd,
@@ -1224,7 +1225,11 @@ async fn run_scheduled_job_internal(
};
match agent
.reply(&all_session_messages, Some(session_config.clone()), None)
.reply(
all_session_messages.clone(),
Some(session_config.clone()),
None,
)
.await
{
Ok(mut stream) => {
@@ -1325,9 +1330,11 @@ async fn run_scheduled_job_internal(
message_count: 0,
..Default::default()
};
if let Err(e) =
crate::session::storage::save_messages_with_metadata(&session_file_path, &metadata, &[])
{
if let Err(e) = crate::session::storage::save_messages_with_metadata(
&session_file_path,
&metadata,
&Conversation::new_unvalidated(vec![]),
) {
tracing::error!(
"[Job {}] Failed to persist metadata for empty job: {}",
job.id,
@@ -1345,7 +1352,6 @@ mod tests {
use super::*;
use crate::recipe::Recipe;
use crate::{
message::MessageContent,
model::ModelConfig, // Use the actual ModelConfig for the mock's field
providers::base::{ProviderMetadata, ProviderUsage, Usage},
providers::errors::ProviderError,
@@ -1356,6 +1362,7 @@ mod tests {
// `read_metadata` is still used by the test itself, so keep it or its module.
use crate::session::storage::read_metadata;
use crate::conversation::message::{Message, MessageContent};
use std::env;
use std::fs::{self, File};
use std::io::Write;
+35 -27
View File
@@ -5,7 +5,8 @@
// - Backup creation
// Additional debug logging can be added if needed for troubleshooting.
use crate::message::Message;
use crate::conversation::message::Message;
use crate::conversation::Conversation;
use crate::providers::base::Provider;
use crate::utils::safe_truncate;
use anyhow::Result;
@@ -399,7 +400,7 @@ pub fn generate_session_id() -> String {
/// Security features:
/// - Validates file paths to prevent directory traversal
/// - Includes all security limits from read_messages_with_truncation
pub fn read_messages(session_file: &Path) -> Result<Vec<Message>> {
pub fn read_messages(session_file: &Path) -> Result<Conversation> {
// Validate the path for security
let secure_path = get_path(Identifier::Path(session_file.to_path_buf()))?;
@@ -428,7 +429,7 @@ pub fn read_messages(session_file: &Path) -> Result<Vec<Message>> {
pub fn read_messages_with_truncation(
session_file: &Path,
max_content_size: Option<usize>,
) -> Result<Vec<Message>> {
) -> Result<Conversation> {
// Security check: file size limit
if session_file.exists() {
let metadata = fs::metadata(session_file)?;
@@ -626,7 +627,7 @@ pub fn read_messages_with_truncation(
}
}
Ok(messages)
Ok(Conversation::new_unvalidated(messages))
}
/// Parse a message from JSON string with optional content truncation
@@ -685,7 +686,7 @@ fn parse_message_with_truncation(
/// Truncate content within a message in place
fn truncate_message_content_in_place(message: &mut Message, max_content_size: usize) {
use crate::message::MessageContent;
use crate::conversation::message::MessageContent;
use rmcp::model::{RawContent, ResourceContents};
for content in &mut message.content {
@@ -1051,7 +1052,7 @@ pub fn read_metadata(session_file: &Path) -> Result<SessionMetadata> {
/// - Validates file paths to prevent directory traversal
pub async fn persist_messages(
session_file: &Path,
messages: &[Message],
messages: &Conversation,
provider: Option<Arc<dyn Provider>>,
working_dir: Option<PathBuf>,
) -> Result<()> {
@@ -1069,7 +1070,7 @@ pub async fn persist_messages(
/// - Uses atomic file operations via save_messages_with_metadata
pub async fn persist_messages_with_schedule_id(
session_file: &Path,
messages: &[Message],
messages: &Conversation,
provider: Option<Arc<dyn Provider>>,
schedule_id: Option<String>,
working_dir: Option<PathBuf>,
@@ -1144,7 +1145,7 @@ pub async fn persist_messages_with_schedule_id(
pub fn save_messages_with_metadata(
session_file: &Path,
metadata: &SessionMetadata,
messages: &[Message],
messages: &Conversation,
) -> Result<()> {
use fs2::FileExt;
@@ -1257,7 +1258,7 @@ pub fn save_messages_with_metadata(
/// of the session based on the conversation history.
pub async fn generate_description(
session_file: &Path,
messages: &[Message],
messages: &Conversation,
provider: Arc<dyn Provider>,
working_dir: Option<PathBuf>,
) -> Result<()> {
@@ -1275,7 +1276,7 @@ pub async fn generate_description(
/// - Uses secure file operations for saving
pub async fn generate_description_with_schedule_id(
session_file: &Path,
messages: &[Message],
messages: &Conversation,
provider: Arc<dyn Provider>,
schedule_id: Option<String>,
working_dir: Option<PathBuf>,
@@ -1346,7 +1347,7 @@ pub async fn update_metadata(session_file: &Path, metadata: &SessionMetadata) ->
#[cfg(test)]
mod tests {
use super::*;
use crate::message::MessageContent;
use crate::conversation::message::{Message, MessageContent};
use tempfile::tempdir;
#[test]
@@ -1428,10 +1429,10 @@ mod tests {
let file_path = dir.path().join("test.jsonl");
// Create some test messages
let messages = vec![
let messages = Conversation::new_unvalidated(vec![
Message::user().with_text("Hello"),
Message::assistant().with_text("Hi there"),
];
]);
// Write messages
persist_messages(&file_path, &messages, None, None).await?;
@@ -1535,7 +1536,7 @@ mod tests {
"}]",
];
let mut messages = Vec::new();
let mut messages = Conversation::empty();
for text in special_chars {
messages.push(Message::user().with_text(text));
messages.push(Message::assistant().with_text(text));
@@ -1601,10 +1602,10 @@ mod tests {
// Create a message with content larger than the 50KB truncation limit
let very_large_text = "A".repeat(100_000); // 100KB of text
let messages = vec![
let messages = Conversation::new_unvalidated(vec![
Message::user().with_text(&very_large_text),
Message::assistant().with_text("Small response"),
];
]);
// Write messages
persist_messages(&file_path, &messages, None, None).await?;
@@ -1615,7 +1616,9 @@ mod tests {
assert_eq!(messages.len(), read_messages.len());
// First message should be truncated
if let Some(MessageContent::Text(read_text)) = read_messages[0].content.first() {
if let Some(MessageContent::Text(read_text)) =
read_messages.first().unwrap().content.first()
{
assert!(
read_text.text.len() < very_large_text.len(),
"Content should be truncated"
@@ -1635,7 +1638,7 @@ mod tests {
}
// Second message should be unchanged
if let Some(MessageContent::Text(read_text)) = read_messages[1].content.first() {
if let Some(MessageContent::Text(read_text)) = read_messages.messages()[1].content.first() {
assert_eq!(read_text.text, "Small response");
} else {
panic!("Expected text content in second message");
@@ -1652,7 +1655,7 @@ mod tests {
let mut metadata = SessionMetadata::default();
metadata.description = "Description with\nnewline and \"quotes\" and 🦆".to_string();
let messages = vec![Message::user().with_text("test")];
let messages = Conversation::new_unvalidated(vec![Message::user().with_text("test")]);
// Write with special metadata
save_messages_with_metadata(&file_path, &metadata, &messages)?;
@@ -1679,7 +1682,7 @@ mod tests {
assert_eq!(metadata.working_dir, get_home_dir());
// Test deserialization of invalid directory
let messages = vec![Message::user().with_text("test")];
let messages = Conversation::new_unvalidated(vec![Message::user().with_text("test")]);
save_messages_with_metadata(&file_path, &metadata, &messages)?;
// Modify the file to include invalid directory
@@ -1709,7 +1712,8 @@ mod tests {
let working_dir_path = working_dir.path().to_path_buf();
// Create messages
let messages = vec![Message::user().with_text("test message")];
let messages =
Conversation::new_unvalidated(vec![Message::user().with_text("test message")]);
// Use persist_messages_with_schedule_id to set working dir
persist_messages_with_schedule_id(
@@ -1728,7 +1732,10 @@ mod tests {
// Verify the messages are also preserved
let read_messages = read_messages(&file_path)?;
assert_eq!(read_messages.len(), 1);
assert_eq!(read_messages[0].role, messages[0].role);
assert_eq!(
read_messages.first().unwrap().role,
messages.messages()[0].role
);
Ok(())
}
@@ -1744,7 +1751,8 @@ mod tests {
let working_dir_path = working_dir.path().to_path_buf();
// Create messages
let messages = vec![Message::user().with_text("test message")];
let messages =
Conversation::new_unvalidated(vec![Message::user().with_text("test message")]);
// Get the home directory for comparison
let home_dir = get_home_dir();
@@ -1911,10 +1919,10 @@ mod tests {
let dir = tempdir()?;
let file_path = dir.path().join("test_save_session.jsonl");
let messages = vec![
let messages = Conversation::new_unvalidated(vec![
Message::user().with_text("Hello"),
Message::assistant().with_text("Hi there"),
];
]);
let metadata = SessionMetadata::default();
@@ -1937,10 +1945,10 @@ mod tests {
let dir = tempdir()?;
let file_path = dir.path().join("test_persist_no_save.jsonl");
let messages = vec![
let messages = Conversation::new_unvalidated(vec![
Message::user().with_text("Test message"),
Message::assistant().with_text("Test response"),
];
]);
// Test persist_messages_with_schedule_id with working_dir parameter
persist_messages_with_schedule_id(
+2 -2
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use tiktoken_rs::CoreBPE;
use tokio::sync::OnceCell;
use crate::message::Message;
use crate::conversation::message::Message;
// Global tokenizer instance to avoid repeated initialization
static TOKENIZER: OnceCell<Arc<CoreBPE>> = OnceCell::const_new();
@@ -380,7 +380,7 @@ pub async fn create_async_token_counter() -> Result<AsyncTokenCounter, String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::message::{Message, MessageContent};
use crate::conversation::message::{Message, MessageContent};
use rmcp::model::{Role, Tool};
use rmcp::object;
+24 -13
View File
@@ -5,7 +5,8 @@ use std::sync::Arc;
use anyhow::Result;
use futures::StreamExt;
use goose::agents::{Agent, AgentEvent};
use goose::message::Message;
use goose::conversation::message::Message;
use goose::conversation::Conversation;
use goose::model::ModelConfig;
use goose::providers::base::Provider;
use goose::providers::{
@@ -118,7 +119,7 @@ async fn run_truncate_test(
agent.update_provider(provider).await?;
let repeat_count = context_window + 10_000;
let large_message_content = "hello ".repeat(repeat_count);
let messages = vec![
let messages = Conversation::new(vec![
Message::user().with_text("hi there. what is 2 + 2?"),
Message::assistant().with_text("hey! I think it's 4."),
Message::user().with_text(&large_message_content),
@@ -128,9 +129,10 @@ async fn run_truncate_test(
Message::user().with_text(
"did I ask you what's 2+2 in this message history? just respond with 'yes' or 'no'",
),
];
])
.unwrap();
let reply_stream = agent.reply(&messages, None, None).await?;
let reply_stream = agent.reply(messages, None, None).await?;
tokio::pin!(reply_stream);
let mut responses = Vec::new();
@@ -166,11 +168,11 @@ async fn run_truncate_test(
assert_eq!(responses[0].content.len(), 1);
match responses[0].content[0] {
goose::message::MessageContent::Text(ref text_content) => {
goose::conversation::message::MessageContent::Text(ref text_content) => {
assert!(text_content.text.to_lowercase().contains("no"));
assert!(!text_content.text.to_lowercase().contains("yes"));
}
goose::message::MessageContent::ContextLengthExceeded(_) => {
goose::conversation::message::MessageContent::ContextLengthExceeded(_) => {
// This is an acceptable outcome for providers that don't truncate themselves
// and correctly report that the context length was exceeded.
println!(
@@ -546,12 +548,14 @@ mod final_output_tool_tests {
use goose::agents::final_output_tool::{
FINAL_OUTPUT_CONTINUATION_MESSAGE, FINAL_OUTPUT_TOOL_NAME,
};
use goose::conversation::Conversation;
use goose::providers::base::MessageStream;
use goose::recipe::Response;
#[tokio::test]
async fn test_final_output_assistant_message_in_reply() -> Result<()> {
use async_trait::async_trait;
use goose::conversation::message::Message;
use goose::model::ModelConfig;
use goose::providers::base::{Provider, ProviderUsage, Usage};
use goose::providers::errors::ProviderError;
@@ -626,7 +630,7 @@ mod final_output_tool_tests {
);
// Simulate the reply stream continuing after the final output tool call.
let reply_stream = agent.reply(&vec![], None, None).await?;
let reply_stream = agent.reply(Conversation::empty(), None, None).await?;
tokio::pin!(reply_stream);
let mut responses = Vec::new();
@@ -652,6 +656,7 @@ mod final_output_tool_tests {
#[tokio::test]
async fn test_when_final_output_not_called_in_reply() -> Result<()> {
use async_trait::async_trait;
use goose::conversation::message::Message;
use goose::model::ModelConfig;
use goose::providers::base::{Provider, ProviderUsage};
use goose::providers::errors::ProviderError;
@@ -723,7 +728,7 @@ mod final_output_tool_tests {
agent.add_final_output_tool(response).await;
// Simulate the reply stream being called.
let reply_stream = agent.reply(&vec![], None, None).await?;
let reply_stream = agent.reply(Conversation::empty(), None, None).await?;
tokio::pin!(reply_stream);
let mut responses = Vec::new();
@@ -773,6 +778,8 @@ mod retry_tests {
use super::*;
use async_trait::async_trait;
use goose::agents::types::{RetryConfig, SessionConfig, SuccessCheck};
use goose::conversation::message::Message;
use goose::conversation::Conversation;
use goose::model::ModelConfig;
use goose::providers::base::{Provider, ProviderUsage, Usage};
use goose::providers::errors::ProviderError;
@@ -855,10 +862,11 @@ mod retry_tests {
retry_config: Some(retry_config),
};
let initial_messages = vec![Message::user().with_text("Complete this task")];
let conversation =
Conversation::new(vec![Message::user().with_text("Complete this task")]).unwrap();
let reply_stream = agent
.reply(&initial_messages, Some(session_config), None)
.reply(conversation, Some(session_config), None)
.await?;
tokio::pin!(reply_stream);
@@ -952,7 +960,8 @@ mod retry_tests {
mod max_turns_tests {
use super::*;
use async_trait::async_trait;
use goose::message::MessageContent;
use goose::conversation::message::{Message, MessageContent};
use goose::conversation::Conversation;
use goose::model::ModelConfig;
use goose::providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage};
use goose::providers::errors::ProviderError;
@@ -1021,9 +1030,11 @@ mod max_turns_tests {
max_turns: Some(1),
retry_config: None,
};
let messages = vec![Message::user().with_text("Hello")];
let conversation = Conversation::new(vec![Message::user().with_text("Hello")]).unwrap();
let reply_stream = agent.reply(&messages, Some(session_config), None).await?;
let reply_stream = agent
.reply(conversation, Some(session_config), None)
.await?;
tokio::pin!(reply_stream);
let mut responses = Vec::new();
+4 -4
View File
@@ -782,10 +782,10 @@ async fn test_schedule_tool_session_content_action_with_real_session() {
// Create test metadata and messages
let metadata = create_test_session_metadata(2, "/tmp");
let messages = vec![
goose::message::Message::user().with_text("Hello"),
goose::message::Message::assistant().with_text("Hi there!"),
];
let messages = goose::conversation::Conversation::new_unvalidated(vec![
goose::conversation::message::Message::user().with_text("Hello"),
goose::conversation::message::Message::assistant().with_text("Hi there!"),
]);
// Save the session file
goose::session::storage::save_messages_with_metadata(&session_path, &metadata, &messages)
+2 -1
View File
@@ -1,6 +1,6 @@
use anyhow::Result;
use dotenvy::dotenv;
use goose::message::{Message, MessageContent};
use goose::conversation::message::{Message, MessageContent};
use goose::providers::base::Provider;
use goose::providers::errors::ProviderError;
use goose::providers::{
@@ -257,6 +257,7 @@ impl ProviderTester {
async fn test_image_content_support(&self) -> Result<()> {
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use goose::conversation::message::Message;
use std::fs;
// Try to read the test image