Files
ARIA-AGENT/aria-brain/proxy_client.py
T
duffyduckandClaude Opus 5 d221c1a320 feat(brain): Sicherheitsfilter blockiert nicht mehr das ganze Projekt
Anthropics Filter ("safeguards flagged this message", Details: [cyber]) trifft
genau unsere Themen — Ports, Tunnel, Zugangsdaten. Bisher war danach der
Projekt-Chat tot, und zwar aus einem Grund, den man der Meldung nicht ansieht:

Der Filter meldet sich NICHT als HTTP-Fehler. Die CLI liefert ein voellig
normales Ergebnis mit is_error=true und dem Fehlertext im content — also dort,
wo sonst ARIAs Antwort steht. proxy_client.py warf das Flag weg, der Agent
konnte Fehler und Antwort nicht unterscheiden und schrieb den Text per
conversation.add("assistant", ...) in den Verlauf. Weil der Verlauf bei jedem
Turn mitgeht, scheiterte ab da JEDE weitere Frage an derselben Stelle.

Zwei Stufen dagegen (aria-brain/safeguard.py):

1. Modellwechsel. Der Filter haengt am Modell ("Opus 5's safeguards ..."),
   Anthropic raet selbst zu "change your model". Derselbe Turn wird automatisch
   mit dem Ausweich-Modell neu gefragt; laeuft der Rest des Turns (Tool-Schritte)
   gleich mit darauf weiter. Der ausloesende Turn wird vermerkt, damit die
   naechsten Turns nicht erst wieder gegen die Wand laufen. Der Vermerk
   verfaellt von selbst, sobald der Turn aus dem Rolling Window gerollt ist —
   nichts muss aufgeraeumt werden, und das Projekt faellt von allein aufs
   Hauptmodell zurueck.

2. Quarantaene. Blockt auch das Ausweich-Modell, liegt es am Verlauf selbst.
   Dann faellt der ausloesende Turn aus dem Prompt-Fenster. NICHT geloescht: er
   bleibt in conversation.jsonl, steht mit Text in der Diagnostic und ist per
   Klick zurueckholbar. Der rohe Fehlertext wird nie persistiert — ARIA sagt in
   eigenen Worten, was passiert ist.

Der Zustand liegt in /data/safeguard.json, bewusst nicht in conversation.jsonl:
ein Markieren dort erzwaenge ein Rewrite, und das schreibt nur das In-Memory-
Window zurueck — alles schon Destillierte waere weg.

Erkennung bewusst zweistufig: mit is_error genuegt ein Marker, ohne muss der
Text mit "API Error" beginnen. Sonst wuerde eine Antwort, in der ARIA ueber den
Filter *spricht*, einen Modellwechsel ausloesen.

Mitgenommen:
- Das Memory-Destillat schickt denselben Verlauf nochmal durch und wurde
  genauso blockiert — nur fiel es dort als "unparsbares JSON" auf und
  wiederholte sich bei jedem Lauf. Weicht jetzt ebenfalls aus.
- clean_poisoned_turns.py raeumt die Altlasten: Fehlertexte, die vor diesem Fix
  schon im Verlauf stehen. Neu auch in Projekten (dort sterben sie ja), mit dem
  ausloesenden User-Turn desselben Kontexts, Dry-Run per Default.
- Diagnostic: Ausweich-Modell einstellbar (runtime.json, ohne Neustart, weil
  safeguard.py erst im Ernstfall liest) und ein Panel, das zeigt was blockiert
  wurde und ob es NOCH wirkt.

Tests: 35/35 fuer die Rettungskette gegen den echten agent.chat(), 16/16 fuer
das Aufraeum-Script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 10:16:24 +02:00

200 lines
7.8 KiB
Python

"""
Claude-Aufruf ueber den lokalen Proxy.
Der Proxy (claude-max-api-proxy) bietet eine OpenAI-kompatible API
unter http://proxy:3456/v1/chat/completions. Wir nutzen non-streaming
mit einem laengeren Timeout — Claude Code spawnt pro Anfrage einen
neuen CLI-Prozess (Cold-Start), das dauert.
"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import List, Optional
import httpx
from pydantic import BaseModel
import metrics
logger = logging.getLogger(__name__)
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
ENV_MODEL = os.environ.get("BRAIN_MODEL", "claude-sonnet-4")
PROXY_URL = os.environ.get("PROXY_URL", "http://proxy:3456")
# Read-Timeout: wie lange wir auf die HTTP-Antwort vom Proxy warten.
# Proxy ist non-streaming → erstes Byte kommt erst NACH subprocess close.
# Agent-Loops (Pentests etc.) koennen >1h dauern → muss hoch sein.
# Default 24h, kann via PROXY_TIMEOUT_SEC env ueberschrieben werden.
PROXY_TIMEOUT_SEC = float(os.environ.get("PROXY_TIMEOUT_SEC", "86400"))
# Connect/Write/Pool: klein damit toter Proxy schnell erkannt wird.
# Wenn der Proxy-Container nicht antwortet beim TCP-Connect oder waehrend
# wir den Request-Body schreiben, ist er kaputt — kein Grund 24h zu warten.
PROXY_CONNECT_TIMEOUT_SEC = float(os.environ.get("PROXY_CONNECT_TIMEOUT_SEC", "10"))
PROXY_WRITE_TIMEOUT_SEC = float(os.environ.get("PROXY_WRITE_TIMEOUT_SEC", "30"))
PROXY_POOL_TIMEOUT_SEC = float(os.environ.get("PROXY_POOL_TIMEOUT_SEC", "10"))
def _read_model_from_runtime() -> str:
"""Liest brainModel aus runtime.json. Fallback: ENV BRAIN_MODEL."""
try:
if RUNTIME_CONFIG_FILE.exists():
data = json.loads(RUNTIME_CONFIG_FILE.read_text(encoding="utf-8"))
m = (data.get("brainModel") or "").strip()
if m:
return m
except Exception as exc:
logger.warning("runtime.json lesen fehlgeschlagen: %s", exc)
return ENV_MODEL
DEFAULT_MODEL = _read_model_from_runtime()
class Message(BaseModel):
role: str # "system" | "user" | "assistant" | "tool"
content: Optional[str] = None
tool_calls: Optional[list] = None
tool_call_id: Optional[str] = None
name: Optional[str] = None # nur fuer role=tool
class ProxyResult(BaseModel):
content: str = ""
tool_calls: list = [] # je: {"id", "name", "arguments" (dict)}
finish_reason: str = ""
# ARIA-Zusatzfelder des Proxys (cli-to-openai.js). Wichtig: ein Fehler der
# CLI kommt NICHT als HTTP-Fehler, sondern als normale Antwort mit
# is_error=true und dem Fehlertext im content. Ohne dieses Flag kann der
# Agent einen Fehlertext nicht von einer Antwort unterscheiden — genau so
# landete die Meldung des Sicherheitsfilters als ARIAs Antwort im Verlauf.
is_error: bool = False
model_used: str = "" # Modell, das tatsaechlich geantwortet hat
class ProxyClient:
def __init__(self, base_url: str = PROXY_URL, model: str = DEFAULT_MODEL):
self.base_url = base_url.rstrip("/")
self.model = model
# Persistente Client-Connection — vermeidet TCP-Handshake bei jedem Call.
# Timeouts split nach Phase: connect/write/pool klein (toter Proxy → schnell
# ReadTimeout), read gross (ARIA darf ewig rechnen).
self._client = httpx.Client(timeout=httpx.Timeout(
connect=PROXY_CONNECT_TIMEOUT_SEC,
read=PROXY_TIMEOUT_SEC,
write=PROXY_WRITE_TIMEOUT_SEC,
pool=PROXY_POOL_TIMEOUT_SEC,
))
def chat(self, messages: List[Message], model: Optional[str] = None) -> str:
"""Convenience: einfacher Chat ohne Tools. Gibt nur den Reply-String zurueck."""
result = self.chat_full(messages, tools=None, model=model)
if not result.content:
raise RuntimeError("Proxy lieferte leeren content")
return result.content
def chat_full(
self,
messages: List[Message],
tools: Optional[list] = None,
model: Optional[str] = None,
project_id: str = "",
) -> ProxyResult:
"""Full chat — kann Tool-Calls liefern (wenn tools mitgegeben).
tools-Format ist OpenAI-Style:
[{"type":"function","function":{"name":..,"description":..,"parameters":{...}}}, ...]
"""
url = f"{self.base_url}/v1/chat/completions"
# Pydantic-Dumps mit exclude_none damit role=tool ohne tool_calls geht
payload = {
"model": model or self.model,
"messages": [m.model_dump(exclude_none=True) for m in messages],
}
if tools:
payload["tools"] = tools
# Projekt-Kontext an den Proxy: routes.js taggt damit die agent_activity-
# /agent_stream-Hooks und trackt den Subprocess pro Kontext (fuer
# kontext-scoped Cancel). Leer = Hauptchat.
if project_id:
payload["aria_project_id"] = project_id
logger.info("Proxy → %s (%d Messages, %d tools, model=%s)",
url, len(messages), len(tools or []), payload["model"])
try:
r = self._client.post(url, json=payload)
except httpx.RequestError as exc:
raise RuntimeError(f"Proxy unreachable: {exc}") from exc
if r.status_code != 200:
raise RuntimeError(f"Proxy HTTP {r.status_code}: {r.text[:300]}")
try:
data = r.json()
except Exception as exc:
raise RuntimeError(f"Proxy invalid JSON: {exc}") from exc
choices = data.get("choices") or []
if not choices:
raise RuntimeError(f"Proxy ohne choices: {str(data)[:300]}")
msg = choices[0].get("message") or {}
finish_reason = choices[0].get("finish_reason", "")
# Diagnose: was hat der Proxy zurueckgegeben?
# Wir loggen die rohe message + finish_reason damit wir sehen ob
# tool_calls da sind, leer oder schlicht weggeschnitten werden.
logger.info("Proxy ← finish=%s keys=%s tool_calls=%d content_len=%d",
finish_reason,
sorted(msg.keys()),
len(msg.get("tool_calls") or []),
len(msg.get("content") or "") if isinstance(msg.get("content"), str)
else sum(len(p.get("text", "")) for p in (msg.get("content") or []) if isinstance(p, dict)))
try:
logger.info("Proxy ← raw-msg=%s", json.dumps(msg)[:1500])
except Exception:
logger.info("Proxy ← raw-msg(non-serial)=%s", str(msg)[:1500])
content = msg.get("content") or ""
if isinstance(content, list):
content = "".join(
part.get("text", "") for part in content if isinstance(part, dict) and part.get("type") == "text"
)
tool_calls_raw = msg.get("tool_calls") or []
tool_calls = []
import json as _json
for tc in tool_calls_raw:
fn = tc.get("function") or {}
args_raw = fn.get("arguments", "{}")
args: dict
if isinstance(args_raw, dict):
args = args_raw
else:
try:
args = _json.loads(args_raw)
except Exception:
args = {"_raw": args_raw}
tool_calls.append({
"id": tc.get("id", ""),
"name": fn.get("name", ""),
"arguments": args,
})
# Call-Metric anhaengen — Token-Schaetzung fuer Quota-Monitoring
metrics.log_call(payload["model"], messages, content or "")
return ProxyResult(
content=content or "",
tool_calls=tool_calls,
finish_reason=finish_reason,
is_error=bool(data.get("aria_error")),
model_used=str(data.get("aria_model") or ""),
)
def close(self):
try:
self._client.close()
except Exception:
pass