From dc4fb38290918dfbef59de8a4a62cbd77385c331 Mon Sep 17 00:00:00 2001 From: "ang-fleet-workers[bot]" <333956806+ang-fleet-workers[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:47:46 -0700 Subject: [PATCH 1/6] wip: web keyed fallback chains (t_194e2328) From 7ff1068eb5796006b672ef238eca95e53b31cfb1 Mon Sep 17 00:00:00 2001 From: "ang-fleet-workers[bot]" <333956806+ang-fleet-workers[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:52:19 -0700 Subject: [PATCH 2/6] wip: dispatch keyed web fallbacks before anonymous rescue --- tests/tools/test_web_keyed_fallbacks.py | 110 ++++++++++++++++ tools/web_tools.py | 166 +++++++++++++++++------- 2 files changed, 228 insertions(+), 48 deletions(-) create mode 100644 tests/tools/test_web_keyed_fallbacks.py diff --git a/tests/tools/test_web_keyed_fallbacks.py b/tests/tools/test_web_keyed_fallbacks.py new file mode 100644 index 0000000000000..88e7a96c1f35f --- /dev/null +++ b/tests/tools/test_web_keyed_fallbacks.py @@ -0,0 +1,110 @@ +"""Configured keyed web fallbacks precede the anonymous rescue ring.""" +import json + +import pytest + +from tools import web_tools +from tools.web_result_cache import search_memo + + +class Provider: + def __init__(self, name, calls, *, search=None, extract=None): + self.name = name + self.calls = calls + self._search = search + self._extract = extract + + def supports_search(self): + return self._search is not None + + def supports_extract(self): + return self._extract is not None + + def is_available(self): + return True + + def search(self, query, limit): + self.calls.append(self.name) + return self._search + + def extract(self, urls, **kwargs): + self.calls.append(self.name) + return [dict(r) for r in self._extract] + + +@pytest.fixture +def setup(monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr("agent.web_search_provider.get_provider_env", + lambda key: "test-key" if key in ("TAVILY_API_KEY", "EXA_API_KEY") else "") + monkeypatch.setattr(web_tools, "_ensure_web_plugins_loaded", lambda: None) + monkeypatch.setattr(web_tools, "_load_web_config", lambda: { + "search_backend": "firecrawl", "extract_backend": "firecrawl", + "search_fallbacks": ["tavily", "exa"], + "extract_fallbacks": ["tavily", "exa"], + }) + async def safe(url): + return True + monkeypatch.setattr(web_tools, "async_is_safe_url", safe) + calls = [] + providers = {} + monkeypatch.setattr("agent.web_search_registry.get_provider", providers.get) + return calls, providers + + +def test_search_order_and_identity(setup, monkeypatch): + calls, providers = setup + fail = {"success": False, "error": "HTTP 402"} + ok = {"success": True, "data": {"web": [{"url": "https://ok.example"}]}} + providers.update(firecrawl=Provider("firecrawl", calls, search=fail), + tavily=Provider("tavily", calls, search=fail), + exa=Provider("exa", calls, search=ok)) + monkeypatch.setattr(web_tools, "_rescue_search", lambda *args: pytest.fail("ring before keyed chain")) + result = json.loads(web_tools.web_search_tool("unique keyed order")) + assert calls == ["firecrawl", "tavily", "exa"] + assert result["data"]["metadata"] == {"served_by": "exa", "fallback_from": "firecrawl"} + + +@pytest.mark.asyncio +async def test_extract_partial_failure_does_not_fallback(setup, monkeypatch): + calls, providers = setup + urls = ["https://a.example", "https://b.example"] + providers["firecrawl"] = Provider("firecrawl", calls, extract=[ + {"url": urls[0], "content": "ok", "error": None}, + {"url": urls[1], "content": "", "error": "404"}, + ]) + monkeypatch.setattr(web_tools, "_rescue_extract", lambda *args: pytest.fail("ring called")) + out = json.loads(await web_tools.web_extract_tool(urls)) + assert calls == ["firecrawl"] + assert out["results"][1]["error"] == "404" + + +@pytest.mark.asyncio +async def test_extract_402_to_keyed_tavily_real_dispatch(setup, monkeypatch): + calls, providers = setup + urls = ["https://a.example"] + providers.update(firecrawl=Provider("firecrawl", calls, extract=[ + {"url": urls[0], "content": "", "error": "HTTP 402 credits exhausted"}]), + tavily=Provider("tavily", calls, extract=[ + {"url": urls[0], "content": "tavily content", "error": None}])) + monkeypatch.setattr(web_tools, "_rescue_extract", lambda *args: pytest.fail("ring before keyed")) + out = json.loads(await web_tools.web_extract_tool(urls)) + assert calls == ["firecrawl", "tavily"] + assert out["results"][0]["content"] == "tavily content" + assert out["results"][0]["metadata"] == {"served_by": "tavily", "fallback_from": "firecrawl"} + + +@pytest.mark.asyncio +async def test_exhausted_chain_reaches_ring(setup, monkeypatch): + calls, providers = setup + urls = ["https://a.example"] + bad = [{"url": urls[0], "error": "HTTP 402"}] + for name in ("firecrawl", "tavily", "exa"): + providers[name] = Provider(name, calls, extract=bad) + monkeypatch.setattr(web_tools, "_rescue_extract", lambda name, urls, results: [ + {"url": urls[0], "content": "ring", "metadata": {"rescued_from": name}}]) + monkeypatch.setattr(web_tools, "_rescue_eligible", lambda provider: True) + out = json.loads(await web_tools.web_extract_tool(urls)) + assert calls == ["firecrawl", "tavily", "exa"] + assert out["results"][0]["content"] == "ring" + assert out["results"][0]["metadata"]["rescued_from"] == "exa" diff --git a/tools/web_tools.py b/tools/web_tools.py index 0d9aca14f3d1c..dc4e8319765f3 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -428,6 +428,50 @@ def _ddgs_package_importable() -> bool: return False +# ─── Configured keyed fallbacks, then one-shot keyless rescue ───────────────── + +def _keyed_fallbacks(capability: str, primary: str): + """Yield distinct configured providers with credentials, never anonymous ring paths.""" + from agent.web_search_registry import get_provider + from agent.web_search_provider import get_provider_env + + key_vars = { + "firecrawl": "FIRECRAWL_API_KEY", "tavily": "TAVILY_API_KEY", + "exa": "EXA_API_KEY", "parallel": "PARALLEL_API_KEY", + "keenable": "KEENABLE_API_KEY", "brave-free": "BRAVE_SEARCH_API_KEY", + "searxng": "SEARXNG_URL", + } + configured = _load_web_config().get(f"{capability}_fallbacks", []) + if not isinstance(configured, list): + return + seen = {primary} + for item in configured: + if not isinstance(item, str): + continue + name = item.lower().strip() + if name in seen: + continue + seen.add(name) + provider = get_provider(name) + if provider is None or not getattr(provider, f"supports_{capability}")(): + continue + # Ring vendors without a key invoke their anonymous endpoint and can + # walk the ring themselves. They belong to the later rescue, not here. + if name in key_vars and not get_provider_env(key_vars[name]): + continue + try: + if provider.is_available(): + yield provider + except Exception as exc: # noqa: BLE001 — skip broken fallback + logger.debug("keyed web fallback %r unavailable: %s", name, exc) + + +def _failed_extract_batch(results: list, urls: list) -> bool: + return (bool(results) and len(results) == len(urls) + and all(r.get("error") for r in results) + and not all(_policy_blocked_result(r) for r in results)) + + # ─── One-shot keyless rescue (keyed/configured backend failed) ─────────────── def _keyless_rescue_enabled() -> bool: @@ -982,28 +1026,36 @@ def web_search_tool(query: str, limit: int = 5) -> str: def _paid_search() -> tuple[dict, bool]: _fetch_limit = _bucket_limit(limit) _rescued = False + current = provider try: - _resp = provider.search(query, _fetch_limit) - except Exception as exc: # noqa: BLE001 — candidate for rescue - if _rescue_eligible(provider): - _rescued = True - _resp = _rescue_search( - provider.name, str(exc), query, _fetch_limit - ) - else: + _resp = current.search(query, _fetch_limit) + except Exception as exc: # noqa: BLE001 — candidate for fallback + _resp = {"success": False, "error": str(exc)} + if not _load_web_config().get("search_fallbacks") and not _rescue_eligible(current): raise - else: - if not _resp.get("success") and _rescue_eligible(provider): - # One-shot keyless rescue: THIS call rides the - # free-tier ring; the next call attempts the chosen - # backend again. - _rescued = True - _resp = _rescue_search( - provider.name, - str(_resp.get("error", "")), - query, - _fetch_limit, + for candidate in _keyed_fallbacks("search", provider.name): + if _resp.get("success"): + break + logger.info( + "web_search backend '%s' failed (%s); trying keyed fallback '%s'", + current.name, str(_resp.get("error", ""))[:200], candidate.name, + ) + current = candidate + try: + _resp = candidate.search(query, _fetch_limit) + except Exception as exc: # noqa: BLE001 — next keyed candidate + _resp = {"success": False, "error": str(exc)} + if current is not provider: + _rescued = True # never cache under the primary vendor's key + if _resp.get("success"): + _resp.setdefault("data", {}).setdefault("metadata", {}).update( + served_by=current.name, fallback_from=provider.name ) + if not _resp.get("success") and _rescue_eligible(current): + _rescued = True + _resp = _rescue_search( + current.name, str(_resp.get("error", "")), query, _fetch_limit + ) return _resp, _rescued response_data = _search_memo.lookup(provider.name, query, limit) @@ -1307,40 +1359,55 @@ async def web_extract_tool( # extract(); exa + tavily are sync. import inspect _extract_rescued = False + current = provider try: - if inspect.iscoroutinefunction(provider.extract): - results = await provider.extract(fetch_urls, format=format) + if inspect.iscoroutinefunction(current.extract): + results = await current.extract(fetch_urls, format=format) else: - # Run sync extract() in a thread so we don't block the - # event loop on network I/O. results = await asyncio.to_thread( - provider.extract, fetch_urls, format=format + current.extract, fetch_urls, format=format ) - except Exception as exc: # noqa: BLE001 — candidate for rescue - if _rescue_eligible(provider): - _extract_rescued = True - failed = [ - {"url": u, "title": "", "content": "", "error": str(exc)} - for u in fetch_urls - ] - results = await asyncio.to_thread( - _rescue_extract, provider.name, fetch_urls, failed - ) - else: + except Exception as exc: # noqa: BLE001 — candidate for fallback + if not _load_web_config().get("extract_fallbacks") and not _rescue_eligible(current): raise - else: - # One-shot keyless rescue when the WHOLE batch failed - # (backend-level outage, not per-page problems). Stateless: - # the next web_extract call uses the chosen backend again. - if ( - results - and all(r.get("error") for r in results) - and _rescue_eligible(provider) - ): - _extract_rescued = True - results = await asyncio.to_thread( - _rescue_extract, provider.name, fetch_urls, results - ) + results = [{"url": u, "title": "", "content": "", "error": str(exc)} + for u in fetch_urls] + for candidate in _keyed_fallbacks("extract", provider.name): + if not _failed_extract_batch(results, fetch_urls): + break + # Never route policy-blocked pages through another vendor. + if any(_policy_blocked_result(r) for r in results): + break + logger.info( + "web_extract backend '%s' failed all %d URL(s) (%s); trying keyed fallback '%s'", + current.name, len(fetch_urls), str(results[0].get("error", ""))[:200], + candidate.name, + ) + current = candidate + try: + if inspect.iscoroutinefunction(current.extract): + results = await current.extract(fetch_urls, format=format) + else: + results = await asyncio.to_thread( + current.extract, fetch_urls, format=format + ) + except Exception as exc: # noqa: BLE001 — next keyed candidate + results = [{"url": u, "title": "", "content": "", "error": str(exc)} + for u in fetch_urls] + if current is not provider: + _extract_rescued = True # do not cache under the primary name + for r in results: + if not r.get("error"): + meta = r.get("metadata") + if not isinstance(meta, dict): + meta = {} + r["metadata"] = meta + meta.update(served_by=current.name, fallback_from=provider.name) + if _failed_extract_batch(results, fetch_urls) and _rescue_eligible(current): + _extract_rescued = True + results = await asyncio.to_thread( + _rescue_extract, current.name, fetch_urls, results + ) # Cache each successful fetch's full clean text for TTL reuse # (best-effort; oversized pages are skipped by the cache). @@ -1452,6 +1519,9 @@ async def web_extract_tool( "title": r.get("title", ""), "content": r.get("content", ""), "error": r.get("error"), + **({"metadata": {k: r["metadata"][k] for k in ("served_by", "fallback_from") + if k in r["metadata"]}} if isinstance(r.get("metadata"), dict) + and any(k in r["metadata"] for k in ("served_by", "fallback_from")) else {}), **({ "blocked_by_policy": r["blocked_by_policy"]} if "blocked_by_policy" in r else {}), } for r in response.get("results", []) From f3c2cf24eacf64e13d7797c05bb2bae7dd5759bd Mon Sep 17 00:00:00 2001 From: "ang-fleet-workers[bot]" <333956806+ang-fleet-workers[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:55:07 -0700 Subject: [PATCH 3/6] wip: expose web fallback configuration and integration proof --- hermes_cli/config_defaults.py | 2 ++ hermes_cli/tools_config.py | 6 ++++ tests/tools/test_web_keyed_fallback_e2e.py | 40 ++++++++++++++++++++++ tests/tools/test_web_keyed_fallbacks.py | 1 - 4 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/tools/test_web_keyed_fallback_e2e.py diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 066acd05e3ea4..160e5b8568641 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -602,6 +602,8 @@ "backend": "", # shared fallback — applies to both search and extract "search_backend": "", # per-capability override for web_search (e.g. "searxng") "extract_backend": "", # per-capability override for web_extract (e.g. "native") + "search_fallbacks": [], # keyed providers tried in order before keyless rescue + "extract_fallbacks": [], # keyed providers tried in order before keyless rescue "extract_char_limit": 15000, # per-page char budget for web_extract; larger pages truncate + store full text in cache/web # Keyless free-tier ring: with NO web backend configured or keyed, # web_search/web_extract rotate round-robin across five vendors' diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index e849eaed49135..33395bfe8bbbd 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -3967,6 +3967,12 @@ def _configure_tool_category( # Use custom title if provided (e.g. "Select Search Provider") title = cat.get("setup_title", "Choose a provider") print(color(f" --- {icon} {name} - {title} ---", Colors.CYAN)) + if ts_key == "web": + web_cfg = config.get("web") or {} + for capability in ("search", "extract"): + chain = web_cfg.get(f"{capability}_fallbacks") if isinstance(web_cfg, dict) else None + if isinstance(chain, list) and chain: + _print_info(f" {capability} keyed fallbacks: {' → '.join(str(v) for v in chain)}") if cat.get("setup_note"): _print_info(f" {cat['setup_note']}") if hidden_nous_message: diff --git a/tests/tools/test_web_keyed_fallback_e2e.py b/tests/tools/test_web_keyed_fallback_e2e.py new file mode 100644 index 0000000000000..87dbd698dd334 --- /dev/null +++ b/tests/tools/test_web_keyed_fallback_e2e.py @@ -0,0 +1,40 @@ +"""End-to-end config and provider resolution with real plugin imports.""" +import json + +import pytest + +from tools import web_tools +from plugins.web.firecrawl.provider import FirecrawlWebSearchProvider +from plugins.web.tavily.provider import TavilyWebSearchProvider + + +@pytest.mark.asyncio +async def test_configured_firecrawl_402_falls_back_to_keyed_tavily(tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + cfg = home / "config.yaml" + cfg.write_text("web:\n extract_backend: firecrawl\n extract_fallbacks: [tavily]\n") + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_CONFIG", str(cfg)) + monkeypatch.setenv("TAVILY_API_KEY", "test-key-not-sent") + monkeypatch.setenv("FIRECRAWL_API_KEY", "test-key-not-sent") + calls = [] + + async def firecrawl(self, urls, **kwargs): + calls.append("firecrawl") + return [{"url": u, "error": "HTTP 402 credits exhausted"} for u in urls] + + def tavily(self, urls, **kwargs): + calls.append("tavily") + return [{"url": u, "content": "real plugin dispatch", "error": None} for u in urls] + + async def safe(url): + return True + + monkeypatch.setattr(FirecrawlWebSearchProvider, "extract", firecrawl) + monkeypatch.setattr(TavilyWebSearchProvider, "extract", tavily) + monkeypatch.setattr(web_tools, "async_is_safe_url", safe) + out = json.loads(await web_tools.web_extract_tool(["https://example.com/article"])) + assert calls == ["firecrawl", "tavily"] + assert out["results"][0]["metadata"]["served_by"] == "tavily" + assert out["results"][0]["metadata"]["fallback_from"] == "firecrawl" diff --git a/tests/tools/test_web_keyed_fallbacks.py b/tests/tools/test_web_keyed_fallbacks.py index 88e7a96c1f35f..7e0a69085544e 100644 --- a/tests/tools/test_web_keyed_fallbacks.py +++ b/tests/tools/test_web_keyed_fallbacks.py @@ -107,4 +107,3 @@ async def test_exhausted_chain_reaches_ring(setup, monkeypatch): out = json.loads(await web_tools.web_extract_tool(urls)) assert calls == ["firecrawl", "tavily", "exa"] assert out["results"][0]["content"] == "ring" - assert out["results"][0]["metadata"]["rescued_from"] == "exa" From af61dac8e834f9eee794a4676663921f053d9443 Mon Sep 17 00:00:00 2001 From: "ang-fleet-workers[bot]" <333956806+ang-fleet-workers[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:58:08 -0700 Subject: [PATCH 4/6] fix(web): include primary serving identity without changing unconfigured results --- tests/tools/test_web_keyed_fallbacks.py | 1 + tools/web_tools.py | 27 ++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_web_keyed_fallbacks.py b/tests/tools/test_web_keyed_fallbacks.py index 7e0a69085544e..4c2840a939542 100644 --- a/tests/tools/test_web_keyed_fallbacks.py +++ b/tests/tools/test_web_keyed_fallbacks.py @@ -77,6 +77,7 @@ async def test_extract_partial_failure_does_not_fallback(setup, monkeypatch): out = json.loads(await web_tools.web_extract_tool(urls)) assert calls == ["firecrawl"] assert out["results"][1]["error"] == "404" + assert out["results"][0]["metadata"] == {"served_by": "firecrawl"} @pytest.mark.asyncio diff --git a/tools/web_tools.py b/tools/web_tools.py index dc4e8319765f3..e8073e9680067 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -457,8 +457,11 @@ def _keyed_fallbacks(capability: str, primary: str): continue # Ring vendors without a key invoke their anonymous endpoint and can # walk the ring themselves. They belong to the later rescue, not here. - if name in key_vars and not get_provider_env(key_vars[name]): - continue + if name in key_vars: + api_key = get_provider_env(key_vars[name]) + from plugins.web.keyless_mcp import use_keyless + if not api_key or use_keyless(name, api_key): + continue try: if provider.is_available(): yield provider @@ -1051,6 +1054,10 @@ def _paid_search() -> tuple[dict, bool]: _resp.setdefault("data", {}).setdefault("metadata", {}).update( served_by=current.name, fallback_from=provider.name ) + if _resp.get("success") and _load_web_config().get("search_fallbacks"): + _resp.setdefault("data", {}).setdefault("metadata", {}).setdefault( + "served_by", current.name + ) if not _resp.get("success") and _rescue_eligible(current): _rescued = True _resp = _rescue_search( @@ -1209,6 +1216,7 @@ async def web_extract_tool( safe_indices.append(index) # Dispatch only safe URLs to the configured backend + backend = "" if not safe_urls: results = [] else: @@ -1403,7 +1411,10 @@ async def web_extract_tool( meta = {} r["metadata"] = meta meta.update(served_by=current.name, fallback_from=provider.name) - if _failed_extract_batch(results, fetch_urls) and _rescue_eligible(current): + if ((results and all(r.get("error") for r in results) + and (_failed_extract_batch(results, fetch_urls) + or not _load_web_config().get("extract_fallbacks"))) + and _rescue_eligible(current)): _extract_rescued = True results = await asyncio.to_thread( _rescue_extract, current.name, fetch_urls, results @@ -1452,6 +1463,16 @@ async def web_extract_tool( ) results = merged + if safe_urls and _load_web_config().get("extract_fallbacks"): + from typing import cast + for r in cast(List[Dict[str, Any]], results): + if not r.get("error") and not _policy_blocked_result(r): + meta = r.get("metadata") + if not isinstance(meta, dict): + meta = {} + r["metadata"] = meta + meta.setdefault("served_by", backend) + # Reconstruct the original input order across invalid, blocked, and # provider-processed entries. Providers are expected to preserve the # order of the safe URL list they receive. From 6a6c0ccae557a500954308b0c96250bed6c11d9a Mon Sep 17 00:00:00 2001 From: "ang-fleet-workers[bot]" <333956806+ang-fleet-workers[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:00:01 -0700 Subject: [PATCH 5/6] test(web): pin credential and policy fallback boundaries --- tests/tools/test_web_keyed_fallbacks.py | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/tools/test_web_keyed_fallbacks.py b/tests/tools/test_web_keyed_fallbacks.py index 4c2840a939542..f226e23d17836 100644 --- a/tests/tools/test_web_keyed_fallbacks.py +++ b/tests/tools/test_web_keyed_fallbacks.py @@ -95,6 +95,32 @@ async def test_extract_402_to_keyed_tavily_real_dispatch(setup, monkeypatch): assert out["results"][0]["metadata"] == {"served_by": "tavily", "fallback_from": "firecrawl"} + + +def test_search_missing_key_skips_candidate(setup, monkeypatch): + calls, providers = setup + monkeypatch.setattr("agent.web_search_provider.get_provider_env", lambda key: "") + providers.update(firecrawl=Provider("firecrawl", calls, search={"success": False, "error": "402"}), + tavily=Provider("tavily", calls, search={"success": True, "data": {"web": []}})) + monkeypatch.setattr(web_tools, "_rescue_eligible", lambda provider: False) + out = json.loads(web_tools.web_search_tool("missing-key-skip")) + assert calls == ["firecrawl"] + assert out["success"] is False + + +@pytest.mark.asyncio +async def test_policy_refusal_never_sent_to_keyed_fallback(setup, monkeypatch): + calls, providers = setup + url = "https://a.example" + providers.update(firecrawl=Provider("firecrawl", calls, extract=[ + {"url": url, "error": "blocked by website policy", "blocked_by_policy": True}]), + tavily=Provider("tavily", calls, extract=[{"url": url, "content": "bypass"}])) + monkeypatch.setattr(web_tools, "_rescue_eligible", lambda provider: False) + out = json.loads(await web_tools.web_extract_tool([url])) + assert calls == ["firecrawl"] + assert out["results"][0]["blocked_by_policy"] is True + + @pytest.mark.asyncio async def test_exhausted_chain_reaches_ring(setup, monkeypatch): calls, providers = setup From 291a5662b094839bc649531da6064d0ccc83b9ac Mon Sep 17 00:00:00 2001 From: "ang-fleet-workers[bot]" <333956806+ang-fleet-workers[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:03:08 -0700 Subject: [PATCH 6/6] docs(web): clarify keyed-tier fallback helper --- tools/web_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/web_tools.py b/tools/web_tools.py index e8073e9680067..fecedccf58d55 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -431,7 +431,7 @@ def _ddgs_package_importable() -> bool: # ─── Configured keyed fallbacks, then one-shot keyless rescue ───────────────── def _keyed_fallbacks(capability: str, primary: str): - """Yield distinct configured providers with credentials, never anonymous ring paths.""" + """Yield distinct configured providers with credentials for the keyed tier.""" from agent.web_search_registry import get_provider from agent.web_search_provider import get_provider_env