Skip to main content

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/// | `ContextUpdate` | Base + phase instruction, replaced on phase transition; tools re-declared per phase and step | Model-role context turns | Gemini 3.8 Live sessions whose phases or flow steps use different tools |
30///
31/// # Example
32///
33/// ```rust,ignore
34/// use gemini_adk_fluent_rs::prelude::*;
35///
36/// // Recommended: base instruction at connect, phase context via injection
37/// let handle = Live::builder()
38///     .instruction("You are a helpful restaurant reservation assistant.")
39///     .steering_mode(SteeringMode::ContextInjection)
40///     .phase("greeting")
41///         .instruction("Welcome the guest and ask how you can help.")
42///         .done()
43///     .phase("booking")
44///         .instruction("Help the guest find an available time slot.")
45///         .done()
46///     .initial_phase("greeting")
47///     .connect_google_ai(api_key)
48///     .await?;
49/// ```
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum SteeringMode {
52    /// Replace system instruction on phase transition.
53    ///
54    /// The model re-processes its full context on every phase change.
55    /// Gives the clearest persona shift but causes a latency spike as the
56    /// model ingests the new instruction.
57    ///
58    /// Per-turn modifiers (`with_state`, `when`, `with_context`) are baked
59    /// into the system instruction text.
60    #[default]
61    InstructionUpdate,
62
63    /// Inject all steering via `send_client_content` (model-role turns).
64    ///
65    /// The system instruction set at connect time is **never updated**.
66    /// Phase instructions and per-turn modifiers are delivered as model-role
67    /// context turns, working *with* the model's conversational intelligence
68    /// rather than overriding it.
69    ///
70    /// Lighter weight, lower latency, avoids instruction re-processing.
71    /// Best for multi-phase apps where the base persona is stable.
72    ContextInjection,
73
74    /// Hybrid: instruction update on phase transition + context injection per turn.
75    ///
76    /// Phase transitions trigger a system instruction replacement (like
77    /// `InstructionUpdate`), but per-turn modifiers are delivered as
78    /// model-role context turns (like `ContextInjection`).
79    ///
80    /// Use when phases represent genuinely different personas but you also
81    /// want lightweight per-turn steering within each phase.
82    Hybrid,
83
84    /// Replace the declared tools and the system instruction with a
85    /// `contextUpdate` message at each phase transition and flow-step change
86    /// (Gemini 3.8 Live).
87    ///
88    /// The model is offered only the tools the current phase lists
89    /// ([`Phase::tools_enabled`](crate::live::phase::Phase::tools_enabled))
90    /// and an enforcing flow admits, from the setup message on. Under the other
91    /// modes those lists are enforced by refusing calls, while the model is
92    /// offered every tool. When a step changes on a tool result, the new
93    /// declarations are sent before that tool response.
94    ///
95    /// The system instruction becomes the connect-time instruction followed by
96    /// the current phase's instruction: unlike an instruction update, a
97    /// `contextUpdate` replaces the instruction outright, so the base is kept
98    /// explicitly. An `instruction_template` still replaces the whole
99    /// instruction. Per-turn modifiers are delivered as model-role context
100    /// turns, as in `Hybrid`: each `contextUpdate` invalidates the server's
101    /// prefix cache, so it is sent only when the instruction or the tool set
102    /// changes.
103    ///
104    /// On a model without `contextUpdate` (Gemini 2.5 Live), the session
105    /// logs a warning at connect and steers as `Hybrid`.
106    ContextUpdate,
107}
108
109/// Build steering context from instruction modifiers.
110///
111/// Converts `InstructionModifier`s into conversational text suitable for
112/// injection as a model-role context turn.
113pub fn build_steering_context(
114    state: &State,
115    modifiers: &[super::phase::InstructionModifier],
116) -> Vec<String> {
117    let mut parts = Vec::new();
118    for modifier in modifiers {
119        match modifier {
120            super::phase::InstructionModifier::StateAppend(keys) => {
121                let pairs: Vec<String> = keys
122                    .iter()
123                    .filter_map(|key| {
124                        let display_key = key
125                            .strip_prefix("derived:")
126                            .or_else(|| key.strip_prefix("session:"))
127                            .or_else(|| key.strip_prefix("app:"))
128                            .or_else(|| key.strip_prefix("user:"))
129                            .unwrap_or(key);
130                        state
131                            .get::<Value>(key)
132                            .map(|v| format!("{display_key}={v}"))
133                    })
134                    .collect();
135                if !pairs.is_empty() {
136                    parts.push(format!("Current context: {}", pairs.join(", ")));
137                }
138            }
139            super::phase::InstructionModifier::Conditional { predicate, text } => {
140                if predicate(state) {
141                    parts.push(text.clone());
142                }
143            }
144            super::phase::InstructionModifier::CustomAppend(f) => {
145                let text = f(state);
146                if !text.is_empty() {
147                    parts.push(text);
148                }
149            }
150        }
151    }
152    parts
153}
154
155/// When to deliver model-role context turns to the wire.
156///
157/// Controls timing of context injection (tool advisory, repair nudge,
158/// steering modifiers, phase instructions, on_enter_context).
159///
160/// | Mode | Behavior | Best for |
161/// |------|----------|----------|
162/// | `Immediate` | Send as single batched frame during TurnComplete | Low-latency apps, text-only |
163/// | `Deferred` | Queue until next user send (audio/text/video) | Voice apps where mid-silence sends cause glitches |
164///
165/// # Example
166///
167/// ```rust,ignore
168/// Live::builder()
169///     .steering_mode(SteeringMode::ContextInjection)
170///     .context_delivery(ContextDelivery::Deferred)  // flush with next user audio
171///     .phase("greeting")
172///         .instruction("Welcome the guest")
173///         .done()
174///     .initial_phase("greeting")
175/// ```
176#[derive(
177    Debug,
178    Clone,
179    Copy,
180    PartialEq,
181    Eq,
182    Default,
183    serde::Serialize,
184    serde::Deserialize,
185    schemars::JsonSchema,
186)]
187#[serde(rename_all = "snake_case")]
188pub enum ContextDelivery {
189    /// Send batched context immediately during TurnComplete processing.
190    ///
191    /// All context turns are accumulated into a single `send_client_content`
192    /// call and sent as one WebSocket frame.  The model receives the context
193    /// as soon as the turn completes, before the next user interaction.
194    #[default]
195    Immediate,
196
197    /// Queue context and flush before the next user send.
198    ///
199    /// Context turns are pushed into a [`PendingContext`](super::context_writer::PendingContext)
200    /// buffer.  The [`DeferredWriter`](super::context_writer::DeferredWriter)
201    /// drains this buffer before forwarding `send_audio`, `send_text`, or
202    /// `send_video` — ensuring context arrives in the same burst as user content.
203    ///
204    /// This eliminates the "extraneous message" problem where isolated context
205    /// frames sent during silence can cause the model to interrupt or produce
206    /// unexpected responses.
207    Deferred,
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::live::phase::InstructionModifier;
214    use std::sync::Arc;
215
216    #[test]
217    fn state_append_builds_context() {
218        let state = State::new();
219        let _ = state.set("mood", "calm");
220        let _ = state.set("app:score", 0.85f64);
221
222        let modifiers = vec![InstructionModifier::StateAppend(vec![
223            "mood".into(),
224            "app:score".into(),
225        ])];
226
227        let parts = build_steering_context(&state, &modifiers);
228        assert_eq!(parts.len(), 1);
229        assert!(parts[0].contains("mood="));
230        assert!(parts[0].contains("score="));
231    }
232
233    #[test]
234    fn conditional_appends_when_true() {
235        let state = State::new();
236        let _ = state.set("urgent", true);
237
238        let modifiers = vec![InstructionModifier::Conditional {
239            predicate: Arc::new(|s: &State| s.get::<bool>("urgent").unwrap_or(false)),
240            text: "Handle with urgency.".into(),
241        }];
242
243        let parts = build_steering_context(&state, &modifiers);
244        assert_eq!(parts, vec!["Handle with urgency."]);
245    }
246
247    #[test]
248    fn conditional_skips_when_false() {
249        let state = State::new();
250
251        let modifiers = vec![InstructionModifier::Conditional {
252            predicate: Arc::new(|s: &State| s.get::<bool>("urgent").unwrap_or(false)),
253            text: "Handle with urgency.".into(),
254        }];
255
256        let parts = build_steering_context(&state, &modifiers);
257        assert!(parts.is_empty());
258    }
259}