Skip to content
Closed
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
12 changes: 11 additions & 1 deletion hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ def ensure_hermes_home():
4: ["VOICE_TOOLS_OPENAI_KEY", "ELEVENLABS_API_KEY"],
5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS",
"SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"],
7: ["TAVILY_API_KEY"],
}

# Required environment variables with metadata for migration prompts.
Expand Down Expand Up @@ -556,6 +557,14 @@ def ensure_hermes_home():
"password": True,
"category": "tool",
},
"TAVILY_API_KEY": {
"description": "Tavily API key for web search, extraction, and crawling",
"prompt": "Tavily API key",
"url": "https://app.tavily.com/home",
"tools": ["web_search", "web_extract"],
"password": True,
"category": "tool",
},
"FIRECRAWL_API_URL": {
"description": "Firecrawl API URL for self-hosted instances (optional)",
"prompt": "Firecrawl API URL (leave empty for cloud)",
Expand Down Expand Up @@ -1456,6 +1465,7 @@ def show_config():
("OPENROUTER_API_KEY", "OpenRouter"),
("VOICE_TOOLS_OPENAI_KEY", "OpenAI (STT/TTS)"),
("FIRECRAWL_API_KEY", "Firecrawl"),
("TAVILY_API_KEY", "Tavily"),
("BROWSERBASE_API_KEY", "Browserbase"),
("BROWSER_USE_API_KEY", "Browser Use"),
("FAL_KEY", "FAL"),
Expand Down Expand Up @@ -1604,7 +1614,7 @@ def set_config_value(key: str, value: str):
# Check if it's an API key (goes to .env)
api_keys = [
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
'FIRECRAWL_API_KEY', 'TAVILY_API_KEY', 'FIRECRAWL_API_URL', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN',
Expand Down
6 changes: 3 additions & 3 deletions hermes_cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,11 +444,11 @@ def _print_setup_summary(config: dict, hermes_home):
else:
tool_status.append(("Mixture of Agents", False, "OPENROUTER_API_KEY"))

# Firecrawl (web tools)
if get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"):
# Web tools (Firecrawl or Tavily)
if get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL") or get_env_value("TAVILY_API_KEY"):
tool_status.append(("Web Search & Extract", True, None))
else:
tool_status.append(("Web Search & Extract", False, "FIRECRAWL_API_KEY"))
tool_status.append(("Web Search & Extract", False, "FIRECRAWL_API_KEY or TAVILY_API_KEY"))

# Browser tools (local Chromium or Browserbase cloud)
import shutil
Expand Down
1 change: 1 addition & 0 deletions hermes_cli/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ def show_status(args):
"MiniMax": "MINIMAX_API_KEY",
"MiniMax-CN": "MINIMAX_CN_API_KEY",
"Firecrawl": "FIRECRAWL_API_KEY",
"Tavily": "TAVILY_API_KEY",
"Browserbase": "BROWSERBASE_API_KEY", # Optional — local browser works without this
"FAL": "FAL_KEY",
"Tinker": "TINKER_API_KEY",
Expand Down
9 changes: 8 additions & 1 deletion hermes_cli/tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,16 @@ def _prompt_yes_no(question: str, default: bool = True) -> bool:
"web": {
"name": "Web Search & Extract",
"setup_title": "Select Search Provider",
"setup_note": "A free DuckDuckGo search skill is also included — skip this if you don't need Firecrawl.",
"setup_note": "A free DuckDuckGo search skill is also included if you do not need the built-in web_search/web_extract tools.",
"icon": "🔍",
"providers": [
{
"name": "Tavily",
"tag": "Hosted web search, extraction, and crawling",
"env_vars": [
{"key": "TAVILY_API_KEY", "prompt": "Tavily API key", "url": "https://app.tavily.com/home"},
],
},
{
"name": "Firecrawl Cloud",
"tag": "Recommended - hosted service",
Expand Down
11 changes: 11 additions & 0 deletions tests/hermes_cli/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

from hermes_cli.config import (
DEFAULT_CONFIG,
ENV_VARS_BY_VERSION,
OPTIONAL_ENV_VARS,
get_hermes_home,
ensure_hermes_home,
load_config,
Expand Down Expand Up @@ -345,3 +347,12 @@ def test_skips_on_version_9_or_later(self, tmp_path):
}):
migrate_config(interactive=False, quiet=True)
assert load_env().get("ANTHROPIC_TOKEN") == "current-token"


class TestOptionalToolKeys:
def test_tavily_api_key_is_registered_for_setup_and_migration(self):
tavily = OPTIONAL_ENV_VARS["TAVILY_API_KEY"]
assert tavily["prompt"] == "Tavily API key"
assert tavily["category"] == "tool"
assert "search" in tavily["description"].lower()
assert "TAVILY_API_KEY" in ENV_VARS_BY_VERSION[7]
14 changes: 14 additions & 0 deletions tests/hermes_cli/test_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from types import SimpleNamespace

from hermes_cli.status import show_status


def test_show_status_includes_tavily_key(monkeypatch, capsys, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("TAVILY_API_KEY", "tvly-1234567890abcdef")

show_status(SimpleNamespace(all=False, deep=False))

output = capsys.readouterr().out
assert "Tavily" in output
assert "tvly...cdef" in output
35 changes: 33 additions & 2 deletions tests/tools/test_web_tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Coverage:
_get_firecrawl_client() — configuration matrix, singleton caching,
constructor failure recovery, return value verification, edge cases.
_get_active_web_backend() / check_firecrawl_api_key() — backend selection.
"""

import os
Expand All @@ -17,14 +18,14 @@ def setup_method(self):
"""Reset client and env vars before each test."""
import tools.web_tools
tools.web_tools._firecrawl_client = None
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"):
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY"):
os.environ.pop(key, None)

def teardown_method(self):
"""Reset client after each test."""
import tools.web_tools
tools.web_tools._firecrawl_client = None
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"):
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY"):
os.environ.pop(key, None)

# ── Configuration matrix ─────────────────────────────────────────
Expand Down Expand Up @@ -117,3 +118,33 @@ def test_empty_string_key_no_url_raises(self):
from tools.web_tools import _get_firecrawl_client
with pytest.raises(ValueError):
_get_firecrawl_client()


class TestWebBackendSelection:
def setup_method(self):
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY"):
os.environ.pop(key, None)

def teardown_method(self):
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY"):
os.environ.pop(key, None)

def test_prefers_firecrawl_when_both_backends_are_configured(self):
with patch.dict(os.environ, {"FIRECRAWL_API_KEY": "fc-test", "TAVILY_API_KEY": "tvly-test"}):
from tools.web_tools import _get_active_web_backend

assert _get_active_web_backend() == "firecrawl"

def test_uses_tavily_when_firecrawl_missing(self):
with patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}):
from tools.web_tools import _get_active_web_backend, check_firecrawl_api_key

assert _get_active_web_backend() == "tavily"
assert check_firecrawl_api_key() is True

def test_no_backend_config_raises_helpful_error(self):
from tools.web_tools import _get_active_web_backend, check_firecrawl_api_key

with pytest.raises(ValueError, match="TAVILY_API_KEY"):
_get_active_web_backend()
assert check_firecrawl_api_key() is False
131 changes: 131 additions & 0 deletions tests/tools/test_web_tools_tavily.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import json

import pytest


class _FakeResponse:
def __init__(self, payload, status_code=200):
self._payload = payload
self.status_code = status_code
self.is_error = status_code >= 400
self.text = json.dumps(payload)

def json(self):
return self._payload


def test_web_search_tool_uses_tavily_backend(monkeypatch):
from tools import web_tools

monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False)
monkeypatch.delenv("FIRECRAWL_API_URL", raising=False)
monkeypatch.setenv("TAVILY_API_KEY", "tvly-test")

calls = []

def fake_post(url, json, timeout):
calls.append({"url": url, "json": json, "timeout": timeout})
return _FakeResponse(
{
"results": [
{
"title": "Hermes Agent",
"url": "https://example.com/hermes",
"content": "Project overview",
"score": 0.98,
}
]
}
)

monkeypatch.setattr(web_tools.httpx, "post", fake_post)

result = json.loads(web_tools.web_search_tool("hermes agent", limit=3))

assert calls[0]["url"] == "https://api.tavily.com/search"
assert calls[0]["json"]["max_results"] == 3
assert result["success"] is True
assert result["data"]["web"][0]["title"] == "Hermes Agent"
assert result["data"]["web"][0]["description"] == "Project overview"


@pytest.mark.asyncio
async def test_web_extract_tool_uses_tavily_backend(monkeypatch):
from tools import web_tools

monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False)
monkeypatch.delenv("FIRECRAWL_API_URL", raising=False)
monkeypatch.setenv("TAVILY_API_KEY", "tvly-test")
monkeypatch.setattr(web_tools, "check_website_access", lambda url: None)
monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False)

def fake_post(url, json, timeout):
assert url == "https://api.tavily.com/extract"
assert json["urls"] == ["https://example.com/doc"]
return _FakeResponse(
{
"results": [
{
"url": "https://example.com/doc",
"title": "Example Doc",
"raw_content": "Full extracted content",
}
]
}
)

monkeypatch.setattr(web_tools.httpx, "post", fake_post)

result = json.loads(
await web_tools.web_extract_tool(
["https://example.com/doc"],
use_llm_processing=False,
)
)

assert result["results"][0]["title"] == "Example Doc"
assert result["results"][0]["content"] == "Full extracted content"
assert result["results"][0]["error"] is None


@pytest.mark.asyncio
async def test_web_crawl_tool_uses_tavily_backend(monkeypatch):
from tools import web_tools

monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False)
monkeypatch.delenv("FIRECRAWL_API_URL", raising=False)
monkeypatch.setenv("TAVILY_API_KEY", "tvly-test")
monkeypatch.setattr(web_tools, "check_website_access", lambda url: None)
monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False)

def fake_post(url, json, timeout):
assert url == "https://api.tavily.com/crawl"
assert json["url"] == "https://example.com"
assert json["instructions"] == "Find docs"
assert json["extract_depth"] == "advanced"
return _FakeResponse(
{
"results": [
{
"url": "https://example.com/docs",
"title": "Docs",
"raw_content": "Documentation page",
}
]
}
)

monkeypatch.setattr(web_tools.httpx, "post", fake_post)

result = json.loads(
await web_tools.web_crawl_tool(
"https://example.com",
instructions="Find docs",
depth="advanced",
use_llm_processing=False,
)
)

assert result["results"][0]["url"] == "https://example.com/docs"
assert result["results"][0]["content"] == "Documentation page"
assert result["results"][0]["error"] is None
8 changes: 8 additions & 0 deletions tests/tools/test_website_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,10 @@ async def test_web_extract_returns_clean_policy_error_for_malformed_config(monke
async def test_web_extract_blocks_redirected_final_url(monkeypatch):
from tools import web_tools

monkeypatch.setenv("FIRECRAWL_API_KEY", "fc-test")
monkeypatch.delenv("FIRECRAWL_API_URL", raising=False)
monkeypatch.delenv("TAVILY_API_KEY", raising=False)

def fake_check(url):
if url == "https://allowed.test":
return None
Expand Down Expand Up @@ -449,6 +453,10 @@ async def test_web_crawl_short_circuits_blocked_url(monkeypatch):
async def test_web_crawl_blocks_redirected_final_url(monkeypatch):
from tools import web_tools

monkeypatch.setenv("FIRECRAWL_API_KEY", "fc-test")
monkeypatch.delenv("FIRECRAWL_API_URL", raising=False)
monkeypatch.delenv("TAVILY_API_KEY", raising=False)

def fake_check(url):
if url == "https://allowed.test":
return None
Expand Down
Loading