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>
This commit is contained in:
HackWeasel
2025-12-12 17:04:45 -05:00
commit b9dfb86260
746 changed files with 232071 additions and 0 deletions

View File

@@ -0,0 +1,854 @@
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