gemini_adk_rs/live/
steering.rs

1//! Context injection steering — an alternative to instruction replacement.
2//!
3//! Instead of replacing the entire system instruction on every turn/phase,
4//! steering injects model-role context turns via `send_client_content`.
5//! This works *with* the model's conversational intelligence rather than
6//! overriding it.
7
8use serde_json::Value;
9
10use crate::state::State;
11
12/// How the phase machine steers the model's behavior.
13///
14/// Controls two things:
15/// 1. **Phase instruction delivery** — whether the phase instruction is sent as
16///    a system instruction update (`update_instruction`) or as a model-role
17///    context turn (`send_client_content`).
18/// 2. **Per-turn modifier delivery** — whether `with_state`, `when`, and
19///    `with_context` modifiers are baked into the system instruction or
20///    injected as model-role context turns.
21///
22/// # Choosing a mode
23///
24/// | Mode | System instruction | Modifiers | Best for |
25/// |------|--------------------|-----------|----------|
26/// | `InstructionUpdate` | Replaced on every phase transition | Baked into instruction | Agents with radically different personas per phase |
27/// | `ContextInjection` | Set once at connect, never touched | Model-role context turns | Multi-phase apps with stable persona (recommended) |
28/// | `Hybrid` | Replaced on phase transition | Model-role context turns | Persona shifts + lightweight per-turn context |
29///
30/// # Example
31///
32/// ```rust,ignore
33/// use gemini_adk_fluent_rs::prelude::*;
34///
35/// // Recommended: base instruction at connect, phase context via injection
36/// let handle = Live::builder()
37///     .instruction("You are a helpful restaurant reservation assistant.")
38///     .steering_mode(SteeringMode::ContextInjection)
39///     .phase("greeting")
40///         .instruction("Welcome the guest and ask how you can help.")
41///         .done()
42///     .phase("booking")
43///         .instruction("Help the guest find an available time slot.")
44///         .done()
45///     .initial_phase("greeting")
46///     .connect_google_ai(api_key)
47///     .await?;
48/// ```
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum SteeringMode {
51    /// Replace system instruction on phase transition.
52    ///
53    /// The model re-processes its full context on every phase change.
54    /// Gives the clearest persona shift but causes a latency spike as the
55    /// model ingests the new instruction.
56    ///
57    /// Per-turn modifiers (`with_state`, `when`, `with_context`) are baked
58    /// into the system instruction text.
59    #[default]
60    InstructionUpdate,
61
62    /// Inject all steering via `send_client_content` (model-role turns).
63    ///
64    /// The system instruction set at connect time is **never updated**.
65    /// Phase instructions and per-turn modifiers are delivered as model-role
66    /// context turns, working *with* the model's conversational intelligence
67    /// rather than overriding it.
68    ///
69    /// Lighter weight, lower latency, avoids instruction re-processing.
70    /// Best for multi-phase apps where the base persona is stable.
71    ContextInjection,
72
73    /// Hybrid: instruction update on phase transition + context injection per turn.
74    ///
75    /// Phase transitions trigger a system instruction replacement (like
76    /// `InstructionUpdate`), but per-turn modifiers are delivered as
77    /// model-role context turns (like `ContextInjection`).
78    ///
79    /// Use when phases represent genuinely different personas but you also
80    /// want lightweight per-turn steering within each phase.
81    Hybrid,
82}
83
84/// Build steering context from instruction modifiers.
85///
86/// Converts `InstructionModifier`s into conversational text suitable for
87/// injection as a model-role context turn.
88pub fn build_steering_context(
89    state: &State,
90    modifiers: &[super::phase::InstructionModifier],
91) -> Vec<String> {
92    let mut parts = Vec::new();
93    for modifier in modifiers {
94        match modifier {
95            super::phase::InstructionModifier::StateAppend(keys) => {
96                let pairs: Vec<String> = keys
97                    .iter()
98                    .filter_map(|key| {
99                        let display_key = key
100                            .strip_prefix("derived:")
101                            .or_else(|| key.strip_prefix("session:"))
102                            .or_else(|| key.strip_prefix("app:"))
103                            .or_else(|| key.strip_prefix("user:"))
104                            .unwrap_or(key);
105                        state
106                            .get::<Value>(key)
107                            .map(|v| format!("{}={}", display_key, v))
108                    })
109                    .collect();
110                if !pairs.is_empty() {
111                    parts.push(format!("Current context: {}", pairs.join(", ")));
112                }
113            }
114            super::phase::InstructionModifier::Conditional { predicate, text } => {
115                if predicate(state) {
116                    parts.push(text.clone());
117                }
118            }
119            super::phase::InstructionModifier::CustomAppend(f) => {
120                let text = f(state);
121                if !text.is_empty() {
122                    parts.push(text);
123                }
124            }
125        }
126    }
127    parts
128}
129
130/// When to deliver model-role context turns to the wire.
131///
132/// Controls timing of context injection (tool advisory, repair nudge,
133/// steering modifiers, phase instructions, on_enter_context).
134///
135/// | Mode | Behavior | Best for |
136/// |------|----------|----------|
137/// | `Immediate` | Send as single batched frame during TurnComplete | Low-latency apps, text-only |
138/// | `Deferred` | Queue until next user send (audio/text/video) | Voice apps where mid-silence sends cause glitches |
139///
140/// # Example
141///
142/// ```rust,ignore
143/// Live::builder()
144///     .steering_mode(SteeringMode::ContextInjection)
145///     .context_delivery(ContextDelivery::Deferred)  // flush with next user audio
146///     .phase("greeting")
147///         .instruction("Welcome the guest")
148///         .done()
149///     .initial_phase("greeting")
150/// ```
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
152pub enum ContextDelivery {
153    /// Send batched context immediately during TurnComplete processing.
154    ///
155    /// All context turns are accumulated into a single `send_client_content`
156    /// call and sent as one WebSocket frame.  The model receives the context
157    /// as soon as the turn completes, before the next user interaction.
158    #[default]
159    Immediate,
160
161    /// Queue context and flush before the next user send.
162    ///
163    /// Context turns are pushed into a [`PendingContext`](super::context_writer::PendingContext)
164    /// buffer.  The [`DeferredWriter`](super::context_writer::DeferredWriter)
165    /// drains this buffer before forwarding `send_audio`, `send_text`, or
166    /// `send_video` — ensuring context arrives in the same burst as user content.
167    ///
168    /// This eliminates the "extraneous message" problem where isolated context
169    /// frames sent during silence can cause the model to interrupt or produce
170    /// unexpected responses.
171    Deferred,
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::live::phase::InstructionModifier;
178    use std::sync::Arc;
179
180    #[test]
181    fn state_append_builds_context() {
182        let state = State::new();
183        state.set("mood", "calm");
184        state.set("app:score", 0.85f64);
185
186        let modifiers = vec![InstructionModifier::StateAppend(vec![
187            "mood".into(),
188            "app:score".into(),
189        ])];
190
191        let parts = build_steering_context(&state, &modifiers);
192        assert_eq!(parts.len(), 1);
193        assert!(parts[0].contains("mood="));
194        assert!(parts[0].contains("score="));
195    }
196
197    #[test]
198    fn conditional_appends_when_true() {
199        let state = State::new();
200        state.set("urgent", true);
201
202        let modifiers = vec![InstructionModifier::Conditional {
203            predicate: Arc::new(|s: &State| s.get::<bool>("urgent").unwrap_or(false)),
204            text: "Handle with urgency.".into(),
205        }];
206
207        let parts = build_steering_context(&state, &modifiers);
208        assert_eq!(parts, vec!["Handle with urgency."]);
209    }
210
211    #[test]
212    fn conditional_skips_when_false() {
213        let state = State::new();
214
215        let modifiers = vec![InstructionModifier::Conditional {
216            predicate: Arc::new(|s: &State| s.get::<bool>("urgent").unwrap_or(false)),
217            text: "Handle with urgency.".into(),
218        }];
219
220        let parts = build_steering_context(&state, &modifiers);
221        assert!(parts.is_empty());
222    }
223}