feat(web): add Olostep as a configurable web search provider

This commit is contained in:
umerkay 2026-04-23 11:21:45 +05:00 committed by chengyongru
parent 407314a672
commit 9cf9272920
6 changed files with 173 additions and 5 deletions

View File

@ -187,6 +187,26 @@ Configure these **two parts** in your config (other options have defaults). Add
nanobot agent 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 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) - Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [Chat Apps](./docs/chat-apps.md)

View File

@ -320,7 +320,12 @@ class AgentLoop:
) )
if self.web_config.enable: if self.web_config.enable:
self.tools.register( 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(WebFetchTool(proxy=self.web_config.proxy))
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound)) self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))

View File

@ -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.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.utils.helpers import build_image_content_blocks 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: if TYPE_CHECKING:
from nanobot.config.schema import WebSearchConfig from nanobot.config.schema import WebSearchConfig
@ -90,15 +105,27 @@ class WebSearchTool(Tool):
"Use web_fetch to read a specific page in full." "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 from nanobot.config.schema import WebSearchConfig
self.config = config if config is not None else WebSearchConfig() self.config = config if config is not None else WebSearchConfig()
self.proxy = proxy 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: def _effective_provider(self) -> str:
"""Resolve the backend that execute() will actually use.""" """Resolve the backend that execute() will actually use."""
provider = self.config.provider.strip().lower() or "brave" provider = self.provider or "brave"
if provider == "duckduckgo": if provider == "duckduckgo":
return "duckduckgo" return "duckduckgo"
if provider == "brave": if provider == "brave":
@ -116,6 +143,8 @@ class WebSearchTool(Tool):
if provider == "kagi": if provider == "kagi":
api_key = self.config.api_key or os.environ.get("KAGI_API_KEY", "") api_key = self.config.api_key or os.environ.get("KAGI_API_KEY", "")
return "kagi" if api_key else "duckduckgo" return "kagi" if api_key else "duckduckgo"
if provider == "olostep":
return "olostep"
return provider return provider
@property @property
@ -128,9 +157,19 @@ class WebSearchTool(Tool):
return self._effective_provider() == "duckduckgo" return self._effective_provider() == "duckduckgo"
async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str: 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) 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": if provider == "duckduckgo":
return await self._search_duckduckgo(query, n) return await self._search_duckduckgo(query, n)
elif provider == "tavily": elif provider == "tavily":
@ -146,6 +185,39 @@ class WebSearchTool(Tool):
else: else:
return f"Error: unknown search provider '{provider}'" 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: async def _search_brave(self, query: str, n: int) -> str:
api_key = self.config.api_key or os.environ.get("BRAVE_API_KEY", "") api_key = self.config.api_key or os.environ.get("BRAVE_API_KEY", "")
if not api_key: if not api_key:

View File

@ -176,8 +176,9 @@ class GatewayConfig(Base):
class WebSearchConfig(Base): class WebSearchConfig(Base):
"""Web search tool configuration.""" """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 = "" api_key: str = ""
olostep_api_key: str | None = None
base_url: str = "" # SearXNG base URL base_url: str = "" # SearXNG base URL
max_results: int = 5 max_results: int = 5
timeout: int = 30 # Wall-clock timeout (seconds) for search operations timeout: int = 30 # Wall-clock timeout (seconds) for search operations

View File

@ -30,6 +30,7 @@ dependencies = [
"websocket-client>=1.9.0,<2.0.0", "websocket-client>=1.9.0,<2.0.0",
"httpx>=0.28.0,<1.0.0", "httpx>=0.28.0,<1.0.0",
"ddgs>=9.5.5,<10.0.0", "ddgs>=9.5.5,<10.0.0",
"olostep>=0.1.0",
"oauth-cli-kit>=0.1.3,<1.0.0", "oauth-cli-kit>=0.1.3,<1.0.0",
"loguru>=0.7.3,<1.0.0", "loguru>=0.7.3,<1.0.0",
"readability-lxml>=0.8.4,<1.0.0", "readability-lxml>=0.8.4,<1.0.0",

View File

@ -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"