From 9b0571a7dc012d8ce626206f464771d05f5d2be4 Mon Sep 17 00:00:00 2001 From: Saumya Date: Sun, 13 Jul 2025 19:59:38 +0530 Subject: [PATCH] versioning setup --- backend/agent/api.py | 301 +++--- backend/agent/config_helper.py | 51 +- backend/agent/run.py | 31 +- backend/agent/version_manager.py | 379 ++++++++ backend/mcp_service/api.py | 31 +- backend/mcp_service/credential_manager.py | 80 +- backend/mcp_service/secure_api.py | 6 +- backend/mcp_service/template_manager.py | 169 ++-- backend/pipedream/api.py | 131 ++- backend/pipedream/client.py | 4 +- .../agents/config/[agentId]/page.tsx | 897 +++++++++--------- .../components/agents/AgentVersionManager.tsx | 2 +- .../components/agents/agent-builder-chat.tsx | 5 +- .../agents/agent-mcp-configuration.tsx | 13 +- .../agents/agent-version-switcher.tsx | 281 ++++++ .../agents/create-version-button.tsx | 125 +++ .../streamlined-install-dialog.tsx | 6 +- .../streamlined-profile-connector.tsx | 12 +- .../agents/mcp/mcp-configuration-new.tsx | 28 +- .../components/agents/mcp/tools-manager.tsx | 105 +- frontend/src/components/agents/mcp/types.ts | 6 + .../components/agents/pipedream/constants.ts | 40 +- .../agents/pipedream/pipedream-connector.tsx | 5 +- .../agents/pipedream/pipedream-registry.tsx | 36 +- .../src/components/agents/pipedream/types.ts | 6 + .../src/components/agents/pipedream/utils.ts | 20 +- .../src/components/agents/style-picker.tsx | 37 +- .../components/agents/version-comparison.tsx | 290 ++++++ .../thread/chat-input/chat-input.tsx | 6 +- frontend/src/components/ui/editable.tsx | 5 +- .../ui/expandable-markdown-editor.tsx | 5 +- ...AgentVersions.ts => use-agent-versions.ts} | 7 +- .../src/hooks/react-query/agents/utils.ts | 8 + .../src/hooks/react-query/pipedream/keys.ts | 1 + .../react-query/pipedream/use-pipedream.ts | 11 + .../src/hooks/react-query/pipedream/utils.ts | 18 + frontend/src/hooks/use-agent-version-data.ts | 139 +++ .../src/lib/stores/agent-version-store.ts | 85 ++ 38 files changed, 2554 insertions(+), 828 deletions(-) create mode 100644 backend/agent/version_manager.py create mode 100644 frontend/src/components/agents/agent-version-switcher.tsx create mode 100644 frontend/src/components/agents/create-version-button.tsx create mode 100644 frontend/src/components/agents/version-comparison.tsx rename frontend/src/hooks/react-query/agents/{useAgentVersions.ts => use-agent-versions.ts} (88%) create mode 100644 frontend/src/hooks/use-agent-version-data.ts create mode 100644 frontend/src/lib/stores/agent-version-store.ts diff --git a/backend/agent/api.py b/backend/agent/api.py index bbc9272a..33ec7b6c 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 .version_manager import version_manager, VersionData router = APIRouter() db = None @@ -77,6 +78,8 @@ class AgentVersionCreateRequest(BaseModel): configured_mcps: Optional[List[Dict[str, Any]]] = [] custom_mcps: Optional[List[Dict[str, Any]]] = [] agentpress_tools: Optional[Dict[str, Any]] = {} + version_name: Optional[str] = None # Custom version name + description: Optional[str] = None # Version description class AgentUpdateRequest(BaseModel): name: Optional[str] = None @@ -306,9 +309,17 @@ async def start_agent( agent_config = None effective_agent_id = body.agent_id or thread_agent_id # Use provided agent_id or the one stored in thread + logger.info(f"[AGENT LOAD] Agent loading flow:") + logger.info(f" - body.agent_id: {body.agent_id}") + logger.info(f" - thread_agent_id: {thread_agent_id}") + logger.info(f" - effective_agent_id: {effective_agent_id}") + if effective_agent_id: + logger.info(f"[AGENT LOAD] Querying for agent: {effective_agent_id}") # Get agent with current version agent_result = await client.table('agents').select('*, agent_versions!current_version_id(*)').eq('agent_id', effective_agent_id).eq('account_id', account_id).execute() + logger.info(f"[AGENT LOAD] Query result: found {len(agent_result.data) if agent_result.data else 0} agents") + if not agent_result.data: if body.agent_id: raise HTTPException(status_code=404, detail="Agent not found or access denied") @@ -318,6 +329,9 @@ async def start_agent( else: agent_data = agent_result.data[0] version_data = agent_data.get('agent_versions') + logger.info(f"[AGENT LOAD] About to call extract_agent_config with agent_data keys: {list(agent_data.keys())}") + logger.info(f"[AGENT LOAD] version_data type: {type(version_data)}, content: {version_data}") + agent_config = extract_agent_config(agent_data, version_data) if version_data: @@ -325,19 +339,32 @@ async def start_agent( else: logger.info(f"Using agent {agent_config['name']} ({effective_agent_id}) - no version data") source = "request" if body.agent_id else "thread" + else: + logger.info(f"[AGENT LOAD] No effective_agent_id, will try default agent") if not agent_config: + logger.info(f"[AGENT LOAD] No agent config yet, querying for default agent") default_agent_result = await client.table('agents').select('*, agent_versions!current_version_id(*)').eq('account_id', account_id).eq('is_default', True).execute() + logger.info(f"[AGENT LOAD] Default agent query result: found {len(default_agent_result.data) if default_agent_result.data else 0} default agents") + if default_agent_result.data: agent_data = default_agent_result.data[0] version_data = agent_data.get('agent_versions') + logger.info(f"[AGENT LOAD] About to call extract_agent_config for DEFAULT agent with version_data: {version_data}") + agent_config = extract_agent_config(agent_data, version_data) if version_data: logger.info(f"Using default agent: {agent_config['name']} ({agent_config['agent_id']}) version {agent_config.get('version_name', 'v1')}") else: logger.info(f"Using default agent: {agent_config['name']} ({agent_config['agent_id']}) - no version data") + else: + logger.warning(f"[AGENT LOAD] No default agent found for account {account_id}") + logger.info(f"[AGENT LOAD] Final agent_config: {agent_config is not None}") + if agent_config: + logger.info(f"[AGENT LOAD] Agent config keys: {list(agent_config.keys())}") + if body.agent_id and body.agent_id != thread_agent_id and agent_config: logger.info(f"Using agent {agent_config['agent_id']} for this agent run (thread remains agent-agnostic)") @@ -843,21 +870,55 @@ async def initiate_agent_with_files( client = await db.client account_id = user_id # In Basejump, personal account_id is the same as user_id - # Load agent configuration if agent_id is provided + # Load agent configuration with version support (same as start_agent endpoint) agent_config = None + + logger.info(f"[AGENT INITIATE] Agent loading flow:") + logger.info(f" - agent_id param: {agent_id}") + if agent_id: - agent_result = await client.table('agents').select('*').eq('agent_id', agent_id).eq('account_id', account_id).execute() + logger.info(f"[AGENT INITIATE] Querying for specific agent: {agent_id}") + # Get agent with current version + agent_result = await client.table('agents').select('*, agent_versions!current_version_id(*)').eq('agent_id', agent_id).eq('account_id', account_id).execute() + logger.info(f"[AGENT INITIATE] Query result: found {len(agent_result.data) if agent_result.data else 0} agents") + if not agent_result.data: raise HTTPException(status_code=404, detail="Agent not found or access denied") - agent_config = agent_result.data[0] - logger.info(f"Using custom agent: {agent_config['name']} ({agent_id})") + + agent_data = agent_result.data[0] + version_data = agent_data.get('agent_versions') + logger.info(f"[AGENT INITIATE] About to call extract_agent_config with version_data: {version_data}") + + agent_config = extract_agent_config(agent_data, version_data) + + if version_data: + logger.info(f"Using custom agent: {agent_config['name']} ({agent_id}) version {agent_config.get('version_name', 'v1')}") + else: + logger.info(f"Using custom agent: {agent_config['name']} ({agent_id}) - no version data") else: - # Try to get default agent for the account - default_agent_result = await client.table('agents').select('*').eq('account_id', account_id).eq('is_default', True).execute() + logger.info(f"[AGENT INITIATE] No agent_id provided, querying for default agent") + # Try to get default agent for the account with version support + default_agent_result = await client.table('agents').select('*, agent_versions!current_version_id(*)').eq('account_id', account_id).eq('is_default', True).execute() + logger.info(f"[AGENT INITIATE] Default agent query result: found {len(default_agent_result.data) if default_agent_result.data else 0} default agents") + if default_agent_result.data: - agent_config = default_agent_result.data[0] - logger.info(f"Using default agent: {agent_config['name']} ({agent_config['agent_id']})") + agent_data = default_agent_result.data[0] + version_data = agent_data.get('agent_versions') + logger.info(f"[AGENT INITIATE] About to call extract_agent_config for DEFAULT agent with version_data: {version_data}") + + agent_config = extract_agent_config(agent_data, version_data) + + if version_data: + logger.info(f"Using default agent: {agent_config['name']} ({agent_config['agent_id']}) version {agent_config.get('version_name', 'v1')}") + else: + logger.info(f"Using default agent: {agent_config['name']} ({agent_config['agent_id']}) - no version data") + else: + logger.warning(f"[AGENT INITIATE] No default agent found for account {account_id}") + logger.info(f"[AGENT INITIATE] Final agent_config: {agent_config is not None}") + if agent_config: + logger.info(f"[AGENT INITIATE] Agent config keys: {list(agent_config.keys())}") + can_use, model_message, allowed_models = await can_use_model(client, account_id, model_name) if not can_use: raise HTTPException(status_code=403, detail={"message": model_message, "allowed_models": allowed_models}) @@ -1733,6 +1794,36 @@ async def update_agent( logger.info(f"Updated agent {agent_id} for user: {user_id}") + try: + auto_version_id = await version_manager.auto_create_version_on_config_change( + agent_id=agent_id, + user_id=user_id, + change_description="Auto-saved configuration changes" + ) + if auto_version_id: + logger.info(f"Auto-created version {auto_version_id} for agent {agent_id}") + updated_agent = await client.table('agents').select('*, agent_versions!current_version_id(*)').eq("agent_id", agent_id).eq("account_id", user_id).maybe_single().execute() + if updated_agent.data: + agent = updated_agent.data + if agent.get('agent_versions'): + version_data = agent['agent_versions'] + current_version = AgentVersionResponse( + version_id=version_data['version_id'], + agent_id=version_data['agent_id'], + version_number=version_data['version_number'], + version_name=version_data['version_name'], + system_prompt=version_data['system_prompt'], + configured_mcps=version_data.get('configured_mcps', []), + custom_mcps=version_data.get('custom_mcps', []), + agentpress_tools=version_data.get('agentpress_tools', {}), + is_active=version_data.get('is_active', True), + created_at=version_data['created_at'], + updated_at=version_data.get('updated_at', version_data['created_at']), + created_by=version_data.get('created_by') + ) + except Exception as e: + logger.warning(f"Auto-versioning failed for agent {agent_id}: {e}") + return AgentResponse( agent_id=agent['agent_id'], account_id=agent['account_id'], @@ -1864,22 +1955,14 @@ async def get_agent_versions( agent_id: str, user_id: str = Depends(get_current_user_id_from_jwt) ): - """Get all versions of an agent.""" - client = await db.client - - # Check if user has access to this agent - agent_result = await client.table('agents').select("*").eq("agent_id", agent_id).execute() - if not agent_result.data: - raise HTTPException(status_code=404, detail="Agent not found") - - agent = agent_result.data[0] - if agent['account_id'] != user_id and not agent.get('is_public', False): - raise HTTPException(status_code=403, detail="Access denied") - - # Get all versions - versions_result = await client.table('agent_versions').select("*").eq("agent_id", agent_id).order("version_number", desc=True).execute() - - return versions_result.data + try: + versions = await version_manager.get_all_versions(agent_id, user_id) + return versions + except HTTPException: + raise + except Exception as e: + logger.error(f"Error fetching versions for agent {agent_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to fetch versions") @router.post("/agents/{agent_id}/versions", response_model=AgentVersionResponse) async def create_agent_version( @@ -1888,61 +1971,29 @@ async def create_agent_version( user_id: str = Depends(get_current_user_id_from_jwt) ): """Create a new version of an agent.""" - client = await db.client - - # Check if user owns this agent - agent_result = await client.table('agents').select("*").eq("agent_id", agent_id).eq("account_id", user_id).execute() - if not agent_result.data: - raise HTTPException(status_code=404, detail="Agent not found or access denied") - - agent = agent_result.data[0] - - # Get next version number - versions_result = await client.table('agent_versions').select("version_number").eq("agent_id", agent_id).order("version_number", desc=True).limit(1).execute() - next_version_number = 1 - if versions_result.data: - next_version_number = versions_result.data[0]['version_number'] + 1 - - # Create new version - new_version_data = { - "agent_id": agent_id, - "version_number": next_version_number, - "version_name": f"v{next_version_number}", - "system_prompt": version_data.system_prompt, - "configured_mcps": version_data.configured_mcps or [], - "custom_mcps": version_data.custom_mcps or [], - "agentpress_tools": version_data.agentpress_tools or {}, - "is_active": True, - "created_by": user_id - } - - # Build unified config for the new version - version_unified_config = build_unified_config( - system_prompt=new_version_data["system_prompt"], - agentpress_tools=new_version_data["agentpress_tools"], - configured_mcps=new_version_data["configured_mcps"], - custom_mcps=new_version_data["custom_mcps"], - avatar=None, # Avatar is not versioned - avatar_color=None # Avatar color is not versioned - ) - new_version_data["config"] = version_unified_config - - new_version = await client.table('agent_versions').insert(new_version_data).execute() - - if not new_version.data: + try: + # Convert request to VersionData model + version_data_model = VersionData( + system_prompt=version_data.system_prompt, + configured_mcps=version_data.configured_mcps or [], + custom_mcps=version_data.custom_mcps or [], + agentpress_tools=version_data.agentpress_tools or {} + ) + + version = await version_manager.create_version( + agent_id=agent_id, + version_data=version_data_model, + user_id=user_id, + version_name=version_data.version_name, + description=version_data.description + ) + + return version + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating version for agent {agent_id}: {str(e)}") raise HTTPException(status_code=500, detail="Failed to create version") - - version = new_version.data[0] - - # Update agent with new version - await client.table('agents').update({ - "current_version_id": version['version_id'], - "version_count": next_version_number - }).eq("agent_id", agent_id).execute() - - logger.info(f"Created version v{next_version_number} for agent {agent_id}") - - return version @router.put("/agents/{agent_id}/versions/{version_id}/activate") async def activate_agent_version( @@ -1951,24 +2002,14 @@ async def activate_agent_version( user_id: str = Depends(get_current_user_id_from_jwt) ): """Switch agent to use a specific version.""" - client = await db.client - - # Check if user owns this agent - agent_result = await client.table('agents').select("*").eq("agent_id", agent_id).eq("account_id", user_id).execute() - if not agent_result.data: - raise HTTPException(status_code=404, detail="Agent not found or access denied") - - # Check if version exists - version_result = await client.table('agent_versions').select("*").eq("version_id", version_id).eq("agent_id", agent_id).execute() - if not version_result.data: - raise HTTPException(status_code=404, detail="Version not found") - - # Update agent's current version - await client.table('agents').update({ - "current_version_id": version_id - }).eq("agent_id", agent_id).execute() - - return {"message": "Version activated successfully"} + try: + await version_manager.activate_version(agent_id, version_id, user_id) + return {"message": "Version activated successfully"} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error activating version {version_id} for agent {agent_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to activate version") @router.get("/agents/{agent_id}/versions/{version_id}", response_model=AgentVersionResponse) async def get_agent_version( @@ -1977,24 +2018,31 @@ async def get_agent_version( user_id: str = Depends(get_current_user_id_from_jwt) ): """Get a specific version of an agent.""" - client = await db.client - - # Check if user has access to this agent - agent_result = await client.table('agents').select("*").eq("agent_id", agent_id).execute() - if not agent_result.data: - raise HTTPException(status_code=404, detail="Agent not found") - - agent = agent_result.data[0] - if agent['account_id'] != user_id and not agent.get('is_public', False): - raise HTTPException(status_code=403, detail="Access denied") - - # Get the specific version - version_result = await client.table('agent_versions').select("*").eq("version_id", version_id).eq("agent_id", agent_id).execute() - - if not version_result.data: - raise HTTPException(status_code=404, detail="Version not found") - - return version_result.data[0] + try: + version = await version_manager.get_version(agent_id, version_id, user_id) + return version + except HTTPException: + raise + except Exception as e: + logger.error(f"Error fetching version {version_id} for agent {agent_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to fetch version") + +@router.get("/agents/{agent_id}/versions/compare") +async def compare_agent_versions( + agent_id: str, + version1_id: str = Query(..., description="First version ID to compare"), + version2_id: str = Query(..., description="Second version ID to compare"), + user_id: str = Depends(get_current_user_id_from_jwt) +): + """Compare two versions of an agent.""" + try: + comparison = await version_manager.compare_versions(agent_id, version1_id, version2_id, user_id) + return comparison + except HTTPException: + raise + except Exception as e: + logger.error(f"Error comparing versions for agent {agent_id}: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to compare versions") @router.get("/agents/{agent_id}/pipedream-tools/{profile_id}") async def get_pipedream_tools_for_agent( @@ -2161,6 +2209,18 @@ async def update_pipedream_tools_for_agent( raise HTTPException(status_code=500, detail="Failed to update agent") logger.info(f"Successfully updated {len(enabled_tools)} tools for agent {agent_id}") + + # 🎯 AUTO-VERSIONING: Create version for Pipedream tools update + try: + auto_version_id = await version_manager.auto_create_version_on_config_change( + agent_id=agent_id, + user_id=user_id, + change_description=f"Auto-saved after updating {profile.app_name} tools" + ) + if auto_version_id: + logger.info(f"Auto-created version {auto_version_id} for Pipedream tools update on agent {agent_id}") + except Exception as e: + logger.warning(f"Auto-versioning failed for Pipedream tools update on agent {agent_id}: {e}") return { 'success': True, @@ -2270,7 +2330,6 @@ async def update_custom_mcp_tools_for_agent( agent = agent_result.data[0] custom_mcps = agent.get('custom_mcps', []) - # Get request data mcp_url = request.get('url') mcp_type = request.get('type', 'sse') enabled_tools = request.get('enabled_tools', []) @@ -2287,7 +2346,6 @@ async def update_custom_mcp_tools_for_agent( break if not updated: - # Create new MCP config new_mcp_config = { "name": f"Custom MCP ({mcp_type.upper()})", "customType": mcp_type, @@ -2299,7 +2357,6 @@ async def update_custom_mcp_tools_for_agent( } custom_mcps.append(new_mcp_config) - # Update the agent update_result = await client.table('agents').update({ 'custom_mcps': custom_mcps }).eq('agent_id', agent_id).execute() @@ -2308,6 +2365,16 @@ async def update_custom_mcp_tools_for_agent( raise HTTPException(status_code=500, detail="Failed to update agent") logger.info(f"Successfully updated {len(enabled_tools)} custom MCP tools for agent {agent_id}") + try: + auto_version_id = await version_manager.auto_create_version_on_config_change( + agent_id=agent_id, + user_id=user_id, + change_description=f"Auto-saved after updating custom MCP tools" + ) + if auto_version_id: + logger.info(f"Auto-created version {auto_version_id} for custom MCP tools update on agent {agent_id}") + except Exception as e: + logger.warning(f"Auto-versioning failed for custom MCP tools update on agent {agent_id}: {e}") return { 'success': True, diff --git a/backend/agent/config_helper.py b/backend/agent/config_helper.py index 52f30024..32bf302e 100644 --- a/backend/agent/config_helper.py +++ b/backend/agent/config_helper.py @@ -3,6 +3,32 @@ from utils.logger import logger def extract_agent_config(agent_data: Dict[str, Any], version_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + agent_id = agent_data.get('agent_id', 'Unknown') + + agent_has_config = bool(agent_data.get('config') and agent_data['config'] != {}) + version_has_config = bool(version_data and version_data.get('config') and version_data['config'] != {}) + + if version_data and version_data.get('config') and version_data['config'] != {}: + config = version_data['config'].copy() + config['agent_id'] = agent_data['agent_id'] + config['name'] = agent_data['name'] + config['description'] = agent_data.get('description') + config['is_default'] = agent_data.get('is_default', False) + config['account_id'] = agent_data.get('account_id') + config['current_version_id'] = agent_data.get('current_version_id') + config['version_name'] = version_data.get('version_name', 'v1') + + metadata = config.get('metadata', {}) + config['avatar'] = metadata.get('avatar', agent_data.get('avatar')) + config['avatar_color'] = metadata.get('avatar_color', agent_data.get('avatar_color')) + + config['agentpress_tools'] = extract_tools_for_agent_run(config) + + config['configured_mcps'] = config.get('tools', {}).get('mcp', []) + config['custom_mcps'] = config.get('tools', {}).get('custom_mcp', []) + + return config + if agent_data.get('config') and agent_data['config'] != {}: config = agent_data['config'].copy() if 'tools' not in config: @@ -32,31 +58,6 @@ def extract_agent_config(agent_data: Dict[str, Any], version_data: Optional[Dict return config - if version_data and version_data.get('config') and version_data['config'] != {}: - config = version_data['config'].copy() - - config['agent_id'] = agent_data['agent_id'] - config['name'] = agent_data['name'] - config['description'] = agent_data.get('description') - config['is_default'] = agent_data.get('is_default', False) - config['account_id'] = agent_data.get('account_id') - config['current_version_id'] = agent_data.get('current_version_id') - config['version_name'] = version_data.get('version_name', 'v1') - - metadata = config.get('metadata', {}) - config['avatar'] = metadata.get('avatar', agent_data.get('avatar')) - config['avatar_color'] = metadata.get('avatar_color', agent_data.get('avatar_color')) - - # Convert agentpress tools to legacy format for run_agent - config['agentpress_tools'] = extract_tools_for_agent_run(config) - - # Also extract MCP configs - config['configured_mcps'] = config.get('tools', {}).get('mcp', []) - config['custom_mcps'] = config.get('tools', {}).get('custom_mcp', []) - - return config - - logger.info(f"Building config from legacy columns for agent {agent_data.get('agent_id')}") source_data = version_data if version_data else agent_data legacy_tools = source_data.get('agentpress_tools', {}) diff --git a/backend/agent/run.py b/backend/agent/run.py index 2d3ba16e..8f9469b0 100644 --- a/backend/agent/run.py +++ b/backend/agent/run.py @@ -50,11 +50,6 @@ async def run_agent( is_agent_builder: Optional[bool] = False, target_agent_id: Optional[str] = None ): - """Run the development agent with specified configuration.""" - logger.info(f"🚀 Starting agent with model: {model_name}") - if agent_config: - logger.info(f"Using custom agent: {agent_config.get('name', 'Unknown')}") - if not trace: trace = langfuse.trace(name="run_agent", session_id=thread_id, metadata={"project_id": project_id}) thread_manager = ThreadManager(trace=trace, is_agent_builder=is_agent_builder or False, target_agent_id=target_agent_id, agent_config=agent_config) @@ -83,7 +78,12 @@ async def run_agent( enabled_tools = None if agent_config and 'agentpress_tools' in agent_config: enabled_tools = agent_config['agentpress_tools'] - logger.info(f"Using custom tool configuration from agent") + logger.info(f"[AGENT RUN] Using custom tool configuration from agent version") + # 🔍 DEBUG: Log which tools are enabled + enabled_tool_names = [name for name, config in enabled_tools.items() + if (isinstance(config, dict) and config.get('enabled', False)) or + (isinstance(config, bool) and config)] + logger.info(f"[AGENT RUN] Enabled tools from version: {enabled_tool_names}") if is_agent_builder: @@ -103,7 +103,7 @@ async def run_agent( if enabled_tools is None: - logger.info("No agent specified - registering all tools for full Suna capabilities") + logger.info("[AGENT RUN] No agent specified - registering all tools for full Suna capabilities") thread_manager.add_tool(SandboxShellTool, project_id=project_id, thread_manager=thread_manager) thread_manager.add_tool(SandboxFilesTool, project_id=project_id, thread_manager=thread_manager) thread_manager.add_tool(SandboxBrowserTool, project_id=project_id, thread_id=thread_id, thread_manager=thread_manager) @@ -117,24 +117,32 @@ async def run_agent( if config.RAPID_API_KEY: thread_manager.add_tool(DataProvidersTool) else: - logger.info("Custom agent specified - registering only enabled tools") + logger.info("[AGENT RUN] Custom agent specified - registering only enabled tools from version") thread_manager.add_tool(ExpandMessageTool, thread_id=thread_id, thread_manager=thread_manager) thread_manager.add_tool(MessageTool) if enabled_tools.get('sb_shell_tool', {}).get('enabled', False): + logger.info("[AGENT RUN] Adding sb_shell_tool (enabled in version)") thread_manager.add_tool(SandboxShellTool, project_id=project_id, thread_manager=thread_manager) if enabled_tools.get('sb_files_tool', {}).get('enabled', False): + logger.info("[AGENT RUN] Adding sb_files_tool (enabled in version)") thread_manager.add_tool(SandboxFilesTool, project_id=project_id, thread_manager=thread_manager) if enabled_tools.get('sb_browser_tool', {}).get('enabled', False): + logger.info("[AGENT RUN] Adding sb_browser_tool (enabled in version)") thread_manager.add_tool(SandboxBrowserTool, project_id=project_id, thread_id=thread_id, thread_manager=thread_manager) if enabled_tools.get('sb_deploy_tool', {}).get('enabled', False): + logger.info("[AGENT RUN] Adding sb_deploy_tool (enabled in version)") thread_manager.add_tool(SandboxDeployTool, project_id=project_id, thread_manager=thread_manager) if enabled_tools.get('sb_expose_tool', {}).get('enabled', False): + logger.info("[AGENT RUN] Adding sb_expose_tool (enabled in version)") thread_manager.add_tool(SandboxExposeTool, project_id=project_id, thread_manager=thread_manager) if enabled_tools.get('web_search_tool', {}).get('enabled', False): + logger.info("[AGENT RUN] Adding web_search_tool (enabled in version)") thread_manager.add_tool(SandboxWebSearchTool, project_id=project_id, thread_manager=thread_manager) if enabled_tools.get('sb_vision_tool', {}).get('enabled', False): + logger.info("[AGENT RUN] Adding sb_vision_tool (enabled in version)") thread_manager.add_tool(SandboxVisionTool, project_id=project_id, thread_id=thread_id, thread_manager=thread_manager) if config.RAPID_API_KEY and enabled_tools.get('data_providers_tool', {}).get('enabled', False): + logger.info("[AGENT RUN] Adding data_providers_tool (enabled in version)") thread_manager.add_tool(DataProvidersTool) # Register MCP tool wrapper if agent has configured MCPs or custom MCPs @@ -250,14 +258,15 @@ async def run_agent( # Completely replace the default system prompt with the custom one # This prevents confusion and tool hallucination system_content = custom_system_prompt - logger.info(f"Using ONLY custom agent system prompt for: {agent_config.get('name', 'Unknown')}") + logger.info(f"[AGENT RUN] Using ONLY custom agent system prompt for: {agent_config.get('name', 'Unknown')}") + logger.info(f"[AGENT RUN] System prompt source: version {agent_config.get('version_name', 'no version')} (length: {len(custom_system_prompt)})") elif is_agent_builder: system_content = get_agent_builder_prompt() - logger.info("Using agent builder system prompt") + logger.info("[AGENT RUN] Using agent builder system prompt") else: # Use just the default system prompt system_content = default_system_content - logger.info("Using default system prompt only") + logger.info("[AGENT RUN] Using default system prompt only") if await is_enabled("knowledge_base"): try: diff --git a/backend/agent/version_manager.py b/backend/agent/version_manager.py new file mode 100644 index 00000000..ffd31bf8 --- /dev/null +++ b/backend/agent/version_manager.py @@ -0,0 +1,379 @@ +""" +Agent Version Manager - Comprehensive module for managing agent versions +""" +from typing import Dict, List, Optional, Any, Union +from datetime import datetime +from uuid import UUID +import json +from pydantic import BaseModel, Field, validator +from utils.logger import logger +from services.supabase import DBConnection +from fastapi import HTTPException + + +class VersionData(BaseModel): + """Model for version data with proper validation""" + system_prompt: str + configured_mcps: List[Dict[str, Any]] = Field(default_factory=list) + custom_mcps: List[Dict[str, Any]] = Field(default_factory=list) + agentpress_tools: Dict[str, Any] = Field(default_factory=dict) + + @validator('custom_mcps', pre=True) + def normalize_custom_mcps(cls, v): + """Ensure custom MCPs have consistent structure""" + if not isinstance(v, list): + return [] + + normalized = [] + for mcp in v: + if isinstance(mcp, dict): + normalized.append({ + 'name': mcp.get('name', 'Unnamed MCP'), + 'type': mcp.get('type') or mcp.get('customType', 'sse'), + 'customType': mcp.get('customType') or mcp.get('type', 'sse'), + 'config': mcp.get('config', {}), + 'enabledTools': mcp.get('enabledTools') or mcp.get('enabled_tools', []) + }) + return normalized + + @validator('configured_mcps', pre=True) + def normalize_configured_mcps(cls, v): + """Ensure configured MCPs are always a list""" + return v if isinstance(v, list) else [] + + @validator('agentpress_tools', pre=True) + def normalize_agentpress_tools(cls, v): + """Ensure agentpress tools are always a dict""" + return v if isinstance(v, dict) else {} + + +class AgentVersionManager: + """Manager class for handling all agent version operations""" + + def __init__(self): + self.logger = logger + self.db = DBConnection() + + async def get_version(self, agent_id: str, version_id: str, user_id: str) -> Dict[str, Any]: + """Get a specific version with proper data normalization""" + client = await self.db.client + + # Verify access + agent_result = await client.table('agents').select('*').eq('agent_id', agent_id).execute() + if not agent_result.data: + raise HTTPException(status_code=404, detail="Agent not found") + + agent = agent_result.data[0] + if agent['account_id'] != user_id and not agent.get('is_public', False): + raise HTTPException(status_code=403, detail="Access denied") + + # Get version + version_result = await client.table('agent_versions').select('*').eq('version_id', version_id).eq('agent_id', agent_id).execute() + + if not version_result.data: + raise HTTPException(status_code=404, detail="Version not found") + + version_data = version_result.data[0] + return self._normalize_version_data(version_data) + + async def get_all_versions(self, agent_id: str, user_id: str) -> List[Dict[str, Any]]: + """Get all versions for an agent""" + client = await self.db.client + + # Verify access + agent_result = await client.table('agents').select('*').eq('agent_id', agent_id).execute() + if not agent_result.data: + raise HTTPException(status_code=404, detail="Agent not found") + + agent = agent_result.data[0] + if agent['account_id'] != user_id and not agent.get('is_public', False): + raise HTTPException(status_code=403, detail="Access denied") + + # Get all versions + versions_result = await client.table('agent_versions').select('*').eq('agent_id', agent_id).order('version_number', desc=True).execute() + + return [self._normalize_version_data(v) for v in versions_result.data] + + async def create_version( + self, + agent_id: str, + version_data: VersionData, + user_id: str, + version_name: Optional[str] = None, + description: Optional[str] = None + ) -> Dict[str, Any]: + """Create a new version with proper validation""" + client = await self.db.client + + # Verify ownership + agent_result = await client.table('agents').select('*').eq('agent_id', agent_id).eq('account_id', user_id).execute() + if not agent_result.data: + raise HTTPException(status_code=404, detail="Agent not found or access denied") + + # Get next version number + versions_result = await client.table('agent_versions').select('version_number').eq('agent_id', agent_id).order('version_number', desc=True).limit(1).execute() + next_version_number = 1 + if versions_result.data: + next_version_number = versions_result.data[0]['version_number'] + 1 + + # Create version + new_version_data = { + "agent_id": agent_id, + "version_number": next_version_number, + "version_name": version_name or f"v{next_version_number}", + "system_prompt": version_data.system_prompt, + "configured_mcps": version_data.configured_mcps, + "custom_mcps": version_data.custom_mcps, + "agentpress_tools": version_data.agentpress_tools, + "is_active": True, + "created_by": user_id, + "change_description": description + } + + # Build unified config + config = self._build_unified_config(version_data) + new_version_data['config'] = config + + try: + version_result = await client.table('agent_versions').insert(new_version_data).execute() + + if not version_result.data: + raise HTTPException(status_code=500, detail="Failed to create version") + + version = version_result.data[0] + + # Update agent's current version + await client.table('agents').update({ + 'current_version_id': version['version_id'], + 'version_count': next_version_number + }).eq('agent_id', agent_id).execute() + + self.logger.info(f"Created version {version['version_name']} for agent {agent_id}") + return self._normalize_version_data(version) + + except Exception as e: + self.logger.error(f"Error creating version: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to create version: {str(e)}") + + async def auto_create_version_on_config_change( + self, + agent_id: str, + user_id: str, + change_description: str = "Auto-saved configuration changes" + ) -> Optional[str]: + try: + db = DBConnection() + current_agent = await db.fetch_one( + "SELECT * FROM agents WHERE agent_id = %s", (agent_id,) + ) + + if not current_agent: + logger.warning(f"Agent {agent_id} not found for auto-versioning") + return None + current_version = None + if current_agent['current_version_id']: + current_version = await db.fetch_one( + "SELECT * FROM agent_versions WHERE version_id = %s", + (current_agent['current_version_id'],) + ) + + current_config = { + 'system_prompt': current_agent['system_prompt'], + 'configured_mcps': current_agent['configured_mcps'] or [], + 'custom_mcps': current_agent['custom_mcps'] or [], + 'agentpress_tools': current_agent['agentpress_tools'] or {} + } + + version_config = None + if current_version: + version_config = { + 'system_prompt': current_version['system_prompt'], + 'configured_mcps': current_version['configured_mcps'] or [], + 'custom_mcps': current_version['custom_mcps'] or [], + 'agentpress_tools': current_version['agentpress_tools'] or {} + } + + if version_config and current_config == version_config: + logger.info(f"No configuration changes detected for agent {agent_id}") + return None + + logger.info(f"Configuration changes detected for agent {agent_id}, creating auto-version") + + version_data = VersionData( + system_prompt=current_config['system_prompt'], + configured_mcps=current_config['configured_mcps'], + custom_mcps=current_config['custom_mcps'], + agentpress_tools=current_config['agentpress_tools'] + ) + + new_version = await self.create_version( + agent_id=agent_id, + version_data=version_data, + user_id=user_id, + description=change_description + ) + + if new_version: + await self.activate_version(agent_id, new_version['version_id'], user_id) + logger.info(f"Auto-created and activated version {new_version['version_name']} for agent {agent_id}") + return new_version['version_id'] + + return None + + except Exception as e: + logger.error(f"Failed to auto-create version for agent {agent_id}: {e}") + return None + + async def activate_version(self, agent_id: str, version_id: str, user_id: str) -> None: + """Activate a specific version""" + client = await self.db.client + + # Verify ownership + agent_result = await client.table('agents').select('*').eq('agent_id', agent_id).eq('account_id', user_id).execute() + if not agent_result.data: + raise HTTPException(status_code=404, detail="Agent not found or access denied") + + # Verify version exists + version_result = await client.table('agent_versions').select('*').eq('version_id', version_id).eq('agent_id', agent_id).execute() + if not version_result.data: + raise HTTPException(status_code=404, detail="Version not found") + + # Update agent's current version + await client.table('agents').update({ + 'current_version_id': version_id + }).eq('agent_id', agent_id).execute() + + self.logger.info(f"Activated version {version_id} for agent {agent_id}") + + async def compare_versions( + self, + agent_id: str, + version1_id: str, + version2_id: str, + user_id: str + ) -> Dict[str, Any]: + """Compare two versions""" + version1 = await self.get_version(agent_id, version1_id, user_id) + version2 = await self.get_version(agent_id, version2_id, user_id) + + return { + 'version1': version1, + 'version2': version2, + 'differences': self._calculate_differences(version1, version2) + } + + def _normalize_version_data(self, version_data: Dict[str, Any]) -> Dict[str, Any]: + """Normalize version data to ensure consistent structure""" + # Handle custom MCPs normalization + custom_mcps = version_data.get('custom_mcps', []) + if custom_mcps is None: + custom_mcps = [] + elif isinstance(custom_mcps, list): + custom_mcps = [ + { + 'name': mcp.get('name', 'Unnamed MCP'), + 'type': mcp.get('type') or mcp.get('customType', 'sse'), + 'customType': mcp.get('customType') or mcp.get('type', 'sse'), + 'config': mcp.get('config', {}), + 'enabledTools': mcp.get('enabledTools') or mcp.get('enabled_tools', []) + } + for mcp in custom_mcps + if isinstance(mcp, dict) + ] + + # Handle configured MCPs + configured_mcps = version_data.get('configured_mcps', []) + if configured_mcps is None: + configured_mcps = [] + elif not isinstance(configured_mcps, list): + configured_mcps = [] + + # Handle agentpress tools + agentpress_tools = version_data.get('agentpress_tools', {}) + if not isinstance(agentpress_tools, dict): + agentpress_tools = {} + + return { + 'version_id': version_data['version_id'], + 'agent_id': version_data['agent_id'], + 'version_number': version_data['version_number'], + 'version_name': version_data['version_name'], + 'system_prompt': version_data.get('system_prompt', ''), + 'configured_mcps': configured_mcps, + 'custom_mcps': custom_mcps, + 'agentpress_tools': agentpress_tools, + 'is_active': version_data.get('is_active', True), + 'created_at': version_data['created_at'], + 'updated_at': version_data.get('updated_at', version_data['created_at']), + 'created_by': version_data.get('created_by'), + 'change_description': version_data.get('change_description'), + 'config': version_data.get('config', {}) + } + + def _build_unified_config(self, version_data: VersionData) -> Dict[str, Any]: + """Build unified config object""" + simplified_tools = {} + for tool_name, tool_config in version_data.agentpress_tools.items(): + if isinstance(tool_config, dict): + simplified_tools[tool_name] = tool_config.get('enabled', False) + elif isinstance(tool_config, bool): + simplified_tools[tool_name] = tool_config + + return { + 'system_prompt': version_data.system_prompt, + 'tools': { + 'agentpress': simplified_tools, + 'mcp': version_data.configured_mcps, + 'custom_mcp': version_data.custom_mcps + } + } + + def _calculate_differences(self, version1: Dict[str, Any], version2: Dict[str, Any]) -> Dict[str, Any]: + """Calculate differences between two versions""" + differences = {} + + # Check system prompt + if version1['system_prompt'] != version2['system_prompt']: + differences['system_prompt'] = { + 'changed': True, + 'version1': version1['system_prompt'][:100] + '...' if len(version1['system_prompt']) > 100 else version1['system_prompt'], + 'version2': version2['system_prompt'][:100] + '...' if len(version2['system_prompt']) > 100 else version2['system_prompt'] + } + + # Check tools + v1_tools = set(version1['agentpress_tools'].keys()) + v2_tools = set(version2['agentpress_tools'].keys()) + + tools_added = v2_tools - v1_tools + tools_removed = v1_tools - v2_tools + tools_changed = [] + + for tool in v1_tools & v2_tools: + if version1['agentpress_tools'][tool] != version2['agentpress_tools'][tool]: + tools_changed.append(tool) + + if tools_added or tools_removed or tools_changed: + differences['agentpress_tools'] = { + 'added': list(tools_added), + 'removed': list(tools_removed), + 'changed': tools_changed + } + + # Check MCPs + if json.dumps(version1['configured_mcps'], sort_keys=True) != json.dumps(version2['configured_mcps'], sort_keys=True): + differences['configured_mcps'] = { + 'version1_count': len(version1['configured_mcps']), + 'version2_count': len(version2['configured_mcps']) + } + + if json.dumps(version1['custom_mcps'], sort_keys=True) != json.dumps(version2['custom_mcps'], sort_keys=True): + differences['custom_mcps'] = { + 'version1_count': len(version1['custom_mcps']), + 'version2_count': len(version2['custom_mcps']) + } + + return differences + + +# Singleton instance +version_manager = AgentVersionManager() \ No newline at end of file diff --git a/backend/mcp_service/api.py b/backend/mcp_service/api.py index 4264f57f..440d5f70 100644 --- a/backend/mcp_service/api.py +++ b/backend/mcp_service/api.py @@ -183,6 +183,13 @@ async def get_mcp_server_details( """ logger.info(f"Fetching details for MCP server: {qualified_name} for user {user_id}") + # 🔥 FIX: Handle Pipedream qualified names by stripping the pipedream: prefix + # Pipedream MCPs use format "pipedream:{app_slug}" but Smithery API expects just "{app_slug}" + smithery_lookup_name = qualified_name + if qualified_name.startswith("pipedream:"): + smithery_lookup_name = qualified_name[len("pipedream:"):] + logger.info(f"Pipedream MCP detected - using app_slug '{smithery_lookup_name}' for Smithery lookup") + try: async with httpx.AsyncClient() as client: headers = { @@ -194,15 +201,17 @@ async def get_mcp_server_details( if SMITHERY_API_KEY: headers["Authorization"] = f"Bearer {SMITHERY_API_KEY}" - # URL encode the qualified name only if it contains special characters - if '@' in qualified_name or '/' in qualified_name: - encoded_name = quote(qualified_name, safe='') + # URL encode the lookup name only if it contains special characters + if '@' in smithery_lookup_name or '/' in smithery_lookup_name: + encoded_name = quote(smithery_lookup_name, safe='') else: - # Don't encode simple names like "exa" - encoded_name = qualified_name + # Don't encode simple names like "exa" or "gmail" + encoded_name = smithery_lookup_name url = f"{SMITHERY_API_BASE_URL}/servers/{encoded_name}" logger.debug(f"Requesting MCP server details from: {url}") + if qualified_name != smithery_lookup_name: + logger.debug(f"Original qualified name: {qualified_name}, Smithery lookup name: {smithery_lookup_name}") response = await client.get( url, # Use registry API for metadata @@ -218,12 +227,22 @@ async def get_mcp_server_details( logger.info(f"Successfully fetched details for MCP server: {qualified_name}") logger.debug(f"Response data keys: {list(data.keys()) if isinstance(data, dict) else 'not a dict'}") + # 🔥 FIX: For Pipedream MCPs, restore the original qualified name in the response + # This ensures the frontend receives the same qualified name it requested + if qualified_name.startswith("pipedream:") and 'qualifiedName' in data: + original_qualified_name = data['qualifiedName'] + data['qualifiedName'] = qualified_name + logger.debug(f"Restored original qualified name: {qualified_name} (Smithery returned: {original_qualified_name})") + return MCPServerDetailResponse(**data) except httpx.HTTPStatusError as e: if e.response.status_code == 404: logger.error(f"Server not found. Response: {e.response.text}") - raise HTTPException(status_code=404, detail=f"MCP server '{qualified_name}' not found") + if qualified_name.startswith("pipedream:"): + raise HTTPException(status_code=404, detail=f"MCP server '{qualified_name}' not found (searched for '{smithery_lookup_name}' in Smithery registry)") + else: + raise HTTPException(status_code=404, detail=f"MCP server '{qualified_name}' not found") logger.error(f"HTTP error fetching MCP server details: {e.response.status_code} - {e.response.text}") raise HTTPException( diff --git a/backend/mcp_service/credential_manager.py b/backend/mcp_service/credential_manager.py index 576bbd02..2471ab6d 100644 --- a/backend/mcp_service/credential_manager.py +++ b/backend/mcp_service/credential_manager.py @@ -431,19 +431,11 @@ class CredentialManager: account_id: str, mcp_qualified_name: str ) -> List[MCPCredentialProfile]: - """ - Get all credential profiles for a specific MCP server - - Args: - account_id: User's account ID - mcp_qualified_name: MCP server qualified name - - Returns: - List of MCPCredentialProfile objects - """ try: client = await db.client + logger.debug(f"Querying credential profiles: account_id={account_id}, mcp_qualified_name='{mcp_qualified_name}'") + result = await client.table('user_mcp_credential_profiles').select('*')\ .eq('account_id', account_id)\ .eq('mcp_qualified_name', mcp_qualified_name)\ @@ -451,6 +443,8 @@ class CredentialManager: .order('is_default', desc=True)\ .order('created_at', desc=False)\ .execute() + + logger.debug(f"Database query returned {len(result.data) if result.data else 0} profiles for '{mcp_qualified_name}'") profiles = [] for profile_data in result.data: @@ -561,7 +555,7 @@ class CredentialManager: mcp_qualified_name: str ) -> Optional[MCPCredentialProfile]: """ - Get the default credential profile for an MCP server + Get the default credential profile for an MCP server using robust lookup Args: account_id: User's account ID @@ -570,12 +564,22 @@ class CredentialManager: Returns: Default MCPCredentialProfile or first available profile """ - profiles = await self.get_credential_profiles(account_id, mcp_qualified_name) + logger.debug(f"Looking for default profile: account_id={account_id}, mcp_qualified_name={mcp_qualified_name}") + + # Use robust profile finder + profiles = await self.find_credential_profiles_robust(account_id, mcp_qualified_name) + logger.debug(f"Found {len(profiles)} profiles for {mcp_qualified_name}") for profile in profiles: if profile.is_default: + logger.debug(f"Found default profile: {profile.profile_id} ({profile.display_name})") return profile + if profiles: + logger.debug(f"No default profile found, returning first profile: {profiles[0].profile_id} ({profiles[0].display_name})") + else: + logger.debug(f"No profiles found for {mcp_qualified_name}") + return profiles[0] if profiles else None async def set_default_profile( @@ -651,8 +655,58 @@ class CredentialManager: logger.error(f"Error deleting credential profile {profile_id}: {str(e)}") return False + async def find_credential_profiles_robust( + self, + account_id: str, + mcp_qualified_name: str + ) -> List[MCPCredentialProfile]: + profiles = [] + profiles = await self.get_credential_profiles(account_id, mcp_qualified_name) + if profiles: + logger.debug(f"Found {len(profiles)} profiles with exact match for '{mcp_qualified_name}'") + return profiles + + if mcp_qualified_name.startswith("pipedream:"): + app_slug = mcp_qualified_name[len("pipedream:"):] + try: + from pipedream.profiles import get_profile_manager + profile_manager = get_profile_manager(db) + pipedream_profiles = await profile_manager.get_profiles(account_id, app_slug=app_slug, is_active=True) + + for pd_profile in pipedream_profiles: + cred_profile = await self.get_credential_by_profile(account_id, str(pd_profile.profile_id)) + if cred_profile: + profiles.append(cred_profile) + + if profiles: + logger.debug(f"Found {len(profiles)} Pipedream profiles via profile manager for '{mcp_qualified_name}'") + return profiles + except Exception as e: + logger.debug(f"Error using Pipedream profile manager: {e}") + + elif not mcp_qualified_name.startswith("pipedream:"): + pipedream_name = f"pipedream:{mcp_qualified_name}" + profiles = await self.get_credential_profiles(account_id, pipedream_name) + if profiles: + logger.debug(f"Found {len(profiles)} profiles with pipedream prefix '{pipedream_name}'") + return profiles + + all_profiles = await self.get_all_user_credential_profiles(account_id) + req_name_clean = mcp_qualified_name.replace("pipedream:", "").lower() + + for profile in all_profiles: + profile_name_clean = profile.mcp_qualified_name.replace("pipedream:", "").lower() + if req_name_clean == profile_name_clean: + profiles.append(profile) + + if profiles: + logger.debug(f"Found {len(profiles)} profiles via fuzzy matching for '{mcp_qualified_name}'") + else: + logger.debug(f"No profiles found for '{mcp_qualified_name}' after all strategies") + + return profiles + async def get_all_user_credential_profiles(self, account_id: str) -> List[MCPCredentialProfile]: - """Get all credential profiles for a user across all MCP servers""" try: client = await db.client diff --git a/backend/mcp_service/secure_api.py b/backend/mcp_service/secure_api.py index cd7adb23..d48399e4 100644 --- a/backend/mcp_service/secure_api.py +++ b/backend/mcp_service/secure_api.py @@ -255,12 +255,12 @@ async def get_credential_profiles_for_mcp( mcp_qualified_name: str, user_id: str = Depends(get_current_user_id_from_jwt) ): - """Get all credential profiles for a specific MCP server""" decoded_name = urllib.parse.unquote(mcp_qualified_name) logger.info(f"Getting credential profiles for '{decoded_name}' for user {user_id}") try: - profiles = await credential_manager.get_credential_profiles(user_id, decoded_name) + profiles = await credential_manager.find_credential_profiles_robust(user_id, decoded_name) + logger.info(f"Found {len(profiles)} credential profiles for '{decoded_name}'") return [ CredentialProfileResponse( @@ -287,9 +287,7 @@ async def get_credential_profile_by_id( profile_id: str, user_id: str = Depends(get_current_user_id_from_jwt) ): - """Get a specific credential profile by its ID""" logger.info(f"Getting credential profile {profile_id} for user {user_id}") - try: profile = await credential_manager.get_credential_by_profile(user_id, profile_id) diff --git a/backend/mcp_service/template_manager.py b/backend/mcp_service/template_manager.py index 943fabb0..9d8a4b73 100644 --- a/backend/mcp_service/template_manager.py +++ b/backend/mcp_service/template_manager.py @@ -81,8 +81,8 @@ class TemplateManager: try: client = await db.client - # Get the agent - agent_result = await client.table('agents').select('*').eq('agent_id', agent_id).execute() + # 🔥 FIX: Get the agent WITH current version data + agent_result = await client.table('agents').select('*, agent_versions!current_version_id(*)').eq('agent_id', agent_id).execute() if not agent_result.data: raise ValueError("Agent not found") @@ -92,11 +92,29 @@ class TemplateManager: if agent['account_id'] != creator_id: raise ValueError("Access denied - you can only create templates from your own agents") + # 🔥 FIX: Get configuration from current version, not legacy columns + version_data = agent.get('agent_versions') + if version_data and version_data.get('config'): + # Use version config (new structure) + version_config = version_data['config'] + system_prompt = version_config.get('system_prompt', '') + agentpress_tools = version_config.get('tools', {}).get('agentpress', {}) + configured_mcps = version_config.get('tools', {}).get('mcp', []) + custom_mcps = version_config.get('tools', {}).get('custom_mcp', []) + logger.info(f"Using VERSION config for template creation from agent {agent_id}") + else: + # Fallback to legacy columns if no version data + system_prompt = agent.get('system_prompt', '') + agentpress_tools = agent.get('agentpress_tools', {}) + configured_mcps = agent.get('configured_mcps', []) + custom_mcps = agent.get('custom_mcps', []) + logger.info(f"Using LEGACY config for template creation from agent {agent_id}") + # Extract MCP requirements from agent configuration mcp_requirements = [] # Process configured_mcps (regular MCP servers) - for mcp in agent.get('configured_mcps', []): + for mcp in configured_mcps: if isinstance(mcp, dict) and 'qualifiedName' in mcp: # Extract required config keys from the config config_keys = list(mcp.get('config', {}).keys()) @@ -110,17 +128,37 @@ class TemplateManager: mcp_requirements.append(requirement) # Process custom_mcps (custom MCP servers) - for custom_mcp in agent.get('custom_mcps', []): + for custom_mcp in custom_mcps: if isinstance(custom_mcp, dict) and 'name' in custom_mcp: # Extract required config keys from the config config_keys = list(custom_mcp.get('config', {}).keys()) + # 🔥 FIX: Handle Pipedream MCPs with correct qualified name format + custom_type = custom_mcp.get('customType', custom_mcp.get('type', 'http')) + + if custom_type == 'pipedream': + # For Pipedream MCPs, extract app_slug and use pipedream:{app_slug} format + app_slug = custom_mcp.get('config', {}).get('app_slug') + if not app_slug and 'headers' in custom_mcp.get('config', {}): + app_slug = custom_mcp['config']['headers'].get('x-pd-app-slug') + + if app_slug: + qualified_name = f"pipedream:{app_slug}" + logger.info(f"Using Pipedream qualified name: {qualified_name} for app_slug: {app_slug}") + else: + # Fallback if no app_slug found + qualified_name = f"pipedream:{custom_mcp['name'].lower().replace(' ', '_')}" + logger.warning(f"No app_slug found for Pipedream MCP {custom_mcp['name']}, using fallback: {qualified_name}") + else: + # For other custom MCPs, use the original logic + qualified_name = custom_mcp['name'].lower().replace(' ', '_') + requirement = { - 'qualified_name': custom_mcp['name'].lower().replace(' ', '_'), + 'qualified_name': qualified_name, 'display_name': custom_mcp['name'], 'enabled_tools': custom_mcp.get('enabledTools', []), 'required_config': config_keys, - 'custom_type': custom_mcp.get('type', 'http') # Default to http + 'custom_type': custom_type } mcp_requirements.append(requirement) @@ -130,14 +168,14 @@ class TemplateManager: is_kortix_team = creator_id in kortix_team_account_ids - # Create the template + # Create the template using version data template_data = { 'creator_id': creator_id, 'name': agent['name'], 'description': agent.get('description'), - 'system_prompt': agent['system_prompt'], + 'system_prompt': system_prompt, # 🔥 From version 'mcp_requirements': mcp_requirements, - 'agentpress_tools': agent.get('agentpress_tools', {}), + 'agentpress_tools': agentpress_tools, # 🔥 From version 'tags': tags or [], 'is_public': make_public, 'is_kortix_team': is_kortix_team, @@ -146,7 +184,7 @@ class TemplateManager: 'metadata': { 'source_agent_id': agent_id, 'source_version_id': agent.get('current_version_id'), - 'source_version_name': agent.get('current_version', {}).get('version_name', 'v1.0') + 'source_version_name': version_data.get('version_name', 'v1') if version_data else 'v1' } } @@ -159,7 +197,7 @@ class TemplateManager: raise ValueError("Failed to create template") template_id = result.data[0]['template_id'] - logger.info(f"Successfully created template {template_id} from agent {agent_id} with is_kortix_team={is_kortix_team}") + logger.info(f"Successfully created template {template_id} from agent {agent_id} version {version_data.get('version_name', 'v1') if version_data else 'legacy'} with is_kortix_team={is_kortix_team}") return template_id @@ -223,53 +261,33 @@ class TemplateManager: profile_mappings: Optional[Dict[str, str]] = None, custom_mcp_configs: Optional[Dict[str, Dict[str, Any]]] = None ) -> Dict[str, Any]: - """ - Install a template as an agent instance for a user - - Args: - template_id: ID of the template to install - account_id: ID of the user installing the template - instance_name: Optional custom name for the instance - custom_system_prompt: Optional custom system prompt override - profile_mappings: Optional dict mapping qualified_name to profile_id - custom_mcp_configs: Optional dict mapping qualified_name to config for custom MCPs - - Returns: - Dictionary with installation result and any missing credentials - """ logger.info(f"Installing template {template_id} for user {account_id}") try: - # Get the template template = await self.get_template(template_id) if not template: raise ValueError("Template not found") - # Check if template is accessible if not template.is_public: - # Check if user owns the template if template.creator_id != account_id: raise ValueError("Access denied to private template") - # Debug: Log template requirements - logger.info(f"Template MCP requirements: {[(req.qualified_name, req.display_name, getattr(req, 'custom_type', None)) for req in template.mcp_requirements]}") - # Separate custom and regular MCP requirements custom_requirements = [req for req in template.mcp_requirements if getattr(req, 'custom_type', None)] regular_requirements = [req for req in template.mcp_requirements if not getattr(req, 'custom_type', None)] - - # If no profile mappings provided, try to use default profiles if not profile_mappings and regular_requirements: profile_mappings = {} for req in regular_requirements: - # Get default profile for this MCP service default_profile = await credential_manager.get_default_credential_profile( account_id, req.qualified_name ) + if default_profile: profile_mappings[req.qualified_name] = default_profile.profile_id + logger.info(f"✅ Mapped {req.qualified_name} -> {default_profile.profile_id} ({default_profile.display_name})") + else: + logger.warning(f"❌ No profile found for {req.qualified_name} after robust lookup") - # Check for missing profile mappings for regular requirements missing_profile_mappings = [] if regular_requirements: provided_mappings = profile_mappings or {} @@ -281,7 +299,6 @@ class TemplateManager: 'required_config': req.required_config }) - # Check for missing custom MCP configs missing_custom_configs = [] if custom_requirements: provided_custom_configs = custom_mcp_configs or {} @@ -294,7 +311,6 @@ class TemplateManager: 'required_config': req.required_config }) - # If we have any missing profile mappings or configs, return them if missing_profile_mappings or missing_custom_configs: return { 'status': 'configs_required', @@ -307,10 +323,8 @@ class TemplateManager: } } - # Create regular agent with secure credentials client = await db.client - - # Build configured_mcps and custom_mcps with user's credential profiles + configured_mcps = [] custom_mcps = [] @@ -318,32 +332,55 @@ class TemplateManager: logger.info(f"Processing requirement: {req.qualified_name}, custom_type: {getattr(req, 'custom_type', None)}") if hasattr(req, 'custom_type') and req.custom_type: - # For custom MCP servers, use the provided config from installation if custom_mcp_configs and req.qualified_name in custom_mcp_configs: provided_config = custom_mcp_configs[req.qualified_name] + if req.custom_type == 'pipedream': + profile_id = provided_config.get('profile_id') + if profile_id: + logger.info(f"Looking up Pipedream profile {profile_id} for {req.qualified_name}") + try: + from pipedream.profiles import get_profile_manager + profile_manager = get_profile_manager(db) + profile = await profile_manager.get_profile(account_id, profile_id) + + if profile: + actual_config = { + 'app_slug': profile.app_slug, + 'profile_id': profile_id, + 'url': 'https://remote.mcp.pipedream.net', + 'headers': { + 'x-pd-app-slug': profile.app_slug, + } + } + logger.info(f"Found Pipedream profile: {profile.app_name} ({profile.profile_name})") + provided_config = actual_config + else: + logger.error(f"Pipedream profile {profile_id} not found for {req.qualified_name}") + raise ValueError(f"Pipedream profile not found for {req.display_name}") + except Exception as e: + logger.error(f"Error looking up Pipedream profile {profile_id}: {e}") + raise ValueError(f"Failed to lookup Pipedream profile for {req.display_name}") custom_mcp_config = { 'name': req.display_name, 'type': req.custom_type, + 'customType': req.custom_type, 'config': provided_config, 'enabledTools': req.enabled_tools } custom_mcps.append(custom_mcp_config) - logger.info(f"Added custom MCP with provided config: {custom_mcp_config}") + logger.info(f"Added custom MCP with config: {custom_mcp_config}") else: logger.warning(f"No custom config provided for {req.qualified_name}") continue else: - # For regular MCP servers, use the selected credential profile if profile_mappings and req.qualified_name in profile_mappings: profile_id = profile_mappings[req.qualified_name] - # Validate profile_id is not empty if not profile_id or profile_id.strip() == '': logger.error(f"Empty profile_id provided for {req.qualified_name}") raise ValueError(f"Invalid credential profile selected for {req.display_name}") - # Get the credential profile profile = await credential_manager.get_credential_by_profile( account_id, profile_id ) @@ -352,20 +389,21 @@ class TemplateManager: logger.error(f"Credential profile {profile_id} not found for {req.qualified_name}") raise ValueError(f"Credential profile not found for {req.display_name}. Please select a valid profile or create a new one.") - # Validate profile is active if not profile.is_active: logger.error(f"Credential profile {profile_id} is inactive for {req.qualified_name}") raise ValueError(f"Selected credential profile for {req.display_name} is inactive. Please select an active profile.") + mcp_config = { 'name': req.display_name, - 'qualifiedName': req.qualified_name, + 'qualifiedName': profile.mcp_qualified_name, # Use profile's qualified name! 'config': profile.config, 'enabledTools': req.enabled_tools, 'selectedProfileId': profile_id } configured_mcps.append(mcp_config) logger.info(f"Added regular MCP with profile: {mcp_config}") + logger.info(f"Used qualified name from profile: {profile.mcp_qualified_name} (template had: {req.qualified_name})") else: logger.error(f"No profile mapping provided for {req.qualified_name}") raise ValueError(f"Missing credential profile for {req.display_name}. Please select a credential profile.") @@ -373,17 +411,33 @@ class TemplateManager: logger.info(f"Final configured_mcps: {configured_mcps}") logger.info(f"Final custom_mcps: {custom_mcps}") + # 🔥 FIX: Build unified config structure like in agent creation + from agent.config_helper import build_unified_config + + system_prompt = custom_system_prompt or template.system_prompt + unified_config = build_unified_config( + system_prompt=system_prompt, + agentpress_tools=template.agentpress_tools, + configured_mcps=configured_mcps, + custom_mcps=custom_mcps, + avatar=template.avatar, + avatar_color=template.avatar_color + ) + agent_data = { 'account_id': account_id, 'name': instance_name or f"{template.name} (from marketplace)", 'description': template.description, - 'system_prompt': custom_system_prompt or template.system_prompt, + 'config': unified_config, + # Keep legacy columns for backward compatibility + 'system_prompt': system_prompt, 'configured_mcps': configured_mcps, 'custom_mcps': custom_mcps, 'agentpress_tools': template.agentpress_tools, 'is_default': False, 'avatar': template.avatar, - 'avatar_color': template.avatar_color + 'avatar_color': template.avatar_color, + 'version_count': 1 } result = await client.table('agents').insert(agent_data).execute() @@ -398,10 +452,12 @@ class TemplateManager: "agent_id": instance_id, "version_number": 1, "version_name": "v1", - "system_prompt": agent_data['system_prompt'], - "configured_mcps": agent_data['configured_mcps'], - "custom_mcps": agent_data['custom_mcps'], - "agentpress_tools": agent_data['agentpress_tools'], + "config": unified_config, + # Keep legacy columns for backward compatibility + "system_prompt": system_prompt, + "configured_mcps": configured_mcps, + "custom_mcps": custom_mcps, + "agentpress_tools": template.agentpress_tools, "is_active": True, "created_by": account_id } @@ -411,8 +467,7 @@ class TemplateManager: if version_result.data: version_id = version_result.data[0]['version_id'] await client.table('agents').update({ - 'current_version_id': version_id, - 'version_count': 1 + 'current_version_id': version_id }).eq('agent_id', instance_id).execute() logger.info(f"Created initial version v1 for installed agent {instance_id}") else: @@ -527,14 +582,14 @@ class TemplateManager: } custom_mcps.append(custom_mcp_config) else: - # Build regular MCP config mcp_config = { 'name': req.display_name, - 'qualifiedName': req.qualified_name, + 'qualifiedName': credential.mcp_qualified_name, # Use credential's qualified name! 'config': credential.config, 'enabledTools': req.enabled_tools } configured_mcps.append(mcp_config) + logger.info(f"Runtime config - Used qualified name from credential: {credential.mcp_qualified_name} (template had: {req.qualified_name})") # Build complete agent config agent_config = { diff --git a/backend/pipedream/api.py b/backend/pipedream/api.py index 838963b2..21944b51 100644 --- a/backend/pipedream/api.py +++ b/backend/pipedream/api.py @@ -90,9 +90,15 @@ async def create_connection_token( ): logger.info(f"Creating Pipedream connection token for user: {user_id}, app: {request.app}") + # 🔥 FIX: Strip pipedream: prefix if present + actual_app = request.app + if request.app and request.app.startswith("pipedream:"): + actual_app = request.app[len("pipedream:"):] + logger.info(f"Stripped pipedream prefix: {request.app} -> {actual_app}") + try: client = get_pipedream_client() - result = await client.create_connection_token(user_id, request.app) + result = await client.create_connection_token(user_id, actual_app) logger.info(f"Successfully created connection token for user: {user_id}") return ConnectionTokenResponse( @@ -100,7 +106,7 @@ async def create_connection_token( link=result.get("connect_link_url"), token=result.get("token"), external_user_id=user_id, - app=request.app, + app=request.app, # Return original app name that was requested expires_at=result.get("expires_at") ) @@ -141,11 +147,17 @@ async def discover_mcp_servers( ): logger.info(f"Discovering MCP servers for user: {user_id}, app: {request.app_slug}") + # 🔥 FIX: Strip pipedream: prefix if present + actual_app_slug = request.app_slug + if request.app_slug and request.app_slug.startswith("pipedream:"): + actual_app_slug = request.app_slug[len("pipedream:"):] + logger.info(f"Stripped pipedream prefix: {request.app_slug} -> {actual_app_slug}") + try: client = get_pipedream_client() mcp_servers = await client.discover_mcp_servers( external_user_id=user_id, - app_slug=request.app_slug, + app_slug=actual_app_slug, oauth_app_id=request.oauth_app_id ) @@ -171,11 +183,17 @@ async def discover_mcp_servers_for_profile( """Discover MCP servers for a specific profile's external_user_id""" logger.info(f"Discovering MCP servers for external_user_id: {request.external_user_id}, app: {request.app_slug}") + # 🔥 FIX: Strip pipedream: prefix if present + actual_app_slug = request.app_slug + if request.app_slug and request.app_slug.startswith("pipedream:"): + actual_app_slug = request.app_slug[len("pipedream:"):] + logger.info(f"Stripped pipedream prefix: {request.app_slug} -> {actual_app_slug}") + try: client = get_pipedream_client() mcp_servers = await client.discover_mcp_servers( external_user_id=request.external_user_id, - app_slug=request.app_slug, + app_slug=actual_app_slug, oauth_app_id=request.oauth_app_id ) @@ -199,11 +217,18 @@ async def create_mcp_connection( user_id: str = Depends(get_current_user_id_from_jwt) ): logger.info(f"Creating MCP connection for user: {user_id}, app: {request.app_slug}") + + # 🔥 FIX: Strip pipedream: prefix if present + actual_app_slug = request.app_slug + if request.app_slug and request.app_slug.startswith("pipedream:"): + actual_app_slug = request.app_slug[len("pipedream:"):] + logger.info(f"Stripped pipedream prefix: {request.app_slug} -> {actual_app_slug}") + try: client = get_pipedream_client() mcp_config = await client.create_mcp_connection( external_user_id=user_id, - app_slug=request.app_slug, + app_slug=actual_app_slug, oauth_app_id=request.oauth_app_id ) logger.info(f"Successfully created MCP connection for user: {user_id}, app: {request.app_slug}") @@ -439,6 +464,11 @@ async def get_credential_profiles( user_id: str = Depends(get_current_user_id_from_jwt) ): logger.info(f"Getting credential profiles for user: {user_id}, app: {app_slug}") + + actual_app_slug = app_slug + if app_slug and app_slug.startswith("pipedream:"): + actual_app_slug = app_slug[len("pipedream:"):] + logger.info(f"Stripped pipedream prefix: {app_slug} -> {actual_app_slug}") try: profile_manager = get_profile_manager(db) @@ -537,12 +567,14 @@ async def connect_credential_profile( app: Optional[str] = Query(None), user_id: str = Depends(get_current_user_id_from_jwt) ): - """Generate connection token for a specific credential profile""" - logger.info(f"Connecting credential profile: {profile_id} for user: {user_id}") + actual_app = app + if app and app.startswith("pipedream:"): + actual_app = app[len("pipedream:"):] + logger.info(f"Stripped pipedream prefix: {app} -> {actual_app}") try: profile_manager = get_profile_manager(db) - result = await profile_manager.connect_profile(user_id, profile_id, app) + result = await profile_manager.connect_profile(user_id, profile_id, actual_app) logger.info(f"Successfully generated connection token for profile: {profile_id}") return result @@ -561,7 +593,6 @@ async def get_profile_connections( profile_id: str, user_id: str = Depends(get_current_user_id_from_jwt) ): - """Get connections for a specific credential profile""" logger.info(f"Getting connections for profile: {profile_id}, user: {user_id}") try: @@ -583,3 +614,85 @@ async def get_profile_connections( status_code=500, detail=f"Failed to get profile connections: {str(e)}" ) + +POPULAR_APP_SLUGS = [ + "gmail", + 'google_calendar', + 'google_drive', + 'google_docs', + 'google_sheets', + 'google_slides', + 'google_forms', + 'google_meet', + "slack", + "discord", + "github", + "google_sheets", + "notion", + "airtable", + "telegram_bot_api", + "openai", + 'linear', + 'asana', + 'supabase' +] + +@router.get("/apps/popular", response_model=Dict[str, Any]) +async def get_popular_pipedream_apps(): + logger.info("Fetching popular Pipedream apps") + + try: + client = get_pipedream_client() + access_token = await client._obtain_access_token() + await client._ensure_rate_limit_token() + + popular_apps = [] + for app_slug in POPULAR_APP_SLUGS: + try: + url = f"https://api.pipedream.com/v1/apps" + params = {"q": app_slug} + + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json" + } + + response = await client._make_request_with_retry( + "GET", + url, + headers=headers, + params=params + ) + + data = response.json() + apps = data.get("data", []) + + exact_match = next((app for app in apps if app.get("name_slug") == app_slug), None) + if exact_match: + popular_apps.append(exact_match) + logger.debug(f"Found popular app: {app_slug}") + else: + logger.warning(f"Popular app not found: {app_slug}") + + except Exception as e: + logger.warning(f"Error fetching popular app {app_slug}: {e}") + continue + + logger.info(f"Successfully fetched {len(popular_apps)} popular apps") + + return { + "success": True, + "apps": popular_apps, + "page_info": { + "total_count": len(popular_apps), + "count": len(popular_apps), + "has_more": False + } + } + + except Exception as e: + logger.error(f"Failed to fetch popular Pipedream apps: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Failed to fetch popular Pipedream apps: {str(e)}" + ) diff --git a/backend/pipedream/client.py b/backend/pipedream/client.py index 7624ace0..127aeff2 100644 --- a/backend/pipedream/client.py +++ b/backend/pipedream/client.py @@ -65,7 +65,7 @@ class PipedreamClient: ) return self.session - async def _obtain_rate_limit_token(self, window_size_seconds: int = 10, requests_per_window: int = 1000) -> str: + async def _obtain_rate_limit_token(self, window_size_seconds: int = 5, requests_per_window: int = 100000) -> str: """Obtain a rate limit token from Pipedream to bypass rate limits""" if self.rate_limit_token: logger.debug(f"Using existing rate limit token: {self.rate_limit_token[:20]}...") @@ -196,7 +196,7 @@ class PipedreamClient: logger.error(f"Error obtaining access token: {str(e)}") raise - async def refresh_rate_limit_token(self, window_size_seconds: int = 10, requests_per_window: int = 1000) -> str: + async def refresh_rate_limit_token(self, window_size_seconds: int = 10, requests_per_window: int = 100000) -> str: """Manually refresh the rate limit token with custom parameters""" self.rate_limit_token = None # Clear existing token return await self._obtain_rate_limit_token(window_size_seconds, requests_per_window) diff --git a/frontend/src/app/(dashboard)/agents/config/[agentId]/page.tsx b/frontend/src/app/(dashboard)/agents/config/[agentId]/page.tsx index 03be9f4b..18f8557f 100644 --- a/frontend/src/app/(dashboard)/agents/config/[agentId]/page.tsx +++ b/frontend/src/app/(dashboard)/agents/config/[agentId]/page.tsx @@ -1,15 +1,17 @@ 'use client'; -import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { Loader2, Settings2, Sparkles, Check, Clock, Eye, Menu, Zap, Brain, Workflow } from 'lucide-react'; +import { Loader2, Save, Eye, Check, Wrench, Server, BookOpen, Workflow, Zap } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; import { Badge } from '@/components/ui/badge'; import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger, DrawerClose } from '@/components/ui/drawer'; +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { useAgent, useUpdateAgent } from '@/hooks/react-query/agents/use-agents'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { useUpdateAgent } from '@/hooks/react-query/agents/use-agents'; +import { useCreateAgentVersion, useActivateAgentVersion } from '@/hooks/react-query/agents/use-agent-versions'; import { AgentMCPConfiguration } from '../../../../../components/agents/agent-mcp-configuration'; import { toast } from 'sonner'; import { AgentToolsConfiguration } from '../../../../../components/agents/agent-tools-configuration'; @@ -18,27 +20,43 @@ import { getAgentAvatar } from '../../../../../lib/utils/get-agent-style'; import { EditableText } from '@/components/ui/editable'; import { ExpandableMarkdownEditor } from '@/components/ui/expandable-markdown-editor'; import { StylePicker } from '../../../../../components/agents/style-picker'; -import { useSidebar } from '@/components/ui/sidebar'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { AgentBuilderChat } from '../../../../../components/agents/agent-builder-chat'; import { AgentTriggersConfiguration } from '@/components/agents/triggers/agent-triggers-configuration'; import { AgentKnowledgeBaseManager } from '@/components/agents/knowledge-base/agent-knowledge-base-manager'; import { AgentWorkflowsConfiguration } from '@/components/agents/workflows/agent-workflows-configuration'; +import { AgentVersionSwitcher } from '@/components/agents/agent-version-switcher'; +import { CreateVersionButton } from '@/components/agents/create-version-button'; +import { VersionComparison } from '../../../../../components/agents/version-comparison'; +import { useAgentVersionData } from '../../../../../hooks/use-agent-version-data'; +import { useAgentVersionStore } from '../../../../../lib/stores/agent-version-store'; -type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; +interface FormData { + name: string; + description: string; + system_prompt: string; + agentpress_tools: any; + configured_mcps: any[]; + custom_mcps: any[]; + is_default: boolean; + avatar: string; + avatar_color: string; +} -export default function AgentConfigurationPage() { +export default function AgentConfigurationPageRefactored() { const params = useParams(); const router = useRouter(); const agentId = params.agentId as string; - const { data: agent, isLoading, error } = useAgent(agentId); + // Use modular hooks + const { agent, versionData, isViewingOldVersion, isLoading, error } = useAgentVersionData({ agentId }); + const { hasUnsavedChanges, setHasUnsavedChanges } = useAgentVersionStore(); + const updateAgentMutation = useUpdateAgent(); - const { state, setOpen, setOpenMobile } = useSidebar(); + const createVersionMutation = useCreateAgentVersion(); + const activateVersionMutation = useActivateAgentVersion(); - const initialLayoutAppliedRef = useRef(false); - - const [formData, setFormData] = useState({ + // State management + const [formData, setFormData] = useState({ name: '', description: '', system_prompt: '', @@ -50,477 +68,488 @@ export default function AgentConfigurationPage() { avatar_color: '', }); - const originalDataRef = useRef(null); - const currentFormDataRef = useRef(formData); - const [saveStatus, setSaveStatus] = useState('idle'); - const debounceTimerRef = useRef(null); + const [originalData, setOriginalData] = useState(formData); + const [isSaving, setIsSaving] = useState(false); const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [isComparisonOpen, setIsComparisonOpen] = useState(false); const [activeTab, setActiveTab] = useState('agent-builder'); - const accordionRef = useRef(null); + + // Initialize form data from agent/version data + useEffect(() => { + if (!agent) return; + + // Determine the source of configuration data + let configSource = agent; + + // If we have explicit version data (from URL param), use it + if (versionData) { + configSource = versionData; + } + // If no URL param but agent has current_version data, use that + else if (agent.current_version) { + configSource = agent.current_version; + } + + const initialData: FormData = { + name: agent.name || '', + description: agent.description || '', + system_prompt: configSource.system_prompt || '', + agentpress_tools: configSource.agentpress_tools || {}, + configured_mcps: configSource.configured_mcps || [], + custom_mcps: configSource.custom_mcps || [], + is_default: agent.is_default || false, + avatar: agent.avatar || '', + avatar_color: agent.avatar_color || '', + }; + + setFormData(initialData); + setOriginalData(initialData); + }, [agent, versionData]); useEffect(() => { - if (!initialLayoutAppliedRef.current) { - setOpen(false); - initialLayoutAppliedRef.current = true; - } - }, [setOpen]); + const hasChanges = JSON.stringify(formData) !== JSON.stringify(originalData); + setHasUnsavedChanges(hasChanges); + }, [formData, originalData, setHasUnsavedChanges]); - useEffect(() => { - if (agent) { - const agentData = agent as any; - const initialData = { - name: agentData.name || '', - description: agentData.description || '', - system_prompt: agentData.system_prompt || '', - agentpress_tools: agentData.agentpress_tools || {}, - configured_mcps: agentData.configured_mcps || [], - custom_mcps: agentData.custom_mcps || [], - is_default: agentData.is_default || false, - avatar: agentData.avatar || '', - avatar_color: agentData.avatar_color || '', - }; - setFormData(initialData); - originalDataRef.current = { ...initialData }; - } - }, [agent]); - - - useEffect(() => { - if (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes('Access denied') || errorMessage.includes('403')) { - toast.error('You don\'t have permission to edit this agent'); - router.push('/agents'); - return; - } - } - }, [error, router]); - - useEffect(() => { - currentFormDataRef.current = formData; - }, [formData]); - - const hasDataChanged = useCallback((newData: typeof formData, originalData: typeof formData | null): boolean => { - if (!originalData) return true; - if (newData.name !== originalData.name || - newData.description !== originalData.description || - newData.system_prompt !== originalData.system_prompt || - newData.is_default !== originalData.is_default || - newData.avatar !== originalData.avatar || - newData.avatar_color !== originalData.avatar_color) { - return true; - } - if (JSON.stringify(newData.agentpress_tools) !== JSON.stringify(originalData.agentpress_tools) || - JSON.stringify(newData.configured_mcps) !== JSON.stringify(originalData.configured_mcps) || - JSON.stringify(newData.custom_mcps) !== JSON.stringify(originalData.custom_mcps)) { - return true; - } - return false; - }, []); - - const saveAgent = useCallback(async (data: typeof formData) => { + const handleSave = useCallback(async () => { + if (!agent || isViewingOldVersion) return; + + setIsSaving(true); try { - setSaveStatus('saving'); + const normalizedCustomMcps = (formData.custom_mcps || []).map(mcp => ({ + name: mcp.name || 'Unnamed MCP', + type: mcp.type || mcp.customType || 'sse', + config: mcp.config || {}, + enabledTools: Array.isArray(mcp.enabledTools) ? mcp.enabledTools : [], + })); + await createVersionMutation.mutateAsync({ + agentId, + data: { + system_prompt: formData.system_prompt, + configured_mcps: formData.configured_mcps, + custom_mcps: normalizedCustomMcps, + agentpress_tools: formData.agentpress_tools, + description: 'Manual save' + } + }); await updateAgentMutation.mutateAsync({ agentId, - ...data + name: formData.name, + description: formData.description, + is_default: formData.is_default, + avatar: formData.avatar, + avatar_color: formData.avatar_color }); - originalDataRef.current = { ...data }; - setSaveStatus('saved'); - setTimeout(() => setSaveStatus('idle'), 2000); + + setOriginalData(formData); + toast.success('Changes saved successfully'); } catch (error) { - console.error('Error updating agent:', error); - setSaveStatus('error'); - toast.error('Failed to update agent'); - setTimeout(() => setSaveStatus('idle'), 3000); + console.error('Save error:', error); + toast.error('Failed to save changes'); + } finally { + setIsSaving(false); } - }, [agentId, updateAgentMutation]); + }, [agent, formData, isViewingOldVersion, agentId, createVersionMutation, updateAgentMutation]); - const debouncedSave = useCallback((data: typeof formData) => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - if (!hasDataChanged(data, originalDataRef.current)) { + const handleFieldChange = useCallback((field: keyof FormData, value: any) => { + if (isViewingOldVersion) { + toast.error('Cannot edit old versions. Please activate this version first to make changes.'); return; } - const timer = setTimeout(() => { - if (hasDataChanged(data, originalDataRef.current)) { - saveAgent(data); - } - }, 500); - - debounceTimerRef.current = timer; - }, [saveAgent, hasDataChanged]); + setFormData(prev => ({ ...prev, [field]: value })); + }, [isViewingOldVersion]); - const handleFieldChange = useCallback((field: string, value: any) => { - const newFormData = { - ...currentFormDataRef.current, - [field]: value - }; - - setFormData(newFormData); - debouncedSave(newFormData); - }, [debouncedSave]); - - const handleBatchMCPChange = useCallback((updates: { configured_mcps: any[]; custom_mcps: any[] }) => { - const newFormData = { - ...currentFormDataRef.current, + const handleMCPChange = useCallback((updates: { configured_mcps: any[]; custom_mcps: any[] }) => { + if (isViewingOldVersion) { + toast.error('Cannot edit old versions. Please activate this version first to make changes.'); + return; + } + setFormData(prev => ({ + ...prev, configured_mcps: updates.configured_mcps, custom_mcps: updates.custom_mcps - }; - - setFormData(newFormData); - debouncedSave(newFormData); - }, [debouncedSave]); - - const scrollToAccordion = useCallback(() => { - if (accordionRef.current) { - accordionRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'end' - }); - } - }, []); + })); + }, [isViewingOldVersion]); const handleStyleChange = useCallback((emoji: string, color: string) => { - const newFormData = { - ...currentFormDataRef.current, + if (isViewingOldVersion) { + toast.error('Cannot edit old versions. Please activate this version first to make changes.'); + return; + } + setFormData(prev => ({ + ...prev, avatar: emoji, - avatar_color: color, - }; - setFormData(newFormData); - debouncedSave(newFormData); - }, [debouncedSave]); + avatar_color: color + })); + }, [isViewingOldVersion]); - const currentStyle = useMemo(() => { - if (formData.avatar && formData.avatar_color) { - return { - avatar: formData.avatar, - color: formData.avatar_color, - }; + // Version activation handler + const handleActivateVersion = useCallback(async (versionId: string) => { + try { + await activateVersionMutation.mutateAsync({ agentId, versionId }); + toast.success('Version activated successfully'); + // Refresh page without version param + router.push(`/agents/config/${agentId}`); + } catch (error) { + toast.error('Failed to activate version'); } - return getAgentAvatar(agentId); - }, [formData.avatar, formData.avatar_color, agentId]); + }, [agentId, activateVersionMutation, router]); - const memoizedAgentBuilderChat = useMemo(() => ( - - ), [agentId, formData, handleFieldChange, handleStyleChange, currentStyle]); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const getSaveStatusBadge = () => { - const showSaved = saveStatus === 'idle' && !hasDataChanged(formData, originalDataRef.current); - switch (saveStatus) { - case 'saving': - return ( - - - Saving... - - ); - case 'saved': - return ( - - - Saved - - ); - case 'error': - return ( - - Error saving - - ); - - default: - return showSaved ? ( - - - Saved - - ) : ( - - Error saving - - ); + // Auto-switch to configuration tab when viewing old versions + useEffect(() => { + if (isViewingOldVersion && activeTab === 'agent-builder') { + setActiveTab('configuration'); } - }; + }, [isViewingOldVersion, activeTab]); - const ConfigurationContent = useMemo(() => { + if (error) { return ( -
-
-
- - - - - Open menu - -
- {getSaveStatusBadge()} -
-
- - - - - - - Agent Preview - -
- -
-
-
-
- - -
-
- - - Prompt to configure - - Config - -
-
- -
-
- {getSaveStatusBadge()} -
-
- -
- {currentStyle.avatar} -
-
-
- handleFieldChange('name', value)} - className="text-lg md:text-xl font-semibold bg-transparent" - placeholder="Click to add agent name..." - /> - handleFieldChange('description', value)} - className="text-muted-foreground text-sm" - placeholder="Click to add description..." - /> -
-
-
-
-
Instructions
-
- handleFieldChange('system_prompt', value)} - placeholder='Click to set system instructions...' - title='System Instructions' - /> -
- -
- - - -
- - Default Tools -
-
- - handleFieldChange('agentpress_tools', tools)} - /> - -
- - - -
- - Integrations & MCPs - New -
-
- - - -
- - - -
- - Triggers - New -
-
- - - -
- - - -
- - Knowledge Base - New -
-
- - - -
- - - -
- - Workflows - New -
-
- - - -
-
-
-
-
- - - {memoizedAgentBuilderChat} - -
+
+ + + {error.message || 'Failed to load agent configuration'} + +
); - }, [ - activeTab, - agentId, - agent, - formData, - currentStyle, - isPreviewOpen, - memoizedAgentBuilderChat, - handleFieldChange, - handleStyleChange, - setOpenMobile, - setIsPreviewOpen, - setActiveTab, - scrollToAccordion, - getSaveStatusBadge, - handleBatchMCPChange - ]); - - useEffect(() => { - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - }; - }, []); + } if (isLoading) { return ( -
-
-
- - Loading agent... -
-
+
+
); } - if (error || !agent) { - const errorMessage = error instanceof Error ? error.message : String(error); - const isAccessDenied = errorMessage.includes('Access denied') || errorMessage.includes('403'); - + if (!agent) { return ( -
-
- {isAccessDenied ? ( - - - You don't have permission to edit this agent. You can only edit agents that you created. - - - ) : ( - <> -

Agent not found

-

The agent you're looking for doesn't exist.

- - )} -
+
+ + Agent not found +
); } + // Use version data when viewing old version, otherwise use form data + const displayData = isViewingOldVersion && versionData ? { + name: agent?.name || '', + description: agent?.description || '', + system_prompt: versionData.system_prompt || '', + agentpress_tools: versionData.agentpress_tools || {}, + configured_mcps: versionData.configured_mcps || [], + custom_mcps: versionData.custom_mcps || [], + is_default: agent?.is_default || false, + avatar: agent?.avatar || '', + avatar_color: agent?.avatar_color || '', + } : formData; + + const currentStyle = displayData.avatar && displayData.avatar_color + ? { avatar: displayData.avatar, color: displayData.avatar_color } + : getAgentAvatar(agentId); + + const previewAgent = { + ...agent, + ...displayData, + agent_id: agentId, + }; + return (
- {ConfigurationContent} +
+
+
+ + { + setOriginalData(formData); + }} + /> +
+
+ {hasUnsavedChanges && !isViewingOldVersion && ( + + )} +
+
+ +
+
+ {isViewingOldVersion && ( + + +
+ + You are viewing a read-only version. To make changes, please activate this version or switch back to the current version. + +
+ {versionData && ( + + {versionData.version_name} + + )} + +
+
+
+ )} +
+ +
+
+
+ +
+
{currentStyle.avatar}
+
+
+ handleFieldChange('name', value)} + className="text-md font-semibold bg-transparent" + placeholder="Click to add agent name..." + disabled={isViewingOldVersion} + /> +
+ + + Agent Builder + + Configuration + +
+
+ + + {isViewingOldVersion ? ( +
+
+
🔒
+
+

Agent Builder Disabled

+

+ The Agent Builder is only available for the current version. + To use the Agent Builder, please activate this version first. +

+
+
+
+ ) : ( + + )} +
+ {activeTab === 'configuration' && ( +
+ handleFieldChange('system_prompt', value)} + placeholder="Click to set system instructions..." + title="System Instructions" + disabled={isViewingOldVersion} + /> +
+ )} + + + + +
+
+ +
+ Tools +
+
+ + handleFieldChange('agentpress_tools', tools)} + /> + +
+ + + +
+
+ +
+ Integrations +
+
+ + + +
+ + + +
+
+ +
+ Knowledge Base +
+
+ + + +
+ + + +
+
+ +
+ Workflows +
+
+ + + +
+ + + +
+
+ +
+ Triggers +
+
+ + + +
+
+
+
+
+
+ + {/* Right Panel - Preview */}
- + {previewAgent && }
-
- {ConfigurationContent} + + {/* Mobile Layout */} +
+ {/* Mobile content similar to desktop but with drawer for preview */} +
+ {/* Similar content structure as desktop */} +
+ + {/* Mobile Preview Drawer */} + + + + + + + Agent Preview + +
+ {previewAgent && } +
+
+
+ + + + setIsComparisonOpen(false)} + onActivateVersion={handleActivateVersion} + /> + +
); -} \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/src/components/agents/AgentVersionManager.tsx b/frontend/src/components/agents/AgentVersionManager.tsx index c55874ed..2944e1c3 100644 --- a/frontend/src/components/agents/AgentVersionManager.tsx +++ b/frontend/src/components/agents/AgentVersionManager.tsx @@ -16,7 +16,7 @@ import { Plus } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; -import { useAgentVersions, useActivateAgentVersion } from '@/hooks/react-query/agents/useAgentVersions'; +import { useAgentVersions, useActivateAgentVersion } from '@/hooks/react-query/agents/use-agent-versions'; import { Agent } from '@/hooks/react-query/agents/utils'; import { cn } from '@/lib/utils'; diff --git a/frontend/src/components/agents/agent-builder-chat.tsx b/frontend/src/components/agents/agent-builder-chat.tsx index fa482849..610204e5 100644 --- a/frontend/src/components/agents/agent-builder-chat.tsx +++ b/frontend/src/components/agents/agent-builder-chat.tsx @@ -389,8 +389,7 @@ export const AgentBuilderChat = React.memo(function AgentBuilderChat({
- -
+
); }, (prevProps, nextProps) => { - // Custom comparison function to prevent unnecessary re-renders + return ( prevProps.agentId === nextProps.agentId && JSON.stringify(prevProps.formData) === JSON.stringify(nextProps.formData) && diff --git a/frontend/src/components/agents/agent-mcp-configuration.tsx b/frontend/src/components/agents/agent-mcp-configuration.tsx index be9ea72f..e670dd19 100644 --- a/frontend/src/components/agents/agent-mcp-configuration.tsx +++ b/frontend/src/components/agents/agent-mcp-configuration.tsx @@ -6,15 +6,21 @@ interface AgentMCPConfigurationProps { customMCPs: any[]; onMCPChange: (updates: { configured_mcps: any[]; custom_mcps: any[] }) => void; agentId?: string; + versionData?: { + configured_mcps?: any[]; + custom_mcps?: any[]; + system_prompt?: string; + agentpress_tools?: any; + }; } export const AgentMCPConfiguration: React.FC = ({ configuredMCPs, customMCPs, onMCPChange, - agentId + agentId, + versionData }) => { - // Combine all MCPs into a single array for the new component const allMCPs = [ ...(configuredMCPs || []), ...(customMCPs || []).map(customMcp => ({ @@ -28,7 +34,6 @@ export const AgentMCPConfiguration: React.FC = ({ ]; const handleConfigurationChange = (mcps: any[]) => { - // Separate back into configured and custom MCPs const configured = mcps.filter(mcp => !mcp.isCustom); const custom = mcps .filter(mcp => mcp.isCustom) @@ -40,7 +45,6 @@ export const AgentMCPConfiguration: React.FC = ({ enabledTools: mcp.enabledTools })); - // Call the parent handler with the proper structure onMCPChange({ configured_mcps: configured, custom_mcps: custom @@ -52,6 +56,7 @@ export const AgentMCPConfiguration: React.FC = ({ configuredMCPs={allMCPs} onConfigurationChange={handleConfigurationChange} agentId={agentId} + versionData={versionData} /> ); }; \ No newline at end of file diff --git a/frontend/src/components/agents/agent-version-switcher.tsx b/frontend/src/components/agents/agent-version-switcher.tsx new file mode 100644 index 00000000..b451a7fe --- /dev/null +++ b/frontend/src/components/agents/agent-version-switcher.tsx @@ -0,0 +1,281 @@ +'use client'; + +import React, { useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { GitBranch, ChevronDown, Clock, RotateCcw, Check, AlertCircle, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { useAgentVersions, useActivateAgentVersion, useCreateAgentVersion } from '@/hooks/react-query/agents/use-agent-versions'; +import { formatDistanceToNow } from 'date-fns'; +import { toast } from 'sonner'; +import { AgentVersion } from '@/hooks/react-query/agents/utils'; + +interface AgentVersionSwitcherProps { + agentId: string; + currentVersionId?: string | null; + currentFormData: { + system_prompt: string; + configured_mcps: any[]; + custom_mcps: any[]; + agentpress_tools: Record; + }; +} + +export function AgentVersionSwitcher({ + agentId, + currentVersionId, + currentFormData +}: AgentVersionSwitcherProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const versionParam = searchParams.get('version'); + + const { data: versions, isLoading } = useAgentVersions(agentId); + const activateVersionMutation = useActivateAgentVersion(); + const createVersionMutation = useCreateAgentVersion(); + + const [showRollbackDialog, setShowRollbackDialog] = useState(false); + const [selectedVersion, setSelectedVersion] = useState(null); + const [isRollingBack, setIsRollingBack] = useState(false); + + const viewingVersionId = versionParam || currentVersionId; + const viewingVersion = versions?.find(v => v.version_id === viewingVersionId) || versions?.[0]; + + const canRollback = viewingVersion && viewingVersion.version_number > 1; + + const handleVersionSelect = async (version: AgentVersion) => { + if (version.version_id === viewingVersionId) return; + const params = new URLSearchParams(searchParams.toString()); + if (version.version_id === currentVersionId) { + params.delete('version'); + } else { + params.set('version', version.version_id); + } + const newUrl = params.toString() ? `?${params.toString()}` : window.location.pathname; + router.push(newUrl); + if (version.version_id !== currentVersionId) { + toast.success(`Viewing ${version.version_name} (read-only)`); + } + }; + + const handleRollback = async () => { + if (!selectedVersion || !viewingVersion) return; + + setIsRollingBack(true); + try { + const newVersion = await createVersionMutation.mutateAsync({ + agentId, + data: { + system_prompt: selectedVersion.system_prompt, + configured_mcps: selectedVersion.configured_mcps, + custom_mcps: selectedVersion.custom_mcps, + agentpress_tools: selectedVersion.agentpress_tools, + description: `Rolled back from ${viewingVersion.version_name} to ${selectedVersion.version_name}` + } + }); + await activateVersionMutation.mutateAsync({ + agentId, + versionId: newVersion.version_id + }); + + const params = new URLSearchParams(searchParams.toString()); + params.delete('version'); + const newUrl = params.toString() ? `?${params.toString()}` : window.location.pathname; + router.push(newUrl); + + setShowRollbackDialog(false); + toast.success(`Rolled back to ${selectedVersion.version_name} configuration`); + } catch (error) { + console.error('Failed to rollback:', error); + toast.error('Failed to rollback version'); + } finally { + setIsRollingBack(false); + } + }; + + const openRollbackDialog = (version: AgentVersion) => { + setSelectedVersion(version); + setShowRollbackDialog(true); + }; + + if (isLoading) { + return ( +
+ + Loading versions... +
+ ); + } + + if (!versions || versions.length === 0) { + return null; + } + + return ( + <> + + + + + + Version History + + +
+ {versions.map((version) => { + const isViewing = version.version_id === viewingVersionId; + const isCurrent = version.version_id === currentVersionId; + + return ( +
+ handleVersionSelect(version)} + className={`cursor-pointer ${isViewing ? 'bg-accent' : ''}`} + > +
+
+
+ {version.version_name} + {isCurrent && ( + + Current + + )} + {isViewing && !isCurrent && ( + + Viewing + + )} +
+
+ + + {formatDistanceToNow(new Date(version.created_at), { addSuffix: true })} + +
+ {version.change_description && ( +

+ {version.change_description} +

+ )} +
+ + {isViewing && ( + + )} +
+
+ + {!isViewing && version.version_number < (viewingVersion?.version_number || 0) && canRollback && ( + + )} +
+ ); + })} +
+ + {versions.length === 1 && ( +
+ + + + This is the first version. Make changes to create a new version. + + +
+ )} +
+
+ + {/* Rollback Confirmation Dialog */} + + + + Rollback to {selectedVersion?.version_name} + + This will create a new version with the configuration from {selectedVersion?.version_name}. + Your current changes will be preserved in the current version. + + + +
+ + + + Note: This action will create a new version (v{(versions?.[0]?.version_number || 0) + 1}) + with the selected configuration. You can always switch back to any previous version. + + +
+ + + + + +
+
+ + ); +} \ No newline at end of file diff --git a/frontend/src/components/agents/create-version-button.tsx b/frontend/src/components/agents/create-version-button.tsx new file mode 100644 index 00000000..99e16a6e --- /dev/null +++ b/frontend/src/components/agents/create-version-button.tsx @@ -0,0 +1,125 @@ +'use client'; + +import React, { useState } from 'react'; +import { Save, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { useCreateAgentVersion } from '@/hooks/react-query/agents/use-agent-versions'; +import { toast } from 'sonner'; + +interface CreateVersionButtonProps { + agentId: string; + currentFormData: { + system_prompt: string; + configured_mcps: any[]; + custom_mcps: any[]; + agentpress_tools: Record; + }; + hasChanges: boolean; + onVersionCreated?: () => void; +} + +export function CreateVersionButton({ + agentId, + currentFormData, + hasChanges, + onVersionCreated +}: CreateVersionButtonProps) { + const [showDialog, setShowDialog] = useState(false); + const [versionName, setVersionName] = useState(''); + const [description, setDescription] = useState(''); + const createVersionMutation = useCreateAgentVersion(); + + const handleCreateVersion = async () => { + if (!versionName.trim()) { + toast.error('Please provide a version name'); + return; + } + + try { + await createVersionMutation.mutateAsync({ + agentId, + data: { + system_prompt: currentFormData.system_prompt, + configured_mcps: currentFormData.configured_mcps, + custom_mcps: currentFormData.custom_mcps, + agentpress_tools: currentFormData.agentpress_tools, + version_name: versionName.trim(), + description: description.trim() || undefined, + } + }); + + setShowDialog(false); + setVersionName(''); + setDescription(''); + + if (onVersionCreated) { + onVersionCreated(); + } + + toast.success('New version created successfully'); + } catch (error) { + console.error('Failed to create version:', error); + toast.error('Failed to create version'); + } + }; + + return ( + <> + + + + Create New Version + + Save the current agent configuration as a new version. This allows you to preserve different configurations and switch between them. + + + +
+
+ + setVersionName(e.target.value)} + autoFocus + /> +
+ +
+ +