File size: 10,528 Bytes
674d56a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
"""Shared plumbing for the bundled web-search provider plugins.

Every helper resolves patched-in-tests collaborators (``get_provider_env``,
``tools.interrupt``, ``plugins.web.keyless_mcp``, ``tools.web_tools`` client
slots) lazily at call time so monkeypatching the source module keeps working.
"""

from __future__ import annotations

import logging
from typing import Any, Awaitable, Callable, Dict, List, Optional

import httpx

from agent.web_search_provider import WebSearchProvider

SEARCH_LIMIT_CAP = 20  # every vendor here caps max_results at 20 server-side


def provider_env(name: str) -> str:
    """Config-aware env lookup (os.environ, then ~/.hermes/.env)."""
    from agent.web_search_provider import get_provider_env
    return get_provider_env(name)


def use_keyless(name: str, api_key: str) -> bool:
    from plugins.web.keyless_mcp import use_keyless as _use_keyless
    return _use_keyless(name, api_key)


def _interrupted() -> bool:
    from tools.interrupt import is_interrupted
    return is_interrupted()


# --- Result shapes (key order is part of the contract — it reaches the model as JSON) ---
def search_ok(web_results: List[Dict[str, Any]]) -> Dict[str, Any]:
    return {"success": True, "data": {"web": web_results}}


def search_fail(error: str) -> Dict[str, Any]:
    return {"success": False, "error": error}


def web_hit(url: str, title: str, description: str, position: int) -> Dict[str, Any]:
    return {"url": url, "title": title, "description": description, "position": position}


def title_hit(title: str, url: str, description: str, position: int) -> Dict[str, Any]:
    """Title-first row — the historical wire shape of brave/searxng/ddgs/tavily/xai."""
    return {"title": title, "url": url, "description": description, "position": position}


def document(url: str, title: str, content: str, *, source_url: Optional[str] = None) -> Dict[str, Any]:
    """Successful extract entry; ``raw_content`` mirrors ``content`` for the legacy pipeline."""
    return {
        "url": url, "title": title, "content": content, "raw_content": content,
        "metadata": {"sourceURL": url if source_url is None else source_url, "title": title},
    }


def page_error(url: str, error: str) -> Dict[str, Any]:
    return {"url": url, "title": "", "content": "", "error": error}


def extract_fail(urls: List[str], error: str) -> List[Dict[str, Any]]:
    return [page_error(u, error) for u in urls]


# --- Keyless ring hand-off (shared by exa / parallel / keenable) ---------------
def keyless_search(display: str, name: str, query: str, limit: int, logger: logging.Logger) -> Dict[str, Any]:
    from plugins.web.keyless_mcp import search_with_failover
    logger.info("%s keyless search: '%s' (limit=%d)", display, query, limit)
    return search_with_failover(name, query, limit)


def keyless_extract(display: str, name: str, urls: List[str], logger: logging.Logger) -> List[Dict[str, Any]]:
    from plugins.web.keyless_mcp import extract_with_failover
    logger.info("%s keyless extract: %d URL(s)", display, len(urls))
    return extract_with_failover(name, list(urls))


# --- Guarded execution: interrupt check + uniform failure classification ---
def _failure_message(vendor: str, kind: str, exc: Exception, logger: logging.Logger, *, sdk: bool, verbatim_value_error: bool) -> str:
    """``verbatim_value_error``: ValueError carries a pre-formatted message (missing key,
    HTTP body) and is returned as-is. ``sdk``: ImportError means the lazily installed
    vendor SDK is missing. Anything else is logged and wrapped."""
    if verbatim_value_error and isinstance(exc, ValueError):
        return str(exc)
    if sdk and isinstance(exc, ImportError):
        return f"{vendor} SDK not installed: {exc}"
    logger.warning("%s %s error: %s", vendor, kind, exc)
    return f"{vendor} {kind} failed: {exc}"


def _guarded(vendor: str, kind: str, logger: logging.Logger, body: Callable[[], Any], interrupted: Any, fail: Callable[[str], Any], sdk: bool, vve: bool) -> Any:
    try:
        if _interrupted():
            return interrupted
        return body()
    except Exception as exc:  # noqa: BLE001 — surface as failure shape
        return fail(_failure_message(vendor, kind, exc, logger, sdk=sdk, verbatim_value_error=vve))


def run_search(vendor: str, logger: logging.Logger, body: Callable[[], Dict[str, Any]], *, sdk: bool = False, verbatim_value_error: bool = True) -> Dict[str, Any]:
    return _guarded(vendor, "search", logger, body, search_fail("Interrupted"), search_fail, sdk, verbatim_value_error)


def _extract_interrupted(urls: List[str]) -> List[Dict[str, Any]]:
    return [{"url": u, "error": "Interrupted", "title": ""} for u in urls]


def run_extract(
    vendor: str, logger: logging.Logger, urls: List[str], body: Callable[[], List[Dict[str, Any]]],
    *, sdk: bool = False, verbatim_value_error: bool = True,
) -> List[Dict[str, Any]]:
    """Per-URL failures are returned as entries with ``error`` — never raised."""
    return _guarded(vendor, "extract", logger, body, _extract_interrupted(urls), lambda m: extract_fail(urls, m), sdk, verbatim_value_error)


async def run_extract_async(
    vendor: str, logger: logging.Logger, urls: List[str], body: Callable[[], Awaitable[List[Dict[str, Any]]]],
    *, sdk: bool = False, verbatim_value_error: bool = True,
) -> List[Dict[str, Any]]:
    """Async twin of :func:`run_extract` (``body`` is awaited inside the guard)."""
    try:
        if _interrupted():
            return _extract_interrupted(urls)
        return await body()
    except Exception as exc:  # noqa: BLE001
        return extract_fail(urls, _failure_message(vendor, "extract", exc, logger, sdk=sdk, verbatim_value_error=verbatim_value_error))


# --- HTTP + SDK client helpers ---
def http_status_detail(response: Any) -> str:
    """Response body text for a >=400 reply, or ``HTTP <code>`` when the body is empty."""
    return (response.text or "").strip() or f"HTTP {response.status_code}"


def http_get_json(
    label: str, url: str, *, params: Dict[str, Any], headers: Dict[str, str], timeout: int,
    logger: logging.Logger, reach_target: Optional[str] = None,
) -> tuple[Any, Optional[Dict[str, Any]]]:
    """GET ``url`` and parse JSON → ``(data, None)`` or ``(None, failure_dict)``.
    ``reach_target`` overrides the "Could not reach ..." subject (SearXNG includes its URL)."""
    try:
        resp = httpx.get(url, params=params, headers=headers, timeout=timeout)
        resp.raise_for_status()
    except httpx.HTTPStatusError as exc:
        logger.warning("%s HTTP error: %s", label, exc)
        return None, search_fail(f"{label} returned HTTP {exc.response.status_code}")
    except httpx.RequestError as exc:
        logger.warning("%s request error: %s", label, exc)
        return None, search_fail(f"Could not reach {reach_target or label}: {exc}")
    try:
        return resp.json(), None
    except Exception as exc:  # noqa: BLE001
        logger.warning("%s response parse error: %s", label, exc)
        return None, search_fail(f"Could not parse {label} response as JSON")


def titled_rows(raw_results: List[Dict[str, Any]], description_key: str) -> List[Dict[str, Any]]:
    """Brave/SearXNG row normalizer: ``str()`` every field, 1-based positions."""
    return [
        title_hit(str(r.get("title", "")), str(r.get("url", "")), str(r.get(description_key, "")), i + 1)
        for i, r in enumerate(raw_results)
    ]


def lazy_ensure(feature: str) -> None:
    """Best-effort ``tools.lazy_deps.ensure``: its own ImportError is benign and swallowed;
    an install hint (any other error) is re-raised as ImportError."""
    try:
        from tools.lazy_deps import ensure as _lazy_ensure
        _lazy_ensure(feature, prompt=False)
    except ImportError:
        pass
    except Exception as exc:  # noqa: BLE001
        raise ImportError(str(exc))


def cached_sdk_client(slot: str, env_var: str, missing_key_error: str, feature: str, factory: Callable[[str], Any]) -> Any:
    """Lazy-build + cache a vendor SDK client on ``tools.web_tools.<slot>`` (so tests that
    reset ``tools.web_tools._<vendor>_client = None`` see fresh state). Raises ValueError
    when the key is unset."""
    import tools.web_tools as _wt
    cached = getattr(_wt, slot, None)
    if cached is not None:
        return cached
    api_key = provider_env(env_var)
    if not api_key:
        raise ValueError(missing_key_error)
    lazy_ensure(feature)
    client = factory(api_key)
    setattr(_wt, slot, client)
    return client


# --- Provider base ---
class BaseWebSearchProvider(WebSearchProvider):
    """Subclasses set ``NAME`` / ``DISPLAY_NAME`` / ``KEY_ENV`` and flip ``EXTRACT`` / ``KEYLESS``.
    ``is_available`` deliberately ignores the keyless tier: otherwise the legacy preference walk
    would route users holding a key for a lower-priority backend onto this vendor's free tier.
    ``is_keyless_available`` is True for keyless vendors unless pinned ``web.provider_tier.<name>: paid``."""

    NAME: str = ""
    DISPLAY_NAME: str = ""
    KEY_ENV: str = ""
    EXTRACT: bool = False
    KEYLESS: bool = False

    name = property(lambda self: self.NAME)
    display_name = property(lambda self: self.DISPLAY_NAME)

    def is_available(self) -> bool:
        return bool(provider_env(self.KEY_ENV))

    def is_keyless_available(self) -> bool:
        from plugins.web.keyless_mcp import keyless_enabled, provider_tier
        return self.KEYLESS and keyless_enabled() and provider_tier(self.NAME) != "paid"

    def supports_search(self) -> bool:
        return True

    def supports_extract(self) -> bool:
        return self.EXTRACT


def setup_schema(name: str, badge: str, tag: str, key_env: str = "", prompt: str = "", url: str = "", **extra: Any) -> Dict[str, Any]:
    """``hermes tools`` picker entry; ``env_vars`` is empty when ``key_env`` is blank."""
    env_vars = [{"key": key_env, "prompt": prompt, "url": url}] if key_env else []
    return {"name": name, "badge": badge, "tag": tag, "env_vars": env_vars, **extra}


def keyless_variant_schema(display: str, key_env: str, key_url: str, *, free_tag: str, paid_tag: str) -> Dict[str, Any]:
    """Picker entry for a keyless-ring vendor with a paid variant."""
    paid = setup_schema(f"{display} · Paid (API key)", "paid", paid_tag, key_env, f"{display} API key", key_url, web_tier="paid")
    return setup_schema(f"{display} · Free (keyless)", "free · no key", free_tag, web_tier="free", variants=[paid])