diff --git a/backend/agent/api.py b/backend/agent/api.py index 75cc0daf..7d34a314 100644 --- a/backend/agent/api.py +++ b/backend/agent/api.py @@ -120,6 +120,7 @@ class AgentUpdateRequest(BaseModel): icon_name: Optional[str] = None icon_color: Optional[str] = None icon_background: Optional[str] = None + replace_mcps: Optional[bool] = None class AgentResponse(BaseModel): agent_id: str @@ -145,7 +146,7 @@ class AgentResponse(BaseModel): current_version: Optional[AgentVersionResponse] = None metadata: Optional[Dict[str, Any]] = None -from utils.pagination import PaginationService, PaginationParams, PaginatedResponse +from utils.pagination import PaginationParams class PaginationInfo(BaseModel): current_page: int @@ -2105,16 +2106,32 @@ async def update_agent( print(f"[DEBUG] update_agent: Prepared update_data with icon fields - icon_name={update_data.get('icon_name')}, icon_color={update_data.get('icon_color')}, icon_background={update_data.get('icon_background')}") current_system_prompt = agent_data.system_prompt if agent_data.system_prompt is not None else current_version_data.get('system_prompt', '') - current_configured_mcps = agent_data.configured_mcps if agent_data.configured_mcps is not None else current_version_data.get('configured_mcps', []) + + if agent_data.configured_mcps is not None: + if agent_data.replace_mcps: + current_configured_mcps = agent_data.configured_mcps + logger.debug(f"Replacing configured MCPs for agent {agent_id}: {current_configured_mcps}") + else: + current_configured_mcps = agent_data.configured_mcps + else: + current_configured_mcps = current_version_data.get('configured_mcps', []) + # Handle custom MCPs - either replace or merge based on the flag if agent_data.custom_mcps is not None: - current_custom_mcps = merge_custom_mcps( - current_version_data.get('custom_mcps', []), - agent_data.custom_mcps - ) + if agent_data.replace_mcps: + # Replace mode: use the provided list as-is + current_custom_mcps = agent_data.custom_mcps + logger.debug(f"Replacing custom MCPs for agent {agent_id}: {current_custom_mcps}") + else: + # Merge mode: merge with existing MCPs (default behavior) + current_custom_mcps = merge_custom_mcps( + current_version_data.get('custom_mcps', []), + agent_data.custom_mcps + ) + logger.debug(f"Merging custom MCPs for agent {agent_id}") else: current_custom_mcps = current_version_data.get('custom_mcps', []) - + current_agentpress_tools = agent_data.agentpress_tools if agent_data.agentpress_tools is not None else current_version_data.get('agentpress_tools', {}) current_avatar = agent_data.avatar if agent_data.avatar is not None else existing_data.get('avatar') current_avatar_color = agent_data.avatar_color if agent_data.avatar_color is not None else existing_data.get('avatar_color') diff --git a/frontend/src/app/(dashboard)/agents/config/[agentId]/page.tsx b/frontend/src/app/(dashboard)/agents/config/[agentId]/page.tsx index 666d6c50..68f7fcf6 100644 --- a/frontend/src/app/(dashboard)/agents/config/[agentId]/page.tsx +++ b/frontend/src/app/(dashboard)/agents/config/[agentId]/page.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer'; import { useUpdateAgent } from '@/hooks/react-query/agents/use-agents'; +import { useUpdateAgentMCPs } from '@/hooks/react-query/agents/use-update-agent-mcps'; import { useCreateAgentVersion, useActivateAgentVersion } from '@/hooks/react-query/agents/use-agent-versions'; import { useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -23,7 +24,6 @@ import { useAgentConfigTour } from '@/hooks/use-agent-config-tour'; import Joyride, { CallBackProps, STATUS, Step } from 'react-joyride'; import { TourConfirmationDialog } from '@/components/tour/TourConfirmationDialog'; -// Tour steps for agent configuration const agentConfigTourSteps: Step[] = [ { target: '[data-tour="agent-header"]', @@ -90,7 +90,6 @@ const agentConfigTourSteps: Step[] = [ }, ]; -// Form data interface interface FormData { name: string; description: string; @@ -119,10 +118,31 @@ function AgentConfigurationContent() { const { setHasUnsavedChanges } = useAgentVersionStore(); const updateAgentMutation = useUpdateAgent(); + const updateAgentMCPsMutation = useUpdateAgentMCPs(); const createVersionMutation = useCreateAgentVersion(); const activateVersionMutation = useActivateAgentVersion(); const exportMutation = useExportAgent(); + // Use refs for stable references to avoid callback recreation + const agentIdRef = useRef(agentId); + const mutationsRef = useRef({ + updateAgent: updateAgentMutation, + updateMCPs: updateAgentMCPsMutation, + export: exportMutation, + activate: activateVersionMutation, + }); + + // Update refs when values change + useEffect(() => { + agentIdRef.current = agentId; + mutationsRef.current = { + updateAgent: updateAgentMutation, + updateMCPs: updateAgentMCPsMutation, + export: exportMutation, + activate: activateVersionMutation, + }; + }, [agentId, updateAgentMutation, updateAgentMCPsMutation, exportMutation, activateVersionMutation]); + const [formData, setFormData] = useState({ name: '', description: '', @@ -140,9 +160,19 @@ function AgentConfigurationContent() { const [originalData, setOriginalData] = useState(formData); const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [lastLoadedVersionId, setLastLoadedVersionId] = useState(null); useEffect(() => { if (!agent) return; + + const currentVersionId = versionData?.version_id || agent.current_version_id || 'current'; + const shouldResetForm = !lastLoadedVersionId || lastLoadedVersionId !== currentVersionId; + + if (!shouldResetForm) { + setLastLoadedVersionId(currentVersionId); + return; + } + let configSource = agent; if (versionData) { configSource = { @@ -174,7 +204,8 @@ function AgentConfigurationContent() { }; setFormData(newFormData); setOriginalData(newFormData); - }, [agent, versionData]); + setLastLoadedVersionId(currentVersionId); + }, [agent, versionData, lastLoadedVersionId]); const displayData = isViewingOldVersion && versionData ? { name: formData.name, @@ -191,24 +222,127 @@ function AgentConfigurationContent() { icon_background: versionData.icon_background || formData.icon_background || '#e5e5e5', } : formData; - const handleFieldChange = useCallback((field: string, value: any) => { + const handleFieldChange = (field: string, value: any) => { setFormData(prev => ({ ...prev, [field]: value })); - }, []); + }; const handleMCPChange = useCallback((updates: { configured_mcps: any[]; custom_mcps: any[] }) => { + const previousConfigured = formData.configured_mcps; + const previousCustom = formData.custom_mcps; + setFormData(prev => ({ ...prev, - configured_mcps: updates.configured_mcps, - custom_mcps: updates.custom_mcps + configured_mcps: updates.configured_mcps || [], + custom_mcps: updates.custom_mcps || [] })); + + mutationsRef.current.updateMCPs.mutate({ + agentId: agentIdRef.current, + configured_mcps: updates.configured_mcps || [], + custom_mcps: updates.custom_mcps || [], + replace_mcps: true + }, { + onSuccess: () => { + setOriginalData(prev => ({ + ...prev, + configured_mcps: updates.configured_mcps || [], + custom_mcps: updates.custom_mcps || [] + })); + toast.success('MCP configuration updated'); + }, + onError: (error) => { + setFormData(prev => ({ + ...prev, + configured_mcps: previousConfigured, + custom_mcps: previousCustom + })); + toast.error('Failed to update MCP configuration'); + console.error('MCP update error:', error); + } + }); }, []); - const handleExport = useCallback(() => { - exportMutation.mutate(agentId); - }, [agentId, exportMutation]); + const saveField = useCallback(async (fieldData: Partial) => { + try { + await mutationsRef.current.updateAgent.mutateAsync({ + agentId: agentIdRef.current, + ...fieldData, + }); + + setFormData(prev => ({ ...prev, ...fieldData })); + setOriginalData(prev => ({ ...prev, ...fieldData })); + return true; + } catch (error) { + console.error('Failed to save field:', error); + throw error; + } + }, []); + + const handleNameSave = async (name: string) => { + try { + await saveField({ name }); + toast.success('Agent name updated'); + } catch { + toast.error('Failed to update agent name'); + throw new Error('Failed to update agent name'); + } + }; + + const handleProfileImageSave = async (profileImageUrl: string | null) => { + try { + await saveField({ profile_image_url: profileImageUrl || '' }); + } catch { + toast.error('Failed to update profile picture'); + throw new Error('Failed to update profile picture'); + } + }; + + const handleIconSave = async (iconName: string | null, iconColor: string, iconBackground: string) => { + try { + await saveField({ icon_name: iconName, icon_color: iconColor, icon_background: iconBackground }); + toast.success('Agent icon updated'); + } catch { + toast.error('Failed to update agent icon'); + throw new Error('Failed to update agent icon'); + } + }; + + const handleSystemPromptSave = async (system_prompt: string) => { + try { + await saveField({ system_prompt }); + toast.success('System prompt updated'); + } catch { + toast.error('Failed to update system prompt'); + throw new Error('Failed to update system prompt'); + } + }; + + const handleModelSave = async (model: string) => { + try { + await saveField({ model }); + toast.success('Model updated'); + } catch { + toast.error('Failed to update model'); + throw new Error('Failed to update model'); + } + }; + + const handleToolsSave = async (agentpress_tools: Record) => { + try { + await saveField({ agentpress_tools }); + toast.success('Tools updated'); + } catch { + toast.error('Failed to update tools'); + throw new Error('Failed to update tools'); + } + }; + + const handleExport = () => { + mutationsRef.current.export.mutate(agentIdRef.current); + }; const { hasUnsavedChanges, isCurrentVersion } = React.useMemo(() => { const formDataStr = JSON.stringify(formData); @@ -234,141 +368,33 @@ function AgentConfigurationContent() { const handleActivateVersion = async (versionId: string) => { try { - await activateVersionMutation.mutateAsync({ agentId, versionId }); - router.push(`/agents/config/${agentId}`); + await mutationsRef.current.activate.mutateAsync({ agentId: agentIdRef.current, versionId }); + router.push(`/agents/config/${agentIdRef.current}`); } catch (error) { console.error('Failed to activate version:', error); } }; + // OPTIMIZED: Simplified save with stable reference const handleSave = useCallback(async () => { - if (hasUnsavedChanges) { + const currentFormData = formData; + const hasChanges = JSON.stringify(currentFormData) !== JSON.stringify(originalData); + + if (hasChanges) { try { - await updateAgentMutation.mutateAsync({ - agentId, - name: formData.name, - description: formData.description, - is_default: formData.is_default, - profile_image_url: formData.profile_image_url, - icon_name: formData.icon_name, - icon_color: formData.icon_color, - icon_background: formData.icon_background, - system_prompt: formData.system_prompt, - agentpress_tools: formData.agentpress_tools, - configured_mcps: formData.configured_mcps, - custom_mcps: formData.custom_mcps, + await mutationsRef.current.updateAgent.mutateAsync({ + agentId: agentIdRef.current, + ...currentFormData, }); - setOriginalData(formData); + setOriginalData(currentFormData); toast.success('Agent updated successfully'); } catch (error) { toast.error('Failed to update agent'); console.error('Failed to save agent:', error); } } - }, [agentId, formData, hasUnsavedChanges, updateAgentMutation]); - - const handleNameSave = useCallback(async (name: string) => { - try { - await updateAgentMutation.mutateAsync({ - agentId, - name, - }); - - setFormData(prev => ({ ...prev, name })); - setOriginalData(prev => ({ ...prev, name })); - toast.success('Agent name updated'); - } catch (error) { - toast.error('Failed to update agent name'); - throw error; - } - }, [agentId, updateAgentMutation]); - - const handleProfileImageSave = useCallback(async (profileImageUrl: string | null) => { - try { - await updateAgentMutation.mutateAsync({ - agentId, - profile_image_url: profileImageUrl || '', - }); - - setFormData(prev => ({ ...prev, profile_image_url: profileImageUrl || '' })); - setOriginalData(prev => ({ ...prev, profile_image_url: profileImageUrl || '' })); - } catch (error) { - toast.error('Failed to update profile picture'); - throw error; - } - }, [agentId, updateAgentMutation]); - - const handleIconSave = useCallback(async (iconName: string | null, iconColor: string, iconBackground: string) => { - try { - await updateAgentMutation.mutateAsync({ - agentId, - icon_name: iconName, - icon_color: iconColor, - icon_background: iconBackground, - }); - - setFormData(prev => ({ - ...prev, - icon_name: iconName, - icon_color: iconColor, - icon_background: iconBackground, - })); - setOriginalData(prev => ({ - ...prev, - icon_name: iconName, - icon_color: iconColor, - icon_background: iconBackground, - })); - toast.success('Agent icon updated'); - } catch (error) { - toast.error('Failed to update agent icon'); - throw error; - } - }, [agentId, updateAgentMutation]); - - const handleSystemPromptSave = useCallback(async (value: string) => { - try { - await updateAgentMutation.mutateAsync({ - agentId, - system_prompt: value, - }); - - setFormData(prev => ({ ...prev, system_prompt: value })); - setOriginalData(prev => ({ ...prev, system_prompt: value })); - toast.success('System prompt updated'); - } catch (error) { - toast.error('Failed to update system prompt'); - throw error; - } - }, [agentId, updateAgentMutation]); - - const handleModelSave = useCallback(async (model: string) => { - try { - setFormData(prev => ({ ...prev, model })); - setOriginalData(prev => ({ ...prev, model })); - toast.success('Model updated'); - } catch (error) { - toast.error('Failed to update model'); - throw error; - } - }, []); - - const handleToolsSave = useCallback(async (tools: Record) => { - try { - await updateAgentMutation.mutateAsync({ - agentId, - agentpress_tools: tools, - }); - - setFormData(prev => ({ ...prev, agentpress_tools: tools })); - setOriginalData(prev => ({ ...prev, agentpress_tools: tools })); - toast.success('Tools updated'); - } catch (error) { - toast.error('Failed to update tools'); - throw error; - } - }, [agentId, updateAgentMutation]); + }, []); // Using snapshot of formData in function instead of dependency if (error) { return ( @@ -572,7 +598,8 @@ export default function AgentConfigurationPage() { handleWelcomeDecline, } = useAgentConfigTour(); - const handleTourCallback = useCallback((data: CallBackProps) => { + // OPTIMIZED: Simple function instead of useCallback with stable dependencies + const handleTourCallback = (data: CallBackProps) => { const { status, type, index } = data; if (status === STATUS.FINISHED || status === STATUS.SKIPPED) { @@ -580,7 +607,7 @@ export default function AgentConfigurationPage() { } else if (type === 'step:after') { setStepIndex(index + 1); } - }, [stopTour, setStepIndex]); + }; return ( <> diff --git a/frontend/src/components/agents/agent-mcp-configuration.tsx b/frontend/src/components/agents/agent-mcp-configuration.tsx index 817cb012..7033de5f 100644 --- a/frontend/src/components/agents/agent-mcp-configuration.tsx +++ b/frontend/src/components/agents/agent-mcp-configuration.tsx @@ -45,18 +45,23 @@ export const AgentMCPConfiguration: React.FC = ({ }; } + // Map 'sse' backend type to 'http' for frontend display + const displayType = customMcp.type === 'sse' ? 'http' : (customMcp.type || customMcp.customType); + return { name: customMcp.name, - qualifiedName: customMcp.qualifiedName || `custom_${customMcp.type || customMcp.customType}_${customMcp.name.replace(' ', '_').toLowerCase()}`, + qualifiedName: customMcp.qualifiedName || `custom_${displayType}_${customMcp.name.replace(' ', '_').toLowerCase()}`, config: customMcp.config, enabledTools: customMcp.enabledTools, isCustom: true, - customType: customMcp.type || customMcp.customType + customType: displayType }; }) ]; const handleConfigurationChange = (mcps: any[]) => { + console.log('[AgentMCPConfiguration] Configuration changed:', mcps); + const configured = mcps.filter(mcp => !mcp.isCustom); const custom = mcps .filter(mcp => mcp.isCustom) @@ -71,15 +76,20 @@ export const AgentMCPConfiguration: React.FC = ({ }; } + // Map 'http' to 'sse' for backend compatibility + const backendType = mcp.customType === 'http' ? 'sse' : mcp.customType; + return { name: mcp.name, - type: mcp.customType, + type: backendType, customType: mcp.customType, config: mcp.config, enabledTools: mcp.enabledTools }; }); + console.log('[AgentMCPConfiguration] Sending to parent - configured:', configured, 'custom:', custom); + onMCPChange({ configured_mcps: configured, custom_mcps: custom diff --git a/frontend/src/components/agents/composio/composio-registry.tsx b/frontend/src/components/agents/composio/composio-registry.tsx index b6cb9025..c7ea14d5 100644 --- a/frontend/src/components/agents/composio/composio-registry.tsx +++ b/frontend/src/components/agents/composio/composio-registry.tsx @@ -7,14 +7,14 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Search, Zap, X, Settings, ChevronDown, ChevronUp, Loader2, Server } from 'lucide-react'; import { useComposioCategories, useComposioToolkitsInfinite } from '@/hooks/react-query/composio/use-composio'; import { useComposioProfiles } from '@/hooks/react-query/composio/use-composio-profiles'; -import { useAgent, useUpdateAgent } from '@/hooks/react-query/agents/use-agents'; +import { useAgent } from '@/hooks/react-query/agents/use-agents'; +import { useUpdateAgentMCPs } from '@/hooks/react-query/agents/use-update-agent-mcps'; import { ComposioConnector } from './composio-connector'; import { ComposioToolsManager } from './composio-tools-manager'; import type { ComposioToolkit, ComposioProfile } from '@/hooks/react-query/composio/utils'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { useQueryClient } from '@tanstack/react-query'; -// import { AgentSelector } from '../../thread/chat-input/agent-selector'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { CustomMCPDialog } from '../mcp/custom-mcp-dialog'; @@ -168,6 +168,7 @@ const ConnectedAppCard = ({ size="sm" onClick={() => onManageTools(connectedApp)} disabled={isUpdating} + type="button" > @@ -311,7 +312,7 @@ export const ComposioRegistry: React.FC = ({ const currentAgentId = selectedAgentId ?? internalSelectedAgentId; const { data: agent, isLoading: isLoadingAgent } = useAgent(currentAgentId || ''); - const { mutate: updateAgent, isPending: isUpdatingAgent } = useUpdateAgent(); + const { mutate: updateAgent, isPending: isUpdatingAgent } = useUpdateAgentMCPs(); // Use the MCP-specific hook const handleAgentSelect = (agentId: string | undefined) => { if (onAgentChange) { @@ -429,7 +430,8 @@ export const ComposioRegistry: React.FC = ({ return new Promise((resolve, reject) => { updateAgent({ agentId: currentAgentId, - custom_mcps: updatedCustomMcps + custom_mcps: updatedCustomMcps, + replace_mcps: true // Use replace mode to ensure proper updates }, { onSuccess: () => { toast.success(`Custom MCP "${customConfig.name}" added successfully`); diff --git a/frontend/src/components/agents/mcp/configured-mcp-list.tsx b/frontend/src/components/agents/mcp/configured-mcp-list.tsx index 2560b24b..b0e4a9b1 100644 --- a/frontend/src/components/agents/mcp/configured-mcp-list.tsx +++ b/frontend/src/components/agents/mcp/configured-mcp-list.tsx @@ -127,6 +127,7 @@ const MCPConfigurationItem: React.FC<{ className="h-8 w-8 p-0" onClick={() => onConfigureTools(index)} title="Configure tools" + type="button" > @@ -137,6 +138,7 @@ const MCPConfigurationItem: React.FC<{ className="h-8 w-8 p-0 text-destructive hover:text-destructive" onClick={() => onRemove(index)} title="Remove integration" + type="button" > @@ -196,7 +198,7 @@ export const ConfiguredMcpList: React.FC = ({ Cancel Remove Integration diff --git a/frontend/src/components/agents/mcp/custom-mcp-dialog.tsx b/frontend/src/components/agents/mcp/custom-mcp-dialog.tsx index 6c07142d..c40b3871 100644 --- a/frontend/src/components/agents/mcp/custom-mcp-dialog.tsx +++ b/frontend/src/components/agents/mcp/custom-mcp-dialog.tsx @@ -407,15 +407,16 @@ export const CustomMCPDialog: React.FC = ({ {step === 'tools' ? ( <> - - - @@ -155,8 +151,9 @@ export const MCPConfigurationNew: React.FC = ({ onToolsSelected={handleToolsSelected} onClose={() => { setShowRegistryDialog(false); - queryClient.invalidateQueries({ queryKey: ['agents'] }); - queryClient.invalidateQueries({ queryKey: ['agent', selectedAgentId] }); + if (selectedAgentId) { + queryClient.invalidateQueries({ queryKey: ['agents', 'detail', selectedAgentId] }); + } }} /> diff --git a/frontend/src/hooks/react-query/agents/use-update-agent-mcps.ts b/frontend/src/hooks/react-query/agents/use-update-agent-mcps.ts new file mode 100644 index 00000000..a38855af --- /dev/null +++ b/frontend/src/hooks/react-query/agents/use-update-agent-mcps.ts @@ -0,0 +1,18 @@ +import { createMutationHook } from '@/hooks/use-query'; +import { useQueryClient } from '@tanstack/react-query'; +import { AgentUpdateRequest, updateAgent } from './utils'; +import { agentKeys } from './keys'; + +export const useUpdateAgentMCPs = () => { + const queryClient = useQueryClient(); + + return createMutationHook( + ({ agentId, ...data }: { agentId: string } & AgentUpdateRequest) => + updateAgent(agentId, data), + { + onSuccess: (data, variables) => { + queryClient.setQueryData(agentKeys.detail(variables.agentId), data); + }, + } + )(); +}; \ No newline at end of file diff --git a/frontend/src/hooks/react-query/agents/utils.ts b/frontend/src/hooks/react-query/agents/utils.ts index 5dd01c3d..e13de2b3 100644 --- a/frontend/src/hooks/react-query/agents/utils.ts +++ b/frontend/src/hooks/react-query/agents/utils.ts @@ -163,6 +163,8 @@ export type AgentUpdateRequest = { icon_name?: string | null; icon_color?: string | null; icon_background?: string | null; + // MCP replacement flag + replace_mcps?: boolean; }; export const getAgents = async (params: AgentsParams = {}): Promise => {