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>
This commit is contained in:
+108
-9
@@ -38,6 +38,7 @@ import triggers as triggers_mod
|
||||
import watcher as watcher_mod
|
||||
import oauth as oauth_mod
|
||||
import projects as projects_mod
|
||||
import safeguard
|
||||
import project_vms as project_vms_mod
|
||||
|
||||
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
||||
@@ -2445,6 +2446,40 @@ class Agent:
|
||||
# 8 war zu knapp — ARIA brach mitten in laufender Arbeit ab. Env-tunebar.
|
||||
MAX_TOOL_ITERATIONS = int(os.getenv("MAX_TOOL_ITERATIONS", "20"))
|
||||
|
||||
def _llm_call(self, messages, tools, project_id: str, model: str,
|
||||
user_turn_ts: str):
|
||||
"""Ein Claude-Call, abgesichert gegen Anthropics Sicherheitsfilter.
|
||||
|
||||
Gibt (result, model) zurueck — das Modell kann sich dabei aendern, dann
|
||||
laufen auch die restlichen Schritte dieses Turns darauf weiter. Sonst
|
||||
wuerde der naechste Tool-Schritt wieder ins Messer laufen.
|
||||
|
||||
Der Filter ist modellspezifisch ("Opus 5's safeguards ...") und
|
||||
Anthropic empfiehlt selbst "change your model" — genau das passiert
|
||||
hier, nur ohne dass Stefan davon etwas merkt.
|
||||
"""
|
||||
result = self.proxy.chat_full(messages, tools=tools, model=model,
|
||||
project_id=project_id)
|
||||
if not safeguard.looks_like_block(result.content, result.is_error):
|
||||
return result, model
|
||||
|
||||
code = safeguard.detail_code(result.content)
|
||||
fb = safeguard.fallback_for(model)
|
||||
logger.warning("[safeguard] %s blockiert (code=%s) — zweiter Versuch mit %s",
|
||||
model, code or "?", fb)
|
||||
safeguard.record_block(project_id, user_turn_ts, model, fb, result.content)
|
||||
|
||||
retry = self.proxy.chat_full(messages, tools=tools, model=fb,
|
||||
project_id=project_id)
|
||||
if not safeguard.looks_like_block(retry.content, retry.is_error):
|
||||
safeguard.mark_recovered(user_turn_ts, model, fb)
|
||||
logger.info("[safeguard] %s hat geantwortet — Turn gerettet, Verlauf heil", fb)
|
||||
return retry, fb
|
||||
|
||||
# Zweites Modell auch blockiert → Stufe 2 in chat().
|
||||
safeguard.record_block(project_id, user_turn_ts, fb, "", retry.content)
|
||||
raise safeguard.Blocked([model, fb], retry.content)
|
||||
|
||||
def chat(self, user_message: str, source: str = "",
|
||||
project_id: Optional[str] = None,
|
||||
pending_queue: Optional[list[str]] = None) -> tuple:
|
||||
@@ -2532,9 +2567,12 @@ class Agent:
|
||||
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
|
||||
return fast_reply, "fast-path", speak, converse, False
|
||||
|
||||
# 1. User-Turn an die Konversation
|
||||
self.conversation.add("user", user_message, source=source,
|
||||
project_id=active_project_id)
|
||||
# 1. User-Turn an die Konversation. Den Zeitstempel behalten wir: er
|
||||
# identifiziert diesen Turn, wenn Anthropics Sicherheitsfilter
|
||||
# zuschlaegt (safeguard.py — Vermerk + ggf. Quarantaene).
|
||||
_user_turn = self.conversation.add("user", user_message, source=source,
|
||||
project_id=active_project_id)
|
||||
user_turn_ts = getattr(_user_turn, "ts", "")
|
||||
if active_project_id:
|
||||
projects_mod.touch_project(active_project_id)
|
||||
|
||||
@@ -2682,9 +2720,28 @@ class Agent:
|
||||
# sieht der LLM nur die Projekt-Turns (sauberer Kontext); im Hauptthread
|
||||
# nur die nicht-getaggten Turns.
|
||||
window = self.conversation.window(project_id=active_project_id)
|
||||
# Quarantaenierte Turns gehen NICHT mit zum Modell. Das sind die, an
|
||||
# denen Anthropics Sicherheitsfilter schon zweimal haengen geblieben
|
||||
# ist — bliebe so einer im Fenster, wuerde er jeden weiteren Turn
|
||||
# dieses Projekts mit abschiessen. Gespeichert bleiben sie trotzdem.
|
||||
window = safeguard.filter_window(window)
|
||||
for t in window:
|
||||
messages.append(ProxyMessage(role=t.role, content=t.content))
|
||||
|
||||
# Modellwahl fuer diesen Turn. Normalfall: das eingestellte Modell. Ist
|
||||
# dieses Modell an einem Turn, der NOCH im Fenster liegt, schon am
|
||||
# Filter gescheitert, nehmen wir direkt das Ausweich-Modell — sonst
|
||||
# laeuft jeder Turn erst gegen dieselbe Wand. Rollt der Turn aus dem
|
||||
# Fenster, gilt wieder das Hauptmodell; das verfaellt von selbst.
|
||||
primary_model = self.proxy.model
|
||||
turn_model = primary_model
|
||||
_blocked = safeguard.blocked_models(active_project_id,
|
||||
[t.ts for t in window])
|
||||
if safeguard.norm_model(primary_model) in _blocked:
|
||||
turn_model = safeguard.fallback_for(primary_model)
|
||||
logger.warning("[safeguard] %s ist in diesem Fenster blockiert — "
|
||||
"Turn laeuft direkt auf %s", primary_model, turn_model)
|
||||
|
||||
logger.info("chat: pinned=%d cold=%d skills=%d/%d window=%d project=%r prompt_chars=%d",
|
||||
len(hot), len(cold), len(active_skills), len(all_skills),
|
||||
len(window), active_project_id or "(main)", len(system_prompt))
|
||||
@@ -2706,8 +2763,8 @@ class Agent:
|
||||
self._ear_control = None # ear_control-Tool: 'off'|'on' oder None
|
||||
try:
|
||||
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
||||
result = self.proxy.chat_full(messages, tools=tools,
|
||||
project_id=active_project_id)
|
||||
result, turn_model = self._llm_call(
|
||||
messages, tools, active_project_id, turn_model, user_turn_ts)
|
||||
if result.tool_calls:
|
||||
# Assistant-Turn mit tool_calls in messages anhaengen (nicht in Conversation!)
|
||||
messages.append(ProxyMessage(
|
||||
@@ -2756,6 +2813,34 @@ class Agent:
|
||||
if not final_reply:
|
||||
raise RuntimeError("Leerer Reply vom Proxy")
|
||||
|
||||
except safeguard.Blocked as exc:
|
||||
# Stufe 2. Auch das Ausweich-Modell wurde blockiert — dann haengt es
|
||||
# nicht am Modell, sondern am Verlauf. Der ausloesende Turn fliegt
|
||||
# aus dem Prompt-Fenster, sonst scheitert ab jetzt JEDE Frage in
|
||||
# diesem Projekt an derselben Stelle ("Projekt im Eimer").
|
||||
# Geloescht wird nichts: der Turn bleibt in conversation.jsonl und
|
||||
# ist in der Diagnostic mit einem Klick zurueckholbar.
|
||||
#
|
||||
# Der rohe API-Fehlertext wird NICHT persistiert. Genau das war der
|
||||
# Kern des Problems: als Assistant-Turn gespeichert, vergiftet er
|
||||
# jeden Folge-Turn. Stattdessen sagt ARIA in ihrer eigenen Stimme,
|
||||
# was passiert ist.
|
||||
safeguard.quarantine(user_turn_ts, active_project_id,
|
||||
reason="Sicherheitsfilter: " + ", ".join(exc.models))
|
||||
_code = safeguard.detail_code(exc.text)
|
||||
logger.error("[safeguard] Auch %s blockiert (code=%s) — Turn %s in "
|
||||
"Quarantaene, Projekt %r bleibt nutzbar",
|
||||
exc.models[-1], _code or "?", user_turn_ts,
|
||||
active_project_id or "(main)")
|
||||
final_reply = (
|
||||
"Das kam nicht durch: Anthropics Sicherheitsfilter hat die Anfrage "
|
||||
"blockiert" + (f" ({_code})" if _code else "") + " — mit "
|
||||
f"{exc.models[0]} genauso wie mit {exc.models[-1]}. "
|
||||
"Ich hab deine letzte Nachricht aus meinem Arbeitsgedaechtnis "
|
||||
"genommen, damit der Rest vom Projekt weiterlaeuft. Sag's gern "
|
||||
"nochmal anders, dann probieren wir's neu."
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
# Conversation-Konsistenz: User-Turn ist drin (Schritt 1), Assistant
|
||||
# muss auch rein damit die Paarung stimmt. Wir schreiben einen
|
||||
@@ -2782,6 +2867,7 @@ class Agent:
|
||||
logger.warning("[guard] Identity-Break in Antwort erkannt — Retry")
|
||||
try:
|
||||
retry = self.proxy.chat_full(messages, tools=tools,
|
||||
model=turn_model,
|
||||
project_id=active_project_id)
|
||||
retry_text = (retry.content or "").strip()
|
||||
except Exception as exc:
|
||||
@@ -4372,11 +4458,24 @@ class Agent:
|
||||
"Extrahiere die wichtigsten Fakten als JSON."
|
||||
)
|
||||
|
||||
distill_messages = [
|
||||
ProxyMessage(role="system", content=system),
|
||||
ProxyMessage(role="user", content=user),
|
||||
]
|
||||
try:
|
||||
raw = self.proxy.chat([
|
||||
ProxyMessage(role="system", content=system),
|
||||
ProxyMessage(role="user", content=user),
|
||||
])
|
||||
raw = self.proxy.chat(distill_messages)
|
||||
# Auch hier kann Anthropics Filter zuschlagen — der Transcript
|
||||
# enthaelt ja genau das Gespraech, das vorher schon blockiert
|
||||
# wurde. Ohne diese Pruefung landet der Fehlertext in _parse_facts,
|
||||
# scheitert dort als "unparsbares JSON", und das Destillat
|
||||
# versucht es bei jedem Lauf aufs Neue erfolglos.
|
||||
if safeguard.looks_like_block(raw, False):
|
||||
fb = safeguard.fallback_for(self.proxy.model)
|
||||
logger.warning("[safeguard] Destillat blockiert — zweiter Versuch mit %s", fb)
|
||||
raw = self.proxy.chat(distill_messages, model=fb)
|
||||
if safeguard.looks_like_block(raw, False):
|
||||
logger.error("[safeguard] Destillat auch mit %s blockiert — Turns bleiben", fb)
|
||||
return {"distilled": 0, "error": "Sicherheitsfilter blockiert das Destillat"}
|
||||
except Exception as exc:
|
||||
logger.warning("Destillat-Call fehlgeschlagen: %s — Turns bleiben", exc)
|
||||
return {"distilled": 0, "error": str(exc)}
|
||||
|
||||
Reference in New Issue
Block a user