From 9354b80a6e4ae05318f4c5fd8baa525792356d0f Mon Sep 17 00:00:00 2001 From: Ilya Gusev Date: Sun, 21 Jun 2026 16:35:00 +0000 Subject: [PATCH] fix(cli): show search engines (incl. Keenable) in onboard wizard The onboard wizard dispatched field handlers by bare field name, so WebSearchConfig.provider was hijacked by the LLM-provider handler and showed LLM providers instead of search engines. Keenable was also never wired into the CLI wizard when it landed in the WebUI. - Add a single source of truth for selectable search providers (SEARCH_PROVIDER_OPTIONS in web.py); WebUI settings now import it. - Add a WebSearchConfig-aware search-provider picker to the wizard and resolve handlers by (model type, field name) so the LLM and search provider fields no longer collide. Co-Authored-By: Claude Opus 4.8 (1M context) --- nanobot/agent/tools/web.py | 18 ++++++++++++++++++ nanobot/cli/onboard.py | 30 +++++++++++++++++++++++++++++- nanobot/webui/settings_api.py | 15 ++------------- tests/agent/test_onboard_logic.py | 24 ++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 169cbb1c..947dc211 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -36,6 +36,24 @@ _VOLCENGINE_TIME_RANGES = {"OneDay", "OneWeek", "OneMonth", "OneYear"} _VOLCENGINE_DATE_RANGE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$") +# Single source of truth for selectable search providers (CLI wizard + WebUI). +# "credential" describes what each provider needs: none / api_key / base_url / +# optional_api_key. +SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = ( + {"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"}, + {"name": "brave", "label": "Brave Search", "credential": "api_key"}, + {"name": "tavily", "label": "Tavily", "credential": "api_key"}, + {"name": "searxng", "label": "SearXNG", "credential": "base_url"}, + {"name": "jina", "label": "Jina", "credential": "api_key"}, + {"name": "kagi", "label": "Kagi", "credential": "api_key"}, + {"name": "exa", "label": "Exa", "credential": "api_key"}, + {"name": "olostep", "label": "Olostep", "credential": "api_key"}, + {"name": "bocha", "label": "Bocha", "credential": "api_key"}, + {"name": "volcengine", "label": "Volcengine Search", "credential": "api_key"}, + {"name": "keenable", "label": "Keenable", "credential": "optional_api_key"}, +) + + class WebSearchConfig(Base): """Web search configuration.""" provider: str = "duckduckgo" diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index 91c6a81a..cc21c4e5 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -836,6 +836,21 @@ def _handle_fallback_models_field( items.clear() +def _handle_search_provider_field( + working_model: BaseModel, field_name: str, field_display: str, current_value: Any +) -> None: + """Handle the web-search 'provider' field with the search-engine list.""" + from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS + + choices = [opt["name"] for opt in SEARCH_PROVIDER_OPTIONS] + default_choice = str(current_value) if current_value in choices else choices[0] + new_value = _select_with_back(field_display, choices, default=default_choice) + if new_value is _BACK_PRESSED: + return + if new_value is not None: + setattr(working_model, field_name, new_value) + + _FIELD_HANDLERS: dict[str, Any] = { "model": _handle_model_field, "context_window_tokens": _handle_context_window_field, @@ -844,6 +859,19 @@ _FIELD_HANDLERS: dict[str, Any] = { "fallback_models": _handle_fallback_models_field, } +# Handlers keyed by (model class name, field name); take precedence over the +# name-only handlers above. Needed because the bare "provider" field name is +# shared by LLM configs (LLM provider list) and WebSearchConfig (search engines). +_TYPED_FIELD_HANDLERS: dict[tuple[str, str], Any] = { + ("WebSearchConfig", "provider"): _handle_search_provider_field, +} + + +def _resolve_field_handler(model: BaseModel, field_name: str) -> Any: + """Resolve a field handler, preferring model-type-specific handlers.""" + typed = _TYPED_FIELD_HANDLERS.get((type(model).__name__, field_name)) + return typed or _FIELD_HANDLERS.get(field_name) + def _is_str_or_none(annotation: Any) -> bool: """Check whether a field annotation is ``str | None`` (or ``Optional[str]``).""" @@ -934,7 +962,7 @@ def _configure_pydantic_model( continue # Registered special-field handlers - handler = _FIELD_HANDLERS.get(field_name) + handler = _resolve_field_handler(working_model, field_name) if handler: handler(working_model, field_name, field_display, current_value) continue diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 78294779..e7ba94a4 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -16,6 +16,7 @@ from zoneinfo import ZoneInfo import httpx from nanobot import __version__ +from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS from nanobot.audio.transcription import resolve_transcription_config from nanobot.audio.transcription_registry import ( resolve_transcription_provider, @@ -79,19 +80,7 @@ _NATIVE_RESTART_BEHAVIOR_BY_SECTION = { "apps": "engineRestart", } -_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = ( - {"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"}, - {"name": "brave", "label": "Brave Search", "credential": "api_key"}, - {"name": "tavily", "label": "Tavily", "credential": "api_key"}, - {"name": "searxng", "label": "SearXNG", "credential": "base_url"}, - {"name": "jina", "label": "Jina", "credential": "api_key"}, - {"name": "kagi", "label": "Kagi", "credential": "api_key"}, - {"name": "exa", "label": "Exa", "credential": "api_key"}, - {"name": "olostep", "label": "Olostep", "credential": "api_key"}, - {"name": "bocha", "label": "Bocha", "credential": "api_key"}, - {"name": "volcengine", "label": "Volcengine Search", "credential": "api_key"}, - {"name": "keenable", "label": "Keenable", "credential": "optional_api_key"}, -) +_WEB_SEARCH_PROVIDER_OPTIONS = SEARCH_PROVIDER_OPTIONS _WEB_SEARCH_PROVIDER_BY_NAME = { provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS } diff --git a/tests/agent/test_onboard_logic.py b/tests/agent/test_onboard_logic.py index 2067f8d6..6a4c71d7 100644 --- a/tests/agent/test_onboard_logic.py +++ b/tests/agent/test_onboard_logic.py @@ -1998,3 +1998,27 @@ class TestModelPresetWizard: defaults = AgentDefaults() _handle_provider_field(defaults, "provider", "Provider", "auto") assert defaults.provider == "anthropic" + + def test_search_provider_field_handler(self, monkeypatch): + """_handle_search_provider_field should set the search engine from choices.""" + from nanobot.agent.tools.web import WebSearchConfig + from nanobot.cli.onboard import _handle_search_provider_field + + monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "keenable") + + cfg = WebSearchConfig() + _handle_search_provider_field(cfg, "provider", "Provider", "duckduckgo") + assert cfg.provider == "keenable" + + def test_provider_field_dispatch_is_model_type_aware(self): + """WebSearchConfig.provider must not be hijacked by the LLM provider handler.""" + from nanobot.agent.tools.web import WebSearchConfig + from nanobot.cli.onboard import ( + _handle_provider_field, + _handle_search_provider_field, + _resolve_field_handler, + ) + from nanobot.config.schema import AgentDefaults + + assert _resolve_field_handler(WebSearchConfig(), "provider") is _handle_search_provider_field + assert _resolve_field_handler(AgentDefaults(), "provider") is _handle_provider_field