This commit is contained in:
pavel 2026-02-11 17:45:04 +01:00
commit beab32894b
3 changed files with 128 additions and 132 deletions

View file

@ -399,7 +399,7 @@ function renderChat() {
chatMessagesEl.innerHTML = state.chatMessages chatMessagesEl.innerHTML = state.chatMessages
.map(msg => ` .map(msg => `
<div class="chat-message ${msg.role}"> <div class="chat-message ${msg.role}">
${escapeHtml(msg.content)} ${DOMPurify.sanitize(marked.parse(msg.content || ''))}
</div> </div>
`) `)
.join(''); .join('');

View file

@ -13,7 +13,7 @@ pub struct Agent {
url: String, url: String,
zen_api_key: Option<String>, zen_api_key: Option<String>,
tavily_api_key: Option<String>, tavily_api_key: Option<String>,
messages: Vec<Message>, pub messages: Vec<Message>,
tools: Option<Vec<Tool>>, tools: Option<Vec<Tool>>,
logs: String, logs: String,
answer: Option<String>, answer: Option<String>,
@ -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<String>,
tavily_api_key: Option<String>,
messages: Vec<Message>,
) -> AppResult<Self> {
let tools = Some(tools::get_tools()); let tools = Some(tools::get_tools());
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60)) .timeout(std::time::Duration::from_secs(120))
.build() .build()
.map_err(|e| AppError::Internal(format!("Failed to build HTTP client: {}", e)))?; .map_err(|e| AppError::Internal(format!("Failed to build HTTP client: {}", e)))?;
@ -105,6 +114,35 @@ impl Agent {
turns, current_role 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<Message> {
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 chat_response = self.call_llm().await?;
let assistant_message = chat_response let assistant_message = chat_response
.choices .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 { for tool_call in tool_calls {
self.log(&format!("Calling tool: {}", tool_call.function.name)); self.log(&format!("Calling tool: {}", tool_call.function.name));
let (tool_message, is_final, tool_answer) = 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 .await
.map_err(|e| { .map_err(|e| {
AppError::Internal(format!("Tool execution failed: {}", e)) AppError::Internal(format!("Tool execution failed: {}", e))
})?; })?;
if let Some(ans) = tool_answer { if let Some(ans) = tool_answer {
self.answer = Some(ans); self.answer = Some(ans.clone());
self.log("Task marked as finished by tool."); final_answer = Some(ans);
self.log("Interaction marked as finished by tool.");
} }
if let Some(content) = &tool_message.content { if let Some(content) = &tool_message.content {
@ -143,20 +185,24 @@ impl Agent {
self.messages.push(tool_message); self.messages.push(tool_message);
if is_final { 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 ---"); if is_final_cycle {
Ok((self.logs.clone(), self.answer.clone())) 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<ChatResponse> { async fn call_llm(&self) -> AppResult<ChatResponse> {
@ -172,23 +218,46 @@ impl Agent {
request_builder = request_builder.header("Authorization", format!("Bearer {}", key)); 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() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let error_text = response let body_text = response
.text() .text()
.await .await
.unwrap_or_else(|_| "Unknown error".into()); .unwrap_or_else(|_| "Unknown body".into());
return Err(AppError::Internal(format!( let err = format!("API request failed: {} - {}", status, body_text);
"API request failed: {} - {}", tracing::error!("{}", err);
status, error_text return Err(AppError::Internal(err));
)));
} }
response let response_text = response.text().await.map_err(|e| {
.json() let err = format!("Failed to read response text: {}", e);
.await tracing::error!("{}", err);
.map_err(|e| AppError::Internal(format!("Failed to parse LLM response: {}", e))) 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)
})
} }
} }

View file

@ -1,4 +1,4 @@
use crate::domain::agent::api::{ChatRequest, ChatResponse, Message}; use crate::domain::agent::api::Message;
use crate::error::AppError; use crate::error::AppError;
use crate::server::AppState; use crate::server::AppState;
use axum::{Json, extract::State}; use axum::{Json, extract::State};
@ -22,109 +22,36 @@ pub async fn chat_handler(
let msg_count = payload.messages.len(); let msg_count = payload.messages.len();
tracing::info!("Received chat request with {} messages", msg_count); 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 mut messages = payload.messages;
let tools = crate::domain::agent::tools::get_tools();
let max_turns = 10; // Inject current date awareness if not already present or as a fresh system message
let mut turns = 0; 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 { let mut agent = crate::domain::agent::Agent::with_messages(
turns += 1; state.db.clone(),
if turns > max_turns { state.config.zen_api_key.clone(),
return Err(AppError::Internal("Chat turn limit exceeded".into())); state.config.tavily_api_key.clone(),
} messages,
)?;
let request = ChatRequest { tracing::info!("Starting interactive agent turn");
model: "big-pickle".to_string(), let assistant_message = agent.execute_turn().await?;
messages: messages.clone(),
tools: Some(tools.clone()),
};
let mut request_builder = client.post(url).json(&request); Ok(Json(ChatResult {
message: assistant_message,
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,
}));
}
} }