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,
+ }))
}