{"repo":"NousResearch/hermes-agent","public_id":"5941","display_label":"Adding SearXNG and free-tier web search backends","summary":"These issues and pull requests concern integrating SearXNG, Brave Search free tier, and DDGS as alternative or fallback web search providers. Several also address authentication support and local/free-tier tooling options.","subsystem":"agent","counts":{"members":7,"open_issues":1,"open_issues_distinct":1,"open_prs":1,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":21337,"merged_at":"2026-05-07T16:59:18+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-07T16:59:18+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3504761904761905},"fix_edges":[{"issue_number":5941,"pr_number":21337,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-07T16:59:18+00:00"},{"issue_number":10644,"pr_number":21337,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-07T16:59:18+00:00"}],"members":[{"kind":"issue","number":5941,"title":"[Feature]: Add Searxng as a default web search provider (alongside firecrawl, tavily, etc)","url":null,"state":"open"},{"kind":"issue","number":9959,"title":"[Feature]: SearXNG as a saerch engine","url":null,"state":"closed"},{"kind":"pr","number":6071,"title":"feat(tools): add SearXNG as web search backend","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#11562","number":11562}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":19607,"title":"feat(tools): local_web_tools — free-tier web search/extract + llama-server launcher","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":19796,"title":"feat(tools/web): add searxng, brave-free, ddgs as fallback backends","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"04193cf71","number":0},{"kind":"commit","value":"cd2cbc73b","number":0},{"kind":"commit","value":"5c906d702","number":0},{"kind":"pr","value":"#21337","number":21337}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":21337,"title":"feat(web): add Brave Search (free tier) and DDGS search providers","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","hermes_cli/tools_config.py","scripts/release.py","tests/tools/test_web_providers_brave_free.py","tests/tools/test_web_providers_ddgs.py","tools/web_providers/brave_free.py","tools/web_providers/ddgs.py","tools/web_tools.py"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -1850,6 +1850,14 @@ def _ensure_hermes_home_managed(home: Path):\n         \"password\": [redacted:secret-assignment]\n         \"category\": \"tool\",\n     },\n+    \"BRAVE_SEARCH_API_KEY\": {\n+        \"description\": \"Brave Search API subscription token (free tier: 2,000 queries/mo)\",\n+        \"prompt\": \"Brave Search subscription token\",\n+        \"url\": \"https://brave.com/search/api/\",\n+        \"tools\": [\"web_search\"],\n+        \"password\": True,\n+        \"category\": \"tool\",\n+    },\n     \"BROWSERBASE_API_KEY\": {\n         \"description\": \"Browserbase API key for cloud browser (optional — local browser works without this)\",\n         \"prompt\": \"Browserbase API key\","]},{"file":"hermes_cli/tools_config.py","hunks":["@@ -308,6 +308,23 @@ def _get_plugin_toolset_keys() -> set:\n                     {\"key\": \"SEARXNG_URL\", \"prompt\": \"Your SearXNG instance URL (e.g., http://localhost:8080)\", \"url\": \"https://searxng.github.io/searxng/\"},\n                 ],\n             },\n+            {\n+                \"name\": \"Brave Search (Free Tier)\",\n+                \"badge\": \"free tier · search only\",\n+                \"tag\": \"2,000 queries/mo free — search only (pair with any extract provider)\",\n+                \"web_backend\": \"brave-free\",\n+                \"env_vars\": [\n+                    {\"key\": \"BRAVE_SEARCH_API_KEY\", \"prompt\": \"Brave Search subscription token\", \"url\": \"https://brave.com/search/api/\"},\n+                ],\n+            },\n+            {\n+                \"name\": \"DuckDuckGo (ddgs)\",\n+                \"badge\": \"free · no key · search only\",\n+                \"tag\": \"Search via the ddgs Python package — no API key (pair with any extract provider)\",\n+                \"web_backend\": \"ddgs\",\n+                \"env_vars\": [],\n+                \"post_setup\": \"ddgs\",\n+            },\n         ],\n     },\n     \"image_gen\": {","@@ -669,6 +686,32 @@ def _run_post_setup(post_setup_key: str):\n         _print_info(\"    Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\")\n         _print_info(\"    Switch voices by setting tts.piper.voice in ~/.hermes/config.yaml\")\n \n+    elif post_setup_key == \"ddgs\":\n+        try:\n+            __import__(\"ddgs\")\n+            _print_success(\"    ddgs is already installed\")\n+        except ImportError:\n+            import subprocess\n+            _print_info(\"    Installing ddgs (DuckDuckGo search package)...\")\n+            try:\n+                result = subprocess.run(\n+                    [sys.executable, \"-m\", \"pip\", \"install\", \"-U\", \"ddgs\", \"--quiet\"],\n+                    capture_output=True, text=True, timeout=300,\n+                )\n+                if result.returncode == 0:\n+                    _print_success(\"    ddgs installed\")\n+                else:\n+                    _print_warning(\"    ddgs install failed:\")\n+                    _print_info(f\"      {result.stderr.strip()[:300]}\")\n+                    _print_info(\"    Run manually: python -m pip install -U ddgs\")\n+                    return\n+            except subprocess.TimeoutExpired:\n+                _print_warning(\"    ddgs install timed out (>5min)\")\n+                _print_info(\"    Run manually: python -m pip install -U ddgs\")\n+                return\n+        _print_info(\"    No API key required. DuckDuckGo enforces server-side rate limits.\")\n+        _print_info(\"    Pair with an extract provider if you also need web_extract.\")\n+\n     elif post_setup_key == \"spotify\":\n         # Run the full `hermes auth spotify` flow — if the user has no\n         # client_id yet, this drops them into the interactive wizard"]},{"file":"scripts/release.py","hunks":["@@ -54,6 +54,7 @@\n     \"127238744+teknium1@users.noreply.github.com\": \"teknium1\",\n     \"128259593+Gutslabs@users.noreply.github.com\": \"Gutslabs\",\n     \"50326054+nocturnum91@users.noreply.github.com\": \"nocturnum91\",\n+    \"223003280+Abd0r@users.noreply.github.com\": \"Abd0r\",\n     \"[redacted:email]\": \"AJV20\",\n     \"[redacted:email]\": \"masonjames\",\n     \"159539633+MottledShadow@users.noreply.github.com\": \"MottledShadow\","]},{"file":"tests/tools/test_web_providers_brave_free.py","hunks":["@@ -0,0 +1,275 @@\n+\"\"\"Tests for the Brave Search (free tier) web search provider.\n+\n+Covers:\n+- BraveFreeSearchProvider.is_configured() env var gating\n+- BraveFreeSearchProvider.search() — happy path, HTTP error, request error, bad JSON\n+- Result normalization (title, url, description, position)\n+- Limit truncation + Brave's count cap (20)\n+- _is_backend_available(\"brave-free\") integration\n+- _get_backend() recognizes \"brave-free\" as a valid configured backend\n+- check_web_api_key() includes brave-free in availability check\n+- web_extract / web_crawl return search-only errors when brave-free is active\n+\"\"\"\n+from __future__ import annotations\n+\n+import json\n+from unittest.mock import MagicMock, patch\n+\n+\n+# ---------------------------------------------------------------------------\n+# BraveFreeSearchProvider unit tests\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestBraveFreeProviderIsConfigured:\n+    def test_configured_when_key_set(self, monkeypatch):\n+        monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n+        from tools.web_providers.brave_free import BraveFreeSearchProvider\n+        assert BraveFreeSearchProvider().is_configured() is True\n+\n+    def test_not_configured_when_key_missing(self, monkeypatch):\n+        monkeypatch.delenv(\"BRAVE_SEARCH_API_KEY\", raising=False)\n+        from tools.web_providers.brave_free import BraveFreeSearchProvider\n+        assert BraveFreeSearchProvider().is_configured() is False\n+\n+    def test_not_configured_when_key_whitespace(self, monkeypatch):\n+        monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"   \")\n+        from tools.web_providers.brave_free import BraveFreeSearchProvider\n+        assert BraveFreeSearchProvider().is_configured() is False\n+\n+    def test_provider_name(self):\n+        from tools.web_providers.brave_free import BraveFreeSearchProvider\n+        assert BraveFreeSearchProvider().provider_name() == \"brave-free\"\n+\n+    def test_implements_web_search_provider(self):\n+        from tools.web_providers.base import WebSearchProvider\n+        from tools.web_providers.brave_free import BraveFreeSearchProvider\n+        assert issubclass(BraveFreeSearchProvider, WebSearchProvider)\n+\n+\n+class TestBraveFreeProviderSearch:\n+    _SAMPLE_RESPONSE = {\n+        \"web\": {\n+            \"results\": [\n+                {\"title\": \"A\", \"url\": \"https://a.example.com\", \"description\": \"desc A\"},\n+                {\"title\": \"B\", \"url\": \"https://b.example.com\", \"description\": \"desc B\"},\n+                {\"title\": \"C\", \"url\": \"https://c.example.com\", \"description\": \"desc C\"},\n+            ]\n+        }\n+    }\n+\n+    @staticmethod\n+    def _mock_resp(json_data, status_code=200):\n+        m = MagicMock()\n+        m.status_code = status_code\n+        m.json.return_value = json_data\n+        m.raise_for_status = MagicMock()\n+        return m\n+\n+    def test_happy_path_normalizes_results(self, monkeypatch):\n+        monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n+        from tools.web_providers.brave_free import BraveFreeSearchProvider\n+\n+        with patch(\"httpx.get\", return_value=self._mock_resp(self._SAMPLE_RESPONSE)):\n+            result = BraveFreeSearchProvider().search(\"test query\", limit=5)\n+\n+        assert result[\"success\"] is True\n+        web = result[\"data\"][\"web\"]\n+        assert len(web) == 3\n+        assert web[0] == {\"title\": \"A\", \"url\": \"https://a.example.com\", \"description\": \"desc A\", \"position\": 1}\n+        assert web[2][\"position\"] == 3\n+\n+    def test_sends_subscription_token_header_and_count(self, monkeypatch):\n+        \"\"\"Brave uses X-Subscription-Token; count maps from limit.\"\"\"\n+        monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n+        from tools.web_providers.brave_free import BraveFreeSearchProvider\n+\n+        captured = {}\n+\n+        def fake_get(url, **kwargs):\n+            captured[\"url\"] = url\n+            captured[\"headers\"] = kwargs.get(\"headers\", {})\n+            captured[\"params\"] = kwargs.get(\"params\", {})\n+            return self._mock_resp({\"web\": {\"results\": []}})\n+\n+        with patch(\"httpx.get\", side_effect=fake_get):\n+            BraveFreeSearchProvider().search(\"q\", limit=5)\n+\n+        assert captured[\"url\"] == \"https://api.search.brave.com/res/v1/web/search\"\n+        assert captured[\"headers\"].get(\"X-Subscription-Token\") == \"BSAkey123\"\n+        assert captured[\"params\"].get(\"q\") == \"q\"\n+        assert captured[\"params\"].get(\"count\") == 5\n+\n+    def test_count_is_capped_at_20(self, monkeypatch):\n+        \"\"\"Brave caps count at 20 — limit above that clamps.\"\"\"\n+        monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n+        from tools.web_providers.brave_free import BraveFreeSearchProvider\n+\n+        captured = {}\n+\n+        def fake_get(url, **kwargs):\n+            captured[\"params\"] = kwargs.get(\"params\", {})\n+            return self._mock_resp({\"web\": {\"results\": []}})\n+\n+        with patch(\"httpx.get\", side_effect=fake_get):\n+            BraveFreeSearchProvider().search(\"q\", limit=100)\n+\n+        assert captured[\"params\"].get(\"count\") == 20\n+\n+    def test_limit_is_respected_client_side(self, monkeypatch):\n+        monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n+        from tools.web_providers.brave_free import BraveFreeSearchProvider\n+\n+        with patch(\"httpx.get\", return_value=self._mock_resp(self._SAMPLE_RESPONSE)):\n+            result = BraveFreeSearchProvider().search(\"q\", limit=2)\n+\n+        assert result[\"success\"] is True\n+        assert len(result[\"data\"][\"web\"]) == 2\n+\n+    def test_empty_results(self, monkeypatch):\n+        monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n+        from tools.web_providers.brave_free import BraveFreeSearchProvider\n+\n+        with patch(\"httpx.get\", return_value=self._mock_resp({\"web\": {\"results\": []}})):\n+            result = BraveFreeSearchProvider().search(\"nothing\", limit=5)\n+\n+        assert result[\"success\"] is True\n+        assert result[\"data\"][\"web\"] == []\n+\n+    def test_missing_web_key_returns_empty(self, monkeypatch):\n+        \"\"\"Responses without a ``web`` block should produce an empty result set, not crash.\"\"\"\n+        monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n+        from tools.web_providers.brave_free import BraveFreeSearchProvider\n+\n+        with patch(\"httpx.get\", return_value=self._mock_resp({})):\n+            result = BraveFreeSearchProvider().search(\"q\", limit=5)\n+\n+        assert result[\"success\"] is True\n+        assert result[\"data\"][\"web\"] == []\n+\n+    def test_http_error_returns_failure(self, monkeypatch):\n+        import httpx\n+        monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n+        from tools.web_providers.brave_free import BraveFreeSearchProvider\n+\n+        bad = MagicMock()\n+        bad.status_code = 429\n+        err = httpx.HTTPStatusError(\"429\", request=MagicMock(), response=bad)\n+\n+        with patch(\"httpx.get\", side_effect=err):\n+            result = BraveFreeSearchProvider().search(\"q\", limit=5)\n+\n+        assert result[\"success\"] is False\n+        assert \"429\" in result[\"error\"]\n+\n+    def test_request_error_returns_failure(self, monkeypatch):\n+        import httpx\n+        monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n+        from tools.web_providers.brave_free import BraveFreeSearchProvider\n+\n+        with patch(\"httpx.get\", side_effect=httpx.RequestError(\"boom\")):\n+            result = BraveFreeSearchProvider().search(\"q\", limit=5)\n+\n+        assert result[\"success\"] is False\n+        assert \"boom\" in result[\"error\"] or \"Brave\" in result[\"error\"]\n+\n+    def test_missing_key_returns_failure(self, monkeypatch):\n+        monkeypatch.delenv(\"BRAVE_SEARCH_API_KEY\", raising=False)\n+        from tools.web_providers.brave_free import BraveFreeSearchProvider\n+\n+        result = BraveFreeSearchProvider().search(\"q\", limit=5)\n+        assert result[\"success\"] is False\n+        assert \"BRAVE_SEARCH_API_KEY\" in result[\"error\"]\n+\n+\n+# ---------------------------------------------------------------------------\n+# Integration: _is_backend_available / _get_backend / check_web_api_key\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestBraveFreeBackendWiring:\n+    def test_is_backend_available_true_when_key_set(self, monkeypatch):\n+        monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n+        from tools.web_tools import _is_backend_available\n+        assert _is_backend_available(\"brave-free\") is True\n+\n+    def test_is_backend_available_false_when_key_missing(self, monkeypatch):\n+        monkeypatch.delenv(\"BRAVE_SEARCH_API_KEY\", raising=False)\n+        from tools.web_tools import _is_backend_available\n+        assert _is_backend_available(\"brave-free\") is False\n+\n+    def test_configured_backend_accepted(self, monkeypatch):\n+        from tools import web_tools\n+        monkeypatch.setattr(web_tools, \"_load_web_config\", lambda: {\"backend\": \"brave-free\"})\n+        monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n+        assert web_tools._get_backend() == \"brave-free\"\n+\n+    def test_auto_detect_picks_brave_free_when_only_key_set(self, monkeypatch):\n+        from tools import web_tools\n+        monkeypatch.setattr(web_tools, \"_load_web_config\", lambda: {})\n+        for key in (\"FIRECRAWL_API_KEY\", \"FIRECRAWL_API_URL\", \"PARALLEL_API_KEY\",\n+                    \"TAVILY_API_KEY\", \"EXA_API_KEY\", \"SEARXNG_URL\"):\n+            monkeypatch.delenv(key, raising=False)\n+        monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n+        monkeypatch.setattr(web_tools, \"_is_tool_gateway_ready\", lambda: False)\n+        monkeypatch.setattr(web_tools, \"_ddgs_package_importable\", lambda: False)\n+        assert web_tools._get_backend() == \"brave-free\"\n+\n+    def test_brave_free_does_not_override_paid_provider(self, monkeypatch):\n+        \"\"\"Tavily (higher priority) should win in auto-detect.\"\"\"\n+        from tools import web_tools\n+        monkeypatch.setattr(web_tools, \"_load_web_config\", lambda: {})\n+        for key in (\"FIRECRAWL_API_KEY\", \"FIRECRAWL_API_URL\", \"PARALLEL_API_KEY\", \"EXA_API_KEY\", \"SEARXNG_URL\"):\n+            monkeypatch.delenv(key, raising=False)\n+        monkeypatch.setenv(\"TAVILY_API_KEY\", \"tvly\")\n+        monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n+        monkeypatch.setattr(web_tools, \"_is_tool_gateway_ready\", lambda: False)\n+        assert web_tools._get_backend() == \"tavily\"\n+\n+    def test_check_web_api_key_true_when_brave_free_configured(self, monkeypatch):\n+        from tools import web_tools\n+        monkeypatch.setattr(web_tools, \"_load_web_config\", lambda: {\"backend\": \"brave-free\"})\n+        monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n+        assert web_tools.check_web_api_key() is True\n+\n+\n+# ---------------------------------------------------------------------------\n+# brave-free is search-only: web_extract / web_crawl return clear errors\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestBraveFreeSearchOnlyErrors:\n+    def test_web_extract_returns_search_only_error(self, monkeypatch):\n+        import asyncio\n+        from tools import web_tools\n+\n+        monkeypatch.setattr(web_tools, \"_load_web_config\", lambda: {\"backend\": \"brave-free\"})\n+        monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n+        monkeypatch.setattr(web_tools, \"_is_tool_gateway_ready\", lambda: False)\n+        monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False, raising=False)\n+\n+        result_str = asyncio.get_event_loop().run_until_complete(\n+            web_tools.web_extract_tool([\"https://example.com\"])\n+        )\n+        result = json.loads(result_str)\n+        assert result[\"success\"] is False\n+        assert \"search-only\" in result[\"error\"].lower()\n+        assert \"brave\" in result[\"error\"].lower()\n+\n+    def test_web_crawl_returns_search_only_error(self, monkeypatch):\n+        import asyncio\n+        from tools import web_tools\n+\n+        monkeypatch.setattr(web_tools, \"_load_web_config\", lambda: {\"backend\": \"brave-free\"})\n+        monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n+        monkeypatch.setattr(web_tools, \"_is_tool_gateway_ready\", lambda: False)\n+        monkeypatch.setattr(web_tools, \"check_firecrawl_api_key\", lambda: False)\n+        monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False, raising=False)\n+\n+        result_str = asyncio.get_event_loop().run_until_complete(\n+            web_tools.web_crawl_tool(\"https://example.com\")\n+        )\n+        result = json.loads(result_str)\n+        assert result[\"success\"] is False\n+        assert \"search-only\" in result[\"error\"].lower()\n+        assert \"brave\" in result[\"error\"].lower()"]},{"file":"tests/tools/test_web_providers_ddgs.py","hunks":["@@ -0,0 +1,246 @@\n+\"\"\"Tests for the DuckDuckGo (ddgs) web search provider.\n+\n+Covers:\n+- DDGSSearchProvider.is_configured() — reflects package importability\n+- DDGSSearchProvider.search() — happy path, missing package, runtime error\n+- Result normalization (title, url, description, position)\n+- _is_backend_available(\"ddgs\") / _get_backend() integration\n+- web_extract / web_crawl return search-only errors when ddgs is active\n+\"\"\"\n+from __future__ import annotations\n+\n+import json\n+import sys\n+import types\n+from unittest.mock import MagicMock\n+\n+\n+def _install_fake_ddgs(monkeypatch, *, text_results=None, text_raises=None):\n+    \"\"\"Install a stub ``ddgs`` module in sys.modules for the duration of a test.\n+\n+    ``text_results``: iterable of dicts to yield from DDGS().text(...).\n+    ``text_raises``: if set, DDGS().text raises this exception instead.\n+    \"\"\"\n+    fake = types.ModuleType(\"ddgs\")\n+\n+    class _FakeDDGS:\n+        def __enter__(self):\n+            return self\n+        def __exit__(self, *_a):\n+            return False\n+        def text(self, query, max_results=5):\n+            if text_raises is not None:\n+                raise text_raises\n+            for hit in (text_results or []):\n+                yield hit\n+\n+    fake.DDGS = _FakeDDGS\n+    monkeypatch.setitem(sys.modules, \"ddgs\", fake)\n+    return fake\n+\n+\n+# ---------------------------------------------------------------------------\n+# DDGSSearchProvider unit tests\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestDDGSProviderIsConfigured:\n+    def test_configured_when_package_importable(self, monkeypatch):\n+        _install_fake_ddgs(monkeypatch)\n+        # Drop any cached ``tools.web_providers.ddgs`` so is_configured re-imports ddgs fresh\n+        monkeypatch.delitem(sys.modules, \"tools.web_providers.ddgs\", raising=False)\n+        from tools.web_providers.ddgs import DDGSSearchProvider\n+        assert DDGSSearchProvider().is_configured() is True\n+\n+    def test_not_configured_when_package_missing(self, monkeypatch):\n+        monkeypatch.delitem(sys.modules, \"ddgs\", raising=False)\n+        monkeypatch.delitem(sys.modules, \"tools.web_providers.ddgs\", raising=False)\n+        # Block the import so ``import ddgs`` raises ImportError even if the package is actually installed\n+        import builtins\n+        orig_import = builtins.__import__\n+\n+        def blocked_import(name, *args, **kwargs):\n+            if name == \"ddgs\":\n+                raise ImportError(\"blocked for test\")\n+            return orig_import(name, *args, **kwargs)\n+\n+        monkeypatch.setattr(builtins, \"__import__\", blocked_import)\n+        from tools.web_providers.ddgs import DDGSSearchProvider\n+        assert DDGSSearchProvider().is_configured() is False\n+\n+    def test_provider_name(self):\n+        from tools.web_providers.ddgs import DDGSSearchProvider\n+        assert DDGSSearchProvider().provider_name() == \"ddgs\"\n+\n+    def test_implements_web_search_provider(self):\n+        from tools.web_providers.base import WebSearchProvider\n+        from tools.web_providers.ddgs import DDGSSearchProvider\n+        assert issubclass(DDGSSearchProvider, WebSearchProvider)\n+\n+\n+class TestDDGSProviderSearch:\n+    def test_happy_path_normalizes_results(self, monkeypatch):\n+        _install_fake_ddgs(monkeypatch, text_results=[\n+            {\"title\": \"A\", \"href\": \"https://a.example.com\", \"body\": \"desc A\"},\n+            {\"title\": \"B\", \"href\": \"https://b.example.com\", \"body\": \"desc B\"},\n+            {\"title\": \"C\", \"href\": \"https://c.example.com\", \"body\": \"desc C\"},\n+        ])\n+        from tools.web_providers.ddgs import DDGSSearchProvider\n+\n+        result = DDGSSearchProvider().search(\"q\", limit=5)\n+\n+        assert result[\"success\"] is True\n+        web = result[\"data\"][\"web\"]\n+        assert len(web) == 3\n+        assert web[0] == {\"title\": \"A\", \"url\": \"https://a.example.com\", \"description\": \"desc A\", \"position\": 1}\n+        assert web[2][\"position\"] == 3\n+\n+    def test_accepts_url_key_as_fallback_for_href(self, monkeypatch):\n+        _install_fake_ddgs(monkeypatch, text_results=[\n+            {\"title\": \"A\", \"url\": \"https://a.example.com\", \"body\": \"desc A\"},\n+        ])\n+        from tools.web_providers.ddgs import DDGSSearchProvider\n+\n+        result = DDGSSearchProvider().search(\"q\", limit=5)\n+\n+        assert result[\"success\"] is True\n+        assert result[\"data\"][\"web\"][0][\"url\"] == \"https://a.example.com\"\n+\n+    def test_limit_is_respected(self, monkeypatch):\n+        _install_fake_ddgs(monkeypatch, text_results=[\n+            {\"title\": f\"R{i}\", \"href\": f\"https://r{i}.example.com\", \"body\": \"\"}\n+            for i in range(10)\n+        ])\n+        from tools.web_providers.ddgs import DDGSSearchProvider\n+\n+        result = DDGSSearchProvider().search(\"q\", limit=3)\n+\n+        assert result[\"success\"] is True\n+        assert len(result[\"data\"][\"web\"]) == 3\n+\n+    def test_missing_package_returns_failure(self, monkeypatch):\n+        monkeypatch.delitem(sys.modules, \"ddgs\", raising=False)\n+        monkeypatch.delitem(sys.modules, \"tools.web_providers.ddgs\", raising=False)\n+        import builtins\n+        orig_import = builtins.__import__\n+\n+        def blocked_import(name, *args, **kwargs):\n+            if name == \"ddgs\":\n+                raise ImportError(\"blocked for test\")\n+            return orig_import(name, *args, **kwargs)\n+\n+        monkeypatch.setattr(builtins, \"__import__\", blocked_import)\n+        from tools.web_providers.ddgs import DDGSSearchProvider\n+\n+        result = DDGSSearchProvider().search(\"q\", limit=5)\n+        assert result[\"success\"] is False\n+        assert \"ddgs\" in result[\"error\"].lower()\n+\n+    def test_runtime_error_returns_failure(self, monkeypatch):\n+        _install_fake_ddgs(monkeypatch, text_raises=RuntimeError(\"rate limited 202\"))\n+        from tools.web_providers.ddgs import DDGSSearchProvider\n+\n+        result = DDGSSearchProvider().search(\"q\", limit=5)\n+        assert result[\"success\"] is False\n+        assert \"rate limited\" in result[\"error\"] or \"failed\" in result[\"error\"].lower()\n+\n+    def test_empty_results(self, monkeypatch):\n+        _install_fake_ddgs(monkeypatch, text_results=[])\n+        from tools.web_providers.ddgs import DDGSSearchProvider\n+\n+        result = DDGSSearchProvider().search(\"nothing\", limit=5)\n+        assert result[\"success\"] is True\n+        assert result[\"data\"][\"web\"] == []\n+\n+\n+# ---------------------------------------------------------------------------\n+# Integration: _is_backend_available / _get_backend / check_web_api_key\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestDDGSBackendWiring:\n+    def test_is_backend_available_true_when_package_importable(self, monkeypatch):\n+        from tools import web_tools\n+        monkeypatch.setattr(web_tools, \"_ddgs_package_importable\", lambda: True)\n+        assert web_tools._is_backend_available(\"ddgs\") is True\n+\n+    def test_is_backend_available_false_when_package_missing(self, monkeypatch):\n+        from tools import web_tools\n+        monkeypatch.setattr(web_tools, \"_ddgs_package_importable\", lambda: False)\n+        assert web_tools._is_backend_available(\"ddgs\") is False\n+\n+    def test_configured_backend_accepted(self, monkeypatch):\n+        from tools import web_tools\n+        monkeypatch.setattr(web_tools, \"_load_web_config\", lambda: {\"backend\": \"ddgs\"})\n+        monkeypatch.setattr(web_tools, \"_ddgs_package_importable\", lambda: True)\n+        assert web_tools._get_backend() == \"ddgs\"\n+\n+    def test_ddgs_trails_paid_providers_in_auto_detect(self, monkeypatch):\n+        \"\"\"Exa (priority) should win over ddgs in auto-detect.\"\"\"\n+        from tools import web_tools\n+        monkeypatch.setattr(web_tools, \"_load_web_config\", lambda: {})\n+        for key in (\"FIRECRAWL_API_KEY\", \"FIRECRAWL_API_URL\", \"PARALLEL_API_KEY\",\n+                    \"TAVILY_API_KEY\", \"SEARXNG_URL\", \"BRAVE_SEARCH_API_KEY\"):\n+            monkeypatch.delenv(key, raising=False)\n+        monkeypatch.setenv(\"EXA_API_KEY\", \"exa-key\")\n+        monkeypatch.setattr(web_tools, \"_is_tool_gateway_ready\", lambda: False)\n+        monkeypatch.setattr(web_tools, \"_ddgs_package_importable\", lambda: True)\n+        assert web_tools._get_backend() == \"exa\"\n+\n+    def test_auto_detect_picks_ddgs_as_last_resort(self, monkeypatch):\n+        from tools import web_tools\n+        monkeypatch.setattr(web_tools, \"_load_web_config\", lambda: {})\n+        for key in (\"FIRECRAWL_API_KEY\", \"FIRECRAWL_API_URL\", \"PARALLEL_API_KEY\",\n+                    \"TAVILY_API_KEY\", \"EXA_API_KEY\", \"SEARXNG_URL\", \"BRAVE_SEARCH_API_KEY\"):\n+            monkeypatch.delenv(key, raising=False)\n+        monkeypatch.setattr(web_tools, \"_is_tool_gateway_ready\", lambda: False)\n+        monkeypatch.setattr(web_tools, \"_ddgs_package_importable\", lambda: True)\n+        assert web_tools._get_backend() == \"ddgs\"\n+\n+    def test_check_web_api_key_true_when_ddgs_configured(self, monkeypatch):\n+        from tools import web_tools\n+        monkeypatch.setattr(web_tools, \"_load_web_config\", lambda: {\"backend\": \"ddgs\"})\n+        monkeypatch.setattr(web_tools, \"_ddgs_package_importable\", lambda: True)\n+        assert web_tools.check_web_api_key() is True\n+\n+\n+# ---------------------------------------------------------------------------\n+# ddgs is search-only: web_extract / web_crawl return clear errors\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestDDGSSearchOnlyErrors:\n+    def test_web_extract_returns_search_only_error(self, monkeypatch):\n+        import asyncio\n+        from tools import web_tools\n+\n+        monkeypatch.setattr(web_tools, \"_load_web_config\", lambda: {\"backend\": \"ddgs\"})\n+        monkeypatch.setattr(web_tools, \"_ddgs_package_importable\", lambda: True)\n+        monkeypatch.setattr(web_tools, \"_is_tool_gateway_ready\", lambda: False)\n+        monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False, raising=False)\n+\n+        result_str = asyncio.get_event_loop().run_until_complete(\n+            web_tools.web_extract_tool([\"https://example.com\"])\n+        )\n+        result = json.loads(result_str)\n+        assert result[\"success\"] is False\n+        assert \"search-only\" in result[\"error\"].lower()\n+        assert \"duckduckgo\" in result[\"error\"].lower() or \"ddgs\" in result[\"error\"].lower()\n+\n+    def test_web_crawl_returns_search_only_error(self, monkeypatch):\n+        import asyncio\n+        from tools import web_tools\n+\n+        monkeypatch.setattr(web_tools, \"_load_web_config\", lambda: {\"backend\": \"ddgs\"})\n+        monkeypatch.setattr(web_tools, \"_ddgs_package_importable\", lambda: True)\n+        monkeypatch.setattr(web_tools, \"_is_tool_gateway_ready\", lambda: False)\n+        monkeypatch.setattr(web_tools, \"check_firecrawl_api_key\", lambda: False)\n+        monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False, raising=False)\n+\n+        result_str = asyncio.get_event_loop().run_until_complete(\n+            web_tools.web_crawl_tool(\"https://example.com\")\n+        )\n+        result = json.loads(result_str)\n+        assert result[\"success\"] is False\n+        assert \"search-only\" in result[\"error\"].lower()\n+        assert \"duckduckgo\" in result[\"error\"].lower() or \"ddgs\" in result[\"error\"].lower()"]},{"file":"tools/web_providers/brave_free.py","hunks":["@@ -0,0 +1,130 @@\n+\"\"\"Brave Search web search provider (free tier).\n+\n+Brave Search's Data-for-Search API offers a free tier (2,000 queries/mo at the\n+time of writing) after signing up at https://brave.com/search/api/.  This\n+provider implements ``WebSearchProvider`` only — the Data-for-Search endpoint\n+returns search results, it does not extract/crawl arbitrary URLs.\n+\n+Configuration::\n+\n+    # ~/.hermes/.env\n+    BRAVE_SEARCH_API_KEY=[redacted:secret-assignment]\n+\n+    # ~/.hermes/config.yaml\n+    web:\n+      search_backend: \"brave-free\"\n+      extract_backend: \"firecrawl\"    # pair with an extract provider if needed\n+\n+The API uses the ``X-Subscription-Token`` header.  Free-tier keys are rate\n+limited (1 qps) and capped at 2k queries/month; see the Brave dashboard for\n+current quotas.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import logging\n+import os\n+from typing import Any, Dict\n+\n+from tools.web_providers.base import WebSearchProvider\n+\n+logger = logging.getLogger(__name__)\n+\n+_BRAVE_ENDPOINT = \"https://api.search.brave.com/res/v1/web/search\"\n+\n+\n+class BraveFreeSearchProvider(WebSearchProvider):\n+    \"\"\"Search via the Brave Search API (free tier).\n+\n+    Requires ``BRAVE_SEARCH_API_KEY`` to be set. The value is passed as the\n+    ``X-Subscription-Token`` header. No extract capability — pair with\n+    Firecrawl/Tavily/Exa/Parallel when you also need ``web_extract``.\n+    \"\"\"\n+\n+    def provider_name(self) -> str:\n+        return \"brave-free\"\n+\n+    def is_configured(self) -> bool:\n+        \"\"\"Return True when ``BRAVE_SEARCH_API_KEY`` is set to a non-empty value.\"\"\"\n+        return bool(os.getenv(\"BRAVE_SEARCH_API_KEY\", \"\").strip())\n+\n+    def search(self, query: str, limit: int = 5) -> Dict[str, Any]:\n+        \"\"\"Execute a search against the Brave Search API.\n+\n+        Returns normalized results::\n+\n+            {\n+                \"success\": True,\n+                \"data\": {\n+                    \"web\": [\n+                        {\n+                            \"title\": str,\n+                            \"url\": str,\n+                            \"description\": str,\n+                            \"position\": int,\n+                        },\n+                        ...\n+                    ]\n+                }\n+            }\n+\n+        On failure returns ``{\"success\": False, \"error\": str}``.\n+        \"\"\"\n+        import httpx\n+\n+        api_key = os.getenv(\"BRAVE_SEARCH_API_KEY\", \"\").strip()\n+        if not api_key:\n+            return {\"success\": False, \"error\": \"BRAVE_SEARCH_API_KEY is not set\"}\n+\n+        # Brave's `count` is capped at 20.\n+        count = max(1, min(int(limit), 20))\n+\n+        try:\n+            resp = httpx.get(\n+                _BRAVE_ENDPOINT,\n+                params={\"q\": query, \"count\": count},\n+                headers={\n+                    \"X-Subscription-Token\": [redacted:secret-assignment]\n+                    \"Accept\": \"application/json\",\n+                },\n+                timeout=15,\n+            )\n+            resp.raise_for_status()\n+        except httpx.HTTPStatusError as exc:\n+            logger.warning(\"Brave Search HTTP error: %s\", exc)\n+            return {\n+                \"success\": False,\n+                \"error\": f\"Brave Search returned HTTP {exc.response.status_code}\",\n+            }\n+        except httpx.RequestError as exc:\n+            logger.warning(\"Brave Search request error: %s\", exc)\n+            return {\"success\": False, \"error\": f\"Could not reach Brave Search: {exc}\"}\n+\n+        try:\n+            data = resp.json()\n+        except Exception as exc:  # noqa: BLE001\n+            logger.warning(\"Brave Search response parse error: %s\", exc)\n+            return {\"success\": False, \"error\": \"Could not parse Brave Search response as JSON\"}\n+\n+        raw_results = (data.get(\"web\") or {}).get(\"results\", []) or []\n+        truncated = raw_results[:limit]\n+\n+        web_results = [\n+            {\n+                \"title\": str(r.get(\"title\", \"\")),\n+                \"url\": str(r.get(\"url\", \"\")),\n+                \"description\": str(r.get(\"description\", \"\")),\n+                \"position\": i + 1,\n+            }\n+            for i, r in enumerate(truncated)\n+        ]\n+\n+        logger.info(\n+            \"Brave Search '%s': %d results (from %d raw, limit %d)\",\n+            query,\n+            len(web_results),\n+            len(raw_results),\n+            limit,\n+        )\n+\n+        return {\"success\": True, \"data\": {\"web\": web_results}}"]},{"file":"tools/web_providers/ddgs.py","hunks":["@@ -0,0 +1,98 @@\n+\"\"\"DuckDuckGo web search provider via the ``ddgs`` Python package.\n+\n+DuckDuckGo does not provide an official programmatic search API.  The\n+community-maintained `ddgs <https://pypi.org/project/ddgs/>`_ package (the\n+renamed successor of ``duckduckgo-search``) scrapes DuckDuckGo's HTML results\n+page and normalizes them.  It implements ``WebSearchProvider`` only — there is\n+no extract capability.\n+\n+Configuration::\n+\n+    # No API key required. Enable by installing the package and pointing the\n+    # web backend at ddgs:\n+    pip install ddgs\n+\n+    # ~/.hermes/config.yaml\n+    web:\n+      search_backend: \"ddgs\"\n+      extract_backend: \"firecrawl\"    # pair with an extract provider if needed\n+\n+Rate limits are enforced server-side by DuckDuckGo.  Expect intermittent\n+``DuckDuckGoSearchException`` / 202 responses under heavy use; this provider\n+surfaces them as ``{\"success\": False, \"error\": ...}`` rather than crashing\n+the tool call.\n+\n+See https://duckduckgo.com/?q=duckduckgo+tos for terms of use.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import logging\n+from typing import Any, Dict\n+\n+from tools.web_providers.base import WebSearchProvider\n+\n+logger = logging.getLogger(__name__)\n+\n+\n+class DDGSSearchProvider(WebSearchProvider):\n+    \"\"\"Search via the ``ddgs`` package (DuckDuckGo HTML scrape).\n+\n+    No API key required.  The provider is considered \"configured\" when the\n+    ``ddgs`` package is importable — there is nothing else to set up.\n+    \"\"\"\n+\n+    def provider_name(self) -> str:\n+        return \"ddgs\"\n+\n+    def is_configured(self) -> bool:\n+        \"\"\"Return True when the ``ddgs`` package is importable.\n+\n+        Called at tool-registration time; must not perform network I/O.\n+        \"\"\"\n+        try:\n+            import ddgs  # noqa: F401\n+            return True\n+        except ImportError:\n+            return False\n+\n+    def search(self, query: str, limit: int = 5) -> Dict[str, Any]:\n+        \"\"\"Execute a DuckDuckGo search and return normalized results.\n+\n+        Returns ``{\"success\": True, \"data\": {\"web\": [...]}}`` on success or\n+        ``{\"success\": False, \"error\": str}`` on failure (missing package,\n+        rate-limited, network error, etc.).\n+        \"\"\"\n+        try:\n+            from ddgs import DDGS  # type: ignore\n+        except ImportError:\n+            return {\n+                \"success\": False,\n+                \"error\": \"ddgs package is not installed — run `pip install ddgs`\",\n+            }\n+\n+        # DDGS().text yields at most `max_results` items; we cap defensively\n+        # in case the package ignores the hint.\n+        safe_limit = max(1, int(limit))\n+\n+        try:\n+            web_results = []\n+            with DDGS() as client:\n+                for i, hit in enumerate(client.text(query, max_results=safe_limit)):\n+                    if i >= safe_limit:\n+                        break\n+                    url = str(hit.get(\"href\") or hit.get(\"url\") or \"\")\n+                    web_results.append(\n+                        {\n+                            \"title\": str(hit.get(\"title\", \"\")),\n+                            \"url\": url,\n+                            \"description\": str(hit.get(\"body\", \"\")),\n+                            \"position\": i + 1,\n+                        }\n+                    )\n+        except Exception as exc:  # noqa: BLE001 — ddgs raises its own exceptions\n+            logger.warning(\"DDGS search error: %s\", exc)\n+            return {\"success\": False, \"error\": f\"DuckDuckGo search failed: {exc}\"}\n+\n+        logger.info(\"DDGS search '%s': %d results (limit %d)\", query, len(web_results), limit)\n+        return {\"success\": True, \"data\": {\"web\": web_results}}"]},{"file":"tools/web_tools.py","hunks":["@@ -126,18 +126,22 @@ 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\"):\n+    if configured in (\"parallel\", \"firecrawl\", \"tavily\", \"exa\", \"searxng\", \"brave-free\", \"ddgs\"):\n         return configured\n \n     # Fallback for manual / legacy config — pick the highest-priority\n     # available backend. Firecrawl also counts as available when the managed\n     # tool gateway is configured for Nous subscribers.\n+    # Free-tier backends (searxng / brave-free / ddgs) trail the paid ones so\n+    # existing paid setups are unaffected.\n     backend_candidates = (\n         (\"firecrawl\", _has_env(\"FIRECRAWL_API_KEY\") or _has_env(\"FIRECRAWL_API_URL\") or _is_tool_gateway_ready()),\n         (\"parallel\", _has_env(\"PARALLEL_API_KEY\")),\n         (\"tavily\", _has_env(\"TAVILY_API_KEY\")),\n         (\"exa\", _has_env(\"EXA_API_KEY\")),\n         (\"searxng\", _has_env(\"SEARXNG_URL\")),\n+        (\"brave-free\", _has_env(\"BRAVE_SEARCH_API_KEY\")),\n+        (\"ddgs\", _ddgs_package_importable()),\n     )\n     for backend, available in backend_candidates:\n         if available:","@@ -196,8 +200,27 @@ def _is_backend_available(backend: str) -> bool:\n         return _has_env(\"TAVILY_API_KEY\")\n     if backend == \"searxng\":\n         return _has_env(\"SEARXNG_URL\")\n+    if backend == \"brave-free\":\n+        return _has_env(\"BRAVE_SEARCH_API_KEY\")\n+    if backend == \"ddgs\":\n+        return _ddgs_package_importable()\n     return False\n \n+\n+def _ddgs_package_importable() -> bool:\n+    \"\"\"Return True when the ``ddgs`` Python package can be imported.\n+\n+    ddgs is the only backend whose availability is driven by a package\n+    presence rather than an env var / config entry.  Wrapped in a helper\n+    so auto-detect and ``_is_backend_available`` share the same check\n+    (and tests can monkeypatch a single symbol).\n+    \"\"\"\n+    try:\n+        import ddgs  # noqa: F401\n+        return True\n+    except ImportError:\n+        return False\n+\n # ─── Firecrawl Client ────────────────────────────────────────────────────────\n \n _firecrawl_client = None","@@ -1200,6 +1223,26 @@ def web_search_tool(query: str, limit: int = 5) -> str:\n             _debug.save()\n             return result_json\n \n+        if backend == \"brave-free\":\n+            from tools.web_providers.brave_free import BraveFreeSearchProvider\n+            response_data = BraveFreeSearchProvider().search(query, limit)\n+            debug_call_data[\"results_count\"] = len(response_data.get(\"data\", {}).get(\"web\", []))\n+            result_json = json.dumps(response_data, indent=2, ensure_ascii=False)\n+            debug_call_data[\"final_response_size\"] = len(result_json)\n+            _debug.log_call(\"web_search_tool\", debug_call_data)\n+            _debug.save()\n+            return result_json\n+\n+        if backend == \"ddgs\":\n+            from tools.web_providers.ddgs import DDGSSearchProvider\n+            response_data = DDGSSearchProvider().search(query, limit)\n+            debug_call_data[\"results_count\"] = len(response_data.get(\"data\", {}).get(\"web\", []))\n+            result_json = json.dumps(response_data, indent=2, ensure_ascii=False)\n+            debug_call_data[\"final_response_size\"] = len(result_json)\n+            _debug.log_call(\"web_search_tool\", debug_call_data)\n+            _debug.save()\n+            return result_json\n+\n         if backend == \"tavily\":\n             logger.info(\"Tavily search: '%s' (limit: %d)\", query, limit)\n             raw = _tavily_request(\"search\", {","@@ -1350,11 +1393,12 @@ async def web_extract_tool(\n                     \"include_images\": False,\n                 })\n                 results = _normalize_tavily_documents(raw, fallback_url=safe_urls[0] if safe_urls else \"\")\n-            elif backend == \"searxng\":\n-                # SearXNG is search-only — it cannot extract URL content\n+            elif backend in (\"searxng\", \"brave-free\", \"ddgs\"):\n+                # These backends are search-only — they cannot extract URL content\n+                _label = {\"searxng\": \"SearXNG\", \"brave-free\": \"Brave Search (free tier)\", \"ddgs\": \"DuckDuckGo (ddgs)\"}[backend]\n                 return json.dumps({\n                     \"success\": False,\n-                    \"error\": \"SearXNG is a search-only backend and cannot extract URL content. \"\n+                    \"error\": f\"{_label} is a search-only backend and cannot extract URL content. \"\n                              \"Set web.extract_backend to firecrawl, tavily, exa, or parallel.\",\n                 }, ensure_ascii=False)\n             else:","@@ -1732,10 +1776,11 @@ async def _process_tavily_crawl(result):\n             _debug.save()\n             return cleaned_result\n \n-        # SearXNG is search-only — it cannot crawl\n-        if backend == \"searxng\":\n+        # SearXNG / Brave Search (free tier) / DuckDuckGo (ddgs) are search-only — they cannot crawl\n+        if backend in (\"searxng\", \"brave-free\", \"ddgs\"):\n+            _label = {\"searxng\": \"SearXNG\", \"brave-free\": \"Brave Search (free tier)\", \"ddgs\": \"DuckDuckGo (ddgs)\"}[backend]\n             return json.dumps({\n-                \"error\": \"SearXNG is a search-only backend and cannot crawl URLs. \"\n+                \"error\": f\"{_label} is a search-only backend and cannot crawl URLs. \"\n                          \"Set FIRECRAWL_API_KEY for crawling, or use web_search instead.\",\n                 \"success\": False,\n             }, ensure_ascii=False)","@@ -2035,9 +2080,12 @@ def check_firecrawl_api_key() -> bool:\n def check_web_api_key() -> bool:\n     \"\"\"Check whether the configured web backend is available.\"\"\"\n     configured = _load_web_config().get(\"backend\", \"\").lower().strip()\n-    if configured in (\"exa\", \"parallel\", \"firecrawl\", \"tavily\", \"searxng\"):\n+    if configured in (\"exa\", \"parallel\", \"firecrawl\", \"tavily\", \"searxng\", \"brave-free\", \"ddgs\"):\n         return _is_backend_available(configured)\n-    return any(_is_backend_available(backend) for backend in (\"exa\", \"parallel\", \"firecrawl\", \"tavily\", \"searxng\"))\n+    return any(\n+        _is_backend_available(backend)\n+        for backend in (\"exa\", \"parallel\", \"firecrawl\", \"tavily\", \"searxng\", \"brave-free\", \"ddgs\")\n+    )\n \n \n def check_auxiliary_model() -> bool:","@@ -2074,6 +2122,10 @@ def check_auxiliary_model() -> bool:\n             print(\"   Using Tavily API (https://tavily.com)\")\n         elif backend == \"searxng\":\n             print(f\"   Using SearXNG (search only): {os.getenv('SEARXNG_URL', '').strip()}\")\n+        elif backend == \"brave-free\":\n+            print(\"   Using Brave Search free tier (search only)\")\n+        elif backend == \"ddgs\":\n+            print(\"   Using DuckDuckGo via ddgs package (search only)\")\n         else:\n             if firecrawl_url_available:\n                 print(f\"   Using self-hosted Firecrawl: {os.getenv('FIRECRAWL_API_URL').strip().rstrip('/')}\")\n"]}]},{"kind":"pr","number":46214,"title":"feat(web): add SEARXNG_API_KEY support for authenticated SearXNG instances","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}