Merge pull request #1206 from mykonos-ibiza/limit-running-agents

feat(agent-run-limits): implement agent run limit checks and UI dialogs
This commit is contained in:
Bobbie 2025-08-05 18:19:51 +05:30 committed by GitHub
commit 09e5dfc012
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 659 additions and 13 deletions

View File

@ -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

View File

@ -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):
@ -75,3 +78,56 @@ async def stop_agent_run(db, agent_run_id: str, error_message: Optional[str] = N
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}")
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': []
}

View File

@ -268,6 +268,33 @@ class 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:
@ -318,6 +345,11 @@ class Configuration:
# 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."""
# Get all configuration fields and their type hints

View File

@ -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 && (
<AgentRunLimitDialog
open={showAgentLimitDialog}
onOpenChange={setShowAgentLimitDialog}
runningCount={agentLimitData.runningCount}
runningThreadIds={agentLimitData.runningThreadIds}
projectId={projectId}
/>
)}
</>
);
}

View File

@ -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<string | null>(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}
/>
</div>
{agentLimitData && (
<AgentRunLimitDialog
open={showAgentLimitDialog}
onOpenChange={setShowAgentLimitDialog}
runningCount={agentLimitData.runningCount}
runningThreadIds={agentLimitData.runningThreadIds}
projectId={undefined} // Dashboard doesn't have a specific project context
/>
)}
</>
);
}

View File

@ -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<string | null>(null);
const threadQuery = useThreadQuery(initiatedThreadId || '');
const chatInputRef = useRef<ChatInputHandles>(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 && (
<AgentRunLimitDialog
open={showAgentLimitDialog}
onOpenChange={setShowAgentLimitDialog}
runningCount={agentLimitData.runningCount}
runningThreadIds={agentLimitData.runningThreadIds}
projectId={undefined} // Hero section doesn't have a specific project context
/>
)}
</section>
);
}

View File

@ -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<RunningThreadItemProps> = ({
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 (
<div className="flex items-center justify-between rounded-lg border bg-muted/50 px-3 py-2 gap-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
<div className="min-w-0 flex-1">
<div className="font-medium text-sm truncate">
{getProjectDisplayName()}
</div>
<code className="text-xs text-muted-foreground/70 truncate block">
{threadInfo.threadId}
</code>
</div>
</div>
<div className="flex items-center gap-1">
{threadInfo.agentRun && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-600"
onClick={handleStop}
disabled={stopAgentMutation.isPending || threadInfo.isLoading}
>
{stopAgentMutation.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Square className="h-3 w-3" />
)}
<span className="sr-only">Stop agent</span>
</Button>
</TooltipTrigger>
<TooltipContent>Stop this agent</TooltipContent>
</Tooltip>
)}
{threadInfo.projectId && (
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-background"
>
<Link href={`/projects/${threadInfo.projectId}/thread/${threadInfo.threadId}`} target="_blank">
<ExternalLink className="h-3 w-3" />
<span className="sr-only">Open thread</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Open thread</TooltipContent>
</Tooltip>
)}
</div>
</div>
);
};
interface AgentRunLimitDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
runningCount: number;
runningThreadIds: string[];
projectId?: string;
}
export const AgentRunLimitDialog: React.FC<AgentRunLimitDialogProps> = ({
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</div>
<DialogTitle className="text-lg font-semibold">
Parallel Runs Limit Reached
</DialogTitle>
</div>
<DialogDescription className="text-sm text-muted-foreground">
You've reached the maximum parallel agent runs allowed.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{(runningThreadIds.length > 0 || runningCount > 0) && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium">Currently Running Agents</h4>
</div>
{isLoadingThreads ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="ml-2 text-sm text-muted-foreground">Loading threads...</span>
</div>
) : runningThreadIds.length === 0 ? (
<div className="text-center py-4 text-sm text-muted-foreground">
<p>Found {runningCount} running agents but unable to load thread details.</p>
<p className="text-xs mt-1">Thread IDs: {JSON.stringify(runningThreadIds)}</p>
</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto">
{runningThreadsInfo.slice(0, 5).map((threadInfo) => (
<RunningThreadItem
key={threadInfo.threadId}
threadInfo={threadInfo}
onThreadStopped={handleThreadStopped}
/>
))}
{runningThreadsInfo.length > 5 && (
<div className="text-center py-2">
<Badge variant="outline" className="text-xs">
+{runningThreadsInfo.length - 5} more running
</Badge>
</div>
)}
</div>
)}
</div>
)}
<Separator />
<div className="space-y-3">
<h4 className="text-sm font-medium">What can you do?</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-muted-foreground mt-2 flex-shrink-0" />
<span>Click the <Square className="h-3 w-3 inline mx-1" /> button to stop running agents</span>
</li>
<li className="flex items-start gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-muted-foreground mt-2 flex-shrink-0" />
<span>Wait for an agent to complete automatically</span>
</li>
</ul>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={handleClose}>
Got it
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -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")) {

View File

@ -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;
}

View File

@ -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 (

View File

@ -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;