ares/api/handlers/
user_agents.rs

1//! User agents API handlers
2//!
3//! This module provides CRUD endpoints for user-created agents with TOON import/export support.
4
5use crate::auth::middleware::AuthUser;
6use crate::db::turso::UserAgent;
7use crate::types::{AppError, Result};
8use crate::utils::toon_config::ToonAgentConfig;
9use crate::AppState;
10use axum::{
11    extract::{Path, Query, State},
12    http::{header, StatusCode},
13    response::{IntoResponse, Response},
14    Json,
15};
16use chrono::Utc;
17use serde::{Deserialize, Serialize};
18use toon_format::{decode_default, encode_default};
19
20// ============= Request/Response Types =============
21
22/// Request to create a new user agent
23#[derive(Debug, Deserialize)]
24pub struct CreateAgentRequest {
25    pub name: String,
26    #[serde(default)]
27    pub display_name: Option<String>,
28    #[serde(default)]
29    pub description: Option<String>,
30    pub model: String,
31    #[serde(default)]
32    pub system_prompt: Option<String>,
33    #[serde(default)]
34    pub tools: Vec<String>,
35    #[serde(default = "default_max_tool_iterations")]
36    pub max_tool_iterations: i32,
37    #[serde(default)]
38    pub parallel_tools: bool,
39    #[serde(default)]
40    pub is_public: bool,
41}
42
43fn default_max_tool_iterations() -> i32 {
44    10
45}
46
47/// Response after creating an agent
48#[derive(Debug, Serialize)]
49pub struct CreateAgentResponse {
50    pub id: String,
51    pub name: String,
52    pub display_name: Option<String>,
53    pub created_at: i64,
54    pub api_endpoint: String,
55    /// TOON serialization for preview/export
56    pub toon_preview: String,
57}
58
59/// Response for agent details
60#[derive(Debug, Serialize)]
61pub struct AgentResponse {
62    pub id: String,
63    pub name: String,
64    pub display_name: Option<String>,
65    pub description: Option<String>,
66    pub model: String,
67    pub system_prompt: Option<String>,
68    pub tools: Vec<String>,
69    pub max_tool_iterations: i32,
70    pub parallel_tools: bool,
71    pub is_public: bool,
72    pub usage_count: i32,
73    pub average_rating: Option<f32>,
74    pub created_at: i64,
75    pub updated_at: i64,
76    /// Source of the agent: "user", "community", or "system"
77    pub source: String,
78}
79
80/// Query parameters for get agent
81#[derive(Debug, Deserialize, Default)]
82pub struct GetAgentQuery {
83    /// Format: "json" (default) or "toon"
84    #[serde(default)]
85    pub format: Option<String>,
86}
87
88/// Query parameters for listing agents
89#[derive(Debug, Deserialize, Default)]
90pub struct ListAgentsQuery {
91    /// Include public/community agents
92    #[serde(default)]
93    pub include_public: bool,
94    /// Limit results
95    #[serde(default = "default_limit")]
96    pub limit: u32,
97    /// Offset for pagination
98    #[serde(default)]
99    pub offset: u32,
100}
101
102fn default_limit() -> u32 {
103    50
104}
105
106/// Request to update an agent
107#[derive(Debug, Deserialize)]
108pub struct UpdateAgentRequest {
109    #[serde(default)]
110    pub display_name: Option<String>,
111    #[serde(default)]
112    pub description: Option<String>,
113    #[serde(default)]
114    pub model: Option<String>,
115    #[serde(default)]
116    pub system_prompt: Option<String>,
117    #[serde(default)]
118    pub tools: Option<Vec<String>>,
119    #[serde(default)]
120    pub max_tool_iterations: Option<i32>,
121    #[serde(default)]
122    pub parallel_tools: Option<bool>,
123    #[serde(default)]
124    pub is_public: Option<bool>,
125}
126
127// ============= Handlers =============
128
129/// Create a new user agent
130///
131/// POST /api/user/agents
132pub async fn create_agent(
133    State(state): State<AppState>,
134    user: AuthUser,
135    Json(req): Json<CreateAgentRequest>,
136) -> Result<Json<CreateAgentResponse>> {
137    // Validate agent name (alphanumeric, hyphens, underscores)
138    if !is_valid_agent_name(&req.name) {
139        return Err(AppError::InvalidInput(
140            "Agent name must be alphanumeric with hyphens and underscores only".to_string(),
141        ));
142    }
143
144    // Check if agent name already exists for this user
145    if state
146        .turso
147        .get_user_agent_by_name(&user.0.sub, &req.name)
148        .await?
149        .is_some()
150    {
151        return Err(AppError::InvalidInput(format!(
152            "Agent '{}' already exists",
153            req.name
154        )));
155    }
156
157    // Validate model exists (check TOON config first, then TOML config)
158    let model_exists = state.dynamic_config.model(&req.model).is_some()
159        || state
160            .config_manager
161            .config()
162            .get_model(&req.model)
163            .is_some();
164
165    if !model_exists {
166        return Err(AppError::InvalidInput(format!(
167            "Model '{}' not found. Available models: {:?}",
168            req.model,
169            state.dynamic_config.model_names()
170        )));
171    }
172
173    // Validate tools exist
174    for tool in &req.tools {
175        let tool_exists = state.dynamic_config.tool(tool).is_some()
176            || state.config_manager.config().get_tool(tool).is_some();
177
178        if !tool_exists {
179            return Err(AppError::InvalidInput(format!(
180                "Tool '{}' not found. Available tools: {:?}",
181                tool,
182                state.dynamic_config.tool_names()
183            )));
184        }
185    }
186
187    // Create ToonAgentConfig for TOON serialization
188    let agent_config = ToonAgentConfig {
189        name: req.name.clone(),
190        model: req.model.clone(),
191        system_prompt: req.system_prompt.clone(),
192        tools: req.tools.clone(),
193        max_tool_iterations: req.max_tool_iterations as usize,
194        parallel_tools: req.parallel_tools,
195        extra: std::collections::HashMap::new(),
196    };
197
198    // Generate TOON preview
199    let toon_preview = encode_default(&agent_config)
200        .map_err(|e| AppError::Internal(format!("TOON encode error: {}", e)))?;
201
202    let id = uuid::Uuid::new_v4().to_string();
203    let now = Utc::now().timestamp();
204
205    let agent = UserAgent {
206        id: id.clone(),
207        user_id: user.0.sub.clone(),
208        name: req.name.clone(),
209        display_name: req.display_name.clone(),
210        description: req.description,
211        model: req.model,
212        system_prompt: req.system_prompt,
213        tools: serde_json::to_string(&req.tools).unwrap_or_else(|_| "[]".to_string()),
214        max_tool_iterations: req.max_tool_iterations,
215        parallel_tools: req.parallel_tools,
216        extra: "{}".to_string(),
217        is_public: req.is_public,
218        usage_count: 0,
219        rating_sum: 0,
220        rating_count: 0,
221        created_at: now,
222        updated_at: now,
223    };
224
225    state.turso.create_user_agent(&agent).await?;
226
227    Ok(Json(CreateAgentResponse {
228        id,
229        name: req.name.clone(),
230        display_name: req.display_name,
231        created_at: now,
232        api_endpoint: format!("/api/user/agents/{}/chat", req.name),
233        toon_preview,
234    }))
235}
236
237/// Import agent from TOON format
238///
239/// POST /api/user/agents/import
240/// Content-Type: text/x-toon
241pub async fn import_agent_toon(
242    State(state): State<AppState>,
243    user: AuthUser,
244    body: String,
245) -> Result<Json<CreateAgentResponse>> {
246    // Parse TOON
247    let agent_config: ToonAgentConfig = decode_default(&body)
248        .map_err(|e| AppError::InvalidInput(format!("Invalid TOON format: {}", e)))?;
249
250    // Convert to CreateAgentRequest and delegate
251    let req = CreateAgentRequest {
252        name: agent_config.name,
253        display_name: None,
254        description: None,
255        model: agent_config.model,
256        system_prompt: agent_config.system_prompt,
257        tools: agent_config.tools,
258        max_tool_iterations: agent_config.max_tool_iterations as i32,
259        parallel_tools: agent_config.parallel_tools,
260        is_public: false,
261    };
262
263    create_agent(State(state), user, Json(req)).await
264}
265
266/// Get a user agent by name
267///
268/// GET /api/user/agents/:name
269pub async fn get_agent(
270    State(state): State<AppState>,
271    Path(name): Path<String>,
272    Query(params): Query<GetAgentQuery>,
273    user: AuthUser,
274) -> Result<Response> {
275    // Resolve agent using the three-tier hierarchy
276    let (agent, source) = resolve_agent(&state, &user.0.sub, &name).await?;
277
278    match params.format.as_deref() {
279        Some("toon") => {
280            // Convert to ToonAgentConfig and serialize
281            let config = ToonAgentConfig {
282                name: agent.name.clone(),
283                model: agent.model.clone(),
284                system_prompt: agent.system_prompt.clone(),
285                tools: agent.tools_vec(),
286                max_tool_iterations: agent.max_tool_iterations as usize,
287                parallel_tools: agent.parallel_tools,
288                extra: std::collections::HashMap::new(),
289            };
290
291            let toon = encode_default(&config)
292                .map_err(|e| AppError::Internal(format!("TOON encode error: {}", e)))?;
293
294            Ok(([(header::CONTENT_TYPE, "text/x-toon")], toon).into_response())
295        }
296        _ => {
297            let tools = agent.tools_vec();
298            let avg_rating = agent.average_rating();
299            Ok(Json(AgentResponse {
300                id: agent.id,
301                name: agent.name,
302                display_name: agent.display_name,
303                description: agent.description,
304                model: agent.model,
305                system_prompt: agent.system_prompt,
306                tools,
307                max_tool_iterations: agent.max_tool_iterations,
308                parallel_tools: agent.parallel_tools,
309                is_public: agent.is_public,
310                usage_count: agent.usage_count,
311                average_rating: avg_rating,
312                created_at: agent.created_at,
313                updated_at: agent.updated_at,
314                source,
315            })
316            .into_response())
317        }
318    }
319}
320
321/// List user agents
322///
323/// GET /api/user/agents
324pub async fn list_agents(
325    State(state): State<AppState>,
326    Query(params): Query<ListAgentsQuery>,
327    user: AuthUser,
328) -> Result<Json<Vec<AgentResponse>>> {
329    let mut agents = Vec::new();
330
331    // Get user's own agents
332    let user_agents = state.turso.list_user_agents(&user.0.sub).await?;
333    for agent in user_agents {
334        agents.push(user_agent_to_response(agent, "user".to_string()));
335    }
336
337    // Include public agents if requested
338    if params.include_public {
339        let public_agents = state
340            .turso
341            .list_public_agents(params.limit, params.offset)
342            .await?;
343
344        for agent in public_agents {
345            // Skip if user already owns this agent
346            if agents.iter().any(|a| a.name == agent.name) {
347                continue;
348            }
349
350            agents.push(user_agent_to_response(agent, "community".to_string()));
351        }
352    }
353
354    Ok(Json(agents))
355}
356
357/// Update a user agent
358///
359/// PUT /api/user/agents/:name
360pub async fn update_agent(
361    State(state): State<AppState>,
362    Path(name): Path<String>,
363    user: AuthUser,
364    Json(req): Json<UpdateAgentRequest>,
365) -> Result<Json<AgentResponse>> {
366    // Get existing agent
367    let mut agent = state
368        .turso
369        .get_user_agent_by_name(&user.0.sub, &name)
370        .await?
371        .ok_or_else(|| AppError::NotFound(format!("Agent '{}' not found", name)))?;
372
373    // Verify ownership
374    if agent.user_id != user.0.sub {
375        return Err(AppError::Auth(
376            "You can only update your own agents".to_string(),
377        ));
378    }
379
380    // Apply updates
381    if let Some(display_name) = req.display_name {
382        agent.display_name = Some(display_name);
383    }
384    if let Some(description) = req.description {
385        agent.description = Some(description);
386    }
387    if let Some(model) = req.model {
388        // Validate model exists
389        let model_exists = state.dynamic_config.model(&model).is_some()
390            || state.config_manager.config().get_model(&model).is_some();
391        if !model_exists {
392            return Err(AppError::InvalidInput(format!(
393                "Model '{}' not found",
394                model
395            )));
396        }
397        agent.model = model;
398    }
399    if let Some(system_prompt) = req.system_prompt {
400        agent.system_prompt = Some(system_prompt);
401    }
402    if let Some(tools) = req.tools {
403        // Validate tools exist
404        for tool in &tools {
405            let tool_exists = state.dynamic_config.tool(tool).is_some()
406                || state.config_manager.config().get_tool(tool).is_some();
407            if !tool_exists {
408                return Err(AppError::InvalidInput(format!("Tool '{}' not found", tool)));
409            }
410        }
411        agent.set_tools(tools);
412    }
413    if let Some(max_tool_iterations) = req.max_tool_iterations {
414        agent.max_tool_iterations = max_tool_iterations;
415    }
416    if let Some(parallel_tools) = req.parallel_tools {
417        agent.parallel_tools = parallel_tools;
418    }
419    if let Some(is_public) = req.is_public {
420        agent.is_public = is_public;
421    }
422
423    agent.updated_at = Utc::now().timestamp();
424
425    state.turso.update_user_agent(&agent).await?;
426
427    Ok(Json(user_agent_to_response(agent, "user".to_string())))
428}
429
430/// Delete a user agent
431///
432/// DELETE /api/user/agents/:name
433pub async fn delete_agent(
434    State(state): State<AppState>,
435    Path(name): Path<String>,
436    user: AuthUser,
437) -> Result<StatusCode> {
438    // Get agent to verify ownership
439    let agent = state
440        .turso
441        .get_user_agent_by_name(&user.0.sub, &name)
442        .await?
443        .ok_or_else(|| AppError::NotFound(format!("Agent '{}' not found", name)))?;
444
445    // Delete agent
446    let deleted = state
447        .turso
448        .delete_user_agent(&agent.id, &user.0.sub)
449        .await?;
450
451    if deleted {
452        Ok(StatusCode::NO_CONTENT)
453    } else {
454        Err(AppError::NotFound(format!("Agent '{}' not found", name)))
455    }
456}
457
458/// Export agent as TOON file
459///
460/// GET /api/user/agents/:name/export
461pub async fn export_agent_toon(
462    State(state): State<AppState>,
463    Path(name): Path<String>,
464    user: AuthUser,
465) -> Result<Response> {
466    let (agent, _) = resolve_agent(&state, &user.0.sub, &name).await?;
467
468    let tools = agent.tools_vec();
469    let filename = format!("{}.toon", agent.name);
470    let config = ToonAgentConfig {
471        name: agent.name,
472        model: agent.model,
473        system_prompt: agent.system_prompt,
474        tools,
475        max_tool_iterations: agent.max_tool_iterations as usize,
476        parallel_tools: agent.parallel_tools,
477        extra: std::collections::HashMap::new(),
478    };
479
480    let toon = encode_default(&config)
481        .map_err(|e| AppError::Internal(format!("TOON encode error: {}", e)))?;
482
483    Ok((
484        [
485            (header::CONTENT_TYPE, "text/x-toon"),
486            (
487                header::CONTENT_DISPOSITION,
488                &format!("attachment; filename=\"{}\"", filename),
489            ),
490        ],
491        toon,
492    )
493        .into_response())
494}
495
496// ============= Helper Functions =============
497
498/// Validate agent name (alphanumeric, hyphens, underscores)
499fn is_valid_agent_name(name: &str) -> bool {
500    !name.is_empty()
501        && name.len() <= 64
502        && name
503            .chars()
504            .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
505}
506
507/// Convert UserAgent to AgentResponse
508fn user_agent_to_response(agent: UserAgent, source: String) -> AgentResponse {
509    let tools = agent.tools_vec();
510    let avg_rating = agent.average_rating();
511    AgentResponse {
512        id: agent.id,
513        name: agent.name,
514        display_name: agent.display_name,
515        description: agent.description,
516        model: agent.model,
517        system_prompt: agent.system_prompt,
518        tools,
519        max_tool_iterations: agent.max_tool_iterations,
520        parallel_tools: agent.parallel_tools,
521        is_public: agent.is_public,
522        usage_count: agent.usage_count,
523        average_rating: avg_rating,
524        created_at: agent.created_at,
525        updated_at: agent.updated_at,
526        source,
527    }
528}
529
530/// Resolve agent by checking: user private -> user public -> community -> system
531/// Returns (UserAgent, source) where source is "user", "community", or "system"
532pub async fn resolve_agent(
533    state: &AppState,
534    user_id: &str,
535    name: &str,
536) -> Result<(UserAgent, String)> {
537    // 1. Check user's agents (private + public)
538    if let Some(agent) = state.turso.get_user_agent_by_name(user_id, name).await? {
539        return Ok((agent, "user".to_string()));
540    }
541
542    // 2. Check community agents (public agents from other users)
543    if let Some(agent) = state.turso.get_public_agent_by_name(name).await? {
544        return Ok((agent, "community".to_string()));
545    }
546
547    // 3. Check system agents (TOON config)
548    if let Some(config) = state.dynamic_config.agent(name) {
549        // Convert ToonAgentConfig to UserAgent for consistent response
550        let agent = UserAgent {
551            id: format!("system-{}", name),
552            user_id: "system".to_string(),
553            name: config.name,
554            display_name: None,
555            description: None,
556            model: config.model,
557            system_prompt: config.system_prompt,
558            tools: serde_json::to_string(&config.tools).unwrap_or_else(|_| "[]".to_string()),
559            max_tool_iterations: config.max_tool_iterations as i32,
560            parallel_tools: config.parallel_tools,
561            extra: "{}".to_string(),
562            is_public: true, // System agents are always "public"
563            usage_count: 0,
564            rating_sum: 0,
565            rating_count: 0,
566            created_at: 0,
567            updated_at: 0,
568        };
569        return Ok((agent, "system".to_string()));
570    }
571
572    // 4. Fallback: Check TOML config (legacy)
573    if let Some(config) = state.config_manager.config().get_agent(name) {
574        let agent = UserAgent {
575            id: format!("system-{}", name),
576            user_id: "system".to_string(),
577            name: name.to_string(),
578            display_name: None,
579            description: None,
580            model: config.model.clone(),
581            system_prompt: config.system_prompt.clone(),
582            tools: serde_json::to_string(&config.tools).unwrap_or_else(|_| "[]".to_string()),
583            max_tool_iterations: config.max_tool_iterations as i32,
584            parallel_tools: config.parallel_tools,
585            extra: "{}".to_string(),
586            is_public: true,
587            usage_count: 0,
588            rating_sum: 0,
589            rating_count: 0,
590            created_at: 0,
591            updated_at: 0,
592        };
593        return Ok((agent, "system".to_string()));
594    }
595
596    Err(AppError::NotFound(format!("Agent '{}' not found", name)))
597}