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
18 changes: 18 additions & 0 deletions hermes_cli/subcommands/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,22 @@ def build_tools_parser(subparsers, *, cmd_tools: Callable) -> None:
metavar="KEY",
help="Post-setup hook key (e.g. agent_browser, camofox, kittentts)",
)

# hermes tools web reorder
tools_web_p = tools_sub.add_parser(
"web",
help="Manage web search backend configuration",
)
tools_web_sub = tools_web_p.add_subparsers(dest="web_action")

tools_web_reorder_p = tools_web_sub.add_parser(
"reorder",
help="Interactively reorder web search provider priority",
description=(
"Show current fallback order and let you rearrange it.\n"
"Providers are tried left-to-right on search failure.\n"
"Writes the new order to config.yaml as web.fallback_backends."
),
)

tools_parser.set_defaults(func=cmd_tools)
111 changes: 111 additions & 0 deletions hermes_cli/tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3373,6 +3373,111 @@ def _reconfigure_simple_requirements(ts_key: str):

# ─── Main Entry Point ─────────────────────────────────────────────────────────

def _tools_web_reorder(config: dict) -> None:
"""``hermes tools web reorder`` — interactively adjust web search backend priority.

Reads the current ``web.fallback_backends`` list, shows it with
numbered indices, lets the user enter a space-separated reorder
(e.g. ``3 1 2``), validates, and writes the result back to config.
"""
web_cfg = config.setdefault("web", {})
current = web_cfg.get("fallback_backends", [])
if isinstance(current, str):
current = [b.strip() for b in current.split(",") if b.strip()]

if not current:
# Populate from auto-discovered providers
try:
from tools.web_tools import _get_fallback_chain
current = _get_fallback_chain()
# Strip the explicit backend if it was added by the chain helper
explicit = (web_cfg.get("backend") or "").lower().strip()
if explicit and current and current[0] == explicit:
current = current[1:]
except Exception:
pass
if not current:
print("No web search providers registered. Run `hermes tools` to set up web search first.")
return

print()
print(color("⚕ Web Search Provider Order", Colors.CYAN, Colors.BOLD))
print(color(" Current fallback order (tried left to right on failure):", Colors.DIM))
print()
for i, name in enumerate(current, 1):
label = _provider_display_name(name)
print(f" {color(str(i), Colors.BOLD)} {label} {color(f'({name})', Colors.DIM)}")
print()
print(color(" Enter a new order as space-separated numbers, e.g.: 3 1 2 4 5", Colors.DIM))
print(color(" Press Enter to keep current order.", Colors.DIM))
print()

try:
user_input = input(" New order: ").strip()
except (EOFError, KeyboardInterrupt):
print()
return

if not user_input:
print(color(" ✓ Order unchanged.", Colors.GREEN))
return

try:
indices = [int(x) for x in user_input.split()]
except ValueError:
print(color(" ✗ Invalid input — enter space-separated numbers only.", Colors.RED))
return

if set(indices) != set(range(1, len(current) + 1)):
print(color(
f" ✗ Must include each number 1–{len(current)} exactly once.",
Colors.RED,
))
return
Comment on lines +3431 to +3436

reordered = [current[i - 1] for i in indices]
web_cfg["fallback_backends"] = reordered

# Save config
try:
from hermes_cli.config import save_config
save_config(config)
except Exception:
print(color(" ✗ Could not save config.", Colors.RED))
return

print()
print(color(" ✓ Updated fallback order:", Colors.GREEN))
for i, name in enumerate(reordered, 1):
label = _provider_display_name(name)
print(f" {i}. {label}")
print()


def _provider_display_name(name: str) -> str:
"""Return a human-readable label for a web provider."""
labels = {
"brave-free": "Brave Search (Free)",
"ddgs": "DuckDuckGo",
"serper": "Serper (Google)",
"baidu": "Baidu",
"bocha": "Bocha (博查)",
"qiniu-baidu": "Qiniu Baidu",
"serpapi": "SerpAPI",
"jina": "Jina AI",
"google-cse": "Google CSE",
"sogou": "Sogou (搜狗)",
"360-search": "360 Search",
"firecrawl": "Firecrawl",
"tavily": "Tavily",
"exa": "Exa",
"parallel": "Parallel",
"searxng": "SearXNG",
"xai": "X (xAI)",
}
return labels.get(name, name.replace("-", " ").title())


def tools_command(args=None, first_install: bool = False, config: dict = None):
"""Entry point for `hermes tools` and `hermes setup tools`.

Expand All @@ -3386,6 +3491,12 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
"""
if config is None:
config = load_config()

# ── Subcommand: hermes tools web reorder ──
if args and getattr(args, "tools_action", None) == "web":
if getattr(args, "web_action", None) == "reorder":
_tools_web_reorder(config)
return
enabled_platforms = _get_enabled_platforms()

print()
Expand Down
1 change: 1 addition & 0 deletions plugins/web/_360_search/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""360 Search (好搜) web search provider plugin."""
7 changes: 7 additions & 0 deletions plugins/web/_360_search/plugin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: web-360-search
version: 1.0.0
description: "360 Search (Haosou) — Chinese search engine. NOTE: no public developer API available."
author: Hermes Community
kind: backend
provides_web_providers:
- 360-search
45 changes: 45 additions & 0 deletions plugins/web/_360_search/provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""360 Search (Haosou) — Chinese search engine. NOTE: no public developer API available."""

from __future__ import annotations

import logging
import os
from typing import Any, Dict

from agent.web_search_provider import WebSearchProvider

logger = logging.getLogger(__name__)


class Three60SearchWebSearchProvider(WebSearchProvider):
"""360 Search (好搜) — unavailable tier search provider."""

@property
def name(self) -> str:
return "360-search"

@property
def display_name(self) -> str:
return "360 Search (好搜)"

def is_available(self) -> bool:
"""No public API available — always unavailable."""
return False

def supports_search(self) -> bool:
return True

def supports_extract(self) -> bool:
return False

def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
return {"success": False, "error": "360 Search (好搜) does not offer a public search API. Use Baidu or Bocha for Chinese-language search."}

def get_setup_schema(self) -> Dict[str, Any]:
return {
"name": "360 Search (好搜)",
"badge": "unavailable",
"tag": "No public API available. Listed for registry completeness.",
"env_vars": [
],
}
1 change: 1 addition & 0 deletions plugins/web/baidu/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Baidu Search web search provider plugin."""
7 changes: 7 additions & 0 deletions plugins/web/baidu/plugin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: web-baidu
version: 1.0.0
description: "Baidu AI Search API — 100 queries/day free. Requires BAIDU_API_KEY from https://ai.baidu.com."
author: Hermes Community
kind: backend
provides_web_providers:
- baidu
65 changes: 65 additions & 0 deletions plugins/web/baidu/provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Baidu AI Search API — 100 queries/day free. Requires BAIDU_API_KEY from https://ai.baidu.com."""

from __future__ import annotations

import logging
import os
from typing import Any, Dict

from agent.web_search_provider import WebSearchProvider

logger = logging.getLogger(__name__)


class BaiduWebSearchProvider(WebSearchProvider):
"""Baidu Search — free tier search provider."""

@property
def name(self) -> str:
return "baidu"

@property
def display_name(self) -> str:
return "Baidu Search"

def is_available(self) -> bool:
"""Return True when BAIDU_API_KEY is set."""
return bool(os.getenv("BAIDU_API_KEY", "").strip())

def supports_search(self) -> bool:
return True

def supports_extract(self) -> bool:
return False

def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
import requests
api_key = os.getenv("BAIDU_API_KEY", "").strip()
if not api_key:
return {"success": False, "error": "BAIDU_API_KEY not set. Get a key at https://ai.baidu.com."}
try:
r = requests.post("https://api.baidu.com/search/v1/websearch", params={"q": query, "topn": min(limit, 50)}, headers={"Authorization": f"Bearer {api_key}"}, timeout=10)
r.raise_for_status()
data = r.json()
raw = data.get("results", [])[:limit]
results = [
{"title": str(i.get("title", "")), "url": str(i.get("url", "")),
"description": str(i.get("summary", "")), "position": n + 1}
for n, i in enumerate(raw)
]
return {"success": True, "data": {"web": results}}
except Exception as e:
return {"success": False, "error": str(e)}
def get_setup_schema(self) -> Dict[str, Any]:
return {
"name": "Baidu Search",
"badge": "free",
"tag": "100 queries/day free tier, native Chinese content.",
"env_vars": [
{
"key": "BAIDU_API_KEY",
"prompt": "Baidu Search API key",
"url": "https://ai.baidu.com",
},
],
}
1 change: 1 addition & 0 deletions plugins/web/bocha/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Bocha (博查) web search provider plugin."""
7 changes: 7 additions & 0 deletions plugins/web/bocha/plugin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: web-bocha
version: 1.0.0
description: "Bochaa AI Search API — leading Chinese search for AI. Free for personal use. Requires BOCHA_API_KEY from https://open.bochaai.com."
author: Hermes Community
kind: backend
provides_web_providers:
- bocha
65 changes: 65 additions & 0 deletions plugins/web/bocha/provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Bochaa AI Search API — leading Chinese search for AI. Free for personal use. Requires BOCHA_API_KEY from https://open.bochaai.com."""

from __future__ import annotations

import logging
import os
from typing import Any, Dict

from agent.web_search_provider import WebSearchProvider

logger = logging.getLogger(__name__)


class BochaWebSearchProvider(WebSearchProvider):
"""Bocha (博查) — free tier search provider."""

@property
def name(self) -> str:
return "bocha"

@property
def display_name(self) -> str:
return "Bocha (博查)"

def is_available(self) -> bool:
"""Return True when BOCHA_API_KEY is set."""
return bool(os.getenv("BOCHA_API_KEY", "").strip())

def supports_search(self) -> bool:
return True

def supports_extract(self) -> bool:
return False

def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
import requests
api_key = os.getenv("BOCHA_API_KEY", "").strip()
if not api_key:
return {"success": False, "error": "BOCHA_API_KEY not set. Get a key at https://open.bochaai.com."}
try:
r = requests.post("https://api.bochaai.com/v1/web-search", json={"query": query, "count": min(limit, 20), "summary": True}, headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, timeout=10)
r.raise_for_status()
data = r.json()
raw = list(((data or {}).get("webPages", []) or {}).get("value", []))[:limit]
results = [
{"title": str(i.get("name", "")), "url": str(i.get("url", "")),
"description": str(i.get("summary", i.get("snippet", ""))), "position": n + 1}
for n, i in enumerate(raw)
]
return {"success": True, "data": {"web": results}}
except Exception as e:
return {"success": False, "error": str(e)}
def get_setup_schema(self) -> Dict[str, Any]:
return {
"name": "Bocha (博查)",
"badge": "free",
"tag": "Free for personal use, best Chinese content quality. 1,000 free queries starter pack.",
"env_vars": [
{
"key": "BOCHA_API_KEY",
"prompt": "Bocha (博查) API key",
"url": "https://open.bochaai.com",
},
],
}
1 change: 1 addition & 0 deletions plugins/web/google-cse/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Google CSE web search provider plugin."""
7 changes: 7 additions & 0 deletions plugins/web/google-cse/plugin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: web-google-cse
version: 1.0.0
description: "Google Custom Search JSON API — requires GCP project + API key + SE ID. NOTE: blocked by GFW in China."
author: Hermes Community
kind: backend
provides_web_providers:
- google-cse
Loading