chat agent
This commit is contained in:
parent
b9c6c831c1
commit
b44e4ed6f9
11 changed files with 626 additions and 16 deletions
154
AGENTS.md
Normal file
154
AGENTS.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -66,6 +66,7 @@
|
|||
<h2>Recent Activity</h2>
|
||||
<p>Track latest agent executions across all directives.</p>
|
||||
</header>
|
||||
<div class="dashboard-content-grid">
|
||||
<div class="dashboard-content glass">
|
||||
<table class="activity-table">
|
||||
<thead>
|
||||
|
|
@ -80,6 +81,25 @@
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="chat-container" class="chat-container glass">
|
||||
<div class="chat-header">
|
||||
<h3>Quick Chat</h3>
|
||||
<button id="clear-chat-btn" class="btn btn-ghost btn-sm">Clear</button>
|
||||
</div>
|
||||
<div id="chat-messages" class="chat-messages">
|
||||
<!-- Chat messages will be injected here -->
|
||||
<div class="chat-empty-state">
|
||||
<p>Start a conversation with the assistant.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="chat-form" class="chat-form">
|
||||
<input type="text" id="chat-input" placeholder="Type a message..." autocomplete="off"
|
||||
required>
|
||||
<button type="submit" id="chat-send-btn" class="btn btn-primary btn-sm">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="task-view" class="task-view hidden">
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<div class="chat-empty-state">
|
||||
<p>Start a conversation with the assistant.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
chatMessagesEl.innerHTML = state.chatMessages
|
||||
.map(msg => `
|
||||
<div class="chat-message ${msg.role}">
|
||||
${escapeHtml(msg.content)}
|
||||
</div>
|
||||
`)
|
||||
.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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
|
|
@ -29,6 +28,7 @@ pub struct FunctionCall {
|
|||
pub struct ChatRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<Message>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<Tool>>,
|
||||
}
|
||||
|
||||
|
|
@ -72,6 +72,7 @@ pub async fn perform_search(
|
|||
query: &str,
|
||||
api_key: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
|
|
@ -21,6 +23,7 @@ use crate::error::{AppError, AppResult};
|
|||
|
||||
impl Agent {
|
||||
pub fn new(
|
||||
db: DatabaseConnection,
|
||||
zen_api_key: Option<String>,
|
||||
tavily_api_key: Option<String>,
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
to: Option<String>,
|
||||
task_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub fn get_tools() -> Vec<Tool> {
|
||||
vec![
|
||||
Tool {
|
||||
|
|
@ -47,12 +58,51 @@ pub fn get_tools() -> Vec<Tool> {
|
|||
}),
|
||||
},
|
||||
},
|
||||
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<String>,
|
||||
db: &DatabaseConnection,
|
||||
) -> Result<(Message, bool, Option<String>), Box<dyn std::error::Error>> {
|
||||
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::<Uuid>() {
|
||||
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)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
130
src/server/chat.rs
Normal file
130
src/server/chat.rs
Normal file
|
|
@ -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<Message>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ChatResult {
|
||||
pub message: Message,
|
||||
}
|
||||
|
||||
pub async fn chat_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<ChatPayload>,
|
||||
) -> Result<Json<ChatResult>, 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod auth;
|
||||
pub mod chat;
|
||||
pub mod tasks;
|
||||
|
||||
use axum::{
|
||||
|
|
@ -120,6 +121,7 @@ fn build_app(state: Arc<AppState>, 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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue