feat: Modell pro Chat waehlen — und sehen, welches gerade laeuft

Nach einem Sicherheitsfilter-Treffer fuhr ein Projekt still auf dem
Ausweich-Modell weiter, und nirgends war zu sehen, auf welchem. Zurueck ging
nur ueber "Liste leeren" im Filter-Panel, und das gleich fuer alle Chats.

Jetzt drei Ebenen (aria-brain/context_model.py):
  Einstellung (brainModel) < pro Chat < Ausweich (Sicherheitsfilter)

- Pro Chat: Hauptchat und jedes Projekt koennen ein eigenes Modell haben, in
  /shared/config/context_models.json. Bewusst kein Feld im Projekt-Manifest —
  der Hauptchat ist kein Projekt und waere sonst aussen vor.
- "↩ zurueck" pro Chat: loescht nur die Filter-Vermerke DIESES Chats. Blockt
  der Filter erneut, weicht das Brain eben wieder aus.
- Angezeigt wird, was wirklich laeuft: chat() gibt das Modell aus modelUsage
  der CLI zurueck (aufgeloeste Version statt Alias) samt Grund; Brain → Bridge
  → chat_backup + Live-Payload → App und Diagnostic. Als Grundeinstellung
  zaehlt das Modell, mit dem das Brain laeuft, nicht der Dateiwert — so faellt
  ein ausstehender Neustart nach brainModel-Aenderung auf.

Diagnostic: 🧠-Auswahl neben "+ Projekt" fuer den fokussierten Chat, Modell im
Untertitel jeder Kontext-Karte, Modell statt "Claude" am Bubble-Badge (orange
mit ↪ bei Ausweich).

App: hinter der Quell-Anzeige (Einstellungen, Default aus — Kopfzeile bleibt
fuer Mama unveraendert) ein Modell-Chip in der Kopfzeile; Tippen oeffnet die
Auswahl fuer diesen Chat inkl. "Zurueck". Laeuft ueber den vorhandenen
brain_request-Kanal, also keine neuen RVS-Typen.

Tests: 47/47 (12 neue fuer die Aufloesungsreihenfolge gegen agent.chat()),
tsc ohne Fehler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 13:39:30 +02:00
co-authored by Claude Opus 5.5
parent d221c1a320
commit 6d978d72b3
9 changed files with 596 additions and 19 deletions
+33 -6
View File
@@ -39,9 +39,13 @@ import watcher as watcher_mod
import oauth as oauth_mod
import projects as projects_mod
import safeguard
import context_model
import project_vms as project_vms_mod
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# Modell-Info fuer Antworten, an denen kein Claude-Modell beteiligt war
# (Fast-Path, Wake-Befehle, lokales LLM). Leer statt geraten.
_NO_MODEL = {"model": "", "requested": "", "reason": ""}
# SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b).
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080").rstrip("/")
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
@@ -2529,7 +2533,7 @@ class Agent:
project_id=active_project_id)
self.conversation.add("assistant", reply, project_id=active_project_id)
logger.info("[wake-off] Sprachbefehl erkannt — App stoppt Listener")
return reply, "wake-off", False, False, False
return reply, "wake-off", False, False, False, _NO_MODEL
# Wake-Word AN per Befehl (Text/Aufnahme-Button): App startet den Listener.
if _user_wants_wake_on(user_message):
@@ -2538,7 +2542,7 @@ class Agent:
project_id=active_project_id)
self.conversation.add("assistant", reply, project_id=active_project_id)
logger.info("[wake-on] Befehl erkannt — App startet Listener")
return reply, "wake-on", False, False, False
return reply, "wake-on", False, False, False, _NO_MODEL
# Fast-Path: einfache "reines Steuern"-Commands ueberspringen Claude komplett.
# Jeder Skill kann in seinem Manifest fast_patterns deklarieren — das Brain
@@ -2565,7 +2569,7 @@ class Agent:
elif _wants_continue:
converse = True
# Fast-Path = reiner Steuerbefehl, nie eine Rueckfrage → awaiting=False.
return fast_reply, "fast-path", speak, converse, False
return fast_reply, "fast-path", speak, converse, False, _NO_MODEL
# 1. User-Turn an die Konversation. Den Zeitstempel behalten wir: er
# identifiziert diesen Turn, wenn Anthropics Sicherheitsfilter
@@ -2591,7 +2595,7 @@ class Agent:
elif _wants_continue:
converse = True
# Local ist tool-loses Reden; blockierende Rueckfragen macht Claude.
return local_reply, "local", speak, converse, False
return local_reply, "local", speak, converse, False, _NO_MODEL
# 2. Hot Memory (alle pinned Punkte)
hot = self.store.list_pinned()
@@ -2733,14 +2737,20 @@ class Agent:
# 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
# Reihenfolge: Einstellung (brainModel) < pro Chat < Ausweich.
primary_model, model_reason = context_model.resolve(
active_project_id, 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)
model_reason = context_model.REASON_FALLBACK
logger.warning("[safeguard] %s ist in diesem Fenster blockiert — "
"Turn laeuft direkt auf %s", primary_model, turn_model)
if model_reason == context_model.REASON_PER_CHAT:
logger.info("[context-model] %s nutzt %s (pro Chat gesetzt)",
active_project_id or "(Hauptchat)", 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),
@@ -2753,6 +2763,9 @@ class Agent:
# zwei Turns, was OpenAI/Anthropic verwirrt und bei strict tools-Aufrufen
# zu 400-Errors fuehren kann.
final_reply = ""
# Welches Modell hat am Ende geantwortet — fuer die Anzeige in App und
# Diagnostic. Default: das angefragte; die CLI darf es praezisieren.
answered_model = turn_model
# Vorlesen ja/nein fuer diesen Claude-Turn. Default True (Gespraech).
# Fuehrt Claude einen Steuerbefehl-Skill (run_*, speak=false) erfolgreich
# aus, wird die Antwort NICHT vorgelesen — der Text landet aber normal in
@@ -2763,8 +2776,16 @@ class Agent:
self._ear_control = None # ear_control-Tool: 'off'|'on' oder None
try:
for iteration in range(self.MAX_TOOL_ITERATIONS):
_model_before = turn_model
result, turn_model = self._llm_call(
messages, tools, active_project_id, turn_model, user_turn_ts)
if turn_model != _model_before:
# _llm_call ist auf das Ausweich-Modell gewechselt.
model_reason = context_model.REASON_FALLBACK
# modelUsage der CLI ist die Wahrheit: bei einem Tier-Alias
# steht hier die aufgeloeste Version ("sonnet" → "claude-...").
if result.model_used:
answered_model = result.model_used
if result.tool_calls:
# Assistant-Turn mit tool_calls in messages anhaengen (nicht in Conversation!)
messages.append(ProxyMessage(
@@ -2915,7 +2936,13 @@ class Agent:
elif self._ear_control == "on":
answered_by, speak, converse = "wake-on", False, False
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
return (final_reply, answered_by, speak, converse, awaiting_reply)
# Modell-Info: was hat geantwortet und warum genau dieses. Die
# Oberflaechen zeigen das an — sonst merkt niemand, dass ein Projekt
# gerade auf dem Ausweich-Modell faehrt.
model_info = {"model": answered_model or turn_model,
"requested": turn_model,
"reason": model_reason}
return (final_reply, answered_by, speak, converse, awaiting_reply, model_info)
# ── Tool-Dispatcher ───────────────────────────────────────