diff --git a/frontend/src/main.js b/frontend/src/main.js index e7ac424..91e3ffd 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -399,7 +399,7 @@ function renderChat() { chatMessagesEl.innerHTML = state.chatMessages .map(msg => `
- ${escapeHtml(msg.content)} + ${DOMPurify.sanitize(marked.parse(msg.content || ''))}
`) .join(''); diff --git a/src/domain/agent/mod.rs b/src/domain/agent/mod.rs index c00f517..1b6dde8 100644 --- a/src/domain/agent/mod.rs +++ b/src/domain/agent/mod.rs @@ -13,7 +13,7 @@ pub struct Agent { url: String, zen_api_key: Option, tavily_api_key: Option, - messages: Vec, + pub messages: Vec, tools: Option>, logs: String, answer: Option, @@ -50,10 +50,19 @@ impl Agent { }, ]; + Self::with_messages(db, zen_api_key, tavily_api_key, messages) + } + + pub fn with_messages( + db: DatabaseConnection, + zen_api_key: Option, + tavily_api_key: Option, + messages: Vec, + ) -> AppResult { let tools = Some(tools::get_tools()); let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(60)) + .timeout(std::time::Duration::from_secs(120)) .build() .map_err(|e| AppError::Internal(format!("Failed to build HTTP client: {}", e)))?; @@ -105,6 +114,35 @@ impl Agent { turns, current_role )); + let assistant_message = self.execute_turn().await?; + + if let Some(tool_calls) = &assistant_message.tool_calls { + if tool_calls.iter().any(|tc| tc.function.name == "answer") { + finished = true; + } + } + + if self.answer.is_some() { + finished = true; + } + } + + self.log("\n--- Execution Finished ---"); + Ok((self.logs.clone(), self.answer.clone())) + } + + pub async fn execute_turn(&mut self) -> AppResult { + let max_sub_turns = 10; + let mut sub_turns = 0; + + loop { + sub_turns += 1; + if sub_turns > max_sub_turns { + return Err(AppError::Internal( + "Interaction cycle turn limit exceeded".into(), + )); + } + let chat_response = self.call_llm().await?; let assistant_message = chat_response .choices @@ -121,20 +159,24 @@ impl Agent { } } - if let Some(tool_calls) = assistant_message.tool_calls { + if let Some(tool_calls) = &assistant_message.tool_calls { + let mut is_final_cycle = false; + let mut final_answer = None; + for tool_call in tool_calls { self.log(&format!("Calling tool: {}", tool_call.function.name)); let (tool_message, is_final, tool_answer) = - tools::handle_tool_call(&tool_call, &self.tavily_api_key, &self.db) + tools::handle_tool_call(tool_call, &self.tavily_api_key, &self.db) .await .map_err(|e| { AppError::Internal(format!("Tool execution failed: {}", e)) })?; if let Some(ans) = tool_answer { - self.answer = Some(ans); - self.log("Task marked as finished by tool."); + self.answer = Some(ans.clone()); + final_answer = Some(ans); + self.log("Interaction marked as finished by tool."); } if let Some(content) = &tool_message.content { @@ -143,20 +185,24 @@ impl Agent { self.messages.push(tool_message); if is_final { - finished = true; + is_final_cycle = true; } } - } else if assistant_message.content.is_some() { - // If assistant just talked without tools, we might be stuck or finished. - // But typically we expect a 'finish' tool call. - self.log("Assistant responded without tool calls."); - // For now we continue unless the assistant explicitly uses a tool to finish, - // or we could add heuristic here if needed. - } - } - self.log("\n--- Execution Finished ---"); - Ok((self.logs.clone(), self.answer.clone())) + if is_final_cycle { + return Ok(Message { + role: "assistant".to_string(), + content: final_answer.or(assistant_message.content), + tool_calls: None, + tool_call_id: None, + }); + } + + continue; + } + + return Ok(assistant_message); + } } async fn call_llm(&self) -> AppResult { @@ -172,23 +218,46 @@ impl Agent { request_builder = request_builder.header("Authorization", format!("Bearer {}", key)); } - let response = request_builder.send().await.map_err(AppError::Network)?; + let start = std::time::Instant::now(); + let response = request_builder.send().await.map_err(|e| { + let duration = start.elapsed(); + let is_timeout = e.is_timeout(); + tracing::error!( + "Network error after {:?} during LLM call (Timeout: {}): {:?}", + duration, + is_timeout, + e + ); + AppError::Network(e) + })?; + + let duration = start.elapsed(); + tracing::info!("LLM request completed in {:?}", duration); if !response.status().is_success() { let status = response.status(); - let error_text = response + let body_text = response .text() .await - .unwrap_or_else(|_| "Unknown error".into()); - return Err(AppError::Internal(format!( - "API request failed: {} - {}", - status, error_text - ))); + .unwrap_or_else(|_| "Unknown body".into()); + let err = format!("API request failed: {} - {}", status, body_text); + tracing::error!("{}", err); + return Err(AppError::Internal(err)); } - response - .json() - .await - .map_err(|e| AppError::Internal(format!("Failed to parse LLM response: {}", e))) + let response_text = response.text().await.map_err(|e| { + let err = format!("Failed to read response text: {}", e); + tracing::error!("{}", err); + AppError::Internal(err) + })?; + + serde_json::from_str(&response_text).map_err(|e| { + let err = format!( + "Failed to parse LLM response: {} | Raw Body: {}", + e, response_text + ); + tracing::error!("{}", err); + AppError::Internal(err) + }) } } diff --git a/src/server/chat.rs b/src/server/chat.rs index 6e46350..5163275 100644 --- a/src/server/chat.rs +++ b/src/server/chat.rs @@ -1,4 +1,4 @@ -use crate::domain::agent::api::{ChatRequest, ChatResponse, Message}; +use crate::domain::agent::api::Message; use crate::error::AppError; use crate::server::AppState; use axum::{Json, extract::State}; @@ -22,109 +22,36 @@ pub async fn chat_handler( let msg_count = payload.messages.len(); tracing::info!("Received chat request with {} messages", msg_count); - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(60)) - .build() - .map_err(|e| { - let err = format!("Failed to build HTTP client: {}", e); - tracing::error!("{}", err); - AppError::Internal(err) - })?; - - let url = "https://opencode.ai/zen/v1/chat/completions"; let mut messages = payload.messages; - let tools = crate::domain::agent::tools::get_tools(); - let max_turns = 10; - let mut turns = 0; + // Inject current date awareness if not already present or as a fresh system message + let now = chrono::Local::now(); + let date_str = now.format("%A, %B %e, %Y at %l:%M %P").to_string(); + messages.insert( + 0, + Message { + role: "system".to_string(), + content: Some(format!( + "The current date and time is {}. Today is {}. You are in interactive chat mode.", + date_str, + now.format("%Y-%m-%d") + )), + tool_calls: None, + tool_call_id: None, + }, + ); - loop { - turns += 1; - if turns > max_turns { - return Err(AppError::Internal("Chat turn limit exceeded".into())); - } + let mut agent = crate::domain::agent::Agent::with_messages( + state.db.clone(), + state.config.zen_api_key.clone(), + state.config.tavily_api_key.clone(), + messages, + )?; - let request = ChatRequest { - model: "big-pickle".to_string(), - messages: messages.clone(), - tools: Some(tools.clone()), - }; + tracing::info!("Starting interactive agent turn"); + let assistant_message = agent.execute_turn().await?; - let mut request_builder = client.post(url).json(&request); - - if let Some(key) = &state.config.zen_api_key { - request_builder = request_builder.header("Authorization", format!("Bearer {}", key)); - } - - let response = request_builder.send().await.map_err(|e| { - tracing::error!("Network error during chat completion: {}", e); - AppError::Network(e) - })?; - - if !response.status().is_success() { - let status = response.status(); - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".into()); - let err = format!("API request failed: {} - {}", status, error_text); - tracing::error!("{}", err); - return Err(AppError::Internal(err)); - } - - let chat_response: ChatResponse = response.json().await.map_err(|e| { - let err = format!("Failed to parse LLM response: {}", e); - tracing::error!("{}", err); - AppError::Internal(err) - })?; - - let assistant_message = chat_response - .choices - .get(0) - .ok_or_else(|| { - let err = "Missing assistant response choices"; - tracing::error!("{}", err); - AppError::Internal(err.into()) - })? - .message - .clone(); - - messages.push(assistant_message.clone()); - - if let Some(tool_calls) = &assistant_message.tool_calls { - for tool_call in tool_calls { - tracing::info!("Chat agent calling tool: {}", tool_call.function.name); - let (tool_message, is_final, answer) = - crate::domain::agent::tools::handle_tool_call( - tool_call, - &state.config.tavily_api_key, - &state.db, - ) - .await - .map_err(|e| AppError::Internal(format!("Tool execution failed: {}", e)))?; - - messages.push(tool_message.clone()); - - if is_final { - tracing::info!("Chat agent finished via tool"); - return Ok(Json(ChatResult { - message: Message { - role: "assistant".to_string(), - content: answer.or(tool_message.content), - tool_calls: None, - tool_call_id: None, - }, - })); - } - } - // After tool calls, we loop back to get another assistant response - continue; - } - - // If no tool calls, it's a final response for this turn - tracing::info!("Chat completion successful"); - return Ok(Json(ChatResult { - message: assistant_message, - })); - } + Ok(Json(ChatResult { + message: assistant_message, + })) }