Skip to main content

adk_realtime/
runner.rs

1//! RealtimeRunner for integrating realtime sessions with agents.
2//!
3//! This module provides the bridge between realtime audio sessions and
4//! the ADK agent framework, handling tool execution and event routing.
5
6use crate::config::{RealtimeConfig, SessionUpdateConfig, ToolDefinition};
7use crate::error::{RealtimeError, Result};
8use crate::events::{ServerEvent, ToolCall, ToolResponse};
9use crate::model::BoxedModel;
10use crate::session::ContextMutationOutcome;
11use async_trait::async_trait;
12use std::collections::HashMap;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
15use tokio::sync::RwLock;
16
17/// Internal state machine tracking the resumability status of the RealtimeRunner.
18#[derive(Debug, Clone, Default, PartialEq)]
19pub enum RunnerState {
20    /// Runner is ready to accept transport resumption immediately.
21    #[default]
22    Idle,
23    /// Model is currently generating a response; tearing down the connection would corrupt context.
24    Generating,
25    /// A tool is currently executing; teardown would cause tool loss.
26    ExecutingTool,
27    /// A context mutation was queued while the runner was busy, and must be executed once Idle.
28    ///
29    /// **Provider Context:** This state is only utilized by providers that do *not* support
30    /// native mid-flight mutability (e.g., Gemini Live), requiring a physical transport teardown
31    /// and rebuild (Phantom Reconnect). Providers like OpenAI natively apply `session.update`
32    /// frames instantly and will never enter this queued state.
33    ///
34    /// **Queue Policy:** The runner keeps only one pending resumption. If a new session update
35    /// arrives while a resumption is already pending, the previous pending resumption is replaced.
36    /// This is intentional: pending session updates represent desired end state, not an ordered
37    /// command queue. The policy is last write wins.
38    PendingResumption {
39        /// The new configuration to apply on reconnection.
40        config: Box<crate::config::RealtimeConfig>,
41        /// An optional message to inject immediately after resumption.
42        bridge_message: Option<String>,
43        /// Number of failed reconnection attempts for this mutation.
44        attempts: u8,
45    },
46}
47
48/// Handler for tool/function calls from the realtime model.
49#[async_trait]
50pub trait ToolHandler: Send + Sync {
51    /// Execute a tool call and return the result.
52    async fn execute(&self, call: &ToolCall) -> Result<serde_json::Value>;
53}
54
55/// A simple function-based tool handler.
56pub struct FnToolHandler<F>
57where
58    F: Fn(&ToolCall) -> Result<serde_json::Value> + Send + Sync,
59{
60    handler: F,
61}
62
63impl<F> FnToolHandler<F>
64where
65    F: Fn(&ToolCall) -> Result<serde_json::Value> + Send + Sync,
66{
67    /// Create a new function-based tool handler.
68    pub fn new(handler: F) -> Self {
69        Self { handler }
70    }
71}
72
73#[async_trait]
74impl<F> ToolHandler for FnToolHandler<F>
75where
76    F: Fn(&ToolCall) -> Result<serde_json::Value> + Send + Sync,
77{
78    async fn execute(&self, call: &ToolCall) -> Result<serde_json::Value> {
79        (self.handler)(call)
80    }
81}
82
83/// Async function-based tool handler.
84#[allow(dead_code)]
85pub struct AsyncToolHandler<F, Fut>
86where
87    F: Fn(ToolCall) -> Fut + Send + Sync,
88    Fut: std::future::Future<Output = Result<serde_json::Value>> + Send,
89{
90    handler: F,
91}
92
93impl<F, Fut> AsyncToolHandler<F, Fut>
94where
95    F: Fn(ToolCall) -> Fut + Send + Sync,
96    Fut: std::future::Future<Output = Result<serde_json::Value>> + Send,
97{
98    /// Create a new async tool handler.
99    pub fn new(handler: F) -> Self {
100        Self { handler }
101    }
102}
103
104/// Event handler for processing realtime events.
105#[async_trait]
106pub trait EventHandler: Send + Sync {
107    /// Called when an audio delta is received (raw PCM bytes).
108    async fn on_audio(&self, _audio: &[u8], _item_id: &str) -> Result<()> {
109        Ok(())
110    }
111
112    /// Called when a text delta is received.
113    async fn on_text(&self, _text: &str, _item_id: &str) -> Result<()> {
114        Ok(())
115    }
116
117    /// Called when a transcript delta is received.
118    async fn on_transcript(&self, _transcript: &str, _item_id: &str) -> Result<()> {
119        Ok(())
120    }
121
122    /// Called when speech is detected.
123    async fn on_speech_started(&self, _audio_start_ms: u64) -> Result<()> {
124        Ok(())
125    }
126
127    /// Called when speech ends.
128    async fn on_speech_stopped(&self, _audio_end_ms: u64) -> Result<()> {
129        Ok(())
130    }
131
132    /// Called when a response completes.
133    async fn on_response_done(&self) -> Result<()> {
134        Ok(())
135    }
136
137    /// Called on any error.
138    /// Called when the provider transport ends and [`RealtimeRunner::run`] is returning.
139    ///
140    /// The runner does **not** reconnect automatically. Reconnection is deliberate: it
141    /// requires deciding what context to replay and, on Gemini, whether a resumption token
142    /// is still valid. Without this hook `run` returned `Ok(())` on transport loss, which a
143    /// caller could not tell apart from a graceful [`RealtimeRunner::close`].
144    ///
145    /// # Example
146    ///
147    /// ```rust,ignore
148    /// async fn on_disconnect(&self) -> adk_realtime::Result<()> {
149    ///     tracing::warn!("realtime transport ended; reconnecting");
150    ///     self.reconnect.notify_one();
151    ///     Ok(())
152    /// }
153    /// ```
154    async fn on_disconnect(&self) -> Result<()> {
155        Ok(())
156    }
157
158    async fn on_error(&self, _error: &RealtimeError) -> Result<()> {
159        Ok(())
160    }
161}
162
163/// Default no-op event handler.
164#[derive(Debug, Clone, Default)]
165pub struct NoOpEventHandler;
166
167#[async_trait]
168impl EventHandler for NoOpEventHandler {}
169
170/// A tool call the run loop still has to dispatch.
171#[derive(Debug, Clone)]
172struct PendingToolCall {
173    call_id: String,
174    name: String,
175    arguments: String,
176}
177
178/// Configuration for the RealtimeRunner.
179#[derive(Clone)]
180pub struct RunnerConfig {
181    /// Whether to automatically execute tool calls.
182    pub auto_execute_tools: bool,
183    /// Whether to automatically send tool responses.
184    pub auto_respond_tools: bool,
185    /// Maximum concurrent tool executions.
186    pub max_concurrent_tools: usize,
187}
188
189impl Default for RunnerConfig {
190    fn default() -> Self {
191        Self { auto_execute_tools: true, auto_respond_tools: true, max_concurrent_tools: 4 }
192    }
193}
194
195/// Builder for RealtimeRunner.
196pub struct RealtimeRunnerBuilder {
197    model: Option<BoxedModel>,
198    config: RealtimeConfig,
199    runner_config: RunnerConfig,
200    tools: HashMap<String, (ToolDefinition, Arc<dyn ToolHandler>)>,
201    event_handler: Option<Arc<dyn EventHandler>>,
202}
203
204impl Default for RealtimeRunnerBuilder {
205    fn default() -> Self {
206        Self::new()
207    }
208}
209
210impl RealtimeRunnerBuilder {
211    /// Create a new builder.
212    pub fn new() -> Self {
213        Self {
214            model: None,
215            config: RealtimeConfig::default(),
216            runner_config: RunnerConfig::default(),
217            tools: HashMap::new(),
218            event_handler: None,
219        }
220    }
221
222    /// Set the realtime model.
223    pub fn model(mut self, model: BoxedModel) -> Self {
224        self.model = Some(model);
225        self
226    }
227
228    /// Set the session configuration.
229    pub fn config(mut self, config: RealtimeConfig) -> Self {
230        self.config = config;
231        self
232    }
233
234    /// Set the runner configuration.
235    pub fn runner_config(mut self, config: RunnerConfig) -> Self {
236        self.runner_config = config;
237        self
238    }
239
240    /// Set the system instruction.
241    pub fn instruction(mut self, instruction: impl Into<String>) -> Self {
242        self.config.instruction = Some(instruction.into());
243        self
244    }
245
246    /// Set the voice.
247    pub fn voice(mut self, voice: impl Into<String>) -> Self {
248        self.config.voice = Some(voice.into());
249        self
250    }
251
252    /// Register a tool with its handler.
253    pub fn tool(mut self, definition: ToolDefinition, handler: impl ToolHandler + 'static) -> Self {
254        let name = definition.name.clone();
255        self.tools.insert(name, (definition, Arc::new(handler)));
256        self
257    }
258
259    /// Register a tool with a sync function handler.
260    pub fn tool_fn<F>(self, definition: ToolDefinition, handler: F) -> Self
261    where
262        F: Fn(&ToolCall) -> Result<serde_json::Value> + Send + Sync + 'static,
263    {
264        self.tool(definition, FnToolHandler::new(handler))
265    }
266
267    /// Register a tool with an `Arc<dyn ToolHandler>` directly.
268    ///
269    /// This is useful when you already have a shared handler and want to avoid
270    /// an extra `Arc` wrapping that the `tool()` method would perform.
271    pub fn tool_arc(mut self, definition: ToolDefinition, handler: Arc<dyn ToolHandler>) -> Self {
272        let name = definition.name.clone();
273        self.tools.insert(name, (definition, handler));
274        self
275    }
276
277    /// Set the event handler.
278    pub fn event_handler(mut self, handler: impl EventHandler + 'static) -> Self {
279        self.event_handler = Some(Arc::new(handler));
280        self
281    }
282
283    /// Set the event handler from an `Arc<dyn EventHandler>` directly.
284    ///
285    /// This is useful when you already have a shared handler and want to avoid
286    /// an extra `Arc` wrapping that the `event_handler()` method would perform.
287    pub fn event_handler_arc(mut self, handler: Arc<dyn EventHandler>) -> Self {
288        self.event_handler = Some(handler);
289        self
290    }
291
292    /// Build the runner (does not connect yet).
293    pub fn build(self) -> Result<RealtimeRunner> {
294        let model = self.model.ok_or_else(|| RealtimeError::config("Model is required"))?;
295
296        // Add tool definitions to config
297        let mut config = self.config;
298        if !self.tools.is_empty() {
299            let tool_defs: Vec<ToolDefinition> =
300                self.tools.values().map(|(def, _)| def.clone()).collect();
301            config.tools = Some(tool_defs);
302        }
303
304        let max_concurrent_tools = self.runner_config.max_concurrent_tools.max(1);
305
306        Ok(RealtimeRunner {
307            model,
308            config: Arc::new(RwLock::new(config)),
309            runner_config: self.runner_config,
310            tools: self.tools,
311            event_handler: self.event_handler.unwrap_or_else(|| Arc::new(NoOpEventHandler)),
312            session: Arc::new(RwLock::new(None)),
313            state: Arc::new(RwLock::new(RunnerState::Idle)),
314            pending_tool_response: AtomicBool::new(false),
315            tool_permits: Arc::new(tokio::sync::Semaphore::new(max_concurrent_tools)),
316            outstanding_tools: Arc::new(AtomicUsize::new(0)),
317            response_closed_awaiting_tools: Arc::new(AtomicBool::new(false)),
318        })
319    }
320}
321
322/// A runner that manages a realtime session with tool execution.
323///
324/// RealtimeRunner provides a high-level interface for:
325/// - Connecting to realtime providers
326/// - Automatically executing tool calls
327/// - Routing events to handlers
328/// - Managing the session lifecycle
329///
330/// # Example
331///
332/// ```rust,ignore
333/// use adk_realtime::{RealtimeRunner, RealtimeConfig, ToolDefinition};
334/// use adk_realtime::openai::OpenAIRealtimeModel;
335///
336/// #[tokio::main]
337/// async fn main() -> Result<()> {
338///     let model = OpenAIRealtimeModel::new(api_key, "gpt-realtime");
339///
340///     let runner = RealtimeRunner::builder()
341///         .model(Box::new(model))
342///         .instruction("You are a helpful voice assistant.")
343///         .voice("alloy")
344///         .tool_fn(
345///             ToolDefinition::new("get_weather")
346///                 .with_description("Get weather for a location"),
347///             |call| {
348///                 Ok(serde_json::json!({"temperature": 72, "condition": "sunny"}))
349///             }
350///         )
351///         .build()?;
352///
353///     runner.connect().await?;
354///     runner.run().await?;
355///
356///     Ok(())
357/// }
358/// ```
359pub struct RealtimeRunner {
360    model: BoxedModel,
361    config: Arc<RwLock<RealtimeConfig>>,
362    runner_config: RunnerConfig,
363    tools: HashMap<String, (ToolDefinition, Arc<dyn ToolHandler>)>,
364    event_handler: Arc<dyn EventHandler>,
365    session: Arc<RwLock<Option<Arc<dyn crate::session::RealtimeSession>>>>,
366    state: Arc<RwLock<RunnerState>>,
367    /// Set when tool output(s) have been sent for the in-flight response and a
368    /// single follow-up `create_response` is owed once that response finishes.
369    pending_tool_response: AtomicBool,
370    /// Bounds how many tool handlers run at once, from
371    /// [`RunnerConfig::max_concurrent_tools`].
372    tool_permits: Arc<tokio::sync::Semaphore>,
373    /// Tool calls dispatched for the current response and not yet finished.
374    outstanding_tools: Arc<AtomicUsize>,
375    /// Set when the dispatching response closed while tool calls were still running, so
376    /// the follow-up `create_response` is owed by whichever tool finishes last.
377    response_closed_awaiting_tools: Arc<AtomicBool>,
378}
379
380impl RealtimeRunner {
381    /// Helper to safely acquire a cloned Arc of the current session, dropping the lock.
382    async fn session_handle(&self) -> Result<Arc<dyn crate::session::RealtimeSession>> {
383        let guard = self.session.read().await;
384        guard.as_ref().cloned().ok_or_else(|| RealtimeError::connection("Not connected"))
385    }
386
387    /// Create a new builder.
388    pub fn builder() -> RealtimeRunnerBuilder {
389        RealtimeRunnerBuilder::new()
390    }
391
392    /// Connect to the realtime provider.
393    pub async fn connect(&self) -> Result<()> {
394        let config = self.config.read().await.clone();
395        let session = self.model.connect(config).await?;
396        let mut guard = self.session.write().await;
397        *guard = Some(session.into());
398        Ok(())
399    }
400
401    /// Check if currently connected.
402    pub async fn is_connected(&self) -> bool {
403        let guard = self.session.read().await;
404        guard.as_ref().map(|s| s.is_connected()).unwrap_or(false)
405    }
406
407    /// Get the session ID if connected.
408    pub async fn session_id(&self) -> Option<String> {
409        let guard = self.session.read().await;
410        guard.as_ref().map(|s| s.session_id().to_string())
411    }
412
413    /// Send a client event directly to the session.
414    ///
415    /// This method intercepts internal control-plane events (like `UpdateSession`) to route
416    /// them through the provider-agnostic orchestration layer instead of forwarding raw JSON
417    /// to the underlying WebSocket transport. This guarantees that `adk-realtime` never leaks
418    /// invalid event payloads to providers (e.g., OpenAI or Gemini) and universally bridges
419    /// the Cognitive Handoff mechanics transparently.
420    pub async fn send_client_event(&self, event: crate::events::ClientEvent) -> Result<()> {
421        match event {
422            crate::events::ClientEvent::UpdateSession { instructions, tools } => {
423                let update_config = SessionUpdateConfig(crate::config::RealtimeConfig {
424                    instruction: instructions,
425                    tools,
426                    ..Default::default()
427                });
428                self.update_session(update_config).await
429            }
430            other => {
431                let session = self.session_handle().await?;
432                session.send_event(other).await
433            }
434        }
435    }
436
437    /// Internal helper to merge a `SessionUpdateConfig` delta into the canonical `RealtimeConfig` state.
438    ///
439    /// **Why this exists**: The `RealtimeRunner` must maintain an absolute, single source of truth
440    /// for its configuration (`self.config`). Orchestrators fire `SessionUpdateConfig`s as sparse
441    /// partial deltas (intents to hot-swap instructions or tools mid-flight). By accumulating
442    /// these sparse updates into the single `base` config, any subsequent "Phantom Reconnect"
443    /// (e.g., due to a Gemini domain shift or an unexpected network drop) natively inherits all
444    /// prior hot-swaps alongside the immutable transport parameters (like sample rates) defined at startup.
445    ///
446    /// Note: This is intentionally narrow and specifically scoped to merge only
447    /// hot-swappable cognitive fields (instruction, tools, voice, temperature, extra).
448    /// Transport-level attributes like sample rates and audio formats are not dynamically swappable.
449    fn merge_config(base: &mut RealtimeConfig, update: &SessionUpdateConfig) {
450        if let Some(instruction) = &update.0.instruction {
451            base.instruction = Some(instruction.clone());
452        }
453        if let Some(tools) = &update.0.tools {
454            base.tools = Some(tools.clone());
455        }
456        if let Some(voice) = &update.0.voice {
457            base.voice = Some(voice.clone());
458        }
459        if let Some(temp) = update.0.temperature {
460            base.temperature = Some(temp);
461        }
462        if let Some(extra) = &update.0.extra {
463            base.extra = Some(extra.clone());
464        }
465    }
466
467    /// Update the session configuration.
468    ///
469    /// Delegates to [`Self::update_session_with_bridge`] with no bridge message.
470    ///
471    /// # Example
472    ///
473    /// ```rust,ignore
474    /// use adk_realtime::config::{SessionUpdateConfig, RealtimeConfig};
475    ///
476    /// async fn example(runner: &adk_realtime::RealtimeRunner) {
477    ///     let update = SessionUpdateConfig(
478    ///         RealtimeConfig::default().with_instruction("You are now a pirate.")
479    ///     );
480    ///     runner.update_session(update).await.unwrap();
481    /// }
482    /// ```
483    pub async fn update_session(&self, config: SessionUpdateConfig) -> Result<()> {
484        self.update_session_with_bridge(config, None).await
485    }
486
487    /// Update the session configuration, optionally injecting a bridge message if
488    /// a transport resumption (Phantom Reconnect) occurs.
489    ///
490    /// The RealtimeRunner will attempt to mutate the session natively if the underlying
491    /// API supports it (e.g., OpenAI). If it does not (e.g., Gemini), the Runner will
492    /// queue a transport resumption, executing it only when the session
493    /// is in a resumable state (Idle) to prevent data corruption.
494    ///
495    /// The runner keeps only one pending resumption. If a new session update arrives while
496    /// a resumption is already pending, the previous pending resumption is replaced. This is
497    /// intentional: pending session updates represent desired end state, not an ordered command queue.
498    /// The policy is last write wins.
499    pub async fn update_session_with_bridge(
500        &self,
501        config: SessionUpdateConfig,
502        bridge_message: Option<String>,
503    ) -> Result<()> {
504        // 1. Merge the incoming delta into the runner's canonical, persisted configuration.
505        // This ensures that any future reconnects (e.g., due to network drops) naturally
506        // inherit this latest state.
507        let mut full_config = self.config.write().await;
508        Self::merge_config(&mut full_config, &config);
509
510        let cloned_config = full_config.clone();
511        drop(full_config); // Free the write lock early to avoid deadlocks.
512
513        // 2. Safely obtain a cloned handle of the active session.
514        let session = self.session_handle().await?;
515
516        // 3. Delegate the mutation attempt to the provider-specific adapter.
517        match session.mutate_context(cloned_config).await? {
518            // PATH A: Native Mutability (e.g., OpenAI)
519            // The provider natively updated the context over the active WebSocket.
520            ContextMutationOutcome::Applied => {
521                tracing::info!("Context mutated natively mid-flight.");
522
523                // Since the transport wasn't dropped, we can inject the bridge message
524                // immediately as a standard user message to update the model's short-term memory.
525                if let Some(msg) = bridge_message {
526                    let event = crate::events::ClientEvent::Message {
527                        role: "user".to_string(),
528                        parts: vec![adk_core::types::Part::Text { text: msg }],
529                    };
530                    session.send_event(event).await?;
531                }
532                Ok(())
533            }
534
535            // PATH B: Rigid Initialization (e.g., Gemini)
536            // The provider requires us to tear down the WebSocket and establish a new one (Phantom Reconnect).
537            ContextMutationOutcome::RequiresResumption(new_config) => {
538                drop(session); // CRITICAL: Drop the cloned handle before attempting state mutation.
539
540                // 4. Check the Runner's internal state machine to ensure it is safe to tear down the socket.
541                let mut state_guard = self.state.write().await;
542
543                if *state_guard == RunnerState::Idle {
544                    // Safe to reconnect: The model is neither generating audio nor executing a tool.
545                    drop(state_guard); // Free state lock before the heavy async network operation.
546                    tracing::info!("Runner is idle. Executing resumption immediately.");
547
548                    if let Err(e) =
549                        self.execute_resumption((*new_config).clone(), bridge_message.clone()).await
550                    {
551                        tracing::error!("Immediate resumption failed: {}. Queueing for retry.", e);
552                        // If the reconnect fails (e.g., transient network issue), we must not lose the mutation intent.
553                        // We push it back into the queue for the background loop to retry.
554                        let mut fallback_state = self.state.write().await;
555                        *fallback_state = RunnerState::PendingResumption {
556                            config: Box::new(*new_config),
557                            bridge_message,
558                            attempts: 1,
559                        };
560                        return Err(e);
561                    }
562                } else {
563                    // Unsafe to reconnect: Tearing down the socket now would corrupt the in-flight context.
564                    // We must queue the mutation. The event loop will execute it once it returns to Idle.
565                    if let RunnerState::PendingResumption { .. } = *state_guard {
566                        tracing::warn!(
567                            "Runner already had a pending resumption. Overwriting with last-write-wins policy."
568                        );
569                    } else {
570                        tracing::info!("Runner is busy ({:?}). Queueing resumption.", *state_guard);
571                    }
572
573                    // Queue the intent using a last-write-wins policy.
574                    *state_guard = RunnerState::PendingResumption {
575                        config: new_config,
576                        bridge_message,
577                        attempts: 0,
578                    };
579                }
580                Ok(())
581            }
582        }
583    }
584
585    /// Internal helper to execute a transport resumption (teardown and rebuild).
586    async fn execute_resumption(
587        &self,
588        new_config: crate::config::RealtimeConfig,
589        bridge_message: Option<String>,
590    ) -> Result<()> {
591        tracing::warn!("Executing transport resumption with new configuration.");
592
593        // 1. Extract the old session safely under the write lock.
594        let old_session = {
595            let mut write_guard = self.session.write().await;
596            write_guard.take()
597        };
598
599        // 2. Explicitly tear down the old WebSocket connection to release upstream resources.
600        // Do this WITHOUT holding the lock across `.await`.
601        if let Some(session) = old_session
602            && let Err(e) = session.close().await
603        {
604            tracing::warn!("Failed to cleanly close old session during resumption: {}", e);
605        }
606
607        // 3. Establish a brand new connection using the provider-agnostic factory interface.
608        // If the provider supports resumption natively (like Gemini), the `new_config`
609        // payload already contains the cached `resumeToken`.
610        let new_session = self.model.connect(new_config).await?;
611
612        // 4. Overwrite the active session pointer with the newly connected transport.
613        {
614            let mut write_guard = self.session.write().await;
615            *write_guard = Some(new_session.into());
616        }
617
618        // 5. If the orchestrator provided a bridge message (e.g. to explain the domain shift),
619        // safely inject it into the new connection's context window.
620        if let Some(msg) = bridge_message {
621            self.inject_bridge_message(msg).await?;
622        }
623
624        tracing::info!("Resumption complete. New transport established.");
625        Ok(())
626    }
627
628    /// Internal helper to safely inject a bridge message directly into the active session.
629    ///
630    /// This intentionally bypasses the `send_client_event` router to avoid `E0733`
631    /// (un-Boxed async recursion) where `send_client_event` -> `update_session` ->
632    /// `execute_resumption` -> `send_client_event` creates an infinite compiler loop.
633    async fn inject_bridge_message(&self, msg: String) -> Result<()> {
634        tracing::info!("Injecting bridge message post-resumption.");
635        let event = crate::events::ClientEvent::Message {
636            role: "user".to_string(),
637            parts: vec![adk_core::types::Part::Text { text: msg }],
638        };
639        let session = self.session_handle().await?;
640        session.send_event(event).await
641    }
642
643    /// Send a typed raw-audio chunk to the session.
644    ///
645    /// This preserves the audio format at the provider boundary and lets the
646    /// provider choose its native encoding path. Prefer this method when the
647    /// caller already owns raw audio bytes.
648    pub async fn send_audio_chunk(&self, audio: &crate::audio::AudioChunk) -> Result<()> {
649        let session = self.session_handle().await?;
650        session.send_audio(audio).await
651    }
652
653    /// Send base64-encoded audio to the session.
654    ///
655    /// This compatibility entry point is useful when the caller already has a
656    /// base64 payload. Raw-audio callers should use
657    /// [`send_audio_chunk`](Self::send_audio_chunk) to avoid forcing an encoding
658    /// decision at the provider-neutral runner boundary.
659    pub async fn send_audio(&self, audio_base64: &str) -> Result<()> {
660        let session = self.session_handle().await?;
661        session.send_audio_base64(audio_base64).await
662    }
663
664    /// Send text to the session.
665    pub async fn send_text(&self, text: &str) -> Result<()> {
666        let session = self.session_handle().await?;
667        session.send_text(text).await
668    }
669
670    /// Send a base64-encoded video/image frame (e.g. `image/jpeg`) for
671    /// multimodal input, where the provider supports it (Gemini Live; OpenAI as
672    /// an image-in-context item).
673    pub async fn send_video_frame(&self, mime_type: &str, data_base64: &str) -> Result<()> {
674        let session = self.session_handle().await?;
675        session.send_video_frame(mime_type, data_base64).await
676    }
677
678    /// Commit the audio buffer (for manual VAD mode).
679    pub async fn commit_audio(&self) -> Result<()> {
680        let session = self.session_handle().await?;
681        session.commit_audio().await
682    }
683
684    /// Trigger a response from the model.
685    pub async fn create_response(&self) -> Result<()> {
686        let session = self.session_handle().await?;
687        session.create_response().await
688    }
689
690    /// Interrupt the current response.
691    pub async fn interrupt(&self) -> Result<()> {
692        let session = self.session_handle().await?;
693        session.interrupt().await
694    }
695
696    /// Get the next raw event from the session.
697    ///
698    /// # Example
699    ///
700    /// ```rust,ignore
701    /// use adk_realtime::events::ServerEvent;
702    /// use tracing::{info, error};
703    ///
704    /// async fn process_events(runner: &adk_realtime::RealtimeRunner) {
705    ///     while let Some(event) = runner.next_event().await {
706    ///         match event {
707    ///             Ok(ServerEvent::SpeechStarted { .. }) => info!("User is speaking"),
708    ///             Ok(_) => info!("Received other event"),
709    ///             Err(e) => error!("Error: {e}"),
710    ///         }
711    ///     }
712    /// }
713    /// ```
714    /// Why the provider ended the stream, once [`Self::next_event`] has returned
715    /// `None`.
716    ///
717    /// Callers that poll `next_event` never see the runner's `on_disconnect`
718    /// dispatch, so without this a provider that deliberately closed an idle
719    /// session is indistinguishable from a dropped socket — and both get
720    /// recorded as the same generic stream failure.
721    pub async fn disconnect_reason(&self) -> Option<crate::session::DisconnectReason> {
722        self.session_handle().await.ok().and_then(|session| session.disconnect_reason())
723    }
724
725    pub async fn next_event(&self) -> Option<Result<ServerEvent>> {
726        let session = match self.session_handle().await {
727            Ok(session) => session,
728            Err(_) => {
729                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
730                return None;
731            }
732        };
733
734        // Some sessions might yield inside next_event, but just in case, yield here too
735        tokio::task::yield_now().await;
736        session.next_event().await
737    }
738
739    /// Send a tool response to the session.
740    ///
741    /// # Example
742    ///
743    /// ```rust,ignore
744    /// use adk_realtime::events::ToolResponse;
745    /// use serde_json::json;
746    ///
747    /// async fn example(runner: &adk_realtime::RealtimeRunner) {
748    ///     let response = ToolResponse {
749    ///         call_id: "call_123".to_string(),
750    ///         output: json!({"temperature": 72}),
751    ///     };
752    ///     runner.send_tool_response(response).await.unwrap();
753    /// }
754    /// ```
755    pub async fn send_tool_response(&self, response: ToolResponse) -> Result<()> {
756        let session = self.session_handle().await?;
757        session.send_tool_response(response).await
758    }
759
760    /// Execute a tool call against the registered handlers, sending the result
761    /// back to the model when `auto_respond_tools` is enabled.
762    ///
763    /// This is the same dispatch the [`run`](Self::run) loop performs for a
764    /// `response.function_call_arguments.done` event, exposed so that callers
765    /// driving the session manually via [`next_event`](Self::next_event) — such
766    /// as `IntegratedRealtimeRunner` (available with the `integration` feature) —
767    /// can execute tools without re-implementing the lookup/respond logic.
768    pub async fn dispatch_tool_call(
769        &self,
770        call_id: &str,
771        name: &str,
772        arguments: &str,
773    ) -> Result<()> {
774        self.execute_tool_call(call_id, name, arguments).await
775    }
776
777    /// Sends a tool result the caller produced, honouring `auto_respond_tools`.
778    ///
779    /// Used by the integration layer, which runs ADK tools through its own policy pipeline and
780    /// then needs the result delivered exactly as `execute_tool_call` would deliver it: the
781    /// output is sent now, and the single follow-up `create_response` is deferred until the
782    /// dispatching response closes, so several parallel calls produce one response.
783    ///
784    /// # Example
785    ///
786    /// ```rust,ignore
787    /// let result = my_policy_pipeline.run(&call).await?;
788    /// runner.send_tool_result(&call.call_id, result).await?;
789    /// ```
790    pub async fn send_tool_result(&self, call_id: &str, output: serde_json::Value) -> Result<()> {
791        if !self.runner_config.auto_respond_tools {
792            return Ok(());
793        }
794
795        if let Ok(session) = self.session_handle().await {
796            session.send_tool_output(ToolResponse { call_id: call_id.to_string(), output }).await?;
797            self.pending_tool_response.store(true, Ordering::Release);
798        }
799        Ok(())
800    }
801
802    /// Run the event loop, processing events until disconnected.
803    pub async fn run(&self) -> Result<()> {
804        use futures::stream::{FuturesUnordered, StreamExt};
805
806        // Tool handlers run as futures on this set rather than inline, so reading the next
807        // provider event never waits for a tool. `max_concurrent_tools` bounds how many
808        // are past their permit acquisition at once.
809        let mut running_tools = FuturesUnordered::new();
810
811        loop {
812            let session = self.session_handle().await?;
813            let old_session_id = session.session_id().to_string();
814
815            // With no tools in flight there is nothing to drain, and polling an empty
816            // `FuturesUnordered` in a `select!` would spin.
817            let event = if running_tools.is_empty() {
818                session.next_event().await
819            } else {
820                tokio::select! {
821                    biased;
822                    Some(finished) = running_tools.next() => {
823                        let () = finished?;
824                        continue;
825                    }
826                    event = session.next_event() => event,
827                }
828            };
829
830            match event {
831                Some(Ok(event)) => {
832                    if let Some(call) = self.handle_event(event).await? {
833                        self.outstanding_tools.fetch_add(1, Ordering::AcqRel);
834                        running_tools.push(self.run_tool_call(call));
835                    }
836                }
837                Some(Err(e)) => {
838                    self.event_handler.on_error(&e).await?;
839                    return Err(e);
840                }
841                None => {
842                    // Session closed or swapped out. Check if a new session was installed (e.g., during reconnect).
843                    let current_session_id = self.session_id().await;
844                    if let Some(id) = current_session_id
845                        && id != old_session_id
846                    {
847                        // A new session handle was installed concurrently. Continue polling.
848                        continue;
849                    }
850                    // A real disconnect. Let dispatched tools finish so their output and
851                    // the follow-up response are not dropped mid-flight.
852                    while let Some(finished) = running_tools.next().await {
853                        finished?;
854                    }
855                    // Surfaced distinctly: `run` returning `Ok(())` alone cannot be told
856                    // apart from a graceful `close`.
857                    self.event_handler.on_disconnect().await?;
858                    break;
859                }
860            }
861        }
862        Ok(())
863    }
864
865    /// Run one dispatched tool call under the configured concurrency bound.
866    ///
867    /// The permit is acquired inside the future so queueing a call never blocks the event
868    /// loop; the bound applies to execution, not to admission.
869    async fn run_tool_call(&self, call: PendingToolCall) -> Result<()> {
870        let permit = Arc::clone(&self.tool_permits).acquire_owned().await;
871        let result = self.execute_tool_call(&call.call_id, &call.name, &call.arguments).await;
872        drop(permit);
873
874        // The follow-up response is owed once *every* dispatched tool is in and the
875        // dispatching response has closed, whichever happens last. Before tools ran
876        // concurrently, ordering was implicit: `ResponseDone` could not arrive until the
877        // inline await returned. It can now, so the last tool to finish issues it.
878        if self.outstanding_tools.fetch_sub(1, Ordering::AcqRel) == 1
879            && self.response_closed_awaiting_tools.swap(false, Ordering::AcqRel)
880        {
881            self.respond_after_tools().await?;
882        }
883        result
884    }
885
886    /// Process a single event.
887    async fn handle_event(&self, event: ServerEvent) -> Result<Option<PendingToolCall>> {
888        // Track state transitions before forwarding the event
889        match &event {
890            ServerEvent::ResponseCreated { .. } => {
891                let mut state = self.state.write().await;
892                if let RunnerState::Idle = *state {
893                    *state = RunnerState::Generating;
894                }
895            }
896            ServerEvent::FunctionCallDone { .. } => {
897                let mut state = self.state.write().await;
898                if let RunnerState::Generating | RunnerState::Idle = *state {
899                    *state = RunnerState::ExecutingTool;
900                }
901            }
902            _ => {}
903        }
904
905        match event {
906            ServerEvent::AudioDelta { delta, item_id, .. } => {
907                self.event_handler.on_audio(&delta, &item_id).await?;
908            }
909            ServerEvent::TextDelta { delta, item_id, .. } => {
910                self.event_handler.on_text(&delta, &item_id).await?;
911            }
912            ServerEvent::TranscriptDelta { delta, item_id, .. } => {
913                self.event_handler.on_transcript(&delta, &item_id).await?;
914            }
915            ServerEvent::SpeechStarted { audio_start_ms, .. } => {
916                self.event_handler.on_speech_started(audio_start_ms).await?;
917            }
918            ServerEvent::SpeechStopped { audio_end_ms, .. } => {
919                self.event_handler.on_speech_stopped(audio_end_ms).await?;
920            }
921            ServerEvent::ResponseDone { .. } => {
922                self.event_handler.on_response_done().await?;
923                // If this response dispatched tool call(s), send the one owed
924                // follow-up response now that it's closed.
925                self.respond_after_tools().await?;
926                self.check_resumption_queue().await?;
927            }
928            ServerEvent::FunctionCallDone { call_id, name, arguments, .. }
929                if self.runner_config.auto_execute_tools =>
930            {
931                // Returned rather than awaited: the run loop dispatches it so event
932                // intake — audio deltas included — continues while the tool runs.
933                return Ok(Some(PendingToolCall { call_id, name, arguments }));
934            }
935            ServerEvent::SessionUpdated { session, .. } => {
936                // Check if the generic session update contains a resumption token
937                if let Some(token) = session.get("resumeToken").and_then(|t| t.as_str()) {
938                    tracing::info!(
939                        "Received Gemini sessionResumption token, saving for future reconnects."
940                    );
941                    let mut config = self.config.write().await;
942                    let mut extra = config.extra.clone().unwrap_or_else(|| serde_json::json!({}));
943                    extra["resumeToken"] = serde_json::Value::String(token.to_string());
944                    config.extra = Some(extra);
945                }
946            }
947            ServerEvent::Error { error, .. } => {
948                let err = RealtimeError::server(error.code.unwrap_or_default(), error.message);
949                self.event_handler.on_error(&err).await?;
950            }
951            _ => {
952                // Ignore other events
953            }
954        }
955        Ok(None)
956    }
957
958    /// Safely transitions the runner back to Idle and executes any queued resumptions.
959    async fn check_resumption_queue(&self) -> Result<()> {
960        // 1. Acquire the state lock to inspect the queue.
961        let mut state = self.state.write().await;
962
963        // 2. Extract the pending configuration and attempt count if one exists.
964        let pending =
965            if let RunnerState::PendingResumption { config, bridge_message, attempts } = &*state {
966                Some((config.clone(), bridge_message.clone(), *attempts))
967            } else {
968                None
969            };
970
971        if let Some((config, bridge_message, attempts)) = pending {
972            tracing::info!(
973                "Executing queued resumption after turn completion. (Attempt {})",
974                attempts + 1
975            );
976
977            // 3. Mark the state as Idle so the background loop is unblocked.
978            *state = RunnerState::Idle;
979
980            // 4. Release the state lock *before* performing the heavy async socket connection.
981            drop(state);
982
983            // 5. Attempt the actual transport teardown/rebuild.
984            if let Err(e) = self.execute_resumption((*config).clone(), bridge_message.clone()).await
985            {
986                tracing::error!("Resumption failed: {}.", e);
987
988                // 6. If the reconnect fails (e.g., transient network error), re-acquire the lock
989                // to safely handle the retry logic without crashing the event loop.
990                let mut fallback_state = self.state.write().await;
991
992                // 7. Enforce a maximum retry budget to prevent infinite "hot-looping"
993                if attempts + 1 >= 3 {
994                    tracing::error!(
995                        "Maximum resumption attempts reached (3). Dropping queued mutation to prevent infinite loop."
996                    );
997                    *fallback_state = RunnerState::Idle;
998                } else {
999                    tracing::info!("Restoring pending queue state for retry.");
1000                    *fallback_state = RunnerState::PendingResumption {
1001                        config,
1002                        bridge_message,
1003                        attempts: attempts + 1,
1004                    };
1005                }
1006
1007                // 8. Do not return Err(e) here, as that would permanently kill the `run()` loop.
1008                // Instead, report the error to the downstream handler and allow the event loop to continue spinning.
1009                let _ = self.event_handler.on_error(&e).await;
1010            }
1011        } else {
1012            // No resumptions were queued; simply mark as Idle.
1013            *state = RunnerState::Idle;
1014        }
1015        Ok(())
1016    }
1017
1018    /// Execute a tool call and optionally send the response.
1019    async fn execute_tool_call(&self, call_id: &str, name: &str, arguments: &str) -> Result<()> {
1020        let handler = self.tools.get(name).map(|(_, h)| h.clone());
1021
1022        let result = if let Some(handler) = handler {
1023            let args: serde_json::Value = serde_json::from_str(arguments)
1024                .unwrap_or(serde_json::Value::Object(Default::default()));
1025
1026            let call =
1027                ToolCall { call_id: call_id.to_string(), name: name.to_string(), arguments: args };
1028
1029            match handler.execute(&call).await {
1030                Ok(value) => value,
1031                Err(e) => serde_json::json!({
1032                    "error": e.to_string()
1033                }),
1034            }
1035        } else {
1036            serde_json::json!({
1037                "error": format!("Unknown tool: {}", name)
1038            })
1039        };
1040
1041        if self.runner_config.auto_respond_tools {
1042            let response = ToolResponse { call_id: call_id.to_string(), output: result };
1043
1044            if let Ok(session) = self.session_handle().await {
1045                // Send the output now, but defer the response trigger: several
1046                // parallel tool calls in one response must produce a *single*
1047                // `create_response`, issued once the dispatch response finishes
1048                // (see `respond_after_tools`). Firing one per output collides
1049                // with the still-active response on OpenAI.
1050                session.send_tool_output(response).await?;
1051                self.pending_tool_response.store(true, Ordering::Release);
1052            }
1053        }
1054
1055        Ok(())
1056    }
1057
1058    /// The system instruction the next connection will use.
1059    ///
1060    /// Exposed so callers and tests can confirm what context a session was actually created
1061    /// with, rather than inferring it from log lines.
1062    pub async fn instruction(&self) -> Option<String> {
1063        self.config.read().await.instruction.clone()
1064    }
1065
1066    /// Prepends a context block to the system instruction before connecting.
1067    ///
1068    /// The integration layer uses this to carry prior conversation history and recalled memory
1069    /// into the provider session. Call it before [`RealtimeRunner::connect`]: providers read
1070    /// the instruction at session creation, so a later change needs `update_session`.
1071    ///
1072    /// # Example
1073    ///
1074    /// ```rust,ignore
1075    /// runner.prepend_instruction_context("Previously discussed: the refund policy.").await;
1076    /// runner.connect().await?;
1077    /// ```
1078    pub async fn prepend_instruction_context(&self, block: &str) {
1079        if block.is_empty() {
1080            return;
1081        }
1082
1083        let mut config = self.config.write().await;
1084        config.instruction = Some(match config.instruction.take() {
1085            Some(existing) if !existing.is_empty() => format!("{block}\n\n{existing}"),
1086            _ => block.to_string(),
1087        });
1088    }
1089
1090    /// Trigger the single follow-up response owed after a tool-dispatching turn.
1091    ///
1092    /// Call this when a response finishes (`ResponseDone`). If tool output(s)
1093    /// were sent back during that response (`auto_respond_tools`), the model now
1094    /// needs one `create_response` to speak its answer — issued here, after the
1095    /// dispatch response is closed and every parallel tool output is in, rather
1096    /// than once per tool call. Gemini's `create_response` is a no-op, so this is
1097    /// safely uniform across providers. No-op when nothing is pending.
1098    pub async fn respond_after_tools(&self) -> Result<()> {
1099        // Tools run concurrently with event intake, so this can be reached before their
1100        // output is in. Defer to the last tool to finish rather than firing a response the
1101        // model cannot yet answer — or, worse, dropping it because
1102        // `pending_tool_response` is not set yet.
1103        if self.outstanding_tools.load(Ordering::Acquire) > 0 {
1104            self.response_closed_awaiting_tools.store(true, Ordering::Release);
1105            return Ok(());
1106        }
1107
1108        if self.pending_tool_response.swap(false, Ordering::AcqRel)
1109            && let Ok(session) = self.session_handle().await
1110        {
1111            session.create_response().await?;
1112        }
1113        Ok(())
1114    }
1115
1116    /// Close the session.
1117    pub async fn close(&self) -> Result<()> {
1118        if let Ok(session) = self.session_handle().await {
1119            session.close().await?;
1120        }
1121        Ok(())
1122    }
1123}
1124
1125#[cfg(test)]
1126mod runner_tests {
1127    use super::*;
1128    use crate::audio::{AudioChunk, AudioFormat};
1129    use crate::events::{ClientEvent, ToolResponse};
1130    use crate::model::RealtimeModel;
1131    use crate::session::{BoxedSession, ContextMutationOutcome, RealtimeSession};
1132    use std::pin::Pin;
1133    use std::sync::atomic::AtomicUsize;
1134
1135    /// A model just good enough to satisfy the builder (never connects in tests).
1136    struct MockModel;
1137
1138    #[async_trait]
1139    impl RealtimeModel for MockModel {
1140        fn provider(&self) -> &str {
1141            "mock"
1142        }
1143        fn model_id(&self) -> &str {
1144            "mock"
1145        }
1146        fn supported_input_formats(&self) -> Vec<AudioFormat> {
1147            vec![]
1148        }
1149        fn supported_output_formats(&self) -> Vec<AudioFormat> {
1150            vec![]
1151        }
1152        fn available_voices(&self) -> Vec<&str> {
1153            vec![]
1154        }
1155        async fn connect(&self, _config: RealtimeConfig) -> Result<BoxedSession> {
1156            Err(RealtimeError::connection("mock model does not connect"))
1157        }
1158    }
1159
1160    /// Records which provider-session entry points the runner calls.
1161    #[derive(Default)]
1162    struct Counts {
1163        raw_audio: AtomicUsize,
1164        base64_audio: AtomicUsize,
1165        last_audio: parking_lot::Mutex<Option<AudioChunk>>,
1166        tool_output: AtomicUsize,
1167        tool_response: AtomicUsize,
1168        create_response: AtomicUsize,
1169    }
1170
1171    struct RecordingSession {
1172        counts: Arc<Counts>,
1173    }
1174
1175    #[async_trait]
1176    impl RealtimeSession for RecordingSession {
1177        fn session_id(&self) -> &str {
1178            "mock-session"
1179        }
1180        fn is_connected(&self) -> bool {
1181            true
1182        }
1183        async fn send_audio(&self, audio: &AudioChunk) -> Result<()> {
1184            self.counts.raw_audio.fetch_add(1, Ordering::SeqCst);
1185            *self.counts.last_audio.lock() = Some(audio.clone());
1186            Ok(())
1187        }
1188        async fn send_audio_base64(&self, _audio: &str) -> Result<()> {
1189            self.counts.base64_audio.fetch_add(1, Ordering::SeqCst);
1190            Ok(())
1191        }
1192        async fn send_text(&self, _text: &str) -> Result<()> {
1193            Ok(())
1194        }
1195        async fn send_tool_response(&self, _response: ToolResponse) -> Result<()> {
1196            self.counts.tool_response.fetch_add(1, Ordering::SeqCst);
1197            Ok(())
1198        }
1199        async fn send_tool_output(&self, _response: ToolResponse) -> Result<()> {
1200            self.counts.tool_output.fetch_add(1, Ordering::SeqCst);
1201            Ok(())
1202        }
1203        async fn commit_audio(&self) -> Result<()> {
1204            Ok(())
1205        }
1206        async fn clear_audio(&self) -> Result<()> {
1207            Ok(())
1208        }
1209        async fn create_response(&self) -> Result<()> {
1210            self.counts.create_response.fetch_add(1, Ordering::SeqCst);
1211            Ok(())
1212        }
1213        async fn interrupt(&self) -> Result<()> {
1214            Ok(())
1215        }
1216        async fn send_event(&self, _event: ClientEvent) -> Result<()> {
1217            Ok(())
1218        }
1219        async fn next_event(&self) -> Option<Result<ServerEvent>> {
1220            None
1221        }
1222        fn events(&self) -> Pin<Box<dyn futures::Stream<Item = Result<ServerEvent>> + Send + '_>> {
1223            Box::pin(futures::stream::empty())
1224        }
1225        async fn close(&self) -> Result<()> {
1226            Ok(())
1227        }
1228        async fn mutate_context(&self, _config: RealtimeConfig) -> Result<ContextMutationOutcome> {
1229            Ok(ContextMutationOutcome::Applied)
1230        }
1231    }
1232
1233    fn tool_def(name: &str) -> ToolDefinition {
1234        ToolDefinition { name: name.into(), description: None, parameters: None }
1235    }
1236
1237    fn ok_tool() -> FnToolHandler<impl Fn(&ToolCall) -> Result<serde_json::Value> + Send + Sync> {
1238        FnToolHandler::new(|_call: &ToolCall| Ok(serde_json::json!({ "ok": true })))
1239    }
1240
1241    fn function_call(call_id: &str, name: &str) -> ServerEvent {
1242        ServerEvent::FunctionCallDone {
1243            event_id: "evt".into(),
1244            response_id: "resp".into(),
1245            item_id: "item".into(),
1246            output_index: 0,
1247            call_id: call_id.into(),
1248            name: name.into(),
1249            arguments: "{}".into(),
1250        }
1251    }
1252
1253    fn response_done() -> ServerEvent {
1254        ServerEvent::ResponseDone { event_id: "evt".into(), response: serde_json::json!({}) }
1255    }
1256
1257    async fn runner_with_session(counts: Arc<Counts>) -> RealtimeRunner {
1258        let runner =
1259            RealtimeRunner::builder().model(Arc::new(MockModel) as BoxedModel).build().unwrap();
1260        let session = Arc::new(RecordingSession { counts }) as Arc<dyn RealtimeSession>;
1261
1262        // The unit test owns the runner and installs the same session handle
1263        // that `connect` would publish after provider setup.
1264        *runner.session.write().await = Some(session);
1265        runner
1266    }
1267
1268    #[tokio::test]
1269    async fn send_audio_chunk_preserves_bytes_and_format_on_raw_path() {
1270        let counts = Arc::new(Counts::default());
1271        let runner = runner_with_session(Arc::clone(&counts)).await;
1272        let chunk = AudioChunk::pcm16_24khz(vec![1, 2, 3, 4]);
1273
1274        runner.send_audio_chunk(&chunk).await.unwrap();
1275
1276        assert_eq!(counts.raw_audio.load(Ordering::SeqCst), 1);
1277        assert_eq!(counts.base64_audio.load(Ordering::SeqCst), 0);
1278        let recorded = counts.last_audio.lock();
1279        let recorded = recorded.as_ref().unwrap();
1280        assert_eq!(recorded.data, chunk.data);
1281        assert_eq!(recorded.format, chunk.format);
1282    }
1283
1284    #[tokio::test]
1285    async fn send_audio_keeps_base64_compatibility_path() {
1286        let counts = Arc::new(Counts::default());
1287        let runner = runner_with_session(Arc::clone(&counts)).await;
1288
1289        runner.send_audio("AQIDBA==").await.unwrap();
1290
1291        assert_eq!(counts.raw_audio.load(Ordering::SeqCst), 0);
1292        assert_eq!(counts.base64_audio.load(Ordering::SeqCst), 1);
1293    }
1294
1295    /// Two parallel tool calls in one response must produce exactly one
1296    /// `create_response` — issued after the dispatch response finishes — not one
1297    /// per tool (which collides with the still-active response on OpenAI).
1298    #[tokio::test]
1299    async fn parallel_tool_calls_trigger_a_single_response() {
1300        let counts = Arc::new(Counts::default());
1301        let runner = RealtimeRunner::builder()
1302            .model(Arc::new(MockModel) as BoxedModel)
1303            .tool(tool_def("get_weather"), ok_tool())
1304            .tool(tool_def("get_time"), ok_tool())
1305            .build()
1306            .unwrap();
1307        *runner.session.write().await =
1308            Some(Arc::new(RecordingSession { counts: counts.clone() }) as Arc<dyn RealtimeSession>);
1309
1310        // One model response dispatching two tool calls, then ending. Driven through
1311        // `run`, because dispatch is the run loop's job now — `handle_event` returns the
1312        // call rather than awaiting it, so event intake is not blocked by a tool.
1313        let scripted = Arc::new(ScriptedSession::new(
1314            counts.clone(),
1315            vec![
1316                function_call("c1", "get_weather"),
1317                function_call("c2", "get_time"),
1318                response_done(),
1319            ],
1320        ));
1321        *runner.session.write().await = Some(scripted as Arc<dyn RealtimeSession>);
1322        runner.run().await.unwrap();
1323
1324        assert_eq!(counts.tool_output.load(Ordering::SeqCst), 2, "both outputs sent");
1325        assert_eq!(counts.create_response.load(Ordering::SeqCst), 1, "exactly one response");
1326        assert_eq!(
1327            counts.tool_response.load(Ordering::SeqCst),
1328            0,
1329            "auto path must use send_tool_output, not the output+create combo"
1330        );
1331
1332        // The follow-up (spoken-answer) response finishing creates nothing more.
1333        runner.handle_event(response_done()).await.unwrap();
1334        assert_eq!(counts.create_response.load(Ordering::SeqCst), 1, "no extra response");
1335    }
1336
1337    /// A response with no tool calls must not trigger an auto follow-up response.
1338    #[tokio::test]
1339    async fn plain_response_triggers_no_auto_response() {
1340        let counts = Arc::new(Counts::default());
1341        let runner =
1342            RealtimeRunner::builder().model(Arc::new(MockModel) as BoxedModel).build().unwrap();
1343        *runner.session.write().await =
1344            Some(Arc::new(RecordingSession { counts: counts.clone() }) as Arc<dyn RealtimeSession>);
1345
1346        runner.handle_event(response_done()).await.unwrap();
1347        assert_eq!(counts.create_response.load(Ordering::SeqCst), 0);
1348        assert_eq!(counts.tool_output.load(Ordering::SeqCst), 0);
1349    }
1350
1351    // ── Bounded concurrent tool dispatch ──────────────────────────────────
1352    //
1353    // `RunnerConfig::max_concurrent_tools` defaulted to four and was read by nothing: no
1354    // semaphore, no scheduler. `FunctionCallDone` was awaited inline inside `handle_event`,
1355    // which the run loop awaited before reading the next event, so tool calls ran strictly
1356    // one at a time *and* stalled audio and every other event for the duration.
1357
1358    /// A session that replays a scripted event sequence, then reports disconnect.
1359    struct ScriptedSession {
1360        counts: Arc<Counts>,
1361        events: parking_lot::Mutex<std::collections::VecDeque<ServerEvent>>,
1362    }
1363
1364    impl ScriptedSession {
1365        fn new(counts: Arc<Counts>, events: Vec<ServerEvent>) -> Self {
1366            Self { counts, events: parking_lot::Mutex::new(events.into()) }
1367        }
1368    }
1369
1370    #[async_trait]
1371    impl RealtimeSession for ScriptedSession {
1372        fn session_id(&self) -> &str {
1373            "scripted-session"
1374        }
1375        fn is_connected(&self) -> bool {
1376            true
1377        }
1378        async fn send_audio(&self, _audio: &AudioChunk) -> Result<()> {
1379            Ok(())
1380        }
1381        async fn send_audio_base64(&self, _audio: &str) -> Result<()> {
1382            Ok(())
1383        }
1384        async fn send_text(&self, _text: &str) -> Result<()> {
1385            Ok(())
1386        }
1387        async fn send_tool_response(&self, _response: ToolResponse) -> Result<()> {
1388            self.counts.tool_response.fetch_add(1, Ordering::SeqCst);
1389            Ok(())
1390        }
1391        async fn send_tool_output(&self, _response: ToolResponse) -> Result<()> {
1392            self.counts.tool_output.fetch_add(1, Ordering::SeqCst);
1393            Ok(())
1394        }
1395        async fn commit_audio(&self) -> Result<()> {
1396            Ok(())
1397        }
1398        async fn clear_audio(&self) -> Result<()> {
1399            Ok(())
1400        }
1401        async fn create_response(&self) -> Result<()> {
1402            self.counts.create_response.fetch_add(1, Ordering::SeqCst);
1403            Ok(())
1404        }
1405        async fn interrupt(&self) -> Result<()> {
1406            Ok(())
1407        }
1408        async fn send_event(&self, _event: ClientEvent) -> Result<()> {
1409            Ok(())
1410        }
1411        async fn next_event(&self) -> Option<Result<ServerEvent>> {
1412            let event = self.events.lock().pop_front();
1413            event.map(Ok)
1414        }
1415        fn events(&self) -> Pin<Box<dyn futures::Stream<Item = Result<ServerEvent>> + Send + '_>> {
1416            Box::pin(futures::stream::empty())
1417        }
1418        async fn close(&self) -> Result<()> {
1419            Ok(())
1420        }
1421        async fn mutate_context(&self, _config: RealtimeConfig) -> Result<ContextMutationOutcome> {
1422            Ok(ContextMutationOutcome::Applied)
1423        }
1424    }
1425
1426    /// A tool that reports how many copies of itself run at once.
1427    struct ConcurrencyProbe {
1428        in_flight: Arc<AtomicUsize>,
1429        peak: Arc<AtomicUsize>,
1430        barrier: Option<Arc<tokio::sync::Barrier>>,
1431    }
1432
1433    #[async_trait]
1434    impl ToolHandler for ConcurrencyProbe {
1435        async fn execute(&self, _call: &ToolCall) -> Result<serde_json::Value> {
1436            let now = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1437            self.peak.fetch_max(now, Ordering::SeqCst);
1438
1439            match &self.barrier {
1440                // Every participant must be running for this to return, so it can only
1441                // complete if the dispatcher truly overlaps them.
1442                Some(barrier) => {
1443                    barrier.wait().await;
1444                }
1445                None => {
1446                    tokio::task::yield_now().await;
1447                }
1448            }
1449
1450            self.in_flight.fetch_sub(1, Ordering::SeqCst);
1451            Ok(serde_json::json!({ "ok": true }))
1452        }
1453    }
1454
1455    fn audio_delta() -> ServerEvent {
1456        ServerEvent::AudioDelta {
1457            event_id: "evt".into(),
1458            response_id: "resp".into(),
1459            item_id: "item".into(),
1460            output_index: 0,
1461            content_index: 0,
1462            delta: vec![1, 2, 3],
1463        }
1464    }
1465
1466    #[tokio::test]
1467    async fn tool_calls_overlap_up_to_the_configured_bound() {
1468        let counts = Arc::new(Counts::default());
1469        let in_flight = Arc::new(AtomicUsize::new(0));
1470        let peak = Arc::new(AtomicUsize::new(0));
1471        // Only satisfiable if all three run at once, which serial dispatch cannot do.
1472        let barrier = Arc::new(tokio::sync::Barrier::new(3));
1473
1474        let probe = || ConcurrencyProbe {
1475            in_flight: Arc::clone(&in_flight),
1476            peak: Arc::clone(&peak),
1477            barrier: Some(Arc::clone(&barrier)),
1478        };
1479
1480        let runner = RealtimeRunner::builder()
1481            .model(Arc::new(MockModel) as BoxedModel)
1482            .runner_config(RunnerConfig {
1483                auto_execute_tools: true,
1484                auto_respond_tools: true,
1485                max_concurrent_tools: 3,
1486            })
1487            .tool(tool_def("a"), probe())
1488            .tool(tool_def("b"), probe())
1489            .tool(tool_def("c"), probe())
1490            .build()
1491            .unwrap();
1492
1493        let scripted = Arc::new(ScriptedSession::new(
1494            Arc::clone(&counts),
1495            vec![function_call("c1", "a"), function_call("c2", "b"), function_call("c3", "c")],
1496        ));
1497        *runner.session.write().await = Some(scripted as Arc<dyn RealtimeSession>);
1498
1499        tokio::time::timeout(std::time::Duration::from_secs(5), runner.run())
1500            .await
1501            .expect("serial dispatch cannot satisfy a three-way barrier")
1502            .unwrap();
1503
1504        assert_eq!(peak.load(Ordering::SeqCst), 3, "all three tools must overlap");
1505        assert_eq!(counts.tool_output.load(Ordering::SeqCst), 3, "every output is sent");
1506    }
1507
1508    #[tokio::test]
1509    async fn the_bound_caps_how_many_tools_overlap() {
1510        let counts = Arc::new(Counts::default());
1511        let in_flight = Arc::new(AtomicUsize::new(0));
1512        let peak = Arc::new(AtomicUsize::new(0));
1513
1514        let probe = || ConcurrencyProbe {
1515            in_flight: Arc::clone(&in_flight),
1516            peak: Arc::clone(&peak),
1517            barrier: None,
1518        };
1519
1520        let runner = RealtimeRunner::builder()
1521            .model(Arc::new(MockModel) as BoxedModel)
1522            .runner_config(RunnerConfig {
1523                auto_execute_tools: true,
1524                auto_respond_tools: true,
1525                max_concurrent_tools: 2,
1526            })
1527            .tool(tool_def("a"), probe())
1528            .tool(tool_def("b"), probe())
1529            .tool(tool_def("c"), probe())
1530            .tool(tool_def("d"), probe())
1531            .build()
1532            .unwrap();
1533
1534        let scripted = Arc::new(ScriptedSession::new(
1535            Arc::clone(&counts),
1536            vec![
1537                function_call("c1", "a"),
1538                function_call("c2", "b"),
1539                function_call("c3", "c"),
1540                function_call("c4", "d"),
1541            ],
1542        ));
1543        *runner.session.write().await = Some(scripted as Arc<dyn RealtimeSession>);
1544
1545        runner.run().await.unwrap();
1546
1547        assert!(
1548            peak.load(Ordering::SeqCst) <= 2,
1549            "the bound was exceeded: peak {}",
1550            peak.load(Ordering::SeqCst)
1551        );
1552        assert_eq!(counts.tool_output.load(Ordering::SeqCst), 4, "all four still complete");
1553    }
1554
1555    /// A tool that blocks until an audio event has been handled.
1556    struct WaitsForAudio {
1557        audio_seen: Arc<tokio::sync::Notify>,
1558    }
1559
1560    #[async_trait]
1561    impl ToolHandler for WaitsForAudio {
1562        async fn execute(&self, _call: &ToolCall) -> Result<serde_json::Value> {
1563            self.audio_seen.notified().await;
1564            Ok(serde_json::json!({ "ok": true }))
1565        }
1566    }
1567
1568    /// Signals the tool once audio is delivered.
1569    struct AudioSignaller {
1570        audio_seen: Arc<tokio::sync::Notify>,
1571        audio_events: Arc<AtomicUsize>,
1572    }
1573
1574    #[async_trait]
1575    impl EventHandler for AudioSignaller {
1576        async fn on_audio(&self, _audio: &[u8], _item_id: &str) -> Result<()> {
1577            self.audio_events.fetch_add(1, Ordering::SeqCst);
1578            self.audio_seen.notify_waiters();
1579            Ok(())
1580        }
1581    }
1582
1583    #[tokio::test]
1584    async fn audio_keeps_flowing_while_a_tool_runs() {
1585        let counts = Arc::new(Counts::default());
1586        let audio_seen = Arc::new(tokio::sync::Notify::new());
1587        let audio_events = Arc::new(AtomicUsize::new(0));
1588
1589        let runner = RealtimeRunner::builder()
1590            .model(Arc::new(MockModel) as BoxedModel)
1591            .tool(tool_def("slow"), WaitsForAudio { audio_seen: Arc::clone(&audio_seen) })
1592            .event_handler(AudioSignaller {
1593                audio_seen: Arc::clone(&audio_seen),
1594                audio_events: Arc::clone(&audio_events),
1595            })
1596            .build()
1597            .unwrap();
1598
1599        // The tool can only finish once the audio delta *after* it has been handled, so
1600        // this sequence completes only if event intake continues during tool execution.
1601        let scripted = Arc::new(ScriptedSession::new(
1602            Arc::clone(&counts),
1603            vec![function_call("c1", "slow"), audio_delta(), response_done()],
1604        ));
1605        *runner.session.write().await = Some(scripted as Arc<dyn RealtimeSession>);
1606
1607        tokio::time::timeout(std::time::Duration::from_secs(5), runner.run())
1608            .await
1609            .expect("a tool awaiting a later event deadlocks when dispatch blocks intake")
1610            .unwrap();
1611
1612        assert_eq!(audio_events.load(Ordering::SeqCst), 1, "the audio delta was handled");
1613        assert_eq!(counts.tool_output.load(Ordering::SeqCst), 1, "the tool still reported output");
1614    }
1615
1616    #[tokio::test]
1617    async fn the_follow_up_response_waits_for_tools_that_outlive_the_response() {
1618        let counts = Arc::new(Counts::default());
1619        let audio_seen = Arc::new(tokio::sync::Notify::new());
1620        let audio_events = Arc::new(AtomicUsize::new(0));
1621
1622        let runner = RealtimeRunner::builder()
1623            .model(Arc::new(MockModel) as BoxedModel)
1624            .tool(tool_def("slow"), WaitsForAudio { audio_seen: Arc::clone(&audio_seen) })
1625            .event_handler(AudioSignaller {
1626                audio_seen: Arc::clone(&audio_seen),
1627                audio_events: Arc::clone(&audio_events),
1628            })
1629            .build()
1630            .unwrap();
1631
1632        // `ResponseDone` arrives while the tool is still running — impossible before
1633        // dispatch was concurrent, and the case that would silently drop the follow-up
1634        // response, since `pending_tool_response` is only set once output is sent.
1635        let scripted = Arc::new(ScriptedSession::new(
1636            Arc::clone(&counts),
1637            vec![function_call("c1", "slow"), response_done(), audio_delta()],
1638        ));
1639        *runner.session.write().await = Some(scripted as Arc<dyn RealtimeSession>);
1640
1641        tokio::time::timeout(std::time::Duration::from_secs(5), runner.run())
1642            .await
1643            .expect("run must finish")
1644            .unwrap();
1645
1646        assert_eq!(counts.tool_output.load(Ordering::SeqCst), 1, "the output was sent");
1647        assert_eq!(
1648            counts.create_response.load(Ordering::SeqCst),
1649            1,
1650            "exactly one follow-up response, issued after the last tool finished"
1651        );
1652    }
1653
1654    /// Records terminal disconnects.
1655    #[derive(Default)]
1656    struct DisconnectWatcher {
1657        disconnects: Arc<AtomicUsize>,
1658    }
1659
1660    #[async_trait]
1661    impl EventHandler for DisconnectWatcher {
1662        async fn on_disconnect(&self) -> Result<()> {
1663            self.disconnects.fetch_add(1, Ordering::SeqCst);
1664            Ok(())
1665        }
1666    }
1667
1668    #[tokio::test]
1669    async fn a_terminal_disconnect_is_surfaced_once() {
1670        let counts = Arc::new(Counts::default());
1671        let disconnects = Arc::new(AtomicUsize::new(0));
1672
1673        let runner = RealtimeRunner::builder()
1674            .model(Arc::new(MockModel) as BoxedModel)
1675            .event_handler(DisconnectWatcher { disconnects: Arc::clone(&disconnects) })
1676            .build()
1677            .unwrap();
1678
1679        let scripted = Arc::new(ScriptedSession::new(Arc::clone(&counts), vec![response_done()]));
1680        *runner.session.write().await = Some(scripted as Arc<dyn RealtimeSession>);
1681
1682        // `run` returns `Ok(())` on transport loss, which on its own is indistinguishable
1683        // from a graceful `close`.
1684        runner.run().await.unwrap();
1685
1686        assert_eq!(
1687            disconnects.load(Ordering::SeqCst),
1688            1,
1689            "transport loss must be reported exactly once"
1690        );
1691    }
1692}