feat(local-llm): B1b — lokale Tools (web_search via SearXNG, Timer, Spotify, Memory)

Das lokale Tier kann jetzt Werkzeuge nutzen — Wetter/News/Fakten laufen lokal
statt ueber Claudes langsamen Web-Fetch.

- SearXNG-Container (aria VM, aria-net): self-hosted Meta-Suche, keyless,
  JSON-API aktiviert (aria-data/searxng/settings.yml), Rate-Limiter aus.
  Brain-Env SEARXNG_URL.
- web_search-Tool in META_TOOLS + _dispatch_tool (_web_search fragt SearXNG,
  gibt Titel/Snippet/URL der Top-Treffer). Steht auch Claude zur Verfuegung.
- Lokaler Tool-Loop (_try_local_fast_lane): kuratiertes Set web_search /
  memory_search / trigger_timer / run_spotify; max 3 Runden, sonst → Claude.
  Break-/Escalate-Guard bleibt.
- Router: should_try_local schliesst nur noch CLAUDE-ONLY-Themen aus (Bild,
  Skill, Projekt, OAuth, Licht, Kalender/Mail). Wetter/Timer/News/Musik/Memory
  gehen jetzt lokal. Verifiziert (alle Testfaelle korrekt).
- Schlanker Local-Prompt mit Tool-Guidance ("nur nutzen wenn noetig, sonst
  Smalltalk direkt beantworten"). Skill-BAUEN bleibt Claude-only.

Deploy: ARIA-VM brain+bridge+searxng, Gamebox llm-adapter (tool-passthrough).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 12:50:54 +02:00
co-authored by Claude Opus 4.8
parent a58aa5594d
commit 3cd7350160
4 changed files with 223 additions and 54 deletions
+141 -32
View File
@@ -21,6 +21,7 @@ import logging
import os import os
import re import re
import urllib.error import urllib.error
import urllib.parse
import urllib.request import urllib.request
from typing import Optional from typing import Optional
@@ -37,6 +38,8 @@ import oauth as oauth_mod
import projects as projects_mod import projects as projects_mod
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090") BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b).
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080").rstrip("/")
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start # FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
# laedt die flux-bridge zudem ~24 GB Modell von HF (~5-10 min). Brain wartet # laedt die flux-bridge zudem ~24 GB Modell von HF (~5-10 min). Brain wartet
# synchron — Stefan kuendigt es vorher an wenn er weiss dass es feuert. # synchron — Stefan kuendigt es vorher an wenn er weiss dass es feuert.
@@ -66,6 +69,38 @@ def _load_flux_config() -> dict:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _web_search(query: str, max_results: int = 5) -> str:
"""Fragt die self-hosted SearXNG-Instanz (JSON-API) und gibt die Top-Treffer
als kompakten Text zurueck (Titel + Snippet + URL). Nie werfen — Fehler als
Text-Resultat, damit der Tool-Loop weitermachen kann."""
try:
params = urllib.parse.urlencode({
"q": query, "format": "json", "language": "de", "safesearch": "0",
})
req = urllib.request.Request(
f"{SEARXNG_URL}/search?{params}",
headers={"User-Agent": "ARIA/1.0", "Accept": "application/json"},
)
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8", "ignore"))
except Exception as exc:
logger.warning("web_search (SearXNG) fehlgeschlagen: %s", exc)
return f"FEHLER: Websuche nicht verfuegbar ({exc})."
results = (data.get("results") or [])[:max_results]
if not results:
answers = data.get("answers") or []
if answers:
return "Direkte Antwort: " + " | ".join(str(a) for a in answers[:3])
return f"Keine Web-Treffer fuer '{query}'."
lines = [f"{len(results)} Web-Treffer fuer '{query}':"]
for i, r in enumerate(results, 1):
title = (r.get("title") or "").strip()
url = (r.get("url") or "").strip()
snippet = (r.get("content") or "").strip()
lines.append(f"\n{i}. {title}\n {snippet[:300]}\n Quelle: {url}")
return "\n".join(lines)
# Meta-Tool: ARIA kann selbst neue Skills bauen # Meta-Tool: ARIA kann selbst neue Skills bauen
META_TOOLS = [ META_TOOLS = [
{ {
@@ -811,6 +846,29 @@ META_TOOLS = [
}, },
}, },
}, },
{
"type": "function",
"function": {
"name": "web_search",
"description": (
"Durchsuche das Web (via SearXNG) nach AKTUELLEN, nachschlagbaren "
"Infos: Wetter, News, Fakten, Oeffnungszeiten, Preise, Definitionen "
"usw. Nutze das immer, wenn die Antwort aktuelles Wissen braucht, "
"das weder im Gedaechtnis noch in deinem Training steht (oder "
"veraltet sein koennte). Gib eine praezise Suchanfrage wie bei "
"Google. Ergebnis = Titel + Snippet + URL der Top-Treffer; fasse "
"daraus knapp die Antwort zusammen und nenne bei Bedarf die Quelle."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Suchanfrage (praezise, Suchmaschinen-Stil)"},
"max_results": {"type": "integer", "description": "Anzahl Treffer (Default 5, max 10)"},
},
"required": ["query"],
},
},
},
# ── Projekte (Stefan-Konzept: Threads im Hauptchat verankert) ── # ── Projekte (Stefan-Konzept: Threads im Hauptchat verankert) ──
{ {
"type": "function", "type": "function",
@@ -1055,62 +1113,104 @@ class Agent:
return reply return reply
return None return None
# ── Fast-Lane: lokales schnelles LLM (Plan B, B1a) ── # ── Fast-Lane: lokales schnelles LLM (Plan B, B1a/B1b) ──
# #
# Zwischen Skill-Fast-Path und Claude-Loop: einfache Plauder-Turns beantwortet # Zwischen Skill-Fast-Path und Claude-Loop: einfache Turns beantwortet das
# das lokale Qwen (schlanker Prompt, KEINE Tools) in <1 s. Gated ueber # lokale Qwen in <1 s. Seit B1b mit kuratierten Tools (web_search,
# memory_search, trigger_timer, Spotify). Gated ueber
# /shared/config/local_llm.json (Default aus → alles Claude wie bisher). # /shared/config/local_llm.json (Default aus → alles Claude wie bisher).
# Rueckgabe: fertige Antwort (str) wenn lokal erledigt, sonst None → Claude. # Rueckgabe: fertige Antwort (str) wenn lokal erledigt, sonst None → Claude.
_LOCAL_WINDOW_TURNS = 8 # nur die letzten N Turns ans lokale Modell (Speed) _LOCAL_WINDOW_TURNS = 8 # nur die letzten N Turns ans lokale Modell (Speed)
_LOCAL_TOOL_ITERATIONS = 3 # max Tool-Runden lokal, sonst → Claude
# Kuratierte Tool-Auswahl fuers lokale Tier (B1b). Namen aus META_TOOLS +
# der Spotify-Skill. Bewusst klein (Speed + Sicherheit); alles andere → Claude.
_LOCAL_TOOL_NAMES = {"web_search", "memory_search", "trigger_timer"}
def _build_local_tools(self) -> list:
tools = [t for t in META_TOOLS
if t.get("function", {}).get("name") in self._LOCAL_TOOL_NAMES]
for s in skills_mod.list_skills(active_only=False):
if s.get("name") == "spotify" and s.get("active", True):
tools.append(_skill_to_tool(s))
break
return tools
def _try_local_fast_lane(self, user_message: str, def _try_local_fast_lane(self, user_message: str,
active_project_id: str) -> Optional[str]: active_project_id: str) -> Optional[str]:
cfg = router_mod.load_config() cfg = router_mod.load_config()
if not router_mod.should_try_local(user_message, cfg): if not router_mod.should_try_local(user_message, cfg):
return None return None
local_only = bool(cfg.get("localOnly"))
tools = self._build_local_tools() # B1b: kuratierte Tools
sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR) sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR,
# Nur die letzten paar Turns ans lokale Modell — es ist fuer kurze has_tools=bool(tools))
# Plauder-Turns da. Volles Fenster (bis 50) wuerde das Prefill aufblaehen # Nur die letzten paar Turns ans lokale Modell (Speed — volles Fenster
# und den Speed-Vorteil auffressen (gemessen: 12 Turns → ~2,6s statt ~0,8s). # wuerde das Prefill aufblaehen).
window = self.conversation.window(project_id=active_project_id)[-self._LOCAL_WINDOW_TURNS:] window = self.conversation.window(project_id=active_project_id)[-self._LOCAL_WINDOW_TURNS:]
messages = [{"role": "system", "content": sys_prompt}] messages = [{"role": "system", "content": sys_prompt}]
messages += [{"role": t.role, "content": t.content} for t in window] messages += [{"role": t.role, "content": t.content} for t in window]
res = local_llm_chat(messages, max_tokens=400, temperature=0.5) # Tool-Loop: lokales Modell darf web_search/memory_search/trigger_timer/
local_only = bool(cfg.get("localOnly")) # Spotify aufrufen. Ergebnisse zurueck, bis es final (ohne tool_calls) antwortet.
final = ""
if not res.get("ok"): for _ in range(self._LOCAL_TOOL_ITERATIONS):
logger.info("[router] lokal fehlgeschlagen (%s) — %s", res = local_llm_chat(messages, max_tokens=500, temperature=0.5, tools=tools)
res.get("error"), "kein Fallback (localOnly)" if local_only else "→ Claude") if not res.get("ok"):
if local_only: logger.info("[router] lokal fehlgeschlagen (%s) — %s", res.get("error"),
# Eval-Modus: KEIN Claude. Ehrliche Fehlermeldung statt Stille. "kein Fallback (localOnly)" if local_only else "→ Claude")
return f"[Lokales LLM nicht erreichbar: {res.get('error', 'unbekannt')}]" if local_only:
return None return f"[Lokales LLM nicht erreichbar: {res.get('error', 'unbekannt')}]"
return None
content = (res.get("content") or "").strip() tcs = res.get("tool_calls")
if tcs:
messages.append({"role": "assistant",
"content": res.get("content") or "",
"tool_calls": tcs})
for tc in tcs:
fn = tc.get("function") or {}
tname = fn.get("name") or ""
try:
targs = json.loads(fn.get("arguments") or "{}")
except Exception:
targs = {}
logger.info("[router] lokal Tool-Call: %s(%s)", tname,
", ".join(targs.keys()))
tresult = self._dispatch_tool(tname, targs)
messages.append({"role": "tool",
"tool_call_id": tc.get("id") or "",
"name": tname,
"content": (tresult or "")[:6000]})
continue # naechste Runde mit Tool-Ergebnissen
final = (res.get("content") or "").strip()
break
else:
logger.info("[router] lokal Tool-Loop-Limit → %s",
"Fallback (localOnly)" if local_only else "Claude")
if not local_only:
return None
final = final or "[Lokales LLM: Tool-Loop-Limit erreicht.]"
if local_only: if local_only:
# Erzwungen lokal: Escalation-Marker ignorieren, Antwort so nehmen. final = final.replace(router_mod.ESCALATE_MARKER, "").strip()
content = content.replace(router_mod.ESCALATE_MARKER, "").strip() if not final:
if not content:
return "[Lokales LLM lieferte keine Antwort.]" return "[Lokales LLM lieferte keine Antwort.]"
logger.info("[router] lokal (localOnly) %sms", res.get("elapsedMs")) logger.info("[router] lokal (localOnly) beantwortet")
self.conversation.add("assistant", content, project_id=active_project_id) self.conversation.add("assistant", final, project_id=active_project_id)
return content return final
# Normalbetrieb: leer, Escalation-Marker oder (unwahrscheinlich) ein # Normalbetrieb: leer, Escalation-Marker oder (unwahrscheinlich) ein
# Identity-Break → Claude uebernehmen. # Identity-Break → Claude uebernehmen.
if (not content or router_mod.ESCALATE_MARKER in content if (not final or router_mod.ESCALATE_MARKER in final
or looks_like_identity_break(content)): or looks_like_identity_break(final)):
logger.info("[router] lokal eskaliert → Claude") logger.info("[router] lokal eskaliert → Claude")
return None return None
logger.info("[router] lokal beantwortet in %sms (%d Zeichen)", logger.info("[router] lokal beantwortet (%d Zeichen)", len(final))
res.get("elapsedMs"), len(content)) self.conversation.add("assistant", final, project_id=active_project_id)
self.conversation.add("assistant", content, project_id=active_project_id) return final
return content
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ── # ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
@@ -1808,6 +1908,15 @@ class Agent:
except Exception as e: except Exception as e:
logger.exception("memory_search fehlgeschlagen") logger.exception("memory_search fehlgeschlagen")
return f"FEHLER: {e}" return f"FEHLER: {e}"
if name == "web_search":
query = (arguments.get("query") or "").strip()
if not query:
return "FEHLER: query ist Pflicht."
try:
n = int(arguments.get("max_results", 5))
except (TypeError, ValueError):
n = 5
return _web_search(query, max(1, min(n, 10)))
if name == "memory_update": if name == "memory_update":
pid = (arguments.get("id") or "").strip() pid = (arguments.get("id") or "").strip()
if not pid: if not pid:
+40 -22
View File
@@ -57,15 +57,17 @@ def load_config() -> dict:
# lokale Tier soll nur die klaren Plauder-Turns abgreifen; Fehlklassifikation # lokale Tier soll nur die klaren Plauder-Turns abgreifen; Fehlklassifikation
# faengt zusaetzlich das <<ESCALATE>> im Modell selbst ab. # faengt zusaetzlich das <<ESCALATE>> im Modell selbst ab.
# Wortstämme, die auf Tool-/Aktionsbedarf hindeuten → nicht lokal (in B1a). # CLAUDE-ONLY-Themen → nicht lokal. Seit B1b hat das lokale Tier Werkzeuge
# (web_search, memory_search, trigger_timer, Spotify), daher gehen Wetter, News,
# Fakten, Timer, Musik, Gedaechtnis-Suche jetzt LOKAL. Nur was das lokale Tier
# nicht kann bleibt hier: Bilder, Skills, Projekte, OAuth, Smart-Home (keine
# Anbindung), Kalender/Mail (kein Tool).
_TOOL_HINTS = re.compile( _TOOL_HINTS = re.compile(
r"\b(wetter|temperatur|regne|timer|erinner|weck|licht|lampe|steckdose|" r"\b(bild|generier|male?\b|malen|zeichne|foto|"
r"spotify|musik|lied|song|playlist|lauter|leiser|" r"skill|projekt|oauth|"
r"bild|generier|male?\b|zeichne|foto|" r"licht|lampe|steckdose|rollade|heizung|"
r"merk dir|memory|gedächtnis|erinnere dich|" r"kalender|termin|"
r"skill|trigger|projekt|oauth|spotify|kalender|termin|" r"maild?|e-?mail|nachricht schreiben)\b",
r"such(e|st)?\b|google|internet|news|nachrichten|aktie|kurs|"
r"maild?|email|nachricht schreiben|sende)\b",
re.IGNORECASE, re.IGNORECASE,
) )
@@ -104,31 +106,47 @@ def should_try_local(user_message: str, cfg: dict) -> bool:
# (WAS ARIA kann, ohne volle Schemas) + Escalation-Regel. KEINE Tool-Schemas, # (WAS ARIA kann, ohne volle Schemas) + Escalation-Regel. KEINE Tool-Schemas,
# kein volles Memory (B1a). # kein volles Memory (B1a).
# Was das lokale Tier NICHT selbst kann → dafuer eskaliert es an Claude.
_AWARENESS = ( _AWARENESS = (
"ARIA kann ausserdem (ueber ihr grosses Backend, nicht in diesem schnellen " "Nur das grosse Modell (nicht du im Schnell-Modus) kann: Bilder generieren, "
"Modus): Wetter/Zeit/Websuche, Timer & Erinnerungen, Musik (Spotify) & Licht " "Skills bauen/aendern, Projekte & OAuth verwalten, ins Gedaechtnis SCHREIBEN, "
"steuern, Bilder generieren, ins Gedaechtnis schreiben/suchen, Skills bauen, " "sowie tiefe/technische Analysen und langen Code."
"Projekte & OAuth verwalten."
) )
def build_local_system_prompt(identity_anchor: str, pinned_persona: str = "") -> str: def build_local_system_prompt(identity_anchor: str, has_tools: bool = False,
pinned_persona: str = "") -> str:
"""Schlanker System-Prompt fuers lokale LLM. identity_anchor = derselbe """Schlanker System-Prompt fuers lokale LLM. identity_anchor = derselbe
Anker wie bei Claude (Rolle haelt); pinned_persona optional kurz.""" Anker wie bei Claude (Rolle haelt)."""
parts = [ parts = [
identity_anchor.strip(), identity_anchor.strip(),
"", "",
"## SCHNELL-MODUS", "## SCHNELL-MODUS",
"Du laeufst gerade als schnelles lokales Modell fuer einfache, kurze " "Du laeufst gerade als schnelles lokales Modell fuer Alltags-Konversation "
"Alltags-Konversation. Antworte knapp, freundlich, auf Deutsch, als ARIA.", "und einfache Aufgaben. Antworte knapp, freundlich, auf Deutsch, als ARIA.",
]
if has_tools:
parts += [
"",
"## DEINE WERKZEUGE — nur nutzen wenn die Frage es WIRKLICH braucht",
"- `web_search`: aktuelle Infos aus dem Netz — Wetter, News, Fakten, "
"Preise, Oeffnungszeiten. Bei Wetter: nimm Stefans Ort aus dem "
"GPS-Hinweis in der Nachricht.",
"- `memory_search`: in ARIAs Gedaechtnis nachsehen (lesen).",
"- `trigger_timer`: Timer/Erinnerung setzen ('in 10 Minuten…').",
"- `run_spotify`: Musik steuern (naechster Titel, Pause, weiter).",
"WICHTIG: Bei reinem Smalltalk ('wie gehts', Begruessung, Meinung) "
"KEIN Werkzeug — einfach direkt antworten. Werkzeuge nur bei echtem "
"Bedarf; erfinde keine.",
]
parts += [
"", "",
"## WAS DU HIER NICHT TUST", "## WAS DU HIER NICHT KANNST",
_AWARENESS, _AWARENESS,
"Fuer alles, was ein Werkzeug/eine Aktion braucht, tiefer/technischer wird, " "Dafuer — und bei tiefen/technischen Fragen, langem Code, oder wenn du "
"aktuelles Wissen oder Nachschlagen verlangt, oder wo du unsicher bist: " f"unsicher bist — antworte AUSSCHLIESSLICH mit exakt `{ESCALATE_MARKER}` "
f"antworte AUSSCHLIESSLICH mit exakt `{ESCALATE_MARKER}` (nichts sonst). " "(nichts sonst). Dann uebernimmt das grosse Modell mit vollem Zugriff. "
"Dann uebernimmt automatisch das grosse Modell mit vollem Zugriff. " "Lieber einmal eskalieren als falsch raten oder ein Werkzeug erfinden.",
"Lieber einmal eskalieren als falsch raten.",
] ]
if pinned_persona.strip(): if pinned_persona.strip():
parts += ["", "## PERSONA", pinned_persona.strip()] parts += ["", "## PERSONA", pinned_persona.strip()]
+25
View File
@@ -0,0 +1,25 @@
# SearXNG-Config fuer ARIA (self-hosted Meta-Suche, Backend fuers web_search-Tool).
# Erbt alle Default-Engines; wir ueberschreiben nur das Noetige:
# - JSON-Format aktiviert (Default AUS) -> Brain kann /search?format=json rufen
# - Rate-Limiter aus -> programmatischer Brain-Zugriff wird nicht geblockt
# - eigener secret_key (interne Instanz auf aria-net, nicht oeffentlich exponiert)
use_default_settings: true
server:
# Interner Dienst auf aria-net, nicht oeffentlich. Trotzdem ein eigener Key.
# Bei Bedarf aendern (beliebiger langer Zufallsstring).
secret_key: "aria-searxng-6f2c9a1e8b7d4f30a5c1e2d9b8a7f6c3"
limiter: false
image_proxy: false
search:
formats:
- html
- json
# Deutsch bevorzugen (Brain kann per Query-Param ueberschreiben).
default_lang: "de"
# Sanftere Timeouts, damit eine langsame Engine die Suche nicht ausbremst.
outgoing:
request_timeout: 5.0
max_request_timeout: 10.0
+17
View File
@@ -53,6 +53,21 @@ services:
networks: networks:
- aria-net - aria-net
# ─── SearXNG (self-hosted Meta-Suche) ────────────────────
# Backend fuer das web_search-Tool (B1b). Aggregiert Google/Bing/Brave/… ohne
# API-Key, laeuft nur intern auf aria-net. Config: aria-data/searxng/settings.yml
# (JSON-Format aktiviert, Rate-Limiter aus fuer den Brain-Zugriff).
searxng:
image: searxng/searxng:latest
container_name: aria-searxng
volumes:
- ./aria-data/searxng:/etc/searxng
environment:
- SEARXNG_BASE_URL=http://searxng:8080/
restart: unless-stopped
networks:
- aria-net
# ─── ARIA Brain (Agent + Memory) ───────────────────────── # ─── ARIA Brain (Agent + Memory) ─────────────────────────
# Loest das alte aria-core (OpenClaw) ab. Vector-DB-basiertes # Loest das alte aria-core (OpenClaw) ab. Vector-DB-basiertes
# Memory, eigener Agent-Loop, SSH zur aria-wohnung-VM. # Memory, eigener Agent-Loop, SSH zur aria-wohnung-VM.
@@ -86,6 +101,8 @@ services:
- RVS_HOST=${RVS_HOST:-} - RVS_HOST=${RVS_HOST:-}
- RVS_PORT_PUBLIC=${RVS_PORT_PUBLIC:-${RVS_PORT:-443}} - RVS_PORT_PUBLIC=${RVS_PORT_PUBLIC:-${RVS_PORT:-443}}
- RVS_TLS=${RVS_TLS:-true} - RVS_TLS=${RVS_TLS:-true}
# SearXNG (self-hosted Meta-Suche) fuer das web_search-Tool (B1b).
- SEARXNG_URL=${SEARXNG_URL:-http://searxng:8080}
volumes: volumes:
- ./aria-data/brain/data:/data # Memory-Cache + Skills + Models (bind-mount fuer Export) - ./aria-data/brain/data:/data # Memory-Cache + Skills + Models (bind-mount fuer Export)
- ./aria-data/brain-import:/import:ro # Quell-MDs fuer den initialen Memory-Import (read-only) - ./aria-data/brain-import:/import:ro # Quell-MDs fuer den initialen Memory-Import (read-only)