Hermes-agent
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
"""Keyless Parallel search via the free hosted Search MCP.
|
||||
|
||||
Covers the transport added in ``plugins/web/parallel/provider.py`` that lets
|
||||
``web_search`` work with no ``PARALLEL_API_KEY``:
|
||||
|
||||
- ``_mcp_headers`` — Bearer attached only when a key is held
|
||||
- ``_decode_mcp_envelope`` — plain-JSON and SSE (``data:``) response bodies
|
||||
- ``_mcp_payload`` — structuredContent preferred, text-block JSON fallback, errors
|
||||
- ``_mcp_web_search`` — full handshake (mocked transport) → standard search shape
|
||||
- ``ParallelWebSearchProvider.search`` — keyless path routes to the MCP
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import plugins.web.parallel.provider as pp
|
||||
|
||||
|
||||
# ─── _mcp_headers ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestMcpHeaders:
|
||||
def test_anonymous_has_no_authorization(self):
|
||||
h = pp._mcp_headers(session_id=None, api_key=None)
|
||||
assert "Authorization" not in h
|
||||
assert h["Accept"] == "application/json, text/event-stream"
|
||||
assert "Mcp-Session-Id" not in h
|
||||
|
||||
def test_user_agent_is_generic_not_hermes(self):
|
||||
# Telemetry policy: no third-party usage attribution without opt-in.
|
||||
# The UA must be set (not python-httpx default) but must not name
|
||||
# hermes, on both the anonymous and keyed paths.
|
||||
for ua in (
|
||||
pp._mcp_headers(session_id=None, api_key=None)["User-Agent"],
|
||||
pp._mcp_headers(session_id="sid", api_key="pk-live")["User-Agent"],
|
||||
):
|
||||
assert ua == f"{pp._MCP_CLIENT_NAME}/{pp._MCP_CLIENT_VERSION}"
|
||||
assert "hermes" not in ua.lower()
|
||||
|
||||
def test_session_id_and_bearer_when_present(self):
|
||||
h = pp._mcp_headers(session_id="sid-123", api_key="pk-live")
|
||||
assert h["Mcp-Session-Id"] == "sid-123"
|
||||
assert h["Authorization"] == "Bearer pk-live"
|
||||
|
||||
|
||||
# ─── SSE / JSON-RPC parsing ──────────────────────────────────────────────────
|
||||
|
||||
class TestMcpResponseParsing:
|
||||
def test_plain_json_matched_by_id(self):
|
||||
body = '{"jsonrpc":"2.0","id":"abc","result":{"ok":true}}'
|
||||
assert pp._mcp_response_envelope(body, "abc")["result"]["ok"] is True
|
||||
|
||||
def test_sse_selects_response_for_request_id_skipping_notifications(self):
|
||||
# A progress notification (no id) precedes the real result; an unrelated
|
||||
# response id is also present. We must pick the one matching our id.
|
||||
body = (
|
||||
'event: message\ndata: {"jsonrpc":"2.0","method":"notifications/progress","params":{"p":1}}\n\n'
|
||||
'event: message\ndata: {"jsonrpc":"2.0","id":"other","result":{"ok":false}}\n\n'
|
||||
'event: message\ndata: {"jsonrpc":"2.0","id":"req-1","result":{"ok":true}}\n\n'
|
||||
)
|
||||
env = pp._mcp_response_envelope(body, "req-1")
|
||||
assert env["result"]["ok"] is True
|
||||
|
||||
def test_sse_multiline_data_concatenated(self):
|
||||
body = 'data: {"jsonrpc":"2.0","id":"x",\ndata: "result":{"n":42}}\n\n'
|
||||
assert pp._mcp_response_envelope(body, "x")["result"]["n"] == 42
|
||||
|
||||
def test_falls_back_to_last_result_when_id_absent(self):
|
||||
body = '{"jsonrpc":"2.0","id":"server-chose","result":{"ok":true}}'
|
||||
# request id doesn't match, but there's a single result → use it
|
||||
assert pp._mcp_response_envelope(body, "mismatch")["result"]["ok"] is True
|
||||
|
||||
def test_empty_body(self):
|
||||
assert pp._mcp_response_envelope("", "x") == {}
|
||||
assert pp._mcp_response_envelope(" ", "x") == {}
|
||||
|
||||
def test_batched_json_array_flattened(self):
|
||||
# Streamable HTTP may batch messages into a JSON array.
|
||||
body = ('[{"jsonrpc":"2.0","method":"notifications/progress"},'
|
||||
'{"jsonrpc":"2.0","id":"req-9","result":{"ok":true}}]')
|
||||
assert pp._mcp_response_envelope(body, "req-9")["result"]["ok"] is True
|
||||
|
||||
def test_batched_sse_data_array_flattened(self):
|
||||
body = 'data: [{"jsonrpc":"2.0","id":"a","result":{"n":1}}]\n\n'
|
||||
assert pp._mcp_response_envelope(body, "a")["result"]["n"] == 1
|
||||
|
||||
|
||||
# ─── _mcp_payload ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestMcpPayload:
|
||||
def test_prefers_structured_content(self):
|
||||
env = {"result": {"structuredContent": {"results": [{"url": "u"}]},
|
||||
"content": [{"type": "text", "text": "ignored"}]}}
|
||||
assert pp._mcp_payload(env) == {"results": [{"url": "u"}]}
|
||||
|
||||
def test_parses_text_block_json(self):
|
||||
inner = {"search_id": "s1", "results": [{"url": "u", "title": "t"}]}
|
||||
env = {"result": {"content": [{"type": "text", "text": json.dumps(inner)}]}}
|
||||
assert pp._mcp_payload(env)["search_id"] == "s1"
|
||||
|
||||
def test_raises_on_jsonrpc_error(self):
|
||||
with pytest.raises(RuntimeError, match="Parallel MCP error"):
|
||||
pp._mcp_payload({"error": {"code": -32000, "message": "boom"}})
|
||||
|
||||
def test_raises_on_tool_iserror(self):
|
||||
with pytest.raises(RuntimeError, match="Parallel MCP tool error"):
|
||||
pp._mcp_payload({"result": {"isError": True, "content": []}})
|
||||
|
||||
|
||||
# ─── _mcp_web_search (mocked transport) ──────────────────────────────────────
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, *, text="", headers=None):
|
||||
self.text = text
|
||||
self.headers = headers or {}
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""Stands in for httpx.Client: replays init → ack → tools/call."""
|
||||
|
||||
def __init__(self, search_payload, init_session_id="server-sid"):
|
||||
self._search_payload = search_payload
|
||||
self._init_session_id = init_session_id
|
||||
self.calls = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def post(self, url, headers=None, json=None):
|
||||
self.calls.append({"headers": headers, "json": json})
|
||||
req = json or {}
|
||||
method = req.get("method")
|
||||
req_id = req.get("id")
|
||||
if method == "initialize":
|
||||
# Echo the request id, as the real server does.
|
||||
return _FakeResponse(
|
||||
text=json_dumps({"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {"protocolVersion": "2099-01-01"}}),
|
||||
headers=(
|
||||
{"mcp-session-id": self._init_session_id}
|
||||
if self._init_session_id is not None
|
||||
else {}
|
||||
),
|
||||
)
|
||||
if method == "notifications/initialized":
|
||||
return _FakeResponse(text="")
|
||||
# tools/call
|
||||
envelope = {"jsonrpc": "2.0", "id": req_id, "result": {
|
||||
"content": [{"type": "text", "text": json_dumps(self._search_payload)}],
|
||||
}}
|
||||
return _FakeResponse(text=json_dumps(envelope))
|
||||
|
||||
|
||||
def json_dumps(obj):
|
||||
return json.dumps(obj)
|
||||
|
||||
|
||||
class TestMcpWebSearch:
|
||||
def _payload(self, n):
|
||||
return {"search_id": "s", "results": [
|
||||
{"url": f"https://ex/{i}", "title": f"t{i}",
|
||||
"excerpts": [f"a{i}", f"b{i}"]}
|
||||
for i in range(n)
|
||||
]}
|
||||
|
||||
def test_returns_standard_shape_and_handshake(self):
|
||||
fake = _FakeClient(self._payload(3))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_search("hello", limit=5, api_key=None)
|
||||
|
||||
assert out["success"] is True
|
||||
# Free-tier results credit Parallel.
|
||||
assert "Parallel" in out["attribution"]
|
||||
web = out["data"]["web"]
|
||||
assert [r["position"] for r in web] == [1, 2, 3]
|
||||
assert web[0]["url"] == "https://ex/0"
|
||||
assert web[0]["description"] == "a0 b0" # excerpts joined
|
||||
# handshake order
|
||||
methods = [c["json"].get("method") for c in fake.calls]
|
||||
assert methods == ["initialize", "notifications/initialized", "tools/call"]
|
||||
# session id from the initialize response header is reused
|
||||
assert fake.calls[-1]["headers"]["Mcp-Session-Id"] == "server-sid"
|
||||
|
||||
def test_stateless_server_no_session_header_not_invented(self):
|
||||
# A stateless Streamable-HTTP server may omit mcp-session-id on
|
||||
# initialize; we must NOT invent one (sending an unissued session id can
|
||||
# get follow-up requests rejected). The follow-ups carry no header.
|
||||
fake = _FakeClient(self._payload(1), init_session_id=None)
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_search("hello", limit=5, api_key=None)
|
||||
assert out["success"] is True
|
||||
follow_ups = [c for c in fake.calls if c["json"].get("method") != "initialize"]
|
||||
assert follow_ups, "expected notifications/initialized + tools/call"
|
||||
assert all("Mcp-Session-Id" not in c["headers"] for c in follow_ups)
|
||||
# anonymous → no Authorization on any call
|
||||
assert all("Authorization" not in c["headers"] for c in fake.calls)
|
||||
# tools/call mirrors query into objective + search_queries
|
||||
args = fake.calls[-1]["json"]["params"]["arguments"]
|
||||
assert args["objective"] == "hello"
|
||||
assert args["search_queries"] == ["hello"]
|
||||
|
||||
def test_limit_is_applied_client_side(self):
|
||||
fake = _FakeClient(self._payload(10))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_search("q", limit=2, api_key=None)
|
||||
assert len(out["data"]["web"]) == 2
|
||||
|
||||
def test_bearer_attached_when_key_present(self):
|
||||
fake = _FakeClient(self._payload(1))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
pp._mcp_web_search("q", limit=1, api_key="pk-live")
|
||||
assert all(c["headers"]["Authorization"] == "Bearer pk-live" for c in fake.calls)
|
||||
|
||||
def test_negotiated_protocol_version_echoed_post_init(self):
|
||||
fake = _FakeClient(self._payload(1))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
pp._mcp_web_search("q", limit=1, api_key=None)
|
||||
# initialize request doesn't carry the (not-yet-negotiated) version...
|
||||
assert "MCP-Protocol-Version" not in fake.calls[0]["headers"]
|
||||
# ...but notifications/initialized and tools/call echo the negotiated one.
|
||||
assert fake.calls[1]["headers"]["MCP-Protocol-Version"] == "2099-01-01"
|
||||
assert fake.calls[-1]["headers"]["MCP-Protocol-Version"] == "2099-01-01"
|
||||
|
||||
|
||||
# ─── provider.search keyless routing ─────────────────────────────────────────
|
||||
|
||||
class TestProviderKeylessSearch:
|
||||
def test_search_without_key_uses_mcp(self, monkeypatch):
|
||||
monkeypatch.delenv("PARALLEL_API_KEY", raising=False)
|
||||
captured = {}
|
||||
|
||||
def _fake(query, limit, api_key):
|
||||
captured.update(query=query, limit=limit, api_key=api_key)
|
||||
return {"success": True, "data": {"web": []}}
|
||||
|
||||
monkeypatch.setattr(pp, "_mcp_web_search", _fake)
|
||||
out = pp.ParallelWebSearchProvider().search("kittens", limit=4)
|
||||
assert out["success"] is True
|
||||
assert captured == {"query": "kittens", "limit": 4, "api_key": None}
|
||||
|
||||
def test_is_available_reflects_key(self, monkeypatch):
|
||||
# is_available() gates the registry's active-provider walk + picker, so
|
||||
# it's key-based (keyless dispatch is handled by _get_backend, not this).
|
||||
monkeypatch.delenv("PARALLEL_API_KEY", raising=False)
|
||||
assert pp.ParallelWebSearchProvider().is_available() is False
|
||||
monkeypatch.setenv("PARALLEL_API_KEY", "k")
|
||||
assert pp.ParallelWebSearchProvider().is_available() is True
|
||||
|
||||
|
||||
# ─── web_fetch (keyless extract) ─────────────────────────────────────────────
|
||||
|
||||
class TestMcpWebFetch:
|
||||
def _payload(self, urls):
|
||||
return {"extract_id": "e1", "results": [
|
||||
{"url": u, "title": f"T{i}", "publish_date": None,
|
||||
"excerpts": [f"chunk-a-{i}", f"chunk-b-{i}"]}
|
||||
for i, u in enumerate(urls)
|
||||
]}
|
||||
|
||||
def test_maps_to_extract_shape(self):
|
||||
urls = ["https://a.test", "https://b.test"]
|
||||
fake = _FakeClient(self._payload(urls))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_fetch(urls, api_key=None)
|
||||
assert [r["url"] for r in out] == urls
|
||||
assert out[0]["content"] == "chunk-a-0\n\nchunk-b-0"
|
||||
assert out[0]["raw_content"] == out[0]["content"]
|
||||
assert out[0]["metadata"] == {"sourceURL": "https://a.test", "title": "T0"}
|
||||
# tools/call targeted web_fetch, requesting full page bodies.
|
||||
args = fake.calls[-1]["json"]["params"]
|
||||
assert args["name"] == "web_fetch"
|
||||
assert args["arguments"]["urls"] == urls
|
||||
assert args["arguments"]["full_content"] is True
|
||||
assert args["arguments"]["session_id"].startswith(f"{pp._MCP_CLIENT_NAME}-")
|
||||
|
||||
def test_prefers_full_content_over_excerpts(self):
|
||||
payload = {"results": [
|
||||
{"url": "https://a.test", "title": "T",
|
||||
"excerpts": ["snippet"], "full_content": "the entire page body"},
|
||||
]}
|
||||
fake = _FakeClient(payload)
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_fetch(["https://a.test"], api_key=None)
|
||||
assert out[0]["content"] == "the entire page body"
|
||||
|
||||
def test_missing_url_becomes_error_entry(self):
|
||||
# Server returns only one of the two requested URLs.
|
||||
fake = _FakeClient(self._payload(["https://a.test"]))
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_fetch(["https://a.test", "https://missing.test"], api_key=None)
|
||||
assert len(out) == 2
|
||||
missing = [r for r in out if r["url"] == "https://missing.test"][0]
|
||||
assert "error" in missing
|
||||
assert missing["content"] == ""
|
||||
|
||||
def test_preserves_order_and_duplicate_inputs(self):
|
||||
# MCP returns each unique URL once; output must still be one row per
|
||||
# input, in order, including the duplicate.
|
||||
fake = _FakeClient(self._payload(["https://a.test", "https://b.test"]))
|
||||
urls = ["https://b.test", "https://a.test", "https://b.test"]
|
||||
with patch.object(pp.httpx, "Client", return_value=fake):
|
||||
out = pp._mcp_web_fetch(urls, api_key=None)
|
||||
assert [r["url"] for r in out] == urls # one row per input, in order
|
||||
assert all("error" not in r for r in out) # all three resolved
|
||||
|
||||
def test_extract_without_key_uses_web_fetch(self, monkeypatch):
|
||||
monkeypatch.delenv("PARALLEL_API_KEY", raising=False)
|
||||
captured = {}
|
||||
|
||||
def _fake(urls, api_key):
|
||||
captured.update(urls=list(urls), api_key=api_key)
|
||||
return [{"url": urls[0], "title": "", "content": "x",
|
||||
"raw_content": "x", "metadata": {}}]
|
||||
|
||||
monkeypatch.setattr(pp, "_mcp_web_fetch", _fake)
|
||||
out = asyncio.run(pp.ParallelWebSearchProvider().extract(["https://x.test"]))
|
||||
assert out[0]["content"] == "x"
|
||||
assert captured == {"urls": ["https://x.test"], "api_key": None}
|
||||
|
||||
|
||||
# ─── keyed v1 REST search ────────────────────────────────────────────────────
|
||||
|
||||
class TestKeyedV1Search:
|
||||
def test_passes_max_results_and_omits_branding(self, monkeypatch):
|
||||
monkeypatch.setenv("PARALLEL_API_KEY", "pk-live")
|
||||
monkeypatch.delenv("PARALLEL_SEARCH_MODE", raising=False)
|
||||
captured = {}
|
||||
|
||||
class _Res:
|
||||
def __init__(self, url):
|
||||
self.url, self.title, self.excerpts = url, "T", ["x"]
|
||||
|
||||
class _Resp:
|
||||
results = [_Res(f"https://r/{i}") for i in range(10)]
|
||||
|
||||
class _Client:
|
||||
def search(self, **kw):
|
||||
captured.update(kw)
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(pp, "_get_sync_client", lambda: _Client())
|
||||
out = pp.ParallelWebSearchProvider().search("q", limit=7)
|
||||
|
||||
assert out["success"] is True
|
||||
# honors the caller's limit via advanced_settings.max_results
|
||||
assert captured["advanced_settings"] == {"max_results": 7}
|
||||
assert captured["mode"] == "advanced" # v1 default
|
||||
assert captured["session_id"].startswith(f"{pp._MCP_CLIENT_NAME}-") # per-call id
|
||||
assert len(out["data"]["web"]) == 7 # client-side slice
|
||||
# paid path: no free-tier attribution, no [Parallel] label signal
|
||||
assert "attribution" not in out
|
||||
assert "provider" not in out
|
||||
|
||||
|
||||
# ─── v1 search mode mapping ──────────────────────────────────────────────────
|
||||
|
||||
class TestResolveSearchMode:
|
||||
@pytest.mark.parametrize("env,expected", [
|
||||
(None, "advanced"), # default
|
||||
("advanced", "advanced"),
|
||||
("basic", "basic"),
|
||||
("fast", "basic"), # legacy → basic
|
||||
("one-shot", "basic"), # legacy → basic
|
||||
("agentic", "advanced"), # legacy → advanced
|
||||
("garbage", "advanced"), # invalid → default
|
||||
("BASIC", "basic"), # case-insensitive
|
||||
])
|
||||
def test_mode_mapping(self, monkeypatch, env, expected):
|
||||
if env is None:
|
||||
monkeypatch.delenv("PARALLEL_SEARCH_MODE", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("PARALLEL_SEARCH_MODE", env)
|
||||
assert pp._resolve_search_mode() == expected
|
||||
@@ -0,0 +1,519 @@
|
||||
"""Plugin-side tests for the web search provider migration (PR #25182).
|
||||
|
||||
Covers:
|
||||
|
||||
- All eight bundled plugins (brave-free, ddgs, searxng, exa, parallel,
|
||||
tavily, firecrawl, xai) instantiate and self-report the expected
|
||||
capabilities + ABC-derived defaults.
|
||||
- Each plugin's ``is_available()`` correctly reflects env-var presence.
|
||||
- The web_search_registry resolves an active provider in the documented
|
||||
scenarios (explicit config wins ignoring availability, fallback walks
|
||||
legacy preference filtered by availability, unknown name falls back).
|
||||
- Plugin response shapes match the legacy bit-for-bit contract.
|
||||
|
||||
Per the dev skill: these tests use *real* imports from the plugin
|
||||
modules — no mocking of provider classes themselves — so the test
|
||||
catches drift in the ABC interface, the registry, and the plugin
|
||||
glue layer simultaneously.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _clear_web_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Strip every web-provider env var so is_available() returns False."""
|
||||
for k in (
|
||||
"BRAVE_SEARCH_API_KEY",
|
||||
"SEARXNG_URL",
|
||||
"TAVILY_API_KEY",
|
||||
"TAVILY_BASE_URL",
|
||||
"EXA_API_KEY",
|
||||
"PARALLEL_API_KEY",
|
||||
"PARALLEL_SEARCH_MODE",
|
||||
"FIRECRAWL_API_KEY",
|
||||
"FIRECRAWL_API_URL",
|
||||
"FIRECRAWL_GATEWAY_URL",
|
||||
"TOOL_GATEWAY_DOMAIN",
|
||||
"TOOL_GATEWAY_USER_TOKEN",
|
||||
"XAI_API_KEY",
|
||||
):
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
|
||||
|
||||
def _ensure_plugins_loaded() -> None:
|
||||
"""Idempotently load plugins so the registry is populated."""
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
|
||||
_ensure_plugins_discovered()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-plugin discovery + capability flags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Each test starts with a clean web-provider env."""
|
||||
_clear_web_env(monkeypatch)
|
||||
|
||||
|
||||
class TestBundledPluginsRegister:
|
||||
"""All eight bundled web plugins discover and register correctly."""
|
||||
|
||||
def test_all_seven_plugins_present_in_registry(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import list_providers
|
||||
|
||||
names = sorted(p.name for p in list_providers())
|
||||
assert names == [
|
||||
"brave-free",
|
||||
"ddgs",
|
||||
"exa",
|
||||
"firecrawl",
|
||||
"parallel",
|
||||
"searxng",
|
||||
"tavily",
|
||||
"xai",
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plugin_name,expected_search,expected_extract",
|
||||
[
|
||||
("brave-free", True, False),
|
||||
("ddgs", True, False),
|
||||
("searxng", True, False),
|
||||
("exa", True, True),
|
||||
("parallel", True, True),
|
||||
("tavily", True, True),
|
||||
("firecrawl", True, True),
|
||||
# xai: search-only via Grok's agentic web_search tool.
|
||||
("xai", True, False),
|
||||
],
|
||||
)
|
||||
def test_capability_flags_match_spec(
|
||||
self,
|
||||
plugin_name: str,
|
||||
expected_search: bool,
|
||||
expected_extract: bool,
|
||||
) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
provider = get_provider(plugin_name)
|
||||
assert provider is not None, f"plugin {plugin_name!r} not registered"
|
||||
assert provider.supports_search() is expected_search
|
||||
assert provider.supports_extract() is expected_extract
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plugin_name",
|
||||
["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl", "xai"],
|
||||
)
|
||||
def test_each_plugin_has_name_and_display_name(self, plugin_name: str) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
provider = get_provider(plugin_name)
|
||||
assert provider is not None
|
||||
assert provider.name == plugin_name
|
||||
assert provider.display_name # any non-empty string
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plugin_name",
|
||||
["brave-free", "ddgs", "searxng", "exa", "parallel", "tavily", "firecrawl", "xai"],
|
||||
)
|
||||
def test_each_plugin_has_setup_schema(self, plugin_name: str) -> None:
|
||||
"""``get_setup_schema()`` returns a dict the picker can consume."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
provider = get_provider(plugin_name)
|
||||
assert provider is not None
|
||||
schema = provider.get_setup_schema()
|
||||
assert isinstance(schema, dict)
|
||||
assert "name" in schema
|
||||
assert "env_vars" in schema
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_available() behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsAvailable:
|
||||
"""Each plugin's ``is_available()`` returns False without env config."""
|
||||
|
||||
def test_brave_free_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("brave-free")
|
||||
assert p is not None
|
||||
assert p.is_available() is False # no BRAVE_SEARCH_API_KEY
|
||||
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "real")
|
||||
assert p.is_available() is True
|
||||
|
||||
def test_searxng_requires_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("searxng")
|
||||
assert p is not None
|
||||
assert p.is_available() is False
|
||||
monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080")
|
||||
assert p.is_available() is True
|
||||
|
||||
def test_tavily_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("tavily")
|
||||
assert p is not None
|
||||
assert p.is_available() is False
|
||||
monkeypatch.setenv("TAVILY_API_KEY", "real")
|
||||
assert p.is_available() is True
|
||||
|
||||
def test_exa_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("exa")
|
||||
assert p is not None
|
||||
assert p.is_available() is False
|
||||
monkeypatch.setenv("EXA_API_KEY", "real")
|
||||
assert p.is_available() is True
|
||||
|
||||
def test_parallel_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""is_available() is key-based — it gates the registry's active-provider
|
||||
walk/picker. (Keyless search/extract still work via the free MCP through
|
||||
_get_backend's terminal default, independent of this flag.)
|
||||
"""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("parallel")
|
||||
assert p is not None
|
||||
monkeypatch.delenv("PARALLEL_API_KEY", raising=False)
|
||||
assert p.is_available() is False
|
||||
monkeypatch.setenv("PARALLEL_API_KEY", "real")
|
||||
assert p.is_available() is True
|
||||
|
||||
def test_firecrawl_requires_either_key_or_url(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("firecrawl")
|
||||
assert p is not None
|
||||
assert p.is_available() is False
|
||||
|
||||
# Either FIRECRAWL_API_KEY or FIRECRAWL_API_URL lights it up.
|
||||
monkeypatch.setenv("FIRECRAWL_API_KEY", "real")
|
||||
assert p.is_available() is True
|
||||
monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False)
|
||||
monkeypatch.setenv("FIRECRAWL_API_URL", "http://localhost:3002")
|
||||
assert p.is_available() is True
|
||||
|
||||
def test_ddgs_always_available_when_package_importable(self) -> None:
|
||||
"""DDGS is the always-on fallback — no API key required.
|
||||
|
||||
It may report unavailable if the ``ddgs`` package itself isn't
|
||||
installed in the env (legitimate — the plugin's post_setup hook
|
||||
triggers pip install on first selection). We only assert that
|
||||
is_available() doesn't raise.
|
||||
"""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("ddgs")
|
||||
assert p is not None
|
||||
# Truthy or falsy, just must not raise.
|
||||
_ = bool(p.is_available())
|
||||
|
||||
def test_xai_requires_api_key_or_oauth(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""xAI needs XAI_API_KEY or OAuth tokens in auth.json."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("xai")
|
||||
assert p is not None
|
||||
assert p.is_available() is False # no XAI_API_KEY, no auth.json
|
||||
monkeypatch.setenv("XAI_API_KEY", "real")
|
||||
assert p.is_available() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry resolution semantics (Option B — conservative smart fallback)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistryResolution:
|
||||
"""``_resolve()`` follows explicit-config + availability-filtered fallback."""
|
||||
|
||||
def test_explicit_configured_provider_returned_even_when_unavailable(
|
||||
self,
|
||||
) -> None:
|
||||
"""Explicit ``web.search_backend`` wins regardless of is_available().
|
||||
|
||||
Without availability filtering on the explicit path, the dispatcher
|
||||
would silently switch backends; with this check the dispatcher
|
||||
surfaces a precise "FOO_API_KEY is not set" error instead.
|
||||
"""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import _resolve
|
||||
|
||||
# No BRAVE_SEARCH_API_KEY (fixture cleared it).
|
||||
result = _resolve("brave-free", capability="search")
|
||||
assert result is not None
|
||||
assert result.name == "brave-free"
|
||||
# Confirm it's the unavailable one — dispatcher will surface
|
||||
# a typed credential-missing error to the caller.
|
||||
assert result.is_available() is False
|
||||
|
||||
def test_unknown_configured_name_falls_back_to_available_provider(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Typo / uninstalled plugin → walk legacy preference, pick available."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import _resolve
|
||||
|
||||
monkeypatch.setenv("EXA_API_KEY", "real")
|
||||
result = _resolve("not-a-real-provider", capability="search")
|
||||
# Either ddgs (no-key fallback) or exa (the only available
|
||||
# premium provider) — both are valid. The point is the unknown
|
||||
# name shouldn't return None when SOMETHING is available.
|
||||
assert result is not None
|
||||
assert result.is_available() is True
|
||||
|
||||
def test_explicit_search_only_provider_for_extract_falls_back(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Asking for extract via a search-only backend → fall back.
|
||||
|
||||
``brave-free`` is search-only (``supports_extract() is False``).
|
||||
When the registry resolves it for an extract capability, the
|
||||
explicit-config branch rejects it as capability-incompatible
|
||||
and the fallback walk picks an extract-capable provider.
|
||||
"""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import _resolve
|
||||
|
||||
monkeypatch.setenv("EXA_API_KEY", "real")
|
||||
result = _resolve("brave-free", capability="extract")
|
||||
# Should land on exa (only extract-capable available provider).
|
||||
assert result is not None
|
||||
assert result.supports_extract() is True
|
||||
assert result.is_available() is True
|
||||
|
||||
def test_no_config_no_credentials_returns_none(
|
||||
self,
|
||||
) -> None:
|
||||
"""No backend configured AND no available providers → typically None.
|
||||
|
||||
``ddgs`` is the no-credential fallback; if its ``ddgs`` Python
|
||||
package is installed in the test env, ddgs will be picked.
|
||||
Otherwise the resolver returns None. Either outcome is correct.
|
||||
"""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import _resolve
|
||||
|
||||
result = _resolve(None, capability="search")
|
||||
if result is not None:
|
||||
# The only no-credential provider is ddgs; anything else
|
||||
# means an env var leaked in.
|
||||
assert result.is_available() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync-vs-async extract detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAsyncExtractDispatch:
|
||||
"""The dispatcher detects async vs sync extract methods correctly."""
|
||||
|
||||
def test_parallel_extract_is_async(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("parallel")
|
||||
assert p is not None
|
||||
assert inspect.iscoroutinefunction(p.extract) is True
|
||||
|
||||
def test_firecrawl_extract_is_async(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("firecrawl")
|
||||
assert p is not None
|
||||
assert inspect.iscoroutinefunction(p.extract) is True
|
||||
|
||||
def test_exa_extract_is_sync(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("exa")
|
||||
assert p is not None
|
||||
assert inspect.iscoroutinefunction(p.extract) is False
|
||||
|
||||
def test_tavily_extract_is_sync(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("tavily")
|
||||
assert p is not None
|
||||
assert inspect.iscoroutinefunction(p.extract) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error response shape (preserved bit-for-bit from legacy)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestErrorResponseShapes:
|
||||
"""When credentials are missing, plugins return typed errors, not raises."""
|
||||
|
||||
def test_brave_free_returns_error_dict_when_unconfigured(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("brave-free")
|
||||
assert p is not None
|
||||
result = p.search("test", limit=5)
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("success") is False
|
||||
assert "error" in result
|
||||
|
||||
def test_searxng_returns_error_dict_when_unconfigured(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("searxng")
|
||||
assert p is not None
|
||||
result = p.search("test", limit=5)
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("success") is False
|
||||
assert "error" in result
|
||||
|
||||
def test_exa_returns_error_dict_when_unconfigured(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("exa")
|
||||
assert p is not None
|
||||
result = p.search("test", limit=5)
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("success") is False
|
||||
assert "error" in result
|
||||
|
||||
def test_tavily_returns_error_dict_when_unconfigured(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("tavily")
|
||||
assert p is not None
|
||||
result = p.search("test", limit=5)
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("success") is False
|
||||
assert "error" in result
|
||||
|
||||
def test_parallel_extract_keyless_uses_mcp_web_fetch(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Without a key, extract routes to the free MCP web_fetch tool rather
|
||||
than erroring. The MCP transport is mocked so the test stays offline."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
import plugins.web.parallel.provider as parallel_provider
|
||||
|
||||
monkeypatch.delenv("PARALLEL_API_KEY", raising=False)
|
||||
captured = {}
|
||||
|
||||
def _fake_fetch(urls, api_key):
|
||||
captured["urls"] = list(urls)
|
||||
captured["api_key"] = api_key
|
||||
return [{"url": urls[0], "title": "Example", "content": "body",
|
||||
"raw_content": "body", "metadata": {"sourceURL": urls[0]}}]
|
||||
|
||||
monkeypatch.setattr(parallel_provider, "_mcp_web_fetch", _fake_fetch)
|
||||
|
||||
p = get_provider("parallel")
|
||||
assert p is not None
|
||||
result = asyncio.run(p.extract(["https://example.com"]))
|
||||
assert isinstance(result, list)
|
||||
assert result[0]["url"] == "https://example.com"
|
||||
assert result[0]["content"] == "body"
|
||||
assert captured == {"urls": ["https://example.com"], "api_key": None}
|
||||
|
||||
def test_firecrawl_extract_returns_per_url_errors_when_unconfigured(self) -> None:
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("firecrawl")
|
||||
assert p is not None
|
||||
# firecrawl extract returns [] when the website-policy gate rejects
|
||||
# the URL, or a per-URL error dict when the gate passes but the
|
||||
# firecrawl client fails. Use a URL the policy allows to make sure
|
||||
# we hit the credential-missing path.
|
||||
result = asyncio.run(p.extract(["https://example.com"]))
|
||||
assert isinstance(result, list)
|
||||
if result: # if anything came back, it should be an error entry
|
||||
assert "error" in result[0]
|
||||
|
||||
def test_firecrawl_config_error_points_paid_users_to_nous_subscription(self, monkeypatch):
|
||||
from plugins.web.firecrawl import provider as firecrawl_provider
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.web_tools.managed_nous_tools_enabled",
|
||||
lambda: True,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
firecrawl_provider._raise_web_backend_configuration_error()
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "With your Nous subscription you can also use the Tool Gateway" in message
|
||||
assert "select Nous Subscription as the web provider" in message
|
||||
assert "managed Firecrawl web tools is unavailable" not in message
|
||||
|
||||
def test_firecrawl_config_error_uses_entitlement_message_when_not_paid(self, monkeypatch):
|
||||
from plugins.web.firecrawl import provider as firecrawl_provider
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.web_tools.managed_nous_tools_enabled",
|
||||
lambda: False,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tools.web_tools.nous_tool_gateway_unavailable_message",
|
||||
lambda capability: f"{capability} denied by test entitlement.",
|
||||
raising=False,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
firecrawl_provider._raise_web_backend_configuration_error()
|
||||
|
||||
assert "managed Firecrawl web tools denied by test entitlement" in str(exc_info.value)
|
||||
|
||||
def test_xai_search_returns_error_dict_when_unconfigured(self) -> None:
|
||||
"""xAI returns a typed error dict (no XAI_API_KEY)."""
|
||||
_ensure_plugins_loaded()
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
p = get_provider("xai")
|
||||
assert p is not None
|
||||
result = p.search("test", limit=5)
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("success") is False
|
||||
assert "error" in result
|
||||
Reference in New Issue
Block a user