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}