diff --git a/README.md b/README.md index 1a361f1a..3eab7d3d 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Strands Agents Tools provides a powerful set of tools for your agents to use. It - 🧠 **Advanced Reasoning** - Tools for complex thinking and reasoning capabilities - 🐝 **Swarm Intelligence** - Coordinate multiple AI agents for parallel problem solving with shared memory - 🔄 **Multiple tools in Parallel** - Call multiple other tools at the same time in parallel with Batch Tool +- 🌐 **Internet Search** - Internet search tool supporting multiple backends (DuckDuckGo, SerpAPI, Tavily, etc.) ## 📦 Installation @@ -121,6 +122,7 @@ Below is a comprehensive table of all available tools, how to use them with an a | use_llm | `agent.tool.use_llm(prompt="Analyze this data", system_prompt="You are a data analyst")` | Create nested AI loops with customized system prompts for specialized tasks | | workflow | `agent.tool.workflow(action="create", name="data_pipeline", steps=[{"tool": "file_read"}, {"tool": "python_repl"}])` | Define, execute, and manage multi-step automated workflows | | batch| `agent.tool.batch(invocations=[{"name": "current_time", "arguments": {"timezone": "Europe/London"}}, {"name": "stop", "arguments": {}}])` | Call multiple other tools in parallel. | +| internet_search| `agent.tool.internet_search(query="latest AI news", max_results=10, backend="serpapi", serpapi_api_key="<api_key>")` | Make search queries via search engine APIs. E.g. DuckDuckGo, SerpAPI, Tavily, etc. | ## 💻 Usage Examples @@ -295,6 +297,21 @@ result = agent.tool.batch( ) ``` + +### Internet Search Tool + +```python +from strands import Agent + +from strands import Agent +from strands_tools import internet_search + +# Example usage of internet search tool +agent = Agent(tools=[internet_search]) + +result = agent.tool.internet_search(query="latest AI news", max_results=10, backend="duckduckgo") +``` + ## 🌍 Environment Variables Configuration Agents Tools provides extensive customization through environment variables. This allows you to configure tool behavior without modifying code, making it ideal for different environments (development, testing, production). diff --git a/pyproject.toml b/pyproject.toml index 3042e361..2b1a98c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,9 @@ dependencies = [ "tenacity>=9.1.2,<10.0.0", "watchdog>=6.0.0,<7.0.0", "slack_bolt>=1.23.0,<2.0.0", + "duckduckgo-search", + "google-search-results", + "tavily-python", # Note: Always want the latest tzdata "tzdata ; platform_system == 'Windows'", ] diff --git a/src/strands_tools/internet_search.py b/src/strands_tools/internet_search.py new file mode 100644 index 00000000..d585dbfc --- /dev/null +++ b/src/strands_tools/internet_search.py @@ -0,0 +1,165 @@ +""" +Internet search tool + +Internet search tool for Strands Agent supporting multiple backends (DuckDuckGo, SerpAPI, Tavily, etc). + +Example usage: + + # DuckDuckGo (default) + result = agent.tool.internet_search(query="latest AI news", max_results=5) + + # SerpAPI (requires API key) + result = agent.tool.internet_search( + query="latest AI news", + max_results=5, + backend="serpapi", + serpapi_api_key="YOUR_SERPAPI_KEY" + ) + + # Tavily (requires API key) + result = agent.tool.internet_search( + query="latest AI news", + max_results=5, + backend="tavily", + tavily_api_key="YOUR_TAVILY_KEY" + ) + +""" + +from typing import Any, Dict, List + +from duckduckgo_search import DDGS +from strands.types.tools import ToolResult, ToolUse + +try: + from serpapi import GoogleSearch +except ImportError: + GoogleSearch = None + +try: + from tavily import TavilyClient +except ImportError: + TavilyClient = None + +TOOL_SPEC = { + "name": "internet_search", + "description": "Search the internet for up-to-date information using DuckDuckGo, SerpAPI, Tavily, etc.", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "The search query to look up on the internet."}, + "max_results": { + "type": "integer", + "description": "Maximum number of search results to return.", + "default": 5, + }, + "backend": { + "type": "string", + "description": "Which backend to use: 'duckduckgo', 'serpapi', or 'tavily'.", + "default": "duckduckgo", + }, + "serpapi_api_key": { + "type": "string", + "description": "API key for SerpAPI (required if backend is 'serpapi').", + }, + "tavily_api_key": { + "type": "string", + "description": "API key for Tavily (required if backend is 'tavily').", + }, + }, + "required": ["query"], + } + }, +} + + +def _search_with_backend( + backend: str, + query: str, + max_results: int, + serpapi_api_key: str = None, + tavily_api_key: str = None, +) -> List[Dict[str, Any]]: + if backend == "duckduckgo": + results = [] + with DDGS() as ddgs: + for r in ddgs.text(query, max_results=max_results): + results.append({"title": r.get("title"), "href": r.get("href"), "body": r.get("body")}) + return results + + elif backend == "serpapi": + if GoogleSearch is None: + raise ImportError("serpapi is not installed. Install with: pip install google-search-results") + if not serpapi_api_key: + raise ValueError("serpapi_api_key is required for SerpAPI backend.") + params = { + "q": query, + "api_key": serpapi_api_key, + "num": max_results, + "engine": "google", + "hl": "en", # Language + } + search = GoogleSearch(params) + serp_results = search.get_dict() + + # Check for error in response + if "error" in serp_results: + raise ValueError(f"SerpAPI error: {serp_results['error']}") + + if not serp_results or "organic_results" not in serp_results: + raise ValueError("No organic results found in SerpAPI response.") + + results = [] + for item in serp_results.get("organic_results", [])[:max_results]: + results.append( + { + "title": item.get("title"), + "href": item.get("link"), + "body": item.get("snippet"), + } + ) + return results + + elif backend == "tavily": + if TavilyClient is None: + raise ImportError("tavily-python is not installed. Install with: pip install tavily-python") + if not tavily_api_key: + raise ValueError("tavily_api_key is required for Tavily backend.") + client = TavilyClient(api_key=tavily_api_key) + tavily_results = client.search(query=query, search_depth="advanced", max_results=max_results) + results = [] + for item in tavily_results.get("results", [])[:max_results]: + results.append( + { + "title": item.get("title"), + "href": item.get("url"), + "body": item.get("content"), + } + ) + return results + + else: + raise ValueError(f"Unknown backend: {backend}") + + +def internet_search(tool: ToolUse, **kwargs: Any) -> ToolResult: + tool_input = tool.get("input", tool) + tool_use_id = tool.get("toolUseId", "default_id") + query = tool_input.get("query") + max_results = tool_input.get("max_results", 5) + backend = tool_input.get("backend", "duckduckgo") + serpapi_api_key = tool_input.get("serpapi_api_key") or kwargs.get("serpapi_api_key") + tavily_api_key = tool_input.get("tavily_api_key") or kwargs.get("tavily_api_key") + + try: + results = _search_with_backend( + backend=backend, + query=query, + max_results=max_results, + serpapi_api_key=serpapi_api_key, + tavily_api_key=tavily_api_key, + ) + return {"toolUseId": tool_use_id, "status": "success", "content": [{"json": {"results": results}}]} + except Exception as e: + return {"toolUseId": tool_use_id, "status": "error", "content": [{"text": f"Internet search failed: {str(e)}"}]} diff --git a/tests/test_internet_search.py b/tests/test_internet_search.py new file mode 100644 index 00000000..5a0fcb5f --- /dev/null +++ b/tests/test_internet_search.py @@ -0,0 +1,90 @@ +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def mock_agent(): + """Fixture to create a mock agent with the internet_search tool.""" + agent = MagicMock() + agent.tool.internet_search = MagicMock() + return agent + + +def extract_results(result): + """Extract the results list from the agent/tool response.""" + if isinstance(result, dict) and "content" in result and isinstance(result["content"], list): + for item in result["content"]: + if "json" in item and "results" in item["json"]: + return item["json"]["results"] + return [] + + +def test_duckduckgo_search_with_mock_agent(mock_agent): + """Test DuckDuckGo backend search using mock agent.""" + mock_results = [ + {"title": "AI News 1", "href": "http://a.com", "body": "Summary 1"}, + {"title": "AI News 2", "href": "http://b.com", "body": "Summary 2"}, + ] + mock_agent.tool.internet_search.return_value = { + "toolUseId": "test-duck-id", + "status": "success", + "content": [{"json": {"results": mock_results}}], + } + result = mock_agent.tool.internet_search(query="AI news", max_results=2) + assert result["status"] == "success" + results = extract_results(result) + assert len(results) == 2 + assert results[0]["title"] == "AI News 1" + + +def test_serpapi_search_with_mock_agent(mock_agent): + """Test SerpAPI backend search using mock agent.""" + mock_results = [ + {"title": "Serp Result 1", "href": "http://serp1.com", "body": "Serp snippet 1"}, + {"title": "Serp Result 2", "href": "http://serp2.com", "body": "Serp snippet 2"}, + ] + mock_agent.tool.internet_search.return_value = { + "toolUseId": "test-serpapi-id", + "status": "success", + "content": [{"json": {"results": mock_results}}], + } + result = mock_agent.tool.internet_search( + query="AI news", max_results=2, backend="serpapi", serpapi_api_key="dummy-key" + ) + assert result["status"] == "success" + results = extract_results(result) + assert len(results) == 2 + assert results[0]["title"] == "Serp Result 1" + + +def test_tavily_search_with_mock_agent(mock_agent): + """Test Tavily backend search using mock agent.""" + mock_results = [ + {"title": "Tavily 1", "href": "http://tav1.com", "body": "Tavily content 1"}, + {"title": "Tavily 2", "href": "http://tav2.com", "body": "Tavily content 2"}, + ] + mock_agent.tool.internet_search.return_value = { + "toolUseId": "test-tavily-id", + "status": "success", + "content": [{"json": {"results": mock_results}}], + } + result = mock_agent.tool.internet_search( + query="AI news", max_results=2, backend="tavily", tavily_api_key="dummy-key" + ) + assert result["status"] == "success" + results = extract_results(result) + assert len(results) == 2 + assert results[0]["title"] == "Tavily 1" + + +def test_invalid_backend_with_mock_agent(mock_agent): + """Test error on unknown backend using mock agent.""" + mock_agent.tool.internet_search.return_value = { + "toolUseId": "test-invalid-backend", + "status": "error", + "content": [{"text": "Unknown backend: notarealbackend"}], + } + result = mock_agent.tool.internet_search(query="AI news", backend="notarealbackend") + assert result["status"] == "error" + assert "Unknown backend" in result["content"][0]["text"]