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}