Skip to content
Open
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
25 changes: 25 additions & 0 deletions tests/test_model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,31 @@ def test_unknown_tool_returns_error(self):
assert "error" in result
assert "totally_fake_tool_xyz" in result["error"]

def test_exception_returns_json_error(self):
# Even if something goes wrong, should return valid JSON with a
# top-level error envelope. Message wording varies (exception wrap
# vs structured "No web search provider configured") — only require
# a non-empty error string, not specific substrings.
result = handle_function_call("web_search", None) # None args may cause issues
parsed = json.loads(result)
assert isinstance(parsed, dict)
assert "error" in parsed
assert isinstance(parsed["error"], str)
assert len(parsed["error"]) > 0

def test_tool_hooks_receive_session_and_tool_call_ids(self):
with (
patch("model_tools.registry.dispatch", return_value='{"ok":true}'),
patch("hermes_cli.plugins.has_hook", return_value=True),
patch("hermes_cli.plugins.invoke_hook") as mock_invoke_hook,
):
result = handle_function_call(
"web_search",
{"q": "test"},
task_id="task-1",
tool_call_id="call-1",
session_id="session-1",
)


def test_post_tool_call_receives_non_negative_integer_duration_ms(self):
Expand Down
15 changes: 10 additions & 5 deletions tests/tools/test_web_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,12 +187,17 @@ def _clear_web_creds(self, monkeypatch):
"FIRECRAWL_API_URL",
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"XAI_API_KEY",
):
monkeypatch.delenv(k, raising=False)

def test_unconfigured_search_emits_top_level_error(self, monkeypatch):
"""``web_search_tool`` with no creds returns ``{"error": "Error searching web: ..."}``
— matching main's ``tool_error()`` envelope, not a per-result shape.
"""``web_search_tool`` with no creds returns a top-level ``error`` string.

When the default backend name is registered but unavailable and the
registry walk finds no provider, the dispatcher returns
``{"success": false, "error": "No web search provider configured..."}``
instead of delegating into Firecrawl and wrapping a SDK/config exception.
"""
from tools import web_tools

Expand All @@ -204,10 +209,10 @@ def test_unconfigured_search_emits_top_level_error(self, monkeypatch):
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {})

result = json.loads(web_tools.web_search_tool("hello world", limit=3))
assert result.get("success") is False, f"expected success=false, got {result}"
assert "error" in result, f"expected top-level 'error' key, got {result}"
# ``Error searching web:`` prefix comes from web_tools' top-level except handler
assert "Error searching web:" in result["error"]
assert "FIRECRAWL_API_KEY" in result["error"]
assert "No web search provider configured" in result["error"]
assert "hermes tools" in result["error"]
# No per-result burying
assert "results" not in result

Expand Down
38 changes: 38 additions & 0 deletions tests/tools/test_web_providers_xai.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,13 @@ def test_configured_backend_xai_accepted(self, monkeypatch):
monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "xai"})
assert web_tools._get_backend() == "xai"

def test_check_web_api_key_true_when_xai_configured(self, monkeypatch):
from tools import web_tools

monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "xai"})
monkeypatch.setenv("XAI_API_KEY", "sk-xai-test")
assert web_tools.check_web_api_key() is True

def test_xai_not_in_legacy_backend_candidate_chain(self, monkeypatch):
"""The hardcoded ``backend_candidates`` tuple in ``_get_backend()``
does not include xAI — by design, since the no-config legacy
Expand All @@ -450,6 +457,37 @@ def test_xai_not_in_legacy_backend_candidate_chain(self, monkeypatch):
monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: False)
assert web_tools._get_backend() != "xai"

def test_web_search_falls_back_when_named_backend_unavailable(self, monkeypatch):
"""``_get_search_backend()`` can return ``firecrawl`` by default while
only xAI credentials exist; dispatch must not call the unavailable
provider's ``search()`` when registry resolution can pick xAI."""
from tools import web_tools

unavailable = MagicMock()
unavailable.supports_search.return_value = True
unavailable.is_available.return_value = False
unavailable.name = "firecrawl"

active = MagicMock()
active.supports_search.return_value = True
active.is_available.return_value = True
active.name = "xai"
active.search.return_value = {"success": True, "data": {"web": []}}

monkeypatch.setattr(web_tools, "_get_search_backend", lambda: "firecrawl")
with patch(
"agent.web_search_registry.get_provider",
return_value=unavailable,
), patch(
"agent.web_search_registry.get_active_search_provider",
return_value=active,
):
result = json.loads(web_tools.web_search_tool("test query", limit=3))

assert result["success"] is True
unavailable.search.assert_not_called()
active.search.assert_called_once_with("test query", 3)


# ---------------------------------------------------------------------------
# OAuth credential resolution (end-to-end through tools.xai_http)
Expand Down
14 changes: 14 additions & 0 deletions tests/tools/test_web_tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,20 @@ def test_configured_firecrawl_backend_accepts_managed_gateway(self):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True

def test_xai_key_only(self):
with patch.dict(os.environ, {"XAI_API_KEY": "sk-xai-test"}):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True

def test_configured_xai_backend_requires_xai_credentials(self):
with patch("tools.web_tools._load_web_config", return_value={"backend": "xai"}):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is False
with patch("tools.web_tools._load_web_config", return_value={"backend": "xai"}):
with patch.dict(os.environ, {"XAI_API_KEY": "sk-xai-test"}):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True


def test_web_requires_env_includes_exa_key():
from tools.web_tools import _web_requires_env
Expand Down
21 changes: 14 additions & 7 deletions tools/web_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,9 +671,9 @@ def web_search_tool(query: str, limit: int = 5) -> str:
if is_interrupted():
return tool_error("Interrupted", success=False)

# Dispatch through the web search registry. All 7 providers
# (brave-free, ddgs, searxng, exa, parallel, tavily, firecrawl)
# now live as plugins; the dispatcher is just a registry lookup +
# Dispatch through the web search registry. Bundled providers
# (brave-free, ddgs, searxng, exa, parallel, tavily, firecrawl, xai,
# …) register at import time; the dispatcher is a registry lookup +
# delegation. Sync only — every provider's search() is sync.
_ensure_web_plugins_loaded()
from agent.web_search_registry import (
Expand All @@ -684,10 +684,17 @@ 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).
if provider is not None and provider.supports_search():
try:
provider_available = bool(provider.is_available())
except Exception:
provider_available = False
else:
provider_available = False
if provider is None or not provider.supports_search() or not provider_available:
# Fall back when the name from config/auto-detect does not map to
# a usable search provider (typo, missing plugin, capability mismatch,
# or no credentials — e.g. default "firecrawl" with only xAI OAuth).
provider = get_active_search_provider()

if provider is None:
Expand Down
Loading