diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 978ac4a..1306d5e 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -21,6 +21,7 @@ import logging import os import re import urllib.error +import urllib.parse import urllib.request from typing import Optional @@ -37,6 +38,8 @@ import oauth as oauth_mod import projects as projects_mod 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 # 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. @@ -66,6 +69,38 @@ def _load_flux_config() -> dict: 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_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) ── { "type": "function", @@ -1055,62 +1113,104 @@ class Agent: return reply 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 - # das lokale Qwen (schlanker Prompt, KEINE Tools) in <1 s. Gated ueber + # Zwischen Skill-Fast-Path und Claude-Loop: einfache Turns beantwortet das + # 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). # 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, active_project_id: str) -> Optional[str]: cfg = router_mod.load_config() if not router_mod.should_try_local(user_message, cfg): 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) - # Nur die letzten paar Turns ans lokale Modell — es ist fuer kurze - # Plauder-Turns da. Volles Fenster (bis 50) wuerde das Prefill aufblaehen - # und den Speed-Vorteil auffressen (gemessen: 12 Turns → ~2,6s statt ~0,8s). + sys_prompt = router_mod.build_local_system_prompt(IDENTITY_ANCHOR, + has_tools=bool(tools)) + # Nur die letzten paar Turns ans lokale Modell (Speed — volles Fenster + # wuerde das Prefill aufblaehen). window = self.conversation.window(project_id=active_project_id)[-self._LOCAL_WINDOW_TURNS:] 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() + # Tool-Loop: lokales Modell darf web_search/memory_search/trigger_timer/ + # Spotify aufrufen. Ergebnisse zurueck, bis es final (ohne tool_calls) antwortet. + final = "" + for _ in range(self._LOCAL_TOOL_ITERATIONS): + res = local_llm_chat(messages, max_tokens=500, temperature=0.5, tools=tools) + 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: + return f"[Lokales LLM nicht erreichbar: {res.get('error', 'unbekannt')}]" + return None + 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: - # Erzwungen lokal: Escalation-Marker ignorieren, Antwort so nehmen. - content = content.replace(router_mod.ESCALATE_MARKER, "").strip() - if not content: + final = final.replace(router_mod.ESCALATE_MARKER, "").strip() + if not final: 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 + logger.info("[router] lokal (localOnly) beantwortet") + self.conversation.add("assistant", final, project_id=active_project_id) + return final # Normalbetrieb: leer, Escalation-Marker oder (unwahrscheinlich) ein # Identity-Break → Claude uebernehmen. - if (not content or router_mod.ESCALATE_MARKER in content - or looks_like_identity_break(content)): + if (not final or router_mod.ESCALATE_MARKER in final + or looks_like_identity_break(final)): 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 + logger.info("[router] lokal beantwortet (%d Zeichen)", len(final)) + self.conversation.add("assistant", final, project_id=active_project_id) + return final # ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ── @@ -1808,6 +1908,15 @@ class Agent: except Exception as e: logger.exception("memory_search fehlgeschlagen") 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": pid = (arguments.get("id") or "").strip() if not pid: diff --git a/aria-brain/router.py b/aria-brain/router.py index 77f0b2f..2b22287 100644 --- a/aria-brain/router.py +++ b/aria-brain/router.py @@ -57,15 +57,17 @@ def load_config() -> dict: # 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). +# 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( - 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", + r"\b(bild|generier|male?\b|malen|zeichne|foto|" + r"skill|projekt|oauth|" + r"licht|lampe|steckdose|rollade|heizung|" + r"kalender|termin|" + r"maild?|e-?mail|nachricht schreiben)\b", 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, # kein volles Memory (B1a). +# Was das lokale Tier NICHT selbst kann → dafuer eskaliert es an Claude. _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." + "Nur das grosse Modell (nicht du im Schnell-Modus) kann: Bilder generieren, " + "Skills bauen/aendern, Projekte & OAuth verwalten, ins Gedaechtnis SCHREIBEN, " + "sowie tiefe/technische Analysen und langen Code." ) -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 - Anker wie bei Claude (Rolle haelt); pinned_persona optional kurz.""" + Anker wie bei Claude (Rolle haelt).""" 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.", + "Du laeufst gerade als schnelles lokales Modell fuer Alltags-Konversation " + "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, - "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.", + "Dafuer — und bei tiefen/technischen Fragen, langem Code, oder wenn du " + f"unsicher bist — antworte AUSSCHLIESSLICH mit exakt `{ESCALATE_MARKER}` " + "(nichts sonst). Dann uebernimmt das grosse Modell mit vollem Zugriff. " + "Lieber einmal eskalieren als falsch raten oder ein Werkzeug erfinden.", ] if pinned_persona.strip(): parts += ["", "## PERSONA", pinned_persona.strip()] diff --git a/aria-data/searxng/settings.yml b/aria-data/searxng/settings.yml new file mode 100644 index 0000000..de4410d --- /dev/null +++ b/aria-data/searxng/settings.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 52373ec..c4cef1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,21 @@ services: networks: - 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) ───────────────────────── # Loest das alte aria-core (OpenClaw) ab. Vector-DB-basiertes # Memory, eigener Agent-Loop, SSH zur aria-wohnung-VM. @@ -86,6 +101,8 @@ services: - RVS_HOST=${RVS_HOST:-} - RVS_PORT_PUBLIC=${RVS_PORT_PUBLIC:-${RVS_PORT:-443}} - RVS_TLS=${RVS_TLS:-true} + # SearXNG (self-hosted Meta-Suche) fuer das web_search-Tool (B1b). + - SEARXNG_URL=${SEARXNG_URL:-http://searxng:8080} volumes: - ./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)