feat(hermes): v8 — Hermes Agent mit Voice-Interface
Lokaler KI-Assistent auf dem Bosgame: Text + Sprache, Tool Calling, WebSocket-Streaming. Steuerzentrale des Agentic OS. - hermes_agent.py: ReAct-Agent mit Tool-Set (read_file, list_dir, run_command, system_status, memory r/w, web_search) - routers/hermes.py: WS /chat, POST /transcribe (Whisper), POST /tts (Piper), GET /status, GET /pubkey - HermesPanel.svelte: Chat-UI mit Token-Streaming, Tool-Anzeige, Mikrofon-Button (MediaRecorder), Setup-Wizard (Windows SSH) - Modell-Routing: scout fuer einfache Tasks, coder fuer komplexe - HERMES_* Env-Vars in config.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+412
@@ -0,0 +1,412 @@
|
||||
"""
|
||||
Hermes Agent — lokaler KI-Assistent fuer das Agentic OS.
|
||||
|
||||
Empfaengt Aufgaben per Text (und Voice), nutzt llama-swap als LLM-Backend,
|
||||
kann per Tools auf das Bosgame und spaeter per SSH auf den Windows-PC zugreifen.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable, Awaitable
|
||||
|
||||
import httpx
|
||||
|
||||
from config import LLAMA_SWAP_URL, HERMES_SIMPLE_MODEL, HERMES_COMPLEX_MODEL
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool-Definitionen (OpenAI Function Calling Format)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TOOL_DEFS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Liest eine Textdatei auf dem Bosgame.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Absoluter Pfad zur Datei"}
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_directory",
|
||||
"description": "Listet Dateien und Ordner eines Verzeichnisses auf dem Bosgame.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Absoluter Pfad zum Verzeichnis"}
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_command",
|
||||
"description": (
|
||||
"Fuehrt einen Lese-Befehl auf dem Bosgame aus (kein sudo, kein rm). "
|
||||
"Geeignet fuer: ls, cat, ps, df, free, journalctl, systemctl status, ip, curl, etc."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {"type": "string", "description": "Shell-Befehl"}
|
||||
},
|
||||
"required": ["command"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_system_status",
|
||||
"description": "Gibt aktuellen System-Status zurueck: CPU, RAM, GPU, laufende Modelle.",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_memories",
|
||||
"description": "Laedt alle gespeicherten Fakten und Entscheidungen.",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "add_memory",
|
||||
"description": "Speichert eine neue Information dauerhaft ins Gedaechtnis.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string", "description": "Der zu speichernde Fakt"},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": ["stable", "versioned", "ephemeral"],
|
||||
"description": "stable=Projektfakten, versioned=Tech-Versionen, ephemeral=temporaer (7 Tage)",
|
||||
},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "Sucht im Internet nach aktuellen Informationen.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Suchbegriff"}
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool-Implementierungen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BLOCKED_CMDS = {
|
||||
"rm", "rmdir", "mv", "cp", "dd", "mkfs", "fdisk",
|
||||
"parted", "shutdown", "reboot", "halt", "poweroff",
|
||||
"chmod", "chown", "passwd", "userdel", "useradd",
|
||||
}
|
||||
|
||||
|
||||
def _exec_read_file(path: str) -> str:
|
||||
try:
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return f"Datei nicht gefunden: {path}"
|
||||
content = p.read_text(errors="replace")
|
||||
if len(content) > 8000:
|
||||
return content[:8000] + f"\n\n... (gekuerzt, gesamt {len(content):,} Zeichen)"
|
||||
return content
|
||||
except Exception as e:
|
||||
return f"Fehler beim Lesen: {e}"
|
||||
|
||||
|
||||
def _exec_list_directory(path: str) -> str:
|
||||
try:
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return f"Pfad nicht gefunden: {path}"
|
||||
items = sorted(p.iterdir(), key=lambda x: (x.is_file(), x.name.lower()))
|
||||
lines = []
|
||||
for item in items[:80]:
|
||||
if item.is_dir():
|
||||
lines.append(f"📁 {item.name}/")
|
||||
else:
|
||||
size = item.stat().st_size
|
||||
sz = f"{size/1024/1024:.1f} MB" if size > 1024*1024 else f"{size/1024:.1f} KB" if size > 1024 else f"{size} B"
|
||||
lines.append(f"📄 {item.name} ({sz})")
|
||||
total = sum(1 for _ in p.iterdir())
|
||||
if total > 80:
|
||||
lines.append(f"... und {total - 80} weitere")
|
||||
return "\n".join(lines) if lines else "(leer)"
|
||||
except Exception as e:
|
||||
return f"Fehler: {e}"
|
||||
|
||||
|
||||
def _exec_run_command(command: str) -> str:
|
||||
first = command.strip().split()[0] if command.strip() else ""
|
||||
if first in _BLOCKED_CMDS or "sudo" in command:
|
||||
return f"Befehl '{first}' blockiert. Nutze Mission Control fuer Systemoperationen."
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command, shell=True, capture_output=True, text=True, timeout=30
|
||||
)
|
||||
out = (result.stdout + result.stderr).strip()
|
||||
if len(out) > 4000:
|
||||
out = out[:4000] + "\n... (Ausgabe gekuerzt)"
|
||||
return out or "(kein Output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Timeout nach 30 Sekunden."
|
||||
except Exception as e:
|
||||
return f"Fehler: {e}"
|
||||
|
||||
|
||||
def _exec_get_system_status() -> str:
|
||||
base = "http://127.0.0.1:9000"
|
||||
try:
|
||||
s = httpx.get(f"{base}/api/status", timeout=5).json()
|
||||
models = s.get("models", [])
|
||||
running = [m for m in models if m.get("state") in ("running", "ready", "loading")]
|
||||
model_str = ", ".join(m["name"] for m in running) if running else "keines"
|
||||
except Exception:
|
||||
model_str = "unbekannt"
|
||||
|
||||
try:
|
||||
sys = httpx.get(f"{base}/api/system/status", timeout=5).json()
|
||||
return (
|
||||
f"Aktives Modell: {model_str}\n"
|
||||
f"CPU: {sys.get('cpu_pct', '?')}% | "
|
||||
f"RAM: {sys.get('ram_used_gb', '?')} / {sys.get('ram_total_gb', '?')} GB | "
|
||||
f"GPU: {sys.get('gpu_mem_used_gb', '?')} / {sys.get('gpu_mem_total_gb', '?')} GB GTT\n"
|
||||
f"Temp: CPU {sys.get('cpu_temp_c', '?')}°C"
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Aktives Modell: {model_str}\nSystem-Metriken nicht verfuegbar: {e}"
|
||||
|
||||
|
||||
def _exec_get_memories() -> str:
|
||||
try:
|
||||
items = httpx.get("http://127.0.0.1:9000/api/memory", timeout=5).json()
|
||||
if not items:
|
||||
return "Kein Gedaechtnis vorhanden."
|
||||
return "\n".join(f"[{m['category']}] {m['content']}" for m in items)
|
||||
except Exception as e:
|
||||
return f"Fehler: {e}"
|
||||
|
||||
|
||||
def _exec_add_memory(content: str, category: str = "stable") -> str:
|
||||
try:
|
||||
r = httpx.post(
|
||||
"http://127.0.0.1:9000/api/memory",
|
||||
json={"content": content, "category": category, "source": "hermes"},
|
||||
timeout=5,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return f"Gespeichert: {content}"
|
||||
except Exception as e:
|
||||
return f"Fehler: {e}"
|
||||
|
||||
|
||||
def _exec_web_search(query: str) -> str:
|
||||
try:
|
||||
r = httpx.get(
|
||||
"https://api.duckduckgo.com/",
|
||||
params={"q": query, "format": "json", "no_html": 1, "skip_disambig": 1},
|
||||
timeout=10,
|
||||
headers={"User-Agent": "HermesAgent/1.0"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
data = r.json()
|
||||
results = []
|
||||
if data.get("AbstractText"):
|
||||
results.append(data["AbstractText"])
|
||||
for rt in data.get("RelatedTopics", [])[:4]:
|
||||
if isinstance(rt, dict) and rt.get("Text"):
|
||||
results.append(rt["Text"])
|
||||
return "\n\n".join(results) if results else "Keine direkten Ergebnisse. Versuche eine genauere Suchanfrage."
|
||||
except Exception as e:
|
||||
return f"Websuche fehlgeschlagen: {e}"
|
||||
|
||||
|
||||
def execute_tool(name: str, args: dict) -> str:
|
||||
if name == "read_file":
|
||||
return _exec_read_file(args.get("path", ""))
|
||||
if name == "list_directory":
|
||||
return _exec_list_directory(args.get("path", ""))
|
||||
if name == "run_command":
|
||||
return _exec_run_command(args.get("command", ""))
|
||||
if name == "get_system_status":
|
||||
return _exec_get_system_status()
|
||||
if name == "get_memories":
|
||||
return _exec_get_memories()
|
||||
if name == "add_memory":
|
||||
return _exec_add_memory(args.get("content", ""), args.get("category", "stable"))
|
||||
if name == "web_search":
|
||||
return _exec_web_search(args.get("query", ""))
|
||||
return f"Unbekanntes Tool: {name}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modell-Auswahl
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_COMPLEX_KW = {
|
||||
"schreib", "erstell", "baue", "plan", "refactor", "debug", "analysier",
|
||||
"implementier", "entwickl", "code", "programm", "erklaer ausfuehrlich",
|
||||
"erstelle", "generier",
|
||||
}
|
||||
|
||||
|
||||
def choose_model(message: str) -> str:
|
||||
lower = message.lower()
|
||||
if any(kw in lower for kw in _COMPLEX_KW):
|
||||
return HERMES_COMPLEX_MODEL
|
||||
return HERMES_SIMPLE_MODEL
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System-Prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
Du bist Hermes, ein lokaler KI-Assistent der dauerhaft auf dem Bosgame M5 laeuft.
|
||||
|
||||
Dein Zuhause (Bosgame M5):
|
||||
- AMD Strix Halo, ~124 GB GTT-Speicher, Ubuntu 26.04, Kernel 7.0
|
||||
- LAN-IP: 192.168.178.151
|
||||
- Dienste: llama-swap :8080 (Inference), Mission Control :9000 (Dashboard)
|
||||
- Modelle: coder (Qwen3-30B-A3B), scout (Qwen3-8B), vision (Qwen3-VL)
|
||||
- Du laeuft als Teil von Mission Control
|
||||
|
||||
Gespeichertes Wissen:
|
||||
{memories}
|
||||
|
||||
Verhaltenregeln:
|
||||
- Antworte praegnant auf Deutsch (max 2-3 Saetze wenn nicht ausdruecklich mehr gewuenscht)
|
||||
- Kuendige an was du tust bevor du es tust
|
||||
- Frag nach bevor du Dateien ueberschreibst oder Dienste neustartest
|
||||
- Nutze get_system_status() wenn du nicht sicher bist was gerade laeuft
|
||||
- Speichere wichtige Entscheidungen und Fakten per add_memory() ins Gedaechtnis
|
||||
- Bei einfachen Fragen: keine Tools, direkt antworten
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM-Aufruf (synchron, in Executor ausfuehren)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _call_llm(model: str, messages: list) -> dict:
|
||||
r = httpx.post(
|
||||
f"{LLAMA_SWAP_URL}/v1/chat/completions",
|
||||
json={
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"tools": TOOL_DEFS,
|
||||
"tool_choice": "auto",
|
||||
"temperature": 0.3,
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Haupt-Agent-Loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SendFn = Callable[[dict], Awaitable[None]]
|
||||
|
||||
|
||||
async def run_agent(message: str, send: SendFn) -> None:
|
||||
"""
|
||||
Fuehrt den Agenten-Loop aus und streamt Ergebnisse via send()-Callback.
|
||||
Nachrichten-Typen:
|
||||
{"type": "info", "content": str} — Status-Meldung
|
||||
{"type": "thinking"} — LLM denkt nach
|
||||
{"type": "token", "content": str} — Wort der Antwort
|
||||
{"type": "tool_call", "name": str, "args": dict}
|
||||
{"type": "tool_result", "name": str, "content": str}
|
||||
{"type": "error", "content": str}
|
||||
{"type": "done"}
|
||||
"""
|
||||
memories = _exec_get_memories()
|
||||
model = choose_model(message)
|
||||
|
||||
await send({"type": "info", "content": f"Modell: {model}"})
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT.format(memories=memories)},
|
||||
{"role": "user", "content": message},
|
||||
]
|
||||
|
||||
for _round in range(8):
|
||||
await send({"type": "thinking"})
|
||||
|
||||
try:
|
||||
result = await asyncio.get_event_loop().run_in_executor(
|
||||
None, lambda: _call_llm(model, messages)
|
||||
)
|
||||
except Exception as exc:
|
||||
await send({"type": "error", "content": f"LLM nicht erreichbar: {exc}"})
|
||||
return
|
||||
|
||||
choice = result["choices"][0]
|
||||
msg = choice["message"]
|
||||
finish = choice.get("finish_reason", "")
|
||||
tool_calls = msg.get("tool_calls") or []
|
||||
|
||||
# Text-Antwort: Wort fuer Wort streamen
|
||||
if msg.get("content"):
|
||||
words = msg["content"].split(" ")
|
||||
for i, word in enumerate(words):
|
||||
token = word + (" " if i < len(words) - 1 else "")
|
||||
await send({"type": "token", "content": token})
|
||||
await asyncio.sleep(0.012)
|
||||
|
||||
if not tool_calls or finish == "stop":
|
||||
break
|
||||
|
||||
# Tool-Calls ausfuehren
|
||||
messages.append(msg)
|
||||
|
||||
for tc in tool_calls:
|
||||
fn = tc["function"]
|
||||
name = fn["name"]
|
||||
try:
|
||||
args = json.loads(fn.get("arguments", "{}"))
|
||||
except Exception:
|
||||
args = {}
|
||||
|
||||
await send({"type": "tool_call", "name": name, "args": args})
|
||||
|
||||
tool_out = await asyncio.get_event_loop().run_in_executor(
|
||||
None, lambda n=name, a=args: execute_tool(n, a)
|
||||
)
|
||||
|
||||
preview = tool_out[:300] + "..." if len(tool_out) > 300 else tool_out
|
||||
await send({"type": "tool_result", "name": name, "content": preview})
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc["id"],
|
||||
"content": tool_out,
|
||||
})
|
||||
|
||||
await send({"type": "done"})
|
||||
Reference in New Issue
Block a user