Skip to main content

gemini_adk_fluent_rs/live/
connect.rs

1//! Connection methods for `Live`.
2
3use gemini_adk_rs::live::{LiveHandle, LiveSessionBuilder, PhaseMachine};
4use gemini_genai_rs::prelude::*;
5
6use super::Live;
7
8/// Fold builder-registered ambient tools into the flow's own list.
9///
10/// Idempotent and duplicate-free, because an application may name a tool the
11/// flow already declares — or an extension may be installed twice.
12pub(crate) fn merge_ambient(flow: &mut gemini_adk_rs::flow::Flow, ambient: &[String]) {
13    for tool in ambient {
14        if !flow.ambient.contains(tool) {
15            flow.ambient.push(tool.clone());
16        }
17    }
18}
19
20/// The one governance object a session drives: the main monitor plus every
21/// digression and repair policy the builder holds. Ambient tools are merged
22/// into each digression's flow too, so a cross-cutting tool stays admitted
23/// while the main flow is suspended.
24pub(crate) fn assemble_stack(
25    main: gemini_adk_rs::flow::FlowMonitor,
26    digressions: Vec<gemini_adk_rs::flow::Overlay>,
27    repair: std::collections::BTreeMap<String, gemini_adk_rs::flow::RepairPolicy>,
28    timing: std::collections::BTreeMap<String, gemini_adk_rs::flow::VoiceTiming>,
29    corrections: std::collections::BTreeMap<String, Vec<String>>,
30    verbatim: std::collections::BTreeMap<String, String>,
31    ambient: &[String],
32) -> gemini_adk_rs::flow::FlowStack {
33    let overlays = digressions.into_iter().map(|mut ov| {
34        merge_ambient(ov.flow_mut(), ambient);
35        ov
36    });
37    gemini_adk_rs::flow::FlowStack::from_monitor(main)
38        .with_overlays(overlays)
39        .with_repairs(repair)
40        .with_timings(timing)
41        .with_corrections(corrections)
42        .with_verbatims(verbatim)
43}
44
45impl Live {
46    /// Connect using a Google AI API key.
47    pub async fn connect_google_ai(
48        mut self,
49        api_key: impl Into<String>,
50    ) -> Result<LiveHandle, gemini_adk_rs::error::AgentError> {
51        self.config.endpoint = ApiEndpoint::google_ai(api_key);
52        self.build_and_connect().await
53    }
54
55    /// Connect using Vertex AI credentials.
56    ///
57    /// `access_token` is a fixed token or an [`AccessToken`]; pass
58    /// `AccessToken::from_fn(..)` for a source that is consulted on every
59    /// reconnect, since Vertex tokens expire after about an hour.
60    pub async fn connect_vertex(
61        mut self,
62        project: impl Into<String>,
63        location: impl Into<String>,
64        access_token: impl Into<AccessToken>,
65    ) -> Result<LiveHandle, gemini_adk_rs::error::AgentError> {
66        self.config.endpoint = ApiEndpoint::vertex(project, location, access_token);
67        self.build_and_connect().await
68    }
69
70    /// Connect by resolving the platform and credentials from standard
71    /// environment variables — the zero-ceremony entry point.
72    ///
73    /// Resolution (see [`ApiEndpoint::from_env`]):
74    /// - `GOOGLE_GENAI_USE_VERTEXAI=true` → Vertex AI using
75    ///   `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_LOCATION` (default
76    ///   `us-central1`), and a token from `GOOGLE_ACCESS_TOKEN`. If that
77    ///   token is unset, the token comes from the metadata server on Cloud
78    ///   Run, GKE or Compute Engine, else from `gcloud auth
79    ///   print-access-token`, and is refreshed before it expires, so a
80    ///   reconnect late in a long session is still authenticated (see
81    ///   [`GoogleAccessToken`](gemini_genai_rs::transport::auth::GoogleAccessToken)).
82    ///   Builds without the `gemini-llm` or `gcs-store` feature have no HTTP
83    ///   client and use the `gcloud` CLI only.
84    /// - otherwise → Google AI using `GEMINI_API_KEY` (or
85    ///   `GOOGLE_GENAI_API_KEY` / `GOOGLE_API_KEY`).
86    ///
87    /// When no `.model(..)` was set, the wire layer resolves a default the
88    /// target platform actually serves (`ModelId::live_default`): `GEMINI_MODEL`
89    /// from the environment if present, else the platform's current
90    /// native-audio Flash model.
91    ///
92    /// ```no_run
93    /// # use gemini_adk_fluent_rs::prelude::*;
94    /// # async fn run() -> Result<(), AgentError> {
95    /// let handle = Live::builder()
96    ///     .voice(Voice::Kore)
97    ///     .connect_from_env()
98    ///     .await?;
99    /// # let _ = handle; Ok(())
100    /// # }
101    /// ```
102    pub async fn connect_from_env(
103        mut self,
104    ) -> Result<LiveHandle, gemini_adk_rs::error::AgentError> {
105        self.config.endpoint = resolve_endpoint_from_env().await?;
106        self.build_and_connect().await
107    }
108
109    /// Connect using a pre-configured SessionConfig for auth and model.
110    ///
111    /// Merges the provided config's `endpoint` and `model` into the builder's
112    /// config, preserving system instruction, tools, voice, transcription, and
113    /// all other settings configured via the fluent API.
114    pub async fn connect(
115        mut self,
116        config: SessionConfig,
117    ) -> Result<LiveHandle, gemini_adk_rs::error::AgentError> {
118        // Merge auth/model from external config, keep everything else from builder.
119        self.config.endpoint = config.endpoint;
120        self.config.model = config.model;
121        self.build_and_connect().await
122    }
123
124    /// Connect over `transport` instead of a WebSocket to Gemini, with
125    /// everything this builder configured: tools, phases, extractors,
126    /// watchers, governance and callbacks run for real against whatever the
127    /// transport answers.
128    ///
129    /// This is how to test a session offline. Script the server with
130    /// [`ScriptedServer`](crate::testing::ScriptedServer); no network or
131    /// credential is used.
132    pub async fn connect_with_transport<T: gemini_genai_rs::transport::Transport>(
133        self,
134        transport: T,
135    ) -> Result<LiveHandle, gemini_adk_rs::error::AgentError> {
136        self.build_and_connect_via(move |builder| builder.connect_with_transport(transport))
137            .await
138    }
139
140    async fn build_and_connect(self) -> Result<LiveHandle, gemini_adk_rs::error::AgentError> {
141        self.build_and_connect_via(LiveSessionBuilder::connect)
142            .await
143    }
144
145    async fn build_and_connect_via<F, Fut>(
146        mut self,
147        connect: F,
148    ) -> Result<LiveHandle, gemini_adk_rs::error::AgentError>
149    where
150        F: FnOnce(LiveSessionBuilder) -> Fut,
151        Fut: std::future::Future<Output = Result<LiveHandle, gemini_adk_rs::error::AgentError>>,
152    {
153        // Builder setters cannot fail; problems they found are reported here,
154        // with a `T::confirm` tool that nothing can confirm (agent and MCP
155        // tools resolved below are never gated, so this check is complete).
156        let mut issues = std::mem::take(&mut self.config_errors);
157        if self.tasks.is_some()
158            && (!self.config.tools.is_empty()
159                || self.flow.is_some()
160                || !self.phases.is_empty()
161                || self
162                    .dispatcher
163                    .as_ref()
164                    .is_some_and(|dispatcher| !dispatcher.is_empty())
165                || !self.deferred_tools.is_empty()
166                || !self.deferred_agent_tools.is_empty()
167                || !self.extractors.is_empty()
168                || !self.policies.is_empty())
169        {
170            issues.push(
171                "task skills cannot be combined with a session-level tool or governance pipeline"
172                    .into(),
173            );
174        }
175        let unconfirmed = self.unconfirmed_tools();
176        if !unconfirmed.is_empty() {
177            issues.push(
178                crate::testing::LiveViolation::UnconfirmedTools { tools: unconfirmed }.to_string(),
179            );
180        }
181        if !issues.is_empty() {
182            return Err(gemini_adk_rs::error::ConfigError { issues }.into());
183        }
184        if uses_audio_output(&self.config) {
185            self.config = self.config.voice_realtime_defaults();
186        }
187
188        // Client interruption authority: the server must stop deciding, so
189        // its automatic activity detection is disabled in the setup message
190        // (the input VAD's activity marks take over in `send_audio`).
191        if self.input_audio.client_authority {
192            self.config =
193                self.config
194                    .server_vad(gemini_genai_rs::prelude::AutomaticActivityDetection {
195                        disabled: Some(true),
196                        start_of_speech_sensitivity: None,
197                        end_of_speech_sensitivity: None,
198                        prefix_padding_ms: None,
199                        silence_duration_ms: None,
200                    });
201        }
202        let input_audio = std::mem::take(&mut self.input_audio);
203
204        // Resolve a `.record_wire(path)` request into a FileWireRecorder now
205        // that we are actually connecting.
206        if let Some(path) = self.record_wire_path.take() {
207            let recorder =
208                gemini_genai_rs::transport::FileWireRecorder::create(&path).map_err(|e| {
209                    gemini_adk_rs::error::AgentError::Config(format!(
210                        "failed to create wire log at {}: {e}",
211                        path.display()
212                    ))
213                })?;
214            self.config = self.config.record_wire(std::sync::Arc::new(recorder));
215        }
216
217        // Config-level tool declarations, captured before `config` moves.
218        let builder_config_tools = self.config.tools.clone();
219        let mut builder = LiveSessionBuilder::new(self.config);
220        if let Some(runtime) = self.tasks.take() {
221            builder = builder.tasks(runtime);
222        }
223
224        // The session's `State`. A caller-supplied one is used as-is so tools
225        // they already built around it write where the flow monitor and phase
226        // machine read; otherwise agent tools get a fresh one as before.
227        // Deferred agent tools and policies need the state before the runtime
228        // exists, so one is made here when the caller gave none.
229        let shared_state = self.state.clone().or_else(|| {
230            (!self.deferred_agent_tools.is_empty() || !self.policies.is_empty())
231                .then(gemini_adk_rs::State::new)
232        });
233        if let Some(ref state) = shared_state {
234            builder = builder.state(state.clone());
235        }
236
237        // Resolve deferred agent tools: register TextAgentTools against it.
238        let mut dispatcher = self.dispatcher;
239        if !self.deferred_agent_tools.is_empty() {
240            let state = shared_state.clone().unwrap_or_default();
241            let d = dispatcher.get_or_insert_with(gemini_adk_rs::tool::ToolDispatcher::new);
242            for deferred in self.deferred_agent_tools {
243                d.register(gemini_adk_rs::TextAgentTool::from_arc(
244                    deferred.name,
245                    deferred.description,
246                    deferred.agent,
247                    state.clone(),
248                ));
249            }
250        }
251
252        // Resolve deferred async tools (MCP connections, etc.).
253        if !self.deferred_tools.is_empty() {
254            let d = dispatcher.get_or_insert_with(gemini_adk_rs::tool::ToolDispatcher::new);
255            for deferred in std::mem::take(&mut self.deferred_tools) {
256                resolve_deferred_tool(deferred, d).await?;
257            }
258        }
259
260        // Enforce redaction and commit-governance policies.
261        if let Some(state) = &shared_state {
262            apply_policies(&self.policies, state, &mut dispatcher)?;
263        }
264
265        // Attach the confirmation provider so `T::confirm(..)` tools are gated.
266        if let Some(provider) = self.confirmation_provider {
267            dispatcher
268                .get_or_insert_with(gemini_adk_rs::tool::ToolDispatcher::new)
269                .set_confirmation_provider(provider);
270        }
271
272        // Capture the resolved tool names before the dispatcher moves into the
273        // builder. This is the only point where the set is complete: MCP tools
274        // exist only after the handshakes above.
275        let resolved_tool_names: Vec<String> = {
276            let mut names = super::introspect::declaration_names(&builder_config_tools);
277            if let Some(d) = &dispatcher {
278                names.extend(super::introspect::declaration_names(
279                    &d.to_tool_declarations(),
280                ));
281            }
282            names.sort();
283            names.dedup();
284            names
285        };
286
287        if let Some(dispatcher) = dispatcher {
288            builder = builder.dispatcher(dispatcher);
289        }
290        if let Some(greeting) = self.greeting {
291            builder = builder.greeting(greeting);
292        }
293        builder = builder.callbacks(self.callbacks);
294        if let Some(decisions) = self.decisions {
295            builder = builder.decisions(decisions);
296        }
297        if let Some(code) = self.code_mode {
298            builder = builder.code_mode(code);
299        }
300        for ext in self.extractors {
301            builder = builder.extractor(ext);
302        }
303
304        // Pass L1 registries
305        if !self.computed.is_empty() {
306            builder = builder.computed(self.computed);
307        }
308        if let Some(initial) = self.initial_phase {
309            let mut pm = PhaseMachine::new(&initial);
310            for phase in self.phases {
311                pm.add_phase(phase);
312            }
313            builder = builder.phase_machine(pm);
314        }
315        if !self.watchers.observed_keys().is_empty() {
316            builder = builder.watchers(self.watchers);
317        }
318        builder = builder.temporal(self.temporal);
319
320        // Pass tool execution modes
321        for (name, mode) in self.tool_execution_modes {
322            builder = builder.tool_execution_mode(name, mode);
323        }
324
325        // Pass control plane configuration
326        if let Some(timeout) = self.soft_turn_timeout {
327            builder = builder.soft_turn_timeout(timeout);
328        }
329        if let Some(mode) = self.steering_mode {
330            builder = builder.steering_mode(mode);
331        }
332        builder = builder.context_delivery(self.context_delivery);
333        builder = builder.delivery(self.delivery);
334        if let Some(redactor) = self.redactor {
335            builder = builder.redaction(redactor);
336        }
337        if let Some(config) = self.repair_config {
338            builder = builder.repair(config);
339        }
340        if let Some(p) = self.persistence {
341            builder = builder.persistence(p);
342        }
343        if let Some(id) = self.session_id {
344            builder = builder.session_id(id);
345        }
346        for layer in self.middleware_layers {
347            builder = builder.middleware(layer);
348        }
349        if let Some(mut flow) = self.flow {
350            // Merged here rather than in `govern`/`ambient_tools` so the two
351            // compose regardless of the order the caller wrote them in.
352            merge_ambient(&mut flow, &self.ambient_tools);
353            // A flow names tools as strings, and a name that matches nothing is
354            // not inert: an `allow` whitelist containing only a typo denies
355            // every tool for as long as that step is active, silently and for
356            // the rest of the session. The registry to check against only
357            // exists here, after the deferred handshakes above.
358            //
359            // Skipped for `govern_compiled`/`observe_compiled`, whose contract
360            // is that the caller already surfaced these diagnostics via
361            // `Flow::compile`/`compile_with_tools` and connect will not repeat
362            // the work.
363            if !self.flow_precompiled {
364                let registry: Vec<&str> = resolved_tool_names.iter().map(String::as_str).collect();
365                flow.clone()
366                    .compile_with_tools(&registry)
367                    .map_err(|errors| {
368                        gemini_adk_rs::error::AgentError::Config(format!(
369                            "governing flow does not match this session's tools: {errors}. \
370                             Registered tools: [{}]. Use `Flow::compile_with_tools` at load \
371                             time and `govern_compiled` to check this yourself.",
372                            registry.join(", ")
373                        ))
374                    })?;
375            }
376            let mut monitor = gemini_adk_rs::flow::FlowMonitor::new(flow, self.flow_mode);
377            for (step, agent, mode) in self.flow_actions {
378                monitor = monitor.on_enter(step, gemini_adk_rs::flow::on_enter(agent, mode));
379            }
380            builder = builder.flow_stack(assemble_stack(
381                monitor,
382                self.digressions,
383                self.repair_policies,
384                self.stage_timings,
385                self.corrections,
386                self.verbatims,
387                &self.ambient_tools,
388            ));
389        }
390        builder = builder.tool_advisory(self.tool_advisory);
391        if let Some(clock) = self.clock.take() {
392            builder = builder.clock(clock);
393        }
394        if let Some(interval) = self.telemetry_interval {
395            builder = builder.telemetry_interval(interval);
396        }
397        if let Some(capacity) = self.event_capacity {
398            builder = builder.event_capacity(capacity);
399        }
400
401        // Spawn fire-and-forget warm-up tasks for OOB LLMs
402        // (pre-establishes TCP+TLS so first extract call is fast)
403        for llm in self.warm_up_llms {
404            tokio::spawn(async move {
405                let _ = llm.warm_up().await;
406            });
407        }
408
409        let handle = connect(builder).await?;
410
411        // Input-audio hardening: materialize the configured stages (in
412        // order) and hand them plus VAD tuning and authority to the handle.
413        if input_audio.is_configured() {
414            let (processors, vad, client_authority, turn_commit) = input_audio.build_processors();
415            handle.configure_input_audio(
416                processors,
417                vad,
418                if client_authority {
419                    gemini_adk_rs::live::ActivityAuthority::Client
420                } else {
421                    gemini_adk_rs::live::ActivityAuthority::Server
422                },
423            );
424            if let Some(config) = turn_commit {
425                handle.set_turn_commit(config);
426            }
427        }
428        Ok(handle)
429    }
430}
431
432/// Enforce `policies` on a session: mark redacted keys on its state and
433/// wrap each commit tool in a [`CommitGuard`](gemini_adk_rs::tool::CommitGuard).
434pub(crate) fn apply_policies(
435    policies: &[crate::policy::Policy],
436    state: &gemini_adk_rs::State,
437    dispatcher: &mut Option<gemini_adk_rs::tool::ToolDispatcher>,
438) -> Result<(), gemini_adk_rs::error::AgentError> {
439    use crate::policy::Policy;
440    use gemini_adk_rs::tool::{CommitGuard, ToolKind};
441
442    let function = |d: &gemini_adk_rs::tool::ToolDispatcher, name: &str| match d.get_tool(name) {
443        Some(ToolKind::Function(f)) => Some(f.clone()),
444        _ => None,
445    };
446    for policy in policies {
447        match policy {
448            Policy::Redact { keys } => state.redact_keys(keys.iter().cloned()),
449            Policy::Commit {
450                tool,
451                idempotency_key,
452                compensate_with,
453            } => {
454                let d = dispatcher.as_mut();
455                let Some(inner) = d.as_ref().and_then(|d| function(d, tool)) else {
456                    return Err(gemini_adk_rs::error::AgentError::Config(format!(
457                        "Policy::commit names `{tool}`, which is not a registered function tool"
458                    )));
459                };
460                let mut guard = CommitGuard::new(inner, state.clone());
461                if let Some(template) = idempotency_key {
462                    guard = guard.idempotency_key(template.clone());
463                }
464                if let Some(undo) = compensate_with {
465                    let Some(undo_fn) = d.as_ref().and_then(|d| function(d, undo)) else {
466                        return Err(gemini_adk_rs::error::AgentError::Config(format!(
467                            "Policy::commit(`{tool}`) compensates with `{undo}`, which is not a \
468                             registered function tool"
469                        )));
470                    };
471                    guard = guard.compensate_with(undo_fn);
472                }
473                if let Some(d) = d {
474                    d.register(guard);
475                }
476            }
477            Policy::SafetyHandoff { .. } => {}
478        }
479    }
480    Ok(())
481}
482
483/// Resolve an [`ApiEndpoint`] from the environment, with a `gcloud` token
484/// fallback for Vertex AI when `GOOGLE_ACCESS_TOKEN` is not set.
485async fn resolve_endpoint_from_env() -> Result<ApiEndpoint, gemini_adk_rs::error::AgentError> {
486    use gemini_adk_rs::error::AgentError;
487    use gemini_genai_rs::protocol::types::EndpointEnvError;
488
489    match ApiEndpoint::from_env() {
490        Ok(endpoint) => Ok(endpoint),
491        // Vertex was selected but no token was in the environment: use the
492        // service's own credentials (metadata server), else the gcloud CLI.
493        Err(EndpointEnvError::Missing("GOOGLE_ACCESS_TOKEN")) => {
494            let project = std::env::var("GOOGLE_CLOUD_PROJECT").map_err(|_| {
495                AgentError::Config("GOOGLE_CLOUD_PROJECT is required for Vertex AI".into())
496            })?;
497            let location = std::env::var("GOOGLE_CLOUD_LOCATION")
498                .unwrap_or_else(|_| "us-central1".to_string());
499            let token = vertex_access_token().await?;
500            Ok(ApiEndpoint::vertex(project, location, token))
501        }
502        Err(e) => Err(AgentError::Config(format!(
503            "connect_from_env: {e}. For Google AI set GEMINI_API_KEY; for Vertex AI set \
504             GOOGLE_GENAI_USE_VERTEXAI=true and GOOGLE_CLOUD_PROJECT (token via \
505             GOOGLE_ACCESS_TOKEN, the metadata server, or the gcloud CLI)."
506        ))),
507    }
508}
509
510/// A Vertex token that keeps itself fresh: from the metadata server when one
511/// answers (Cloud Run, GKE, Compute Engine), else from the gcloud CLI.
512#[cfg(any(feature = "gemini-llm", feature = "gcs-store"))]
513async fn vertex_access_token() -> Result<AccessToken, gemini_adk_rs::error::AgentError> {
514    gemini_genai_rs::transport::auth::GoogleAccessToken::from_env()
515        .into_access_token()
516        .await
517        .map_err(|e| {
518            gemini_adk_rs::error::AgentError::Config(format!(
519                "Vertex AI needs an access token: set GOOGLE_ACCESS_TOKEN, run on Google \
520                 Cloud with a service account, or install the gcloud CLI ({e})"
521            ))
522        })
523}
524
525/// Without an HTTP client, only the gcloud CLI can supply a token.
526#[cfg(not(any(feature = "gemini-llm", feature = "gcs-store")))]
527#[allow(
528    clippy::unused_async,
529    reason = "same signature as the metadata-server variant it replaces"
530)]
531async fn vertex_access_token() -> Result<AccessToken, gemini_adk_rs::error::AgentError> {
532    gcloud_access_token().map(AccessToken::from)
533}
534
535/// Fetch an OAuth2 access token via `gcloud auth print-access-token`.
536#[cfg(not(any(feature = "gemini-llm", feature = "gcs-store")))]
537fn gcloud_access_token() -> Result<String, gemini_adk_rs::error::AgentError> {
538    use gemini_adk_rs::error::AgentError;
539
540    let output = std::process::Command::new("gcloud")
541        .args(["auth", "print-access-token"])
542        .output()
543        .map_err(|e| {
544            AgentError::Config(format!(
545                "Vertex AI needs an access token: set GOOGLE_ACCESS_TOKEN, or install the \
546                 gcloud CLI (failed to run `gcloud auth print-access-token`: {e})"
547            ))
548        })?;
549    if !output.status.success() {
550        return Err(AgentError::Config(format!(
551            "`gcloud auth print-access-token` failed: {}",
552            String::from_utf8_lossy(&output.stderr).trim()
553        )));
554    }
555    let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
556    if token.is_empty() {
557        return Err(AgentError::Config(
558            "`gcloud auth print-access-token` returned an empty token".into(),
559        ));
560    }
561    Ok(token)
562}
563
564/// Resolve a single [`DeferredTool`](crate::compose::tools::DeferredTool) into
565/// concrete tool registrations on the dispatcher. Runs at connect time because
566/// these tools require async I/O (a network call or a subprocess handshake).
567async fn resolve_deferred_tool(
568    tool: crate::compose::tools::DeferredTool,
569    dispatcher: &mut gemini_adk_rs::tool::ToolDispatcher,
570) -> Result<(), gemini_adk_rs::error::AgentError> {
571    use crate::compose::tools::DeferredTool;
572    use gemini_adk_rs::error::AgentError;
573    use gemini_adk_rs::tools::mcp::{McpSessionManager, McpTool};
574    use std::sync::Arc;
575
576    match tool {
577        DeferredTool::Mcp { params } => {
578            let manager = Arc::new(McpSessionManager::new(parse_mcp_params(&params)));
579            let infos = manager.list_tools().await.map_err(|e| {
580                AgentError::Config(format!("MCP tool discovery failed for {params:?}: {e}"))
581            })?;
582            for info in infos {
583                dispatcher.register_function(Arc::new(McpTool::new(
584                    info.name,
585                    info.description,
586                    Some(info.input_schema),
587                    manager.clone(),
588                )));
589            }
590            Ok(())
591        }
592    }
593}
594
595/// Parse an MCP connection string: an `http(s)://` URL becomes an SSE/HTTP
596/// connection, anything else is treated as a stdio command line.
597pub(crate) fn parse_mcp_params(params: &str) -> gemini_adk_rs::tools::mcp::McpConnectionParams {
598    use gemini_adk_rs::tools::mcp::McpConnectionParams;
599
600    let trimmed = params.trim();
601    if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
602        McpConnectionParams::Sse {
603            url: trimmed.to_string(),
604            headers: None,
605        }
606    } else {
607        let mut parts = trimmed.split_whitespace();
608        let command = parts.next().unwrap_or_default().to_string();
609        let args = parts.map(str::to_string).collect();
610        McpConnectionParams::Stdio {
611            command,
612            args,
613            timeout: Some(std::time::Duration::from_secs(30)),
614        }
615    }
616}
617
618fn uses_audio_output(config: &SessionConfig) -> bool {
619    config
620        .generation_config
621        .response_modalities
622        .as_ref()
623        .map(|modalities| modalities.iter().any(|m| matches!(m, Modality::Audio)))
624        .unwrap_or(true)
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn uses_audio_output_defaults_to_audio() {
633        let config = SessionConfig::new("key");
634        assert!(uses_audio_output(&config));
635    }
636
637    #[test]
638    fn uses_audio_output_respects_text_only() {
639        let config = SessionConfig::new("key").text_only();
640        assert!(!uses_audio_output(&config));
641    }
642
643    // ─── default model resolution ───────────────────────────────────────────
644
645    #[test]
646    fn unset_model_is_left_to_the_wire_layer_to_resolve() {
647        let live = Live::builder();
648        assert!(live.config.model.is_none(), "no default is baked in at L2");
649        let live = Live::builder().model(ModelId::LIVE_2_5_FLASH_NATIVE_AUDIO);
650        assert_eq!(
651            live.config.model.as_ref(),
652            Some(&ModelId::LIVE_2_5_FLASH_NATIVE_AUDIO)
653        );
654    }
655
656    // ─── ambient tool merge ─────────────────────────────────────────────────
657
658    use gemini_adk_rs::flow::{Flow, Guard};
659
660    fn whitelisting_flow() -> Flow {
661        Flow::new()
662            .step("book")
663            .allow(["book_table"])
664            .done(Guard::called_ok("book_table"))
665            .build()
666            .expect("flow is structurally valid")
667    }
668
669    #[test]
670    fn merge_ambient_adds_registered_tools() {
671        let mut flow = whitelisting_flow();
672        merge_ambient(&mut flow, &["recall_context".to_string()]);
673        assert_eq!(flow.ambient, ["recall_context"]);
674    }
675
676    #[test]
677    fn merge_ambient_does_not_duplicate() {
678        // The flow already declares it and an extension registers it too.
679        let mut flow = Flow::new()
680            .ambient(["recall_context"])
681            .step("book")
682            .allow(["book_table"])
683            .done(Guard::called_ok("book_table"))
684            .build()
685            .expect("flow is structurally valid");
686        merge_ambient(&mut flow, &["recall_context".to_string()]);
687        merge_ambient(&mut flow, &["recall_context".to_string()]);
688        assert_eq!(
689            flow.ambient,
690            ["recall_context"],
691            "merging is idempotent, so an extension installed twice is harmless"
692        );
693    }
694
695    // ─── connect-time flow validation ───────────────────────────────────────
696    //
697    // These run to a real `connect_google_ai` with a junk key. That is safe and
698    // deliberate: validation happens before `builder.connect()`, so a `Config`
699    // error proves the check fired *and* that it fired before any socket was
700    // opened. A `Session` error would mean the flow was accepted and the
701    // failure came from the network instead.
702
703    fn book_tool() -> crate::compose::tools::ToolComposite {
704        crate::compose::T::simple("book_table", "Book a table", |_| async {
705            Ok(serde_json::json!({"ok": true}))
706        })
707    }
708
709    #[tokio::test]
710    async fn a_flow_naming_an_unregistered_tool_is_refused_at_connect() {
711        let flow = Flow::new()
712            .step("book")
713            .allow(["book_tabel"]) // typo: the registered tool is `book_table`
714            .done(Guard::called_ok("book_tabel"))
715            .build()
716            .expect("structurally valid — the name is the problem, not the shape");
717
718        let err = Live::builder()
719            .tools(book_tool())
720            .govern(flow)
721            .connect_google_ai("not-a-real-key")
722            .await
723            .err()
724            .expect("a flow naming a tool that does not exist must not connect");
725
726        let msg = err.to_string();
727        assert!(
728            msg.contains("book_tabel"),
729            "the error must name the tool that does not exist: {msg}"
730        );
731        assert!(
732            msg.contains("book_table"),
733            "and list what is registered, so the typo is visible: {msg}"
734        );
735    }
736
737    #[tokio::test]
738    async fn a_flow_matching_the_registered_tools_passes_validation() {
739        // Reaches the network and fails there — which is the proof that the
740        // flow check passed rather than short-circuiting.
741        let flow = Flow::new()
742            .step("book")
743            .allow(["book_table"])
744            .done(Guard::called_ok("book_table"))
745            .build()
746            .expect("valid");
747
748        let err = Live::builder()
749            .tools(book_tool())
750            .govern(flow)
751            .connect_google_ai("not-a-real-key")
752            .await
753            .err()
754            .expect("the key is junk, so this still fails — just not on the flow");
755
756        assert!(
757            !err.to_string().contains("governing flow"),
758            "a flow whose tools all exist must clear validation: {err}"
759        );
760    }
761
762    #[tokio::test]
763    async fn ambient_tools_count_as_registered_for_validation() {
764        // `ambient` joins the tool universe, so an ambient tool that is really
765        // registered must satisfy the check rather than trip it.
766        let flow = Flow::new()
767            .ambient(["book_table"])
768            .step("book")
769            .done(Guard::called_ok("book_table"))
770            .build()
771            .expect("valid");
772
773        let err = Live::builder()
774            .tools(book_tool())
775            .govern(flow)
776            .connect_google_ai("not-a-real-key")
777            .await
778            .err()
779            .expect("junk key");
780
781        assert!(
782            !err.to_string().contains("governing flow"),
783            "an ambient tool that exists must not be reported missing: {err}"
784        );
785    }
786
787    #[tokio::test]
788    async fn a_precompiled_flow_is_not_revalidated() {
789        // `govern_compiled` documents that connect does not re-check. Honour it
790        // even when the flow would fail the check, or the documented
791        // compile-once-govern-many path would silently stop working.
792        let compiled = Flow::new()
793            .step("book")
794            .allow(["book_tabel"])
795            .done(Guard::called_ok("book_tabel"))
796            .build()
797            .expect("valid shape")
798            .compile()
799            .expect("compiles without a tool registry");
800
801        let err = Live::builder()
802            .tools(book_tool())
803            .govern_compiled(compiled)
804            .connect_google_ai("not-a-real-key")
805            .await
806            .err()
807            .expect("junk key");
808
809        assert!(
810            !err.to_string().contains("governing flow"),
811            "a pre-compiled flow must not be re-validated at connect: {err}"
812        );
813    }
814
815    #[test]
816    fn ambient_tools_registers_regardless_of_govern_order() {
817        // The whole reason the merge happens at connect: an application may
818        // write `.govern(..)` before or after the extension that needs ambient
819        // tools, and neither order may lose the registration.
820        let before = Live::builder()
821            .govern(whitelisting_flow())
822            .ambient_tools(["recall_context"]);
823        let after = Live::builder()
824            .ambient_tools(["recall_context"])
825            .govern(whitelisting_flow());
826        assert_eq!(before.ambient_tool_names(), ["recall_context"]);
827        assert_eq!(after.ambient_tool_names(), ["recall_context"]);
828    }
829}