From 28f9bbff314cf90b0401b3aa220ca7a723c4f4ab Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 28 Apr 2026 18:30:30 +0800 Subject: [PATCH] feat(web_search): add olostep provider Adds Olostep (https://www.olostep.com) as an optional web_search backend using the official olostep Python SDK (client.answers.create()). Changes: - pyproject.toml: adds olostep>=0.1.0 optional dependency - schema.py: adds olostep to provider comment in WebSearchConfig - web.py: adds _search_olostep() with lazy import and provider branching - docs/configuration.md: documents Olostep setup under web search config - tests: unit tests for the new provider Backward compatible: existing users see no behavior change unless they opt into provider: "olostep". No hard dependency at runtime path. Co-authored-by: umerkay --- docs/configuration.md | 17 +++++++ nanobot/agent/tools/web.py | 57 +++++++++++++++++++++ nanobot/config/schema.py | 2 +- pyproject.toml | 3 ++ tests/tools/test_web_search_tool.py | 79 +++++++++++++++++++++++++++++ 5 files changed, 157 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index c919db067..427b64d4c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -546,6 +546,7 @@ By default, web search uses `duckduckgo`, and it works out of the box without an | `tavily` | `apiKey` | `TAVILY_API_KEY` | No | | `jina` | `apiKey` | `JINA_API_KEY` | Free tier (10M tokens) | | `kagi` | `apiKey` | `KAGI_API_KEY` | No | +| `olostep` | `apiKey` | `OLOSTEP_API_KEY` | No | | `searxng` | `baseUrl` | `SEARXNG_BASE_URL` | Yes (self-hosted) | | `duckduckgo` (default) | — | — | Yes | @@ -605,6 +606,22 @@ By default, web search uses `duckduckgo`, and it works out of the box without an } ``` +**Olostep:** +```json +{ + "tools": { + "web": { + "search": { + "provider": "olostep", + "apiKey": "YOUR_OLOSTEP_API_KEY" + } + } + } +} +``` + +You can also set `OLOSTEP_API_KEY` in the environment instead of storing it in config. + **SearXNG** (self-hosted, no API key needed): ```json { diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 28a4c557f..e0372bead 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -119,6 +119,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 == "olostep": + api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "") + return "olostep" if api_key else "duckduckgo" return provider @property @@ -134,6 +137,8 @@ class WebSearchTool(Tool): provider = self.config.provider.strip().lower() or "brave" n = min(max(count or self.config.max_results, 1), 10) + if provider == "olostep": + return await self._search_olostep(query, n) if provider == "duckduckgo": return await self._search_duckduckgo(query, n) elif provider == "tavily": @@ -149,6 +154,58 @@ class WebSearchTool(Tool): else: return f"Error: unknown search provider '{provider}'" + async def _search_olostep(self, query: str, n: int) -> str: + try: + from olostep import AsyncOlostep, Olostep_BaseError + except ImportError: + return "Error: olostep package not installed. Run: pip install olostep" + api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "") + if not api_key: + logger.warning("OLOSTEP_API_KEY not set, falling back to DuckDuckGo") + return await self._search_duckduckgo(query, n) + try: + async with AsyncOlostep(api_key=api_key) as client: + if self.proxy: + transport = getattr(client, "_transport", None) + http_client = getattr(transport, "_client", None) + if transport is not None and isinstance(http_client, httpx.AsyncClient): + await http_client.aclose() + transport._client = httpx.AsyncClient( # type: ignore[attr-defined] + proxy=self.proxy, + headers=dict(http_client.headers), + timeout=http_client.timeout, + limits=httpx.Limits( + max_keepalive_connections=100, + max_connections=200, + ), + http2=True, + ) + result = await client.answers.create(task=query) + + sources = getattr(result, "sources", None) or [] + source_lines = [] + for i, source in enumerate(sources[:n], 1): + if isinstance(source, dict): + title = source.get("title", "") + url = source.get("url", "") + else: + title = getattr(source, "title", "") + url = getattr(source, "url", "") + if title and url: + source_lines.append(f"{i}. {title} — {url}") + elif url: + source_lines.append(f"{i}. {url}") + elif title: + source_lines.append(f"{i}. {title}") + + answer_text = getattr(result, "answer", "") or "" + items = [{"title": answer_text or "Olostep answer", "url": "", "content": "\n".join(source_lines)}] + return _format_results(query, items, n) + except Olostep_BaseError as e: + return f"Olostep search error: {type(e).__name__}: {e}" + except Exception as e: + return f"Olostep search error: {type(e).__name__}: {e}" + async def _search_brave(self, query: str, n: int) -> str: api_key = self.config.api_key or os.environ.get("BRAVE_API_KEY", "") if not api_key: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 4a9bd97a2..a6c9d10c4 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -181,7 +181,7 @@ class GatewayConfig(Base): class WebSearchConfig(Base): """Web search tool configuration.""" - provider: str = "duckduckgo" # brave, tavily, duckduckgo, searxng, jina, kagi + provider: str = "duckduckgo" # brave, tavily, duckduckgo, searxng, jina, kagi, olostep api_key: str = "" base_url: str = "" # SearXNG base URL max_results: int = 5 diff --git a/pyproject.toml b/pyproject.toml index 1e4ca97df..0ac2c775d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,9 @@ langsmith = [ pdf = [ "pymupdf>=1.25.0", ] +olostep = [ + "olostep>=0.1.0", +] dev = [ "pytest>=9.0.0,<10.0.0", "pytest-asyncio>=1.3.0,<2.0.0", diff --git a/tests/tools/test_web_search_tool.py b/tests/tools/test_web_search_tool.py index 116d4db09..910703f0b 100644 --- a/tests/tools/test_web_search_tool.py +++ b/tests/tools/test_web_search_tool.py @@ -294,3 +294,82 @@ async def test_duckduckgo_timeout_returns_error(monkeypatch): result = await tool.execute(query="test") gate.set() assert "Error" in result + + +@pytest.mark.asyncio +async def test_olostep_search_formats_answer_and_sources(monkeypatch): + from types import SimpleNamespace + + calls: dict[str, str] = {} + + class MockAsyncOlostep: + def __init__(self, api_key: str): + calls["api_key"] = api_key + self.answers = self + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def create(self, task: str): + calls["task"] = task + return SimpleNamespace( + answer="Mocked Olostep answer", + sources=[SimpleNamespace(title="Example Source", url="https://example.com")], + ) + + import sys + import types + + fake_mod = types.ModuleType("olostep") + fake_mod.AsyncOlostep = MockAsyncOlostep + fake_mod.Olostep_BaseError = Exception + monkeypatch.setitem(sys.modules, "olostep", fake_mod) + + tool = _tool(provider="olostep", api_key="olostep-key") + result = await tool.execute(query="test query") + + assert calls["api_key"] == "olostep-key" + assert calls["task"] == "test query" + assert "Mocked Olostep answer" in result + assert "Example Source" in result + assert "https://example.com" in result + + +@pytest.mark.asyncio +async def test_olostep_missing_key_falls_back_to_duckduckgo(monkeypatch): + import sys + import types + from unittest.mock import patch + + class MockDDGS: + def __init__(self, **kw): + pass + + def text(self, query, max_results=5): + return [{"title": "Fallback", "href": "https://ddg.example", "body": "fallback"}] + + fake_mod = types.ModuleType("olostep") + fake_mod.AsyncOlostep = object + fake_mod.Olostep_BaseError = Exception + monkeypatch.setitem(sys.modules, "olostep", fake_mod) + + monkeypatch.delenv("OLOSTEP_API_KEY", raising=False) + with patch("ddgs.DDGS", MockDDGS): + tool = _tool(provider="olostep", api_key="") + result = await tool.execute(query="test query") + + assert "Fallback" in result + + +@pytest.mark.asyncio +async def test_olostep_package_missing_returns_install_hint(monkeypatch): + import sys + monkeypatch.delitem(sys.modules, "olostep", raising=False) + monkeypatch.setitem(sys.modules, "olostep", None) + tool = _tool(provider="olostep", api_key="olostep-key") + result = await tool.execute(query="test query") + + assert result == "Error: olostep package not installed. Run: pip install olostep"