diff --git a/aria-brain/agent.py b/aria-brain/agent.py index f1b12f5..7d7dc13 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -26,8 +26,10 @@ from typing import Optional from conversation import Conversation, Turn from memory import Embedder, VectorStore, MemoryPoint -from prompts import build_system_prompt, IDENTITY_SEED +from prompts import build_system_prompt, IDENTITY_SEED, IDENTITY_ANCHOR from proxy_client import ProxyClient, Message as ProxyMessage +import router as router_mod +from local_llm import local_llm_chat import skills as skills_mod import triggers as triggers_mod import watcher as watcher_mod @@ -1053,6 +1055,56 @@ class Agent: return reply return None + # ── Fast-Lane: lokales schnelles LLM (Plan B, B1a) ── + # + # Zwischen Skill-Fast-Path und Claude-Loop: einfache Plauder-Turns beantwortet + # das lokale Qwen (schlanker Prompt, KEINE Tools) in <1 s. Gated ueber + # /shared/config/local_llm.json (Default aus → alles Claude wie bisher). + # Rueckgabe: fertige Antwort (str) wenn lokal erledigt, sonst None → Claude. + + def _try_local_fast_lane(self, user_message: str, + active_project_id: str) -> Optional[str]: + cfg = router_mod.load_config() + if not router_mod.should_try_local(user_message, cfg): + return None + + sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR) + window = self.conversation.window(project_id=active_project_id) + messages = [{"role": "system", "content": sys_prompt}] + messages += [{"role": t.role, "content": t.content} for t in window] + + res = local_llm_chat(messages, max_tokens=400, temperature=0.5) + local_only = bool(cfg.get("localOnly")) + + if not res.get("ok"): + logger.info("[router] lokal fehlgeschlagen (%s) — %s", + res.get("error"), "kein Fallback (localOnly)" if local_only else "→ Claude") + if local_only: + # Eval-Modus: KEIN Claude. Ehrliche Fehlermeldung statt Stille. + return f"[Lokales LLM nicht erreichbar: {res.get('error', 'unbekannt')}]" + return None + + content = (res.get("content") or "").strip() + + if local_only: + # Erzwungen lokal: Escalation-Marker ignorieren, Antwort so nehmen. + content = content.replace(router_mod.ESCALATE_MARKER, "").strip() + if not content: + return "[Lokales LLM lieferte keine Antwort.]" + logger.info("[router] lokal (localOnly) %sms", res.get("elapsedMs")) + self.conversation.add("assistant", content, project_id=active_project_id) + return content + + # Normalbetrieb: leere Antwort oder Escalation-Marker → Claude. + if not content or router_mod.ESCALATE_MARKER in content: + logger.info("[router] lokal eskaliert → Claude") + return None + + logger.info("[router] lokal beantwortet in %sms (%d Zeichen)", + res.get("elapsedMs"), len(content)) + self.conversation.add("assistant", content, project_id=active_project_id) + return content + # ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ── MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops @@ -1101,6 +1153,14 @@ class Agent: if active_project_id: projects_mod.touch_project(active_project_id) + # Fast-Lane: lokales schnelles LLM (Plan B, B1a). Gated ueber + # /shared/config/local_llm.json (Default aus → alles laeuft wie bisher + # ueber Claude). Erledigt es den Turn: fertige Antwort zurueck, der + # teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude. + local_reply = self._try_local_fast_lane(user_message, active_project_id) + if local_reply is not None: + return local_reply + # 2. Hot Memory (alle pinned Punkte) hot = self.store.list_pinned() diff --git a/aria-brain/router.py b/aria-brain/router.py new file mode 100644 index 0000000..77f0b2f --- /dev/null +++ b/aria-brain/router.py @@ -0,0 +1,135 @@ +""" +Router (Plan B, B1a) — entscheidet pro Turn: lokales schnelles LLM oder Claude. + +Gestaffelt: +- B1a (hier): „nur reden" — einfache Plauder-Turns → lokales Qwen (schlanker + Prompt, KEINE Tools). Antwortet es sauber → fertig in <1 s. Sagt es + `<>`, braucht ein Tool oder faellt aus → Claude (bestehender Pfad). +- B1b (spaeter): kuratierte lokale Tools + lokale Tool-Loop. + +Schalter kommen aus /shared/config/local_llm.json (Diagnostic schreibt, Brain +liest pro Request): + { + "enabled": false, # Master: lokales Tier an/aus (aus = alles Claude) + "localOnly": false, # Eval: erzwinge lokal, KEIN Claude-Fallback + "toolVariant": "slim" # "slim" | "full" (B1b; "full" braucht mehr VRAM) + } +Default (Datei fehlt/kaputt): enabled=false → Verhalten wie bisher (alles Claude). +""" + +from __future__ import annotations + +import json +import logging +import os +import re + +logger = logging.getLogger(__name__) + +CONFIG_PATH = os.environ.get("LOCAL_LLM_CONFIG", "/shared/config/local_llm.json") + +ESCALATE_MARKER = "<>" + +DEFAULT_CONFIG = {"enabled": False, "localOnly": False, "toolVariant": "slim"} + + +def load_config() -> dict: + """Liest die Schalter. Nie werfen — bei Fehler Defaults (= alles Claude).""" + try: + with open(CONFIG_PATH, encoding="utf-8") as f: + data = json.load(f) or {} + return { + "enabled": bool(data.get("enabled", False)), + "localOnly": bool(data.get("localOnly", False)), + "toolVariant": data.get("toolVariant", "slim") or "slim", + } + except (FileNotFoundError, json.JSONDecodeError): + return dict(DEFAULT_CONFIG) + except Exception as exc: + logger.debug("local_llm-Config lesen fehlgeschlagen: %s", exc) + return dict(DEFAULT_CONFIG) + + +# ── Heuristik: ist dieser Turn „einfach genug" fuers lokale Tier (B1a)? ── +# +# B1a ist reden-ohne-Tools. Also: alles, was ein Tool/Aktion braucht oder tief/ +# technisch ist, geht an Claude. Lieber konservativ (im Zweifel Claude) — das +# lokale Tier soll nur die klaren Plauder-Turns abgreifen; Fehlklassifikation +# faengt zusaetzlich das <> im Modell selbst ab. + +# Wortstämme, die auf Tool-/Aktionsbedarf hindeuten → nicht lokal (in B1a). +_TOOL_HINTS = re.compile( + r"\b(wetter|temperatur|regne|timer|erinner|weck|licht|lampe|steckdose|" + r"spotify|musik|lied|song|playlist|lauter|leiser|" + r"bild|generier|male?\b|zeichne|foto|" + r"merk dir|memory|gedächtnis|erinnere dich|" + r"skill|trigger|projekt|oauth|spotify|kalender|termin|" + r"such(e|st)?\b|google|internet|news|nachrichten|aktie|kurs|" + r"maild?|email|nachricht schreiben|sende)\b", + re.IGNORECASE, +) + +# Technik-/Tiefe-Marker → Claude (lokales 8B soll das nicht raten). +_HARD_HINTS = re.compile( + r"```|" # Codeblock + r"\b(code|fehler|error|stacktrace|exception|bug|debug|pentest|exploit|" + r"vuln|payload|regex|sql|python|javascript|docker|kubernetes|" + r"analysier|erklär.*genau|schritt für schritt|refactor|implementier)\b", + re.IGNORECASE, +) + +_MAX_LEN_FOR_LOCAL = 220 # laengere Nachrichten = eher komplexe Aufgaben → Claude + + +def should_try_local(user_message: str, cfg: dict) -> bool: + """True, wenn der Router diesen Turn (B1a, reden-only) lokal versuchen soll. + localOnly überschreibt die Heuristik (dann IMMER lokal).""" + if not cfg.get("enabled"): + return False + if cfg.get("localOnly"): + return True + msg = (user_message or "").strip() + if not msg or len(msg) > _MAX_LEN_FOR_LOCAL: + return False + if _TOOL_HINTS.search(msg): + return False + if _HARD_HINTS.search(msg): + return False + return True + + +# ── Schlanker System-Prompt fuers lokale Tier ── +# +# Klein halten (Speed!). Persona-Kern + Identitaets-Anker + kurze Awareness-Liste +# (WAS ARIA kann, ohne volle Schemas) + Escalation-Regel. KEINE Tool-Schemas, +# kein volles Memory (B1a). + +_AWARENESS = ( + "ARIA kann ausserdem (ueber ihr grosses Backend, nicht in diesem schnellen " + "Modus): Wetter/Zeit/Websuche, Timer & Erinnerungen, Musik (Spotify) & Licht " + "steuern, Bilder generieren, ins Gedaechtnis schreiben/suchen, Skills bauen, " + "Projekte & OAuth verwalten." +) + + +def build_local_system_prompt(identity_anchor: str, pinned_persona: str = "") -> str: + """Schlanker System-Prompt fuers lokale LLM. identity_anchor = derselbe + Anker wie bei Claude (Rolle haelt); pinned_persona optional kurz.""" + parts = [ + identity_anchor.strip(), + "", + "## SCHNELL-MODUS", + "Du laeufst gerade als schnelles lokales Modell fuer einfache, kurze " + "Alltags-Konversation. Antworte knapp, freundlich, auf Deutsch, als ARIA.", + "", + "## WAS DU HIER NICHT TUST", + _AWARENESS, + "Fuer alles, was ein Werkzeug/eine Aktion braucht, tiefer/technischer wird, " + "aktuelles Wissen oder Nachschlagen verlangt, oder wo du unsicher bist: " + f"antworte AUSSCHLIESSLICH mit exakt `{ESCALATE_MARKER}` (nichts sonst). " + "Dann uebernimmt automatisch das grosse Modell mit vollem Zugriff. " + "Lieber einmal eskalieren als falsch raten.", + ] + if pinned_persona.strip(): + parts += ["", "## PERSONA", pinned_persona.strip()] + return "\n".join(parts)