From 43bc71779e8b283a42eff6e5e3a51f0a68db3ce3 Mon Sep 17 00:00:00 2001 From: mykonos-ibiza <222371740+mykonos-ibiza@users.noreply.github.com> Date: Mon, 4 Aug 2025 22:23:25 +0530 Subject: [PATCH 1/3] feat(agent-run-limits): implement agent run limit checks and UI dialogs - Added functionality to check the maximum parallel agent runs allowed within a 24-hour period. - Introduced `AgentRunLimitError` to handle cases where the limit is exceeded. - Updated API to return appropriate error responses for agent run limits. - Created `AgentRunLimitDialog` component to inform users when the limit is reached, displaying currently running agents and options to stop them. - Integrated limit checks into agent initiation and running processes across relevant components. --- backend/agent/api.py | 25 ++ backend/agent/utils.py | 64 +++- .../[projectId]/thread/[threadId]/page.tsx | 38 +- .../dashboard/dashboard-content.tsx | 30 ++ .../components/home/sections/hero-section.tsx | 30 ++ .../thread/agent-run-limit-dialog.tsx | 330 ++++++++++++++++++ .../dashboard/use-initiate-agent.ts | 12 +- .../react-query/threads/use-agent-run.ts | 3 +- frontend/src/lib/api.ts | 126 ++++++- frontend/src/lib/error-handler.ts | 9 +- 10 files changed, 654 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/thread/agent-run-limit-dialog.tsx diff --git a/backend/agent/api.py b/backend/agent/api.py index 85cd38d6..3850133f 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, MAX_PARALLEL_AGENT_RUNS 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 {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": 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 {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": 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..f94056d9 100644 --- a/backend/agent/utils.py +++ b/backend/agent/utils.py @@ -1,7 +1,12 @@ 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 services import redis +from run_agent_background import update_agent_run_status + +# Agent run limits +MAX_PARALLEL_AGENT_RUNS = 3 async def _cleanup_redis_response_list(agent_run_id: str): @@ -67,11 +72,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 < 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/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx b/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx index 2e1611a3..e7d1c807 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,12 @@ 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; + limit: number; + runningThreadIds: string[]; + } | null>(null); // Refs - simplified for flex-column-reverse @@ -320,6 +327,22 @@ export default function ThreadPage({ return; } + if (error instanceof AgentRunLimitError) { + console.log("Caught AgentRunLimitError:", error.detail); + const { running_thread_ids, running_count, limit } = error.detail; + + // Show the dialog with limit information + setAgentLimitData({ + runningCount: running_count, + limit: limit, + 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 +354,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 +758,17 @@ 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..e2dc9db3 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,12 @@ export function DashboardContent() { const [initiatedThreadId, setInitiatedThreadId] = useState(null); const { billingError, handleBillingError, clearBillingError } = useBillingError(); + const [showAgentLimitDialog, setShowAgentLimitDialog] = useState(false); + const [agentLimitData, setAgentLimitData] = useState<{ + runningCount: number; + limit: number; + runningThreadIds: string[]; + } | null>(null); const router = useRouter(); const searchParams = useSearchParams(); const isMobile = useIsMobile(); @@ -153,6 +161,17 @@ 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, limit } = error.detail; + + // Show the dialog with limit information + setAgentLimitData({ + runningCount: running_count, + limit: limit, + runningThreadIds: running_thread_ids, + }); + setShowAgentLimitDialog(true); } setIsSubmitting(false); } @@ -257,6 +276,17 @@ 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..7a444362 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,12 @@ 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; + limit: number; + runningThreadIds: string[]; + } | null>(null); // Fetch agents for selection const { data: agentsResponse } = createQueryHook( @@ -199,6 +207,17 @@ 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, limit } = error.detail; + + // Show the dialog with limit information + setAgentLimitData({ + runningCount: running_count, + limit: limit, + runningThreadIds: running_thread_ids, + }); + setShowAgentLimitDialog(true); } else { const isConnectionError = error instanceof TypeError && @@ -414,6 +433,17 @@ 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..896d96da --- /dev/null +++ b/frontend/src/components/thread/agent-run-limit-dialog.tsx @@ -0,0 +1,330 @@ +'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; + limit: number; + runningThreadIds: string[]; + projectId?: string; +} + +export const AgentRunLimitDialog: React.FC = ({ + open, + onOpenChange, + runningCount, + limit, + 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..dd49e9dc 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4,6 +4,9 @@ import { handleApiError } from './error-handler'; // Get backend URL from environment variables const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ''; +// Agent run limits +const MAX_PARALLEL_AGENT_RUNS = 3; + // Set to keep track of agent runs that are known to be non-running const nonRunningAgentRuns = new Set(); // Map to keep track of active EventSource streams @@ -29,6 +32,38 @@ 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; + limit: number; + [key: string]: any; + }; + + constructor( + status: number, + detail: { + message: string; + running_thread_ids: string[]; + running_count: number; + limit: 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 +742,32 @@ 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, + limit: MAX_PARALLEL_AGENT_RUNS + }; + 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; + } + if (typeof detail.limit !== 'number') { + detail.limit = MAX_PARALLEL_AGENT_RUNS; + } + throw new AgentRunLimitError(response.status, detail); + } + // Handle other errors const errorText = await response .text() @@ -723,8 +784,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 +1574,58 @@ 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, + limit: MAX_PARALLEL_AGENT_RUNS + }; + 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; + } + if (typeof detail.limit !== 'number') { + detail.limit = MAX_PARALLEL_AGENT_RUNS; + } + throw new AgentRunLimitError(response.status, detail); + } + + // Handle other errors const errorText = await response .text() .catch(() => 'No error details available'); @@ -1522,9 +1635,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 +1649,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; From ddef5fab19bf9baf893c7a5c97f9c480a56785b2 Mon Sep 17 00:00:00 2001 From: mykonos-ibiza <222371740+mykonos-ibiza@users.noreply.github.com> Date: Tue, 5 Aug 2025 18:15:52 +0530 Subject: [PATCH 2/3] refactor(agent-run-limits): centralize agent run limit configuration - Removed hardcoded maximum parallel agent runs and replaced with a configurable property in the Configuration class. - Updated API and agent limit checks to utilize the new configuration property. - Simplified error handling by removing limit from error details in the frontend components. - Adjusted UI components to reflect changes in agent limit data structure. --- backend/agent/api.py | 10 +++--- backend/agent/utils.py | 6 ++-- backend/utils/config.py | 32 +++++++++++++++++++ .../[projectId]/thread/[threadId]/page.tsx | 5 +-- .../dashboard/dashboard-content.tsx | 5 +-- .../components/home/sections/hero-section.tsx | 5 +-- .../thread/agent-run-limit-dialog.tsx | 2 -- frontend/src/lib/api.ts | 14 -------- 8 files changed, 42 insertions(+), 37 deletions(-) diff --git a/backend/agent/api.py b/backend/agent/api.py index 3850133f..367a6d6f 100644 --- a/backend/agent/api.py +++ b/backend/agent/api.py @@ -25,7 +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, MAX_PARALLEL_AGENT_RUNS +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 @@ -442,10 +442,10 @@ async def start_agent( limit_check = await check_agent_run_limit(client, account_id) if not limit_check['can_start']: error_detail = { - "message": f"Maximum of {MAX_PARALLEL_AGENT_RUNS} parallel agent runs allowed within 24 hours. You currently have {limit_check['running_count']} running.", + "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": MAX_PARALLEL_AGENT_RUNS + "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) @@ -1066,10 +1066,10 @@ async def initiate_agent_with_files( limit_check = await check_agent_run_limit(client, account_id) if not limit_check['can_start']: error_detail = { - "message": f"Maximum of {MAX_PARALLEL_AGENT_RUNS} parallel agent runs allowed within 24 hours. You currently have {limit_check['running_count']} running.", + "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": MAX_PARALLEL_AGENT_RUNS + "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) diff --git a/backend/agent/utils.py b/backend/agent/utils.py index f94056d9..cb0ac8f9 100644 --- a/backend/agent/utils.py +++ b/backend/agent/utils.py @@ -2,12 +2,10 @@ import json 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 -# Agent run limits -MAX_PARALLEL_AGENT_RUNS = 3 - async def _cleanup_redis_response_list(agent_run_id: str): try: @@ -120,7 +118,7 @@ async def check_agent_run_limit(client, account_id: str) -> Dict[str, Any]: logger.info(f"Account {account_id} has {running_count} running agent runs in the past 24 hours") return { - 'can_start': running_count < MAX_PARALLEL_AGENT_RUNS, + 'can_start': running_count < config.MAX_PARALLEL_AGENT_RUNS, 'running_count': running_count, 'running_thread_ids': running_thread_ids } 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 e7d1c807..d502fb81 100644 --- a/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx +++ b/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx @@ -59,7 +59,6 @@ export default function ThreadPage({ const [showAgentLimitDialog, setShowAgentLimitDialog] = useState(false); const [agentLimitData, setAgentLimitData] = useState<{ runningCount: number; - limit: number; runningThreadIds: string[]; } | null>(null); @@ -329,12 +328,11 @@ export default function ThreadPage({ if (error instanceof AgentRunLimitError) { console.log("Caught AgentRunLimitError:", error.detail); - const { running_thread_ids, running_count, limit } = error.detail; + const { running_thread_ids, running_count } = error.detail; // Show the dialog with limit information setAgentLimitData({ runningCount: running_count, - limit: limit, runningThreadIds: running_thread_ids, }); setShowAgentLimitDialog(true); @@ -764,7 +762,6 @@ export default function ThreadPage({ open={showAgentLimitDialog} onOpenChange={setShowAgentLimitDialog} runningCount={agentLimitData.runningCount} - limit={agentLimitData.limit} runningThreadIds={agentLimitData.runningThreadIds} projectId={projectId} /> diff --git a/frontend/src/components/dashboard/dashboard-content.tsx b/frontend/src/components/dashboard/dashboard-content.tsx index e2dc9db3..94d6ccb1 100644 --- a/frontend/src/components/dashboard/dashboard-content.tsx +++ b/frontend/src/components/dashboard/dashboard-content.tsx @@ -48,7 +48,6 @@ export function DashboardContent() { const [showAgentLimitDialog, setShowAgentLimitDialog] = useState(false); const [agentLimitData, setAgentLimitData] = useState<{ runningCount: number; - limit: number; runningThreadIds: string[]; } | null>(null); const router = useRouter(); @@ -163,12 +162,11 @@ export function DashboardContent() { onOpen("paymentRequiredDialog"); } else if (error instanceof AgentRunLimitError) { console.log('Handling AgentRunLimitError:', error.detail); - const { running_thread_ids, running_count, limit } = error.detail; + const { running_thread_ids, running_count } = error.detail; // Show the dialog with limit information setAgentLimitData({ runningCount: running_count, - limit: limit, runningThreadIds: running_thread_ids, }); setShowAgentLimitDialog(true); @@ -282,7 +280,6 @@ export function DashboardContent() { open={showAgentLimitDialog} onOpenChange={setShowAgentLimitDialog} runningCount={agentLimitData.runningCount} - limit={agentLimitData.limit} runningThreadIds={agentLimitData.runningThreadIds} projectId={undefined} // Dashboard doesn't have a specific project context /> diff --git a/frontend/src/components/home/sections/hero-section.tsx b/frontend/src/components/home/sections/hero-section.tsx index 7a444362..7d74e754 100644 --- a/frontend/src/components/home/sections/hero-section.tsx +++ b/frontend/src/components/home/sections/hero-section.tsx @@ -72,7 +72,6 @@ export function HeroSection() { const [showAgentLimitDialog, setShowAgentLimitDialog] = useState(false); const [agentLimitData, setAgentLimitData] = useState<{ runningCount: number; - limit: number; runningThreadIds: string[]; } | null>(null); @@ -209,12 +208,11 @@ export function HeroSection() { onOpen("paymentRequiredDialog"); } else if (error instanceof AgentRunLimitError) { console.log('Handling AgentRunLimitError:', error.detail); - const { running_thread_ids, running_count, limit } = error.detail; + const { running_thread_ids, running_count } = error.detail; // Show the dialog with limit information setAgentLimitData({ runningCount: running_count, - limit: limit, runningThreadIds: running_thread_ids, }); setShowAgentLimitDialog(true); @@ -439,7 +437,6 @@ export function HeroSection() { open={showAgentLimitDialog} onOpenChange={setShowAgentLimitDialog} runningCount={agentLimitData.runningCount} - limit={agentLimitData.limit} runningThreadIds={agentLimitData.runningThreadIds} projectId={undefined} // Hero section doesn't have a specific project context /> diff --git a/frontend/src/components/thread/agent-run-limit-dialog.tsx b/frontend/src/components/thread/agent-run-limit-dialog.tsx index 896d96da..c5699386 100644 --- a/frontend/src/components/thread/agent-run-limit-dialog.tsx +++ b/frontend/src/components/thread/agent-run-limit-dialog.tsx @@ -148,7 +148,6 @@ interface AgentRunLimitDialogProps { open: boolean; onOpenChange: (open: boolean) => void; runningCount: number; - limit: number; runningThreadIds: string[]; projectId?: string; } @@ -157,7 +156,6 @@ export const AgentRunLimitDialog: React.FC = ({ open, onOpenChange, runningCount, - limit, runningThreadIds, projectId, }) => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index dd49e9dc..c5e3a83f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4,9 +4,6 @@ import { handleApiError } from './error-handler'; // Get backend URL from environment variables const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ''; -// Agent run limits -const MAX_PARALLEL_AGENT_RUNS = 3; - // Set to keep track of agent runs that are known to be non-running const nonRunningAgentRuns = new Set(); // Map to keep track of active EventSource streams @@ -39,8 +36,6 @@ export class AgentRunLimitError extends Error { message: string; running_thread_ids: string[]; running_count: number; - limit: number; - [key: string]: any; }; constructor( @@ -49,7 +44,6 @@ export class AgentRunLimitError extends Error { message: string; running_thread_ids: string[]; running_count: number; - limit: number; [key: string]: any; }, message?: string, @@ -751,7 +745,6 @@ export const startAgent = async ( message: 'Too many agent runs running', running_thread_ids: [], running_count: 0, - limit: MAX_PARALLEL_AGENT_RUNS }; if (typeof detail.message !== 'string') { detail.message = 'Too many agent runs running'; @@ -762,9 +755,6 @@ export const startAgent = async ( if (typeof detail.running_count !== 'number') { detail.running_count = 0; } - if (typeof detail.limit !== 'number') { - detail.limit = MAX_PARALLEL_AGENT_RUNS; - } throw new AgentRunLimitError(response.status, detail); } @@ -1608,7 +1598,6 @@ export const initiateAgent = async ( message: 'Too many agent runs running', running_thread_ids: [], running_count: 0, - limit: MAX_PARALLEL_AGENT_RUNS }; if (typeof detail.message !== 'string') { detail.message = 'Too many agent runs running'; @@ -1619,9 +1608,6 @@ export const initiateAgent = async ( if (typeof detail.running_count !== 'number') { detail.running_count = 0; } - if (typeof detail.limit !== 'number') { - detail.limit = MAX_PARALLEL_AGENT_RUNS; - } throw new AgentRunLimitError(response.status, detail); } From 584a4192d76a4844b406d83ac35112385e3322da Mon Sep 17 00:00:00 2001 From: mykonos-ibiza <222371740+mykonos-ibiza@users.noreply.github.com> Date: Wed, 6 Aug 2025 11:40:11 +0530 Subject: [PATCH 3/3] Fix billing service to get customer data from Stripe if it's missing from DB - Updated `get_stripe_customer_id` function to accept a Supabase client and handle missing user_id metadata in Stripe customers. - Added logic to create or update records in the `billing_customers` table based on Stripe customer data. - Improved logging for customer metadata updates and billing record changes. --- backend/services/billing.py | 42 +++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) 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'])