1mod codegen;
24pub mod project;
25mod simulate;
26pub mod store;
27
28pub use project::{ProjectFile, ProjectLanguage, ProjectOptions, SdkSource};
29pub use store::{BundleRef, BundleStore, BundleVersion, StoreError, open_store};
30
31pub use simulate::{
32 SimEvent, SimSnapshot, SpecTest, TestExpectation, TestReport, TestStepResult, trace_test,
33};
34
35use std::collections::BTreeMap;
36use std::sync::Arc;
37
38use serde::{Deserialize, Serialize};
39use serde_json::{Value, json};
40
41use gemini_adk_rs::expr::Expr;
42use gemini_adk_rs::flow::{Constraint, Flow, Guard, Pred, Step};
43use gemini_adk_rs::live::extractor::{ExtractionTrigger, FieldPromotion, LlmExtractor};
44use gemini_adk_rs::live::{ContextDelivery, RepairConfig, SteeringMode};
45use gemini_adk_rs::llm::BaseLlm;
46use gemini_adk_rs::state::State;
47use gemini_adk_rs::tool::{SimpleTool, ToolDispatcher, ToolFunction};
48use gemini_genai_rs::prelude::{
49 AutomaticActivityDetection, Content, FunctionResponseScheduling, Sensitivity, SessionWriter,
50 Voice,
51};
52
53use crate::compose::tools::T;
54use crate::live::Live;
55
56#[derive(
58 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
59)]
60#[serde(rename_all = "lowercase")]
61pub enum SpecModality {
62 #[default]
64 Text,
65 Audio,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
76pub struct HttpBinding {
77 #[serde(default = "default_method")]
79 pub method: String,
80 pub url: String,
82 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
84 pub headers: BTreeMap<String, String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub body: Option<Value>,
88 #[serde(skip)]
91 #[schemars(skip)]
92 pub(crate) reach: Option<BindingAllowlist>,
93}
94
95fn default_method() -> String {
96 "GET".to_string()
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
106pub struct ToolSpec {
107 pub name: String,
109 #[serde(default)]
111 pub description: String,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub parameters: Option<Value>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub response: Option<Value>,
118 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
120 pub set_state: BTreeMap<String, Value>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub save_response_as: Option<String>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub http: Option<HttpBinding>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub mcp: Option<String>,
134 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
138 pub background: bool,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub scheduling: Option<SchedulingSpec>,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
146#[serde(rename_all = "snake_case")]
147pub enum SchedulingSpec {
148 Interrupt,
150 WhenIdle,
152 Silent,
154}
155
156impl SchedulingSpec {
157 fn to_wire(self) -> FunctionResponseScheduling {
158 match self {
159 SchedulingSpec::Interrupt => FunctionResponseScheduling::Interrupt,
160 SchedulingSpec::WhenIdle => FunctionResponseScheduling::WhenIdle,
161 SchedulingSpec::Silent => FunctionResponseScheduling::Silent,
162 }
163 }
164}
165
166#[derive(
168 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
169)]
170#[serde(rename_all = "snake_case")]
171pub enum PromotePolicy {
172 #[default]
174 KeepKnown,
175 Overwrite,
177 TrueOnly,
179 NonEmpty,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
186pub struct PromoteSpec {
187 pub field: String,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub to: Option<String>,
192 #[serde(default)]
194 pub policy: PromotePolicy,
195}
196
197impl PromoteSpec {
198 fn target(&self) -> &str {
199 self.to.as_deref().unwrap_or(&self.field)
200 }
201
202 fn to_rule(&self) -> FieldPromotion {
203 let rule = match self.policy {
204 PromotePolicy::KeepKnown => FieldPromotion::keep_known(&self.field),
205 PromotePolicy::Overwrite => FieldPromotion::overwrite(&self.field),
206 PromotePolicy::TrueOnly => FieldPromotion::true_only(&self.field),
207 PromotePolicy::NonEmpty => FieldPromotion::non_empty(&self.field),
208 };
209 match &self.to {
210 Some(key) => rule.to(key),
211 None => rule,
212 }
213 }
214}
215
216#[derive(
218 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
219)]
220#[serde(rename_all = "snake_case")]
221pub enum TriggerSpec {
222 #[default]
224 EveryTurn,
225 AfterToolCall,
227 OnGenerationComplete,
229 OnPhaseChange,
231}
232
233impl TriggerSpec {
234 fn to_trigger(self) -> ExtractionTrigger {
235 match self {
236 TriggerSpec::EveryTurn => ExtractionTrigger::EveryTurn,
237 TriggerSpec::AfterToolCall => ExtractionTrigger::AfterToolCall,
238 TriggerSpec::OnGenerationComplete => ExtractionTrigger::OnGenerationComplete,
239 TriggerSpec::OnPhaseChange => ExtractionTrigger::OnPhaseChange,
240 }
241 }
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
249pub struct ExtractSpec {
250 pub name: String,
252 pub instruction: String,
254 pub schema: Value,
256 #[serde(default = "default_window")]
258 pub window: usize,
259 #[serde(default)]
261 pub trigger: TriggerSpec,
262 #[serde(default, skip_serializing_if = "Vec::is_empty")]
264 pub promote: Vec<PromoteSpec>,
265}
266
267fn default_window() -> usize {
268 3
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
275#[serde(rename_all = "snake_case")]
276pub enum EffectSpec {
277 Set(BTreeMap<String, Value>),
279 Context(String),
282 Prompt(String),
285 Remember(String),
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
296pub struct TransitionSpec {
297 pub to: String,
299 pub when: Guard,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub description: Option<String>,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
308pub struct PhaseSpec {
309 pub name: String,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub instruction: Option<String>,
314 #[serde(default, skip_serializing_if = "Vec::is_empty")]
316 pub tools: Vec<String>,
317 #[serde(default, skip_serializing_if = "Vec::is_empty")]
319 pub needs: Vec<String>,
320 #[serde(default, skip_serializing_if = "Vec::is_empty")]
322 pub on_enter: Vec<EffectSpec>,
323 #[serde(default, skip_serializing_if = "Vec::is_empty")]
325 pub transitions: Vec<TransitionSpec>,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub prompt_on_enter: Option<bool>,
329 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
331 pub terminal: bool,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
336#[serde(rename_all = "snake_case")]
337pub enum WatchCondition {
338 Changed,
340 ChangedTo(Value),
342 CrossedAbove(f64),
344 CrossedBelow(f64),
346 BecameTrue,
348 BecameFalse,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
357pub struct WatchSpec {
358 pub key: String,
360 pub condition: WatchCondition,
362 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
364 pub set: BTreeMap<String, Value>,
365 #[serde(default, skip_serializing_if = "Vec::is_empty")]
367 pub effects: Vec<EffectSpec>,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
377pub struct PatternSpec {
378 pub name: String,
380 pub when: Guard,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub sustained_secs: Option<u64>,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub turns: Option<u32>,
388 #[serde(default, skip_serializing_if = "Vec::is_empty")]
390 pub effects: Vec<EffectSpec>,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
400pub struct ComputedSpec {
401 pub key: String,
403 pub from: Expr,
405 #[serde(default, skip_serializing_if = "String::is_empty")]
407 pub description: String,
408}
409
410#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
412#[serde(rename_all = "snake_case")]
413pub enum StateType {
414 Boolean,
416 Number,
418 String,
420 Object,
422 Array,
424}
425
426impl StateType {
427 fn matches(self, value: &Value) -> bool {
428 matches!(
429 (self, value),
430 (StateType::Boolean, Value::Bool(_))
431 | (StateType::Number, Value::Number(_))
432 | (StateType::String, Value::String(_))
433 | (StateType::Object, Value::Object(_))
434 | (StateType::Array, Value::Array(_))
435 )
436 }
437}
438
439#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
446pub struct StateFieldSpec {
447 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
449 pub kind: Option<StateType>,
450 #[serde(default, skip_serializing_if = "String::is_empty")]
452 pub description: String,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub default: Option<Value>,
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
463pub struct MemorySlotSpec {
464 pub predicate: String,
466 pub to: String,
468}
469
470#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
477pub struct MemorySpec {
478 #[serde(default, skip_serializing_if = "Vec::is_empty")]
480 pub slots: Vec<MemorySlotSpec>,
481}
482
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
485#[serde(rename_all = "snake_case")]
486pub enum SensitivitySpec {
487 Low,
489 Medium,
491 High,
493}
494
495impl SensitivitySpec {
496 fn to_wire(self) -> Sensitivity {
497 match self {
498 SensitivitySpec::Low => Sensitivity::SensitivityLow,
499 SensitivitySpec::Medium => Sensitivity::SensitivityMedium,
500 SensitivitySpec::High => Sensitivity::SensitivityHigh,
501 }
502 }
503}
504
505#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
508pub struct VadSpec {
509 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub start_sensitivity: Option<SensitivitySpec>,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub end_sensitivity: Option<SensitivitySpec>,
515 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub prefix_padding_ms: Option<u32>,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub silence_duration_ms: Option<u32>,
521}
522
523#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
529pub struct AudioSpec {
530 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub denoise: Option<bool>,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
537 pub noise_gate: Option<NoiseGateSpec>,
538 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub client_vad: Option<ClientVadSpec>,
542 #[serde(default, skip_serializing_if = "Option::is_none")]
544 pub authority: Option<AuthoritySpec>,
545 #[serde(default, skip_serializing_if = "Option::is_none")]
549 pub eot_hold_ms: Option<u32>,
550 #[serde(default, skip_serializing_if = "Option::is_none")]
554 pub min_interruption_ms: Option<u32>,
555}
556
557#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
559pub struct NoiseGateSpec {
560 #[serde(default = "default_gate_threshold")]
563 pub threshold_rms: f64,
564 #[serde(default = "default_gate_hold")]
566 pub hold_frames: u32,
567}
568
569fn default_gate_threshold() -> f64 {
570 700.0
571}
572fn default_gate_hold() -> u32 {
573 3
574}
575
576#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
578pub struct ClientVadSpec {
579 #[serde(default, skip_serializing_if = "Option::is_none")]
581 pub preset: Option<ClientVadPreset>,
582 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub start_threshold_db: Option<f64>,
585 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub stop_threshold_db: Option<f64>,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub min_speech_frames: Option<u32>,
591 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub hangover_frames: Option<u32>,
594}
595
596#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
598#[serde(rename_all = "snake_case")]
599pub enum ClientVadPreset {
600 Default,
602 NoisyStreet,
605}
606
607impl ClientVadSpec {
608 pub fn to_config(&self) -> gemini_genai_rs::vad::VadConfig {
610 let mut config = match self.preset {
611 Some(ClientVadPreset::NoisyStreet) => gemini_genai_rs::vad::VadConfig::noisy_street(),
612 _ => gemini_genai_rs::vad::VadConfig::default(),
613 };
614 if let Some(v) = self.start_threshold_db {
615 config.start_threshold_db = v;
616 }
617 if let Some(v) = self.stop_threshold_db {
618 config.stop_threshold_db = v;
619 }
620 if let Some(v) = self.min_speech_frames {
621 config.min_speech_frames = v;
622 }
623 if let Some(v) = self.hangover_frames {
624 config.hangover_frames = v;
625 }
626 config
627 }
628}
629
630#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
633#[serde(rename_all = "snake_case")]
634pub enum AuthoritySpec {
635 Server,
637 Client,
640}
641
642#[derive(Debug, Clone, Copy, Serialize, Deserialize, schemars::JsonSchema)]
644pub struct TranscriptionSpec {
645 #[serde(default = "default_true")]
647 pub input: bool,
648 #[serde(default = "default_true")]
650 pub output: bool,
651}
652
653fn default_true() -> bool {
654 true
655}
656
657#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
659#[serde(rename_all = "snake_case")]
660pub enum SteeringSpec {
661 InstructionUpdate,
663 ContextInjection,
665 Hybrid,
667}
668
669#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
671#[serde(rename_all = "snake_case")]
672pub enum ContextDeliverySpec {
673 Immediate,
675 Deferred,
677}
678
679#[derive(Debug, Clone, Copy, Serialize, Deserialize, schemars::JsonSchema)]
681pub struct RepairSpec {
682 pub nudge_after: u32,
684 pub escalate_after: u32,
686}
687
688#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
690#[serde(rename_all = "snake_case")]
691pub enum PersistenceSpec {
692 Fs {
694 dir: String,
696 },
697 Memory,
699}
700
701#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
706pub struct RuntimeSpec {
707 #[serde(default, skip_serializing_if = "Option::is_none")]
709 pub temperature: Option<f32>,
710 #[serde(default, skip_serializing_if = "Option::is_none")]
712 pub thinking_budget: Option<u32>,
713 #[serde(default, skip_serializing_if = "Option::is_none")]
715 pub include_thoughts: Option<bool>,
716 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub transcription: Option<TranscriptionSpec>,
719 #[serde(default, skip_serializing_if = "Option::is_none")]
721 pub proactive_audio: Option<bool>,
722 #[serde(default, skip_serializing_if = "Option::is_none")]
724 pub vad: Option<VadSpec>,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub audio: Option<AudioSpec>,
728 #[serde(default, skip_serializing_if = "Option::is_none")]
730 pub soft_turn_timeout_ms: Option<u64>,
731 #[serde(default, skip_serializing_if = "Option::is_none")]
733 pub steering: Option<SteeringSpec>,
734 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub context_delivery: Option<ContextDeliverySpec>,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub repair: Option<RepairSpec>,
740 #[serde(default, skip_serializing_if = "Option::is_none")]
742 pub persistence: Option<PersistenceSpec>,
743 #[serde(default, skip_serializing_if = "Option::is_none")]
745 pub session_id: Option<String>,
746 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
748 pub lossy_audio: bool,
749 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
751 pub lossy_transcript: bool,
752}
753
754#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
762pub struct UseFragment {
763 pub fragment: String,
765 pub namespace: String,
767 #[serde(default, skip_serializing_if = "Vec::is_empty")]
769 pub after: Vec<String>,
770}
771
772#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
774pub struct SessionSpec {
775 #[serde(default)]
777 pub name: String,
778 #[serde(default, skip_serializing_if = "String::is_empty")]
780 pub version: String,
781 #[serde(default)]
783 pub description: String,
784 #[serde(default)]
786 pub instruction: String,
787 #[serde(default, skip_serializing_if = "Option::is_none")]
789 pub greeting: Option<String>,
790 #[serde(default)]
792 pub modality: SpecModality,
793 #[serde(default, skip_serializing_if = "Option::is_none")]
795 pub voice: Option<String>,
796 #[serde(default, skip_serializing_if = "Vec::is_empty")]
798 pub tools: Vec<ToolSpec>,
799 #[serde(default, skip_serializing_if = "Vec::is_empty")]
802 pub mcp: Vec<String>,
803 #[serde(default, skip_serializing_if = "Vec::is_empty")]
805 pub extract: Vec<ExtractSpec>,
806 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
808 pub state: BTreeMap<String, StateFieldSpec>,
809 #[serde(default, skip_serializing_if = "Vec::is_empty")]
811 pub computed: Vec<ComputedSpec>,
812 #[serde(default, skip_serializing_if = "Option::is_none")]
816 pub memory: Option<MemorySpec>,
817 #[serde(default, skip_serializing_if = "Option::is_none")]
819 pub runtime: Option<RuntimeSpec>,
820 #[serde(default, skip_serializing_if = "Vec::is_empty")]
822 pub phases: Vec<PhaseSpec>,
823 #[serde(default, skip_serializing_if = "Option::is_none")]
825 pub initial_phase: Option<String>,
826 #[serde(default, skip_serializing_if = "Vec::is_empty")]
828 pub watch: Vec<WatchSpec>,
829 #[serde(default, skip_serializing_if = "Vec::is_empty")]
831 pub patterns: Vec<PatternSpec>,
832 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
834 pub fragments: BTreeMap<String, Flow>,
835 #[serde(default, skip_serializing_if = "Vec::is_empty")]
837 pub use_fragments: Vec<UseFragment>,
838 #[serde(default, skip_serializing_if = "Option::is_none")]
840 pub flow: Option<Flow>,
841 #[serde(default, skip_serializing_if = "Option::is_none")]
846 pub conversation: Option<crate::conversation::ConversationSpec>,
847 #[serde(default, skip_serializing_if = "Vec::is_empty")]
850 pub scenarios: Vec<crate::simulation::Scenario>,
851 #[serde(default, skip_serializing_if = "Vec::is_empty")]
854 pub tests: Vec<SpecTest>,
855}
856
857#[derive(Debug, Clone, Serialize)]
859pub struct SpecValidation {
860 pub valid: bool,
862 pub errors: Vec<String>,
864 pub warnings: Vec<String>,
866 pub mermaid: String,
868 pub tools: Vec<String>,
870 pub steps: usize,
872}
873
874pub trait MemoryBinding: Send + Sync {
882 fn install(&self, live: Live, memory: &MemorySpec) -> Live;
884 fn remember(&self, note: String);
887}
888
889#[derive(Debug, Clone, Default, PartialEq, Eq)]
901pub struct BindingAllowlist {
902 http_prefixes: Vec<String>,
903 mcp: Vec<String>,
904}
905
906impl BindingAllowlist {
907 pub fn allow_http_prefix(mut self, prefix: impl Into<String>) -> Self {
911 let mut prefix = prefix.into();
912 let after_scheme = prefix.split_once("://").map_or("", |(_, rest)| rest);
913 if !after_scheme.contains('/') {
914 prefix.push('/');
915 }
916 self.http_prefixes.push(prefix);
917 self
918 }
919
920 pub fn allow_mcp(mut self, params: impl Into<String>) -> Self {
922 self.mcp.push(params.into());
923 self
924 }
925
926 fn allows_http(&self, url: &str) -> bool {
931 let Ok(target) = url::Url::parse(url) else {
932 return false;
933 };
934 if !target.username().is_empty() || target.password().is_some() {
935 return false;
936 }
937 self.http_prefixes.iter().any(|prefix| {
938 url::Url::parse(prefix).is_ok_and(|prefix| {
939 prefix.scheme() == target.scheme()
940 && prefix.host() == target.host()
941 && prefix.port_or_known_default() == target.port_or_known_default()
942 && target.path().starts_with(prefix.path())
943 })
944 })
945 }
946
947 fn allows_mcp(&self, params: &str) -> bool {
948 self.mcp.iter().any(|m| m.trim() == params.trim())
949 }
950}
951
952const RUNTIME_WRITTEN: [&str; 6] = [
955 "flow:",
956 "session:",
957 "verbatim:",
958 "correction:",
959 "repair:",
960 "telephony:",
961];
962
963#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
965pub struct ScenarioReport {
966 pub name: String,
968 pub passed: bool,
970 #[serde(default, skip_serializing_if = "Option::is_none")]
972 pub error: Option<String>,
973}
974
975pub const MEMORY_TOOL_NAMES: [&str; 2] = ["recall_context", "manage_memory"];
977
978#[derive(Default)]
981pub struct SpecResources {
982 pub extraction_llm: Option<Arc<dyn BaseLlm>>,
984 pub memory: Option<Arc<dyn MemoryBinding>>,
987 pub tools: BTreeMap<String, Arc<dyn ToolFunction>>,
993}
994
995impl SpecResources {
996 pub fn implement(mut self, tool: impl ToolFunction + 'static) -> Self {
998 self.tools.insert(tool.name().to_string(), Arc::new(tool));
999 self
1000 }
1001}
1002
1003impl SessionSpec {
1004 pub fn from_value(value: Value) -> Result<Self, String> {
1007 let is_bare_flow = value.get("flow").is_none() && value.get("steps").is_some();
1008 if is_bare_flow {
1009 let flow: Flow =
1010 serde_json::from_value(value).map_err(|e| format!("invalid flow JSON: {e}"))?;
1011 return Ok(Self {
1012 flow: Some(flow),
1013 ..Self::default()
1014 });
1015 }
1016 serde_json::from_value(value).map_err(|e| format!("invalid session spec JSON: {e}"))
1017 }
1018
1019 pub fn json_schema() -> Value {
1022 serde_json::to_value(schemars::schema_for!(SessionSpec)).unwrap_or_else(|_| json!({}))
1023 }
1024
1025 pub fn tool_names(&self) -> Vec<String> {
1027 self.tools.iter().map(|t| t.name.clone()).collect()
1028 }
1029
1030 pub fn effective_flow(&self) -> Result<Flow, Vec<String>> {
1032 if let Some(conversation) = &self.conversation {
1033 if self.flow.is_some() || !self.use_fragments.is_empty() {
1034 return Err(vec![
1035 "set either `conversation` or `flow` (with `use_fragments`), not both".into(),
1036 ]);
1037 }
1038 return crate::conversation::Conversation::from_spec_stubbing_resolvers(
1039 conversation.clone(),
1040 )
1041 .map(|compiled| compiled.flow().flow().clone())
1042 .map_err(|e| vec![format!("conversation: {e}")]);
1043 }
1044 let mut flow = self.flow.clone().unwrap_or_default();
1045 let mut errors = Vec::new();
1046 for use_frag in &self.use_fragments {
1047 match self.fragments.get(&use_frag.fragment) {
1048 Some(fragment) => {
1049 splice_fragment(&mut flow, fragment, use_frag, &mut errors);
1050 }
1051 None => errors.push(format!(
1052 "use_fragments references unknown fragment '{}'",
1053 use_frag.fragment
1054 )),
1055 }
1056 }
1057 if errors.is_empty() {
1058 Ok(flow)
1059 } else {
1060 Err(errors)
1061 }
1062 }
1063
1064 pub fn state_keys_written(&self) -> std::collections::BTreeSet<String> {
1068 let mut keys = std::collections::BTreeSet::new();
1069 for t in &self.tools {
1070 keys.extend(t.set_state.keys().cloned());
1071 keys.extend(t.save_response_as.iter().cloned());
1072 }
1073 for e in &self.extract {
1074 keys.insert(e.name.clone());
1075 for p in &e.promote {
1076 keys.insert(p.target().to_string());
1077 }
1078 }
1079 for p in &self.phases {
1080 for eff in &p.on_enter {
1081 if let EffectSpec::Set(map) = eff {
1082 keys.extend(map.keys().cloned());
1083 }
1084 }
1085 }
1086 for w in &self.watch {
1087 keys.extend(w.set.keys().cloned());
1088 for eff in &w.effects {
1089 if let EffectSpec::Set(map) = eff {
1090 keys.extend(map.keys().cloned());
1091 }
1092 }
1093 }
1094 for p in &self.patterns {
1095 for eff in &p.effects {
1096 if let EffectSpec::Set(map) = eff {
1097 keys.extend(map.keys().cloned());
1098 }
1099 }
1100 }
1101 for c in &self.computed {
1102 keys.insert(c.key.clone());
1105 keys.insert(format!("derived:{}", c.key));
1106 }
1107 if let Some(memory) = &self.memory {
1108 for slot in &memory.slots {
1109 keys.extend([slot.to.clone()]);
1110 }
1111 }
1112 for (key, field) in &self.state {
1113 if field.default.is_some() {
1114 keys.insert(key.clone());
1115 }
1116 }
1117 for stage in self.conversation_stages() {
1119 keys.extend(stage.collect.iter().cloned());
1120 keys.extend(stage.resolve.iter().map(|r| r.slot.clone()));
1121 if let Some(frame) = &stage.frame {
1122 keys.extend(frame.slot_keys());
1123 }
1124 }
1125 keys
1126 }
1127
1128 fn conversation_stages(&self) -> impl Iterator<Item = &crate::conversation::StageSpec> {
1130 self.conversation.iter().flat_map(|c| {
1131 c.stages
1132 .iter()
1133 .chain(c.overlays.iter().flat_map(|o| o.stages.iter()))
1134 })
1135 }
1136
1137 fn resolver_registry(
1140 &self,
1141 tools: &[Arc<SimpleTool>],
1142 ) -> crate::conversation::ResolverRegistry {
1143 let mut registry = crate::conversation::ResolverRegistry::new();
1144 for stage in self.conversation_stages() {
1145 for resolve in &stage.resolve {
1146 let name = resolve
1147 .resolver
1148 .clone()
1149 .unwrap_or_else(|| resolve.slot.clone());
1150 if let Some(tool) = tools
1151 .iter()
1152 .find(|t| ToolFunction::name(t.as_ref()) == name)
1153 {
1154 let tool = tool.clone();
1155 registry.add(name, move |args| {
1156 let tool = tool.clone();
1157 async move {
1158 gemini_adk_rs::tool::ToolFunction::call(tool.as_ref(), args)
1159 .await
1160 .map_err(|e| e.to_string())
1161 }
1162 });
1163 }
1164 }
1165 }
1166 registry
1167 }
1168
1169 pub async fn run_scenarios(&self) -> Vec<ScenarioReport> {
1173 let failed = |error: String| -> Vec<ScenarioReport> {
1174 self.scenarios
1175 .iter()
1176 .map(|s| ScenarioReport {
1177 name: s.name.clone(),
1178 passed: false,
1179 error: Some(error.clone()),
1180 })
1181 .collect()
1182 };
1183 let Some(conversation) = &self.conversation else {
1184 return failed("the spec has no `conversation` to run against".into());
1185 };
1186 let compiled = match crate::conversation::Conversation::from_spec_stubbing_resolvers(
1187 conversation.clone(),
1188 ) {
1189 Ok(compiled) => compiled,
1190 Err(e) => return failed(format!("conversation: {e}")),
1191 };
1192 let mut reports = Vec::with_capacity(self.scenarios.len());
1193 for scenario in &self.scenarios {
1194 let result = scenario
1195 .run(&compiled, gemini_adk_rs::flow::Enforcement::Enforce)
1196 .await;
1197 reports.push(ScenarioReport {
1198 name: scenario.name.clone(),
1199 passed: result.is_ok(),
1200 error: result.err(),
1201 });
1202 }
1203 reports
1204 }
1205
1206 fn all_effects(&self) -> Vec<(String, &EffectSpec)> {
1208 let mut out = Vec::new();
1209 for p in &self.phases {
1210 for eff in &p.on_enter {
1211 out.push((format!("phase '{}'", p.name), eff));
1212 }
1213 }
1214 for w in &self.watch {
1215 for eff in &w.effects {
1216 out.push((format!("watch '{}'", w.key), eff));
1217 }
1218 }
1219 for p in &self.patterns {
1220 for eff in &p.effects {
1221 out.push((format!("pattern '{}'", p.name), eff));
1222 }
1223 }
1224 out
1225 }
1226
1227 pub(crate) fn recompute_computed(&self, state: &State) {
1232 for _ in 0..self.computed.len() {
1233 let mut changed = false;
1234 for c in &self.computed {
1235 if let Some(value) = c.from.eval(state) {
1236 let derived = format!("derived:{}", c.key);
1237 if state.get_raw(&derived).as_ref() != Some(&value) {
1238 let _ = state.set(&derived, value);
1239 changed = true;
1240 }
1241 }
1242 }
1243 if !changed {
1244 break;
1245 }
1246 }
1247 }
1248
1249 pub(crate) fn seed_state_defaults(&self, state: &State) {
1251 for (key, field) in &self.state {
1252 if let Some(default) = &field.default
1253 && state.get_raw(key).is_none()
1254 {
1255 let _ = state.set(key, default.clone());
1256 }
1257 }
1258 }
1259
1260 pub fn validate(&self) -> SpecValidation {
1264 let mut errors = Vec::new();
1265 let mut warnings = Vec::new();
1266
1267 for use_frag in &self.use_fragments {
1269 if use_frag.namespace.is_empty() {
1270 errors.push(
1271 "use_fragments directive has empty namespace — step ids would be malformed"
1272 .into(),
1273 );
1274 }
1275 }
1276
1277 let flow = match self.effective_flow() {
1278 Ok(flow) => flow,
1279 Err(errs) => {
1280 errors.extend(errs);
1281 self.flow.clone().unwrap_or_default()
1282 }
1283 };
1284 for stage in self.conversation_stages() {
1285 for resolve in &stage.resolve {
1286 let name = resolve.resolver.as_deref().unwrap_or(&resolve.slot);
1287 if !self.tools.iter().any(|t| t.name == name) {
1288 errors.push(format!(
1289 "stage '{}' resolves '{}' with '{name}', which is not a declared tool",
1290 stage.id, resolve.slot
1291 ));
1292 }
1293 }
1294 }
1295 if !self.scenarios.is_empty() && self.conversation.is_none() {
1296 errors.push("`scenarios` need a `conversation` to run against".into());
1297 }
1298 let mermaid = flow.to_mermaid();
1299 let steps = flow.steps.len();
1300 let has_flow = !flow.steps.is_empty();
1301
1302 if !has_flow && self.phases.is_empty() {
1303 errors.push("spec has neither a flow nor phases — nothing to run".into());
1304 }
1305 if !self.phases.is_empty() && self.initial_phase.is_none() {
1306 errors.push("phases are declared but initial_phase is not set".into());
1307 }
1308 if let Some(initial) = &self.initial_phase
1309 && !self.phases.iter().any(|p| &p.name == initial)
1310 {
1311 errors.push(format!("initial_phase '{initial}' is not a declared phase"));
1312 }
1313 {
1315 let phase_names: std::collections::BTreeSet<&str> =
1316 self.phases.iter().map(|p| p.name.as_str()).collect();
1317 if phase_names.len() != self.phases.len() {
1318 let mut seen = std::collections::BTreeSet::new();
1319 for p in &self.phases {
1320 if !seen.insert(p.name.as_str()) {
1321 errors.push(format!("phase '{}' is declared more than once", p.name));
1322 }
1323 }
1324 }
1325 }
1326 for pattern in &self.patterns {
1327 match (pattern.sustained_secs, pattern.turns) {
1328 (Some(_), Some(_)) | (None, None) => errors.push(format!(
1329 "pattern '{}' must set exactly one of sustained_secs or turns",
1330 pattern.name
1331 )),
1332 _ => {}
1333 }
1334 if guard_uses_marking(&pattern.when) {
1335 errors.push(format!(
1336 "pattern '{}' uses a called_ok/done atom — pattern guards see state only",
1337 pattern.name
1338 ));
1339 }
1340 }
1341 for p in &self.phases {
1342 for t in &p.transitions {
1343 if guard_uses_marking(&t.when) {
1344 errors.push(format!(
1345 "phase '{}' transition to '{}' uses a called_ok/done atom — phase guards \
1346 see state only (no flow marking); latch a state key instead",
1347 p.name, t.to
1348 ));
1349 }
1350 }
1351 }
1352 for t in &self.tools {
1353 if t.http.is_some() && t.mcp.is_some() {
1354 errors.push(format!(
1355 "tool '{}' has both an http and an mcp binding; keep one",
1356 t.name
1357 ));
1358 }
1359 }
1360 if cfg!(not(feature = "http-tools")) {
1361 for t in &self.tools {
1362 if t.http.is_some() {
1363 errors.push(format!(
1364 "tool '{}' has an http binding but the `http-tools` feature is not \
1365 enabled",
1366 t.name
1367 ));
1368 }
1369 }
1370 }
1371
1372 {
1376 let computed_keys: std::collections::BTreeSet<&str> =
1377 self.computed.iter().map(|c| c.key.as_str()).collect();
1378 if computed_keys.len() != self.computed.len() {
1379 errors.push("computed variables declare a duplicate key".into());
1380 }
1381 let normalize = |k: &str| k.strip_prefix("derived:").unwrap_or(k).to_string();
1382 let mut in_degree: BTreeMap<&str, usize> =
1383 computed_keys.iter().map(|k| (*k, 0)).collect();
1384 let mut dependents: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1385 for c in &self.computed {
1386 for dep in c.from.keys_read() {
1387 let dep = normalize(&dep);
1388 if dep != c.key && computed_keys.contains(dep.as_str()) {
1389 let dep_key = *computed_keys.get(dep.as_str()).unwrap();
1390 dependents.entry(dep_key).or_default().push(c.key.as_str());
1391 *in_degree.entry(c.key.as_str()).or_default() += 1;
1392 }
1393 if dep == c.key {
1394 errors.push(format!("computed '{}' reads its own key", c.key));
1395 }
1396 }
1397 }
1398 let mut queue: Vec<&str> = in_degree
1399 .iter()
1400 .filter(|(_, d)| **d == 0)
1401 .map(|(k, _)| *k)
1402 .collect();
1403 let mut visited = 0usize;
1404 while let Some(key) = queue.pop() {
1405 visited += 1;
1406 for dependent in dependents.get(key).cloned().unwrap_or_default() {
1407 let d = in_degree.get_mut(dependent).unwrap();
1408 *d -= 1;
1409 if *d == 0 {
1410 queue.push(dependent);
1411 }
1412 }
1413 }
1414 if visited != computed_keys.len() {
1415 let cycle: Vec<&str> = in_degree
1416 .iter()
1417 .filter(|(_, d)| **d > 0)
1418 .map(|(k, _)| *k)
1419 .collect();
1420 errors.push(format!(
1421 "computed variables form a dependency cycle: {}",
1422 cycle.join(", ")
1423 ));
1424 }
1425 }
1426
1427 for (location, effect) in self.all_effects() {
1430 if matches!(effect, EffectSpec::Remember(_)) && self.memory.is_none() {
1431 errors.push(format!(
1432 "{location} uses a `remember` effect but the spec has no `memory` section"
1433 ));
1434 }
1435 }
1436 if let Some(memory) = &self.memory {
1437 for slot in &memory.slots {
1438 if slot.to.starts_with("derived:") {
1439 errors.push(format!(
1440 "memory slot '{}' targets read-only key '{}' — the `derived:` scope \
1441 belongs to computed variables",
1442 slot.predicate, slot.to
1443 ));
1444 }
1445 }
1446 }
1447
1448 for (key, field) in &self.state {
1451 if let (Some(kind), Some(default)) = (field.kind, &field.default)
1452 && !kind.matches(default)
1453 {
1454 warnings.push(format!(
1455 "state key '{key}' declares type {kind:?} but its default is {default}"
1456 ));
1457 }
1458 }
1459 if !self.state.is_empty() {
1460 let declared: std::collections::BTreeSet<&str> =
1461 self.state.keys().map(String::as_str).collect();
1462 let mut undeclared = std::collections::BTreeSet::new();
1463 for key in self.state_keys_written() {
1464 let bare = key.strip_prefix("derived:").unwrap_or(&key);
1465 if !declared.contains(bare) && !self.computed.iter().any(|c| c.key == bare) {
1466 undeclared.insert(key.clone());
1467 }
1468 }
1469 for key in &undeclared {
1470 warnings.push(format!(
1471 "state key '{key}' is written but not declared in the `state` section"
1472 ));
1473 }
1474 }
1475
1476 {
1479 let written = self.state_keys_written();
1480 for c in &self.computed {
1481 for dep in c.from.keys_read() {
1482 let bare = dep.strip_prefix("derived:").unwrap_or(&dep);
1483 if !written.contains(&dep)
1484 && !written.contains(bare)
1485 && !dep.ends_with(":result")
1486 {
1487 warnings.push(format!(
1488 "computed '{}' reads state key '{dep}' but nothing writes it",
1489 c.key
1490 ));
1491 }
1492 }
1493 }
1494 }
1495
1496 if let Some(runtime) = &self.runtime {
1498 if runtime.include_thoughts == Some(true) && runtime.thinking_budget.is_none() {
1499 warnings.push(
1500 "runtime.include_thoughts is set without runtime.thinking_budget — no \
1501 thoughts will arrive"
1502 .into(),
1503 );
1504 }
1505 if let Some(audio) = &runtime.audio {
1506 if audio.denoise == Some(true) && !cfg!(feature = "denoise") {
1507 warnings.push(
1508 "runtime.audio.denoise requires building with the `denoise` feature — \
1509 the stage will be skipped"
1510 .into(),
1511 );
1512 }
1513 if audio.authority == Some(AuthoritySpec::Client) && audio.denoise != Some(true) {
1514 warnings.push(
1515 "runtime.audio.authority=client without denoise — in noise the raw \
1516 energy VAD latches open and will drive interruptions falsely \
1517 (measured); enable denoise or expect spurious barge-ins"
1518 .into(),
1519 );
1520 }
1521 if audio.noise_gate.is_some() && audio.denoise != Some(true) {
1522 warnings.push(
1523 "runtime.audio.noise_gate without denoise — the gate calibrates on \
1524 noisy levels; chain it behind denoise so it gates clean audio"
1525 .into(),
1526 );
1527 }
1528 if audio.authority == Some(AuthoritySpec::Client) && runtime.vad.is_some() {
1529 warnings.push(
1530 "runtime.audio.authority=client disables the server's automatic \
1531 activity detection — runtime.vad sensitivities will have no effect"
1532 .into(),
1533 );
1534 }
1535 if let Some(eot_ms) = audio.eot_hold_ms
1536 && eot_ms > 1600
1537 {
1538 warnings.push(
1539 "runtime.audio.eot_hold_ms exceeds the measured frontier (1600 ms): \
1540 recall fell to 0.508 at 1600ms on TurnBench dev — values beyond this \
1541 may cause missed turn-end detection"
1542 .into(),
1543 );
1544 }
1545 if let Some(min_int_ms) = audio.min_interruption_ms
1546 && min_int_ms > 2000
1547 {
1548 warnings.push(
1549 "runtime.audio.min_interruption_ms exceeds the interruption match \
1550 window (2000 ms) — commits may land too late to count"
1551 .into(),
1552 );
1553 }
1554 }
1555 if runtime.session_id.is_some() && runtime.persistence.is_none() {
1556 warnings.push(
1557 "runtime.session_id is set without runtime.persistence — nothing will be \
1558 snapshotted"
1559 .into(),
1560 );
1561 }
1562 }
1563
1564 let (valid_flow, referenced) = if has_flow {
1568 let compile_result = if self.tools.is_empty() || !self.mcp.is_empty() {
1569 if !self.mcp.is_empty() && !self.tools.is_empty() {
1570 warnings.push(
1571 "MCP toolsets resolve at connect time, so tool-name checking against \
1572 the flow is skipped"
1573 .into(),
1574 );
1575 }
1576 flow.clone().compile()
1577 } else {
1578 let mut names = self.tool_names();
1579 if self.memory.is_some() {
1580 names.extend(
1583 MEMORY_TOOL_NAMES
1584 .iter()
1585 .map(std::string::ToString::to_string),
1586 );
1587 }
1588 let refs: Vec<&str> = names.iter().map(String::as_str).collect();
1589 flow.clone().compile_with_tools(&refs)
1590 };
1591 match compile_result {
1592 Ok(compiled) => (
1593 true,
1594 compiled
1595 .tool_surface()
1596 .tools
1597 .iter()
1598 .cloned()
1599 .collect::<Vec<_>>(),
1600 ),
1601 Err(errs) => {
1602 errors.extend(errs.0.iter().map(std::string::ToString::to_string));
1603 (false, Vec::new())
1604 }
1605 }
1606 } else {
1607 (true, Vec::new())
1608 };
1609
1610 if valid_flow && has_flow {
1611 let written = self.state_keys_written();
1614 for key in flow.state_keys_read() {
1615 if written.contains(&key) || RUNTIME_WRITTEN.iter().any(|p| key.starts_with(p)) {
1616 continue;
1617 }
1618 if key.ends_with(":result") {
1620 continue;
1621 }
1622 let hint = written
1623 .iter()
1624 .filter(|w| levenshtein(&key, w) <= 2)
1625 .cloned()
1626 .collect::<Vec<_>>();
1627 let suffix = if hint.is_empty() {
1628 String::new()
1629 } else {
1630 format!(" — did you mean {}?", hint.join(" / "))
1631 };
1632 warnings.push(format!(
1633 "a guard reads state key '{key}' but no tool, extractor, phase, or watcher \
1634 writes it (it can never latch){suffix}"
1635 ));
1636 }
1637 for t in &self.tools {
1638 if !referenced.contains(&t.name) {
1639 warnings.push(format!(
1640 "tool '{}' is declared but no step or constraint references it \
1641 (it will be denied whenever a step with an `allow` list is active \
1642 unless you add it to `ambient`)",
1643 t.name
1644 ));
1645 }
1646 }
1647 for s in &flow.steps {
1648 if !s.terminal && s.posture.is_none() {
1649 warnings.push(format!(
1650 "step '{}' has no posture — the model gets no steering while it is active",
1651 s.id
1652 ));
1653 }
1654 }
1655 }
1656
1657 SpecValidation {
1658 valid: errors.is_empty(),
1659 errors,
1660 warnings,
1661 mermaid,
1662 tools: referenced,
1663 steps,
1664 }
1665 }
1666
1667 pub fn build_dispatcher(&self, state: &State) -> ToolDispatcher {
1669 self.build_dispatcher_with(state, &SpecResources::default())
1670 }
1671
1672 pub fn build_dispatcher_with(
1676 &self,
1677 state: &State,
1678 resources: &SpecResources,
1679 ) -> ToolDispatcher {
1680 let mut dispatcher = ToolDispatcher::new();
1681 for tool in self.bound_tools(state, resources) {
1682 dispatcher.register(tool);
1683 }
1684 dispatcher
1685 }
1686
1687 fn bound_tools(&self, state: &State, resources: &SpecResources) -> Vec<Arc<SimpleTool>> {
1690 let mut servers: BTreeMap<String, Arc<gemini_adk_rs::tools::mcp::McpSessionManager>> =
1691 BTreeMap::new();
1692 let mut tools = Vec::new();
1693 for tool in &self.tools {
1694 let call = match (resources.tools.get(&tool.name), &tool.mcp) {
1695 (Some(implementation), _) => ToolCall::Code(implementation.clone()),
1696 (None, Some(params)) => ToolCall::Mcp(
1697 servers
1698 .entry(params.clone())
1699 .or_insert_with(|| {
1700 Arc::new(gemini_adk_rs::tools::mcp::McpSessionManager::new(
1701 crate::live::connect::parse_mcp_params(params),
1702 ))
1703 })
1704 .clone(),
1705 ),
1706 (None, None) => ToolCall::Declared,
1707 };
1708 tools.push(Arc::new(build_tool(tool, state, call)));
1709 }
1710 tools
1711 }
1712
1713 pub(crate) fn apply_tool_state(&self, name: &str, state: &State) {
1716 if let Some(tool) = self.tools.iter().find(|t| t.name == name) {
1717 for (key, value) in &tool.set_state {
1718 let _ = state.set(key, value.clone());
1719 }
1720 }
1721 }
1722
1723 pub fn run_tests(&self) -> Vec<TestReport> {
1726 simulate::run_tests(self)
1727 }
1728
1729 pub fn sandboxed(&self, allow: &BindingAllowlist) -> (SessionSpec, Vec<String>) {
1738 let mut spec = self.clone();
1739 let mut notes = Vec::new();
1740 spec.mcp.retain(|params| {
1741 let keep = allow.allows_mcp(params);
1742 if !keep {
1743 notes.push(format!(
1744 "mcp entry `{params}` is not allowed on this server; removed"
1745 ));
1746 }
1747 keep
1748 });
1749 for tool in &mut spec.tools {
1750 if let Some(params) = &tool.mcp
1751 && !allow.allows_mcp(params)
1752 {
1753 notes.push(format!(
1754 "tool `{}`: MCP binding `{params}` is not allowed on this server; it runs as a mock",
1755 tool.name
1756 ));
1757 tool.mcp = None;
1758 }
1759 let Some(binding) = &mut tool.http else {
1760 continue;
1761 };
1762 if allow.allows_http(&binding.url) {
1763 binding.reach = Some(allow.clone());
1766 } else {
1767 notes.push(format!(
1768 "tool `{}`: HTTP binding to `{}` is not allowed on this server; it runs as a mock",
1769 tool.name, binding.url
1770 ));
1771 tool.http = None;
1772 }
1773 }
1774 (spec, notes)
1775 }
1776
1777 pub fn apply(
1788 &self,
1789 live: Live,
1790 state: &State,
1791 resources: &SpecResources,
1792 ) -> Result<Live, String> {
1793 let validation = self.validate();
1794 if !validation.valid {
1795 return Err(format!(
1796 "spec failed validation: {}",
1797 validation.errors.join("; ")
1798 ));
1799 }
1800 if !self.extract.is_empty() && resources.extraction_llm.is_none() {
1801 return Err(
1802 "spec declares extraction but SpecResources.extraction_llm is not set".into(),
1803 );
1804 }
1805 if self.memory.is_some() && resources.memory.is_none() {
1806 return Err("spec declares memory but SpecResources.memory is not set".into());
1807 }
1808 if let Some(name) = resources
1809 .tools
1810 .keys()
1811 .find(|name| !self.tools.iter().any(|t| &&t.name == name))
1812 {
1813 return Err(format!(
1814 "SpecResources implements tool '{name}', which the spec does not declare"
1815 ));
1816 }
1817
1818 self.seed_state_defaults(state);
1820
1821 let mut live = live
1822 .state(state.clone())
1823 .instruction(if self.instruction.is_empty() {
1824 "Follow the conversation flow you are given.".to_string()
1825 } else {
1826 self.instruction.clone()
1827 });
1828
1829 if let Some(greeting) = &self.greeting {
1830 live = live.greeting(greeting.clone());
1831 }
1832 live = match self.modality {
1833 SpecModality::Text => live.text_only(),
1834 SpecModality::Audio => live.voice(resolve_voice(self.voice.as_deref())),
1835 };
1836
1837 let tools = self.bound_tools(state, resources);
1840 if !tools.is_empty() {
1841 let mut dispatcher = ToolDispatcher::new();
1842 for tool in &tools {
1843 dispatcher.register(tool.clone());
1844 }
1845 live = live.dispatcher(dispatcher);
1846 }
1847 for params in &self.mcp {
1848 live = live.tools(T::mcp(params.clone()));
1849 }
1850
1851 if let Some(conversation) = &self.conversation {
1854 let compiled = crate::conversation::Conversation::from_spec_with_resolvers(
1855 conversation.clone(),
1856 &self.resolver_registry(&tools),
1857 )
1858 .map_err(|e| format!("conversation: {e}"))?;
1859 live = live.converse(&compiled);
1860 } else {
1861 let flow = self.effective_flow().map_err(|e| e.join("; "))?;
1862 if !flow.steps.is_empty() {
1863 live = live.govern(flow);
1864 }
1865 }
1866
1867 for c in &self.computed {
1870 let expr = c.from.clone();
1871 let deps: Vec<String> = expr.keys_read().into_iter().collect();
1872 let dep_refs: Vec<&str> = deps.iter().map(String::as_str).collect();
1873 live = live.computed(c.key.clone(), &dep_refs, move |s| expr.eval(s));
1874 }
1875
1876 let memory_binding = resources.memory.clone();
1878 if let (Some(memory), Some(binding)) = (&self.memory, &memory_binding) {
1879 live = binding.install(live, memory);
1880 }
1881
1882 for tool in &self.tools {
1884 match (tool.background, tool.scheduling) {
1885 (_, Some(scheduling)) => {
1886 live = live
1887 .tool_background_with_scheduling(tool.name.clone(), scheduling.to_wire());
1888 }
1889 (true, None) => {
1890 live = live.tool_background(tool.name.clone());
1891 }
1892 (false, None) => {}
1893 }
1894 }
1895
1896 if let Some(runtime) = &self.runtime {
1898 live = apply_runtime(live, runtime);
1899 }
1900
1901 if let Some(llm) = &resources.extraction_llm {
1903 for e in &self.extract {
1904 let mut extractor =
1905 LlmExtractor::new(e.name.clone(), llm.clone(), e.instruction.clone(), e.window)
1906 .with_schema(e.schema.clone())
1907 .with_min_words(3)
1908 .with_trigger(e.trigger.to_trigger());
1909 if !e.promote.is_empty() {
1910 extractor = extractor
1911 .with_promotions(e.promote.iter().map(PromoteSpec::to_rule).collect());
1912 }
1913 live = live.extractor(Arc::new(extractor));
1914 }
1915 }
1916
1917 for p in &self.phases {
1919 let mut builder = live.phase(p.name.clone());
1920 if let Some(instruction) = &p.instruction {
1921 builder = builder.instruction(instruction.clone());
1922 }
1923 if !p.tools.is_empty() {
1924 builder = builder.tools(p.tools.clone());
1925 }
1926 if !p.needs.is_empty() {
1927 let refs: Vec<&str> = p.needs.iter().map(String::as_str).collect();
1928 builder = builder.needs(&refs);
1929 }
1930 if p.prompt_on_enter == Some(true) {
1931 builder = builder.prompt_on_enter();
1932 }
1933 if p.terminal {
1934 builder = builder.terminal();
1935 }
1936 for t in &p.transitions {
1937 let guard = t.when.clone();
1938 let predicate = move |s: &State| guard.eval_state(s);
1939 builder = match &t.description {
1940 Some(desc) => builder.transition_with(&t.to, predicate, desc.clone()),
1941 None => builder.transition(&t.to, predicate),
1942 };
1943 }
1944 if !p.on_enter.is_empty() {
1945 let effects = p.on_enter.clone();
1946 let memory = memory_binding.clone();
1947 builder = builder.on_enter(move |state, writer| {
1948 let effects = effects.clone();
1949 let memory = memory.clone();
1950 async move {
1951 run_effects(&effects, &state, &writer, memory.as_ref()).await;
1952 }
1953 });
1954 }
1955 live = builder.done();
1956 }
1957 if let Some(initial) = &self.initial_phase {
1958 live = live.initial_phase(initial.clone());
1959 }
1960
1961 for pattern in &self.patterns {
1963 let guard = pattern.when.clone();
1964 let condition = move |s: &State| guard.eval_state(s);
1965 let effects = pattern.effects.clone();
1966 let memory = memory_binding.clone();
1967 let action = move |state: State, writer: Arc<dyn SessionWriter>| {
1968 let effects = effects.clone();
1969 let memory = memory.clone();
1970 async move {
1971 run_effects(&effects, &state, &writer, memory.as_ref()).await;
1972 }
1973 };
1974 if let Some(secs) = pattern.sustained_secs {
1975 live = live.when_sustained(
1976 pattern.name.clone(),
1977 condition,
1978 std::time::Duration::from_secs(secs),
1979 action,
1980 );
1981 } else if let Some(turns) = pattern.turns {
1982 live = live.when_turns(pattern.name.clone(), condition, turns, action);
1983 }
1984 }
1985
1986 for w in &self.watch {
1988 let builder = live.watch(w.key.clone());
1989 let builder = match &w.condition {
1990 WatchCondition::Changed => builder.changed(),
1991 WatchCondition::ChangedTo(v) => builder.changed_to(v.clone()),
1992 WatchCondition::CrossedAbove(t) => builder.crossed_above(*t),
1993 WatchCondition::CrossedBelow(t) => builder.crossed_below(*t),
1994 WatchCondition::BecameTrue => builder.became_true(),
1995 WatchCondition::BecameFalse => builder.became_false(),
1996 };
1997 let sets = w.set.clone();
1998 let effects = w.effects.clone();
1999 let memory = memory_binding.clone();
2000 live = builder.then_with_writer(move |_old, _new, state, writer| {
2001 let sets = sets.clone();
2002 let effects = effects.clone();
2003 let memory = memory.clone();
2004 async move {
2005 for (key, value) in &sets {
2006 let _ = state.set(key, value.clone());
2007 }
2008 run_effects(&effects, &state, &writer, memory.as_ref()).await;
2009 }
2010 });
2011 }
2012
2013 Ok(live)
2014 }
2015}
2016
2017async fn run_effects(
2021 effects: &[EffectSpec],
2022 state: &State,
2023 writer: &Arc<dyn SessionWriter>,
2024 memory: Option<&Arc<dyn MemoryBinding>>,
2025) {
2026 for effect in effects {
2027 match effect {
2028 EffectSpec::Set(map) => {
2029 for (key, value) in map {
2030 let _ = state.set(key, value.clone());
2031 }
2032 }
2033 EffectSpec::Context(text) => {
2034 let _ = writer
2035 .send_client_content(vec![Content::model(text.clone())], false)
2036 .await;
2037 }
2038 EffectSpec::Prompt(text) => {
2039 let _ = writer
2040 .send_client_content(vec![Content::model(text.clone())], true)
2041 .await;
2042 }
2043 EffectSpec::Remember(template) => {
2044 if let Some(binding) = memory {
2045 binding.remember(interpolate(template, &Value::Null, state));
2046 }
2047 }
2048 }
2049 }
2050}
2051
2052fn apply_turn_commit(
2056 mut live: Live,
2057 eot_hold_ms: Option<u32>,
2058 min_interruption_ms: Option<u32>,
2059) -> Live {
2060 if let Some(ms) = eot_hold_ms {
2061 live = live.turn_commit_eot_hold_ms(u64::from(ms));
2062 }
2063 if let Some(ms) = min_interruption_ms {
2064 live = live.turn_commit_min_interruption_ms(u64::from(ms));
2065 }
2066 live
2067}
2068
2069fn apply_runtime(mut live: Live, runtime: &RuntimeSpec) -> Live {
2071 if let Some(t) = runtime.temperature {
2072 live = live.temperature(t);
2073 }
2074 if let Some(budget) = runtime.thinking_budget {
2075 live = live.thinking(budget);
2076 }
2077 if runtime.include_thoughts == Some(true) {
2078 live = live.include_thoughts();
2079 }
2080 if let Some(t) = runtime.transcription {
2081 if t.input {
2082 live = live.input_transcription();
2083 }
2084 if t.output {
2085 live = live.output_transcription();
2086 }
2087 }
2088 if runtime.proactive_audio == Some(true) {
2089 live = live.proactive_audio();
2090 }
2091 if let Some(vad) = &runtime.vad {
2092 live = live.vad(AutomaticActivityDetection {
2093 disabled: None,
2094 start_of_speech_sensitivity: vad.start_sensitivity.map(SensitivitySpec::to_wire),
2095 end_of_speech_sensitivity: vad.end_sensitivity.map(SensitivitySpec::to_wire),
2096 prefix_padding_ms: vad.prefix_padding_ms,
2097 silence_duration_ms: vad.silence_duration_ms,
2098 });
2099 }
2100 if let Some(audio) = &runtime.audio {
2101 #[cfg(feature = "denoise")]
2102 if audio.denoise == Some(true) {
2103 live = live.mic_denoise();
2104 }
2105 if let Some(gate) = &audio.noise_gate {
2106 live = live.mic_noise_gate(gate.threshold_rms, gate.hold_frames);
2107 }
2108 if let Some(vad) = &audio.client_vad {
2109 live = live.input_vad(vad.to_config());
2110 }
2111 if audio.authority == Some(AuthoritySpec::Client) {
2112 live = live.client_interruption_authority();
2113 }
2114 live = apply_turn_commit(live, audio.eot_hold_ms, audio.min_interruption_ms);
2117 }
2118 if let Some(ms) = runtime.soft_turn_timeout_ms {
2119 live = live.soft_turn_timeout(std::time::Duration::from_millis(ms));
2120 }
2121 if let Some(steering) = runtime.steering {
2122 live = live.steering_mode(match steering {
2123 SteeringSpec::InstructionUpdate => SteeringMode::InstructionUpdate,
2124 SteeringSpec::ContextInjection => SteeringMode::ContextInjection,
2125 SteeringSpec::Hybrid => SteeringMode::Hybrid,
2126 });
2127 }
2128 if let Some(delivery) = runtime.context_delivery {
2129 live = live.context_delivery(match delivery {
2130 ContextDeliverySpec::Immediate => ContextDelivery::Immediate,
2131 ContextDeliverySpec::Deferred => ContextDelivery::Deferred,
2132 });
2133 }
2134 if let Some(repair) = runtime.repair {
2135 live = live.repair(RepairConfig {
2136 nudge_after: repair.nudge_after,
2137 escalate_after: repair.escalate_after,
2138 });
2139 }
2140 if let Some(persistence) = &runtime.persistence {
2141 live = match persistence {
2142 PersistenceSpec::Fs { dir } => {
2143 live.persistence(Arc::new(gemini_adk_rs::live::FsPersistence::new(dir)))
2144 }
2145 PersistenceSpec::Memory => {
2146 live.persistence(Arc::new(gemini_adk_rs::live::MemoryPersistence::new()))
2147 }
2148 };
2149 }
2150 if let Some(id) = &runtime.session_id {
2151 live = live.session_id(id.clone());
2152 }
2153 if runtime.lossy_audio {
2154 live = live.lossy_audio();
2155 }
2156 if runtime.lossy_transcript {
2157 live = live.lossy_transcript();
2158 }
2159 live
2160}
2161
2162enum ToolCall {
2164 Declared,
2166 Code(Arc<dyn ToolFunction>),
2168 Mcp(Arc<gemini_adk_rs::tools::mcp::McpSessionManager>),
2170}
2171
2172fn mcp_value(result: Value) -> Value {
2175 if let Some(structured) = result.get("structuredContent") {
2176 return structured.clone();
2177 }
2178 let texts: Vec<&str> = result["content"]
2179 .as_array()
2180 .into_iter()
2181 .flatten()
2182 .filter_map(|part| part["text"].as_str())
2183 .collect();
2184 match texts.as_slice() {
2185 [text] => serde_json::from_str(text).unwrap_or_else(|_| json!({ "output": text })),
2186 [] => result,
2187 many => json!({ "output": many.join("\n") }),
2188 }
2189}
2190
2191fn build_tool(tool: &ToolSpec, state: &State, call: ToolCall) -> SimpleTool {
2194 let description = if tool.description.is_empty() {
2195 format!("Tool '{}'", tool.name)
2196 } else {
2197 tool.description.clone()
2198 };
2199 let response = tool.response.clone().unwrap_or_else(|| json!({"ok": true}));
2200 let sets = tool.set_state.clone();
2201 let save_as = tool.save_response_as.clone();
2202 let http = tool.http.clone();
2203 let st = state.clone();
2204 let name = tool.name.clone();
2205 let call = Arc::new(call);
2206 SimpleTool::new(
2207 &tool.name,
2208 description,
2209 tool.parameters.clone(),
2210 move |args| {
2211 let response = response.clone();
2212 let sets = sets.clone();
2213 let save_as = save_as.clone();
2214 let http = http.clone();
2215 let st = st.clone();
2216 let name = name.clone();
2217 let call = call.clone();
2218 async move {
2219 let result = match (call.as_ref(), &http) {
2220 (ToolCall::Code(implementation), _) => implementation.call(args).await?,
2221 (ToolCall::Mcp(server), _) => {
2222 mcp_value(server.call_tool(&name, args).await.map_err(|e| {
2223 gemini_adk_rs::error::ToolError::ExecutionFailed(format!("{name}: {e}"))
2224 })?)
2225 }
2226 (ToolCall::Declared, Some(binding)) => {
2227 execute_http(binding, &args, &st).await.map_err(|e| {
2228 gemini_adk_rs::error::ToolError::Other(format!("{name}: {e}"))
2229 })?
2230 }
2231 (ToolCall::Declared, None) => response,
2232 };
2233 for (key, value) in &sets {
2234 let _ = st.set(key, value.clone());
2235 }
2236 if let Some(key) = &save_as {
2237 let _ = st.set(key, result.clone());
2238 }
2239 Ok(result)
2240 }
2241 },
2242 )
2243}
2244
2245fn interpolate(template: &str, args: &Value, state: &State) -> String {
2247 let mut out = String::with_capacity(template.len());
2248 let mut rest = template;
2249 while let Some(open) = rest.find('{') {
2250 out.push_str(&rest[..open]);
2251 let after = &rest[open + 1..];
2252 let Some(close) = after.find('}') else {
2253 out.push_str(&rest[open..]);
2254 return out;
2255 };
2256 let expr = after[..close].trim();
2257 let value = if let Some(field) = expr.strip_prefix("args.") {
2258 args.get(field).cloned()
2259 } else if let Some(key) = expr.strip_prefix("state.") {
2260 state.get::<Value>(key)
2261 } else {
2262 None
2263 };
2264 match value {
2265 Some(Value::String(s)) => out.push_str(&s),
2266 Some(v) => out.push_str(&v.to_string()),
2267 None => {}
2268 }
2269 rest = &after[close + 1..];
2270 }
2271 out.push_str(rest);
2272 out
2273}
2274
2275#[cfg_attr(not(feature = "http-tools"), allow(dead_code))]
2277fn interpolate_value(value: &Value, args: &Value, state: &State) -> Value {
2278 match value {
2279 Value::String(s) => Value::String(interpolate(s, args, state)),
2280 Value::Array(items) => Value::Array(
2281 items
2282 .iter()
2283 .map(|v| interpolate_value(v, args, state))
2284 .collect(),
2285 ),
2286 Value::Object(map) => Value::Object(
2287 map.iter()
2288 .map(|(k, v)| (k.clone(), interpolate_value(v, args, state)))
2289 .collect(),
2290 ),
2291 other => other.clone(),
2292 }
2293}
2294
2295#[cfg(feature = "http-tools")]
2296async fn execute_http(binding: &HttpBinding, args: &Value, state: &State) -> Result<Value, String> {
2297 let url = interpolate(&binding.url, args, state);
2298 let mut client = reqwest::Client::builder();
2299 if let Some(reach) = binding.reach.clone() {
2300 if !reach.allows_http(&url) {
2301 return Err(format!("{url} is outside this server's HTTP allowlist"));
2302 }
2303 client = client.redirect(reqwest::redirect::Policy::custom(move |attempt| {
2304 if attempt.previous().len() >= 10 {
2305 attempt.error("too many redirects")
2306 } else if reach.allows_http(attempt.url().as_str()) {
2307 attempt.follow()
2308 } else {
2309 let error = format!(
2310 "redirect to {} is outside this server's HTTP allowlist",
2311 attempt.url()
2312 );
2313 attempt.error(error)
2314 }
2315 }));
2316 }
2317 let client = client.build().map_err(|e| e.to_string())?;
2318 let method = reqwest::Method::from_bytes(binding.method.to_uppercase().as_bytes())
2319 .map_err(|_| format!("invalid HTTP method '{}'", binding.method))?;
2320 let mut request = client.request(method, &url);
2321 for (name, value) in &binding.headers {
2322 request = request.header(name, interpolate(value, args, state));
2323 }
2324 if let Some(body) = &binding.body {
2325 request = request.json(&interpolate_value(body, args, state));
2326 }
2327 let response = request.send().await.map_err(|e| e.to_string())?;
2328 let status = response.status().as_u16();
2329 let text = response.text().await.map_err(|e| e.to_string())?;
2330 Ok(serde_json::from_str(&text).unwrap_or_else(|_| json!({ "status": status, "body": text })))
2331}
2332
2333#[cfg(not(feature = "http-tools"))]
2334#[allow(
2335 clippy::unused_async,
2336 reason = "same signature as the http-tools implementation so the call site is feature-agnostic"
2337)]
2338async fn execute_http(
2339 _binding: &HttpBinding,
2340 _args: &Value,
2341 _state: &State,
2342) -> Result<Value, String> {
2343 Err("http tool bindings require the `http-tools` feature".to_string())
2344}
2345
2346fn splice_fragment(
2348 flow: &mut Flow,
2349 fragment: &Flow,
2350 directive: &UseFragment,
2351 errors: &mut Vec<String>,
2352) {
2353 let ns = &directive.namespace;
2354 let prefix = |id: &str| format!("{ns}/{id}");
2355 let internal: std::collections::BTreeSet<&str> =
2356 fragment.steps.iter().map(|s| s.id.as_str()).collect();
2357
2358 for step in &fragment.steps {
2359 let new_id = prefix(&step.id);
2360 if flow.steps.iter().any(|s| s.id == new_id) {
2361 errors.push(format!(
2362 "fragment splice '{ns}' collides with existing step '{new_id}'"
2363 ));
2364 continue;
2365 }
2366 let mut after: Vec<gemini_adk_rs::flow::Edge> = step
2367 .after
2368 .iter()
2369 .map(|d| gemini_adk_rs::flow::Edge {
2370 step: if internal.contains(d.step.as_str()) {
2371 prefix(&d.step)
2372 } else {
2373 d.step.clone()
2374 },
2375 when: d
2376 .when
2377 .clone()
2378 .map(|g| rewrite_guard_steps(g, &internal, ns)),
2379 })
2380 .collect();
2381 if step.after.is_empty() {
2382 after.extend(
2383 directive
2384 .after
2385 .iter()
2386 .cloned()
2387 .map(gemini_adk_rs::flow::Edge::to),
2388 );
2389 }
2390 flow.steps.push(Step {
2391 id: new_id,
2392 after,
2393 join: step.join,
2394 gate: step
2395 .gate
2396 .clone()
2397 .map(|g| rewrite_guard_steps(g, &internal, ns)),
2398 done: step
2399 .done
2400 .clone()
2401 .map(|g| rewrite_guard_steps(g, &internal, ns)),
2402 posture: step.posture.clone(),
2403 ground: step.ground.clone(),
2404 allow: step.allow.clone(),
2405 deny: step.deny.clone(),
2406 terminal: step.terminal,
2407 });
2408 }
2409 for constraint in &fragment.constraints {
2410 flow.constraints.push(match constraint {
2411 Constraint::Once(t) => Constraint::Once(t.clone()),
2412 Constraint::Before(a, b) => Constraint::Before(
2413 if internal.contains(a.as_str()) {
2414 prefix(a)
2415 } else {
2416 a.clone()
2417 },
2418 if internal.contains(b.as_str()) {
2419 prefix(b)
2420 } else {
2421 b.clone()
2422 },
2423 ),
2424 Constraint::NeverUntil { tool, until } => Constraint::NeverUntil {
2425 tool: tool.clone(),
2426 until: rewrite_guard_steps(until.clone(), &internal, ns),
2427 },
2428 Constraint::Require(rs) => Constraint::Require(
2429 rs.iter()
2430 .map(|r| {
2431 if internal.contains(r.as_str()) {
2432 prefix(r)
2433 } else {
2434 r.clone()
2435 }
2436 })
2437 .collect(),
2438 ),
2439 Constraint::Reset { steps, when } => Constraint::Reset {
2440 steps: steps
2441 .iter()
2442 .map(|r| {
2443 if internal.contains(r.as_str()) {
2444 prefix(r)
2445 } else {
2446 r.clone()
2447 }
2448 })
2449 .collect(),
2450 when: rewrite_guard_steps(when.clone(), &internal, ns),
2451 },
2452 });
2453 }
2454 for tool in &fragment.ambient {
2455 if !flow.ambient.contains(tool) {
2456 flow.ambient.push(tool.clone());
2457 }
2458 }
2459 for tool in &fragment.confirm_tools {
2460 if !flow.confirm_tools.contains(tool) {
2461 flow.confirm_tools.push(tool.clone());
2462 }
2463 }
2464}
2465
2466fn rewrite_guard_steps(
2468 guard: Guard,
2469 internal: &std::collections::BTreeSet<&str>,
2470 ns: &str,
2471) -> Guard {
2472 fn rewrite(pred: Pred, internal: &std::collections::BTreeSet<&str>, ns: &str) -> Pred {
2473 match pred {
2474 Pred::Done(s) if internal.contains(s.as_str()) => Pred::Done(format!("{ns}/{s}")),
2475 Pred::All(ps) => Pred::All(ps.into_iter().map(|p| rewrite(p, internal, ns)).collect()),
2476 Pred::Any(ps) => Pred::Any(ps.into_iter().map(|p| rewrite(p, internal, ns)).collect()),
2477 Pred::Not(p) => Pred::Not(Box::new(rewrite(*p, internal, ns))),
2478 other => other,
2479 }
2480 }
2481 match guard {
2482 Guard::Spec(p) => Guard::Spec(rewrite(p, internal, ns)),
2483 custom => custom,
2484 }
2485}
2486
2487fn guard_uses_marking(guard: &Guard) -> bool {
2490 fn walk(pred: &Pred) -> bool {
2491 match pred {
2492 Pred::CalledOk(_) | Pred::Done(_) => true,
2493 Pred::All(ps) | Pred::Any(ps) => ps.iter().any(walk),
2494 Pred::Not(p) => walk(p),
2495 _ => false,
2496 }
2497 }
2498 match guard {
2499 Guard::Spec(p) => walk(p),
2500 Guard::Custom(_) => false,
2501 }
2502}
2503
2504fn resolve_voice(name: Option<&str>) -> Voice {
2506 match name {
2507 Some("Aoede") => Voice::Aoede,
2508 Some("Charon") => Voice::Charon,
2509 Some("Fenrir") => Voice::Fenrir,
2510 Some("Kore") => Voice::Kore,
2511 Some("Puck") | None => Voice::Puck,
2512 Some(other) => Voice::Custom(other.to_string()),
2513 }
2514}
2515
2516fn levenshtein(a: &str, b: &str) -> usize {
2518 let a: Vec<char> = a.chars().collect();
2519 let b: Vec<char> = b.chars().collect();
2520 let mut prev: Vec<usize> = (0..=b.len()).collect();
2521 let mut current = vec![0; b.len() + 1];
2522 for (i, ca) in a.iter().enumerate() {
2523 current[0] = i + 1;
2524 for (j, cb) in b.iter().enumerate() {
2525 let cost = usize::from(ca != cb);
2526 current[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(current[j] + 1);
2527 }
2528 std::mem::swap(&mut prev, &mut current);
2529 }
2530 prev[b.len()]
2531}
2532
2533#[cfg(test)]
2534mod tests {
2535
2536 #[test]
2537 fn audio_spec_lowers_and_validates() {
2538 let spec: SessionSpec = serde_json::from_str(
2539 r#"{
2540 "name": "noisy",
2541 "instruction": "hi",
2542 "runtime": {
2543 "audio": {
2544 "denoise": true,
2545 "noise_gate": { "threshold_rms": 700.0 },
2546 "client_vad": { "preset": "noisy_street", "hangover_frames": 12 },
2547 "authority": "client"
2548 }
2549 }
2550 }"#,
2551 )
2552 .unwrap();
2553 let audio = spec.runtime.as_ref().unwrap().audio.as_ref().unwrap();
2554 assert_eq!(audio.noise_gate.as_ref().unwrap().hold_frames, 3); let config = audio.client_vad.as_ref().unwrap().to_config();
2556 assert_eq!(config.start_threshold_db, 21.0); assert_eq!(config.hangover_frames, 12); assert_eq!(audio.authority, Some(AuthoritySpec::Client));
2559 let json = serde_json::to_value(&spec).unwrap();
2561 let back: SessionSpec = serde_json::from_value(json).unwrap();
2562 assert_eq!(
2563 back.runtime
2564 .unwrap()
2565 .audio
2566 .unwrap()
2567 .client_vad
2568 .unwrap()
2569 .hangover_frames,
2570 Some(12)
2571 );
2572 }
2573
2574 #[test]
2575 fn audio_spec_warns_on_risky_combinations() {
2576 let spec: SessionSpec = serde_json::from_str(
2577 r#"{
2578 "name": "risky",
2579 "instruction": "hi",
2580 "runtime": { "audio": { "authority": "client", "noise_gate": {} } }
2581 }"#,
2582 )
2583 .unwrap();
2584 let validation = spec.validate();
2585 assert!(
2586 validation
2587 .warnings
2588 .iter()
2589 .any(|w| w.contains("authority=client without denoise")),
2590 "expected client-authority warning, got {:?}",
2591 validation.warnings
2592 );
2593 assert!(
2594 validation
2595 .warnings
2596 .iter()
2597 .any(|w| w.contains("noise_gate without denoise")),
2598 "expected gate warning, got {:?}",
2599 validation.warnings
2600 );
2601 }
2602
2603 #[test]
2604 fn turn_commit_tuning_knobs_serialize_and_round_trip() {
2605 let spec: SessionSpec = serde_json::from_str(
2606 r#"{
2607 "name": "tune",
2608 "instruction": "hi",
2609 "runtime": {
2610 "audio": {
2611 "eot_hold_ms": 800,
2612 "min_interruption_ms": 1400
2613 }
2614 }
2615 }"#,
2616 )
2617 .unwrap();
2618 let audio = spec.runtime.as_ref().unwrap().audio.as_ref().unwrap();
2619 assert_eq!(audio.eot_hold_ms, Some(800));
2620 assert_eq!(audio.min_interruption_ms, Some(1400));
2621 let json = serde_json::to_value(&spec).unwrap();
2623 let back: SessionSpec = serde_json::from_value(json).unwrap();
2624 let audio_back = back.runtime.unwrap().audio.unwrap();
2625 assert_eq!(audio_back.eot_hold_ms, Some(800));
2626 assert_eq!(audio_back.min_interruption_ms, Some(1400));
2627 }
2628
2629 #[test]
2630 fn turn_commit_tuning_knobs_validate_thresholds() {
2631 let spec: SessionSpec = serde_json::from_str(
2633 r#"{
2634 "name": "frontier",
2635 "instruction": "hi",
2636 "runtime": { "audio": { "eot_hold_ms": 1700 } }
2637 }"#,
2638 )
2639 .unwrap();
2640 let validation = spec.validate();
2641 assert!(
2642 validation
2643 .warnings
2644 .iter()
2645 .any(|w| w.contains("eot_hold_ms") && w.contains("1600") && w.contains("frontier")),
2646 "expected eot_hold_ms frontier warning, got {:?}",
2647 validation.warnings
2648 );
2649
2650 let spec2: SessionSpec = serde_json::from_str(
2652 r#"{
2653 "name": "window",
2654 "instruction": "hi",
2655 "runtime": { "audio": { "min_interruption_ms": 2100 } }
2656 }"#,
2657 )
2658 .unwrap();
2659 let validation2 = spec2.validate();
2660 assert!(
2661 validation2
2662 .warnings
2663 .iter()
2664 .any(|w| w.contains("min_interruption_ms")
2665 && w.contains("2000")
2666 && w.contains("window")),
2667 "expected min_interruption_ms window warning, got {:?}",
2668 validation2.warnings
2669 );
2670 }
2671 use super::*;
2672
2673 fn collections_spec() -> SessionSpec {
2674 SessionSpec::from_value(json!({
2675 "name": "collections",
2676 "instruction": "Collect payments.",
2677 "tools": [
2678 {"name": "verify_identity", "set_state": {"identity_verified": true}},
2679 {"name": "charge_card", "response": {"charged": true}}
2680 ],
2681 "extract": [{
2682 "name": "ptp",
2683 "instruction": "Extract the promise to pay.",
2684 "schema": {"type": "object", "properties": {
2685 "ptp_amount": {"type": "number"}, "ptp_date": {"type": "string"}}},
2686 "promote": [
2687 {"field": "ptp_amount"},
2688 {"field": "ptp_date", "policy": "overwrite"}
2689 ]
2690 }],
2691 "flow": {
2692 "steps": [
2693 {"id": "verify", "posture": "Verify the caller.",
2694 "allow": ["verify_identity"],
2695 "done": {"is_true": "identity_verified"}},
2696 {"id": "pay", "after": ["verify"], "posture": "Take payment.",
2697 "allow": ["charge_card"],
2698 "gate": {"captured": ["ptp_amount", "ptp_date"]},
2699 "done": {"called_ok": "charge_card"}}
2700 ]
2701 }
2702 }))
2703 .expect("spec parses")
2704 }
2705
2706 #[test]
2707 fn extraction_promotions_satisfy_guard_reads() {
2708 let v = collections_spec().validate();
2709 assert!(v.valid, "errors: {:?}", v.errors);
2710 assert!(
2711 v.warnings.iter().all(|w| !w.contains("can never latch")),
2712 "promoted keys cover the guard reads: {:?}",
2713 v.warnings
2714 );
2715 }
2716
2717 #[test]
2718 fn unwritten_guard_key_warns_with_suggestion() {
2719 let mut spec = collections_spec();
2720 spec.flow.as_mut().unwrap().steps[0].done = Some(Guard::is_true("identity_verifed"));
2722 let v = spec.validate();
2723 assert!(v.valid);
2724 let warning = v
2725 .warnings
2726 .iter()
2727 .find(|w| w.contains("identity_verifed"))
2728 .expect("warns about the unwritten key");
2729 assert!(
2730 warning.contains("identity_verified"),
2731 "suggests the fix: {warning}"
2732 );
2733 }
2734
2735 #[test]
2736 fn phase_guard_rejects_marking_atoms() {
2737 let spec = SessionSpec::from_value(json!({
2738 "instruction": "x",
2739 "phases": [{"name": "a", "transitions": [
2740 {"to": "b", "when": {"called_ok": "some_tool"}}]},
2741 {"name": "b"}],
2742 "initial_phase": "a"
2743 }))
2744 .expect("parses");
2745 let v = spec.validate();
2746 assert!(!v.valid);
2747 assert!(v.errors.iter().any(|e| e.contains("called_ok")));
2748 }
2749
2750 #[test]
2751 fn fragments_splice_with_namespacing() {
2752 let spec = SessionSpec::from_value(json!({
2753 "instruction": "x",
2754 "tools": [
2755 {"name": "check_id", "set_state": {"id_ok": true}},
2756 {"name": "book", "response": {}}
2757 ],
2758 "fragments": {
2759 "verify": {"steps": [
2760 {"id": "ask", "posture": "Ask for ID.", "allow": ["check_id"],
2761 "done": {"is_true": "id_ok"}},
2762 {"id": "confirm", "after": ["ask"], "terminal": true,
2763 "gate": {"done": "ask"}}
2764 ]}
2765 },
2766 "use_fragments": [{"fragment": "verify", "namespace": "v"}],
2767 "flow": {"steps": [
2768 {"id": "book_step", "after": ["v/confirm"], "allow": ["book"],
2769 "done": {"called_ok": "book"}}
2770 ]}
2771 }))
2772 .expect("parses");
2773 let flow = spec.effective_flow().expect("splices");
2774 let ids: Vec<&str> = flow.steps.iter().map(|s| s.id.as_str()).collect();
2775 assert!(ids.contains(&"v/ask") && ids.contains(&"v/confirm"));
2776 let confirm = flow.steps.iter().find(|s| s.id == "v/confirm").unwrap();
2778 assert_eq!(
2779 serde_json::to_value(confirm.gate.as_ref().unwrap()).unwrap(),
2780 json!({"done": "v/ask"})
2781 );
2782 let v = spec.validate();
2783 assert!(v.valid, "errors: {:?}", v.errors);
2784 }
2785
2786 #[test]
2787 fn bare_flow_round_trips() {
2788 let spec = SessionSpec::from_value(json!({
2789 "steps": [{"id": "only", "terminal": true}]
2790 }))
2791 .expect("parses");
2792 assert!(spec.validate().valid);
2793 }
2794
2795 #[test]
2796 fn empty_fragment_namespace_is_rejected() {
2797 let spec = SessionSpec::from_value(json!({
2798 "instruction": "x",
2799 "fragments": {
2800 "verify": {"steps": [
2801 {"id": "ask", "terminal": true}
2802 ]}
2803 },
2804 "use_fragments": [{"fragment": "verify", "namespace": ""}],
2805 "flow": {"steps": [
2806 {"id": "start", "terminal": true}
2807 ]}
2808 }))
2809 .expect("parses");
2810 let v = spec.validate();
2811 assert!(
2813 !v.valid,
2814 "empty namespace should fail validation, got errors: {:?}",
2815 v.errors
2816 );
2817 assert!(
2818 v.errors.iter().any(|e| e.contains("namespace")),
2819 "should mention namespace issue: {:?}",
2820 v.errors
2821 );
2822 }
2823
2824 #[test]
2825 fn duplicate_computed_keys_are_rejected() {
2826 let spec = SessionSpec::from_value(json!({
2827 "instruction": "x",
2828 "flow": {"steps": [{"id": "only", "terminal": true}]},
2829 "computed": [
2830 {"key": "risk", "from": {"key": "score"}},
2831 {"key": "risk", "from": {"key": "other_score"}}
2832 ]
2833 }))
2834 .expect("parses");
2835 let v = spec.validate();
2836 assert!(!v.valid, "duplicate computed keys should fail validation");
2837 assert!(
2838 v.errors.iter().any(|e| e.contains("duplicate")),
2839 "should mention duplicate computed key: {:?}",
2840 v.errors
2841 );
2842 }
2843
2844 #[test]
2845 fn duplicate_phase_names_are_rejected() {
2846 let spec = SessionSpec::from_value(json!({
2847 "instruction": "x",
2848 "phases": [
2849 {"name": "greet", "instruction": "Welcome."},
2850 {"name": "greet", "instruction": "Hi again."}
2851 ],
2852 "initial_phase": "greet"
2853 }))
2854 .expect("parses");
2855 let v = spec.validate();
2856 assert!(!v.valid, "duplicate phase names should fail validation");
2857 assert!(
2858 v.errors.iter().any(|e| e.contains("phase")),
2859 "should mention duplicate phase: {:?}",
2860 v.errors
2861 );
2862 }
2863
2864 #[test]
2865 fn tool_background_false_round_trips() {
2866 let spec = SessionSpec::from_value(json!({
2868 "instruction": "x",
2869 "tools": [
2870 {"name": "search", "background": false},
2871 {"name": "log", "background": true}
2872 ],
2873 "flow": {"steps": [{"id": "only", "terminal": true}]}
2874 }))
2875 .expect("parses");
2876
2877 let serialized = serde_json::to_value(&spec).unwrap();
2879 let tools = serialized["tools"].as_array().unwrap();
2880
2881 assert!(
2884 tools[0].get("background").is_none(),
2885 "background: false is optimized away"
2886 );
2887
2888 assert_eq!(tools[1].get("background"), Some(&json!(true)));
2890
2891 let back: SessionSpec = serde_json::from_value(serialized).unwrap();
2893 assert!(!back.tools[0].background);
2894 assert!(back.tools[1].background);
2895 }
2896
2897 #[test]
2898 fn promotion_spec_with_no_to_field_uses_field_name() {
2899 let spec = SessionSpec::from_value(json!({
2901 "instruction": "x",
2902 "tools": [{"name": "extract", "set_state": {"test": true}}],
2903 "extract": [{
2904 "name": "data",
2905 "instruction": "Extract.",
2906 "schema": {"type": "object"},
2907 "promote": [
2908 {"field": "amount"},
2909 {"field": "date", "to": "extracted_date"}
2910 ]
2911 }],
2912 "flow": {"steps": [{"id": "only", "terminal": true}]}
2913 }))
2914 .expect("parses");
2915
2916 let promote = &spec.extract[0].promote;
2917 assert_eq!(promote[0].target(), "amount");
2918 assert_eq!(promote[1].target(), "extracted_date");
2919 }
2920
2921 #[test]
2922 fn spec_schema_publishes() {
2923 let schema = SessionSpec::json_schema().to_string();
2924 for token in [
2925 "is_true",
2926 "never_until",
2927 "set_state",
2928 "use_fragments",
2929 "promote",
2930 ] {
2931 assert!(schema.contains(token), "schema missing {token}");
2932 }
2933 }
2934
2935 #[test]
2936 fn interpolation_reads_args_and_state() {
2937 let state = State::new();
2938 let _ = state.set("city", "Paris");
2939 let args = json!({"guests": 4});
2940 assert_eq!(
2941 interpolate("book/{state.city}/{args.guests}/{missing}", &args, &state),
2942 "book/Paris/4/"
2943 );
2944 }
2945
2946 #[tokio::test]
2947 async fn declared_tools_write_state() {
2948 let spec = collections_spec();
2949 let state = State::new();
2950 let dispatcher = spec.build_dispatcher(&state);
2951 let out = dispatcher
2952 .call_function("verify_identity", json!({}))
2953 .await
2954 .expect("tool runs");
2955 assert_eq!(out, json!({"ok": true}));
2956 assert_eq!(state.get::<bool>("identity_verified"), Some(true));
2957 }
2958
2959 #[test]
2960 fn computed_cycles_are_load_time_errors() {
2961 let spec = SessionSpec::from_value(json!({
2962 "instruction": "x",
2963 "flow": {"steps": [{"id": "only", "terminal": true}]},
2964 "computed": [
2965 {"key": "a", "from": {"add": [{"key": "b"}, {"const": 1}]}},
2966 {"key": "b", "from": {"add": [{"key": "derived:a"}, {"const": 1}]}}
2967 ]
2968 }))
2969 .expect("parses");
2970 let v = spec.validate();
2971 assert!(!v.valid);
2972 assert!(v.errors.iter().any(|e| e.contains("dependency cycle")));
2973
2974 let self_read = SessionSpec::from_value(json!({
2975 "instruction": "x",
2976 "flow": {"steps": [{"id": "only", "terminal": true}]},
2977 "computed": [{"key": "a", "from": {"key": "a"}}]
2978 }))
2979 .expect("parses");
2980 assert!(
2981 self_read
2982 .validate()
2983 .errors
2984 .iter()
2985 .any(|e| e.contains("reads its own key"))
2986 );
2987 }
2988
2989 #[test]
2990 fn computed_keys_satisfy_guard_reads_and_deps_are_checked() {
2991 let spec = SessionSpec::from_value(json!({
2992 "instruction": "x",
2993 "tools": [{"name": "record", "set_state": {"score": 0.8}}],
2994 "computed": [{"key": "high_risk",
2995 "from": {"gt": [{"key": "score"}, {"const": 0.5}]}}],
2996 "flow": {"steps": [
2997 {"id": "assess", "posture": "Assess.", "allow": ["record"],
2998 "done": {"is_true": "high_risk"}}
2999 ]}
3000 }))
3001 .expect("parses");
3002 let v = spec.validate();
3003 assert!(v.valid, "errors: {:?}", v.errors);
3004 assert!(
3005 v.warnings.iter().all(|w| !w.contains("can never latch")),
3006 "computed key covers the guard read: {:?}",
3007 v.warnings
3008 );
3009
3010 let mut dangling = spec.clone();
3011 dangling.computed[0].from =
3012 serde_json::from_value(json!({"gt": [{"key": "scoer"}, {"const": 0.5}]})).unwrap();
3013 let v = dangling.validate();
3014 assert!(
3015 v.warnings
3016 .iter()
3017 .any(|w| w.contains("computed 'high_risk' reads state key 'scoer'"))
3018 );
3019 }
3020
3021 #[test]
3022 fn remember_requires_the_memory_section() {
3023 let spec = SessionSpec::from_value(json!({
3024 "instruction": "x",
3025 "flow": {"steps": [{"id": "only", "terminal": true}]},
3026 "patterns": [{"name": "note", "when": {"is_true": "flag"}, "turns": 2,
3027 "effects": [{"remember": "caller likes {state.thing}"}]}]
3028 }))
3029 .expect("parses");
3030 let v = spec.validate();
3031 assert!(!v.valid);
3032 assert!(v.errors.iter().any(|e| e.contains("no `memory` section")));
3033 }
3034
3035 #[test]
3036 fn memory_slots_join_written_keys_and_reject_derived_targets() {
3037 let spec = SessionSpec::from_value(json!({
3038 "instruction": "x",
3039 "memory": {"slots": [{"predicate": "dietary_identity", "to": "user:diet"}]},
3040 "flow": {"steps": [
3041 {"id": "plan", "posture": "Plan dinner.",
3042 "done": {"is_set": "user:diet"}}
3043 ]}
3044 }))
3045 .expect("parses");
3046 let v = spec.validate();
3047 assert!(v.valid, "errors: {:?}", v.errors);
3048 assert!(v.warnings.iter().all(|w| !w.contains("can never latch")));
3049
3050 let mut bad = spec.clone();
3051 bad.memory.as_mut().unwrap().slots[0].to = "derived:diet".into();
3052 assert!(
3053 bad.validate()
3054 .errors
3055 .iter()
3056 .any(|e| e.contains("read-only key"))
3057 );
3058 }
3059
3060 #[test]
3061 fn memory_section_requires_a_binding_at_apply() {
3062 let spec = SessionSpec::from_value(json!({
3063 "instruction": "x",
3064 "memory": {},
3065 "flow": {"steps": [{"id": "only", "terminal": true}]}
3066 }))
3067 .expect("parses");
3068 let err = spec
3069 .apply(Live::builder(), &State::new(), &SpecResources::default())
3070 .err()
3071 .expect("memory binding required");
3072 assert!(err.contains("SpecResources.memory"));
3073
3074 struct NullBinding;
3075 impl MemoryBinding for NullBinding {
3076 fn install(&self, live: Live, _memory: &MemorySpec) -> Live {
3077 live
3078 }
3079 fn remember(&self, _note: String) {}
3080 }
3081 let resources = SpecResources {
3082 memory: Some(Arc::new(NullBinding)),
3083 ..Default::default()
3084 };
3085 assert!(
3086 spec.apply(Live::builder(), &State::new(), &resources)
3087 .is_ok()
3088 );
3089 }
3090
3091 #[test]
3092 fn state_dictionary_seeds_defaults_and_flags_undeclared_writes() {
3093 let spec = SessionSpec::from_value(json!({
3094 "instruction": "x",
3095 "state": {
3096 "attempts": {"type": "number", "default": 0,
3097 "description": "Verification attempts so far."},
3098 "verified": {"type": "boolean", "default": "yes"}
3099 },
3100 "tools": [{"name": "verify", "set_state": {"verified": true, "vip": true}}],
3101 "flow": {"steps": [
3102 {"id": "v", "posture": "Verify.", "allow": ["verify"],
3103 "done": {"is_true": "verified"}}
3104 ]}
3105 }))
3106 .expect("parses");
3107 let v = spec.validate();
3108 assert!(v.valid, "errors: {:?}", v.errors);
3109 assert!(
3110 v.warnings
3111 .iter()
3112 .any(|w| w.contains("'verified' declares type Boolean")),
3113 "type-mismatched default warns: {:?}",
3114 v.warnings
3115 );
3116 assert!(
3117 v.warnings
3118 .iter()
3119 .any(|w| w.contains("'vip' is written but not declared")),
3120 "undeclared write warns: {:?}",
3121 v.warnings
3122 );
3123
3124 let state = State::new();
3125 spec.seed_state_defaults(&state);
3126 assert_eq!(state.get::<i64>("attempts"), Some(0));
3127 }
3128
3129 #[test]
3130 fn runtime_section_lowers_onto_the_builder() {
3131 let spec = SessionSpec::from_value(json!({
3132 "instruction": "x",
3133 "flow": {"steps": [{"id": "only", "terminal": true}]},
3134 "runtime": {
3135 "temperature": 0.4,
3136 "thinking_budget": 1024,
3137 "include_thoughts": true,
3138 "transcription": {"input": true, "output": false},
3139 "proactive_audio": true,
3140 "vad": {"start_sensitivity": "high", "silence_duration_ms": 400},
3141 "soft_turn_timeout_ms": 1500,
3142 "steering": "context_injection",
3143 "context_delivery": "deferred",
3144 "repair": {"nudge_after": 2, "escalate_after": 5},
3145 "persistence": "memory",
3146 "session_id": "user-1",
3147 "lossy_audio": true
3148 }
3149 }))
3150 .expect("parses");
3151 let v = spec.validate();
3152 assert!(v.valid, "errors: {:?}", v.errors);
3153 assert!(
3154 spec.apply(Live::builder(), &State::new(), &SpecResources::default())
3155 .is_ok()
3156 );
3157
3158 let mut incoherent = spec.clone();
3159 incoherent.runtime.as_mut().unwrap().thinking_budget = None;
3160 assert!(
3161 incoherent
3162 .validate()
3163 .warnings
3164 .iter()
3165 .any(|w| w.contains("include_thoughts"))
3166 );
3167 }
3168
3169 #[test]
3170 fn background_tools_and_scheduling_parse_and_apply() {
3171 let spec = SessionSpec::from_value(json!({
3172 "instruction": "x",
3173 "tools": [
3174 {"name": "search_kb", "background": true},
3175 {"name": "log_event", "scheduling": "silent"}
3176 ],
3177 "flow": {"steps": [
3178 {"id": "s", "posture": "Serve.", "allow": ["search_kb", "log_event"],
3179 "done": {"called_ok": "search_kb"}}
3180 ]}
3181 }))
3182 .expect("parses");
3183 assert!(spec.validate().valid);
3184 assert!(
3185 spec.apply(Live::builder(), &State::new(), &SpecResources::default())
3186 .is_ok()
3187 );
3188 }
3189
3190 #[test]
3191 fn a_sandboxed_spec_reaches_only_what_the_operator_allows() {
3192 let spec = SessionSpec::from_value(json!({
3193 "name": "posted",
3194 "mcp": ["rm -rf /", "https://mcp.example.com/sse"],
3195 "tools": [
3196 { "name": "ok", "http": { "url": "https://api.example.com/v1/{args.id}" } },
3197 { "name": "internal", "http": { "url": "http://169.254.169.254/latest/meta-data" } },
3198 { "name": "host_from_args", "http": { "url": "https://{args.host}/x" } },
3199 { "name": "mock", "response": { "ok": true } }
3200 ]
3201 }))
3202 .unwrap();
3203
3204 let (nothing, notes) = spec.sandboxed(&BindingAllowlist::default());
3205 assert!(nothing.mcp.is_empty());
3206 assert!(nothing.tools.iter().all(|t| t.http.is_none()));
3207 assert_eq!(notes.len(), 5, "{notes:?}");
3208
3209 let allow = BindingAllowlist::default()
3210 .allow_http_prefix("https://api.example.com")
3211 .allow_mcp("https://mcp.example.com/sse");
3212 let (some, notes) = spec.sandboxed(&allow);
3213 assert_eq!(some.mcp, ["https://mcp.example.com/sse"]);
3214 let bound: Vec<&str> = some
3215 .tools
3216 .iter()
3217 .filter(|t| t.http.is_some())
3218 .map(|t| t.name.as_str())
3219 .collect();
3220 assert_eq!(bound, ["ok"]);
3221 assert_eq!(notes.len(), 3, "{notes:?}");
3222
3223 let tricky = BindingAllowlist::default().allow_http_prefix("https://api.example.com");
3225 assert!(!tricky.allows_http("https://api.example.com.evil.net/x"));
3226 assert!(!tricky.allows_http("https://api.example.com@evil.net/x"));
3227 assert!(!tricky.allows_http("https://api.example.com:8443/x"));
3228 assert!(!tricky.allows_http("http://api.example.com/x"));
3229
3230 let subtree = BindingAllowlist::default().allow_http_prefix("https://api.example.com/v2/");
3232 assert!(subtree.allows_http("https://API.example.com/v2/orders/{args.id}"));
3233 assert!(!subtree.allows_http("https://api.example.com/v2/../admin"));
3234 assert!(!subtree.allows_http("https://api.example.com/v2/%2e%2e/admin"));
3235 assert!(!subtree.allows_http("https://api.example.com/v2/%2E%2E/admin"));
3236 }
3237
3238 #[cfg(feature = "http-tools")]
3239 #[tokio::test]
3240 async fn a_sandboxed_binding_is_checked_again_when_it_runs() {
3241 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3242
3243 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3246 let origin = format!("http://{}", listener.local_addr().unwrap());
3247 tokio::spawn(async move {
3248 while let Ok((mut socket, _)) = listener.accept().await {
3249 let mut buf = vec![0u8; 4096];
3250 let n = socket.read(&mut buf).await.unwrap_or(0);
3251 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3252 let path = request.split_whitespace().nth(1).unwrap_or("/").to_string();
3253 let response = if path == "/v2/hop" {
3254 "HTTP/1.1 302 Found\r\nlocation: /admin\r\ncontent-length: 0\r\nconnection: close\r\n\r\n".to_string()
3255 } else {
3256 let body = json!({ "path": path }).to_string();
3257 format!(
3258 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
3259 body.len()
3260 )
3261 };
3262 let _ = socket.write_all(response.as_bytes()).await;
3263 }
3264 });
3265
3266 let spec = SessionSpec::from_value(json!({
3267 "name": "posted",
3268 "tools": [{ "name": "get", "http": { "url": format!("{origin}/v2/{{args.path}}") } }]
3269 }))
3270 .unwrap();
3271 let allow = BindingAllowlist::default().allow_http_prefix(format!("{origin}/v2/"));
3272 let (sandboxed, notes) = spec.sandboxed(&allow);
3273 assert!(notes.is_empty(), "{notes:?}");
3274 let state = State::new();
3275 let dispatcher = sandboxed.build_dispatcher(&state);
3276
3277 let ok = dispatcher
3278 .call_function("get", json!({ "path": "orders" }))
3279 .await
3280 .unwrap();
3281 assert_eq!(ok["path"], "/v2/orders");
3282 let climbed = dispatcher
3284 .call_function("get", json!({ "path": "../admin" }))
3285 .await;
3286 assert!(climbed.is_err(), "{climbed:?}");
3287 let redirected = dispatcher
3289 .call_function("get", json!({ "path": "hop" }))
3290 .await;
3291 assert!(redirected.is_err(), "{redirected:?}");
3292
3293 let trusted = spec.build_dispatcher(&state);
3295 let followed = trusted
3296 .call_function("get", json!({ "path": "hop" }))
3297 .await
3298 .unwrap();
3299 assert_eq!(followed["path"], "/admin");
3300 }
3301
3302 fn booking_bundle() -> SessionSpec {
3305 let conversation: Value =
3306 serde_json::from_str(include_str!("../../../../conversations/booking.spec.json"))
3307 .unwrap();
3308 let scenario = |file: &str| -> Value { serde_json::from_str(file).unwrap() };
3309 SessionSpec::from_value(json!({
3310 "name": "booking",
3311 "modality": "audio",
3312 "tools": [{
3313 "name": "book",
3314 "description": "Book the table",
3315 "response": { "confirmation": "B-1" }
3316 }],
3317 "conversation": conversation,
3318 "scenarios": [
3319 scenario(include_str!("../../../../conversations/booking.happy.scenario.json")),
3320 scenario(include_str!(
3321 "../../../../conversations/booking.no_book_without_confirm.scenario.json"
3322 )),
3323 ]
3324 }))
3325 .unwrap()
3326 }
3327
3328 #[test]
3329 fn a_conversation_spec_validates_like_a_flow() {
3330 let spec = booking_bundle();
3331 let report = spec.validate();
3332 assert!(report.valid, "{:?}", report.errors);
3333 let unwritten: Vec<&String> = report
3336 .warnings
3337 .iter()
3338 .filter(|w| w.contains("no tool, extractor"))
3339 .collect();
3340 assert_eq!(unwritten.len(), 1, "{:?}", report.warnings);
3341 assert!(unwritten[0].contains("user_confirmed"));
3342 assert!(report.mermaid.contains("confirm"), "{}", report.mermaid);
3343 assert!(report.tools.contains(&"book".to_string()));
3344
3345 let mut both = spec.clone();
3347 both.flow = Some(Flow::default());
3348 assert!(!both.validate().valid);
3349
3350 let mut resolving = spec;
3352 resolving.conversation.as_mut().unwrap().stages[0]
3353 .resolve
3354 .push(crate::conversation::ResolveSpec {
3355 slot: "availability".into(),
3356 resolver: None,
3357 args: vec![],
3358 ttl_secs: None,
3359 });
3360 let report = resolving.validate();
3361 assert!(
3362 report.errors.iter().any(|e| e.contains("availability")),
3363 "{:?}",
3364 report.errors
3365 );
3366 }
3367
3368 #[tokio::test]
3369 async fn a_conversation_spec_runs_its_scenarios_and_applies() {
3370 let spec = booking_bundle();
3371 let reports = spec.run_scenarios().await;
3372 assert_eq!(reports.len(), 2);
3373 assert!(reports.iter().all(|r| r.passed), "{reports:?}");
3374
3375 let mut broken = spec.clone();
3376 broken.scenarios[0]
3377 .steps
3378 .push(crate::simulation::SimStep::ExpectActive(vec![
3379 "nowhere".into(),
3380 ]));
3381 assert!(!broken.run_scenarios().await[0].passed);
3382
3383 spec.apply(Live::builder(), &State::new(), &SpecResources::default())
3384 .expect("a conversation spec applies");
3385 }
3386
3387 fn booking_tool(extra: Value) -> SessionSpec {
3388 let mut tool = json!({
3389 "name": "book",
3390 "description": "Book the table",
3391 "response": { "confirmation": "MOCK" },
3392 "set_state": { "booked": true },
3393 "save_response_as": "booking"
3394 });
3395 tool.as_object_mut()
3396 .unwrap()
3397 .extend(extra.as_object().unwrap().clone());
3398 SessionSpec::from_value(json!({
3399 "name": "t",
3400 "tools": [tool],
3401 "flow": { "steps": [{ "id": "s", "allow": ["book"] }] }
3402 }))
3403 .unwrap()
3404 }
3405
3406 #[tokio::test]
3407 async fn an_implementation_replaces_the_call_not_the_declaration() {
3408 let spec = booking_tool(json!({}));
3409 let state = State::new();
3410 let resources = SpecResources::default().implement(gemini_adk_rs::tool::SimpleTool::new(
3411 "book",
3412 "real booking",
3413 None,
3414 |_| async { Ok(json!({ "confirmation": "REAL-7" })) },
3415 ));
3416 let dispatcher = spec.build_dispatcher_with(&state, &resources);
3417 let out = dispatcher.call_function("book", json!({})).await.unwrap();
3418 assert_eq!(out["confirmation"], "REAL-7");
3419 assert_eq!(state.get::<bool>("booked"), Some(true));
3421 assert_eq!(
3422 state.get::<Value>("booking").unwrap()["confirmation"],
3423 "REAL-7"
3424 );
3425 assert_eq!(
3427 dispatcher.to_tool_declarations()[0]
3428 .function_declarations
3429 .as_ref()
3430 .unwrap()[0]
3431 .description,
3432 "Book the table"
3433 );
3434
3435 let stray = SpecResources::default().implement(gemini_adk_rs::tool::SimpleTool::new(
3437 "refund",
3438 "",
3439 None,
3440 |_| async { Ok(json!({})) },
3441 ));
3442 assert!(spec.apply(Live::builder(), &state, &stray).is_err());
3443 }
3444
3445 #[cfg(unix)]
3446 #[tokio::test]
3447 async fn an_mcp_binding_calls_the_tool_on_that_server() {
3448 let dir = std::env::temp_dir().join(format!("mcp-bind-{}", std::process::id()));
3450 std::fs::create_dir_all(&dir).unwrap();
3451 let script = dir.join("server.sh");
3452 std::fs::write(
3453 &script,
3454 r#"while IFS= read -r line; do
3455 id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p')
3456 [ -z "$id" ] && continue
3457 case "$line" in
3458 *'"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"t","version":"1"}}}\n' "$id" ;;
3459 *'"tools/call"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"{\\"confirmation\\":\\"MCP-1\\"}"}]}}\n' "$id" ;;
3460 *) printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;;
3461 esac
3462done
3463"#,
3464 )
3465 .unwrap();
3466 let spec = booking_tool(json!({ "mcp": format!("sh {}", script.display()) }));
3467 let state = State::new();
3468 let dispatcher = spec.build_dispatcher_with(&state, &SpecResources::default());
3469 let out = dispatcher
3470 .call_function("book", json!({ "party": 2 }))
3471 .await
3472 .unwrap();
3473 assert_eq!(out, json!({ "confirmation": "MCP-1" }));
3474 assert_eq!(state.get::<bool>("booked"), Some(true));
3475
3476 let both = booking_tool(json!({
3478 "mcp": "x",
3479 "http": { "url": "https://api.example.com/book" }
3480 }));
3481 assert!(!both.validate().valid);
3482
3483 let (sandboxed, notes) = spec.sandboxed(&BindingAllowlist::default());
3485 assert!(sandboxed.tools[0].mcp.is_none());
3486 assert_eq!(notes.len(), 1, "{notes:?}");
3487 let _ = std::fs::remove_dir_all(dir);
3488 }
3489
3490 #[test]
3491 fn mcp_results_become_plain_values() {
3492 assert_eq!(
3493 mcp_value(json!({ "structuredContent": { "a": 1 }, "content": [] })),
3494 json!({ "a": 1 })
3495 );
3496 assert_eq!(
3497 mcp_value(json!({ "content": [{ "type": "text", "text": "{\"a\":2}" }] })),
3498 json!({ "a": 2 })
3499 );
3500 assert_eq!(
3501 mcp_value(json!({ "content": [{ "type": "text", "text": "done" }] })),
3502 json!({ "output": "done" })
3503 );
3504 }
3505
3506 #[test]
3507 fn apply_configures_a_builder() {
3508 let spec = collections_spec();
3509 let state = State::new();
3510 let err = spec
3512 .apply(Live::builder(), &state, &SpecResources::default())
3513 .err()
3514 .expect("requires extraction llm");
3515 assert!(err.contains("extraction_llm"));
3516
3517 let mut no_extract = spec.clone();
3519 no_extract.extract.clear();
3520 assert!(
3521 no_extract
3522 .apply(Live::builder(), &state, &SpecResources::default())
3523 .is_ok()
3524 );
3525 }
3526}