feat(fast-paths): Universeller Fast-Path-Layer -- Steuerbefehle ohne LLM-Roundtrip

Motivation: selbst mit allen bisherigen Proxy-Fixes braucht jede Nachricht
mindestens einen vollen LLM-Turn (mehrere Sekunden). Fuer "pause" oder
"naechstes Lied" unnoetig langsam -- Alexa & Co. loesen sowas ohne
generatives Modell per Intent-Klassifikation in Millisekunden.

Neues Hermes-Plugin fast-paths/, haengt am pre_gateway_dispatch-Hook
(feuert bei JEDER eingehenden Nachricht auf JEDER Plattform, noch VOR
Auth/Pairing und Agent-Dispatch -- gefunden im echten Hermes-Sourcecode,
hermes_cli/plugins.py + gateway/run.py). Matcht der normalisierte Text
gegen eine Regex, wird der Handler direkt ausgefuehrt und das LLM komplett
uebersprungen. Ein File pro Skill unter patterns/ (analog zu ARIAs eigenen
fast_patterns), erster Skill: patterns/spotify.py (pause/next/previous/
volume/current, plus "spiel Playlist X auf Geraet Y ab" per Fuzzy-Match
ohne LLM fuer den Freitext-Namen).

Sicherheit: pre_gateway_dispatch feuert VOR Hermes' eigener Autorisierung
-- der Loader prueft deshalb explizit gateway._is_user_authorized() vor
jedem Pattern-Treffer, sonst koennte ein nicht autorisierter Absender per
Fast-Path an Hermes vorbei Aktionen ausloesen. Handler koennen sich zudem
bewusst per Rueckgabe None zurueckziehen (z.B. mehrdeutiger Playlist-Name)
und faellen dann normal ans LLM zurueck statt zu raten.

Lokal verifiziert (Testharness auf aria-wohnung, Hermes' eigenen
_load_directory_module-Lademechanismus nachgebaut, gemockter SpotifyClient):
Matching, Sicherheits-Check bei nicht autorisiertem Sender, Fuzzy-Match
fuer Playlist+Geraet, und mehrere Regression-Faelle gegen False-Positives
bei laengeren/anderen Saetzen -- alle Faelle bestehen.

docker-compose.yml: neuer verschachtelter Read-Only-Mount
./fast-paths -> /opt/data/plugins/fast-paths (User-Plugin-Pfad unter
$HERMES_HOME). Muss zusaetzlich einmalig in config.yaml unter
plugins.enabled aktiviert werden (Hermes-Plugins sind Opt-in) -- siehe
README.md und fast-paths/README.md.
This commit is contained in:
ARIA
2026-07-20 16:17:29 +00:00
parent 128c11b49d
commit 9ea72886dd
8 changed files with 735 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
"""fast-paths -- universeller Fast-Path-Layer fuer Hermes.
Faengt einfache Steuerbefehle per Regex VOR dem eigentlichen LLM-Aufruf ab
und fuehrt sie direkt aus -- kein Claude-Proxy-Roundtrip noetig. Motivation
und Architektur-Entscheidung: siehe README.md daneben.
Funktionsweise in Kurzform:
1. Hermes' Plugin-System laedt dieses Verzeichnis als kind="standalone"-
Plugin (siehe plugin.yaml) und ruft register(ctx) einmal beim Start auf.
2. register() sammelt alle Fast-Path-Regeln aus patterns/*.py ein (ein File
pro Skill, z.B. patterns/spotify.py -- neue Skills: einfach eine neue
Datei dort reinlegen, siehe patterns/_base.py fuer die Konventionen) und
registriert EINEN Callback auf den "pre_gateway_dispatch"-Hook.
3. Dieser Hook feuert bei JEDER eingehenden Nachricht, auf JEDER Plattform
(TUI, Telegram, Discord, ...) -- noch VOR Auth/Pairing und noch VOR dem
eigentlichen Agent-Dispatch (siehe gateway/run.py _handle_message). Genau
der richtige Abfangpunkt: universell fuer alle Plattformen, aber weit
genug vorne dass wir dem LLM wirklich zuvorkommen.
4. Matcht der normalisierte Text gegen eine der gesammelten Regeln, wird der
zugehoerige Handler synchron aufgerufen. Antwortet der Handler mit einem
String, wird der per adapter.send() direkt an den User geschickt (siehe
patterns/_base.py send_reply()) und die Nachricht per
{"action": "skip"} aus der normalen Pipeline genommen -- das LLM sieht
diese Nachricht nie.
5. Matcht nichts, ODER lehnt ein Handler bewusst ab (Rueckgabe None, z.B.
weil ein Playlist-Name mehrdeutig war), faellt alles ganz normal auf den
LLM-Turn zurueck -- als waere dieses Plugin gar nicht da.
Sicherheitshinweis (wichtig, nicht optional): "pre_gateway_dispatch" feuert
VOR Hermes' eigener Autorisierungspruefung. Ohne expliziten Check wuerde
ein Fast-Path-Treffer also auch fuer NICHT autorisierte Absender ausgefuehrt
-- z.B. koennte irgendwer, der Hermes' Telegram-Bot anschreibt, ohne
Autorisierung "pause" tippen und tatsaechlich Spotify pausieren. Deshalb
prueft der Hook-Callback unten explizit gateway._is_user_authorized(...)
BEVOR irgendein Pattern ueberhaupt versucht wird.
"""
from __future__ import annotations
import importlib
import logging
import re
import time
from pathlib import Path
from typing import Any, List, Optional
logger = logging.getLogger("hermes_plugins.fast_paths")
_PATTERNS: List[Any] = []
def _load_pattern_modules() -> None:
"""Importiert jede patterns/<skill>.py und sammelt ihre PATTERNS-Liste."""
global _PATTERNS
loaded: List[Any] = []
patterns_dir = Path(__file__).resolve().parent / "patterns"
for py_file in sorted(patterns_dir.glob("*.py")):
mod_name = py_file.stem
if mod_name.startswith("_") or mod_name == "__init__":
continue # _base.py etc. -- Infrastruktur, kein Skill
try:
mod = importlib.import_module(f"{__package__}.patterns.{mod_name}")
except Exception:
logger.exception(
"fast_paths: Skill-Datei '%s.py' konnte nicht geladen werden -- uebersprungen",
mod_name,
)
continue
skill_patterns = getattr(mod, "PATTERNS", None)
if not skill_patterns:
logger.warning("fast_paths: '%s.py' hat keine PATTERNS-Liste -- uebersprungen", mod_name)
continue
for p in skill_patterns:
p.skill = mod_name
loaded.append(p)
logger.info("fast_paths: %d Pattern(s) aus '%s.py' geladen", len(skill_patterns), mod_name)
_PATTERNS = loaded
skill_count = len({p.skill for p in _PATTERNS})
logger.info("fast_paths: insgesamt %d Pattern(s) aus %d Skill-Datei(en) aktiv", len(_PATTERNS), skill_count)
def _normalize(text: str) -> str:
"""Wie ARIAs eigene fast_patterns-Konvention: lowercase, Satzzeichen am
Ende weg, Mehrfach-Leerzeichen zusammenfassen -- damit die Regexe in den
Skill-Dateien einfach bleiben."""
t = (text or "").strip().lower()
t = re.sub(r"[.!?]+$", "", t).strip()
t = re.sub(r"\s+", " ", t)
return t
def _on_pre_gateway_dispatch(event=None, gateway=None, session_store=None, **_kw) -> Optional[dict]:
if event is None or gateway is None:
return None
if getattr(event, "internal", False):
return None # System-generierte Events (z.B. Background-Notifications) nie fast-pathen
text = _normalize(getattr(event, "text", "") or "")
if not text:
return None
# Erst pruefen ob ueberhaupt ein Pattern matcht -- die (minimal teurere)
# Autorisierungspruefung erst danach, damit der ganz normale Nachrichten-
# Strom (der zu 99% NICHT matcht) keinen zusaetzlichen Call pro Turn zahlt.
for fp in _PATTERNS:
m = fp.regex.match(text)
if not m:
continue
# SICHERHEITSKRITISCH: pre_gateway_dispatch feuert VOR Hermes' eigener
# Auth-Pruefung. Ein Fast-Path-Treffer darf NIEMALS fuer einen nicht
# autorisierten Absender ausgefuehrt werden -- sonst koennte z.B. ein
# fremder Telegram-User ungefragt "pause" tippen und wirklich Spotify
# steuern. Bei fehlender Autorisierung: exakt wie "kein Match"
# behandeln, normale Pipeline (inkl. Pairing-Flow) uebernimmt.
try:
authorized = gateway._is_user_authorized(event.source)
except Exception:
logger.exception("fast_paths: Autorisierungspruefung fehlgeschlagen -- Pattern wird NICHT ausgefuehrt")
return None
if not authorized:
logger.info(
"fast_paths: Pattern '%s' haette gematcht, Absender aber nicht autorisiert -- normale Pipeline uebernimmt",
fp.name,
)
return None
t0 = time.monotonic()
try:
reply = fp.handler(m, event, gateway)
except Exception:
logger.exception(
"fast_paths: Handler '%s' (skill=%s) hat eine Exception geworfen -- Fallback aufs LLM",
fp.name, fp.skill,
)
return None
elapsed_ms = int((time.monotonic() - t0) * 1000)
if reply is None:
# Handler ist sich bewusst nicht sicher (z.B. mehrdeutiger
# Playlist-Name) -- NICHT raten, normale LLM-Verarbeitung uebernimmt.
logger.info(
"fast_paths: '%s' (skill=%s) hat gematcht, Handler aber abgelehnt (%dms) -- Fallback aufs LLM",
fp.name, fp.skill, elapsed_ms,
)
return None
logger.info(
"fast_paths: '%s' (skill=%s) in %dms erledigt, LLM-Aufruf uebersprungen",
fp.name, fp.skill, elapsed_ms,
)
if reply:
from .patterns._base import send_reply
send_reply(gateway, event, reply)
return {"action": "skip", "reason": f"fast_path:{fp.skill}:{fp.name}"}
return None
def register(ctx) -> None:
_load_pattern_modules()
ctx.register_hook("pre_gateway_dispatch", _on_pre_gateway_dispatch)