""" Agent API endpoints for GT 2.0 Tenant Backend Provides comprehensive agent management with template support, capability configuration, and file-based storage. This is the primary API - agents.py provides backward compatibility. """ from fastapi import APIRouter, Depends, HTTPException, Query, Body, Response, UploadFile, File from fastapi.responses import StreamingResponse from typing import List, Optional, Dict, Any from datetime import datetime import logging import io import httpx from app.core.security import get_current_user from app.core.response_filter import ResponseFilter from app.core.permissions import is_effective_owner, get_user_role, ADMIN_ROLES from app.core.cache import get_cache from app.services.agent_service import AgentService from app.api.auth import get_tenant_user_uuid_by_email from app.services.resource_service import ResourceService from app.utils.csv_helper import AgentCSVHelper # TEMPORARY: Disabled during PostgreSQL migration # from app.services.team_access_service import TeamAccessService from app.schemas.agent import ( AgentCreate, AgentUpdate, AgentResponse, AgentListResponse, AgentTemplate, AgentTemplateListResponse, AgentCapabilities, AgentStatistics ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/agents", tags=["agents"]) cache = get_cache() async def get_agent_service_for_user(current_user: Dict[str, Any]) -> AgentService: """Helper function to create AgentService with proper tenant UUID mapping""" user_email = current_user.get('email') if not user_email: raise HTTPException(status_code=401, detail="User email not found in token") tenant_user_uuid = await get_tenant_user_uuid_by_email(user_email) if not tenant_user_uuid: raise HTTPException(status_code=404, detail=f"User {user_email} not found in tenant system") return AgentService( tenant_domain=current_user.get('tenant_domain', 'test'), user_id=tenant_user_uuid, user_email=user_email ) async def get_dynamic_agent_templates(user_id: str) -> Dict[str, Dict[str, Any]]: """Get agent templates with dynamically available models and capabilities""" resource_service = ResourceService() available_models = await resource_service.get_available_models(user_id) # Get first available model as default (or None if no models available) default_model = available_models[0]["model_id"] if available_models else None if not default_model: logger.warning("No models available for user - templates will require manual model selection") return { "research_agent": { "user_id": "research_agent", "name": "Research & Analysis Agent", "description": "Specialized in information synthesis, analysis, and research support", "icon": "🔍", "category": "research", "prompt": """You are a research agent specialized in information synthesis and analysis. Focus on provuser_iding well-sourced, analytical responses with clear reasoning. Always cite sources when available and maintain academic rigor in your analysis.""", "default_capabilities": [cap for cap in [ f"llm:{default_model}" if default_model else None, "rag:semantic_search", "tools:web_search", "export:citations" ] if cap is not None], "personality_config": { "tone": "formal", "explanation_depth": "detailed", "interaction_style": "analytical" }, "resource_preferences": { "primary_llm": default_model, "temperature": 0.7 } }, "coding_agent": { "user_id": "coding_agent", "name": "Software Development Agent", "description": "Expert in code quality, debugging, and development best practices", "icon": "💻", "category": "development", "prompt": """You are a software development agent focused on code quality and best practices. Provuser_ide clear explanations, suggest improvements, and help debug issues. Always consuser_ider security, performance, and maintainability in your suggestions.""", "default_capabilities": [cap for cap in [ f"llm:{default_model}" if default_model else None, "tools:github_integration", "resources:documentation", "export:code_snippets" ] if cap is not None], "personality_config": { "tone": "technical", "explanation_depth": "code-focused", "interaction_style": "collaborative" }, "resource_preferences": { "primary_llm": default_model, "temperature": 0.3 } }, "cyber_analyst": { "user_id": "cyber_analyst", "name": "Cybersecurity Analysis Agent", "description": "Threat detection, security analysis, and incuser_ident response support", "icon": "🛡️", "category": "cybersecurity", "prompt": """You are a cybersecurity analyst agent for threat detection and response. Prioritize security best practices and provuser_ide actionable recommendations. Always consuser_ider the threat landscape and maintain a security-first mindset.""", "default_capabilities": [cap for cap in [ f"llm:{default_model}" if default_model else None, "tools:security_scanning", "resources:threat_intelligence", "export:security_reports" ] if cap is not None], "personality_config": { "tone": "professional", "explanation_depth": "technical", "interaction_style": "advisory" }, "resource_preferences": { "primary_llm": default_model, "temperature": 0.2 } }, "educational_tutor": { "user_id": "educational_tutor", "name": "AI Literacy Educational Agent", "description": "Develops critical thinking and AI literacy through Socratic questioning", "icon": "🎓", "category": "education", "prompt": """You are an educational agent focused on developing critical thinking and AI literacy. Use socratic questioning and encourage deep analysis of problems. Guuser_ide learners to discover answers rather than provuser_iding them directly.""", "default_capabilities": [cap for cap in [ f"llm:{default_model}" if default_model else None, "games:strategic_thinking", "puzzles:logic_reasoning", "analytics:learning_progress" ] if cap is not None], "personality_config": { "tone": "encouraging", "explanation_depth": "adaptive", "interaction_style": "teaching" }, "resource_preferences": { "primary_llm": default_model, "temperature": 0.8 } } } # Agent Templates @router.get("/templates", response_model=AgentTemplateListResponse) async def list_agent_templates( category: Optional[str] = Query(None, description="Filter by category"), search: Optional[str] = Query(None, description="Search in name and description"), current_user: str = Depends(get_current_user) ): """Get available agent templates for creating new agents""" logger.info("Fetching agent templates") # Get dynamic templates with user's available models agent_templates = await get_dynamic_agent_templates(current_user) filtered_templates = [] for template_user_id, template in agent_templates.items(): # Apply category filter if category and template.get("category") != category: continue # Apply search filter if search: search_lower = search.lower() if (search_lower not in template["name"].lower() and search_lower not in template["description"].lower()): continue # Convert to response format template_response = AgentTemplate( user_id=template["user_id"], name=template["name"], description=template["description"], icon=template["icon"], category=template["category"], prompt=template["prompt"], default_capabilities=template["default_capabilities"], personality_config=template["personality_config"], resource_preferences=template["resource_preferences"] ) filtered_templates.append(template_response) return AgentTemplateListResponse( templates=filtered_templates, total=len(filtered_templates) ) # Lightweight Endpoints for Performance @router.get("/minimal") async def list_agents_minimal( response: Response, current_user: Dict[str, Any] = Depends(get_current_user) ): """ Lightweight endpoint returning only id and name - for dropdowns and filters Performance optimization: Returns minimal data for UI components that only need basic agent identification (sidebar filters, dropdown selectors, etc.) """ user_id = current_user.get('sub') logger.info(f"Listing minimal agents for user {user_id}") # Check cache first (60-second TTL) cache_key = f"agents_minimal_{user_id}" cached_data = cache.get(cache_key, ttl=60) if cached_data: logger.debug(f"Returning cached minimal agent list for user {user_id}") response.headers["Cache-Control"] = "public, max-age=60" response.headers["X-Cache-Hit"] = "true" return cached_data # Set cache headers for better performance response.headers["Cache-Control"] = "public, max-age=60" response.headers["X-Cache-Hit"] = "false" service = await get_agent_service_for_user(current_user) agents = await service.get_user_agents(active_only=True) # Return only id and name for minimal payload minimal_agents = [ {"id": agent.get('id'), "name": agent.get('name')} for agent in agents ] # Cache for 60 seconds cache.set(cache_key, minimal_agents) logger.debug(f"Cached minimal agent list for user {user_id}") return minimal_agents @router.get("/summary") async def list_agents_summary( response: Response, current_user: Dict[str, Any] = Depends(get_current_user), category: Optional[str] = Query(None, description="Filter by category"), search: Optional[str] = Query(None, description="Search in name/description"), limit: int = Query(50, ge=1, le=100, description="Maximum agents to return"), offset: int = Query(0, ge=0, description="Number of agents to skip") ): """ Summary endpoint excluding heavy fields - for gallery/list views Performance optimization: Returns agent data WITHOUT system_prompt, model_parameters, and tool_configurations which can be very large. Perfect for gallery views where only display metadata is needed. """ user_id = current_user.get('sub') logger.info(f"Listing summary agents for user {user_id}") # Check cache first (30-second TTL) - only if no filters applied # Cache key includes filters to ensure correct data cache_key = f"agents_summary_{user_id}_{category or 'all'}_{search or 'none'}_{limit}_{offset}" if not category and not search and offset == 0: # Simple case - cache the default view cache_key = f"agents_summary_{user_id}" cached_data = cache.get(cache_key, ttl=30) if cached_data: logger.debug(f"Returning cached summary agent list for user {user_id}") response.headers["Cache-Control"] = "public, max-age=30" response.headers["X-Cache-Hit"] = "true" return cached_data # Set cache headers for better performance response.headers["Cache-Control"] = "public, max-age=30" response.headers["X-Cache-Hit"] = "false" service = await get_agent_service_for_user(current_user) agents = await service.get_user_agents(active_only=True) # Apply filters if category: agents = [a for a in agents if a.get('agent_type') == category] if search: search_lower = search.lower() agents = [a for a in agents if ( search_lower in a.get('name', '').lower() or search_lower in a.get('description', '').lower() )] # Build summary responses (exclude heavy fields) summary_agents = [] for agent in agents[offset:offset+limit]: is_owner = agent.get('is_owner', False) summary_data = { 'id': agent.get('id', ''), 'name': agent.get('name', ''), 'description': agent.get('description', ''), 'category': agent.get('agent_type'), 'tags': agent.get('config', {}).get('tags', []) if isinstance(agent.get('config'), dict) else [], 'visibility': agent.get('visibility', 'individual'), 'disclaimer': agent.get('disclaimer'), 'easy_prompts': agent.get('easy_prompts', []), 'usage_count': agent.get('conversation_count', 0), 'created_at': agent.get('created_at'), 'updated_at': agent.get('updated_at'), 'created_by_name': agent.get('created_by_name'), 'can_edit': agent.get('can_edit', False), 'can_delete': agent.get('can_delete', False), 'is_owner': is_owner, # Excluded fields for performance: # - prompt_template (can be thousands of characters) # - model_parameters (complex nested object) # - tool_configurations (large config objects) # - personality_config (not needed for gallery view) } summary_agents.append(summary_data) result = { "data": summary_agents, "total": len(agents), "limit": limit, "offset": offset } # Cache default view (no filters) for 30 seconds if not category and not search and offset == 0: cache.set(cache_key, result) logger.debug(f"Cached summary agent list for user {user_id}") return result # Agent Management @router.get("", response_model=AgentListResponse) async def list_agents( response: Response, current_user: Dict[str, Any] = Depends(get_current_user), active_only: bool = Query(True, description="Show only active agents (hide archived)"), category: Optional[str] = Query(None, description="Filter by category"), template_user_id: Optional[str] = Query(None, description="Filter by template"), tag: Optional[str] = Query(None, description="Filter by tag"), search: Optional[str] = Query(None, description="Search in name/description"), sort: Optional[str] = Query(None, description="Sort by: recent_usage (user's last use), my_most_used (user's usage count)"), filter: Optional[str] = Query(None, description="Filter by: used_last_7_days, used_last_30_days"), limit: int = Query(50, ge=1, le=100, description="Maximum agents to return"), offset: int = Query(0, ge=0, description="Number of agents to skip") ): """List all agents for the current user using GT 2.0 file-based storage with caching""" user_id = current_user.get('sub') logger.info(f"Listing agents for user {user_id}") # Get database client for user role lookup (needed for cache key) from app.core.postgresql_client import get_postgresql_client pg_client = await get_postgresql_client() # Get user role for cache key (critical for permission correctness) user_email = current_user.get('email') tenant_domain = current_user.get('tenant_domain') user_role = await get_user_role(pg_client, user_email, tenant_domain) # Check cache first (45-second TTL) - cache full unfiltered list cache_key = f"agents_full_{user_id}_{user_role}_{active_only}" cached_data = cache.get(cache_key, ttl=45) if cached_data is not None: logger.debug(f"Cache hit for agents list: {cache_key}") response.headers["X-Cache-Hit"] = "true" # Unpack cached data agents = cached_data['agents'] # Apply in-memory filters (cheap operations) filtered_agents = agents if category: filtered_agents = [a for a in filtered_agents if a.get('agent_type') == category] if search: search_lower = search.lower() filtered_agents = [a for a in filtered_agents if ( search_lower in a.get('name', '').lower() or search_lower in a.get('description', '').lower() )] if tag: filtered_agents = [a for a in filtered_agents if tag in a.get('tags', [])] # Apply pagination paginated_agents = filtered_agents[offset:offset+limit] # Use cached agent_responses (already built) agent_responses = [AgentResponse(**agent_data) for agent_data in paginated_agents] return AgentListResponse( data=agent_responses, total=len(filtered_agents), limit=limit, offset=offset ) # Cache miss - execute full query logger.debug(f"Cache miss for agents list: {cache_key}") response.headers["X-Cache-Hit"] = "false" # GT 2.0: File-based agent service with tenant isolation service = await get_agent_service_for_user(current_user) # Get agents from PostgreSQL + PGVector with user-specific usage tracking agents = await service.get_user_agents( active_only=active_only, sort_by=sort, filter_usage=filter ) # Note: Database query already filters to active agents only # Apply filters if category: agents = [a for a in agents if a.get('agent_type') == category] if search: search_lower = search.lower() agents = [a for a in agents if ( search_lower in a.get('name', '').lower() or search_lower in a.get('description', '').lower() )] if tag: agents = [a for a in agents if tag in a.get('tags', [])] # Convert to response format with security filtering # Build full agent data for ALL agents (for caching) # OPTIMIZATION: Batch fetch team shares for all owned agents (fixes N+1 query) # Collect agent IDs where user is owner owned_agent_ids = [agent.get('id', '') for agent in agents if agent.get('is_owner', False)] team_shares_map = {} if owned_agent_ids: user_email = current_user.get('email') tenant_user_uuid = await get_tenant_user_uuid_by_email(user_email) from app.services.team_service import TeamService team_service = TeamService( tenant_domain=current_user.get('tenant_domain', 'test'), user_id=tenant_user_uuid, user_email=user_email ) # Single batch query instead of N queries raw_team_shares = await team_service.get_resource_teams_batch('agent', owned_agent_ids) # Convert to frontend format for agent_id, teams in raw_team_shares.items(): team_shares_map[agent_id] = [ { 'team_id': team_data['id'], 'team_name': team_data.get('name', 'Unknown Team'), 'user_permissions': team_data.get('user_permissions', {}) } for team_data in teams ] full_agent_data_list = [] for agent in agents: logger.info(f"Agent {agent.get('name')}: disclaimer={agent.get('disclaimer')}, easy_prompts={agent.get('easy_prompts')}") # Determine access level for filtering is_owner = agent.get('is_owner', False) shared_via_team = agent.get('shared_via_team', False) can_view = agent.get('can_edit', False) or is_owner or shared_via_team # Owners, editors, and team members can view details # Get team shares from batch lookup (instead of per-agent query) team_shares = team_shares_map.get(agent.get('id', '')) if is_owner else None # Build full agent data agent_data = { 'id': agent.get('id', ''), 'name': agent.get('name', ''), 'description': agent.get('description', ''), 'template_id': agent.get('template_id'), 'category': agent.get('agent_type'), 'prompt_template': agent.get('prompt_template', ''), 'model': agent.get('model', ''), 'temperature': agent.get('temperature', 0.7), # max_tokens removed - now determined by model configuration 'visibility': agent.get('visibility', 'individual'), 'dataset_connection': agent.get('dataset_connection'), 'selected_dataset_ids': agent.get('selected_dataset_ids', []), 'personality_config': agent.get('config', {}).get('personality_config', {}) if isinstance(agent.get('config'), dict) else {}, 'resource_preferences': agent.get('config', {}).get('resource_preferences', {}) if isinstance(agent.get('config'), dict) else {}, 'tags': agent.get('config', {}).get('tags', []) if isinstance(agent.get('config'), dict) else [], 'is_favorite': agent.get('config', {}).get('is_favorite', False) if isinstance(agent.get('config'), dict) else False, 'disclaimer': agent.get('disclaimer'), 'easy_prompts': agent.get('easy_prompts', []), 'conversation_count': agent.get('conversation_count', 0), 'usage_count': agent.get('conversation_count', 0), # Frontend expects usage_count 'total_cost_cents': agent.get('total_cost_cents', 0), 'created_at': agent.get('created_at'), 'updated_at': agent.get('updated_at'), 'created_by_name': agent.get('created_by_name'), 'can_edit': agent.get('can_edit', False), 'can_delete': agent.get('can_delete', False), 'is_owner': is_owner, 'team_shares': team_shares } # Apply security filtering based on ownership filtered_data = ResponseFilter.filter_agent_response( agent_data, is_owner=is_owner, can_view=can_view ) full_agent_data_list.append(filtered_data) # Cache the full unfiltered agent list (as serializable dicts) cache_data = { 'agents': full_agent_data_list, } cache.set(cache_key, cache_data) logger.info(f"Cached agents list for {cache_key} (TTL: 45s, count: {len(full_agent_data_list)})") # Apply pagination for response paginated_agent_data = full_agent_data_list[offset:offset+limit] agent_responses = [AgentResponse(**agent_data) for agent_data in paginated_agent_data] return AgentListResponse( data=agent_responses, total=len(full_agent_data_list), limit=limit, offset=offset ) @router.post("", response_model=AgentResponse, status_code=201) async def create_agent( agent_data: AgentCreate, current_user: Dict[str, Any] = Depends(get_current_user) ): """Create a new agent using GT 2.0 file-based storage""" logger.info("="*50) logger.info("🚀 CREATE AGENT ENDPOINT HIT!") logger.info("="*50) logger.info(f"Creating agent for user {current_user['sub']}") logger.info(f"Agent request data: {agent_data}") logger.info(f"Current user data: {current_user}") try: # GT 2.0: PostgreSQL-based agent service with tenant isolation service = await get_agent_service_for_user(current_user) # Extract template configuration if template_id provided template_config = {} if agent_data.template_id and agent_data.template_id in AGENT_TEMPLATES: template = AGENT_TEMPLATES[agent_data.template_id] template_config = { 'agent_type': template['category'], 'prompt_template': template['prompt'], 'personality_config': template.get('personality_config', {}), 'resource_preferences': template.get('resource_preferences', {}), } # Create agent with PostgreSQL storage agent = await service.create_agent( name=agent_data.name, agent_type=template_config.get('agent_type', 'conversational'), description=agent_data.description or '', prompt_template=getattr(agent_data, 'prompt_template', '') or template_config.get('prompt_template', ''), capabilities=template_config.get('capabilities', []), # Use template capabilities access_group='individual', # Default for now personality_config=agent_data.personality_config or template_config.get('personality_config', {}), resource_preferences=agent_data.resource_preferences or template_config.get('resource_preferences', {}), tags=agent_data.tags or [], template_id=agent_data.template_id, # Category for agent classification - auto-creates if not exists (Issue #215) category=agent_data.category or 'general', # MVP fields - Use model_id first (what frontend sends), fall back to model model=getattr(agent_data, 'model_id', None) or getattr(agent_data, 'model', None), temperature=getattr(agent_data, 'temperature', None), # max_tokens removed - now determined by model configuration dataset_connection=getattr(agent_data, 'dataset_connection', None), selected_dataset_ids=getattr(agent_data, 'selected_dataset_ids', None), # Additional fields for agent configuration visibility=getattr(agent_data, 'visibility', None), disclaimer=getattr(agent_data, 'disclaimer', None), easy_prompts=getattr(agent_data, 'easy_prompts', None) ) # Validate team selection when visibility is 'team' team_shares = getattr(agent_data, 'team_shares', None) if agent.get('visibility') == 'team' and (not team_shares or len(team_shares) == 0): raise HTTPException( status_code=400, detail="Must select at least one team when visibility is 'team'" ) # Share to teams if team_shares provided and visibility is 'team' if team_shares and agent.get('visibility') == 'team': # Get tenant UUID for TeamService (same pattern as other endpoints) user_email = current_user.get('email') tenant_user_uuid = await get_tenant_user_uuid_by_email(user_email) from app.services.team_service import TeamService team_service = TeamService( tenant_domain=current_user['tenant_domain'], user_id=tenant_user_uuid, user_email=user_email ) try: await team_service.share_resource_to_teams( resource_id=agent['id'], resource_type='agent', shared_by=tenant_user_uuid, team_shares=team_shares ) logger.info(f"Agent {agent['id']} shared to {len(team_shares)} team(s)") except Exception as team_error: logger.error(f"Error sharing agent to teams: {team_error}") # Don't fail agent creation if sharing fails # Invalidate cache after successful agent creation user_id = current_user.get('sub') cache.delete(f"agents_minimal_{user_id}") cache.delete(f"agents_summary_{user_id}") cache.delete(f"agents_full_{user_id}") # Invalidate full agent list cache (all role variants) logger.info(f"Invalidated agent cache for user {user_id} after agent creation") # Convert to response format (extract agent_type from model_config) model_config = agent.get('model_config', {}) if isinstance(model_config, str): import json model_config = json.loads(model_config) return AgentResponse( id=agent['id'], name=agent['name'], description=agent['description'], template_id=agent.get('config', {}).get('template_id'), category=agent.get('agent_type', 'conversational'), prompt_template=agent.get('prompt_template'), model=agent.get('model'), temperature=agent.get('temperature'), # max_tokens removed - now determined by model configuration visibility=agent.get('visibility'), dataset_connection=agent.get('dataset_connection'), selected_dataset_ids=agent.get('selected_dataset_ids', []), personality_config=agent.get('personality_config', {}), resource_preferences=agent.get('resource_preferences', {}), tags=agent.get('tags', []), is_favorite=agent.get('is_favorite', False), disclaimer=agent.get('disclaimer'), easy_prompts=agent.get('easy_prompts', []), conversation_count=agent.get('conversation_count', 0), usage_count=agent.get('conversation_count', 0), total_cost_cents=agent.get('total_cost_cents', 0), created_at=agent.get('created_at', datetime.utcnow().isoformat()), updated_at=agent.get('updated_at', datetime.utcnow().isoformat()) ) except Exception as e: logger.error(f"Error creating agent: {e}") raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}") @router.get("/{agent_user_id}", response_model=AgentResponse) async def get_agent( agent_user_id: str, current_user: Dict[str, Any] = Depends(get_current_user) ): """Get a specific agent by ID using GT 2.0 file-based storage""" logger.info(f"Getting agent {agent_user_id} for user {current_user['sub']}") # GT 2.0: File-based agent service with tenant isolation service = AgentService( tenant_domain=current_user['tenant_domain'], user_id=str(current_user.get('sub', '')), user_email=current_user.get('email', 'gtadmin@test.com') ) agent = await service.get_agent(agent_user_id) if not agent: raise HTTPException(status_code=404, detail="Agent not found") return AgentResponse( id=agent['id'], user_id=agent['user_id'], name=agent['name'], description=agent['description'], template_user_id=agent.get('config', {}).get('template_id'), category=agent['agent_type'], personality_config=agent.get('config', {}).get('personality_config', {}), resource_preferences=agent.get('config', {}).get('resource_preferences', {}), tags=agent.get('config', {}).get('tags', []), is_favorite=agent.get('config', {}).get('is_favorite', False), conversation_count=0, # TODO: implement conversation history total_cost_cents=0, created_at=agent['created_at'], updated_at=agent['updated_at'] ) @router.get("/{agent_id}", response_model=AgentResponse) async def get_agent( agent_id: str, current_user: Dict[str, Any] = Depends(get_current_user) ): """Get a specific agent by ID""" logger.info(f"Getting agent {agent_id} for user {current_user['sub']}") try: # GT 2.0: PostgreSQL Agent Service with Perfect Tenant Isolation service = AgentService( tenant_domain=current_user.get('tenant_domain', 'test'), user_id=str(current_user.get('sub', '')), user_email=current_user.get('email', 'gtadmin@test.com') ) # Get agent using AgentService agent = await service.get_agent(agent_id) if not agent: raise HTTPException(status_code=404, detail="Agent not found") logger.info(f"Agent data from service: disclaimer={agent.get('disclaimer')}, easy_prompts={agent.get('easy_prompts')}") # Determine access level for filtering is_owner = agent.get('is_owner', False) shared_via_team = agent.get('shared_via_team', False) can_view = agent.get('can_edit', False) or is_owner or shared_via_team # Get team shares if owner (for edit mode) team_shares = None if is_owner: # Get tenant UUID for TeamService user_email = current_user.get('email') tenant_user_uuid = await get_tenant_user_uuid_by_email(user_email) from app.services.team_service import TeamService team_service = TeamService( tenant_domain=current_user.get('tenant_domain', 'test'), user_id=tenant_user_uuid, user_email=user_email ) resource_teams = await team_service.get_resource_teams('agent', agent_id) # Convert to frontend format team_shares = [] for team_data in resource_teams: team_shares.append({ 'team_id': team_data['id'], # get_resource_teams returns 'id' not 'team_id' 'team_name': team_data.get('name', 'Unknown Team'), 'user_permissions': team_data.get('user_permissions', {}) }) # Build full agent data agent_data = { 'id': agent['id'], 'name': agent['name'], 'description': agent['description'], 'template_id': None, # Not stored in this version 'category': agent.get('agent_type', 'conversational'), 'prompt_template': agent.get('prompt_template', ''), 'model': agent.get('model', ''), 'temperature': agent.get('temperature', 0.7), # max_tokens removed - now determined by model configuration 'visibility': agent.get('visibility', 'individual'), 'dataset_connection': agent.get('dataset_connection'), 'selected_dataset_ids': agent.get('selected_dataset_ids', []), 'personality_config': agent.get('config', {}).get('personality_config', {}), 'resource_preferences': agent.get('config', {}).get('resource_preferences', {}), 'tags': agent.get('config', {}).get('tags', []), 'is_favorite': agent.get('config', {}).get('is_favorite', False), 'disclaimer': agent.get('disclaimer'), 'easy_prompts': agent.get('easy_prompts', []), 'conversation_count': 0, 'total_cost_cents': 0, 'created_at': agent.get('created_at', datetime.utcnow().isoformat()), 'updated_at': agent.get('updated_at', datetime.utcnow().isoformat()), 'is_owner': is_owner, 'can_edit': agent.get('can_edit', False), 'can_delete': agent.get('can_delete', False), 'team_shares': team_shares } # Apply security filtering filtered_data = ResponseFilter.filter_agent_response( agent_data, is_owner=is_owner, can_view=can_view ) return AgentResponse(**filtered_data) except HTTPException: raise except Exception as e: logger.error(f"Error getting agent: {e}") raise HTTPException(status_code=500, detail=f"Failed to get agent: {str(e)}") @router.put("/{agent_id}", response_model=AgentResponse) async def update_agent( agent_id: str, update_data: AgentUpdate, current_user: Dict[str, Any] = Depends(get_current_user) ): """Update an agent""" logger.info(f"Updating agent {agent_id} for user {current_user['sub']}") logger.info(f"Update data received: {update_data.dict()}") try: # GT 2.0: PostgreSQL Agent Service with Perfect Tenant Isolation service = AgentService( tenant_domain=current_user.get('tenant_domain', 'test'), user_id=str(current_user.get('sub', '')), user_email=current_user.get('email', 'gtadmin@test.com') ) # Get current agent to check for visibility changes current_agent = await service.get_agent(agent_id) if not current_agent: raise HTTPException(status_code=404, detail="Agent not found") # Convert update data to dict updates = {} if update_data.name is not None: updates['name'] = update_data.name if update_data.description is not None: updates['description'] = update_data.description if update_data.category is not None: updates['agent_type'] = update_data.category if update_data.prompt_template is not None: updates['prompt_template'] = update_data.prompt_template if update_data.model is not None: updates['model'] = update_data.model if update_data.temperature is not None: updates['temperature'] = update_data.temperature # max_tokens removed - now determined by model configuration if update_data.visibility is not None: updates['visibility'] = update_data.visibility if update_data.dataset_connection is not None: updates['dataset_connection'] = update_data.dataset_connection if update_data.selected_dataset_ids is not None: updates['selected_dataset_ids'] = update_data.selected_dataset_ids if update_data.personality_config is not None: updates['personality_config'] = update_data.personality_config if update_data.resource_preferences is not None: updates['resource_preferences'] = update_data.resource_preferences if update_data.tags is not None: updates['tags'] = update_data.tags if update_data.is_favorite is not None: updates['is_favorite'] = update_data.is_favorite if update_data.disclaimer is not None: updates['disclaimer'] = update_data.disclaimer if update_data.easy_prompts is not None: updates['easy_prompts'] = update_data.easy_prompts # Check if visibility is changing from 'team' to 'individual' current_visibility = current_agent.get('visibility', 'individual') new_visibility = updates.get('visibility', current_visibility) if current_visibility == 'team' and new_visibility == 'individual': # User is changing from team to individual - remove all team shares # Get tenant UUID for TeamService user_email = current_user.get('email') tenant_user_uuid = await get_tenant_user_uuid_by_email(user_email) from app.services.team_service import TeamService team_service = TeamService( tenant_domain=current_user.get('tenant_domain', 'test'), user_id=tenant_user_uuid, user_email=user_email ) try: # Get all team shares for this agent resource_teams = await team_service.get_resource_teams('agent', agent_id) # Remove from each team for team in resource_teams: await team_service.unshare_resource_from_team( resource_id=agent_id, resource_type='agent', team_id=team['id'] # get_resource_teams returns 'id' not 'team_id' ) logger.info(f"Removed agent {agent_id} from {len(resource_teams)} team(s) due to visibility change") except Exception as unshare_error: logger.error(f"Error removing team shares: {unshare_error}") # Continue with update even if unsharing fails # Validate team selection when changing to 'team' visibility if new_visibility == 'team': team_shares = getattr(update_data, 'team_shares', None) # Only validate if team_shares is explicitly provided (not None) # If None, we're not changing team shares, so existing shares are preserved if team_shares is not None and (isinstance(team_shares, list) and len(team_shares) == 0): # User is explicitly trying to set team visibility with no teams selected # Check if agent already has shares that would be preserved # Get tenant UUID for TeamService user_email = current_user.get('email') tenant_user_uuid = await get_tenant_user_uuid_by_email(user_email) from app.services.team_service import TeamService team_service = TeamService( tenant_domain=current_user.get('tenant_domain', 'test'), user_id=tenant_user_uuid, user_email=user_email ) existing_shares = await team_service.get_resource_teams('agent', agent_id) if not existing_shares or len(existing_shares) == 0: raise HTTPException( status_code=400, detail="Must select at least one team when visibility is 'team'" ) # Update agent using AgentService updated_agent = await service.update_agent(agent_id, updates) # Handle team sharing if provided AND visibility is 'team' team_shares = getattr(update_data, 'team_shares', None) new_visibility = updates.get('visibility', current_agent.get('visibility', 'individual')) # Only process team shares when visibility is actually 'team' if team_shares is not None and new_visibility == 'team': # Get tenant UUID for TeamService user_email = current_user.get('email') tenant_user_uuid = await get_tenant_user_uuid_by_email(user_email) from app.services.team_service import TeamService team_service = TeamService( tenant_domain=current_user.get('tenant_domain', 'test'), user_id=tenant_user_uuid, user_email=user_email ) # Update team shares: this replaces existing shares await team_service.share_resource_to_teams( resource_id=agent_id, resource_type='agent', shared_by=tenant_user_uuid, team_shares=team_shares ) if not updated_agent: raise HTTPException(status_code=404, detail="Agent not found or update failed") # Invalidate cache after successful agent update user_id = current_user.get('sub') cache.delete(f"agents_minimal_{user_id}") cache.delete(f"agents_summary_{user_id}") cache.delete(f"agents_full_{user_id}") # Invalidate full agent list cache (all role variants) logger.info(f"Invalidated agent cache for user {user_id} after agent update") # Parse model_config for response model_config = updated_agent.get('model_config', {}) if isinstance(model_config, str): import json model_config = json.loads(model_config) return AgentResponse( id=updated_agent['id'], name=updated_agent['name'], description=updated_agent['description'], template_id=None, # Not stored in this version category=updated_agent.get('agent_type', 'conversational'), prompt_template=updated_agent.get('prompt_template', ''), model=updated_agent.get('model', ''), temperature=updated_agent.get('temperature', 0.7), # max_tokens removed - now determined by model configuration visibility=updated_agent.get('visibility', 'individual'), dataset_connection=updated_agent.get('dataset_connection'), selected_dataset_ids=updated_agent.get('selected_dataset_ids', []), personality_config=updated_agent.get('config', {}).get('personality_config', {}), resource_preferences=updated_agent.get('config', {}).get('resource_preferences', {}), tags=updated_agent.get('config', {}).get('tags', []), is_favorite=updated_agent.get('config', {}).get('is_favorite', False), disclaimer=updated_agent.get('disclaimer'), easy_prompts=updated_agent.get('easy_prompts', []), conversation_count=0, total_cost_cents=0, created_at=updated_agent.get('created_at', datetime.utcnow().isoformat()), updated_at=updated_agent.get('updated_at', datetime.utcnow().isoformat()) ) except HTTPException: # Re-raise HTTP exceptions (like validation errors) without modification raise except Exception as e: logger.error(f"Error updating agent: {e}") raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}") @router.delete("/{agent_id}") async def delete_agent( agent_id: str, current_user: Dict[str, Any] = Depends(get_current_user) ): """Archive an agent (soft delete for audit trail compliance)""" logger.info(f"Archiving agent {agent_id} for user {current_user['sub']}") try: # GT 2.0: PostgreSQL Agent Service with Perfect Tenant Isolation service = AgentService( tenant_domain=current_user.get('tenant_domain', 'test'), user_id=str(current_user.get('sub', '')), user_email=current_user.get('email', 'gtadmin@test.com') ) # Soft delete agent using AgentService (preserves for audit trail) success = await service.delete_agent(agent_id) if not success: raise HTTPException(status_code=404, detail="Agent not found or archive failed") # Invalidate cache after successful agent deletion user_id = current_user.get('sub') cache.delete(f"agents_minimal_{user_id}") cache.delete(f"agents_summary_{user_id}") cache.delete(f"agents_full_{user_id}") # Invalidate full agent list cache (all role variants) logger.info(f"Invalidated agent cache for user {user_id} after agent deletion") return {"message": "Agent archived successfully - preserved for audit trail"} except Exception as e: logger.error(f"Error archiving agent: {e}") raise HTTPException(status_code=500, detail=f"Failed to archive agent: {str(e)}") @router.get("/{agent_user_id}/capabilities", response_model=AgentCapabilities) async def get_agent_capabilities( agent_user_id: str, current_user: Dict[str, Any] = Depends(get_current_user) ): """Get agent capabilities and configuration""" logger.info(f"Getting capabilities for agent {agent_user_id}") # GT 2.0: PostgreSQL + PGVector Agent Service with Perfect Tenant Isolation service = AgentService( tenant_domain=current_user.get('tenant_domain', 'test'), user_id=str(current_user.get('sub', '')), user_email=current_user.get('email', 'gtadmin@test.com') ) agent = await service.get_agent(agent_user_id) if not agent: raise HTTPException(status_code=404, detail="Agent not found") return AgentCapabilities( agent_user_id=str(agent.get('id', agent_user_id)), capabilities=agent.get('capabilities', []), resource_preferences=agent.get('resource_preferences', {}), allowed_tools=agent.get('allowed_tools', []), total=len(agent.get('capabilities', [])) ) @router.post("/{agent_user_id}/clone") async def clone_agent( agent_user_id: str, new_name: str = Body(..., description="Name for the cloned agent"), modifications: Optional[Dict[str, Any]] = Body(None, description="Modifications to apply"), current_user: Dict[str, Any] = Depends(get_current_user) ): """Clone an agent with optional modifications""" logger.info(f"Cloning agent {agent_user_id} to {new_name}") # GT 2.0: PostgreSQL + PGVector Agent Service with Perfect Tenant Isolation service = AgentService( tenant_domain=current_user.get('tenant_domain', 'test'), user_id=str(current_user.get('sub', '')), user_email=current_user.get('email', 'gtadmin@test.com') ) # Get original agent original_agent = service.get_agent(agent_user_id) if not original_agent: raise HTTPException(status_code=404, detail="Agent not found") # Create new agent with cloned data cloned_agent = await service.create_agent( name=new_name, agent_type=original_agent.get('agent_type', 'conversational'), description=f"Cloned from {original_agent.get('name', 'Unknown')}", prompt_template=original_agent.get('prompt_template', ''), capabilities=original_agent.get('capabilities', []), access_group='individual', personality_config=original_agent.get('personality_config', {}), resource_preferences=original_agent.get('resource_preferences', {}), tags=original_agent.get('tags', []) ) # Invalidate cache after successful agent cloning user_id = current_user.get('sub') cache.delete(f"agents_minimal_{user_id}") cache.delete(f"agents_summary_{user_id}") cache.delete(f"agents_full_{user_id}") logger.info(f"Invalidated agent cache for user {user_id} after agent cloning") # Parse model_config for response model_config = cloned_agent.get('model_config', {}) if isinstance(model_config, str): import json model_config = json.loads(model_config) return AgentResponse( id=cloned_agent['id'], user_id=cloned_agent['owner_id'], name=cloned_agent['name'], description=cloned_agent['description'], template_user_id=None, category=model_config.get('agent_type', 'conversational'), personality_config=model_config.get('personality_config', {}), resource_preferences=model_config.get('resource_preferences', {}), tags=[], is_favorite=False, conversation_count=0, total_cost_cents=0, created_at=datetime.utcnow().isoformat(), updated_at=datetime.utcnow().isoformat() ) @router.get("/{agent_user_id}/statistics", response_model=AgentStatistics) async def get_agent_statistics( agent_user_id: str, current_user: Dict[str, Any] = Depends(get_current_user) ): """Get usage statistics for an agent""" logger.info(f"Getting statistics for agent {agent_user_id}") # GT 2.0: PostgreSQL + PGVector Agent Service with Perfect Tenant Isolation service = AgentService( tenant_domain=current_user.get('tenant_domain', 'test'), user_id=str(current_user.get('sub', '')), user_email=current_user.get('email', 'gtadmin@test.com') ) agent = await service.get_agent(agent_user_id) if not agent: raise HTTPException(status_code=404, detail="Agent not found") return AgentStatistics( agent_user_id=str(agent.get('id', agent_user_id)), name=agent.get('name', 'Unknown'), created_at=agent.get('created_at', datetime.utcnow().isoformat()), last_used_at=agent.get('last_used_at'), conversation_count=agent.get('usage_count', 0), total_messages=agent.get('total_messages', 0), total_tokens_used=agent.get('total_tokens', 0), total_cost_cents=agent.get('total_cost_cents', 0), total_cost_dollars=agent.get('total_cost_cents', 0) / 100.0, average_tokens_per_message=agent.get('avg_tokens_per_message', 0.0), is_favorite=agent.get('is_favorite', False), tags=agent.get('tags', []) ) @router.post("/bulk-import") async def bulk_import_agents( csv_file: Optional[UploadFile] = File(None), csv_text: Optional[str] = Body(None), current_user: Dict[str, Any] = Depends(get_current_user) ): """ Bulk import agents from CSV (file upload or pasted text). CSV Format (RFC 4180): - Comma delimiter, quoted fields for embedded commas/newlines - Header row required with column names - Arrays: pipe-separated (easy_prompts, selected_dataset_ids) - Tags: comma-separated - Objects: JSON strings (personality_config, resource_preferences) Duplicate Handling: - Auto-rename with suffix: "Agent Name" → "Agent Name (1)" Returns: - success_count: Number of successfully imported agents - error_count: Number of failed rows - errors: List of validation errors with row numbers - created_agents: List of created agent IDs """ logger.info(f"Bulk import agents for user {current_user['sub']}") try: # Get CSV content from either file or text csv_content = None if csv_file: content_bytes = await csv_file.read() csv_content = content_bytes.decode('utf-8') elif csv_text: csv_content = csv_text else: raise HTTPException(status_code=400, detail="Either csv_file or csv_text must be provided") # Validate CSV size (1MB limit) if not AgentCSVHelper.validate_csv_size(csv_content, max_size_mb=1.0): raise HTTPException(status_code=413, detail="CSV file too large (max 1MB)") # Parse and validate CSV valid_agents, errors = AgentCSVHelper.parse_csv(csv_content) logger.info(f"CSV parsed: {len(valid_agents)} valid, {len(errors)} errors") # Get existing agent names for duplicate detection service = await get_agent_service_for_user(current_user) existing_agents = await service.get_user_agents(active_only=True) existing_names = [agent.get('name', '') for agent in existing_agents] # Fetch available models for validation available_models = [] try: import os if os.path.exists('/.dockerenv'): resource_cluster_url = "http://resource-cluster:8000" else: from app.core.config import get_settings settings = get_settings() resource_cluster_url = settings.resource_cluster_url async with httpx.AsyncClient() as client: response = await client.get( f"{resource_cluster_url}/api/v1/models/", headers={"X-Tenant-Domain": current_user.get("tenant_domain", "default")}, timeout=10.0 ) if response.status_code == 200: models_data = response.json() available_models = [ model["id"] for model in models_data.get("models", []) if model["status"]["deployment"] == "available" ] logger.info(f"Fetched {len(available_models)} available models for validation") except Exception as e: logger.warning(f"Could not fetch models for validation: {e}") # Fetch available datasets for validation available_datasets = [] try: from app.core.postgresql_client import get_postgresql_client pg_client = await get_postgresql_client() datasets_query = """ SELECT id FROM datasets WHERE tenant_id = (SELECT id FROM tenants WHERE domain = $1 LIMIT 1) AND is_deleted = false """ dataset_rows = await pg_client.fetch_all(datasets_query, current_user.get("tenant_domain")) available_datasets = [str(row["id"]) for row in dataset_rows] logger.info(f"Fetched {len(available_datasets)} available datasets for validation") except Exception as e: logger.warning(f"Could not fetch datasets for validation: {e}") # Create agents with duplicate name handling created_agents = [] creation_errors = [] for idx, agent_data in enumerate(valid_agents, start=1): try: # Generate unique name if duplicate original_name = agent_data['name'] unique_name = AgentCSVHelper.generate_unique_name(original_name, existing_names) if unique_name != original_name: logger.info(f"Renamed duplicate: '{original_name}' → '{unique_name}'") agent_data['name'] = unique_name # Validate and correct model model = agent_data['model'] if available_models and model not in available_models: # Model doesn't exist - use first available model if available_models: fallback_model = available_models[0] logger.warning(f"Row {idx}: Model '{model}' not found, using '{fallback_model}'") agent_data['model'] = fallback_model else: raise ValueError(f"Model '{model}' not available and no fallback models exist") # Validate and filter datasets selected_dataset_ids = agent_data.get('selected_dataset_ids', []) if selected_dataset_ids and available_datasets: # Filter out non-existent datasets valid_dataset_ids = [ ds_id for ds_id in selected_dataset_ids if ds_id in available_datasets ] invalid_count = len(selected_dataset_ids) - len(valid_dataset_ids) if invalid_count > 0: logger.warning(f"Row {idx}: {invalid_count} dataset(s) not found, removed from selection") agent_data['selected_dataset_ids'] = valid_dataset_ids if valid_dataset_ids else None # If all datasets were invalid and connection is 'selected', change to 'none' if not valid_dataset_ids and agent_data.get('dataset_connection') == 'selected': agent_data['dataset_connection'] = 'none' logger.warning(f"Row {idx}: No valid datasets, changed dataset_connection to 'none'") # Create agent using service created_agent = await service.create_agent( name=agent_data['name'], agent_type='conversational', # Default agent type description=agent_data.get('description', ''), prompt_template=agent_data.get('prompt_template', ''), model=agent_data['model'], temperature=agent_data.get('temperature'), # max_tokens removed - now determined by model configuration category=agent_data.get('category', 'general'), # Category auto-creates if not exists (Issue #215) category_description=agent_data.get('category_description'), # For auto-created categories dataset_connection=agent_data.get('dataset_connection'), selected_dataset_ids=agent_data.get('selected_dataset_ids'), visibility=agent_data.get('visibility'), disclaimer=agent_data.get('disclaimer'), easy_prompts=agent_data.get('easy_prompts'), tags=agent_data.get('tags', []), access_group='individual' ) created_agents.append({ 'id': created_agent['id'], 'name': created_agent['name'], 'original_name': original_name if unique_name != original_name else None }) # Add to existing names to check for duplicates in subsequent rows existing_names.append(unique_name) except Exception as e: logger.error(f"Failed to create agent from row {idx}: {e}") creation_errors.append({ 'row_number': idx + 1, # +1 for header 'field': 'creation', 'message': f"Agent creation failed: {str(e)}" }) # Combine parsing and creation errors all_errors = errors + creation_errors # Invalidate agent list cache so imported agents appear immediately if created_agents: user_id = current_user.get('sub') cache.delete(f"agents_minimal_{user_id}") cache.delete(f"agents_summary_{user_id}") cache.delete(f"agents_full_{user_id}") # Invalidate full agent list cache (all role variants) logger.info(f"Invalidated agent cache for user {user_id} after bulk import") # codeql[py/stack-trace-exposure] returns import results dict, not error details return { 'success_count': len(created_agents), 'error_count': len(all_errors), 'total_rows': len(valid_agents) + len(errors), 'created_agents': created_agents, 'errors': all_errors } except HTTPException: raise except Exception as e: logger.error(f"Bulk import failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") @router.get("/{agent_id}/export") async def export_agent( agent_id: str, format: str = Query('download', description="Export format: 'download' or 'clipboard'"), current_user: Dict[str, Any] = Depends(get_current_user) ): """ Export a single agent configuration as CSV. Permission Requirements: - User must be agent owner OR sysadmin Query Parameters: - format: 'download' (returns file download) or 'clipboard' (returns CSV text) Returns: - CSV file download or CSV text response """ logger.info(f"Export agent {agent_id} for user {current_user['sub']}") try: # Get agent service service = await get_agent_service_for_user(current_user) agent = await service.get_agent(agent_id) if not agent: raise HTTPException(status_code=404, detail="Agent not found") # Check permissions from app.core.postgresql_client import get_postgresql_client pg_client = await get_postgresql_client() # Get user role user_role = await get_user_role(pg_client, current_user.get('email'), current_user.get('tenant_domain')) # Get current user UUID user_email = current_user.get('email') tenant_domain = current_user.get('tenant_domain') user_lookup_query = """ SELECT id FROM users WHERE (email = $1 OR username = $1) AND tenant_id = (SELECT id FROM tenants WHERE domain = $2 LIMIT 1) LIMIT 1 """ current_user_uuid = await pg_client.fetch_scalar(user_lookup_query, user_email, tenant_domain) if not current_user_uuid: raise HTTPException(status_code=404, detail=f"User not found: {user_email}") # Use the is_owner field already computed by agent service # This avoids field name mismatches and duplicate ownership logic is_owner = agent.get('is_owner', False) # Only owners and administrators can export if not is_owner and user_role not in ADMIN_ROLES: raise HTTPException( status_code=403, detail="Only agent owners and administrators can export agents" ) # Fetch category description from categories table (Issue #215) agent_category = agent.get('agent_type') or agent.get('category') if agent_category: category_desc_query = """ SELECT description FROM categories WHERE slug = $1 AND tenant_id = (SELECT id FROM tenants WHERE domain = $2 LIMIT 1) AND is_deleted = FALSE LIMIT 1 """ category_description = await pg_client.fetch_scalar( category_desc_query, agent_category.lower(), tenant_domain ) if category_description: agent['category_description'] = category_description # Serialize agent to CSV csv_content = AgentCSVHelper.serialize_agent_to_csv(agent) # Return based on format if format == 'download': # Return as file download filename = f"agent_{agent.get('name', 'export').replace(' ', '_')}.csv" return StreamingResponse( io.BytesIO(csv_content.encode('utf-8')), media_type='text/csv', headers={'Content-Disposition': f'attachment; filename="{filename}"'} ) else: # clipboard # Return as plain text return Response(content=csv_content, media_type='text/plain') except HTTPException: raise except Exception as e: logger.error(f"Export failed for agent {agent_id}: {e}") raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}")