Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Описать бы более детальное разделение тулов. Зачем столько много в примере.
Мы уверены, что прям так надо показывать пользователю?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

тут паттерн проекта такой что целые опциональные секции (prompts, proxy, доп mcp серверы) комментируются, а тулы с конфигами всегда раскомментированы. пользователь просто не добавляет ненужный тул в agent tools список. standalone тулы следуют этому же паттерну, плюс комментарий-разделитель уже есть на строке 72

но я согласен что пояснение можно сделать лучше, сейчас одна строчка и всё. можно расписать два сценария подробнее

как по твоему лучше оформить - закоментировать блоки или оставить с более подробным описанием?


agents:
custom_research_agent:
Expand Down
1 change: 1 addition & 0 deletions examples/sgr_deep_research/config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ tools:
# Core tools (base_class defaults to sgr_agent_core.tools.*)
# Search tools: configure Tavily API key and search limits per tool
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_searches: 4 # Max search operations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ tools:
# Core tools (base_class defaults to sgr_agent_core.tools.*)
# Search tools: configure Tavily API key and search limits per tool
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_searches: 4 # Max search operations
Expand Down
24 changes: 16 additions & 8 deletions sgr_agent_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,16 @@
SourceData,
)
from sgr_agent_core.next_step_tool import NextStepToolsBuilder, NextStepToolStub
from sgr_agent_core.services import AgentRegistry, MCP2ToolConverter, PromptLoader, ToolRegistry
from sgr_agent_core.services import (
AgentRegistry,
BaseSearchService,
BraveSearchService,
MCP2ToolConverter,
PerplexitySearchService,
PromptLoader,
TavilySearchService,
ToolRegistry,
)
from sgr_agent_core.tools import * # noqa: F403

__all__ = [
Expand All @@ -49,9 +58,13 @@
"SourceData",
# Services
"AgentRegistry",
"ToolRegistry",
"PromptLoader",
"BaseSearchService",
"BraveSearchService",
"MCP2ToolConverter",
"PerplexitySearchService",
"TavilySearchService",
"PromptLoader",
"ToolRegistry",
# Configuration
"AgentConfig",
"AgentDefinition",
Expand All @@ -63,11 +76,6 @@
# Next step tools
"NextStepToolStub",
"NextStepToolsBuilder",
# Models
"AgentStatesEnum",
"AgentContext",
"SearchResult",
"SourceData",
# Factory
"AgentFactory",
]
15 changes: 15 additions & 0 deletions sgr_agent_core/agent_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@ class SearchConfig(BaseModel, extra="allow"):
max_results: int = Field(default=10, ge=1, description="Maximum number of search results")
content_limit: int = Field(default=3500, gt=0, description="Content character limit per source")

engine: Literal["tavily", "brave", "perplexity"] = Field(
default="tavily",
description="Search engine provider to use",
)
brave_api_key: str | None = Field(default=None, description="Brave Search API key")
brave_api_base_url: str = Field(
default="https://api.search.brave.com/res/v1/web/search",
description="Brave Search API base URL",
)
perplexity_api_key: str | None = Field(default=None, description="Perplexity API key")
perplexity_api_base_url: str = Field(
default="https://api.perplexity.ai/search",
description="Perplexity Search API base URL",
)


class PromptsConfig(BaseModel, extra="allow"):
system_prompt_file: FilePath | None = Field(
Expand Down
2 changes: 1 addition & 1 deletion sgr_agent_core/base_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Copy link
Member

@EvilFreelancer EvilFreelancer Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Думаю _BaseSearchTool тут лишне, потому что _BaseSearchTool и так наследует BaseTool.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_BaseSearchTool тут нужен - без него он зарегается в ToolRegistry как полноценный тул с именем _basesearchtool а это абстрактный базовый класс, его нельзя вызвать

сам подход с хардкодом имён конечно так себе, в идеале заменить на маркер типа _abstract = True, но это уже отдельная задача

ToolRegistry.register(cls, name=cls.tool_name)


Expand Down
6 changes: 6 additions & 0 deletions sgr_agent_core/services/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
"""Services module for external integrations and business logic."""

from sgr_agent_core.services.base_search import BaseSearchService
from sgr_agent_core.services.brave_search import BraveSearchService
from sgr_agent_core.services.mcp_service import MCP2ToolConverter
from sgr_agent_core.services.perplexity_search import PerplexitySearchService
from sgr_agent_core.services.prompt_loader import PromptLoader
from sgr_agent_core.services.registry import AgentRegistry, StreamingGeneratorRegistry, ToolRegistry
from sgr_agent_core.services.tavily_search import TavilySearchService
from sgr_agent_core.services.tool_instantiator import ToolInstantiator

__all__ = [
"BaseSearchService",
"BraveSearchService",
"PerplexitySearchService",
"TavilySearchService",
"MCP2ToolConverter",
"ToolRegistry",
Expand Down
79 changes: 79 additions & 0 deletions sgr_agent_core/services/base_search.py
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:
"""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}")
98 changes: 98 additions & 0 deletions sgr_agent_core/services/brave_search.py
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
Loading