Merge main into bb/gui.
Resolve merge conflicts while preserving bb/gui dashboard paths and STT provider support.
This commit is contained in:
+9
-9
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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), "
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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  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,
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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."""
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user