diff --git a/README.md b/README.md index 09577b1b5..90a3d2f4c 100644 --- a/README.md +++ b/README.md @@ -187,26 +187,6 @@ 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/docs/configuration.md b/docs/configuration.md index 6bb70ea99..70bba0dc6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -485,7 +485,7 @@ When a channel `send()` raises, nanobot retries at the channel-manager layer. By nanobot supports multiple web search providers. Configure in `~/.nanobot/config.json` under `tools.web.search`. -By default, web tools are enabled and web search uses `duckduckgo`, so search works out of the box without an API key. +By default, web tools are enabled and web search uses `brave` when a Brave key is configured; otherwise it falls back to `duckduckgo`, so search still works out of the box without an API key. If you want to disable all built-in web tools entirely, set `tools.web.enable` to `false`. This removes both `web_search` and `web_fetch` from the tool list sent to the LLM. @@ -505,6 +505,7 @@ If you need to allow trusted private ranges such as Tailscale / CGNAT addresses, | `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 | @@ -575,6 +576,22 @@ If you need to allow trusted private ranges such as Tailscale / CGNAT addresses, } ``` +**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/loop.py b/nanobot/agent/loop.py index 740f10eb7..f1efc16e2 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -320,12 +320,7 @@ class AgentLoop: ) if self.web_config.enable: self.tools.register( - 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, - ) + WebSearchTool(config=self.web_config.search, proxy=self.web_config.proxy) ) 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 0d5305003..22dff91bd 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -18,14 +18,11 @@ from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_paramet from nanobot.utils.helpers import build_image_content_blocks try: - from olostep import AsyncOlostep, Olostep, Olostep_BaseError + from olostep import AsyncOlostep, 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.""" @@ -109,19 +106,12 @@ class WebSearchTool(Tool): 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", "") - ) + self.provider = (self.config.provider or "brave").strip().lower() def _effective_provider(self) -> str: """Resolve the backend that execute() will actually use.""" @@ -144,7 +134,8 @@ class WebSearchTool(Tool): 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" + api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "") + return "olostep" if api_key else "duckduckgo" return provider @property @@ -161,14 +152,6 @@ class WebSearchTool(Tool): 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) @@ -188,31 +171,48 @@ class WebSearchTool(Tool): async def _search_olostep(self, query: str, n: int) -> str: if AsyncOlostep is None: 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=self.olostep_api_key) as client: + 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) - 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) + 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: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 22166ff77..cca842f2b 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -178,7 +178,6 @@ class WebSearchConfig(Base): 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 a106779ef..1e4ca97df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ 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 index 6207a4900..f68ca58ad 100644 --- a/tests/test_web_search_olostep.py +++ b/tests/test_web_search_olostep.py @@ -1,16 +1,17 @@ """Tests for Olostep web search provider.""" -from types import SimpleNamespace +from __future__ import annotations -import pytest +import asyncio +from types import SimpleNamespace +from unittest.mock import patch 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): +def test_olostep_search_formats_answer_and_sources(): calls: dict[str, str] = {} class MockAsyncOlostep: @@ -31,11 +32,9 @@ async def test_olostep_search_formats_answer_and_sources(monkeypatch): 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") + with patch.object(web_mod, "AsyncOlostep", MockAsyncOlostep): + tool = WebSearchTool(config=WebSearchConfig(provider="olostep", api_key="olostep-key")) + result = asyncio.run(tool.execute(query="test query")) assert calls["api_key"] == "olostep-key" assert calls["task"] == "test query" @@ -44,26 +43,24 @@ async def test_olostep_search_formats_answer_and_sources(monkeypatch): 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) +def test_olostep_missing_key_falls_back_to_duckduckgo(): + class MockDDGS: + def __init__(self, **kw): + pass - tool = WebSearchTool(config=WebSearchConfig(provider="olostep", olostep_api_key="")) - result = await tool.execute(query="test query") + def text(self, query, max_results=5): + return [{"title": "Fallback", "href": "https://ddg.example", "body": "fallback"}] - assert ( - result - == "Error: Olostep API key not configured. " - "Set it in ~/.nanobot/config.json under " - "tools.web.search.olostepApiKey and restart." - ) + with patch.dict(web_mod.os.environ, {}, clear=False), patch("ddgs.DDGS", MockDDGS): + tool = WebSearchTool(config=WebSearchConfig(provider="olostep", api_key="")) + result = asyncio.run(tool.execute(query="test query")) + + assert "Fallback" in result -@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") +def test_olostep_package_missing_returns_install_hint(): + with patch.object(web_mod, "AsyncOlostep", None): + tool = WebSearchTool(config=WebSearchConfig(provider="olostep", api_key="olostep-key")) + result = asyncio.run(tool.execute(query="test query")) assert result == "Error: olostep package not installed. Run: pip install olostep"