diff --git a/backend/agent/api.py b/backend/agent/api.py index a012b2fd..fc4dad87 100644 --- a/backend/agent/api.py +++ b/backend/agent/api.py @@ -25,6 +25,7 @@ from utils.constants import MODEL_NAME_ALIASES from flags.flags import is_enabled from .config_helper import extract_agent_config, build_unified_config, extract_tools_for_agent_run, get_mcp_configs +from .utils import check_agent_run_limit from .versioning.version_service import get_version_service from .versioning.api import router as version_router, initialize as initialize_versioning @@ -437,6 +438,18 @@ async def start_agent( if not can_run: raise HTTPException(status_code=402, detail={"message": message, "subscription": subscription}) + # Check agent run limit (maximum parallel runs in past 24 hours) + limit_check = await check_agent_run_limit(client, account_id) + if not limit_check['can_start']: + error_detail = { + "message": f"Maximum of {config.MAX_PARALLEL_AGENT_RUNS} parallel agent runs allowed within 24 hours. You currently have {limit_check['running_count']} running.", + "running_thread_ids": limit_check['running_thread_ids'], + "running_count": limit_check['running_count'], + "limit": config.MAX_PARALLEL_AGENT_RUNS + } + logger.warning(f"Agent run limit exceeded for account {account_id}: {limit_check['running_count']} running agents") + raise HTTPException(status_code=429, detail=error_detail) + try: project_result = await client.table('projects').select('*').eq('project_id', project_id).execute() if not project_result.data: @@ -1049,6 +1062,18 @@ async def initiate_agent_with_files( if not can_run: raise HTTPException(status_code=402, detail={"message": message, "subscription": subscription}) + # Check agent run limit (maximum parallel runs in past 24 hours) + limit_check = await check_agent_run_limit(client, account_id) + if not limit_check['can_start']: + error_detail = { + "message": f"Maximum of {config.MAX_PARALLEL_AGENT_RUNS} parallel agent runs allowed within 24 hours. You currently have {limit_check['running_count']} running.", + "running_thread_ids": limit_check['running_thread_ids'], + "running_count": limit_check['running_count'], + "limit": config.MAX_PARALLEL_AGENT_RUNS + } + logger.warning(f"Agent run limit exceeded for account {account_id}: {limit_check['running_count']} running agents") + raise HTTPException(status_code=429, detail=error_detail) + try: # 1. Create Project placeholder_name = f"{prompt[:30]}..." if len(prompt) > 30 else prompt diff --git a/backend/agent/utils.py b/backend/agent/utils.py index b42d67c8..cb0ac8f9 100644 --- a/backend/agent/utils.py +++ b/backend/agent/utils.py @@ -1,7 +1,10 @@ import json -from typing import Optional +from typing import Optional, List, Dict, Any +from datetime import datetime, timezone, timedelta from utils.logger import logger +from utils.config import config from services import redis +from run_agent_background import update_agent_run_status async def _cleanup_redis_response_list(agent_run_id: str): @@ -67,11 +70,64 @@ async def stop_agent_run(db, agent_run_id: str, error_message: Optional[str] = N except Exception as e: logger.warning(f"Failed to publish STOP signal to instance channel {instance_control_channel}: {str(e)}") else: - logger.warning(f"Unexpected key format found: {key}") + logger.warning(f"Unexpected key format found: {key}") await _cleanup_redis_response_list(agent_run_id) except Exception as e: logger.error(f"Failed to find or signal active instances for {agent_run_id}: {str(e)}") - logger.info(f"Successfully initiated stop process for agent run: {agent_run_id}") \ No newline at end of file + logger.info(f"Successfully initiated stop process for agent run: {agent_run_id}") + + +async def check_agent_run_limit(client, account_id: str) -> Dict[str, Any]: + """ + Check if the account has reached the limit of 3 parallel agent runs within the past 24 hours. + + Returns: + Dict with 'can_start' (bool), 'running_count' (int), 'running_thread_ids' (list) + """ + try: + # Calculate 24 hours ago + twenty_four_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24) + twenty_four_hours_ago_iso = twenty_four_hours_ago.isoformat() + + logger.debug(f"Checking agent run limit for account {account_id} since {twenty_four_hours_ago_iso}") + + # Get all threads for this account + threads_result = await client.table('threads').select('thread_id').eq('account_id', account_id).execute() + + if not threads_result.data: + logger.debug(f"No threads found for account {account_id}") + return { + 'can_start': True, + 'running_count': 0, + 'running_thread_ids': [] + } + + thread_ids = [thread['thread_id'] for thread in threads_result.data] + logger.debug(f"Found {len(thread_ids)} threads for account {account_id}") + + # Query for running agent runs within the past 24 hours for these threads + running_runs_result = await client.table('agent_runs').select('id', 'thread_id', 'started_at').in_('thread_id', thread_ids).eq('status', 'running').gte('started_at', twenty_four_hours_ago_iso).execute() + + running_runs = running_runs_result.data or [] + running_count = len(running_runs) + running_thread_ids = [run['thread_id'] for run in running_runs] + + logger.info(f"Account {account_id} has {running_count} running agent runs in the past 24 hours") + + return { + 'can_start': running_count < config.MAX_PARALLEL_AGENT_RUNS, + 'running_count': running_count, + 'running_thread_ids': running_thread_ids + } + + except Exception as e: + logger.error(f"Error checking agent run limit for account {account_id}: {str(e)}") + # In case of error, allow the run to proceed but log the error + return { + 'can_start': True, + 'running_count': 0, + 'running_thread_ids': [] + } \ No newline at end of file diff --git a/backend/services/billing.py b/backend/services/billing.py index a6289788..41903ea7 100644 --- a/backend/services/billing.py +++ b/backend/services/billing.py @@ -8,6 +8,8 @@ from fastapi import APIRouter, HTTPException, Depends, Request from typing import Optional, Dict, Tuple import stripe from datetime import datetime, timezone, timedelta + +from supabase import Client as SupabaseClient from utils.logger import logger from utils.config import config, EnvMode from services.supabase import DBConnection @@ -162,7 +164,7 @@ class SubscriptionStatus(BaseModel): subscription: Optional[Dict] = None # Helper functions -async def get_stripe_customer_id(client, user_id: str) -> Optional[str]: +async def get_stripe_customer_id(client: SupabaseClient, user_id: str) -> Optional[str]: """Get the Stripe customer ID for a user.""" result = await client.schema('basejump').from_('billing_customers') \ .select('id') \ @@ -171,6 +173,42 @@ async def get_stripe_customer_id(client, user_id: str) -> Optional[str]: if result.data and len(result.data) > 0: return result.data[0]['id'] + + customer_result = await stripe.Customer.search_async( + query=f"metadata['user_id']:'{user_id}' OR metadata['basejump_account_id']:'{user_id}'" + ) + + if customer_result.data and len(customer_result.data) > 0: + customer = customer_result.data[0] + # If the customer does not have 'user_id' in metadata, add it now + if not customer.get('metadata', {}).get('user_id'): + try: + await stripe.Customer.modify_async( + customer['id'], + metadata={**customer.get('metadata', {}), 'user_id': user_id} + ) + logger.info(f"Added missing user_id metadata to Stripe customer {customer['id']}") + except Exception as e: + logger.error(f"Failed to add user_id metadata to Stripe customer {customer['id']}: {str(e)}") + + has_active = len((await stripe.Subscription.list_async( + customer=customer['id'], + status='active', + limit=1 + )).get('data', [])) > 0 + + # Create or update record in billing_customers table + await client.schema('basejump').from_('billing_customers').upsert({ + 'id': customer['id'], + 'account_id': user_id, + 'email': customer.get('email'), + 'provider': 'stripe', + 'active': has_active + }).execute() + logger.info(f"Updated billing_customers record for customer {customer['id']} and user {user_id}") + + return customer['id'] + return None async def create_stripe_customer(client, user_id: str, email: str) -> str: @@ -676,7 +714,7 @@ async def create_checkout_session( # Get or create Stripe customer customer_id = await get_stripe_customer_id(client, current_user_id) if not customer_id: customer_id = await create_stripe_customer(client, current_user_id, email) - + # Get the target price and product ID try: price = await stripe.Price.retrieve_async(request.price_id, expand=['product']) diff --git a/backend/utils/config.py b/backend/utils/config.py index 508b15ac..5d6d5d81 100644 --- a/backend/utils/config.py +++ b/backend/utils/config.py @@ -267,7 +267,34 @@ class Configuration: # API Keys system configuration API_KEY_SECRET: str = "default-secret-key-change-in-production" API_KEY_LAST_USED_THROTTLE_SECONDS: int = 900 + + # Agent execution limits (can be overridden via environment variable) + _MAX_PARALLEL_AGENT_RUNS_ENV: Optional[str] = None + @property + def MAX_PARALLEL_AGENT_RUNS(self) -> int: + """ + Get the maximum parallel agent runs limit. + + Can be overridden via MAX_PARALLEL_AGENT_RUNS environment variable. + Defaults: + - Production: 3 + - Local/Staging: 999999 (effectively infinite) + """ + # Check for environment variable override first + if self._MAX_PARALLEL_AGENT_RUNS_ENV is not None: + try: + return int(self._MAX_PARALLEL_AGENT_RUNS_ENV) + except ValueError: + logger.warning(f"Invalid MAX_PARALLEL_AGENT_RUNS value: {self._MAX_PARALLEL_AGENT_RUNS_ENV}, using default") + + # Environment-based defaults + if self.ENV_MODE == EnvMode.PRODUCTION: + return 3 + else: + # Local and staging: effectively infinite + return 999999 + @property def STRIPE_PRODUCT_ID(self) -> str: if self.ENV_MODE == EnvMode.STAGING: @@ -317,6 +344,11 @@ class Configuration: else: # String or other type setattr(self, key, env_val) + + # Custom handling for environment-dependent properties + max_parallel_runs_env = os.getenv("MAX_PARALLEL_AGENT_RUNS") + if max_parallel_runs_env is not None: + self._MAX_PARALLEL_AGENT_RUNS_ENV = max_parallel_runs_env def _validate(self): """Validate configuration based on type hints.""" diff --git a/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx b/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx index 2e1611a3..d502fb81 100644 --- a/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx +++ b/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx @@ -8,7 +8,7 @@ import React, { useMemo, } from 'react'; import { useSearchParams } from 'next/navigation'; -import { BillingError } from '@/lib/api'; +import { BillingError, AgentRunLimitError } from '@/lib/api'; import { toast } from 'sonner'; import { ChatInput } from '@/components/thread/chat-input/chat-input'; import { useSidebar } from '@/components/ui/sidebar'; @@ -28,6 +28,7 @@ import { useThreadData, useToolCalls, useBilling, useKeyboardShortcuts } from '. import { ThreadError, UpgradeDialog, ThreadLayout } from '../_components'; import { useVncPreloader } from '@/hooks/useVncPreloader'; import { useThreadAgent } from '@/hooks/react-query/agents/use-agents'; +import { AgentRunLimitDialog } from '@/components/thread/agent-run-limit-dialog'; export default function ThreadPage({ params, @@ -55,6 +56,11 @@ export default function ThreadPage({ const [isSidePanelAnimating, setIsSidePanelAnimating] = useState(false); const [userInitiatedRun, setUserInitiatedRun] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [showAgentLimitDialog, setShowAgentLimitDialog] = useState(false); + const [agentLimitData, setAgentLimitData] = useState<{ + runningCount: number; + runningThreadIds: string[]; + } | null>(null); // Refs - simplified for flex-column-reverse @@ -320,6 +326,21 @@ export default function ThreadPage({ return; } + if (error instanceof AgentRunLimitError) { + console.log("Caught AgentRunLimitError:", error.detail); + const { running_thread_ids, running_count } = error.detail; + + // Show the dialog with limit information + setAgentLimitData({ + runningCount: running_count, + runningThreadIds: running_thread_ids, + }); + setShowAgentLimitDialog(true); + + setMessages(prev => prev.filter(m => m.message_id !== optimisticUserMessage.message_id)); + return; + } + throw new Error(`Failed to start agent: ${error?.message || error}`); } @@ -331,7 +352,7 @@ export default function ThreadPage({ } catch (err) { console.error('Error sending message or starting agent:', err); - if (!(err instanceof BillingError)) { + if (!(err instanceof BillingError) && !(err instanceof AgentRunLimitError)) { toast.error(err instanceof Error ? err.message : 'Operation failed'); } setMessages((prev) => @@ -735,6 +756,16 @@ export default function ThreadPage({ onOpenChange={setShowUpgradeDialog} onDismiss={handleDismissUpgradeDialog} /> + + {agentLimitData && ( + + )} ); } \ No newline at end of file diff --git a/frontend/src/components/dashboard/dashboard-content.tsx b/frontend/src/components/dashboard/dashboard-content.tsx index 8ae9c517..94d6ccb1 100644 --- a/frontend/src/components/dashboard/dashboard-content.tsx +++ b/frontend/src/components/dashboard/dashboard-content.tsx @@ -10,6 +10,7 @@ import { } from '@/components/thread/chat-input/chat-input'; import { BillingError, + AgentRunLimitError, } from '@/lib/api'; import { useIsMobile } from '@/hooks/use-mobile'; import { useSidebar } from '@/components/ui/sidebar'; @@ -32,6 +33,7 @@ import { Examples } from './examples'; import { useThreadQuery } from '@/hooks/react-query/threads/use-threads'; import { normalizeFilenameToNFC } from '@/lib/utils/unicode'; import { KortixLogo } from '../sidebar/kortix-logo'; +import { AgentRunLimitDialog } from '@/components/thread/agent-run-limit-dialog'; const PENDING_PROMPT_KEY = 'pendingAgentPrompt'; @@ -43,6 +45,11 @@ export function DashboardContent() { const [initiatedThreadId, setInitiatedThreadId] = useState(null); const { billingError, handleBillingError, clearBillingError } = useBillingError(); + const [showAgentLimitDialog, setShowAgentLimitDialog] = useState(false); + const [agentLimitData, setAgentLimitData] = useState<{ + runningCount: number; + runningThreadIds: string[]; + } | null>(null); const router = useRouter(); const searchParams = useSearchParams(); const isMobile = useIsMobile(); @@ -153,6 +160,16 @@ export function DashboardContent() { if (error instanceof BillingError) { console.log('Handling BillingError:', error.detail); onOpen("paymentRequiredDialog"); + } else if (error instanceof AgentRunLimitError) { + console.log('Handling AgentRunLimitError:', error.detail); + const { running_thread_ids, running_count } = error.detail; + + // Show the dialog with limit information + setAgentLimitData({ + runningCount: running_count, + runningThreadIds: running_thread_ids, + }); + setShowAgentLimitDialog(true); } setIsSubmitting(false); } @@ -257,6 +274,16 @@ export function DashboardContent() { isOpen={!!billingError} /> + + {agentLimitData && ( + + )} ); } diff --git a/frontend/src/components/home/sections/hero-section.tsx b/frontend/src/components/home/sections/hero-section.tsx index de2e9d08..7d74e754 100644 --- a/frontend/src/components/home/sections/hero-section.tsx +++ b/frontend/src/components/home/sections/hero-section.tsx @@ -11,6 +11,7 @@ import { useRouter } from 'next/navigation'; import { useAuth } from '@/components/AuthProvider'; import { BillingError, + AgentRunLimitError, } from '@/lib/api'; import { useInitiateAgentMutation } from '@/hooks/react-query/dashboard/use-initiate-agent'; import { useThreadQuery } from '@/hooks/react-query/threads/use-threads'; @@ -37,6 +38,7 @@ import { normalizeFilenameToNFC } from '@/lib/utils/unicode'; import { createQueryHook } from '@/hooks/use-query'; import { agentKeys } from '@/hooks/react-query/agents/keys'; import { getAgents } from '@/hooks/react-query/agents/utils'; +import { AgentRunLimitDialog } from '@/components/thread/agent-run-limit-dialog'; // Custom dialog overlay with blur effect const BlurredDialogOverlay = () => ( @@ -67,6 +69,11 @@ export function HeroSection() { const [initiatedThreadId, setInitiatedThreadId] = useState(null); const threadQuery = useThreadQuery(initiatedThreadId || ''); const chatInputRef = useRef(null); + const [showAgentLimitDialog, setShowAgentLimitDialog] = useState(false); + const [agentLimitData, setAgentLimitData] = useState<{ + runningCount: number; + runningThreadIds: string[]; + } | null>(null); // Fetch agents for selection const { data: agentsResponse } = createQueryHook( @@ -199,6 +206,16 @@ export function HeroSection() { if (error instanceof BillingError) { console.log('Billing error:', error.detail); onOpen("paymentRequiredDialog"); + } else if (error instanceof AgentRunLimitError) { + console.log('Handling AgentRunLimitError:', error.detail); + const { running_thread_ids, running_count } = error.detail; + + // Show the dialog with limit information + setAgentLimitData({ + runningCount: running_count, + runningThreadIds: running_thread_ids, + }); + setShowAgentLimitDialog(true); } else { const isConnectionError = error instanceof TypeError && @@ -414,6 +431,16 @@ export function HeroSection() { onDismiss={clearBillingError} isOpen={!!billingError} /> + + {agentLimitData && ( + + )} ); } diff --git a/frontend/src/components/thread/agent-run-limit-dialog.tsx b/frontend/src/components/thread/agent-run-limit-dialog.tsx new file mode 100644 index 00000000..c5699386 --- /dev/null +++ b/frontend/src/components/thread/agent-run-limit-dialog.tsx @@ -0,0 +1,328 @@ +'use client'; + +import React, { useState, useMemo } from 'react'; +import { AlertTriangle, ExternalLink, X, Square, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import Link from 'next/link'; +import { useStopAgentMutation } from '@/hooks/react-query/threads/use-agent-run'; +import { AgentRun, getAgentRuns } from '@/lib/api'; +import { toast } from 'sonner'; +import { useQueries, useQueryClient } from '@tanstack/react-query'; +import { getThread, getProject } from '@/hooks/react-query/threads/utils'; +import { threadKeys } from '@/hooks/react-query/threads/keys'; + +interface RunningThreadInfo { + threadId: string; + name: string; + projectId: string | null; + projectName: string; + agentRun: AgentRun | null; + isLoading: boolean; + error?: boolean; +} + +interface RunningThreadItemProps { + threadInfo: RunningThreadInfo; + onThreadStopped: () => void; +} + +const RunningThreadItem: React.FC = ({ + threadInfo, + onThreadStopped, +}) => { + const stopAgentMutation = useStopAgentMutation(); + const queryClient = useQueryClient(); + + const handleStop = async () => { + if (!threadInfo.agentRun?.id) return; + + try { + await stopAgentMutation.mutateAsync(threadInfo.agentRun.id); + + // Invalidate relevant queries to refetch updated data + await Promise.all([ + // Refetch agent runs for this thread to update the running status + queryClient.invalidateQueries({ + queryKey: threadKeys.agentRuns(threadInfo.threadId) + }), + // Refetch thread details in case the status affects the thread + queryClient.invalidateQueries({ + queryKey: threadKeys.details(threadInfo.threadId) + }) + ]); + + toast.success('Agent stopped successfully'); + onThreadStopped(); + } catch (error) { + console.error('Failed to stop agent:', error); + toast.error('Failed to stop agent'); + } + }; + + const getThreadDisplayName = () => { + if (threadInfo.name && threadInfo.name.trim()) { + return threadInfo.name; + } + return `Thread ${threadInfo.threadId.slice(0, 8)}...`; + }; + + const getProjectDisplayName = () => { + if (threadInfo.projectName && threadInfo.projectName.trim()) { + return threadInfo.projectName; + } + return threadInfo.projectId ? `Project ${threadInfo.projectId.slice(0, 8)}...` : 'No Project'; + }; + + return ( +
+
+
+
+
+ {getProjectDisplayName()} +
+ + {threadInfo.threadId} + +
+
+ +
+ {threadInfo.agentRun && ( + + + + + Stop this agent + + )} + + {threadInfo.projectId && ( + + + + + Open thread + + )} +
+
+ ); +}; + +interface AgentRunLimitDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + runningCount: number; + runningThreadIds: string[]; + projectId?: string; +} + +export const AgentRunLimitDialog: React.FC = ({ + open, + onOpenChange, + runningCount, + runningThreadIds, + projectId, +}) => { + // Use the exact same query keys as existing hooks for perfect cache consistency with sidebar + // This means data fetched by the sidebar is immediately available here without additional requests + const threadQueries = useQueries({ + queries: runningThreadIds.map(threadId => ({ + queryKey: threadKeys.details(threadId), + queryFn: () => getThread(threadId), + enabled: open && !!threadId, + retry: 1, + })) + }); + + const agentRunQueries = useQueries({ + queries: runningThreadIds.map(threadId => ({ + queryKey: threadKeys.agentRuns(threadId), // This matches useAgentRunsQuery exactly + queryFn: () => getAgentRuns(threadId), + enabled: open && !!threadId, + retry: 1, + })) + }); + + // Use the same query keys as useProjectQuery for cache consistency + const projectQueries = useQueries({ + queries: runningThreadIds.map(threadId => { + const threadQuery = threadQueries.find((_, index) => runningThreadIds[index] === threadId); + const projectId = threadQuery?.data?.project_id; + + return { + queryKey: threadKeys.project(projectId || ""), + queryFn: () => projectId ? getProject(projectId) : null, + enabled: open && !!projectId, + retry: 1, + }; + }) + }); + + // Process the React Query results into our thread info structure + const runningThreadsInfo: RunningThreadInfo[] = useMemo(() => { + return runningThreadIds.map((threadId, index) => { + const threadQuery = threadQueries[index]; + const agentRunQuery = agentRunQueries[index]; + const projectQuery = projectQueries[index]; + + const isLoading = threadQuery.isLoading || agentRunQuery.isLoading || projectQuery.isLoading; + const hasError = threadQuery.isError || agentRunQuery.isError || projectQuery.isError; + + // Find the running agent run for this thread + const runningAgentRun = agentRunQuery.data?.find((run: AgentRun) => run.status === 'running') || null; + + // Get thread name from first user message if available + let threadName = ''; + if (threadQuery.data?.messages?.length > 0) { + const firstUserMessage = threadQuery.data.messages.find((msg: any) => msg.type === 'user'); + if (firstUserMessage?.content) { + threadName = firstUserMessage.content.substring(0, 50); + if (firstUserMessage.content.length > 50) threadName += '...'; + } + } + + // Get project information + const projectId = threadQuery.data?.project_id || null; + const projectName = projectQuery.data?.name || ''; + + return { + threadId, + name: threadName, + projectId, + projectName, + agentRun: runningAgentRun, + isLoading, + error: hasError, + }; + }); + }, [runningThreadIds, threadQueries, agentRunQueries, projectQueries]); + + const isLoadingThreads = threadQueries.some(q => q.isLoading) || agentRunQueries.some(q => q.isLoading) || projectQueries.some(q => q.isLoading); + + const handleClose = () => { + onOpenChange(false); + }; + + const handleThreadStopped = () => { + // No need to close the dialog - the queries will automatically refetch + // and update the UI to show the current running state + }; + + return ( + + + +
+
+ +
+ + Parallel Runs Limit Reached + +
+ + You've reached the maximum parallel agent runs allowed. + +
+ +
+ {(runningThreadIds.length > 0 || runningCount > 0) && ( +
+
+

Currently Running Agents

+
+ + {isLoadingThreads ? ( +
+ + Loading threads... +
+ ) : runningThreadIds.length === 0 ? ( +
+

Found {runningCount} running agents but unable to load thread details.

+

Thread IDs: {JSON.stringify(runningThreadIds)}

+
+ ) : ( +
+ {runningThreadsInfo.slice(0, 5).map((threadInfo) => ( + + ))} + + {runningThreadsInfo.length > 5 && ( +
+ + +{runningThreadsInfo.length - 5} more running + +
+ )} +
+ )} +
+ )} + + + +
+

What can you do?

+
    +
  • +
    + Click the button to stop running agents +
  • +
  • +
    + Wait for an agent to complete automatically +
  • +
+
+ +
+ +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/hooks/react-query/dashboard/use-initiate-agent.ts b/frontend/src/hooks/react-query/dashboard/use-initiate-agent.ts index cf9c3b01..7ac328af 100644 --- a/frontend/src/hooks/react-query/dashboard/use-initiate-agent.ts +++ b/frontend/src/hooks/react-query/dashboard/use-initiate-agent.ts @@ -1,6 +1,6 @@ 'use client'; -import { initiateAgent, InitiateAgentResponse } from "@/lib/api"; +import { initiateAgent, InitiateAgentResponse, BillingError, AgentRunLimitError } from "@/lib/api"; import { createMutationHook } from "@/hooks/use-query"; import { handleApiSuccess, handleApiError } from "@/lib/error-handler"; import { dashboardKeys } from "./keys"; @@ -19,6 +19,10 @@ export const useInitiateAgentMutation = createMutationHook< handleApiSuccess("Agent initiated successfully", "Your AI assistant is ready to help"); }, onError: (error) => { + // Let BillingError and AgentRunLimitError bubble up to be handled by components + if (error instanceof BillingError || error instanceof AgentRunLimitError) { + throw error; + } if (error instanceof Error && error.message.toLowerCase().includes("payment required")) { return; } @@ -38,6 +42,12 @@ export const useInitiateAgentWithInvalidation = () => { }, onError: (error) => { console.log('Mutation error:', error); + + // Let AgentRunLimitError bubble up to be handled by components + if (error instanceof AgentRunLimitError) { + throw error; + } + if (error instanceof Error) { const errorMessage = error.message; if (errorMessage.toLowerCase().includes("payment required")) { diff --git a/frontend/src/hooks/react-query/threads/use-agent-run.ts b/frontend/src/hooks/react-query/threads/use-agent-run.ts index c09ed9f1..08ba7ec1 100644 --- a/frontend/src/hooks/react-query/threads/use-agent-run.ts +++ b/frontend/src/hooks/react-query/threads/use-agent-run.ts @@ -1,6 +1,6 @@ import { createMutationHook, createQueryHook } from "@/hooks/use-query"; import { threadKeys } from "./keys"; -import { BillingError, getAgentRuns, startAgent, stopAgent } from "@/lib/api"; +import { BillingError, AgentRunLimitError, getAgentRuns, startAgent, stopAgent } from "@/lib/api"; export const useAgentRunsQuery = (threadId: string) => createQueryHook( @@ -29,6 +29,7 @@ export const useStartAgentMutation = () => }) => startAgent(threadId, options), { onError: (error) => { + // Only silently handle BillingError - let AgentRunLimitError bubble up to be handled by the page component if (!(error instanceof BillingError)) { throw error; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b21e4c2e..c5e3a83f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -29,6 +29,35 @@ export class BillingError extends Error { } } +// Custom error for agent run limit exceeded +export class AgentRunLimitError extends Error { + status: number; + detail: { + message: string; + running_thread_ids: string[]; + running_count: number; + }; + + constructor( + status: number, + detail: { + message: string; + running_thread_ids: string[]; + running_count: number; + [key: string]: any; + }, + message?: string, + ) { + super(message || detail.message || `Agent Run Limit Exceeded: ${status}`); + this.name = 'AgentRunLimitError'; + this.status = status; + this.detail = detail; + + // Set the prototype explicitly. + Object.setPrototypeOf(this, AgentRunLimitError.prototype); + } +} + export class NoAccessTokenAvailableError extends Error { constructor(message?: string, options?: { cause?: Error }) { super(message || 'No access token available', options); @@ -707,6 +736,28 @@ export const startAgent = async ( } } + // Check for 429 Too Many Requests (Agent Run Limit) + if (response.status === 429) { + const errorData = await response.json(); + console.error(`[API] Agent run limit error starting agent (429):`, errorData); + // Ensure detail exists and has required properties + const detail = errorData?.detail || { + message: 'Too many agent runs running', + running_thread_ids: [], + running_count: 0, + }; + if (typeof detail.message !== 'string') { + detail.message = 'Too many agent runs running'; + } + if (!Array.isArray(detail.running_thread_ids)) { + detail.running_thread_ids = []; + } + if (typeof detail.running_count !== 'number') { + detail.running_count = 0; + } + throw new AgentRunLimitError(response.status, detail); + } + // Handle other errors const errorText = await response .text() @@ -723,8 +774,8 @@ export const startAgent = async ( const result = await response.json(); return result; } catch (error) { - // Rethrow BillingError instances directly - if (error instanceof BillingError) { + // Rethrow BillingError and AgentRunLimitError instances directly + if (error instanceof BillingError || error instanceof AgentRunLimitError) { throw error; } @@ -1513,6 +1564,54 @@ export const initiateAgent = async ( }); if (!response.ok) { + // Check for 402 Payment Required first + if (response.status === 402) { + try { + const errorData = await response.json(); + console.error(`[API] Billing error initiating agent (402):`, errorData); + // Ensure detail exists and has a message property + const detail = errorData?.detail || { message: 'Payment Required' }; + if (typeof detail.message !== 'string') { + detail.message = 'Payment Required'; // Default message if missing + } + throw new BillingError(response.status, detail); + } catch (parseError) { + // Handle cases where parsing fails or the structure isn't as expected + console.error( + '[API] Could not parse 402 error response body:', + parseError, + ); + throw new BillingError( + response.status, + { message: 'Payment Required' }, + `Error initiating agent: ${response.statusText} (402)`, + ); + } + } + + // Check for 429 Too Many Requests (Agent Run Limit) + if (response.status === 429) { + const errorData = await response.json(); + console.error(`[API] Agent run limit error initiating agent (429):`, errorData); + // Ensure detail exists and has required properties + const detail = errorData?.detail || { + message: 'Too many agent runs running', + running_thread_ids: [], + running_count: 0, + }; + if (typeof detail.message !== 'string') { + detail.message = 'Too many agent runs running'; + } + if (!Array.isArray(detail.running_thread_ids)) { + detail.running_thread_ids = []; + } + if (typeof detail.running_count !== 'number') { + detail.running_count = 0; + } + throw new AgentRunLimitError(response.status, detail); + } + + // Handle other errors const errorText = await response .text() .catch(() => 'No error details available'); @@ -1522,9 +1621,7 @@ export const initiateAgent = async ( errorText, ); - if (response.status === 402) { - throw new Error('Payment Required'); - } else if (response.status === 401) { + if (response.status === 401) { throw new Error('Authentication error: Please sign in again'); } else if (response.status >= 500) { throw new Error('Server error: Please try again later'); @@ -1538,6 +1635,11 @@ export const initiateAgent = async ( const result = await response.json(); return result; } catch (error) { + // Rethrow BillingError and AgentRunLimitError instances directly + if (error instanceof BillingError || error instanceof AgentRunLimitError) { + throw error; + } + console.error('[API] Failed to initiate agent:', error); if ( diff --git a/frontend/src/lib/error-handler.ts b/frontend/src/lib/error-handler.ts index a2fd7244..39689705 100644 --- a/frontend/src/lib/error-handler.ts +++ b/frontend/src/lib/error-handler.ts @@ -1,5 +1,5 @@ import { toast } from 'sonner'; -import { BillingError } from './api'; +import { BillingError, AgentRunLimitError } from './api'; export interface ApiError extends Error { status?: number; @@ -50,6 +50,10 @@ const extractErrorMessage = (error: any): string => { return error.detail?.message || error.message || 'Billing issue detected'; } + if (error instanceof AgentRunLimitError) { + return error.detail?.message || error.message || 'Agent run limit exceeded'; + } + if (error instanceof Error) { return error.message; } @@ -85,6 +89,9 @@ const shouldShowError = (error: any, context?: ErrorContext): boolean => { if (error instanceof BillingError) { return false; } + if (error instanceof AgentRunLimitError) { + return false; + } if (error?.status === 404 && context?.resource) { return false;