diff --git a/README.md b/README.md index ccfdf6ec..d22ad3ce 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ Suna's powerful toolkit includes seamless browser automation to navigate the web [![License](https://img.shields.io/badge/License-Apache--2.0-blue)](./license) [![Discord Follow](https://dcbadge.limes.pink/api/server/Py6pCBUUPw?style=flat)](https://discord.gg/Py6pCBUUPw) [![Twitter Follow](https://img.shields.io/twitter/follow/kortixai)](https://x.com/kortixai) -[![GitHub Repo stars](https://img.shields.io/github/stars/kortix-ai/agentpress)](https://github.com/kortix-ai/agentpress) -[![Issues](https://img.shields.io/github/issues/kortix-ai/agentpress -)](https://github.com/kortix-ai/agentpress/labels/bug) +[![GitHub Repo stars](https://img.shields.io/github/stars/kortix-ai/suna)](https://github.com/kortix-ai/suna) +[![Issues](https://img.shields.io/github/issues/kortix-ai/suna +)](https://github.com/kortix-ai/suna/labels/bug) @@ -135,13 +135,21 @@ You'll need the following components: 5. **Search API Key** (Optional): - For enhanced search capabilities, obtain an [Exa API key](https://dashboard.exa.ai/playground) + +6. **RapidAPI API Key** (Optional): + - To enable API services like LinkedIn, and others, you'll need a RapidAPI key + - Each service requires individual activation in your RapidAPI account: + 1. Locate the service's `base_url` in its corresponding file (e.g., `"https://linkedin-data-scraper.p.rapidapi.com"` in [`backend/agent/tools/api_services/LinkedInService.py`](backend/agent/tools/api_services/LinkedInService.py)) + 2. Visit that specific API on the RapidAPI marketplace + 3. Subscribe to the service (many offer free tiers with limited requests) + 4. Once subscribed, the service will be available to your agent through the API Services tool ### Installation Steps 1. **Clone the repository**: ```bash -git clone https://github.com/kortix-ai/agentpress.git -cd agentpress +git clone https://github.com/kortix-ai/suna.git +cd suna ``` 2. **Configure backend environment**: @@ -181,6 +189,7 @@ MODEL_TO_USE="gpt-4o" # Optional but recommended EXA_API_KEY=your_exa_api_key # Optional +RAPID_API_KEY= ``` 3. **Set up Supabase database**: diff --git a/backend/agent/run.py b/backend/agent/run.py index 5004c6c1..0cb1146b 100644 --- a/backend/agent/run.py +++ b/backend/agent/run.py @@ -14,10 +14,10 @@ from agentpress.response_processor import ProcessorConfig from agent.tools.sb_shell_tool import SandboxShellTool from agent.tools.sb_files_tool import SandboxFilesTool from agent.tools.sb_browser_tool import SandboxBrowserTool +from agent.tools.api_services_tool import APIServicesTool from agent.prompt import get_system_prompt -from sandbox.sandbox import daytona, create_sandbox, get_or_start_sandbox +from sandbox.sandbox import create_sandbox, get_or_start_sandbox from utils.billing import check_billing_status, get_account_id_from_thread -from utils.db import update_agent_run_status load_dotenv() @@ -72,6 +72,9 @@ async def run_agent(thread_id: str, project_id: str, stream: bool = True, thread if os.getenv("EXA_API_KEY"): thread_manager.add_tool(WebSearchTool) + + if os.getenv("RAPID_API_KEY"): + thread_manager.add_tool(APIServicesTool) xml_examples = "" for tag_name, example in thread_manager.tool_registry.get_xml_examples().items(): diff --git a/backend/agent/tools/api_services/APIServicesBase.py b/backend/agent/tools/api_services/APIServicesBase.py new file mode 100644 index 00000000..7ea9c9b1 --- /dev/null +++ b/backend/agent/tools/api_services/APIServicesBase.py @@ -0,0 +1,61 @@ +import os +import requests +from typing import Dict, Any, Optional, TypedDict, Literal + + +class EndpointSchema(TypedDict): + route: str + method: Literal['GET', 'POST'] + name: str + description: str + payload: Dict[str, Any] + + +class APIServicesBase: + def __init__(self, base_url: str, endpoints: Dict[str, EndpointSchema]): + self.base_url = base_url + self.endpoints = endpoints + + def get_endpoints(self): + return self.endpoints + + def call_endpoint( + self, + route: str, + payload: Optional[Dict[str, Any]] = None + ): + """ + Call an API endpoint with the given parameters and data. + + Args: + endpoint (EndpointSchema): The endpoint configuration dictionary + params (dict, optional): Query parameters for GET requests + payload (dict, optional): JSON payload for POST requests + + Returns: + dict: The JSON response from the API + """ + if route.startswith("/"): + route = route[1:] + + endpoint = self.endpoints.get(route) + if not endpoint: + raise ValueError(f"Endpoint {route} not found") + + url = f"{self.base_url}{endpoint['route']}" + + headers = { + "x-rapidapi-key": os.getenv("RAPID_API_KEY"), + "x-rapidapi-host": url.split("//")[1].split("/")[0], + "Content-Type": "application/json" + } + + method = endpoint.get('method', 'GET').upper() + + if method == 'GET': + response = requests.get(url, params=payload, headers=headers) + elif method == 'POST': + response = requests.post(url, json=payload, headers=headers) + else: + raise ValueError(f"Unsupported HTTP method: {method}") + return response.json() diff --git a/backend/agent/tools/api_services/LinkedInService.py b/backend/agent/tools/api_services/LinkedInService.py new file mode 100644 index 00000000..adb483db --- /dev/null +++ b/backend/agent/tools/api_services/LinkedInService.py @@ -0,0 +1,250 @@ +from typing import Dict + +from agent.tools.api_services.APIServicesBase import APIServicesBase, EndpointSchema + + +class LinkedInService(APIServicesBase): + def __init__(self): + endpoints: Dict[str, EndpointSchema] = { + "person": { + "route": "/person", + "method": "POST", + "name": "Person Data", + "description": "Fetches any Linkedin profiles data including skills, certificates, experiences, qualifications and much more.", + "payload": { + "link": "LinkedIn Profile URL" + } + }, + "person_urn": { + "route": "/person_urn", + "method": "POST", + "name": "Person Data (Using Urn)", + "description": "It takes profile urn instead of profile public identifier in input", + "payload": { + "link": "LinkedIn Profile URL or URN" + } + }, + "person_deep": { + "route": "/person_deep", + "method": "POST", + "name": "Person Data (Deep)", + "description": "Fetches all experiences, educations, skills, languages, publications... related to a profile.", + "payload": { + "link": "LinkedIn Profile URL" + } + }, + "profile_updates": { + "route": "/profile_updates", + "method": "GET", + "name": "Person Posts (WITH PAGINATION)", + "description": "Fetches posts of a linkedin profile alongwith reactions, comments, postLink and reposts data.", + "payload": { + "profile_url": "LinkedIn Profile URL", + "page": "Page number", + "reposts": "Include reposts (1 or 0)", + "comments": "Include comments (1 or 0)" + } + }, + "profile_recent_comments": { + "route": "/profile_recent_comments", + "method": "POST", + "name": "Person Recent Activity (Comments on Posts)", + "description": "Fetches 20 most recent comments posted by a linkedin user (per page).", + "payload": { + "profile_url": "LinkedIn Profile URL", + "page": "Page number", + "paginationToken": "Token for pagination" + } + }, + "comments_from_recent_activity": { + "route": "/comments_from_recent_activity", + "method": "GET", + "name": "Comments from recent activity", + "description": "Fetches recent comments posted by a person as per his recent activity tab.", + "payload": { + "profile_url": "LinkedIn Profile URL", + "page": "Page number" + } + }, + "person_skills": { + "route": "/person_skills", + "method": "POST", + "name": "Person Skills", + "description": "Scraper all skills of a linkedin user", + "payload": { + "link": "LinkedIn Profile URL" + } + }, + "email_to_linkedin_profile": { + "route": "/email_to_linkedin_profile", + "method": "POST", + "name": "Email to LinkedIn Profile", + "description": "Finds LinkedIn profile associated with an email address", + "payload": { + "email": "Email address to search" + } + }, + "company": { + "route": "/company", + "method": "POST", + "name": "Company Data", + "description": "Fetches LinkedIn company profile data", + "payload": { + "link": "LinkedIn Company URL" + } + }, + "web_domain": { + "route": "/web-domain", + "method": "POST", + "name": "Web Domain to Company", + "description": "Fetches LinkedIn company profile data from a web domain", + "payload": { + "link": "Website domain (e.g., huzzle.app)" + } + }, + "similar_profiles": { + "route": "/similar_profiles", + "method": "GET", + "name": "Similar Profiles", + "description": "Fetches profiles similar to a given LinkedIn profile", + "payload": { + "profileUrl": "LinkedIn Profile URL" + } + }, + "company_jobs": { + "route": "/company_jobs", + "method": "POST", + "name": "Company Jobs", + "description": "Fetches job listings from a LinkedIn company page", + "payload": { + "company_url": "LinkedIn Company URL", + "count": "Number of job listings to fetch" + } + }, + "company_updates": { + "route": "/company_updates", + "method": "GET", + "name": "Company Posts", + "description": "Fetches posts from a LinkedIn company page", + "payload": { + "company_url": "LinkedIn Company URL", + "page": "Page number", + "reposts": "Include reposts (0, 1, or 2)", + "comments": "Include comments (0, 1, or 2)" + } + }, + "company_employee": { + "route": "/company_employee", + "method": "GET", + "name": "Company Employees", + "description": "Fetches employees of a LinkedIn company using company ID", + "payload": { + "companyId": "LinkedIn Company ID", + "page": "Page number" + } + }, + "company_updates_post": { + "route": "/company_updates", + "method": "POST", + "name": "Company Posts (POST)", + "description": "Fetches posts from a LinkedIn company page with specific count parameters", + "payload": { + "company_url": "LinkedIn Company URL", + "posts": "Number of posts to fetch", + "comments": "Number of comments to fetch per post", + "reposts": "Number of reposts to fetch" + } + }, + "search_posts_with_filters": { + "route": "/search_posts_with_filters", + "method": "GET", + "name": "Search Posts With Filters", + "description": "Searches LinkedIn posts with various filtering options", + "payload": { + "query": "Keywords/Search terms (text you put in LinkedIn search bar)", + "page": "Page number (1-100, each page contains 20 results)", + "sort_by": "Sort method: 'relevance' (Top match) or 'date_posted' (Latest)", + "author_job_title": "Filter by job title of author (e.g., CEO)", + "content_type": "Type of content post contains (photos, videos, liveVideos, collaborativeArticles, documents)", + "from_member": "URN of person who posted (comma-separated for multiple)", + "from_organization": "ID of organization who posted (comma-separated for multiple)", + "author_company": "ID of company author works for (comma-separated for multiple)", + "author_industry": "URN of industry author is connected with (comma-separated for multiple)", + "mentions_member": "URN of person mentioned in post (comma-separated for multiple)", + "mentions_organization": "ID of organization mentioned in post (comma-separated for multiple)" + } + }, + "search_jobs": { + "route": "/search_jobs", + "method": "GET", + "name": "Search Jobs", + "description": "Searches LinkedIn jobs with various filtering options", + "payload": { + "query": "Job search keywords (e.g., Software developer)", + "page": "Page number", + "searchLocationId": "Location ID for job search (get from Suggestion location endpoint)", + "easyApply": "Filter for easy apply jobs (true or false)", + "experience": "Experience level required (1=Internship, 2=Entry level, 3=Associate, 4=Mid senior, 5=Director, 6=Executive, comma-separated)", + "jobType": "Job type (F=Full time, P=Part time, C=Contract, T=Temporary, V=Volunteer, I=Internship, O=Other, comma-separated)", + "postedAgo": "Time jobs were posted in seconds (e.g., 3600 for past hour)", + "workplaceType": "Workplace type (1=On-Site, 2=Remote, 3=Hybrid, comma-separated)", + "sortBy": "Sort method (DD=most recent, R=most relevant)", + "companyIdsList": "List of company IDs, comma-separated", + "industryIdsList": "List of industry IDs, comma-separated", + "functionIdsList": "List of function IDs, comma-separated", + "titleIdsList": "List of job title IDs, comma-separated", + "locationIdsList": "List of location IDs within specified searchLocationId country, comma-separated" + } + }, + "search_people_with_filters": { + "route": "/search_people_with_filters", + "method": "POST", + "name": "Search People With Filters", + "description": "Searches LinkedIn profiles with detailed filtering options", + "payload": { + "keyword": "General search keyword", + "page": "Page number", + "title_free_text": "Job title to filter by (e.g., CEO)", + "company_free_text": "Company name to filter by", + "first_name": "First name of person", + "last_name": "Last name of person", + "current_company_list": "List of current companies (comma-separated IDs)", + "past_company_list": "List of past companies (comma-separated IDs)", + "location_list": "List of locations (comma-separated IDs)", + "language_list": "List of languages (comma-separated)", + "service_catagory_list": "List of service categories (comma-separated)", + "school_free_text": "School name to filter by", + "industry_list": "List of industries (comma-separated IDs)", + "school_list": "List of schools (comma-separated IDs)" + } + }, + "search_company_with_filters": { + "route": "/search_company_with_filters", + "method": "POST", + "name": "Search Company With Filters", + "description": "Searches LinkedIn companies with detailed filtering options", + "payload": { + "keyword": "General search keyword", + "page": "Page number", + "company_size_list": "List of company sizes (comma-separated, e.g., A,D)", + "hasJobs": "Filter companies with jobs (true or false)", + "location_list": "List of location IDs (comma-separated)", + "industry_list": "List of industry IDs (comma-separated)" + } + } + } + base_url = "https://linkedin-data-scraper.p.rapidapi.com" + super().__init__(base_url, endpoints) + + +if __name__ == "__main__": + import os + os.environ["RAPID_API_KEY"] = "" + tool = LinkedInService() + + result = tool.call_endpoint( + route="comments_from_recent_activity", + payload={"profile_url": "https://www.linkedin.com/in/adamcohenhillel/", "page": 1} + ) + print(result) + diff --git a/backend/agent/tools/api_services_tool.py b/backend/agent/tools/api_services_tool.py new file mode 100644 index 00000000..9afdf0bb --- /dev/null +++ b/backend/agent/tools/api_services_tool.py @@ -0,0 +1,165 @@ +import json +from typing import Dict, Any, Optional + +from agentpress.tool import Tool, ToolResult, openapi_schema, xml_schema +from agent.tools.api_services.LinkedInService import LinkedInService + +class APIServicesTool(Tool): + """Tool for making requests to various API services.""" + + def __init__(self): + super().__init__() + + self.register_apis = { + "linkedin": LinkedInService() + } + + @openapi_schema({ + "type": "function", + "function": { + "name": "get_api_service_endpoints", + "description": "Get available endpoints for a specific API service", + "parameters": { + "type": "object", + "properties": { + "service_name": { + "type": "string", + "description": "The name of the API service (e.g., 'linkedin')" + } + }, + "required": ["service_name"] + } + } + }) + @xml_schema( + tag_name="get-api-service-endpoints", + mappings=[ + {"param_name": "service_name", "node_type": "attribute", "path": "."} + ], + example=''' + + + + + + ''' + ) + async def get_api_service_endpoints( + self, + service_name: str + ) -> ToolResult: + """ + Get available endpoints for a specific API service. + + Parameters: + - service_name: The name of the API service (e.g., 'linkedin') + """ + try: + if not service_name: + return self.fail_response("API name is required.") + + if service_name not in self.register_apis: + return self.fail_response(f"API '{service_name}' not found. Available APIs: {list(self.register_apis.keys())}") + + endpoints = self.register_apis[service_name].get_endpoints() + return self.success_response(endpoints) + + except Exception as e: + error_message = str(e) + simplified_message = f"Error getting API endpoints: {error_message[:200]}" + if len(error_message) > 200: + simplified_message += "..." + return self.fail_response(simplified_message) + + @openapi_schema({ + "type": "function", + "function": { + "name": "execute_api_call", + "description": "Execute a call to a specific API endpoint", + "parameters": { + "type": "object", + "properties": { + "service_name": { + "type": "string", + "description": "The name of the API service (e.g., 'linkedin')" + }, + "route": { + "type": "string", + "description": "The key of the endpoint to call" + }, + "payload": { + "type": "object", + "description": "The payload to send with the API call" + } + }, + "required": ["service_name", "route"] + } + } + }) + @xml_schema( + tag_name="execute-api-call", + mappings=[ + {"param_name": "service_name", "node_type": "attribute", "path": "service_name"}, + {"param_name": "route", "node_type": "attribute", "path": "route"}, + {"param_name": "payload", "node_type": "content", "path": "."} + ], + example=''' + + + + + {"link": "https://www.linkedin.com/in/johndoe/"} + + ''' + ) + async def execute_api_call( + self, + service_name: str, + route: str, + payload: str # this actually a json string + ) -> ToolResult: + """ + Execute a call to a specific API endpoint. + + Parameters: + - service_name: The name of the API service (e.g., 'linkedin') + - route: The key of the endpoint to call + - payload: The payload to send with the API call + """ + try: + payload = json.loads(payload) + + if not service_name: + return self.fail_response("service_name is required.") + + if not route: + return self.fail_response("route is required.") + + if service_name not in self.register_apis: + return self.fail_response(f"API '{service_name}' not found. Available APIs: {list(self.register_apis.keys())}") + + api_service = self.register_apis[service_name] + if route == service_name: + return self.fail_response(f"route '{route}' is the same as service_name '{service_name}'. YOU FUCKING IDIOT!") + + if route not in api_service.get_endpoints().keys(): + return self.fail_response(f"Endpoint '{route}' not found in {service_name} API.") + + + result = api_service.call_endpoint(route, payload) + return self.success_response(result) + + except Exception as e: + error_message = str(e) + print(error_message) + simplified_message = f"Error executing API call: {error_message[:200]}" + if len(error_message) > 200: + simplified_message += "..." + return self.fail_response(simplified_message)