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
20impl Live {
21    /// Connect using a Google AI API key.
22    pub async fn connect_google_ai(
23        mut self,
24        api_key: impl Into<String>,
25    ) -> Result<LiveHandle, gemini_adk_rs::error::AgentError> {
26        self.config.endpoint = ApiEndpoint::google_ai(api_key);
27        self.build_and_connect().await
28    }
29
30    /// Connect using Vertex AI credentials.
31    ///
32    /// `access_token` is a fixed token or an [`AccessToken`]; pass
33    /// `AccessToken::from_fn(..)` for a source that is consulted on every
34    /// reconnect, since Vertex tokens expire after about an hour.
35    pub async fn connect_vertex(
36        mut self,
37        project: impl Into<String>,
38        location: impl Into<String>,
39        access_token: impl Into<AccessToken>,
40    ) -> Result<LiveHandle, gemini_adk_rs::error::AgentError> {
41        self.config.endpoint = ApiEndpoint::vertex(project, location, access_token);
42        self.build_and_connect().await
43    }
44
45    /// Connect by resolving the platform and credentials from standard
46    /// environment variables — the zero-ceremony entry point.
47    ///
48    /// Resolution (see [`ApiEndpoint::from_env`]):
49    /// - `GOOGLE_GENAI_USE_VERTEXAI=true` → Vertex AI using
50    ///   `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_LOCATION` (default
51    ///   `us-central1`), and a token from `GOOGLE_ACCESS_TOKEN`. If that
52    ///   token is unset, this falls back to running
53    ///   `gcloud auth print-access-token`.
54    /// - otherwise → Google AI using `GEMINI_API_KEY` (or
55    ///   `GOOGLE_GENAI_API_KEY` / `GOOGLE_API_KEY`).
56    ///
57    /// When no `.model(..)` was set, the wire layer resolves a default the
58    /// target platform actually serves (`ModelId::live_default`): `GEMINI_MODEL`
59    /// from the environment if present, else the platform's current
60    /// native-audio Flash model.
61    ///
62    /// ```no_run
63    /// # use gemini_adk_fluent_rs::prelude::*;
64    /// # async fn run() -> Result<(), AgentError> {
65    /// let handle = Live::builder()
66    ///     .voice(Voice::Kore)
67    ///     .connect_from_env()
68    ///     .await?;
69    /// # let _ = handle; Ok(())
70    /// # }
71    /// ```
72    pub async fn connect_from_env(
73        mut self,
74    ) -> Result<LiveHandle, gemini_adk_rs::error::AgentError> {
75        self.config.endpoint = resolve_endpoint_from_env()?;
76        self.build_and_connect().await
77    }
78
79    /// Connect using a pre-configured SessionConfig for auth and model.
80    ///
81    /// Merges the provided config's `endpoint` and `model` into the builder's
82    /// config, preserving system instruction, tools, voice, transcription, and
83    /// all other settings configured via the fluent API.
84    pub async fn connect(
85        mut self,
86        config: SessionConfig,
87    ) -> Result<LiveHandle, gemini_adk_rs::error::AgentError> {
88        // Merge auth/model from external config, keep everything else from builder.
89        self.config.endpoint = config.endpoint;
90        self.config.model = config.model;
91        self.build_and_connect().await
92    }
93
94    async fn build_and_connect(mut self) -> Result<LiveHandle, gemini_adk_rs::error::AgentError> {
95        // Builder setters cannot fail; problems they found are reported here.
96        if !self.config_errors.is_empty() {
97            return Err(gemini_adk_rs::error::ConfigError {
98                issues: std::mem::take(&mut self.config_errors),
99            }
100            .into());
101        }
102        if uses_audio_output(&self.config) {
103            self.config = self.config.voice_realtime_defaults();
104        }
105
106        // Client interruption authority: the server must stop deciding, so
107        // its automatic activity detection is disabled in the setup message
108        // (the input VAD's activity marks take over in `send_audio`).
109        if self.input_audio.client_authority {
110            self.config =
111                self.config
112                    .server_vad(gemini_genai_rs::prelude::AutomaticActivityDetection {
113                        disabled: Some(true),
114                        start_of_speech_sensitivity: None,
115                        end_of_speech_sensitivity: None,
116                        prefix_padding_ms: None,
117                        silence_duration_ms: None,
118                    });
119        }
120        let input_audio = std::mem::take(&mut self.input_audio);
121
122        // Resolve a `.record_wire(path)` request into a FileWireRecorder now
123        // that we are actually connecting.
124        if let Some(path) = self.record_wire_path.take() {
125            let recorder =
126                gemini_genai_rs::transport::FileWireRecorder::create(&path).map_err(|e| {
127                    gemini_adk_rs::error::AgentError::Config(format!(
128                        "failed to create wire log at {}: {e}",
129                        path.display()
130                    ))
131                })?;
132            self.config = self.config.record_wire(std::sync::Arc::new(recorder));
133        }
134
135        // Config-level tool declarations, captured before `config` moves.
136        let builder_config_tools = self.config.tools.clone();
137        let mut builder = LiveSessionBuilder::new(self.config);
138
139        // The session's `State`. A caller-supplied one is used as-is so tools
140        // they already built around it write where the flow monitor and phase
141        // machine read; otherwise agent tools get a fresh one as before.
142        let shared_state = self.state.clone();
143        if let Some(ref state) = shared_state {
144            builder = builder.state(state.clone());
145        }
146
147        // Resolve deferred agent tools: register TextAgentTools against it.
148        let mut dispatcher = self.dispatcher;
149        if !self.deferred_agent_tools.is_empty() {
150            let state = shared_state.clone().unwrap_or_default();
151            let d = dispatcher.get_or_insert_with(gemini_adk_rs::tool::ToolDispatcher::new);
152            for deferred in self.deferred_agent_tools {
153                d.register(gemini_adk_rs::TextAgentTool::from_arc(
154                    deferred.name,
155                    deferred.description,
156                    deferred.agent,
157                    state.clone(),
158                ));
159            }
160            builder = builder.state(state);
161        }
162
163        // Resolve deferred async tools (MCP connections, etc.).
164        if !self.deferred_tools.is_empty() {
165            let d = dispatcher.get_or_insert_with(gemini_adk_rs::tool::ToolDispatcher::new);
166            for deferred in std::mem::take(&mut self.deferred_tools) {
167                resolve_deferred_tool(deferred, d).await?;
168            }
169        }
170
171        // Attach the confirmation provider so `T::confirm(..)` tools are gated.
172        if let Some(provider) = self.confirmation_provider {
173            dispatcher
174                .get_or_insert_with(gemini_adk_rs::tool::ToolDispatcher::new)
175                .set_confirmation_provider(provider);
176        }
177
178        // Capture the resolved tool names before the dispatcher moves into the
179        // builder. This is the only point where the set is complete: MCP tools
180        // exist only after the handshakes above.
181        let resolved_tool_names: Vec<String> = {
182            let mut names = super::introspect::declaration_names(&builder_config_tools);
183            if let Some(d) = &dispatcher {
184                names.extend(super::introspect::declaration_names(
185                    &d.to_tool_declarations(),
186                ));
187            }
188            names.sort();
189            names.dedup();
190            names
191        };
192
193        if let Some(dispatcher) = dispatcher {
194            builder = builder.dispatcher(dispatcher);
195        }
196        if let Some(greeting) = self.greeting {
197            builder = builder.greeting(greeting);
198        }
199        builder = builder.callbacks(self.callbacks);
200        for ext in self.extractors {
201            builder = builder.extractor(ext);
202        }
203
204        // Pass L1 registries
205        if !self.computed.is_empty() {
206            builder = builder.computed(self.computed);
207        }
208        if let Some(initial) = self.initial_phase {
209            let mut pm = PhaseMachine::new(&initial);
210            for phase in self.phases {
211                pm.add_phase(phase);
212            }
213            builder = builder.phase_machine(pm);
214        }
215        if !self.watchers.observed_keys().is_empty() {
216            builder = builder.watchers(self.watchers);
217        }
218        builder = builder.temporal(self.temporal);
219
220        // Pass tool execution modes
221        for (name, mode) in self.tool_execution_modes {
222            builder = builder.tool_execution_mode(name, mode);
223        }
224
225        // Pass control plane configuration
226        if let Some(timeout) = self.soft_turn_timeout {
227            builder = builder.soft_turn_timeout(timeout);
228        }
229        builder = builder.steering_mode(self.steering_mode);
230        builder = builder.context_delivery(self.context_delivery);
231        builder = builder.delivery(self.delivery);
232        if let Some(redactor) = self.redactor {
233            builder = builder.redaction(redactor);
234        }
235        if let Some(config) = self.repair_config {
236            builder = builder.repair(config);
237        }
238        if let Some(p) = self.persistence {
239            builder = builder.persistence(p);
240        }
241        if let Some(id) = self.session_id {
242            builder = builder.session_id(id);
243        }
244        for layer in self.middleware_layers {
245            builder = builder.middleware(layer);
246        }
247        if let Some(mut flow) = self.flow {
248            // Merged here rather than in `govern`/`ambient_tools` so the two
249            // compose regardless of the order the caller wrote them in.
250            merge_ambient(&mut flow, &self.ambient_tools);
251            // A flow names tools as strings, and a name that matches nothing is
252            // not inert: an `allow` whitelist containing only a typo denies
253            // every tool for as long as that step is active, silently and for
254            // the rest of the session. The registry to check against only
255            // exists here, after the deferred handshakes above.
256            //
257            // Skipped for `govern_compiled`/`observe_compiled`, whose contract
258            // is that the caller already surfaced these diagnostics via
259            // `Flow::compile`/`compile_with_tools` and connect will not repeat
260            // the work.
261            if !self.flow_precompiled {
262                let registry: Vec<&str> = resolved_tool_names.iter().map(String::as_str).collect();
263                flow.clone()
264                    .compile_with_tools(&registry)
265                    .map_err(|errors| {
266                        gemini_adk_rs::error::AgentError::Config(format!(
267                            "governing flow does not match this session's tools: {errors}. \
268                             Registered tools: [{}]. Use `Flow::compile_with_tools` at load \
269                             time and `govern_compiled` to check this yourself.",
270                            registry.join(", ")
271                        ))
272                    })?;
273            }
274            let mut monitor = gemini_adk_rs::flow::FlowMonitor::new(flow, self.flow_mode);
275            for (step, agent, mode) in self.flow_actions {
276                monitor = monitor.on_enter(step, gemini_adk_rs::flow::on_enter(agent, mode));
277            }
278            builder = builder.flow_monitor(monitor);
279        }
280        builder = builder.tool_advisory(self.tool_advisory);
281        if let Some(interval) = self.telemetry_interval {
282            builder = builder.telemetry_interval(interval);
283        }
284
285        // Spawn fire-and-forget warm-up tasks for OOB LLMs
286        // (pre-establishes TCP+TLS so first extract call is fast)
287        for llm in self.warm_up_llms {
288            tokio::spawn(async move {
289                let _ = llm.warm_up().await;
290            });
291        }
292
293        let handle = builder.connect().await?;
294
295        // Input-audio hardening: materialize the configured stages (in
296        // order) and hand them plus VAD tuning and authority to the handle.
297        if input_audio.is_configured() {
298            let (processors, vad, client_authority, turn_commit) = input_audio.build_processors();
299            handle.configure_input_audio(
300                processors,
301                vad,
302                if client_authority {
303                    gemini_adk_rs::live::ActivityAuthority::Client
304                } else {
305                    gemini_adk_rs::live::ActivityAuthority::Server
306                },
307            );
308            if let Some(config) = turn_commit {
309                handle.set_turn_commit(config);
310            }
311        }
312        Ok(handle)
313    }
314}
315
316/// Resolve an [`ApiEndpoint`] from the environment, with a `gcloud` token
317/// fallback for Vertex AI when `GOOGLE_ACCESS_TOKEN` is not set.
318fn resolve_endpoint_from_env() -> Result<ApiEndpoint, gemini_adk_rs::error::AgentError> {
319    use gemini_adk_rs::error::AgentError;
320    use gemini_genai_rs::protocol::types::EndpointEnvError;
321
322    match ApiEndpoint::from_env() {
323        Ok(endpoint) => Ok(endpoint),
324        // Vertex was selected but no token was in the environment — fall back
325        // to Application Default Credentials via the gcloud CLI.
326        Err(EndpointEnvError::Missing("GOOGLE_ACCESS_TOKEN")) => {
327            let project = std::env::var("GOOGLE_CLOUD_PROJECT").map_err(|_| {
328                AgentError::Config("GOOGLE_CLOUD_PROJECT is required for Vertex AI".into())
329            })?;
330            let location = std::env::var("GOOGLE_CLOUD_LOCATION")
331                .unwrap_or_else(|_| "us-central1".to_string());
332            let token = gcloud_access_token()?;
333            Ok(ApiEndpoint::vertex(project, location, token))
334        }
335        Err(e) => Err(AgentError::Config(format!(
336            "connect_from_env: {e}. For Google AI set GEMINI_API_KEY; for Vertex AI set \
337             GOOGLE_GENAI_USE_VERTEXAI=true and GOOGLE_CLOUD_PROJECT (token via \
338             GOOGLE_ACCESS_TOKEN or the gcloud CLI)."
339        ))),
340    }
341}
342
343/// Fetch an OAuth2 access token via `gcloud auth print-access-token`.
344fn gcloud_access_token() -> Result<String, gemini_adk_rs::error::AgentError> {
345    use gemini_adk_rs::error::AgentError;
346
347    let output = std::process::Command::new("gcloud")
348        .args(["auth", "print-access-token"])
349        .output()
350        .map_err(|e| {
351            AgentError::Config(format!(
352                "Vertex AI needs an access token: set GOOGLE_ACCESS_TOKEN, or install the \
353                 gcloud CLI (failed to run `gcloud auth print-access-token`: {e})"
354            ))
355        })?;
356    if !output.status.success() {
357        return Err(AgentError::Config(format!(
358            "`gcloud auth print-access-token` failed: {}",
359            String::from_utf8_lossy(&output.stderr).trim()
360        )));
361    }
362    let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
363    if token.is_empty() {
364        return Err(AgentError::Config(
365            "`gcloud auth print-access-token` returned an empty token".into(),
366        ));
367    }
368    Ok(token)
369}
370
371/// Resolve a single [`DeferredTool`](crate::compose::tools::DeferredTool) into
372/// concrete tool registrations on the dispatcher. Runs at connect time because
373/// these tools require async I/O (a network call or a subprocess handshake).
374async fn resolve_deferred_tool(
375    tool: crate::compose::tools::DeferredTool,
376    dispatcher: &mut gemini_adk_rs::tool::ToolDispatcher,
377) -> Result<(), gemini_adk_rs::error::AgentError> {
378    use crate::compose::tools::DeferredTool;
379    use gemini_adk_rs::error::AgentError;
380    use gemini_adk_rs::tools::mcp::{McpSessionManager, McpTool};
381    use std::sync::Arc;
382
383    match tool {
384        DeferredTool::Mcp { params } => {
385            let manager = Arc::new(McpSessionManager::new(parse_mcp_params(&params)));
386            let infos = manager.list_tools().await.map_err(|e| {
387                AgentError::Config(format!("MCP tool discovery failed for {params:?}: {e}"))
388            })?;
389            for info in infos {
390                dispatcher.register_function(Arc::new(McpTool::new(
391                    info.name,
392                    info.description,
393                    Some(info.input_schema),
394                    manager.clone(),
395                )));
396            }
397            Ok(())
398        }
399    }
400}
401
402/// Parse an MCP connection string: an `http(s)://` URL becomes an SSE/HTTP
403/// connection, anything else is treated as a stdio command line.
404fn parse_mcp_params(params: &str) -> gemini_adk_rs::tools::mcp::McpConnectionParams {
405    use gemini_adk_rs::tools::mcp::McpConnectionParams;
406
407    let trimmed = params.trim();
408    if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
409        McpConnectionParams::Sse {
410            url: trimmed.to_string(),
411            headers: None,
412        }
413    } else {
414        let mut parts = trimmed.split_whitespace();
415        let command = parts.next().unwrap_or_default().to_string();
416        let args = parts.map(str::to_string).collect();
417        McpConnectionParams::Stdio {
418            command,
419            args,
420            timeout: Some(std::time::Duration::from_secs(30)),
421        }
422    }
423}
424
425fn uses_audio_output(config: &SessionConfig) -> bool {
426    config
427        .generation_config
428        .response_modalities
429        .as_ref()
430        .map(|modalities| modalities.iter().any(|m| matches!(m, Modality::Audio)))
431        .unwrap_or(true)
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn uses_audio_output_defaults_to_audio() {
440        let config = SessionConfig::new("key");
441        assert!(uses_audio_output(&config));
442    }
443
444    #[test]
445    fn uses_audio_output_respects_text_only() {
446        let config = SessionConfig::new("key").text_only();
447        assert!(!uses_audio_output(&config));
448    }
449
450    // ─── default model resolution ───────────────────────────────────────────
451
452    #[test]
453    fn unset_model_is_left_to_the_wire_layer_to_resolve() {
454        let live = Live::builder();
455        assert!(live.config.model.is_none(), "no default is baked in at L2");
456        let live = Live::builder().model(ModelId::LIVE_2_5_FLASH_NATIVE_AUDIO);
457        assert_eq!(
458            live.config.model.as_ref(),
459            Some(&ModelId::LIVE_2_5_FLASH_NATIVE_AUDIO)
460        );
461    }
462
463    // ─── ambient tool merge ─────────────────────────────────────────────────
464
465    use gemini_adk_rs::flow::{Flow, Guard};
466
467    fn whitelisting_flow() -> Flow {
468        Flow::new()
469            .step("book")
470            .allow(["book_table"])
471            .done(Guard::called_ok("book_table"))
472            .build()
473            .expect("flow is structurally valid")
474    }
475
476    #[test]
477    fn merge_ambient_adds_registered_tools() {
478        let mut flow = whitelisting_flow();
479        merge_ambient(&mut flow, &["recall_context".to_string()]);
480        assert_eq!(flow.ambient, ["recall_context"]);
481    }
482
483    #[test]
484    fn merge_ambient_does_not_duplicate() {
485        // The flow already declares it and an extension registers it too.
486        let mut flow = Flow::new()
487            .ambient(["recall_context"])
488            .step("book")
489            .allow(["book_table"])
490            .done(Guard::called_ok("book_table"))
491            .build()
492            .expect("flow is structurally valid");
493        merge_ambient(&mut flow, &["recall_context".to_string()]);
494        merge_ambient(&mut flow, &["recall_context".to_string()]);
495        assert_eq!(
496            flow.ambient,
497            ["recall_context"],
498            "merging is idempotent, so an extension installed twice is harmless"
499        );
500    }
501
502    // ─── connect-time flow validation ───────────────────────────────────────
503    //
504    // These run to a real `connect_google_ai` with a junk key. That is safe and
505    // deliberate: validation happens before `builder.connect()`, so a `Config`
506    // error proves the check fired *and* that it fired before any socket was
507    // opened. A `Session` error would mean the flow was accepted and the
508    // failure came from the network instead.
509
510    fn book_tool() -> crate::compose::tools::ToolComposite {
511        crate::compose::T::simple("book_table", "Book a table", |_| async {
512            Ok(serde_json::json!({"ok": true}))
513        })
514    }
515
516    #[tokio::test]
517    async fn a_flow_naming_an_unregistered_tool_is_refused_at_connect() {
518        let flow = Flow::new()
519            .step("book")
520            .allow(["book_tabel"]) // typo: the registered tool is `book_table`
521            .done(Guard::called_ok("book_tabel"))
522            .build()
523            .expect("structurally valid — the name is the problem, not the shape");
524
525        let err = Live::builder()
526            .tools(book_tool())
527            .govern(flow)
528            .connect_google_ai("not-a-real-key")
529            .await
530            .err()
531            .expect("a flow naming a tool that does not exist must not connect");
532
533        let msg = err.to_string();
534        assert!(
535            msg.contains("book_tabel"),
536            "the error must name the tool that does not exist: {msg}"
537        );
538        assert!(
539            msg.contains("book_table"),
540            "and list what is registered, so the typo is visible: {msg}"
541        );
542    }
543
544    #[tokio::test]
545    async fn a_flow_matching_the_registered_tools_passes_validation() {
546        // Reaches the network and fails there — which is the proof that the
547        // flow check passed rather than short-circuiting.
548        let flow = Flow::new()
549            .step("book")
550            .allow(["book_table"])
551            .done(Guard::called_ok("book_table"))
552            .build()
553            .expect("valid");
554
555        let err = Live::builder()
556            .tools(book_tool())
557            .govern(flow)
558            .connect_google_ai("not-a-real-key")
559            .await
560            .err()
561            .expect("the key is junk, so this still fails — just not on the flow");
562
563        assert!(
564            !err.to_string().contains("governing flow"),
565            "a flow whose tools all exist must clear validation: {err}"
566        );
567    }
568
569    #[tokio::test]
570    async fn ambient_tools_count_as_registered_for_validation() {
571        // `ambient` joins the tool universe, so an ambient tool that is really
572        // registered must satisfy the check rather than trip it.
573        let flow = Flow::new()
574            .ambient(["book_table"])
575            .step("book")
576            .done(Guard::called_ok("book_table"))
577            .build()
578            .expect("valid");
579
580        let err = Live::builder()
581            .tools(book_tool())
582            .govern(flow)
583            .connect_google_ai("not-a-real-key")
584            .await
585            .err()
586            .expect("junk key");
587
588        assert!(
589            !err.to_string().contains("governing flow"),
590            "an ambient tool that exists must not be reported missing: {err}"
591        );
592    }
593
594    #[tokio::test]
595    async fn a_precompiled_flow_is_not_revalidated() {
596        // `govern_compiled` documents that connect does not re-check. Honour it
597        // even when the flow would fail the check, or the documented
598        // compile-once-govern-many path would silently stop working.
599        let compiled = Flow::new()
600            .step("book")
601            .allow(["book_tabel"])
602            .done(Guard::called_ok("book_tabel"))
603            .build()
604            .expect("valid shape")
605            .compile()
606            .expect("compiles without a tool registry");
607
608        let err = Live::builder()
609            .tools(book_tool())
610            .govern_compiled(compiled)
611            .connect_google_ai("not-a-real-key")
612            .await
613            .err()
614            .expect("junk key");
615
616        assert!(
617            !err.to_string().contains("governing flow"),
618            "a pre-compiled flow must not be re-validated at connect: {err}"
619        );
620    }
621
622    #[test]
623    fn ambient_tools_registers_regardless_of_govern_order() {
624        // The whole reason the merge happens at connect: an application may
625        // write `.govern(..)` before or after the extension that needs ambient
626        // tools, and neither order may lose the registration.
627        let before = Live::builder()
628            .govern(whitelisting_flow())
629            .ambient_tools(["recall_context"]);
630        let after = Live::builder()
631            .ambient_tools(["recall_context"])
632            .govern(whitelisting_flow());
633        assert_eq!(before.ambient_tool_names(), ["recall_context"]);
634        assert_eq!(after.ambient_tool_names(), ["recall_context"]);
635    }
636}