Merge main into bb/gui.

Resolve merge conflicts while preserving bb/gui dashboard paths and STT provider support.
This commit is contained in:
Brooklyn Nicholson
2026-05-15 15:33:28 -05:00
415 changed files with 38391 additions and 20402 deletions
+9 -9
View File
@@ -19,7 +19,7 @@ import unicodedata
from typing import Optional
from hermes_cli.config import cfg_get
from utils import is_truthy_value
from utils import env_var_enabled, is_truthy_value
logger = logging.getLogger(__name__)
@@ -108,9 +108,9 @@ def _is_gateway_approval_context() -> bool:
fall through to the gateway branch would submit a pending approval
with no listener and block the job indefinitely.
"""
if os.getenv("HERMES_CRON_SESSION"):
if env_var_enabled("HERMES_CRON_SESSION"):
return False
if os.getenv("HERMES_GATEWAY_SESSION"):
if env_var_enabled("HERMES_GATEWAY_SESSION"):
return True
return bool(_get_session_platform())
@@ -928,12 +928,12 @@ def check_dangerous_command(command: str, env_type: str,
if is_approved(session_key, pattern_key):
return {"approved": True, "message": None}
is_cli = os.getenv("HERMES_INTERACTIVE")
is_cli = env_var_enabled("HERMES_INTERACTIVE")
is_gateway = _is_gateway_approval_context()
if not is_cli and not is_gateway:
# Cron sessions: respect cron_mode config
if os.getenv("HERMES_CRON_SESSION"):
if env_var_enabled("HERMES_CRON_SESSION"):
if _get_cron_approval_mode() == "deny":
return {
"approved": False,
@@ -947,7 +947,7 @@ def check_dangerous_command(command: str, env_type: str,
}
return {"approved": True, "message": None}
if is_gateway or os.getenv("HERMES_EXEC_ASK"):
if is_gateway or env_var_enabled("HERMES_EXEC_ASK"):
submit_pending(session_key, {
"command": command,
"pattern_key": pattern_key,
@@ -1056,15 +1056,15 @@ def check_all_command_guards(command: str, env_type: str,
if is_truthy_value(os.getenv("HERMES_YOLO_MODE")) or is_current_session_yolo_enabled() or approval_mode == "off":
return {"approved": True, "message": None}
is_cli = os.getenv("HERMES_INTERACTIVE")
is_cli = env_var_enabled("HERMES_INTERACTIVE")
is_gateway = _is_gateway_approval_context()
is_ask = os.getenv("HERMES_EXEC_ASK")
is_ask = env_var_enabled("HERMES_EXEC_ASK")
# Preserve the existing non-interactive behavior: outside CLI/gateway/ask
# flows, we do not block on approvals and we skip external guard work.
if not is_cli and not is_gateway and not is_ask:
# Cron sessions: respect cron_mode config
if os.getenv("HERMES_CRON_SESSION"):
if env_var_enabled("HERMES_CRON_SESSION"):
if _get_cron_approval_mode() == "deny":
# Run detection to get a description for the block message
is_dangerous, _pk, description = detect_dangerous_command(command)
+16 -6
View File
@@ -137,12 +137,22 @@ class BrowserUseProvider(CloudBrowserProvider):
else {}
)
response = requests.post(
f"{config['base_url']}/browsers",
headers=headers,
json=payload,
timeout=30,
)
try:
response = requests.post(
f"{config['base_url']}/browsers",
headers=headers,
json=payload,
timeout=30,
)
except requests.RequestException as exc:
# Managed mode: propagate raw so callers can retry with the
# preserved idempotency key. Direct mode: wrap network failures
# into a clean RuntimeError for end users.
if managed_mode:
raise
raise RuntimeError(
f"Browser Use API connection failed: {exc}"
) from exc
if not response.ok:
if managed_mode and not _should_preserve_pending_create_key(response):
+41 -36
View File
@@ -92,45 +92,50 @@ class BrowserbaseProvider(CloudBrowserProvider):
"X-BB-API-Key": config["api_key"],
}
response = requests.post(
f"{config['base_url']}/v1/sessions",
headers=headers,
json=session_config,
timeout=30,
)
try:
response = requests.post(
f"{config['base_url']}/v1/sessions",
headers=headers,
json=session_config,
timeout=30,
)
proxies_fallback = False
keepalive_fallback = False
proxies_fallback = False
keepalive_fallback = False
# Handle 402 — paid features unavailable
if response.status_code == 402:
if enable_keep_alive:
keepalive_fallback = True
logger.warning(
"keepAlive may require paid plan (402), retrying without it. "
"Sessions may timeout during long operations."
)
session_config.pop("keepAlive", None)
response = requests.post(
f"{config['base_url']}/v1/sessions",
headers=headers,
json=session_config,
timeout=30,
)
# Handle 402 — paid features unavailable
if response.status_code == 402:
if enable_keep_alive:
keepalive_fallback = True
logger.warning(
"keepAlive may require paid plan (402), retrying without it. "
"Sessions may timeout during long operations."
)
session_config.pop("keepAlive", None)
response = requests.post(
f"{config['base_url']}/v1/sessions",
headers=headers,
json=session_config,
timeout=30,
)
if response.status_code == 402 and enable_proxies:
proxies_fallback = True
logger.warning(
"Proxies unavailable (402), retrying without proxies. "
"Bot detection may be less effective."
)
session_config.pop("proxies", None)
response = requests.post(
f"{config['base_url']}/v1/sessions",
headers=headers,
json=session_config,
timeout=30,
)
if response.status_code == 402 and enable_proxies:
proxies_fallback = True
logger.warning(
"Proxies unavailable (402), retrying without proxies. "
"Bot detection may be less effective."
)
session_config.pop("proxies", None)
response = requests.post(
f"{config['base_url']}/v1/sessions",
headers=headers,
json=session_config,
timeout=30,
)
except requests.RequestException as exc:
raise RuntimeError(
f"Browserbase API connection failed: {exc}"
) from exc
if not response.ok:
raise RuntimeError(
+11 -6
View File
@@ -47,12 +47,17 @@ class FirecrawlProvider(CloudBrowserProvider):
body: Dict[str, object] = {"ttl": ttl}
response = requests.post(
f"{self._api_url()}/v2/browser",
headers=self._headers(),
json=body,
timeout=30,
)
try:
response = requests.post(
f"{self._api_url()}/v2/browser",
headers=self._headers(),
json=body,
timeout=30,
)
except requests.RequestException as exc:
raise RuntimeError(
f"Firecrawl API connection failed: {exc}"
) from exc
if not response.ok:
raise RuntimeError(
+17 -6
View File
@@ -1873,7 +1873,13 @@ def _run_browser_command(
# - Ubuntu 23.10+ / AppArmor systems: unprivileged user namespaces
# are restricted, causing Chromium to exit with "No usable sandbox"
# even for non-root users running under systemd or containers.
if "AGENT_BROWSER_CHROME_FLAGS" not in browser_env:
# Honour either the legacy AGENT_BROWSER_CHROME_FLAGS (never consumed by
# agent-browser itself, but documented in older notes) or the real
# AGENT_BROWSER_ARGS — if the user pre-sets either, don't overwrite it.
if (
"AGENT_BROWSER_ARGS" not in browser_env
and "AGENT_BROWSER_CHROME_FLAGS" not in browser_env
):
_needs_sandbox_bypass = False
if hasattr(os, "geteuid") and os.geteuid() == 0:
_needs_sandbox_bypass = True
@@ -1892,8 +1898,8 @@ def _run_browser_command(
except OSError:
pass
if _needs_sandbox_bypass:
browser_env["AGENT_BROWSER_CHROME_FLAGS"] = (
"--no-sandbox --disable-dev-shm-usage"
browser_env["AGENT_BROWSER_ARGS"] = (
"--no-sandbox,--disable-dev-shm-usage"
)
# Use temp files for stdout/stderr instead of pipes.
@@ -3381,8 +3387,8 @@ def _chromium_installed() -> bool:
1. ``AGENT_BROWSER_EXECUTABLE_PATH`` env var — the official way to point
agent-browser at a pre-installed Chrome/Chromium.
2. System Chrome/Chromium in PATH (``google-chrome``, ``chromium-browser``,
``chrome``).
2. System Chrome/Chromium in PATH (``google-chrome``, ``chromium``,
``chromium-browser``, ``chrome``).
3. Playwright's browser cache (current logic) — directories containing
``chromium-*`` or ``chromium_headless_shell-*``.
@@ -3405,7 +3411,12 @@ def _chromium_installed() -> bool:
return True
# 2. System Chrome/Chromium in PATH (common names)
system_chrome = shutil.which("google-chrome") or shutil.which("chromium-browser") or shutil.which("chrome")
system_chrome = (
shutil.which("google-chrome")
or shutil.which("chromium")
or shutil.which("chromium-browser")
or shutil.which("chrome")
)
if system_chrome:
_cached_chromium_installed = True
return True
-1
View File
@@ -1,6 +1,5 @@
"""Configurable budget constants for tool result persistence.
Overridable at the RL environment level via HermesAgentEnvConfig fields.
Per-tool resolution: pinned > config overrides > registry > default.
"""
+36 -7
View File
@@ -21,12 +21,14 @@ logger = logging.getLogger(__name__)
sys.path.insert(0, str(Path(__file__).parent.parent))
from cron.jobs import (
AmbiguousJobReference,
create_job,
get_job,
list_jobs,
parse_schedule,
pause_job,
remove_job,
resolve_job_ref,
resume_job,
trigger_job,
update_job,
@@ -393,12 +395,32 @@ def cronjob(
if not job_id:
return tool_error(f"job_id is required for action '{normalized}'", success=False)
job = get_job(job_id)
if not job:
try:
job = resolve_job_ref(job_id)
except AmbiguousJobReference as exc:
return json.dumps(
{"success": False, "error": f"Job with ID '{job_id}' not found. Use cronjob(action='list') to inspect jobs."},
{
"success": False,
"error": str(exc),
"matches": [
{
"id": m["id"],
"name": m.get("name"),
"schedule": m.get("schedule_display"),
"next_run_at": m.get("next_run_at"),
}
for m in exc.matches
],
},
indent=2,
)
if not job:
return json.dumps(
{"success": False, "error": f"Job with ID or name '{job_id}' not found. Use cronjob(action='list') to inspect jobs."},
indent=2,
)
# Resolve to canonical ID (supports name-based lookup)
job_id = job["id"]
if normalized == "remove":
removed = remove_job(job_id)
@@ -647,11 +669,18 @@ def check_cronjob_requirements() -> bool:
Available in interactive CLI mode and gateway/messaging platforms.
The cron system is internal (JSON file-based scheduler ticked by the gateway),
so no external crontab executable is required.
Session env vars must hold an explicit truthy string (``1``, ``true``,
``yes``, ``on``) — false-like values (``0``, ``false``, ``no``, ``off``)
leave the tool disabled. Uses the shared ``env_var_enabled`` helper so
every consumer of these flags agrees on the truthy set.
"""
return bool(
os.getenv("HERMES_INTERACTIVE")
or os.getenv("HERMES_GATEWAY_SESSION")
or os.getenv("HERMES_EXEC_ASK")
from utils import env_var_enabled
return (
env_var_enabled("HERMES_INTERACTIVE")
or env_var_enabled("HERMES_GATEWAY_SESSION")
or env_var_enabled("HERMES_EXEC_ASK")
)
+12 -1
View File
@@ -1017,7 +1017,18 @@ def _build_child_agent(
effective_provider = override_provider or getattr(parent_agent, "provider", None)
effective_base_url = override_base_url or parent_agent.base_url
effective_api_key = override_api_key or parent_api_key
effective_api_mode = override_api_mode or getattr(parent_agent, "api_mode", None)
# Bug #20558 / PR #20563: api_mode must NOT be inherited when the child uses a
# different provider than the parent — each provider has its own API surface
# (e.g. MiniMax uses anthropic_messages, DeepSeek uses chat_completions).
# Inheriting the parent's mode causes 404 errors when the child routes to the
# wrong endpoint. Derive the mode from the target provider when it differs.
_parent_provider = getattr(parent_agent, "provider", None) or ""
if override_api_mode is not None:
effective_api_mode = override_api_mode
elif effective_provider != _parent_provider:
effective_api_mode = None # force re-derivation from provider's defaults
else:
effective_api_mode = getattr(parent_agent, "api_mode", None)
effective_acp_command = override_acp_command or getattr(
parent_agent, "acp_command", None
)
+77 -15
View File
@@ -909,19 +909,29 @@ class ShellFileOperations(FileOperations):
if _is_write_denied(path):
return WriteResult(error=f"Write denied: '{path}' is a protected system/credential file.")
# Capture pre-write content for lint-delta computation. Only do this
# when an in-process OR shell linter exists for this extension — no
# point paying for the read otherwise. For in-process linters we
# pass the content directly; for shell linters the pre-state isn't
# useful (we'd have to re-write-read to lint the old version, which
# defeats the purpose), so we skip the capture and accept the naive
# "all errors" report.
# Capture pre-write content. Two consumers want it:
#
# 1. The lint-delta layer (for in-process linters like ast.parse
# and json.loads) needs the previous content to compute the
# set of NEW lint errors introduced by this write.
# 2. The LSP layer needs pre/post content to build a line-shift
# map — pre-existing diagnostics below the edit point shift
# when lines are added/removed, and the shift map remaps
# baseline diagnostics into post-edit coordinates so the
# strict (range-aware) delta key matches.
#
# The set of extensions we capture pre_content for is therefore
# the UNION of in-process lint coverage and LSP coverage. For
# extensions outside both sets (binaries, opaque formats),
# skipping the read keeps the hot path fast.
ext = os.path.splitext(path)[1].lower()
pre_content: Optional[str] = None
if ext in LINTERS_INPROC:
want_pre = ext in LINTERS_INPROC or self._lsp_handles_extension(ext)
if want_pre:
# Best-effort read; failure (file missing, permission) leaves
# pre_content as None which makes the delta step degrade
# gracefully to "report all errors".
# pre_content as None which makes both downstream consumers
# degrade gracefully (lint reports all errors; LSP skips the
# shift map).
read_cmd = f"cat {self._escape_shell_arg(path)} 2>/dev/null"
read_result = self._exec(read_cmd)
if read_result.exit_code == 0 and read_result.stdout:
@@ -966,11 +976,15 @@ class ShellFileOperations(FileOperations):
# Semantic diagnostics from the LSP layer — separate channel.
# Only fired when the syntax tier reported clean (no point asking
# an LSP for a file that won't even parse). Best-effort:
# ``""`` is returned for any failure path.
# an LSP for a file that won't even parse). Pass pre/post
# content so the LSP layer can build a line-shift map and
# remap baseline diagnostics into post-edit coordinates.
# Best-effort: ``""`` is returned for any failure path.
lsp_diagnostics: Optional[str] = None
if lint_result.success or lint_result.skipped:
block = self._maybe_lsp_diagnostics(path)
block = self._maybe_lsp_diagnostics(
path, pre_content=pre_content, post_content=content
)
if block:
lsp_diagnostics = block
@@ -1295,6 +1309,29 @@ class ShellFileOperations(FileOperations):
return False
return isinstance(env, LocalEnvironment)
def _lsp_handles_extension(self, ext: str) -> bool:
"""Return True iff some registered LSP server claims this extension.
Used to decide whether to capture pre-write content for the
line-shift map. Capturing is cheap (one ``cat`` on the host)
but pointless if no LSP would ever look at the file.
Safe to call on remote backends — the registry is purely
in-process metadata; we still gate the actual LSP path on
:meth:`_lsp_local_only`.
"""
if not ext:
return False
try:
from agent.lsp.servers import SERVERS
except Exception: # noqa: BLE001
return False
ext_lower = ext.lower()
for srv in SERVERS:
if ext_lower in srv.extensions:
return True
return False
def _snapshot_lsp_baseline(self, path: str) -> None:
"""Capture pre-edit LSP diagnostics so the post-write delta is correct.
@@ -1318,12 +1355,25 @@ class ShellFileOperations(FileOperations):
except Exception: # noqa: BLE001
pass
def _maybe_lsp_diagnostics(self, path: str) -> str:
def _maybe_lsp_diagnostics(
self,
path: str,
*,
pre_content: Optional[str] = None,
post_content: Optional[str] = None,
) -> str:
"""Best-effort LSP semantic diagnostics for ``path``.
Returns a formatted ``<diagnostics>`` block, or empty string
when LSP is unavailable / disabled / produced no errors.
When both ``pre_content`` and ``post_content`` are provided,
a line-shift map is built and passed to the LSPService so
baseline diagnostics are remapped into post-edit coordinates
before the set-difference. Without this, edits that delete
or insert lines surface every pre-existing diagnostic below
the edit point as "introduced by this edit".
Wraps everything in a try/except so a misbehaving LSP server
can't break a write. This intentionally swallows all errors
— the calling tier already returned a clean syntax result, so
@@ -1344,8 +1394,20 @@ class ShellFileOperations(FileOperations):
return ""
if svc is None or not svc.enabled_for(path):
return ""
# Build a line-shift map when we have both pre and post — it
# remaps baseline diagnostics into post-edit coordinates so
# the strict (range-aware) delta key matches correctly.
line_shift = None
if pre_content is not None and post_content is not None and pre_content != post_content:
try:
from agent.lsp.range_shift import build_line_shift
line_shift = build_line_shift(pre_content, post_content)
except Exception: # noqa: BLE001
line_shift = None
try:
diagnostics = svc.get_diagnostics_sync(path, delta=True)
diagnostics = svc.get_diagnostics_sync(path, delta=True, line_shift=line_shift)
except Exception: # noqa: BLE001
return ""
if not diagnostics:
+37 -4
View File
@@ -698,10 +698,7 @@ def image_generate_tool(
raise ValueError("Prompt is required and must be a non-empty string")
if not (fal_key_is_configured() or _resolve_managed_fal_gateway()):
message = "FAL_KEY environment variable not set"
if managed_nous_tools_enabled():
message += " and managed FAL gateway is unavailable"
raise ValueError(message)
raise ValueError(_build_no_backend_setup_message())
aspect_lc = (aspect_ratio or DEFAULT_ASPECT_RATIO).lower().strip()
if aspect_lc not in VALID_ASPECT_RATIOS:
@@ -811,6 +808,42 @@ def check_fal_api_key() -> bool:
return bool(fal_key_is_configured() or _resolve_managed_fal_gateway())
def _build_no_backend_setup_message() -> str:
"""Build an actionable error string when no FAL backend is reachable.
Used by the in-tree FAL path. Mentions:
- FAL_KEY signup link
- managed-gateway status (if Nous tools are enabled)
- plugin alternative pointer (so users on a stale ``image_gen.provider``
know the registry exists and how to inspect it)
"""
lines = ["Image generation is unavailable in this environment.", ""]
lines.append("Missing requirements:")
if managed_nous_tools_enabled():
lines.append(
" - FAL_KEY is not set and the managed FAL gateway is unreachable"
)
else:
lines.append(" - FAL_KEY environment variable is not set")
lines.append("")
lines.append("To enable image generation, do one of:")
lines.append(
" 1. Get a free API key at https://fal.ai and set "
"FAL_KEY=<your-key> (then restart the session)"
)
if managed_nous_tools_enabled():
lines.append(
" 2. Sign in to a Nous account that has the managed FAL "
"gateway enabled (`hermes setup`)"
)
lines.append(
" 3. Configure a different image_gen provider via `hermes tools` "
"→ Image Generation (run `hermes plugins list` to see installed "
"backends)"
)
return "\n".join(lines)
def check_image_generation_requirements() -> bool:
"""True if any image gen backend is available.
+174 -7
View File
@@ -59,7 +59,7 @@ import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
@@ -116,10 +116,16 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
# ─── Messaging platforms (lazy-installable on demand) ──────────────────
"platform.telegram": ("python-telegram-bot[webhooks]==22.6",),
"platform.discord": ("discord.py[voice]==2.7.1",),
# brotlicffi gives aiohttp a working 2-arg Decompressor.process() for
# Discord CDN's Brotli-encoded attachments. Without it, aiohttp falls
# back to google's `Brotli` package (1-arg API), and any .txt/.md/.doc
# uploaded to the Discord gateway fails to decode at att.read() with
# "Can not decode content-encoding: br" — see #12511 / #15744.
"platform.discord": ("discord.py[voice]==2.7.1", "brotlicffi==1.2.0.1"),
"platform.slack": (
"slack-bolt==1.27.0",
"slack-sdk==3.40.1",
"aiohttp==3.13.3",
),
"platform.matrix": (
"mautrix[encryption]==0.21.0",
@@ -247,12 +253,69 @@ def _pkg_name_from_spec(spec: str) -> str:
return m.group(1) if m else spec
def _is_satisfied(spec: str) -> bool:
"""Best-effort check: is ``spec`` already satisfied in the current env?
def _specifier_from_spec(spec: str) -> str:
"""Extract just the version-specifier portion of a pip spec.
We don't enforce the version range — if the package is importable
we assume the user knows what they're doing. This matches how the
lazy-import sites already behave.
``"honcho-ai==2.0.1"`` ``"==2.0.1"``
``"mautrix[encryption]>=0.20,<1"`` ``">=0.20,<1"``
``"package"`` ``""`` (no version constraint)
"""
# Strip the package name + optional [extras] block.
m = re.match(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*(?:\[[A-Za-z0-9_,\-]+\])?", spec)
if not m:
return ""
return spec[m.end():]
def _is_satisfied(spec: str) -> bool:
"""Is ``spec`` already satisfied in the current env?
Checks both presence AND version. If the package is installed at a
version outside the spec's range, returns False so the caller will
upgrade/downgrade to the pinned version. This is what makes
``hermes update`` propagate pin bumps in :data:`LAZY_DEPS` to already-
installed backends instead of silently leaving stale versions in place.
If ``packaging`` is unavailable for any reason (it's a transitive of
pip so this should never happen), we fall back to a presence-only check
so we err on the side of "don't churn".
"""
pkg = _pkg_name_from_spec(spec)
try:
from importlib.metadata import PackageNotFoundError, version
except ImportError:
return False
try:
installed = version(pkg)
except PackageNotFoundError:
return False
except Exception:
return False
spec_tail = _specifier_from_spec(spec)
if not spec_tail:
# Bare ``"package"`` — no version constraint, presence is enough.
return True
try:
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
except ImportError:
# packaging unavailable — fall back to "installed counts as satisfied".
return True
try:
return Version(installed) in SpecifierSet(spec_tail)
except (InvalidSpecifier, InvalidVersion, Exception):
# Malformed spec or installed version we can't parse — don't churn.
return True
def _is_present(spec: str) -> bool:
"""Cheap presence-only check (package name installed at any version).
Used by :func:`active_features` to detect backends the user has
previously activated, regardless of whether the version pin moved.
"""
pkg = _pkg_name_from_spec(spec)
try:
@@ -439,3 +502,107 @@ def feature_install_command(feature: str) -> Optional[str]:
return None
specs = LAZY_DEPS[feature]
return "uv pip install " + " ".join(repr(s) for s in specs)
def active_features() -> list[str]:
"""Return the list of features the user has ever lazy-installed.
A feature counts as "active" if at least one of its declared packages
is currently installed in the venv (presence check, ignoring version).
Features the user has never enabled stay quiet.
Used by ``hermes update`` to figure out which lazy backends need a
refresh pass when pins move in :data:`LAZY_DEPS`.
"""
active = []
for feature, specs in LAZY_DEPS.items():
if any(_is_present(s) for s in specs):
active.append(feature)
return active
def refresh_active_features(*, prompt: bool = False) -> dict[str, str]:
"""Re-run ``ensure`` for every feature the user has previously activated.
Returns a ``{feature: status}`` map where status is one of:
``"current"`` pins already satisfied, no install run
``"refreshed"`` pins were stale, reinstall succeeded
``"failed: <reason>"`` install attempt failed; caller decides
whether to surface it (we don't raise)
``"skipped: <reason>"`` gated off (config flag, user decline)
Intended for ``hermes update``. Never raises; lazy-install failures
here must not block the rest of the update flow.
"""
results: dict[str, str] = {}
for feature in active_features():
missing = feature_missing(feature)
if not missing:
results[feature] = "current"
continue
try:
ensure(feature, prompt=prompt)
results[feature] = "refreshed"
except FeatureUnavailable as e:
# Distinguish "user opted out" from "install failed" so the
# update command can render the right message.
if "lazy installs disabled" in str(e) or "declined" in str(e):
results[feature] = f"skipped: {e.reason}"
else:
results[feature] = f"failed: {e.reason}"
except Exception as e:
results[feature] = f"failed: {e}"
return results
def ensure_and_bind(
feature: str,
importer: Callable[[], dict[str, Any]],
target_globals: dict,
*,
prompt: bool = False,
) -> bool:
"""Ensure a feature is installed, then rebind names into the caller's globals.
Combines :func:`ensure` with a post-install import step that rebinds
module-level names. This eliminates the error-prone pattern of manually
listing every global that needs updating after lazy-install.
``importer`` is a zero-arg callable that returns a dict of
``{name: value}`` for all symbols the caller needs rebound. It is called
only after :func:`ensure` succeeds (or if the packages are already
installed).
Returns True on success, False if deps couldn't be installed or imported.
Example usage in a platform adapter::
def check_slack_requirements() -> bool:
if SLACK_AVAILABLE:
return True
def _import():
from slack_bolt.async_app import AsyncApp
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
from slack_sdk.web.async_client import AsyncWebClient
import aiohttp
return {
"AsyncApp": AsyncApp,
"AsyncSocketModeHandler": AsyncSocketModeHandler,
"AsyncWebClient": AsyncWebClient,
"aiohttp": aiohttp,
"SLACK_AVAILABLE": True,
}
return ensure_and_bind("platform.slack", _import, globals(), prompt=False)
"""
try:
ensure(feature, prompt=prompt)
except (FeatureUnavailable, Exception):
return False
try:
bindings = importer()
except ImportError:
return False
target_globals.update(bindings)
return True
+16 -1
View File
@@ -279,6 +279,11 @@ _CREDENTIAL_PATTERN = re.compile(
re.IGNORECASE,
)
# Pre-compiled pattern for ${VAR_NAME} style env-var interpolation.
# Supports any non-} characters in the variable name (hyphens, dots, etc.)
# so providers like MY-VAR or my.var work correctly.
_ENV_VAR_PATTERN = re.compile(r"\$\{([^}]+)\}")
# ---------------------------------------------------------------------------
# Security helpers
@@ -1499,6 +1504,16 @@ class MCPServerTask:
# should not permanently kill the server.
# (Ported from Kilo Code's MCP resilience fix.)
if not self._ready.is_set():
if _is_auth_error(exc):
logger.warning(
"MCP server '%s' failed initial OAuth authentication, "
"not retrying automatically: %s",
self.name, exc,
)
self._error = exc
self._ready.set()
return
initial_retries += 1
if initial_retries > _MAX_INITIAL_CONNECT_RETRIES:
logger.warning(
@@ -2094,7 +2109,7 @@ def _interpolate_env_vars(value):
if isinstance(value, str):
def _replace(m):
return os.environ.get(m.group(1), m.group(0))
return re.sub(r"\$\{([^}]+)\}", _replace, value)
return _ENV_VAR_PATTERN.sub(_replace, value)
if isinstance(value, dict):
return {k: _interpolate_env_vars(v) for k, v in value.items()}
if isinstance(value, list):
+58
View File
@@ -827,6 +827,26 @@ class ProcessRegistry:
"""Check if a completion notification was already consumed via wait/poll/log."""
return session_id in self._completion_consumed
def drain_notifications(self) -> "list[tuple[dict, str]]":
"""Pop all pending notification events and return formatted pairs.
Returns a list of (raw_event, formatted_text) tuples.
Skips completion events that were already consumed via wait/poll/log.
"""
results = []
while not self.completion_queue.empty():
try:
evt = self.completion_queue.get_nowait()
except Exception:
break
_evt_sid = evt.get("session_id", "")
if evt.get("type") == "completion" and self.is_completion_consumed(_evt_sid):
continue
text = format_process_notification(evt)
if text:
results.append((evt, text))
return results
def get(self, session_id: str) -> Optional[ProcessSession]:
"""Get a session by ID (running or finished)."""
with self._lock:
@@ -1389,6 +1409,44 @@ class ProcessRegistry:
process_registry = ProcessRegistry()
def format_process_notification(evt: dict) -> "str | None":
"""Format a process notification event into a [IMPORTANT: ...] message.
Handles completion events (notify_on_complete), watch pattern matches,
and watch disabled events from the unified completion_queue.
"""
evt_type = evt.get("type", "completion")
_sid = evt.get("session_id", "unknown")
_cmd = evt.get("command", "unknown")
if evt_type == "watch_disabled":
return f"[IMPORTANT: {evt.get('message', '')}]"
if evt_type == "watch_match":
_pat = evt.get("pattern", "?")
_out = evt.get("output", "")
_sup = evt.get("suppressed", 0)
text = (
f"[IMPORTANT: Background process {_sid} matched "
f"watch pattern \"{_pat}\".\n"
f"Command: {_cmd}\n"
f"Matched output:\n{_out}"
)
if _sup:
text += f"\n({_sup} earlier matches were suppressed by rate limit)"
text += "]"
return text
_exit = evt.get("exit_code", "?")
_out = evt.get("output", "")
return (
f"[IMPORTANT: Background process {_sid} completed "
f"(exit code {_exit}).\n"
f"Command: {_cmd}\n"
f"Output:\n{_out}]"
)
# ---------------------------------------------------------------------------
# Registry -- the "process" tool schema + handler
# ---------------------------------------------------------------------------
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -36,7 +36,7 @@ from typing import List, Tuple
# Hardcoded trust configuration
# ---------------------------------------------------------------------------
TRUSTED_REPOS = {"openai/skills", "anthropics/skills"}
TRUSTED_REPOS = {"openai/skills", "anthropics/skills", "huggingface/skills"}
INSTALL_POLICY = {
# safe caution dangerous
+1
View File
@@ -329,6 +329,7 @@ class GitHubSource(SkillSource):
DEFAULT_TAPS = [
{"repo": "openai/skills", "path": "skills/"},
{"repo": "anthropics/skills", "path": "skills/"},
{"repo": "huggingface/skills", "path": "skills/"},
{"repo": "VoltAgent/awesome-agent-skills", "path": "skills/"},
{"repo": "garrytan/gstack", "path": ""},
{"repo": "MiniMax-AI/cli", "path": "skill/"},
+68 -33
View File
@@ -78,6 +78,7 @@ from typing import Dict, Any, List, Optional, Set, Tuple
from tools.registry import registry, tool_error
from hermes_cli.config import cfg_get
from utils import env_var_enabled
logger = logging.getLogger(__name__)
@@ -365,7 +366,7 @@ def _capture_required_environment_variables(
def _is_gateway_surface() -> bool:
if os.getenv("HERMES_GATEWAY_SESSION"):
if env_var_enabled("HERMES_GATEWAY_SESSION"):
return True
from gateway.session_context import get_session_env
return bool(get_session_env("HERMES_SESSION_PLATFORM"))
@@ -956,49 +957,83 @@ def skill_view(
skill_dir = None
skill_md = None
# Search all dirs: local first, then external (first match wins)
# Collision detection: collect ALL candidates across every dir using
# every lookup strategy (direct path, recursive by parent dir name,
# legacy flat <name>.md). If more than one matches, refuse and tell
# the caller — silent shadowing of a local skill by a same-named
# external skill is a real bug class (`/skills` shows one, agent
# loaded the other) so we surface it loudly instead of guessing.
from agent.skill_utils import iter_skill_index_files
candidates: List[Tuple[Optional[Path], Path]] = [] # (skill_dir, skill_md)
seen_md: set = set()
def _record(sd: Optional[Path], smd: Path) -> None:
try:
key = smd.resolve()
except Exception:
key = smd
if key in seen_md:
return
seen_md.add(key)
candidates.append((sd, smd))
for search_dir in all_dirs:
# Try direct path first (e.g., "mlops/axolotl")
# Strategy 1: direct path (e.g., "mlops/axolotl" or bare "axolotl"
# at the top of the dir).
direct_path = search_dir / name
if direct_path.is_dir() and (direct_path / "SKILL.md").exists():
skill_dir = direct_path
skill_md = direct_path / "SKILL.md"
break
_record(direct_path, direct_path / "SKILL.md")
elif direct_path.with_suffix(".md").exists():
skill_md = direct_path.with_suffix(".md")
break
_record(None, direct_path.with_suffix(".md"))
# Strategy 1b: categorized form for plugin namespace fall-through
# (e.g., a "myplugin:explore" name with no plugin registered also
# tries the on-disk path "myplugin/explore").
if local_category_name:
categorized_path = search_dir / local_category_name
if categorized_path.is_dir() and (categorized_path / "SKILL.md").exists():
skill_dir = categorized_path
skill_md = categorized_path / "SKILL.md"
break
_record(categorized_path, categorized_path / "SKILL.md")
elif categorized_path.with_suffix(".md").exists():
skill_md = categorized_path.with_suffix(".md")
break
_record(None, categorized_path.with_suffix(".md"))
# Search by directory name across all dirs
if not skill_md:
for search_dir in all_dirs:
from agent.skill_utils import iter_skill_index_files
# Strategy 2: recursive by directory name (catches nested skills
# like "foundations/runtime/explore-codebase" called by bare name).
for found_skill_md in iter_skill_index_files(search_dir, "SKILL.md"):
if found_skill_md.parent.name == name:
_record(found_skill_md.parent, found_skill_md)
for found_skill_md in iter_skill_index_files(search_dir, "SKILL.md"):
if found_skill_md.parent.name == name:
skill_dir = found_skill_md.parent
skill_md = found_skill_md
break
if skill_md:
break
# Strategy 3: legacy flat <name>.md files anywhere under the dir.
for found_md in search_dir.rglob(f"{name}.md"):
if found_md.name != "SKILL.md":
_record(None, found_md)
# Legacy: flat .md files
if not skill_md:
for search_dir in all_dirs:
for found_md in search_dir.rglob(f"{name}.md"):
if found_md.name != "SKILL.md":
skill_md = found_md
break
if skill_md:
break
if len(candidates) > 1:
paths = [str(smd) for _, smd in candidates]
logging.getLogger(__name__).warning(
"Skill name collision for '%s': %d candidates — %s",
name, len(candidates), "; ".join(paths),
)
return json.dumps(
{
"success": False,
"error": (
f"Ambiguous skill name '{name}': {len(candidates)} skills "
"match across your local skills dir and external_dirs. "
"Refusing to guess — load one explicitly by its categorized path."
),
"matches": paths,
"hint": (
"Pass the full relative path instead of the bare name "
"(e.g., 'category/skill-name'), or rename one of the "
"colliding skills so each name is unique."
),
},
ensure_ascii=False,
)
if candidates:
skill_dir, skill_md = candidates[0]
if not skill_md or not skill_md.exists():
available = [s["name"] for s in _sort_skills(_find_all_skills())[:20]]
+32 -6
View File
@@ -47,6 +47,8 @@ import subprocess
from pathlib import Path
from typing import Optional, Dict, Any, List
from utils import env_var_enabled
logger = logging.getLogger(__name__)
@@ -360,7 +362,7 @@ def _handle_sudo_failure(output: str, env_type: str) -> str:
Returns enhanced output if sudo failed in messaging context, else original.
"""
is_gateway = os.getenv("HERMES_GATEWAY_SESSION")
is_gateway = env_var_enabled("HERMES_GATEWAY_SESSION")
if not is_gateway:
return output
@@ -868,7 +870,7 @@ def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None
if not has_configured_password and not sudo_password and _sudo_nopasswd_works():
return command, None
if not has_configured_password and not sudo_password and os.getenv("HERMES_INTERACTIVE"):
if not has_configured_password and not sudo_password and env_var_enabled("HERMES_INTERACTIVE"):
sudo_password = _prompt_for_sudo_password(timeout_seconds=45)
if sudo_password:
_set_cached_sudo_password(sudo_password)
@@ -1544,9 +1546,29 @@ def _command_requires_pipe_stdin(command: str) -> bool:
)
_SHELL_LEVEL_BACKGROUND_RE = re.compile(r"\b(?:nohup|disown|setsid)\b", re.IGNORECASE)
_SHELL_LEVEL_BACKGROUND_RE = re.compile(
r"(?:^|[;&|]\s*|&&\s*|\|\|\s*|\$\(\s*)(?:nohup|disown|setsid)\b", re.IGNORECASE | re.MULTILINE
)
_INLINE_BACKGROUND_AMP_RE = re.compile(r"\s&\s")
_TRAILING_BACKGROUND_AMP_RE = re.compile(r"\s&\s*(?:#.*)?$")
def _strip_quotes(command: str) -> str:
"""Remove single- and double-quoted content so regex checks don't match inside strings.
This prevents false positives when keywords like 'nohup' or 'setsid' appear
in commit messages, Python -c code, echo arguments, or PR body text.
Also strips backtick-quoted content and heredoc-style inline text.
"""
# Remove single-quoted strings (no escaping inside single quotes in shell)
result = re.sub(r"'[^']*'", "''", command)
# Remove double-quoted strings (handle escaped quotes)
result = re.sub(r'"(?:[^"\\]|\\.)*"', '""', result)
# Remove backtick-quoted strings
result = re.sub(r"`[^`]*`", "``", result)
return result
_LONG_LIVED_FOREGROUND_PATTERNS = (
re.compile(r"\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|watch)\b", re.IGNORECASE),
re.compile(r"\bdocker\s+compose\s+up\b", re.IGNORECASE),
@@ -1579,21 +1601,25 @@ def _foreground_background_guidance(command: str) -> str | None:
if _looks_like_help_or_version_command(command):
return None
if _SHELL_LEVEL_BACKGROUND_RE.search(command):
# Strip quoted content so keywords inside strings/arguments don't trigger
# false positives (e.g., git commit -m "... setsid ...", python3 -c "os.setsid").
unquoted = _strip_quotes(command)
if _SHELL_LEVEL_BACKGROUND_RE.search(unquoted):
return (
"Foreground command uses shell-level background wrappers (nohup/disown/setsid). "
"Use terminal(background=true) so Hermes can track the process, then run "
"readiness checks and tests in separate commands."
)
if _INLINE_BACKGROUND_AMP_RE.search(command) or _TRAILING_BACKGROUND_AMP_RE.search(command):
if _INLINE_BACKGROUND_AMP_RE.search(unquoted) or _TRAILING_BACKGROUND_AMP_RE.search(unquoted):
return (
"Foreground command uses '&' backgrounding. Use terminal(background=true) for long-lived "
"processes, then run health checks and tests in follow-up terminal calls."
)
for pattern in _LONG_LIVED_FOREGROUND_PATTERNS:
if pattern.search(command):
if pattern.search(unquoted):
return (
"This foreground command appears to start a long-lived server/watch process. "
"Run it with background=true, verify readiness (health endpoint/log signal), "
+32 -11
View File
@@ -269,10 +269,12 @@ def _get_provider(stt_config: dict) -> str:
return "none"
if provider == "xai":
if get_env_value("XAI_API_KEY"):
from tools.xai_http import resolve_xai_http_credentials
if resolve_xai_http_credentials().get("api_key"):
return "xai"
logger.warning(
"STT provider 'xai' configured but XAI_API_KEY not set"
"STT provider 'xai' configured but no xAI credentials are available"
)
return "none"
@@ -300,9 +302,14 @@ def _get_provider(stt_config: dict) -> str:
if _HAS_OPENAI and _has_openai_audio_backend():
logger.info("No local STT available, using OpenAI Whisper API")
return "openai"
if get_env_value("XAI_API_KEY"):
logger.info("No local STT available, using xAI Grok STT API")
return "xai"
try:
from tools.xai_http import resolve_xai_http_credentials
if resolve_xai_http_credentials().get("api_key"):
logger.info("No local STT available, using xAI Grok STT API")
return "xai"
except Exception:
pass
if get_env_value("ELEVENLABS_API_KEY"):
logger.info("No local STT available, using ElevenLabs Scribe STT API")
return "elevenlabs"
@@ -519,7 +526,13 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any]
language=shlex.quote(language),
model=shlex.quote(normalized_model),
)
subprocess.run(command, shell=True, check=True, capture_output=True, text=True)
# User-provided templates (env var) may contain shell syntax; auto-detected commands are safe for list mode.
use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip())
if use_shell:
subprocess.run(command, shell=True, check=True, capture_output=True, text=True)
else:
subprocess.run(shlex.split(command), check=True, capture_output=True, text=True)
txt_files = sorted(Path(output_dir).glob("*.txt"))
if not txt_files:
@@ -712,15 +725,23 @@ def _transcribe_xai(file_path: str, model_name: str) -> Dict[str, Any]:
Supports Inverse Text Normalization, diarization, and word-level timestamps.
Requires ``XAI_API_KEY`` environment variable.
"""
api_key = get_env_value("XAI_API_KEY")
from tools.xai_http import resolve_xai_http_credentials
creds = resolve_xai_http_credentials()
api_key = str(creds.get("api_key") or "").strip()
if not api_key:
return {"success": False, "transcript": "", "error": "XAI_API_KEY not set"}
return {
"success": False,
"transcript": "",
"error": "No xAI credentials found. Configure xAI OAuth in `hermes model` or set XAI_API_KEY",
}
stt_config = _load_stt_config()
xai_config = stt_config.get("xai", {})
base_url = str(
xai_config.get("base_url")
or get_env_value("XAI_STT_BASE_URL")
or creds.get("base_url")
or XAI_STT_BASE_URL
).strip().rstrip("/")
language = str(
@@ -971,9 +992,9 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A
"No STT provider available. Install faster-whisper for free local "
f"transcription, configure {LOCAL_STT_COMMAND_ENV} or install a local whisper CLI, "
"set GROQ_API_KEY for free Groq Whisper, set MISTRAL_API_KEY for Mistral "
"Voxtral Transcribe, set XAI_API_KEY for xAI Grok STT, set ELEVENLABS_API_KEY "
"for ElevenLabs Scribe, or set VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY for "
"the OpenAI Whisper API."
"Voxtral Transcribe, configure xAI OAuth or set XAI_API_KEY for xAI Grok STT, "
"set ELEVENLABS_API_KEY for ElevenLabs Scribe, or set VOICE_TOOLS_OPENAI_KEY "
"or OPENAI_API_KEY for the OpenAI Whisper API."
),
}
+105 -46
View File
@@ -9,7 +9,7 @@ Built-in TTS providers:
- MiniMax TTS: High-quality with voice cloning, needs MINIMAX_API_KEY
- Mistral (Voxtral TTS): Multilingual, native Opus, needs MISTRAL_API_KEY
- Google Gemini TTS: Controllable, 30 prebuilt voices, needs GEMINI_API_KEY
- xAI TTS: Grok voices, needs XAI_API_KEY
- xAI TTS: Grok voices, uses xAI Grok OAuth credentials or XAI_API_KEY
- NeuTTS (local, free, no API key): On-device TTS via neutts
- KittenTTS (local, free, no API key): On-device 25MB model
- Piper (local, free, no API key): OHF-Voice/piper1-gpl neural VITS, 44 languages
@@ -159,9 +159,9 @@ DEFAULT_KITTENTTS_VOICE = "Jasper"
DEFAULT_PIPER_VOICE = "en_US-lessac-medium" # balanced size/quality
DEFAULT_OPENAI_VOICE = "alloy"
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
DEFAULT_MINIMAX_MODEL = "speech-01"
DEFAULT_MINIMAX_VOICE_ID = "female-shaonv"
DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.chat/v1/text_to_speech"
DEFAULT_MINIMAX_MODEL = "speech-02-hd"
DEFAULT_MINIMAX_VOICE_ID = "English_expressive_narrator"
DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1/t2a_v2"
DEFAULT_MISTRAL_TTS_MODEL = "voxtral-mini-tts-2603"
DEFAULT_MISTRAL_TTS_VOICE_ID = "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral
DEFAULT_XAI_VOICE_ID = "eve"
@@ -902,9 +902,12 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -
"""
import requests
api_key = (get_env_value("XAI_API_KEY") or "").strip()
from tools.xai_http import resolve_xai_http_credentials
creds = resolve_xai_http_credentials()
api_key = str(creds.get("api_key") or "").strip()
if not api_key:
raise ValueError("XAI_API_KEY not set. Get one at https://console.x.ai/")
raise ValueError("No xAI credentials found. Configure xAI OAuth in `hermes model` or set XAI_API_KEY.")
xai_config = tts_config.get("xai", {})
voice_id = str(xai_config.get("voice_id", DEFAULT_XAI_VOICE_ID)).strip() or DEFAULT_XAI_VOICE_ID
@@ -913,6 +916,7 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -
bit_rate = int(xai_config.get("bit_rate", DEFAULT_XAI_BIT_RATE))
base_url = str(
xai_config.get("base_url")
or creds.get("base_url")
or get_env_value("XAI_BASE_URL")
or DEFAULT_XAI_BASE_URL
).strip().rstrip("/")
@@ -960,11 +964,11 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -
# ===========================================================================
def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
"""
Generate audio using MiniMax TTS API (v1/text_to_speech).
Generate audio using MiniMax TTS API.
The current API (api.minimax.chat/v1/text_to_speech) uses a simple payload
and returns raw audio bytes directly (Content-Type: audio/mpeg), unlike
the deprecated v1/t2a_v2 endpoint which returned JSON with hex-encoded audio.
Supports two endpoints:
- v1/text_to_speech: simple payload, returns raw audio (Content-Type: audio/mpeg)
- v1/t2a_v2: nested voice_setting/audio_setting, returns JSON with hex-encoded audio
Args:
text: Text to convert (max 10,000 characters).
@@ -984,56 +988,106 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
model = mm_config.get("model", DEFAULT_MINIMAX_MODEL)
voice_id = mm_config.get("voice_id", DEFAULT_MINIMAX_VOICE_ID)
base_url = mm_config.get("base_url", DEFAULT_MINIMAX_BASE_URL)
speed = mm_config.get("speed", 1.0)
vol = mm_config.get("vol", 1.0)
pitch = mm_config.get("pitch", 0)
emotion = mm_config.get("emotion", "neutral")
sample_rate = mm_config.get("sample_rate", 32000)
bitrate = mm_config.get("bitrate", 128000)
payload = {
"model": model,
"text": text,
"voice_id": voice_id,
}
# MiniMax accounts scope TTS requests by GroupId. When present, the docs
# show it as a ?GroupId=<id> query param on the t2a_v2 URL. Accept it
# from config or from the MINIMAX_GROUP_ID env var; only attach when the
# URL doesn't already carry one.
group_id = (
str(mm_config.get("group_id") or "").strip()
or (get_env_value("MINIMAX_GROUP_ID") or "").strip()
)
if group_id and "GroupId=" not in base_url:
sep = "&" if "?" in base_url else "?"
base_url = f"{base_url}{sep}GroupId={group_id}"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
# Detect endpoint from URL
is_t2a_v2 = "t2a_v2" in base_url
if is_t2a_v2:
# t2a_v2 endpoint: nested voice_setting/audio_setting structure
payload = {
"model": model,
"text": text,
"voice_setting": {
"voice_id": voice_id,
"speed": speed,
"vol": vol,
"pitch": pitch,
"emotion": emotion,
},
"audio_setting": {
"sample_rate": sample_rate,
"bitrate": bitrate,
"format": "mp3",
"channel": 1,
},
}
else:
# text_to_speech endpoint: flat payload
payload = {
"model": model,
"text": text,
"voice_id": voice_id,
}
response = requests.post(base_url, json=payload, headers=headers, timeout=60)
content_type = response.headers.get("Content-Type", "")
if is_t2a_v2:
# t2a_v2 returns JSON with hex-encoded audio
result = response.json()
base_resp = result.get("base_resp", {})
status_code = base_resp.get("status_code", -1)
if "audio/" in content_type:
# New API: returns raw audio directly
if status_code != 0:
status_msg = base_resp.get("status_msg", "unknown error")
raise RuntimeError(f"MiniMax TTS API error (code {status_code}): {status_msg}")
hex_audio = result.get("data", {}).get("audio", "")
if not hex_audio:
raise RuntimeError("MiniMax TTS returned empty audio data")
audio_bytes = bytes.fromhex(hex_audio)
with open(output_path, "wb") as f:
f.write(response.content)
f.write(audio_bytes)
return output_path
# Legacy / fallback: try parsing as JSON with hex-encoded audio
try:
result = response.json()
except Exception:
response.raise_for_status()
raise RuntimeError(
f"MiniMax TTS returned unexpected Content-Type '{content_type}' "
f"({len(response.content)} bytes)"
)
else:
# text_to_speech returns raw audio directly
content_type = response.headers.get("Content-Type", "")
base_resp = result.get("base_resp", {})
status_code = base_resp.get("status_code", -1)
if "audio/" in content_type:
with open(output_path, "wb") as f:
f.write(response.content)
return output_path
if status_code != 0:
status_msg = base_resp.get("status_msg", "unknown error")
raise RuntimeError(f"MiniMax TTS API error (code {status_code}): {status_msg}")
# Fallback: try parsing as JSON
try:
result = response.json()
base_resp = result.get("base_resp", {})
status_code = base_resp.get("status_code", -1)
if status_code != 0:
status_msg = base_resp.get("status_msg", "unknown error")
raise RuntimeError(f"MiniMax TTS API error (code {status_code}): {status_msg}")
except Exception:
response.raise_for_status()
raise RuntimeError(
f"MiniMax TTS returned unexpected Content-Type '{content_type}' "
f"({len(response.content)} bytes)"
)
hex_audio = result.get("data", {}).get("audio", "")
if not hex_audio:
raise RuntimeError("MiniMax TTS returned empty audio data")
# Legacy: hex-encoded audio
audio_bytes = bytes.fromhex(hex_audio)
with open(output_path, "wb") as f:
f.write(audio_bytes)
return output_path
raise RuntimeError("MiniMax TTS returned no audio data")
# ===========================================================================
@@ -1867,8 +1921,13 @@ def check_tts_requirements() -> bool:
pass
if get_env_value("MINIMAX_API_KEY"):
return True
if get_env_value("XAI_API_KEY"):
return True
try:
from tools.xai_http import resolve_xai_http_credentials
if resolve_xai_http_credentials().get("api_key"):
return True
except Exception:
pass
if get_env_value("GEMINI_API_KEY") or get_env_value("GOOGLE_API_KEY"):
return True
try:
+3
View File
@@ -263,6 +263,9 @@ def is_safe_url(url: str) -> bool:
parsed = urlparse(url)
hostname = (parsed.hostname or "").strip().lower().rstrip(".")
scheme = (parsed.scheme or "").strip().lower()
if scheme not in {"http", "https"}:
logger.warning("Blocked request — unsupported URL scheme: %s", scheme or "<empty>")
return False
if not hostname:
return False
+561
View File
@@ -0,0 +1,561 @@
#!/usr/bin/env python3
"""
Video Generation Tool
=====================
Single ``video_generate`` tool that dispatches to a plugin-registered
video generation provider. Mirrors the ``image_generate`` design:
- ``agent/video_gen_provider.py`` defines the :class:`VideoGenProvider` ABC.
- ``agent/video_gen_registry.py`` holds the active providers (populated by
plugins at import time).
- Each provider lives under ``plugins/video_gen/<name>/``.
The tool itself is intentionally backend-agnostic and ships **no in-tree
provider** turn on a backend by enabling a plugin (``hermes plugins
enable video_gen/<name>``) and selecting it in ``hermes tools`` Video
Generation.
Unified surface
---------------
One tool covers the common cases text-to-video, image-to-video, video
edit, video extend with a compact schema:
prompt text instruction (required for generate/edit)
operation "generate" | "edit" | "extend"
image_url drives image-to-video when operation=generate
video_url source video for edit/extend
reference_image_urls list, up to provider-declared cap
duration seconds (provider clamps)
aspect_ratio "16:9" | "9:16" | "1:1" | ...
resolution "480p" | "540p" | "720p" | "1080p"
negative_prompt optional (Pixverse/Kling style)
audio optional (Veo3/Pixverse pricing tier)
seed optional
model optional, override the active provider's default
Providers ignore parameters they do not support. The tool layer does
**lightweight** validation (type/required-prompt) and lets each provider
do its own clamping inside :meth:`VideoGenProvider.generate` that keeps
the tool surface stable as new providers ship with different capabilities.
"""
from __future__ import annotations
import json
import logging
from typing import Any, Dict, List, Optional
from agent.video_gen_provider import (
COMMON_ASPECT_RATIOS,
COMMON_RESOLUTIONS,
DEFAULT_ASPECT_RATIO,
DEFAULT_RESOLUTION,
error_response,
)
from tools.registry import registry, tool_error
logger = logging.getLogger(__name__)
VIDEO_GENERATE_SCHEMA: Dict[str, Any] = {
"name": "video_generate",
# Placeholder — the real description is built dynamically at
# get_tool_definitions() time so it reflects the active backend's
# actual capabilities (which modalities / resolutions / duration
# ranges the user's currently-selected model supports).
# See _build_dynamic_video_schema() below and the dynamic-tool-schemas
# skill at github/hermes-agent-dev/references/dynamic-tool-schemas.md.
"description": "(rebuilt at get_definitions() time — see _build_dynamic_video_schema)",
"parameters": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": (
"Text instruction describing the desired video, motion, "
"subject, style, camera movement, etc."
),
},
"image_url": {
"type": "string",
"description": (
"Optional public URL of a still image. When provided, "
"the active backend routes to its image-to-video "
"endpoint (animate the image); when omitted, it routes "
"to text-to-video. Pass either a URL the user supplied "
"or a path/URL from the conversation."
),
},
"reference_image_urls": {
"type": "array",
"items": {"type": "string"},
"description": (
"Optional list of reference image URLs (style or "
"character refs). Only supported by some backends; "
"the active backend's description below indicates whether "
"this is honored and what the max is."
),
},
"duration": {
"type": "integer",
"description": (
"Desired video duration in seconds. Providers clamp to "
"their supported range (commonly 4-15s). Omit to use the "
"provider's default."
),
},
"aspect_ratio": {
"type": "string",
"enum": list(COMMON_ASPECT_RATIOS),
"description": (
"Output aspect ratio. Providers clamp to their supported "
"set."
),
"default": DEFAULT_ASPECT_RATIO,
},
"resolution": {
"type": "string",
"enum": list(COMMON_RESOLUTIONS),
"description": (
"Output resolution. Providers clamp to their supported "
"set."
),
"default": DEFAULT_RESOLUTION,
},
"negative_prompt": {
"type": "string",
"description": (
"Optional negative prompt — content to avoid in the "
"output. Supported by Pixverse, Kling, and similar; "
"ignored by providers that do not support it."
),
},
"audio": {
"type": "boolean",
"description": (
"Optional audio generation toggle. Supported by Veo3 and "
"Pixverse (affects pricing tier); ignored elsewhere."
),
},
"seed": {
"type": "integer",
"description": (
"Optional seed for reproducible outputs (provider-"
"dependent)."
),
},
"model": {
"type": "string",
"description": (
"Optional model override. If omitted, the user's "
"configured ``video_gen.model`` (set via `hermes tools` "
"→ Video Generation) is used. Models that the active "
"provider does not know are rejected."
),
},
},
"required": ["prompt"],
},
}
# ---------------------------------------------------------------------------
# Config readers (mirror image_generation_tool.py)
# ---------------------------------------------------------------------------
def _read_video_gen_section() -> Dict[str, Any]:
try:
from hermes_cli.config import load_config
cfg = load_config()
section = cfg.get("video_gen") if isinstance(cfg, dict) else None
return section if isinstance(section, dict) else {}
except Exception as exc:
logger.debug("Could not read video_gen config: %s", exc)
return {}
def _read_configured_video_provider() -> Optional[str]:
value = _read_video_gen_section().get("provider")
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _read_configured_video_model() -> Optional[str]:
value = _read_video_gen_section().get("model")
if isinstance(value, str) and value.strip():
return value.strip()
return None
# ---------------------------------------------------------------------------
# Availability check
# ---------------------------------------------------------------------------
def check_video_generation_requirements() -> bool:
"""Return True when at least one registered provider reports available.
Triggers plugin discovery (idempotent) so user-installed plugins are
visible to the toolset gate.
"""
try:
from agent.video_gen_registry import list_providers
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
for provider in list_providers():
try:
if provider.is_available():
return True
except Exception:
continue
except Exception:
pass
return False
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
def _resolve_active_provider():
"""Return the active provider object or None.
Forces plugin discovery before checking the registry handles cases
where a long-lived session was started before a plugin was installed.
"""
try:
from agent.video_gen_registry import get_active_provider
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
provider = get_active_provider()
if provider is None:
_ensure_plugins_discovered(force=True)
provider = get_active_provider()
return provider
except Exception as exc:
logger.debug("video_gen provider resolution failed: %s", exc)
return None
def _missing_provider_error(configured: Optional[str]) -> str:
if configured:
msg = (
f"video_gen.provider='{configured}' is set but no plugin "
f"registered that name. Run `hermes plugins list` to see "
f"installed video gen backends, or `hermes tools` → Video "
f"Generation to pick one."
)
return json.dumps(error_response(
error=msg, error_type="provider_not_registered",
provider=configured,
))
msg = (
"No video generation backend is configured. Run `hermes tools` → "
"Video Generation to enable one (xAI, FAL, or Google Veo)."
)
return json.dumps(error_response(
error=msg, error_type="no_provider_configured",
))
# ---------------------------------------------------------------------------
# Handler
# ---------------------------------------------------------------------------
def _coerce_int(value: Any) -> Optional[int]:
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _coerce_bool(value: Any) -> Optional[bool]:
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, str):
v = value.strip().lower()
if v in ("true", "1", "yes", "on"):
return True
if v in ("false", "0", "no", "off"):
return False
return None
def _normalize_reference_images(value: Any) -> Optional[List[str]]:
if value is None:
return None
if isinstance(value, str):
value = [value]
if not isinstance(value, (list, tuple)):
return None
out: List[str] = []
for item in value:
if isinstance(item, str) and item.strip():
out.append(item.strip())
return out or None
def _handle_video_generate(args: Dict[str, Any], **_kw: Any) -> str:
prompt = (args.get("prompt") or "").strip()
image_url = (args.get("image_url") or "").strip() or None
reference_image_urls = _normalize_reference_images(args.get("reference_image_urls"))
duration = _coerce_int(args.get("duration"))
aspect_ratio = (args.get("aspect_ratio") or DEFAULT_ASPECT_RATIO).strip() or DEFAULT_ASPECT_RATIO
resolution = (args.get("resolution") or DEFAULT_RESOLUTION).strip() or DEFAULT_RESOLUTION
negative_prompt = (args.get("negative_prompt") or "").strip() or None
audio = _coerce_bool(args.get("audio"))
seed = _coerce_int(args.get("seed"))
model_override = (args.get("model") or "").strip() or None
# Soft validation — providers do their own. Prompt is required by the
# schema; the backend may still accept image-only on its image-to-video
# endpoint but our surface always needs a prompt.
if not prompt:
return tool_error("prompt is required for video generation")
# Resolve the active provider.
configured = _read_configured_video_provider()
provider = _resolve_active_provider()
if provider is None:
return _missing_provider_error(configured)
# Resolve model: explicit arg wins, then config, then provider default.
model = model_override or _read_configured_video_model() or provider.default_model()
kwargs: Dict[str, Any] = {
"model": model,
"image_url": image_url,
"reference_image_urls": reference_image_urls,
"duration": duration,
"aspect_ratio": aspect_ratio,
"resolution": resolution,
"negative_prompt": negative_prompt,
"audio": audio,
"seed": seed,
}
# Drop None entries so providers see clean defaults.
kwargs = {k: v for k, v in kwargs.items() if v is not None}
try:
result = provider.generate(prompt=prompt, **kwargs)
except TypeError as exc:
# A provider that hasn't widened its signature is a bug, not a
# caller error — log and surface a clear contract message.
logger.warning(
"video_gen provider '%s' rejected kwargs (signature too narrow): %s",
getattr(provider, "name", "?"), exc,
)
return json.dumps(error_response(
error=(
f"Provider '{getattr(provider, 'name', '?')}' signature is "
f"out of date with the video_generate schema. Report this "
f"to the plugin author."
),
error_type="provider_contract",
provider=getattr(provider, "name", ""),
model=model or "",
prompt=prompt,
))
except Exception as exc:
logger.warning(
"video_gen provider '%s' raised: %s",
getattr(provider, "name", "?"), exc,
)
return json.dumps(error_response(
error=f"Provider '{getattr(provider, 'name', '?')}' error: {exc}",
error_type="provider_exception",
provider=getattr(provider, "name", ""),
model=model or "",
prompt=prompt,
))
if not isinstance(result, dict):
return json.dumps(error_response(
error="Provider returned a non-dict result",
error_type="provider_contract",
provider=getattr(provider, "name", ""),
model=model or "",
prompt=prompt,
))
return json.dumps(result)
# ---------------------------------------------------------------------------
# Dynamic schema — reflect the active backend's actual capabilities
# ---------------------------------------------------------------------------
#
# Why dynamic: the user's configured backend determines which operations
# (generate/edit/extend), modalities (text / image / refs), aspect ratios,
# resolutions, durations, and audio/negative-prompt flags are real. A model
# that calls video_generate without knowing the active backend wastes a
# turn on something like "fal-ai/veo3.1/image-to-video requires image_url".
# Surfacing the per-model surface in the description means the model
# usually gets the call right on the first try.
#
# Memoization: model_tools.get_tool_definitions() keys its cache on
# config.yaml mtime, so when the user changes provider/model via
# `hermes tools` or `/skills`, the schema rebuilds automatically.
_GENERIC_DESCRIPTION = (
"Generate a video from a text prompt (text-to-video) or animate a "
"still image (image-to-video) using the user's configured video "
"generation backend. Pass `image_url` to animate that image; omit it "
"to generate from text alone. The backend auto-routes to the right "
"endpoint. The backend and model family are user-configured via "
"`hermes tools` → Video Generation; the agent does not pick them. "
"Long-running generations may take 30 seconds to several minutes — "
"the call blocks until the video is ready. Returns either an HTTP "
"URL or an absolute file path in the `video` field; display it with "
"markdown ![description](url-or-path) and the gateway will deliver it."
)
def _format_model_caveats(
model_meta: Dict[str, Any],
backend_caps: Dict[str, Any],
) -> List[str]:
"""Pull human-readable caveats out of one model's catalog metadata.
Only surfaces things that meaningfully differ from the backend's
overall capabilities repeating defaults is noise.
"""
caveats: List[str] = []
modalities = set(model_meta.get("modalities") or [])
modality = model_meta.get("modality") # FAL's plugin uses this key for single-modality entries
if modality:
modalities.add(modality)
if "image" in modalities and "text" not in modalities:
caveats.append(
"this model is image-to-video only — image_url is REQUIRED; "
"text-only calls will be rejected"
)
elif "text" in modalities and "image" not in modalities:
caveats.append(
"this model is text-to-video only — image_url is not supported"
)
return caveats
def _build_dynamic_video_schema() -> Dict[str, Any]:
"""Build a description that reflects the active backend's actual surface.
Cheap: reads config (already memoized by the caller), asks the active
provider for `capabilities()` and the active model's catalog entry,
and formats a few lines of prose. Falls back to the generic
description when no provider is configured or registered.
"""
parts: List[str] = [_GENERIC_DESCRIPTION]
configured = _read_configured_video_provider()
configured_model = _read_configured_video_model()
if not configured:
parts.append(
"\nNo video backend is configured. Calls will return an error "
"until the user picks one via `hermes tools` → Video Generation."
)
return {"description": "\n".join(parts)}
try:
from agent.video_gen_registry import get_provider
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
provider = get_provider(configured)
except Exception:
provider = None
if provider is None:
parts.append(
f"\nActive backend: {configured} (plugin not yet loaded — the "
f"tool will retry discovery on first call)."
)
return {"description": "\n".join(parts)}
try:
caps = provider.capabilities() or {}
except Exception:
caps = {}
try:
models = provider.list_models() or []
except Exception:
models = []
active_model = configured_model or provider.default_model()
model_meta = next(
(m for m in models if isinstance(m, dict) and m.get("id") == active_model),
{},
)
backend_label = provider.display_name
line = f"\nActive backend: {backend_label}"
if active_model:
line += f" · model: {active_model}"
parts.append(line)
# Model-specific caveats (the high-signal stuff)
for c in _format_model_caveats(model_meta, caps):
parts.append(f"- {c}")
# Backend modality summary — only useful when the backend supports
# both text and image. Single-modality backends are already covered by
# the model caveat above.
modalities = set(caps.get("modalities") or [])
if "text" in modalities and "image" in modalities and not model_meta.get("modality"):
parts.append(
"- supports both text-to-video (omit image_url) and "
"image-to-video (pass image_url) — routes automatically"
)
if caps.get("aspect_ratios"):
parts.append(f"- aspect_ratio choices: {', '.join(caps['aspect_ratios'])}")
if caps.get("resolutions"):
parts.append(f"- resolution choices: {', '.join(caps['resolutions'])}")
if caps.get("min_duration") and caps.get("max_duration"):
parts.append(
f"- duration range: {caps['min_duration']}-{caps['max_duration']}s"
)
if caps.get("supports_audio"):
parts.append("- audio: pass `audio=true` to enable native audio (pricing tier)")
if caps.get("supports_negative_prompt"):
parts.append("- negative_prompt: supported")
max_refs = caps.get("max_reference_images") or 0
if max_refs:
parts.append(f"- reference_image_urls: up to {max_refs} images")
return {"description": "\n".join(parts)}
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
registry.register(
name="video_generate",
toolset="video_gen",
schema=VIDEO_GENERATE_SCHEMA,
handler=_handle_video_generate,
check_fn=check_video_generation_requirements,
requires_env=[],
is_async=False,
emoji="🎬",
dynamic_schema_overrides=_build_dynamic_video_schema,
)
-73
View File
@@ -1,73 +0,0 @@
# Web Tools Provider Architecture
## Overview
Web tools (`web_search`, `web_extract`) use a **per-capability backend selection** system that allows different providers for search and extract independently.
## Config Keys
```yaml
web:
backend: "firecrawl" # Shared fallback — applies to both if specific keys not set
search_backend: "" # Per-capability override for web_search
extract_backend: "" # Per-capability override for web_extract
```
**Selection priority (per capability):**
1. `web.search_backend` / `web.extract_backend` (explicit per-capability)
2. `web.backend` (shared fallback)
3. Auto-detect from environment variables
When per-capability keys are empty (default), behavior is identical to the legacy single-backend selection.
## Architecture
```
web_search_tool()
└─ _get_search_backend()
├─ web.search_backend (if set + available)
└─ _get_backend() fallback
web_extract_tool()
└─ _get_extract_backend()
├─ web.extract_backend (if set + available)
└─ _get_backend() fallback
```
## Provider ABCs
New providers implement these interfaces in `tools/web_providers/`:
```python
from tools.web_providers.base import WebSearchProvider, WebExtractProvider
class MySearchProvider(WebSearchProvider):
def provider_name(self) -> str: ...
def is_configured(self) -> bool: ...
def search(self, query: str, limit: int = 5) -> Dict[str, Any]: ...
class MyExtractProvider(WebExtractProvider):
def provider_name(self) -> str: ...
def is_configured(self) -> bool: ...
def extract(self, urls: List[str], **kwargs) -> Dict[str, Any]: ...
```
## Adding a New Search Provider
1. Create `tools/web_providers/your_provider.py` implementing `WebSearchProvider`
2. Add availability check to `_is_backend_available()` in `web_tools.py`
3. Add dispatch branch in `web_search_tool()`
4. Add provider to `hermes tools` picker in `tools_config.py`
5. Add env var to `OPTIONAL_ENV_VARS` in `config.py` (if needed)
6. Write tests in `tests/tools/`
Search-only providers (like SearXNG) don't need to implement `WebExtractProvider`.
Extract-only providers don't need to implement `WebSearchProvider`.
## hermes tools UX
The provider picker uses **progressive disclosure**:
- **Default path** (90% of users): Pick one provider → sets `web.backend` for both. One selection, done.
- **Advanced path**: "Configure separately" option at bottom → two-step sub-picker for search + extract independently.
See `.hermes/plans/2026-05-03-web-tools-provider-architecture.md` for the full UX flow diagram.
-6
View File
@@ -1,6 +0,0 @@
"""Web capability providers — search, extract, crawl.
Each capability has an ABC in ``base.py`` and vendor implementations in
sibling modules. Provider registries in ``web_tools.py`` map config names
to provider classes.
"""
-89
View File
@@ -1,89 +0,0 @@
"""Abstract base classes for web capability providers."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Dict, List
class WebSearchProvider(ABC):
"""Interface for web search backends (Firecrawl, Tavily, Exa, etc.).
Implementations live in sibling modules. The user selects a provider
via ``hermes tools``; the choice is persisted as
``config["web"]["search_backend"]`` (falling back to
``config["web"]["backend"]``).
Search providers return results in a normalized format::
{
"success": True,
"data": {
"web": [
{"title": str, "url": str, "description": str, "position": int},
...
]
}
}
On failure::
{"success": False, "error": str}
"""
@abstractmethod
def provider_name(self) -> str:
"""Short, human-readable name shown in logs and diagnostics."""
@abstractmethod
def is_configured(self) -> bool:
"""Return True when all required env vars / credentials are present.
Called at tool-registration time to gate availability.
Must be cheap no network calls.
"""
@abstractmethod
def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
"""Execute a web search and return normalized results."""
class WebExtractProvider(ABC):
"""Interface for web content extraction backends.
Implementations live in sibling modules. The user selects a provider
via ``hermes tools``; the choice is persisted as
``config["web"]["extract_backend"]`` (falling back to
``config["web"]["backend"]``).
Extract providers return results in a normalized format::
{
"success": True,
"data": [
{"url": str, "title": str, "content": str,
"raw_content": str, "metadata": dict},
...
]
}
On failure::
{"success": False, "error": str}
"""
@abstractmethod
def provider_name(self) -> str:
"""Short, human-readable name shown in logs and diagnostics."""
@abstractmethod
def is_configured(self) -> bool:
"""Return True when all required env vars / credentials are present.
Called at tool-registration time to gate availability.
Must be cheap no network calls.
"""
@abstractmethod
def extract(self, urls: List[str], **kwargs) -> Dict[str, Any]:
"""Extract content from the given URLs and return normalized results."""
-130
View File
@@ -1,130 +0,0 @@
"""Brave Search web search provider (free tier).
Brave Search's Data-for-Search API offers a free tier (2,000 queries/mo at the
time of writing) after signing up at https://brave.com/search/api/. This
provider implements ``WebSearchProvider`` only the Data-for-Search endpoint
returns search results, it does not extract/crawl arbitrary URLs.
Configuration::
# ~/.hermes/.env
BRAVE_SEARCH_API_KEY=your-subscription-token
# ~/.hermes/config.yaml
web:
search_backend: "brave-free"
extract_backend: "firecrawl" # pair with an extract provider if needed
The API uses the ``X-Subscription-Token`` header. Free-tier keys are rate
limited (1 qps) and capped at 2k queries/month; see the Brave dashboard for
current quotas.
"""
from __future__ import annotations
import logging
import os
from typing import Any, Dict
from tools.web_providers.base import WebSearchProvider
logger = logging.getLogger(__name__)
_BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
class BraveFreeSearchProvider(WebSearchProvider):
"""Search via the Brave Search API (free tier).
Requires ``BRAVE_SEARCH_API_KEY`` to be set. The value is passed as the
``X-Subscription-Token`` header. No extract capability pair with
Firecrawl/Tavily/Exa/Parallel when you also need ``web_extract``.
"""
def provider_name(self) -> str:
return "brave-free"
def is_configured(self) -> bool:
"""Return True when ``BRAVE_SEARCH_API_KEY`` is set to a non-empty value."""
return bool(os.getenv("BRAVE_SEARCH_API_KEY", "").strip())
def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
"""Execute a search against the Brave Search API.
Returns normalized results::
{
"success": True,
"data": {
"web": [
{
"title": str,
"url": str,
"description": str,
"position": int,
},
...
]
}
}
On failure returns ``{"success": False, "error": str}``.
"""
import httpx
api_key = os.getenv("BRAVE_SEARCH_API_KEY", "").strip()
if not api_key:
return {"success": False, "error": "BRAVE_SEARCH_API_KEY is not set"}
# Brave's `count` is capped at 20.
count = max(1, min(int(limit), 20))
try:
resp = httpx.get(
_BRAVE_ENDPOINT,
params={"q": query, "count": count},
headers={
"X-Subscription-Token": api_key,
"Accept": "application/json",
},
timeout=15,
)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
logger.warning("Brave Search HTTP error: %s", exc)
return {
"success": False,
"error": f"Brave Search returned HTTP {exc.response.status_code}",
}
except httpx.RequestError as exc:
logger.warning("Brave Search request error: %s", exc)
return {"success": False, "error": f"Could not reach Brave Search: {exc}"}
try:
data = resp.json()
except Exception as exc: # noqa: BLE001
logger.warning("Brave Search response parse error: %s", exc)
return {"success": False, "error": "Could not parse Brave Search response as JSON"}
raw_results = (data.get("web") or {}).get("results", []) or []
truncated = raw_results[:limit]
web_results = [
{
"title": str(r.get("title", "")),
"url": str(r.get("url", "")),
"description": str(r.get("description", "")),
"position": i + 1,
}
for i, r in enumerate(truncated)
]
logger.info(
"Brave Search '%s': %d results (from %d raw, limit %d)",
query,
len(web_results),
len(raw_results),
limit,
)
return {"success": True, "data": {"web": web_results}}
-98
View File
@@ -1,98 +0,0 @@
"""DuckDuckGo web search provider via the ``ddgs`` Python package.
DuckDuckGo does not provide an official programmatic search API. The
community-maintained `ddgs <https://pypi.org/project/ddgs/>`_ package (the
renamed successor of ``duckduckgo-search``) scrapes DuckDuckGo's HTML results
page and normalizes them. It implements ``WebSearchProvider`` only there is
no extract capability.
Configuration::
# No API key required. Enable by installing the package and pointing the
# web backend at ddgs:
pip install ddgs
# ~/.hermes/config.yaml
web:
search_backend: "ddgs"
extract_backend: "firecrawl" # pair with an extract provider if needed
Rate limits are enforced server-side by DuckDuckGo. Expect intermittent
``DuckDuckGoSearchException`` / 202 responses under heavy use; this provider
surfaces them as ``{"success": False, "error": ...}`` rather than crashing
the tool call.
See https://duckduckgo.com/?q=duckduckgo+tos for terms of use.
"""
from __future__ import annotations
import logging
from typing import Any, Dict
from tools.web_providers.base import WebSearchProvider
logger = logging.getLogger(__name__)
class DDGSSearchProvider(WebSearchProvider):
"""Search via the ``ddgs`` package (DuckDuckGo HTML scrape).
No API key required. The provider is considered "configured" when the
``ddgs`` package is importable there is nothing else to set up.
"""
def provider_name(self) -> str:
return "ddgs"
def is_configured(self) -> bool:
"""Return True when the ``ddgs`` package is importable.
Called at tool-registration time; must not perform network I/O.
"""
try:
import ddgs # noqa: F401
return True
except ImportError:
return False
def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
"""Execute a DuckDuckGo search and return normalized results.
Returns ``{"success": True, "data": {"web": [...]}}`` on success or
``{"success": False, "error": str}`` on failure (missing package,
rate-limited, network error, etc.).
"""
try:
from ddgs import DDGS # type: ignore
except ImportError:
return {
"success": False,
"error": "ddgs package is not installed — run `pip install ddgs`",
}
# DDGS().text yields at most `max_results` items; we cap defensively
# in case the package ignores the hint.
safe_limit = max(1, int(limit))
try:
web_results = []
with DDGS() as client:
for i, hit in enumerate(client.text(query, max_results=safe_limit)):
if i >= safe_limit:
break
url = str(hit.get("href") or hit.get("url") or "")
web_results.append(
{
"title": str(hit.get("title", "")),
"url": url,
"description": str(hit.get("body", "")),
"position": i + 1,
}
)
except Exception as exc: # noqa: BLE001 — ddgs raises its own exceptions
logger.warning("DDGS search error: %s", exc)
return {"success": False, "error": f"DuckDuckGo search failed: {exc}"}
logger.info("DDGS search '%s': %d results (limit %d)", query, len(web_results), limit)
return {"success": True, "data": {"web": web_results}}
-132
View File
@@ -1,132 +0,0 @@
"""SearXNG web search provider.
SearXNG is a free, self-hosted, privacy-respecting metasearch engine.
It implements ``WebSearchProvider`` only there is no extract capability.
Configuration::
# ~/.hermes/.env
SEARXNG_URL=http://localhost:8080
# Use SearXNG for search, pair with any extract provider:
# ~/.hermes/config.yaml
web:
search_backend: "searxng"
extract_backend: "firecrawl"
Public SearXNG instances are listed at https://searx.space/ but self-hosting
is recommended for production use (rate limits and availability vary per
public instance).
"""
from __future__ import annotations
import logging
import os
from typing import Any, Dict
from tools.web_providers.base import WebSearchProvider
logger = logging.getLogger(__name__)
class SearXNGSearchProvider(WebSearchProvider):
"""Search via a SearXNG instance.
Requires ``SEARXNG_URL`` to be set (e.g. ``http://localhost:8080``).
No API key needed SearXNG is open-source and self-hosted.
Uses the SearXNG JSON API (``/search?format=json``). Results are
sorted by SearXNG's own score and truncated to *limit*.
"""
def provider_name(self) -> str:
return "searxng"
def is_configured(self) -> bool:
"""Return True when ``SEARXNG_URL`` is set to a non-empty value."""
return bool(os.getenv("SEARXNG_URL", "").strip())
def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
"""Execute a search against the configured SearXNG instance.
Returns normalized results::
{
"success": True,
"data": {
"web": [
{
"title": str,
"url": str,
"description": str,
"position": int,
},
...
]
}
}
On failure returns ``{"success": False, "error": str}``.
"""
import httpx
base_url = os.getenv("SEARXNG_URL", "").strip().rstrip("/")
if not base_url:
return {"success": False, "error": "SEARXNG_URL is not set"}
params: Dict[str, Any] = {
"q": query,
"format": "json",
"pageno": 1,
}
try:
resp = httpx.get(
f"{base_url}/search",
params=params,
timeout=15,
headers={"Accept": "application/json"},
)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
logger.warning("SearXNG HTTP error: %s", exc)
return {"success": False, "error": f"SearXNG returned HTTP {exc.response.status_code}"}
except httpx.RequestError as exc:
logger.warning("SearXNG request error: %s", exc)
return {"success": False, "error": f"Could not reach SearXNG at {base_url}: {exc}"}
try:
data = resp.json()
except Exception as exc: # noqa: BLE001
logger.warning("SearXNG response parse error: %s", exc)
return {"success": False, "error": "Could not parse SearXNG response as JSON"}
raw_results = data.get("results", [])
# SearXNG may return a score field; sort descending and cap to limit.
sorted_results = sorted(
raw_results,
key=lambda r: float(r.get("score", 0)),
reverse=True,
)[:limit]
web_results = [
{
"title": str(r.get("title", "")),
"url": str(r.get("url", "")),
"description": str(r.get("content", "")),
"position": i + 1,
}
for i, r in enumerate(sorted_results)
]
logger.info(
"SearXNG search '%s': %d results (from %d raw, limit %d)",
query,
len(web_results),
len(raw_results),
limit,
)
return {"success": True, "data": {"web": web_results}}
+287 -1046
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -2,6 +2,28 @@
from __future__ import annotations
import os
from typing import Dict
try:
from hermes_cli.config import get_env_value as _hermes_get_env_value
except Exception:
_hermes_get_env_value = None
def get_env_value(name: str, default=None):
"""Read ``name`` from ``~/.hermes/.env`` first, then ``os.environ``.
Wraps :func:`hermes_cli.config.get_env_value` so tests can patch
``tools.xai_http.get_env_value`` to inject dotenv-only secrets into the
xAI credential resolver.
"""
if _hermes_get_env_value is not None:
value = _hermes_get_env_value(name)
if value is not None:
return value
return os.environ.get(name, default)
def hermes_xai_user_agent() -> str:
"""Return a stable Hermes-specific User-Agent for xAI HTTP calls."""
@@ -10,3 +32,52 @@ def hermes_xai_user_agent() -> str:
except Exception:
__version__ = "unknown"
return f"Hermes-Agent/{__version__}"
def resolve_xai_http_credentials() -> Dict[str, str]:
"""Resolve bearer credentials for direct xAI HTTP endpoints.
Prefers Hermes-managed xAI OAuth credentials when available, then falls back
to ``XAI_API_KEY`` resolved via ``hermes_cli.config.get_env_value`` so keys
stored in ``~/.hermes/.env`` (the standard Hermes location) are honored
not just ones already exported into ``os.environ``. This keeps direct xAI
endpoints (images, TTS, STT, etc.) aligned with the main runtime auth model
and preserves the regression contract from PR #17140 / #17163.
"""
try:
from hermes_cli.runtime_provider import resolve_runtime_provider
runtime = resolve_runtime_provider(requested="xai-oauth")
access_token = str(runtime.get("api_key") or "").strip()
base_url = str(runtime.get("base_url") or "").strip().rstrip("/")
if access_token:
return {
"provider": "xai-oauth",
"api_key": access_token,
"base_url": base_url or "https://api.x.ai/v1",
}
except Exception:
pass
try:
from hermes_cli.auth import resolve_xai_oauth_runtime_credentials
creds = resolve_xai_oauth_runtime_credentials()
access_token = str(creds.get("api_key") or "").strip()
base_url = str(creds.get("base_url") or "").strip().rstrip("/")
if access_token:
return {
"provider": "xai-oauth",
"api_key": access_token,
"base_url": base_url or "https://api.x.ai/v1",
}
except Exception:
pass
api_key = str(get_env_value("XAI_API_KEY") or "").strip()
base_url = str(get_env_value("XAI_BASE_URL") or "https://api.x.ai/v1").strip().rstrip("/")
return {
"provider": "xai",
"api_key": api_key,
"base_url": base_url,
}