| """ |
| Web Search Tool - Tavily and Exa implementations |
| Author: @mangubee |
| Date: 2026-01-02 |
| |
| Provides web search functionality with: |
| - Tavily as primary search (free tier: 1000 req/month) |
| - Exa as fallback (paid tier) |
| - Retry logic with exponential backoff |
| - Structured error handling |
| """ |
|
|
| import logging |
| from typing import Dict, List, Optional |
| from tenacity import ( |
| retry, |
| stop_after_attempt, |
| wait_exponential, |
| retry_if_exception_type, |
| ) |
|
|
| from src.config.settings import Settings |
|
|
| |
| |
| |
| MAX_RETRIES = 3 |
| RETRY_MIN_WAIT = 1 |
| RETRY_MAX_WAIT = 10 |
| DEFAULT_MAX_RESULTS = 5 |
|
|
| |
| |
| |
| logger = logging.getLogger(__name__) |
|
|
|
|
| |
| |
| |
|
|
|
|
| @retry( |
| stop=stop_after_attempt(MAX_RETRIES), |
| wait=wait_exponential(multiplier=1, min=RETRY_MIN_WAIT, max=RETRY_MAX_WAIT), |
| retry=retry_if_exception_type((ConnectionError, TimeoutError)), |
| reraise=True, |
| ) |
| def tavily_search(query: str, max_results: int = DEFAULT_MAX_RESULTS) -> Dict: |
| """ |
| Search using Tavily API with retry logic. |
| |
| Args: |
| query: Search query string |
| max_results: Maximum number of results to return (default: 5) |
| |
| Returns: |
| Dict with structure: { |
| "results": [{"title": str, "url": str, "snippet": str}, ...], |
| "source": "tavily", |
| "query": str, |
| "count": int |
| } |
| |
| Raises: |
| ValueError: If API key not configured |
| ConnectionError: If API connection fails after retries |
| Exception: For other API errors |
| """ |
| try: |
| from tavily import TavilyClient |
|
|
| settings = Settings() |
| api_key = settings.tavily_api_key |
|
|
| if not api_key: |
| raise ValueError("TAVILY_API_KEY not configured in settings") |
|
|
| logger.info(f"Tavily search: query='{query}', max_results={max_results}") |
|
|
| client = TavilyClient(api_key=api_key) |
| response = client.search(query=query, max_results=max_results) |
|
|
| |
| results = [] |
| for item in response.get("results", []): |
| results.append( |
| { |
| "title": item.get("title", ""), |
| "url": item.get("url", ""), |
| "snippet": item.get("content", ""), |
| } |
| ) |
|
|
| logger.info(f"Tavily search successful: {len(results)} results") |
|
|
| return { |
| "results": results, |
| "source": "tavily", |
| "query": query, |
| "count": len(results), |
| } |
|
|
| except ValueError as e: |
| logger.error(f"Tavily configuration error: {e}") |
| raise |
| except (ConnectionError, TimeoutError) as e: |
| logger.warning(f"Tavily connection error (will retry): {e}") |
| raise |
| except Exception as e: |
| logger.error(f"Tavily search error: {e}") |
| raise Exception(f"Tavily search failed: {str(e)}") |
|
|
|
|
| |
| |
| |
|
|
|
|
| @retry( |
| stop=stop_after_attempt(MAX_RETRIES), |
| wait=wait_exponential(multiplier=1, min=RETRY_MIN_WAIT, max=RETRY_MAX_WAIT), |
| retry=retry_if_exception_type((ConnectionError, TimeoutError)), |
| reraise=True, |
| ) |
| def exa_search(query: str, max_results: int = DEFAULT_MAX_RESULTS) -> Dict: |
| """ |
| Search using Exa API with retry logic. |
| |
| Args: |
| query: Search query string |
| max_results: Maximum number of results to return (default: 5) |
| |
| Returns: |
| Dict with structure: { |
| "results": [{"title": str, "url": str, "snippet": str}, ...], |
| "source": "exa", |
| "query": str, |
| "count": int |
| } |
| |
| Raises: |
| ValueError: If API key not configured |
| ConnectionError: If API connection fails after retries |
| Exception: For other API errors |
| """ |
| try: |
| from exa_py import Exa |
|
|
| settings = Settings() |
| api_key = settings.exa_api_key |
|
|
| if not api_key: |
| raise ValueError("EXA_API_KEY not configured in settings") |
|
|
| logger.info(f"Exa search: query='{query}', max_results={max_results}") |
|
|
| client = Exa(api_key=api_key) |
| response = client.search( |
| query=query, num_results=max_results, use_autoprompt=True |
| ) |
|
|
| |
| results = [] |
| for item in response.results: |
| results.append( |
| { |
| "title": item.title if hasattr(item, "title") else "", |
| "url": item.url if hasattr(item, "url") else "", |
| "snippet": item.text if hasattr(item, "text") else "", |
| } |
| ) |
|
|
| logger.info(f"Exa search successful: {len(results)} results") |
|
|
| return { |
| "results": results, |
| "source": "exa", |
| "query": query, |
| "count": len(results), |
| } |
|
|
| except ValueError as e: |
| logger.error(f"Exa configuration error: {e}") |
| raise |
| except (ConnectionError, TimeoutError) as e: |
| logger.warning(f"Exa connection error (will retry): {e}") |
| raise |
| except Exception as e: |
| logger.error(f"Exa search error: {e}") |
| raise Exception(f"Exa search failed: {str(e)}") |
|
|
|
|
| |
| |
| |
|
|
|
|
| def search(query: str, max_results: int = DEFAULT_MAX_RESULTS) -> Dict: |
| """ |
| Unified search function with automatic fallback. |
| |
| Tries Tavily first (free tier), falls back to Exa if Tavily fails. |
| |
| Args: |
| query: Search query string |
| max_results: Maximum number of results to return (default: 5) |
| |
| Returns: |
| Dict with search results from either Tavily or Exa |
| |
| Raises: |
| Exception: If both Tavily and Exa searches fail |
| """ |
| settings = Settings() |
| default_tool = settings.default_search_tool |
|
|
| |
| if default_tool == "tavily": |
| try: |
| return tavily_search(query, max_results) |
| except Exception as e: |
| logger.warning(f"Tavily failed, falling back to Exa: {e}") |
| try: |
| return exa_search(query, max_results) |
| except Exception as exa_error: |
| logger.error(f"Both Tavily and Exa failed") |
| raise Exception(f"Search failed - Tavily: {e}, Exa: {exa_error}") |
| else: |
| |
| try: |
| return exa_search(query, max_results) |
| except Exception as e: |
| logger.warning(f"Exa failed, falling back to Tavily: {e}") |
| try: |
| return tavily_search(query, max_results) |
| except Exception as tavily_error: |
| logger.error(f"Both Exa and Tavily failed") |
| raise Exception(f"Search failed - Exa: {e}, Tavily: {tavily_error}") |
|
|