mirror of https://github.com/kortix-ai/suna.git
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:
commit
09e5dfc012
|
@ -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
|
||||
|
|
|
@ -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}")
|
||||
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': []
|
||||
}
|
|
@ -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."""
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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")) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue