feat: add web search tool using SearXNG

Add WebSearchTool that queries the self-hosted SearXNG metasearch engine
for current information, documentation, and facts beyond training data.

- Add SEARXNG_URL and SEARXNG_TIMEOUT config settings
- Create WebSearchTool with query, num_results, categories params
- Register web_search tool with explore agent
- Add 10 tests for search functionality

Usage: Agents can now use web_search(query="...") to find current info.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 14:09:21 +01:00
co-authored by Claude Opus 4.5
parent f6256363a2
commit 82a816a5b5
6 changed files with 434 additions and 2 deletions
+217
View File
@@ -0,0 +1,217 @@
"""
Tests for WebSearchTool.
"""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from src.domains.tools.search.web import WebSearchTool
class TestWebSearchTool:
"""Tests for WebSearchTool."""
@pytest.fixture
def tool(self):
return WebSearchTool(searxng_url="http://localhost:8087", timeout=5)
@pytest.fixture
def mock_search_response(self):
"""Sample SearXNG response."""
return {
"query": "test query",
"number_of_results": 3,
"results": [
{
"title": "First Result",
"url": "https://example.com/1",
"content": "This is the first result content.",
"engine": "google",
"publishedDate": "2024-01-15",
},
{
"title": "Second Result",
"url": "https://example.com/2",
"content": "This is the second result content.",
"engine": "brave",
"publishedDate": None,
},
{
"title": "Third Result",
"url": "https://example.com/3",
"content": "This is the third result content.",
"engine": "duckduckgo",
"publishedDate": "2024-01-10",
},
],
}
@pytest.mark.anyio
async def test_search_success(self, tool, mock_search_response):
"""Test successful search."""
with patch("httpx.AsyncClient") as mock_client:
mock_response = MagicMock()
mock_response.json.return_value = mock_search_response
mock_response.raise_for_status = MagicMock()
mock_instance = AsyncMock()
mock_instance.get.return_value = mock_response
mock_instance.__aenter__.return_value = mock_instance
mock_instance.__aexit__.return_value = None
mock_client.return_value = mock_instance
result = await tool.execute(query="test query", num_results=3)
assert result.success
assert "First Result" in result.data
assert "https://example.com/1" in result.data
assert result.metadata["result_count"] == 3
@pytest.mark.anyio
async def test_search_empty_query(self, tool):
"""Test empty query is rejected."""
result = await tool.execute(query="")
assert not result.success
assert "empty" in result.error.lower()
@pytest.mark.anyio
async def test_search_whitespace_query(self, tool):
"""Test whitespace-only query is rejected."""
result = await tool.execute(query=" ")
assert not result.success
assert "empty" in result.error.lower()
@pytest.mark.anyio
async def test_search_no_results(self, tool):
"""Test when search returns no results."""
with patch("httpx.AsyncClient") as mock_client:
mock_response = MagicMock()
mock_response.json.return_value = {"query": "obscure", "results": []}
mock_response.raise_for_status = MagicMock()
mock_instance = AsyncMock()
mock_instance.get.return_value = mock_response
mock_instance.__aenter__.return_value = mock_instance
mock_instance.__aexit__.return_value = None
mock_client.return_value = mock_instance
result = await tool.execute(query="obscure nonexistent thing")
assert result.success
assert "No results" in result.data
assert result.metadata["result_count"] == 0
@pytest.mark.anyio
async def test_search_num_results_limit(self, tool, mock_search_response):
"""Test num_results limits output."""
with patch("httpx.AsyncClient") as mock_client:
mock_response = MagicMock()
mock_response.json.return_value = mock_search_response
mock_response.raise_for_status = MagicMock()
mock_instance = AsyncMock()
mock_instance.get.return_value = mock_response
mock_instance.__aenter__.return_value = mock_instance
mock_instance.__aexit__.return_value = None
mock_client.return_value = mock_instance
result = await tool.execute(query="test", num_results=2)
assert result.success
assert result.metadata["result_count"] == 2
@pytest.mark.anyio
async def test_search_num_results_clamped(self, tool, mock_search_response):
"""Test num_results is clamped to max."""
with patch("httpx.AsyncClient") as mock_client:
mock_response = MagicMock()
mock_response.json.return_value = mock_search_response
mock_response.raise_for_status = MagicMock()
mock_instance = AsyncMock()
mock_instance.get.return_value = mock_response
mock_instance.__aenter__.return_value = mock_instance
mock_instance.__aexit__.return_value = None
mock_client.return_value = mock_instance
# Request 100 but max is 10
result = await tool.execute(query="test", num_results=100)
assert result.success
# Should only get 3 (what's in mock response, capped at 10)
assert result.metadata["result_count"] <= 10
@pytest.mark.anyio
async def test_search_timeout_error(self, tool):
"""Test timeout handling."""
import httpx
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_instance.get.side_effect = httpx.TimeoutException("timeout")
mock_instance.__aenter__.return_value = mock_instance
mock_instance.__aexit__.return_value = None
mock_client.return_value = mock_instance
result = await tool.execute(query="test")
assert not result.success
assert "timed out" in result.error.lower()
@pytest.mark.anyio
async def test_search_request_error(self, tool):
"""Test network error handling."""
import httpx
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_instance.get.side_effect = httpx.RequestError("connection failed")
mock_instance.__aenter__.return_value = mock_instance
mock_instance.__aexit__.return_value = None
mock_client.return_value = mock_instance
result = await tool.execute(query="test")
assert not result.success
assert "failed" in result.error.lower()
@pytest.mark.anyio
async def test_search_includes_engines_metadata(self, tool, mock_search_response):
"""Test engines used are included in metadata."""
with patch("httpx.AsyncClient") as mock_client:
mock_response = MagicMock()
mock_response.json.return_value = mock_search_response
mock_response.raise_for_status = MagicMock()
mock_instance = AsyncMock()
mock_instance.get.return_value = mock_response
mock_instance.__aenter__.return_value = mock_instance
mock_instance.__aexit__.return_value = None
mock_client.return_value = mock_instance
result = await tool.execute(query="test", num_results=3)
assert result.success
engines = result.metadata.get("engines_used", [])
assert "google" in engines or "brave" in engines
@pytest.mark.anyio
async def test_search_with_categories(self, tool, mock_search_response):
"""Test category parameter is passed."""
with patch("httpx.AsyncClient") as mock_client:
mock_response = MagicMock()
mock_response.json.return_value = mock_search_response
mock_response.raise_for_status = MagicMock()
mock_instance = AsyncMock()
mock_instance.get.return_value = mock_response
mock_instance.__aenter__.return_value = mock_instance
mock_instance.__aexit__.return_value = None
mock_client.return_value = mock_instance
result = await tool.execute(query="test", categories="it")
assert result.success
# Verify get was called with categories parameter
call_args = mock_instance.get.call_args
assert "categories" in call_args.kwargs["params"]
assert call_args.kwargs["params"]["categories"] == "it"