Skip to main content

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};
19use utoipa::{IntoParams, ToSchema};
20
21// ============= Request/Response Types =============
22
23/// Request to create a new user agent
24#[derive(Debug, Deserialize, ToSchema)]
25pub struct CreateAgentRequest {
26    /// Unique agent name (alphanumeric, hyphens, underscores)
27    pub name: String,
28    /// Human-readable display name
29    #[serde(default)]
30    pub display_name: Option<String>,
31    /// Agent description
32    #[serde(default)]
33    pub description: Option<String>,
34    /// LLM model identifier (e.g., "gpt-4", "llama3.2")
35    pub model: String,
36    /// System prompt defining agent behavior
37    #[serde(default)]
38    pub system_prompt: Option<String>,
39    /// List of tool names the agent can use
40    #[serde(default)]
41    pub tools: Vec<String>,
42    /// Maximum iterations for tool use loops
43    #[serde(default = "default_max_tool_iterations")]
44    pub max_tool_iterations: i32,
45    /// Whether to execute tools in parallel
46    #[serde(default)]
47    pub parallel_tools: bool,
48    /// Whether agent is visible in community marketplace
49    #[serde(default)]
50    pub is_public: bool,
51}
52
53fn default_max_tool_iterations() -> i32 {
54    10
55}
56
57/// Response after creating an agent
58#[derive(Debug, Serialize, ToSchema)]
59pub struct CreateAgentResponse {
60    /// Unique agent identifier (UUID)
61    pub id: String,
62    /// Agent name
63    pub name: String,
64    /// Human-readable display name
65    pub display_name: Option<String>,
66    /// Unix timestamp of creation
67    pub created_at: i64,
68    /// API endpoint for chatting with this agent
69    pub api_endpoint: String,
70    /// TOON serialization for preview/export
71    pub toon_preview: String,
72}
73
74/// Response for agent details
75#[derive(Debug, Serialize, ToSchema)]
76pub struct AgentResponse {
77    /// Unique agent identifier (UUID)
78    pub id: String,
79    /// Agent name
80    pub name: String,
81    /// Human-readable display name
82    pub display_name: Option<String>,
83    /// Agent description
84    pub description: Option<String>,
85    /// LLM model identifier
86    pub model: String,
87    /// System prompt defining agent behavior
88    pub system_prompt: Option<String>,
89    /// List of tool names the agent can use
90    pub tools: Vec<String>,
91    /// Maximum iterations for tool use loops
92    pub max_tool_iterations: i32,
93    /// Whether to execute tools in parallel
94    pub parallel_tools: bool,
95    /// Whether agent is visible in community marketplace
96    pub is_public: bool,
97    /// Number of times this agent has been used
98    pub usage_count: i32,
99    /// Average rating (1-5 stars)
100    pub average_rating: Option<f32>,
101    /// Unix timestamp of creation
102    pub created_at: i64,
103    /// Unix timestamp of last update
104    pub updated_at: i64,
105    /// Source of the agent: "user", "community", or "system"
106    pub source: String,
107}
108
109/// Query parameters for get agent
110#[derive(Debug, Deserialize, Default, ToSchema, IntoParams)]
111pub struct GetAgentQuery {
112    /// Format: "json" (default) or "toon"
113    #[serde(default)]
114    pub format: Option<String>,
115}
116
117/// Query parameters for listing agents
118#[derive(Debug, Deserialize, Default, ToSchema, IntoParams)]
119pub struct ListAgentsQuery {
120    /// Include public/community agents
121    #[serde(default)]
122    pub include_public: bool,
123    /// Limit results
124    #[serde(default = "default_limit")]
125    pub limit: u32,
126    /// Offset for pagination
127    #[serde(default)]
128    pub offset: u32,
129}
130
131fn default_limit() -> u32 {
132    50
133}
134
135/// Request to update an agent
136#[derive(Debug, Deserialize, ToSchema)]
137pub struct UpdateAgentRequest {
138    /// Human-readable display name
139    #[serde(default)]
140    pub display_name: Option<String>,
141    /// Agent description
142    #[serde(default)]
143    pub description: Option<String>,
144    /// LLM model identifier
145    #[serde(default)]
146    pub model: Option<String>,
147    /// System prompt defining agent behavior
148    #[serde(default)]
149    pub system_prompt: Option<String>,
150    /// List of tool names the agent can use
151    #[serde(default)]
152    pub tools: Option<Vec<String>>,
153    /// Maximum iterations for tool use loops
154    #[serde(default)]
155    pub max_tool_iterations: Option<i32>,
156    /// Whether to execute tools in parallel
157    #[serde(default)]
158    pub parallel_tools: Option<bool>,
159    /// Whether agent is visible in community marketplace
160    #[serde(default)]
161    pub is_public: Option<bool>,
162}
163
164// ============= Handlers =============
165
166/// Create a new user agent
167///
168/// POST /api/user/agents
169#[utoipa::path(
170    post,
171    path = "/api/user/agents",
172    request_body = CreateAgentRequest,
173    responses(
174        (status = 200, description = "Agent created successfully", body = CreateAgentResponse),
175        (status = 400, description = "Invalid input"),
176        (status = 401, description = "Unauthorized"),
177        (status = 409, description = "Agent already exists")
178    ),
179    tag = "user_agents",
180    security(("bearer" = []))
181)]
182pub async fn create_agent(
183    State(state): State<AppState>,
184    user: AuthUser,
185    Json(req): Json<CreateAgentRequest>,
186) -> Result<Json<CreateAgentResponse>> {
187    // Validate agent name (alphanumeric, hyphens, underscores)
188    if !is_valid_agent_name(&req.name) {
189        return Err(AppError::InvalidInput(
190            "Agent name must be alphanumeric with hyphens and underscores only".to_string(),
191        ));
192    }
193
194    // Check if agent name already exists for this user
195    if state
196        .turso
197        .get_user_agent_by_name(&user.0.sub, &req.name)
198        .await?
199        .is_some()
200    {
201        return Err(AppError::InvalidInput(format!(
202            "Agent '{}' already exists",
203            req.name
204        )));
205    }
206
207    // Validate model exists (check TOON config first, then TOML config)
208    let model_exists = state.dynamic_config.model(&req.model).is_some()
209        || state
210            .config_manager
211            .config()
212            .get_model(&req.model)
213            .is_some();
214
215    if !model_exists {
216        return Err(AppError::InvalidInput(format!(
217            "Model '{}' not found. Available models: {:?}",
218            req.model,
219            state.dynamic_config.model_names()
220        )));
221    }
222
223    // Validate tools exist
224    for tool in &req.tools {
225        let tool_exists = state.dynamic_config.tool(tool).is_some()
226            || state.config_manager.config().get_tool(tool).is_some();
227
228        if !tool_exists {
229            return Err(AppError::InvalidInput(format!(
230                "Tool '{}' not found. Available tools: {:?}",
231                tool,
232                state.dynamic_config.tool_names()
233            )));
234        }
235    }
236
237    // Create ToonAgentConfig for TOON serialization
238    let agent_config = ToonAgentConfig {
239        name: req.name.clone(),
240        model: req.model.clone(),
241        system_prompt: req.system_prompt.clone(),
242        tools: req.tools.clone(),
243        max_tool_iterations: req.max_tool_iterations as usize,
244        parallel_tools: req.parallel_tools,
245        extra: std::collections::HashMap::new(),
246    };
247
248    // Generate TOON preview
249    let toon_preview = encode_default(&agent_config)
250        .map_err(|e| AppError::Internal(format!("TOON encode error: {}", e)))?;
251
252    let id = uuid::Uuid::new_v4().to_string();
253    let now = Utc::now().timestamp();
254
255    let agent = UserAgent {
256        id: id.clone(),
257        user_id: user.0.sub.clone(),
258        name: req.name.clone(),
259        display_name: req.display_name.clone(),
260        description: req.description,
261        model: req.model,
262        system_prompt: req.system_prompt,
263        tools: serde_json::to_string(&req.tools).unwrap_or_else(|_| "[]".to_string()),
264        max_tool_iterations: req.max_tool_iterations,
265        parallel_tools: req.parallel_tools,
266        extra: "{}".to_string(),
267        is_public: req.is_public,
268        usage_count: 0,
269        rating_sum: 0,
270        rating_count: 0,
271        created_at: now,
272        updated_at: now,
273    };
274
275    state.turso.create_user_agent(&agent).await?;
276
277    Ok(Json(CreateAgentResponse {
278        id,
279        name: req.name.clone(),
280        display_name: req.display_name,
281        created_at: now,
282        api_endpoint: format!("/api/user/agents/{}/chat", req.name),
283        toon_preview,
284    }))
285}
286
287/// Import agent from TOON format
288///
289/// POST /api/user/agents/import
290/// Content-Type: text/x-toon
291#[utoipa::path(
292    post,
293    path = "/api/user/agents/import",
294    request_body(content = String, content_type = "text/x-toon"),
295    responses(
296        (status = 200, description = "Agent imported successfully", body = CreateAgentResponse),
297        (status = 400, description = "Invalid TOON format"),
298        (status = 401, description = "Unauthorized")
299    ),
300    tag = "user_agents",
301    security(("bearer" = []))
302)]
303pub async fn import_agent_toon(
304    State(state): State<AppState>,
305    user: AuthUser,
306    body: String,
307) -> Result<Json<CreateAgentResponse>> {
308    // Parse TOON
309    let agent_config: ToonAgentConfig = decode_default(&body)
310        .map_err(|e| AppError::InvalidInput(format!("Invalid TOON format: {}", e)))?;
311
312    // Convert to CreateAgentRequest and delegate
313    let req = CreateAgentRequest {
314        name: agent_config.name,
315        display_name: None,
316        description: None,
317        model: agent_config.model,
318        system_prompt: agent_config.system_prompt,
319        tools: agent_config.tools,
320        max_tool_iterations: agent_config.max_tool_iterations as i32,
321        parallel_tools: agent_config.parallel_tools,
322        is_public: false,
323    };
324
325    create_agent(State(state), user, Json(req)).await
326}
327
328/// Get a user agent by name
329///
330/// GET /api/user/agents/:name
331#[utoipa::path(
332    get,
333    path = "/api/user/agents/{name}",
334    params(
335        ("name" = String, Path, description = "Agent name"),
336        GetAgentQuery
337    ),
338    responses(
339        (status = 200, description = "Agent details", body = AgentResponse),
340        (status = 200, description = "Agent in TOON format", content_type = "text/x-toon"),
341        (status = 401, description = "Unauthorized"),
342        (status = 404, description = "Agent not found")
343    ),
344    tag = "user_agents",
345    security(("bearer" = []))
346)]
347pub async fn get_agent(
348    State(state): State<AppState>,
349    Path(name): Path<String>,
350    Query(params): Query<GetAgentQuery>,
351    user: AuthUser,
352) -> Result<Response> {
353    // Resolve agent using the three-tier hierarchy
354    let (agent, source) = resolve_agent(&state, &user.0.sub, &name).await?;
355
356    match params.format.as_deref() {
357        Some("toon") => {
358            // Convert to ToonAgentConfig and serialize
359            let config = ToonAgentConfig {
360                name: agent.name.clone(),
361                model: agent.model.clone(),
362                system_prompt: agent.system_prompt.clone(),
363                tools: agent.tools_vec(),
364                max_tool_iterations: agent.max_tool_iterations as usize,
365                parallel_tools: agent.parallel_tools,
366                extra: std::collections::HashMap::new(),
367            };
368
369            let toon = encode_default(&config)
370                .map_err(|e| AppError::Internal(format!("TOON encode error: {}", e)))?;
371
372            Ok(([(header::CONTENT_TYPE, "text/x-toon")], toon).into_response())
373        }
374        _ => {
375            let tools = agent.tools_vec();
376            let avg_rating = agent.average_rating();
377            Ok(Json(AgentResponse {
378                id: agent.id,
379                name: agent.name,
380                display_name: agent.display_name,
381                description: agent.description,
382                model: agent.model,
383                system_prompt: agent.system_prompt,
384                tools,
385                max_tool_iterations: agent.max_tool_iterations,
386                parallel_tools: agent.parallel_tools,
387                is_public: agent.is_public,
388                usage_count: agent.usage_count,
389                average_rating: avg_rating,
390                created_at: agent.created_at,
391                updated_at: agent.updated_at,
392                source,
393            })
394            .into_response())
395        }
396    }
397}
398
399/// List user agents
400///
401/// GET /api/user/agents
402#[utoipa::path(
403    get,
404    path = "/api/user/agents",
405    params(ListAgentsQuery),
406    responses(
407        (status = 200, description = "List of agents", body = Vec<AgentResponse>),
408        (status = 401, description = "Unauthorized")
409    ),
410    tag = "user_agents",
411    security(("bearer" = []))
412)]
413pub async fn list_agents(
414    State(state): State<AppState>,
415    Query(params): Query<ListAgentsQuery>,
416    user: AuthUser,
417) -> Result<Json<Vec<AgentResponse>>> {
418    let mut agents = Vec::new();
419
420    // Get user's own agents
421    let user_agents = state.turso.list_user_agents(&user.0.sub).await?;
422    for agent in user_agents {
423        agents.push(user_agent_to_response(agent, "user".to_string()));
424    }
425
426    // Include public agents if requested
427    if params.include_public {
428        let public_agents = state
429            .turso
430            .list_public_agents(params.limit, params.offset)
431            .await?;
432
433        for agent in public_agents {
434            // Skip if user already owns this agent
435            if agents.iter().any(|a| a.name == agent.name) {
436                continue;
437            }
438
439            agents.push(user_agent_to_response(agent, "community".to_string()));
440        }
441    }
442
443    Ok(Json(agents))
444}
445
446/// Update a user agent
447///
448/// PUT /api/user/agents/:name
449#[utoipa::path(
450    put,
451    path = "/api/user/agents/{name}",
452    params(
453        ("name" = String, Path, description = "Agent name")
454    ),
455    request_body = UpdateAgentRequest,
456    responses(
457        (status = 200, description = "Agent updated successfully", body = AgentResponse),
458        (status = 400, description = "Invalid input"),
459        (status = 401, description = "Unauthorized"),
460        (status = 403, description = "Forbidden - not owner"),
461        (status = 404, description = "Agent not found")
462    ),
463    tag = "user_agents",
464    security(("bearer" = []))
465)]
466pub async fn update_agent(
467    State(state): State<AppState>,
468    Path(name): Path<String>,
469    user: AuthUser,
470    Json(req): Json<UpdateAgentRequest>,
471) -> Result<Json<AgentResponse>> {
472    // Get existing agent
473    let mut agent = state
474        .turso
475        .get_user_agent_by_name(&user.0.sub, &name)
476        .await?
477        .ok_or_else(|| AppError::NotFound(format!("Agent '{}' not found", name)))?;
478
479    // Verify ownership
480    if agent.user_id != user.0.sub {
481        return Err(AppError::Auth(
482            "You can only update your own agents".to_string(),
483        ));
484    }
485
486    // Apply updates
487    if let Some(display_name) = req.display_name {
488        agent.display_name = Some(display_name);
489    }
490    if let Some(description) = req.description {
491        agent.description = Some(description);
492    }
493    if let Some(model) = req.model {
494        // Validate model exists
495        let model_exists = state.dynamic_config.model(&model).is_some()
496            || state.config_manager.config().get_model(&model).is_some();
497        if !model_exists {
498            return Err(AppError::InvalidInput(format!(
499                "Model '{}' not found",
500                model
501            )));
502        }
503        agent.model = model;
504    }
505    if let Some(system_prompt) = req.system_prompt {
506        agent.system_prompt = Some(system_prompt);
507    }
508    if let Some(tools) = req.tools {
509        // Validate tools exist
510        for tool in &tools {
511            let tool_exists = state.dynamic_config.tool(tool).is_some()
512                || state.config_manager.config().get_tool(tool).is_some();
513            if !tool_exists {
514                return Err(AppError::InvalidInput(format!("Tool '{}' not found", tool)));
515            }
516        }
517        agent.set_tools(tools);
518    }
519    if let Some(max_tool_iterations) = req.max_tool_iterations {
520        agent.max_tool_iterations = max_tool_iterations;
521    }
522    if let Some(parallel_tools) = req.parallel_tools {
523        agent.parallel_tools = parallel_tools;
524    }
525    if let Some(is_public) = req.is_public {
526        agent.is_public = is_public;
527    }
528
529    agent.updated_at = Utc::now().timestamp();
530
531    state.turso.update_user_agent(&agent).await?;
532
533    Ok(Json(user_agent_to_response(agent, "user".to_string())))
534}
535
536/// Delete a user agent
537///
538/// DELETE /api/user/agents/:name
539#[utoipa::path(
540    delete,
541    path = "/api/user/agents/{name}",
542    params(
543        ("name" = String, Path, description = "Agent name")
544    ),
545    responses(
546        (status = 204, description = "Agent deleted successfully"),
547        (status = 401, description = "Unauthorized"),
548        (status = 404, description = "Agent not found")
549    ),
550    tag = "user_agents",
551    security(("bearer" = []))
552)]
553pub async fn delete_agent(
554    State(state): State<AppState>,
555    Path(name): Path<String>,
556    user: AuthUser,
557) -> Result<StatusCode> {
558    // Get agent to verify ownership
559    let agent = state
560        .turso
561        .get_user_agent_by_name(&user.0.sub, &name)
562        .await?
563        .ok_or_else(|| AppError::NotFound(format!("Agent '{}' not found", name)))?;
564
565    // Delete agent
566    let deleted = state
567        .turso
568        .delete_user_agent(&agent.id, &user.0.sub)
569        .await?;
570
571    if deleted {
572        Ok(StatusCode::NO_CONTENT)
573    } else {
574        Err(AppError::NotFound(format!("Agent '{}' not found", name)))
575    }
576}
577
578/// Export agent as TOON file
579///
580/// GET /api/user/agents/:name/export
581#[utoipa::path(
582    get,
583    path = "/api/user/agents/{name}/export",
584    params(
585        ("name" = String, Path, description = "Agent name")
586    ),
587    responses(
588        (status = 200, description = "Agent exported as TOON file", content_type = "text/x-toon"),
589        (status = 401, description = "Unauthorized"),
590        (status = 404, description = "Agent not found")
591    ),
592    tag = "user_agents",
593    security(("bearer" = []))
594)]
595pub async fn export_agent_toon(
596    State(state): State<AppState>,
597    Path(name): Path<String>,
598    user: AuthUser,
599) -> Result<Response> {
600    let (agent, _) = resolve_agent(&state, &user.0.sub, &name).await?;
601
602    let tools = agent.tools_vec();
603    let filename = format!("{}.toon", agent.name);
604    let config = ToonAgentConfig {
605        name: agent.name,
606        model: agent.model,
607        system_prompt: agent.system_prompt,
608        tools,
609        max_tool_iterations: agent.max_tool_iterations as usize,
610        parallel_tools: agent.parallel_tools,
611        extra: std::collections::HashMap::new(),
612    };
613
614    let toon = encode_default(&config)
615        .map_err(|e| AppError::Internal(format!("TOON encode error: {}", e)))?;
616
617    Ok((
618        [
619            (header::CONTENT_TYPE, "text/x-toon"),
620            (
621                header::CONTENT_DISPOSITION,
622                &format!("attachment; filename=\"{}\"", filename),
623            ),
624        ],
625        toon,
626    )
627        .into_response())
628}
629
630// ============= Helper Functions =============
631
632/// Validate agent name (alphanumeric, hyphens, underscores)
633fn is_valid_agent_name(name: &str) -> bool {
634    !name.is_empty()
635        && name.len() <= 64
636        && name
637            .chars()
638            .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
639}
640
641/// Convert UserAgent to AgentResponse
642fn user_agent_to_response(agent: UserAgent, source: String) -> AgentResponse {
643    let tools = agent.tools_vec();
644    let avg_rating = agent.average_rating();
645    AgentResponse {
646        id: agent.id,
647        name: agent.name,
648        display_name: agent.display_name,
649        description: agent.description,
650        model: agent.model,
651        system_prompt: agent.system_prompt,
652        tools,
653        max_tool_iterations: agent.max_tool_iterations,
654        parallel_tools: agent.parallel_tools,
655        is_public: agent.is_public,
656        usage_count: agent.usage_count,
657        average_rating: avg_rating,
658        created_at: agent.created_at,
659        updated_at: agent.updated_at,
660        source,
661    }
662}
663
664/// Resolve agent by checking: user private -> user public -> community -> system
665/// Returns (UserAgent, source) where source is "user", "community", or "system"
666pub async fn resolve_agent(
667    state: &AppState,
668    user_id: &str,
669    name: &str,
670) -> Result<(UserAgent, String)> {
671    // 1. Check user's agents (private + public)
672    if let Some(agent) = state.turso.get_user_agent_by_name(user_id, name).await? {
673        return Ok((agent, "user".to_string()));
674    }
675
676    // 2. Check community agents (public agents from other users)
677    if let Some(agent) = state.turso.get_public_agent_by_name(name).await? {
678        return Ok((agent, "community".to_string()));
679    }
680
681    // 3. Check system agents (TOON config)
682    if let Some(config) = state.dynamic_config.agent(name) {
683        // Convert ToonAgentConfig to UserAgent for consistent response
684        let agent = UserAgent {
685            id: format!("system-{}", name),
686            user_id: "system".to_string(),
687            name: config.name,
688            display_name: None,
689            description: None,
690            model: config.model,
691            system_prompt: config.system_prompt,
692            tools: serde_json::to_string(&config.tools).unwrap_or_else(|_| "[]".to_string()),
693            max_tool_iterations: config.max_tool_iterations as i32,
694            parallel_tools: config.parallel_tools,
695            extra: "{}".to_string(),
696            is_public: true, // System agents are always "public"
697            usage_count: 0,
698            rating_sum: 0,
699            rating_count: 0,
700            created_at: 0,
701            updated_at: 0,
702        };
703        return Ok((agent, "system".to_string()));
704    }
705
706    // 4. Fallback: Check TOML config (legacy)
707    if let Some(config) = state.config_manager.config().get_agent(name) {
708        let agent = UserAgent {
709            id: format!("system-{}", name),
710            user_id: "system".to_string(),
711            name: name.to_string(),
712            display_name: None,
713            description: None,
714            model: config.model.clone(),
715            system_prompt: config.system_prompt.clone(),
716            tools: serde_json::to_string(&config.tools).unwrap_or_else(|_| "[]".to_string()),
717            max_tool_iterations: config.max_tool_iterations as i32,
718            parallel_tools: config.parallel_tools,
719            extra: "{}".to_string(),
720            is_public: true,
721            usage_count: 0,
722            rating_sum: 0,
723            rating_count: 0,
724            created_at: 0,
725            updated_at: 0,
726        };
727        return Ok((agent, "system".to_string()));
728    }
729
730    Err(AppError::NotFound(format!("Agent '{}' not found", name)))
731}