fix(web): honor the stored web backend selection; no silent backend swaps
_get_backend returns the stored web.backend verbatim (mapping the managed 'nous' selection to the firecrawl provider) — unknown names surface the honest selection-naming error at dispatch instead of silently rerouting through the credential ladder, which now runs only on never-configured installs. _get_capability_backend no longer discards an explicit search/extract backend when its availability probe fails. The firecrawl client resolves strictly: 'nous' => managed gateway only (unavailable => selection-naming error), stored vendor => direct only (no FIRECRAWL key => error, never a silent managed fallback billed to Nous).
This commit is contained in:
@@ -48,7 +48,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
from typing import Any, Dict, List, NoReturn, Optional, TYPE_CHECKING
|
||||
|
||||
from agent.web_search_provider import WebSearchProvider
|
||||
from tools.url_safety import is_safe_url
|
||||
@@ -168,11 +168,22 @@ def _has_direct_firecrawl_config() -> bool:
|
||||
|
||||
|
||||
def check_firecrawl_api_key() -> bool:
|
||||
"""Return True when Firecrawl backend (direct or gateway) is usable.
|
||||
"""Return True when the Firecrawl backend selected via `hermes tools`
|
||||
(or, on a never-configured install, either route) is usable.
|
||||
|
||||
Re-exported by :mod:`tools.web_tools` for backward compatibility with
|
||||
existing tests and the ``hermes tools`` setup flow.
|
||||
"""
|
||||
from tools.tool_backend_helpers import (
|
||||
NOUS_MANAGED_PROVIDER,
|
||||
read_selection,
|
||||
)
|
||||
|
||||
selected = read_selection("web")
|
||||
if selected == NOUS_MANAGED_PROVIDER:
|
||||
return _is_tool_gateway_ready()
|
||||
if selected is not None:
|
||||
return _has_direct_firecrawl_config()
|
||||
return _has_direct_firecrawl_config() or _is_tool_gateway_ready()
|
||||
|
||||
|
||||
@@ -188,7 +199,7 @@ def _firecrawl_backend_help_suffix() -> str:
|
||||
)
|
||||
|
||||
|
||||
def _raise_web_backend_configuration_error() -> None:
|
||||
def _raise_web_backend_configuration_error() -> "NoReturn":
|
||||
"""Raise a clear error for unsupported web backend configuration."""
|
||||
import tools.web_tools as _wt
|
||||
|
||||
@@ -212,46 +223,95 @@ def _raise_web_backend_configuration_error() -> None:
|
||||
def _get_firecrawl_client() -> Any:
|
||||
"""Get or create the cached Firecrawl client.
|
||||
|
||||
When ``web.use_gateway`` is set in config, the managed Tool Gateway is
|
||||
preferred even if direct Firecrawl credentials are present. Otherwise
|
||||
direct Firecrawl takes precedence when explicitly configured.
|
||||
Strict selection semantics (switch on the stored ``web`` selection):
|
||||
- ``"nous"`` (or legacy ``use_gateway: true``) → managed Tool Gateway
|
||||
ONLY; unavailable is a selection-naming error (a present
|
||||
FIRECRAWL_API_KEY does not reroute).
|
||||
- any other stored web backend → direct Firecrawl ONLY; missing config
|
||||
is a selection-naming error — never a silent managed fallback billed
|
||||
to Nous.
|
||||
- never-configured web section → legacy behavior: direct config when
|
||||
present, else the managed gateway.
|
||||
|
||||
Raises ValueError when neither path is usable.
|
||||
Raises ValueError when the resolved path is unusable.
|
||||
|
||||
The cached client is stored on :mod:`tools.web_tools` (as
|
||||
``_firecrawl_client`` and ``_firecrawl_client_config``) rather than on
|
||||
this plugin module so that unit tests that reset the cache via
|
||||
``tools.web_tools._firecrawl_client = None`` keep working. Helper
|
||||
functions (``prefers_gateway``, ``resolve_managed_tool_gateway``,
|
||||
``_read_nous_access_token``, ``Firecrawl``) are also looked up via
|
||||
:mod:`tools.web_tools` for the same reason — see
|
||||
:func:`_is_tool_gateway_ready`.
|
||||
functions (``resolve_managed_tool_gateway``, ``_read_nous_access_token``,
|
||||
``Firecrawl``) are also looked up via :mod:`tools.web_tools` for the same
|
||||
reason — see :func:`_is_tool_gateway_ready`.
|
||||
"""
|
||||
import tools.web_tools as _wt
|
||||
from tools.tool_backend_helpers import (
|
||||
NOUS_MANAGED_PROVIDER,
|
||||
read_selection,
|
||||
selection_error,
|
||||
selection_exists,
|
||||
)
|
||||
|
||||
selected = read_selection("web")
|
||||
|
||||
direct_config = _get_direct_firecrawl_config()
|
||||
if direct_config is not None and not _wt.prefers_gateway("web"):
|
||||
kwargs, client_config = direct_config
|
||||
else:
|
||||
|
||||
def _managed_kwargs():
|
||||
managed_gateway = _wt.resolve_managed_tool_gateway(
|
||||
"firecrawl", token_reader=_wt._read_nous_access_token
|
||||
)
|
||||
if managed_gateway is None:
|
||||
return None
|
||||
kwargs = {
|
||||
"api_key": managed_gateway.nous_user_token,
|
||||
"api_url": managed_gateway.gateway_origin,
|
||||
}
|
||||
return kwargs, (
|
||||
"tool-gateway",
|
||||
kwargs["api_url"],
|
||||
managed_gateway.nous_user_token,
|
||||
)
|
||||
|
||||
if selected == NOUS_MANAGED_PROVIDER:
|
||||
managed = _managed_kwargs()
|
||||
if managed is None:
|
||||
logger.error(
|
||||
"Firecrawl client initialization failed: the Nous "
|
||||
"Subscription web selection is stored but the tool gateway "
|
||||
"is unavailable."
|
||||
)
|
||||
raise ValueError(selection_error(
|
||||
"web",
|
||||
NOUS_MANAGED_PROVIDER,
|
||||
"the Nous Tool Gateway is not available (not entitled or "
|
||||
"unreachable)",
|
||||
))
|
||||
kwargs, client_config = managed
|
||||
elif selected is not None or selection_exists("web"):
|
||||
# Stored vendor selection (or per-capability web keys routing to
|
||||
# firecrawl): direct Firecrawl only.
|
||||
if direct_config is None:
|
||||
logger.error(
|
||||
"Firecrawl client initialization failed: direct Firecrawl "
|
||||
"selected but FIRECRAWL_API_KEY/FIRECRAWL_API_URL is not set."
|
||||
)
|
||||
raise ValueError(selection_error(
|
||||
"web",
|
||||
selected or "firecrawl",
|
||||
"neither FIRECRAWL_API_KEY nor FIRECRAWL_API_URL is set",
|
||||
))
|
||||
kwargs, client_config = direct_config
|
||||
elif direct_config is not None:
|
||||
kwargs, client_config = direct_config
|
||||
else:
|
||||
# Never-configured web section: legacy managed fallback.
|
||||
managed = _managed_kwargs()
|
||||
if managed is None:
|
||||
logger.error(
|
||||
"Firecrawl client initialization failed: "
|
||||
"missing direct config and tool-gateway auth."
|
||||
)
|
||||
_raise_web_backend_configuration_error()
|
||||
|
||||
kwargs = {
|
||||
"api_key": managed_gateway.nous_user_token,
|
||||
"api_url": managed_gateway.gateway_origin,
|
||||
}
|
||||
client_config = (
|
||||
"tool-gateway",
|
||||
kwargs["api_url"],
|
||||
managed_gateway.nous_user_token,
|
||||
)
|
||||
kwargs, client_config = managed
|
||||
|
||||
cached = getattr(_wt, "_firecrawl_client", None)
|
||||
cached_config = getattr(_wt, "_firecrawl_client_config", None)
|
||||
|
||||
+91
-12
@@ -223,16 +223,36 @@ def _list_registered_web_providers():
|
||||
def _get_backend() -> str:
|
||||
"""Determine which web backend to use (shared fallback).
|
||||
|
||||
Reads ``web.backend`` from config.yaml (set by ``hermes tools``).
|
||||
Falls back to whichever API key is present for users who configured
|
||||
keys manually without running setup.
|
||||
Reads ``web.backend`` from config.yaml (set by ``hermes tools``). A
|
||||
stored backend name is returned as-is — no availability probe, no
|
||||
fallback — so the vendor path can raise its own honest error when the
|
||||
selection is broken. The credential/entitlement autodetect ladder runs
|
||||
ONLY when no web selection has ever been stored.
|
||||
"""
|
||||
configured = (_load_web_config().get("backend") or "").lower().strip()
|
||||
if configured in _LEGACY_WEB_BACKENDS or _registered_web_provider(configured) is not None:
|
||||
if configured:
|
||||
# Strict: the stored selection is final, known name or not — an
|
||||
# unknown/typoed name surfaces as the vendor path's honest error
|
||||
# rather than silently rerouting through the credential ladder.
|
||||
# The managed "Nous Subscription" selection ("nous") is serviced by
|
||||
# the firecrawl provider, whose client resolver routes it through
|
||||
# the managed Tool Gateway.
|
||||
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER
|
||||
|
||||
if configured == NOUS_MANAGED_PROVIDER:
|
||||
return "firecrawl"
|
||||
return configured
|
||||
|
||||
# Fallback for manual / legacy config — pick the highest-priority
|
||||
# available backend. Explicit user credentials (TAVILY_API_KEY etc.)
|
||||
from tools.tool_backend_helpers import selection_exists
|
||||
|
||||
if selection_exists("web"):
|
||||
# A web selection exists (e.g. use_gateway key or per-capability
|
||||
# backends) but the shared backend name is empty — keep the
|
||||
# firecrawl default rather than credential-laddering.
|
||||
return "firecrawl"
|
||||
|
||||
# Never-configured install — pick the highest-priority available
|
||||
# backend. Explicit user credentials (TAVILY_API_KEY etc.)
|
||||
# beat the managed-tool-gateway probe so a deliberate setup is not
|
||||
# pre-empted by a Nous OAuth token whose subscription tier may not
|
||||
# actually grant web-search access (the gateway then fails at runtime
|
||||
@@ -298,12 +318,16 @@ def _get_extract_backend() -> str:
|
||||
def _get_capability_backend(capability: str) -> str:
|
||||
"""Shared helper for per-capability backend selection.
|
||||
|
||||
Reads ``web.{capability}_backend`` from config; if set and available,
|
||||
uses it. Otherwise falls through to the shared ``_get_backend()``.
|
||||
Reads ``web.{capability}_backend`` from config; a stored value is
|
||||
returned unconditionally (strict selection — no availability probe).
|
||||
A selected-but-broken backend surfaces the vendor path's honest error
|
||||
instead of being silently replaced by whatever the credential ladder
|
||||
finds. Falls through to the shared ``_get_backend()`` only when no
|
||||
per-capability override is stored.
|
||||
"""
|
||||
cfg = _load_web_config()
|
||||
specific = (cfg.get(f"{capability}_backend") or "").lower().strip()
|
||||
if specific and _is_backend_available(specific):
|
||||
if specific:
|
||||
return specific
|
||||
return _get_backend()
|
||||
|
||||
@@ -692,9 +716,35 @@ def web_search_tool(query: str, limit: int = 5) -> str:
|
||||
backend = _get_search_backend()
|
||||
provider = _wsp_get_provider(backend) if backend else None
|
||||
if provider is None or not provider.supports_search():
|
||||
# Fall back to availability-walked active provider when the
|
||||
# configured backend isn't a registered search provider (typo,
|
||||
# uninstalled plugin, or capability mismatch).
|
||||
from tools.tool_backend_helpers import (
|
||||
selection_error,
|
||||
selection_exists,
|
||||
)
|
||||
|
||||
if provider is None and backend and selection_exists("web"):
|
||||
disabled_key = _disabled_web_plugin_for(capability="search")
|
||||
if disabled_key:
|
||||
_vendor = disabled_key.split("/", 1)[-1]
|
||||
error_text = (
|
||||
f"web.search_backend is set to '{_vendor}', but its "
|
||||
f"plugin ('{disabled_key}') is disabled in config. "
|
||||
f"Re-enable it with `hermes plugins enable {disabled_key}` "
|
||||
"(or remove it from plugins.disabled)."
|
||||
)
|
||||
else:
|
||||
error_text = selection_error(
|
||||
"web",
|
||||
f"'{backend}'",
|
||||
"no registered web search provider has that name",
|
||||
)
|
||||
response_data = {"success": False, "error": error_text}
|
||||
result_json = json.dumps(response_data, indent=2, ensure_ascii=False)
|
||||
debug_call_data["error"] = error_text
|
||||
_debug.log_call("web_search_tool", debug_call_data)
|
||||
_debug.save()
|
||||
return result_json
|
||||
# Never-configured install: fall back to the availability-walked
|
||||
# active provider (legacy autodetect behavior).
|
||||
provider = get_active_search_provider()
|
||||
|
||||
if provider is None:
|
||||
@@ -898,6 +948,35 @@ async def web_extract_tool(
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
from tools.tool_backend_helpers import (
|
||||
selection_error,
|
||||
selection_exists,
|
||||
)
|
||||
|
||||
if backend and selection_exists("web"):
|
||||
# Strict selection: a stored-but-unregistered backend
|
||||
# errors by name instead of silently switching to
|
||||
# whatever the availability walk finds.
|
||||
disabled_key = _disabled_web_plugin_for(capability="extract")
|
||||
if disabled_key:
|
||||
_vendor = disabled_key.split("/", 1)[-1]
|
||||
error_text = (
|
||||
f"web.extract_backend is set to '{_vendor}', but "
|
||||
f"its plugin ('{disabled_key}') is disabled in "
|
||||
f"config. Re-enable it with `hermes plugins "
|
||||
f"enable {disabled_key}` (or remove it from "
|
||||
"plugins.disabled)."
|
||||
)
|
||||
else:
|
||||
error_text = selection_error(
|
||||
"web",
|
||||
f"'{backend}'",
|
||||
"no registered web extract provider has that name",
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": False, "error": error_text},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
provider = get_active_extract_provider()
|
||||
if provider is None:
|
||||
# If the configured backend is a bundled web plugin the
|
||||
|
||||
Reference in New Issue
Block a user