""" Conversation-Loop. Eine Anfrage von Stefan, eine Antwort von ARIA. Pro Turn: 1. user-Turn an die laufende Conversation appenden 2. Hot Memory holen (alle pinned Punkte) 3. Cold Memory holen (Top-K semantisch zur user-Nachricht) 4. System-Prompt aus Hot+Cold bauen 5. Messages = [system, *window, user] 6. Claude via Proxy aufrufen 7. Assistant-Reply in Conversation appenden + zurueckgeben Memory-Destillat laeuft asynchron NACH dem Reply, gesteuert vom /chat-Endpoint ueber BackgroundTasks. """ from __future__ import annotations import json import logging import os import urllib.error import urllib.request from typing import Optional from conversation import Conversation, Turn from memory import Embedder, VectorStore, MemoryPoint from prompts import build_system_prompt from proxy_client import ProxyClient, Message as ProxyMessage import skills as skills_mod import triggers as triggers_mod import watcher as watcher_mod BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090") # 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. FLUX_HTTP_TIMEOUT_SEC = 1200 # Diagnostic-Settings fuer FLUX (Default-Modell + User-Keywords) liegen im # selben File wie F5-TTS/Whisper Config — von der aria-bridge geschrieben. VOICE_CONFIG_PATH = "/shared/config/voice_config.json" def _load_flux_config() -> dict: """Liest fluxXxx-Felder aus der Voice-Config. Default-Werte wenn nichts persistiert ist — Stefan hat in Diagnostic vielleicht noch nichts gesetzt.""" try: with open(VOICE_CONFIG_PATH, encoding="utf-8") as f: data = json.load(f) or {} except (FileNotFoundError, json.JSONDecodeError): data = {} except Exception as exc: logger.debug("Voice-Config lesen fehlgeschlagen: %s", exc) data = {} return { "fluxDefaultModel": data.get("fluxDefaultModel", "dev"), "fluxKeywordRaw": data.get("fluxKeywordRaw", "flux"), "fluxKeywordSwitch": data.get("fluxKeywordSwitch", "fix"), } logger = logging.getLogger(__name__) # Meta-Tool: ARIA kann selbst neue Skills bauen META_TOOLS = [ { "type": "function", "function": { "name": "skill_create", "description": ( "Erstelle einen neuen Skill (wiederverwendbare Faehigkeit). " "Skills sind IMMER Python — jeder Skill bekommt seine eigene venv " "mit den pip_packages die er braucht.\n\n" "HARTE REGEL — IMMER Skill anlegen wenn: die Loesung erfordert eine " "pip-Library. Sonst muesste der Install bei jedem Container-Restart " "neu laufen (Brain hat keinen persistenten State ausser /data/skills/).\n\n" "Sonst NUR wenn ALLE Kriterien erfuellt sind:\n" " 1) wiederkehrend (Aufgabe kommt realistisch nochmal),\n" " 2) nicht-trivial (mehrere Schritte),\n" " 3) parametrisierbar (nimmt Eingaben, gibt Ergebnis),\n" " 4) wiederverwendbar als ganzes Paket.\n" "NICHT fuer einzelne Shell-Befehle (date, hostname, ls etc.) und " "nicht fuer Einmal-Faelle. Stefan kann Skill-Erstellung explizit " "triggern (\"bau daraus einen Skill\").\n\n" "Wenn etwas nur via apt-Paket geht — Stefan fragen ob es ins " "Brain-Dockerfile soll, NICHT als Skill bauen." ), "parameters": { "type": "object", "properties": { "name": {"type": "string", "description": "kurz, kebab-case, a-z 0-9 - _"}, "description": {"type": "string", "description": "Was kann der Skill? 1 Satz."}, "entry_code": { "type": "string", "description": ( "Python-Code. Args lesen via os.environ['ARG_NAME']. " "Resultat per print() (stdout) zurueck. Bei Fehler: " "non-zero exit (sys.exit(1) o.ae.)." ), }, "readme": {"type": "string", "description": "Markdown — was macht der Skill, Beispiel-Aufrufe"}, "pip_packages": { "type": "array", "items": {"type": "string"}, "description": "pip-Pakete die in der venv installiert werden (z.B. requests, yt-dlp, pypdf)", }, "args": { "type": "array", "items": {"type": "object"}, "description": "Argumente-Schema [{name, type, required, description}]", }, }, "required": ["name", "description", "entry_code"], }, }, }, { "type": "function", "function": { "name": "skill_list", "description": "Zeigt alle Skills (inkl. deaktivierte). Sollte selten noetig sein — die Liste steht eh im System-Prompt.", "parameters": {"type": "object", "properties": {}}, }, }, { "type": "function", "function": { "name": "trigger_timer", "description": ( "Lege einen Timer-Trigger an — feuert EINMALIG und ruft dich dann selbst auf " "(Push-Nachricht an Stefan). Use-Case: 'erinnere mich in 10min', " "'sag mir um 14:30 Bescheid'. Genau EINES von `in_seconds` ODER `fires_at` " "muss gesetzt sein." ), "parameters": { "type": "object", "properties": { "name": {"type": "string", "description": "kurzer kebab-case-Name, a-z 0-9 - _"}, "in_seconds": { "type": "integer", "description": ( "Relativ ab jetzt in Sekunden. Bevorzugt bei Angaben wie " "'in 2 Minuten' (=120), 'in 1 Stunde' (=3600). " "Server berechnet daraus den absoluten Feuer-Zeitpunkt." ), }, "fires_at": { "type": "string", "description": ( "Absoluter ISO-Timestamp UTC fuer feste Termine, z.B. " "'2026-05-12T14:30:00Z'. Die aktuelle Zeit findest du im " "System-Prompt unter '## Aktuelle Zeit'. Fuer relative Angaben " "lieber `in_seconds` nutzen." ), }, "message": {"type": "string", "description": "Was soll bei der Erinnerung gesagt werden"}, }, "required": ["name", "message"], }, }, }, { "type": "function", "function": { "name": "trigger_watcher", "description": ( "Lege einen Watcher-Trigger an — pollt eine Condition, " "feuert wenn sie wahr wird (mit Throttle damit's nicht spammt). " "Use-Case: 'sag bescheid wenn Disk unter 5GB', 'pingt mich wenn um 8 Uhr'. " "Welche Variablen verfuegbar sind und ihre Bedeutung steht im System-Prompt.\n\n" "Fuer GPS-Trigger gibt es DREI Modi — waehle nach Use-Case:\n" "- **`near(lat, lon, r)`**: SOLANGE im Radius (mit Throttle gegen Spam). " "Use-Case: 'bin ich noch in der Naehe von X?'. Empfohlener throttle 300-3600s.\n" "- **`entered_near(lat, lon, r)`**: EINMAL beim Eintritt (Uebergang draussen→innen). " "Use-Case: Blitzer-Warner, Ankunfts-Erinnerung. Mit grossem r (z.B. 2000) " "wird's zur Vorwarnung 2 km vor dem Ziel. Empfohlener throttle: kurz (30-60s, " "nur gegen GPS-Jitter).\n" "- **`left_near(lat, lon, r)`**: EINMAL beim Verlassen (Uebergang innen→draussen). " "Use-Case: 'Hast du am Parkplatz X was vergessen?'. Empfohlener throttle: kurz." ), "parameters": { "type": "object", "properties": { "name": {"type": "string", "description": "kurzer Name"}, "condition": { "type": "string", "description": ( "Boolescher Ausdruck mit den erlaubten Variablen, z.B. " "'disk_free_gb < 5', 'hour_of_day == 8 and day_of_week == \"mon\"'. " "Operatoren: < > <= >= == != and or not" ), }, "message": {"type": "string", "description": "Was soll bei Erfuellung gesagt werden"}, "check_interval_sec": { "type": "integer", "description": "Wie oft Condition pruefen (Default 300 = alle 5min, min 30)", }, "throttle_sec": { "type": "integer", "description": "Mindestabstand zwischen 2 Feuerungen (Default 3600 = max 1x/h)", }, }, "required": ["name", "condition", "message"], }, }, }, { "type": "function", "function": { "name": "trigger_cancel", "description": "Loescht einen Trigger (Timer abbrechen oder Watcher entfernen).", "parameters": { "type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"], }, }, }, { "type": "function", "function": { "name": "trigger_list", "description": "Zeigt alle Trigger (active + inaktiv). Selten noetig — Stefan sieht sie im Diagnostic.", "parameters": {"type": "object", "properties": {}}, }, }, { "type": "function", "function": { "name": "request_location_tracking", "description": ( "Bittet die App, das kontinuierliche GPS-Tracking zu aktivieren oder zu " "deaktivieren. Default ist AUS (Akku-Schutz). Nutze das wenn du einen " "GPS-basierten Watcher anlegst (z.B. `near(...)`), sonst hat die App " "veraltete Position und der Watcher feuert nie. Auch wieder ausschalten " "wenn der letzte GPS-Watcher geloescht wurde." ), "parameters": { "type": "object", "properties": { "on": {"type": "boolean", "description": "true = Tracking an, false = aus"}, "reason": {"type": "string", "description": "Kurzer Grund (wird in App-Notification angezeigt)"}, }, "required": ["on"], }, }, }, { "type": "function", "function": { "name": "flux_generate", "description": ( "Generiere ein Bild aus einem Text-Prompt via FLUX auf der Gamebox-GPU. " "Brauchbar fuer 'mal mir ein X', 'wie sieht ein Y aus?', Mockups, " "Konzept-Skizzen, Memes. Render dauert 20-90s — kuendige es Stefan " "kurz an, dann ist er nicht ueberrascht.\n\n" "**Schreibe deine Antwort wie immer auf Deutsch**, und referenziere das " "fertige Bild MIT dem `[FILE: ...]`-Marker, GENAU im Pfad-Format das das " "Tool zurueckgibt. Beispiel:\n" " 'Hier dein Aquarell:\\n[FILE: /shared/uploads/aria_generated_1234.png]'\n\n" "Der Marker wird beim App-Renderer ausgeblendet und das Bild stattdessen " "inline als Anhang gezeigt.\n\n" "**Prompt-Sprache: bevorzugt Englisch.** FLUX versteht zwar Deutsch, " "liefert aber mit englischen Prompts deutlich konsistentere Ergebnisse. " "Uebersetze Stefans deutsche Beschreibung selbststaendig — AUSSER `raw=true`.\n\n" "**Modus `raw=true` (Pipe-Modus):** Wenn Stefan das Raw-Keyword aus dem " "FLUX-Settings-Block im System-Prompt nutzt (typischerweise `flux`), " "leite seinen Text 1:1 als prompt durch — KEIN Uebersetzen, KEIN " "Beautify, KEINE Qualitaets-Keywords. Stefan formuliert dann selbst und " "der Prompt geht roh an FLUX. Brauchbar wenn er den vollen Output ohne " "ARIAs Filter haben will.\n\n" "**Modell-Wahl (`model`):** \n" "- `default` (oder weglassen): das in den Diagnostic-Settings eingestellte " "Default-Modell (steht im FLUX-Block im System-Prompt).\n" "- `dev`: hochqualitatives FLUX.1-dev, 20-90s, ~28 steps.\n" "- `schnell`: FLUX.1-schnell, 4-step distillation, ~5-15s.\n" "Wenn Stefan das Switch-Keyword (steht ebenfalls im FLUX-Block) im Prompt " "verwendet → setze `model` auf das ANDERE Modell als das Default. Bei " "'in hoher Qualitaet'/'detailliert' → `dev`. Bei 'schnell mal'/'fix' → `schnell`.\n\n" "Modell-Switch kostet einmalig 15-30s (Pipeline-Reload aus HF-Cache). " "Stefan sieht den Status im Diagnostic-Banner.\n\n" "Caps:\n" "- `width`/`height`: 256-1536, wird auf Vielfache von 64 gesnappt (Default 1024)\n" "- `steps`: 1-50 (Default 28 fuer dev, 4 fuer schnell)\n" "- `guidance_scale`: 0.0-20.0 (Default 3.5)\n" "- `seed`: optional, gleicher seed + gleicher prompt → gleiches Bild" ), "parameters": { "type": "object", "properties": { "prompt": { "type": "string", "description": ( "Bei raw=false (Default): englischer Bild-Prompt, von dir aus Stefans Worten gebaut, " "mit Stil/Licht/Kamera-Stichworten. Bei raw=true: Stefans Text 1:1 ohne Aenderung." ), }, "raw": { "type": "boolean", "description": ( "true = Pipe-Modus, kein Rewriting. Setzen wenn Stefan das Raw-Keyword " "(siehe FLUX-Block im System-Prompt) am Anfang seiner Nachricht verwendet." ), }, "model": { "type": "string", "enum": ["default", "dev", "schnell"], "description": "Default-Modell oder explizit dev/schnell. Default = Diagnostic-Setting.", }, "width": {"type": "integer", "description": "Breite in px (Default 1024, max 1536)"}, "height": {"type": "integer", "description": "Hoehe in px (Default 1024, max 1536)"}, "steps": {"type": "integer", "description": "Inference-Steps (Default 28, max 50). Mehr = besser+langsamer."}, "guidance_scale": {"type": "number", "description": "Wie strikt am Prompt kleben (Default 3.5)"}, "seed": {"type": "integer", "description": "Reproduzierbarkeits-Seed (optional)"}, }, "required": ["prompt"], }, }, }, { "type": "function", "function": { "name": "memory_search", "description": ( "Durchsuche aktiv dein Gedaechtnis (Qdrant-DB). Nutze das wenn:\n" "- der User sagt 'schau in deinem Gedaechtnis' / 'ich hab das Memory aktualisiert'\n" "- du dir bei einer Info aus dem Konversations-Verlauf unsicher bist " "(z.B. ob das noch der aktuelle Stand ist)\n" "- du pruefen willst ob's schon einen Memory zu einem Thema gibt bevor " "du via memory_save einen neuen anlegst (vermeidet Fragmentierung)\n\n" "**WICHTIG: Memory ist Truth ueber dem Conversation-Window.** " "Wenn dort was anders steht als in deinem Gespraechs-Verlauf, gilt das " "was im Memory steht — der User koennte gerade was korrigiert haben.\n\n" "Mode 'text' = Substring (case-insensitive), gut fuer exakte Begriffe " "wie 'cessna'. Mode 'semantic' = Embedder-Search, gut fuer 'wann hatten " "wir ueber X gesprochen'-Fragen." ), "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "Such-Begriff"}, "mode": { "type": "string", "enum": ["text", "semantic"], "description": "Default 'text' (Substring). 'semantic' fuer aehnlichkeits-Suche.", }, "k": {"type": "integer", "description": "Wieviele Treffer (Default 5, max 20)"}, }, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "memory_update", "description": ( "Aktualisiere einen existierenden Memory-Eintrag — gibt die ID aus " "memory_search oder dem Cold-Memory an. Nur die uebergebenen Felder werden " "ueberschrieben, der Rest bleibt unangetastet. **Bevorzuge das ueber " "memory_save** wenn der User eine Korrektur macht oder du zusaetzliche " "Details zum gleichen Thema hast — vermeidet doppelte Eintraege." ), "parameters": { "type": "object", "properties": { "id": {"type": "string", "description": "Memory-ID (UUID, aus memory_search oder Cold-Memory)"}, "title": {"type": "string", "description": "Neuer Titel (optional)"}, "content": {"type": "string", "description": "Neuer Content — wird neu embedded fuer Search (optional)"}, "category": {"type": "string", "description": "Neue Kategorie (optional)"}, "tags": {"type": "array", "items": {"type": "string"}, "description": "Neue Tags (ueberschreibt komplett)"}, "pinned": {"type": "boolean", "description": "Pinning aendern (optional)"}, }, "required": ["id"], }, }, }, { "type": "function", "function": { "name": "memory_save", "description": ( "Speichere eine Information dauerhaft in deinem Gedaechtnis (Qdrant-DB). " "Nutze das wenn Stefan 'merk dir das' sagt oder du selbst etwas Wichtiges " "festhalten willst. ALTERNATIVEN VERMEIDEN: du hast KEIN persistentes " "File-Memory mehr — schreibe nicht in `~/.claude/projects/...`, das ist tot.\n\n" "Type-Wahl:\n" "- identity: ARIAs Selbstbild / Wesensart (PINNED)\n" "- rule: harte Regel / Sicherheit / Werte (PINNED)\n" "- preference: Stefans Vorlieben/Arbeitsweise (PINNED)\n" "- tool: Tool-Freigaben / Infrastruktur (PINNED)\n" "- skill: Faehigkeit / Workflow-Anleitung (PINNED)\n" "- fact: Wissen ueber Stefan/Welt/Sachen (Vorlieben, Besitz, Orte, " "Termine, Personen). Cold Memory, kommt nur via Semantic Search " "rein. **Default fuer 'merk-dir-das'-Anfragen.**\n" "- reminder: Termin/Aufgabe. Fuer ARIA-soll-ausloesen lieber trigger_timer.\n\n" "Wenn unsicher: type=fact, pinned=false.\n\n" "### Anhaenge\n" "`attach_paths` haengt Dateien (Bilder, PDFs, ...) aus `/shared/uploads/` " "an die Memory. Pfade kommen typischerweise aus dem Chat (Stefan haengt " "ein Foto an, du siehst den Pfad in der User-Message).\n\n" "**WICHTIG vor dem Speichern bei Bildern**: Schau dir das Bild ZUERST " "an mit `Read ` (dein Read-Tool ist multi-modal — es liest Bilder " "wie Vision-API). Extrahiere alles Relevante in den content: sichtbare " "Texte, Marken/Modelle, Kennzeichen/Seriennummern, Personen, Orte, " "auffaellige Details. Dann erst memory_save mit dem extrahierten " "content + attach_paths fuer das Bild. So weisst du beim spaeteren " "Cold-Memory-Lookup was im Bild war, ohne es nochmal lesen zu muessen.\n\n" "Beispiel-Workflow:\n" "1. User: 'Ich hab eine Cessna 172' + /shared/uploads/aria_xy.jpg\n" "2. Du: `Read /shared/uploads/aria_xy.jpg` → siehst Foto, erkennst Kennung D-EAAA\n" "3. Du: `memory_save(type='fact', title='Stefans Cessna 172', " "content='Stefan besitzt eine Cessna 172, Kennung D-EAAA, " "weiss/rot lackiert, vor Hangar fotografiert.', " "attach_paths=['/shared/uploads/aria_xy.jpg'])`" ), "parameters": { "type": "object", "properties": { "title": {"type": "string", "description": "Kurzer Titel (max ~80 Zeichen)"}, "content": {"type": "string", "description": "Der eigentliche Inhalt — wird embedded fuer Semantic Search. Bei Bildern: extrahierte Infos REINSCHREIBEN (Texte, Kennungen, Marken, etc.)"}, "type": { "type": "string", "enum": ["identity", "rule", "preference", "tool", "skill", "fact", "conversation", "reminder"], "description": "Memory-Typ (siehe oben)", }, "category": {"type": "string", "description": "Optional, freier Tag z.B. 'meine-sachen', 'kunden', 'persoenlichkeit'"}, "tags": {"type": "array", "items": {"type": "string"}, "description": "Optionale Tags"}, "pinned": {"type": "boolean", "description": "Default false. Nur true wenn die Info IMMER im System-Prompt liegen muss (Identitaet/Regeln/Praeferenzen)."}, "attach_paths": { "type": "array", "items": {"type": "string"}, "description": "Optional. Pfade unter /shared/uploads/ die als Anhang an die Memory wandern. Files werden serverseitig nach /shared/memory-attachments// kopiert — Originale bleiben.", }, }, "required": ["title", "content", "type"], }, }, }, ] def _skill_to_tool(s: dict) -> dict: """Mappt einen Skill auf ein OpenAI-Function-Tool.""" args = s.get("args") or [] props = {} required = [] for a in args: if not isinstance(a, dict): continue name = a.get("name") or "" if not name: continue props[name] = { "type": a.get("type", "string"), "description": a.get("description", ""), } if a.get("required"): required.append(name) return { "type": "function", "function": { "name": f"run_{s['name']}", "description": s.get("description", "(ohne Beschreibung)"), "parameters": { "type": "object", "properties": props, "required": required, }, }, } class Agent: # Mindest-Score den ein Cold-Memory-Treffer haben muss um in den # System-Prompt aufgenommen zu werden. Unter dieser Schwelle ist's # Rauschen — die MiniLM-multilingual Embeddings haben fuer "irgendwas # vs. irgendwas anderes" gerne mal 0.10-0.20 Score selbst bei voellig # unverwandten Inhalten. Mit 0.30 als Untergrenze vermeiden wir # Cross-Talk (z.B. 'hab ich ein flugzeug' triggert die Firmenadresse). COLD_SCORE_THRESHOLD = 0.30 def __init__(self, store: VectorStore, embedder: Embedder, conversation: Conversation, proxy: ProxyClient, cold_k: int = 5): self.store = store self.embedder = embedder self.conversation = conversation self.proxy = proxy self.cold_k = cold_k # Side-Channel-Events die im Turn entstehen (z.B. skill_create). # Werden vom /chat-Endpoint in der Response mitgeschickt, damit # Stefan in der App und Diagnostic eine sichtbare Bubble bekommt. self._pending_events: list[dict] = [] def pop_events(self) -> list[dict]: """Holt die Events des letzten chat()-Calls und leert die Liste.""" events = self._pending_events self._pending_events = [] return events # ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ── MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops def chat(self, user_message: str, source: str = "") -> str: user_message = (user_message or "").strip() if not user_message: raise ValueError("Leere Nachricht") # Events vom letzten Turn weglassen self._pending_events = [] # 1. User-Turn an die Konversation self.conversation.add("user", user_message, source=source) # 2. Hot Memory (alle pinned Punkte) hot = self.store.list_pinned() # 3. Cold Memory (Top-K semantic) — mit Score-Threshold gegen Rauschen try: qvec = self.embedder.embed(user_message) cold = self.store.search( qvec, k=self.cold_k, exclude_pinned=True, score_threshold=self.COLD_SCORE_THRESHOLD, ) except Exception as exc: logger.warning("Cold-Search fehlgeschlagen: %s", exc) cold = [] # 4. Aktive Skills holen + Tool-Liste bauen all_skills = skills_mod.list_skills(active_only=False) active_skills = [s for s in all_skills if s.get("active", True)] tools = list(META_TOOLS) + [_skill_to_tool(s) for s in active_skills] # Trigger-Liste + Variablen-Info fuer den System-Prompt all_triggers = triggers_mod.list_triggers(active_only=False) condition_vars = watcher_mod.describe_variables() condition_funcs = watcher_mod.describe_functions() # 5. System-Prompt + Window-Messages flux_config = _load_flux_config() system_prompt = build_system_prompt(hot, cold, skills=all_skills, triggers=all_triggers, condition_vars=condition_vars, condition_funcs=condition_funcs, flux_config=flux_config) messages = [ProxyMessage(role="system", content=system_prompt)] for t in self.conversation.window(): messages.append(ProxyMessage(role=t.role, content=t.content)) logger.info("chat: pinned=%d cold=%d skills=%d/%d window=%d prompt_chars=%d", len(hot), len(cold), len(active_skills), len(all_skills), len(self.conversation.window()), len(system_prompt)) # 6. Tool-Use-Loop final_reply = "" for iteration in range(self.MAX_TOOL_ITERATIONS): result = self.proxy.chat_full(messages, tools=tools) if result.tool_calls: # Assistant-Turn mit tool_calls in messages anhaengen (nicht in Conversation!) messages.append(ProxyMessage( role="assistant", content=result.content or None, tool_calls=[{ "id": tc["id"], "type": "function", "function": {"name": tc["name"], "arguments": json.dumps(tc["arguments"])}, } for tc in result.tool_calls], )) # Tools ausfuehren + Ergebnis als role=tool zurueck for tc in result.tool_calls: tool_result = self._dispatch_tool(tc["name"], tc["arguments"]) messages.append(ProxyMessage( role="tool", tool_call_id=tc["id"], name=tc["name"], content=tool_result[:8000], )) continue # next iteration mit Tool-Results # Kein Tool-Call mehr → final reply final_reply = (result.content or "").strip() break else: # Loop-Limit erreicht final_reply = "[Tool-Loop-Limit erreicht — ARIA hat zu viele Tool-Calls gemacht ohne fertig zu werden]" logger.warning("Tool-Loop hit MAX_TOOL_ITERATIONS=%d", self.MAX_TOOL_ITERATIONS) if not final_reply: raise RuntimeError("Leerer Reply vom Proxy") # 7. Assistant-Turn (final reply) in die Conversation self.conversation.add("assistant", final_reply) return final_reply # ── Tool-Dispatcher ─────────────────────────────────────── def _dispatch_tool(self, name: str, arguments: dict) -> str: """Fuehrt einen Tool-Call aus und gibt ein kurzes Text-Resultat zurueck. Niemals werfen — Fehler werden als Text-Resultat reportet damit Claude weitermachen kann.""" try: if name == "skill_create": # ARIA-Skills sind immer Python — execution ist nicht mehr im Schema manifest = skills_mod.create_skill( name=arguments["name"], description=arguments["description"], execution="local-venv", entry_code=arguments["entry_code"], readme=arguments.get("readme", ""), args=arguments.get("args", []), pip_packages=arguments.get("pip_packages", []), author="aria", ) # Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt self._pending_events.append({ "type": "skill_created", "skill": { "name": manifest["name"], "description": manifest.get("description", ""), "execution": manifest.get("execution", ""), "active": manifest.get("active", True), "setup_error": manifest.get("setup_error"), }, }) return f"OK — Skill '{manifest['name']}' erstellt (active={manifest['active']})." if name == "skill_list": items = skills_mod.list_skills(active_only=False) if not items: return "(keine Skills vorhanden)" return "\n".join( f"- {s['name']} ({s['execution']}) {'aktiv' if s.get('active', True) else 'DEAKTIVIERT'}: {s.get('description', '')}" for s in items ) if name.startswith("run_"): skill_name = name[len("run_"):] res = skills_mod.run_skill(skill_name, args=arguments) snippet = (res.get("stdout") or "")[:2000] or "(kein stdout)" err = (res.get("stderr") or "")[:500] marker = "OK" if res["ok"] else f"FEHLER (exit={res['exit_code']})" out = f"{marker} · {res['duration_sec']}s\nstdout:\n{snippet}" if err: out += f"\nstderr:\n{err}" return out if name == "trigger_timer": fires_at_iso = arguments.get("fires_at") in_seconds = arguments.get("in_seconds") if not fires_at_iso and in_seconds is not None: from datetime import datetime as _dt, timezone as _tz, timedelta as _td try: secs = int(in_seconds) except (TypeError, ValueError): return "FEHLER: in_seconds muss eine ganze Zahl sein." if secs < 1: return "FEHLER: in_seconds muss >= 1 sein." fires_at_iso = (_dt.now(_tz.utc) + _td(seconds=secs)).isoformat(timespec="seconds") if not fires_at_iso: return "FEHLER: entweder `in_seconds` ODER `fires_at` muss gesetzt sein." t = triggers_mod.create_timer( name=arguments["name"], fires_at_iso=fires_at_iso, message=arguments["message"], author="aria", ) self._pending_events.append({ "type": "trigger_created", "trigger": {"name": t["name"], "type": "timer", "fires_at": t["fires_at"], "message": t["message"]}, }) return f"OK — Timer '{t['name']}' angelegt, feuert um {t['fires_at']}." if name == "trigger_watcher": t = triggers_mod.create_watcher( name=arguments["name"], condition=arguments["condition"], message=arguments["message"], check_interval_sec=int(arguments.get("check_interval_sec", 300)), throttle_sec=int(arguments.get("throttle_sec", 3600)), author="aria", ) self._pending_events.append({ "type": "trigger_created", "trigger": {"name": t["name"], "type": "watcher", "condition": t["condition"], "message": t["message"]}, }) return f"OK — Watcher '{t['name']}' angelegt: feuert wenn '{t['condition']}'." if name == "trigger_cancel": try: triggers_mod.delete(arguments["name"]) return f"OK — Trigger '{arguments['name']}' geloescht." except ValueError as e: return f"FEHLER: {e}" if name == "request_location_tracking": on = bool(arguments.get("on", False)) reason = (arguments.get("reason") or "").strip() self._pending_events.append({ "type": "location_tracking", "on": on, "reason": reason, }) return f"OK — Tracking-Request gesendet (on={on}). App wird in Kuerze umschalten." if name == "trigger_list": items = triggers_mod.list_triggers(active_only=False) if not items: return "(keine Trigger vorhanden)" lines = [] for t in items: state = "aktiv" if t.get("active", True) else "DEAKTIVIERT" if t["type"] == "timer": lines.append(f"- {t['name']} (timer, {state}): feuert {t.get('fires_at')} — \"{t.get('message','')[:50]}\"") elif t["type"] == "watcher": lines.append(f"- {t['name']} (watcher, {state}): cond=\"{t.get('condition')}\", throttle={t.get('throttle_sec')}s") else: lines.append(f"- {t['name']} ({t['type']}, {state})") return "\n".join(lines) if name == "flux_generate": prompt = (arguments.get("prompt") or "").strip() if not prompt: return "FEHLER: prompt ist Pflicht." req: dict = {"prompt": prompt} for key in ("width", "height", "steps", "seed"): if key in arguments and arguments[key] is not None: try: req[key] = int(arguments[key]) except (TypeError, ValueError): pass if arguments.get("guidance_scale") is not None: try: req["guidance_scale"] = float(arguments["guidance_scale"]) except (TypeError, ValueError): pass # Modell-Wahl: 'default' (oder weglassen) → flux-bridge nimmt Diagnostic-Default. # 'dev' / 'schnell' → expliziter Override. model_arg = (arguments.get("model") or "").strip().lower() if model_arg in ("dev", "schnell"): req["model"] = model_arg # `raw` ist Brain-Domain (kein Rewriting des prompt) und wird hier # nicht durchgereicht — der prompt enthaelt bei raw=true bereits # Stefans Originaltext. try: body = json.dumps(req).encode("utf-8") http_req = urllib.request.Request( f"{BRIDGE_URL}/internal/flux-generate", data=body, method="POST", headers={"Content-Type": "application/json"}, ) with urllib.request.urlopen(http_req, timeout=FLUX_HTTP_TIMEOUT_SEC) as resp: raw = resp.read() result = json.loads(raw.decode("utf-8", "ignore")) except urllib.error.HTTPError as exc: try: err_body = exc.read().decode("utf-8", "ignore") err_data = json.loads(err_body) err = err_data.get("error") or err_body except Exception: err = str(exc) return f"FEHLER (flux-bridge): {err}" except Exception as exc: logger.exception("flux_generate HTTP-Call fehlgeschlagen") return f"FEHLER: flux-bridge nicht erreichbar ({exc})" if not result.get("ok"): return f"FEHLER (flux-bridge): {result.get('error', 'unbekannt')}" # Kompakte Rueckmeldung: Pfad + Render-Stats. Brain bettet den # Pfad in ihre Antwort als [FILE: ...]-Marker ein (siehe Tool-Beschreibung). return ( f"OK — Bild generiert.\n" f"path: {result['path']}\n" f"size: {result.get('width','?')}x{result.get('height','?')} " f"({result.get('sizeBytes',0)//1024} KB)\n" f"steps={result.get('steps','?')} guidance={result.get('guidance','?')} " f"seed={result.get('seed','?')} model={result.get('model','?')}\n" f"renderSeconds={result.get('renderSeconds','?')}\n\n" f"WICHTIG: Schreibe in deiner Antwort an Stefan den Pfad EXAKT als " f"Marker: [FILE: {result['path']}] — dann zeigt die App das Bild inline." ) if name == "memory_search": query = (arguments.get("query") or "").strip() if not query: return "FEHLER: query ist Pflicht." mode = arguments.get("mode") or "text" try: k = int(arguments.get("k", 5)) except (TypeError, ValueError): k = 5 k = max(1, min(k, 20)) try: if mode == "semantic": qvec = self.embedder.embed(query) results = self.store.search( qvec, k=k, exclude_pinned=False, score_threshold=0.30, ) else: results = self.store.search_text(query, k=k, exclude_pinned=False) if not results: return f"Keine Treffer fuer '{query}' (mode={mode})." lines = [f"{len(results)} Treffer fuer '{query}' (mode={mode}):"] for m in results: score_part = f" [score={m.score:.2f}]" if m.score is not None else "" pin = "📌 " if m.pinned else "" atts = m.attachments or [] att_part = f" 📎{len(atts)}" if atts else "" lines.append("") lines.append(f"## {pin}{m.title} ({m.type}){score_part}{att_part}") lines.append(f"id: {m.id}") lines.append(m.content or "") if atts: for a in atts: lines.append(f" 📎 {a.get('name', '?')} ({a.get('mime', '')}) — {a.get('path', '')}") return "\n".join(lines) except Exception as e: logger.exception("memory_search fehlgeschlagen") return f"FEHLER: {e}" if name == "memory_update": pid = (arguments.get("id") or "").strip() if not pid: return "FEHLER: id ist Pflicht." existing = self.store.get(pid) if not existing: return f"FEHLER: Memory mit id={pid[:8]} nicht gefunden." try: from memory.vector_store import COLLECTION import datetime as _dt content_changed = False if "title" in arguments and arguments["title"] is not None: existing.title = str(arguments["title"]).strip() if "content" in arguments and arguments["content"] is not None: new_content = str(arguments["content"]).strip() if new_content != existing.content: content_changed = True existing.content = new_content if "category" in arguments and arguments["category"] is not None: existing.category = str(arguments["category"]).strip() if "tags" in arguments and arguments["tags"] is not None: existing.tags = [str(t).strip() for t in (arguments["tags"] or []) if str(t).strip()] if "pinned" in arguments and arguments["pinned"] is not None: existing.pinned = bool(arguments["pinned"]) existing.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat() if content_changed: vec = self.embedder.embed(existing.content) self.store.upsert(existing, vec) else: self.store.client.set_payload( collection_name=COLLECTION, payload=existing.to_payload() | {"updated_at": existing.updated_at}, points=[pid], ) saved = self.store.get(pid) self._pending_events.append({ "type": "memory_saved", "action": "updated", "memory": { "id": saved.id, "type": saved.type, "title": saved.title, "content_preview": (saved.content or "")[:140], "category": saved.category, "pinned": saved.pinned, "attachments": saved.attachments or [], }, }) return f"OK — Memory '{saved.title}' aktualisiert (id={pid[:8]})." except Exception as e: logger.exception("memory_update fehlgeschlagen") return f"FEHLER: {e}" if name == "memory_save": title = (arguments.get("title") or "").strip() content = (arguments.get("content") or "").strip() mem_type = (arguments.get("type") or "fact").strip() if not title or not content: return "FEHLER: title und content sind Pflicht." valid_types = {"identity", "rule", "preference", "tool", "skill", "fact", "conversation", "reminder"} if mem_type not in valid_types: return f"FEHLER: type muss einer von {sorted(valid_types)} sein." category = (arguments.get("category") or "").strip() tags_in = arguments.get("tags") or [] tags = [str(t).strip() for t in tags_in if str(t).strip()] if isinstance(tags_in, list) else [] pinned = bool(arguments.get("pinned", False)) attach_paths_in = arguments.get("attach_paths") or [] attach_paths = [str(p).strip() for p in attach_paths_in if str(p).strip()] if isinstance(attach_paths_in, list) else [] try: from memory import MemoryPoint vec = self.embedder.embed(content) point = MemoryPoint( id="", type=mem_type, title=title, content=content, pinned=pinned, category=category, source="aria", tags=tags, ) pid = self.store.upsert(point, vec) # Anhaenge kopieren + Payload updaten attach_errors: list[str] = [] if attach_paths: import memory_attachments as mem_att new_atts = [] for src in attach_paths: try: meta = mem_att.attach_from_path(pid, src) new_atts.append(meta) except ValueError as e: attach_errors.append(f"{src}: {e}") if new_atts: from qdrant_client.http import models as qm from memory.vector_store import COLLECTION import datetime as _dt now = _dt.datetime.now(_dt.timezone.utc).isoformat() current = self.store.get(pid) current.attachments = (current.attachments or []) + new_atts current.updated_at = now self.store.client.set_payload( collection_name=COLLECTION, payload=current.to_payload() | {"updated_at": now}, points=[pid], ) saved = self.store.get(pid) self._pending_events.append({ "type": "memory_saved", "action": "created", "memory": { "id": saved.id, "type": saved.type, "title": saved.title, "content_preview": (saved.content or "")[:140], "category": saved.category, "pinned": saved.pinned, "attachments": saved.attachments or [], }, }) n_att = len(saved.attachments or []) msg = (f"OK — Memory '{title}' gespeichert " f"(type={mem_type}, pinned={pinned}, id={saved.id[:8]}" + (f", {n_att} Anhang/Anhaenge" if n_att else "") + ").") if attach_errors: msg += "\nHinweis: nicht alle Anhaenge konnten kopiert werden:\n - " + "\n - ".join(attach_errors) return msg except Exception as e: logger.exception("memory_save fehlgeschlagen") return f"FEHLER beim Speichern: {e}" return f"Unbekanntes Tool: {name}" except Exception as exc: logger.exception("Tool '%s' fehlgeschlagen", name) return f"FEHLER: {exc}" # ── Memory-Destillat (laeuft im Hintergrund) ────────────── def distill_old_turns(self) -> dict: """Nimmt die N aeltesten Turns und destilliert sie zu fact-Memories. Pattern: separater Claude-Call, lieferte 3-7 JSON-Facts, die als type=fact, source=distilled gespeichert werden. Erfolgreiches Schreiben → Turns aus dem Window entfernen. """ if not self.conversation.needs_distill(): return {"distilled": 0, "reason": "kein Bedarf"} old_turns = self.conversation.take_oldest_for_distill() if not old_turns: return {"distilled": 0, "reason": "keine alten Turns"} # Konversation als Klartext bauen transcript = "\n".join( f"[{t.role.upper()}] {t.content}" for t in old_turns )[:30000] # Cap auf 30k Zeichen damit der Prompt nicht explodiert system = ( "Du extrahierst aus einer Konversation zwischen Stefan und ARIA die " "wichtigsten dauerhaft relevanten Fakten — keine Smalltalk-Details, " "keine flüchtigen Zustände. Antworte AUSSCHLIESSLICH mit gültigem JSON " "im Format: {\"facts\": [{\"title\": \"kurz, max 80 Zeichen\", " "\"content\": \"1-3 Sätze, konkret und nützlich\"}]}. " "Mindestens 0, höchstens 7 Facts. Wenn nichts wichtig genug ist: leeres Array." ) user = ( "Hier ist der Konversations-Abschnitt:\n\n" f"{transcript}\n\n" "Extrahiere die wichtigsten Fakten als JSON." ) try: raw = self.proxy.chat([ ProxyMessage(role="system", content=system), ProxyMessage(role="user", content=user), ]) except Exception as exc: logger.warning("Destillat-Call fehlgeschlagen: %s — Turns bleiben", exc) return {"distilled": 0, "error": str(exc)} facts = self._parse_facts(raw) if facts is None: logger.warning("Destillat lieferte unparsbares JSON: %r", raw[:200]) return {"distilled": 0, "error": "JSON parse failed", "raw": raw[:200]} # Facts in die DB schreiben created = 0 for f in facts: content = (f.get("content") or "").strip() if not content: continue title = (f.get("title") or "").strip()[:120] or "Fakt" point = MemoryPoint( id="", type="fact", title=title, content=content, pinned=False, category="konversation", source="distilled", tags=[], ) try: vec = self.embedder.embed(content) self.store.upsert(point, vec) created += 1 except Exception as exc: logger.warning("Fakt schreiben fehlgeschlagen: %s", exc) # Erst nach erfolgreichem Schreiben aus dem Window entfernen last_ts = old_turns[-1].ts self.conversation.commit_distill(last_ts) logger.info("Destillat: %d Facts geschrieben, %d Turns aus Window entfernt", created, len(old_turns)) return {"distilled": created, "removed_turns": len(old_turns)} @staticmethod def _parse_facts(raw: str) -> Optional[list]: if not raw: return None # JSON robust extrahieren — Claude kann Code-Fences setzen cleaned = raw.strip() if cleaned.startswith("```"): # ```json oder ``` rauswerfen cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned[3:] if cleaned.endswith("```"): cleaned = cleaned[: -3] cleaned = cleaned.strip() # Erstes { bis letztes } start = cleaned.find("{") end = cleaned.rfind("}") if start == -1 or end == -1 or end < start: return None try: obj = json.loads(cleaned[start: end + 1]) except Exception: return None facts = obj.get("facts") if isinstance(obj, dict) else None if not isinstance(facts, list): return None return facts