diff --git a/app/tool/browser_use_tool.py b/app/tool/browser_use_tool.py index 8a5d86d..57dce59 100644 --- a/app/tool/browser_use_tool.py +++ b/app/tool/browser_use_tool.py @@ -267,32 +267,20 @@ class BrowserUseTool(BaseTool, Generic[Context]): return ToolResult( error="Query is required for 'web_search' action" ) - search_results = await self.web_search_tool.execute(query) + # Execute the web search and return results directly without browser navigation + search_response = await self.web_search_tool.execute( + query=query, fetch_content=True, num_results=1 + ) + # Navigate to the first search result + first_search_result = search_response.results[0] + url_to_navigate = first_search_result.url - if search_results: - # Navigate to the first search result - first_result = search_results[0] - if isinstance(first_result, dict) and "url" in first_result: - url_to_navigate = first_result["url"] - elif isinstance(first_result, str): - url_to_navigate = first_result - else: - return ToolResult( - error=f"Invalid search result format: {first_result}" - ) + page = await context.get_current_page() + await page.goto(url_to_navigate) + await page.wait_for_load_state() - page = await context.get_current_page() - await page.goto(url_to_navigate) - await page.wait_for_load_state() - - return ToolResult( - output=f"Searched for '{query}' and navigated to first result: {url_to_navigate}\nAll results:" - + "\n".join([str(r) for r in search_results]) - ) - else: - return ToolResult( - error=f"No search results found for '{query}'" - ) + # Use the to_tool_result method to get consistent formatting + return search_response.to_tool_result() # Element interaction actions elif action == "click_element": diff --git a/app/tool/search/baidu_search.py b/app/tool/search/baidu_search.py index d415ce8..a6fca59 100644 --- a/app/tool/search/baidu_search.py +++ b/app/tool/search/baidu_search.py @@ -1,9 +1,50 @@ from baidusearch.baidusearch import search -from app.tool.search.base import WebSearchEngine +from app.tool.search.base import SearchItem, WebSearchEngine class BaiduSearchEngine(WebSearchEngine): def perform_search(self, query, num_results=10, *args, **kwargs): - """Baidu search engine.""" - return search(query, num_results=num_results) + """ + Baidu search engine. + + Returns results formatted according to SearchItem model. + """ + raw_results = search(query, num_results=num_results) + + # Convert raw results to SearchItem format + results = [] + for i, item in enumerate(raw_results): + if isinstance(item, str): + # If it's just a URL + results.append( + SearchItem(title=f"Baidu Result {i+1}", url=item, description=None) + ) + elif isinstance(item, dict): + # If it's a dictionary with details + results.append( + SearchItem( + title=item.get("title", f"Baidu Result {i+1}"), + url=item.get("url", ""), + description=item.get("abstract", None), + ) + ) + else: + # Try to get attributes directly + try: + results.append( + SearchItem( + title=getattr(item, "title", f"Baidu Result {i+1}"), + url=getattr(item, "url", ""), + description=getattr(item, "abstract", None), + ) + ) + except Exception: + # Fallback to a basic result + results.append( + SearchItem( + title=f"Baidu Result {i+1}", url=str(item), description=None + ) + ) + + return results diff --git a/app/tool/search/base.py b/app/tool/search/base.py index 3132381..1f2aeb7 100644 --- a/app/tool/search/base.py +++ b/app/tool/search/base.py @@ -1,9 +1,28 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class SearchItem(BaseModel): + """Represents a single search result item""" + + title: str = Field(description="The title of the search result") + url: str = Field(description="The URL of the search result") + description: Optional[str] = Field( + default=None, description="A description or snippet of the search result" + ) + + def __str__(self) -> str: + """String representation of a search result item.""" + return f"{self.title} - {self.url}" + + class WebSearchEngine(object): def perform_search( self, query: str, num_results: int = 10, *args, **kwargs - ) -> list[dict]: + ) -> List[SearchItem]: """ - Perform a web search and return a list of URLs. + Perform a web search and return a list of search items. Args: query (str): The search query to submit to the search engine. @@ -12,6 +31,6 @@ class WebSearchEngine(object): kwargs: Additional keyword arguments. Returns: - List: A list of dict matching the search query. + List[SearchItem]: A list of SearchItem objects matching the search query. """ raise NotImplementedError diff --git a/app/tool/search/bing_search.py b/app/tool/search/bing_search.py index 46955b5..3a316aa 100644 --- a/app/tool/search/bing_search.py +++ b/app/tool/search/bing_search.py @@ -1,10 +1,10 @@ -from typing import List +from typing import List, Tuple import requests from bs4 import BeautifulSoup from app.logger import logger -from app.tool.search.base import WebSearchEngine +from app.tool.search.base import SearchItem, WebSearchEngine ABSTRACT_MAX_LENGTH = 300 @@ -44,21 +44,16 @@ class BingSearchEngine(WebSearchEngine): self.session = requests.Session() self.session.headers.update(HEADERS) - def _search_sync(self, query: str, num_results: int = 10) -> List[str]: + def _search_sync(self, query: str, num_results: int = 10) -> List[SearchItem]: """ - Synchronous Bing search implementation to retrieve a list of URLs matching a query. + Synchronous Bing search implementation to retrieve search results. Args: - query (str): The search query to submit to Bing. Must not be empty. - num_results (int, optional): The maximum number of URLs to return. Defaults to 10. + query (str): The search query to submit to Bing. + num_results (int, optional): Maximum number of results to return. Defaults to 10. Returns: - List[str]: A list of URLs from the search results, capped at `num_results`. - Returns an empty list if the query is empty or no results are found. - - Notes: - - Pagination is handled by incrementing the `first` parameter and following `next_url` links. - - If fewer results than `num_results` are available, all found URLs are returned. + List[SearchItem]: A list of search items with title, URL, and description. """ if not query: return [] @@ -72,25 +67,21 @@ class BingSearchEngine(WebSearchEngine): next_url, rank_start=len(list_result), first=first ) if data: - list_result.extend([item["url"] for item in data]) + list_result.extend(data) if not next_url: break first += 10 return list_result[:num_results] - def _parse_html(self, url: str, rank_start: int = 0, first: int = 1) -> tuple: + def _parse_html( + self, url: str, rank_start: int = 0, first: int = 1 + ) -> Tuple[List[SearchItem], str]: """ - Parse Bing search result HTML synchronously to extract search results and the next page URL. + Parse Bing search result HTML to extract search results and the next page URL. - Args: - url (str): The URL of the Bing search results page to parse. - rank_start (int, optional): The starting rank for numbering the search results. Defaults to 0. - first (int, optional): Unused parameter (possibly legacy). Defaults to 1. Returns: - tuple: A tuple containing: - - list: A list of dictionaries with keys 'title', 'abstract', 'url', and 'rank' for each result. - - str or None: The URL of the next results page, or None if there is no next page. + tuple: (List of SearchItem objects, next page URL or None) """ try: res = self.session.get(url=url) @@ -120,13 +111,14 @@ class BingSearchEngine(WebSearchEngine): abstract = abstract[:ABSTRACT_MAX_LENGTH] rank_start += 1 + + # Create a SearchItem object list_data.append( - { - "title": title, - "abstract": abstract, - "url": url, - "rank": rank_start, - } + SearchItem( + title=title or f"Bing Result {rank_start}", + url=url, + description=abstract, + ) ) except Exception: continue @@ -142,5 +134,9 @@ class BingSearchEngine(WebSearchEngine): return [], None def perform_search(self, query, num_results=10, *args, **kwargs): - """Bing search engine.""" + """ + Bing search engine. + + Returns results formatted according to SearchItem model. + """ return self._search_sync(query, num_results=num_results) diff --git a/app/tool/search/duckduckgo_search.py b/app/tool/search/duckduckgo_search.py index 3dd5c52..d7024fd 100644 --- a/app/tool/search/duckduckgo_search.py +++ b/app/tool/search/duckduckgo_search.py @@ -1,9 +1,53 @@ from duckduckgo_search import DDGS -from app.tool.search.base import WebSearchEngine +from app.tool.search.base import SearchItem, WebSearchEngine class DuckDuckGoSearchEngine(WebSearchEngine): - async def perform_search(self, query, num_results=10, *args, **kwargs): - """DuckDuckGo search engine.""" - return DDGS.text(query, num_results=num_results) + def perform_search(self, query, num_results=10, *args, **kwargs): + """ + DuckDuckGo search engine. + + Returns results formatted according to SearchItem model. + """ + raw_results = DDGS().text(query, max_results=num_results) + + results = [] + for i, item in enumerate(raw_results): + if isinstance(item, str): + # If it's just a URL + results.append( + SearchItem( + title=f"DuckDuckGo Result {i+1}", url=item, description=None + ) + ) + elif isinstance(item, dict): + # Extract data from the dictionary + results.append( + SearchItem( + title=item.get("title", f"DuckDuckGo Result {i+1}"), + url=item.get("href", ""), + description=item.get("body", None), + ) + ) + else: + # Try to extract attributes directly + try: + results.append( + SearchItem( + title=getattr(item, "title", f"DuckDuckGo Result {i+1}"), + url=getattr(item, "href", ""), + description=getattr(item, "body", None), + ) + ) + except Exception: + # Fallback + results.append( + SearchItem( + title=f"DuckDuckGo Result {i+1}", + url=str(item), + description=None, + ) + ) + + return results diff --git a/app/tool/search/google_search.py b/app/tool/search/google_search.py index 425106d..03f21b5 100644 --- a/app/tool/search/google_search.py +++ b/app/tool/search/google_search.py @@ -1,9 +1,29 @@ from googlesearch import search -from app.tool.search.base import WebSearchEngine +from app.tool.search.base import SearchItem, WebSearchEngine class GoogleSearchEngine(WebSearchEngine): def perform_search(self, query, num_results=10, *args, **kwargs): - """Google search engine.""" - return search(query, num_results=num_results) + """ + Google search engine. + + Returns results formatted according to SearchItem model. + """ + raw_results = search(query, num_results=num_results, advanced=True) + + results = [] + for i, item in enumerate(raw_results): + if isinstance(item, str): + # If it's just a URL + results.append( + {"title": f"Google Result {i+1}", "url": item, "description": ""} + ) + else: + results.append( + SearchItem( + title=item.title, url=item.url, description=item.description + ) + ) + + return results diff --git a/app/tool/web_search.py b/app/tool/web_search.py index 950bb65..0ce504f 100644 --- a/app/tool/web_search.py +++ b/app/tool/web_search.py @@ -1,11 +1,14 @@ import asyncio -from typing import List +from typing import Any, Dict, List, Optional +import requests +from bs4 import BeautifulSoup +from pydantic import BaseModel, ConfigDict, Field from tenacity import retry, stop_after_attempt, wait_exponential from app.config import config from app.logger import logger -from app.tool.base import BaseTool +from app.tool.base import BaseTool, ToolResult from app.tool.search import ( BaiduSearchEngine, BingSearchEngine, @@ -15,11 +18,153 @@ from app.tool.search import ( ) +class SearchResult(BaseModel): + """Represents a single search result returned by a search engine.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + position: int = Field(description="Position in search results") + url: str = Field(description="URL of the search result") + title: str = Field(default="", description="Title of the search result") + description: str = Field( + default="", description="Description or snippet of the search result" + ) + source: str = Field(description="The search engine that provided this result") + raw_content: Optional[str] = Field( + default=None, description="Raw content from the search result page if available" + ) + + def __str__(self) -> str: + """String representation of a search result.""" + return f"{self.title} ({self.url})" + + +class SearchMetadata(BaseModel): + """Metadata about the search operation.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + total_results: int = Field(description="Total number of results found") + language: str = Field(description="Language code used for the search") + country: str = Field(description="Country code used for the search") + + +class SearchResponse(BaseModel): + """Structured response from the web search tool.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + status: str = Field( + description="Status of the search operation ('success' or 'error')" + ) + query: str = Field(description="The search query that was executed") + results: List[SearchResult] = Field( + default_factory=list, description="List of search results" + ) + metadata: Optional[SearchMetadata] = Field( + default=None, description="Metadata about the search" + ) + message: Optional[str] = Field( + default=None, description="Error or status message if applicable" + ) + + def to_tool_result(self) -> ToolResult: + """Convert search response to a ToolResult for consistent API usage.""" + if self.status == "error": + return ToolResult(error=self.message or "Search failed") + + result_text = [f"Search results for '{self.query}':"] + + for i, result in enumerate(self.results, 1): + # Add title with position number + title = result.title.strip() or "No title" + result_text.append(f"\n{i}. {title}") + + # Add URL with proper indentation + result_text.append(f" URL: {result.url}") + + # Add description if available + if result.description.strip(): + result_text.append(f" Description: {result.description}") + + # Add content preview if available + if result.raw_content: + content_preview = result.raw_content[:1000].replace("\n", " ").strip() + if len(result.raw_content) > 1000: + content_preview += "..." + result_text.append(f" Content: {content_preview}") + + # Add metadata at the bottom if available + if self.metadata: + result_text.extend( + [ + f"\nMetadata:", + f"- Total results: {self.metadata.total_results}", + f"- Language: {self.metadata.language}", + f"- Country: {self.metadata.country}", + ] + ) + + return ToolResult(output="\n".join(result_text)) + + +class WebContentFetcher: + """Utility class for fetching web content.""" + + @staticmethod + async def fetch_content(url: str, timeout: int = 10) -> Optional[str]: + """ + Fetch and extract the main content from a webpage. + + Args: + url: The URL to fetch content from + timeout: Request timeout in seconds + + Returns: + Extracted text content or None if fetching fails + """ + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + } + + try: + # Use asyncio to run requests in a thread pool + response = await asyncio.get_event_loop().run_in_executor( + None, lambda: requests.get(url, headers=headers, timeout=timeout) + ) + + if response.status_code != 200: + logger.warning( + f"Failed to fetch content from {url}: HTTP {response.status_code}" + ) + return None + + # Parse HTML with BeautifulSoup + soup = BeautifulSoup(response.text, "html.parser") + + # Remove script and style elements + for script in soup(["script", "style", "header", "footer", "nav"]): + script.extract() + + # Get text content + text = soup.get_text(separator="\n", strip=True) + + # Clean up whitespace and limit size (100KB max) + text = " ".join(text.split()) + return text[:10000] if text else None + + except Exception as e: + logger.warning(f"Error fetching content from {url}: {e}") + return None + + class WebSearch(BaseTool): + """Search the web for information using various search engines.""" + name: str = "web_search" - description: str = """Perform a web search and return a list of relevant links. - This function attempts to use the primary search engine API to get up-to-date results. - If an error occurs, it falls back to an alternative search engine.""" + description: str = """Search the web for real-time information about any topic. + This tool returns comprehensive search results with relevant information, URLs, titles, and descriptions. + If the primary search engine fails, it automatically falls back to alternative engines.""" parameters: dict = { "type": "object", "properties": { @@ -29,8 +174,23 @@ class WebSearch(BaseTool): }, "num_results": { "type": "integer", - "description": "(optional) The number of search results to return. Default is 10.", - "default": 10, + "description": "(optional) The number of search results to return. Default is 5.", + "default": 5, + }, + "lang": { + "type": "string", + "description": "(optional) Language code for search results (default: en).", + "default": "en", + }, + "country": { + "type": "string", + "description": "(optional) Country code for search results (default: us).", + "default": "us", + }, + "fetch_content": { + "type": "boolean", + "description": "(optional) Whether to fetch full content from result pages. Default is false.", + "default": False, }, }, "required": ["query"], @@ -41,35 +201,62 @@ class WebSearch(BaseTool): "duckduckgo": DuckDuckGoSearchEngine(), "bing": BingSearchEngine(), } + content_fetcher: WebContentFetcher = WebContentFetcher() - async def execute(self, query: str, num_results: int = 10) -> List[str]: + async def execute( + self, + query: str, + num_results: int = 5, + lang: str = "en", + country: str = "us", + fetch_content: bool = False, + ) -> SearchResponse: """ - Execute a Web search and return a list of URLs. - Tries engines in order based on configuration, falling back if an engine fails with errors. - If all engines fail, it will wait and retry up to the configured number of times. + Execute a Web search and return detailed search results. Args: - query (str): The search query to submit to the search engine. - num_results (int, optional): The number of search results to return. Default is 10. + query: The search query to submit to the search engine + num_results: The number of search results to return (default: 5) + lang: Language code for search results (default: en) + country: Country code for search results (default: us) + fetch_content: Whether to fetch content from result pages (default: False) Returns: - List[str]: A list of URLs matching the search query. + A structured response containing search results and metadata """ # Get retry settings from config - retry_delay = 60 # Default to 60 seconds - max_retries = 3 # Default to 3 retries - - if config.search_config: - retry_delay = getattr(config.search_config, "retry_delay", 60) - max_retries = getattr(config.search_config, "max_retries", 3) + retry_delay = ( + getattr(config.search_config, "retry_delay", 60) + if config.search_config + else 60 + ) + max_retries = ( + getattr(config.search_config, "max_retries", 3) + if config.search_config + else 3 + ) + search_params = {"lang": lang, "country": country} # Try searching with retries when all engines fail - for retry_count in range( - max_retries + 1 - ): # +1 because first try is not a retry - links = await self._try_all_engines(query, num_results) - if links: - return links + for retry_count in range(max_retries + 1): + results = await self._try_all_engines(query, num_results, search_params) + + if results: + # Fetch content if requested + if fetch_content: + results = await self._fetch_content_for_results(results) + + # Return a successful structured response + return SearchResponse( + status="success", + query=query, + results=results, + metadata=SearchMetadata( + total_results=len(results), + language=lang, + country=country, + ), + ) if retry_count < max_retries: # All engines failed, wait and retry @@ -82,95 +269,140 @@ class WebSearch(BaseTool): f"All search engines failed after {max_retries} retries. Giving up." ) - return [] + # Return an error response + return SearchResponse( + status="error", + query=query, + message="All search engines failed to return results", + results=[], + ) - async def _try_all_engines(self, query: str, num_results: int) -> List[str]: - """ - Try all search engines in the configured order. - - Args: - query (str): The search query to submit to the search engine. - num_results (int): The number of search results to return. - - Returns: - List[str]: A list of URLs matching the search query, or empty list if all engines fail. - """ + async def _try_all_engines( + self, query: str, num_results: int, search_params: Dict[str, Any] + ) -> List[SearchResult]: + """Try all search engines in the configured order.""" engine_order = self._get_engine_order() failed_engines = [] for engine_name in engine_order: engine = self._search_engine[engine_name] - try: - logger.info(f"🔎 Attempting search with {engine_name.capitalize()}...") - links = await self._perform_search_with_engine( - engine, query, num_results - ) - if links: - if failed_engines: - logger.info( - f"Search successful with {engine_name.capitalize()} after trying: {', '.join(failed_engines)}" - ) - return links - except Exception as e: - failed_engines.append(engine_name.capitalize()) - is_rate_limit = "429" in str(e) or "Too Many Requests" in str(e) + logger.info(f"🔎 Attempting search with {engine_name.capitalize()}...") + search_items = await self._perform_search_with_engine( + engine, query, num_results, search_params + ) - if is_rate_limit: - logger.warning( - f"⚠️ {engine_name.capitalize()} search engine rate limit exceeded, trying next engine..." - ) - else: - logger.warning( - f"⚠️ {engine_name.capitalize()} search failed with error: {e}" - ) + if not search_items: + continue + + if failed_engines: + logger.info( + f"Search successful with {engine_name.capitalize()} after trying: {', '.join(failed_engines)}" + ) + + # Transform search items into structured results + return [ + SearchResult( + position=i + 1, + url=item.url, + title=item.title + or f"Result {i+1}", # Ensure we always have a title + description=item.description or "", + source=engine_name, + ) + for i, item in enumerate(search_items) + ] if failed_engines: logger.error(f"All search engines failed: {', '.join(failed_engines)}") return [] + async def _fetch_content_for_results( + self, results: List[SearchResult] + ) -> List[SearchResult]: + """Fetch and add web content to search results.""" + if not results: + return [] + + # Create tasks for each result + tasks = [self._fetch_single_result_content(result) for result in results] + + # Type annotation to help type checker + fetched_results: List[SearchResult] = await asyncio.gather(*tasks) + + # Explicit validation of return type + return [ + ( + result + if isinstance(result, SearchResult) + else SearchResult(**result.dict()) + ) + for result in fetched_results + ] + + async def _fetch_single_result_content(self, result: SearchResult) -> SearchResult: + """Fetch content for a single search result.""" + if result.url: + content = await self.content_fetcher.fetch_content(result.url) + if content: + result.raw_content = content + return result + def _get_engine_order(self) -> List[str]: - """ - Determines the order in which to try search engines. - Preferred engine is first (based on configuration), followed by fallback engines, - and then the remaining engines. + """Determines the order in which to try search engines.""" + preferred = ( + getattr(config.search_config, "engine", "google").lower() + if config.search_config + else "google" + ) + fallbacks = ( + [engine.lower() for engine in config.search_config.fallback_engines] + if config.search_config + and hasattr(config.search_config, "fallback_engines") + else [] + ) - Returns: - List[str]: Ordered list of search engine names. - """ - preferred = "google" - fallbacks = [] - - if config.search_config: - if config.search_config.engine: - preferred = config.search_config.engine.lower() - if config.search_config.fallback_engines: - fallbacks = [ - engine.lower() for engine in config.search_config.fallback_engines - ] - - engine_order = [] - # Add preferred engine first - if preferred in self._search_engine: - engine_order.append(preferred) - - # Add configured fallback engines in order - for fallback in fallbacks: - if fallback in self._search_engine and fallback not in engine_order: - engine_order.append(fallback) + # Start with preferred engine, then fallbacks, then remaining engines + engine_order = [preferred] if preferred in self._search_engine else [] + engine_order.extend( + [ + fb + for fb in fallbacks + if fb in self._search_engine and fb not in engine_order + ] + ) + engine_order.extend([e for e in self._search_engine if e not in engine_order]) return engine_order @retry( - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=1, min=1, max=10), + stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10) ) async def _perform_search_with_engine( self, engine: WebSearchEngine, query: str, num_results: int, - ) -> List[str]: - loop = asyncio.get_event_loop() - return await loop.run_in_executor( - None, lambda: list(engine.perform_search(query, num_results=num_results)) + search_params: Dict[str, Any], + ) -> List[Any]: + """Execute search with the given engine and parameters.""" + return await asyncio.get_event_loop().run_in_executor( + None, + lambda: list( + engine.perform_search( + query, + num_results=num_results, + lang=search_params.get("lang"), + country=search_params.get("country"), + ) + ), ) + + +if __name__ == "__main__": + web_search = WebSearch() + search_response = asyncio.run( + web_search.execute( + query="Python programming", fetch_content=True, num_results=1 + ) + ) + print(search_response.to_tool_result()) diff --git a/config/config.example.toml b/config/config.example.toml index f60c99d..82a80c4 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -71,10 +71,10 @@ temperature = 0.0 # Controls randomness for vision mode # Optional configuration, Search settings. # [search] -# Search engine for agent to use. Default is "Google", can be set to "Baidu" or "DuckDuckGo". +# Search engine for agent to use. Default is "Google", can be set to "Baidu" or "DuckDuckGo" or "Bing". #engine = "Google" -# Fallback engine order. Default is ["DuckDuckGo", "Baidu"] - will try in this order after primary engine fails. -#fallback_engines = ["DuckDuckGo", "Baidu"] +# Fallback engine order. Default is ["DuckDuckGo", "Baidu", "Bing"] - will try in this order after primary engine fails. +#fallback_engines = ["DuckDuckGo", "Baidu", "Bing"] # Seconds to wait before retrying all engines again when they all fail due to rate limits. Default is 60. #retry_delay = 60 # Maximum number of times to retry all engines when all fail. Default is 3.