Files
ARIA-AGENT/aria-brain/agent.py
T
duffyduck 70d1500096 feat(brain): Phase B — Vector-DB-Memory, Conversation-Loop, Skills, Tool-Use
OpenClaw (aria-core) ist raus, ARIA laeuft jetzt mit eigenem Agent-Framework
im aria-brain Container. Vector-DB-basiertes Gedaechtnis statt Sessions,
eigener Conversation-Loop mit Hot+Cold-Memory + Rolling Window, Tool-Use
fuer Skills, Memory-Destillat-Pipeline.

aria-brain/ (neuer Container)
  - main.py            FastAPI auf 8080, alle Endpoints
  - agent.py           Conversation-Loop mit Tool-Use (skill_create + run_<skill>)
  - conversation.py    Rolling Window, JSONL-Persistenz, Distill-Marker
  - proxy_client.py    httpx-Wrapper zum Claude-Proxy, OpenAI-Format
  - prompts.py         System-Prompt aus Hot+Cold+Skills
  - migration.py       Markdown-Parser fuer brain-import/ → atomare Memories
  - skills.py          Filesystem-Layer fuer /data/skills/<name>/ (Python-only,
                       venv pro Skill, tar.gz Export/Import, Run-Logs)
  - memory/            Embedder (sentence-transformers, multilingual MiniLM)
                       + VectorStore (Qdrant-Wrapper)

docker-compose.yml
  - aria-core (OpenClaw) raus, openclaw-config Volume raus
  - aria-brain Service (FastAPI + Memory)
  - aria-qdrant Service (Vector-DB) mit Bind-Mount aria-data/brain/qdrant/
  - Diagnostic teilt jetzt Netzwerk mit Bridge (vorher: aria-core)
  - Brain bekommt SSH-Mount fuer aria-wohnung + /import fuer brain-import/

bridge/aria_bridge.py
  - send_to_core → HTTP-Call an aria-brain:8080/chat (statt OpenClaw-WS)
  - aria-core-spezifische Handler raus: doctor_fix, aria_restart,
    aria_session_reset, Auto-Compact-Logik, OpenClaw-Handshake
  - Generischer container_restart-Handler (Whitelist Bridge/Brain/Qdrant)
  - Side-Channel-Events aus /chat-Response (z.B. skill_created) werden
    als RVS-Events forwarded
  - file_list_request / file_delete_request → an Diagnostic forwarded
  - Tote OpenClaw-Connection-Logik bleibt im Code als Referenz (nicht aktiv)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:23:17 +02:00

386 lines
16 KiB
Python

"""
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
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
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": {}},
},
},
]
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:
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)
try:
qvec = self.embedder.embed(user_message)
cold = self.store.search(qvec, k=self.cold_k, exclude_pinned=True)
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]
# 5. System-Prompt + Window-Messages
system_prompt = build_system_prompt(hot, cold, skills=all_skills)
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
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