Files
gt-ai-os-community/apps/tenant-backend/app/services/agent_service.py
HackWeasel b9dfb86260 GT AI OS Community Edition v2.0.33
Security hardening release addressing CodeQL and Dependabot alerts:

- Fix stack trace exposure in error responses
- Add SSRF protection with DNS resolution checking
- Implement proper URL hostname validation (replaces substring matching)
- Add centralized path sanitization to prevent path traversal
- Fix ReDoS vulnerability in email validation regex
- Improve HTML sanitization in validation utilities
- Fix capability wildcard matching in auth utilities
- Update glob dependency to address CVE
- Add CodeQL suppression comments for verified false positives

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 17:04:45 -05:00

854 lines
41 KiB
Python

import json
import os
import uuid
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
from pathlib import Path
from app.core.config import get_settings
from app.core.postgresql_client import get_postgresql_client
from app.core.permissions import get_user_role, validate_visibility_permission, can_edit_resource, can_delete_resource, is_effective_owner
from app.services.category_service import CategoryService
import logging
logger = logging.getLogger(__name__)
class AgentService:
"""GT 2.0 PostgreSQL+PGVector Agent Service with Perfect Tenant Isolation"""
def __init__(self, tenant_domain: str, user_id: str, user_email: str = None):
"""Initialize with tenant and user isolation using PostgreSQL+PGVector storage"""
self.tenant_domain = tenant_domain
self.user_id = user_id
self.user_email = user_email or user_id # Fallback to user_id if no email provided
self.settings = get_settings()
self._resolved_user_uuid = None # Cache for resolved user UUID (performance optimization)
logger.info(f"Agent service initialized with PostgreSQL+PGVector for {tenant_domain}/{user_id} (email: {self.user_email})")
async def _get_resolved_user_uuid(self, user_identifier: Optional[str] = None) -> str:
"""
Resolve user identifier to UUID with caching for performance.
This optimization reduces repeated database lookups by caching the resolved UUID.
Performance impact: ~50% reduction in query time for operations with multiple queries.
Pattern matches conversation_service.py for consistency.
"""
identifier = user_identifier or self.user_email or self.user_id
# Return cached UUID if already resolved for this instance
if self._resolved_user_uuid and str(identifier) in [str(self.user_email), str(self.user_id)]:
return self._resolved_user_uuid
# Check if already a UUID
if "@" not in str(identifier):
try:
# Validate it's a proper UUID format
uuid.UUID(str(identifier))
if str(identifier) == str(self.user_id):
self._resolved_user_uuid = str(identifier)
return str(identifier)
except (ValueError, AttributeError):
pass # Not a valid UUID, treat as email/username
# Resolve email to UUID
pg_client = await get_postgresql_client()
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
"""
result = await pg_client.fetch_one(query, str(identifier), self.tenant_domain)
if not result:
raise ValueError(f"User not found: {identifier}")
user_uuid = str(result["id"])
# Cache if this is the service's primary user
if str(identifier) in [str(self.user_email), str(self.user_id)]:
self._resolved_user_uuid = user_uuid
return user_uuid
async def create_agent(
self,
name: str,
agent_type: str = "conversational",
prompt_template: str = "",
description: str = "",
capabilities: Optional[List[str]] = None,
access_group: str = "INDIVIDUAL",
**kwargs
) -> Dict[str, Any]:
"""Create a new agent using PostgreSQL+PGVector storage following GT 2.0 principles"""
try:
# Get PostgreSQL client
pg_client = await get_postgresql_client()
# Generate agent ID
agent_id = str(uuid.uuid4())
# Resolve user UUID with caching (performance optimization)
user_id = await self._get_resolved_user_uuid()
logger.info(f"Found user ID: {user_id} for email/id: {self.user_email}/{self.user_id}")
# Create agent in PostgreSQL
query = """
INSERT INTO agents (
id, name, description, system_prompt,
tenant_id, created_by, model, temperature, max_tokens,
visibility, configuration, is_active, access_group, agent_type
) VALUES (
$1, $2, $3, $4,
(SELECT id FROM tenants WHERE domain = $5 LIMIT 1),
$6,
$7, $8, $9, $10, $11, true, $12, $13
)
RETURNING id, name, description, system_prompt, model, temperature, max_tokens,
visibility, configuration, access_group, agent_type, created_at, updated_at
"""
# Prepare configuration with additional kwargs
# Ensure list fields are always lists, never None
configuration = {
"agent_type": agent_type,
"capabilities": capabilities or [],
"personality_config": kwargs.get("personality_config", {}),
"resource_preferences": kwargs.get("resource_preferences", {}),
"model_config": kwargs.get("model_config", {}),
"tags": kwargs.get("tags") or [],
"easy_prompts": kwargs.get("easy_prompts") or [],
"selected_dataset_ids": kwargs.get("selected_dataset_ids") or [],
**{k: v for k, v in kwargs.items() if k not in ["tags", "easy_prompts", "selected_dataset_ids"]}
}
# Extract model configuration
model = kwargs.get("model")
if not model:
raise ValueError("Model is required for agent creation")
temperature = kwargs.get("temperature", 0.7)
max_tokens = kwargs.get("max_tokens", 8000) # Increased to match Groq Llama 3.1 capabilities
# Use access_group as visibility directly (individual, organization only)
visibility = access_group.lower()
# Validate visibility permission based on user role
user_role = await get_user_role(pg_client, self.user_email, self.tenant_domain)
validate_visibility_permission(visibility, user_role)
logger.info(f"User {self.user_email} (role: {user_role}) creating agent with visibility: {visibility}")
# Auto-create category if specified (Issue #215)
# This ensures imported agents with unknown categories create those categories
# Category is stored in agent_type column
category = kwargs.get("category")
if category and isinstance(category, str) and category.strip():
category_slug = category.strip().lower()
try:
category_service = CategoryService(self.tenant_domain, user_id, self.user_email)
# Pass category_description from CSV import if provided
category_description = kwargs.get("category_description")
await category_service.get_or_create_category(category_slug, description=category_description)
logger.info(f"Ensured category exists: {category}")
except Exception as cat_err:
logger.warning(f"Failed to ensure category '{category}' exists: {cat_err}")
# Continue with agent creation even if category creation fails
# Use category as agent_type (they map to the same column)
agent_type = category_slug
agent_data = await pg_client.fetch_one(
query,
agent_id, name, description, prompt_template,
self.tenant_domain, user_id,
model, temperature, max_tokens, visibility,
json.dumps(configuration), access_group, agent_type
)
if not agent_data:
raise RuntimeError("Failed to create agent - no data returned")
# Convert to dict with proper types
# Parse configuration JSON if it's a string
config = agent_data["configuration"]
if isinstance(config, str):
config = json.loads(config)
elif config is None:
config = {}
result = {
"id": str(agent_data["id"]),
"name": agent_data["name"],
"agent_type": config.get("agent_type", "conversational"),
"prompt_template": agent_data["system_prompt"],
"description": agent_data["description"],
"capabilities": config.get("capabilities", []),
"access_group": agent_data["access_group"],
"config": config,
"model": agent_data["model"],
"temperature": float(agent_data["temperature"]) if agent_data["temperature"] is not None else None,
"max_tokens": agent_data["max_tokens"],
"top_p": config.get("top_p"),
"frequency_penalty": config.get("frequency_penalty"),
"presence_penalty": config.get("presence_penalty"),
"visibility": agent_data["visibility"],
"dataset_connection": config.get("dataset_connection"),
"selected_dataset_ids": config.get("selected_dataset_ids", []),
"max_chunks_per_query": config.get("max_chunks_per_query"),
"history_context": config.get("history_context"),
"personality_config": config.get("personality_config", {}),
"resource_preferences": config.get("resource_preferences", {}),
"tags": config.get("tags", []),
"is_favorite": config.get("is_favorite", False),
"conversation_count": 0,
"total_cost_cents": 0,
"created_at": agent_data["created_at"].isoformat(),
"updated_at": agent_data["updated_at"].isoformat(),
"user_id": self.user_id,
"tenant_domain": self.tenant_domain
}
logger.info(f"Created agent {agent_id} in PostgreSQL for user {self.user_id}")
return result
except Exception as e:
logger.error(f"Failed to create agent: {e}")
raise
async def get_user_agents(
self,
active_only: bool = True,
sort_by: Optional[str] = None,
filter_usage: Optional[str] = None
) -> List[Dict[str, Any]]:
"""Get all agents for the current user using PostgreSQL storage"""
try:
# Get PostgreSQL client
pg_client = await get_postgresql_client()
# Resolve user UUID with caching (performance optimization)
try:
user_id = await self._get_resolved_user_uuid()
except ValueError as e:
logger.warning(f"User not found for agents list: {self.user_email} (or {self.user_id}) in tenant {self.tenant_domain}: {e}")
return []
# Get user role to determine access level
user_role = await get_user_role(pg_client, self.user_email, self.tenant_domain)
is_admin = user_role in ["admin", "developer"]
# Query agents from PostgreSQL with conversation counts
# Admins see ALL agents, others see only their own or organization-level agents
if is_admin:
where_clause = "WHERE a.tenant_id = (SELECT id FROM tenants WHERE domain = $1)"
params = [self.tenant_domain]
else:
where_clause = "WHERE (a.created_by = $1 OR a.visibility = 'organization') AND a.tenant_id = (SELECT id FROM tenants WHERE domain = $2)"
params = [user_id, self.tenant_domain]
# Prepare user_id parameter for per-user usage tracking
# Need to add user_id as an additional parameter for usage calculations
user_id_param_index = len(params) + 1
params.append(user_id)
# Per-user usage tracking: Count only conversations for this user
query = f"""
SELECT
a.id, a.name, a.description, a.system_prompt, a.model, a.temperature, a.max_tokens,
a.visibility, a.configuration, a.access_group, a.created_at, a.updated_at,
a.is_active, a.created_by, a.agent_type,
u.full_name as created_by_name,
COUNT(CASE WHEN c.user_id = ${user_id_param_index}::uuid THEN c.id END) as user_conversation_count,
MAX(CASE WHEN c.user_id = ${user_id_param_index}::uuid THEN c.created_at END) as user_last_used_at
FROM agents a
LEFT JOIN conversations c ON a.id = c.agent_id
LEFT JOIN users u ON a.created_by = u.id
{where_clause}
"""
if active_only:
query += " AND a.is_active = true"
# Time-based usage filters (per-user)
if filter_usage == "used_last_7_days":
query += f" AND EXISTS (SELECT 1 FROM conversations c2 WHERE c2.agent_id = a.id AND c2.user_id = ${user_id_param_index}::uuid AND c2.created_at >= NOW() - INTERVAL '7 days')"
elif filter_usage == "used_last_30_days":
query += f" AND EXISTS (SELECT 1 FROM conversations c2 WHERE c2.agent_id = a.id AND c2.user_id = ${user_id_param_index}::uuid AND c2.created_at >= NOW() - INTERVAL '30 days')"
query += " GROUP BY a.id, a.name, a.description, a.system_prompt, a.model, a.temperature, a.max_tokens, a.visibility, a.configuration, a.access_group, a.created_at, a.updated_at, a.is_active, a.created_by, a.agent_type, u.full_name"
# User-specific sorting
if sort_by == "recent_usage":
query += " ORDER BY user_last_used_at DESC NULLS LAST, a.updated_at DESC"
elif sort_by == "my_most_used":
query += " ORDER BY user_conversation_count DESC, a.updated_at DESC"
else:
query += " ORDER BY a.updated_at DESC"
agents_data = await pg_client.execute_query(query, *params)
# Convert to proper format
agents = []
for agent in agents_data:
# Debug logging for creator name
logger.info(f"🔍 Agent '{agent['name']}': created_by={agent.get('created_by')}, created_by_name={agent.get('created_by_name')}")
# Parse configuration JSON if it's a string
config = agent["configuration"]
if isinstance(config, str):
config = json.loads(config)
elif config is None:
config = {}
disclaimer_val = config.get("disclaimer")
easy_prompts_val = config.get("easy_prompts", [])
logger.info(f"get_user_agents - Agent {agent['name']}: disclaimer={disclaimer_val}, easy_prompts={easy_prompts_val}")
# Determine if user can edit this agent
# User can edit if they created it OR if they're admin/developer
# Use cached user_role from line 190 (no need to re-query for each agent)
is_owner = is_effective_owner(str(agent["created_by"]), str(user_id), user_role)
can_edit = can_edit_resource(str(agent["created_by"]), str(user_id), user_role, agent["visibility"])
can_delete = can_delete_resource(str(agent["created_by"]), str(user_id), user_role)
logger.info(f"Agent {agent['name']}: created_by={agent['created_by']}, user_id={user_id}, user_role={user_role}, is_owner={is_owner}, can_edit={can_edit}, can_delete={can_delete}")
agents.append({
"id": str(agent["id"]),
"name": agent["name"],
"agent_type": agent["agent_type"] or "conversational",
"prompt_template": agent["system_prompt"],
"description": agent["description"],
"capabilities": config.get("capabilities", []),
"access_group": agent["access_group"],
"config": config,
"model": agent["model"],
"temperature": float(agent["temperature"]) if agent["temperature"] is not None else None,
"max_tokens": agent["max_tokens"],
"visibility": agent["visibility"],
"dataset_connection": config.get("dataset_connection"),
"selected_dataset_ids": config.get("selected_dataset_ids", []),
"personality_config": config.get("personality_config", {}),
"resource_preferences": config.get("resource_preferences", {}),
"tags": config.get("tags", []),
"is_favorite": config.get("is_favorite", False),
"disclaimer": disclaimer_val,
"easy_prompts": easy_prompts_val,
"conversation_count": int(agent["user_conversation_count"]) if agent.get("user_conversation_count") is not None else 0,
"last_used_at": agent["user_last_used_at"].isoformat() if agent.get("user_last_used_at") else None,
"total_cost_cents": 0,
"created_at": agent["created_at"].isoformat() if agent["created_at"] else None,
"updated_at": agent["updated_at"].isoformat() if agent["updated_at"] else None,
"is_active": agent["is_active"],
"user_id": agent["created_by"],
"created_by_name": agent.get("created_by_name", "Unknown"),
"tenant_domain": self.tenant_domain,
"can_edit": can_edit,
"can_delete": can_delete,
"is_owner": is_owner
})
# Fetch team-shared agents and merge with owned agents
team_shared = await self.get_team_shared_agents(user_id)
# Merge and deduplicate (owned agents take precedence)
agent_ids_seen = {agent["id"] for agent in agents}
for team_agent in team_shared:
if team_agent["id"] not in agent_ids_seen:
agents.append(team_agent)
agent_ids_seen.add(team_agent["id"])
logger.info(f"Retrieved {len(agents)} total agents ({len(agents) - len(team_shared)} owned + {len(team_shared)} team-shared) from PostgreSQL for user {self.user_id}")
return agents
except Exception as e:
logger.error(f"Error reading agents for user {self.user_id}: {e}")
return []
async def get_team_shared_agents(self, user_id: str) -> List[Dict[str, Any]]:
"""
Get agents shared to teams where user is a member (via junction table).
Uses the user_accessible_resources view for efficient lookups.
Returns agents with permission flags:
- can_edit: True if user has 'edit' permission for this agent
- can_delete: False (only owner can delete)
- is_owner: False (team-shared agents)
- shared_via_team: True (indicates team sharing)
- shared_in_teams: Number of teams this agent is shared with
"""
try:
pg_client = await get_postgresql_client()
# Query agents using the efficient user_accessible_resources view
# This view joins team_memberships -> team_resource_shares -> agents
# Include per-user usage statistics
query = """
SELECT DISTINCT
a.id, a.name, a.description, a.system_prompt, a.model, a.temperature, a.max_tokens,
a.visibility, a.configuration, a.access_group, a.created_at, a.updated_at,
a.is_active, a.created_by, a.agent_type,
u.full_name as created_by_name,
COUNT(DISTINCT CASE WHEN c.user_id = $1::uuid THEN c.id END) as user_conversation_count,
MAX(CASE WHEN c.user_id = $1::uuid THEN c.created_at END) as user_last_used_at,
uar.best_permission as user_permission,
uar.shared_in_teams,
uar.team_ids
FROM user_accessible_resources uar
INNER JOIN agents a ON a.id = uar.resource_id
LEFT JOIN users u ON a.created_by = u.id
LEFT JOIN conversations c ON a.id = c.agent_id
WHERE uar.user_id = $1::uuid
AND uar.resource_type = 'agent'
AND a.tenant_id = (SELECT id FROM tenants WHERE domain = $2 LIMIT 1)
AND a.is_active = true
GROUP BY a.id, a.name, a.description, a.system_prompt, a.model, a.temperature,
a.max_tokens, a.visibility, a.configuration, a.access_group, a.created_at,
a.updated_at, a.is_active, a.created_by, a.agent_type, u.full_name,
uar.best_permission, uar.shared_in_teams, uar.team_ids
ORDER BY a.updated_at DESC
"""
agents_data = await pg_client.execute_query(query, user_id, self.tenant_domain)
# Format agents with team sharing metadata
agents = []
for agent in agents_data:
# Parse configuration JSON
config = agent["configuration"]
if isinstance(config, str):
config = json.loads(config)
elif config is None:
config = {}
# Get permission from view (will be "read" or "edit")
user_permission = agent.get("user_permission")
can_edit = user_permission == "edit"
# Get team sharing metadata
shared_in_teams = agent.get("shared_in_teams", 0)
team_ids = agent.get("team_ids", [])
agents.append({
"id": str(agent["id"]),
"name": agent["name"],
"agent_type": agent["agent_type"] or "conversational",
"prompt_template": agent["system_prompt"],
"description": agent["description"],
"capabilities": config.get("capabilities", []),
"access_group": agent["access_group"],
"config": config,
"model": agent["model"],
"temperature": float(agent["temperature"]) if agent["temperature"] is not None else None,
"max_tokens": agent["max_tokens"],
"visibility": agent["visibility"],
"dataset_connection": config.get("dataset_connection"),
"selected_dataset_ids": config.get("selected_dataset_ids", []),
"personality_config": config.get("personality_config", {}),
"resource_preferences": config.get("resource_preferences", {}),
"tags": config.get("tags", []),
"is_favorite": config.get("is_favorite", False),
"disclaimer": config.get("disclaimer"),
"easy_prompts": config.get("easy_prompts", []),
"conversation_count": int(agent["user_conversation_count"]) if agent.get("user_conversation_count") else 0,
"last_used_at": agent["user_last_used_at"].isoformat() if agent.get("user_last_used_at") else None,
"total_cost_cents": 0,
"created_at": agent["created_at"].isoformat() if agent["created_at"] else None,
"updated_at": agent["updated_at"].isoformat() if agent["updated_at"] else None,
"is_active": agent["is_active"],
"user_id": agent["created_by"],
"created_by_name": agent.get("created_by_name", "Unknown"),
"tenant_domain": self.tenant_domain,
"can_edit": can_edit,
"can_delete": False, # Only owner can delete
"is_owner": False, # Team-shared agents
"shared_via_team": True,
"shared_in_teams": shared_in_teams,
"team_ids": [str(tid) for tid in team_ids] if team_ids else [],
"team_permission": user_permission
})
logger.info(f"Retrieved {len(agents)} team-shared agents for user {user_id}")
return agents
except Exception as e:
logger.error(f"Error fetching team-shared agents for user {user_id}: {e}")
return []
async def get_agent(self, agent_id: str) -> Optional[Dict[str, Any]]:
"""Get a specific agent by ID using PostgreSQL"""
try:
# Get PostgreSQL client
pg_client = await get_postgresql_client()
# Resolve user UUID with caching (performance optimization)
try:
user_id = await self._get_resolved_user_uuid()
except ValueError as e:
logger.warning(f"User not found: {self.user_email} (or {self.user_id}) in tenant {self.tenant_domain}: {e}")
return None
# Check if user is admin - admins can see all agents
user_role = await get_user_role(pg_client, self.user_email, self.tenant_domain)
is_admin = user_role in ["admin", "developer"]
# Query the agent first
query = """
SELECT
a.id, a.name, a.description, a.system_prompt, a.model, a.temperature, a.max_tokens,
a.visibility, a.configuration, a.access_group, a.created_at, a.updated_at,
a.is_active, a.created_by, a.agent_type,
COUNT(c.id) as conversation_count
FROM agents a
LEFT JOIN conversations c ON a.id = c.agent_id
WHERE a.id = $1 AND a.tenant_id = (SELECT id FROM tenants WHERE domain = $2)
GROUP BY a.id, a.name, a.description, a.system_prompt, a.model, a.temperature, a.max_tokens,
a.visibility, a.configuration, a.access_group, a.created_at, a.updated_at,
a.is_active, a.created_by, a.agent_type
LIMIT 1
"""
agent_data = await pg_client.fetch_one(query, agent_id, self.tenant_domain)
logger.info(f"Agent query result: {agent_data is not None}")
# If agent doesn't exist, return None
if not agent_data:
return None
# Check access: admin, owner, organization, or team-based
if not is_admin:
is_owner = str(agent_data["created_by"]) == str(user_id)
is_org_wide = agent_data["visibility"] == "organization"
# Check team-based access if not owner or org-wide
if not is_owner and not is_org_wide:
# Import TeamService here to avoid circular dependency
from app.services.team_service import TeamService
team_service = TeamService(self.tenant_domain, str(user_id), self.user_email)
has_team_access = await team_service.check_user_resource_permission(
user_id=str(user_id),
resource_type="agent",
resource_id=agent_id,
required_permission="read"
)
if not has_team_access:
logger.warning(f"User {user_id} denied access to agent {agent_id}")
return None
logger.info(f"User {user_id} has team-based access to agent {agent_id}")
if agent_data:
# Parse configuration JSON if it's a string
config = agent_data["configuration"]
if isinstance(config, str):
config = json.loads(config)
elif config is None:
config = {}
# Convert to proper format
logger.info(f"Config disclaimer: {config.get('disclaimer')}, easy_prompts: {config.get('easy_prompts')}")
# Compute is_owner for export permission checks
is_owner = str(agent_data["created_by"]) == str(user_id)
result = {
"id": str(agent_data["id"]),
"name": agent_data["name"],
"agent_type": agent_data["agent_type"] or "conversational",
"prompt_template": agent_data["system_prompt"],
"description": agent_data["description"],
"capabilities": config.get("capabilities", []),
"access_group": agent_data["access_group"],
"config": config,
"model": agent_data["model"],
"temperature": float(agent_data["temperature"]) if agent_data["temperature"] is not None else None,
"max_tokens": agent_data["max_tokens"],
"visibility": agent_data["visibility"],
"dataset_connection": config.get("dataset_connection"),
"selected_dataset_ids": config.get("selected_dataset_ids", []),
"personality_config": config.get("personality_config", {}),
"resource_preferences": config.get("resource_preferences", {}),
"tags": config.get("tags", []),
"is_favorite": config.get("is_favorite", False),
"disclaimer": config.get("disclaimer"),
"easy_prompts": config.get("easy_prompts", []),
"conversation_count": int(agent_data["conversation_count"]) if agent_data.get("conversation_count") is not None else 0,
"total_cost_cents": 0,
"created_at": agent_data["created_at"].isoformat() if agent_data["created_at"] else None,
"updated_at": agent_data["updated_at"].isoformat() if agent_data["updated_at"] else None,
"is_active": agent_data["is_active"],
"created_by": agent_data["created_by"], # Keep DB field
"user_id": agent_data["created_by"], # Alias for compatibility
"is_owner": is_owner, # Computed ownership for export/edit permissions
"tenant_domain": self.tenant_domain
}
return result
return None
except Exception as e:
logger.error(f"Error reading agent {agent_id}: {e}")
return None
async def update_agent(
self,
agent_id: str,
updates: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""Update an agent's configuration using PostgreSQL with permission checks"""
try:
logger.info(f"Processing updates for agent {agent_id}: {updates}")
# Log which fields will be processed
logger.info(f"Update fields being processed: {list(updates.keys())}")
# Get PostgreSQL client
pg_client = await get_postgresql_client()
# Get user role for permission checks
user_role = await get_user_role(pg_client, self.user_email, self.tenant_domain)
# If updating visibility, validate permission
if "visibility" in updates:
validate_visibility_permission(updates["visibility"], user_role)
logger.info(f"User {self.user_email} (role: {user_role}) updating agent visibility to: {updates['visibility']}")
# Build dynamic UPDATE query based on provided updates
set_clauses = []
params = []
param_idx = 1
# Collect all configuration updates in a single object
config_updates = {}
# Handle each update field mapping to correct column names
for field, value in updates.items():
if field in ["name", "description", "access_group"]:
set_clauses.append(f"{field} = ${param_idx}")
params.append(value)
param_idx += 1
elif field == "prompt_template":
set_clauses.append(f"system_prompt = ${param_idx}")
params.append(value)
param_idx += 1
elif field in ["model", "temperature", "max_tokens", "visibility", "agent_type"]:
set_clauses.append(f"{field} = ${param_idx}")
params.append(value)
param_idx += 1
elif field == "is_active":
set_clauses.append(f"is_active = ${param_idx}")
params.append(value)
param_idx += 1
elif field in ["config", "configuration", "personality_config", "resource_preferences", "tags", "is_favorite",
"dataset_connection", "selected_dataset_ids", "disclaimer", "easy_prompts"]:
# Collect configuration updates
if field in ["config", "configuration"]:
config_updates.update(value if isinstance(value, dict) else {})
else:
config_updates[field] = value
# Apply configuration updates as a single operation
if config_updates:
set_clauses.append(f"configuration = configuration || ${param_idx}::jsonb")
params.append(json.dumps(config_updates))
param_idx += 1
if not set_clauses:
logger.warning(f"No valid update fields provided for agent {agent_id}")
return await self.get_agent(agent_id)
# Add updated_at timestamp
set_clauses.append(f"updated_at = NOW()")
# Resolve user UUID with caching (performance optimization)
try:
user_id = await self._get_resolved_user_uuid()
except ValueError as e:
logger.warning(f"User not found for update: {self.user_email} (or {self.user_id}) in tenant {self.tenant_domain}: {e}")
return None
# Check if user is admin - admins can update any agent
is_admin = user_role in ["admin", "developer"]
# Build final query - admins can update any agent in tenant, others only their own
if is_admin:
query = f"""
UPDATE agents
SET {', '.join(set_clauses)}
WHERE id = ${param_idx}
AND tenant_id = (SELECT id FROM tenants WHERE domain = ${param_idx + 1})
RETURNING id
"""
params.extend([agent_id, self.tenant_domain])
else:
query = f"""
UPDATE agents
SET {', '.join(set_clauses)}
WHERE id = ${param_idx}
AND tenant_id = (SELECT id FROM tenants WHERE domain = ${param_idx + 1})
AND created_by = ${param_idx + 2}
RETURNING id
"""
params.extend([agent_id, self.tenant_domain, user_id])
# Execute update
logger.info(f"Executing update query: {query}")
logger.info(f"Query parameters: {params}")
updated_id = await pg_client.fetch_scalar(query, *params)
logger.info(f"Update result: {updated_id}")
if updated_id:
# Get updated agent data
updated_agent = await self.get_agent(agent_id)
logger.info(f"Updated agent {agent_id} in PostgreSQL")
return updated_agent
return None
except Exception as e:
logger.error(f"Error updating agent {agent_id}: {e}")
return None
async def delete_agent(self, agent_id: str) -> bool:
"""Soft delete an agent using PostgreSQL"""
try:
# Get PostgreSQL client
pg_client = await get_postgresql_client()
# Get user role to check if admin
user_role = await get_user_role(pg_client, self.user_email, self.tenant_domain)
is_admin = user_role in ["admin", "developer"]
# Soft delete in PostgreSQL - admins can delete any agent, others only their own
if is_admin:
query = """
UPDATE agents
SET is_active = false, updated_at = NOW()
WHERE id = $1
AND tenant_id = (SELECT id FROM tenants WHERE domain = $2)
RETURNING id
"""
deleted_id = await pg_client.fetch_scalar(query, agent_id, self.tenant_domain)
else:
query = """
UPDATE agents
SET is_active = false, updated_at = NOW()
WHERE id = $1
AND tenant_id = (SELECT id FROM tenants WHERE domain = $2)
AND created_by = (SELECT id FROM users WHERE email = $3)
RETURNING id
"""
deleted_id = await pg_client.fetch_scalar(query, agent_id, self.tenant_domain, self.user_email or self.user_id)
if deleted_id:
logger.info(f"Deleted agent {agent_id} from PostgreSQL")
return True
return False
except Exception as e:
logger.error(f"Error deleting agent {agent_id}: {e}")
return False
async def check_access_permission(self, agent_id: str, requesting_user_id: str, access_type: str = "read") -> bool:
"""
Check if user has access to agent (via ownership, organization, or team).
Args:
agent_id: UUID of the agent
requesting_user_id: UUID of the user requesting access
access_type: 'read' or 'edit' (default: 'read')
Returns:
True if user has required access
"""
try:
pg_client = await get_postgresql_client()
# Check if admin/developer
user_role = await get_user_role(pg_client, requesting_user_id, self.tenant_domain)
if user_role in ["admin", "developer"]:
return True
# Get agent to check ownership and visibility
query = """
SELECT created_by, visibility
FROM agents
WHERE id = $1 AND tenant_id = (SELECT id FROM tenants WHERE domain = $2)
"""
agent_data = await pg_client.fetch_one(query, agent_id, self.tenant_domain)
if not agent_data:
return False
owner_id = str(agent_data["created_by"])
visibility = agent_data["visibility"]
# Owner has full access
if requesting_user_id == owner_id:
return True
# Organization-wide resources are accessible to all in tenant
if visibility == "organization":
return True
# Check team-based access
from app.services.team_service import TeamService
team_service = TeamService(self.tenant_domain, requesting_user_id, requesting_user_id)
return await team_service.check_user_resource_permission(
user_id=requesting_user_id,
resource_type="agent",
resource_id=agent_id,
required_permission=access_type
)
except Exception as e:
logger.error(f"Error checking access permission for agent {agent_id}: {e}")
return False
async def _check_team_membership(self, user_id: str, team_members: List[str]) -> bool:
"""Check if user is in the team members list"""
return user_id in team_members
async def _check_same_tenant(self, user_id: str) -> bool:
"""Check if requesting user is in the same tenant through PostgreSQL"""
try:
pg_client = await get_postgresql_client()
# Check if user exists in same tenant
query = """
SELECT COUNT(*) as count
FROM users
WHERE id = $1 AND tenant_id = (SELECT id FROM tenants WHERE domain = $2)
"""
result = await pg_client.fetch_one(query, user_id, self.tenant_domain)
return result and result["count"] > 0
except Exception as e:
logger.error(f"Failed to check tenant membership for user {user_id}: {e}")
return False
def get_agent_conversation_history(self, agent_id: str) -> List[Dict[str, Any]]:
"""Get conversation history for an agent (file-based)"""
conversations_path = Path(f"/data/{self.tenant_domain}/users/{self.user_id}/conversations")
conversations_path.mkdir(parents=True, exist_ok=True, mode=0o700)
conversations = []
try:
for conv_file in conversations_path.glob("*.json"):
with open(conv_file, 'r') as f:
conv_data = json.load(f)
if conv_data.get("agent_id") == agent_id:
conversations.append(conv_data)
except Exception as e:
logger.error(f"Error reading conversations for agent {agent_id}: {e}")
conversations.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
return conversations