| | """ |
| | Web Search Tool - Tavily and Exa implementations |
| | Author: @mangobee |
| | 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}") |
| |
|