gemini_adk_rs/live/
reactor.rs

1//! First-class reactor vocabulary for Live sessions.
2//!
3//! This module is intentionally small: it defines the normalized events,
4//! reactions, and typed effects that existing mechanisms can migrate onto
5//! incrementally without rewriting the current control plane in one step.
6
7use std::sync::Mutex;
8use std::time::{Duration, Instant};
9
10use gemini_genai_rs::prelude::Content;
11
12use crate::state::StateMutation;
13
14use super::ExecutionMode;
15use super::events::LiveEvent;
16
17/// A normalized event that can drive ADK-level reactions.
18#[derive(Debug, Clone)]
19pub enum ReactorEvent {
20    /// Existing semantic Live event.
21    Live(LiveEvent),
22    /// State changed since the last reactor cursor.
23    StateChanged(Vec<StateMutation>),
24    /// Periodic tick for timers and sustained conditions.
25    TimerTick {
26        /// Time observed by the control lane for this tick.
27        now: Instant,
28    },
29    /// Client-side playback drained after model audio was generated.
30    PlaybackDrained {
31        /// Whether the control plane has armed a deferred model prompt.
32        prompt_pending: bool,
33    },
34    /// Client detected that the user started speaking.
35    UserSpeechStarted,
36    /// Client detected that the user stopped speaking.
37    UserSpeechEnded {
38        /// Whether the control plane has armed a deferred model prompt.
39        prompt_pending: bool,
40    },
41    /// User speech ended, but the model may or may not produce a turn.
42    SoftTurnComplete,
43}
44
45/// Voice-flow state owned by the reactor.
46#[derive(Debug, Clone, Default)]
47pub struct VoiceRuntimeState {
48    /// Whether the client believes the user is currently speaking.
49    pub user_speaking: bool,
50    /// Whether browser playback is believed to be active.
51    pub playback_active: bool,
52    /// Whether a deferred model prompt is armed.
53    pub prompt_pending: bool,
54    /// Monotonic epoch bumped whenever a prompt is cancelled or armed state changes.
55    pub prompt_epoch: u64,
56    /// Last time the client reported barge-in/user speech start.
57    pub last_barge_in_at: Option<Instant>,
58    /// Last time browser playback reported drained.
59    pub last_playback_drained_at: Option<Instant>,
60}
61
62impl VoiceRuntimeState {
63    /// Apply an incoming event to the voice runtime state before rules run.
64    pub fn apply_event(&mut self, event: &ReactorEvent) {
65        self.apply_event_at(event, Instant::now());
66    }
67
68    /// [`apply_event`](Self::apply_event) with an explicit `now`, for callers
69    /// that read time from a [`Clock`](crate::clock::Clock).
70    pub fn apply_event_at(&mut self, event: &ReactorEvent, now: Instant) {
71        match event {
72            ReactorEvent::PlaybackDrained { prompt_pending } => {
73                self.playback_active = false;
74                self.prompt_pending = *prompt_pending;
75                self.last_playback_drained_at = Some(now);
76            }
77            ReactorEvent::UserSpeechStarted => {
78                self.user_speaking = true;
79                self.playback_active = false;
80                if self.prompt_pending {
81                    self.prompt_epoch = self.prompt_epoch.saturating_add(1);
82                }
83                self.prompt_pending = false;
84                self.last_barge_in_at = Some(now);
85            }
86            ReactorEvent::UserSpeechEnded { prompt_pending } => {
87                self.user_speaking = false;
88                self.prompt_pending = *prompt_pending && !self.playback_active;
89            }
90            _ => {}
91        }
92    }
93}
94
95/// Execution policy for an effect.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct EffectPolicy {
98    /// Whether this effect blocks later effects from running.
99    pub mode: ExecutionMode,
100    /// Optional maximum time budget for the effect.
101    pub timeout: Option<Duration>,
102}
103
104impl Default for EffectPolicy {
105    fn default() -> Self {
106        Self {
107            mode: ExecutionMode::Blocking,
108            timeout: None,
109        }
110    }
111}
112
113/// A typed runtime effect emitted by a reaction.
114#[derive(Debug, Clone)]
115pub enum LiveEffect {
116    /// No operation; useful for conditional reaction builders.
117    Noop,
118    /// Add state/context turns to the session.
119    SendContext(Vec<Content>),
120    /// Ask the model to generate from accumulated context.
121    PromptModel,
122    /// Cancel a deferred model prompt while leaving queued context intact.
123    CancelDeferredPrompt,
124    /// Tell the Live API that user speech activity started.
125    SignalUserActivityStart,
126    /// Tell the Live API that user speech activity ended.
127    SignalUserActivityEnd,
128    /// Replace or amend the active instruction.
129    UpdateInstruction(String),
130    /// Emit a semantic event for observers.
131    Emit(LiveEvent),
132}
133
134/// A policy-wrapped effect.
135#[derive(Debug, Clone)]
136pub struct Reaction {
137    /// Rule or subsystem that produced the reaction.
138    pub source: &'static str,
139    /// Runtime effect requested by the rule.
140    pub effect: LiveEffect,
141    /// Execution policy for the effect.
142    pub policy: EffectPolicy,
143}
144
145impl Reaction {
146    /// Create a blocking reaction.
147    pub fn blocking(source: &'static str, effect: LiveEffect) -> Self {
148        Self {
149            source,
150            effect,
151            policy: EffectPolicy::default(),
152        }
153    }
154
155    /// Create a concurrent reaction.
156    pub fn concurrent(source: &'static str, effect: LiveEffect) -> Self {
157        Self {
158            source,
159            effect,
160            policy: EffectPolicy {
161                mode: ExecutionMode::Concurrent,
162                ..EffectPolicy::default()
163            },
164        }
165    }
166}
167
168/// A rule that reacts to normalized events and emits typed effects.
169pub trait ReactorRule: Send + Sync {
170    /// Stable rule name for diagnostics and reaction provenance.
171    fn name(&self) -> &str;
172    /// Produce reactions for a normalized event.
173    fn react(&self, event: &ReactorEvent, voice: &VoiceRuntimeState) -> Vec<Reaction>;
174}
175
176/// Ordered collection of reactor rules.
177pub struct LiveReactor {
178    rules: Vec<Box<dyn ReactorRule>>,
179    voice: Mutex<VoiceRuntimeState>,
180    clock: crate::clock::SharedClock,
181}
182
183impl Default for LiveReactor {
184    fn default() -> Self {
185        Self {
186            rules: Vec::new(),
187            voice: Mutex::new(VoiceRuntimeState::default()),
188            clock: crate::clock::system_clock(),
189        }
190    }
191}
192
193impl LiveReactor {
194    /// Create an empty reactor.
195    pub fn new() -> Self {
196        Self::default()
197    }
198
199    /// Create a reactor with the default voice-flow rules.
200    pub fn voice_defaults() -> Self {
201        let mut reactor = Self::new();
202        reactor.add_rule(PromptOnPlaybackDrained);
203        reactor.add_rule(UserSpeechActivityRule);
204        reactor
205    }
206
207    /// Timestamp voice events with `clock` instead of the system clock.
208    pub fn with_clock(mut self, clock: crate::clock::SharedClock) -> Self {
209        self.clock = clock;
210        self
211    }
212
213    /// Add a rule to the end of the ordered rule list.
214    pub fn add_rule(&mut self, rule: impl ReactorRule + 'static) {
215        self.rules.push(Box::new(rule));
216    }
217
218    /// Run all rules against an event and collect reactions in rule order.
219    pub fn react(&self, event: &ReactorEvent) -> Vec<Reaction> {
220        let voice = {
221            let mut voice = self.voice.lock().expect("voice reactor state poisoned");
222            voice.apply_event_at(event, self.clock.now());
223            voice.clone()
224        };
225
226        self.rules
227            .iter()
228            .flat_map(|rule| rule.react(event, &voice))
229            .collect()
230    }
231
232    /// Return a snapshot of the current voice runtime state.
233    pub fn voice_state(&self) -> VoiceRuntimeState {
234        self.voice
235            .lock()
236            .expect("voice reactor state poisoned")
237            .clone()
238    }
239}
240
241/// Prompt the model when browser playback is fully drained and a prompt is armed.
242pub struct PromptOnPlaybackDrained;
243
244impl ReactorRule for PromptOnPlaybackDrained {
245    fn name(&self) -> &str {
246        "prompt_on_playback_drained"
247    }
248
249    fn react(&self, event: &ReactorEvent, voice: &VoiceRuntimeState) -> Vec<Reaction> {
250        if matches!(event, ReactorEvent::PlaybackDrained { .. })
251            && voice.prompt_pending
252            && !voice.user_speaking
253            && !voice.playback_active
254        {
255            vec![Reaction::blocking(
256                "prompt_on_playback_drained",
257                LiveEffect::PromptModel,
258            )]
259        } else {
260            Vec::new()
261        }
262    }
263}
264
265/// Cancel pending model prompts and signal activity around user speech.
266pub struct UserSpeechActivityRule;
267
268impl ReactorRule for UserSpeechActivityRule {
269    fn name(&self) -> &str {
270        "user_speech_activity"
271    }
272
273    fn react(&self, event: &ReactorEvent, voice: &VoiceRuntimeState) -> Vec<Reaction> {
274        match event {
275            ReactorEvent::UserSpeechStarted => vec![
276                Reaction::blocking("user_speech_activity", LiveEffect::CancelDeferredPrompt),
277                Reaction::blocking("user_speech_activity", LiveEffect::SignalUserActivityStart),
278            ],
279            ReactorEvent::UserSpeechEnded { .. } => {
280                let mut reactions = vec![Reaction::blocking(
281                    "user_speech_activity",
282                    LiveEffect::SignalUserActivityEnd,
283                )];
284                if voice.prompt_pending && !voice.playback_active {
285                    reactions.push(Reaction::blocking(
286                        "user_speech_activity",
287                        LiveEffect::PromptModel,
288                    ));
289                }
290                reactions
291            }
292            _ => Vec::new(),
293        }
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn reactor_collects_reactions_in_rule_order() {
303        let mut reactor = LiveReactor::new();
304        reactor.add_rule(PromptOnPlaybackDrained);
305
306        let reactions = reactor.react(&ReactorEvent::PlaybackDrained {
307            prompt_pending: true,
308        });
309        assert_eq!(reactions.len(), 1);
310        assert_eq!(reactions[0].source, "prompt_on_playback_drained");
311        assert_eq!(reactions[0].policy.mode, ExecutionMode::Blocking);
312        assert!(matches!(reactions[0].effect, LiveEffect::PromptModel));
313    }
314
315    #[test]
316    fn playback_drained_without_pending_prompt_is_noop() {
317        let reactor = LiveReactor::voice_defaults();
318
319        let reactions = reactor.react(&ReactorEvent::PlaybackDrained {
320            prompt_pending: false,
321        });
322
323        assert!(reactions.is_empty());
324    }
325
326    #[test]
327    fn user_speech_started_cancels_prompt_and_signals_activity() {
328        let reactor = LiveReactor::voice_defaults();
329
330        let prompt_reactions = reactor.react(&ReactorEvent::PlaybackDrained {
331            prompt_pending: true,
332        });
333        assert_eq!(prompt_reactions.len(), 1);
334
335        let reactions = reactor.react(&ReactorEvent::UserSpeechStarted);
336
337        assert_eq!(reactions.len(), 2);
338        assert!(matches!(
339            reactions[0].effect,
340            LiveEffect::CancelDeferredPrompt
341        ));
342        assert!(matches!(
343            reactions[1].effect,
344            LiveEffect::SignalUserActivityStart
345        ));
346        let voice = reactor.voice_state();
347        assert!(voice.user_speaking);
348        assert!(!voice.prompt_pending);
349        assert_eq!(voice.prompt_epoch, 1);
350    }
351
352    #[test]
353    fn user_speech_ended_signals_activity_end() {
354        let reactor = LiveReactor::voice_defaults();
355
356        let reactions = reactor.react(&ReactorEvent::UserSpeechEnded {
357            prompt_pending: false,
358        });
359
360        assert_eq!(reactions.len(), 1);
361        assert!(matches!(
362            reactions[0].effect,
363            LiveEffect::SignalUserActivityEnd
364        ));
365        assert!(!reactor.voice_state().user_speaking);
366    }
367
368    #[test]
369    fn speech_end_prompts_when_playback_already_drained_and_prompt_pending() {
370        let reactor = LiveReactor::voice_defaults();
371
372        reactor.react(&ReactorEvent::UserSpeechStarted);
373        let drain_reactions = reactor.react(&ReactorEvent::PlaybackDrained {
374            prompt_pending: true,
375        });
376        assert!(drain_reactions.is_empty());
377
378        let reactions = reactor.react(&ReactorEvent::UserSpeechEnded {
379            prompt_pending: true,
380        });
381
382        assert_eq!(reactions.len(), 2);
383        assert!(matches!(
384            reactions[0].effect,
385            LiveEffect::SignalUserActivityEnd
386        ));
387        assert!(matches!(reactions[1].effect, LiveEffect::PromptModel));
388    }
389
390    #[test]
391    fn playback_drained_does_not_prompt_while_user_is_speaking() {
392        let reactor = LiveReactor::voice_defaults();
393
394        reactor.react(&ReactorEvent::UserSpeechStarted);
395        let reactions = reactor.react(&ReactorEvent::PlaybackDrained {
396            prompt_pending: true,
397        });
398
399        assert!(reactions.is_empty());
400        let voice = reactor.voice_state();
401        assert!(voice.user_speaking);
402        assert!(voice.prompt_pending);
403    }
404
405    #[test]
406    fn speech_start_wins_after_pending_prompt_snapshot() {
407        let reactor = LiveReactor::voice_defaults();
408
409        reactor.react(&ReactorEvent::PlaybackDrained {
410            prompt_pending: true,
411        });
412        reactor.react(&ReactorEvent::UserSpeechStarted);
413        let reactions = reactor.react(&ReactorEvent::PlaybackDrained {
414            prompt_pending: false,
415        });
416
417        assert!(reactions.is_empty());
418        let voice = reactor.voice_state();
419        assert!(voice.user_speaking);
420        assert!(!voice.prompt_pending);
421        assert_eq!(voice.prompt_epoch, 1);
422    }
423}