-
Notifications
You must be signed in to change notification settings - Fork 174
Multi-provider search (Brave, Perplexity) #177
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
3f25e97
d610f91
104a20b
2844f2f
1c53809
c83dec6
7146546
6c89cdb
3dc9da1
968239c
c262eaf
72381b0
d8ca318
358550a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,17 +57,28 @@ tools: | |
| base_class: path.to.my.tools.CustomTool | ||
| my_other_tool: | ||
| base_class: "name_of_tool_class_in_registry" | ||
| # Search tools: configure Tavily API key and search limits per tool | ||
| # Search tools: configure search provider and API keys per tool | ||
| # (can be overridden per-agent in tools list) | ||
| web_search_tool: | ||
| engine: "tavily" # Search engine: "tavily" (default), "brave", or "perplexity" | ||
| tavily_api_key: "your-tavily-api-key-here" # Tavily API key (get at tavily.com) | ||
| tavily_api_base_url: "https://api.tavily.com" # Tavily API URL | ||
| max_results: 12 | ||
| max_searches: 6 | ||
| extract_page_content_tool: | ||
| tavily_api_key: "your-tavily-api-key-here" # Same Tavily API key | ||
| tavily_api_key: "your-tavily-api-key-here" # Same Tavily API key (Tavily-only feature) | ||
| tavily_api_base_url: "https://api.tavily.com" | ||
| content_limit: 2000 | ||
| # Standalone search tools (for multi-engine setups where LLM picks the engine) | ||
| brave_search_tool: | ||
| brave_api_key: "your-brave-api-key-here" # Brave Search API key | ||
| brave_api_base_url: "https://api.search.brave.com/res/v1/web/search" | ||
| perplexity_search_tool: | ||
| perplexity_api_key: "your-perplexity-api-key-here" # Perplexity API key | ||
| perplexity_api_base_url: "https://api.perplexity.ai/search" | ||
| tavily_search_tool: | ||
| tavily_api_key: "your-tavily-api-key-here" | ||
| tavily_api_base_url: "https://api.tavily.com" | ||
|
||
|
|
||
| agents: | ||
| custom_research_agent: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,7 +20,7 @@ | |
| class ToolRegistryMixin: | ||
| def __init_subclass__(cls, **kwargs) -> None: | ||
| super().__init_subclass__(**kwargs) | ||
| if cls.__name__ not in ("BaseTool", "MCPBaseTool"): | ||
| if cls.__name__ not in ("BaseTool", "MCPBaseTool", "_BaseSearchTool"): | ||
|
||
| ToolRegistry.register(cls, name=cls.tool_name) | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import logging | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| from sgr_agent_core.models import SourceData | ||
|
|
||
| if TYPE_CHECKING: | ||
| from sgr_agent_core.agent_definition import SearchConfig | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class BaseSearchService: | ||
nikmd1306 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| """Base class for search service providers. | ||
|
|
||
| Subclasses must implement the `search` method. | ||
| """ | ||
|
|
||
| def __init__(self, search_config: "SearchConfig"): | ||
| self._config = search_config | ||
|
|
||
| async def search( | ||
| self, | ||
| query: str, | ||
| max_results: int | None = None, | ||
| offset: int = 0, | ||
| include_raw_content: bool = True, | ||
| ) -> list[SourceData]: | ||
| """Perform a search and return results as SourceData list. | ||
|
|
||
| Each provider handles offset internally: | ||
| - Brave: uses native API offset parameter | ||
| - Tavily/Perplexity: over-fetch+slice | ||
|
|
||
| Args: | ||
| query: Search query string | ||
| max_results: Maximum number of results to return (after offset) | ||
| offset: Number of results to skip | ||
| include_raw_content: Whether to include raw page content | ||
|
|
||
| Returns: | ||
| List of SourceData results (at most max_results items) | ||
| """ | ||
| raise NotImplementedError("Subclasses must implement search()") | ||
|
|
||
| @staticmethod | ||
| def rearrange_sources(sources: list[SourceData], starting_number: int = 1) -> list[SourceData]: | ||
| """Renumber sources sequentially starting from given number.""" | ||
| for i, source in enumerate(sources, starting_number): | ||
| source.number = i | ||
| return sources | ||
|
|
||
| @classmethod | ||
| def create(cls, config: "SearchConfig") -> "BaseSearchService": | ||
| """Factory method to create a search service based on config.engine. | ||
|
|
||
| Args: | ||
| config: SearchConfig with engine and API keys | ||
|
|
||
| Returns: | ||
| Appropriate search service instance | ||
|
|
||
| Raises: | ||
| ValueError: If engine is not supported | ||
| """ | ||
| from sgr_agent_core.services.brave_search import BraveSearchService | ||
| from sgr_agent_core.services.perplexity_search import PerplexitySearchService | ||
| from sgr_agent_core.services.tavily_search import TavilySearchService | ||
|
|
||
| engine = config.engine | ||
| logger.debug(f"Creating search service for engine: {engine}") | ||
| if engine == "tavily": | ||
| return TavilySearchService(config) | ||
| elif engine == "brave": | ||
| return BraveSearchService(config) | ||
| elif engine == "perplexity": | ||
| return PerplexitySearchService(config) | ||
| else: | ||
| logger.error(f"Unsupported search engine requested: {engine}") | ||
| raise ValueError(f"Unsupported search engine: {engine}") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| import logging | ||
| from typing import Any | ||
|
|
||
| import httpx | ||
|
|
||
| from sgr_agent_core.agent_definition import SearchConfig | ||
| from sgr_agent_core.models import SourceData | ||
| from sgr_agent_core.services.base_search import BaseSearchService | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class BraveSearchService(BaseSearchService): | ||
| """Search service using Brave Search API. | ||
|
|
||
| Uses httpx.AsyncClient for HTTP requests. | ||
| Auth: X-Subscription-Token header. | ||
| Brave API supports native offset for pagination. | ||
| """ | ||
|
|
||
| def __init__(self, search_config: SearchConfig): | ||
| super().__init__(search_config) | ||
| if not search_config.brave_api_key: | ||
| raise ValueError("brave_api_key is required for BraveSearchService") | ||
|
|
||
| async def search( | ||
| self, | ||
| query: str, | ||
| max_results: int | None = None, | ||
| offset: int = 0, | ||
| include_raw_content: bool = True, | ||
| ) -> list[SourceData]: | ||
| """Perform search through Brave Search API. | ||
|
|
||
| Brave supports native offset parameter for efficient pagination. | ||
|
|
||
| Args: | ||
| query: Search query string | ||
| max_results: Maximum number of results (max 20 per Brave API) | ||
| offset: Number of results to skip (native Brave API support) | ||
| include_raw_content: Ignored for Brave (no raw content extraction) | ||
|
|
||
| Returns: | ||
| List of SourceData results | ||
| """ | ||
| max_results = min(max_results or self._config.max_results, 20) | ||
| logger.info(f"🔍 Brave search: '{query}' (max_results={max_results}, offset={offset})") | ||
|
|
||
| headers = { | ||
| "Accept": "application/json", | ||
| "Accept-Encoding": "gzip", | ||
| "X-Subscription-Token": self._config.brave_api_key, | ||
| } | ||
| params: dict[str, Any] = { | ||
| "q": query, | ||
| "count": max_results, | ||
| } | ||
| if offset > 0: | ||
| params["offset"] = offset | ||
|
|
||
| try: | ||
| async with httpx.AsyncClient() as client: | ||
| response = await client.get( | ||
| self._config.brave_api_base_url, | ||
| headers=headers, | ||
| params=params, | ||
| timeout=30.0, | ||
| ) | ||
| response.raise_for_status() | ||
| data = response.json() | ||
| except httpx.HTTPStatusError as e: | ||
| logger.error(f"Brave API HTTP error: {e.response.status_code} — {e.response.text[:200]}") | ||
| raise | ||
| except httpx.RequestError as e: | ||
| logger.error(f"Brave API request error: {e}") | ||
| raise | ||
|
|
||
| return self._convert_to_source_data(data) | ||
|
|
||
| def _convert_to_source_data(self, response: dict) -> list[SourceData]: | ||
| """Convert Brave Search API response to SourceData list.""" | ||
| sources = [] | ||
| web_results = response.get("web", {}).get("results", []) | ||
|
|
||
| for i, result in enumerate(web_results): | ||
| url = result.get("url", "") | ||
| if not url: | ||
| continue | ||
|
|
||
| source = SourceData( | ||
| number=i, | ||
| title=result.get("title", ""), | ||
| url=url, | ||
| snippet=result.get("description", ""), | ||
| ) | ||
| sources.append(source) | ||
|
|
||
| return sources |
Uh oh!
There was an error while loading. Please reload this page.