From 9cf9272920d198399b6ed4db7c50f1cb1fce6f81 Mon Sep 17 00:00:00 2001 From: umerkay Date: Thu, 23 Apr 2026 11:21:45 +0500 Subject: [PATCH] feat(web): add Olostep as a configurable web search provider --- README.md | 20 ++++++++ nanobot/agent/loop.py | 7 ++- nanobot/agent/tools/web.py | 78 ++++++++++++++++++++++++++++++-- nanobot/config/schema.py | 3 +- pyproject.toml | 1 + tests/test_web_search_olostep.py | 69 ++++++++++++++++++++++++++++ 6 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 tests/test_web_search_olostep.py diff --git a/README.md b/README.md index 90a3d2f4c..09577b1b5 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,26 @@ Configure these **two parts** in your config (other options have defaults). Add nanobot agent ``` +**Optional: Web search via Olostep** + +Default search provider stays `brave`, so existing setups are unchanged. To switch to Olostep: + +```json +{ + "tools": { + "web": { + "search": { + "provider": "olostep", + "olostepApiKey": "YOUR_OLOSTEP_API_KEY" + } + } + } +} +``` + +You can also set the key with the `OLOSTEP_API_KEY` environment variable. +Get an API key at https://www.olostep.com/dashboard. + - Want different LLM providers, web search, MCP, security settings, or more config options? See [Configuration](./docs/configuration.md) - Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [Chat Apps](./docs/chat-apps.md) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index f1efc16e2..740f10eb7 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -320,7 +320,12 @@ class AgentLoop: ) if self.web_config.enable: self.tools.register( - WebSearchTool(config=self.web_config.search, proxy=self.web_config.proxy) + WebSearchTool( + config=self.web_config.search, + proxy=self.web_config.proxy, + provider=self.web_config.search.provider, + olostep_api_key=self.web_config.search.olostep_api_key, + ) ) self.tools.register(WebFetchTool(proxy=self.web_config.proxy)) self.tools.register(MessageTool(send_callback=self.bus.publish_outbound)) diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 31d4cdef2..0d5305003 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -17,6 +17,21 @@ from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema from nanobot.utils.helpers import build_image_content_blocks +try: + from olostep import AsyncOlostep, Olostep, Olostep_BaseError + + _OLOSTEP_SYNC_CLIENT = Olostep + _OLOSTEP_AVAILABLE = True +except ImportError: + Olostep = None + AsyncOlostep = None + _OLOSTEP_SYNC_CLIENT = None + + class Olostep_BaseError(Exception): + """Fallback error type when olostep package is unavailable.""" + + _OLOSTEP_AVAILABLE = False + if TYPE_CHECKING: from nanobot.config.schema import WebSearchConfig @@ -90,15 +105,27 @@ class WebSearchTool(Tool): "Use web_fetch to read a specific page in full." ) - def __init__(self, config: WebSearchConfig | None = None, proxy: str | None = None): + def __init__( + self, + config: WebSearchConfig | None = None, + proxy: str | None = None, + provider: str | None = None, + olostep_api_key: str | None = None, + ): from nanobot.config.schema import WebSearchConfig self.config = config if config is not None else WebSearchConfig() self.proxy = proxy + self.provider = (provider or self.config.provider or "brave").strip().lower() + self.olostep_api_key = ( + olostep_api_key + or self.config.olostep_api_key + or os.environ.get("OLOSTEP_API_KEY", "") + ) def _effective_provider(self) -> str: """Resolve the backend that execute() will actually use.""" - provider = self.config.provider.strip().lower() or "brave" + provider = self.provider or "brave" if provider == "duckduckgo": return "duckduckgo" if provider == "brave": @@ -116,6 +143,8 @@ 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": + return "olostep" return provider @property @@ -128,9 +157,19 @@ class WebSearchTool(Tool): return self._effective_provider() == "duckduckgo" async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str: - provider = self.config.provider.strip().lower() or "brave" + provider = self.provider or "brave" n = min(max(count or self.config.max_results, 1), 10) + if provider == "olostep": + if not self.olostep_api_key: + return ( + "Error: Olostep API key not configured. " + "Set it in ~/.nanobot/config.json under " + "tools.web.search.olostepApiKey and restart." + ) + if not _OLOSTEP_AVAILABLE: + return "Error: olostep package not installed. Run: pip install olostep" + return await self._search_olostep(query, n) if provider == "duckduckgo": return await self._search_duckduckgo(query, n) elif provider == "tavily": @@ -146,6 +185,39 @@ class WebSearchTool(Tool): else: return f"Error: unknown search provider '{provider}'" + async def _search_olostep(self, query: str, n: int) -> str: + if AsyncOlostep is None: + return "Error: olostep package not installed. Run: pip install olostep" + try: + async with AsyncOlostep(api_key=self.olostep_api_key) as client: + result = await client.answers.create(task=query) + + answer_text = (getattr(result, "answer", "") or "").strip() + lines = [f"Answer: {answer_text}"] if answer_text else ["Answer:"] + + sources = getattr(result, "sources", None) or [] + if sources: + lines.append("") + lines.append("Sources:") + 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: + lines.append(f"{i}. {title} — {url}") + elif url: + lines.append(f"{i}. {url}") + elif title: + lines.append(f"{i}. {title}") + return "\n".join(lines) + 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 7f523cef0..22166ff77 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -176,8 +176,9 @@ class GatewayConfig(Base): class WebSearchConfig(Base): """Web search tool configuration.""" - provider: str = "duckduckgo" # brave, tavily, duckduckgo, searxng, jina, kagi + provider: str = "brave" # brave, tavily, duckduckgo, searxng, jina, kagi, olostep api_key: str = "" + olostep_api_key: str | None = None base_url: str = "" # SearXNG base URL max_results: int = 5 timeout: int = 30 # Wall-clock timeout (seconds) for search operations diff --git a/pyproject.toml b/pyproject.toml index 1e4ca97df..a106779ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "websocket-client>=1.9.0,<2.0.0", "httpx>=0.28.0,<1.0.0", "ddgs>=9.5.5,<10.0.0", + "olostep>=0.1.0", "oauth-cli-kit>=0.1.3,<1.0.0", "loguru>=0.7.3,<1.0.0", "readability-lxml>=0.8.4,<1.0.0", diff --git a/tests/test_web_search_olostep.py b/tests/test_web_search_olostep.py new file mode 100644 index 000000000..6207a4900 --- /dev/null +++ b/tests/test_web_search_olostep.py @@ -0,0 +1,69 @@ +"""Tests for Olostep web search provider.""" + +from types import SimpleNamespace + +import pytest + +import nanobot.agent.tools.web as web_mod +from nanobot.agent.tools.web import WebSearchTool +from nanobot.config.schema import WebSearchConfig + + +@pytest.mark.asyncio +async def test_olostep_search_formats_answer_and_sources(monkeypatch): + 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")], + ) + + monkeypatch.setattr(web_mod, "_OLOSTEP_AVAILABLE", True) + monkeypatch.setattr(web_mod, "AsyncOlostep", MockAsyncOlostep) + + tool = WebSearchTool(config=WebSearchConfig(provider="olostep", 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_returns_config_error(monkeypatch): + monkeypatch.delenv("OLOSTEP_API_KEY", raising=False) + + tool = WebSearchTool(config=WebSearchConfig(provider="olostep", olostep_api_key="")) + result = await tool.execute(query="test query") + + assert ( + result + == "Error: Olostep API key not configured. " + "Set it in ~/.nanobot/config.json under " + "tools.web.search.olostepApiKey and restart." + ) + + +@pytest.mark.asyncio +async def test_olostep_package_missing_returns_install_hint(monkeypatch): + monkeypatch.setattr(web_mod, "_OLOSTEP_AVAILABLE", False) + + tool = WebSearchTool(config=WebSearchConfig(provider="olostep", olostep_api_key="olostep-key")) + result = await tool.execute(query="test query") + + assert result == "Error: olostep package not installed. Run: pip install olostep"