diff --git a/hermes_cli/web_server_config.py b/hermes_cli/web_server_config.py index c1c8600f73b6..119aea016b76 100644 --- a/hermes_cli/web_server_config.py +++ b/hermes_cli/web_server_config.py @@ -60,6 +60,19 @@ def _select(description: str, *options: str, **extra: Any) -> Dict[str, Any]: return {"type": "select", "description": description, "options": list(options), **extra} +# Built-in web backends, mirrored from ``tools/web_tools.py`` (_LEGACY_WEB_BACKENDS for search; the +# extract-capable subset the runtime names in its "search-only" error). Leading "" = fall back to the +# shared ``web.backend`` / auto-detect. Kept as module constants so the static override and the +# per-request current-value preservation in _schema_with_dynamic_provider_options share one source of +# truth. Web backends are plugin-extensible, so these are suggestions, not an exhaustive gate — a +# configured name outside the list is preserved. +_WEB_SEARCH_BACKEND_OPTIONS = [ + "", "firecrawl", "searxng", "brave-free", "ddgs", "tavily", "exa", "parallel", "perplexity", "xai", + "keenable", +] +_WEB_EXTRACT_BACKEND_OPTIONS = ["", "firecrawl", "tavily", "exa", "parallel"] + + # Manual overrides for fields that need select options or custom types. _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "timezone": _select( @@ -84,6 +97,18 @@ def _select(description: str, *options: str, **extra: Any) -> Dict[str, Any]: # sync with _SUPPORTED_VERCEL_RUNTIMES in terminal_tool.py "terminal.vercel_runtime": _select("Vercel Sandbox runtime", "node24", "node22", "python3.13"), "terminal.modal_mode": _select("Modal sandbox mode", "sandbox", "function"), + "web.backend": _select( + "Shared web search + extract backend (blank = auto-detect from credentials)", + *_WEB_SEARCH_BACKEND_OPTIONS, + ), + "web.search_backend": _select( + "Per-capability override for web_search (blank = fall back to web.backend)", + *_WEB_SEARCH_BACKEND_OPTIONS, + ), + "web.extract_backend": _select( + "Per-capability override for web_extract (blank = fall back to web.backend)", + *_WEB_EXTRACT_BACKEND_OPTIONS, + ), "proxy.enabled": { "type": "boolean", "description": ( @@ -352,6 +377,63 @@ def _schema_select_options(key: str) -> Optional[List[str]]: return options if isinstance(options, list) else None +def _registry_web_backend_names(capability: str) -> List[str]: + """Names of registered web providers that support *capability*. + + ``capability`` is ``"search"``, ``"extract"``, or ``"any"`` (supports at least one). Reads + ``agent.web_search_registry.list_providers()`` — the same registry the ``web_search``/ + ``web_extract`` tools resolve against — so an installed provider (built-in or plugin) is + enumerated per capability. Returns ``[]`` on any import/registry failure so the built-in base + list stays the floor; a single misbehaving provider is skipped, not fatal. + """ + try: + from agent.web_search_registry import list_providers + + providers = list_providers() + except Exception: # pragma: no cover - registry must never break the schema + return [] + + names: List[str] = [] + for provider in providers: + try: + if capability == "search" and not provider.supports_search(): + continue + if capability == "extract" and not provider.supports_extract(): + continue + if capability == "any" and not (provider.supports_search() or provider.supports_extract()): + continue + name = str(provider.name).strip() + except Exception: # noqa: BLE001 - skip a provider that raises on introspection + continue + if name: + names.append(name) + return names + + +def _web_backend_schema_options(base: List[str], configured: Any, capability: str) -> List[str]: + """Options for a web-backend select: built-ins + every installed registry provider that + supports *capability*, plus a configured-but-undiscoverable fallback. + + The dashboard renders the select as a closed gate, so an installed provider that isn't in the + static ``base`` list (a plugin-registered backend, or a built-in not enumerated in ``base``) has + to be added here or it can't be chosen. We therefore enumerate the registry per capability rather + than only preserving whatever is already configured. A configured value that is neither built-in + nor currently registered is still appended so switching away from it never drops it from the + dropdown. ``base`` is always the floor; order is base → newly-discovered registry names → + configured fallback. + """ + options = list(base) + seen = set(options) + for name in _registry_web_backend_names(capability): + if name not in seen: + options.append(name) + seen.add(name) + current = str(configured or "").strip() + if current and current not in seen: + options.append(current) + return options + + def _schema_with_dynamic_provider_options() -> Dict[str, Dict[str, Any]]: """CONFIG_SCHEMA with per-request discovery-driven ``*.provider`` options merged. @@ -390,6 +472,16 @@ def merge(key: str, options: List[str]) -> None: if plugin_names: merge("terminal.backend", [*tb_options, *plugin_names]) + # Web backends are plugin-extensible, and the dashboard renders a select as a closed gate. + # Enumerate installed registry providers per capability and preserve a configured value outside + # the built-in list so a plugin/custom backend never silently vanishes from the dropdown (mirrors + # the tts/stt/memory current-value preservation above). + web = cfg.get("web") + web = web if isinstance(web, dict) else {} + merge("web.backend", _web_backend_schema_options(_WEB_SEARCH_BACKEND_OPTIONS, web.get("backend"), "any")) + merge("web.search_backend", _web_backend_schema_options(_WEB_SEARCH_BACKEND_OPTIONS, web.get("search_backend"), "search")) + merge("web.extract_backend", _web_backend_schema_options(_WEB_EXTRACT_BACKEND_OPTIONS, web.get("extract_backend"), "extract")) + return {**CONFIG_SCHEMA, **overlay} if overlay else CONFIG_SCHEMA diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 6cd0f848b9e3..43035294a976 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2569,6 +2569,193 @@ def tracked_get_messages(self, session_id, *args, **kwargs): class TestBuildSchemaFromConfig: + def test_web_backend_fields_render_as_selects(self): + """web.backend / search_backend / extract_backend must be dropdowns. + + Previously they rendered as bare text inputs, so a user had to know the + exact backend string (e.g. ``brave-free``, not ``brave``) with no hint + of valid values (#71929). Each is a leaf key in DEFAULT_CONFIG, so the + _SCHEMA_OVERRIDES entry attaches. + """ + from hermes_cli.web_server_config import CONFIG_SCHEMA + + for key in ("web.backend", "web.search_backend", "web.extract_backend"): + entry = CONFIG_SCHEMA[key] + assert entry["type"] == "select", key + # Leading "" = shared-backend / auto-detect fallback stays selectable. + assert entry["options"][0] == "", key + + def test_web_backend_options_match_runtime_backends(self): + """Search-backend options must equal the runtime's built-in web set. + + Guards against the schema drifting from ``tools/web_tools.py`` — a stale + option would silently offer a backend the runtime rejects. + """ + from hermes_cli.web_server_config import CONFIG_SCHEMA + from tools.web_tools import _LEGACY_WEB_BACKENDS + + for key in ("web.backend", "web.search_backend"): + offered = {o for o in CONFIG_SCHEMA[key]["options"] if o} + assert offered == set(_LEGACY_WEB_BACKENDS), key + + def test_web_extract_backend_omits_search_only(self): + """Extract options are the extract-capable subset only. + + Search-only providers (searxng, brave-free, ddgs, xai) cannot extract — + the runtime rejects them with a "search-only backend" error — so they + must not be offered for web.extract_backend. + """ + from hermes_cli.web_server_config import CONFIG_SCHEMA + + offered = {o for o in CONFIG_SCHEMA["web.extract_backend"]["options"] if o} + assert offered == {"firecrawl", "tavily", "exa", "parallel"} + assert offered.isdisjoint({"searxng", "brave-free", "ddgs", "xai"}) + + def test_dynamic_merge_preserves_configured_web_backend(self, monkeypatch): + """A plugin/custom web backend outside the built-in list stays selectable. + + Web backends are plugin-extensible and the dashboard renders a select as + a closed gate, so a configured value the built-in list doesn't contain + must be appended — otherwise it silently vanishes and the next save + clobbers it. + """ + monkeypatch.setattr( + _cfg_mod, + "load_config", + lambda: {"web": {"backend": "my_plugin_backend", "extract_backend": "custom_extract"}}, + ) + + fields = _web_server_config._schema_with_dynamic_provider_options() + + assert "my_plugin_backend" in fields["web.backend"]["options"] + assert "custom_extract" in fields["web.extract_backend"]["options"] + # The module-level schema is copied, not mutated in place. + assert _web_server_config.CONFIG_SCHEMA["web.backend"] is not fields["web.backend"] + assert _web_server_config.CONFIG_SCHEMA["web.backend"]["type"] == "select" + + def test_dynamic_merge_no_overlay_for_builtin_web_backend(self, monkeypatch): + """A built-in (or blank) web backend with nothing new registered needs no + overlay — options unchanged. + + The merge fires only when a configured value falls outside the list OR + the registry surfaces a provider the base list doesn't already contain. + Here the configured value is a built-in and the registry adds nothing, so + the common case returns the frozen import-time entry untouched. The + registry enumeration is stubbed empty to isolate that invariant from + whatever providers happen to be registered in the test process. + """ + monkeypatch.setattr( + _cfg_mod, "load_config", lambda: {"web": {"backend": "searxng", "search_backend": ""}} + ) + monkeypatch.setattr(_web_server_config, "_registry_web_backend_names", lambda capability: []) + + fields = _web_server_config._schema_with_dynamic_provider_options() + + # No web override was added → identity with the module-level schema. + assert fields["web.backend"] is _web_server_config.CONFIG_SCHEMA["web.backend"] + assert fields["web.search_backend"] is _web_server_config.CONFIG_SCHEMA["web.search_backend"] + + def test_dynamic_merge_enumerates_registry_web_backends_by_capability(self, monkeypatch): + """An INSTALLED-but-unconfigured registry provider becomes selectable in + the capability-appropriate web-backend dropdowns. + + This tests the relation to the provider capability flags rather than + freezing a provider list: a search-only provider must reach the shared + ``web.backend`` and ``web.search_backend`` selects but be kept out of + ``web.extract_backend``; an extract-capable provider must reach + ``web.extract_backend``. A closed select can't choose a provider that + isn't in its options, so enumeration is what makes an installed plugin + backend pickable at all. + """ + from agent.web_search_provider import WebSearchProvider + + class _SearchOnly(WebSearchProvider): + @property + def name(self) -> str: + return "fake_search_only" + + def is_available(self) -> bool: + return True + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return False + + class _ExtractCapable(WebSearchProvider): + @property + def name(self) -> str: + return "fake_extract_only" + + def is_available(self) -> bool: + return True + + def supports_search(self) -> bool: + return False + + def supports_extract(self) -> bool: + return True + + registered = [_SearchOnly(), _ExtractCapable()] + monkeypatch.setattr(_cfg_mod, "load_config", lambda: {"web": {}}) + # Drive enumeration off the two fakes, independent of ambient registry state. + monkeypatch.setattr( + _web_server_config, + "_registry_web_backend_names", + lambda capability: [p.name for p in registered + if (capability == "search" and p.supports_search()) + or (capability == "extract" and p.supports_extract()) + or (capability == "any" and (p.supports_search() or p.supports_extract()))], + ) + + fields = _web_server_config._schema_with_dynamic_provider_options() + backend = fields["web.backend"]["options"] + search = fields["web.search_backend"]["options"] + extract = fields["web.extract_backend"]["options"] + + # Search-only reaches the shared + search selects, never the extract one. + assert "fake_search_only" in backend + assert "fake_search_only" in search + assert "fake_search_only" not in extract + # Extract-capable reaches the shared + extract selects. + assert "fake_extract_only" in backend + assert "fake_extract_only" in extract + + def test_registry_web_backend_names_filters_by_capability(self): + """``_registry_web_backend_names`` returns provider names filtered by the + requested capability, mirroring the registry's own capability flags. + + Exercises the real registry helper against a temporarily-registered + provider so the enumeration is validated end-to-end (not stubbed). + """ + import agent.web_search_registry as registry + from agent.web_search_provider import WebSearchProvider + + class _SearchOnly(WebSearchProvider): + @property + def name(self) -> str: + return "capflag_search_only" + + def is_available(self) -> bool: + return True + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return False + + saved = dict(registry._providers) + try: + registry.register_provider(_SearchOnly()) + assert "capflag_search_only" in _web_server_config._registry_web_backend_names("search") + assert "capflag_search_only" in _web_server_config._registry_web_backend_names("any") + assert "capflag_search_only" not in _web_server_config._registry_web_backend_names("extract") + finally: + registry._providers.clear() + registry._providers.update(saved) + def test_timezone_field_is_searchable_select(self): """timezone must ship as a searchable, clearable select of IANA ids.