refactoring

This commit is contained in:
pavel 2026-02-11 01:29:23 +01:00
commit 13e17770ca
12 changed files with 634 additions and 598 deletions

114
src/domain/agent/api.rs Normal file
View file

@ -0,0 +1,114 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Message {
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub call_type: String,
pub function: FunctionCall,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FunctionCall {
pub name: String,
pub arguments: String,
}
#[derive(Serialize, Debug)]
pub struct ChatRequest {
pub model: String,
pub messages: Vec<Message>,
pub tools: Option<Vec<Tool>>,
}
#[derive(Deserialize, Debug)]
pub struct ChatResponse {
pub choices: Vec<Choice>,
}
#[derive(Deserialize, Debug)]
pub struct Choice {
pub message: Message,
}
#[derive(Serialize, Debug, Clone)]
pub struct Tool {
#[serde(rename = "type")]
pub tool_type: String,
pub function: FunctionDefinition,
}
#[derive(Serialize, Debug, Clone)]
pub struct FunctionDefinition {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
#[derive(Deserialize, Debug)]
struct TavilyResponse {
results: Vec<TavilyResult>,
}
#[derive(Deserialize, Debug)]
struct TavilyResult {
title: String,
url: String,
content: String,
}
pub async fn perform_search(
query: &str,
api_key: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
let response = client
.post("https://api.tavily.com/search")
.json(&serde_json::json!({
"api_key": api_key,
"query": query,
"search_depth": "basic",
"include_answer": false,
"include_images": false,
"include_raw_content": false,
"max_results": 5,
}))
.send()
.await?;
if !response.status().is_success() {
return Err(format!("Tavily API error: {}", response.status()).into());
}
let search_data: TavilyResponse = response.json().await?;
let mut results_text = String::new();
for (i, result) in search_data.results.iter().enumerate() {
results_text.push_str(&format!(
"{}. {} ({})\nSnippet: {}\n\n",
i + 1,
result.title,
result.url,
result.content
));
}
if results_text.is_empty() {
Ok("No results found.".to_string())
} else {
Ok(results_text)
}
}

168
src/domain/agent/mod.rs Normal file
View file

@ -0,0 +1,168 @@
pub mod api;
pub mod tools;
use chrono::Utc;
use std::time::{Duration, Instant};
use self::api::{ChatRequest, ChatResponse, Message, Tool};
pub struct Agent {
client: reqwest::Client,
url: String,
zen_api_key: Option<String>,
tavily_api_key: Option<String>,
messages: Vec<Message>,
tools: Option<Vec<Tool>>,
logs: String,
answer: Option<String>,
}
impl Agent {
pub fn new(
zen_api_key: Option<String>,
tavily_api_key: Option<String>,
initial_message: String,
) -> Result<Self, Box<dyn std::error::Error>> {
let intro = format!(
"You are an autonomous agent. You have access to tools that can help
you achieve your goals. Use them wisely. The user is unable to respond to you
so do not ask for clarification and use the
answer tool once you to give your final answer. current date is {}",
Utc::now().format("%B %d, %Y %H:%M:%S UTC").to_string()
);
let messages = vec![
Message {
role: "system".to_string(),
content: Some(intro),
tool_calls: None,
tool_call_id: None,
},
Message {
role: "user".to_string(),
content: Some(initial_message),
tool_calls: None,
tool_call_id: None,
},
];
let tools = Some(tools::get_tools());
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()?;
Ok(Self {
client,
url: "https://opencode.ai/zen/v1/chat/completions".to_string(),
zen_api_key,
tavily_api_key,
messages,
tools,
logs: String::new(),
answer: None,
})
}
fn log(&mut self, message: &str) {
self.logs.push_str(message);
self.logs.push('\n');
}
pub async fn run(
&mut self,
config: &crate::config::Config,
) -> Result<(String, Option<String>), Box<dyn std::error::Error>> {
let mut finished = false;
let start_time = Instant::now();
let max_duration = Duration::from_secs(config.agent_max_duration_secs);
let max_turns = config.agent_max_turns;
let mut turns = 0;
while !finished {
if start_time.elapsed() > max_duration {
return Err("Agent run timed out".into());
}
if turns >= max_turns {
return Err("Agent run exceeded max turns".into());
}
turns += 1;
let request = ChatRequest {
model: "kimi-k2.5".to_string(),
messages: self.messages.clone(),
tools: self.tools.clone(),
};
self.log(&format!(
"--- Sending request to Zen API (Role: {}) ---",
self.messages.last().unwrap().role
));
let mut request_builder = self.client.post(&self.url).json(&request);
if let Some(key) = &self.zen_api_key {
request_builder =
request_builder.header("Authorization", format!("Bearer {}", key));
}
let response = request_builder.send().await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await?;
self.log(&format!("Error: API request failed with status {}", status));
self.log(&format!("Error details: {}", error_text));
return Err(format!("API request failed: {}", status).into());
}
let chat_response: ChatResponse = response.json().await?;
let assistant_message = chat_response
.choices
.get(0)
.ok_or("Missing assistant response")?
.message
.clone();
self.messages.push(assistant_message.clone());
if let Some(content) = &assistant_message.content {
if !content.is_empty() {
self.log(&format!("\nAssistant response:\n{}\n", content));
}
}
if let Some(tool_calls) = assistant_message.tool_calls {
for tool_call in tool_calls {
let (tool_message, written, tool_answer) =
tools::handle_tool_call(&tool_call, &self.tavily_api_key).await?;
if let Some(ans) = tool_answer {
self.answer = Some(ans);
}
if let Some(content) = &tool_message.content {
self.log(&format!(
"Tool result ({}): {}",
tool_call.function.name, content
));
}
self.messages.push(tool_message);
if written {
finished = true;
}
}
// Continue the loop to send tool results back
continue;
}
// No tool calls from assistant, but we only exit if the task was finished
if !finished {
self.log("--- Assistant didn't finish yet. Waiting for next turn... ---");
}
}
Ok((self.logs.clone(), self.answer.clone()))
}
}

83
src/domain/agent/tools.rs Normal file
View file

@ -0,0 +1,83 @@
use super::api::{self, FunctionDefinition, Message, Tool, ToolCall};
use std::collections::HashMap;
pub fn get_tools() -> Vec<Tool> {
vec![
Tool {
tool_type: "function".to_string(),
function: FunctionDefinition {
name: "google_search".to_string(),
description: "Search the web for information".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query"
}
},
"required": ["query"]
}),
},
},
Tool {
tool_type: "function".to_string(),
function: FunctionDefinition {
name: "finish".to_string(),
description: "Finish the task".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"result": {
"type": "string",
"description": "The result of the task"
}
},
"required": ["result"]
}),
},
},
]
}
pub async fn handle_tool_call(
tool_call: &ToolCall,
tavily_api_key: &Option<String>,
) -> Result<(Message, bool, Option<String>), Box<dyn std::error::Error>> {
let mut answer = None;
let name = &tool_call.function.name;
let (content, written) = if name == "google_search" {
let args: HashMap<String, String> = serde_json::from_str(&tool_call.function.arguments)?;
let query = args.get("query").ok_or("Missing query argument")?;
let search_result = if let Some(key) = tavily_api_key {
match api::perform_search(query, key).await {
Ok(results) => results,
Err(e) => format!("Search error: {}", e),
}
} else {
"Error: TAVILY_API_KEY is not set. Cannot perform real search.".to_string()
};
(search_result, false)
} else if name == "finish" {
let args: HashMap<String, String> = serde_json::from_str(&tool_call.function.arguments)?;
let result = args.get("result").ok_or("Missing result argument")?;
answer = Some(result.clone());
(result.clone(), true)
} else {
(format!("Error: Unknown tool {}", name), false)
};
Ok((
Message {
role: "tool".to_string(),
content: Some(content),
tool_calls: None,
tool_call_id: Some(tool_call.id.clone()),
},
written,
answer,
))
}

193
src/domain/auth.rs Normal file
View file

@ -0,0 +1,193 @@
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub exp: usize,
pub iat: usize,
pub iss: String,
pub aud: Audience,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Audience {
Single(String),
Multiple(Vec<String>),
}
#[derive(Debug, Deserialize, Clone)]
struct Jwk {
#[serde(rename = "kty")]
_kty: String,
kid: String,
n: String,
e: String,
#[serde(rename = "alg")]
_alg: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Jwks {
keys: Vec<Jwk>,
}
pub struct JwksVerifier {
issuer: String,
audience: String,
jwks_uri: String,
keys: Arc<RwLock<Vec<Jwk>>>,
client: Client,
}
impl JwksVerifier {
pub async fn new(issuer: String, audience: String) -> Result<Self, Box<dyn std::error::Error>> {
let client = Client::new();
let discovery_url = format!(
"{}/.well-known/openid-configuration",
issuer.trim_end_matches('/')
);
let config: serde_json::Value = client.get(&discovery_url).send().await?.json().await?;
let jwks_uri = config["jwks_uri"]
.as_str()
.ok_or("Missing jwks_uri in discovery")?
.to_string();
let verifier = Self {
issuer,
audience,
jwks_uri,
keys: Arc::new(RwLock::new(Vec::new())),
client,
};
verifier.refresh_keys().await?;
Ok(verifier)
}
pub async fn refresh_keys(&self) -> Result<(), Box<dyn std::error::Error>> {
let jwks: Jwks = self.client.get(&self.jwks_uri).send().await?.json().await?;
let mut keys = self.keys.write().await;
*keys = jwks.keys;
Ok(())
}
pub async fn verify(&self, token: &str) -> Result<Claims, Box<dyn std::error::Error>> {
let header = decode_header(token)?;
let kid = header.kid.ok_or("Missing kid in token header")?;
let jwk = {
let keys = self.keys.read().await;
keys.iter().find(|k| k.kid == kid).cloned()
};
let jwk = match jwk {
Some(key) => key,
None => {
self.refresh_keys().await?;
let keys = self.keys.read().await;
keys.iter()
.find(|k| k.kid == kid)
.cloned()
.ok_or("Key not found in JWKS")?
}
};
let decoding_key = DecodingKey::from_rsa_components(&jwk.n, &jwk.e)?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[self.issuer.clone()]);
validation.set_audience(&[self.audience.clone()]);
validation.validate_aud = true;
let token_data = decode::<Claims>(token, &decoding_key, &validation)?;
Ok(token_data.claims)
}
}
pub struct Authenticator {
client_id: String,
client_secret: String,
token_url: String,
client: Client,
}
impl Authenticator {
pub async fn new(
issuer: String,
client_id: String,
client_secret: String,
) -> Result<Self, Box<dyn std::error::Error>> {
let client = Client::new();
let discovery_url = format!(
"{}/.well-known/openid-configuration",
issuer.trim_end_matches('/')
);
let config: serde_json::Value = client.get(&discovery_url).send().await?.json().await?;
let token_url = config["token_endpoint"]
.as_str()
.ok_or("Missing token_endpoint in discovery")?
.to_string();
Ok(Self {
client_id,
client_secret,
token_url,
client,
})
}
pub async fn exchange_code(
&self,
code: String,
redirect_uri: String,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let params = [
("grant_type", "authorization_code"),
("code", &code),
("redirect_uri", &redirect_uri),
("client_id", &self.client_id),
("client_secret", &self.client_secret),
];
let res = self
.client
.post(&self.token_url)
.form(&params)
.send()
.await?
.json()
.await?;
Ok(res)
}
pub async fn refresh_token(
&self,
refresh_token: String,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let params = [
("grant_type", "refresh_token"),
("refresh_token", &refresh_token),
("client_id", &self.client_id),
("client_secret", &self.client_secret),
];
let res = self
.client
.post(&self.token_url)
.form(&params)
.send()
.await?
.json()
.await?;
Ok(res)
}
}

3
src/domain/mod.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod agent;
pub mod auth;
pub mod tasks;

131
src/domain/tasks.rs Normal file
View file

@ -0,0 +1,131 @@
use chrono::Utc;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::config::Config;
use crate::domain::agent::Agent;
use crate::entities::task::Entity as Task;
use crate::entities::task_run::{self, Entity as TaskRun};
use crate::scheduler::Scheduler;
#[derive(Deserialize)]
pub struct CreateTaskRequest {
pub goal: String,
pub cron: Option<String>,
}
#[derive(Deserialize)]
pub struct UpdateTaskRequest {
pub goal: String,
pub cron: Option<String>,
}
#[derive(Serialize)]
pub struct TaskResponse {
pub id: Uuid,
pub goal: String,
pub cron: Option<String>,
pub created_at: chrono::DateTime<chrono::FixedOffset>,
pub runs: Vec<TaskRunResponse>,
}
#[derive(Serialize)]
pub struct TaskRunResponse {
pub id: Uuid,
pub status: String,
pub logs: String,
pub answer: Option<String>,
pub created_at: chrono::DateTime<chrono::FixedOffset>,
}
#[derive(Serialize)]
pub struct RecentRunResponse {
pub id: Uuid,
pub task_id: Uuid,
pub goal: String,
pub status: String,
pub created_at: chrono::DateTime<chrono::FixedOffset>,
}
pub async fn execute_agent_run(
db: &DatabaseConnection,
_scheduler: &Arc<Scheduler>,
config: &Arc<Config>,
task_id: Uuid,
goal: String,
) -> Result<TaskResponse, Box<dyn std::error::Error>> {
let run_id = Uuid::new_v4();
let new_run = task_run::ActiveModel {
id: Set(run_id),
task_id: Set(task_id),
status: Set("running".to_string()),
logs: Set(String::new()),
answer: Set(None),
created_at: Set(Utc::now().into()),
};
new_run.insert(db).await?;
let mut agent = Agent::new(
config.zen_api_key.clone(),
config.tavily_api_key.clone(),
goal.clone(),
)?;
let (logs, answer, status) = match agent.run(config).await {
Ok((logs, answer)) => (logs, answer, "completed".to_string()),
Err(e) => (
format!("Execution failed: {}", e),
None,
"failed".to_string(),
),
};
let mut run: task_run::ActiveModel = TaskRun::find_by_id(run_id)
.one(db)
.await?
.ok_or("Run not found after insert")?
.into();
run.logs = Set(logs.clone());
run.answer = Set(answer.clone());
run.status = Set(status);
run.update(db).await?;
get_task_inner(task_id, db).await
}
pub async fn get_task_inner(
id: Uuid,
db: &DatabaseConnection,
) -> Result<TaskResponse, Box<dyn std::error::Error>> {
let results = Task::find_by_id(id)
.find_with_related(TaskRun)
.all(db)
.await?;
let (t, mut runs) = results.into_iter().next().ok_or("Task not found")?;
runs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(TaskResponse {
id: t.id,
goal: t.goal,
cron: t.cron,
created_at: t.created_at,
runs: runs
.into_iter()
.map(|r| TaskRunResponse {
id: r.id,
status: r.status,
logs: r.logs,
answer: r.answer,
created_at: r.created_at,
})
.collect(),
})
}