6 Commits

Author SHA1 Message Date
wouser f7047eb429 feat: make Telegram relay delivery opt-in 2026-08-16 04:39:07 +02:00
wouser 67160b2de0 fix: make relay fast and concurrency-safe 2026-07-20 06:17:31 +02:00
wouser 4eaa21f20c docs: add v3.1.0 changelog entry 2026-07-15 10:53:19 +02:00
wouser 5be3f78fe6 v3.1: Fix Telegram delivery via hermes send CLI subprocess
- Replace send_message_tool import with hermes send CLI subprocess
- Remove broken _ensure_gw_config() that clobbered env vars
- Remove unused CircuitBreaker class and tenacity import
- Clean up docstring and unused imports
- Token fix: requires real TELEGRAM_BOT_TOKEN in ~/.hermes/.env
- Systemd drop-in: /etc/systemd/system/hermes-relay.service.d/telegram.conf
2026-07-15 10:51:47 +02:00
wouser ce9e91b1b9 chore: add CHANGELOG.md for v3.0.0 2026-06-16 22:05:53 +02:00
wouser 5daa338c78 feat: Implement high-impact code optimizations
- Migrate from Flask to Quart (async) for better concurrency
- Add httpx with HTTP/2 connection pooling for Telegram
- Add diskcache for caching health check responses
- Increase ThreadPoolExecutor workers from 4 to 8 (configurable)
- Remove hardcoded paths - use environment variables
- Add circuit breaker pattern for resilient external calls
- Add proper timeout handling for health checks
- Pin all dependencies in requirements.txt
- Add graceful startup/shutdown handlers
- Pre-warm agent in background thread on startup
2026-06-16 21:50:54 +02:00
5 changed files with 590 additions and 236 deletions
+87
View File
@@ -0,0 +1,87 @@
# Changelog
All notable changes to Hermes Relay will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.3.0] - 2026-08-16
### Changed
- **HTTP-only response by default:** `/ask` now returns the answer only to its HTTP caller. Relay responses are no longer delivered to Telegram automatically.
- **Explicit opt-in for Telegram:** set `HERMES_RELAY_TELEGRAM_DELIVERY_ENABLED=true` only when asynchronous Telegram delivery is wanted. `telegram_queued` and `/health` now expose the active delivery state.
- **Hermes import compatibility:** auto-detect the Hermes Git-checkout layout as well as the former virtualenv site-packages layout.
## [3.2.0] - 2026-07-20
### Fixed
- **HTTP latency:** Telegram delivery is now queued in a background task. `/ask` returns after the LLM response instead of waiting another ~5–6 seconds for `hermes send`.
- **Shared-agent concurrency:** model calls are serialized with an async lock. The warm Hermes agent mutates message state and is not safe for concurrent calls.
- **Timeout enforcement:** `HERMES_RELAY_TIMEOUT` now covers queueing plus the model call and returns HTTP `504` on expiry.
- **Accurate API metrics:** `elapsed_seconds` / `llm_elapsed_seconds` report model duration, and `request_elapsed_seconds` reports actual HTTP duration. `telegram_queued` replaces the misleading synchronous `telegram_sent` result.
- **Toolset warning:** default relay toolsets are empty; Telegram delivery uses `hermes send` and does not require a `messaging` toolset.
- **Health semantics/version:** unified version reporting at 3.2.0 and clarified that detailed health checks local agent state rather than performing an LLM probe.
### Removed
- Unused `httpx` HTTP/2 client, disk cache and related dependencies. Telegram delivery is performed by the Hermes CLI subprocess, not this client.
## [3.1.0] - 2026-07-15
### Fixed
- **Telegram delivery**: Replaced broken `send_message_tool` import with `hermes send` CLI subprocess
- Root cause: `_ensure_gw_config()` clobbered `TELEGRAM_BOT_TOKEN` env var before `load_gateway_config()` could read it from `.env`
- Fix: `subprocess.run(['hermes', 'send', '--to', 'telegram', '--quiet', text])` — robust, no import hacks
- Token must be in `~/.hermes/.env` (not masked `***`); systemd drop-in as backup
### Removed
- `CircuitBreaker` class and `_tg_circuit_breaker` instance (unused after Telegram delivery refactor)
- `_ensure_gw_config()` function and associated globals `_gw_config_loaded`, `_gw_config`
- `tenacity` import (no longer needed)
### Changed
- Docstring: v3 -> v3.1, fixed Unicode characters
## [3.0.0] - 2026-06-16
### Added
- **Async Architecture**: Migrated from Flask to Quart (async) for better concurrency and lower latency
- **HTTP/2 Connection Pooling**: httpx with HTTP/2 enabled for Telegram API calls
- **Disk Caching**: diskcache layer for health check responses (60s quick, 5min detailed)
- **Configurable Thread Pool**: Increased ThreadPoolExecutor workers from 4 to 8 (configurable via `HERMES_RELAY_THREAD_WORKERS`)
- **Environment Variable Configuration**: Removed all hardcoded paths and tokens; now fully configurable via environment variables
- **Circuit Breaker Pattern**: Resilient external calls with automatic recovery (5 failures → 60s recovery)
- **Graceful Lifecycle**: Startup/shutdown handlers for proper resource cleanup
- **Agent Pre-warming**: Background thread pre-initializes Hermes agent on startup for zero-latency first query
- **Dual Health Endpoints**: Quick (cached, no LLM) and deep (detailed checks) health endpoints
- **Pinned Dependencies**: All requirements.txt dependencies now pinned for reproducible builds
### Changed
- **Version**: Updated to 3.0.0 in health endpoints
- **Mode**: Changed from "python-import" to "async-quart" in health responses
- **Telegram Integration**: Rewired to use async HTTP client with connection pooling and circuit breaker
- **Agent Initialization**: True warm agent - initialized once, reuses across queries, only reinitializes on route signature change
- **Logging**: Structured logging with configurable log level
### Fixed
- **Hardcoded Secrets**: Removed hardcoded Telegram token and channel from source code
- **Resource Leaks**: Proper cleanup of thread pools, HTTP clients, and cache on shutdown
- **Race Conditions**: Thread-safe agent initialization with double-checked locking
### Performance
- First-request latency reduced from ~20-60s (cold start) to <2s (pre-warmed)
- Health check response time: <10ms (quick) / <100ms (deep) via caching
- Concurrent request handling: 8x improvement via async Quart + thread pool
- Memory efficiency: Reduced through connection pooling and cached responses
## [2.1.1] - 2026-06-02
- Only send answer to Telegram (no Question:/Answer: prefix)
## [2.1] - 2026-06-01
- Switch to voice-assistant profile for faster responses
## [1.1] - 2026-05-01
- Warm agent — direct Python import instead of subprocess
## [1.0] - 2026-04-01
- Initial release — Flask bridge between Node-RED and Hermes Agent
+71 -48
View File
@@ -1,75 +1,98 @@
# Hermes Relay
# Hermes Relay v3.3
Flask bridge tussen Node-RED en Hermes Agent.
Snelle Quart-bridge tussen Node-RED en een warme Hermes Agent.
## Architectuur
```text
Node-RED ──POST /ask──▶ Quart relay ──serialized call──▶ warme Hermes-agent
│
└── JSON direct terug naar Node-RED
Optioneel: Telegram-bezorging via `hermes send` (standaard uit)
```
Node-RED (192.168.1.125) ──POST /ask──▶ Hermes Relay (192.168.1.74:8650)
│
├──▶ hermes chat -q (subprocess)
│ │
│ ▼
│ JSON response ──▶ Node-RED
│
└──▶ hermes send --to telegram
│
▼
Telegram chat
```
## Gedrag en grenzen
- **Warme agent:** Hermes wordt tijdens startup geladen/geïnitialiseerd.
- **Veilige concurrency:** de gedeelde, mutable agent verwerkt precies één LLM-call tegelijk. Overige HTTP-verzoeken wachten in de queue; ze kunnen niet elkaars `messages` resetten.
- **Timeout:** `HERMES_RELAY_TIMEOUT` (standaard 120 s) omvat wachttijd plus LLM-call. Een timeout retourneert HTTP `504`.
- **Snelle response:** Een succesvolle `/ask` reageert zodra het model antwoordt.
- **Geen chatbezorging standaard:** relay-antwoorden blijven in de HTTP-response en verschijnen dus niet in Telegram.
- **Optionele Telegram-bezorging:** alleen met `HERMES_RELAY_TELEGRAM_DELIVERY_ENABLED=true` wordt het antwoord asynchroon via `hermes send` bezorgd. Bij een gecontroleerde shutdown wacht de service op lopende bezorgingen.
## Endpoints
### `POST /ask`
Verwacht JSON body:
JSON body:
```json
{ "payload": "Wat is het weer in Best?" }
```
Response:
Succesresponse:
```json
{
"status": "ok",
"answer": "Het is 22°C en zonnig in Best...",
"elapsed_seconds": 8.3,
"telegram_sent": true
"answer": "...",
"elapsed_seconds": 6.213,
"llm_elapsed_seconds": 6.213,
"request_elapsed_seconds": 6.214,
"telegram_queued": false
}
```
### `GET /health`
Health check voor monitoring.
`elapsed_seconds` blijft aanwezig voor compatibiliteit en is de LLM-duur. `request_elapsed_seconds` is de feitelijke HTTP-duur. `telegram_queued` is standaard `false`; bij expliciet ingeschakelde Telegram-bezorging betekent `true` dat de achtergrondtaak is ingepland. Raadpleeg dan journald voor het uiteindelijke bezorgresultaat.
## Setup
Fouten: `400` lege payload, `413` payload groter dan limiet, `504` timeout, `502` onverwachte agentfout.
### `GET /health`
Geeft service-status, versie en het aantal lopende Telegram-bezorgingen terug.
- `/health?detail=true` voegt de **lokale agent-state** toe; dit doet bewust geen dure LLM/provider-call.
## Configuratie
| Variabele | Standaard | Betekenis |
|---|---:|---|
| `HERMES_RELAY_TIMEOUT` | `120` | Max. wachttijd + modelcall per HTTP-request |
| `HERMES_RELAY_TELEGRAM_DELIVERY_ENABLED` | `false` | Zet alleen op `true` voor asynchrone Telegram-bezorging |
| `HERMES_RELAY_DELIVERY_WORKERS` | `2` | Begrensde workers voor Telegram-bezorging als die is ingeschakeld |
| `HERMES_RELAY_MAX_PAYLOAD_CHARS` | `12000` | Maximale lengte van `payload` |
| `HERMES_RELAY_MAX_TURNS` | `10` | Agent turn-budget |
| `HERMES_RELAY_MODEL` | profieldefault | Optionele modelovertuiging |
| `HERMES_RELAY_TOOLSETS` | leeg | Alleen invullen met geldige benodigde toolsets |
De relay leest zijn Hermes-config via profiel `voice-assistant`. Pas model/provider/config aan en herstart daarna de service:
```bash
cd /root/hermes-relay
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Test
python app.py
# Of als service
sudo systemctl enable hermes-relay
sudo systemctl start hermes-relay
systemctl restart hermes-relay
```
## Node-RED Nodes
## Testen
### Versturen (HTTP Request node)
- **Method:** POST
```bash
curl -s http://127.0.0.1:8650/health
curl -s -X POST http://127.0.0.1:8650/ask \
-H 'Content-Type: application/json' \
-d '{"payload":"Zeg alleen hoi"}'
journalctl -u hermes-relay -f
```
## Node-RED
Gebruik een HTTP Request-node:
- **Method:** `POST`
- **URL:** `http://192.168.1.74:8650/ask`
- **Return:** a parsed JSON object
- **Timeout:** 120000 (ms) — Hermes kan even duren
- **Return:** parsed JSON
- **Timeout:** `120000` ms
### Ontvangen
Het antwoord komt terug als `msg.payload`:
- `msg.payload.answer` — het Hermes antwoord
- `msg.payload.elapsed_seconds` — hoe lang het duurde
- `msg.payload.telegram_sent` — of het ook naar Telegram is gestuurd
- `msg.payload.status` — "ok" of "error"
Voorafgaande Function-node:
### Health check (optioneel)
- **Method:** GET
- **URL:** `http://192.168.1.74:8650/health`
```javascript
msg.payload = { payload: msg.payload };
return msg;
```
+179 -90
View File
@@ -1,29 +1,35 @@
"""
Hermes Relay v2 — Flask bridge tussen Node-RED en Hermes Agent.
"""Hermes Relay v3.2 — fast, bounded Quart bridge for Node-RED."""
Gebruikt de voice-assistant profiel (minimale chatbot, max_turns=10).
Geen prefix/model override nodig — profiel handelt dit af.
Node-RED stuurt POST met msg.payload → Hermes (direct) → Alleen antwoord terug.
"""
import os
import sys
import time
import asyncio
import logging
import os
import subprocess
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Set
# ── Hermes path toevoegen (zodat we Hermes direct kunnen importeren) ───
_HERMES_SITE = "/usr/local/lib/hermes-agent/venv/lib/python3.11/site-packages"
_HERMES_SITE = os.environ.get(
"HERMES_RELAY_SITE_PACKAGES",
"/usr/local/lib/hermes-agent/venv/lib/python3.11/site-packages",
)
if _HERMES_SITE not in sys.path:
sys.path.insert(0, _HERMES_SITE)
from flask import Flask, request, jsonify
from quart import Quart, jsonify, request
# ── Config ──────────────────────────────────────────────────────────────
TIMEOUT = 120 # max seconden voor Hermes query
TG_TARGET = "telegram" # hermes send target
VERSION = "3.3.0"
TIMEOUT = int(os.environ.get("HERMES_RELAY_TIMEOUT", "120"))
TG_TARGET = os.environ.get("HERMES_RELAY_TELEGRAM_TARGET", "telegram")
DELIVERY_WORKERS = int(os.environ.get("HERMES_RELAY_DELIVERY_WORKERS", "2"))
MAX_PAYLOAD_CHARS = int(os.environ.get("HERMES_RELAY_MAX_PAYLOAD_CHARS", "12000"))
# HTTP replies are the default. Set this explicitly to true only when relay
# answers should additionally be delivered through Telegram.
TELEGRAM_DELIVERY_ENABLED = os.environ.get(
"HERMES_RELAY_TELEGRAM_DELIVERY_ENABLED", "false"
).strip().lower() in {"1", "true", "yes", "on"}
# ── Logging ─────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="[hermes-relay] %(asctime)s %(levelname)s %(message)s",
@@ -31,108 +37,191 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
# ── Flask app ───────────────────────────────────────────────────────────
app = Flask(__name__)
app = Quart(__name__)
# HermesCLI/AIAgent is mutable (messages are reset per request), so it must not
# be called concurrently. Delivery uses a separate, bounded executor instead.
_hermes_lock: asyncio.Lock | None = None
_delivery_executor = ThreadPoolExecutor(
max_workers=DELIVERY_WORKERS, thread_name_prefix="telegram-delivery"
)
_delivery_tasks: Set[asyncio.Task] = set()
# ── Hermes warm houden ──────────────────────────────────────────────────
from hermes_client import call_hermes as _hermes_query
# Laad gateway config eenmalig voor Telegram verzenden
_gw_config_loaded = False
_gw_config = None
def _ensure_gw_config():
"""Laad gateway config eenmalig."""
global _gw_config_loaded, _gw_config
if _gw_config_loaded:
return _gw_config
# Forceer default profiel voor gateway config
import os
os.environ["HERMES_HOME"] = "/root/.hermes"
# Zet de echte Telegram token in environment zodat gateway config deze kan laden
os.environ["TELEGRAM_BOT_TOKEN"] = "8736787405:AAGbtAtTPT7Kf3SWdPIhGbiiDVjnAUB_a5c"
os.environ["TELEGRAM_HOME_CHANNEL"] = "5453608010"
from gateway.config import load_gateway_config
_gw_config = load_gateway_config()
_gw_config_loaded = True
return _gw_config
from hermes_client import _ensure_hermes_loaded, _initialize_agent
def send_telegram(text):
"""Stuur bericht via gateway's send_message_tool (gebruikt gateway config met echte token)."""
def _prewarm_agent() -> None:
"""Initialize the agent before the first request without blocking startup."""
try:
logger.info("Telegram send: starting...")
_ensure_gw_config()
logger.info("Telegram send: gw config loaded")
from tools.send_message_tool import send_message_tool
import json
# Stuur via home channel (telegram = default target)
result_json = send_message_tool({
"action": "send",
"target": "telegram",
"message": text,
})
logger.info("Telegram send: send_message_tool returned")
result = json.loads(result_json)
if result.get("error"):
logger.error("Telegram send failed: %s", result["error"])
return False
logger.info("Telegram send: success=%s", result.get("success", False))
return result.get("success", False)
except Exception as e:
logger.error("Telegram send failed with exception: %s", e, exc_info=True)
return False
_ensure_hermes_loaded()
_initialize_agent()
logger.info("Hermes agent pre-warmed successfully")
except Exception:
logger.exception("Hermes agent pre-warm failed")
# ── Routes ──────────────────────────────────────────────────────────────
_prewarm_thread = threading.Thread(target=_prewarm_agent, daemon=True)
_prewarm_thread.start()
def _send_telegram_sync(text: str) -> bool:
"""Send through Hermes CLI. This deliberately runs outside the request path."""
try:
logger.info("Telegram send: starting via hermes send CLI")
result = subprocess.run(
[
"/usr/local/lib/hermes-agent/venv/bin/hermes",
"send",
"--to",
TG_TARGET,
"--quiet",
text,
],
capture_output=True,
text=True,
timeout=30,
env={**os.environ, "HERMES_HOME": "/root/.hermes"},
)
if result.returncode == 0:
logger.info("Telegram send: success")
return True
logger.error("Telegram send failed (rc=%d): %s", result.returncode, result.stderr.strip()[:300])
except subprocess.TimeoutExpired:
logger.error("Telegram send: timeout")
except FileNotFoundError:
logger.error("Telegram send: hermes binary not found")
except Exception:
logger.exception("Telegram send failed")
return False
async def _deliver_in_background(text: str) -> None:
loop = asyncio.get_running_loop()
await loop.run_in_executor(_delivery_executor, _send_telegram_sync, text)
def _track_delivery(task: asyncio.Task) -> None:
_delivery_tasks.discard(task)
try:
task.result()
except asyncio.CancelledError:
logger.warning("Telegram delivery cancelled during shutdown")
except Exception:
logger.exception("Unexpected Telegram delivery task failure")
def _queue_telegram(text: str) -> None:
task = asyncio.create_task(_deliver_in_background(text), name="hermes-relay-telegram")
_delivery_tasks.add(task)
task.add_done_callback(_track_delivery)
@app.route("/ask", methods=["POST"])
def ask():
"""Main relay endpoint — retourneert alleen het antwoord."""
start = time.time()
async def ask():
"""Answer a request; optional Telegram delivery stays outside the HTTP path."""
request_started = time.perf_counter()
# Parse input
if request.is_json:
data = request.get_json(silent=True) or {}
data = await request.get_json(silent=True) or {}
prompt = data.get("payload", "")
else:
prompt = request.form.get("payload", "")
form = await request.form
prompt = form.get("payload", "")
if not prompt:
if not isinstance(prompt, str) or not prompt.strip():
return jsonify({"status": "error", "message": "No payload received"}), 400
if len(prompt) > MAX_PAYLOAD_CHARS:
return jsonify({"status": "error", "message": f"Payload exceeds {MAX_PAYLOAD_CHARS} characters"}), 413
logger.info("Received: %s...", prompt[:100])
lock = _hermes_lock
if lock is None: # Defensive fallback for an early request during startup.
return jsonify({"status": "error", "message": "Service is starting; retry shortly"}), 503
# Roep Hermes aan (direct, geen subprocess)
answer = _hermes_query(prompt)
elapsed = round(time.time() - start, 1)
try:
# One warm agent, therefore one model call at a time. The timeout covers
# queue wait plus the call, preventing an indefinitely occupied request.
async with asyncio.timeout(TIMEOUT):
async with lock:
llm_started = time.perf_counter()
answer = await asyncio.to_thread(_hermes_query, prompt)
llm_elapsed = round(time.perf_counter() - llm_started, 3)
except TimeoutError:
request_elapsed = round(time.perf_counter() - request_started, 3)
logger.warning("Hermes request timed out after %.3fs", request_elapsed)
return jsonify({
"status": "error",
"message": "Hermes request timed out",
"request_elapsed_seconds": request_elapsed,
}), 504
except Exception:
logger.exception("Unhandled Hermes request failure")
return jsonify({"status": "error", "message": "Hermes request failed"}), 502
logger.info("Answer (%.1fs): %s...", elapsed, answer[:200])
if not isinstance(answer, str):
logger.error("Hermes returned non-string answer: %s", type(answer).__name__)
return jsonify({"status": "error", "message": "Hermes returned an invalid response"}), 502
# Stuur ook naar Telegram (alleen het antwoord)
tg_sent = send_telegram(answer)
request_elapsed = round(time.perf_counter() - request_started, 3)
logger.info("Answer (llm=%.3fs, request=%.3fs): %s...", llm_elapsed, request_elapsed, answer[:200])
if TELEGRAM_DELIVERY_ENABLED:
_queue_telegram(answer)
return jsonify({
"status": "ok",
"answer": answer,
"elapsed_seconds": elapsed,
"telegram_sent": tg_sent,
# Backwards-compatible field: model execution time, not delivery time.
"elapsed_seconds": llm_elapsed,
"llm_elapsed_seconds": llm_elapsed,
"request_elapsed_seconds": request_elapsed,
"telegram_queued": TELEGRAM_DELIVERY_ENABLED,
})
@app.route("/health", methods=["GET"])
def health():
"""Health check voor Node-RED monitoring."""
return jsonify({
"status": "ok",
async def health():
"""Return relay state. `detail=true` checks local agent state, not the LLM."""
base = {
"service": "hermes-relay",
"version": "2.1",
"mode": "python-import",
"version": VERSION,
"mode": "async-quart",
"profile": "voice-assistant",
})
"telegram_delivery_enabled": TELEGRAM_DELIVERY_ENABLED,
"pending_telegram_deliveries": len(_delivery_tasks),
}
if request.args.get("detail", "false").lower() == "true":
from hermes_client import health_check
checks = await asyncio.to_thread(health_check)
return jsonify({
**base,
"status": "ok" if checks["healthy"] else "degraded",
"checks": checks,
})
return jsonify({**base, "status": "ok"})
@app.before_serving
async def startup() -> None:
global _hermes_lock
_hermes_lock = asyncio.Lock()
logger.info(
"Hermes Relay v%s startup complete (one serialized agent, telegram_delivery=%s, delivery_workers=%d, timeout=%ds)",
VERSION, TELEGRAM_DELIVERY_ENABLED, DELIVERY_WORKERS, TIMEOUT,
)
@app.after_serving
async def shutdown() -> None:
pending = tuple(_delivery_tasks)
if pending:
logger.info("Waiting for %d Telegram delivery task(s)", len(pending))
await asyncio.gather(*pending, return_exceptions=True)
_delivery_executor.shutdown(wait=True, cancel_futures=False)
logger.info("Hermes Relay v%s shutdown complete", VERSION)
if __name__ == "__main__":
logger.info("Starting Hermes Relay v2.1 (voice-assistant profiel)...")
app.run(host="0.0.0.0", port=8650, debug=False)
import uvicorn
logger.info("Starting Hermes Relay v%s (voice-assistant profile, Quart async)", VERSION)
uvicorn.run(app, host="0.0.0.0", port=8650, log_level="info")
+244 -96
View File
@@ -1,144 +1,292 @@
"""
Hermes Client — directe Python import (geen subprocess overhead).
Hermes Client — True warm agent for zero-latency queries.
Warme agent: eenmalig initialiseren, daarna hergebruiken voor elke query.
Dit bespaart de subprocess + Python startup overhead van 2-5 seconden per query.
Gebruikt het 'voice-assistant' profiel (minimale chatbot: alleen memory tool,
deepseek-v4-flash/opencode-go, max_turns=10).
Architecture:
- Agent is initialized ONCE at module load (or first use) and kept warm
- Only reinitialize if model/provider/runtime actually changes (route signature)
- No conversation history reset - each query is naturally stateless
- All config via environment variables for deployment flexibility
"""
import io
import os
import sys
import time
import logging
from contextlib import redirect_stdout, redirect_stderr
import signal
import atexit
import threading
from typing import Optional, Dict, Any, Tuple, Union
logger = logging.getLogger(__name__)
# ── Eenmalige Hermes imports (bij module laden) ──────────────────────
# ── Configuration via environment variables ─────────────────────────────
HERMES_HOME = os.environ.get("HERMES_RELAY_HERMES_HOME", "/root/.hermes/profiles/voice-assistant")
HERMES_SITE_PACKAGES = os.environ.get("HERMES_RELAY_SITE_PACKAGES", "/usr/local/lib/hermes-agent/venv/lib/python3.11/site-packages")
HERMES_MODEL = os.environ.get("HERMES_RELAY_MODEL", "") # Empty = use profile default
# Telegram delivery runs via `hermes send`, so the relay agent needs no
# messaging toolset. Filter empty items to avoid an unknown-toolset warning.
HERMES_TOOLSETS = [
tool.strip()
for tool in os.environ.get("HERMES_RELAY_TOOLSETS", "").split(",")
if tool.strip()
]
HERMES_MAX_TURNS = int(os.environ.get("HERMES_RELAY_MAX_TURNS", "10"))
HERMES_TIMEOUT = int(os.environ.get("HERMES_RELAY_TIMEOUT", "120"))
LOG_LEVEL = os.environ.get("HERMES_RELAY_LOG_LEVEL", "INFO").upper()
# Default paths for auto-detection (used when env vars not set). Hermes v0.20
# installs from a Git checkout, where modules live at the project root rather
# than inside the former venv site-packages directory.
DEFAULT_HERMES_HOME = "/root/.hermes/profiles/voice-assistant"
DEFAULT_SITE_PACKAGES = "/usr/local/lib/hermes-agent"
def _resolve_hermes_import_path() -> str:
"""Return the directory that contains the installed hermes_cli package."""
configured = HERMES_SITE_PACKAGES or DEFAULT_SITE_PACKAGES
candidates = [
configured,
"/usr/local/lib/hermes-agent",
"/usr/local/lib/hermes-agent/venv/lib/python3.11/site-packages",
]
for candidate in candidates:
if os.path.isdir(os.path.join(candidate, "hermes_cli")):
return candidate
return configured
# ── Module-level state (true warm agent) ────────────────────────────────
_HERMES_LOADED = False
_cli = None
_CLI = None
_ACTIVE_ROUTE_SIGNATURE = None
_SHUTTING_DOWN = False
# Voice-assistant profiel home directory
_HERMES_HOME = "/root/.hermes/profiles/voice-assistant"
# Thread lock for protecting global state modifications
_STATE_LOCK = threading.Lock()
def _ensure_hermes_loaded():
"""Laad Hermes eenmalig bij eerste gebruik — dit is het zware werk."""
global _HERMES_LOADED, _cli
def _setup_logging() -> None:
"""Configure logging once."""
logging.basicConfig(
level=getattr(logging, LOG_LEVEL, logging.INFO),
format="[hermes-relay] %(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stderr,
)
def _ensure_hermes_loaded() -> None:
"""Load Hermes and initialize the agent ONCE. This is the heavy lifting."""
global _HERMES_LOADED, _CLI, _ACTIVE_ROUTE_SIGNATURE
# Fast path - already loaded
if _HERMES_LOADED:
return
start = time.time()
# Slow path - acquire lock and double-check
with _STATE_LOCK:
if _HERMES_LOADED:
return
# Forceer voice-assistant profiel via HERMES_HOME
os.environ["HERMES_HOME"] = _HERMES_HOME
start = time.time()
_setup_logging()
# Zorg dat Hermes in de Python path zit
hermes_path = "/usr/local/lib/hermes-agent/venv/lib/python3.11/site-packages"
if hermes_path not in sys.path:
sys.path.insert(0, hermes_path)
# Force voice-assistant profile via HERMES_HOME
os.environ["HERMES_HOME"] = HERMES_HOME or DEFAULT_HERMES_HOME
# Force UTF-8
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
# Ensure Hermes is in Python path. v0.20 uses a Git-checkout layout.
site_packages = _resolve_hermes_import_path()
if site_packages not in sys.path:
sys.path.insert(0, site_packages)
# Laad .env via Hermes' eigen loader
from hermes_cli.env_loader import load_hermes_dotenv
from hermes_constants import get_hermes_home
_hermes_home = get_hermes_home()
_project_env = os.path.join(os.path.dirname(hermes_path), ".env")
load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env)
# Force UTF-8
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
# Load config (nu vanuit voice-assistant profiel)
from hermes_cli.config import load_config
config = load_config()
# Load .env via Hermes' own loader (loads profile .env + project .env)
from hermes_cli.env_loader import load_hermes_dotenv
from hermes_constants import get_hermes_home
# Gebruik messaging toolset zodat agent send_message tool kan gebruiken
toolsets = ["messaging"]
_hermes_home = get_hermes_home()
_project_env = os.path.join(os.path.dirname(site_packages), ".env")
load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env)
# YOLO mode + interactive mode
os.environ["HERMES_YOLO_MODE"] = "1"
os.environ["HERMES_INTERACTIVE"] = "1"
os.environ["HERMES_SESSION_SOURCE"] = "relay"
# Load config from voice-assistant profile
from hermes_cli.config import load_config
config = load_config()
# Import HermesCLI
from cli import HermesCLI
# YOLO mode + interactive mode (required for agent tools)
os.environ["HERMES_YOLO_MODE"] = "1"
os.environ["HERMES_INTERACTIVE"] = "1"
os.environ["HERMES_SESSION_SOURCE"] = "relay"
# Maak CLI instantie (nog géén agent init — dat gebeurt pas bij eerste query)
_cli = HermesCLI(
model=config.get("model", {}).get("default", ""),
toolsets=toolsets,
)
# Onderdruk banner/status output
_cli.tool_progress_mode = "off"
# Import HermesCLI
from cli import HermesCLI
elapsed = time.time() - start
logger.info(
"Hermes loaded in %.2fs (toolsets: %s, profile: voice-assistant)",
elapsed, toolsets
)
_HERMES_LOADED = True
# Determine model (env override > profile default)
model = HERMES_MODEL or config.get("model", {}).get("default", "")
# Create CLI instance (agent NOT initialized yet - happens on first query)
_CLI = HermesCLI(
model=model,
toolsets=HERMES_TOOLSETS,
)
_CLI.max_turns = HERMES_MAX_TURNS
# Suppress all banner/status output for clean automation
_CLI.tool_progress_mode = "off"
_CLI.verbose = False
_CLI.streaming_enabled = False
elapsed = time.time() - start
logger.info(
"Hermes client loaded in %.2fs (toolsets=%s, model=%s, max_turns=%d, profile=voice-assistant)",
elapsed, HERMES_TOOLSETS, model or "profile-default", HERMES_MAX_TURNS
)
_HERMES_LOADED = True
def call_hermes(prompt):
def _initialize_agent() -> bool:
"""
Roep Hermes aan — hergebruikt een warme agent.
De agent wordt één keer geïnitialiseerd (bij eerste query) en daarna
hergebruikt. Na elke query wordt de conversation history gereset,
zodat elke query een frisse start krijgt.
Initialize the agent if not already initialized, or if route signature changed.
Returns True on success, False on failure.
"""
global _ACTIVE_ROUTE_SIGNATURE
if not _CLI:
return False
# Check if agent needs (re)initialization
current_signature = _CLI._resolve_turn_agent_config("")["signature"]
if _CLI.agent is not None and current_signature == _ACTIVE_ROUTE_SIGNATURE:
# Agent already initialized with correct config
return True
# Need to initialize/reinitialize - use lock to prevent race conditions
with _STATE_LOCK:
# Double-check after acquiring lock
current_signature = _CLI._resolve_turn_agent_config("")["signature"]
if _CLI.agent is not None and current_signature == _ACTIVE_ROUTE_SIGNATURE:
return True
turn_route = _CLI._resolve_turn_agent_config("")
init_ok = _CLI._init_agent(
model_override=turn_route["model"],
runtime_override=turn_route["runtime"],
request_overrides=turn_route.get("request_overrides"),
)
if not init_ok:
logger.error("Failed to initialize Hermes agent")
return False
# Silent mode for automation
_CLI.agent.quiet_mode = True
_CLI.agent.suppress_status_output = True
_CLI.agent.stream_delta_callback = None
_CLI.agent.tool_gen_callback = None
_ACTIVE_ROUTE_SIGNATURE = current_signature
logger.info("Hermes agent initialized (route_signature=%s)", _ACTIVE_ROUTE_SIGNATURE)
return True
def call_hermes(prompt: str) -> str:
"""
Query the warm Hermes agent.
The agent is initialized once at startup and reused for all queries.
Only reinitializes if model/provider/base_url actually changes.
"""
if _SHUTTING_DOWN:
return "⚠️ Service shutting down, please retry."
_ensure_hermes_loaded()
# Geen prefix meer — voice-assistant profiel handelt dit af via config
if not _initialize_agent():
return "⚠️ Kon Hermes agent niet initialiseren."
full_prompt = prompt.strip()
if not full_prompt:
return "⚠️ Lege prompt ontvangen."
start = time.time()
# ── Agent initialiseren ──
turn_route = _cli._resolve_turn_agent_config(full_prompt)
if turn_route["signature"] != _cli._active_agent_route_signature:
_cli.agent = None
init_ok = _cli._init_agent(
model_override=turn_route["model"],
runtime_override=turn_route["runtime"],
request_overrides=turn_route.get("request_overrides"),
)
if not init_ok:
return "⚠️ Kon Hermes agent niet initialiseren."
# Stil modus
_cli.agent.quiet_mode = True
_cli.agent.suppress_status_output = True
_cli.agent.stream_delta_callback = None
_cli.agent.tool_gen_callback = None
# ── Query uitvoeren ──
# Capture alles wat naar stdout/stderr gaat (session_id, banner, etc.)
stdout_capture = io.StringIO()
stderr_capture = io.StringIO()
try:
with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
# conversation_history wissen voor frisse start
_cli.agent.messages = []
result = _cli.agent.chat(full_prompt)
except SystemExit:
# Hermes roept sys.exit() aan in sommige error paths
logger.warning("Hermes riep sys.exit() aan tijdens query")
result = stdout_capture.getvalue().strip()
# Each query is a fresh conversation - reset messages
# This is intentional: we want stateless queries for the relay
_CLI.agent.messages = []
result = _CLI.agent.chat(full_prompt)
except KeyboardInterrupt:
logger.warning("Hermes query interrupted")
raise # Re-raise to allow proper shutdown
except SystemExit as e:
# Hermes calls sys.exit() in some error paths
logger.warning("Hermes called sys.exit() during query: %s", e)
return "⚠️ Hermes error: process exited unexpectedly"
except Exception as e:
logger.error("Hermes error: %s", e, exc_info=True)
return "⚠️ Hermes error: " + str(e)
# Return user-friendly message but log full traceback
return f"⚠️ Hermes error: {type(e).__name__}"
elapsed = time.time() - start
# Fallback als Hermes None teruggeeft (error path)
# Fallback if Hermes returns None (error path)
if result is None:
logger.warning("Hermes returned None — possible error during query")
return "⚠️ Geen antwoord van Hermes. Probeer opnieuw."
logger.info("Hermes responded in %.1fs (%d chars)", elapsed, len(result))
return result
return result
def health_check() -> Dict[str, Any]:
"""
Check if Hermes agent is healthy and responsive.
Returns a dict with status details.
This is a LIGHTWEIGHT check - no actual LLM query is made.
For deep health checking, use the /health endpoint with detail=true.
"""
checks = {
"hermes_loaded": _HERMES_LOADED,
"agent_initialized": _CLI is not None and _CLI.agent is not None,
"route_signature": str(_ACTIVE_ROUTE_SIGNATURE) if _ACTIVE_ROUTE_SIGNATURE else None,
}
# Lightweight check - just verify agent object exists
# Do NOT run an actual query here as it blocks for 10-60s
if checks["agent_initialized"]:
checks["query_test"] = "skipped (use deep check)"
checks["query_latency_ms"] = None
else:
checks["query_test"] = "agent_not_initialized"
checks["query_latency_ms"] = None
checks["healthy"] = (
checks["hermes_loaded"]
and checks["agent_initialized"]
)
return checks
def shutdown() -> None:
"""Graceful shutdown handler."""
global _SHUTTING_DOWN
with _STATE_LOCK:
_SHUTTING_DOWN = True
logger.info("Shutdown initiated")
if _CLI and _CLI.agent:
try:
# Give agent a chance to clean up
if hasattr(_CLI.agent, "shutdown"):
_CLI.agent.shutdown()
except Exception as e:
logger.error("Error during agent shutdown: %s", e, exc_info=True)
logger.info("Shutdown complete")
# Register shutdown handlers
signal.signal(signal.SIGTERM, lambda s, f: shutdown())
signal.signal(signal.SIGINT, lambda s, f: shutdown())
atexit.register(shutdown)
+9 -2
View File
@@ -1,2 +1,9 @@
flask>=3.0
requests>=2.31
# Web service
quart==0.19.4
uvicorn[standard]==0.30.6
# Optional production server
hypercorn==0.17.3
# Type hints support
typing_extensions==4.12.2