feat(queue): Pro-Projekt-Nachrichten-Queue mit Rueckfrage-Loop + Textfeld-Entwuerfe (App)

Zweite Nachricht waehrend ARIA arbeitet wird jetzt ANGESTELLT statt den
laufenden Task abzubrechen. Stellt ARIA eine blockierende Rueckfrage, pausiert
die Queue und die naechste Eingabe beantwortet sie — bis eine finale Antwort
kommt, dann laeuft der naechste Queue-Eintrag (pro Projekt unabhaengig).

- Brain: ARIA deklariert Rueckfragen per unsichtbarem [[AWAIT]]-Marker
  (wie speak/converse; kein '?'-Raten). _extract_await_marker strippt ihn,
  chat() gibt 5-Tupel (+awaiting_reply), System-Prompt erklaert den Marker.
- Bridge: awaiting_reply aus Brain-Response in die chat-Broadcast-Payload.
- App: app-lokale Queue + Zustandsautomat (idle/running/awaiting_reply) pro
  Projekt; Send-Flow von Abbruch auf Anstellen; Stop-Button (cancelRequest)
  schaltet die Queue weiter; sichtbare pending_queue-Bubbles (tippen loescht);
  Rueckfrage-/Queue-Banner ueber dem Eingabefeld. Voice bricht nicht mehr ab
  (haltet nur TTS, serialisiert im Brain-Lock); Text-Send erkennt Brain-busy
  als Fallback. Pro-Projekt-Textfeld-Entwuerfe (Draft-Map + AsyncStorage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 09:35:46 +02:00
co-authored by Claude Opus 4.8
parent 2d9c42a7ea
commit 8a0670a3d2
5 changed files with 265 additions and 66 deletions
+45 -4
View File
@@ -1164,6 +1164,22 @@ def _claims_live_media_state(text: str) -> bool:
return False
_AWAIT_MARKER_RE = re.compile(r"\[\[\s*AWAIT(?:_REPLY)?\s*\]\]", re.IGNORECASE)
def _extract_await_marker(text: str) -> tuple:
"""Erkennt ARIAs Rueckfrage-Marker `[[AWAIT]]` — den sie ans Ende haengt,
wenn ihre Antwort eine echte Rueckfrage ist, auf die sie eine Nutzer-Antwort
BRAUCHT, bevor der aktuelle Task fertig ist. Entfernt den Marker (nicht
anzeigen/vorlesen/in History) und meldet, ob er da war. Wie speak/converse:
ARIA deklariert den Zustand selbst — kein '?'-Raten."""
if not text:
return text, False
if _AWAIT_MARKER_RE.search(text):
return _AWAIT_MARKER_RE.sub("", text).strip(), True
return text, False
def _normalize_for_fast_match(text: str) -> str:
norm = _strip_leading_hint_blocks(text).lower()
norm = _fold_umlauts(norm)
@@ -1588,7 +1604,8 @@ class Agent:
speak = bool(getattr(self, "_fast_path_speak", False))
# converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
converse = bool(getattr(self, "_fast_path_converse", False))
return fast_reply, "fast-path", speak, converse
# 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,
@@ -1606,7 +1623,8 @@ class Agent:
# dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
speak = getattr(self, "_local_turn_speak", True)
converse = getattr(self, "_local_turn_converse", True)
return local_reply, "local", speak, converse
# Local ist tool-loses Reden; blockierende Rueckfragen macht Claude.
return local_reply, "local", speak, converse, False
# 2. Hot Memory (alle pinned Punkte)
hot = self.store.list_pinned()
@@ -1653,6 +1671,23 @@ class Agent:
oauth_callback_host=oauth_host,
oauth_callback_port=oauth_port,
oauth_callback_tls=oauth_tls)
# Rueckfrage-Signal: ARIA haengt [[AWAIT]] an, wenn ihre Antwort eine
# echte, blockierende Rueckfrage ist. Die App pausiert dann die Projekt-
# Queue und leitet Stefans naechste Eingabe als ANTWORT darauf weiter
# (statt als neuen Auftrag). Wie speak/converse: ARIA deklariert selbst.
system_prompt += (
"\n\n## RUECKFRAGE-SIGNAL [[AWAIT]]\n"
"Wenn deine Antwort eine echte RUECKFRAGE ist, auf die du Stefans "
"Antwort BRAUCHST, um den aktuellen Task abzuschliessen (z.B. 'Welche "
"der drei Playlists meinst du?', 'Soll ich X oder Y nehmen?'), haenge "
"als ALLERLETZTES exakt `[[AWAIT]]` an. Der Marker wird entfernt (nicht "
"angezeigt, nicht vorgelesen) und sagt der App: warte auf Stefans "
"Antwort, bevor der naechste Task der Warteschlange laeuft.\n"
"NUR bei echten, blockierenden Rueckfragen — NICHT bei rhetorischen "
"Fragen, unverbindlichen Vorschlaegen ('soll ich noch...?', die auch "
"ohne Antwort ok sind) oder wenn du den Task einfach fertig hast. Im "
"Zweifel: KEIN Marker."
)
# Queue-Aware Prompting: wenn nach diesem Turn weitere Nachrichten
# in der Warteschlange liegen, muss ARIA pruefen ob eine spaetere die
# aktuelle Aufgabe korrigiert/annuliert (→ Skip statt Doppelarbeit).
@@ -1817,13 +1852,19 @@ class Agent:
final_reply = ("Hey, ich bin ARIA. \U0001F60A Bei mir ist alles bereit — "
"sag mir einfach, was du brauchst.")
# Rueckfrage-Marker aus dem finalen Text ziehen (vor History/Return, damit
# er nicht angezeigt/vorgelesen wird und nicht die Conversation vergiftet).
final_reply, awaiting_reply = _extract_await_marker(final_reply)
# 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply,
project_id=active_project_id)
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech).
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech);
# awaiting_reply = ARIA stellt eine blockierende Rueckfrage (Queue pausiert).
return (final_reply, "claude",
bool(getattr(self, "_claude_turn_speak", True)),
bool(getattr(self, "_claude_turn_converse", True)))
bool(getattr(self, "_claude_turn_converse", True)),
awaiting_reply)
# ── Tool-Dispatcher ───────────────────────────────────────
+1 -1
View File
@@ -150,7 +150,7 @@ async def _fire(trigger: dict, agent_factory) -> None:
try:
agent = agent_factory()
reply, _, _, _ = agent.chat(prompt, source="trigger")
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]})
+6 -1
View File
@@ -639,6 +639,10 @@ class ChatOut(BaseModel):
# Soll die App nach der Antwort 30s weiterlauschen (Gespraech)? Einzelaktionen/
# Skills = False (direkt zurueck aufs Wake-Word), Konversation = True.
converse: bool = True
# Stellt ARIA eine blockierende Rueckfrage (braucht Stefans Antwort, bevor der
# Task fertig ist)? Dann pausiert die App die Projekt-Queue und leitet die
# naechste Eingabe als Antwort weiter, statt sie als neuen Auftrag anzustellen.
awaiting_reply: bool = False
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet.
@@ -722,7 +726,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
loop = asyncio.get_running_loop()
reply, answered_by, speak, converse = await loop.run_in_executor(
reply, answered_by, speak, converse, awaiting_reply = await loop.run_in_executor(
None,
lambda: a.chat(
body.message, source=body.source, project_id=pid,
@@ -747,6 +751,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
answered_by=answered_by,
speak=speak,
converse=converse,
awaiting_reply=awaiting_reply,
)
finally:
_project_pending[pid] = [