diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 9ac923050..b8aeab47b 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -8,7 +8,7 @@ import json import os import re from typing import TYPE_CHECKING, Any -from urllib.parse import urlparse +from urllib.parse import quote, urlparse import httpx from loguru import logger @@ -182,10 +182,10 @@ class WebSearchTool(Tool): return await self._search_duckduckgo(query, n) try: headers = {"Accept": "application/json", "Authorization": f"Bearer {api_key}"} + encoded_query = quote(query, safe="") async with httpx.AsyncClient(proxy=self.proxy) as client: r = await client.get( - f"https://s.jina.ai/", - params={"q": query}, + f"https://s.jina.ai/{encoded_query}", headers=headers, timeout=15.0, ) @@ -197,7 +197,8 @@ class WebSearchTool(Tool): ] return _format_results(query, items, n) except Exception as e: - return f"Error: {e}" + logger.warning("Jina search failed ({}), falling back to DuckDuckGo", e) + return await self._search_duckduckgo(query, n) async def _search_duckduckgo(self, query: str, n: int) -> str: try: diff --git a/tests/tools/test_web_search_tool.py b/tests/tools/test_web_search_tool.py index 02bf44395..5445fc67b 100644 --- a/tests/tools/test_web_search_tool.py +++ b/tests/tools/test_web_search_tool.py @@ -160,3 +160,70 @@ async def test_searxng_invalid_url(): tool = _tool(provider="searxng", base_url="not-a-url") result = await tool.execute(query="test") assert "Error" in result + + +@pytest.mark.asyncio +async def test_jina_422_falls_back_to_duckduckgo(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"}] + + async def mock_get(self, url, **kw): + assert "s.jina.ai" in str(url) + raise httpx.HTTPStatusError( + "422 Unprocessable Entity", + request=httpx.Request("GET", str(url)), + response=httpx.Response(422, request=httpx.Request("GET", str(url))), + ) + + monkeypatch.setattr(httpx.AsyncClient, "get", mock_get) + monkeypatch.setattr("ddgs.DDGS", MockDDGS) + + tool = _tool(provider="jina", api_key="jina-key") + result = await tool.execute(query="test") + assert "DuckDuckGo fallback" in result + + +@pytest.mark.asyncio +async def test_jina_search_uses_path_encoded_query(monkeypatch): + calls = {} + + async def mock_get(self, url, **kw): + calls["url"] = str(url) + calls["params"] = kw.get("params") + return _response(json={ + "data": [{"title": "Jina Result", "url": "https://jina.ai", "content": "AI search"}] + }) + + monkeypatch.setattr(httpx.AsyncClient, "get", mock_get) + tool = _tool(provider="jina", api_key="jina-key") + await tool.execute(query="hello world") + assert calls["url"].rstrip("/") == "https://s.jina.ai/hello%20world" + assert calls["params"] in (None, {}) + + +@pytest.mark.asyncio +async def test_jina_422_falls_back_to_duckduckgo(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"}] + + async def mock_get(self, url, **kw): + raise httpx.HTTPStatusError( + "422 Unprocessable Entity", + request=httpx.Request("GET", str(url)), + response=httpx.Response(422, request=httpx.Request("GET", str(url))), + ) + + monkeypatch.setattr(httpx.AsyncClient, "get", mock_get) + monkeypatch.setattr("ddgs.DDGS", MockDDGS) + + tool = _tool(provider="jina", api_key="jina-key") + result = await tool.execute(query="test") + assert "DuckDuckGo fallback" in result