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
20pub(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 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 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 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 pub async fn connect(
115 mut self,
116 config: SessionConfig,
117 ) -> Result<LiveHandle, gemini_adk_rs::error::AgentError> {
118 self.config.endpoint = config.endpoint;
120 self.config.model = config.model;
121 self.build_and_connect().await
122 }
123
124 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 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 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 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 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 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 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 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 if let Some(state) = &shared_state {
262 apply_policies(&self.policies, state, &mut dispatcher)?;
263 }
264
265 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 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 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 for (name, mode) in self.tool_execution_modes {
322 builder = builder.tool_execution_mode(name, mode);
323 }
324
325 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 merge_ambient(&mut flow, &self.ambient_tools);
353 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(®istry)
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 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 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
432pub(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
483async 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 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#[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#[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#[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
564async 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(¶ms)));
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
595pub(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 #[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 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 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 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"]) .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 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 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 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 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}