gemini_adk_rs/flow/
timing.rs

1//! Voice timing per stage: how the conversation *sounds* while a step is
2//! active.
3//!
4//! A flow decides what may happen. A [`VoiceTiming`] decides the pacing:
5//!
6//! - how long to wait out the user's silence before asking again;
7//! - when to cue a filler while a tool runs;
8//! - whether the user can talk over the model;
9//! - how long a pause must last before the user's turn is over;
10//! - whether steering context goes out at once or rides the user's next
11//!   message.
12//!
13//! Timings attach to steps on the [`FlowStack`](super::FlowStack). Whenever
14//! the active step changes, the stack publishes the merged timing of the
15//! active steps to [`VOICE_TIMING_KEY`] in state, and the runtime reads it
16//! there:
17//!
18//! | Setting | Applied by |
19//! |---|---|
20//! | `reprompt_after_ms` | the control lane: after that much user silence, it sends the reprompt and emits `LiveEvent::Reprompted` |
21//! | `filler_after_ms` | tool dispatch: a call still running after that long emits `LiveEvent::FillerCue` for the app to play an earcon or line |
22//! | `interruptible: false` | `LiveHandle::send_audio`: while the model speaks, mic audio is replaced by silence, so neither VAD can cut the model off |
23//! | `end_of_speech_ms` | `LiveHandle::send_audio`: the turn-commit end-of-turn hold, under client activity authority. The server's VAD is fixed at setup, so with server authority this has no effect. |
24//! | `context_delivery` | the turn lifecycle: overrides the session's context delivery for this stage |
25//!
26//! Everything here is serializable, so a `ConversationSpec` carries it and a
27//! simulator can inspect it.
28
29use std::time::Duration;
30
31use serde::{Deserialize, Serialize};
32
33use crate::live::steering::ContextDelivery;
34
35/// The state key the active stage's merged timing is published under.
36pub const VOICE_TIMING_KEY: &str = "session:voice_timing";
37
38/// The reprompt sent when a stage sets `reprompt_after_ms` but no text.
39pub const DEFAULT_REPROMPT: &str = "The user has not answered. Briefly repeat or rephrase your last question, and do not add anything new.";
40
41/// Voice pacing for one stage. Every field is optional; an unset field leaves
42/// the session's own behaviour alone.
43#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
44#[serde(default)]
45pub struct VoiceTiming {
46    /// Reprompt once the user has been silent this long (ms) with the floor
47    /// theirs.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub reprompt_after_ms: Option<u64>,
50    /// What to tell the model when reprompting. Defaults to
51    /// [`DEFAULT_REPROMPT`].
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub reprompt: Option<String>,
54    /// Cue a filler when a tool call runs longer than this (ms).
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub filler_after_ms: Option<u64>,
57    /// `Some(false)` holds the floor for the model: the user cannot barge in
58    /// while it speaks (statutory readouts, disclosures).
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub interruptible: Option<bool>,
61    /// How long a pause must last before the user's turn ends (ms), under
62    /// client activity authority.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub end_of_speech_ms: Option<u64>,
65    /// Context delivery while this stage is active.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub context_delivery: Option<ContextDelivery>,
68}
69
70impl VoiceTiming {
71    /// No timing overrides.
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// Reprompt after `silence` with the default reprompt.
77    pub fn reprompt_after(mut self, silence: Duration) -> Self {
78        self.reprompt_after_ms = Some(millis(silence));
79        self
80    }
81
82    /// Reprompt after `silence`, telling the model `text`.
83    pub fn reprompt_with(mut self, silence: Duration, text: impl Into<String>) -> Self {
84        self.reprompt_after_ms = Some(millis(silence));
85        self.reprompt = Some(text.into());
86        self
87    }
88
89    /// Cue a filler once a tool call has run for `after`.
90    pub fn filler_after(mut self, after: Duration) -> Self {
91        self.filler_after_ms = Some(millis(after));
92        self
93    }
94
95    /// The user cannot interrupt the model in this stage.
96    pub fn uninterruptible(mut self) -> Self {
97        self.interruptible = Some(false);
98        self
99    }
100
101    /// The user's turn ends after a pause of `pause`.
102    pub fn end_of_speech(mut self, pause: Duration) -> Self {
103        self.end_of_speech_ms = Some(millis(pause));
104        self
105    }
106
107    /// Deliver steering context this way while the stage is active.
108    pub fn context_delivery(mut self, delivery: ContextDelivery) -> Self {
109        self.context_delivery = Some(delivery);
110        self
111    }
112
113    /// Whether nothing is overridden.
114    pub fn is_empty(&self) -> bool {
115        self == &Self::default()
116    }
117
118    /// Whether the model holds the floor (the user cannot barge in).
119    pub fn holds_floor(&self) -> bool {
120        self.interruptible == Some(false)
121    }
122
123    /// The reprompt text to send.
124    pub fn reprompt_text(&self) -> &str {
125        self.reprompt.as_deref().unwrap_or(DEFAULT_REPROMPT)
126    }
127
128    /// Combine the timing of two steps active at once, taking the more
129    /// cautious setting of each: the shorter reprompt and filler waits, the
130    /// longer end-of-speech pause, and no barge-in if either forbids it.
131    /// Context delivery and reprompt text come from `self` when set.
132    pub fn merge(&self, other: &Self) -> Self {
133        fn min(a: Option<u64>, b: Option<u64>) -> Option<u64> {
134            match (a, b) {
135                (Some(a), Some(b)) => Some(a.min(b)),
136                (a, b) => a.or(b),
137            }
138        }
139        Self {
140            reprompt_after_ms: min(self.reprompt_after_ms, other.reprompt_after_ms),
141            reprompt: self.reprompt.clone().or_else(|| other.reprompt.clone()),
142            filler_after_ms: min(self.filler_after_ms, other.filler_after_ms),
143            interruptible: match (self.interruptible, other.interruptible) {
144                (Some(false), _) | (_, Some(false)) => Some(false),
145                (a, b) => a.or(b),
146            },
147            end_of_speech_ms: self.end_of_speech_ms.max(other.end_of_speech_ms),
148            context_delivery: self.context_delivery.or(other.context_delivery),
149        }
150    }
151}
152
153fn millis(d: Duration) -> u64 {
154    u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn merge_takes_the_cautious_setting() {
163        let a = VoiceTiming::new()
164            .reprompt_after(Duration::from_secs(8))
165            .end_of_speech(Duration::from_millis(400));
166        let b = VoiceTiming::new()
167            .reprompt_after(Duration::from_secs(5))
168            .uninterruptible()
169            .end_of_speech(Duration::from_millis(900));
170        let m = a.merge(&b);
171        assert_eq!(m.reprompt_after_ms, Some(5_000));
172        assert!(m.holds_floor());
173        assert_eq!(m.end_of_speech_ms, Some(900));
174    }
175
176    #[test]
177    fn round_trips_json_and_omits_unset_fields() {
178        let t = VoiceTiming::new()
179            .filler_after(Duration::from_millis(1500))
180            .context_delivery(ContextDelivery::Deferred);
181        let json = serde_json::to_value(&t).unwrap();
182        assert_eq!(
183            json,
184            serde_json::json!({ "filler_after_ms": 1500, "context_delivery": "deferred" })
185        );
186        assert_eq!(serde_json::from_value::<VoiceTiming>(json).unwrap(), t);
187        assert!(VoiceTiming::new().is_empty());
188    }
189}