gemini_adk_fluent_rs/live/
connect.rs1use gemini_adk_rs::live::{LiveHandle, LiveSessionBuilder, PhaseMachine};
4use gemini_genai_rs::prelude::*;
5
6use super::Live;
7
8pub(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 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 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 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 pub async fn connect(
85 mut self,
86 config: SessionConfig,
87 ) -> Result<LiveHandle, gemini_adk_rs::error::AgentError> {
88 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 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 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 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 let builder_config_tools = self.config.tools.clone();
137 let mut builder = LiveSessionBuilder::new(self.config);
138
139 let shared_state = self.state.clone();
143 if let Some(ref state) = shared_state {
144 builder = builder.state(state.clone());
145 }
146
147 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 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 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 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 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 for (name, mode) in self.tool_execution_modes {
222 builder = builder.tool_execution_mode(name, mode);
223 }
224
225 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 merge_ambient(&mut flow, &self.ambient_tools);
251 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(®istry)
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 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 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
316fn 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 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
343fn 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
371async 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(¶ms)));
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
402fn 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 #[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 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 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 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"]) .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 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 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 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 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}