Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7047eb429 | |||
| 67160b2de0 | |||
| 4eaa21f20c | |||
| 5be3f78fe6 | |||
| ce9e91b1b9 | |||
| 5daa338c78 |
@@ -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
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
│
|
## Gedrag en grenzen
|
||||||
├──▶ hermes chat -q (subprocess)
|
|
||||||
│ │
|
- **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.
|
||||||
│ JSON response ──▶ Node-RED
|
- **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.
|
||||||
└──▶ hermes send --to telegram
|
- **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.
|
||||||
▼
|
|
||||||
Telegram chat
|
|
||||||
```
|
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
### `POST /ask`
|
### `POST /ask`
|
||||||
Verwacht JSON body:
|
|
||||||
|
JSON body:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "payload": "Wat is het weer in Best?" }
|
{ "payload": "Wat is het weer in Best?" }
|
||||||
```
|
```
|
||||||
|
|
||||||
Response:
|
Succesresponse:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"answer": "Het is 22°C en zonnig in Best...",
|
"answer": "...",
|
||||||
"elapsed_seconds": 8.3,
|
"elapsed_seconds": 6.213,
|
||||||
"telegram_sent": true
|
"llm_elapsed_seconds": 6.213,
|
||||||
|
"request_elapsed_seconds": 6.214,
|
||||||
|
"telegram_queued": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `GET /health`
|
`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.
|
||||||
Health check voor monitoring.
|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
cd /root/hermes-relay
|
systemctl restart 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Node-RED Nodes
|
## Testen
|
||||||
|
|
||||||
### Versturen (HTTP Request node)
|
```bash
|
||||||
- **Method:** POST
|
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`
|
- **URL:** `http://192.168.1.74:8650/ask`
|
||||||
- **Return:** a parsed JSON object
|
- **Return:** parsed JSON
|
||||||
- **Timeout:** 120000 (ms) — Hermes kan even duren
|
- **Timeout:** `120000` ms
|
||||||
|
|
||||||
### Ontvangen
|
Voorafgaande Function-node:
|
||||||
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"
|
|
||||||
|
|
||||||
### Health check (optioneel)
|
```javascript
|
||||||
- **Method:** GET
|
msg.payload = { payload: msg.payload };
|
||||||
- **URL:** `http://192.168.1.74:8650/health`
|
return msg;
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,29 +1,35 @@
|
|||||||
"""
|
"""Hermes Relay v3.2 — fast, bounded Quart bridge for Node-RED."""
|
||||||
Hermes Relay v2 — Flask bridge tussen Node-RED en Hermes Agent.
|
|
||||||
|
|
||||||
Gebruikt de voice-assistant profiel (minimale chatbot, max_turns=10).
|
import asyncio
|
||||||
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 logging
|
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 = os.environ.get(
|
||||||
_HERMES_SITE = "/usr/local/lib/hermes-agent/venv/lib/python3.11/site-packages"
|
"HERMES_RELAY_SITE_PACKAGES",
|
||||||
|
"/usr/local/lib/hermes-agent/venv/lib/python3.11/site-packages",
|
||||||
|
)
|
||||||
if _HERMES_SITE not in sys.path:
|
if _HERMES_SITE not in sys.path:
|
||||||
sys.path.insert(0, _HERMES_SITE)
|
sys.path.insert(0, _HERMES_SITE)
|
||||||
|
|
||||||
from flask import Flask, request, jsonify
|
from quart import Quart, jsonify, request
|
||||||
|
|
||||||
# ── Config ──────────────────────────────────────────────────────────────
|
VERSION = "3.3.0"
|
||||||
TIMEOUT = 120 # max seconden voor Hermes query
|
TIMEOUT = int(os.environ.get("HERMES_RELAY_TIMEOUT", "120"))
|
||||||
TG_TARGET = "telegram" # hermes send target
|
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(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="[hermes-relay] %(asctime)s %(levelname)s %(message)s",
|
format="[hermes-relay] %(asctime)s %(levelname)s %(message)s",
|
||||||
@@ -31,108 +37,191 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Flask app ───────────────────────────────────────────────────────────
|
app = Quart(__name__)
|
||||||
app = Flask(__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
|
from hermes_client import call_hermes as _hermes_query
|
||||||
|
from hermes_client import _ensure_hermes_loaded, _initialize_agent
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
def send_telegram(text):
|
def _prewarm_agent() -> None:
|
||||||
"""Stuur bericht via gateway's send_message_tool (gebruikt gateway config met echte token)."""
|
"""Initialize the agent before the first request without blocking startup."""
|
||||||
try:
|
try:
|
||||||
logger.info("Telegram send: starting...")
|
_ensure_hermes_loaded()
|
||||||
_ensure_gw_config()
|
_initialize_agent()
|
||||||
logger.info("Telegram send: gw config loaded")
|
logger.info("Hermes agent pre-warmed successfully")
|
||||||
from tools.send_message_tool import send_message_tool
|
except Exception:
|
||||||
import json
|
logger.exception("Hermes agent pre-warm failed")
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
# ── 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"])
|
@app.route("/ask", methods=["POST"])
|
||||||
def ask():
|
async def ask():
|
||||||
"""Main relay endpoint — retourneert alleen het antwoord."""
|
"""Answer a request; optional Telegram delivery stays outside the HTTP path."""
|
||||||
start = time.time()
|
request_started = time.perf_counter()
|
||||||
|
|
||||||
# Parse input
|
|
||||||
if request.is_json:
|
if request.is_json:
|
||||||
data = request.get_json(silent=True) or {}
|
data = await request.get_json(silent=True) or {}
|
||||||
prompt = data.get("payload", "")
|
prompt = data.get("payload", "")
|
||||||
else:
|
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
|
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])
|
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)
|
try:
|
||||||
answer = _hermes_query(prompt)
|
# One warm agent, therefore one model call at a time. The timeout covers
|
||||||
elapsed = round(time.time() - start, 1)
|
# 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)
|
request_elapsed = round(time.perf_counter() - request_started, 3)
|
||||||
tg_sent = send_telegram(answer)
|
logger.info("Answer (llm=%.3fs, request=%.3fs): %s...", llm_elapsed, request_elapsed, answer[:200])
|
||||||
|
|
||||||
|
if TELEGRAM_DELIVERY_ENABLED:
|
||||||
|
_queue_telegram(answer)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"answer": answer,
|
"answer": answer,
|
||||||
"elapsed_seconds": elapsed,
|
# Backwards-compatible field: model execution time, not delivery time.
|
||||||
"telegram_sent": tg_sent,
|
"elapsed_seconds": llm_elapsed,
|
||||||
|
"llm_elapsed_seconds": llm_elapsed,
|
||||||
|
"request_elapsed_seconds": request_elapsed,
|
||||||
|
"telegram_queued": TELEGRAM_DELIVERY_ENABLED,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/health", methods=["GET"])
|
@app.route("/health", methods=["GET"])
|
||||||
def health():
|
async def health():
|
||||||
"""Health check voor Node-RED monitoring."""
|
"""Return relay state. `detail=true` checks local agent state, not the LLM."""
|
||||||
return jsonify({
|
base = {
|
||||||
"status": "ok",
|
|
||||||
"service": "hermes-relay",
|
"service": "hermes-relay",
|
||||||
"version": "2.1",
|
"version": VERSION,
|
||||||
"mode": "python-import",
|
"mode": "async-quart",
|
||||||
"profile": "voice-assistant",
|
"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__":
|
if __name__ == "__main__":
|
||||||
logger.info("Starting Hermes Relay v2.1 (voice-assistant profiel)...")
|
import uvicorn
|
||||||
app.run(host="0.0.0.0", port=8650, debug=False)
|
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
@@ -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.
|
Architecture:
|
||||||
Dit bespaart de subprocess + Python startup overhead van 2-5 seconden per query.
|
- Agent is initialized ONCE at module load (or first use) and kept warm
|
||||||
|
- Only reinitialize if model/provider/runtime actually changes (route signature)
|
||||||
Gebruikt het 'voice-assistant' profiel (minimale chatbot: alleen memory tool,
|
- No conversation history reset - each query is naturally stateless
|
||||||
deepseek-v4-flash/opencode-go, max_turns=10).
|
- All config via environment variables for deployment flexibility
|
||||||
"""
|
"""
|
||||||
import io
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import logging
|
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__)
|
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
|
_HERMES_LOADED = False
|
||||||
_cli = None
|
_CLI = None
|
||||||
|
_ACTIVE_ROUTE_SIGNATURE = None
|
||||||
|
_SHUTTING_DOWN = False
|
||||||
|
|
||||||
# Voice-assistant profiel home directory
|
# Thread lock for protecting global state modifications
|
||||||
_HERMES_HOME = "/root/.hermes/profiles/voice-assistant"
|
_STATE_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _ensure_hermes_loaded():
|
def _setup_logging() -> None:
|
||||||
"""Laad Hermes eenmalig bij eerste gebruik — dit is het zware werk."""
|
"""Configure logging once."""
|
||||||
global _HERMES_LOADED, _cli
|
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:
|
if _HERMES_LOADED:
|
||||||
return
|
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
|
start = time.time()
|
||||||
os.environ["HERMES_HOME"] = _HERMES_HOME
|
_setup_logging()
|
||||||
|
|
||||||
# Zorg dat Hermes in de Python path zit
|
# Force voice-assistant profile via HERMES_HOME
|
||||||
hermes_path = "/usr/local/lib/hermes-agent/venv/lib/python3.11/site-packages"
|
os.environ["HERMES_HOME"] = HERMES_HOME or DEFAULT_HERMES_HOME
|
||||||
if hermes_path not in sys.path:
|
|
||||||
sys.path.insert(0, hermes_path)
|
|
||||||
|
|
||||||
# Force UTF-8
|
# Ensure Hermes is in Python path. v0.20 uses a Git-checkout layout.
|
||||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
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
|
# Force UTF-8
|
||||||
from hermes_cli.env_loader import load_hermes_dotenv
|
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||||
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)
|
|
||||||
|
|
||||||
# Load config (nu vanuit voice-assistant profiel)
|
# Load .env via Hermes' own loader (loads profile .env + project .env)
|
||||||
from hermes_cli.config import load_config
|
from hermes_cli.env_loader import load_hermes_dotenv
|
||||||
config = load_config()
|
from hermes_constants import get_hermes_home
|
||||||
|
|
||||||
# Gebruik messaging toolset zodat agent send_message tool kan gebruiken
|
_hermes_home = get_hermes_home()
|
||||||
toolsets = ["messaging"]
|
_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
|
# Load config from voice-assistant profile
|
||||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
from hermes_cli.config import load_config
|
||||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
config = load_config()
|
||||||
os.environ["HERMES_SESSION_SOURCE"] = "relay"
|
|
||||||
|
|
||||||
# Import HermesCLI
|
# YOLO mode + interactive mode (required for agent tools)
|
||||||
from cli import HermesCLI
|
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)
|
# Import HermesCLI
|
||||||
_cli = HermesCLI(
|
from cli import HermesCLI
|
||||||
model=config.get("model", {}).get("default", ""),
|
|
||||||
toolsets=toolsets,
|
|
||||||
)
|
|
||||||
# Onderdruk banner/status output
|
|
||||||
_cli.tool_progress_mode = "off"
|
|
||||||
|
|
||||||
elapsed = time.time() - start
|
# Determine model (env override > profile default)
|
||||||
logger.info(
|
model = HERMES_MODEL or config.get("model", {}).get("default", "")
|
||||||
"Hermes loaded in %.2fs (toolsets: %s, profile: voice-assistant)",
|
|
||||||
elapsed, toolsets
|
# Create CLI instance (agent NOT initialized yet - happens on first query)
|
||||||
)
|
_CLI = HermesCLI(
|
||||||
_HERMES_LOADED = True
|
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.
|
Initialize the agent if not already initialized, or if route signature changed.
|
||||||
|
Returns True on success, False on failure.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
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()
|
_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()
|
full_prompt = prompt.strip()
|
||||||
|
if not full_prompt:
|
||||||
|
return "⚠️ Lege prompt ontvangen."
|
||||||
|
|
||||||
start = time.time()
|
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:
|
try:
|
||||||
with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
|
# Each query is a fresh conversation - reset messages
|
||||||
# conversation_history wissen voor frisse start
|
# This is intentional: we want stateless queries for the relay
|
||||||
_cli.agent.messages = []
|
_CLI.agent.messages = []
|
||||||
result = _cli.agent.chat(full_prompt)
|
result = _CLI.agent.chat(full_prompt)
|
||||||
except SystemExit:
|
|
||||||
# Hermes roept sys.exit() aan in sommige error paths
|
except KeyboardInterrupt:
|
||||||
logger.warning("Hermes riep sys.exit() aan tijdens query")
|
logger.warning("Hermes query interrupted")
|
||||||
result = stdout_capture.getvalue().strip()
|
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:
|
except Exception as e:
|
||||||
logger.error("Hermes error: %s", e, exc_info=True)
|
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
|
elapsed = time.time() - start
|
||||||
|
|
||||||
# Fallback als Hermes None teruggeeft (error path)
|
# Fallback if Hermes returns None (error path)
|
||||||
if result is None:
|
if result is None:
|
||||||
logger.warning("Hermes returned None — possible error during query")
|
logger.warning("Hermes returned None — possible error during query")
|
||||||
return "⚠️ Geen antwoord van Hermes. Probeer opnieuw."
|
return "⚠️ Geen antwoord van Hermes. Probeer opnieuw."
|
||||||
|
|
||||||
logger.info("Hermes responded in %.1fs (%d chars)", elapsed, len(result))
|
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
@@ -1,2 +1,9 @@
|
|||||||
flask>=3.0
|
# Web service
|
||||||
requests>=2.31
|
quart==0.19.4
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
|
||||||
|
# Optional production server
|
||||||
|
hypercorn==0.17.3
|
||||||
|
|
||||||
|
# Type hints support
|
||||||
|
typing_extensions==4.12.2
|
||||||
|
|||||||
Reference in New Issue
Block a user