Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
6 changes: 6 additions & 0 deletions hermes_cli/tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
40 changes: 40 additions & 0 deletions tests/tools/test_web_keyed_fallback_e2e.py
Original file line number Diff line number Diff line change
@@ -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"
136 changes: 136 additions & 0 deletions tests/tools/test_web_keyed_fallbacks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""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"
assert out["results"][0]["metadata"] == {"served_by": "firecrawl"}


@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"}




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
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"
Loading
Loading