{"repo":"NousResearch/hermes-agent","public_id":"6106","display_label":"Web search/extract provider architecture and backend selection","summary":"These issues and PRs concern refactoring web tools into a capability-based provider system that separates search, extract, and browse backends, adding new providers like SearXNG, and implementing fallback/priority logic for backend selection.","subsystem":"agent","counts":{"members":9,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":6,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":57779},"resolution":{"state":"partially_resolved","by":"pr-57779"},"freshness":{"state":"verified","last_verified_at":"2026-07-03T14:44:28+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.14333333333333334},"fix_edges":[{"issue_number":28651,"pr_number":57779,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":31873,"pr_number":57779,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":35970,"pr_number":57779,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":6106,"title":"feat: Support Brave Search as web search backend","url":null,"state":"closed"},{"kind":"issue","number":19197,"title":"refactor: capability-based provider architecture for web tools (search/extract split)","url":null,"state":"closed"},{"kind":"issue","number":19198,"title":"refactor: capability-based provider architecture for web tools (search/extract/browse split)","url":null,"state":"closed"},{"kind":"issue","number":21003,"title":"[Feature]: Clarification / documentation request for Tavily search + self-hosted Firecrawl extract","url":null,"state":"closed"},{"kind":"issue","number":32698,"title":"[Bug]: web_extract gives dead-end error with only SearXNG configured","url":null,"state":"closed"},{"kind":"issue","number":41893,"title":"[Feature]: web_search backend priority list with automatic fallback","url":null,"state":"closed"},{"kind":"pr","number":8233,"title":"feat: add Brave Search as web search backend","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4372","number":4372}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":35327,"title":"fix(tools): honor available plugin extract/search backends in web selection","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#57779","number":57779}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":57779,"title":"fix(web_tools): honor plugin-registered provider availability at the tool gate","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/tools/test_web_tools_config.py","tools/web_tools.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -1135,6 +1135,7 @@\n     \"[redacted:email]\": \"hata1234\",\n     \"[redacted:email]\": \"Hmbown\",\n     \"[redacted:email]\": \"m0n5t3r\",\n+    \"[redacted:email]\": \"m0n5t3r\",\n     \"[redacted:email]\": \"JiayuuWang\",\n     \"[redacted:email]\": \"sjh9714\",\n     \"[redacted:email]\": \"yoniebans\","]},{"file":"tests/tools/test_web_tools_config.py","hunks":["@@ -668,3 +668,122 @@ def test_web_requires_env_includes_exa_key():\n     from tools.web_tools import _web_requires_env\n \n     assert \"EXA_API_KEY\" in _web_requires_env()\n+\n+\n+class TestNonBuiltinProviderAvailability:\n+    \"\"\"Regression: a plugin-registered WebSearchProvider with no built-in\n+    provider credentials must still light up web_search / web_extract tools.\n+\n+    The web_tools availability gate delegates non-legacy backend names to the\n+    web_search_registry's provider ``is_available()``.  This class verifies\n+    that a custom (non-built-in) provider discovered via the registry is\n+    sufficient to make check_web_api_key() return True, _get_backend() return\n+    the custom name, the per-capability selection honor it (issue #32698), and\n+    the tool registry entries remain active.\n+\n+    Original tests contributed by @m0n5t3r (PR #28652 / issue #28651).\n+    \"\"\"\n+\n+    # All env vars that could make a built-in provider available.\n+    _WEB_ENV_KEYS = (\n+        \"EXA_API_KEY\",\n+        \"PARALLEL_API_KEY\",\n+        \"FIRECRAWL_API_KEY\",\n+        \"FIRECRAWL_API_URL\",\n+        \"FIRECRAWL_GATEWAY_URL\",\n+        \"TOOL_GATEWAY_DOMAIN\",\n+        \"TOOL_GATEWAY_SCHEME\",\n+        \"TOOL_GATEWAY_USER_TOKEN\",\n+        \"TAVILY_API_KEY\",\n+        \"SEARXNG_URL\",\n+        \"BRAVE_SEARCH_API_KEY\",\n+        \"XAI_API_KEY\",\n+    )\n+\n+    @staticmethod\n+    def _create_fake_provider(*, search=True, extract=True):\n+        \"\"\"Dynamically create a WebSearchProvider subclass.\n+\n+        Uses a local class definition (not a nested class) to avoid\n+        Python 3.13 __bases__ deallocator issue with nested class\n+        reassignment.\n+        \"\"\"\n+        from agent.web_search_provider import WebSearchProvider\n+\n+        class FakePluginProvider(WebSearchProvider):\n+            @property\n+            def name(self):\n+                return \"fake-plugin-prov\"\n+\n+            def is_available(self):\n+                return True\n+\n+            def supports_search(self):\n+                return search\n+\n+            def supports_extract(self):\n+                return extract\n+\n+        return FakePluginProvider()\n+\n+    def setup_method(self):\n+        \"\"\"Strip all built-in web provider env vars and reset the registry.\"\"\"\n+        for key in self._WEB_ENV_KEYS:\n+            os.environ.pop(key, None)\n+        from agent.web_search_registry import _reset_for_tests, register_provider\n+        _reset_for_tests()\n+        register_provider(self._create_fake_provider())\n+\n+    def teardown_method(self):\n+        \"\"\"Reset the registry and restore env after each test.\"\"\"\n+        from agent.web_search_registry import _reset_for_tests\n+        _reset_for_tests()\n+        for key in self._WEB_ENV_KEYS:\n+            os.environ.pop(key, None)\n+\n+    def test_check_web_api_key_returns_true_for_custom_provider(self):\n+        \"\"\"With only a custom provider registered (no built-in creds),\n+        check_web_api_key() must return True.\"\"\"\n+        with patch(\"tools.web_tools._ddgs_package_importable\", return_value=False), \\\n+             patch(\"tools.web_tools._peek_nous_access_token\", return_value=None):\n+            from tools.web_tools import check_web_api_key\n+            assert check_web_api_key() is True\n+\n+    def test_get_backend_discovers_custom_provider(self):\n+        \"\"\"_get_backend() must return the custom provider name when it's\n+        the only available provider.\"\"\"\n+        with patch(\"tools.web_tools._ddgs_package_importable\", return_value=False), \\\n+             patch(\"tools.web_tools._peek_nous_access_token\", return_value=None):\n+            from tools.web_tools import _get_backend\n+            assert _get_backend() == \"fake-plugin-prov\"\n+\n+    def test_is_backend_available_delegates_to_registry(self):\n+        \"\"\"_is_backend_available() must consult the registry for a\n+        non-legacy backend name.\"\"\"\n+        from tools.web_tools import _is_backend_available\n+        assert _is_backend_available(\"fake-plugin-prov\") is True\n+        # Unknown, unregistered name -> False (no legacy probe matches).\n+        assert _is_backend_available(\"totally-unknown-backend\") is False\n+\n+    def test_capability_backend_honors_custom_extract_provider(self):\n+        \"\"\"Per-capability selection (_get_extract_backend) must resolve the\n+        custom provider when configured, instead of dead-ending — issue #32698.\"\"\"\n+        with patch(\"tools.web_tools._ddgs_package_importable\", return_value=False), \\\n+             patch(\"tools.web_tools._peek_nous_access_token\", return_value=None), \\\n+             patch(\"tools.web_tools._load_web_config\",\n+                   return_value={\"extract_backend\": \"fake-plugin-prov\"}):\n+            from tools.web_tools import _get_extract_backend\n+            assert _get_extract_backend() == \"fake-plugin-prov\"\n+\n+    def test_tool_registry_entries_not_filtered_out(self):\n+        \"\"\"web_search and web_extract tool entries must remain in the\n+        registry when only a custom provider is available.\"\"\"\n+        with patch(\"tools.web_tools._ddgs_package_importable\", return_value=False), \\\n+             patch(\"tools.web_tools._peek_nous_access_token\", return_value=None):\n+            import tools.web_tools\n+            web_search_entry = tools.web_tools.registry.get_entry(\"web_search\")\n+            web_extract_entry = tools.web_tools.registry.get_entry(\"web_extract\")\n+            assert web_search_entry is not None, \\\n+                \"web_search tool was filtered out despite custom provider being available\"\n+            assert web_extract_entry is not None, \\\n+                \"web_extract tool was filtered out despite custom provider being available\""]},{"file":"tools/web_tools.py","hunks":["@@ -136,6 +136,71 @@ def _load_web_config() -> dict:\n     except (ImportError, Exception):\n         return {}\n \n+\n+# The built-in web backends whose availability is driven by hardcoded\n+# env-var / package / OAuth probes below. Any name NOT in this set is a\n+# candidate plugin-registered provider and must be resolved through the\n+# web_search_registry (``is_available()``) instead. Kept as a single named\n+# constant so the whitelist early-returns and the availability chokepoint\n+# stay in sync.\n+#\n+# NOTE: this intentionally includes ``xai``, which the registry's\n+# ``_LEGACY_PREFERENCE`` does NOT — xai availability is probed via\n+# ``has_xai_credentials()`` (env var OR auth.json OAuth), not a registered\n+# WebSearchProvider. Keep the two sets aligned by hand: if xai ever ships as\n+# a registered provider, drop it here so the registry path takes over.\n+_LEGACY_WEB_BACKENDS = frozenset(\n+    {\"parallel\", \"firecrawl\", \"tavily\", \"exa\", \"searxng\", \"brave-free\", \"ddgs\", \"xai\"}\n+)\n+\n+\n+def _registered_web_provider(backend: str):\n+    \"\"\"Return a plugin-registered web provider by name, or ``None``.\n+\n+    Consults ``agent.web_search_registry`` so backends contributed by the\n+    plugin system (which are absent from :data:`_LEGACY_WEB_BACKENDS`) are\n+    discoverable during availability/selection resolution. Returns ``None``\n+    on any lookup failure so callers can fall through to legacy checks.\n+    \"\"\"\n+    if not backend:\n+        return None\n+    try:\n+        from agent.web_search_registry import get_provider\n+\n+        return get_provider(backend)\n+    except Exception as exc:  # noqa: BLE001 — registry optional; never fatal\n+        logger.debug(\"web provider registry lookup failed for %r: %s\", backend, exc)\n+        return None\n+\n+\n+def _registered_web_provider_available(backend: str):\n+    \"\"\"Availability of a *registered* web provider, or ``None`` if unregistered.\n+\n+    Returns ``True``/``False`` when *backend* names a registered provider\n+    (calling its ``is_available()``), or ``None`` when it isn't registered —\n+    letting the caller fall through to the legacy built-in probes.\n+    \"\"\"\n+    provider = _registered_web_provider(backend)\n+    if provider is None:\n+        return None\n+    try:\n+        return bool(provider.is_available())\n+    except Exception as exc:  # noqa: BLE001 — a broken provider is \"unavailable\"\n+        logger.debug(\"web provider %r.is_available() raised: %s\", backend, exc)\n+        return False\n+\n+\n+def _list_registered_web_providers():\n+    \"\"\"Return all plugin-registered web providers (empty list on failure).\"\"\"\n+    try:\n+        from agent.web_search_registry import list_providers\n+\n+        return list_providers()\n+    except Exception as exc:  # noqa: BLE001 — registry optional; never fatal\n+        logger.debug(\"web provider registry list failed: %s\", exc)\n+        return []\n+\n+\n def _get_backend() -> str:\n     \"\"\"Determine which web backend to use (shared fallback).\n ","@@ -144,7 +209,7 @@ def _get_backend() -> str:\n     keys manually without running setup.\n     \"\"\"\n     configured = (_load_web_config().get(\"backend\") or \"\").lower().strip()\n-    if configured in {\"parallel\", \"firecrawl\", \"tavily\", \"exa\", \"searxng\", \"brave-free\", \"ddgs\", \"xai\"}:\n+    if configured in _LEGACY_WEB_BACKENDS or _registered_web_provider(configured) is not None:\n         return configured\n \n     # Fallback for manual / legacy config — pick the highest-priority","@@ -168,6 +233,21 @@ def _get_backend() -> str:\n         if available:\n             return backend\n \n+    # Final fallback: walk plugin-registered providers so a custom backend\n+    # (with no built-in creds present) still resolves. Built-in names are\n+    # already covered above, so this only surfaces plugin-contributed\n+    # providers via their own is_available() gate. We hold the provider\n+    # object already, so probe it directly rather than round-tripping through\n+    # _is_backend_available() (which would re-do the registry lookup).\n+    for provider in _list_registered_web_providers():\n+        if provider.name in _LEGACY_WEB_BACKENDS:\n+            continue\n+        try:\n+            if provider.is_available():\n+                return provider.name\n+        except Exception as exc:  # noqa: BLE001 — a broken provider is skipped\n+            logger.debug(\"web provider %r.is_available() raised: %s\", provider.name, exc)\n+\n     return \"firecrawl\"  # default (backward compat)\n \n ","@@ -210,7 +290,22 @@ def _get_capability_backend(capability: str) -> str:\n \n \n def _is_backend_available(backend: str) -> bool:\n-    \"\"\"Return True when the selected backend is currently usable.\"\"\"\n+    \"\"\"Return True when the selected backend is currently usable.\n+\n+    For plugin-registered backends (any name outside\n+    :data:`_LEGACY_WEB_BACKENDS`), availability is delegated to the\n+    provider's ``is_available()`` via the web_search_registry. This is the\n+    single chokepoint through which ``_get_backend``,\n+    ``_get_capability_backend``, and ``check_web_api_key`` all resolve\n+    availability — fixing custom-provider discovery for every caller at once\n+    (issues #28651, #31873, #32698). Built-in backends keep their cheap\n+    hardcoded probes below.\n+    \"\"\"\n+    backend = (backend or \"\").lower().strip()\n+    if backend not in _LEGACY_WEB_BACKENDS:\n+        registered = _registered_web_provider_available(backend)\n+        if registered is not None:\n+            return registered\n     if backend == \"exa\":\n         return _has_env(\"EXA_API_KEY\")\n     if backend == \"parallel\":","@@ -861,14 +956,39 @@ async def web_extract_tool(\n \n # Convenience function to check Firecrawl credentials\n def check_web_api_key() -> bool:\n-    \"\"\"Check whether the configured web backend is available.\"\"\"\n+    \"\"\"Check whether the configured web backend is available.\n+\n+    Used as the ``check_fn`` gate for the ``web_search`` and ``web_extract``\n+    tool registry entries — so a plugin-registered provider that reports\n+    ``is_available()`` must light the tools up even when no built-in backend\n+    has credentials (issues #28651, #31873). Resolution funnels through\n+    :func:`_is_backend_available`, which delegates non-legacy names to the\n+    registry.\n+    \"\"\"\n     configured = _load_web_config().get(\"backend\", \"\").lower().strip()\n-    if configured in {\"exa\", \"parallel\", \"firecrawl\", \"tavily\", \"searxng\", \"brave-free\", \"ddgs\", \"xai\"}:\n-        return _is_backend_available(configured)\n-    return any(\n-        _is_backend_available(backend)\n-        for backend in (\"exa\", \"parallel\", \"firecrawl\", \"tavily\", \"searxng\", \"brave-free\", \"ddgs\", \"xai\")\n-    )\n+    if configured and _is_backend_available(configured):\n+        return True\n+    # Any built-in backend with credentials present. This is a boolean OR, so\n+    # unlike _get_backend() the probe order is irrelevant.\n+    if any(_is_backend_available(backend) for backend in _LEGACY_WEB_BACKENDS):\n+        return True\n+    # Any plugin-registered provider the registry considers active for either\n+    # capability. Delegating to the registry's own availability-filtered\n+    # resolvers keeps a single authority for \"is a custom provider usable\"\n+    # rather than re-implementing the walk here.\n+    try:\n+        from agent.web_search_registry import (\n+            get_active_search_provider,\n+            get_active_extract_provider,\n+        )\n+\n+        return (\n+            get_active_search_provider() is not None\n+            or get_active_extract_provider() is not None\n+        )\n+    except Exception as exc:  # noqa: BLE001 — registry optional; never fatal\n+        logger.debug(\"web provider registry availability check failed: %s\", exc)\n+        return False\n \n \n if __name__ == \"__main__\":\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}