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
+204 -59
View File
@@ -64,6 +64,14 @@ interface Attachment {
deleted?: boolean; // Datei wurde nachtraeglich geloescht (Diagnostic-Manager) 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 { interface ChatMessage {
id: string; id: string;
sender: 'user' | 'aria'; sender: 'user' | 'aria';
@@ -131,7 +139,7 @@ interface ChatMessage {
/** Delivery-Status der User-Bubble (WhatsApp-style): queued = noch nicht /** Delivery-Status der User-Bubble (WhatsApp-style): queued = noch nicht
* raus (offline), sending = an Bridge unterwegs, sent = Bridge hat ACK * raus (offline), sending = an Bridge unterwegs, sent = Bridge hat ACK
* gesendet, delivered = Brain hat geantwortet, failed = Retry-Limit. */ * 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). */ /** Anzahl der bisherigen Sende-Versuche (fuer Retry-Limit). */
sendAttempts?: number; sendAttempts?: number;
} }
@@ -275,6 +283,7 @@ async function checkFileExists(uri: string): Promise<boolean> {
const ChatScreen: React.FC = () => { const ChatScreen: React.FC = () => {
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputText, setInputText] = useState(''); const [inputText, setInputText] = useState('');
const inputTextRef = useRef('');
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected'); const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
const [showFileUpload, setShowFileUpload] = useState(false); const [showFileUpload, setShowFileUpload] = useState(false);
const [showCameraUpload, setShowCameraUpload] = useState(false); const [showCameraUpload, setShowCameraUpload] = useState(false);
@@ -301,6 +310,24 @@ const ChatScreen: React.FC = () => {
// Ref-Spiegel fuer Callbacks (interruptAriaIfBusy liest den aktuellen // Ref-Spiegel fuer Callbacks (interruptAriaIfBusy liest den aktuellen
// Busy-Status des fokussierten Kontexts ohne stale Closure). // Busy-Status des fokussierten Kontexts ohne stale Closure).
const queueStatusRef = useRef<Record<string, { busy: boolean; queue_size: number }>>({}); const queueStatusRef = useRef<Record<string, { busy: boolean; queue_size: number }>>({});
// ── 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<Record<string, CtxState>>({});
const [projectQueues, setProjectQueues] = useState<Record<string, QueuedItem[]>>({});
const projectStatesRef = useRef<Record<string, CtxState>>({});
const projectQueuesRef = useRef<Record<string, QueuedItem[]>>({});
// Pro-Projekt-Textfeld-Entwuerfe (noch nicht gesendeter Feldinhalt). Key = pid.
const projectDraftsRef = useRef<Record<string, string>>({});
const prevFocusedPidRef = useRef<string>('');
// 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 [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]); const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''}); const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
@@ -509,14 +536,39 @@ const ChatScreen: React.FC = () => {
return () => unsub(); return () => unsub();
}, []); }, []);
// Focus in Storage spiegeln damit der letzte Kontext nach Neustart wieder // Focus in Storage spiegeln + Pro-Projekt-Textfeld-Entwuerfe wechseln.
// da ist. Kein zwingender UX-Fix (Default = Hauptchat waere auch ok), aber
// fuer den Auto-Fall angenehm.
useEffect(() => { useEffect(() => {
AsyncStorage.setItem('aria_focused_project_id', focusedProjectId).catch(() => {}); AsyncStorage.setItem('aria_focused_project_id', focusedProjectId).catch(() => {});
focusedProjectIdRef.current = focusedProjectId; 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]); }, [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 // Ref-Spiegel damit useCallback-Handler die aktuelle Focus-ID lesen
// ohne dass wir die Deps in jedes Callback muessen (sonst re-createn // ohne dass wir die Deps in jedes Callback muessen (sonst re-createn
// die sich bei jedem Wechsel). // die sich bei jedem Wechsel).
@@ -1236,6 +1288,18 @@ const ChatScreen: React.FC = () => {
: prev); : prev);
setAgentActivity(cur => setAgentActivity(cur =>
cur.activity === 'idle' ? cur : { activity: 'idle', tool: '' }); 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 // ALLE noch laufenden ACK-Timer clearen — Bridge hat unsere Messages
// ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn // ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn
@@ -1972,6 +2036,65 @@ const ChatScreen: React.FC = () => {
// --- Nachricht senden --- // --- 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 // Aufraeumen von "verarbeitet"-Placeholder die nie ein STT-Result bekommen
// haben (leere Aufnahme, Wake-Word-Echo, STT-Fehler etc). Timeout skaliert // haben (leere Aufnahme, Wake-Word-Echo, STT-Fehler etc). Timeout skaliert
// mit der Aufnahmedauer — Whisper braucht auf der Gamebox grob real-time/5, // 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: '' } })); setAgentActivityByCtx(prev => ({ ...prev, [pid]: { activity: 'idle', tool: '' } }));
clearStuckWatchdog(); clearStuckWatchdog();
rvs.send('cancel_request' as any, { projectId: pid }); 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- // Queue-Modus („immer anstellen"): eine neue Sprachnachricht bricht ARIAs
// Nachricht aufnimmt, alte Aktivitaet sofort abbrechen — TTS verstummen, // laufende Arbeit NICHT mehr ab. Sie wird — wie Text — angestellt und laeuft
// aria-core-Run via cancel_request abbrechen. So kann man "ach vergiss es, // serialisiert (der Brain-Lock pro Projekt reiht /chat-/audio-Turns auf).
// mach lieber X" sagen wie in einem echten Gespraech. // 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 interruptAriaIfBusy = useCallback(() => {
const speaking = audioService.isPlayingAudio(); if (audioService.isPlayingAudio()) {
// Multi-Threading: NUR den fokussierten Kontext als "busy" werten — nicht audioService.haltAllPlayback('user startet Aufnahme (Queue-Modus, kein Abbruch)');
// 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 });
} }
return true; return false;
}, []); }, []);
// Manueller Aufnahme-Knopf (VoiceButton) — Start. // Manueller Aufnahme-Knopf (VoiceButton) — Start.
@@ -2216,37 +2327,37 @@ const ChatScreen: React.FC = () => {
if (!text) return; 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 activePid = focusedProjectIdRef.current;
const userMsg: ChatMessage = { setInputText('');
id: nextId(), // Draft dieses Projekts leeren (wurde ja gerade abgeschickt/angestellt).
sender: 'user', projectDraftsRef.current = { ...projectDraftsRef.current, [activePid]: '' };
text,
timestamp: Date.now(),
clientMsgId: cmid,
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
sendAttempts: 1,
projectId: activePid,
};
setMessages(prev => capMessages([...prev, userMsg]));
console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s project=%s', const focusKey = activePid || '__main__';
cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted, activePid || '(main)'); const brainBusy = !!queueStatusRef.current?.[focusKey]?.busy;
dispatchWithAck(cmid, 'chat', { let state = getCtxState(activePid);
text, // Fallback: ein per SPRACHE gestarteter Turn streamt live und setzt den
voice: localXttsVoiceRef.current, // App-State nicht — aber der Brain meldet busy. Dann Kontext als 'running'
speed: ttsSpeedRef.current, // behandeln (anstellen; die spaetere Antwort schaltet die Queue weiter).
interrupted: wasInterrupted, if (state === 'idle' && brainBusy) { setCtxState(activePid, 'running'); state = 'running'; }
projectId: activePid, if (state === 'running') {
...(location && { location }), // ARIA arbeitet noch am aktuellen Task → ANSTELLEN statt abbrechen.
}); // Sichtbare pending_queue-Bubble; laeuft der Reihe nach, wenn der
}, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]); // 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 --- // --- Rendering ---
@@ -2546,7 +2657,16 @@ const ChatScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
) : null} ) : null}
{isUser && item.deliveryStatus ? ( {isUser && item.deliveryStatus ? (
item.deliveryStatus === 'failed' && item.clientMsgId ? ( item.deliveryStatus === 'pending_queue' ? (
// Wartet in der Projekt-Queue → tippen entfernt den Eintrag.
<TouchableOpacity
hitSlop={{top:6,bottom:6,left:6,right:6}}
onPress={() => removeQueued(item.projectId || '', item.id)}
accessibilityLabel="Aus Warteschlange entfernen"
>
<Text style={styles.statusQueued}>{'⏸ wartet · ✕'}</Text>
</TouchableOpacity>
) : item.deliveryStatus === 'failed' && item.clientMsgId ? (
<TouchableOpacity <TouchableOpacity
hitSlop={{top:6,bottom:6,left:6,right:6}} hitSlop={{top:6,bottom:6,left:6,right:6}}
onPress={() => retryFailedMessage(item.clientMsgId!)} onPress={() => retryFailedMessage(item.clientMsgId!)}
@@ -2969,6 +3089,18 @@ const ChatScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
)} )}
{/* Rueckfrage-Banner / Queue-Zaehler fuer den fokussierten Kontext */}
{(projectStates[focusedProjectId] === 'awaiting_reply' ||
(projectQueues[focusedProjectId]?.length || 0) > 0) && (
<View style={styles.queueBanner}>
<Text style={styles.queueBannerText}>
{projectStates[focusedProjectId] === 'awaiting_reply'
? '❓ ARIA fragt nach — deine Eingabe beantwortet das'
: `${projectQueues[focusedProjectId]?.length || 0} in der Warteschlange`}
</Text>
</View>
)}
{/* Eingabebereich */} {/* Eingabebereich */}
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
{/* Datei-Buttons */} {/* Datei-Buttons */}
@@ -3466,6 +3598,19 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
marginTop: 4, marginTop: 4,
}, },
queueBanner: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: '#2A2410',
borderTopWidth: 1,
borderTopColor: '#4A3F14',
},
queueBannerText: {
color: '#FFD60A',
fontSize: 13,
fontWeight: '600',
textAlign: 'center',
},
inputContainer: { inputContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-end', alignItems: 'flex-end',
+45 -4
View File
@@ -1164,6 +1164,22 @@ def _claims_live_media_state(text: str) -> bool:
return False 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: def _normalize_for_fast_match(text: str) -> str:
norm = _strip_leading_hint_blocks(text).lower() norm = _strip_leading_hint_blocks(text).lower()
norm = _fold_umlauts(norm) norm = _fold_umlauts(norm)
@@ -1588,7 +1604,8 @@ class Agent:
speak = bool(getattr(self, "_fast_path_speak", False)) speak = bool(getattr(self, "_fast_path_speak", False))
# converse folgt dem Skill (Manifest/Output) — nicht mehr generell False. # converse folgt dem Skill (Manifest/Output) — nicht mehr generell False.
converse = bool(getattr(self, "_fast_path_converse", 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 # 1. User-Turn an die Konversation
self.conversation.add("user", user_message, source=source, self.conversation.add("user", user_message, source=source,
@@ -1606,7 +1623,8 @@ class Agent:
# dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s). # dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
speak = getattr(self, "_local_turn_speak", True) speak = getattr(self, "_local_turn_speak", True)
converse = getattr(self, "_local_turn_converse", 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) # 2. Hot Memory (alle pinned Punkte)
hot = self.store.list_pinned() hot = self.store.list_pinned()
@@ -1653,6 +1671,23 @@ class Agent:
oauth_callback_host=oauth_host, oauth_callback_host=oauth_host,
oauth_callback_port=oauth_port, oauth_callback_port=oauth_port,
oauth_callback_tls=oauth_tls) 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 # Queue-Aware Prompting: wenn nach diesem Turn weitere Nachrichten
# in der Warteschlange liegen, muss ARIA pruefen ob eine spaetere die # in der Warteschlange liegen, muss ARIA pruefen ob eine spaetere die
# aktuelle Aufgabe korrigiert/annuliert (→ Skip statt Doppelarbeit). # 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 — " final_reply = ("Hey, ich bin ARIA. \U0001F60A Bei mir ist alles bereit — "
"sag mir einfach, was du brauchst.") "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 # 7. Assistant-Turn (final reply) in die Conversation
self.conversation.add("assistant", final_reply, self.conversation.add("assistant", final_reply,
project_id=active_project_id) 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", return (final_reply, "claude",
bool(getattr(self, "_claude_turn_speak", True)), 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 ─────────────────────────────────────── # ── Tool-Dispatcher ───────────────────────────────────────
+1 -1
View File
@@ -150,7 +150,7 @@ async def _fire(trigger: dict, agent_factory) -> None:
try: try:
agent = agent_factory() agent = agent_factory()
reply, _, _, _ = agent.chat(prompt, source="trigger") reply, _, _, _, _ = agent.chat(prompt, source="trigger")
events = agent.pop_events() events = agent.pop_events()
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80]) logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]}) 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/ # Soll die App nach der Antwort 30s weiterlauschen (Gespraech)? Einzelaktionen/
# Skills = False (direkt zurueck aufs Wake-Word), Konversation = True. # Skills = False (direkt zurueck aufs Wake-Word), Konversation = True.
converse: bool = 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 # Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der # ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
# UI landet. # UI landet.
@@ -722,7 +726,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken — # Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen. # chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
loop = asyncio.get_running_loop() 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, None,
lambda: a.chat( lambda: a.chat(
body.message, source=body.source, project_id=pid, body.message, source=body.source, project_id=pid,
@@ -747,6 +751,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
answered_by=answered_by, answered_by=answered_by,
speak=speak, speak=speak,
converse=converse, converse=converse,
awaiting_reply=awaiting_reply,
) )
finally: finally:
_project_pending[pid] = [ _project_pending[pid] = [
+9 -1
View File
@@ -1585,6 +1585,10 @@ class ARIABridge:
# Nach dieser Antwort 30s weiterlauschen? Steuert das Gespraechs- # Nach dieser Antwort 30s weiterlauschen? Steuert das Gespraechs-
# Fenster in der App (Skill/Einzelaktion=False, Konversation=True). # Fenster in der App (Skill/Einzelaktion=False, Konversation=True).
"converse": bool(payload.get("converse", True)) if isinstance(payload, dict) else 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), "timestamp": int(asyncio.get_event_loop().time() * 1000),
}) })
@@ -1948,6 +1952,9 @@ class ARIABridge:
# Nach der Antwort 30s weiterlauschen (Gespraech) oder direkt zurueck aufs # Nach der Antwort 30s weiterlauschen (Gespraech) oder direkt zurueck aufs
# Wake-Word? Einzelaktionen/Skills = False. Reicht die App aus. # Wake-Word? Einzelaktionen/Skills = False. Reicht die App aus.
converse = data.get("converse", True) 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) # Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
# damit sie in der UI vor der Reply auftauchen # 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, await self._process_core_response(reply, {"projectId": turn_project_id,
"answeredBy": answered_by, "answeredBy": answered_by,
"speak": speak, "speak": speak,
"converse": converse}) "converse": converse,
"awaiting_reply": awaiting_reply})
except Exception: except Exception:
logger.exception("[brain] _process_core_response Fehler") logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "", project_id=project_id) await self._emit_activity("idle", "", project_id=project_id)