diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..907bd9b
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,154 @@
+# AGENTS.md
+
+## Overview
+
+This is **Antigravity Agent** — an autonomous AI agent platform built with a Rust backend and a vanilla JS frontend. Users create "tasks" (goals), and the system dispatches an LLM-powered agent to accomplish them. Tasks can be run on-demand or on a cron schedule. Each execution produces logs and a final answer, all persisted to PostgreSQL.
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ Frontend (Vite + Vanilla JS) │
+│ - OAuth login via Authentik │
+│ - Dashboard: recent runs, task list, logs, answers │
+│ - Polls /api/tasks every 3s for live updates │
+└──────────────────────┬──────────────────────────────────┘
+ │ /api/*
+┌──────────────────────▼──────────────────────────────────┐
+│ Axum HTTP Server (src/server/) │
+│ - CORS, CSP, rate limiting (1 MB body) │
+│ - Cookie-based + Bearer token auth │
+│ - Routes: tasks CRUD, runs, auth (callback/refresh) │
+├─────────────────────────────────────────────────────────┤
+│ Domain Layer (src/domain/) │
+│ ├─ agent/ Agent loop, LLM API client, tool defs │
+│ ├─ auth JwksVerifier, Authenticator (OIDC) │
+│ └─ tasks Task execution, run management │
+├─────────────────────────────────────────────────────────┤
+│ Scheduler (src/scheduler.rs) │
+│ - tokio-cron-scheduler for recurring task execution │
+├─────────────────────────────────────────────────────────┤
+│ Entities (src/entities/) │
+│ - task, task_run (SeaORM models) │
+├─────────────────────────────────────────────────────────┤
+│ PostgreSQL │
+│ - Migrations managed via sea-orm-migration │
+└─────────────────────────────────────────────────────────┘
+```
+
+## Project Structure
+
+```
+bot/
+├── Cargo.toml # Workspace root (members: ".", "migration")
+├── src/
+│ ├── main.rs # Entrypoint: tracing init → server::start()
+│ ├── config.rs # Config struct loaded from env vars
+│ ├── error.rs # AppError enum (thiserror) → Axum responses
+│ ├── scheduler.rs # Cron job scheduler (wraps tokio-cron-scheduler)
+│ ├── tests.rs # Unit tests for error handling & config
+│ ├── domain/
+│ │ ├── agent/
+│ │ │ ├── mod.rs # Agent struct: agentic loop with turn/time limits
+│ │ │ ├── api.rs # LLM request/response types, Tavily search client
+│ │ │ └── tools.rs # Tool definitions (google_search, finish) & dispatch
+│ │ ├── auth.rs # JwksVerifier (RSA/JWKS), Authenticator (code exchange, refresh)
+│ │ └── tasks.rs # Task execution logic, response DTOs
+│ ├── entities/
+│ │ ├── task.rs # SeaORM entity: tasks table
+│ │ └── task_run.rs # SeaORM entity: task_runs table (belongs_to task)
+│ └── server/
+│ ├── mod.rs # App bootstrap: DB, scheduler, auth, router, CORS
+│ ├── auth.rs # Auth routes & AuthenticatedUser extractor
+│ └── tasks.rs # Task CRUD & run endpoints
+├── migration/
+│ └── src/ # SeaORM migrations (tasks, answer col, runs table, cron col)
+├── frontend/
+│ ├── index.html # SPA shell with glassmorphism dark theme
+│ ├── src/
+│ │ ├── main.js # All app logic: auth flow, task/run rendering, polling
+│ │ └── style.css # Styles
+│ ├── vite.config.js # Dev proxy: /api → localhost:3000
+│ └── package.json # Deps: vite, marked, dompurify
+└── .forgejo/workflows/
+ └── pipeline.yaml # CI: build frontend + cargo build → deploy via systemd
+```
+
+## Agent System
+
+The agent (`src/domain/agent/`) is a turn-based autonomous loop:
+
+1. A system prompt is injected with the current date and instructions not to ask the user for clarification.
+2. The user's goal is sent as the initial message.
+3. Each turn calls the **Kimi K2.5** model via the Zen API (`https://opencode.ai/zen/v1/chat/completions`).
+4. The model can invoke tools:
+ - **`google_search`** — web search via the Tavily API.
+ - **`finish`** — signals completion and provides the final answer.
+5. Tool results are appended to the conversation and the loop continues.
+6. The loop terminates when `finish` is called, the turn limit is hit (`AGENT_MAX_TURNS`, default 20), or the time limit expires (`AGENT_MAX_DURATION_SECS`, default 120s).
+
+All turns and tool calls are logged. The final answer (if any) and the full log are persisted to the `task_runs` table.
+
+## Environment Variables
+
+| Variable | Required | Default | Description |
+|---------------------------|----------|---------|----------------------------------------------|
+| `DATABASE_URL` | ✅ | — | PostgreSQL connection string |
+| `PORT` | | `3000` | HTTP server port |
+| `ZEN_API_KEY` | | — | API key for Zen/Kimi LLM |
+| `TAVILY_API_KEY` | | — | API key for Tavily web search |
+| `AUTHENTIK_ISSUER` | ✅ | — | OIDC issuer URL (Authentik) |
+| `AUTHENTIK_CLIENT_ID` | ✅ | — | OAuth client ID |
+| `AUTHENTIK_CLIENT_SECRET` | ✅ | — | OAuth client secret |
+| `CORS_ALLOWED_ORIGINS` | | — | Comma-separated allowed origins (or mirror) |
+| `COOKIE_SECURE` | | `false` | Set `true` for HTTPS-only cookies |
+| `AGENT_MAX_TURNS` | | `20` | Max LLM turns per agent run |
+| `AGENT_MAX_DURATION_SECS` | | `120` | Max wall-clock seconds per agent run |
+
+## API Routes
+
+All task/run routes require authentication (cookie or Bearer token).
+
+| Method | Path | Description |
+|--------|------------------------|-----------------------------------|
+| GET | `/api/tasks` | List all tasks with their runs |
+| POST | `/api/tasks` | Create a new task |
+| GET | `/api/tasks/:id` | Get a single task with runs |
+| PUT | `/api/tasks/:id` | Update task goal/cron schedule |
+| POST | `/api/tasks/:id/runs` | Trigger a manual re-run |
+| GET | `/api/runs/recent` | Latest 50 runs across all tasks |
+| GET | `/api/auth/session` | Check current session |
+| GET | `/api/auth/callback` | OAuth code → token exchange |
+| POST | `/api/auth/refresh` | Refresh access token |
+| POST | `/api/auth/logout` | Clear auth cookies |
+
+## Database Schema
+
+**`tasks`** — `id` (UUID PK), `goal` (text), `cron` (text, nullable), `created_at` (timestamptz)
+
+**`task_runs`** — `id` (UUID PK), `task_id` (FK → tasks, cascade delete), `status` (text), `logs` (text), `answer` (text, nullable), `created_at` (timestamptz)
+
+Migrations are in `migration/src/` and run automatically on startup via `Migrator::up()`.
+
+## Development
+
+```bash
+# Backend (from repo root)
+cargo run # requires DATABASE_URL + Authentik vars
+
+# Frontend (from frontend/)
+npm install
+npm run dev # Vite dev server on :5173, proxies /api to :3000
+
+# Tests
+cargo test
+```
+
+## CI / Deployment
+
+The Forgejo Actions pipeline (`.forgejo/workflows/pipeline.yaml`) triggers on release publish:
+
+1. Builds the frontend (`npm install && npm run build`).
+2. Builds the Rust binary (`cargo build -r`).
+3. Uploads the binary as a release asset.
+4. Deploys to the host: copies binary + frontend dist, restarts the `bot` systemd user service.
diff --git a/frontend/index.html b/frontend/index.html
index ea8c9bd..4a60860 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -66,19 +66,39 @@
Recent Activity
Track latest agent executions across all directives.
-
-
-
-
- | Directive |
- Status |
- Date |
-
-
-
-
-
-
+
+
+
+
+
+ | Directive |
+ Status |
+ Date |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Start a conversation with the assistant.
+
+
+
+
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 64ef57e..76062aa 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -16,7 +16,8 @@ const state = {
selectedRunId: null,
currentView: 'dashboard', // 'dashboard' or 'task'
isEditing: false,
- isAuthenticated: false
+ isAuthenticated: false,
+ chatMessages: []
};
// DOM elements
const loginOverlay = document.getElementById('login-overlay');
@@ -53,6 +54,11 @@ const schedulePresets = document.getElementById('schedule-presets');
const toggleCustomCronBtn = document.getElementById('toggle-custom-cron');
const customCronContainer = document.getElementById('custom-cron-container');
const presetBtns = document.querySelectorAll('.btn-preset');
+const chatMessagesEl = document.getElementById('chat-messages');
+const chatForm = document.getElementById('chat-form');
+const chatInput = document.getElementById('chat-input');
+const clearChatBtn = document.getElementById('clear-chat-btn');
+const chatSendBtn = document.getElementById('chat-send-btn');
function updateState(newState) {
Object.assign(state, newState);
@@ -89,6 +95,7 @@ function renderApp() {
if (state.currentView === 'dashboard') {
fetchRecentRuns();
+ renderChat();
} else if (state.selectedTaskId) {
const task = state.tasks.find(t => t.id === state.selectedTaskId);
if (task) {
@@ -354,6 +361,62 @@ function escapeHtml(text) {
return div.innerHTML;
}
+function renderChat() {
+ if (!chatMessagesEl) return;
+
+ if (state.chatMessages.length === 0) {
+ chatMessagesEl.innerHTML = `
+
+
Start a conversation with the assistant.
+
+ `;
+ return;
+ }
+
+ chatMessagesEl.innerHTML = state.chatMessages
+ .map(msg => `
+
+ ${escapeHtml(msg.content)}
+
+ `)
+ .join('');
+
+ chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
+}
+
+async function sendChatMessage(text) {
+ const userMessage = { role: 'user', content: text };
+ state.chatMessages.push(userMessage);
+ renderChat();
+
+ chatInput.value = '';
+ chatInput.disabled = true;
+ chatSendBtn.disabled = true;
+
+ try {
+ const response = await fetchWithAuth(`${API_URL}/chat`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ messages: state.chatMessages })
+ });
+
+ if (!response.ok) throw new Error('Chat API failed');
+
+ const result = await response.json();
+ state.chatMessages.push(result.message);
+ renderChat();
+ } catch (error) {
+ console.error('Chat error:', error);
+ showToast('Failed to get chat response.', 'error');
+ state.chatMessages.push({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' });
+ renderChat();
+ } finally {
+ chatInput.disabled = false;
+ chatSendBtn.disabled = false;
+ chatInput.focus();
+ }
+}
+
// Event Listeners
rerunBtn.addEventListener('click', async () => {
if (!state.selectedTaskId) return;
@@ -447,6 +510,19 @@ toggleCustomCronBtn.addEventListener('click', () => {
customCronContainer.classList.toggle('hidden');
});
+chatForm.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const text = chatInput.value.trim();
+ if (text) {
+ sendChatMessage(text);
+ }
+});
+
+clearChatBtn.addEventListener('click', () => {
+ state.chatMessages = [];
+ renderChat();
+});
+
cronInput.addEventListener('input', () => {
// If user types manually, update presets active state
updateScheduleUI(cronInput.value);
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 0761f29..4552f6f 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -124,6 +124,104 @@ body {
background: rgba(255, 255, 255, 0.03);
}
+.dashboard-content-grid {
+ display: grid;
+ grid-template-columns: 1fr 350px;
+ gap: 24px;
+ flex: 1;
+ min-height: 0;
+}
+
+.chat-container {
+ display: flex;
+ flex-direction: column;
+ border-radius: 12px;
+ overflow: hidden;
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.chat-header {
+ padding: 16px 20px;
+ background: rgba(255, 255, 255, 0.03);
+ border-bottom: 1px solid var(--glass-border);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.chat-header h3 {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.chat-messages {
+ flex: 1;
+ padding: 20px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.chat-empty-state {
+ margin: auto;
+ text-align: center;
+ color: var(--text-dim);
+ font-style: italic;
+ font-size: 13px;
+ opacity: 0.6;
+}
+
+.chat-message {
+ max-width: 85%;
+ padding: 10px 14px;
+ border-radius: 12px;
+ font-size: 14px;
+ line-height: 1.4;
+ word-wrap: break-word;
+}
+
+.chat-message.user {
+ align-self: flex-end;
+ background: var(--primary);
+ color: white;
+ border-bottom-right-radius: 4px;
+}
+
+.chat-message.assistant {
+ align-self: flex-start;
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--text-main);
+ border-bottom-left-radius: 4px;
+}
+
+.chat-form {
+ padding: 16px;
+ background: rgba(255, 255, 255, 0.02);
+ border-top: 1px solid var(--glass-border);
+ display: flex;
+ gap: 10px;
+}
+
+.chat-form input {
+ flex: 1;
+ background: rgba(0, 0, 0, 0.2);
+ border: 1px solid var(--glass-border);
+ border-radius: 6px;
+ padding: 8px 12px;
+ color: var(--text-main);
+ font-size: 14px;
+ outline: none;
+ transition: var(--transition);
+}
+
+.chat-form input:focus {
+ border-color: var(--primary);
+}
+
.activity-table .status-badge {
display: inline-block;
}
diff --git a/src/domain/agent/api.rs b/src/domain/agent/api.rs
index 2d2ae37..f886c34 100644
--- a/src/domain/agent/api.rs
+++ b/src/domain/agent/api.rs
@@ -3,7 +3,6 @@ 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,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option>,
@@ -29,6 +28,7 @@ pub struct FunctionCall {
pub struct ChatRequest {
pub model: String,
pub messages: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option>,
}
@@ -72,6 +72,7 @@ pub async fn perform_search(
query: &str,
api_key: &str,
) -> Result> {
+ tracing::info!(query = %query, "Performing Tavily web search");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
@@ -94,6 +95,7 @@ pub async fn perform_search(
}
let search_data: TavilyResponse = response.json().await?;
+ tracing::info!("Web search yielded {} results", search_data.results.len());
let mut results_text = String::new();
for (i, result) in search_data.results.iter().enumerate() {
diff --git a/src/domain/agent/mod.rs b/src/domain/agent/mod.rs
index 9f6933f..c00f517 100644
--- a/src/domain/agent/mod.rs
+++ b/src/domain/agent/mod.rs
@@ -2,11 +2,13 @@ pub mod api;
pub mod tools;
use chrono::Utc;
+use sea_orm::DatabaseConnection;
use std::time::{Duration, Instant};
use self::api::{ChatRequest, ChatResponse, Message, Tool};
pub struct Agent {
+ db: DatabaseConnection,
client: reqwest::Client,
url: String,
zen_api_key: Option,
@@ -21,6 +23,7 @@ use crate::error::{AppError, AppResult};
impl Agent {
pub fn new(
+ db: DatabaseConnection,
zen_api_key: Option,
tavily_api_key: Option,
initial_message: String,
@@ -55,6 +58,7 @@ impl Agent {
.map_err(|e| AppError::Internal(format!("Failed to build HTTP client: {}", e)))?;
Ok(Self {
+ db,
client,
url: "https://opencode.ai/zen/v1/chat/completions".to_string(),
zen_api_key,
@@ -122,7 +126,7 @@ impl Agent {
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)
+ tools::handle_tool_call(&tool_call, &self.tavily_api_key, &self.db)
.await
.map_err(|e| {
AppError::Internal(format!("Tool execution failed: {}", e))
diff --git a/src/domain/agent/tools.rs b/src/domain/agent/tools.rs
index 663b1b1..bbdff4c 100644
--- a/src/domain/agent/tools.rs
+++ b/src/domain/agent/tools.rs
@@ -1,5 +1,9 @@
use super::api::{self, FunctionDefinition, Message, Tool, ToolCall};
+use sea_orm::{
+ ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect,
+};
use serde::Deserialize;
+use uuid::Uuid;
#[derive(Deserialize)]
struct GoogleSearchArgs {
@@ -11,6 +15,13 @@ struct FinishArgs {
result: String,
}
+#[derive(Deserialize)]
+struct ListRunsArgs {
+ from: Option,
+ to: Option,
+ task_ids: Option>,
+}
+
pub fn get_tools() -> Vec {
vec![
Tool {
@@ -47,12 +58,51 @@ pub fn get_tools() -> Vec {
}),
},
},
+ Tool {
+ tool_type: "function".to_string(),
+ function: FunctionDefinition {
+ name: "list_tasks".to_string(),
+ description: "List all existing tasks and their goals".to_string(),
+ parameters: serde_json::json!({
+ "type": "object",
+ "properties": {},
+ }),
+ },
+ },
+ Tool {
+ tool_type: "function".to_string(),
+ function: FunctionDefinition {
+ name: "list_runs".to_string(),
+ description: "List task runs with optional filters for date and task IDs"
+ .to_string(),
+ parameters: serde_json::json!({
+ "type": "object",
+ "properties": {
+ "from": {
+ "type": "string",
+ "description": "ISO 8601 date string to filter runs from"
+ },
+ "to": {
+ "type": "string",
+ "description": "ISO 8601 date string to filter runs to"
+ },
+ "task_ids": {
+ "type": "array",
+ "items": { "type": "string", "format": "uuid" },
+ "description": "List of task IDs to filter runs for"
+ },
+
+ },
+ }),
+ },
+ },
]
}
pub async fn handle_tool_call(
tool_call: &ToolCall,
tavily_api_key: &Option,
+ db: &DatabaseConnection,
) -> Result<(Message, bool, Option), Box> {
let mut answer = None;
let name = &tool_call.function.name;
@@ -76,6 +126,78 @@ pub async fn handle_tool_call(
answer = Some(result.clone());
(result.clone(), true)
+ } else if name == "list_tasks" {
+ tracing::info!("Listing tasks from database");
+ use crate::entities::task;
+ let tasks = task::Entity::find()
+ .order_by_desc(task::Column::CreatedAt)
+ .all(db)
+ .await?;
+
+ let mut out = String::from("Tasks:\n");
+ for t in tasks {
+ out.push_str(&format!("- ID: {}, Goal: {}\n", t.id, t.goal));
+ }
+ (out, false)
+ } else if name == "list_runs" {
+ use crate::entities::task_run;
+ tracing::info!(
+ "Listing runs from database, args: {}",
+ tool_call.function.arguments
+ );
+ let args: ListRunsArgs = serde_json::from_str(&tool_call.function.arguments)?;
+
+ tracing::info!(
+ "Listing runs from database with filters: from={:?}, to={:?}, task_ids={:?}",
+ args.from,
+ args.to,
+ args.task_ids
+ );
+
+ let query = task_run::Entity::find();
+
+ let mut condition = Condition::all();
+
+ if let Some(from_str) = args.from {
+ if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&from_str) {
+ condition = condition.add(task_run::Column::CreatedAt.gte(dt));
+ }
+ }
+
+ if let Some(to_str) = args.to {
+ if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&to_str) {
+ condition = condition.add(task_run::Column::CreatedAt.lte(dt));
+ }
+ }
+
+ if let Some(id_strs) = args.task_ids {
+ let mut valid_ids = Vec::new();
+ for id_str in id_strs {
+ match id_str.parse::() {
+ Ok(uuid) => valid_ids.push(uuid),
+ Err(e) => tracing::warn!("Skipping invalid UUID '{}' from LLM: {}", id_str, e),
+ }
+ }
+ if !valid_ids.is_empty() {
+ condition = condition.add(task_run::Column::TaskId.is_in(valid_ids));
+ }
+ }
+
+ let runs = query
+ .filter(condition)
+ .order_by_desc(task_run::Column::CreatedAt)
+ .limit(20)
+ .all(db)
+ .await?;
+
+ let mut out = String::from("Recent Runs:\n");
+ for r in runs {
+ out.push_str(&format!(
+ "- ID: {}, Task ID: {}, Status: {}, Created At: {}, Answer: {:?}\n",
+ r.id, r.task_id, r.status, r.created_at, r.answer
+ ));
+ }
+ (out, false)
} else {
(format!("Error: Unknown tool {}", name), false)
};
diff --git a/src/domain/tasks.rs b/src/domain/tasks.rs
index 5092091..3856fb2 100644
--- a/src/domain/tasks.rs
+++ b/src/domain/tasks.rs
@@ -76,6 +76,7 @@ pub async fn execute_agent_run(
.map_err(crate::error::AppError::Database)?;
let mut agent = Agent::new(
+ db.clone(),
config.zen_api_key.clone(),
config.tavily_api_key.clone(),
goal.clone(),
diff --git a/src/scheduler.rs b/src/scheduler.rs
index 2ee8a01..1bbbb95 100644
--- a/src/scheduler.rs
+++ b/src/scheduler.rs
@@ -107,6 +107,7 @@ impl Scheduler {
// Start agent in background
let mut agent = Agent::new(
+ db.clone(),
config.zen_api_key.clone(),
config.tavily_api_key.clone(),
task.goal.clone(),
diff --git a/src/server/chat.rs b/src/server/chat.rs
new file mode 100644
index 0000000..6e46350
--- /dev/null
+++ b/src/server/chat.rs
@@ -0,0 +1,130 @@
+use crate::domain::agent::api::{ChatRequest, ChatResponse, Message};
+use crate::error::AppError;
+use crate::server::AppState;
+use axum::{Json, extract::State};
+use serde::{Deserialize, Serialize};
+use std::sync::Arc;
+
+#[derive(Debug, Deserialize)]
+pub struct ChatPayload {
+ pub messages: Vec,
+}
+
+#[derive(Debug, Serialize)]
+pub struct ChatResult {
+ pub message: Message,
+}
+
+pub async fn chat_handler(
+ State(state): State>,
+ Json(payload): Json,
+) -> Result, AppError> {
+ 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;
+
+ loop {
+ turns += 1;
+ if turns > max_turns {
+ return Err(AppError::Internal("Chat turn limit exceeded".into()));
+ }
+
+ let request = ChatRequest {
+ model: "big-pickle".to_string(),
+ messages: messages.clone(),
+ tools: Some(tools.clone()),
+ };
+
+ 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,
+ }));
+ }
+}
diff --git a/src/server/mod.rs b/src/server/mod.rs
index 79d28e2..b40635f 100644
--- a/src/server/mod.rs
+++ b/src/server/mod.rs
@@ -1,4 +1,5 @@
pub mod auth;
+pub mod chat;
pub mod tasks;
use axum::{
@@ -120,6 +121,7 @@ fn build_app(state: Arc, config: &crate::config::Config) -> Router {
.route("/api/auth/callback", get(auth::auth_callback))
.route("/api/auth/refresh", post(auth::auth_refresh))
.route("/api/auth/logout", post(auth::auth_logout))
+ .route("/api/chat", post(chat::chat_handler))
.layer(cors)
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::CONTENT_SECURITY_POLICY,