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
26 changes: 26 additions & 0 deletions hermes_cli/tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,32 @@ def _get_plugin_toolset_keys() -> set:
{"key": "FIRECRAWL_API_URL", "prompt": "Your Firecrawl instance URL (e.g., http://localhost:3002)"},
],
},
{
"name": "SearXNG",
"badge": "free · self-hosted",
"tag": "Metasearch — point at any SearXNG instance",
"web_backend": "searxng",
"env_vars": [
{"key": "SEARXNG_BASE_URL", "prompt": "SearXNG base URL (e.g., http://localhost:8080)"},
],
},
{
"name": "Brave Search (Free Tier)",
"badge": "free tier",
"tag": "Brave Search API on the free tier",
"web_backend": "brave-free",
"env_vars": [
{"key": "BRAVE_FREE_KEY", "prompt": "Brave Search API key", "url": "https://brave.com/search/api/"},
],
},
{
"name": "DuckDuckGo (ddgs)",
"badge": "free · no key",
"tag": "DuckDuckGo via the ddgs Python library — ratelimited",
"web_backend": "ddgs",
"env_vars": [],
"pip_install": "ddgs",
},
],
},
"image_gen": {
Expand Down
108 changes: 108 additions & 0 deletions scripts/start-llama-server.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# start-llama-server.sh — boot llama.cpp's llama-server with flags appropriate
# for OpenAI-compatible local inference and Qwen3.5 / Qwen3.6 chat templates.
#
# Sets --jinja (required for Qwen3.5/3.6 chat-template + tool-call handling)
# and prints the chat_template_kwargs flag clients must pass for those models.
# Any Hermes tool that talks to /v1/chat/completions can point at the resulting
# server via $LLM_BASE_URL=http://127.0.0.1:8088.
#
# Usage:
# start-llama-server.sh <gguf-path>
# start-llama-server.sh ~/models/Qwen3.5-9B-Q4_K_M.gguf
#
# Or with explicit overrides:
# PORT=9000 CTX=32768 THREADS=8 start-llama-server.sh <gguf>
#
# Requires: llama-server binary on PATH (or LLAMA_SERVER env var pointing to it)
# See https://github.com/ggerganov/llama.cpp/releases for prebuilt binaries.

set -e

GGUF="${1:-}"
PORT="${PORT:-8088}"
CTX="${CTX:-16384}"
THREADS="${THREADS:-$(nproc 2>/dev/null || echo 8)}"
HOST="${HOST:-127.0.0.1}"
N_GPU_LAYERS="${N_GPU_LAYERS:-0}" # 0=CPU only; -1=all layers on GPU; positive int = N layers
LLAMA_SERVER="${LLAMA_SERVER:-llama-server}"

if [ -z "$GGUF" ]; then
cat << 'USAGE'
Usage: start-llama-server.sh <path-to-gguf>

Qwen3.5 / Qwen3.6 GGUFs (Apache 2.0):
unsloth/Qwen3.5-4B-GGUF (~2.5 GB)
unsloth/Qwen3.5-9B-GGUF (~5.5 GB)
unsloth/Qwen3.5-27B-GGUF (~16 GB)
unsloth/Qwen3.6-27B-GGUF (~16 GB, dense)
unsloth/Qwen3.6-35B-A3B-GGUF (~21 GB, MoE)

Environment overrides:
PORT=8088 Server port
CTX=16384 Context window (Qwen3.5 supports up to 262144 native, 1M with YaRN)
THREADS=12 CPU threads
N_GPU_LAYERS=0 0=CPU only, -1=all layers on GPU, positive int=N layers
LLAMA_SERVER=llama-server Path to llama-server binary
USAGE
exit 1
fi

[ ! -f "$GGUF" ] && { echo "ERROR: GGUF file not found: $GGUF"; exit 2; }

if ! command -v "$LLAMA_SERVER" >/dev/null 2>&1; then
cat << 'EOF'
ERROR: llama-server not found on PATH.

Install:
Linux x86_64: https://github.com/ggerganov/llama.cpp/releases
curl -fsSL "https://github.com/ggerganov/llama.cpp/releases/download/b9010/llama-b9010-bin-ubuntu-x64.tar.gz" | tar xz
export PATH=$(pwd)/llama-b9010:$PATH
macOS: brew install llama.cpp
Pip: pip install llama-cpp-python[server]
# then: python -m llama_cpp.server --model <gguf> --port 8088

Or set LLAMA_SERVER=/path/to/llama-server explicitly.
EOF
exit 3
fi

# Detect Qwen3.5 / 3.6 from filename for friendly logging
MODEL_FAMILY="(generic)"
case "$(basename "$GGUF" | tr '[:upper:]' '[:lower:]')" in
*qwen3.5*|*qwen3_5*) MODEL_FAMILY="Qwen3.5" ;;
*qwen3.6*|*qwen3_6*) MODEL_FAMILY="Qwen3.6" ;;
*qwen3*) MODEL_FAMILY="Qwen3" ;;
esac

echo "=== llama-server boot ==="
echo " model: $GGUF"
echo " family: $MODEL_FAMILY"
echo " endpoint: http://${HOST}:${PORT}/v1/chat/completions"
echo " context: $CTX"
echo " threads: $THREADS"
echo " gpu layers: $N_GPU_LAYERS"
echo ""

# Build args. --jinja loads the model's chat template (required for Qwen3.5/3.6 tool calls
# + the chat_template_kwargs.enable_thinking switch).
ARGS=(
-m "$GGUF"
--host "$HOST"
--port "$PORT"
--ctx-size "$CTX"
--threads "$THREADS"
--jinja
--n-gpu-layers "$N_GPU_LAYERS"
)

# Qwen3.5/3.6 default to thinking and do NOT honor /think /no_think directives.
# Per the official model card, tool-calling clients must send
# chat_template_kwargs={"enable_thinking": false}.
if [[ "$MODEL_FAMILY" =~ Qwen3\.[56] ]]; then
echo " Qwen3.5/3.6 detected."
echo " Tool-calling clients must pass: chat_template_kwargs={\"enable_thinking\": false}"
echo ""
fi

exec "$LLAMA_SERVER" "${ARGS[@]}"
162 changes: 161 additions & 1 deletion tools/web_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,17 +126,23 @@ def _get_backend() -> str:
keys manually without running setup.
"""
configured = (_load_web_config().get("backend") or "").lower().strip()
if configured in ("parallel", "firecrawl", "tavily", "exa"):
if configured in ("parallel", "firecrawl", "tavily", "exa",
"searxng", "brave-free", "ddgs"):
return configured

# Fallback for manual / legacy config — pick the highest-priority
# available backend. Firecrawl also counts as available when the managed
# tool gateway is configured for Nous subscribers.
# Free-tier backends (searxng / brave-free / ddgs) are tried last so
# paid configurations are unaffected by their presence.
backend_candidates = (
("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL") or _is_tool_gateway_ready()),
("parallel", _has_env("PARALLEL_API_KEY")),
("tavily", _has_env("TAVILY_API_KEY")),
("exa", _has_env("EXA_API_KEY")),
("searxng", _has_searxng_config()),
("brave-free", _has_brave_free_config()),
("ddgs", _has_ddgs_available()),
)
for backend, available in backend_candidates:
if available:
Expand All @@ -155,6 +161,12 @@ def _is_backend_available(backend: str) -> bool:
return check_firecrawl_api_key()
if backend == "tavily":
return _has_env("TAVILY_API_KEY")
if backend == "searxng":
return _has_searxng_config()
if backend == "brave-free":
return _has_brave_free_config()
if backend == "ddgs":
return _has_ddgs_available()
return False

# ─── Firecrawl Client ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -995,6 +1007,120 @@ def _exa_extract(urls: List[str]) -> List[Dict[str, Any]]:
return results


# ─── SearXNG / Brave / ddgs backends ─────────────────────────────────────────
# Tried last in the fallback chain so paid backends are unaffected.

def _has_searxng_config() -> bool:
return bool(os.getenv("SEARXNG_BASE_URL", "").strip())


def _has_brave_free_config() -> bool:
return bool(os.getenv("BRAVE_FREE_KEY", "").strip())


def _has_ddgs_available() -> bool:
try:
import ddgs # noqa: F401
return True
except ImportError:
return False


def _searxng_search(query: str, limit: int = 5) -> dict:
import httpx
base = os.getenv("SEARXNG_BASE_URL", "").rstrip("/")
if not base:
raise RuntimeError("SEARXNG_BASE_URL not configured")
r = httpx.get(
f"{base}/search",
params={"q": query, "format": "json"},
timeout=20.0,
headers={"Accept": "application/json"},
)
r.raise_for_status()
data = r.json()
web_results = []
for i, item in enumerate(data.get("results", [])[:limit]):
web_results.append({
"title": item.get("title", "") or "",
"url": item.get("url", "") or "",
"description": item.get("content", "") or "",
"position": i + 1,
})
return {"success": True, "data": {"web": web_results}}


def _brave_free_search(query: str, limit: int = 5) -> dict:
import httpx
api_key = os.getenv("BRAVE_FREE_KEY", "").strip()
if not api_key:
raise RuntimeError("BRAVE_FREE_KEY not configured")
r = httpx.get(
"https://api.search.brave.com/res/v1/web/search",
headers={"X-Subscription-Token": api_key, "Accept": "application/json"},
params={"q": query, "count": min(max(limit, 1), 20)},
timeout=20.0,
)
r.raise_for_status()
data = r.json()
web_results = []
for i, item in enumerate(data.get("web", {}).get("results", [])[:limit]):
web_results.append({
"title": item.get("title", "") or "",
"url": item.get("url", "") or "",
"description": item.get("description", "") or "",
"position": i + 1,
})
return {"success": True, "data": {"web": web_results}}


def _ddgs_search(query: str, limit: int = 5) -> dict:
try:
from ddgs import DDGS # type: ignore
except ImportError:
raise RuntimeError("ddgs package not installed; pip install ddgs")
web_results: list = []
with DDGS() as d:
for i, r in enumerate(d.text(query, max_results=limit)):
web_results.append({
"title": r.get("title", "") or "",
"url": r.get("href", "") or r.get("url", "") or "",
"description": r.get("body", "") or "",
"position": i + 1,
})
if i + 1 >= limit:
break
return {"success": True, "data": {"web": web_results}}


def _lynx_extract(urls: List[str]) -> List[Dict[str, Any]]:
"""Extract via the ``lynx`` text browser. Requires lynx on PATH.

Used as the extract path when the active backend is searxng / brave-free / ddgs.
"""
import shutil
import subprocess as _sp
if not shutil.which("lynx"):
raise RuntimeError("lynx not installed (apt install lynx OR brew install lynx)")
out: List[Dict[str, Any]] = []
for url in urls[:5]:
try:
proc = _sp.run(
["lynx", "-dump", "-nolist", "-width", "120", url],
capture_output=True, text=True, timeout=30,
)
text = (proc.stdout or "").strip()
out.append({
"url": url,
"title": "",
"content": text,
"char_count": len(text),
})
except Exception as e:
out.append({"url": url, "title": "", "content": "", "error": str(e)})
return out


# ─── Parallel Search & Extract Helpers ────────────────────────────────────────

def _parallel_search(query: str, limit: int = 5) -> dict:
Expand Down Expand Up @@ -1163,6 +1289,36 @@ def web_search_tool(query: str, limit: int = 5) -> str:
_debug.save()
return result_json

if backend == "searxng":
logger.info("SearXNG search: '%s' (limit: %d)", query, limit)
response_data = _searxng_search(query, limit)
debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", []))
result_json = json.dumps(response_data, indent=2, ensure_ascii=False)
debug_call_data["final_response_size"] = len(result_json)
_debug.log_call("web_search_tool", debug_call_data)
_debug.save()
return result_json

if backend == "brave-free":
logger.info("Brave free search: '%s' (limit: %d)", query, limit)
response_data = _brave_free_search(query, limit)
debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", []))
result_json = json.dumps(response_data, indent=2, ensure_ascii=False)
debug_call_data["final_response_size"] = len(result_json)
_debug.log_call("web_search_tool", debug_call_data)
_debug.save()
return result_json

if backend == "ddgs":
logger.info("DuckDuckGo (ddgs) search: '%s' (limit: %d)", query, limit)
response_data = _ddgs_search(query, limit)
debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", []))
result_json = json.dumps(response_data, indent=2, ensure_ascii=False)
debug_call_data["final_response_size"] = len(result_json)
_debug.log_call("web_search_tool", debug_call_data)
_debug.save()
return result_json

logger.info("Searching the web for: '%s' (limit: %d)", query, limit)

response = _get_firecrawl_client().search(
Expand Down Expand Up @@ -1297,6 +1453,10 @@ async def web_extract_tool(
"include_images": False,
})
results = _normalize_tavily_documents(raw, fallback_url=safe_urls[0] if safe_urls else "")
elif backend in ("searxng", "brave-free", "ddgs"):
# Free-tier search backends use lynx for content extraction.
logger.info("lynx extract (free-tier backend %s): %d URL(s)", backend, len(safe_urls))
results = _lynx_extract(safe_urls)
else:
# ── Firecrawl extraction ──
# Determine requested formats for Firecrawl v2
Expand Down