feat(router): B1a — lokale Fast-Lane (reden-only) mit Schaltern, gated (Default aus)
Zwischen Skill-Fast-Path und Claude-Loop: einfache Plauder-Turns → lokales Qwen (schlanker Prompt, KEINE Tools) in <1 s; sonst Claude wie bisher. - router.py: load_config() liest /shared/config/local_llm.json (enabled/localOnly/ toolVariant; Default enabled=false → alles Claude). should_try_local() Heuristik (kurz + keine Tool-/Technik-Marker; localOnly erzwingt lokal). build_local_ system_prompt() = schlank (IDENTITY_ANCHOR + Schnell-Modus + Awareness-Liste + <<ESCALATE>>-Regel, keine Tool-Schemas). - agent.py: _try_local_fast_lane() — baut schlanken Prompt + Window, ruft local_llm_chat; leer/ESCALATE → None (→ Claude), sonst Antwort + persistiert. localOnly: kein Claude-Fallback (Eval), ehrliche Fehlermeldung bei Nichterreich. In chat() nach User-Turn gated aufgerufen. Heuristik lokal verifiziert (alle Testfaelle korrekt). Gated off → laufendes Verhalten unveraendert bis Master-Schalter an. Diagnostic-Toggles (B1a-2) + lokale Tools (B1b) folgen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+61
-1
@@ -26,8 +26,10 @@ from typing import Optional
|
|||||||
|
|
||||||
from conversation import Conversation, Turn
|
from conversation import Conversation, Turn
|
||||||
from memory import Embedder, VectorStore, MemoryPoint
|
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
|
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 skills as skills_mod
|
||||||
import triggers as triggers_mod
|
import triggers as triggers_mod
|
||||||
import watcher as watcher_mod
|
import watcher as watcher_mod
|
||||||
@@ -1053,6 +1055,56 @@ class Agent:
|
|||||||
return reply
|
return reply
|
||||||
return None
|
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 ──
|
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
|
||||||
|
|
||||||
MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops
|
MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops
|
||||||
@@ -1101,6 +1153,14 @@ class Agent:
|
|||||||
if active_project_id:
|
if active_project_id:
|
||||||
projects_mod.touch_project(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)
|
# 2. Hot Memory (alle pinned Punkte)
|
||||||
hot = self.store.list_pinned()
|
hot = self.store.list_pinned()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
`<<ESCALATE>>`, 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 = "<<ESCALATE>>"
|
||||||
|
|
||||||
|
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 <<ESCALATE>> 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)
|
||||||
Reference in New Issue
Block a user