diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index f4221ca5b..29b6aa562 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -300,6 +300,9 @@ class WebSearchTool(Tool): if provider == "kagi": api_key = self.config.api_key or os.environ.get("KAGI_API_KEY", "") return "kagi" if api_key else "duckduckgo" + if provider == "exa": + api_key = self.config.api_key or os.environ.get("EXA_API_KEY", "") + return "exa" if api_key else "duckduckgo" if provider == "olostep": api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "") return "olostep" if api_key else "duckduckgo" @@ -356,6 +359,8 @@ class WebSearchTool(Tool): return await self._search_brave(query, n) elif provider == "kagi": return await self._search_kagi(query, n) + elif provider == "exa": + return await self._search_exa(query, n) else: return f"Error: unknown search provider '{provider}'" @@ -542,6 +547,56 @@ class WebSearchTool(Tool): except Exception as e: return f"Error: {e}" + async def _search_exa(self, query: str, n: int) -> str: + api_key = self.config.api_key or os.environ.get("EXA_API_KEY", "") + if not api_key: + logger.warning("EXA_API_KEY not set, falling back to DuckDuckGo") + return await self._search_duckduckgo(query, n) + try: + headers = { + "Content-Type": "application/json", + "x-api-key": api_key, + "User-Agent": self.user_agent, + } + body = { + "query": query, + "numResults": n, + "contents": {"highlights": True}, + } + async with httpx.AsyncClient(proxy=self.proxy) as client: + r = await client.post( + "https://api.exa.ai/search", + headers=headers, + json=body, + timeout=float(self.config.timeout), + ) + r.raise_for_status() + items = [] + for result in r.json().get("results", []): + if not isinstance(result, dict): + continue + highlights = result.get("highlights") or [] + if isinstance(highlights, list): + content = "\n".join(str(highlight) for highlight in highlights if highlight) + else: + content = str(highlights) + if not content: + content = str(result.get("summary") or result.get("text") or "")[:500] + items.append( + { + "title": result.get("title", ""), + "url": result.get("url", ""), + "content": content, + } + ) + return _format_results(query, items, n) + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: + return "Error: Exa search rate limited. Try again later or reduce search frequency." + return f"Error: Exa search failed ({e.response.status_code}): {e}" + except Exception as e: + return f"Error: Exa search failed: {e}" + async def _search_volcengine( self, query: str, diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 87d0b77e1..bfa2eb736 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -78,6 +78,7 @@ _WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = ( {"name": "searxng", "label": "SearXNG", "credential": "base_url"}, {"name": "jina", "label": "Jina", "credential": "api_key"}, {"name": "kagi", "label": "Kagi", "credential": "api_key"}, + {"name": "exa", "label": "Exa", "credential": "api_key"}, {"name": "olostep", "label": "Olostep", "credential": "api_key"}, {"name": "volcengine", "label": "Volcengine Search", "credential": "api_key"}, ) diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index a0dd8ddf4..eaf0fac97 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -1699,6 +1699,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert body["web"]["fetch"]["use_jina_reader"] is True search_providers = {provider["name"]: provider for provider in body["web_search"]["providers"]} assert search_providers["duckduckgo"]["credential"] == "none" + assert search_providers["exa"]["credential"] == "api_key" assert search_providers["volcengine"]["credential"] == "api_key" assert search_providers["searxng"]["credential"] == "base_url" assert body["image_generation"]["enabled"] is False diff --git a/tests/tools/test_web_search_tool.py b/tests/tools/test_web_search_tool.py index 6c3225fbe..4645384f7 100644 --- a/tests/tools/test_web_search_tool.py +++ b/tests/tools/test_web_search_tool.py @@ -291,6 +291,71 @@ async def test_kagi_search(monkeypatch): assert "ignored related search" not in result +@pytest.mark.asyncio +async def test_exa_search(monkeypatch): + async def mock_post(self, url, **kw): + assert url == "https://api.exa.ai/search" + assert kw["headers"]["x-api-key"] == "exa-key" + assert kw["headers"]["User-Agent"] == "nanobot-search-test" + assert kw["json"] == { + "query": "test", + "numResults": 2, + "contents": {"highlights": True}, + } + return _response(json={ + "results": [ + { + "title": "Exa Result", + "url": "https://exa.ai", + "highlights": ["Relevant Exa highlight"], + } + ] + }) + + monkeypatch.setattr(httpx.AsyncClient, "post", mock_post) + tool = _tool(provider="exa", api_key="exa-key", user_agent="nanobot-search-test") + result = await tool.execute(query="test", count=2) + + assert "Exa Result" in result + assert "https://exa.ai" in result + assert "Relevant Exa highlight" in result + + +@pytest.mark.asyncio +async def test_exa_search_uses_env_api_key(monkeypatch): + async def mock_post(self, url, **kw): + assert kw["headers"]["x-api-key"] == "env-exa-key" + return _response(json={ + "results": [ + { + "title": "Env Exa Result", + "url": "https://exa.ai/env", + "summary": "Summary fallback", + } + ] + }) + + monkeypatch.setattr(httpx.AsyncClient, "post", mock_post) + monkeypatch.setenv("EXA_API_KEY", "env-exa-key") + tool = _tool(provider="exa", api_key="") + result = await tool.execute(query="test", count=1) + + assert "Env Exa Result" in result + assert "Summary fallback" in result + + +@pytest.mark.asyncio +async def test_exa_search_http_error(monkeypatch): + async def mock_post(self, url, **kw): + return _response(status=401, json={"error": "invalid key"}) + + monkeypatch.setattr(httpx.AsyncClient, "post", mock_post) + tool = _tool(provider="exa", api_key="bad-exa-key") + result = await tool.execute(query="test") + + assert "Error: Exa search failed (401)" in result + + @pytest.mark.asyncio async def test_unknown_provider(): tool = _tool(provider="unknown") @@ -377,6 +442,23 @@ async def test_kagi_fallback_to_duckduckgo_when_no_key(monkeypatch): assert "Fallback" in result +@pytest.mark.asyncio +async def test_exa_fallback_to_duckduckgo_when_no_key(monkeypatch): + class MockDDGS: + def __init__(self, **kw): + pass + + def text(self, query, max_results=5): + return [{"title": "Fallback", "href": "https://ddg.example", "body": "DuckDuckGo fallback"}] + + monkeypatch.setattr("ddgs.DDGS", MockDDGS) + monkeypatch.delenv("EXA_API_KEY", raising=False) + + tool = _tool(provider="exa", api_key="") + result = await tool.execute(query="test") + assert "Fallback" in result + + @pytest.mark.asyncio async def test_jina_search_uses_path_encoded_query(monkeypatch): calls = {}