1mod codegen;
24mod simulate;
25
26pub use simulate::{
27 SimEvent, SimSnapshot, SpecTest, TestExpectation, TestReport, TestStepResult, trace_test,
28};
29
30use std::collections::BTreeMap;
31use std::sync::Arc;
32
33use serde::{Deserialize, Serialize};
34use serde_json::{Value, json};
35
36use gemini_adk_rs::expr::Expr;
37use gemini_adk_rs::flow::{Constraint, Flow, Guard, Pred, Step};
38use gemini_adk_rs::live::extractor::{ExtractionTrigger, FieldPromotion, LlmExtractor};
39use gemini_adk_rs::live::{ContextDelivery, RepairConfig, SteeringMode};
40use gemini_adk_rs::llm::BaseLlm;
41use gemini_adk_rs::state::State;
42use gemini_adk_rs::tool::{SimpleTool, ToolDispatcher};
43use gemini_genai_rs::prelude::{
44 AutomaticActivityDetection, Content, FunctionResponseScheduling, Sensitivity, SessionWriter,
45 Voice,
46};
47
48use crate::compose::tools::T;
49use crate::live::Live;
50
51#[derive(
53 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
54)]
55#[serde(rename_all = "lowercase")]
56pub enum SpecModality {
57 #[default]
59 Text,
60 Audio,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
71pub struct HttpBinding {
72 #[serde(default = "default_method")]
74 pub method: String,
75 pub url: String,
77 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
79 pub headers: BTreeMap<String, String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub body: Option<Value>,
83}
84
85fn default_method() -> String {
86 "GET".to_string()
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
96pub struct ToolSpec {
97 pub name: String,
99 #[serde(default)]
101 pub description: String,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub parameters: Option<Value>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub response: Option<Value>,
108 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
110 pub set_state: BTreeMap<String, Value>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub save_response_as: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub http: Option<HttpBinding>,
117 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
121 pub background: bool,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub scheduling: Option<SchedulingSpec>,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
129#[serde(rename_all = "snake_case")]
130pub enum SchedulingSpec {
131 Interrupt,
133 WhenIdle,
135 Silent,
137}
138
139impl SchedulingSpec {
140 fn to_wire(self) -> FunctionResponseScheduling {
141 match self {
142 SchedulingSpec::Interrupt => FunctionResponseScheduling::Interrupt,
143 SchedulingSpec::WhenIdle => FunctionResponseScheduling::WhenIdle,
144 SchedulingSpec::Silent => FunctionResponseScheduling::Silent,
145 }
146 }
147}
148
149#[derive(
151 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
152)]
153#[serde(rename_all = "snake_case")]
154pub enum PromotePolicy {
155 #[default]
157 KeepKnown,
158 Overwrite,
160 TrueOnly,
162 NonEmpty,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
169pub struct PromoteSpec {
170 pub field: String,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub to: Option<String>,
175 #[serde(default)]
177 pub policy: PromotePolicy,
178}
179
180impl PromoteSpec {
181 fn target(&self) -> &str {
182 self.to.as_deref().unwrap_or(&self.field)
183 }
184
185 fn to_rule(&self) -> FieldPromotion {
186 let rule = match self.policy {
187 PromotePolicy::KeepKnown => FieldPromotion::keep_known(&self.field),
188 PromotePolicy::Overwrite => FieldPromotion::overwrite(&self.field),
189 PromotePolicy::TrueOnly => FieldPromotion::true_only(&self.field),
190 PromotePolicy::NonEmpty => FieldPromotion::non_empty(&self.field),
191 };
192 match &self.to {
193 Some(key) => rule.to(key),
194 None => rule,
195 }
196 }
197}
198
199#[derive(
201 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
202)]
203#[serde(rename_all = "snake_case")]
204pub enum TriggerSpec {
205 #[default]
207 EveryTurn,
208 AfterToolCall,
210 OnGenerationComplete,
212 OnPhaseChange,
214}
215
216impl TriggerSpec {
217 fn to_trigger(self) -> ExtractionTrigger {
218 match self {
219 TriggerSpec::EveryTurn => ExtractionTrigger::EveryTurn,
220 TriggerSpec::AfterToolCall => ExtractionTrigger::AfterToolCall,
221 TriggerSpec::OnGenerationComplete => ExtractionTrigger::OnGenerationComplete,
222 TriggerSpec::OnPhaseChange => ExtractionTrigger::OnPhaseChange,
223 }
224 }
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
232pub struct ExtractSpec {
233 pub name: String,
235 pub instruction: String,
237 pub schema: Value,
239 #[serde(default = "default_window")]
241 pub window: usize,
242 #[serde(default)]
244 pub trigger: TriggerSpec,
245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
247 pub promote: Vec<PromoteSpec>,
248}
249
250fn default_window() -> usize {
251 3
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
258#[serde(rename_all = "snake_case")]
259pub enum EffectSpec {
260 Set(BTreeMap<String, Value>),
262 Context(String),
265 Prompt(String),
268 Remember(String),
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
279pub struct TransitionSpec {
280 pub to: String,
282 pub when: Guard,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub description: Option<String>,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
291pub struct PhaseSpec {
292 pub name: String,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub instruction: Option<String>,
297 #[serde(default, skip_serializing_if = "Vec::is_empty")]
299 pub tools: Vec<String>,
300 #[serde(default, skip_serializing_if = "Vec::is_empty")]
302 pub needs: Vec<String>,
303 #[serde(default, skip_serializing_if = "Vec::is_empty")]
305 pub on_enter: Vec<EffectSpec>,
306 #[serde(default, skip_serializing_if = "Vec::is_empty")]
308 pub transitions: Vec<TransitionSpec>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub prompt_on_enter: Option<bool>,
312 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
314 pub terminal: bool,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
319#[serde(rename_all = "snake_case")]
320pub enum WatchCondition {
321 Changed,
323 ChangedTo(Value),
325 CrossedAbove(f64),
327 CrossedBelow(f64),
329 BecameTrue,
331 BecameFalse,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
340pub struct WatchSpec {
341 pub key: String,
343 pub condition: WatchCondition,
345 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
347 pub set: BTreeMap<String, Value>,
348 #[serde(default, skip_serializing_if = "Vec::is_empty")]
350 pub effects: Vec<EffectSpec>,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
360pub struct PatternSpec {
361 pub name: String,
363 pub when: Guard,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub sustained_secs: Option<u64>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub turns: Option<u32>,
371 #[serde(default, skip_serializing_if = "Vec::is_empty")]
373 pub effects: Vec<EffectSpec>,
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
383pub struct ComputedSpec {
384 pub key: String,
386 pub from: Expr,
388 #[serde(default, skip_serializing_if = "String::is_empty")]
390 pub description: String,
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
395#[serde(rename_all = "snake_case")]
396pub enum StateType {
397 Boolean,
399 Number,
401 String,
403 Object,
405 Array,
407}
408
409impl StateType {
410 fn matches(self, value: &Value) -> bool {
411 matches!(
412 (self, value),
413 (StateType::Boolean, Value::Bool(_))
414 | (StateType::Number, Value::Number(_))
415 | (StateType::String, Value::String(_))
416 | (StateType::Object, Value::Object(_))
417 | (StateType::Array, Value::Array(_))
418 )
419 }
420}
421
422#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
429pub struct StateFieldSpec {
430 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
432 pub kind: Option<StateType>,
433 #[serde(default, skip_serializing_if = "String::is_empty")]
435 pub description: String,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub default: Option<Value>,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
446pub struct MemorySlotSpec {
447 pub predicate: String,
449 pub to: String,
451}
452
453#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
460pub struct MemorySpec {
461 #[serde(default, skip_serializing_if = "Vec::is_empty")]
463 pub slots: Vec<MemorySlotSpec>,
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
468#[serde(rename_all = "snake_case")]
469pub enum SensitivitySpec {
470 Low,
472 Medium,
474 High,
476}
477
478impl SensitivitySpec {
479 fn to_wire(self) -> Sensitivity {
480 match self {
481 SensitivitySpec::Low => Sensitivity::SensitivityLow,
482 SensitivitySpec::Medium => Sensitivity::SensitivityMedium,
483 SensitivitySpec::High => Sensitivity::SensitivityHigh,
484 }
485 }
486}
487
488#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
491pub struct VadSpec {
492 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub start_sensitivity: Option<SensitivitySpec>,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub end_sensitivity: Option<SensitivitySpec>,
498 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub prefix_padding_ms: Option<u32>,
501 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub silence_duration_ms: Option<u32>,
504}
505
506#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
512pub struct AudioSpec {
513 #[serde(default, skip_serializing_if = "Option::is_none")]
516 pub denoise: Option<bool>,
517 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub noise_gate: Option<NoiseGateSpec>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub client_vad: Option<ClientVadSpec>,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub authority: Option<AuthoritySpec>,
528 #[serde(default, skip_serializing_if = "Option::is_none")]
532 pub eot_hold_ms: Option<u32>,
533 #[serde(default, skip_serializing_if = "Option::is_none")]
537 pub min_interruption_ms: Option<u32>,
538}
539
540#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
542pub struct NoiseGateSpec {
543 #[serde(default = "default_gate_threshold")]
546 pub threshold_rms: f64,
547 #[serde(default = "default_gate_hold")]
549 pub hold_frames: u32,
550}
551
552fn default_gate_threshold() -> f64 {
553 700.0
554}
555fn default_gate_hold() -> u32 {
556 3
557}
558
559#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
561pub struct ClientVadSpec {
562 #[serde(default, skip_serializing_if = "Option::is_none")]
564 pub preset: Option<ClientVadPreset>,
565 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub start_threshold_db: Option<f64>,
568 #[serde(default, skip_serializing_if = "Option::is_none")]
570 pub stop_threshold_db: Option<f64>,
571 #[serde(default, skip_serializing_if = "Option::is_none")]
573 pub min_speech_frames: Option<u32>,
574 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub hangover_frames: Option<u32>,
577}
578
579#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
581#[serde(rename_all = "snake_case")]
582pub enum ClientVadPreset {
583 Default,
585 NoisyStreet,
588}
589
590impl ClientVadSpec {
591 pub fn to_config(&self) -> gemini_genai_rs::vad::VadConfig {
593 let mut config = match self.preset {
594 Some(ClientVadPreset::NoisyStreet) => gemini_genai_rs::vad::VadConfig::noisy_street(),
595 _ => gemini_genai_rs::vad::VadConfig::default(),
596 };
597 if let Some(v) = self.start_threshold_db {
598 config.start_threshold_db = v;
599 }
600 if let Some(v) = self.stop_threshold_db {
601 config.stop_threshold_db = v;
602 }
603 if let Some(v) = self.min_speech_frames {
604 config.min_speech_frames = v;
605 }
606 if let Some(v) = self.hangover_frames {
607 config.hangover_frames = v;
608 }
609 config
610 }
611}
612
613#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
616#[serde(rename_all = "snake_case")]
617pub enum AuthoritySpec {
618 Server,
620 Client,
623}
624
625#[derive(Debug, Clone, Copy, Serialize, Deserialize, schemars::JsonSchema)]
627pub struct TranscriptionSpec {
628 #[serde(default = "default_true")]
630 pub input: bool,
631 #[serde(default = "default_true")]
633 pub output: bool,
634}
635
636fn default_true() -> bool {
637 true
638}
639
640#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
642#[serde(rename_all = "snake_case")]
643pub enum SteeringSpec {
644 InstructionUpdate,
646 ContextInjection,
648 Hybrid,
650}
651
652#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
654#[serde(rename_all = "snake_case")]
655pub enum ContextDeliverySpec {
656 Immediate,
658 Deferred,
660}
661
662#[derive(Debug, Clone, Copy, Serialize, Deserialize, schemars::JsonSchema)]
664pub struct RepairSpec {
665 pub nudge_after: u32,
667 pub escalate_after: u32,
669}
670
671#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
673#[serde(rename_all = "snake_case")]
674pub enum PersistenceSpec {
675 Fs {
677 dir: String,
679 },
680 Memory,
682}
683
684#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
689pub struct RuntimeSpec {
690 #[serde(default, skip_serializing_if = "Option::is_none")]
692 pub temperature: Option<f32>,
693 #[serde(default, skip_serializing_if = "Option::is_none")]
695 pub thinking_budget: Option<u32>,
696 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub include_thoughts: Option<bool>,
699 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub transcription: Option<TranscriptionSpec>,
702 #[serde(default, skip_serializing_if = "Option::is_none")]
704 pub proactive_audio: Option<bool>,
705 #[serde(default, skip_serializing_if = "Option::is_none")]
707 pub vad: Option<VadSpec>,
708 #[serde(default, skip_serializing_if = "Option::is_none")]
710 pub audio: Option<AudioSpec>,
711 #[serde(default, skip_serializing_if = "Option::is_none")]
713 pub soft_turn_timeout_ms: Option<u64>,
714 #[serde(default, skip_serializing_if = "Option::is_none")]
716 pub steering: Option<SteeringSpec>,
717 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub context_delivery: Option<ContextDeliverySpec>,
720 #[serde(default, skip_serializing_if = "Option::is_none")]
722 pub repair: Option<RepairSpec>,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
725 pub persistence: Option<PersistenceSpec>,
726 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub session_id: Option<String>,
729 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
731 pub lossy_audio: bool,
732 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
734 pub lossy_transcript: bool,
735}
736
737#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
745pub struct UseFragment {
746 pub fragment: String,
748 pub namespace: String,
750 #[serde(default, skip_serializing_if = "Vec::is_empty")]
752 pub after: Vec<String>,
753}
754
755#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
757pub struct SessionSpec {
758 #[serde(default)]
760 pub name: String,
761 #[serde(default, skip_serializing_if = "String::is_empty")]
763 pub version: String,
764 #[serde(default)]
766 pub description: String,
767 #[serde(default)]
769 pub instruction: String,
770 #[serde(default, skip_serializing_if = "Option::is_none")]
772 pub greeting: Option<String>,
773 #[serde(default)]
775 pub modality: SpecModality,
776 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub voice: Option<String>,
779 #[serde(default, skip_serializing_if = "Vec::is_empty")]
781 pub tools: Vec<ToolSpec>,
782 #[serde(default, skip_serializing_if = "Vec::is_empty")]
785 pub mcp: Vec<String>,
786 #[serde(default, skip_serializing_if = "Vec::is_empty")]
788 pub extract: Vec<ExtractSpec>,
789 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
791 pub state: BTreeMap<String, StateFieldSpec>,
792 #[serde(default, skip_serializing_if = "Vec::is_empty")]
794 pub computed: Vec<ComputedSpec>,
795 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub memory: Option<MemorySpec>,
800 #[serde(default, skip_serializing_if = "Option::is_none")]
802 pub runtime: Option<RuntimeSpec>,
803 #[serde(default, skip_serializing_if = "Vec::is_empty")]
805 pub phases: Vec<PhaseSpec>,
806 #[serde(default, skip_serializing_if = "Option::is_none")]
808 pub initial_phase: Option<String>,
809 #[serde(default, skip_serializing_if = "Vec::is_empty")]
811 pub watch: Vec<WatchSpec>,
812 #[serde(default, skip_serializing_if = "Vec::is_empty")]
814 pub patterns: Vec<PatternSpec>,
815 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
817 pub fragments: BTreeMap<String, Flow>,
818 #[serde(default, skip_serializing_if = "Vec::is_empty")]
820 pub use_fragments: Vec<UseFragment>,
821 #[serde(default, skip_serializing_if = "Option::is_none")]
823 pub flow: Option<Flow>,
824 #[serde(default, skip_serializing_if = "Vec::is_empty")]
827 pub tests: Vec<SpecTest>,
828}
829
830#[derive(Debug, Clone, Serialize)]
832pub struct SpecValidation {
833 pub valid: bool,
835 pub errors: Vec<String>,
837 pub warnings: Vec<String>,
839 pub mermaid: String,
841 pub tools: Vec<String>,
843 pub steps: usize,
845}
846
847pub trait MemoryBinding: Send + Sync {
855 fn install(&self, live: Live, memory: &MemorySpec) -> Live;
857 fn remember(&self, note: String);
860}
861
862pub const MEMORY_TOOL_NAMES: [&str; 2] = ["recall_context", "manage_memory"];
864
865#[derive(Default)]
868pub struct SpecResources {
869 pub extraction_llm: Option<Arc<dyn BaseLlm>>,
871 pub memory: Option<Arc<dyn MemoryBinding>>,
874}
875
876impl SessionSpec {
877 pub fn from_value(value: Value) -> Result<Self, String> {
880 let is_bare_flow = value.get("flow").is_none() && value.get("steps").is_some();
881 if is_bare_flow {
882 let flow: Flow =
883 serde_json::from_value(value).map_err(|e| format!("invalid flow JSON: {e}"))?;
884 return Ok(Self {
885 flow: Some(flow),
886 ..Self::default()
887 });
888 }
889 serde_json::from_value(value).map_err(|e| format!("invalid session spec JSON: {e}"))
890 }
891
892 pub fn json_schema() -> Value {
895 serde_json::to_value(schemars::schema_for!(SessionSpec)).unwrap_or_else(|_| json!({}))
896 }
897
898 pub fn tool_names(&self) -> Vec<String> {
900 self.tools.iter().map(|t| t.name.clone()).collect()
901 }
902
903 pub fn effective_flow(&self) -> Result<Flow, Vec<String>> {
905 let mut flow = self.flow.clone().unwrap_or_default();
906 let mut errors = Vec::new();
907 for use_frag in &self.use_fragments {
908 match self.fragments.get(&use_frag.fragment) {
909 Some(fragment) => {
910 splice_fragment(&mut flow, fragment, use_frag, &mut errors);
911 }
912 None => errors.push(format!(
913 "use_fragments references unknown fragment '{}'",
914 use_frag.fragment
915 )),
916 }
917 }
918 if errors.is_empty() {
919 Ok(flow)
920 } else {
921 Err(errors)
922 }
923 }
924
925 pub fn state_keys_written(&self) -> std::collections::BTreeSet<String> {
929 let mut keys = std::collections::BTreeSet::new();
930 for t in &self.tools {
931 keys.extend(t.set_state.keys().cloned());
932 keys.extend(t.save_response_as.iter().cloned());
933 }
934 for e in &self.extract {
935 keys.insert(e.name.clone());
936 for p in &e.promote {
937 keys.insert(p.target().to_string());
938 }
939 }
940 for p in &self.phases {
941 for eff in &p.on_enter {
942 if let EffectSpec::Set(map) = eff {
943 keys.extend(map.keys().cloned());
944 }
945 }
946 }
947 for w in &self.watch {
948 keys.extend(w.set.keys().cloned());
949 for eff in &w.effects {
950 if let EffectSpec::Set(map) = eff {
951 keys.extend(map.keys().cloned());
952 }
953 }
954 }
955 for p in &self.patterns {
956 for eff in &p.effects {
957 if let EffectSpec::Set(map) = eff {
958 keys.extend(map.keys().cloned());
959 }
960 }
961 }
962 for c in &self.computed {
963 keys.insert(c.key.clone());
966 keys.insert(format!("derived:{}", c.key));
967 }
968 if let Some(memory) = &self.memory {
969 for slot in &memory.slots {
970 keys.extend([slot.to.clone()]);
971 }
972 }
973 for (key, field) in &self.state {
974 if field.default.is_some() {
975 keys.insert(key.clone());
976 }
977 }
978 keys
979 }
980
981 fn all_effects(&self) -> Vec<(String, &EffectSpec)> {
983 let mut out = Vec::new();
984 for p in &self.phases {
985 for eff in &p.on_enter {
986 out.push((format!("phase '{}'", p.name), eff));
987 }
988 }
989 for w in &self.watch {
990 for eff in &w.effects {
991 out.push((format!("watch '{}'", w.key), eff));
992 }
993 }
994 for p in &self.patterns {
995 for eff in &p.effects {
996 out.push((format!("pattern '{}'", p.name), eff));
997 }
998 }
999 out
1000 }
1001
1002 pub(crate) fn recompute_computed(&self, state: &State) {
1007 for _ in 0..self.computed.len() {
1008 let mut changed = false;
1009 for c in &self.computed {
1010 if let Some(value) = c.from.eval(state) {
1011 let derived = format!("derived:{}", c.key);
1012 if state.get_raw(&derived).as_ref() != Some(&value) {
1013 let _ = state.set(&derived, value);
1014 changed = true;
1015 }
1016 }
1017 }
1018 if !changed {
1019 break;
1020 }
1021 }
1022 }
1023
1024 pub(crate) fn seed_state_defaults(&self, state: &State) {
1026 for (key, field) in &self.state {
1027 if let Some(default) = &field.default
1028 && state.get_raw(key).is_none()
1029 {
1030 let _ = state.set(key, default.clone());
1031 }
1032 }
1033 }
1034
1035 pub fn validate(&self) -> SpecValidation {
1039 let mut errors = Vec::new();
1040 let mut warnings = Vec::new();
1041
1042 for use_frag in &self.use_fragments {
1044 if use_frag.namespace.is_empty() {
1045 errors.push(
1046 "use_fragments directive has empty namespace — step ids would be malformed"
1047 .into(),
1048 );
1049 }
1050 }
1051
1052 let flow = match self.effective_flow() {
1053 Ok(flow) => flow,
1054 Err(errs) => {
1055 errors.extend(errs);
1056 self.flow.clone().unwrap_or_default()
1057 }
1058 };
1059 let mermaid = flow.to_mermaid();
1060 let steps = flow.steps.len();
1061 let has_flow = !flow.steps.is_empty();
1062
1063 if !has_flow && self.phases.is_empty() {
1064 errors.push("spec has neither a flow nor phases — nothing to run".into());
1065 }
1066 if !self.phases.is_empty() && self.initial_phase.is_none() {
1067 errors.push("phases are declared but initial_phase is not set".into());
1068 }
1069 if let Some(initial) = &self.initial_phase
1070 && !self.phases.iter().any(|p| &p.name == initial)
1071 {
1072 errors.push(format!("initial_phase '{initial}' is not a declared phase"));
1073 }
1074 {
1076 let phase_names: std::collections::BTreeSet<&str> =
1077 self.phases.iter().map(|p| p.name.as_str()).collect();
1078 if phase_names.len() != self.phases.len() {
1079 let mut seen = std::collections::BTreeSet::new();
1080 for p in &self.phases {
1081 if !seen.insert(p.name.as_str()) {
1082 errors.push(format!("phase '{}' is declared more than once", p.name));
1083 }
1084 }
1085 }
1086 }
1087 for pattern in &self.patterns {
1088 match (pattern.sustained_secs, pattern.turns) {
1089 (Some(_), Some(_)) | (None, None) => errors.push(format!(
1090 "pattern '{}' must set exactly one of sustained_secs or turns",
1091 pattern.name
1092 )),
1093 _ => {}
1094 }
1095 if guard_uses_marking(&pattern.when) {
1096 errors.push(format!(
1097 "pattern '{}' uses a called_ok/done atom — pattern guards see state only",
1098 pattern.name
1099 ));
1100 }
1101 }
1102 for p in &self.phases {
1103 for t in &p.transitions {
1104 if guard_uses_marking(&t.when) {
1105 errors.push(format!(
1106 "phase '{}' transition to '{}' uses a called_ok/done atom — phase guards \
1107 see state only (no flow marking); latch a state key instead",
1108 p.name, t.to
1109 ));
1110 }
1111 }
1112 }
1113 if cfg!(not(feature = "http-tools")) {
1114 for t in &self.tools {
1115 if t.http.is_some() {
1116 errors.push(format!(
1117 "tool '{}' has an http binding but the `http-tools` feature is not \
1118 enabled",
1119 t.name
1120 ));
1121 }
1122 }
1123 }
1124
1125 {
1129 let computed_keys: std::collections::BTreeSet<&str> =
1130 self.computed.iter().map(|c| c.key.as_str()).collect();
1131 if computed_keys.len() != self.computed.len() {
1132 errors.push("computed variables declare a duplicate key".into());
1133 }
1134 let normalize = |k: &str| k.strip_prefix("derived:").unwrap_or(k).to_string();
1135 let mut in_degree: BTreeMap<&str, usize> =
1136 computed_keys.iter().map(|k| (*k, 0)).collect();
1137 let mut dependents: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
1138 for c in &self.computed {
1139 for dep in c.from.keys_read() {
1140 let dep = normalize(&dep);
1141 if dep != c.key && computed_keys.contains(dep.as_str()) {
1142 let dep_key = *computed_keys.get(dep.as_str()).unwrap();
1143 dependents.entry(dep_key).or_default().push(c.key.as_str());
1144 *in_degree.entry(c.key.as_str()).or_default() += 1;
1145 }
1146 if dep == c.key {
1147 errors.push(format!("computed '{}' reads its own key", c.key));
1148 }
1149 }
1150 }
1151 let mut queue: Vec<&str> = in_degree
1152 .iter()
1153 .filter(|(_, d)| **d == 0)
1154 .map(|(k, _)| *k)
1155 .collect();
1156 let mut visited = 0usize;
1157 while let Some(key) = queue.pop() {
1158 visited += 1;
1159 for dependent in dependents.get(key).cloned().unwrap_or_default() {
1160 let d = in_degree.get_mut(dependent).unwrap();
1161 *d -= 1;
1162 if *d == 0 {
1163 queue.push(dependent);
1164 }
1165 }
1166 }
1167 if visited != computed_keys.len() {
1168 let cycle: Vec<&str> = in_degree
1169 .iter()
1170 .filter(|(_, d)| **d > 0)
1171 .map(|(k, _)| *k)
1172 .collect();
1173 errors.push(format!(
1174 "computed variables form a dependency cycle: {}",
1175 cycle.join(", ")
1176 ));
1177 }
1178 }
1179
1180 for (location, effect) in self.all_effects() {
1183 if matches!(effect, EffectSpec::Remember(_)) && self.memory.is_none() {
1184 errors.push(format!(
1185 "{location} uses a `remember` effect but the spec has no `memory` section"
1186 ));
1187 }
1188 }
1189 if let Some(memory) = &self.memory {
1190 for slot in &memory.slots {
1191 if slot.to.starts_with("derived:") {
1192 errors.push(format!(
1193 "memory slot '{}' targets read-only key '{}' — the `derived:` scope \
1194 belongs to computed variables",
1195 slot.predicate, slot.to
1196 ));
1197 }
1198 }
1199 }
1200
1201 for (key, field) in &self.state {
1204 if let (Some(kind), Some(default)) = (field.kind, &field.default)
1205 && !kind.matches(default)
1206 {
1207 warnings.push(format!(
1208 "state key '{key}' declares type {kind:?} but its default is {default}"
1209 ));
1210 }
1211 }
1212 if !self.state.is_empty() {
1213 let declared: std::collections::BTreeSet<&str> =
1214 self.state.keys().map(String::as_str).collect();
1215 let mut undeclared = std::collections::BTreeSet::new();
1216 for key in self.state_keys_written() {
1217 let bare = key.strip_prefix("derived:").unwrap_or(&key);
1218 if !declared.contains(bare) && !self.computed.iter().any(|c| c.key == bare) {
1219 undeclared.insert(key.clone());
1220 }
1221 }
1222 for key in &undeclared {
1223 warnings.push(format!(
1224 "state key '{key}' is written but not declared in the `state` section"
1225 ));
1226 }
1227 }
1228
1229 {
1232 let written = self.state_keys_written();
1233 for c in &self.computed {
1234 for dep in c.from.keys_read() {
1235 let bare = dep.strip_prefix("derived:").unwrap_or(&dep);
1236 if !written.contains(&dep)
1237 && !written.contains(bare)
1238 && !dep.ends_with(":result")
1239 {
1240 warnings.push(format!(
1241 "computed '{}' reads state key '{dep}' but nothing writes it",
1242 c.key
1243 ));
1244 }
1245 }
1246 }
1247 }
1248
1249 if let Some(runtime) = &self.runtime {
1251 if runtime.include_thoughts == Some(true) && runtime.thinking_budget.is_none() {
1252 warnings.push(
1253 "runtime.include_thoughts is set without runtime.thinking_budget — no \
1254 thoughts will arrive"
1255 .into(),
1256 );
1257 }
1258 if let Some(audio) = &runtime.audio {
1259 if audio.denoise == Some(true) && !cfg!(feature = "denoise") {
1260 warnings.push(
1261 "runtime.audio.denoise requires building with the `denoise` feature — \
1262 the stage will be skipped"
1263 .into(),
1264 );
1265 }
1266 if audio.authority == Some(AuthoritySpec::Client) && audio.denoise != Some(true) {
1267 warnings.push(
1268 "runtime.audio.authority=client without denoise — in noise the raw \
1269 energy VAD latches open and will drive interruptions falsely \
1270 (measured); enable denoise or expect spurious barge-ins"
1271 .into(),
1272 );
1273 }
1274 if audio.noise_gate.is_some() && audio.denoise != Some(true) {
1275 warnings.push(
1276 "runtime.audio.noise_gate without denoise — the gate calibrates on \
1277 noisy levels; chain it behind denoise so it gates clean audio"
1278 .into(),
1279 );
1280 }
1281 if audio.authority == Some(AuthoritySpec::Client) && runtime.vad.is_some() {
1282 warnings.push(
1283 "runtime.audio.authority=client disables the server's automatic \
1284 activity detection — runtime.vad sensitivities will have no effect"
1285 .into(),
1286 );
1287 }
1288 if let Some(eot_ms) = audio.eot_hold_ms
1289 && eot_ms > 1600
1290 {
1291 warnings.push(
1292 "runtime.audio.eot_hold_ms exceeds the measured frontier (1600 ms): \
1293 recall fell to 0.508 at 1600ms on TurnBench dev — values beyond this \
1294 may cause missed turn-end detection"
1295 .into(),
1296 );
1297 }
1298 if let Some(min_int_ms) = audio.min_interruption_ms
1299 && min_int_ms > 2000
1300 {
1301 warnings.push(
1302 "runtime.audio.min_interruption_ms exceeds the interruption match \
1303 window (2000 ms) — commits may land too late to count"
1304 .into(),
1305 );
1306 }
1307 }
1308 if runtime.session_id.is_some() && runtime.persistence.is_none() {
1309 warnings.push(
1310 "runtime.session_id is set without runtime.persistence — nothing will be \
1311 snapshotted"
1312 .into(),
1313 );
1314 }
1315 }
1316
1317 let (valid_flow, referenced) = if has_flow {
1321 let compile_result = if self.tools.is_empty() || !self.mcp.is_empty() {
1322 if !self.mcp.is_empty() && !self.tools.is_empty() {
1323 warnings.push(
1324 "MCP toolsets resolve at connect time, so tool-name checking against \
1325 the flow is skipped"
1326 .into(),
1327 );
1328 }
1329 flow.clone().compile()
1330 } else {
1331 let mut names = self.tool_names();
1332 if self.memory.is_some() {
1333 names.extend(
1336 MEMORY_TOOL_NAMES
1337 .iter()
1338 .map(std::string::ToString::to_string),
1339 );
1340 }
1341 let refs: Vec<&str> = names.iter().map(String::as_str).collect();
1342 flow.clone().compile_with_tools(&refs)
1343 };
1344 match compile_result {
1345 Ok(compiled) => (
1346 true,
1347 compiled
1348 .tool_surface()
1349 .tools
1350 .iter()
1351 .cloned()
1352 .collect::<Vec<_>>(),
1353 ),
1354 Err(errs) => {
1355 errors.extend(errs.0.iter().map(std::string::ToString::to_string));
1356 (false, Vec::new())
1357 }
1358 }
1359 } else {
1360 (true, Vec::new())
1361 };
1362
1363 if valid_flow && has_flow {
1364 let written = self.state_keys_written();
1367 for key in flow.state_keys_read() {
1368 if written.contains(&key) {
1369 continue;
1370 }
1371 if key.ends_with(":result") {
1373 continue;
1374 }
1375 let hint = written
1376 .iter()
1377 .filter(|w| levenshtein(&key, w) <= 2)
1378 .cloned()
1379 .collect::<Vec<_>>();
1380 let suffix = if hint.is_empty() {
1381 String::new()
1382 } else {
1383 format!(" — did you mean {}?", hint.join(" / "))
1384 };
1385 warnings.push(format!(
1386 "a guard reads state key '{key}' but no tool, extractor, phase, or watcher \
1387 writes it (it can never latch){suffix}"
1388 ));
1389 }
1390 for t in &self.tools {
1391 if !referenced.contains(&t.name) {
1392 warnings.push(format!(
1393 "tool '{}' is declared but no step or constraint references it \
1394 (it will be denied whenever a step with an `allow` list is active \
1395 unless you add it to `ambient`)",
1396 t.name
1397 ));
1398 }
1399 }
1400 for s in &flow.steps {
1401 if !s.terminal && s.posture.is_none() {
1402 warnings.push(format!(
1403 "step '{}' has no posture — the model gets no steering while it is active",
1404 s.id
1405 ));
1406 }
1407 }
1408 }
1409
1410 SpecValidation {
1411 valid: errors.is_empty(),
1412 errors,
1413 warnings,
1414 mermaid,
1415 tools: referenced,
1416 steps,
1417 }
1418 }
1419
1420 pub fn build_dispatcher(&self, state: &State) -> ToolDispatcher {
1422 let mut dispatcher = ToolDispatcher::new();
1423 for tool in &self.tools {
1424 dispatcher.register(build_tool(tool, state));
1425 }
1426 dispatcher
1427 }
1428
1429 pub(crate) fn apply_tool_state(&self, name: &str, state: &State) {
1432 if let Some(tool) = self.tools.iter().find(|t| t.name == name) {
1433 for (key, value) in &tool.set_state {
1434 let _ = state.set(key, value.clone());
1435 }
1436 }
1437 }
1438
1439 pub fn run_tests(&self) -> Vec<TestReport> {
1442 simulate::run_tests(self)
1443 }
1444
1445 pub fn apply(
1453 &self,
1454 live: Live,
1455 state: &State,
1456 resources: &SpecResources,
1457 ) -> Result<Live, String> {
1458 let validation = self.validate();
1459 if !validation.valid {
1460 return Err(format!(
1461 "spec failed validation: {}",
1462 validation.errors.join("; ")
1463 ));
1464 }
1465 if !self.extract.is_empty() && resources.extraction_llm.is_none() {
1466 return Err(
1467 "spec declares extraction but SpecResources.extraction_llm is not set".into(),
1468 );
1469 }
1470 if self.memory.is_some() && resources.memory.is_none() {
1471 return Err("spec declares memory but SpecResources.memory is not set".into());
1472 }
1473
1474 self.seed_state_defaults(state);
1476
1477 let mut live = live
1478 .state(state.clone())
1479 .instruction(if self.instruction.is_empty() {
1480 "Follow the conversation flow you are given.".to_string()
1481 } else {
1482 self.instruction.clone()
1483 });
1484
1485 if let Some(greeting) = &self.greeting {
1486 live = live.greeting(greeting.clone());
1487 }
1488 live = match self.modality {
1489 SpecModality::Text => live.text_only(),
1490 SpecModality::Audio => live.voice(resolve_voice(self.voice.as_deref())),
1491 };
1492
1493 if !self.tools.is_empty() {
1495 live = live.dispatcher(self.build_dispatcher(state));
1496 }
1497 for params in &self.mcp {
1498 live = live.tools(T::mcp(params.clone()));
1499 }
1500
1501 let flow = self.effective_flow().map_err(|e| e.join("; "))?;
1503 if !flow.steps.is_empty() {
1504 live = live.govern(flow);
1505 }
1506
1507 for c in &self.computed {
1510 let expr = c.from.clone();
1511 let deps: Vec<String> = expr.keys_read().into_iter().collect();
1512 let dep_refs: Vec<&str> = deps.iter().map(String::as_str).collect();
1513 live = live.computed(c.key.clone(), &dep_refs, move |s| expr.eval(s));
1514 }
1515
1516 let memory_binding = resources.memory.clone();
1518 if let (Some(memory), Some(binding)) = (&self.memory, &memory_binding) {
1519 live = binding.install(live, memory);
1520 }
1521
1522 for tool in &self.tools {
1524 match (tool.background, tool.scheduling) {
1525 (_, Some(scheduling)) => {
1526 live = live
1527 .tool_background_with_scheduling(tool.name.clone(), scheduling.to_wire());
1528 }
1529 (true, None) => {
1530 live = live.tool_background(tool.name.clone());
1531 }
1532 (false, None) => {}
1533 }
1534 }
1535
1536 if let Some(runtime) = &self.runtime {
1538 live = apply_runtime(live, runtime);
1539 }
1540
1541 if let Some(llm) = &resources.extraction_llm {
1543 for e in &self.extract {
1544 let mut extractor =
1545 LlmExtractor::new(e.name.clone(), llm.clone(), e.instruction.clone(), e.window)
1546 .with_schema(e.schema.clone())
1547 .with_min_words(3)
1548 .with_trigger(e.trigger.to_trigger());
1549 if !e.promote.is_empty() {
1550 extractor = extractor
1551 .with_promotions(e.promote.iter().map(PromoteSpec::to_rule).collect());
1552 }
1553 live = live.extractor(Arc::new(extractor));
1554 }
1555 }
1556
1557 for p in &self.phases {
1559 let mut builder = live.phase(p.name.clone());
1560 if let Some(instruction) = &p.instruction {
1561 builder = builder.instruction(instruction.clone());
1562 }
1563 if !p.tools.is_empty() {
1564 builder = builder.tools(p.tools.clone());
1565 }
1566 if !p.needs.is_empty() {
1567 let refs: Vec<&str> = p.needs.iter().map(String::as_str).collect();
1568 builder = builder.needs(&refs);
1569 }
1570 if p.prompt_on_enter == Some(true) {
1571 builder = builder.prompt_on_enter();
1572 }
1573 if p.terminal {
1574 builder = builder.terminal();
1575 }
1576 for t in &p.transitions {
1577 let guard = t.when.clone();
1578 let predicate = move |s: &State| guard.eval_state(s);
1579 builder = match &t.description {
1580 Some(desc) => builder.transition_with(&t.to, predicate, desc.clone()),
1581 None => builder.transition(&t.to, predicate),
1582 };
1583 }
1584 if !p.on_enter.is_empty() {
1585 let effects = p.on_enter.clone();
1586 let memory = memory_binding.clone();
1587 builder = builder.on_enter(move |state, writer| {
1588 let effects = effects.clone();
1589 let memory = memory.clone();
1590 async move {
1591 run_effects(&effects, &state, &writer, memory.as_ref()).await;
1592 }
1593 });
1594 }
1595 live = builder.done();
1596 }
1597 if let Some(initial) = &self.initial_phase {
1598 live = live.initial_phase(initial.clone());
1599 }
1600
1601 for pattern in &self.patterns {
1603 let guard = pattern.when.clone();
1604 let condition = move |s: &State| guard.eval_state(s);
1605 let effects = pattern.effects.clone();
1606 let memory = memory_binding.clone();
1607 let action = move |state: State, writer: Arc<dyn SessionWriter>| {
1608 let effects = effects.clone();
1609 let memory = memory.clone();
1610 async move {
1611 run_effects(&effects, &state, &writer, memory.as_ref()).await;
1612 }
1613 };
1614 if let Some(secs) = pattern.sustained_secs {
1615 live = live.when_sustained(
1616 pattern.name.clone(),
1617 condition,
1618 std::time::Duration::from_secs(secs),
1619 action,
1620 );
1621 } else if let Some(turns) = pattern.turns {
1622 live = live.when_turns(pattern.name.clone(), condition, turns, action);
1623 }
1624 }
1625
1626 for w in &self.watch {
1628 let builder = live.watch(w.key.clone());
1629 let builder = match &w.condition {
1630 WatchCondition::Changed => builder.changed(),
1631 WatchCondition::ChangedTo(v) => builder.changed_to(v.clone()),
1632 WatchCondition::CrossedAbove(t) => builder.crossed_above(*t),
1633 WatchCondition::CrossedBelow(t) => builder.crossed_below(*t),
1634 WatchCondition::BecameTrue => builder.became_true(),
1635 WatchCondition::BecameFalse => builder.became_false(),
1636 };
1637 let sets = w.set.clone();
1638 let effects = w.effects.clone();
1639 let memory = memory_binding.clone();
1640 live = builder.then_with_writer(move |_old, _new, state, writer| {
1641 let sets = sets.clone();
1642 let effects = effects.clone();
1643 let memory = memory.clone();
1644 async move {
1645 for (key, value) in &sets {
1646 let _ = state.set(key, value.clone());
1647 }
1648 run_effects(&effects, &state, &writer, memory.as_ref()).await;
1649 }
1650 });
1651 }
1652
1653 Ok(live)
1654 }
1655}
1656
1657async fn run_effects(
1661 effects: &[EffectSpec],
1662 state: &State,
1663 writer: &Arc<dyn SessionWriter>,
1664 memory: Option<&Arc<dyn MemoryBinding>>,
1665) {
1666 for effect in effects {
1667 match effect {
1668 EffectSpec::Set(map) => {
1669 for (key, value) in map {
1670 let _ = state.set(key, value.clone());
1671 }
1672 }
1673 EffectSpec::Context(text) => {
1674 let _ = writer
1675 .send_client_content(vec![Content::model(text.clone())], false)
1676 .await;
1677 }
1678 EffectSpec::Prompt(text) => {
1679 let _ = writer
1680 .send_client_content(vec![Content::model(text.clone())], true)
1681 .await;
1682 }
1683 EffectSpec::Remember(template) => {
1684 if let Some(binding) = memory {
1685 binding.remember(interpolate(template, &Value::Null, state));
1686 }
1687 }
1688 }
1689 }
1690}
1691
1692fn apply_turn_commit(
1696 mut live: Live,
1697 eot_hold_ms: Option<u32>,
1698 min_interruption_ms: Option<u32>,
1699) -> Live {
1700 if let Some(ms) = eot_hold_ms {
1701 live = live.turn_commit_eot_hold_ms(u64::from(ms));
1702 }
1703 if let Some(ms) = min_interruption_ms {
1704 live = live.turn_commit_min_interruption_ms(u64::from(ms));
1705 }
1706 live
1707}
1708
1709fn apply_runtime(mut live: Live, runtime: &RuntimeSpec) -> Live {
1711 if let Some(t) = runtime.temperature {
1712 live = live.temperature(t);
1713 }
1714 if let Some(budget) = runtime.thinking_budget {
1715 live = live.thinking(budget);
1716 }
1717 if runtime.include_thoughts == Some(true) {
1718 live = live.include_thoughts();
1719 }
1720 if let Some(t) = runtime.transcription {
1721 if t.input {
1722 live = live.input_transcription();
1723 }
1724 if t.output {
1725 live = live.output_transcription();
1726 }
1727 }
1728 if runtime.proactive_audio == Some(true) {
1729 live = live.proactive_audio();
1730 }
1731 if let Some(vad) = &runtime.vad {
1732 live = live.vad(AutomaticActivityDetection {
1733 disabled: None,
1734 start_of_speech_sensitivity: vad.start_sensitivity.map(SensitivitySpec::to_wire),
1735 end_of_speech_sensitivity: vad.end_sensitivity.map(SensitivitySpec::to_wire),
1736 prefix_padding_ms: vad.prefix_padding_ms,
1737 silence_duration_ms: vad.silence_duration_ms,
1738 });
1739 }
1740 if let Some(audio) = &runtime.audio {
1741 #[cfg(feature = "denoise")]
1742 if audio.denoise == Some(true) {
1743 live = live.mic_denoise();
1744 }
1745 if let Some(gate) = &audio.noise_gate {
1746 live = live.mic_noise_gate(gate.threshold_rms, gate.hold_frames);
1747 }
1748 if let Some(vad) = &audio.client_vad {
1749 live = live.input_vad(vad.to_config());
1750 }
1751 if audio.authority == Some(AuthoritySpec::Client) {
1752 live = live.client_interruption_authority();
1753 }
1754 live = apply_turn_commit(live, audio.eot_hold_ms, audio.min_interruption_ms);
1757 }
1758 if let Some(ms) = runtime.soft_turn_timeout_ms {
1759 live = live.soft_turn_timeout(std::time::Duration::from_millis(ms));
1760 }
1761 if let Some(steering) = runtime.steering {
1762 live = live.steering_mode(match steering {
1763 SteeringSpec::InstructionUpdate => SteeringMode::InstructionUpdate,
1764 SteeringSpec::ContextInjection => SteeringMode::ContextInjection,
1765 SteeringSpec::Hybrid => SteeringMode::Hybrid,
1766 });
1767 }
1768 if let Some(delivery) = runtime.context_delivery {
1769 live = live.context_delivery(match delivery {
1770 ContextDeliverySpec::Immediate => ContextDelivery::Immediate,
1771 ContextDeliverySpec::Deferred => ContextDelivery::Deferred,
1772 });
1773 }
1774 if let Some(repair) = runtime.repair {
1775 live = live.repair(RepairConfig {
1776 nudge_after: repair.nudge_after,
1777 escalate_after: repair.escalate_after,
1778 });
1779 }
1780 if let Some(persistence) = &runtime.persistence {
1781 live = match persistence {
1782 PersistenceSpec::Fs { dir } => {
1783 live.persistence(Arc::new(gemini_adk_rs::live::FsPersistence::new(dir)))
1784 }
1785 PersistenceSpec::Memory => {
1786 live.persistence(Arc::new(gemini_adk_rs::live::MemoryPersistence::new()))
1787 }
1788 };
1789 }
1790 if let Some(id) = &runtime.session_id {
1791 live = live.session_id(id.clone());
1792 }
1793 if runtime.lossy_audio {
1794 live = live.lossy_audio();
1795 }
1796 if runtime.lossy_transcript {
1797 live = live.lossy_transcript();
1798 }
1799 live
1800}
1801
1802fn build_tool(tool: &ToolSpec, state: &State) -> SimpleTool {
1804 let description = if tool.description.is_empty() {
1805 format!("Tool '{}'", tool.name)
1806 } else {
1807 tool.description.clone()
1808 };
1809 let response = tool.response.clone().unwrap_or_else(|| json!({"ok": true}));
1810 let sets = tool.set_state.clone();
1811 let save_as = tool.save_response_as.clone();
1812 let http = tool.http.clone();
1813 let st = state.clone();
1814 let name = tool.name.clone();
1815 SimpleTool::new(
1816 &tool.name,
1817 description,
1818 tool.parameters.clone(),
1819 move |args| {
1820 let response = response.clone();
1821 let sets = sets.clone();
1822 let save_as = save_as.clone();
1823 let http = http.clone();
1824 let st = st.clone();
1825 let name = name.clone();
1826 async move {
1827 let result = match &http {
1828 Some(binding) => execute_http(binding, &args, &st).await.map_err(|e| {
1829 gemini_adk_rs::error::ToolError::Other(format!("{name}: {e}"))
1830 })?,
1831 None => response,
1832 };
1833 for (key, value) in &sets {
1834 let _ = st.set(key, value.clone());
1835 }
1836 if let Some(key) = &save_as {
1837 let _ = st.set(key, result.clone());
1838 }
1839 Ok(result)
1840 }
1841 },
1842 )
1843}
1844
1845fn interpolate(template: &str, args: &Value, state: &State) -> String {
1847 let mut out = String::with_capacity(template.len());
1848 let mut rest = template;
1849 while let Some(open) = rest.find('{') {
1850 out.push_str(&rest[..open]);
1851 let after = &rest[open + 1..];
1852 let Some(close) = after.find('}') else {
1853 out.push_str(&rest[open..]);
1854 return out;
1855 };
1856 let expr = after[..close].trim();
1857 let value = if let Some(field) = expr.strip_prefix("args.") {
1858 args.get(field).cloned()
1859 } else if let Some(key) = expr.strip_prefix("state.") {
1860 state.get::<Value>(key)
1861 } else {
1862 None
1863 };
1864 match value {
1865 Some(Value::String(s)) => out.push_str(&s),
1866 Some(v) => out.push_str(&v.to_string()),
1867 None => {}
1868 }
1869 rest = &after[close + 1..];
1870 }
1871 out.push_str(rest);
1872 out
1873}
1874
1875#[cfg_attr(not(feature = "http-tools"), allow(dead_code))]
1877fn interpolate_value(value: &Value, args: &Value, state: &State) -> Value {
1878 match value {
1879 Value::String(s) => Value::String(interpolate(s, args, state)),
1880 Value::Array(items) => Value::Array(
1881 items
1882 .iter()
1883 .map(|v| interpolate_value(v, args, state))
1884 .collect(),
1885 ),
1886 Value::Object(map) => Value::Object(
1887 map.iter()
1888 .map(|(k, v)| (k.clone(), interpolate_value(v, args, state)))
1889 .collect(),
1890 ),
1891 other => other.clone(),
1892 }
1893}
1894
1895#[cfg(feature = "http-tools")]
1896async fn execute_http(binding: &HttpBinding, args: &Value, state: &State) -> Result<Value, String> {
1897 let url = interpolate(&binding.url, args, state);
1898 let client = reqwest::Client::new();
1899 let method = reqwest::Method::from_bytes(binding.method.to_uppercase().as_bytes())
1900 .map_err(|_| format!("invalid HTTP method '{}'", binding.method))?;
1901 let mut request = client.request(method, &url);
1902 for (name, value) in &binding.headers {
1903 request = request.header(name, interpolate(value, args, state));
1904 }
1905 if let Some(body) = &binding.body {
1906 request = request.json(&interpolate_value(body, args, state));
1907 }
1908 let response = request.send().await.map_err(|e| e.to_string())?;
1909 let status = response.status().as_u16();
1910 let text = response.text().await.map_err(|e| e.to_string())?;
1911 Ok(serde_json::from_str(&text).unwrap_or_else(|_| json!({ "status": status, "body": text })))
1912}
1913
1914#[cfg(not(feature = "http-tools"))]
1915#[allow(
1916 clippy::unused_async,
1917 reason = "same signature as the http-tools implementation so the call site is feature-agnostic"
1918)]
1919async fn execute_http(
1920 _binding: &HttpBinding,
1921 _args: &Value,
1922 _state: &State,
1923) -> Result<Value, String> {
1924 Err("http tool bindings require the `http-tools` feature".to_string())
1925}
1926
1927fn splice_fragment(
1929 flow: &mut Flow,
1930 fragment: &Flow,
1931 directive: &UseFragment,
1932 errors: &mut Vec<String>,
1933) {
1934 let ns = &directive.namespace;
1935 let prefix = |id: &str| format!("{ns}/{id}");
1936 let internal: std::collections::BTreeSet<&str> =
1937 fragment.steps.iter().map(|s| s.id.as_str()).collect();
1938
1939 for step in &fragment.steps {
1940 let new_id = prefix(&step.id);
1941 if flow.steps.iter().any(|s| s.id == new_id) {
1942 errors.push(format!(
1943 "fragment splice '{ns}' collides with existing step '{new_id}'"
1944 ));
1945 continue;
1946 }
1947 let mut after: Vec<gemini_adk_rs::flow::Edge> = step
1948 .after
1949 .iter()
1950 .map(|d| gemini_adk_rs::flow::Edge {
1951 step: if internal.contains(d.step.as_str()) {
1952 prefix(&d.step)
1953 } else {
1954 d.step.clone()
1955 },
1956 when: d
1957 .when
1958 .clone()
1959 .map(|g| rewrite_guard_steps(g, &internal, ns)),
1960 })
1961 .collect();
1962 if step.after.is_empty() {
1963 after.extend(
1964 directive
1965 .after
1966 .iter()
1967 .cloned()
1968 .map(gemini_adk_rs::flow::Edge::to),
1969 );
1970 }
1971 flow.steps.push(Step {
1972 id: new_id,
1973 after,
1974 join: step.join,
1975 gate: step
1976 .gate
1977 .clone()
1978 .map(|g| rewrite_guard_steps(g, &internal, ns)),
1979 done: step
1980 .done
1981 .clone()
1982 .map(|g| rewrite_guard_steps(g, &internal, ns)),
1983 posture: step.posture.clone(),
1984 ground: step.ground.clone(),
1985 allow: step.allow.clone(),
1986 deny: step.deny.clone(),
1987 terminal: step.terminal,
1988 });
1989 }
1990 for constraint in &fragment.constraints {
1991 flow.constraints.push(match constraint {
1992 Constraint::Once(t) => Constraint::Once(t.clone()),
1993 Constraint::Before(a, b) => Constraint::Before(
1994 if internal.contains(a.as_str()) {
1995 prefix(a)
1996 } else {
1997 a.clone()
1998 },
1999 if internal.contains(b.as_str()) {
2000 prefix(b)
2001 } else {
2002 b.clone()
2003 },
2004 ),
2005 Constraint::NeverUntil { tool, until } => Constraint::NeverUntil {
2006 tool: tool.clone(),
2007 until: rewrite_guard_steps(until.clone(), &internal, ns),
2008 },
2009 Constraint::Require(rs) => Constraint::Require(
2010 rs.iter()
2011 .map(|r| {
2012 if internal.contains(r.as_str()) {
2013 prefix(r)
2014 } else {
2015 r.clone()
2016 }
2017 })
2018 .collect(),
2019 ),
2020 Constraint::Reset { steps, when } => Constraint::Reset {
2021 steps: steps
2022 .iter()
2023 .map(|r| {
2024 if internal.contains(r.as_str()) {
2025 prefix(r)
2026 } else {
2027 r.clone()
2028 }
2029 })
2030 .collect(),
2031 when: rewrite_guard_steps(when.clone(), &internal, ns),
2032 },
2033 });
2034 }
2035 for tool in &fragment.ambient {
2036 if !flow.ambient.contains(tool) {
2037 flow.ambient.push(tool.clone());
2038 }
2039 }
2040 for tool in &fragment.confirm_tools {
2041 if !flow.confirm_tools.contains(tool) {
2042 flow.confirm_tools.push(tool.clone());
2043 }
2044 }
2045}
2046
2047fn rewrite_guard_steps(
2049 guard: Guard,
2050 internal: &std::collections::BTreeSet<&str>,
2051 ns: &str,
2052) -> Guard {
2053 fn rewrite(pred: Pred, internal: &std::collections::BTreeSet<&str>, ns: &str) -> Pred {
2054 match pred {
2055 Pred::Done(s) if internal.contains(s.as_str()) => Pred::Done(format!("{ns}/{s}")),
2056 Pred::All(ps) => Pred::All(ps.into_iter().map(|p| rewrite(p, internal, ns)).collect()),
2057 Pred::Any(ps) => Pred::Any(ps.into_iter().map(|p| rewrite(p, internal, ns)).collect()),
2058 Pred::Not(p) => Pred::Not(Box::new(rewrite(*p, internal, ns))),
2059 other => other,
2060 }
2061 }
2062 match guard {
2063 Guard::Spec(p) => Guard::Spec(rewrite(p, internal, ns)),
2064 custom => custom,
2065 }
2066}
2067
2068fn guard_uses_marking(guard: &Guard) -> bool {
2071 fn walk(pred: &Pred) -> bool {
2072 match pred {
2073 Pred::CalledOk(_) | Pred::Done(_) => true,
2074 Pred::All(ps) | Pred::Any(ps) => ps.iter().any(walk),
2075 Pred::Not(p) => walk(p),
2076 _ => false,
2077 }
2078 }
2079 match guard {
2080 Guard::Spec(p) => walk(p),
2081 Guard::Custom(_) => false,
2082 }
2083}
2084
2085fn resolve_voice(name: Option<&str>) -> Voice {
2087 match name {
2088 Some("Aoede") => Voice::Aoede,
2089 Some("Charon") => Voice::Charon,
2090 Some("Fenrir") => Voice::Fenrir,
2091 Some("Kore") => Voice::Kore,
2092 Some("Puck") | None => Voice::Puck,
2093 Some(other) => Voice::Custom(other.to_string()),
2094 }
2095}
2096
2097fn levenshtein(a: &str, b: &str) -> usize {
2099 let a: Vec<char> = a.chars().collect();
2100 let b: Vec<char> = b.chars().collect();
2101 let mut prev: Vec<usize> = (0..=b.len()).collect();
2102 let mut current = vec![0; b.len() + 1];
2103 for (i, ca) in a.iter().enumerate() {
2104 current[0] = i + 1;
2105 for (j, cb) in b.iter().enumerate() {
2106 let cost = usize::from(ca != cb);
2107 current[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(current[j] + 1);
2108 }
2109 std::mem::swap(&mut prev, &mut current);
2110 }
2111 prev[b.len()]
2112}
2113
2114#[cfg(test)]
2115mod tests {
2116
2117 #[test]
2118 fn audio_spec_lowers_and_validates() {
2119 let spec: SessionSpec = serde_json::from_str(
2120 r#"{
2121 "name": "noisy",
2122 "instruction": "hi",
2123 "runtime": {
2124 "audio": {
2125 "denoise": true,
2126 "noise_gate": { "threshold_rms": 700.0 },
2127 "client_vad": { "preset": "noisy_street", "hangover_frames": 12 },
2128 "authority": "client"
2129 }
2130 }
2131 }"#,
2132 )
2133 .unwrap();
2134 let audio = spec.runtime.as_ref().unwrap().audio.as_ref().unwrap();
2135 assert_eq!(audio.noise_gate.as_ref().unwrap().hold_frames, 3); let config = audio.client_vad.as_ref().unwrap().to_config();
2137 assert_eq!(config.start_threshold_db, 21.0); assert_eq!(config.hangover_frames, 12); assert_eq!(audio.authority, Some(AuthoritySpec::Client));
2140 let json = serde_json::to_value(&spec).unwrap();
2142 let back: SessionSpec = serde_json::from_value(json).unwrap();
2143 assert_eq!(
2144 back.runtime
2145 .unwrap()
2146 .audio
2147 .unwrap()
2148 .client_vad
2149 .unwrap()
2150 .hangover_frames,
2151 Some(12)
2152 );
2153 }
2154
2155 #[test]
2156 fn audio_spec_warns_on_risky_combinations() {
2157 let spec: SessionSpec = serde_json::from_str(
2158 r#"{
2159 "name": "risky",
2160 "instruction": "hi",
2161 "runtime": { "audio": { "authority": "client", "noise_gate": {} } }
2162 }"#,
2163 )
2164 .unwrap();
2165 let validation = spec.validate();
2166 assert!(
2167 validation
2168 .warnings
2169 .iter()
2170 .any(|w| w.contains("authority=client without denoise")),
2171 "expected client-authority warning, got {:?}",
2172 validation.warnings
2173 );
2174 assert!(
2175 validation
2176 .warnings
2177 .iter()
2178 .any(|w| w.contains("noise_gate without denoise")),
2179 "expected gate warning, got {:?}",
2180 validation.warnings
2181 );
2182 }
2183
2184 #[test]
2185 fn turn_commit_tuning_knobs_serialize_and_round_trip() {
2186 let spec: SessionSpec = serde_json::from_str(
2187 r#"{
2188 "name": "tune",
2189 "instruction": "hi",
2190 "runtime": {
2191 "audio": {
2192 "eot_hold_ms": 800,
2193 "min_interruption_ms": 1400
2194 }
2195 }
2196 }"#,
2197 )
2198 .unwrap();
2199 let audio = spec.runtime.as_ref().unwrap().audio.as_ref().unwrap();
2200 assert_eq!(audio.eot_hold_ms, Some(800));
2201 assert_eq!(audio.min_interruption_ms, Some(1400));
2202 let json = serde_json::to_value(&spec).unwrap();
2204 let back: SessionSpec = serde_json::from_value(json).unwrap();
2205 let audio_back = back.runtime.unwrap().audio.unwrap();
2206 assert_eq!(audio_back.eot_hold_ms, Some(800));
2207 assert_eq!(audio_back.min_interruption_ms, Some(1400));
2208 }
2209
2210 #[test]
2211 fn turn_commit_tuning_knobs_validate_thresholds() {
2212 let spec: SessionSpec = serde_json::from_str(
2214 r#"{
2215 "name": "frontier",
2216 "instruction": "hi",
2217 "runtime": { "audio": { "eot_hold_ms": 1700 } }
2218 }"#,
2219 )
2220 .unwrap();
2221 let validation = spec.validate();
2222 assert!(
2223 validation
2224 .warnings
2225 .iter()
2226 .any(|w| w.contains("eot_hold_ms") && w.contains("1600") && w.contains("frontier")),
2227 "expected eot_hold_ms frontier warning, got {:?}",
2228 validation.warnings
2229 );
2230
2231 let spec2: SessionSpec = serde_json::from_str(
2233 r#"{
2234 "name": "window",
2235 "instruction": "hi",
2236 "runtime": { "audio": { "min_interruption_ms": 2100 } }
2237 }"#,
2238 )
2239 .unwrap();
2240 let validation2 = spec2.validate();
2241 assert!(
2242 validation2
2243 .warnings
2244 .iter()
2245 .any(|w| w.contains("min_interruption_ms")
2246 && w.contains("2000")
2247 && w.contains("window")),
2248 "expected min_interruption_ms window warning, got {:?}",
2249 validation2.warnings
2250 );
2251 }
2252 use super::*;
2253
2254 fn collections_spec() -> SessionSpec {
2255 SessionSpec::from_value(json!({
2256 "name": "collections",
2257 "instruction": "Collect payments.",
2258 "tools": [
2259 {"name": "verify_identity", "set_state": {"identity_verified": true}},
2260 {"name": "charge_card", "response": {"charged": true}}
2261 ],
2262 "extract": [{
2263 "name": "ptp",
2264 "instruction": "Extract the promise to pay.",
2265 "schema": {"type": "object", "properties": {
2266 "ptp_amount": {"type": "number"}, "ptp_date": {"type": "string"}}},
2267 "promote": [
2268 {"field": "ptp_amount"},
2269 {"field": "ptp_date", "policy": "overwrite"}
2270 ]
2271 }],
2272 "flow": {
2273 "steps": [
2274 {"id": "verify", "posture": "Verify the caller.",
2275 "allow": ["verify_identity"],
2276 "done": {"is_true": "identity_verified"}},
2277 {"id": "pay", "after": ["verify"], "posture": "Take payment.",
2278 "allow": ["charge_card"],
2279 "gate": {"captured": ["ptp_amount", "ptp_date"]},
2280 "done": {"called_ok": "charge_card"}}
2281 ]
2282 }
2283 }))
2284 .expect("spec parses")
2285 }
2286
2287 #[test]
2288 fn extraction_promotions_satisfy_guard_reads() {
2289 let v = collections_spec().validate();
2290 assert!(v.valid, "errors: {:?}", v.errors);
2291 assert!(
2292 v.warnings.iter().all(|w| !w.contains("can never latch")),
2293 "promoted keys cover the guard reads: {:?}",
2294 v.warnings
2295 );
2296 }
2297
2298 #[test]
2299 fn unwritten_guard_key_warns_with_suggestion() {
2300 let mut spec = collections_spec();
2301 spec.flow.as_mut().unwrap().steps[0].done = Some(Guard::is_true("identity_verifed"));
2303 let v = spec.validate();
2304 assert!(v.valid);
2305 let warning = v
2306 .warnings
2307 .iter()
2308 .find(|w| w.contains("identity_verifed"))
2309 .expect("warns about the unwritten key");
2310 assert!(
2311 warning.contains("identity_verified"),
2312 "suggests the fix: {warning}"
2313 );
2314 }
2315
2316 #[test]
2317 fn phase_guard_rejects_marking_atoms() {
2318 let spec = SessionSpec::from_value(json!({
2319 "instruction": "x",
2320 "phases": [{"name": "a", "transitions": [
2321 {"to": "b", "when": {"called_ok": "some_tool"}}]},
2322 {"name": "b"}],
2323 "initial_phase": "a"
2324 }))
2325 .expect("parses");
2326 let v = spec.validate();
2327 assert!(!v.valid);
2328 assert!(v.errors.iter().any(|e| e.contains("called_ok")));
2329 }
2330
2331 #[test]
2332 fn fragments_splice_with_namespacing() {
2333 let spec = SessionSpec::from_value(json!({
2334 "instruction": "x",
2335 "tools": [
2336 {"name": "check_id", "set_state": {"id_ok": true}},
2337 {"name": "book", "response": {}}
2338 ],
2339 "fragments": {
2340 "verify": {"steps": [
2341 {"id": "ask", "posture": "Ask for ID.", "allow": ["check_id"],
2342 "done": {"is_true": "id_ok"}},
2343 {"id": "confirm", "after": ["ask"], "terminal": true,
2344 "gate": {"done": "ask"}}
2345 ]}
2346 },
2347 "use_fragments": [{"fragment": "verify", "namespace": "v"}],
2348 "flow": {"steps": [
2349 {"id": "book_step", "after": ["v/confirm"], "allow": ["book"],
2350 "done": {"called_ok": "book"}}
2351 ]}
2352 }))
2353 .expect("parses");
2354 let flow = spec.effective_flow().expect("splices");
2355 let ids: Vec<&str> = flow.steps.iter().map(|s| s.id.as_str()).collect();
2356 assert!(ids.contains(&"v/ask") && ids.contains(&"v/confirm"));
2357 let confirm = flow.steps.iter().find(|s| s.id == "v/confirm").unwrap();
2359 assert_eq!(
2360 serde_json::to_value(confirm.gate.as_ref().unwrap()).unwrap(),
2361 json!({"done": "v/ask"})
2362 );
2363 let v = spec.validate();
2364 assert!(v.valid, "errors: {:?}", v.errors);
2365 }
2366
2367 #[test]
2368 fn bare_flow_round_trips() {
2369 let spec = SessionSpec::from_value(json!({
2370 "steps": [{"id": "only", "terminal": true}]
2371 }))
2372 .expect("parses");
2373 assert!(spec.validate().valid);
2374 }
2375
2376 #[test]
2377 fn empty_fragment_namespace_is_rejected() {
2378 let spec = SessionSpec::from_value(json!({
2379 "instruction": "x",
2380 "fragments": {
2381 "verify": {"steps": [
2382 {"id": "ask", "terminal": true}
2383 ]}
2384 },
2385 "use_fragments": [{"fragment": "verify", "namespace": ""}],
2386 "flow": {"steps": [
2387 {"id": "start", "terminal": true}
2388 ]}
2389 }))
2390 .expect("parses");
2391 let v = spec.validate();
2392 assert!(
2394 !v.valid,
2395 "empty namespace should fail validation, got errors: {:?}",
2396 v.errors
2397 );
2398 assert!(
2399 v.errors.iter().any(|e| e.contains("namespace")),
2400 "should mention namespace issue: {:?}",
2401 v.errors
2402 );
2403 }
2404
2405 #[test]
2406 fn duplicate_computed_keys_are_rejected() {
2407 let spec = SessionSpec::from_value(json!({
2408 "instruction": "x",
2409 "flow": {"steps": [{"id": "only", "terminal": true}]},
2410 "computed": [
2411 {"key": "risk", "from": {"key": "score"}},
2412 {"key": "risk", "from": {"key": "other_score"}}
2413 ]
2414 }))
2415 .expect("parses");
2416 let v = spec.validate();
2417 assert!(!v.valid, "duplicate computed keys should fail validation");
2418 assert!(
2419 v.errors.iter().any(|e| e.contains("duplicate")),
2420 "should mention duplicate computed key: {:?}",
2421 v.errors
2422 );
2423 }
2424
2425 #[test]
2426 fn duplicate_phase_names_are_rejected() {
2427 let spec = SessionSpec::from_value(json!({
2428 "instruction": "x",
2429 "phases": [
2430 {"name": "greet", "instruction": "Welcome."},
2431 {"name": "greet", "instruction": "Hi again."}
2432 ],
2433 "initial_phase": "greet"
2434 }))
2435 .expect("parses");
2436 let v = spec.validate();
2437 assert!(!v.valid, "duplicate phase names should fail validation");
2438 assert!(
2439 v.errors.iter().any(|e| e.contains("phase")),
2440 "should mention duplicate phase: {:?}",
2441 v.errors
2442 );
2443 }
2444
2445 #[test]
2446 fn tool_background_false_round_trips() {
2447 let spec = SessionSpec::from_value(json!({
2449 "instruction": "x",
2450 "tools": [
2451 {"name": "search", "background": false},
2452 {"name": "log", "background": true}
2453 ],
2454 "flow": {"steps": [{"id": "only", "terminal": true}]}
2455 }))
2456 .expect("parses");
2457
2458 let serialized = serde_json::to_value(&spec).unwrap();
2460 let tools = serialized["tools"].as_array().unwrap();
2461
2462 assert!(
2465 tools[0].get("background").is_none(),
2466 "background: false is optimized away"
2467 );
2468
2469 assert_eq!(tools[1].get("background"), Some(&json!(true)));
2471
2472 let back: SessionSpec = serde_json::from_value(serialized).unwrap();
2474 assert!(!back.tools[0].background);
2475 assert!(back.tools[1].background);
2476 }
2477
2478 #[test]
2479 fn promotion_spec_with_no_to_field_uses_field_name() {
2480 let spec = SessionSpec::from_value(json!({
2482 "instruction": "x",
2483 "tools": [{"name": "extract", "set_state": {"test": true}}],
2484 "extract": [{
2485 "name": "data",
2486 "instruction": "Extract.",
2487 "schema": {"type": "object"},
2488 "promote": [
2489 {"field": "amount"},
2490 {"field": "date", "to": "extracted_date"}
2491 ]
2492 }],
2493 "flow": {"steps": [{"id": "only", "terminal": true}]}
2494 }))
2495 .expect("parses");
2496
2497 let promote = &spec.extract[0].promote;
2498 assert_eq!(promote[0].target(), "amount");
2499 assert_eq!(promote[1].target(), "extracted_date");
2500 }
2501
2502 #[test]
2503 fn spec_schema_publishes() {
2504 let schema = SessionSpec::json_schema().to_string();
2505 for token in [
2506 "is_true",
2507 "never_until",
2508 "set_state",
2509 "use_fragments",
2510 "promote",
2511 ] {
2512 assert!(schema.contains(token), "schema missing {token}");
2513 }
2514 }
2515
2516 #[test]
2517 fn interpolation_reads_args_and_state() {
2518 let state = State::new();
2519 let _ = state.set("city", "Paris");
2520 let args = json!({"guests": 4});
2521 assert_eq!(
2522 interpolate("book/{state.city}/{args.guests}/{missing}", &args, &state),
2523 "book/Paris/4/"
2524 );
2525 }
2526
2527 #[tokio::test]
2528 async fn declared_tools_write_state() {
2529 let spec = collections_spec();
2530 let state = State::new();
2531 let dispatcher = spec.build_dispatcher(&state);
2532 let out = dispatcher
2533 .call_function("verify_identity", json!({}))
2534 .await
2535 .expect("tool runs");
2536 assert_eq!(out, json!({"ok": true}));
2537 assert_eq!(state.get::<bool>("identity_verified"), Some(true));
2538 }
2539
2540 #[test]
2541 fn computed_cycles_are_load_time_errors() {
2542 let spec = SessionSpec::from_value(json!({
2543 "instruction": "x",
2544 "flow": {"steps": [{"id": "only", "terminal": true}]},
2545 "computed": [
2546 {"key": "a", "from": {"add": [{"key": "b"}, {"const": 1}]}},
2547 {"key": "b", "from": {"add": [{"key": "derived:a"}, {"const": 1}]}}
2548 ]
2549 }))
2550 .expect("parses");
2551 let v = spec.validate();
2552 assert!(!v.valid);
2553 assert!(v.errors.iter().any(|e| e.contains("dependency cycle")));
2554
2555 let self_read = SessionSpec::from_value(json!({
2556 "instruction": "x",
2557 "flow": {"steps": [{"id": "only", "terminal": true}]},
2558 "computed": [{"key": "a", "from": {"key": "a"}}]
2559 }))
2560 .expect("parses");
2561 assert!(
2562 self_read
2563 .validate()
2564 .errors
2565 .iter()
2566 .any(|e| e.contains("reads its own key"))
2567 );
2568 }
2569
2570 #[test]
2571 fn computed_keys_satisfy_guard_reads_and_deps_are_checked() {
2572 let spec = SessionSpec::from_value(json!({
2573 "instruction": "x",
2574 "tools": [{"name": "record", "set_state": {"score": 0.8}}],
2575 "computed": [{"key": "high_risk",
2576 "from": {"gt": [{"key": "score"}, {"const": 0.5}]}}],
2577 "flow": {"steps": [
2578 {"id": "assess", "posture": "Assess.", "allow": ["record"],
2579 "done": {"is_true": "high_risk"}}
2580 ]}
2581 }))
2582 .expect("parses");
2583 let v = spec.validate();
2584 assert!(v.valid, "errors: {:?}", v.errors);
2585 assert!(
2586 v.warnings.iter().all(|w| !w.contains("can never latch")),
2587 "computed key covers the guard read: {:?}",
2588 v.warnings
2589 );
2590
2591 let mut dangling = spec.clone();
2592 dangling.computed[0].from =
2593 serde_json::from_value(json!({"gt": [{"key": "scoer"}, {"const": 0.5}]})).unwrap();
2594 let v = dangling.validate();
2595 assert!(
2596 v.warnings
2597 .iter()
2598 .any(|w| w.contains("computed 'high_risk' reads state key 'scoer'"))
2599 );
2600 }
2601
2602 #[test]
2603 fn remember_requires_the_memory_section() {
2604 let spec = SessionSpec::from_value(json!({
2605 "instruction": "x",
2606 "flow": {"steps": [{"id": "only", "terminal": true}]},
2607 "patterns": [{"name": "note", "when": {"is_true": "flag"}, "turns": 2,
2608 "effects": [{"remember": "caller likes {state.thing}"}]}]
2609 }))
2610 .expect("parses");
2611 let v = spec.validate();
2612 assert!(!v.valid);
2613 assert!(v.errors.iter().any(|e| e.contains("no `memory` section")));
2614 }
2615
2616 #[test]
2617 fn memory_slots_join_written_keys_and_reject_derived_targets() {
2618 let spec = SessionSpec::from_value(json!({
2619 "instruction": "x",
2620 "memory": {"slots": [{"predicate": "dietary_identity", "to": "user:diet"}]},
2621 "flow": {"steps": [
2622 {"id": "plan", "posture": "Plan dinner.",
2623 "done": {"is_set": "user:diet"}}
2624 ]}
2625 }))
2626 .expect("parses");
2627 let v = spec.validate();
2628 assert!(v.valid, "errors: {:?}", v.errors);
2629 assert!(v.warnings.iter().all(|w| !w.contains("can never latch")));
2630
2631 let mut bad = spec.clone();
2632 bad.memory.as_mut().unwrap().slots[0].to = "derived:diet".into();
2633 assert!(
2634 bad.validate()
2635 .errors
2636 .iter()
2637 .any(|e| e.contains("read-only key"))
2638 );
2639 }
2640
2641 #[test]
2642 fn memory_section_requires_a_binding_at_apply() {
2643 let spec = SessionSpec::from_value(json!({
2644 "instruction": "x",
2645 "memory": {},
2646 "flow": {"steps": [{"id": "only", "terminal": true}]}
2647 }))
2648 .expect("parses");
2649 let err = spec
2650 .apply(Live::builder(), &State::new(), &SpecResources::default())
2651 .err()
2652 .expect("memory binding required");
2653 assert!(err.contains("SpecResources.memory"));
2654
2655 struct NullBinding;
2656 impl MemoryBinding for NullBinding {
2657 fn install(&self, live: Live, _memory: &MemorySpec) -> Live {
2658 live
2659 }
2660 fn remember(&self, _note: String) {}
2661 }
2662 let resources = SpecResources {
2663 memory: Some(Arc::new(NullBinding)),
2664 ..Default::default()
2665 };
2666 assert!(
2667 spec.apply(Live::builder(), &State::new(), &resources)
2668 .is_ok()
2669 );
2670 }
2671
2672 #[test]
2673 fn state_dictionary_seeds_defaults_and_flags_undeclared_writes() {
2674 let spec = SessionSpec::from_value(json!({
2675 "instruction": "x",
2676 "state": {
2677 "attempts": {"type": "number", "default": 0,
2678 "description": "Verification attempts so far."},
2679 "verified": {"type": "boolean", "default": "yes"}
2680 },
2681 "tools": [{"name": "verify", "set_state": {"verified": true, "vip": true}}],
2682 "flow": {"steps": [
2683 {"id": "v", "posture": "Verify.", "allow": ["verify"],
2684 "done": {"is_true": "verified"}}
2685 ]}
2686 }))
2687 .expect("parses");
2688 let v = spec.validate();
2689 assert!(v.valid, "errors: {:?}", v.errors);
2690 assert!(
2691 v.warnings
2692 .iter()
2693 .any(|w| w.contains("'verified' declares type Boolean")),
2694 "type-mismatched default warns: {:?}",
2695 v.warnings
2696 );
2697 assert!(
2698 v.warnings
2699 .iter()
2700 .any(|w| w.contains("'vip' is written but not declared")),
2701 "undeclared write warns: {:?}",
2702 v.warnings
2703 );
2704
2705 let state = State::new();
2706 spec.seed_state_defaults(&state);
2707 assert_eq!(state.get::<i64>("attempts"), Some(0));
2708 }
2709
2710 #[test]
2711 fn runtime_section_lowers_onto_the_builder() {
2712 let spec = SessionSpec::from_value(json!({
2713 "instruction": "x",
2714 "flow": {"steps": [{"id": "only", "terminal": true}]},
2715 "runtime": {
2716 "temperature": 0.4,
2717 "thinking_budget": 1024,
2718 "include_thoughts": true,
2719 "transcription": {"input": true, "output": false},
2720 "proactive_audio": true,
2721 "vad": {"start_sensitivity": "high", "silence_duration_ms": 400},
2722 "soft_turn_timeout_ms": 1500,
2723 "steering": "context_injection",
2724 "context_delivery": "deferred",
2725 "repair": {"nudge_after": 2, "escalate_after": 5},
2726 "persistence": "memory",
2727 "session_id": "user-1",
2728 "lossy_audio": true
2729 }
2730 }))
2731 .expect("parses");
2732 let v = spec.validate();
2733 assert!(v.valid, "errors: {:?}", v.errors);
2734 assert!(
2735 spec.apply(Live::builder(), &State::new(), &SpecResources::default())
2736 .is_ok()
2737 );
2738
2739 let mut incoherent = spec.clone();
2740 incoherent.runtime.as_mut().unwrap().thinking_budget = None;
2741 assert!(
2742 incoherent
2743 .validate()
2744 .warnings
2745 .iter()
2746 .any(|w| w.contains("include_thoughts"))
2747 );
2748 }
2749
2750 #[test]
2751 fn background_tools_and_scheduling_parse_and_apply() {
2752 let spec = SessionSpec::from_value(json!({
2753 "instruction": "x",
2754 "tools": [
2755 {"name": "search_kb", "background": true},
2756 {"name": "log_event", "scheduling": "silent"}
2757 ],
2758 "flow": {"steps": [
2759 {"id": "s", "posture": "Serve.", "allow": ["search_kb", "log_event"],
2760 "done": {"called_ok": "search_kb"}}
2761 ]}
2762 }))
2763 .expect("parses");
2764 assert!(spec.validate().valid);
2765 assert!(
2766 spec.apply(Live::builder(), &State::new(), &SpecResources::default())
2767 .is_ok()
2768 );
2769 }
2770
2771 #[test]
2772 fn apply_configures_a_builder() {
2773 let spec = collections_spec();
2774 let state = State::new();
2775 let err = spec
2777 .apply(Live::builder(), &state, &SpecResources::default())
2778 .err()
2779 .expect("requires extraction llm");
2780 assert!(err.contains("extraction_llm"));
2781
2782 let mut no_extract = spec.clone();
2784 no_extract.extract.clear();
2785 assert!(
2786 no_extract
2787 .apply(Live::builder(), &state, &SpecResources::default())
2788 .is_ok()
2789 );
2790 }
2791}