diff --git a/Cargo.lock b/Cargo.lock index 35afe85..5f4854d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,14 +6,17 @@ version = 4 name = "OwnAssist" version = "0.1.0" dependencies = [ - "chrono", "env_logger", "log", "ollama-rs", + "rand 0.10.0", "reqwest 0.13.2", "rmcp", "serde", + "thiserror 2.0.18", "tokio", + "toml", + "url", ] [[package]] @@ -220,6 +223,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "chrono" version = "0.4.44" @@ -294,6 +308,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -607,6 +630,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.0", "wasip2", "wasip3", ] @@ -1160,6 +1184,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f647d8676b95a6b6205e11453c9fac338d73c9cdcc011c94d1ba9c9bfea582cd" dependencies = [ "async-stream", + "http", "log", "ollama-rs-macros", "reqwest 0.12.28", @@ -1426,6 +1451,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -1464,6 +1500,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "ref-cast" version = "1.0.25" @@ -1893,6 +1935,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1912,7 +1963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2192,6 +2243,45 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -2787,6 +2877,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 3da64b2..89306c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,11 +4,14 @@ version = "0.1.0" edition = "2024" [dependencies] -ollama-rs = {version = "0.3.4", features = ["macros"]} +ollama-rs = {version = "0.3.4", features = ["macros", "headers"]} reqwest = "0.13.2" tokio = { version = "1.50.0", features = ["rt", "rt-multi-thread", "macros"] } -chrono = "0.4.44" rmcp = {version="1.3.0", features = ["transport-streamable-http-client-reqwest", "reqwest", "client", "auth", "transport-child-process"]} log = {version = "0.4.29"} env_logger = "0.11.10" -serde = { version = "1.0.228", features = ["derive"] } \ No newline at end of file +serde = { version = "1.0.228", features = ["derive"] } +thiserror = "2.0.17" +toml = "1.1.2+spec-1.1.0" +url = "2.5.8" +rand = "0.10.0" \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..1329fec --- /dev/null +++ b/src/config.rs @@ -0,0 +1,92 @@ +use std::collections::HashMap; +use log::error; +use ollama_rs::headers::{HeaderMap, HeaderValue}; +use ollama_rs::Ollama; +use rmcp::model::Implementation; +use serde::Deserialize; +use thiserror::Error; +use url::Url; +use crate::mcp; +use crate::mcp::{guaranteed_mcp_server_name, MCPClient}; + +#[derive(Debug, Deserialize)] +pub struct Config { + ollama: OllamaConfig, + #[serde(rename = "mcp-servers")] + mcp_servers: Vec +} + +#[derive(Debug, Error)] +pub enum ConfigLoadingError{ + #[error(transparent)] + IoError(#[from] std::io::Error), + #[error(transparent)] + ParseError(#[from] toml::de::Error), +} + +impl Config { + pub async fn from_file() -> Result { + let toml_string = tokio::fs::read_to_string("assist.toml").await?; + Ok(toml::from_str(&toml_string)?) + } + + pub fn ollama_config(&self) -> &OllamaConfig { + &self.ollama + } + + pub fn ollama_instance(&self) -> Ollama { + let ollama_default_url = Url::parse("http://127.0.0.1:11434").unwrap(); + + let mut ollama_headers = HeaderMap::new(); + + if let Some(authorization_header) = &self.ollama.authorization { + ollama_headers.append("Authorization", HeaderValue::from_str(&authorization_header).unwrap()); + } + + let mut ollama = Ollama::from_url(self.ollama.url.clone().unwrap_or(ollama_default_url)); + ollama.set_headers(Some(ollama_headers)); + + ollama + } + + pub async fn mcp_clients(&self) -> HashMap { + let mut mcp_clients: HashMap = HashMap::new(); + + for mcp_server in &self.mcp_servers { + let client = mcp::get_client( + mcp_server.url.as_str(), + mcp_server.authorization.clone(), + Implementation::new("OwnAssist", "0.0.1"), + ).await.unwrap(); + + let server_name = guaranteed_mcp_server_name(mcp_server.name.clone(), &client); + let server_data = client; + + mcp_clients.insert(server_name.clone(), server_data); + } + + mcp_clients + } +} + +#[derive(Debug, Deserialize)] +pub struct OllamaConfig { + pub url: Option, + pub model: OllamaModelConfig, + pub authorization: Option, // TODO: implement +} + +#[derive(Debug, Deserialize)] +pub struct OllamaModelConfig { + pub from_model: String, + pub name: String, + pub context_size: Option, + pub temperature: Option, +} + +#[derive(Debug, Deserialize)] +pub struct MCPServerConfig { + pub name: Option, + pub url: Url, + pub authorization: Option, // should probably implement oauth some time +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 598fb3f..c3a3893 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,76 +1,46 @@ pub mod mcp; +mod config; +mod model; use std::collections::HashMap; -use chrono::Datelike; use ollama_rs::Ollama; -use ollama_rs::generation::chat::ChatMessage; -use ollama_rs::generation::chat::request::ChatMessageRequest; -use rmcp::model::{Implementation, InitializedNotificationMethod}; - -/// Get the current local datetime as an iso string -/// -/// # Arguments -/// -/// returns: Result> -/// -/// # Examples -/// -/// ``` -/// -/// ``` -#[ollama_rs::function] -async fn get_current_iso_datetime() -> Result> { - use chrono::prelude::*; - - let datetime_iso = Local::now().format("%+").to_string(); - - println!("iso time requested: {datetime_iso}"); - - Ok(datetime_iso) -} - -/// Get the current weekday as a string in english -/// -/// # Arguments -/// -/// returns: Result> -#[ollama_rs::function] -async fn get_current_weekday() -> Result> { - let weekday = chrono::offset::Local::now().weekday().to_string(); - - println!("Weekday requested: {weekday}"); - Ok(weekday) -} - -/// creates a new task to be completed at given timestamp -/// -/// # Arguments -/// -/// * `name`: a short name of the task -/// * `description`: optional description, keep empty if not needed -/// * `iso_datetime`: must be in ISO 8601, specifies when user should be reminded of task. choose 15:00 if time not specified -/// -/// returns: Empty String -/// -/// # Examples -/// -/// ``` -/// create_task("Müll rausbringen".to_string(), None, "2026-11-08T16:23:47.947244200+02:00") -/// ``` -#[ollama_rs::function] -async fn create_task( - name: String, - description: Option, - iso_datetime: String, -) -> Result> { - println!("lol, {name}, {description:?}, {iso_datetime}"); - Ok(String::new()) -} +use reqwest::header::{HeaderMap, HeaderValue}; +use rmcp::model::{Implementation}; +use url::Url; +use crate::config::Config; +use crate::mcp::chat::MCPServerData; +use crate::mcp::guaranteed_mcp_server_name; +use crate::model::create_model_from_config; #[tokio::main] async fn main() { env_logger::init(); + let config = Config::from_file().await.inspect_err(|e|{ + log::error!("error loading assist.toml {}", e); + std::process::exit(1); + }).unwrap(); + + let ollama = config.ollama_instance(); + + let mcp_clients = config.mcp_clients().await; + + let model_name = &config.ollama_config().model.name; + + create_model_from_config(&ollama, &config.ollama_config().model).await.inspect_err(|e|{ + log::error!("failed creating ollama model {model_name}: {e}") + }).unwrap(); + + let mut agent_chat = mcp::chat::AgentChat::new(ollama, model_name.clone(), mcp_clients).await.inspect_err( + |e| { + log::error!("error creating agent: {}", e); + } + ); + + log::debug!("chat: {:#?}", agent_chat); + + + /* let ollama = Ollama::default(); let fetch_client = mcp::get_client( @@ -83,9 +53,10 @@ async fn main() { ("fetch".to_string(), fetch_client), ]); - let mut agent_chat = mcp::chat::AgentChat::new(ollama, mcp_clients).await.unwrap(); + let mut agent_chat = mcp::chat::AgentChat::new(ollama, "ollama:e2b".to_string(), mcp_clients).await.unwrap(); log::debug!("chat: {:#?}", agent_chat); agent_chat.message(String::from("Was ist auf der Website https://uno.mboemer.com zu finden?")).await.unwrap(); + */ /*let ollama = Ollama::default(); let mut history = vec![ diff --git a/src/mcp/chat.rs b/src/mcp/chat.rs index 6993f11..edf7412 100644 --- a/src/mcp/chat.rs +++ b/src/mcp/chat.rs @@ -1,13 +1,14 @@ -use std::collections::HashMap; -use ollama_rs::error::OllamaError; use crate::mcp::MCPClient; use ollama_rs::Ollama; +use ollama_rs::error::OllamaError; use ollama_rs::generation::chat::ChatMessage; use ollama_rs::generation::chat::request::ChatMessageRequest; use ollama_rs::generation::tools::ToolInfo; -use rmcp::model::{CallToolRequestParams, CallToolResult}; use rmcp::ServiceError; -use serde::__private228::de::content_as_str; +use rmcp::model::{CallToolRequestParams, CallToolResult, }; +use std::collections::HashMap; + +use thiserror::Error; #[derive(Debug, Copy, Clone, Default)] pub enum ToolPermission { @@ -52,13 +53,27 @@ impl MCPServerData { #[derive(Debug)] pub struct AgentChat { ollama_client: Ollama, + model: String, mcp_servers: HashMap, message_history: Vec, } +#[derive(Debug, Error)] +pub enum ChatError { + #[error(transparent)] + OllamaError(#[from] OllamaError), + #[error(transparent)] + ServiceError(#[from] ServiceError), + #[error("the function name could not be parsed")] + FunctionParseError(String), + #[error("error parsing provided arguments")] + ArgumentParsingError, +} + impl AgentChat { pub async fn new( ollama_client: Ollama, + model: String, mcp_clients: HashMap, ) -> Result { let history = vec![ @@ -78,6 +93,7 @@ impl AgentChat { Ok(Self { ollama_client, + model, mcp_servers: servers, message_history: history, }) @@ -95,19 +111,17 @@ impl AgentChat { all_tools } - pub async fn message(&mut self, user_message: String) -> Result<(), OllamaError> { + pub async fn message(&mut self, user_message: String) -> Result<(), ChatError> { let all_tools = self.get_all_tools(); log::debug!("all tools: {:#?}", all_tools); - let mut response = self.ollama_client + let mut response = self + .ollama_client .send_chat_messages_with_history( &mut self.message_history, - ChatMessageRequest::new( - "gemma4:e2b".to_string(), - vec![ChatMessage::user(user_message)], - ) - .tools(all_tools), + ChatMessageRequest::new(self.model.clone(), vec![ChatMessage::user(user_message)]) + .tools(all_tools.clone()), ) .await?; @@ -119,31 +133,70 @@ impl AgentChat { for tool_call in &response.message.tool_calls { log::debug!("calling tool {}", tool_call.function.name); - let result = self.call_tool(tool_call).await.unwrap(); // FIXME: nuh uh - let contents = result.content.iter().filter_map(|content| content.as_text()).map(|text_content|text_content.text.clone()).collect::>(); + let result = self.call_tool(tool_call).await?; + let contents = result + .content + .iter() + .filter_map(|content| content.as_text()) + .map(|text_content| text_content.text.clone()) + .collect::>(); log::debug!("contents: {:#?}", contents); - self.message_history.push(ChatMessage::tool(contents.join("\n"))); + self.message_history + .push(ChatMessage::tool(contents.join("\n"))); } - response = self.ollama_client.send_chat_messages_with_history(&mut self.message_history, ChatMessageRequest::new("gemma4:e2b".to_string(), Vec::new())).await?; + response = self + .ollama_client + .send_chat_messages_with_history( + &mut self.message_history, + ChatMessageRequest::new(self.model.clone(), Vec::new()).tools(all_tools.clone()), + ) + .await?; log::debug!("received message: {:#?}", response.message); } Ok(()) } - async fn call_tool(&self, tool_call: &ollama_rs::generation::tools::ToolCall) -> Result { // TODO: replace ServiceError handling with something more flexible - let arguments_json_object = tool_call.function.arguments.as_object().unwrap(); // FIXME: remove unwrap + async fn call_tool( + &self, + tool_call: &ollama_rs::generation::tools::ToolCall, + ) -> Result { + let arguments_json_object = tool_call + .function + .arguments + .as_object() + .ok_or(ChatError::ArgumentParsingError) + .inspect_err(|_| log::error!("arguments are not of type object"))?; log::trace!("arguments_json_object: {:?}", arguments_json_object); - let (mcp_name, function_name) = tool_call.function.name.split_once("::").unwrap(); // FIXME: this is not how we handle errors in rust + let (mcp_name, function_name) = + tool_call + .function + .name + .split_once("::") + .ok_or(ChatError::FunctionParseError( + tool_call.function.name.clone(), + ))?; let (mcp_name, function_name) = (mcp_name.to_string(), function_name.to_string()); - let request_params = CallToolRequestParams::new(function_name).with_arguments(arguments_json_object.clone()); + let request_params = + CallToolRequestParams::new(function_name).with_arguments(arguments_json_object.clone()); - log::debug!("calling tool {} with request_params: {:#?}", tool_call.function.name, request_params); - self.mcp_servers.get(&mcp_name).unwrap().client.call_tool(request_params).await + log::debug!( + "calling tool {} with request_params: {:#?}", + tool_call.function.name, + request_params + ); + + Ok(self + .mcp_servers + .get(&mcp_name) + .unwrap() + .client + .call_tool(request_params) + .await?) } } diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 8b1301d..e36695d 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -13,7 +13,7 @@ use rmcp::{ }; use std::sync::Arc; -type MCPClient = RunningService; +pub type MCPClient = RunningService; pub(crate) async fn get_client( uri: impl Into>, @@ -33,4 +33,27 @@ pub(crate) async fn get_client( client_info.serve(transport).await.inspect_err(|e| { log::error!("client error: {:?}", e); }) +} + +fn generate_random_mcp_server_name() -> String { + use rand::distr::Alphanumeric; + use rand::RngExt; + + let mut rng = rand::rng(); + + (0..5) + .map(|_| rng.sample(Alphanumeric) as char) + .collect() +} + +pub fn guaranteed_mcp_server_name(user_specified_name: Option, client: &MCPClient) -> String { + if let Some(user_specified_name) = user_specified_name { + user_specified_name + } else if let Some(peer_info) = client.peer_info() { + peer_info.server_info.name.clone() + } else { + let random_name = generate_random_mcp_server_name(); + log::warn!("no name was specified by the user or the client, so a random name had to be generated: `{}` for {:#?}", random_name, client); + random_name + } } \ No newline at end of file diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..ddfc8ed --- /dev/null +++ b/src/model.rs @@ -0,0 +1,22 @@ +use crate::config::OllamaModelConfig; +use ollama_rs::Ollama; +use ollama_rs::error::OllamaError; +use ollama_rs::models::ModelOptions; +use ollama_rs::models::create::{CreateModelRequest, CreateModelStatus}; + +pub async fn create_model_from_config( + ollama: &Ollama, + config: &OllamaModelConfig, +) -> Result { + let model_options = ModelOptions::default() + .num_ctx(config.context_size.unwrap_or(2048))// 2048 is the ollama default. + .temperature(config.temperature.unwrap_or(0.8)); // 0.8 is the ollama default + + ollama + .create_model( + CreateModelRequest::new(config.name.clone()) + .from_model(config.from_model.clone()) + .parameters(model_options), + ) + .await +}