1pub mod authoring;
24mod code;
25mod codegen;
26mod decisions;
27pub mod project;
28mod simulate;
29mod skills;
30pub mod store;
31
32pub use code::{CodeLimitsSpec, CodeSpec, schema_shape, value_shape};
33pub use decisions::{BooleanCriteriaSpec, DecisionKind, DecisionSpec};
34pub use project::{ProjectFile, ProjectLanguage, ProjectOptions, SdkSource};
35pub use store::{BundleRef, BundleStore, BundleVersion, StoreError, open_store};
36
37pub use skills::{
38 SkillSpec, SkillToolSpec, TaskExpectation, TaskObservationKind, TaskScenario, TaskStep,
39 TaskTraceSnapshot,
40};
41
42pub use simulate::{
43 SimEvent, SimSnapshot, SpecTest, TestExpectation, TestReport, TestStepResult, trace_test,
44};
45
46use std::collections::BTreeMap;
47use std::sync::Arc;
48
49use serde::{Deserialize, Serialize};
50use serde_json::{Value, json};
51
52use gemini_adk_rs::expr::Expr;
53use gemini_adk_rs::flow::{Constraint, Flow, Guard, Pred, Step};
54use gemini_adk_rs::live::extractor::{ExtractionTrigger, FieldPromotion, LlmExtractor};
55use gemini_adk_rs::live::{ContextDelivery, RepairConfig, SteeringMode};
56use gemini_adk_rs::llm::BaseLlm;
57use gemini_adk_rs::state::State;
58use gemini_adk_rs::tool::{SimpleTool, ToolDispatcher, ToolFunction};
59use gemini_genai_rs::prelude::{
60 AutomaticActivityDetection, Content, FunctionResponseScheduling, Sensitivity, SessionWriter,
61 Voice,
62};
63
64use crate::compose::tools::T;
65use crate::live::Live;
66
67#[derive(
69 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
70)]
71#[serde(rename_all = "lowercase")]
72pub enum SpecModality {
73 #[default]
75 Text,
76 Audio,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
87pub struct HttpBinding {
88 #[serde(default = "default_method")]
90 pub method: String,
91 pub url: String,
93 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
95 pub headers: BTreeMap<String, String>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub body: Option<Value>,
99 #[serde(skip)]
102 #[schemars(skip)]
103 pub(crate) reach: Option<BindingAllowlist>,
104}
105
106fn default_method() -> String {
107 "GET".to_string()
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
117pub struct ToolSpec {
118 pub name: String,
120 #[serde(default)]
122 pub description: String,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub parameters: Option<Value>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub response: Option<Value>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub returns: Option<Value>,
134 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
136 pub set_state: BTreeMap<String, Value>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub save_response_as: Option<String>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub http: Option<HttpBinding>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub mcp: Option<String>,
150 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
154 pub background: bool,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub scheduling: Option<SchedulingSpec>,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
162#[serde(rename_all = "snake_case")]
163pub enum SchedulingSpec {
164 Interrupt,
166 WhenIdle,
168 Silent,
170}
171
172impl SchedulingSpec {
173 fn to_wire(self) -> FunctionResponseScheduling {
174 match self {
175 SchedulingSpec::Interrupt => FunctionResponseScheduling::Interrupt,
176 SchedulingSpec::WhenIdle => FunctionResponseScheduling::WhenIdle,
177 SchedulingSpec::Silent => FunctionResponseScheduling::Silent,
178 }
179 }
180}
181
182#[derive(
184 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
185)]
186#[serde(rename_all = "snake_case")]
187pub enum PromotePolicy {
188 #[default]
190 KeepKnown,
191 Overwrite,
193 TrueOnly,
195 NonEmpty,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
202pub struct PromoteSpec {
203 pub field: String,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub to: Option<String>,
208 #[serde(default)]
210 pub policy: PromotePolicy,
211}
212
213impl PromoteSpec {
214 fn target(&self) -> &str {
215 self.to.as_deref().unwrap_or(&self.field)
216 }
217
218 fn to_rule(&self) -> FieldPromotion {
219 let rule = match self.policy {
220 PromotePolicy::KeepKnown => FieldPromotion::keep_known(&self.field),
221 PromotePolicy::Overwrite => FieldPromotion::overwrite(&self.field),
222 PromotePolicy::TrueOnly => FieldPromotion::true_only(&self.field),
223 PromotePolicy::NonEmpty => FieldPromotion::non_empty(&self.field),
224 };
225 match &self.to {
226 Some(key) => rule.to(key),
227 None => rule,
228 }
229 }
230}
231
232#[derive(
234 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
235)]
236#[serde(rename_all = "snake_case")]
237pub enum TriggerSpec {
238 #[default]
240 EveryTurn,
241 AfterToolCall,
243 OnGenerationComplete,
245 OnPhaseChange,
247}
248
249impl TriggerSpec {
250 fn to_trigger(self) -> ExtractionTrigger {
251 match self {
252 TriggerSpec::EveryTurn => ExtractionTrigger::EveryTurn,
253 TriggerSpec::AfterToolCall => ExtractionTrigger::AfterToolCall,
254 TriggerSpec::OnGenerationComplete => ExtractionTrigger::OnGenerationComplete,
255 TriggerSpec::OnPhaseChange => ExtractionTrigger::OnPhaseChange,
256 }
257 }
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
265pub struct ExtractSpec {
266 pub name: String,
268 pub instruction: String,
270 pub schema: Value,
272 #[serde(default = "default_window")]
274 pub window: usize,
275 #[serde(default)]
277 pub trigger: TriggerSpec,
278 #[serde(default, skip_serializing_if = "Vec::is_empty")]
280 pub promote: Vec<PromoteSpec>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub model: Option<String>,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub thinking_budget: Option<u32>,
289}
290
291pub const RECOMMENDED_EXTRACTION_MODEL: &str = "gemini-3.5-flash-lite";
298
299pub const RECOMMENDED_EXTRACTION_THINKING_BUDGET: u32 =
302 gemini_adk_rs::live::extractor::FALLBACK_THINKING_BUDGET;
303
304const UNPINNED_EXTRACTION_WARNING: &str = "extraction names no model, so it runs on the host \
306 default (gemini-flash-latest, a rolling alias whose model and latency change under you, \
307 unless GEMINI_TEXT_MODEL is set); every turn waits for it: set models.extraction";
308
309#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
311pub struct ModelsSpec {
312 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub extraction: Option<String>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub extraction_thinking_budget: Option<u32>,
324}
325
326fn default_window() -> usize {
327 3
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
334#[serde(rename_all = "snake_case")]
335pub enum EffectSpec {
336 Set(BTreeMap<String, Value>),
338 Context(String),
341 Prompt(String),
344 Remember(String),
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
355pub struct TransitionSpec {
356 pub to: String,
358 pub when: Guard,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub description: Option<String>,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
367pub struct PhaseSpec {
368 pub name: String,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub instruction: Option<String>,
373 #[serde(default, skip_serializing_if = "Vec::is_empty")]
375 pub tools: Vec<String>,
376 #[serde(default, skip_serializing_if = "Vec::is_empty")]
378 pub needs: Vec<String>,
379 #[serde(default, skip_serializing_if = "Vec::is_empty")]
381 pub on_enter: Vec<EffectSpec>,
382 #[serde(default, skip_serializing_if = "Vec::is_empty")]
384 pub transitions: Vec<TransitionSpec>,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub prompt_on_enter: Option<bool>,
388 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
390 pub terminal: bool,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
395#[serde(rename_all = "snake_case")]
396pub enum WatchCondition {
397 Changed,
399 ChangedTo(Value),
401 CrossedAbove(f64),
403 CrossedBelow(f64),
405 BecameTrue,
407 BecameFalse,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
416pub struct WatchSpec {
417 pub key: String,
419 pub condition: WatchCondition,
421 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
423 pub set: BTreeMap<String, Value>,
424 #[serde(default, skip_serializing_if = "Vec::is_empty")]
426 pub effects: Vec<EffectSpec>,
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
436pub struct PatternSpec {
437 pub name: String,
439 pub when: Guard,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub sustained_secs: Option<u64>,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub turns: Option<u32>,
447 #[serde(default, skip_serializing_if = "Vec::is_empty")]
449 pub effects: Vec<EffectSpec>,
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
459pub struct ComputedSpec {
460 pub key: String,
462 pub from: Expr,
464 #[serde(default, skip_serializing_if = "String::is_empty")]
466 pub description: String,
467}
468
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
471#[serde(rename_all = "snake_case")]
472pub enum StateType {
473 Boolean,
475 Number,
477 String,
479 Object,
481 Array,
483}
484
485impl StateType {
486 fn matches(self, value: &Value) -> bool {
487 matches!(
488 (self, value),
489 (StateType::Boolean, Value::Bool(_))
490 | (StateType::Number, Value::Number(_))
491 | (StateType::String, Value::String(_))
492 | (StateType::Object, Value::Object(_))
493 | (StateType::Array, Value::Array(_))
494 )
495 }
496}
497
498#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
505pub struct StateFieldSpec {
506 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
508 pub kind: Option<StateType>,
509 #[serde(default, skip_serializing_if = "String::is_empty")]
511 pub description: String,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub default: Option<Value>,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
522pub struct MemorySlotSpec {
523 pub predicate: String,
525 pub to: String,
527}
528
529#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
536pub struct MemorySpec {
537 #[serde(default, skip_serializing_if = "Vec::is_empty")]
539 pub slots: Vec<MemorySlotSpec>,
540}
541
542#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
544#[serde(rename_all = "snake_case")]
545pub enum SensitivitySpec {
546 Low,
548 Medium,
550 High,
552}
553
554impl SensitivitySpec {
555 fn to_wire(self) -> Sensitivity {
556 match self {
557 SensitivitySpec::Low => Sensitivity::SensitivityLow,
558 SensitivitySpec::Medium => Sensitivity::SensitivityMedium,
559 SensitivitySpec::High => Sensitivity::SensitivityHigh,
560 }
561 }
562}
563
564#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
567pub struct VadSpec {
568 #[serde(default, skip_serializing_if = "Option::is_none")]
570 pub start_sensitivity: Option<SensitivitySpec>,
571 #[serde(default, skip_serializing_if = "Option::is_none")]
573 pub end_sensitivity: Option<SensitivitySpec>,
574 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub prefix_padding_ms: Option<u32>,
577 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub silence_duration_ms: Option<u32>,
580}
581
582#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
588pub struct AudioSpec {
589 #[serde(default, skip_serializing_if = "Option::is_none")]
592 pub denoise: Option<bool>,
593 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub noise_gate: Option<NoiseGateSpec>,
597 #[serde(default, skip_serializing_if = "Option::is_none")]
600 pub client_vad: Option<ClientVadSpec>,
601 #[serde(default, skip_serializing_if = "Option::is_none")]
603 pub authority: Option<AuthoritySpec>,
604 #[serde(default, skip_serializing_if = "Option::is_none")]
608 pub eot_hold_ms: Option<u32>,
609 #[serde(default, skip_serializing_if = "Option::is_none")]
613 pub min_interruption_ms: Option<u32>,
614}
615
616#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
618pub struct NoiseGateSpec {
619 #[serde(default = "default_gate_threshold")]
622 pub threshold_rms: f64,
623 #[serde(default = "default_gate_hold")]
625 pub hold_frames: u32,
626}
627
628fn default_gate_threshold() -> f64 {
629 700.0
630}
631fn default_gate_hold() -> u32 {
632 3
633}
634
635#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
637pub struct ClientVadSpec {
638 #[serde(default, skip_serializing_if = "Option::is_none")]
640 pub preset: Option<ClientVadPreset>,
641 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub start_threshold_db: Option<f64>,
644 #[serde(default, skip_serializing_if = "Option::is_none")]
646 pub stop_threshold_db: Option<f64>,
647 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub min_speech_frames: Option<u32>,
650 #[serde(default, skip_serializing_if = "Option::is_none")]
652 pub hangover_frames: Option<u32>,
653}
654
655#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
657#[serde(rename_all = "snake_case")]
658pub enum ClientVadPreset {
659 Default,
661 NoisyStreet,
664}
665
666impl ClientVadSpec {
667 pub fn to_config(&self) -> gemini_genai_rs::vad::VadConfig {
669 let mut config = match self.preset {
670 Some(ClientVadPreset::NoisyStreet) => gemini_genai_rs::vad::VadConfig::noisy_street(),
671 _ => gemini_genai_rs::vad::VadConfig::default(),
672 };
673 if let Some(v) = self.start_threshold_db {
674 config.start_threshold_db = v;
675 }
676 if let Some(v) = self.stop_threshold_db {
677 config.stop_threshold_db = v;
678 }
679 if let Some(v) = self.min_speech_frames {
680 config.min_speech_frames = v;
681 }
682 if let Some(v) = self.hangover_frames {
683 config.hangover_frames = v;
684 }
685 config
686 }
687}
688
689#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
692#[serde(rename_all = "snake_case")]
693pub enum AuthoritySpec {
694 Server,
696 Client,
699}
700
701#[derive(Debug, Clone, Copy, Serialize, Deserialize, schemars::JsonSchema)]
703pub struct TranscriptionSpec {
704 #[serde(default = "default_true")]
706 pub input: bool,
707 #[serde(default = "default_true")]
709 pub output: bool,
710}
711
712fn default_true() -> bool {
713 true
714}
715
716#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
718#[serde(rename_all = "snake_case")]
719pub enum SteeringSpec {
720 InstructionUpdate,
722 ContextInjection,
724 Hybrid,
726 ContextUpdate,
730}
731
732#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
734#[serde(rename_all = "snake_case")]
735pub enum ContextDeliverySpec {
736 Immediate,
738 Deferred,
740}
741
742#[derive(Debug, Clone, Copy, Serialize, Deserialize, schemars::JsonSchema)]
744pub struct RepairSpec {
745 pub nudge_after: u32,
747 pub escalate_after: u32,
749}
750
751#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
753#[serde(rename_all = "snake_case")]
754pub enum PersistenceSpec {
755 Fs {
757 dir: String,
759 },
760 Memory,
762}
763
764#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
769pub struct RuntimeSpec {
770 #[serde(default, skip_serializing_if = "Option::is_none")]
772 pub temperature: Option<f32>,
773 #[serde(default, skip_serializing_if = "Option::is_none")]
775 pub thinking_budget: Option<u32>,
776 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub include_thoughts: Option<bool>,
779 #[serde(default, skip_serializing_if = "Option::is_none")]
781 pub transcription: Option<TranscriptionSpec>,
782 #[serde(default, skip_serializing_if = "Option::is_none")]
784 pub proactive_audio: Option<bool>,
785 #[serde(default, skip_serializing_if = "Option::is_none")]
787 pub vad: Option<VadSpec>,
788 #[serde(default, skip_serializing_if = "Option::is_none")]
790 pub audio: Option<AudioSpec>,
791 #[serde(default, skip_serializing_if = "Option::is_none")]
793 pub soft_turn_timeout_ms: Option<u64>,
794 #[serde(default, skip_serializing_if = "Option::is_none")]
796 pub steering: Option<SteeringSpec>,
797 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub context_delivery: Option<ContextDeliverySpec>,
800 #[serde(default, skip_serializing_if = "Option::is_none")]
802 pub repair: Option<RepairSpec>,
803 #[serde(default, skip_serializing_if = "Option::is_none")]
805 pub persistence: Option<PersistenceSpec>,
806 #[serde(default, skip_serializing_if = "Option::is_none")]
808 pub session_id: Option<String>,
809 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
811 pub lossy_audio: bool,
812 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
814 pub lossy_transcript: bool,
815}
816
817#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
825pub struct UseFragment {
826 pub fragment: String,
828 pub namespace: String,
830 #[serde(default, skip_serializing_if = "Vec::is_empty")]
832 pub after: Vec<String>,
833}
834
835#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
837pub struct SessionSpec {
838 #[serde(default)]
840 pub name: String,
841 #[serde(default, skip_serializing_if = "String::is_empty")]
843 pub version: String,
844 #[serde(default)]
846 pub description: String,
847 #[serde(default)]
849 pub instruction: String,
850 #[serde(default, skip_serializing_if = "Option::is_none")]
852 pub greeting: Option<String>,
853 #[serde(default)]
855 pub modality: SpecModality,
856 #[serde(default, skip_serializing_if = "Option::is_none")]
858 pub voice: Option<String>,
859 #[serde(default, skip_serializing_if = "Vec::is_empty")]
861 pub tools: Vec<ToolSpec>,
862 #[serde(default, skip_serializing_if = "Vec::is_empty")]
865 pub mcp: Vec<String>,
866 #[serde(default, skip_serializing_if = "Vec::is_empty")]
868 pub extract: Vec<ExtractSpec>,
869 #[serde(default, skip_serializing_if = "is_default_models")]
871 pub models: ModelsSpec,
872 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
877 pub decisions: BTreeMap<String, DecisionSpec>,
878 #[serde(default, skip_serializing_if = "Option::is_none")]
882 pub code: Option<CodeSpec>,
883 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
885 pub state: BTreeMap<String, StateFieldSpec>,
886 #[serde(default, skip_serializing_if = "Vec::is_empty")]
888 pub computed: Vec<ComputedSpec>,
889 #[serde(default, skip_serializing_if = "Option::is_none")]
893 pub memory: Option<MemorySpec>,
894 #[serde(default, skip_serializing_if = "Option::is_none")]
896 pub runtime: Option<RuntimeSpec>,
897 #[serde(default, skip_serializing_if = "Vec::is_empty")]
899 pub phases: Vec<PhaseSpec>,
900 #[serde(default, skip_serializing_if = "Option::is_none")]
902 pub initial_phase: Option<String>,
903 #[serde(default, skip_serializing_if = "Vec::is_empty")]
905 pub watch: Vec<WatchSpec>,
906 #[serde(default, skip_serializing_if = "Vec::is_empty")]
908 pub patterns: Vec<PatternSpec>,
909 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
911 pub fragments: BTreeMap<String, Flow>,
912 #[serde(default, skip_serializing_if = "Vec::is_empty")]
914 pub use_fragments: Vec<UseFragment>,
915 #[serde(default, skip_serializing_if = "Option::is_none")]
917 pub flow: Option<Flow>,
918 #[serde(default, skip_serializing_if = "Option::is_none")]
923 pub conversation: Option<crate::conversation::ConversationSpec>,
924 #[serde(default, skip_serializing_if = "Vec::is_empty")]
926 pub skills: Vec<SkillSpec>,
927 #[serde(default, skip_serializing_if = "Vec::is_empty")]
929 pub task_scenarios: Vec<TaskScenario>,
930 #[serde(default, skip_serializing_if = "Vec::is_empty")]
933 pub scenarios: Vec<crate::simulation::Scenario>,
934 #[serde(default, skip_serializing_if = "Vec::is_empty")]
937 pub tests: Vec<SpecTest>,
938}
939
940#[derive(Debug, Clone, Serialize)]
942pub struct SpecValidation {
943 pub valid: bool,
945 pub errors: Vec<String>,
947 pub warnings: Vec<String>,
949 pub mermaid: String,
951 pub tools: Vec<String>,
953 pub steps: usize,
955}
956
957pub trait MemoryBinding: Send + Sync {
967 fn install(&self, live: Live, memory: &MemorySpec) -> Live;
969 fn remember(&self, note: String);
972 fn task_memory(
975 &self,
976 _memory: &MemorySpec,
977 ) -> Result<Arc<dyn gemini_adk_rs::tasks::TaskMemoryService>, String> {
978 Err("this memory binding does not support task-owned memory services".into())
979 }
980 fn task_tools(&self) -> Vec<Arc<dyn ToolFunction>> {
983 Vec::new()
984 }
985 fn install_task_lifecycle(&self, live: Live) -> Live {
988 live
989 }
990}
991
992#[derive(Debug, Clone, Default, PartialEq, Eq)]
1004pub struct BindingAllowlist {
1005 http_prefixes: Vec<String>,
1006 mcp: Vec<String>,
1007}
1008
1009impl BindingAllowlist {
1010 pub fn allow_http_prefix(mut self, prefix: impl Into<String>) -> Self {
1014 let mut prefix = prefix.into();
1015 let after_scheme = prefix.split_once("://").map_or("", |(_, rest)| rest);
1016 if !after_scheme.contains('/') {
1017 prefix.push('/');
1018 }
1019 self.http_prefixes.push(prefix);
1020 self
1021 }
1022
1023 pub fn allow_mcp(mut self, params: impl Into<String>) -> Self {
1025 self.mcp.push(params.into());
1026 self
1027 }
1028
1029 fn allows_http(&self, url: &str) -> bool {
1034 let Ok(target) = url::Url::parse(url) else {
1035 return false;
1036 };
1037 if !target.username().is_empty() || target.password().is_some() {
1038 return false;
1039 }
1040 self.http_prefixes.iter().any(|prefix| {
1041 url::Url::parse(prefix).is_ok_and(|prefix| {
1042 prefix.scheme() == target.scheme()
1043 && prefix.host() == target.host()
1044 && prefix.port_or_known_default() == target.port_or_known_default()
1045 && target.path().starts_with(prefix.path())
1046 })
1047 })
1048 }
1049
1050 fn allows_mcp(&self, params: &str) -> bool {
1051 self.mcp.iter().any(|m| m.trim() == params.trim())
1052 }
1053}
1054
1055const RUNTIME_WRITTEN: [&str; 6] = [
1058 "flow:",
1059 "session:",
1060 "verbatim:",
1061 "correction:",
1062 "repair:",
1063 "telephony:",
1064];
1065
1066#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1068pub struct ScenarioReport {
1069 pub name: String,
1071 pub passed: bool,
1073 #[serde(default, skip_serializing_if = "Option::is_none")]
1075 pub error: Option<String>,
1076}
1077
1078pub const MEMORY_TOOL_NAMES: [&str; 2] = ["recall_context", "manage_memory"];
1080
1081#[derive(Default, Clone)]
1084pub struct SpecResources {
1085 pub extraction_llm: Option<Arc<dyn BaseLlm>>,
1088 pub models: Option<Arc<gemini_adk_rs::llm::LlmRegistry>>,
1094 pub decision_model: Option<Arc<dyn gemini_adk_rs::decision::DecisionModel>>,
1098 pub memory: Option<Arc<dyn MemoryBinding>>,
1101 pub code_sandbox: Option<Arc<dyn gemini_adk_rs::code_mode::CodeSandbox>>,
1105 pub tools: BTreeMap<String, Arc<dyn ToolFunction>>,
1111}
1112
1113impl SpecResources {
1114 pub fn implement_skill(mut self, skill: &str, tool: impl ToolFunction + 'static) -> Self {
1116 self.tools
1117 .insert(format!("{skill}/{}", tool.name()), Arc::new(tool));
1118 self
1119 }
1120
1121 pub fn implement(mut self, tool: impl ToolFunction + 'static) -> Self {
1123 self.tools.insert(tool.name().to_string(), Arc::new(tool));
1124 self
1125 }
1126}
1127
1128fn is_default_models(models: &ModelsSpec) -> bool {
1129 *models == ModelsSpec::default()
1130}
1131
1132fn resolve_model(
1135 name: &str,
1136 resources: &SpecResources,
1137 cache: &mut BTreeMap<String, Arc<dyn BaseLlm>>,
1138) -> Result<Arc<dyn BaseLlm>, String> {
1139 if let Some(llm) = cache.get(name) {
1140 return Ok(llm.clone());
1141 }
1142 let llm = match resources.models.as_ref().and_then(|r| r.resolve(name)) {
1143 Some(llm) => llm,
1144 None if name.starts_with("gemini") || name.starts_with("models/gemini") => Arc::new(
1145 gemini_adk_rs::llm::GeminiLlm::try_new(gemini_adk_rs::llm::GeminiLlmParams {
1146 model: Some(name.to_string()),
1147 ..Default::default()
1148 })
1149 .map_err(|e| format!("model '{name}': {e}"))?,
1150 )
1151 as Arc<dyn BaseLlm>,
1152 None => {
1153 return Err(format!(
1154 "model '{name}': nothing resolves it; register it in SpecResources.models"
1155 ));
1156 }
1157 };
1158 cache.insert(name.to_string(), llm.clone());
1159 Ok(llm)
1160}
1161
1162pub(crate) fn extraction_llm_for(
1165 entry: &ExtractSpec,
1166 default: Option<&str>,
1167 resources: &SpecResources,
1168 cache: &mut BTreeMap<String, Arc<dyn BaseLlm>>,
1169) -> Result<Arc<dyn BaseLlm>, String> {
1170 match entry.model.as_deref().or(default) {
1171 Some(name) => resolve_model(name, resources, cache)
1172 .map_err(|e| format!("extract '{}': {e}", entry.name)),
1173 None => resources.extraction_llm.clone().ok_or_else(|| {
1174 format!(
1175 "extract '{}' names no model and SpecResources.extraction_llm is not set; \
1176 set models.extraction",
1177 entry.name
1178 )
1179 }),
1180 }
1181}
1182
1183impl SessionSpec {
1184 pub fn uses_default_extraction_model(&self) -> bool {
1187 let unnamed = |entries: &[ExtractSpec], default: Option<&str>| {
1188 default.is_none() && entries.iter().any(|e| e.model.is_none())
1189 };
1190 unnamed(&self.extract, self.models.extraction.as_deref())
1191 || self
1192 .skills
1193 .iter()
1194 .any(|skill| unnamed(&skill.extract, self.models.extraction.as_deref()))
1195 }
1196
1197 pub fn requires_extraction(&self) -> bool {
1199 !self.extract.is_empty()
1200 || self.conversation.as_ref().is_some_and(|conversation| {
1201 conversation
1202 .stages
1203 .iter()
1204 .chain(
1205 conversation
1206 .overlays
1207 .iter()
1208 .flat_map(|overlay| &overlay.stages),
1209 )
1210 .any(|stage| stage.frame.is_some())
1211 })
1212 || self.skills.iter().any(|skill| {
1213 !skill.extract.is_empty()
1214 || skill.conversation.as_ref().is_some_and(|conversation| {
1215 conversation
1216 .stages
1217 .iter()
1218 .chain(
1219 conversation
1220 .overlays
1221 .iter()
1222 .flat_map(|overlay| &overlay.stages),
1223 )
1224 .any(|stage| stage.frame.is_some())
1225 })
1226 })
1227 }
1228
1229 pub fn requires_memory(&self) -> bool {
1231 self.memory.is_some() || self.skills.iter().any(|skill| skill.memory.is_some())
1232 }
1233
1234 pub fn from_value(value: Value) -> Result<Self, String> {
1237 let is_bare_flow = value.get("flow").is_none() && value.get("steps").is_some();
1238 if is_bare_flow {
1239 let flow: Flow =
1240 serde_json::from_value(value).map_err(|e| format!("invalid flow JSON: {e}"))?;
1241 return Ok(Self {
1242 flow: Some(flow),
1243 ..Self::default()
1244 });
1245 }
1246 serde_json::from_value(value).map_err(|e| format!("invalid session spec JSON: {e}"))
1247 }
1248
1249 pub fn json_schema() -> Value {
1252 serde_json::to_value(schemars::schema_for!(SessionSpec)).unwrap_or_else(|_| json!({}))
1253 }
1254
1255 pub fn tool_names(&self) -> Vec<String> {
1257 self.tools.iter().map(|t| t.name.clone()).collect()
1258 }
1259
1260 pub fn effective_flow(&self) -> Result<Flow, Vec<String>> {
1262 if let Some(conversation) = &self.conversation {
1263 if self.flow.is_some() || !self.use_fragments.is_empty() {
1264 return Err(vec![
1265 "set either `conversation` or `flow` (with `use_fragments`), not both".into(),
1266 ]);
1267 }
1268 return crate::conversation::Conversation::from_spec_stubbing_resolvers(
1269 conversation.clone(),
1270 )
1271 .map(|compiled| compiled.flow().flow().clone())
1272 .map_err(|e| vec![format!("conversation: {e}")]);
1273 }
1274 let mut flow = self.flow.clone().unwrap_or_default();
1275 let mut errors = Vec::new();
1276 for use_frag in &self.use_fragments {
1277 match self.fragments.get(&use_frag.fragment) {
1278 Some(fragment) => {
1279 splice_fragment(&mut flow, fragment, use_frag, &mut errors);
1280 }
1281 None => errors.push(format!(
1282 "use_fragments references unknown fragment '{}'",
1283 use_frag.fragment
1284 )),
1285 }
1286 }
1287 if errors.is_empty() {
1288 Ok(flow)
1289 } else {
1290 Err(errors)
1291 }
1292 }
1293
1294 pub fn state_keys_written(&self) -> std::collections::BTreeSet<String> {
1298 let mut keys = std::collections::BTreeSet::new();
1299 for t in &self.tools {
1300 keys.extend(t.set_state.keys().cloned());
1301 keys.extend(t.save_response_as.iter().cloned());
1302 }
1303 for e in &self.extract {
1304 keys.insert(e.name.clone());
1305 for p in &e.promote {
1306 keys.insert(p.target().to_string());
1307 }
1308 }
1309 keys.extend(self.decisions.values().filter_map(|d| d.writes.clone()));
1310 for p in &self.phases {
1311 for eff in &p.on_enter {
1312 if let EffectSpec::Set(map) = eff {
1313 keys.extend(map.keys().cloned());
1314 }
1315 }
1316 }
1317 for w in &self.watch {
1318 keys.extend(w.set.keys().cloned());
1319 for eff in &w.effects {
1320 if let EffectSpec::Set(map) = eff {
1321 keys.extend(map.keys().cloned());
1322 }
1323 }
1324 }
1325 for p in &self.patterns {
1326 for eff in &p.effects {
1327 if let EffectSpec::Set(map) = eff {
1328 keys.extend(map.keys().cloned());
1329 }
1330 }
1331 }
1332 for c in &self.computed {
1333 keys.insert(c.key.clone());
1336 keys.insert(format!("derived:{}", c.key));
1337 }
1338 if let Some(memory) = &self.memory {
1339 for slot in &memory.slots {
1340 keys.extend([slot.to.clone()]);
1341 }
1342 }
1343 for (key, field) in &self.state {
1344 if field.default.is_some() {
1345 keys.insert(key.clone());
1346 }
1347 }
1348 for stage in self.conversation_stages() {
1350 keys.extend(stage.collect.iter().cloned());
1351 keys.extend(stage.resolve.iter().map(|r| r.slot.clone()));
1352 if let Some(frame) = &stage.frame {
1353 keys.extend(frame.slot_keys());
1354 }
1355 }
1356 keys
1357 }
1358
1359 fn conversation_stages(&self) -> impl Iterator<Item = &crate::conversation::StageSpec> {
1361 self.conversation.iter().flat_map(|c| {
1362 c.stages
1363 .iter()
1364 .chain(c.overlays.iter().flat_map(|o| o.stages.iter()))
1365 })
1366 }
1367
1368 fn resolver_registry(
1371 &self,
1372 tools: &[Arc<SimpleTool>],
1373 ) -> crate::conversation::ResolverRegistry {
1374 let mut registry = crate::conversation::ResolverRegistry::new();
1375 for stage in self.conversation_stages() {
1376 for resolve in &stage.resolve {
1377 let name = resolve
1378 .resolver
1379 .clone()
1380 .unwrap_or_else(|| resolve.slot.clone());
1381 if let Some(tool) = tools
1382 .iter()
1383 .find(|t| ToolFunction::name(t.as_ref()) == name)
1384 {
1385 let tool = tool.clone();
1386 registry.add(name, move |args| {
1387 let tool = tool.clone();
1388 async move {
1389 gemini_adk_rs::tool::ToolFunction::call(tool.as_ref(), args)
1390 .await
1391 .map_err(|e| e.to_string())
1392 }
1393 });
1394 }
1395 }
1396 }
1397 registry
1398 }
1399
1400 pub async fn run_scenarios(&self) -> Vec<ScenarioReport> {
1404 let failed = |error: String| -> Vec<ScenarioReport> {
1405 self.scenarios
1406 .iter()
1407 .map(|s| ScenarioReport {
1408 name: s.name.clone(),
1409 passed: false,
1410 error: Some(error.clone()),
1411 })
1412 .collect()
1413 };
1414 let Some(conversation) = &self.conversation else {
1415 return failed("the spec has no `conversation` to run against".into());
1416 };
1417 let compiled = match crate::conversation::Conversation::from_spec_stubbing_resolvers(
1418 conversation.clone(),
1419 ) {
1420 Ok(compiled) => compiled,
1421 Err(e) => return failed(format!("conversation: {e}")),
1422 };
1423 let bank: BTreeMap<String, gemini_adk_rs::decision::Decision> = self
1424 .decisions
1425 .iter()
1426 .map(|(id, d)| (id.clone(), d.compile()))
1427 .collect();
1428 let mut reports = Vec::with_capacity(self.scenarios.len());
1429 for scenario in &self.scenarios {
1430 let sim =
1431 crate::simulation::Sim::new(&compiled, gemini_adk_rs::flow::Enforcement::Enforce)
1432 .with_decisions(bank.clone());
1433 let result = scenario.run_in(sim).await;
1434 reports.push(ScenarioReport {
1435 name: scenario.name.clone(),
1436 passed: result.is_ok(),
1437 error: result.err(),
1438 });
1439 }
1440 reports
1441 }
1442
1443 fn all_effects(&self) -> Vec<(String, &EffectSpec)> {
1445 let mut out = Vec::new();
1446 for p in &self.phases {
1447 for eff in &p.on_enter {
1448 out.push((format!("phase '{}'", p.name), eff));
1449 }
1450 }
1451 for w in &self.watch {
1452 for eff in &w.effects {
1453 out.push((format!("watch '{}'", w.key), eff));
1454 }
1455 }
1456 for p in &self.patterns {
1457 for eff in &p.effects {
1458 out.push((format!("pattern '{}'", p.name), eff));
1459 }
1460 }
1461 out
1462 }
1463
1464 pub(crate) fn recompute_computed(&self, state: &State) {
1469 for _ in 0..self.computed.len() {
1470 let mut changed = false;
1471 for c in &self.computed {
1472 if let Some(value) = c.from.eval(state) {
1473 let derived = format!("derived:{}", c.key);
1474 if state.get_raw(&derived).as_ref() != Some(&value) {
1475 let _ = state.set(&derived, value);
1476 changed = true;
1477 }
1478 }
1479 }
1480 if !changed {
1481 break;
1482 }
1483 }
1484 }
1485
1486 pub(crate) fn seed_state_defaults(&self, state: &State) {
1488 for (key, field) in &self.state {
1489 if let Some(default) = &field.default
1490 && state.get_raw(key).is_none()
1491 {
1492 let _ = state.set(key, default.clone());
1493 }
1494 }
1495 }
1496
1497 pub fn validate(&self) -> SpecValidation {
1501 self.validate_with_http_support(true)
1502 }
1503
1504 pub fn validate_for_replay(&self) -> SpecValidation {
1510 self.validate_with_http_support(false)
1511 }
1512
1513 fn validate_with_http_support(&self, require_http_support: bool) -> SpecValidation {
1514 self.validate_parts(require_http_support, false, &Default::default())
1515 }
1516
1517 fn validate_definition(
1518 &self,
1519 require_http_support: bool,
1520 activation_inputs: &std::collections::BTreeSet<String>,
1521 ) -> SpecValidation {
1522 self.validate_parts(require_http_support, true, activation_inputs)
1523 }
1524
1525 fn validate_parts(
1526 &self,
1527 require_http_support: bool,
1528 allow_ungoverned: bool,
1529 activation_inputs: &std::collections::BTreeSet<String>,
1530 ) -> SpecValidation {
1531 let mut errors = Vec::new();
1532 let mut warnings = Vec::new();
1533 let mut skill_tools = Vec::new();
1534 let mut skill_steps = 0;
1535 let mut written = self.state_keys_written();
1536 written.extend(activation_inputs.iter().cloned());
1537
1538 if !self.skills.is_empty() {
1539 if self.flow.is_some()
1540 || self.conversation.is_some()
1541 || !self.phases.is_empty()
1542 || !self.tools.is_empty()
1543 || !self.mcp.is_empty()
1544 || !self.extract.is_empty()
1545 || !self.decisions.is_empty()
1546 || !self.computed.is_empty()
1547 || !self.watch.is_empty()
1548 || !self.patterns.is_empty()
1549 || !self.use_fragments.is_empty()
1550 || self.memory.is_some()
1551 || !self.state.is_empty()
1552 {
1553 errors.push("skills own their state, tools and governance; do not combine them with a session-level pipeline".into());
1554 }
1555 let mut names = std::collections::BTreeSet::new();
1556 for skill in &self.skills {
1557 if !names.insert(&skill.name) {
1558 errors.push(format!(
1559 "skill '{}' is installed more than once",
1560 skill.name
1561 ));
1562 }
1563 let validation = skill.validation(require_http_support);
1564 skill_steps += validation.steps;
1565 skill_tools.extend(
1566 skill
1567 .tools
1568 .iter()
1569 .map(|tool| format!("{}__{}", skill.name, tool.tool.name)),
1570 );
1571 errors.extend(
1572 validation
1573 .errors
1574 .into_iter()
1575 .map(|problem| format!("skill '{}': {problem}", skill.name)),
1576 );
1577 warnings.extend(
1578 validation
1579 .warnings
1580 .into_iter()
1581 .filter(|warning| warning != UNPINNED_EXTRACTION_WARNING)
1584 .map(|warning| format!("skill '{}': {warning}", skill.name)),
1585 );
1586 }
1587 } else if !self.task_scenarios.is_empty() {
1588 errors.push("task_scenarios require skills".into());
1589 }
1590
1591 for use_frag in &self.use_fragments {
1593 if use_frag.namespace.is_empty() {
1594 errors.push(
1595 "use_fragments directive has empty namespace — step ids would be malformed"
1596 .into(),
1597 );
1598 }
1599 }
1600
1601 let flow = match self.effective_flow() {
1602 Ok(flow) => flow,
1603 Err(errs) => {
1604 errors.extend(errs);
1605 self.flow.clone().unwrap_or_default()
1606 }
1607 };
1608 for stage in self.conversation_stages() {
1609 for resolve in &stage.resolve {
1610 let name = resolve.resolver.as_deref().unwrap_or(&resolve.slot);
1611 if !self.tools.iter().any(|t| t.name == name) {
1612 errors.push(format!(
1613 "stage '{}' resolves '{}' with '{name}', which is not a declared tool",
1614 stage.id, resolve.slot
1615 ));
1616 }
1617 }
1618 }
1619 if !self.scenarios.is_empty() && self.conversation.is_none() {
1620 errors.push("`scenarios` need a `conversation` to run against".into());
1621 }
1622 let mermaid = flow.to_mermaid();
1623 let steps = flow.steps.len();
1624 let has_flow = !flow.steps.is_empty();
1625
1626 if !has_flow && self.phases.is_empty() && self.skills.is_empty() && !allow_ungoverned {
1627 errors.push("spec has neither a flow nor phases — nothing to run".into());
1628 }
1629 if !self.phases.is_empty() && self.initial_phase.is_none() {
1630 errors.push("phases are declared but initial_phase is not set".into());
1631 }
1632 if let Some(initial) = &self.initial_phase
1633 && !self.phases.iter().any(|p| &p.name == initial)
1634 {
1635 errors.push(format!("initial_phase '{initial}' is not a declared phase"));
1636 }
1637 {
1639 let phase_names: std::collections::BTreeSet<&str> =
1640 self.phases.iter().map(|p| p.name.as_str()).collect();
1641 if phase_names.len() != self.phases.len() {
1642 let mut seen = std::collections::BTreeSet::new();
1643 for p in &self.phases {
1644 if !seen.insert(p.name.as_str()) {
1645 errors.push(format!("phase '{}' is declared more than once", p.name));
1646 }
1647 }
1648 }
1649 }
1650 for pattern in &self.patterns {
1651 match (pattern.sustained_secs, pattern.turns) {
1652 (Some(_), Some(_)) | (None, None) => errors.push(format!(
1653 "pattern '{}' must set exactly one of sustained_secs or turns",
1654 pattern.name
1655 )),
1656 _ => {}
1657 }
1658 if guard_uses_marking(&pattern.when) {
1659 errors.push(format!(
1660 "pattern '{}' uses a called_ok/done atom — pattern guards see state only",
1661 pattern.name
1662 ));
1663 }
1664 }
1665 for p in &self.phases {
1666 for t in &p.transitions {
1667 if guard_uses_marking(&t.when) {
1668 errors.push(format!(
1669 "phase '{}' transition to '{}' uses a called_ok/done atom — phase guards \
1670 see state only (no flow marking); latch a state key instead",
1671 p.name, t.to
1672 ));
1673 }
1674 }
1675 }
1676 for t in &self.tools {
1677 if t.http.is_some() && t.mcp.is_some() {
1678 errors.push(format!(
1679 "tool '{}' has both an http and an mcp binding; keep one",
1680 t.name
1681 ));
1682 }
1683 }
1684 if require_http_support && cfg!(not(feature = "http-tools")) {
1685 for t in &self.tools {
1686 if t.http.is_some() {
1687 errors.push(format!(
1688 "tool '{}' has an http binding but the `http-tools` feature is not \
1689 enabled",
1690 t.name
1691 ));
1692 }
1693 }
1694 }
1695
1696 {
1700 let computed_keys: std::collections::BTreeSet<&str> =
1701 self.computed.iter().map(|c| c.key.as_str()).collect();
1702 if computed_keys.len() != self.computed.len() {
1703 errors.push("computed variables declare a duplicate key".into());
1704 }
1705 let normalize = |k: &str| k.strip_prefix("derived:").unwrap_or(k).to_string();
1706 let mut in_degree: BTreeMap<&str, usize> =
1707 computed_keys.iter().map(|k| (*k, 0)).collect();
1708 let mut dependents: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1709 for c in &self.computed {
1710 for dep in c.from.keys_read() {
1711 let dep = normalize(&dep);
1712 if dep != c.key && computed_keys.contains(dep.as_str()) {
1713 let dep_key = *computed_keys.get(dep.as_str()).unwrap();
1714 dependents.entry(dep_key).or_default().push(c.key.as_str());
1715 *in_degree.entry(c.key.as_str()).or_default() += 1;
1716 }
1717 if dep == c.key {
1718 errors.push(format!("computed '{}' reads its own key", c.key));
1719 }
1720 }
1721 }
1722 let mut queue: Vec<&str> = in_degree
1723 .iter()
1724 .filter(|(_, d)| **d == 0)
1725 .map(|(k, _)| *k)
1726 .collect();
1727 let mut visited = 0usize;
1728 while let Some(key) = queue.pop() {
1729 visited += 1;
1730 for dependent in dependents.get(key).cloned().unwrap_or_default() {
1731 let d = in_degree.get_mut(dependent).unwrap();
1732 *d -= 1;
1733 if *d == 0 {
1734 queue.push(dependent);
1735 }
1736 }
1737 }
1738 if visited != computed_keys.len() {
1739 let cycle: Vec<&str> = in_degree
1740 .iter()
1741 .filter(|(_, d)| **d > 0)
1742 .map(|(k, _)| *k)
1743 .collect();
1744 errors.push(format!(
1745 "computed variables form a dependency cycle: {}",
1746 cycle.join(", ")
1747 ));
1748 }
1749 }
1750
1751 for (location, effect) in self.all_effects() {
1754 if matches!(effect, EffectSpec::Remember(_)) && self.memory.is_none() {
1755 errors.push(format!(
1756 "{location} uses a `remember` effect but the spec has no `memory` section"
1757 ));
1758 }
1759 }
1760 if let Some(memory) = &self.memory {
1761 for slot in &memory.slots {
1762 if slot.to.starts_with("derived:") {
1763 errors.push(format!(
1764 "memory slot '{}' targets read-only key '{}' — the `derived:` scope \
1765 belongs to computed variables",
1766 slot.predicate, slot.to
1767 ));
1768 }
1769 }
1770 }
1771
1772 for (key, field) in &self.state {
1775 if let (Some(kind), Some(default)) = (field.kind, &field.default)
1776 && !kind.matches(default)
1777 {
1778 warnings.push(format!(
1779 "state key '{key}' declares type {kind:?} but its default is {default}"
1780 ));
1781 }
1782 }
1783 if !self.state.is_empty() {
1784 let declared: std::collections::BTreeSet<&str> =
1785 self.state.keys().map(String::as_str).collect();
1786 let mut undeclared = std::collections::BTreeSet::new();
1787 for key in &written {
1788 let bare = key.strip_prefix("derived:").unwrap_or(key);
1789 if !declared.contains(bare) && !self.computed.iter().any(|c| c.key == bare) {
1790 undeclared.insert(key.clone());
1791 }
1792 }
1793 for key in &undeclared {
1794 warnings.push(format!(
1795 "state key '{key}' is written but not declared in the `state` section"
1796 ));
1797 }
1798 }
1799
1800 {
1803 for c in &self.computed {
1804 for dep in c.from.keys_read() {
1805 let bare = dep.strip_prefix("derived:").unwrap_or(&dep);
1806 if !written.contains(&dep)
1807 && !written.contains(bare)
1808 && !dep.ends_with(":result")
1809 {
1810 warnings.push(format!(
1811 "computed '{}' reads state key '{dep}' but nothing writes it",
1812 c.key
1813 ));
1814 }
1815 }
1816 }
1817 }
1818
1819 if let Some(runtime) = &self.runtime {
1821 if runtime.include_thoughts == Some(true) && runtime.thinking_budget.is_none() {
1822 warnings.push(
1823 "runtime.include_thoughts is set without runtime.thinking_budget — no \
1824 thoughts will arrive"
1825 .into(),
1826 );
1827 }
1828 if let Some(audio) = &runtime.audio {
1829 if audio.denoise == Some(true) && !cfg!(feature = "denoise") {
1830 warnings.push(
1831 "runtime.audio.denoise requires building with the `denoise` feature — \
1832 the stage will be skipped"
1833 .into(),
1834 );
1835 }
1836 if audio.authority == Some(AuthoritySpec::Client) && audio.denoise != Some(true) {
1837 warnings.push(
1838 "runtime.audio.authority=client without denoise — in noise the raw \
1839 energy VAD latches open and will drive interruptions falsely \
1840 (measured); enable denoise or expect spurious barge-ins"
1841 .into(),
1842 );
1843 }
1844 if audio.noise_gate.is_some() && audio.denoise != Some(true) {
1845 warnings.push(
1846 "runtime.audio.noise_gate without denoise — the gate calibrates on \
1847 noisy levels; chain it behind denoise so it gates clean audio"
1848 .into(),
1849 );
1850 }
1851 if audio.authority == Some(AuthoritySpec::Client) && runtime.vad.is_some() {
1852 warnings.push(
1853 "runtime.audio.authority=client disables the server's automatic \
1854 activity detection — runtime.vad sensitivities will have no effect"
1855 .into(),
1856 );
1857 }
1858 if let Some(eot_ms) = audio.eot_hold_ms
1859 && eot_ms > 1600
1860 {
1861 warnings.push(
1862 "runtime.audio.eot_hold_ms exceeds the measured frontier (1600 ms): \
1863 recall fell to 0.508 at 1600ms on TurnBench dev — values beyond this \
1864 may cause missed turn-end detection"
1865 .into(),
1866 );
1867 }
1868 if let Some(min_int_ms) = audio.min_interruption_ms
1869 && min_int_ms > 2000
1870 {
1871 warnings.push(
1872 "runtime.audio.min_interruption_ms exceeds the interruption match \
1873 window (2000 ms) — commits may land too late to count"
1874 .into(),
1875 );
1876 }
1877 }
1878 if runtime.session_id.is_some() && runtime.persistence.is_none() {
1879 warnings.push(
1880 "runtime.session_id is set without runtime.persistence — nothing will be \
1881 snapshotted"
1882 .into(),
1883 );
1884 }
1885 }
1886
1887 let (valid_flow, referenced) = if has_flow {
1891 let compile_result = if self.tools.is_empty() || !self.mcp.is_empty() {
1892 if !self.mcp.is_empty() && !self.tools.is_empty() {
1893 warnings.push(
1894 "MCP toolsets resolve at connect time, so tool-name checking against \
1895 the flow is skipped"
1896 .into(),
1897 );
1898 }
1899 flow.clone().compile()
1900 } else {
1901 let mut names = self.tool_names();
1902 if self.memory.is_some() {
1903 names.extend(
1906 MEMORY_TOOL_NAMES
1907 .iter()
1908 .map(std::string::ToString::to_string),
1909 );
1910 }
1911 let refs: Vec<&str> = names.iter().map(String::as_str).collect();
1912 flow.clone().compile_with_tools(&refs)
1913 };
1914 match compile_result {
1915 Ok(compiled) => (
1916 true,
1917 compiled
1918 .tool_surface()
1919 .tools
1920 .iter()
1921 .cloned()
1922 .collect::<Vec<_>>(),
1923 ),
1924 Err(errs) => {
1925 errors.extend(errs.0.iter().map(std::string::ToString::to_string));
1926 (false, Vec::new())
1927 }
1928 }
1929 } else {
1930 (true, Vec::new())
1931 };
1932
1933 if valid_flow && has_flow {
1934 for key in flow.state_keys_read() {
1937 if written.contains(&key) || RUNTIME_WRITTEN.iter().any(|p| key.starts_with(p)) {
1938 continue;
1939 }
1940 if key.ends_with(":result") {
1942 continue;
1943 }
1944 let hint = written
1945 .iter()
1946 .filter(|w| levenshtein(&key, w) <= 2)
1947 .cloned()
1948 .collect::<Vec<_>>();
1949 let suffix = if hint.is_empty() {
1950 String::new()
1951 } else {
1952 format!(" — did you mean {}?", hint.join(" / "))
1953 };
1954 warnings.push(format!(
1955 "a guard reads state key '{key}' but no tool, extractor, phase, or watcher \
1956 writes it (it can never latch){suffix}"
1957 ));
1958 }
1959 let overlay_tools: std::collections::BTreeSet<&str> = self
1962 .conversation
1963 .iter()
1964 .flat_map(|c| c.overlays.iter().flat_map(|o| o.stages.iter()))
1965 .flat_map(|s| {
1966 s.allow
1967 .iter()
1968 .map(String::as_str)
1969 .chain(s.commit.iter().map(|c| c.tool.as_str()))
1970 })
1971 .collect();
1972 for t in &self.tools {
1973 if !referenced.contains(&t.name) && !overlay_tools.contains(t.name.as_str()) {
1974 warnings.push(format!(
1975 "tool '{}' is declared but no step or constraint references it \
1976 (it will be denied whenever a step with an `allow` list is active \
1977 unless you add it to `ambient`)",
1978 t.name
1979 ));
1980 }
1981 }
1982 for s in &flow.steps {
1983 if !s.terminal && s.posture.is_none() {
1984 warnings.push(format!(
1985 "step '{}' has no posture — the model gets no steering while it is active",
1986 s.id
1987 ));
1988 }
1989 }
1990 }
1991
1992 if self.uses_default_extraction_model() {
1994 warnings.push(UNPINNED_EXTRACTION_WARNING.into());
1995 }
1996 for (place, name) in self
1997 .extract
1998 .iter()
1999 .filter_map(|e| {
2000 e.model
2001 .as_deref()
2002 .map(|m| (format!("extract '{}'", e.name), m))
2003 })
2004 .chain(
2005 self.models
2006 .extraction
2007 .as_deref()
2008 .map(|m| ("models.extraction".into(), m)),
2009 )
2010 {
2011 if name.trim().is_empty() {
2012 errors.push(format!("{place}: model is empty"));
2013 }
2014 }
2015
2016 let (decision_errors, decision_warnings) = self.decision_problems();
2018 errors.extend(decision_errors);
2019 warnings.extend(decision_warnings);
2020
2021 let (code_errors, code_warnings) = self.code_problems();
2023 errors.extend(code_errors);
2024 warnings.extend(code_warnings);
2025
2026 SpecValidation {
2027 valid: errors.is_empty(),
2028 errors,
2029 warnings,
2030 mermaid,
2031 tools: referenced.into_iter().chain(skill_tools).collect(),
2032 steps: steps + skill_steps,
2033 }
2034 }
2035
2036 pub fn build_dispatcher(&self, state: &State) -> ToolDispatcher {
2038 self.build_dispatcher_with(state, &SpecResources::default())
2039 }
2040
2041 pub fn build_dispatcher_with(
2045 &self,
2046 state: &State,
2047 resources: &SpecResources,
2048 ) -> ToolDispatcher {
2049 let mut dispatcher = ToolDispatcher::new();
2050 for tool in self.bound_tools(state, resources) {
2051 dispatcher.register(tool);
2052 }
2053 dispatcher
2054 }
2055
2056 fn bound_tools(&self, state: &State, resources: &SpecResources) -> Vec<Arc<SimpleTool>> {
2059 let mut servers: BTreeMap<String, Arc<gemini_adk_rs::tools::mcp::McpSessionManager>> =
2060 BTreeMap::new();
2061 let mut tools = Vec::new();
2062 for tool in &self.tools {
2063 let call = match (resources.tools.get(&tool.name), &tool.mcp) {
2064 (Some(implementation), _) => ToolCall::Code(implementation.clone()),
2065 (None, Some(params)) => ToolCall::Mcp(
2066 servers
2067 .entry(params.clone())
2068 .or_insert_with(|| {
2069 Arc::new(gemini_adk_rs::tools::mcp::McpSessionManager::new(
2070 crate::live::connect::parse_mcp_params(params),
2071 ))
2072 })
2073 .clone(),
2074 ),
2075 (None, None) => ToolCall::Declared,
2076 };
2077 tools.push(Arc::new(build_tool(tool, state, call)));
2078 }
2079 tools
2080 }
2081
2082 pub(crate) fn apply_tool_state(&self, name: &str, state: &State) {
2085 if let Some(tool) = self.tools.iter().find(|t| t.name == name) {
2086 for (key, value) in &tool.set_state {
2087 let _ = state.set(key, value.clone());
2088 }
2089 }
2090 }
2091
2092 pub fn run_tests(&self) -> Vec<TestReport> {
2096 let mut reports = simulate::run_tests(self);
2097 for skill in &self.skills {
2098 reports.extend(skill.run_tests().into_iter().map(|mut report| {
2099 report.name = format!("{}/{}", skill.name, report.name);
2100 report
2101 }));
2102 }
2103 reports
2104 }
2105
2106 pub fn sandboxed(&self, allow: &BindingAllowlist) -> (SessionSpec, Vec<String>) {
2115 let mut spec = self.clone();
2116 let mut notes = Vec::new();
2117 for skill in &mut spec.skills {
2118 let (sandboxed, disabled) = skill.sandboxed(allow);
2119 *skill = sandboxed;
2120 notes.extend(disabled);
2121 }
2122 spec.mcp.retain(|params| {
2123 let keep = allow.allows_mcp(params);
2124 if !keep {
2125 notes.push(format!(
2126 "mcp entry `{params}` is not allowed on this server; removed"
2127 ));
2128 }
2129 keep
2130 });
2131 for tool in &mut spec.tools {
2132 if let Some(params) = &tool.mcp
2133 && !allow.allows_mcp(params)
2134 {
2135 notes.push(format!(
2136 "tool `{}`: MCP binding `{params}` is not allowed on this server; it runs as a mock",
2137 tool.name
2138 ));
2139 tool.mcp = None;
2140 }
2141 let Some(binding) = &mut tool.http else {
2142 continue;
2143 };
2144 if allow.allows_http(&binding.url) {
2145 binding.reach = Some(allow.clone());
2148 } else {
2149 notes.push(format!(
2150 "tool `{}`: HTTP binding to `{}` is not allowed on this server; it runs as a mock",
2151 tool.name, binding.url
2152 ));
2153 tool.http = None;
2154 }
2155 }
2156 (spec, notes)
2157 }
2158
2159 pub fn apply(
2170 &self,
2171 live: Live,
2172 state: &State,
2173 resources: &SpecResources,
2174 ) -> Result<Live, String> {
2175 let validation = self.validate();
2176 if !validation.valid {
2177 return Err(format!(
2178 "spec failed validation: {}",
2179 validation.errors.join("; ")
2180 ));
2181 }
2182 if self.requires_extraction()
2183 && self.uses_default_extraction_model()
2184 && resources.extraction_llm.is_none()
2185 {
2186 return Err(
2187 "spec declares extraction that names no model, and SpecResources.extraction_llm \
2188 is not set; set models.extraction"
2189 .into(),
2190 );
2191 }
2192 if self.requires_memory() && resources.memory.is_none() {
2193 return Err("spec declares memory but SpecResources.memory is not set".into());
2194 }
2195 if !self.decisions.is_empty() && resources.decision_model.is_none() {
2196 return Err(
2197 "spec declares decisions but SpecResources.decision_model is not set \
2198 (for Jev on Vercel AI Gateway: GatewayDecisionModel::from_env(), which reads \
2199 AI_GATEWAY_API_KEY)"
2200 .into(),
2201 );
2202 }
2203 if let Some(name) = resources.tools.keys().find(|name| {
2204 !self.tools.iter().any(|t| &&t.name == name)
2205 && !self.skills.iter().any(|skill| {
2206 skill.tools.iter().any(|tool| {
2207 *name == &tool.tool.name
2208 || **name == format!("{}/{}", skill.name, tool.tool.name)
2209 })
2210 })
2211 }) {
2212 return Err(format!(
2213 "SpecResources implements tool '{name}', which the spec does not declare"
2214 ));
2215 }
2216
2217 self.seed_state_defaults(state);
2219
2220 let mut live = live
2221 .state(state.clone())
2222 .instruction(if self.instruction.is_empty() {
2223 "Follow the conversation flow you are given.".to_string()
2224 } else {
2225 self.instruction.clone()
2226 });
2227
2228 if let Some(greeting) = &self.greeting {
2229 live = live.greeting(greeting.clone());
2230 }
2231 live = match self.modality {
2232 SpecModality::Text => live.text_only(),
2233 SpecModality::Audio => live.voice(resolve_voice(self.voice.as_deref())),
2234 };
2235
2236 if !self.skills.is_empty() {
2237 let runtime = self.compile_tasks(resources)?;
2238 live = live.tasks(runtime);
2239 if self.skills.iter().any(|skill| skill.memory.is_some())
2240 && let Some(binding) = &resources.memory
2241 {
2242 live = binding.install_task_lifecycle(live);
2243 }
2244 }
2245
2246 let tools = self.bound_tools(state, resources);
2249 if !tools.is_empty() {
2250 let mut dispatcher = ToolDispatcher::new();
2251 for tool in &tools {
2252 dispatcher.register(tool.clone());
2253 }
2254 live = live.dispatcher(dispatcher);
2255 }
2256 for params in &self.mcp {
2257 live = live.tools(T::mcp(params.clone()));
2258 }
2259
2260 if let Some(conversation) = &self.conversation {
2263 let compiled = crate::conversation::Conversation::from_spec_with_resolvers(
2264 conversation.clone(),
2265 &self.resolver_registry(&tools),
2266 )
2267 .map_err(|e| format!("conversation: {e}"))?;
2268 live = live.converse(&compiled);
2269 } else {
2270 let flow = self.effective_flow().map_err(|e| e.join("; "))?;
2271 if !flow.steps.is_empty() {
2272 live = live.govern(flow);
2273 }
2274 }
2275
2276 for c in &self.computed {
2279 let expr = c.from.clone();
2280 let deps: Vec<String> = expr.keys_read().into_iter().collect();
2281 let dep_refs: Vec<&str> = deps.iter().map(String::as_str).collect();
2282 live = live.computed(c.key.clone(), &dep_refs, move |s| expr.eval(s));
2283 }
2284
2285 let memory_binding = resources.memory.clone();
2287 if let (Some(memory), Some(binding)) = (&self.memory, &memory_binding) {
2288 live = binding.install(live, memory);
2289 }
2290
2291 for tool in &self.tools {
2293 match (tool.background, tool.scheduling) {
2294 (_, Some(scheduling)) => {
2295 live = live
2296 .tool_background_with_scheduling(tool.name.clone(), scheduling.to_wire());
2297 }
2298 (true, None) => {
2299 live = live.tool_background(tool.name.clone());
2300 }
2301 (false, None) => {}
2302 }
2303 }
2304
2305 if let Some(runtime) = &self.runtime {
2307 live = apply_runtime(live, runtime);
2308 }
2309
2310 let mut models = BTreeMap::new();
2312 for e in &self.extract {
2313 let llm =
2314 extraction_llm_for(e, self.models.extraction.as_deref(), resources, &mut models)?;
2315 live = live.extractor(Arc::new(compile_extractor(e, llm, &self.models)));
2316 }
2317 if let Some(model) = &resources.decision_model
2318 && !self.decisions.is_empty()
2319 {
2320 live = live.decisions(self.compile_decisions(model.clone()));
2321 }
2322 if let Some(code) = self.compile_code_mode(resources.code_sandbox.clone()) {
2323 live = live.code_mode(code);
2324 }
2325
2326 for p in &self.phases {
2328 let mut builder = live.phase(p.name.clone());
2329 if let Some(instruction) = &p.instruction {
2330 builder = builder.instruction(instruction.clone());
2331 }
2332 if !p.tools.is_empty() {
2333 builder = builder.tools(p.tools.clone());
2334 }
2335 if !p.needs.is_empty() {
2336 let refs: Vec<&str> = p.needs.iter().map(String::as_str).collect();
2337 builder = builder.needs(&refs);
2338 }
2339 if p.prompt_on_enter == Some(true) {
2340 builder = builder.prompt_on_enter();
2341 }
2342 if p.terminal {
2343 builder = builder.terminal();
2344 }
2345 for t in &p.transitions {
2346 let guard = t.when.clone();
2347 let predicate = move |s: &State| guard.eval_state(s);
2348 builder = match &t.description {
2349 Some(desc) => builder.transition_with(&t.to, predicate, desc.clone()),
2350 None => builder.transition(&t.to, predicate),
2351 };
2352 }
2353 if !p.on_enter.is_empty() {
2354 let effects = p.on_enter.clone();
2355 let memory = memory_binding.clone();
2356 builder = builder.on_enter(move |state, writer| {
2357 let effects = effects.clone();
2358 let memory = memory.clone();
2359 async move {
2360 run_effects(&effects, &state, &writer, memory.as_ref()).await;
2361 }
2362 });
2363 }
2364 live = builder.done();
2365 }
2366 if let Some(initial) = &self.initial_phase {
2367 live = live.initial_phase(initial.clone());
2368 }
2369
2370 for pattern in &self.patterns {
2372 let guard = pattern.when.clone();
2373 let condition = move |s: &State| guard.eval_state(s);
2374 let effects = pattern.effects.clone();
2375 let memory = memory_binding.clone();
2376 let action = move |state: State, writer: Arc<dyn SessionWriter>| {
2377 let effects = effects.clone();
2378 let memory = memory.clone();
2379 async move {
2380 run_effects(&effects, &state, &writer, memory.as_ref()).await;
2381 }
2382 };
2383 if let Some(secs) = pattern.sustained_secs {
2384 live = live.when_sustained(
2385 pattern.name.clone(),
2386 condition,
2387 std::time::Duration::from_secs(secs),
2388 action,
2389 );
2390 } else if let Some(turns) = pattern.turns {
2391 live = live.when_turns(pattern.name.clone(), condition, turns, action);
2392 }
2393 }
2394
2395 for w in &self.watch {
2397 let builder = live.watch(w.key.clone());
2398 let builder = match &w.condition {
2399 WatchCondition::Changed => builder.changed(),
2400 WatchCondition::ChangedTo(v) => builder.changed_to(v.clone()),
2401 WatchCondition::CrossedAbove(t) => builder.crossed_above(*t),
2402 WatchCondition::CrossedBelow(t) => builder.crossed_below(*t),
2403 WatchCondition::BecameTrue => builder.became_true(),
2404 WatchCondition::BecameFalse => builder.became_false(),
2405 };
2406 let sets = w.set.clone();
2407 let effects = w.effects.clone();
2408 let memory = memory_binding.clone();
2409 live = builder.then_with_writer(move |_old, _new, state, writer| {
2410 let sets = sets.clone();
2411 let effects = effects.clone();
2412 let memory = memory.clone();
2413 async move {
2414 for (key, value) in &sets {
2415 let _ = state.set(key, value.clone());
2416 }
2417 run_effects(&effects, &state, &writer, memory.as_ref()).await;
2418 }
2419 });
2420 }
2421
2422 Ok(live)
2423 }
2424}
2425
2426fn compile_extractor(e: &ExtractSpec, llm: Arc<dyn BaseLlm>, models: &ModelsSpec) -> LlmExtractor {
2429 let mut extractor = LlmExtractor::new(e.name.clone(), llm, e.instruction.clone(), e.window)
2430 .with_schema(e.schema.clone())
2431 .with_min_words(3)
2432 .with_trigger(e.trigger.to_trigger());
2433 if let Some(budget) = e.thinking_budget.or(models.extraction_thinking_budget) {
2434 extractor = extractor.with_thinking_budget(Some(budget));
2435 }
2436 if e.promote.is_empty() {
2437 extractor
2438 } else {
2439 extractor.with_promotions(e.promote.iter().map(PromoteSpec::to_rule).collect())
2440 }
2441}
2442
2443async fn run_effects(
2447 effects: &[EffectSpec],
2448 state: &State,
2449 writer: &Arc<dyn SessionWriter>,
2450 memory: Option<&Arc<dyn MemoryBinding>>,
2451) {
2452 for effect in effects {
2453 match effect {
2454 EffectSpec::Set(map) => {
2455 for (key, value) in map {
2456 let _ = state.set(key, value.clone());
2457 }
2458 }
2459 EffectSpec::Context(text) => {
2460 let _ = writer
2461 .send_client_content(vec![Content::model(text.clone())], false)
2462 .await;
2463 }
2464 EffectSpec::Prompt(text) => {
2465 let _ = writer
2466 .send_client_content(vec![Content::model(text.clone())], true)
2467 .await;
2468 }
2469 EffectSpec::Remember(template) => {
2470 if let Some(binding) = memory {
2471 binding.remember(interpolate(template, &Value::Null, state));
2472 }
2473 }
2474 }
2475 }
2476}
2477
2478fn apply_turn_commit(
2482 mut live: Live,
2483 eot_hold_ms: Option<u32>,
2484 min_interruption_ms: Option<u32>,
2485) -> Live {
2486 if let Some(ms) = eot_hold_ms {
2487 live = live.turn_commit_eot_hold_ms(u64::from(ms));
2488 }
2489 if let Some(ms) = min_interruption_ms {
2490 live = live.turn_commit_min_interruption_ms(u64::from(ms));
2491 }
2492 live
2493}
2494
2495fn apply_runtime(mut live: Live, runtime: &RuntimeSpec) -> Live {
2497 if let Some(t) = runtime.temperature {
2498 live = live.temperature(t);
2499 }
2500 if let Some(budget) = runtime.thinking_budget {
2501 live = live.thinking(budget);
2502 }
2503 if runtime.include_thoughts == Some(true) {
2504 live = live.include_thoughts();
2505 }
2506 if let Some(t) = runtime.transcription {
2507 if t.input {
2508 live = live.input_transcription();
2509 }
2510 if t.output {
2511 live = live.output_transcription();
2512 }
2513 }
2514 if runtime.proactive_audio == Some(true) {
2515 live = live.proactive_audio();
2516 }
2517 if let Some(vad) = &runtime.vad {
2518 live = live.vad(AutomaticActivityDetection {
2519 disabled: None,
2520 start_of_speech_sensitivity: vad.start_sensitivity.map(SensitivitySpec::to_wire),
2521 end_of_speech_sensitivity: vad.end_sensitivity.map(SensitivitySpec::to_wire),
2522 prefix_padding_ms: vad.prefix_padding_ms,
2523 silence_duration_ms: vad.silence_duration_ms,
2524 });
2525 }
2526 if let Some(audio) = &runtime.audio {
2527 #[cfg(feature = "denoise")]
2528 if audio.denoise == Some(true) {
2529 live = live.mic_denoise();
2530 }
2531 if let Some(gate) = &audio.noise_gate {
2532 live = live.mic_noise_gate(gate.threshold_rms, gate.hold_frames);
2533 }
2534 if let Some(vad) = &audio.client_vad {
2535 live = live.input_vad(vad.to_config());
2536 }
2537 if audio.authority == Some(AuthoritySpec::Client) {
2538 live = live.client_interruption_authority();
2539 }
2540 live = apply_turn_commit(live, audio.eot_hold_ms, audio.min_interruption_ms);
2543 }
2544 if let Some(ms) = runtime.soft_turn_timeout_ms {
2545 live = live.soft_turn_timeout(std::time::Duration::from_millis(ms));
2546 }
2547 if let Some(steering) = runtime.steering {
2548 live = live.steering_mode(match steering {
2549 SteeringSpec::InstructionUpdate => SteeringMode::InstructionUpdate,
2550 SteeringSpec::ContextInjection => SteeringMode::ContextInjection,
2551 SteeringSpec::Hybrid => SteeringMode::Hybrid,
2552 SteeringSpec::ContextUpdate => SteeringMode::ContextUpdate,
2553 });
2554 }
2555 if let Some(delivery) = runtime.context_delivery {
2556 live = live.context_delivery(match delivery {
2557 ContextDeliverySpec::Immediate => ContextDelivery::Immediate,
2558 ContextDeliverySpec::Deferred => ContextDelivery::Deferred,
2559 });
2560 }
2561 if let Some(repair) = runtime.repair {
2562 live = live.repair(RepairConfig {
2563 nudge_after: repair.nudge_after,
2564 escalate_after: repair.escalate_after,
2565 });
2566 }
2567 if let Some(persistence) = &runtime.persistence {
2568 live = match persistence {
2569 PersistenceSpec::Fs { dir } => {
2570 live.persistence(Arc::new(gemini_adk_rs::live::FsPersistence::new(dir)))
2571 }
2572 PersistenceSpec::Memory => {
2573 live.persistence(Arc::new(gemini_adk_rs::live::MemoryPersistence::new()))
2574 }
2575 };
2576 }
2577 if let Some(id) = &runtime.session_id {
2578 live = live.session_id(id.clone());
2579 }
2580 if runtime.lossy_audio {
2581 live = live.lossy_audio();
2582 }
2583 if runtime.lossy_transcript {
2584 live = live.lossy_transcript();
2585 }
2586 live
2587}
2588
2589enum ToolCall {
2591 Declared,
2593 Code(Arc<dyn ToolFunction>),
2595 Mcp(Arc<gemini_adk_rs::tools::mcp::McpSessionManager>),
2597}
2598
2599fn mcp_value(result: Value) -> Value {
2602 if let Some(structured) = result.get("structuredContent") {
2603 return structured.clone();
2604 }
2605 let texts: Vec<&str> = result["content"]
2606 .as_array()
2607 .into_iter()
2608 .flatten()
2609 .filter_map(|part| part["text"].as_str())
2610 .collect();
2611 match texts.as_slice() {
2612 [text] => serde_json::from_str(text).unwrap_or_else(|_| json!({ "output": text })),
2613 [] => result,
2614 many => json!({ "output": many.join("\n") }),
2615 }
2616}
2617
2618fn build_tool(tool: &ToolSpec, state: &State, call: ToolCall) -> SimpleTool {
2621 let description = if tool.description.is_empty() {
2622 format!("Tool '{}'", tool.name)
2623 } else {
2624 tool.description.clone()
2625 };
2626 let response = tool.response.clone().unwrap_or_else(|| json!({"ok": true}));
2627 let sets = tool.set_state.clone();
2628 let save_as = tool.save_response_as.clone();
2629 let http = tool.http.clone();
2630 let st = state.clone();
2631 let name = tool.name.clone();
2632 let call = Arc::new(call);
2633 SimpleTool::new(
2634 &tool.name,
2635 description,
2636 tool.parameters.clone(),
2637 move |args| {
2638 let response = response.clone();
2639 let sets = sets.clone();
2640 let save_as = save_as.clone();
2641 let http = http.clone();
2642 let st = st.clone();
2643 let name = name.clone();
2644 let call = call.clone();
2645 async move {
2646 let result = match (call.as_ref(), &http) {
2647 (ToolCall::Code(implementation), _) => implementation.call(args).await?,
2648 (ToolCall::Mcp(server), _) => {
2649 mcp_value(server.call_tool(&name, args).await.map_err(|e| {
2650 gemini_adk_rs::error::ToolError::ExecutionFailed(format!("{name}: {e}"))
2651 })?)
2652 }
2653 (ToolCall::Declared, Some(binding)) => {
2654 execute_http(binding, &args, &st).await.map_err(|e| {
2655 gemini_adk_rs::error::ToolError::Other(format!("{name}: {e}"))
2656 })?
2657 }
2658 (ToolCall::Declared, None) => response,
2659 };
2660 for (key, value) in &sets {
2661 let _ = st.set(key, value.clone());
2662 }
2663 if let Some(key) = &save_as {
2664 let _ = st.set(key, result.clone());
2665 }
2666 Ok(result)
2667 }
2668 },
2669 )
2670}
2671
2672fn interpolate(template: &str, args: &Value, state: &State) -> String {
2674 let mut out = String::with_capacity(template.len());
2675 let mut rest = template;
2676 while let Some(open) = rest.find('{') {
2677 out.push_str(&rest[..open]);
2678 let after = &rest[open + 1..];
2679 let Some(close) = after.find('}') else {
2680 out.push_str(&rest[open..]);
2681 return out;
2682 };
2683 let expr = after[..close].trim();
2684 let value = if let Some(field) = expr.strip_prefix("args.") {
2685 args.get(field).cloned()
2686 } else if let Some(key) = expr.strip_prefix("state.") {
2687 state.get::<Value>(key)
2688 } else {
2689 None
2690 };
2691 match value {
2692 Some(Value::String(s)) => out.push_str(&s),
2693 Some(v) => out.push_str(&v.to_string()),
2694 None => {}
2695 }
2696 rest = &after[close + 1..];
2697 }
2698 out.push_str(rest);
2699 out
2700}
2701
2702#[cfg_attr(not(feature = "http-tools"), allow(dead_code))]
2704fn interpolate_value(value: &Value, args: &Value, state: &State) -> Value {
2705 match value {
2706 Value::String(s) => Value::String(interpolate(s, args, state)),
2707 Value::Array(items) => Value::Array(
2708 items
2709 .iter()
2710 .map(|v| interpolate_value(v, args, state))
2711 .collect(),
2712 ),
2713 Value::Object(map) => Value::Object(
2714 map.iter()
2715 .map(|(k, v)| (k.clone(), interpolate_value(v, args, state)))
2716 .collect(),
2717 ),
2718 other => other.clone(),
2719 }
2720}
2721
2722#[cfg(feature = "http-tools")]
2723async fn execute_http(binding: &HttpBinding, args: &Value, state: &State) -> Result<Value, String> {
2724 let url = interpolate(&binding.url, args, state);
2725 let mut client = reqwest::Client::builder();
2726 if let Some(reach) = binding.reach.clone() {
2727 if !reach.allows_http(&url) {
2728 return Err(format!("{url} is outside this server's HTTP allowlist"));
2729 }
2730 client = client.redirect(reqwest::redirect::Policy::custom(move |attempt| {
2731 if attempt.previous().len() >= 10 {
2732 attempt.error("too many redirects")
2733 } else if reach.allows_http(attempt.url().as_str()) {
2734 attempt.follow()
2735 } else {
2736 let error = format!(
2737 "redirect to {} is outside this server's HTTP allowlist",
2738 attempt.url()
2739 );
2740 attempt.error(error)
2741 }
2742 }));
2743 }
2744 let client = client.build().map_err(|e| e.to_string())?;
2745 let method = reqwest::Method::from_bytes(binding.method.to_uppercase().as_bytes())
2746 .map_err(|_| format!("invalid HTTP method '{}'", binding.method))?;
2747 let mut request = client.request(method, &url);
2748 for (name, value) in &binding.headers {
2749 request = request.header(name, interpolate(value, args, state));
2750 }
2751 if let Some(body) = &binding.body {
2752 request = request.json(&interpolate_value(body, args, state));
2753 }
2754 let response = request.send().await.map_err(|e| e.to_string())?;
2755 let status = response.status().as_u16();
2756 let text = response.text().await.map_err(|e| e.to_string())?;
2757 Ok(serde_json::from_str(&text).unwrap_or_else(|_| json!({ "status": status, "body": text })))
2758}
2759
2760#[cfg(not(feature = "http-tools"))]
2761#[allow(
2762 clippy::unused_async,
2763 reason = "same signature as the http-tools implementation so the call site is feature-agnostic"
2764)]
2765async fn execute_http(
2766 _binding: &HttpBinding,
2767 _args: &Value,
2768 _state: &State,
2769) -> Result<Value, String> {
2770 Err("http tool bindings require the `http-tools` feature".to_string())
2771}
2772
2773fn splice_fragment(
2775 flow: &mut Flow,
2776 fragment: &Flow,
2777 directive: &UseFragment,
2778 errors: &mut Vec<String>,
2779) {
2780 let ns = &directive.namespace;
2781 let prefix = |id: &str| format!("{ns}/{id}");
2782 let internal: std::collections::BTreeSet<&str> =
2783 fragment.steps.iter().map(|s| s.id.as_str()).collect();
2784
2785 for step in &fragment.steps {
2786 let new_id = prefix(&step.id);
2787 if flow.steps.iter().any(|s| s.id == new_id) {
2788 errors.push(format!(
2789 "fragment splice '{ns}' collides with existing step '{new_id}'"
2790 ));
2791 continue;
2792 }
2793 let mut after: Vec<gemini_adk_rs::flow::Edge> = step
2794 .after
2795 .iter()
2796 .map(|d| gemini_adk_rs::flow::Edge {
2797 step: if internal.contains(d.step.as_str()) {
2798 prefix(&d.step)
2799 } else {
2800 d.step.clone()
2801 },
2802 when: d
2803 .when
2804 .clone()
2805 .map(|g| rewrite_guard_steps(g, &internal, ns)),
2806 })
2807 .collect();
2808 if step.after.is_empty() {
2809 after.extend(
2810 directive
2811 .after
2812 .iter()
2813 .cloned()
2814 .map(gemini_adk_rs::flow::Edge::to),
2815 );
2816 }
2817 flow.steps.push(Step {
2818 id: new_id,
2819 after,
2820 join: step.join,
2821 gate: step
2822 .gate
2823 .clone()
2824 .map(|g| rewrite_guard_steps(g, &internal, ns)),
2825 done: step
2826 .done
2827 .clone()
2828 .map(|g| rewrite_guard_steps(g, &internal, ns)),
2829 posture: step.posture.clone(),
2830 ground: step.ground.clone(),
2831 allow: step.allow.clone(),
2832 deny: step.deny.clone(),
2833 terminal: step.terminal,
2834 });
2835 }
2836 for constraint in &fragment.constraints {
2837 flow.constraints.push(match constraint {
2838 Constraint::Once(t) => Constraint::Once(t.clone()),
2839 Constraint::Before(a, b) => Constraint::Before(
2840 if internal.contains(a.as_str()) {
2841 prefix(a)
2842 } else {
2843 a.clone()
2844 },
2845 if internal.contains(b.as_str()) {
2846 prefix(b)
2847 } else {
2848 b.clone()
2849 },
2850 ),
2851 Constraint::NeverUntil { tool, until } => Constraint::NeverUntil {
2852 tool: tool.clone(),
2853 until: rewrite_guard_steps(until.clone(), &internal, ns),
2854 },
2855 Constraint::Require(rs) => Constraint::Require(
2856 rs.iter()
2857 .map(|r| {
2858 if internal.contains(r.as_str()) {
2859 prefix(r)
2860 } else {
2861 r.clone()
2862 }
2863 })
2864 .collect(),
2865 ),
2866 Constraint::Reset { steps, when } => Constraint::Reset {
2867 steps: steps
2868 .iter()
2869 .map(|r| {
2870 if internal.contains(r.as_str()) {
2871 prefix(r)
2872 } else {
2873 r.clone()
2874 }
2875 })
2876 .collect(),
2877 when: rewrite_guard_steps(when.clone(), &internal, ns),
2878 },
2879 });
2880 }
2881 for tool in &fragment.ambient {
2882 if !flow.ambient.contains(tool) {
2883 flow.ambient.push(tool.clone());
2884 }
2885 }
2886 for tool in &fragment.confirm_tools {
2887 if !flow.confirm_tools.contains(tool) {
2888 flow.confirm_tools.push(tool.clone());
2889 }
2890 }
2891}
2892
2893fn rewrite_guard_steps(
2895 guard: Guard,
2896 internal: &std::collections::BTreeSet<&str>,
2897 ns: &str,
2898) -> Guard {
2899 fn rewrite(pred: Pred, internal: &std::collections::BTreeSet<&str>, ns: &str) -> Pred {
2900 match pred {
2901 Pred::Done(s) if internal.contains(s.as_str()) => Pred::Done(format!("{ns}/{s}")),
2902 Pred::All(ps) => Pred::All(ps.into_iter().map(|p| rewrite(p, internal, ns)).collect()),
2903 Pred::Any(ps) => Pred::Any(ps.into_iter().map(|p| rewrite(p, internal, ns)).collect()),
2904 Pred::Not(p) => Pred::Not(Box::new(rewrite(*p, internal, ns))),
2905 other => other,
2906 }
2907 }
2908 match guard {
2909 Guard::Spec(p) => Guard::Spec(rewrite(p, internal, ns)),
2910 custom => custom,
2911 }
2912}
2913
2914fn guard_uses_marking(guard: &Guard) -> bool {
2917 fn walk(pred: &Pred) -> bool {
2918 match pred {
2919 Pred::CalledOk(_) | Pred::Done(_) => true,
2920 Pred::All(ps) | Pred::Any(ps) => ps.iter().any(walk),
2921 Pred::Not(p) => walk(p),
2922 _ => false,
2923 }
2924 }
2925 match guard {
2926 Guard::Spec(p) => walk(p),
2927 Guard::Custom(_) => false,
2928 }
2929}
2930
2931fn resolve_voice(name: Option<&str>) -> Voice {
2933 match name {
2934 Some("Aoede") => Voice::Aoede,
2935 Some("Charon") => Voice::Charon,
2936 Some("Fenrir") => Voice::Fenrir,
2937 Some("Kore") => Voice::Kore,
2938 Some("Puck") | None => Voice::Puck,
2939 Some(other) => Voice::Custom(other.to_string()),
2940 }
2941}
2942
2943fn levenshtein(a: &str, b: &str) -> usize {
2945 let a: Vec<char> = a.chars().collect();
2946 let b: Vec<char> = b.chars().collect();
2947 let mut prev: Vec<usize> = (0..=b.len()).collect();
2948 let mut current = vec![0; b.len() + 1];
2949 for (i, ca) in a.iter().enumerate() {
2950 current[0] = i + 1;
2951 for (j, cb) in b.iter().enumerate() {
2952 let cost = usize::from(ca != cb);
2953 current[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(current[j] + 1);
2954 }
2955 std::mem::swap(&mut prev, &mut current);
2956 }
2957 prev[b.len()]
2958}
2959
2960#[cfg(test)]
2961mod tests {
2962
2963 #[test]
2964 fn audio_spec_lowers_and_validates() {
2965 let spec: SessionSpec = serde_json::from_str(
2966 r#"{
2967 "name": "noisy",
2968 "instruction": "hi",
2969 "runtime": {
2970 "audio": {
2971 "denoise": true,
2972 "noise_gate": { "threshold_rms": 700.0 },
2973 "client_vad": { "preset": "noisy_street", "hangover_frames": 12 },
2974 "authority": "client"
2975 }
2976 }
2977 }"#,
2978 )
2979 .unwrap();
2980 let audio = spec.runtime.as_ref().unwrap().audio.as_ref().unwrap();
2981 assert_eq!(audio.noise_gate.as_ref().unwrap().hold_frames, 3); let config = audio.client_vad.as_ref().unwrap().to_config();
2983 assert_eq!(config.start_threshold_db, 21.0); assert_eq!(config.hangover_frames, 12); assert_eq!(audio.authority, Some(AuthoritySpec::Client));
2986 let json = serde_json::to_value(&spec).unwrap();
2988 let back: SessionSpec = serde_json::from_value(json).unwrap();
2989 assert_eq!(
2990 back.runtime
2991 .unwrap()
2992 .audio
2993 .unwrap()
2994 .client_vad
2995 .unwrap()
2996 .hangover_frames,
2997 Some(12)
2998 );
2999 }
3000
3001 #[test]
3002 fn audio_spec_warns_on_risky_combinations() {
3003 let spec: SessionSpec = serde_json::from_str(
3004 r#"{
3005 "name": "risky",
3006 "instruction": "hi",
3007 "runtime": { "audio": { "authority": "client", "noise_gate": {} } }
3008 }"#,
3009 )
3010 .unwrap();
3011 let validation = spec.validate();
3012 assert!(
3013 validation
3014 .warnings
3015 .iter()
3016 .any(|w| w.contains("authority=client without denoise")),
3017 "expected client-authority warning, got {:?}",
3018 validation.warnings
3019 );
3020 assert!(
3021 validation
3022 .warnings
3023 .iter()
3024 .any(|w| w.contains("noise_gate without denoise")),
3025 "expected gate warning, got {:?}",
3026 validation.warnings
3027 );
3028 }
3029
3030 #[test]
3031 fn turn_commit_tuning_knobs_serialize_and_round_trip() {
3032 let spec: SessionSpec = serde_json::from_str(
3033 r#"{
3034 "name": "tune",
3035 "instruction": "hi",
3036 "runtime": {
3037 "audio": {
3038 "eot_hold_ms": 800,
3039 "min_interruption_ms": 1400
3040 }
3041 }
3042 }"#,
3043 )
3044 .unwrap();
3045 let audio = spec.runtime.as_ref().unwrap().audio.as_ref().unwrap();
3046 assert_eq!(audio.eot_hold_ms, Some(800));
3047 assert_eq!(audio.min_interruption_ms, Some(1400));
3048 let json = serde_json::to_value(&spec).unwrap();
3050 let back: SessionSpec = serde_json::from_value(json).unwrap();
3051 let audio_back = back.runtime.unwrap().audio.unwrap();
3052 assert_eq!(audio_back.eot_hold_ms, Some(800));
3053 assert_eq!(audio_back.min_interruption_ms, Some(1400));
3054 }
3055
3056 #[test]
3057 fn turn_commit_tuning_knobs_validate_thresholds() {
3058 let spec: SessionSpec = serde_json::from_str(
3060 r#"{
3061 "name": "frontier",
3062 "instruction": "hi",
3063 "runtime": { "audio": { "eot_hold_ms": 1700 } }
3064 }"#,
3065 )
3066 .unwrap();
3067 let validation = spec.validate();
3068 assert!(
3069 validation
3070 .warnings
3071 .iter()
3072 .any(|w| w.contains("eot_hold_ms") && w.contains("1600") && w.contains("frontier")),
3073 "expected eot_hold_ms frontier warning, got {:?}",
3074 validation.warnings
3075 );
3076
3077 let spec2: SessionSpec = serde_json::from_str(
3079 r#"{
3080 "name": "window",
3081 "instruction": "hi",
3082 "runtime": { "audio": { "min_interruption_ms": 2100 } }
3083 }"#,
3084 )
3085 .unwrap();
3086 let validation2 = spec2.validate();
3087 assert!(
3088 validation2
3089 .warnings
3090 .iter()
3091 .any(|w| w.contains("min_interruption_ms")
3092 && w.contains("2000")
3093 && w.contains("window")),
3094 "expected min_interruption_ms window warning, got {:?}",
3095 validation2.warnings
3096 );
3097 }
3098 use super::*;
3099
3100 fn collections_spec() -> SessionSpec {
3101 SessionSpec::from_value(json!({
3102 "name": "collections",
3103 "instruction": "Collect payments.",
3104 "tools": [
3105 {"name": "verify_identity", "set_state": {"identity_verified": true}},
3106 {"name": "charge_card", "response": {"charged": true}}
3107 ],
3108 "extract": [{
3109 "name": "ptp",
3110 "instruction": "Extract the promise to pay.",
3111 "schema": {"type": "object", "properties": {
3112 "ptp_amount": {"type": "number"}, "ptp_date": {"type": "string"}}},
3113 "promote": [
3114 {"field": "ptp_amount"},
3115 {"field": "ptp_date", "policy": "overwrite"}
3116 ]
3117 }],
3118 "flow": {
3119 "steps": [
3120 {"id": "verify", "posture": "Verify the caller.",
3121 "allow": ["verify_identity"],
3122 "done": {"is_true": "identity_verified"}},
3123 {"id": "pay", "after": ["verify"], "posture": "Take payment.",
3124 "allow": ["charge_card"],
3125 "gate": {"captured": ["ptp_amount", "ptp_date"]},
3126 "done": {"called_ok": "charge_card"}}
3127 ]
3128 }
3129 }))
3130 .expect("spec parses")
3131 }
3132
3133 #[test]
3134 fn extraction_promotions_satisfy_guard_reads() {
3135 let v = collections_spec().validate();
3136 assert!(v.valid, "errors: {:?}", v.errors);
3137 assert!(
3138 v.warnings.iter().all(|w| !w.contains("can never latch")),
3139 "promoted keys cover the guard reads: {:?}",
3140 v.warnings
3141 );
3142 }
3143
3144 #[test]
3145 fn unwritten_guard_key_warns_with_suggestion() {
3146 let mut spec = collections_spec();
3147 spec.flow.as_mut().unwrap().steps[0].done = Some(Guard::is_true("identity_verifed"));
3149 let v = spec.validate();
3150 assert!(v.valid);
3151 let warning = v
3152 .warnings
3153 .iter()
3154 .find(|w| w.contains("identity_verifed"))
3155 .expect("warns about the unwritten key");
3156 assert!(
3157 warning.contains("identity_verified"),
3158 "suggests the fix: {warning}"
3159 );
3160 }
3161
3162 #[test]
3163 fn phase_guard_rejects_marking_atoms() {
3164 let spec = SessionSpec::from_value(json!({
3165 "instruction": "x",
3166 "phases": [{"name": "a", "transitions": [
3167 {"to": "b", "when": {"called_ok": "some_tool"}}]},
3168 {"name": "b"}],
3169 "initial_phase": "a"
3170 }))
3171 .expect("parses");
3172 let v = spec.validate();
3173 assert!(!v.valid);
3174 assert!(v.errors.iter().any(|e| e.contains("called_ok")));
3175 }
3176
3177 #[test]
3178 fn fragments_splice_with_namespacing() {
3179 let spec = SessionSpec::from_value(json!({
3180 "instruction": "x",
3181 "tools": [
3182 {"name": "check_id", "set_state": {"id_ok": true}},
3183 {"name": "book", "response": {}}
3184 ],
3185 "fragments": {
3186 "verify": {"steps": [
3187 {"id": "ask", "posture": "Ask for ID.", "allow": ["check_id"],
3188 "done": {"is_true": "id_ok"}},
3189 {"id": "confirm", "after": ["ask"], "terminal": true,
3190 "gate": {"done": "ask"}}
3191 ]}
3192 },
3193 "use_fragments": [{"fragment": "verify", "namespace": "v"}],
3194 "flow": {"steps": [
3195 {"id": "book_step", "after": ["v/confirm"], "allow": ["book"],
3196 "done": {"called_ok": "book"}}
3197 ]}
3198 }))
3199 .expect("parses");
3200 let flow = spec.effective_flow().expect("splices");
3201 let ids: Vec<&str> = flow.steps.iter().map(|s| s.id.as_str()).collect();
3202 assert!(ids.contains(&"v/ask") && ids.contains(&"v/confirm"));
3203 let confirm = flow.steps.iter().find(|s| s.id == "v/confirm").unwrap();
3205 assert_eq!(
3206 serde_json::to_value(confirm.gate.as_ref().unwrap()).unwrap(),
3207 json!({"done": "v/ask"})
3208 );
3209 let v = spec.validate();
3210 assert!(v.valid, "errors: {:?}", v.errors);
3211 }
3212
3213 #[test]
3214 fn bare_flow_round_trips() {
3215 let spec = SessionSpec::from_value(json!({
3216 "steps": [{"id": "only", "terminal": true}]
3217 }))
3218 .expect("parses");
3219 assert!(spec.validate().valid);
3220 }
3221
3222 #[test]
3223 fn empty_fragment_namespace_is_rejected() {
3224 let spec = SessionSpec::from_value(json!({
3225 "instruction": "x",
3226 "fragments": {
3227 "verify": {"steps": [
3228 {"id": "ask", "terminal": true}
3229 ]}
3230 },
3231 "use_fragments": [{"fragment": "verify", "namespace": ""}],
3232 "flow": {"steps": [
3233 {"id": "start", "terminal": true}
3234 ]}
3235 }))
3236 .expect("parses");
3237 let v = spec.validate();
3238 assert!(
3240 !v.valid,
3241 "empty namespace should fail validation, got errors: {:?}",
3242 v.errors
3243 );
3244 assert!(
3245 v.errors.iter().any(|e| e.contains("namespace")),
3246 "should mention namespace issue: {:?}",
3247 v.errors
3248 );
3249 }
3250
3251 #[test]
3252 fn duplicate_computed_keys_are_rejected() {
3253 let spec = SessionSpec::from_value(json!({
3254 "instruction": "x",
3255 "flow": {"steps": [{"id": "only", "terminal": true}]},
3256 "computed": [
3257 {"key": "risk", "from": {"key": "score"}},
3258 {"key": "risk", "from": {"key": "other_score"}}
3259 ]
3260 }))
3261 .expect("parses");
3262 let v = spec.validate();
3263 assert!(!v.valid, "duplicate computed keys should fail validation");
3264 assert!(
3265 v.errors.iter().any(|e| e.contains("duplicate")),
3266 "should mention duplicate computed key: {:?}",
3267 v.errors
3268 );
3269 }
3270
3271 #[test]
3272 fn duplicate_phase_names_are_rejected() {
3273 let spec = SessionSpec::from_value(json!({
3274 "instruction": "x",
3275 "phases": [
3276 {"name": "greet", "instruction": "Welcome."},
3277 {"name": "greet", "instruction": "Hi again."}
3278 ],
3279 "initial_phase": "greet"
3280 }))
3281 .expect("parses");
3282 let v = spec.validate();
3283 assert!(!v.valid, "duplicate phase names should fail validation");
3284 assert!(
3285 v.errors.iter().any(|e| e.contains("phase")),
3286 "should mention duplicate phase: {:?}",
3287 v.errors
3288 );
3289 }
3290
3291 #[test]
3292 fn tool_background_false_round_trips() {
3293 let spec = SessionSpec::from_value(json!({
3295 "instruction": "x",
3296 "tools": [
3297 {"name": "search", "background": false},
3298 {"name": "log", "background": true}
3299 ],
3300 "flow": {"steps": [{"id": "only", "terminal": true}]}
3301 }))
3302 .expect("parses");
3303
3304 let serialized = serde_json::to_value(&spec).unwrap();
3306 let tools = serialized["tools"].as_array().unwrap();
3307
3308 assert!(
3311 tools[0].get("background").is_none(),
3312 "background: false is optimized away"
3313 );
3314
3315 assert_eq!(tools[1].get("background"), Some(&json!(true)));
3317
3318 let back: SessionSpec = serde_json::from_value(serialized).unwrap();
3320 assert!(!back.tools[0].background);
3321 assert!(back.tools[1].background);
3322 }
3323
3324 #[test]
3325 fn promotion_spec_with_no_to_field_uses_field_name() {
3326 let spec = SessionSpec::from_value(json!({
3328 "instruction": "x",
3329 "tools": [{"name": "extract", "set_state": {"test": true}}],
3330 "extract": [{
3331 "name": "data",
3332 "instruction": "Extract.",
3333 "schema": {"type": "object"},
3334 "promote": [
3335 {"field": "amount"},
3336 {"field": "date", "to": "extracted_date"}
3337 ]
3338 }],
3339 "flow": {"steps": [{"id": "only", "terminal": true}]}
3340 }))
3341 .expect("parses");
3342
3343 let promote = &spec.extract[0].promote;
3344 assert_eq!(promote[0].target(), "amount");
3345 assert_eq!(promote[1].target(), "extracted_date");
3346 }
3347
3348 #[test]
3349 fn spec_schema_publishes() {
3350 let schema = SessionSpec::json_schema().to_string();
3351 for token in [
3352 "is_true",
3353 "never_until",
3354 "set_state",
3355 "use_fragments",
3356 "promote",
3357 ] {
3358 assert!(schema.contains(token), "schema missing {token}");
3359 }
3360 }
3361
3362 #[test]
3363 fn interpolation_reads_args_and_state() {
3364 let state = State::new();
3365 let _ = state.set("city", "Paris");
3366 let args = json!({"guests": 4});
3367 assert_eq!(
3368 interpolate("book/{state.city}/{args.guests}/{missing}", &args, &state),
3369 "book/Paris/4/"
3370 );
3371 }
3372
3373 #[tokio::test]
3374 async fn declared_tools_write_state() {
3375 let spec = collections_spec();
3376 let state = State::new();
3377 let dispatcher = spec.build_dispatcher(&state);
3378 let out = dispatcher
3379 .call_function("verify_identity", json!({}))
3380 .await
3381 .expect("tool runs");
3382 assert_eq!(out, json!({"ok": true}));
3383 assert_eq!(state.get::<bool>("identity_verified"), Some(true));
3384 }
3385
3386 #[test]
3387 fn computed_cycles_are_load_time_errors() {
3388 let spec = SessionSpec::from_value(json!({
3389 "instruction": "x",
3390 "flow": {"steps": [{"id": "only", "terminal": true}]},
3391 "computed": [
3392 {"key": "a", "from": {"add": [{"key": "b"}, {"const": 1}]}},
3393 {"key": "b", "from": {"add": [{"key": "derived:a"}, {"const": 1}]}}
3394 ]
3395 }))
3396 .expect("parses");
3397 let v = spec.validate();
3398 assert!(!v.valid);
3399 assert!(v.errors.iter().any(|e| e.contains("dependency cycle")));
3400
3401 let self_read = SessionSpec::from_value(json!({
3402 "instruction": "x",
3403 "flow": {"steps": [{"id": "only", "terminal": true}]},
3404 "computed": [{"key": "a", "from": {"key": "a"}}]
3405 }))
3406 .expect("parses");
3407 assert!(
3408 self_read
3409 .validate()
3410 .errors
3411 .iter()
3412 .any(|e| e.contains("reads its own key"))
3413 );
3414 }
3415
3416 #[test]
3417 fn computed_keys_satisfy_guard_reads_and_deps_are_checked() {
3418 let spec = SessionSpec::from_value(json!({
3419 "instruction": "x",
3420 "tools": [{"name": "record", "set_state": {"score": 0.8}}],
3421 "computed": [{"key": "high_risk",
3422 "from": {"gt": [{"key": "score"}, {"const": 0.5}]}}],
3423 "flow": {"steps": [
3424 {"id": "assess", "posture": "Assess.", "allow": ["record"],
3425 "done": {"is_true": "high_risk"}}
3426 ]}
3427 }))
3428 .expect("parses");
3429 let v = spec.validate();
3430 assert!(v.valid, "errors: {:?}", v.errors);
3431 assert!(
3432 v.warnings.iter().all(|w| !w.contains("can never latch")),
3433 "computed key covers the guard read: {:?}",
3434 v.warnings
3435 );
3436
3437 let mut dangling = spec.clone();
3438 dangling.computed[0].from =
3439 serde_json::from_value(json!({"gt": [{"key": "scoer"}, {"const": 0.5}]})).unwrap();
3440 let v = dangling.validate();
3441 assert!(
3442 v.warnings
3443 .iter()
3444 .any(|w| w.contains("computed 'high_risk' reads state key 'scoer'"))
3445 );
3446 }
3447
3448 #[test]
3449 fn remember_requires_the_memory_section() {
3450 let spec = SessionSpec::from_value(json!({
3451 "instruction": "x",
3452 "flow": {"steps": [{"id": "only", "terminal": true}]},
3453 "patterns": [{"name": "note", "when": {"is_true": "flag"}, "turns": 2,
3454 "effects": [{"remember": "caller likes {state.thing}"}]}]
3455 }))
3456 .expect("parses");
3457 let v = spec.validate();
3458 assert!(!v.valid);
3459 assert!(v.errors.iter().any(|e| e.contains("no `memory` section")));
3460 }
3461
3462 #[test]
3463 fn memory_slots_join_written_keys_and_reject_derived_targets() {
3464 let spec = SessionSpec::from_value(json!({
3465 "instruction": "x",
3466 "memory": {"slots": [{"predicate": "dietary_identity", "to": "user:diet"}]},
3467 "flow": {"steps": [
3468 {"id": "plan", "posture": "Plan dinner.",
3469 "done": {"is_set": "user:diet"}}
3470 ]}
3471 }))
3472 .expect("parses");
3473 let v = spec.validate();
3474 assert!(v.valid, "errors: {:?}", v.errors);
3475 assert!(v.warnings.iter().all(|w| !w.contains("can never latch")));
3476
3477 let mut bad = spec.clone();
3478 bad.memory.as_mut().unwrap().slots[0].to = "derived:diet".into();
3479 assert!(
3480 bad.validate()
3481 .errors
3482 .iter()
3483 .any(|e| e.contains("read-only key"))
3484 );
3485 }
3486
3487 #[test]
3488 fn memory_section_requires_a_binding_at_apply() {
3489 let spec = SessionSpec::from_value(json!({
3490 "instruction": "x",
3491 "memory": {},
3492 "flow": {"steps": [{"id": "only", "terminal": true}]}
3493 }))
3494 .expect("parses");
3495 let err = spec
3496 .apply(Live::builder(), &State::new(), &SpecResources::default())
3497 .err()
3498 .expect("memory binding required");
3499 assert!(err.contains("SpecResources.memory"));
3500
3501 struct NullBinding;
3502 impl MemoryBinding for NullBinding {
3503 fn install(&self, live: Live, _memory: &MemorySpec) -> Live {
3504 live
3505 }
3506 fn remember(&self, _note: String) {}
3507 }
3508 let resources = SpecResources {
3509 memory: Some(Arc::new(NullBinding)),
3510 ..Default::default()
3511 };
3512 assert!(
3513 spec.apply(Live::builder(), &State::new(), &resources)
3514 .is_ok()
3515 );
3516 }
3517
3518 #[test]
3519 fn state_dictionary_seeds_defaults_and_flags_undeclared_writes() {
3520 let spec = SessionSpec::from_value(json!({
3521 "instruction": "x",
3522 "state": {
3523 "attempts": {"type": "number", "default": 0,
3524 "description": "Verification attempts so far."},
3525 "verified": {"type": "boolean", "default": "yes"}
3526 },
3527 "tools": [{"name": "verify", "set_state": {"verified": true, "vip": true}}],
3528 "flow": {"steps": [
3529 {"id": "v", "posture": "Verify.", "allow": ["verify"],
3530 "done": {"is_true": "verified"}}
3531 ]}
3532 }))
3533 .expect("parses");
3534 let v = spec.validate();
3535 assert!(v.valid, "errors: {:?}", v.errors);
3536 assert!(
3537 v.warnings
3538 .iter()
3539 .any(|w| w.contains("'verified' declares type Boolean")),
3540 "type-mismatched default warns: {:?}",
3541 v.warnings
3542 );
3543 assert!(
3544 v.warnings
3545 .iter()
3546 .any(|w| w.contains("'vip' is written but not declared")),
3547 "undeclared write warns: {:?}",
3548 v.warnings
3549 );
3550
3551 let state = State::new();
3552 spec.seed_state_defaults(&state);
3553 assert_eq!(state.get::<i64>("attempts"), Some(0));
3554 }
3555
3556 #[test]
3557 fn runtime_section_lowers_onto_the_builder() {
3558 let spec = SessionSpec::from_value(json!({
3559 "instruction": "x",
3560 "flow": {"steps": [{"id": "only", "terminal": true}]},
3561 "runtime": {
3562 "temperature": 0.4,
3563 "thinking_budget": 1024,
3564 "include_thoughts": true,
3565 "transcription": {"input": true, "output": false},
3566 "proactive_audio": true,
3567 "vad": {"start_sensitivity": "high", "silence_duration_ms": 400},
3568 "soft_turn_timeout_ms": 1500,
3569 "steering": "context_injection",
3570 "context_delivery": "deferred",
3571 "repair": {"nudge_after": 2, "escalate_after": 5},
3572 "persistence": "memory",
3573 "session_id": "user-1",
3574 "lossy_audio": true
3575 }
3576 }))
3577 .expect("parses");
3578 let v = spec.validate();
3579 assert!(v.valid, "errors: {:?}", v.errors);
3580 assert!(
3581 spec.apply(Live::builder(), &State::new(), &SpecResources::default())
3582 .is_ok()
3583 );
3584
3585 let mut incoherent = spec.clone();
3586 incoherent.runtime.as_mut().unwrap().thinking_budget = None;
3587 assert!(
3588 incoherent
3589 .validate()
3590 .warnings
3591 .iter()
3592 .any(|w| w.contains("include_thoughts"))
3593 );
3594 }
3595
3596 #[test]
3597 fn context_update_steering_lowers_onto_the_builder() {
3598 let spec = SessionSpec::from_value(json!({
3599 "instruction": "x",
3600 "flow": {"steps": [{"id": "only", "terminal": true}]},
3601 "runtime": {"steering": "context_update"}
3602 }))
3603 .expect("parses");
3604 assert!(spec.validate().valid);
3605 let live = spec
3606 .apply(Live::builder(), &State::new(), &SpecResources::default())
3607 .expect("applies");
3608 assert_eq!(
3609 live.describe_contract().controls.steering_mode,
3610 "ContextUpdate"
3611 );
3612 assert!(
3613 spec.to_rust()
3614 .contains(".steering_mode(SteeringMode::ContextUpdate)"),
3615 "codegen emits the same mode"
3616 );
3617 }
3618
3619 #[test]
3620 fn background_tools_and_scheduling_parse_and_apply() {
3621 let spec = SessionSpec::from_value(json!({
3622 "instruction": "x",
3623 "tools": [
3624 {"name": "search_kb", "background": true},
3625 {"name": "log_event", "scheduling": "silent"}
3626 ],
3627 "flow": {"steps": [
3628 {"id": "s", "posture": "Serve.", "allow": ["search_kb", "log_event"],
3629 "done": {"called_ok": "search_kb"}}
3630 ]}
3631 }))
3632 .expect("parses");
3633 assert!(spec.validate().valid);
3634 assert!(
3635 spec.apply(Live::builder(), &State::new(), &SpecResources::default())
3636 .is_ok()
3637 );
3638 }
3639
3640 #[test]
3641 fn a_sandboxed_spec_reaches_only_what_the_operator_allows() {
3642 let spec = SessionSpec::from_value(json!({
3643 "name": "posted",
3644 "mcp": ["rm -rf /", "https://mcp.example.com/sse"],
3645 "tools": [
3646 { "name": "ok", "http": { "url": "https://api.example.com/v1/{args.id}" } },
3647 { "name": "internal", "http": { "url": "http://169.254.169.254/latest/meta-data" } },
3648 { "name": "host_from_args", "http": { "url": "https://{args.host}/x" } },
3649 { "name": "mock", "response": { "ok": true } }
3650 ]
3651 }))
3652 .unwrap();
3653
3654 let (nothing, notes) = spec.sandboxed(&BindingAllowlist::default());
3655 assert!(nothing.mcp.is_empty());
3656 assert!(nothing.tools.iter().all(|t| t.http.is_none()));
3657 assert_eq!(notes.len(), 5, "{notes:?}");
3658
3659 let allow = BindingAllowlist::default()
3660 .allow_http_prefix("https://api.example.com")
3661 .allow_mcp("https://mcp.example.com/sse");
3662 let (some, notes) = spec.sandboxed(&allow);
3663 assert_eq!(some.mcp, ["https://mcp.example.com/sse"]);
3664 let bound: Vec<&str> = some
3665 .tools
3666 .iter()
3667 .filter(|t| t.http.is_some())
3668 .map(|t| t.name.as_str())
3669 .collect();
3670 assert_eq!(bound, ["ok"]);
3671 assert_eq!(notes.len(), 3, "{notes:?}");
3672
3673 let tricky = BindingAllowlist::default().allow_http_prefix("https://api.example.com");
3675 assert!(!tricky.allows_http("https://api.example.com.evil.net/x"));
3676 assert!(!tricky.allows_http("https://api.example.com@evil.net/x"));
3677 assert!(!tricky.allows_http("https://api.example.com:8443/x"));
3678 assert!(!tricky.allows_http("http://api.example.com/x"));
3679
3680 let subtree = BindingAllowlist::default().allow_http_prefix("https://api.example.com/v2/");
3682 assert!(subtree.allows_http("https://API.example.com/v2/orders/{args.id}"));
3683 assert!(!subtree.allows_http("https://api.example.com/v2/../admin"));
3684 assert!(!subtree.allows_http("https://api.example.com/v2/%2e%2e/admin"));
3685 assert!(!subtree.allows_http("https://api.example.com/v2/%2E%2E/admin"));
3686 }
3687
3688 #[cfg(feature = "http-tools")]
3689 #[tokio::test]
3690 async fn a_sandboxed_binding_is_checked_again_when_it_runs() {
3691 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3692
3693 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3696 let origin = format!("http://{}", listener.local_addr().unwrap());
3697 tokio::spawn(async move {
3698 while let Ok((mut socket, _)) = listener.accept().await {
3699 let mut buf = vec![0u8; 4096];
3700 let n = socket.read(&mut buf).await.unwrap_or(0);
3701 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3702 let path = request.split_whitespace().nth(1).unwrap_or("/").to_string();
3703 let response = if path == "/v2/hop" {
3704 "HTTP/1.1 302 Found\r\nlocation: /admin\r\ncontent-length: 0\r\nconnection: close\r\n\r\n".to_string()
3705 } else {
3706 let body = json!({ "path": path }).to_string();
3707 format!(
3708 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
3709 body.len()
3710 )
3711 };
3712 let _ = socket.write_all(response.as_bytes()).await;
3713 }
3714 });
3715
3716 let spec = SessionSpec::from_value(json!({
3717 "name": "posted",
3718 "tools": [{ "name": "get", "http": { "url": format!("{origin}/v2/{{args.path}}") } }]
3719 }))
3720 .unwrap();
3721 let allow = BindingAllowlist::default().allow_http_prefix(format!("{origin}/v2/"));
3722 let (sandboxed, notes) = spec.sandboxed(&allow);
3723 assert!(notes.is_empty(), "{notes:?}");
3724 let state = State::new();
3725 let dispatcher = sandboxed.build_dispatcher(&state);
3726
3727 let ok = dispatcher
3728 .call_function("get", json!({ "path": "orders" }))
3729 .await
3730 .unwrap();
3731 assert_eq!(ok["path"], "/v2/orders");
3732 let climbed = dispatcher
3734 .call_function("get", json!({ "path": "../admin" }))
3735 .await;
3736 assert!(climbed.is_err(), "{climbed:?}");
3737 let redirected = dispatcher
3739 .call_function("get", json!({ "path": "hop" }))
3740 .await;
3741 assert!(redirected.is_err(), "{redirected:?}");
3742
3743 let trusted = spec.build_dispatcher(&state);
3745 let followed = trusted
3746 .call_function("get", json!({ "path": "hop" }))
3747 .await
3748 .unwrap();
3749 assert_eq!(followed["path"], "/admin");
3750 }
3751
3752 fn booking_bundle() -> SessionSpec {
3755 let conversation: Value =
3756 serde_json::from_str(include_str!("../../../../conversations/booking.spec.json"))
3757 .unwrap();
3758 let scenario = |file: &str| -> Value { serde_json::from_str(file).unwrap() };
3759 SessionSpec::from_value(json!({
3760 "name": "booking",
3761 "modality": "audio",
3762 "tools": [{
3763 "name": "book",
3764 "description": "Book the table",
3765 "response": { "confirmation": "B-1" }
3766 }],
3767 "conversation": conversation,
3768 "scenarios": [
3769 scenario(include_str!("../../../../conversations/booking.happy.scenario.json")),
3770 scenario(include_str!(
3771 "../../../../conversations/booking.no_book_without_confirm.scenario.json"
3772 )),
3773 ]
3774 }))
3775 .unwrap()
3776 }
3777
3778 #[test]
3779 fn tools_used_only_in_a_digression_are_referenced() {
3780 let mut spec = booking_bundle();
3781 spec.tools
3782 .push(serde_json::from_value(json!({ "name": "transfer" })).unwrap());
3783 let unreferenced = |spec: &SessionSpec| {
3784 spec.validate()
3785 .warnings
3786 .iter()
3787 .any(|w| w.contains("'transfer' is declared but no step"))
3788 };
3789 assert!(unreferenced(&spec));
3790 spec.conversation.as_mut().unwrap().overlays = serde_json::from_value(json!([{
3791 "name": "handoff",
3792 "trigger": { "is_true": "intent:human_agent" },
3793 "stages": [{ "id": "transfer", "allow": ["transfer"], "done": { "called_ok": "transfer" } }],
3794 "resume": "terminate"
3795 }]))
3796 .unwrap();
3797 assert!(!unreferenced(&spec));
3798 }
3799
3800 #[test]
3801 fn a_conversation_spec_validates_like_a_flow() {
3802 let spec = booking_bundle();
3803 let report = spec.validate();
3804 assert!(report.valid, "{:?}", report.errors);
3805 let unwritten: Vec<&String> = report
3808 .warnings
3809 .iter()
3810 .filter(|w| w.contains("no tool, extractor"))
3811 .collect();
3812 assert_eq!(unwritten.len(), 1, "{:?}", report.warnings);
3813 assert!(unwritten[0].contains("user_confirmed"));
3814 assert!(report.mermaid.contains("confirm"), "{}", report.mermaid);
3815 assert!(report.tools.contains(&"book".to_string()));
3816
3817 let mut both = spec.clone();
3819 both.flow = Some(Flow::default());
3820 assert!(!both.validate().valid);
3821
3822 let mut resolving = spec;
3824 resolving.conversation.as_mut().unwrap().stages[0]
3825 .resolve
3826 .push(crate::conversation::ResolveSpec {
3827 slot: "availability".into(),
3828 resolver: None,
3829 args: vec![],
3830 ttl_secs: None,
3831 });
3832 let report = resolving.validate();
3833 assert!(
3834 report.errors.iter().any(|e| e.contains("availability")),
3835 "{:?}",
3836 report.errors
3837 );
3838 }
3839
3840 #[tokio::test]
3841 async fn a_conversation_spec_runs_its_scenarios_and_applies() {
3842 let spec = booking_bundle();
3843 let reports = spec.run_scenarios().await;
3844 assert_eq!(reports.len(), 2);
3845 assert!(reports.iter().all(|r| r.passed), "{reports:?}");
3846
3847 let mut broken = spec.clone();
3848 broken.scenarios[0]
3849 .steps
3850 .push(crate::simulation::SimStep::ExpectActive(vec![
3851 "nowhere".into(),
3852 ]));
3853 assert!(!broken.run_scenarios().await[0].passed);
3854
3855 spec.apply(Live::builder(), &State::new(), &SpecResources::default())
3856 .expect("a conversation spec applies");
3857 }
3858
3859 fn booking_tool(extra: Value) -> SessionSpec {
3860 let mut tool = json!({
3861 "name": "book",
3862 "description": "Book the table",
3863 "response": { "confirmation": "MOCK" },
3864 "set_state": { "booked": true },
3865 "save_response_as": "booking"
3866 });
3867 tool.as_object_mut()
3868 .unwrap()
3869 .extend(extra.as_object().unwrap().clone());
3870 SessionSpec::from_value(json!({
3871 "name": "t",
3872 "tools": [tool],
3873 "flow": { "steps": [{ "id": "s", "allow": ["book"] }] }
3874 }))
3875 .unwrap()
3876 }
3877
3878 #[tokio::test]
3879 async fn an_implementation_replaces_the_call_not_the_declaration() {
3880 let spec = booking_tool(json!({}));
3881 let state = State::new();
3882 let resources = SpecResources::default().implement(gemini_adk_rs::tool::SimpleTool::new(
3883 "book",
3884 "real booking",
3885 None,
3886 |_| async { Ok(json!({ "confirmation": "REAL-7" })) },
3887 ));
3888 let dispatcher = spec.build_dispatcher_with(&state, &resources);
3889 let out = dispatcher.call_function("book", json!({})).await.unwrap();
3890 assert_eq!(out["confirmation"], "REAL-7");
3891 assert_eq!(state.get::<bool>("booked"), Some(true));
3893 assert_eq!(
3894 state.get::<Value>("booking").unwrap()["confirmation"],
3895 "REAL-7"
3896 );
3897 assert_eq!(
3899 dispatcher.to_tool_declarations()[0]
3900 .function_declarations
3901 .as_ref()
3902 .unwrap()[0]
3903 .description,
3904 "Book the table"
3905 );
3906
3907 let stray = SpecResources::default().implement(gemini_adk_rs::tool::SimpleTool::new(
3909 "refund",
3910 "",
3911 None,
3912 |_| async { Ok(json!({})) },
3913 ));
3914 assert!(spec.apply(Live::builder(), &state, &stray).is_err());
3915 }
3916
3917 #[cfg(unix)]
3918 #[tokio::test]
3919 async fn an_mcp_binding_calls_the_tool_on_that_server() {
3920 let dir = std::env::temp_dir().join(format!("mcp-bind-{}", std::process::id()));
3922 std::fs::create_dir_all(&dir).unwrap();
3923 let script = dir.join("server.sh");
3924 std::fs::write(
3925 &script,
3926 r#"while IFS= read -r line; do
3927 id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p')
3928 [ -z "$id" ] && continue
3929 case "$line" in
3930 *'"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"t","version":"1"}}}\n' "$id" ;;
3931 *'"tools/call"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"{\\"confirmation\\":\\"MCP-1\\"}"}]}}\n' "$id" ;;
3932 *) printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;;
3933 esac
3934done
3935"#,
3936 )
3937 .unwrap();
3938 let spec = booking_tool(json!({ "mcp": format!("sh {}", script.display()) }));
3939 let state = State::new();
3940 let dispatcher = spec.build_dispatcher_with(&state, &SpecResources::default());
3941 let out = dispatcher
3942 .call_function("book", json!({ "party": 2 }))
3943 .await
3944 .unwrap();
3945 assert_eq!(out, json!({ "confirmation": "MCP-1" }));
3946 assert_eq!(state.get::<bool>("booked"), Some(true));
3947
3948 let both = booking_tool(json!({
3950 "mcp": "x",
3951 "http": { "url": "https://api.example.com/book" }
3952 }));
3953 assert!(!both.validate().valid);
3954
3955 let (sandboxed, notes) = spec.sandboxed(&BindingAllowlist::default());
3957 assert!(sandboxed.tools[0].mcp.is_none());
3958 assert_eq!(notes.len(), 1, "{notes:?}");
3959 let _ = std::fs::remove_dir_all(dir);
3960 }
3961
3962 #[test]
3963 fn mcp_results_become_plain_values() {
3964 assert_eq!(
3965 mcp_value(json!({ "structuredContent": { "a": 1 }, "content": [] })),
3966 json!({ "a": 1 })
3967 );
3968 assert_eq!(
3969 mcp_value(json!({ "content": [{ "type": "text", "text": "{\"a\":2}" }] })),
3970 json!({ "a": 2 })
3971 );
3972 assert_eq!(
3973 mcp_value(json!({ "content": [{ "type": "text", "text": "done" }] })),
3974 json!({ "output": "done" })
3975 );
3976 }
3977
3978 #[test]
3979 fn extraction_runs_on_the_model_the_spec_names() {
3980 use gemini_adk_rs::llm::{LlmRegistry, MockLlm};
3981 let mut spec = collections_spec();
3982 let mut second = spec.extract[0].clone();
3983 second.name = "second".into();
3984 spec.extract.push(second);
3985 spec.models.extraction = Some("fast-lite".into());
3986 spec.extract[0].model = Some("fast-full".into());
3987 assert!(!spec.uses_default_extraction_model());
3988 assert!(
3989 spec.validate()
3990 .warnings
3991 .iter()
3992 .all(|w| !w.contains("models.extraction")),
3993 "{:?}",
3994 spec.validate().warnings
3995 );
3996
3997 let mut registry = LlmRegistry::new();
3998 registry.register("fast", |name| {
3999 Arc::new(MockLlm::text("{}").with_model_id(name)) as Arc<dyn BaseLlm>
4000 });
4001 let resources = SpecResources {
4002 models: Some(Arc::new(registry)),
4003 ..SpecResources::default()
4004 };
4005 let mut cache = BTreeMap::new();
4006 let ids: Vec<String> = spec
4007 .extract
4008 .iter()
4009 .map(|e| {
4010 extraction_llm_for(e, spec.models.extraction.as_deref(), &resources, &mut cache)
4011 .unwrap()
4012 .model_id()
4013 .to_string()
4014 })
4015 .collect();
4016 assert_eq!(ids[0], "fast-full", "an entry's model wins");
4017 assert!(ids[1..].iter().all(|id| id == "fast-lite"), "{ids:?}");
4018 assert_eq!(cache.len(), 2, "one model per name");
4019
4020 spec.apply(Live::builder(), &State::new(), &resources)
4022 .expect("applies on the named models");
4023 }
4024
4025 #[tokio::test]
4026 async fn an_entry_or_the_spec_sets_the_thinking_budget() {
4027 use gemini_adk_rs::live::TurnExtractor;
4028 use gemini_adk_rs::llm::{LlmResponse, MockLlm};
4029 let budgets = Arc::new(parking_lot::Mutex::new(Vec::new()));
4030 let seen = budgets.clone();
4031 let llm: Arc<dyn BaseLlm> = Arc::new(MockLlm::from_fn(move |req| {
4032 seen.lock().push(req.thinking_budget);
4033 Ok(LlmResponse::from_text("{}"))
4034 }));
4035 let window = [gemini_adk_rs::live::TranscriptTurn {
4036 turn_number: 0,
4037 user: "I need a refill of my lisinopril.".into(),
4038 model: String::new(),
4039 tool_calls: Vec::new(),
4040 timestamp: std::time::Instant::now(),
4041 }];
4042 let entry = collections_spec().extract[0].clone();
4043 let models = ModelsSpec {
4044 extraction: None,
4045 extraction_thinking_budget: Some(64),
4046 };
4047 let mut own = entry.clone();
4048 own.thinking_budget = Some(8);
4049 for (e, m) in [
4050 (&entry, &models),
4051 (&own, &models),
4052 (&entry, &ModelsSpec::default()),
4053 ] {
4054 compile_extractor(e, llm.clone(), m)
4055 .extract(&window)
4056 .await
4057 .unwrap();
4058 }
4059 assert_eq!(*budgets.lock(), [Some(64), Some(8), Some(0)]);
4060 }
4061
4062 #[test]
4063 fn unnamed_extraction_warns_and_an_unknown_model_fails() {
4064 let unnamed = collections_spec();
4065 assert!(unnamed.uses_default_extraction_model());
4066 assert!(
4067 unnamed
4068 .validate()
4069 .warnings
4070 .iter()
4071 .any(|w| w.contains("set models.extraction")),
4072 "{:?}",
4073 unnamed.validate().warnings
4074 );
4075
4076 let mut unknown = collections_spec();
4077 unknown.models.extraction = Some("gemma-decide".into());
4078 let err = unknown
4079 .apply(Live::builder(), &State::new(), &SpecResources::default())
4080 .err()
4081 .expect("nothing resolves gemma-decide");
4082 assert!(err.contains("register it in SpecResources.models"), "{err}");
4083
4084 let mut empty = collections_spec();
4085 empty.models.extraction = Some(" ".into());
4086 assert!(!empty.validate().valid);
4087 }
4088
4089 #[test]
4090 fn apply_configures_a_builder() {
4091 let spec = collections_spec();
4092 let state = State::new();
4093 let err = spec
4095 .apply(Live::builder(), &state, &SpecResources::default())
4096 .err()
4097 .expect("requires extraction llm");
4098 assert!(err.contains("extraction_llm"));
4099
4100 let mut no_extract = spec.clone();
4102 no_extract.extract.clear();
4103 assert!(
4104 no_extract
4105 .apply(Live::builder(), &state, &SpecResources::default())
4106 .is_ok()
4107 );
4108 }
4109}