From 8a0670a3d2267f3c529f749d51cac2e2507208e1 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 12 Jul 2026 09:35:46 +0200 Subject: [PATCH] feat(queue): Pro-Projekt-Nachrichten-Queue mit Rueckfrage-Loop + Textfeld-Entwuerfe (App) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- android/src/screens/ChatScreen.tsx | 263 ++++++++++++++++++++++------- aria-brain/agent.py | 49 +++++- aria-brain/background.py | 2 +- aria-brain/main.py | 7 +- bridge/aria_bridge.py | 10 +- 5 files changed, 265 insertions(+), 66 deletions(-) diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 4b23059..c4f1c8c 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -64,6 +64,14 @@ interface Attachment { deleted?: boolean; // Datei wurde nachtraeglich geloescht (Diagnostic-Manager) } +// Pro-Projekt-Queue-Zustand: idle = nichts laeuft; running = ARIA arbeitet am +// aktuellen Task; awaiting_reply = ARIA hat eine blockierende Rueckfrage gestellt, +// die naechste Eingabe beantwortet sie (statt einen neuen Auftrag anzustellen). +type CtxState = 'idle' | 'running' | 'awaiting_reply'; + +// Wartender Queue-Eintrag (id = die Bubble-ID der pending_queue-Nachricht). +interface QueuedItem { id: string; text: string; } + interface ChatMessage { id: string; sender: 'user' | 'aria'; @@ -131,7 +139,7 @@ interface ChatMessage { /** Delivery-Status der User-Bubble (WhatsApp-style): queued = noch nicht * raus (offline), sending = an Bridge unterwegs, sent = Bridge hat ACK * gesendet, delivered = Brain hat geantwortet, failed = Retry-Limit. */ - deliveryStatus?: 'queued' | 'sending' | 'sent' | 'delivered' | 'failed'; + deliveryStatus?: 'queued' | 'pending_queue' | 'sending' | 'sent' | 'delivered' | 'failed'; /** Anzahl der bisherigen Sende-Versuche (fuer Retry-Limit). */ sendAttempts?: number; } @@ -275,6 +283,7 @@ async function checkFileExists(uri: string): Promise { const ChatScreen: React.FC = () => { const [messages, setMessages] = useState([]); const [inputText, setInputText] = useState(''); + const inputTextRef = useRef(''); const [connectionState, setConnectionState] = useState('disconnected'); const [showFileUpload, setShowFileUpload] = useState(false); const [showCameraUpload, setShowCameraUpload] = useState(false); @@ -301,6 +310,24 @@ const ChatScreen: React.FC = () => { // Ref-Spiegel fuer Callbacks (interruptAriaIfBusy liest den aktuellen // Busy-Status des fokussierten Kontexts ohne stale Closure). const queueStatusRef = useRef>({}); + // ── Pro-Projekt-Nachrichten-Queue + Zustandsautomat (app-lokal) ── + // Key = projectId ('' = Hauptchat). state pro Kontext + Queue der wartenden + // User-Bubbles. Sendet man waehrend 'running', wird angestellt statt + // abgebrochen; 'awaiting_reply' leitet die naechste Eingabe als Antwort weiter. + const [projectStates, setProjectStates] = useState>({}); + const [projectQueues, setProjectQueues] = useState>({}); + const projectStatesRef = useRef>({}); + const projectQueuesRef = useRef>({}); + // Pro-Projekt-Textfeld-Entwuerfe (noch nicht gesendeter Feldinhalt). Key = pid. + const projectDraftsRef = useRef>({}); + const prevFocusedPidRef = useRef(''); + // Ref-Bruecke, damit der frueh deklarierte RVS-Message-Handler den erst spaeter + // deklarierten Queue-Automaten aufrufen kann (ohne TDZ / stale Closure). + const queueApiRef = useRef<{ + getCtxState: (pid: string) => CtxState; + setCtxState: (pid: string, s: CtxState) => void; + advanceQueue: (pid: string) => void; + } | null>(null); const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]); const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''}); @@ -509,14 +536,39 @@ const ChatScreen: React.FC = () => { return () => unsub(); }, []); - // Focus in Storage spiegeln damit der letzte Kontext nach Neustart wieder - // da ist. Kein zwingender UX-Fix (Default = Hauptchat waere auch ok), aber - // fuer den Auto-Fall angenehm. + // Focus in Storage spiegeln + Pro-Projekt-Textfeld-Entwuerfe wechseln. useEffect(() => { AsyncStorage.setItem('aria_focused_project_id', focusedProjectId).catch(() => {}); focusedProjectIdRef.current = focusedProjectId; + // Draft-Wechsel: der aktuelle Feldinhalt gehoert dem VERLASSENEN Projekt, + // der Entwurf des neuen Projekts wird geladen (leer = leeres Feld). + const prevPid = prevFocusedPidRef.current; + if (prevPid !== focusedProjectId) { + projectDraftsRef.current = { ...projectDraftsRef.current, [prevPid]: inputTextRef.current }; + setInputText(projectDraftsRef.current[focusedProjectId] || ''); + prevFocusedPidRef.current = focusedProjectId; + AsyncStorage.setItem('aria_project_drafts', JSON.stringify(projectDraftsRef.current)).catch(() => {}); + } }, [focusedProjectId]); + // inputText-Spiegel (damit der Draft-Wechsel oben inputText nicht als Dep braucht). + useEffect(() => { inputTextRef.current = inputText; }, [inputText]); + + // Drafts beim Start aus Storage laden. + useEffect(() => { + AsyncStorage.getItem('aria_project_drafts').then(v => { + if (!v) return; + try { + const map = JSON.parse(v); + if (map && typeof map === 'object') { + projectDraftsRef.current = map; + const cur = map[focusedProjectIdRef.current] || ''; + if (cur) setInputText(cur); + } + } catch {} + }).catch(() => {}); + }, []); + // Ref-Spiegel damit useCallback-Handler die aktuelle Focus-ID lesen // ohne dass wir die Deps in jedes Callback muessen (sonst re-createn // die sich bei jedem Wechsel). @@ -1236,6 +1288,18 @@ const ChatScreen: React.FC = () => { : prev); setAgentActivity(cur => cur.activity === 'idle' ? cur : { activity: 'idle', tool: '' }); + // ── Pro-Projekt-Queue-Automat: auf ARIAs Antwort reagieren ── + const awaiting = !!(message.payload as any).awaiting_reply; + const qapi = queueApiRef.current; + if (qapi) { + const st = qapi.getCtxState(ansPid); + // Nur reagieren, wenn wir fuer diesen Kontext wirklich auf eine + // Antwort warten (nicht bei unaufgeforderten Trigger-Nachrichten). + if (st === 'running' || st === 'awaiting_reply') { + if (awaiting) qapi.setCtxState(ansPid, 'awaiting_reply'); + else qapi.advanceQueue(ansPid); + } + } } // ALLE noch laufenden ACK-Timer clearen — Bridge hat unsere Messages // ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn @@ -1972,6 +2036,65 @@ const ChatScreen: React.FC = () => { // --- Nachricht senden --- + // ── Pro-Projekt-Queue: Zustands-Helfer ────────────────────────── + const getCtxState = useCallback((pid: string): CtxState => + projectStatesRef.current[pid] || 'idle', []); + + const setCtxState = useCallback((pid: string, s: CtxState) => { + projectStatesRef.current = { ...projectStatesRef.current, [pid]: s }; + setProjectStates(prev => ({ ...prev, [pid]: s })); + }, []); + + const setCtxQueue = useCallback((pid: string, items: QueuedItem[]) => { + projectQueuesRef.current = { ...projectQueuesRef.current, [pid]: items }; + setProjectQueues(prev => ({ ...prev, [pid]: items })); + }, []); + + // Sendet eine Nachricht WIRKLICH (Location holen + dispatchWithAck) und setzt + // den Kontext auf 'running'. existingId gesetzt = eine bereits als pending_queue + // angezeigte Bubble wird auf 'sending' gehoben (Dequeue), sonst neue Bubble. + const actuallySend = useCallback(async (pid: string, text: string, existingId?: string) => { + const cmid = nextClientMsgId(); + const location = await getCurrentLocation(); + const status: ChatMessage['deliveryStatus'] = + connectionStateRef.current === 'connected' ? 'sending' : 'queued'; + if (existingId) { + setMessages(prev => prev.map(m => + m.id === existingId ? { ...m, clientMsgId: cmid, deliveryStatus: status, sendAttempts: 1 } : m)); + } else { + setMessages(prev => capMessages([...prev, { + id: nextId(), sender: 'user', text, timestamp: Date.now(), + clientMsgId: cmid, deliveryStatus: status, sendAttempts: 1, projectId: pid, + }])); + } + setCtxState(pid, 'running'); + dispatchWithAck(cmid, 'chat', { + text, voice: localXttsVoiceRef.current, speed: ttsSpeedRef.current, + projectId: pid, ...(location && { location }), + }); + }, [getCurrentLocation, dispatchWithAck, setCtxState]); + + // Naechsten Queue-Eintrag von pid abarbeiten (falls vorhanden), sonst idle. + const advanceQueue = useCallback((pid: string) => { + const q = projectQueuesRef.current[pid] || []; + if (q.length === 0) { setCtxState(pid, 'idle'); return; } + const [next, ...rest] = q; + setCtxQueue(pid, rest); + actuallySend(pid, next.text, next.id); + }, [actuallySend, setCtxQueue, setCtxState]); + + // Einen wartenden Eintrag aus der Queue entfernen (User tippt auf ✕). + const removeQueued = useCallback((pid: string, id: string) => { + setCtxQueue(pid, (projectQueuesRef.current[pid] || []).filter(it => it.id !== id)); + setMessages(prev => prev.filter(m => m.id !== id)); + }, [setCtxQueue]); + + // Ref-Bruecke fuellen, damit der frueh deklarierte Message-Handler den Automaten + // erreicht (ohne TDZ / stale Closure). + useEffect(() => { + queueApiRef.current = { getCtxState, setCtxState, advanceQueue }; + }, [getCtxState, setCtxState, advanceQueue]); + // Aufraeumen von "verarbeitet"-Placeholder die nie ein STT-Result bekommen // haben (leere Aufnahme, Wake-Word-Echo, STT-Fehler etc). Timeout skaliert // mit der Aufnahmedauer — Whisper braucht auf der Gamebox grob real-time/5, @@ -2002,34 +2125,22 @@ const ChatScreen: React.FC = () => { setAgentActivityByCtx(prev => ({ ...prev, [pid]: { activity: 'idle', tool: '' } })); clearStuckWatchdog(); rvs.send('cancel_request' as any, { projectId: pid }); - }, []); + // Aktuellen Task abgebrochen → naechsten Queue-Eintrag dieses Projekts + // starten (oder idle, wenn leer). Wartende Eintraege einzeln loeschbar. + advanceQueue(pid); + }, [advanceQueue]); - // Barge-In: wenn der User waehrend ARIA arbeitet/spricht eine neue Sprach- - // Nachricht aufnimmt, alte Aktivitaet sofort abbrechen — TTS verstummen, - // aria-core-Run via cancel_request abbrechen. So kann man "ach vergiss es, - // mach lieber X" sagen wie in einem echten Gespraech. + // Queue-Modus („immer anstellen"): eine neue Sprachnachricht bricht ARIAs + // laufende Arbeit NICHT mehr ab. Sie wird — wie Text — angestellt und laeuft + // serialisiert (der Brain-Lock pro Projekt reiht /chat-/audio-Turns auf). + // Nur das TTS wird akustisch gestoppt, damit das Mikro ARIAs eigene Stimme + // nicht mithoert. Explizites Abbrechen laeuft ueber den Stop-Button + // (cancelRequest). Rueckgabe = false, weil kein Barge-In/Interrupt mehr. const interruptAriaIfBusy = useCallback(() => { - const speaking = audioService.isPlayingAudio(); - // Multi-Threading: NUR den fokussierten Kontext als "busy" werten — nicht - // global. Sonst bricht eine Nachricht im Hauptchat die parallele Arbeit in - // einem Projekt ab (bzw. wird faelschlich als Barge-In behandelt und die - // eigene Anfrage geht unter). Der Busy-Status kommt kontextgenau aus - // /projects/queue-status (queueStatusRef). agentActivity ist global und - // taugt dafuer nicht. - const pid = focusedProjectIdRef.current || ''; - const focusKey = pid || '__main__'; - const focusBusy = !!queueStatusRef.current?.[focusKey]?.busy; - if (!speaking && !focusBusy) return false; - console.log('[Chat] Barge-In: speaking=%s focusBusy=%s (ctx=%s) — interrupting', - speaking, focusBusy, focusKey); - // TTS immer stoppen wenn ARIA gerade spricht — egal welcher Kontext. - if (speaking) audioService.haltAllPlayback('user spricht (barge-in)'); - // Brain-Arbeit nur abbrechen wenn GENAU dieser Kontext arbeitet. - if (focusBusy) { - clearStuckWatchdog(); - rvs.send('cancel_request' as any, { projectId: pid }); + if (audioService.isPlayingAudio()) { + audioService.haltAllPlayback('user startet Aufnahme (Queue-Modus, kein Abbruch)'); } - return true; + return false; }, []); // Manueller Aufnahme-Knopf (VoiceButton) — Start. @@ -2216,37 +2327,37 @@ const ChatScreen: React.FC = () => { if (!text) return; - setInputText(''); - - // Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist. - const wasInterrupted = interruptAriaIfBusy(); - const location = await getCurrentLocation(); - - const cmid = nextClientMsgId(); const activePid = focusedProjectIdRef.current; - const userMsg: ChatMessage = { - id: nextId(), - sender: 'user', - text, - timestamp: Date.now(), - clientMsgId: cmid, - deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued', - sendAttempts: 1, - projectId: activePid, - }; - setMessages(prev => capMessages([...prev, userMsg])); + setInputText(''); + // Draft dieses Projekts leeren (wurde ja gerade abgeschickt/angestellt). + projectDraftsRef.current = { ...projectDraftsRef.current, [activePid]: '' }; - console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s project=%s', - cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted, activePid || '(main)'); - dispatchWithAck(cmid, 'chat', { - text, - voice: localXttsVoiceRef.current, - speed: ttsSpeedRef.current, - interrupted: wasInterrupted, - projectId: activePid, - ...(location && { location }), - }); - }, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]); + const focusKey = activePid || '__main__'; + const brainBusy = !!queueStatusRef.current?.[focusKey]?.busy; + let state = getCtxState(activePid); + // Fallback: ein per SPRACHE gestarteter Turn streamt live und setzt den + // App-State nicht — aber der Brain meldet busy. Dann Kontext als 'running' + // behandeln (anstellen; die spaetere Antwort schaltet die Queue weiter). + if (state === 'idle' && brainBusy) { setCtxState(activePid, 'running'); state = 'running'; } + if (state === 'running') { + // ARIA arbeitet noch am aktuellen Task → ANSTELLEN statt abbrechen. + // Sichtbare pending_queue-Bubble; laeuft der Reihe nach, wenn der + // aktuelle Task (und ggf. seine Rueckfragen) fertig ist. + const id = nextId(); + setMessages(prev => capMessages([...prev, { + id, sender: 'user', text, timestamp: Date.now(), + projectId: activePid, deliveryStatus: 'pending_queue', + }])); + setCtxQueue(activePid, [...(projectQueuesRef.current[activePid] || []), { id, text }]); + console.log('[Chat] angestellt (queue) project=%s len=%d', activePid || '(main)', + (projectQueuesRef.current[activePid] || []).length); + } else { + // idle ODER awaiting_reply → sofort senden. Bei awaiting_reply ist DAS die + // Antwort auf ARIAs Rueckfrage (normaler Turn in diesem Projekt). + console.log('[Chat] sende sofort (state=%s) project=%s', state, activePid || '(main)'); + actuallySend(activePid, text); + } + }, [inputText, pendingAttachments, sendPendingAttachments, getCtxState, setCtxState, setCtxQueue, actuallySend]); // --- Rendering --- @@ -2546,7 +2657,16 @@ const ChatScreen: React.FC = () => { ) : null} {isUser && item.deliveryStatus ? ( - item.deliveryStatus === 'failed' && item.clientMsgId ? ( + item.deliveryStatus === 'pending_queue' ? ( + // Wartet in der Projekt-Queue → tippen entfernt den Eintrag. + removeQueued(item.projectId || '', item.id)} + accessibilityLabel="Aus Warteschlange entfernen" + > + {'⏸ wartet · ✕'} + + ) : item.deliveryStatus === 'failed' && item.clientMsgId ? ( retryFailedMessage(item.clientMsgId!)} @@ -2969,6 +3089,18 @@ const ChatScreen: React.FC = () => { )} + {/* Rueckfrage-Banner / Queue-Zaehler fuer den fokussierten Kontext */} + {(projectStates[focusedProjectId] === 'awaiting_reply' || + (projectQueues[focusedProjectId]?.length || 0) > 0) && ( + + + {projectStates[focusedProjectId] === 'awaiting_reply' + ? '❓ ARIA fragt nach — deine Eingabe beantwortet das' + : `⏸ ${projectQueues[focusedProjectId]?.length || 0} in der Warteschlange`} + + + )} + {/* Eingabebereich */} {/* Datei-Buttons */} @@ -3466,6 +3598,19 @@ const styles = StyleSheet.create({ fontSize: 14, marginTop: 4, }, + queueBanner: { + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: '#2A2410', + borderTopWidth: 1, + borderTopColor: '#4A3F14', + }, + queueBannerText: { + color: '#FFD60A', + fontSize: 13, + fontWeight: '600', + textAlign: 'center', + }, inputContainer: { flexDirection: 'row', alignItems: 'flex-end', diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 2d23351..acd2c6c 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -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 ─────────────────────────────────────── diff --git a/aria-brain/background.py b/aria-brain/background.py index c7811bd..a7d45e8 100644 --- a/aria-brain/background.py +++ b/aria-brain/background.py @@ -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]}) diff --git a/aria-brain/main.py b/aria-brain/main.py index 4996b6f..a195376 100644 --- a/aria-brain/main.py +++ b/aria-brain/main.py @@ -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] = [ diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index b55cfa2..cca3f61 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -1585,6 +1585,10 @@ class ARIABridge: # Nach dieser Antwort 30s weiterlauschen? Steuert das Gespraechs- # Fenster in der App (Skill/Einzelaktion=False, Konversation=True). "converse": bool(payload.get("converse", True)) if isinstance(payload, dict) else True, + # Stellt ARIA eine blockierende Rueckfrage? Dann pausiert die App + # die Projekt-Queue und leitet die naechste Eingabe als Antwort auf + # DIESE Rueckfrage weiter, statt sie als neuen Auftrag anzustellen. + "awaiting_reply": bool(payload.get("awaiting_reply", False)) if isinstance(payload, dict) else False, }, "timestamp": int(asyncio.get_event_loop().time() * 1000), }) @@ -1948,6 +1952,9 @@ class ARIABridge: # Nach der Antwort 30s weiterlauschen (Gespraech) oder direkt zurueck aufs # Wake-Word? Einzelaktionen/Skills = False. Reicht die App aus. converse = data.get("converse", True) + # Stellt ARIA eine blockierende Rueckfrage? Dann pausiert die App die + # Projekt-Queue und leitet die naechste Eingabe als Antwort weiter. + awaiting_reply = bool(data.get("awaiting_reply", False)) # Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created) # damit sie in der UI vor der Reply auftauchen @@ -2017,7 +2024,8 @@ class ARIABridge: await self._process_core_response(reply, {"projectId": turn_project_id, "answeredBy": answered_by, "speak": speak, - "converse": converse}) + "converse": converse, + "awaiting_reply": awaiting_reply}) except Exception: logger.exception("[brain] _process_core_response Fehler") await self._emit_activity("idle", "", project_id=project_id)