70d1500096
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>
386 lines
16 KiB
Python
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
|