From b44e4ed6f9254978c9ae9fc0642c5a4b38fc742e Mon Sep 17 00:00:00 2001 From: pavel Date: Wed, 11 Feb 2026 16:43:05 +0100 Subject: [PATCH] chat agent --- AGENTS.md | 154 ++++++++++++++++++++++++++++++++++++++ frontend/index.html | 46 ++++++++---- frontend/src/main.js | 78 ++++++++++++++++++- frontend/src/style.css | 98 ++++++++++++++++++++++++ src/domain/agent/api.rs | 4 +- src/domain/agent/mod.rs | 6 +- src/domain/agent/tools.rs | 122 ++++++++++++++++++++++++++++++ src/domain/tasks.rs | 1 + src/scheduler.rs | 1 + src/server/chat.rs | 130 ++++++++++++++++++++++++++++++++ src/server/mod.rs | 2 + 11 files changed, 626 insertions(+), 16 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/server/chat.rs 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.

-
- - - - - - - - - - - -
DirectiveStatusDate
+
+
+ + + + + + + + + + + +
DirectiveStatusDate
+
+ +
+
+

Quick Chat

+ +
+
+ +
+

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,