gemini_adk_rs/live/
builder.rs

1//! LiveSessionBuilder — combines SessionConfig + callbacks + tools into one setup.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use tokio_util::sync::CancellationToken;
7
8use gemini_genai_rs::prelude::{ConnectBuilder, SessionConfig, SessionEvent, SessionPhase};
9use gemini_genai_rs::session::{SessionError, SessionHandle, SessionWriter};
10use gemini_genai_rs::transport::{Transport, TransportConfig};
11
12use crate::error::AgentError;
13use crate::state::State;
14use crate::tool::ToolDispatcher;
15
16use super::background_tool::{BackgroundToolTracker, ToolExecutionMode};
17use super::callbacks::EventCallbacks;
18use super::computed::ComputedRegistry;
19use super::context_writer::{DeferredWriter, PendingContext};
20use super::extractor::TurnExtractor;
21use super::handle::LiveHandle;
22use super::needs::{NeedsFulfillment, RepairConfig};
23use super::persistence::SessionPersistence;
24use super::phase::PhaseMachine;
25use super::processor::{ControlPlaneConfig, spawn_event_processor, spawn_telemetry_lane};
26use super::session_signals::SessionSignals;
27use super::soft_turn::SoftTurnDetector;
28use super::steering::{ContextDelivery, SteeringMode};
29use super::telemetry::SessionTelemetry;
30use super::temporal::TemporalRegistry;
31use super::watcher::WatcherRegistry;
32
33/// Builder for a callback-driven Live session.
34///
35/// Combines [`SessionConfig`], [`EventCallbacks`], tool dispatching, extractors,
36/// computed state, phase machines, watchers, and temporal patterns into a
37/// single connection setup. Call [`connect()`](Self::connect) to establish
38/// the WebSocket connection and start the three-lane event processor.
39///
40/// For ergonomic usage, prefer the L2 `Live` builder from `gemini-adk-fluent-rs`
41/// which wraps this with a fluent API.
42pub struct LiveSessionBuilder {
43    config: SessionConfig,
44    callbacks: EventCallbacks,
45    dispatcher: Option<Arc<ToolDispatcher>>,
46    extractors: Vec<Arc<dyn TurnExtractor>>,
47    computed: Option<ComputedRegistry>,
48    phase_machine: Option<PhaseMachine>,
49    watchers: Option<WatcherRegistry>,
50    temporal: Option<TemporalRegistry>,
51    greeting: Option<String>,
52    state: Option<State>,
53    execution_modes: HashMap<String, ToolExecutionMode>,
54    // Control plane configuration
55    soft_turn_timeout: Option<std::time::Duration>,
56    steering_mode: SteeringMode,
57    context_delivery: ContextDelivery,
58    delivery: super::processor::DeliveryConfig,
59    repair_config: Option<RepairConfig>,
60    persistence: Option<Arc<dyn SessionPersistence>>,
61    session_id: Option<String>,
62    tool_advisory: bool,
63    telemetry_interval: Option<std::time::Duration>,
64    middleware: Vec<Arc<dyn crate::middleware::Middleware>>,
65    flow: Option<crate::flow::FlowStack>,
66    redactor: Option<Arc<super::redaction::TranscriptRedactor>>,
67    clock: Option<crate::clock::SharedClock>,
68    lockstep: Option<Arc<super::processor::Lockstep>>,
69}
70
71impl LiveSessionBuilder {
72    /// Create a new builder with the given session config.
73    pub fn new(config: SessionConfig) -> Self {
74        Self {
75            config,
76            callbacks: EventCallbacks::default(),
77            dispatcher: None,
78            extractors: Vec::new(),
79            computed: None,
80            phase_machine: None,
81            watchers: None,
82            temporal: None,
83            greeting: None,
84            state: None,
85            execution_modes: HashMap::new(),
86            soft_turn_timeout: None,
87            steering_mode: SteeringMode::default(),
88            context_delivery: ContextDelivery::default(),
89            delivery: super::processor::DeliveryConfig::default(),
90            repair_config: None,
91            persistence: None,
92            session_id: None,
93            tool_advisory: true,
94            telemetry_interval: None,
95            middleware: Vec::new(),
96            flow: None,
97            redactor: None,
98            clock: None,
99            lockstep: None,
100        }
101    }
102
103    /// Replay lockstep: the router waits for both lanes to handle each event
104    /// and counts it in `lockstep`. Offline replay only.
105    pub(crate) fn lockstep(mut self, lockstep: Arc<super::processor::Lockstep>) -> Self {
106        self.lockstep = Some(lockstep);
107        self
108    }
109
110    /// Install transcript redaction — see
111    /// [`redaction::TranscriptRedactor`](super::redaction::TranscriptRedactor).
112    ///
113    /// Applied at the event router, before callbacks, the transcript buffer,
114    /// extraction, or persistence see the text. An inactive redactor (no
115    /// rules enabled) is dropped rather than installed.
116    pub fn redaction(mut self, redactor: super::redaction::TranscriptRedactor) -> Self {
117        self.redactor = redactor.is_active().then(|| Arc::new(redactor));
118        self
119    }
120
121    /// Read the time from `clock` instead of the system clock.
122    ///
123    /// Installed on the session [`State`] (see
124    /// [`State::set_clock`](crate::state::State::set_clock)), so temporal
125    /// patterns, phase durations, resolver cache expiry, the `session:`
126    /// timing signals and journal timestamps all follow it. Pass a
127    /// [`ManualClock`](crate::clock::ManualClock) to make timing decisions
128    /// reproducible in tests; replay installs one driven by the recording.
129    pub fn clock(mut self, clock: crate::clock::SharedClock) -> Self {
130        self.clock = Some(clock);
131        self
132    }
133
134    /// Add a middleware layer.
135    ///
136    /// Layers run around tool dispatch in the control lane: `before_tool`
137    /// (a returned error vetoes the call), `after_tool`, and `on_tool_error`.
138    /// Multiple calls accumulate in order.
139    pub fn middleware(mut self, layer: Arc<dyn crate::middleware::Middleware>) -> Self {
140        self.middleware.push(layer);
141        self
142    }
143
144    /// Attach a governed-flow monitor (built from a `Flow` + `Mode`). The
145    /// monitor becomes the main layer of a [`FlowStack`](crate::flow::FlowStack)
146    /// with no digressions.
147    pub fn flow_monitor(self, monitor: crate::flow::FlowMonitor) -> Self {
148        self.flow_stack(monitor.into_stack())
149    }
150
151    /// Attach a governed-flow stack: the main flow plus its digressions and
152    /// repair policies. This is the one governance object the control plane
153    /// drives; `flow_monitor` is the no-digression special case.
154    pub fn flow_stack(mut self, stack: crate::flow::FlowStack) -> Self {
155        self.flow = Some(stack);
156        self
157    }
158
159    /// Provide a pre-created State to use for this session.
160    ///
161    /// If not set, a new State is created at connect time. Use this when
162    /// the State needs to be shared with tools or other components before
163    /// the session connects.
164    pub fn state(mut self, state: State) -> Self {
165        self.state = Some(state);
166        self
167    }
168
169    /// Set a greeting prompt sent on connect to trigger the model to speak first.
170    pub fn greeting(mut self, prompt: impl Into<String>) -> Self {
171        self.greeting = Some(prompt.into());
172        self
173    }
174
175    /// Set the tool dispatcher for auto-dispatch of tool calls.
176    pub fn dispatcher(mut self, dispatcher: ToolDispatcher) -> Self {
177        // Add tool declarations to session config
178        for tool in dispatcher.to_tool_declarations() {
179            self.config = self.config.add_tool(tool);
180        }
181        self.dispatcher = Some(Arc::new(dispatcher));
182        self
183    }
184
185    /// Set the event callbacks.
186    pub fn callbacks(mut self, callbacks: EventCallbacks) -> Self {
187        self.callbacks = callbacks;
188        self
189    }
190
191    /// Add a turn extractor that runs between turns.
192    pub fn extractor(mut self, extractor: Arc<dyn TurnExtractor>) -> Self {
193        self.extractors.push(extractor);
194        self
195    }
196
197    /// Set the computed variable registry for derived state.
198    pub fn computed(mut self, registry: ComputedRegistry) -> Self {
199        self.computed = Some(registry);
200        self
201    }
202
203    /// Set the phase machine for declarative conversation phase management.
204    pub fn phase_machine(mut self, machine: PhaseMachine) -> Self {
205        self.phase_machine = Some(machine);
206        self
207    }
208
209    /// Set the watcher registry for state change watchers.
210    pub fn watchers(mut self, registry: WatcherRegistry) -> Self {
211        self.watchers = Some(registry);
212        self
213    }
214
215    /// Set the temporal pattern registry.
216    pub fn temporal(mut self, registry: TemporalRegistry) -> Self {
217        self.temporal = Some(registry);
218        self
219    }
220
221    /// Set the execution mode for a named tool.
222    ///
223    /// Tools default to [`ToolExecutionMode::Standard`]. Set to
224    /// [`ToolExecutionMode::Background`] for zero-dead-air execution.
225    pub fn tool_execution_mode(
226        mut self,
227        tool_name: impl Into<String>,
228        mode: ToolExecutionMode,
229    ) -> Self {
230        self.execution_modes.insert(tool_name.into(), mode);
231        self
232    }
233
234    /// Enable soft turn detection for proactive silence awareness.
235    ///
236    /// When `proactiveAudio` is enabled, the model may choose not to respond.
237    /// This sets a timeout after VAD end — if the model stays silent, a
238    /// lightweight "soft turn" fires to keep state updated without forcing
239    /// the model to speak.
240    pub fn soft_turn_timeout(mut self, timeout: std::time::Duration) -> Self {
241        self.soft_turn_timeout = Some(timeout);
242        self
243    }
244
245    /// Set the steering mode for how the phase machine delivers instructions.
246    pub fn steering_mode(mut self, mode: SteeringMode) -> Self {
247        self.steering_mode = mode;
248        self
249    }
250
251    /// Set the context delivery timing.
252    ///
253    /// - `Immediate` (default): send batched context during TurnComplete.
254    /// - `Deferred`: queue context and flush with next user send.
255    pub fn context_delivery(mut self, mode: ContextDelivery) -> Self {
256        self.context_delivery = mode;
257        self
258    }
259
260    /// Set the fast-lane delivery (backpressure) policy per event class.
261    ///
262    /// Defaults to all-[`Lossless`](super::processor::Delivery::Lossless), which
263    /// preserves the historical `send().await` routing behavior. Opt classes
264    /// into [`LossyDropNewest`](super::processor::Delivery::LossyDropNewest) to
265    /// keep the router from stalling when a fast-lane consumer falls behind.
266    pub fn delivery(mut self, delivery: super::processor::DeliveryConfig) -> Self {
267        self.delivery = delivery;
268        self
269    }
270
271    /// Enable the conversation repair protocol.
272    ///
273    /// Tracks need fulfillment per phase and nudges the model when the
274    /// conversation stalls on gathering required information.
275    pub fn repair(mut self, config: RepairConfig) -> Self {
276        self.repair_config = Some(config);
277        self
278    }
279
280    /// Set a session persistence backend for surviving process restarts.
281    pub fn persistence(mut self, backend: Arc<dyn SessionPersistence>) -> Self {
282        self.persistence = Some(backend);
283        self
284    }
285
286    /// Set the session ID for persistence.
287    pub fn session_id(mut self, id: impl Into<String>) -> Self {
288        self.session_id = Some(id.into());
289        self
290    }
291
292    /// Enable or disable tool availability advisory on phase transitions.
293    pub fn tool_advisory(mut self, enabled: bool) -> Self {
294        self.tool_advisory = enabled;
295        self
296    }
297
298    /// Set the periodic telemetry emission interval.
299    ///
300    /// When set, the processor periodically emits `LiveEvent::Telemetry`
301    /// and `LiveEvent::TurnMetrics` to the event stream.
302    pub fn telemetry_interval(mut self, interval: std::time::Duration) -> Self {
303        self.telemetry_interval = Some(interval);
304        self
305    }
306
307    /// Connect to Gemini and start the three-lane event processor.
308    ///
309    /// This is a thin orchestrator over three explicit, behavior-preserving
310    /// stages:
311    ///
312    /// 1. `into_plan` — pure derivation/validation of the resolved startup
313    ///    configuration (`SessionPlan`); no I/O, no spawning.
314    /// 2. Connect the L0 transport using the plan's resolved [`SessionConfig`].
315    /// 3. `build_runtime` — assemble the runtime wiring (channels, shared
316    ///    state, dispatcher, control plane) from the plan + connected session.
317    /// 4. `spawn_lanes` — spawn the telemetry/event/tool lanes and return the
318    ///    assembled [`LiveHandle`].
319    pub async fn connect(self) -> Result<LiveHandle, AgentError> {
320        let mut plan = self.into_plan()?;
321
322        // Connect via L0 using the resolved config (taken out of the plan so
323        // the rest of the plan can be moved into the runtime stage).
324        let config = plan.config.take().expect("plan always carries a config");
325        let session = ConnectBuilder::new(config)
326            .connect()
327            .await
328            .map_err(AgentError::Session)?;
329        finish_connect(plan, session).await
330    }
331
332    /// [`connect`](Self::connect) over `transport` instead of a WebSocket to
333    /// Gemini: the whole runtime (phases, tools, extractors, watchers, flow
334    /// governance) runs as it would live, against whatever the transport
335    /// answers.
336    ///
337    /// Use it to test a session without a network or a credential, over a
338    /// [`ReplayTransport`](gemini_genai_rs::transport::ReplayTransport)
339    /// scripted with server frames. The transport is not reconnected if it
340    /// closes.
341    pub async fn connect_with_transport<T: Transport>(
342        self,
343        transport: T,
344    ) -> Result<LiveHandle, AgentError> {
345        let mut plan = self.into_plan()?;
346        let config = plan.config.take().expect("plan always carries a config");
347        let session = ConnectBuilder::new(config)
348            .transport_config(TransportConfig {
349                max_reconnect_attempts: 0,
350                ..TransportConfig::default()
351            })
352            .transport(transport)
353            .connect()
354            .await
355            .map_err(AgentError::Session)?;
356        finish_connect(plan, session).await
357    }
358}
359
360/// Wait for a freshly connected session to become active, then build and
361/// start its runtime.
362async fn finish_connect(
363    plan: SessionPlan,
364    session: SessionHandle,
365) -> Result<LiveHandle, AgentError> {
366    // Wait for Active phase — or for the session to give up trying.
367    //
368    // `wait_for_phase` alone waits forever: the L0 session loop retries the
369    // setup handshake `max_reconnect_attempts` times, then emits
370    // `Disconnected` and returns, but the phase watch stays alive because
371    // the handle holds it. Active never arrives and nothing ever wakes the
372    // waiter — so a permanently unacceptable setup (a retired model name is
373    // the common one) wedges `connect()` with no error, forever. Racing the
374    // terminal event turns that into a returned failure.
375    let mut events = session.subscribe();
376    tokio::select! {
377        () = session.wait_for_phase(SessionPhase::Active) => {}
378        failure = wait_for_connect_failure(&mut events) => {
379            return Err(AgentError::Session(SessionError::SetupFailed(
380                gemini_genai_rs::session::SetupError::ServerRejected {
381                    code: None,
382                    message: failure,
383                },
384            )));
385        }
386    }
387    let runtime = build_runtime(plan, session);
388    spawn_lanes(runtime).await
389}
390
391impl LiveSessionBuilder {
392    /// Derive the resolved [`SessionPlan`] from this builder.
393    ///
394    /// This is a pure transformation: it runs build-time validations and
395    /// resolves the startup configuration (notably applying `NonBlocking`
396    /// behavior to background-tool declarations) without performing any I/O or
397    /// spawning any tasks. It is unit-testable without a live connection.
398    pub(crate) fn into_plan(self) -> Result<SessionPlan, AgentError> {
399        // Build-time validations
400        if let Some(ref pm) = self.phase_machine {
401            pm.validate()?;
402        }
403        if let Some(ref computed) = self.computed {
404            computed.validate()?;
405        }
406
407        // Apply NON_BLOCKING behavior to tool declarations for background tools
408        let mut config = self.config;
409        for (tool_name, mode) in &self.execution_modes {
410            if matches!(
411                mode,
412                super::background_tool::ToolExecutionMode::Background { .. }
413            ) {
414                for tool in &mut config.tools {
415                    if let Some(ref mut decls) = tool.function_declarations {
416                        for decl in decls {
417                            if decl.name == *tool_name {
418                                decl.behavior = Some(
419                                    gemini_genai_rs::prelude::FunctionCallingBehavior::NonBlocking,
420                                );
421                            }
422                        }
423                    }
424                }
425            }
426        }
427
428        // The instruction the caller set at connect, kept as text so an
429        // `instruction_amendment` has something to compose onto in a session
430        // with no phase machine. Without it such an amendment was computed and
431        // then dropped — see the note in `control_plane::lifecycle` step 10.
432        let base_instruction = config.system_instruction.as_ref().map(|content| {
433            content
434                .parts
435                .iter()
436                .filter_map(|part| match part {
437                    gemini_genai_rs::prelude::Part::Text { text } => Some(text.as_str()),
438                    _ => None,
439                })
440                .collect::<Vec<_>>()
441                .join("\n")
442        });
443
444        Ok(SessionPlan {
445            config: Some(config),
446            base_instruction,
447            callbacks: self.callbacks,
448            dispatcher: self.dispatcher,
449            extractors: self.extractors,
450            computed: self.computed,
451            phase_machine: self.phase_machine,
452            watchers: self.watchers,
453            temporal: self.temporal,
454            greeting: self.greeting,
455            state: self.state,
456            execution_modes: self.execution_modes,
457            soft_turn_timeout: self.soft_turn_timeout,
458            steering_mode: self.steering_mode,
459            context_delivery: self.context_delivery,
460            delivery: self.delivery,
461            repair_config: self.repair_config,
462            persistence: self.persistence,
463            session_id: self.session_id,
464            tool_advisory: self.tool_advisory,
465            telemetry_interval: self.telemetry_interval,
466            middleware: self.middleware,
467            flow: self.flow,
468            redactor: self.redactor,
469            clock: self.clock,
470            lockstep: self.lockstep,
471        })
472    }
473}
474
475/// The resolved startup configuration for a Live session.
476///
477/// Produced purely from a [`LiveSessionBuilder`] via
478/// [`into_plan`](LiveSessionBuilder::into_plan) — no I/O, no task spawning. The
479/// `config` is held in an `Option` so [`connect`](LiveSessionBuilder::connect)
480/// can take it out to open the transport while moving the remaining plan into
481/// [`build_runtime`].
482pub(crate) struct SessionPlan {
483    /// Resolved session config (background-tool `NonBlocking` already applied).
484    /// `Some` until the transport is opened; `connect` takes it out.
485    config: Option<SessionConfig>,
486    /// The connect-time system instruction as text, for amendments to compose
487    /// onto when there is no phase to supply a base.
488    base_instruction: Option<String>,
489    callbacks: EventCallbacks,
490    dispatcher: Option<Arc<ToolDispatcher>>,
491    extractors: Vec<Arc<dyn TurnExtractor>>,
492    computed: Option<ComputedRegistry>,
493    phase_machine: Option<PhaseMachine>,
494    watchers: Option<WatcherRegistry>,
495    temporal: Option<TemporalRegistry>,
496    greeting: Option<String>,
497    state: Option<State>,
498    execution_modes: HashMap<String, ToolExecutionMode>,
499    soft_turn_timeout: Option<std::time::Duration>,
500    steering_mode: SteeringMode,
501    context_delivery: ContextDelivery,
502    delivery: super::processor::DeliveryConfig,
503    repair_config: Option<RepairConfig>,
504    persistence: Option<Arc<dyn SessionPersistence>>,
505    session_id: Option<String>,
506    tool_advisory: bool,
507    telemetry_interval: Option<std::time::Duration>,
508    middleware: Vec<Arc<dyn crate::middleware::Middleware>>,
509    flow: Option<crate::flow::FlowStack>,
510    redactor: Option<Arc<super::redaction::TranscriptRedactor>>,
511    clock: Option<crate::clock::SharedClock>,
512    lockstep: Option<Arc<super::processor::Lockstep>>,
513}
514
515/// Fully wired runtime for a connected Live session, ready for lane spawning.
516///
517/// Produced by [`build_runtime`] from a [`SessionPlan`] plus the connected
518/// [`SessionHandle`]. Holds the channels, shared atomics/state, dispatcher,
519/// control-plane config, and the resolved writers — but spawns nothing. The
520/// final stage [`spawn_lanes`] consumes this to start the lanes and assemble
521/// the [`LiveHandle`].
522pub(crate) struct SessionRuntime {
523    session: SessionHandle,
524    callbacks: Arc<EventCallbacks>,
525    dispatcher: Option<Arc<ToolDispatcher>>,
526    extractors: Vec<Arc<dyn TurnExtractor>>,
527    computed: Option<ComputedRegistry>,
528    phase_machine: Option<tokio::sync::Mutex<PhaseMachine>>,
529    watchers: Option<WatcherRegistry>,
530    temporal: Option<Arc<TemporalRegistry>>,
531    greeting: Option<String>,
532    state: State,
533    execution_modes: HashMap<String, ToolExecutionMode>,
534    background_tracker: Arc<BackgroundToolTracker>,
535    telemetry: Arc<SessionTelemetry>,
536    telemetry_interval: Option<std::time::Duration>,
537    control_plane: ControlPlaneConfig,
538    pending_context: Option<Arc<PendingContext>>,
539    /// Writer used by the processor for internal sends.
540    writer: Arc<dyn SessionWriter>,
541    /// User-facing writer handed to the `LiveHandle` (and used for greeting).
542    user_writer: Arc<dyn SessionWriter>,
543    event_rx: tokio::sync::broadcast::Receiver<gemini_genai_rs::prelude::SessionEvent>,
544    telem_rx: tokio::sync::broadcast::Receiver<gemini_genai_rs::prelude::SessionEvent>,
545    on_usage_cb: Option<super::callbacks::UsageCallback>,
546    live_event_tx: tokio::sync::broadcast::Sender<super::events::LiveEvent>,
547    telem_cancel: CancellationToken,
548    flow_monitor: Option<crate::flow::SharedFlowStack>,
549}
550
551/// Stage 3 input: construct the runtime wiring from a resolved plan and the
552/// connected session. Builds channels, shared state, the dispatcher set, the
553/// control-plane config (including deferred-context writer wrapping), and the
554/// telemetry handle — but does not spawn any lanes.
555pub(crate) fn build_runtime(plan: SessionPlan, session: SessionHandle) -> SessionRuntime {
556    // Share the governed-flow stack between the control lane (which
557    // advances it) and the LiveHandle (which snapshots explain).
558    let flow_monitor = plan.flow.map(crate::flow::FlowStack::into_shared);
559    let mut callbacks = plan.callbacks;
560    let on_usage_cb = callbacks.on_usage.take();
561    let callbacks = Arc::new(callbacks);
562    let raw_writer: Arc<dyn SessionWriter> = Arc::new(session.clone());
563    let state = plan.state.unwrap_or_default();
564    if let Some(clock) = plan.clock {
565        state.set_clock(clock);
566    }
567    if let Some(stack) = &flow_monitor {
568        stack.lock().publish_timing(&state);
569    }
570
571    // Subscribe twice: one for router → fast/ctrl, one for telemetry lane
572    let event_rx = session.subscribe();
573    let telem_rx = session.subscribe();
574
575    // Store initial phase's `needs` metadata for ContextBuilder.
576    if let Some(ref pm) = plan.phase_machine {
577        let _ = state.session().set("phase", pm.current());
578        if let Some(phase) = pm.current_phase()
579            && !phase.needs.is_empty()
580        {
581            let _ = state.set("session:phase_needs", phase.needs.clone());
582        }
583    }
584
585    let phase_machine_mutex = plan.phase_machine.map(|mut machine| {
586        machine.set_clock(state.clock());
587        tokio::sync::Mutex::new(machine)
588    });
589    let temporal_arc = plan.temporal.map(Arc::new);
590    let background_tracker = Arc::new(BackgroundToolTracker::new());
591
592    // Create telemetry (auto-collected by the telemetry lane)
593    let telemetry = Arc::new(SessionTelemetry::new());
594    let telem_cancel = CancellationToken::new();
595
596    // Build control plane config
597    let mut control_plane = ControlPlaneConfig {
598        soft_turn: plan.soft_turn_timeout.map(SoftTurnDetector::new),
599        steering_mode: plan.steering_mode,
600        context_delivery: plan.context_delivery,
601        delivery: plan.delivery,
602        needs_fulfillment: plan.repair_config.map(NeedsFulfillment::new),
603        persistence: plan.persistence,
604        session_id: plan.session_id,
605        tool_advisory: plan.tool_advisory,
606        base_instruction: plan.base_instruction,
607        pending_context: None, // set after PendingContext is created below
608        middleware: {
609            let mut chain = crate::middleware::MiddlewareChain::new();
610            for layer in plan.middleware {
611                chain.add(layer);
612            }
613            Arc::new(chain)
614        },
615        flow: flow_monitor.clone(),
616        redactor: plan.redactor,
617        lockstep: plan.lockstep,
618        playback: super::playback::PlaybackClock::new(state.clock()),
619    };
620
621    // Create shared PendingContext for deferred delivery.
622    // The SAME Arc is given to both the DeferredWriter (which drains it before
623    // user sends) and the ControlPlaneConfig (which the processor uses to push
624    // context turns from the control lane).
625    // A stage whose voice timing defers context needs the queue even when the
626    // session as a whole delivers immediately.
627    let stage_defers = flow_monitor.as_ref().is_some_and(|stack| {
628        stack
629            .lock()
630            .timing_policies()
631            .values()
632            .any(|t| t.context_delivery == Some(ContextDelivery::Deferred))
633    });
634    let pending_context = if plan.context_delivery == ContextDelivery::Deferred || stage_defers {
635        Some(Arc::new(PendingContext::new()))
636    } else {
637        None
638    };
639
640    // Wrap writer in DeferredWriter if deferred context delivery is enabled.
641    let (writer, user_writer) = if let Some(ref pending) = pending_context {
642        let deferred: Arc<dyn SessionWriter> =
643            Arc::new(DeferredWriter::new(raw_writer.clone(), pending.clone()));
644        // Processor uses raw_writer for internal sends (lifecycle context
645        // goes through PendingContext, not through the writer directly).
646        // User-facing LiveHandle uses the DeferredWriter.
647        (raw_writer, deferred)
648    } else {
649        (raw_writer.clone(), raw_writer)
650    };
651
652    // Pass shared pending context to control plane config
653    control_plane.pending_context = pending_context.clone();
654
655    // Create LiveEvent broadcast channel
656    use super::events::LiveEvent;
657    use tokio::sync::broadcast;
658    let (live_event_tx, _) = broadcast::channel::<LiveEvent>(4096);
659
660    SessionRuntime {
661        session,
662        callbacks,
663        dispatcher: plan.dispatcher,
664        extractors: plan.extractors,
665        computed: plan.computed,
666        phase_machine: phase_machine_mutex,
667        watchers: plan.watchers,
668        temporal: temporal_arc,
669        greeting: plan.greeting,
670        state,
671        execution_modes: plan.execution_modes,
672        background_tracker,
673        telemetry,
674        telemetry_interval: plan.telemetry_interval,
675        control_plane,
676        pending_context,
677        writer,
678        user_writer,
679        event_rx,
680        telem_rx,
681        on_usage_cb,
682        live_event_tx,
683        telem_cancel,
684        flow_monitor,
685    }
686}
687
688/// Stage 4: spawn the telemetry lane, event processor, and periodic telemetry
689/// emitter, send any greeting, and assemble the [`LiveHandle`].
690/// Resolve once the session has definitively failed to come up, returning a
691/// message that names the cause.
692///
693/// The L0 loop reports each failed setup attempt as `Error` and only says
694/// `Disconnected` when it stops retrying, so the last `Error` carries the real
695/// reason ("Setup failed: …") while `Disconnected` carries only "max attempts
696/// exceeded". Both are reported: the terminal event proves it is over, the
697/// retained error says why.
698///
699/// Pends forever if the session comes up normally — the caller races it against
700/// the phase wait.
701async fn wait_for_connect_failure(
702    events: &mut tokio::sync::broadcast::Receiver<SessionEvent>,
703) -> String {
704    let mut last_error: Option<String> = None;
705    loop {
706        match events.recv().await {
707            Ok(SessionEvent::Error(err)) => last_error = Some(err.to_string()),
708            Ok(SessionEvent::Disconnected(reason)) => {
709                let reason = reason.unwrap_or_else(|| "connection closed".to_string());
710                return match last_error {
711                    Some(err) => format!("{reason} ({err})"),
712                    None => reason,
713                };
714            }
715            Ok(_) => {}
716            // The sender is gone, which is itself terminal.
717            Err(tokio::sync::broadcast::error::RecvError::Closed) => {
718                return last_error.unwrap_or_else(|| "session ended during setup".to_string());
719            }
720            Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
721        }
722    }
723}
724
725pub(crate) async fn spawn_lanes(rt: SessionRuntime) -> Result<LiveHandle, AgentError> {
726    use super::events::LiveEvent;
727
728    // One root span per session, so a trace backend shows every turn of a
729    // conversation under it, keyed by the conversation id (the persistence
730    // session id when set, else the transport's). The lanes are spawned
731    // inside it and each turn span is its child.
732    let conversation_id = rt
733        .control_plane
734        .session_id
735        .clone()
736        .unwrap_or_else(|| rt.session.session_id().to_string());
737    let session_span = tracing::info_span!(
738        "live_session",
739        "gen_ai.operation.name" = "live",
740        "gen_ai.system" = "gemini",
741        "gen_ai.conversation.id" = %conversation_id,
742    );
743    let entered = session_span.enter();
744
745    // Spawn telemetry lane (SessionSignals + SessionTelemetry on own broadcast rx)
746    let session_signals = SessionSignals::new(rt.state.clone());
747    let _telem_handle = spawn_telemetry_lane(
748        rt.telem_rx,
749        session_signals,
750        rt.telemetry.clone(),
751        rt.telem_cancel.clone(),
752        rt.on_usage_cb,
753    );
754
755    // Spawn fast + control lanes (no session_signals, no transcript mutex)
756    let greeting_writer = rt.user_writer.clone();
757    let playback = rt.control_plane.playback.clone();
758    let (fast_handle, ctrl_handle, ctrl_tx) = spawn_event_processor(
759        rt.event_rx,
760        rt.callbacks,
761        rt.dispatcher,
762        rt.writer,
763        rt.extractors,
764        rt.state.clone(),
765        rt.computed,
766        rt.phase_machine,
767        rt.watchers,
768        rt.temporal,
769        Some(rt.background_tracker.clone()),
770        rt.execution_modes,
771        rt.control_plane,
772        rt.live_event_tx.clone(),
773    );
774    drop(entered);
775
776    // Spawn periodic telemetry emitter if interval is set
777    if let Some(interval) = rt.telemetry_interval {
778        let telem_tx = rt.live_event_tx.clone();
779        let telem_ref = rt.telemetry.clone();
780        tokio::spawn(async move {
781            let mut tick = tokio::time::interval(interval);
782            let mut prev_turns = 0u64;
783            loop {
784                tick.tick().await;
785                let snap = telem_ref.snapshot();
786                if let Some(obj) = snap.as_object() {
787                    let tc = obj
788                        .get("turn_count")
789                        .or_else(|| obj.get("response_count"))
790                        .and_then(serde_json::Value::as_u64)
791                        .unwrap_or(0);
792                    if tc > prev_turns {
793                        let latency = obj
794                            .get("last_response_latency_ms")
795                            .and_then(serde_json::Value::as_u64)
796                            .unwrap_or(0) as u32;
797                        let prompt = obj
798                            .get("prompt_token_count")
799                            .and_then(serde_json::Value::as_u64)
800                            .unwrap_or(0) as u32;
801                        let response = obj
802                            .get("response_token_count")
803                            .and_then(serde_json::Value::as_u64)
804                            .unwrap_or(0) as u32;
805                        let _ = telem_tx.send(LiveEvent::TurnMetrics {
806                            turn: tc as u32,
807                            latency_ms: latency,
808                            prompt_tokens: prompt,
809                            response_tokens: response,
810                        });
811                        prev_turns = tc;
812                    }
813                }
814                if telem_tx.send(LiveEvent::Telemetry(snap)).is_err() {
815                    break;
816                }
817            }
818        });
819    }
820
821    // Send greeting prompt to trigger model-initiated conversation
822    if let Some(greeting) = rt.greeting {
823        greeting_writer
824            .send_text(greeting)
825            .await
826            .map_err(AgentError::Session)?;
827    }
828
829    Ok(LiveHandle::new(
830        rt.session,
831        rt.user_writer,
832        fast_handle,
833        ctrl_handle,
834        rt.state,
835        rt.telemetry,
836        rt.live_event_tx,
837        rt.pending_context,
838        rt.flow_monitor,
839        rt.background_tracker,
840        rt.telem_cancel,
841    )
842    .with_control_sender(ctrl_tx)
843    .with_playback(playback))
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849
850    #[test]
851    fn builder_creates_with_defaults() {
852        let config = SessionConfig::new("test-key");
853        let builder = LiveSessionBuilder::new(config);
854        assert!(builder.dispatcher.is_none());
855        assert!(builder.computed.is_none());
856        assert!(builder.phase_machine.is_none());
857        assert!(builder.watchers.is_none());
858        assert!(builder.temporal.is_none());
859    }
860
861    #[test]
862    fn into_plan_derives_defaults() {
863        let config = SessionConfig::new("test-key");
864        let plan = LiveSessionBuilder::new(config)
865            .into_plan()
866            .expect("default builder should produce a plan");
867
868        // Config is carried (taken out only at connect time).
869        assert!(plan.config.is_some());
870        // Defaults preserved.
871        assert!(plan.dispatcher.is_none());
872        assert!(plan.phase_machine.is_none());
873        assert!(plan.persistence.is_none());
874        assert!(plan.session_id.is_none());
875        assert!(plan.greeting.is_none());
876        assert!(plan.soft_turn_timeout.is_none());
877        assert!(plan.telemetry_interval.is_none());
878        assert!(plan.repair_config.is_none());
879        assert!(plan.flow.is_none());
880        assert!(plan.execution_modes.is_empty());
881        assert!(plan.middleware.is_empty());
882        assert_eq!(plan.steering_mode, SteeringMode::default());
883        assert_eq!(plan.context_delivery, ContextDelivery::default());
884        // Default builder enables tool advisory.
885        assert!(plan.tool_advisory);
886    }
887
888    #[test]
889    fn into_plan_carries_persistence_and_session_id() {
890        let config = SessionConfig::new("test-key");
891        let plan = LiveSessionBuilder::new(config)
892            .session_id("user-123-session-456")
893            .into_plan()
894            .expect("plan derivation should succeed");
895
896        assert_eq!(plan.session_id.as_deref(), Some("user-123-session-456"));
897    }
898
899    #[test]
900    fn into_plan_carries_steering_and_context_delivery() {
901        let config = SessionConfig::new("test-key");
902        let plan = LiveSessionBuilder::new(config)
903            .steering_mode(SteeringMode::ContextInjection)
904            .context_delivery(ContextDelivery::Deferred)
905            .tool_advisory(false)
906            .into_plan()
907            .expect("plan derivation should succeed");
908
909        assert_eq!(plan.steering_mode, SteeringMode::ContextInjection);
910        assert_eq!(plan.context_delivery, ContextDelivery::Deferred);
911        assert!(!plan.tool_advisory);
912    }
913
914    #[test]
915    fn into_plan_carries_greeting_and_telemetry_interval() {
916        let config = SessionConfig::new("test-key");
917        let plan = LiveSessionBuilder::new(config)
918            .greeting("Hello there")
919            .telemetry_interval(std::time::Duration::from_secs(5))
920            .soft_turn_timeout(std::time::Duration::from_secs(2))
921            .into_plan()
922            .expect("plan derivation should succeed");
923
924        assert_eq!(plan.greeting.as_deref(), Some("Hello there"));
925        assert_eq!(
926            plan.telemetry_interval,
927            Some(std::time::Duration::from_secs(5))
928        );
929        assert_eq!(
930            plan.soft_turn_timeout,
931            Some(std::time::Duration::from_secs(2))
932        );
933    }
934
935    #[test]
936    fn into_plan_validates_phase_machine() {
937        // A PhaseMachine whose initial phase doesn't exist must fail validation
938        // during plan derivation (no connection required).
939        let config = SessionConfig::new("test-key");
940        let pm = PhaseMachine::new("nonexistent");
941        let result = LiveSessionBuilder::new(config)
942            .phase_machine(pm)
943            .into_plan();
944        assert!(result.is_err(), "invalid phase machine should fail to plan");
945    }
946
947    #[test]
948    fn into_plan_carries_valid_phase_machine_and_seeds_nothing() {
949        // A valid phase machine is carried into the plan; into_plan does NOT
950        // seed state (that happens in build_runtime), so this stays I/O-free.
951        let config = SessionConfig::new("test-key");
952        let mut pm = PhaseMachine::new("start");
953        pm.add_phase(crate::live::phase::Phase::new("start", "Start phase"));
954        let plan = LiveSessionBuilder::new(config)
955            .phase_machine(pm)
956            .into_plan()
957            .expect("valid phase machine should plan");
958
959        assert!(plan.phase_machine.is_some());
960    }
961
962    #[test]
963    fn into_plan_applies_non_blocking_to_background_tools() {
964        use gemini_genai_rs::prelude::{FunctionCallingBehavior, FunctionDeclaration, Tool};
965
966        let decl = FunctionDeclaration {
967            name: "search_kb".into(),
968            description: "Search".into(),
969            parameters: None,
970            behavior: None,
971        };
972        let config = SessionConfig::new("test-key").add_tool(Tool::functions(vec![decl]));
973
974        let plan = LiveSessionBuilder::new(config)
975            .tool_execution_mode(
976                "search_kb",
977                ToolExecutionMode::Background {
978                    formatter: None,
979                    scheduling: None,
980                },
981            )
982            .into_plan()
983            .expect("plan derivation should succeed");
984
985        let cfg = plan.config.expect("config carried");
986        let decl = cfg.tools[0]
987            .function_declarations
988            .as_ref()
989            .unwrap()
990            .iter()
991            .find(|d| d.name == "search_kb")
992            .unwrap();
993        assert_eq!(decl.behavior, Some(FunctionCallingBehavior::NonBlocking));
994    }
995}