Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0fd13c554 | |||
| 82d6c28bd5 | |||
| dc7d504aca | |||
| 9e411f7d70 | |||
| 708f187549 | |||
| d7c41f3cef | |||
| 6893c3befc | |||
| 5cdc24c2e2 | |||
| 2dd286c162 | |||
| 924857c3e3 | |||
| ba3bbf5b53 | |||
| d6b4fa2e9f | |||
| df1bf0a209 | |||
| 49a49983e4 | |||
| e97c0cb578 | |||
| c0aa06f300 | |||
| 3273732891 | |||
| 09ebf8b252 | |||
| 33c89e52ec |
+4
-4
@@ -15,6 +15,8 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from utils import atomic_json_write
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -64,12 +66,10 @@ def _load_disk_cache() -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _save_disk_cache(data: Dict[str, Any]) -> None:
|
||||
"""Save models.dev data to disk cache."""
|
||||
"""Save models.dev data to disk cache atomically."""
|
||||
try:
|
||||
cache_path = _get_cache_path()
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(cache_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
atomic_json_write(cache_path, data, indent=None, separators=(",", ":"))
|
||||
except Exception as e:
|
||||
logger.debug("Failed to save models.dev disk cache: %s", e)
|
||||
|
||||
|
||||
@@ -223,6 +223,23 @@ if AIOHTTP_AVAILABLE:
|
||||
else:
|
||||
body_limit_middleware = None # type: ignore[assignment]
|
||||
|
||||
_SECURITY_HEADERS = {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
}
|
||||
|
||||
|
||||
if AIOHTTP_AVAILABLE:
|
||||
@web.middleware
|
||||
async def security_headers_middleware(request, handler):
|
||||
"""Add security headers to all responses (including errors)."""
|
||||
response = await handler(request)
|
||||
for k, v in _SECURITY_HEADERS.items():
|
||||
response.headers.setdefault(k, v)
|
||||
return response
|
||||
else:
|
||||
security_headers_middleware = None # type: ignore[assignment]
|
||||
|
||||
|
||||
class _IdempotencyCache:
|
||||
"""In-memory idempotency cache with TTL and basic LRU semantics."""
|
||||
@@ -307,6 +324,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
if "*" in self._cors_origins:
|
||||
headers = dict(_CORS_HEADERS)
|
||||
headers["Access-Control-Allow-Origin"] = "*"
|
||||
headers["Access-Control-Max-Age"] = "600"
|
||||
return headers
|
||||
|
||||
if origin not in self._cors_origins:
|
||||
@@ -315,6 +333,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
headers = dict(_CORS_HEADERS)
|
||||
headers["Access-Control-Allow-Origin"] = origin
|
||||
headers["Vary"] = "Origin"
|
||||
headers["Access-Control-Max-Age"] = "600"
|
||||
return headers
|
||||
|
||||
def _origin_allowed(self, origin: str) -> bool:
|
||||
@@ -582,10 +601,14 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
import queue as _q
|
||||
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"},
|
||||
)
|
||||
sse_headers = {"Content-Type": "text/event-stream", "Cache-Control": "no-cache"}
|
||||
# CORS middleware can't inject headers into StreamResponse after
|
||||
# prepare() flushes them, so resolve CORS headers up front.
|
||||
origin = request.headers.get("Origin", "")
|
||||
cors = self._cors_headers_for_origin(origin) if origin else None
|
||||
if cors:
|
||||
sse_headers.update(cors)
|
||||
response = web.StreamResponse(status=200, headers=sse_headers)
|
||||
await response.prepare(request)
|
||||
|
||||
try:
|
||||
@@ -1218,10 +1241,11 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
return False
|
||||
|
||||
try:
|
||||
mws = [mw for mw in (cors_middleware, body_limit_middleware) if mw is not None]
|
||||
mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None]
|
||||
self._app = web.Application(middlewares=mws)
|
||||
self._app["api_server_adapter"] = self
|
||||
self._app.router.add_get("/health", self._handle_health)
|
||||
self._app.router.add_get("/v1/health", self._handle_health)
|
||||
self._app.router.add_get("/v1/models", self._handle_models)
|
||||
self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
|
||||
self._app.router.add_post("/v1/responses", self._handle_responses)
|
||||
|
||||
@@ -333,7 +333,10 @@ class MessageEvent:
|
||||
return None
|
||||
# Split on space and get first word, strip the /
|
||||
parts = self.text.split(maxsplit=1)
|
||||
return parts[0][1:].lower() if parts else None
|
||||
raw = parts[0][1:].lower() if parts else None
|
||||
if raw and "@" in raw:
|
||||
raw = raw.split("@", 1)[0]
|
||||
return raw
|
||||
|
||||
def get_command_args(self) -> str:
|
||||
"""Get the arguments after a command."""
|
||||
|
||||
@@ -40,7 +40,9 @@ logger = logging.getLogger(__name__)
|
||||
MAX_MESSAGE_LENGTH = 4000
|
||||
|
||||
# Store directory for E2EE keys and sync state.
|
||||
_STORE_DIR = Path.home() / ".hermes" / "matrix" / "store"
|
||||
# Uses get_hermes_home() so each profile gets its own Matrix store.
|
||||
from hermes_constants import get_hermes_home as _get_hermes_home
|
||||
_STORE_DIR = _get_hermes_home() / "matrix" / "store"
|
||||
|
||||
# Grace period: ignore messages older than this many seconds before startup.
|
||||
_STARTUP_GRACE_SECONDS = 5
|
||||
|
||||
@@ -345,7 +345,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int) -> None:
|
||||
"""Save a newly created thread_id back into config.yaml so it persists across restarts."""
|
||||
try:
|
||||
config_path = _Path.home() / ".hermes" / "config.yaml"
|
||||
from hermes_constants import get_hermes_home
|
||||
config_path = get_hermes_home() / "config.yaml"
|
||||
if not config_path.exists():
|
||||
logger.warning("[%s] Config file not found at %s, cannot persist thread_id", self.name, config_path)
|
||||
return
|
||||
@@ -1757,7 +1758,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
recognized without a gateway restart.
|
||||
"""
|
||||
try:
|
||||
config_path = _Path.home() / ".hermes" / "config.yaml"
|
||||
from hermes_constants import get_hermes_home
|
||||
config_path = get_hermes_home() / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from typing import Iterable, Optional
|
||||
|
||||
@@ -43,6 +44,14 @@ _DOH_PROVIDERS: list[dict] = [
|
||||
_SEED_FALLBACK_IPS: list[str] = ["149.154.167.220"]
|
||||
|
||||
|
||||
def _resolve_proxy_url() -> str | None:
|
||||
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy"):
|
||||
value = (os.environ.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
class TelegramFallbackTransport(httpx.AsyncBaseTransport):
|
||||
"""Retry Telegram Bot API requests via fallback IPs while preserving TLS/SNI.
|
||||
|
||||
@@ -54,6 +63,9 @@ class TelegramFallbackTransport(httpx.AsyncBaseTransport):
|
||||
|
||||
def __init__(self, fallback_ips: Iterable[str], **transport_kwargs):
|
||||
self._fallback_ips = [ip for ip in dict.fromkeys(_normalize_fallback_ips(fallback_ips))]
|
||||
proxy_url = _resolve_proxy_url()
|
||||
if proxy_url and "proxy" not in transport_kwargs:
|
||||
transport_kwargs["proxy"] = proxy_url
|
||||
self._primary = httpx.AsyncHTTPTransport(**transport_kwargs)
|
||||
self._fallbacks = {
|
||||
ip: httpx.AsyncHTTPTransport(**transport_kwargs) for ip in self._fallback_ips
|
||||
|
||||
@@ -526,6 +526,7 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
image_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> SendResult:
|
||||
"""Send a local image file natively via bridge."""
|
||||
return await self._send_media_to_bridge(chat_id, image_path, "image", caption)
|
||||
@@ -536,6 +537,7 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
video_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> SendResult:
|
||||
"""Send a video natively via bridge — plays inline in WhatsApp."""
|
||||
return await self._send_media_to_bridge(chat_id, video_path, "video", caption)
|
||||
@@ -547,6 +549,7 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
caption: Optional[str] = None,
|
||||
file_name: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> SendResult:
|
||||
"""Send a document/file as a downloadable attachment via bridge."""
|
||||
return await self._send_media_to_bridge(
|
||||
|
||||
+16
-4
@@ -745,10 +745,22 @@ class GatewayRunner:
|
||||
logger.error("No connected messaging platforms remain. Shutting down gateway cleanly.")
|
||||
await self.stop()
|
||||
elif not self.adapters and self._failed_platforms:
|
||||
logger.warning(
|
||||
"No connected messaging platforms remain, but %d platform(s) queued for reconnection",
|
||||
len(self._failed_platforms),
|
||||
)
|
||||
# All platforms are down and queued for background reconnection.
|
||||
# If the error is retryable, exit with failure so systemd Restart=on-failure
|
||||
# can restart the process. Otherwise stay alive and keep retrying in background.
|
||||
if adapter.fatal_error_retryable:
|
||||
self._exit_reason = adapter.fatal_error_message or "All messaging platforms failed with retryable errors"
|
||||
self._exit_with_failure = True
|
||||
logger.error(
|
||||
"All messaging platforms failed with retryable errors. "
|
||||
"Shutting down gateway for service restart (systemd will retry)."
|
||||
)
|
||||
await self.stop()
|
||||
else:
|
||||
logger.warning(
|
||||
"No connected messaging platforms remain, but %d platform(s) queued for reconnection",
|
||||
len(self._failed_platforms),
|
||||
)
|
||||
|
||||
def _request_clean_exit(self, reason: str) -> None:
|
||||
self._exit_cleanly = True
|
||||
|
||||
+87
-16
@@ -125,20 +125,43 @@ _SERVICE_BASE = "hermes-gateway"
|
||||
SERVICE_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration"
|
||||
|
||||
|
||||
def _profile_suffix() -> str:
|
||||
"""Derive a service-name suffix from the current HERMES_HOME.
|
||||
|
||||
Returns ``""`` for the default ``~/.hermes``, the profile name for
|
||||
``~/.hermes/profiles/<name>``, or a short hash for any other custom
|
||||
HERMES_HOME path.
|
||||
"""
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path as _Path
|
||||
home = get_hermes_home().resolve()
|
||||
default = (_Path.home() / ".hermes").resolve()
|
||||
if home == default:
|
||||
return ""
|
||||
# Detect ~/.hermes/profiles/<name> pattern → use the profile name
|
||||
profiles_root = (default / "profiles").resolve()
|
||||
try:
|
||||
rel = home.relative_to(profiles_root)
|
||||
parts = rel.parts
|
||||
if len(parts) == 1 and re.match(r"^[a-z0-9][a-z0-9_-]{0,63}$", parts[0]):
|
||||
return parts[0]
|
||||
except ValueError:
|
||||
pass
|
||||
# Fallback: short hash for arbitrary HERMES_HOME paths
|
||||
return hashlib.sha256(str(home).encode()).hexdigest()[:8]
|
||||
|
||||
|
||||
def get_service_name() -> str:
|
||||
"""Derive a systemd service name scoped to this HERMES_HOME.
|
||||
|
||||
Default ``~/.hermes`` returns ``hermes-gateway`` (backward compatible).
|
||||
Any other HERMES_HOME appends a short hash so multiple installations
|
||||
can each have their own systemd service without conflicting.
|
||||
Profile ``~/.hermes/profiles/coder`` returns ``hermes-gateway-coder``.
|
||||
Any other HERMES_HOME appends a short hash for uniqueness.
|
||||
"""
|
||||
import hashlib
|
||||
from pathlib import Path as _Path # local import to avoid monkeypatch interference
|
||||
home = get_hermes_home().resolve()
|
||||
default = (_Path.home() / ".hermes").resolve()
|
||||
if home == default:
|
||||
suffix = _profile_suffix()
|
||||
if not suffix:
|
||||
return _SERVICE_BASE
|
||||
suffix = hashlib.sha256(str(home).encode()).hexdigest()[:8]
|
||||
return f"{_SERVICE_BASE}-{suffix}"
|
||||
|
||||
|
||||
@@ -369,7 +392,14 @@ def print_systemd_linger_guidance() -> None:
|
||||
print(" sudo loginctl enable-linger $USER")
|
||||
|
||||
def get_launchd_plist_path() -> Path:
|
||||
return Path.home() / "Library" / "LaunchAgents" / "ai.hermes.gateway.plist"
|
||||
"""Return the launchd plist path, scoped per profile.
|
||||
|
||||
Default ``~/.hermes`` → ``ai.hermes.gateway.plist`` (backward compatible).
|
||||
Profile ``~/.hermes/profiles/coder`` → ``ai.hermes.gateway-coder.plist``.
|
||||
"""
|
||||
suffix = _profile_suffix()
|
||||
name = f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway"
|
||||
return Path.home() / "Library" / "LaunchAgents" / f"{name}.plist"
|
||||
|
||||
def _detect_venv_dir() -> Path | None:
|
||||
"""Detect the active virtualenv directory.
|
||||
@@ -769,18 +799,46 @@ def systemd_status(deep: bool = False, system: bool = False):
|
||||
# Launchd (macOS)
|
||||
# =============================================================================
|
||||
|
||||
def get_launchd_label() -> str:
|
||||
"""Return the launchd service label, scoped per profile."""
|
||||
suffix = _profile_suffix()
|
||||
return f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway"
|
||||
|
||||
|
||||
def generate_launchd_plist() -> str:
|
||||
python_path = get_python_path()
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
hermes_home = str(get_hermes_home().resolve())
|
||||
log_dir = get_hermes_home() / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
label = get_launchd_label()
|
||||
# Build a sane PATH for the launchd plist. launchd provides only a
|
||||
# minimal default (/usr/bin:/bin:/usr/sbin:/sbin) which misses Homebrew,
|
||||
# nvm, cargo, etc. We prepend venv/bin and node_modules/.bin (matching
|
||||
# the systemd unit), then capture the user's full shell PATH so every
|
||||
# user-installed tool (node, ffmpeg, …) is reachable.
|
||||
detected_venv = _detect_venv_dir()
|
||||
venv_bin = str(detected_venv / "bin") if detected_venv else str(PROJECT_ROOT / "venv" / "bin")
|
||||
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
|
||||
node_bin = str(PROJECT_ROOT / "node_modules" / ".bin")
|
||||
# Resolve the directory containing the node binary (e.g. Homebrew, nvm)
|
||||
# so it's explicitly in PATH even if the user's shell PATH changes later.
|
||||
priority_dirs = [venv_bin, node_bin]
|
||||
resolved_node = shutil.which("node")
|
||||
if resolved_node:
|
||||
resolved_node_dir = str(Path(resolved_node).resolve().parent)
|
||||
if resolved_node_dir not in priority_dirs:
|
||||
priority_dirs.append(resolved_node_dir)
|
||||
sane_path = ":".join(
|
||||
dict.fromkeys(priority_dirs + [p for p in os.environ.get("PATH", "").split(":") if p])
|
||||
)
|
||||
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>ai.hermes.gateway</string>
|
||||
<string>{label}</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
@@ -795,6 +853,16 @@ def generate_launchd_plist() -> str:
|
||||
<key>WorkingDirectory</key>
|
||||
<string>{working_dir}</string>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>{sane_path}</string>
|
||||
<key>VIRTUAL_ENV</key>
|
||||
<string>{venv_dir}</string>
|
||||
<key>HERMES_HOME</key>
|
||||
<string>{hermes_home}</string>
|
||||
</dict>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
@@ -882,18 +950,20 @@ def launchd_uninstall():
|
||||
def launchd_start():
|
||||
refresh_launchd_plist_if_needed()
|
||||
plist_path = get_launchd_plist_path()
|
||||
label = get_launchd_label()
|
||||
try:
|
||||
subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
|
||||
subprocess.run(["launchctl", "start", label], check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
if e.returncode != 3 or not plist_path.exists():
|
||||
raise
|
||||
print("↻ launchd job was unloaded; reloading service definition")
|
||||
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
|
||||
subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
|
||||
subprocess.run(["launchctl", "start", label], check=True)
|
||||
print("✓ Service started")
|
||||
|
||||
def launchd_stop():
|
||||
subprocess.run(["launchctl", "stop", "ai.hermes.gateway"], check=True)
|
||||
label = get_launchd_label()
|
||||
subprocess.run(["launchctl", "stop", label], check=True)
|
||||
print("✓ Service stopped")
|
||||
|
||||
def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float = 5.0):
|
||||
@@ -948,8 +1018,9 @@ def launchd_restart():
|
||||
|
||||
def launchd_status(deep: bool = False):
|
||||
plist_path = get_launchd_plist_path()
|
||||
label = get_launchd_label()
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", "ai.hermes.gateway"],
|
||||
["launchctl", "list", label],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
@@ -1454,7 +1525,7 @@ def _is_service_running() -> bool:
|
||||
return False
|
||||
elif is_macos() and get_launchd_plist_path().exists():
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", "ai.hermes.gateway"],
|
||||
["launchctl", "list", get_launchd_label()],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
+13
-6
@@ -2917,10 +2917,15 @@ def cmd_update(args):
|
||||
print(f" ℹ️ {len(missing_config)} new config option(s) available")
|
||||
|
||||
print()
|
||||
if sys.stdin.isatty():
|
||||
response = input("Would you like to configure them now? [Y/n]: ").strip().lower()
|
||||
else:
|
||||
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||||
print(" ℹ Non-interactive session — skipping config migration prompt.")
|
||||
print(" Run 'hermes config migrate' later to apply any new config/env options.")
|
||||
response = "n"
|
||||
else:
|
||||
try:
|
||||
response = input("Would you like to configure them now? [Y/n]: ").strip().lower()
|
||||
except EOFError:
|
||||
response = "n"
|
||||
|
||||
if response in ('', 'y', 'yes'):
|
||||
print()
|
||||
@@ -2968,10 +2973,11 @@ def cmd_update(args):
|
||||
# Check for macOS launchd service
|
||||
if is_macos():
|
||||
try:
|
||||
from hermes_cli.gateway import get_launchd_label
|
||||
plist_path = get_launchd_plist_path()
|
||||
if plist_path.exists():
|
||||
check = subprocess.run(
|
||||
["launchctl", "list", "ai.hermes.gateway"],
|
||||
["launchctl", "list", get_launchd_label()],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
has_launchd_service = check.returncode == 0
|
||||
@@ -3027,12 +3033,13 @@ def cmd_update(args):
|
||||
# after a manual SIGTERM, which would race with the
|
||||
# PID file cleanup.
|
||||
print("→ Restarting gateway service...")
|
||||
_launchd_label = get_launchd_label()
|
||||
stop = subprocess.run(
|
||||
["launchctl", "stop", "ai.hermes.gateway"],
|
||||
["launchctl", "stop", _launchd_label],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
start = subprocess.run(
|
||||
["launchctl", "start", "ai.hermes.gateway"],
|
||||
["launchctl", "start", _launchd_label],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if start.returncode == 0:
|
||||
|
||||
@@ -24,6 +24,10 @@ PLATFORMS = {
|
||||
"whatsapp": "📱 WhatsApp",
|
||||
"signal": "📡 Signal",
|
||||
"email": "📧 Email",
|
||||
"homeassistant": "🏠 Home Assistant",
|
||||
"mattermost": "💬 Mattermost",
|
||||
"matrix": "💬 Matrix",
|
||||
"dingtalk": "💬 DingTalk",
|
||||
}
|
||||
|
||||
# ─── Config Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
+38
-21
@@ -304,7 +304,8 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
||||
|
||||
|
||||
def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
console: Optional[Console] = None, skip_confirm: bool = False) -> None:
|
||||
console: Optional[Console] = None, skip_confirm: bool = False,
|
||||
invalidate_cache: bool = True) -> None:
|
||||
"""Fetch, quarantine, scan, confirm, and install a skill."""
|
||||
from tools.skills_hub import (
|
||||
GitHubAuth, create_source_router, ensure_hub_dirs,
|
||||
@@ -417,12 +418,16 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
c.print(f"[bold green]Installed:[/] {install_dir.relative_to(SKILLS_DIR)}")
|
||||
c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n")
|
||||
|
||||
# Invalidate the skills prompt cache so the new skill appears immediately
|
||||
try:
|
||||
from agent.prompt_builder import clear_skills_system_prompt_cache
|
||||
clear_skills_system_prompt_cache(clear_snapshot=True)
|
||||
except Exception:
|
||||
pass
|
||||
if invalidate_cache:
|
||||
# Invalidate the skills prompt cache so the new skill appears immediately
|
||||
try:
|
||||
from agent.prompt_builder import clear_skills_system_prompt_cache
|
||||
clear_skills_system_prompt_cache(clear_snapshot=True)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
c.print("[dim]Skill will be available in your next session.[/]")
|
||||
c.print("[dim]Use /reset to start a new session now, or --now to activate immediately (invalidates prompt cache).[/]\n")
|
||||
|
||||
|
||||
def do_inspect(identifier: str, console: Optional[Console] = None) -> None:
|
||||
@@ -610,7 +615,8 @@ def do_audit(name: Optional[str] = None, console: Optional[Console] = None) -> N
|
||||
|
||||
|
||||
def do_uninstall(name: str, console: Optional[Console] = None,
|
||||
skip_confirm: bool = False) -> None:
|
||||
skip_confirm: bool = False,
|
||||
invalidate_cache: bool = True) -> None:
|
||||
"""Remove a hub-installed skill with confirmation."""
|
||||
from tools.skills_hub import uninstall_skill
|
||||
|
||||
@@ -630,11 +636,15 @@ def do_uninstall(name: str, console: Optional[Console] = None,
|
||||
success, msg = uninstall_skill(name)
|
||||
if success:
|
||||
c.print(f"[bold green]{msg}[/]\n")
|
||||
try:
|
||||
from agent.prompt_builder import clear_skills_system_prompt_cache
|
||||
clear_skills_system_prompt_cache(clear_snapshot=True)
|
||||
except Exception:
|
||||
pass
|
||||
if invalidate_cache:
|
||||
try:
|
||||
from agent.prompt_builder import clear_skills_system_prompt_cache
|
||||
clear_skills_system_prompt_cache(clear_snapshot=True)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
c.print("[dim]Change will take effect in your next session.[/]")
|
||||
c.print("[dim]Use /reset to start a new session now, or --now to apply immediately (invalidates prompt cache).[/]\n")
|
||||
else:
|
||||
c.print(f"[bold red]Error:[/] {msg}\n")
|
||||
|
||||
@@ -1071,19 +1081,23 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
||||
|
||||
elif action == "install":
|
||||
if not args:
|
||||
c.print("[bold red]Usage:[/] /skills install <identifier> [--category <cat>] [--force|--yes]\n")
|
||||
c.print("[bold red]Usage:[/] /skills install <identifier> [--category <cat>] [--force] [--now]\n")
|
||||
return
|
||||
identifier = args[0]
|
||||
category = ""
|
||||
# --yes / -y bypasses confirmation prompt (needed in TUI mode)
|
||||
# --force handles reinstall override
|
||||
skip_confirm = any(flag in args for flag in ("--yes", "-y"))
|
||||
# Slash commands run inside prompt_toolkit where input() hangs.
|
||||
# Always skip confirmation — the user typing the command is implicit consent.
|
||||
skip_confirm = True
|
||||
force = "--force" in args
|
||||
# --now invalidates prompt cache immediately (costs more money).
|
||||
# Default: defer to next session to preserve cache.
|
||||
invalidate_cache = "--now" in args
|
||||
for i, a in enumerate(args):
|
||||
if a == "--category" and i + 1 < len(args):
|
||||
category = args[i + 1]
|
||||
do_install(identifier, category=category, force=force,
|
||||
skip_confirm=skip_confirm, console=c)
|
||||
skip_confirm=skip_confirm, invalidate_cache=invalidate_cache,
|
||||
console=c)
|
||||
|
||||
elif action == "inspect":
|
||||
if not args:
|
||||
@@ -1113,10 +1127,13 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
||||
|
||||
elif action == "uninstall":
|
||||
if not args:
|
||||
c.print("[bold red]Usage:[/] /skills uninstall <name> [--yes]\n")
|
||||
c.print("[bold red]Usage:[/] /skills uninstall <name> [--now]\n")
|
||||
return
|
||||
skip_confirm = any(flag in args for flag in ("--yes", "-y"))
|
||||
do_uninstall(args[0], console=c, skip_confirm=skip_confirm)
|
||||
# Slash commands run inside prompt_toolkit where input() hangs.
|
||||
skip_confirm = True
|
||||
invalidate_cache = "--now" in args
|
||||
do_uninstall(args[0], console=c, skip_confirm=skip_confirm,
|
||||
invalidate_cache=invalidate_cache)
|
||||
|
||||
elif action == "publish":
|
||||
if not args:
|
||||
|
||||
@@ -292,8 +292,9 @@ def show_status(args):
|
||||
print(" Manager: systemd (user)")
|
||||
|
||||
elif sys.platform == 'darwin':
|
||||
from hermes_cli.gateway import get_launchd_label
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", "ai.hermes.gateway"],
|
||||
["launchctl", "list", get_launchd_label()],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
@@ -138,6 +138,7 @@ PLATFORMS = {
|
||||
"matrix": {"label": "💬 Matrix", "default_toolset": "hermes-matrix"},
|
||||
"dingtalk": {"label": "💬 DingTalk", "default_toolset": "hermes-dingtalk"},
|
||||
"api_server": {"label": "🌐 API Server", "default_toolset": "hermes-api-server"},
|
||||
"mattermost": {"label": "💬 Mattermost", "default_toolset": "hermes-mattermost"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
+24
-1
@@ -3883,6 +3883,12 @@ class AIAgent:
|
||||
content_parts: list = []
|
||||
tool_calls_acc: dict = {}
|
||||
tool_gen_notified: set = set()
|
||||
# Ollama-compatible endpoints reuse index 0 for every tool call
|
||||
# in a parallel batch, distinguishing them only by id. Track
|
||||
# the last seen id per raw index so we can detect a new tool
|
||||
# call starting at the same index and redirect it to a fresh slot.
|
||||
_last_id_at_idx: dict = {} # raw_index -> last seen non-empty id
|
||||
_active_slot_by_idx: dict = {} # raw_index -> current slot in tool_calls_acc
|
||||
finish_reason = None
|
||||
model_name = None
|
||||
role = "assistant"
|
||||
@@ -3945,7 +3951,24 @@ class AIAgent:
|
||||
# Accumulate tool call deltas — notify display on first name
|
||||
if delta and delta.tool_calls:
|
||||
for tc_delta in delta.tool_calls:
|
||||
idx = tc_delta.index if tc_delta.index is not None else 0
|
||||
raw_idx = tc_delta.index if tc_delta.index is not None else 0
|
||||
delta_id = tc_delta.id or ""
|
||||
|
||||
# Ollama fix: detect a new tool call reusing the same
|
||||
# raw index (different id) and redirect to a fresh slot.
|
||||
if raw_idx not in _active_slot_by_idx:
|
||||
_active_slot_by_idx[raw_idx] = raw_idx
|
||||
if (
|
||||
delta_id
|
||||
and raw_idx in _last_id_at_idx
|
||||
and delta_id != _last_id_at_idx[raw_idx]
|
||||
):
|
||||
new_slot = max(tool_calls_acc, default=-1) + 1
|
||||
_active_slot_by_idx[raw_idx] = new_slot
|
||||
if delta_id:
|
||||
_last_id_at_idx[raw_idx] = delta_id
|
||||
idx = _active_slot_by_idx[raw_idx]
|
||||
|
||||
if idx not in tool_calls_acc:
|
||||
tool_calls_acc[idx] = {
|
||||
"id": tc_delta.id or "",
|
||||
|
||||
@@ -28,6 +28,7 @@ from gateway.platforms.api_server import (
|
||||
_CORS_HEADERS,
|
||||
check_api_server_requirements,
|
||||
cors_middleware,
|
||||
security_headers_middleware,
|
||||
)
|
||||
|
||||
|
||||
@@ -214,9 +215,11 @@ def _make_adapter(api_key: str = "", cors_origins=None) -> APIServerAdapter:
|
||||
|
||||
def _create_app(adapter: APIServerAdapter) -> web.Application:
|
||||
"""Create the aiohttp app from the adapter (without starting the full server)."""
|
||||
app = web.Application(middlewares=[cors_middleware])
|
||||
mws = [mw for mw in (cors_middleware, security_headers_middleware) if mw is not None]
|
||||
app = web.Application(middlewares=mws)
|
||||
app["api_server_adapter"] = adapter
|
||||
app.router.add_get("/health", adapter._handle_health)
|
||||
app.router.add_get("/v1/health", adapter._handle_health)
|
||||
app.router.add_get("/v1/models", adapter._handle_models)
|
||||
app.router.add_post("/v1/chat/completions", adapter._handle_chat_completions)
|
||||
app.router.add_post("/v1/responses", adapter._handle_responses)
|
||||
@@ -241,6 +244,16 @@ def auth_adapter():
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
async def test_security_headers_present(self, adapter):
|
||||
"""Responses should include basic security headers."""
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/health")
|
||||
assert resp.status == 200
|
||||
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert resp.headers.get("Referrer-Policy") == "no-referrer"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_returns_ok(self, adapter):
|
||||
app = _create_app(adapter)
|
||||
@@ -251,6 +264,17 @@ class TestHealthEndpoint:
|
||||
assert data["status"] == "ok"
|
||||
assert data["platform"] == "hermes-agent"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v1_health_alias_returns_ok(self, adapter):
|
||||
"""GET /v1/health should return the same response as /health."""
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/v1/health")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["platform"] == "hermes-agent"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /v1/models endpoint
|
||||
@@ -1344,6 +1368,21 @@ class TestCORS:
|
||||
assert "Authorization" in resp.headers.get("Access-Control-Allow-Headers", "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cors_preflight_sets_max_age(self):
|
||||
adapter = _make_adapter(cors_origins=["http://localhost:3000"])
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.options(
|
||||
"/v1/chat/completions",
|
||||
headers={
|
||||
"Origin": "http://localhost:3000",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "Authorization, Content-Type",
|
||||
},
|
||||
)
|
||||
assert resp.status == 200
|
||||
assert resp.headers.get("Access-Control-Max-Age") == "600"
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conversation parameter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -10,6 +10,7 @@ Covers:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -227,7 +228,8 @@ def test_persist_dm_topic_thread_id_writes_config(tmp_path):
|
||||
|
||||
adapter = _make_adapter()
|
||||
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
with patch.object(Path, "home", return_value=tmp_path), \
|
||||
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
|
||||
adapter._persist_dm_topic_thread_id(111, "General", 999)
|
||||
|
||||
with open(config_file) as f:
|
||||
@@ -366,7 +368,8 @@ def test_get_dm_topic_info_hot_reloads_from_config(tmp_path):
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
with patch.object(Path, "home", return_value=tmp_path), \
|
||||
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
|
||||
result = adapter._get_dm_topic_info("111", "555")
|
||||
|
||||
assert result is not None
|
||||
|
||||
@@ -62,6 +62,18 @@ class TestMessageEventGetCommand:
|
||||
event = MessageEvent(text="/")
|
||||
assert event.get_command() == ""
|
||||
|
||||
def test_command_with_at_botname(self):
|
||||
event = MessageEvent(text="/new@TigerNanoBot")
|
||||
assert event.get_command() == "new"
|
||||
|
||||
def test_command_with_at_botname_and_args(self):
|
||||
event = MessageEvent(text="/compress@TigerNanoBot")
|
||||
assert event.get_command() == "compress"
|
||||
|
||||
def test_command_mixed_case_with_at_botname(self):
|
||||
event = MessageEvent(text="/RESET@TigerNanoBot")
|
||||
assert event.get_command() == "reset"
|
||||
|
||||
|
||||
class TestMessageEventGetCommandArgs:
|
||||
def test_command_with_args(self):
|
||||
|
||||
@@ -344,6 +344,7 @@ class TestRuntimeDisconnectQueuing:
|
||||
async def test_retryable_runtime_error_queued_for_reconnect(self):
|
||||
"""Retryable runtime errors should add the platform to _failed_platforms."""
|
||||
runner = _make_runner()
|
||||
runner.stop = AsyncMock()
|
||||
|
||||
adapter = StubAdapter(succeed=True)
|
||||
adapter._set_fatal_error("network_error", "DNS failure", retryable=True)
|
||||
@@ -371,8 +372,12 @@ class TestRuntimeDisconnectQueuing:
|
||||
assert Platform.TELEGRAM not in runner._failed_platforms
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retryable_error_prevents_shutdown_when_queued(self):
|
||||
"""Gateway should not shut down if failed platforms are queued for reconnection."""
|
||||
async def test_retryable_error_exits_for_service_restart_when_all_down(self):
|
||||
"""Gateway should exit with failure when all platforms fail with retryable errors.
|
||||
|
||||
This lets systemd Restart=on-failure restart the process, which is more
|
||||
reliable than in-process background reconnection after exhausted retries.
|
||||
"""
|
||||
runner = _make_runner()
|
||||
runner.stop = AsyncMock()
|
||||
|
||||
@@ -382,7 +387,28 @@ class TestRuntimeDisconnectQueuing:
|
||||
|
||||
await runner._handle_adapter_fatal_error(adapter)
|
||||
|
||||
# stop() should NOT have been called since we have platforms queued
|
||||
# stop() SHOULD be called — gateway exits for systemd restart
|
||||
runner.stop.assert_called_once()
|
||||
assert runner._exit_with_failure is True
|
||||
assert Platform.TELEGRAM in runner._failed_platforms
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retryable_error_no_exit_when_other_adapters_still_connected(self):
|
||||
"""Gateway should NOT exit if some adapters are still connected."""
|
||||
runner = _make_runner()
|
||||
runner.stop = AsyncMock()
|
||||
|
||||
failing_adapter = StubAdapter(succeed=True)
|
||||
failing_adapter._set_fatal_error("network_error", "DNS failure", retryable=True)
|
||||
runner.adapters[Platform.TELEGRAM] = failing_adapter
|
||||
|
||||
# Another adapter is still connected
|
||||
healthy_adapter = StubAdapter(succeed=True)
|
||||
runner.adapters[Platform.DISCORD] = healthy_adapter
|
||||
|
||||
await runner._handle_adapter_fatal_error(failing_adapter)
|
||||
|
||||
# stop() should NOT have been called — Discord is still up
|
||||
runner.stop.assert_not_called()
|
||||
assert Platform.TELEGRAM in runner._failed_platforms
|
||||
|
||||
|
||||
@@ -89,7 +89,8 @@ async def test_runner_queues_retryable_runtime_fatal_for_reconnection(monkeypatc
|
||||
|
||||
await runner._handle_adapter_fatal_error(adapter)
|
||||
|
||||
# Should NOT shut down — platform is queued for reconnection
|
||||
runner.stop.assert_not_awaited()
|
||||
# Should shut down with failure — systemd Restart=on-failure will restart
|
||||
runner.stop.assert_awaited_once()
|
||||
assert runner._exit_with_failure is True
|
||||
assert Platform.WHATSAPP in runner._failed_platforms
|
||||
assert runner._failed_platforms[Platform.WHATSAPP]["attempts"] == 0
|
||||
|
||||
@@ -315,6 +315,24 @@ class TestFallbackTransportInit:
|
||||
transport = tnet.TelegramFallbackTransport(["149.154.167.220", "not-an-ip"])
|
||||
assert transport._fallback_ips == ["149.154.167.220"]
|
||||
|
||||
def test_uses_proxy_env_for_primary_and_fallback_transports(self, monkeypatch):
|
||||
seen_kwargs = []
|
||||
|
||||
def factory(**kwargs):
|
||||
seen_kwargs.append(kwargs.copy())
|
||||
return FakeTransport([], {})
|
||||
|
||||
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
|
||||
monkeypatch.setattr(tnet.httpx, "AsyncHTTPTransport", factory)
|
||||
|
||||
transport = tnet.TelegramFallbackTransport(["149.154.167.220"])
|
||||
|
||||
assert transport._fallback_ips == ["149.154.167.220"]
|
||||
assert len(seen_kwargs) == 2
|
||||
assert all(kwargs["proxy"] == "http://proxy.example:8080" for kwargs in seen_kwargs)
|
||||
|
||||
|
||||
class TestFallbackTransportClose:
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -105,3 +105,24 @@ class TestCmdUpdateBranchFallback:
|
||||
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
|
||||
pull_cmds = [c for c in commands if "pull" in c]
|
||||
assert len(pull_cmds) == 0
|
||||
|
||||
def test_update_non_interactive_skips_migration_prompt(self, mock_args, capsys):
|
||||
"""When stdin/stdout aren't TTYs, config migration prompt is skipped."""
|
||||
with patch("shutil.which", return_value=None), patch(
|
||||
"subprocess.run"
|
||||
) as mock_run, patch("builtins.input") as mock_input, patch(
|
||||
"hermes_cli.config.get_missing_env_vars", return_value=["MISSING_KEY"]
|
||||
), patch("hermes_cli.config.get_missing_config_fields", return_value=[]), patch(
|
||||
"hermes_cli.config.check_config_version", return_value=(1, 2)
|
||||
), patch("hermes_cli.main.sys") as mock_sys:
|
||||
mock_sys.stdin.isatty.return_value = False
|
||||
mock_sys.stdout.isatty.return_value = False
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
branch="main", verify_ok=True, commit_count="1"
|
||||
)
|
||||
|
||||
cmd_update(mock_args)
|
||||
|
||||
mock_input.assert_not_called()
|
||||
captured = capsys.readouterr()
|
||||
assert "Non-interactive session" in captured.out
|
||||
|
||||
@@ -153,12 +153,13 @@ class TestLaunchdServiceRecovery:
|
||||
def test_launchd_start_reloads_unloaded_job_and_retries(self, tmp_path, monkeypatch):
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8")
|
||||
label = gateway_cli.get_launchd_label()
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
calls.append(cmd)
|
||||
if cmd == ["launchctl", "start", "ai.hermes.gateway"] and calls.count(cmd) == 1:
|
||||
if cmd == ["launchctl", "start", label] and calls.count(cmd) == 1:
|
||||
raise gateway_cli.subprocess.CalledProcessError(3, cmd, stderr="Could not find service")
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
@@ -168,9 +169,9 @@ class TestLaunchdServiceRecovery:
|
||||
gateway_cli.launchd_start()
|
||||
|
||||
assert calls == [
|
||||
["launchctl", "start", "ai.hermes.gateway"],
|
||||
["launchctl", "start", label],
|
||||
["launchctl", "load", str(plist_path)],
|
||||
["launchctl", "start", "ai.hermes.gateway"],
|
||||
["launchctl", "start", label],
|
||||
]
|
||||
|
||||
def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path, monkeypatch, capsys):
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""
|
||||
Tests for skip_confirm behavior in /skills install and /skills uninstall.
|
||||
Tests for skip_confirm and invalidate_cache behavior in /skills install
|
||||
and /skills uninstall slash commands.
|
||||
|
||||
Verifies that --yes / -y bypasses the interactive confirmation prompt
|
||||
that hangs inside prompt_toolkit's TUI.
|
||||
Slash commands always skip confirmation (input() hangs in TUI).
|
||||
Cache invalidation is deferred by default; --now opts into immediate
|
||||
invalidation (at the cost of breaking prompt cache mid-session).
|
||||
|
||||
Based on PR #1595 by 333Alden333 (salvaged).
|
||||
Updated for PR #3586 (cache-aware install/uninstall).
|
||||
"""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
@@ -32,23 +35,43 @@ class TestHandleSkillsSlashInstallFlags:
|
||||
_, kwargs = mock_install.call_args
|
||||
assert kwargs.get("skip_confirm") is True
|
||||
|
||||
def test_force_flag_sets_force_not_skip(self):
|
||||
def test_force_flag_sets_force(self):
|
||||
from hermes_cli.skills_hub import handle_skills_slash
|
||||
with patch("hermes_cli.skills_hub.do_install") as mock_install:
|
||||
handle_skills_slash("/skills install test/skill --force")
|
||||
mock_install.assert_called_once()
|
||||
_, kwargs = mock_install.call_args
|
||||
assert kwargs.get("force") is True
|
||||
assert kwargs.get("skip_confirm") is False
|
||||
# Slash commands always skip confirmation (input() hangs in TUI)
|
||||
assert kwargs.get("skip_confirm") is True
|
||||
|
||||
def test_no_flags(self):
|
||||
def test_no_flags_still_skips_confirm(self):
|
||||
"""Slash commands always skip confirmation — input() hangs in TUI."""
|
||||
from hermes_cli.skills_hub import handle_skills_slash
|
||||
with patch("hermes_cli.skills_hub.do_install") as mock_install:
|
||||
handle_skills_slash("/skills install test/skill")
|
||||
mock_install.assert_called_once()
|
||||
_, kwargs = mock_install.call_args
|
||||
assert kwargs.get("force") is False
|
||||
assert kwargs.get("skip_confirm") is False
|
||||
assert kwargs.get("skip_confirm") is True
|
||||
|
||||
def test_default_defers_cache_invalidation(self):
|
||||
"""Without --now, cache invalidation is deferred to next session."""
|
||||
from hermes_cli.skills_hub import handle_skills_slash
|
||||
with patch("hermes_cli.skills_hub.do_install") as mock_install:
|
||||
handle_skills_slash("/skills install test/skill")
|
||||
mock_install.assert_called_once()
|
||||
_, kwargs = mock_install.call_args
|
||||
assert kwargs.get("invalidate_cache") is False
|
||||
|
||||
def test_now_flag_invalidates_cache(self):
|
||||
"""--now opts into immediate cache invalidation."""
|
||||
from hermes_cli.skills_hub import handle_skills_slash
|
||||
with patch("hermes_cli.skills_hub.do_install") as mock_install:
|
||||
handle_skills_slash("/skills install test/skill --now")
|
||||
mock_install.assert_called_once()
|
||||
_, kwargs = mock_install.call_args
|
||||
assert kwargs.get("invalidate_cache") is True
|
||||
|
||||
|
||||
class TestHandleSkillsSlashUninstallFlags:
|
||||
@@ -70,13 +93,32 @@ class TestHandleSkillsSlashUninstallFlags:
|
||||
_, kwargs = mock_uninstall.call_args
|
||||
assert kwargs.get("skip_confirm") is True
|
||||
|
||||
def test_no_flags(self):
|
||||
def test_no_flags_still_skips_confirm(self):
|
||||
"""Slash commands always skip confirmation — input() hangs in TUI."""
|
||||
from hermes_cli.skills_hub import handle_skills_slash
|
||||
with patch("hermes_cli.skills_hub.do_uninstall") as mock_uninstall:
|
||||
handle_skills_slash("/skills uninstall test-skill")
|
||||
mock_uninstall.assert_called_once()
|
||||
_, kwargs = mock_uninstall.call_args
|
||||
assert kwargs.get("skip_confirm", False) is False
|
||||
assert kwargs.get("skip_confirm") is True
|
||||
|
||||
def test_default_defers_cache_invalidation(self):
|
||||
"""Without --now, cache invalidation is deferred to next session."""
|
||||
from hermes_cli.skills_hub import handle_skills_slash
|
||||
with patch("hermes_cli.skills_hub.do_uninstall") as mock_uninstall:
|
||||
handle_skills_slash("/skills uninstall test-skill")
|
||||
mock_uninstall.assert_called_once()
|
||||
_, kwargs = mock_uninstall.call_args
|
||||
assert kwargs.get("invalidate_cache") is False
|
||||
|
||||
def test_now_flag_invalidates_cache(self):
|
||||
"""--now opts into immediate cache invalidation."""
|
||||
from hermes_cli.skills_hub import handle_skills_slash
|
||||
with patch("hermes_cli.skills_hub.do_uninstall") as mock_uninstall:
|
||||
handle_skills_slash("/skills uninstall test-skill --now")
|
||||
mock_uninstall.assert_called_once()
|
||||
_, kwargs = mock_uninstall.call_args
|
||||
assert kwargs.get("invalidate_cache") is True
|
||||
|
||||
|
||||
class TestDoInstallSkipConfirm:
|
||||
|
||||
@@ -237,3 +237,53 @@ def test_save_platform_tools_still_preserves_mcp_with_platform_default_present()
|
||||
|
||||
# Deselected configurable toolset removed
|
||||
assert "terminal" not in saved
|
||||
|
||||
|
||||
# ── Platform / toolset consistency ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPlatformToolsetConsistency:
|
||||
"""Every platform in tools_config.PLATFORMS must have a matching toolset."""
|
||||
|
||||
def test_all_platforms_have_toolset_definitions(self):
|
||||
"""Each platform's default_toolset must exist in TOOLSETS."""
|
||||
from hermes_cli.tools_config import PLATFORMS
|
||||
from toolsets import TOOLSETS
|
||||
|
||||
for platform, meta in PLATFORMS.items():
|
||||
ts_name = meta["default_toolset"]
|
||||
assert ts_name in TOOLSETS, (
|
||||
f"Platform {platform!r} references toolset {ts_name!r} "
|
||||
f"which is not defined in toolsets.py"
|
||||
)
|
||||
|
||||
def test_gateway_toolset_includes_all_messaging_platforms(self):
|
||||
"""hermes-gateway includes list should cover all messaging platforms."""
|
||||
from hermes_cli.tools_config import PLATFORMS
|
||||
from toolsets import TOOLSETS
|
||||
|
||||
gateway_includes = set(TOOLSETS["hermes-gateway"]["includes"])
|
||||
# Exclude non-messaging platforms from the check
|
||||
non_messaging = {"cli", "api_server"}
|
||||
for platform, meta in PLATFORMS.items():
|
||||
if platform in non_messaging:
|
||||
continue
|
||||
ts_name = meta["default_toolset"]
|
||||
assert ts_name in gateway_includes, (
|
||||
f"Platform {platform!r} toolset {ts_name!r} missing from "
|
||||
f"hermes-gateway includes"
|
||||
)
|
||||
|
||||
def test_skills_config_covers_tools_config_platforms(self):
|
||||
"""skills_config.PLATFORMS should have entries for all gateway platforms."""
|
||||
from hermes_cli.tools_config import PLATFORMS as TOOLS_PLATFORMS
|
||||
from hermes_cli.skills_config import PLATFORMS as SKILLS_PLATFORMS
|
||||
|
||||
non_messaging = {"api_server"}
|
||||
for platform in TOOLS_PLATFORMS:
|
||||
if platform in non_messaging:
|
||||
continue
|
||||
assert platform in SKILLS_PLATFORMS, (
|
||||
f"Platform {platform!r} in tools_config but missing from "
|
||||
f"skills_config PLATFORMS"
|
||||
)
|
||||
|
||||
@@ -101,6 +101,69 @@ class TestLaunchdPlistReplace:
|
||||
assert replace_idx == run_idx + 1
|
||||
|
||||
|
||||
class TestLaunchdPlistPath:
|
||||
def test_plist_contains_environment_variables(self):
|
||||
plist = gateway_cli.generate_launchd_plist()
|
||||
assert "<key>EnvironmentVariables</key>" in plist
|
||||
assert "<key>PATH</key>" in plist
|
||||
assert "<key>VIRTUAL_ENV</key>" in plist
|
||||
assert "<key>HERMES_HOME</key>" in plist
|
||||
|
||||
def test_plist_path_includes_venv_bin(self):
|
||||
plist = gateway_cli.generate_launchd_plist()
|
||||
detected = gateway_cli._detect_venv_dir()
|
||||
venv_bin = str(detected / "bin") if detected else str(gateway_cli.PROJECT_ROOT / "venv" / "bin")
|
||||
assert venv_bin in plist
|
||||
|
||||
def test_plist_path_starts_with_venv_bin(self):
|
||||
plist = gateway_cli.generate_launchd_plist()
|
||||
lines = plist.splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
if "<key>PATH</key>" in line.strip():
|
||||
path_value = lines[i + 1].strip()
|
||||
path_value = path_value.replace("<string>", "").replace("</string>", "")
|
||||
detected = gateway_cli._detect_venv_dir()
|
||||
venv_bin = str(detected / "bin") if detected else str(gateway_cli.PROJECT_ROOT / "venv" / "bin")
|
||||
assert path_value.startswith(venv_bin + ":")
|
||||
break
|
||||
else:
|
||||
raise AssertionError("PATH key not found in plist")
|
||||
|
||||
def test_plist_path_includes_node_modules_bin(self):
|
||||
plist = gateway_cli.generate_launchd_plist()
|
||||
node_bin = str(gateway_cli.PROJECT_ROOT / "node_modules" / ".bin")
|
||||
lines = plist.splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
if "<key>PATH</key>" in line.strip():
|
||||
path_value = lines[i + 1].strip()
|
||||
path_value = path_value.replace("<string>", "").replace("</string>", "")
|
||||
assert node_bin in path_value.split(":")
|
||||
break
|
||||
else:
|
||||
raise AssertionError("PATH key not found in plist")
|
||||
|
||||
def test_plist_path_includes_current_env_path(self, monkeypatch):
|
||||
monkeypatch.setenv("PATH", "/custom/bin:/usr/bin:/bin")
|
||||
plist = gateway_cli.generate_launchd_plist()
|
||||
assert "/custom/bin" in plist
|
||||
|
||||
def test_plist_path_deduplicates_venv_bin_when_already_in_path(self, monkeypatch):
|
||||
detected = gateway_cli._detect_venv_dir()
|
||||
venv_bin = str(detected / "bin") if detected else str(gateway_cli.PROJECT_ROOT / "venv" / "bin")
|
||||
monkeypatch.setenv("PATH", f"{venv_bin}:/usr/bin:/bin")
|
||||
plist = gateway_cli.generate_launchd_plist()
|
||||
lines = plist.splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
if "<key>PATH</key>" in line.strip():
|
||||
path_value = lines[i + 1].strip()
|
||||
path_value = path_value.replace("<string>", "").replace("</string>", "")
|
||||
parts = path_value.split(":")
|
||||
assert parts.count(venv_bin) == 1
|
||||
break
|
||||
else:
|
||||
raise AssertionError("PATH key not found in plist")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cmd_update — macOS launchd detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2793,6 +2793,50 @@ class TestStreamingApiCall:
|
||||
assert tc[0].function.name == "search"
|
||||
assert tc[1].function.name == "read"
|
||||
|
||||
def test_ollama_reused_index_separate_tool_calls(self, agent):
|
||||
"""Ollama sends every tool call at index 0 with different ids.
|
||||
|
||||
Without the fix, names and arguments get concatenated into one slot.
|
||||
"""
|
||||
chunks = [
|
||||
_make_chunk(tool_calls=[_make_tc_delta(0, "call_a", "search", '{"q":"hello"}')]),
|
||||
# Second tool call at the SAME index 0, but different id
|
||||
_make_chunk(tool_calls=[_make_tc_delta(0, "call_b", "read_file", '{"path":"x.py"}')]),
|
||||
_make_chunk(finish_reason="tool_calls"),
|
||||
]
|
||||
agent.client.chat.completions.create.return_value = iter(chunks)
|
||||
|
||||
resp = agent._interruptible_streaming_api_call({"messages": []})
|
||||
|
||||
tc = resp.choices[0].message.tool_calls
|
||||
assert len(tc) == 2, f"Expected 2 tool calls, got {len(tc)}: {[t.function.name for t in tc]}"
|
||||
assert tc[0].function.name == "search"
|
||||
assert tc[0].function.arguments == '{"q":"hello"}'
|
||||
assert tc[0].id == "call_a"
|
||||
assert tc[1].function.name == "read_file"
|
||||
assert tc[1].function.arguments == '{"path":"x.py"}'
|
||||
assert tc[1].id == "call_b"
|
||||
|
||||
def test_ollama_reused_index_streamed_args(self, agent):
|
||||
"""Ollama with streamed arguments across multiple chunks at same index."""
|
||||
chunks = [
|
||||
_make_chunk(tool_calls=[_make_tc_delta(0, "call_a", "search", '{"q":')]),
|
||||
_make_chunk(tool_calls=[_make_tc_delta(0, None, None, '"hello"}')]),
|
||||
# New tool call, same index 0
|
||||
_make_chunk(tool_calls=[_make_tc_delta(0, "call_b", "read", '{}')]),
|
||||
_make_chunk(finish_reason="tool_calls"),
|
||||
]
|
||||
agent.client.chat.completions.create.return_value = iter(chunks)
|
||||
|
||||
resp = agent._interruptible_streaming_api_call({"messages": []})
|
||||
|
||||
tc = resp.choices[0].message.tool_calls
|
||||
assert len(tc) == 2
|
||||
assert tc[0].function.name == "search"
|
||||
assert tc[0].function.arguments == '{"q":"hello"}'
|
||||
assert tc[1].function.name == "read"
|
||||
assert tc[1].function.arguments == '{}'
|
||||
|
||||
def test_content_and_tool_calls_together(self, agent):
|
||||
chunks = [
|
||||
_make_chunk(content="I'll search"),
|
||||
|
||||
@@ -362,9 +362,11 @@ class TestStreamingCallbacks:
|
||||
|
||||
# Text before tool call IS fired (we don't know yet it will have tools)
|
||||
assert "thinking..." in deltas
|
||||
# Text after tool call is NOT fired
|
||||
assert " more text" not in deltas
|
||||
# But content is still accumulated in the response
|
||||
# Text after tool call IS still routed to stream_delta_callback so that
|
||||
# reasoning tag extraction can fire (PR #3566). Display-level suppression
|
||||
# of non-reasoning text happens in the CLI's _stream_delta, not here.
|
||||
assert " more text" in deltas
|
||||
# Content is still accumulated in the response
|
||||
assert response.choices[0].message.content == "thinking... more text"
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -171,8 +171,9 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str =
|
||||
# Security: block direct reads of internal Hermes cache/index files
|
||||
# to prevent prompt injection via catalog or hub metadata files.
|
||||
import pathlib as _pathlib
|
||||
from hermes_constants import get_hermes_home as _get_hh
|
||||
_resolved = _pathlib.Path(path).expanduser().resolve()
|
||||
_hermes_home = _pathlib.Path("~/.hermes").expanduser().resolve()
|
||||
_hermes_home = _get_hh().resolve()
|
||||
_blocked_dirs = [
|
||||
_hermes_home / "skills" / ".hub" / "index-cache",
|
||||
_hermes_home / "skills" / ".hub",
|
||||
|
||||
+19
-1
@@ -333,6 +333,24 @@ TOOLSETS = {
|
||||
"includes": []
|
||||
},
|
||||
|
||||
"hermes-mattermost": {
|
||||
"description": "Mattermost bot toolset - self-hosted team messaging (full access)",
|
||||
"tools": _HERMES_CORE_TOOLS,
|
||||
"includes": []
|
||||
},
|
||||
|
||||
"hermes-matrix": {
|
||||
"description": "Matrix bot toolset - decentralized encrypted messaging (full access)",
|
||||
"tools": _HERMES_CORE_TOOLS,
|
||||
"includes": []
|
||||
},
|
||||
|
||||
"hermes-dingtalk": {
|
||||
"description": "DingTalk bot toolset - enterprise messaging platform (full access)",
|
||||
"tools": _HERMES_CORE_TOOLS,
|
||||
"includes": []
|
||||
},
|
||||
|
||||
"hermes-sms": {
|
||||
"description": "SMS bot toolset - interact with Hermes via SMS (Twilio)",
|
||||
"tools": _HERMES_CORE_TOOLS,
|
||||
@@ -342,7 +360,7 @@ TOOLSETS = {
|
||||
"hermes-gateway": {
|
||||
"description": "Gateway toolset - union of all messaging platform tools",
|
||||
"tools": [],
|
||||
"includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms"]
|
||||
"includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -376,15 +376,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/0a/0896b829a39b5669a2d811e1a79598de661693685cd62b31f11d0c18e65b/av-17.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dba98603fc4665b4f750de86fbaf6c0cfaece970671a9b529e0e3d1711e8367e", size = 22071058, upload-time = "2026-03-14T14:38:43.663Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bashlex"
|
||||
version = "0.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/60/aae0bb54f9af5e0128ba90eb83d8d0d506ee8f0475c4fdda3deeda20b1d2/bashlex-0.18.tar.gz", hash = "sha256:5bb03a01c6d5676338c36fd1028009c8ad07e7d61d8a1ce3f513b7fff52796ee", size = 68742, upload-time = "2023-01-18T15:21:26.402Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/be/6985abb1011fda8a523cfe21ed9629e397d6e06fb5bae99750402b25c95b/bashlex-0.18-py2.py3-none-any.whl", hash = "sha256:91d73a23a3e51711919c1c899083890cdecffc91d8c088942725ac13e9dcfffa", size = 69539, upload-time = "2023-01-18T15:21:24.167Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.9.0"
|
||||
@@ -394,34 +385,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.42.57"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "s3transfer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/86/46898eaae75ab2185bcf2af406fb4cd1646a0bc277d5dab8ca36c30b7e5e/boto3-1.42.57.tar.gz", hash = "sha256:b598f1705f231f118a81abbfde0c5b52879b1b1997a1aba513f04d61e7b12cbd", size = 112799, upload-time = "2026-02-25T20:31:59.362Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/21/854be1e1829a33450079c1a05f89ef03a2a44bdad590de3e10dc09d73cbd/boto3-1.42.57-py3-none-any.whl", hash = "sha256:74f47051e3b741a0c1e64d57b891076c2c68f8d7b98aee36b044fab1849b4823", size = 140554, upload-time = "2026-02-25T20:31:53.215Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.42.57"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/9c/f9e289f44985fe5b2e3ffc127a55cf7e87ef88499f5a8001db86d74ecfb1/botocore-1.42.57.tar.gz", hash = "sha256:51f94c602b687a70aa11d8bbea2b741b87b0aef7bddb43e5386247bf4311c479", size = 14940952, upload-time = "2026-02-25T20:31:42.049Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/bd/89d0fdb65488d6ee40194268b07316433b41f3aa3f242676ed804c3200f5/botocore-1.42.57-py3-none-any.whl", hash = "sha256:0d26c09955e52ac5090d9cf9e218542df81670077049a606be7c3bd235208e67", size = 14614741, upload-time = "2026-02-25T20:31:39.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cachetools"
|
||||
version = "5.5.2"
|
||||
@@ -1637,7 +1600,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hermes-agent"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "anthropic" },
|
||||
@@ -1674,6 +1637,7 @@ all = [
|
||||
{ name = "elevenlabs" },
|
||||
{ name = "honcho-ai" },
|
||||
{ name = "mcp" },
|
||||
{ name = "modal" },
|
||||
{ name = "numpy" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
{ name = "pytest" },
|
||||
@@ -1685,7 +1649,6 @@ all = [
|
||||
{ name = "slack-bolt" },
|
||||
{ name = "slack-sdk" },
|
||||
{ name = "sounddevice" },
|
||||
{ name = "swe-rex", extra = ["modal"] },
|
||||
]
|
||||
cli = [
|
||||
{ name = "simple-term-menu" },
|
||||
@@ -1725,7 +1688,7 @@ messaging = [
|
||||
{ name = "slack-sdk" },
|
||||
]
|
||||
modal = [
|
||||
{ name = "swe-rex", extra = ["modal"] },
|
||||
{ name = "modal" },
|
||||
]
|
||||
pty = [
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
@@ -1758,7 +1721,7 @@ yc-bench = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-client-protocol", marker = "extra == 'acp'", specifier = ">=0.8.1,<1.0" },
|
||||
{ name = "agent-client-protocol", marker = "extra == 'acp'", specifier = ">=0.8.1,<0.9" },
|
||||
{ name = "aiohttp", marker = "extra == 'homeassistant'", specifier = ">=3.9.0,<4" },
|
||||
{ name = "aiohttp", marker = "extra == 'messaging'", specifier = ">=3.13.3,<4" },
|
||||
{ name = "aiohttp", marker = "extra == 'sms'", specifier = ">=3.9.0,<4" },
|
||||
@@ -1797,6 +1760,7 @@ requires-dist = [
|
||||
{ name = "matrix-nio", extras = ["e2e"], marker = "extra == 'matrix'", specifier = ">=0.24.0,<1" },
|
||||
{ name = "mcp", marker = "extra == 'dev'", specifier = ">=1.2.0,<2" },
|
||||
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.2.0,<2" },
|
||||
{ name = "modal", marker = "extra == 'modal'", specifier = ">=1.0.0,<2" },
|
||||
{ name = "numpy", marker = "extra == 'voice'", specifier = ">=1.24.0,<3" },
|
||||
{ name = "openai", specifier = ">=2.21.0,<3" },
|
||||
{ name = "parallel-web", specifier = ">=0.4.2,<1" },
|
||||
@@ -1819,7 +1783,6 @@ requires-dist = [
|
||||
{ name = "slack-sdk", marker = "extra == 'messaging'", specifier = ">=3.27.0,<4" },
|
||||
{ name = "slack-sdk", marker = "extra == 'slack'", specifier = ">=3.27.0,<4" },
|
||||
{ name = "sounddevice", marker = "extra == 'voice'", specifier = ">=0.4.6,<1" },
|
||||
{ name = "swe-rex", extras = ["modal"], marker = "extra == 'modal'", specifier = ">=1.4.0,<2" },
|
||||
{ name = "tenacity", specifier = ">=9.1.4,<10" },
|
||||
{ name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" },
|
||||
@@ -2150,15 +2113,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jmespath"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "joblib"
|
||||
version = "1.5.3"
|
||||
@@ -3256,18 +3210,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/41/19c65578ef9a54b3083253c68a607f099642747168fe00f3a2bceb7c3a34/peewee-3.19.0-py3-none-any.whl", hash = "sha256:de220b94766e6008c466e00ce4ba5299b9a832117d9eb36d45d0062f3cfd7417", size = 411885, upload-time = "2026-01-07T17:24:58.33Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pexpect"
|
||||
version = "4.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ptyprocess" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.1.1"
|
||||
@@ -4301,18 +4243,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "s3transfer"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "safetensors"
|
||||
version = "0.7.0"
|
||||
@@ -4547,31 +4477,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/e6/412c1e1f200ca8c32ecf10201839183e261ad61ced3ede34a66f6d4be3cf/streamlit-1.55.0-py3-none-any.whl", hash = "sha256:1e4a16449c6131696180f4ddb40ea8c51834e89c2a43e1b0362bc9b1cfd9b415", size = 9075714, upload-time = "2026-03-03T22:25:59.126Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swe-rex"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bashlex" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "pexpect" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "requests" },
|
||||
{ name = "rich" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/86/a069f93ec866151a4d476d546e60220e66b3788878b6e248b2df3ab2c5f1/swe_rex-1.4.0.tar.gz", hash = "sha256:14f8a24c49a63f9e251340b1109ac75a4aacbaece410f8599209de9bfca843c0", size = 41755, upload-time = "2025-08-14T01:19:20.22Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/0d/d06ab2aa78138055c297490762cd7b4d8ac58a544783f874c869cdb7b534/swe_rex-1.4.0-py3-none-any.whl", hash = "sha256:61261ad03eb23b717b5901cd5d229f24f6e1be2e120aad5c2e5ea3384a1d15ad", size = 47756, upload-time = "2025-08-14T01:19:18.93Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
modal = [
|
||||
{ name = "boto3" },
|
||||
{ name = "modal" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sympy"
|
||||
version = "1.14.0"
|
||||
|
||||
@@ -168,11 +168,15 @@ journalctl -u hermes-gateway -f
|
||||
|
||||
```bash
|
||||
# macOS — manage the service
|
||||
launchctl start ai.hermes.gateway
|
||||
launchctl stop ai.hermes.gateway
|
||||
hermes gateway start
|
||||
hermes gateway stop
|
||||
tail -f ~/.hermes/logs/gateway.log
|
||||
```
|
||||
|
||||
:::tip macOS PATH
|
||||
The launchd plist captures your shell PATH at install time so gateway subprocesses can find tools like Node.js and ffmpeg. If you install new tools later, re-run `hermes gateway install` to update the plist.
|
||||
:::
|
||||
|
||||
### Verify It's Running
|
||||
|
||||
```bash
|
||||
|
||||
@@ -357,6 +357,23 @@ lsof -i :8080
|
||||
hermes config show
|
||||
```
|
||||
|
||||
#### macOS: Node.js / ffmpeg / other tools not found by gateway
|
||||
|
||||
**Cause:** launchd services inherit a minimal PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) that doesn't include Homebrew, nvm, cargo, or other user-installed tool directories. This commonly breaks the WhatsApp bridge (`node not found`) or voice transcription (`ffmpeg not found`).
|
||||
|
||||
**Solution:** The gateway captures your shell PATH when you run `hermes gateway install`. If you installed tools after setting up the gateway, re-run the install to capture the updated PATH:
|
||||
|
||||
```bash
|
||||
hermes gateway install # Re-snapshots your current PATH
|
||||
hermes gateway start # Detects the updated plist and reloads
|
||||
```
|
||||
|
||||
You can verify the plist has the correct PATH:
|
||||
```bash
|
||||
/usr/libexec/PlistBuddy -c "Print :EnvironmentVariables:PATH" \
|
||||
~/Library/LaunchAgents/ai.hermes.gateway.plist
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Performance Issues
|
||||
|
||||
@@ -289,12 +289,27 @@ If you run multiple Hermes installations on the same machine (with different `HE
|
||||
### macOS (launchd)
|
||||
|
||||
```bash
|
||||
hermes gateway install
|
||||
launchctl start ai.hermes.gateway
|
||||
launchctl stop ai.hermes.gateway
|
||||
tail -f ~/.hermes/logs/gateway.log
|
||||
hermes gateway install # Install as launchd agent
|
||||
hermes gateway start # Start the service
|
||||
hermes gateway stop # Stop the service
|
||||
hermes gateway status # Check status
|
||||
tail -f ~/.hermes/logs/gateway.log # View logs
|
||||
```
|
||||
|
||||
The generated plist lives at `~/Library/LaunchAgents/ai.hermes.gateway.plist`. It includes three environment variables:
|
||||
|
||||
- **PATH** — your full shell PATH at install time, with the venv `bin/` and `node_modules/.bin` prepended. This ensures user-installed tools (Node.js, ffmpeg, etc.) are available to gateway subprocesses like the WhatsApp bridge.
|
||||
- **VIRTUAL_ENV** — points to the Python virtualenv so tools can resolve packages correctly.
|
||||
- **HERMES_HOME** — scopes the gateway to your Hermes installation.
|
||||
|
||||
:::tip PATH changes after install
|
||||
launchd plists are static — if you install new tools (e.g. a new Node.js version via nvm, or ffmpeg via Homebrew) after setting up the gateway, run `hermes gateway install` again to capture the updated PATH. The gateway will detect the stale plist and reload automatically.
|
||||
:::
|
||||
|
||||
:::info Multiple installations
|
||||
Like the Linux systemd service, each `HERMES_HOME` directory gets its own launchd label. The default `~/.hermes` uses `ai.hermes.gateway`; other installations use `ai.hermes.gateway-<suffix>`.
|
||||
:::
|
||||
|
||||
## Platform-Specific Toolsets
|
||||
|
||||
Each platform has its own toolset:
|
||||
|
||||
@@ -36,22 +36,6 @@ brew install signal-cli
|
||||
# Extract and add to PATH
|
||||
```
|
||||
|
||||
### Alternative: Docker (signal-cli-rest-api)
|
||||
|
||||
If you prefer Docker, use the [signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api) container:
|
||||
|
||||
```bash
|
||||
docker run -d --name signal-cli \
|
||||
-p 8080:8080 \
|
||||
-v $HOME/.local/share/signal-cli:/home/.local/share/signal-cli \
|
||||
-e MODE=json-rpc \
|
||||
bbernhard/signal-cli-rest-api
|
||||
```
|
||||
|
||||
:::tip
|
||||
Use `MODE=json-rpc` for best performance. The `normal` mode spawns a JVM per request and is much slower.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Link Your Signal Account
|
||||
|
||||
@@ -114,7 +114,22 @@ Without these events, Slack simply never delivers channel messages to the bot.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Install App to Workspace
|
||||
## Step 5: Enable the Messages Tab
|
||||
|
||||
This step enables direct messages to the bot. Without it, users see **"Sending messages to this app has been turned off"** when trying to DM the bot.
|
||||
|
||||
1. In the sidebar, go to **Features → App Home**
|
||||
2. Scroll to **Show Tabs**
|
||||
3. Toggle **Messages Tab** to ON
|
||||
4. Check **"Allow users to send Slash commands and messages from the messages tab"**
|
||||
|
||||
:::danger Without this step, DMs are completely blocked
|
||||
Even with all the correct scopes and event subscriptions, Slack will not allow users to send direct messages to the bot unless the Messages Tab is enabled. This is a Slack platform requirement, not a Hermes configuration issue.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Install App to Workspace
|
||||
|
||||
1. In the sidebar, go to **Settings → Install App**
|
||||
2. Click **Install to Workspace**
|
||||
@@ -129,7 +144,7 @@ to take effect. The Install App page will show a banner prompting you to do so.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Find User IDs for the Allowlist
|
||||
## Step 7: Find User IDs for the Allowlist
|
||||
|
||||
Hermes uses Slack **Member IDs** (not usernames or display names) for the allowlist.
|
||||
|
||||
@@ -144,7 +159,7 @@ Member IDs look like `U01ABC2DEF3`. You need your own Member ID at minimum.
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Configure Hermes
|
||||
## Step 8: Configure Hermes
|
||||
|
||||
Add the following to your `~/.hermes/.env` file:
|
||||
|
||||
@@ -175,7 +190,7 @@ sudo hermes gateway install --system # Linux only: boot-time system service
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Invite the Bot to Channels
|
||||
## Step 9: Invite the Bot to Channels
|
||||
|
||||
After starting the gateway, you need to **invite the bot** to any channel where you want it to respond:
|
||||
|
||||
@@ -239,6 +254,7 @@ Hermes supports voice on Slack:
|
||||
| Bot works in DMs but not in channels | **Most common issue.** Add `message.channels` and `message.groups` to event subscriptions, reinstall the app, and invite the bot to the channel with `/invite @Hermes Agent` |
|
||||
| Bot doesn't respond to @mentions in channels | 1) Check `message.channels` event is subscribed. 2) Bot must be invited to the channel. 3) Ensure `channels:history` scope is added. 4) Reinstall the app after scope/event changes |
|
||||
| Bot ignores messages in private channels | Add both the `message.groups` event subscription and `groups:history` scope, then reinstall the app and `/invite` the bot |
|
||||
| "Sending messages to this app has been turned off" in DMs | Enable the **Messages Tab** in App Home settings (see Step 5) |
|
||||
| "not_authed" or "invalid_auth" errors | Regenerate your Bot Token and App Token, update `.env` |
|
||||
| Bot responds but can't post in a channel | Invite the bot to the channel with `/invite @Hermes Agent` |
|
||||
| "missing_scope" error | Add the required scope in OAuth & Permissions, then **reinstall** the app |
|
||||
|
||||
@@ -173,6 +173,7 @@ whatsapp:
|
||||
| **Logged out unexpectedly** | WhatsApp unlinks devices after long inactivity. Keep the phone on and connected to the network, then re-pair with `hermes whatsapp` if needed. |
|
||||
| **Bridge crashes or reconnect loops** | Restart the gateway, update Hermes, and re-pair if the session was invalidated by a WhatsApp protocol change. |
|
||||
| **Bot stops working after WhatsApp update** | Update Hermes to get the latest bridge version, then re-pair. |
|
||||
| **macOS: "Node.js not installed" but node works in terminal** | launchd services don't inherit your shell PATH. Run `hermes gateway install` to re-snapshot your current PATH into the plist, then `hermes gateway start`. See the [Gateway Service docs](./index.md#macos-launchd) for details. |
|
||||
| **Messages not being received** | Verify `WHATSAPP_ALLOWED_USERS` includes the sender's number (with country code, no `+` or spaces). |
|
||||
| **Bot replies to strangers with a pairing code** | Set `whatsapp.unauthorized_dm_behavior: ignore` in `~/.hermes/config.yaml` if you want unauthorized DMs to be silently ignored instead. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user