mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-20 08:32:25 +00:00
feat(web): add Olostep as a configurable web search provider
This commit is contained in:
parent
407314a672
commit
9cf9272920
20
README.md
20
README.md
@ -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)
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
69
tests/test_web_search_olostep.py
Normal file
69
tests/test_web_search_olostep.py
Normal 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"
|
||||||
Loading…
x
Reference in New Issue
Block a user