Compare commits

...
2 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 4.7 9ea7908fe4 docs: README + issue — Proxy-Tool-Use-Patch + Trigger-Reply-Push dokumentiert
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:52:04 +02:00
duffyduckandClaude Opus 4.7 7237f05344 fix(trigger): Trigger-Antworten landen jetzt im Chat — Brain → Bridge Push
Bug: Wenn der Brain-Background-Loop einen Timer/Watcher feuert, ruft
er agent.chat() direkt im eigenen Prozess. Die Antwort wurde nur ins
Trigger-Log geschrieben — kein RVS-Broadcast, kein TTS, nichts in
App/Diagnostic sichtbar.

Fix: Bridge ↔ Brain bekommen einen internen HTTP-Push-Kanal.

Bridge (Port 8090, nicht exposed, nur aria-net intern):
  asyncio.start_server-basierter HTTP-Listener.
  POST /internal/trigger-fired
    body: {reply, trigger_name, type, events}
  → _handle_trigger_fired feuert Side-Channel-Events
    (trigger_created/skill_created/location_tracking) erst,
    dann _process_core_response(reply) — exakt der gleiche Pfad
    wie normale Chat-Antworten (Chat-Bubble + TTS + chat_backup).

Brain background.py:
  Nach agent.chat() in _fire wird agent.pop_events() ausgelesen
  und zusammen mit dem Reply via urllib an aria-bridge:8090
  gepostet (run_in_executor damit es den asyncio-Loop nicht
  blockiert). Failures werden geloggt, der Trigger selbst bleibt
  trotzdem als 'fired' markiert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:50:32 +02:00
4 changed files with 190 additions and 7 deletions
+10 -6
View File
@@ -216,11 +216,14 @@ Der Proxy-Container (`node:22-alpine`) installiert bei jedem Start:
- `@anthropic-ai/claude-code` — Claude Code CLI
- `claude-max-api-proxy` — OpenAI-kompatible API
Danach werden per `sed` vier Patches angewendet:
1. **Host-Binding**: Server hoert auf `0.0.0.0` statt localhost
2. **Model-Fallback**: Undefined Model → `claude-sonnet-4`
3. **Content-Format**: Array → String Konvertierung fuer die CLI
4. **Tool-Permissions**: `--dangerously-skip-permissions` Flag injizieren
Danach wird der Proxy gepatcht:
1. **Host-Binding** (sed): Server hoert auf `0.0.0.0` statt localhost
2. **Tool-Permissions** (sed): `--dangerously-skip-permissions` Flag injizieren
3. **Tool-Use-Adapter** (Datei-Overwrite aus [`proxy-patches/`](proxy-patches/)):
- `openai-to-cli.js` injiziert das OpenAI-`tools`-Feld als `<system>`-Block mit Schema-Beschreibungen + Anweisung `<tool_call name="X">{json}</tool_call>` als Antwortformat. `role=tool`-Messages werden als `<tool_result>`-Bloecke eingewoben. Multimodal-Content (Array von Parts) bleibt String-kompatibel.
- `cli-to-openai.js` parsed `<tool_call>`-Bloecke aus Claudes Antwort und liefert sie als echte OpenAI `tool_calls` mit `finish_reason="tool_calls"`. Pre-Tool-Text bleibt im `content`. Mehrere parallele Calls werden korrekt aufgeteilt. Model-Name null-safe.
**Warum?** Die npm-Version des Proxys ignoriert das `tools`-Feld komplett und reicht nur einen Prompt-String an die CLI weiter. Claude Code nutzt dann ihre internen Tools (Bash, Read, …) und „simuliert" Aktionen — z.B. `sleep 120` statt `trigger_timer`. Mit den eigenen Adaptern landen ARIA-Tools wieder auf der Linie und Side-Effects (Trigger anlegen, Skills aufrufen, GPS-Tracking schalten) funktionieren.
**Wichtige Umgebungsvariablen im Proxy:**
- `HOST=0.0.0.0` — API von aussen erreichbar (Docker-Netz)
@@ -862,7 +865,8 @@ docker exec aria-brain curl localhost:8080/memory/stats
- [x] **Phase B Punkt 2:** Migration aus `aria-data/brain-import/` → atomare Memory-Punkte (Identity / Rule / Preference / Tool / Skill, idempotent ueber migration_key) + Bootstrap-Snapshot Export/Import (nur pinned)
- [x] **Phase B Punkt 3:** Brain Conversation-Loop (Single-Chat UI, Rolling Window 50 Turns, Schwelle 60 → automatisches Destillat, manueller Trigger)
- [x] **Phase B Punkt 4:** Skills-System (Python-only via local-venv, skill_create als Tool, dynamische run_<skill> Tools, Diagnostic Skills-Tab mit Logs/Toggle/Export/Import, skill_created Live-Notification in App+Diagnostic, harte Schwelle "pip → Skill")
- [x] **Phase B Punkt 5:** Triggers-System (passive Aufweck-Quellen — Timer + Watcher mit safe Condition-Parser, GPS-near(), Diagnostic Trigger-Tab, kontinuierliches GPS-Tracking in der App fuer Use-Cases wie Blitzer-Warner)
- [x] **Phase B Punkt 5:** Triggers-System (passive Aufweck-Quellen — Timer + Watcher mit safe Condition-Parser, GPS-near(), Diagnostic Trigger-Tab, kontinuierliches GPS-Tracking in der App fuer Use-Cases wie Blitzer-Warner). Inklusive Brain → Bridge HTTP-Push (Port 8090 intern) damit Trigger-Antworten ueber RVS in App + Diagnostic + TTS landen.
- [x] **Proxy Tool-Use durchreichen**: claude-max-api-proxy patcht via eigene Adapter (`proxy-patches/`) den `tools`/`tool_calls`-Roundtrip — Claude Code rief vorher ihre internen Tools (Bash, sleep) statt der ARIA-Brain-Tools (trigger_timer, skill_*, ...). Jetzt funktioniert Tool-Use End-to-End.
- [x] Sprachmodell-Setting wieder funktional (brainModel in runtime.json statt aria-core)
- [x] App-Chat-Sync: kompletter Server-Sync bei Reconnect (Server = Source of Truth) + chat_cleared Live-Update. Lokal-only Bubbles (Skill-Notifications, laufende Voice ohne STT) bleiben erhalten.
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
+37
View File
@@ -14,7 +14,11 @@ Feuern bedeutet:
from __future__ import annotations
import asyncio
import json
import logging
import os
import urllib.error
import urllib.request
from datetime import datetime, timezone
from typing import Optional
@@ -24,6 +28,34 @@ import watcher as watcher_mod
logger = logging.getLogger(__name__)
TICK_SEC = 30
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
def _push_to_bridge(reply: str, trigger_name: str, ttype: str, events: list) -> None:
"""POSTed eine Trigger-Antwort an die Bridge fuer RVS-Broadcast + TTS.
Synchron via urllib — wird per run_in_executor aus dem async-Loop
gerufen. Failures werden geloggt, brechen aber nicht ab.
"""
payload = json.dumps({
"reply": reply,
"trigger_name": trigger_name,
"type": ttype,
"events": events or [],
}).encode("utf-8")
url = f"{BRIDGE_URL}/internal/trigger-fired"
try:
req = urllib.request.Request(
url, data=payload, method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=15) as resp:
if resp.status != 200:
logger.warning("[trigger-push] Bridge hat %s zurueckgegeben", resp.status)
except urllib.error.URLError as exc:
logger.warning("[trigger-push] Bridge unerreichbar (%s): %s", url, exc)
except Exception as exc:
logger.warning("[trigger-push] Push fehlgeschlagen: %s", exc)
def _now_iso() -> str:
@@ -114,8 +146,13 @@ async def _fire(trigger: dict, agent_factory) -> None:
try:
agent = agent_factory()
reply = agent.chat(prompt, source="trigger")
events = agent.pop_events()
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
# Reply an die Bridge pushen, damit App + Diagnostic + TTS sie kriegen.
# Ohne diesen Push wuerde die Antwort nur im Brain-Log landen.
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _push_to_bridge, reply, name, ttype, events)
except Exception as e:
logger.exception("Trigger %s feuern fehlgeschlagen: %s", name, e)
triggers_mod.append_log(name, {"event": "error", "error": str(e)[:300]})
+141
View File
@@ -2392,6 +2392,145 @@ class ARIABridge:
logger.exception("Fehler in der Audio-Schleife")
await asyncio.sleep(1)
# ── Internal HTTP (Brain → Bridge: Trigger-Feuer-Push) ───
async def _serve_internal_http(self) -> None:
"""Kleiner asyncio HTTP-Listener auf Port 8090.
Empfaengt Push-Events vom Brain wenn ein Trigger feuert. Nicht
nach aussen exposed — nur erreichbar im docker-internen aria-net.
Endpoint:
POST /internal/trigger-fired
{ "reply": "...", "trigger_name": "...", "type": "timer",
"events": [{"type":"trigger_created",...}, ...] }
"""
host, port = "0.0.0.0", 8090
async def _send_response(writer, status: int, payload: dict) -> None:
body = json.dumps(payload).encode("utf-8")
status_text = "OK" if status == 200 else "Error"
writer.write(
f"HTTP/1.1 {status} {status_text}\r\n"
f"Content-Type: application/json\r\n"
f"Content-Length: {len(body)}\r\n"
f"Connection: close\r\n\r\n".encode("utf-8")
)
writer.write(body)
await writer.drain()
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
request_line = await asyncio.wait_for(reader.readline(), timeout=10)
if not request_line:
return
try:
method, path, _ver = request_line.decode("utf-8", "ignore").strip().split(" ", 2)
except ValueError:
await _send_response(writer, 400, {"error": "bad request line"})
return
headers: dict[str, str] = {}
while True:
line = await asyncio.wait_for(reader.readline(), timeout=5)
if not line or line in (b"\r\n", b"\n"):
break
name, _, value = line.decode("utf-8", "ignore").partition(":")
headers[name.strip().lower()] = value.strip()
content_length = int(headers.get("content-length", "0") or "0")
body = await reader.readexactly(content_length) if content_length else b""
if method == "POST" and path == "/internal/trigger-fired":
try:
data = json.loads(body.decode("utf-8", "ignore"))
except Exception as exc:
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
return
reply = (data.get("reply") or "").strip()
trigger_name = data.get("trigger_name", "")
ttype = data.get("type", "trigger")
events = data.get("events") or []
logger.info("[bridge ← brain] Trigger '%s' (%s) gefeuert, reply=%d chars, events=%d",
trigger_name, ttype, len(reply), len(events))
# Async-spawn — HTTP-Antwort nicht durch RVS-Broadcast blockieren
asyncio.create_task(
self._handle_trigger_fired(reply, trigger_name, ttype, events)
)
await _send_response(writer, 200, {"ok": True})
elif method == "GET" and path == "/health":
await _send_response(writer, 200, {"ok": True, "service": "bridge-internal"})
else:
await _send_response(writer, 404, {"error": "not found"})
except asyncio.TimeoutError:
logger.warning("[bridge http] Timeout beim Request-Lesen")
except Exception as exc:
logger.exception("[bridge http] Fehler: %s", exc)
try:
await _send_response(writer, 500, {"error": str(exc)[:200]})
except Exception:
pass
finally:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
try:
server = await asyncio.start_server(handle, host, port)
logger.info("[bridge] Internal HTTP-Listener auf %s:%d (Brain-Push)", host, port)
async with server:
await server.serve_forever()
except Exception:
logger.exception("[bridge] Internal HTTP-Listener konnte nicht starten")
async def _handle_trigger_fired(self, reply: str, trigger_name: str,
ttype: str, events: list) -> None:
"""Spiegelt eine Brain-Trigger-Antwort wie eine normale ARIA-Antwort.
Side-Channel-Events zuerst (trigger_created, location_tracking, ...),
dann _process_core_response (Chat-Bubble, TTS, chat_backup).
"""
# Side-Channel-Events erst (gleich wie in send_to_core)
for event in events or []:
etype = event.get("type")
try:
if etype == "skill_created":
await self._send_to_rvs({
"type": "skill_created",
"payload": event.get("skill", {}),
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
elif etype == "trigger_created":
await self._send_to_rvs({
"type": "trigger_created",
"payload": event.get("trigger", {}),
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
elif etype == "location_tracking":
await self._send_to_rvs({
"type": "location_tracking",
"payload": {
"on": bool(event.get("on")),
"reason": event.get("reason") or "",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
except Exception:
logger.exception("[trigger-fire] Side-Channel-Event %s fehlgeschlagen", etype)
if not reply:
logger.info("[trigger-fire] Trigger '%s' hat leeren Reply — nichts zu broadcasten",
trigger_name)
return
# Reply wie eine normale ARIA-Antwort behandeln
try:
await self._process_core_response(
reply,
{"metadata": {"trigger_name": trigger_name, "trigger_type": ttype}},
)
except Exception:
logger.exception("[trigger-fire] _process_core_response fehlgeschlagen")
# ── Run & Shutdown ───────────────────────────────────────
async def run(self) -> None:
@@ -2405,6 +2544,8 @@ class ARIABridge:
# connect_to_core entfaellt — Bridge ruft jetzt aria-brain ueber
# HTTP (siehe send_to_core). Keine persistente WS-Verbindung mehr.
asyncio.create_task(self.connect_to_rvs()),
# Interner HTTP-Listener — empfaengt Trigger-Feuer-Pushes vom Brain.
asyncio.create_task(self._serve_internal_http()),
]
if self.audio_available:
+2 -1
View File
@@ -55,6 +55,8 @@ Wichtige Mechanismen:
### Bugs / Fixes
- [x] **Trigger-Antworten landen jetzt im Chat** (App + Diagnostic + TTS): Wenn der Brain-Background-Loop einen Timer/Watcher feuert, ruft er `agent.chat()` direkt im eigenen Prozess. Die Antwort wurde nur ins Trigger-Log geschrieben — kein RVS-Broadcast, nichts sichtbar. Fix: Bridge hat jetzt einen kleinen asyncio HTTP-Listener auf Port 8090 (intern, nicht exposed). Brain pusht nach jedem Trigger-Feuer per `urllib.request.urlopen` an `http://aria-bridge:8090/internal/trigger-fired` mit `{reply, trigger_name, type, events}`. Bridge ruft `_handle_trigger_fired` → Side-Channel-Events (skill_created/trigger_created/location_tracking) + `_process_core_response` — exakt derselbe Pfad wie normale Chat-Antworten (Bubble + TTS + chat_backup)
- [x] **Tool-Use im Proxy durchgereicht** (claude-max-api-proxy): Der Proxy nahm das OpenAI-`tools`-Feld an, ignorierte es aber komplett — `openai-to-cli.js` wandelte nur `messages` zu einem String, `manager.js` rief `claude --print` ohne Tools. Claude Code nutzte ihre internen Tools (Bash, Read, ...) und „simulierte" Aktionen wie `sleep 120` statt `trigger_timer` zu rufen. Fix: zwei eigene Adapter-Files unter `proxy-patches/`, die zur Container-Startzeit ueber die npm-Version kopiert werden. `openai-to-cli.js` injiziert die `tools` als `<system>`-Block mit Schema-Beschreibungen und der Anweisung `<tool_call name="X">{json}</tool_call>` als Antwortformat zu verwenden; weiterhin verarbeitet sie `role=tool`-Messages als `<tool_result>`-Bloecke fuer den Loop-Replay. `cli-to-openai.js` parsed die `<tool_call>`-Bloecke aus dem Result-Text zurueck zu OpenAI `tool_calls` mit `finish_reason=tool_calls`. Mehrere Tool-Calls + Pre-Tool-Text werden korrekt aufgeteilt
- [x] **Timer "in 2 Minuten" wird wieder angelegt**: ARIA hatte keine Moeglichkeit die aktuelle Zeit zu kennen — kein Bash-Tool, kein Time-Tool, kein Timestamp im System-Prompt. Die Tool-Beschreibung von `trigger_timer` empfahl sogar `date -u -d '+10 minutes'` via Bash, aber Bash gab's nicht. Folge: LLM liess den Tool-Call entweder weg oder riet einen Cutoff-Zeitstempel (Vergangenheit) → Background-Loop feuerte beim naechsten 30s-Tick sofort statt in 2min. Fix: (1) `build_time_section()` in `prompts.py` injiziert UTC + lokale Europa/Berlin-Zeit als `## Aktuelle Zeit`-Block oben im System-Prompt. (2) `trigger_timer` akzeptiert jetzt `in_seconds` als Alternative zu `fires_at` — Server rechnet den absoluten Timestamp, ARIA muss nicht ISO-rechnen
- [x] **"ARIA denkt..." haengt nach Brain-Antwort** (App + Diagnostic): `send_to_core` schickte `thinking` direkt via `_send_to_rvs`, hat aber `_last_activity_state` nicht gepflegt — der spaetere `_emit_activity("idle")` wurde dedupliziert und verschluckt. Fix: durchgehend `_emit_activity` fuer beide Zustaende
- [x] **Such-Scroll in App-Chat springt jetzt zur Treffer-Bubble**: `scrollToIndex` wurde zu frueh gerufen + `viewPosition: 0.4` schoss vorbei. Fix: `requestAnimationFrame` + `viewPosition: 0.5` + `onScrollToIndexFailed`-Fallback mit averageItemLength-Schaetzung + 250ms-Retry
@@ -301,6 +303,5 @@ Skills mit Tool-Use.
- [ ] RVS Zombie-Connections endgueltig loesen
- [ ] Gamebox: kleine Web-Oberflaeche fuer Credentials/Server-Config oder zentral aus Diagnostic per RVS push
- [ ] Erste Skills bauen lassen (yt-dlp, pdf-extract, image-resize, etc.) — durch normale Anfragen, ARIA legt sie selbst an
- [ ] Tool-Use-Verifikation: Live-Test ob claude-max-api-proxy `tools` und `tool_calls` sauber durchreicht
- [ ] Heartbeat (periodische Selbst-Checks)
- [ ] Lokales LLM als Waechter (Triage vor Claude-Call)