1use ai_agents_core::{AgentError, Result};
2use ai_agents_disambiguation::StateDisambiguationOverride;
3use ai_agents_process::ProcessConfig;
4use ai_agents_reasoning::{ReasoningConfig, ReflectionConfig};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::{HashMap, HashSet};
8
9#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
10#[serde(rename_all = "lowercase")]
11pub enum PromptMode {
12 #[default]
13 Append,
14 Replace,
15 Prepend,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct StateConfig {
21 pub initial: String,
22 #[serde(default)]
23 pub states: HashMap<String, StateDefinition>,
24 #[serde(default)]
25 pub global_transitions: Vec<Transition>,
26 #[serde(default)]
27 pub fallback: Option<String>,
28 #[serde(default)]
29 pub max_no_transition: Option<u32>,
30
31 #[serde(default = "default_true")]
33 pub regenerate_on_transition: bool,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
37#[serde(deny_unknown_fields)]
38pub struct StateDefinition {
39 #[serde(default)]
40 pub prompt: Option<String>,
41
42 #[serde(default)]
43 pub prompt_mode: PromptMode,
44
45 #[serde(default)]
46 pub llm: Option<String>,
47
48 #[serde(default)]
49 pub skills: Vec<String>,
50
51 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub tools: Option<Vec<ToolRef>>,
57
58 #[serde(default)]
59 pub transitions: Vec<Transition>,
60
61 #[serde(default)]
62 pub max_turns: Option<u32>,
63
64 #[serde(default)]
65 pub timeout_to: Option<String>,
66
67 #[serde(default)]
68 pub initial: Option<String>,
69
70 #[serde(default)]
71 pub states: Option<HashMap<String, StateDefinition>>,
72
73 #[serde(default = "default_inherit_parent")]
74 pub inherit_parent: bool,
75
76 #[serde(default)]
77 pub on_enter: Vec<StateAction>,
78
79 #[serde(default)]
81 pub on_reenter: Vec<StateAction>,
82
83 #[serde(default)]
84 pub on_exit: Vec<StateAction>,
85
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub regenerate_on_enter: Option<bool>,
89
90 #[serde(default)]
92 pub extract: Vec<ContextExtractor>,
93
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub reasoning: Option<ReasoningConfig>,
96
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub reflection: Option<ReflectionConfig>,
99
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub disambiguation: Option<StateDisambiguationOverride>,
102
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub process: Option<ProcessConfig>,
106
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub delegate: Option<String>,
110
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub delegate_context: Option<DelegateContextMode>,
114
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub concurrent: Option<ConcurrentStateConfig>,
118
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub group_chat: Option<GroupChatStateConfig>,
122
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub pipeline: Option<PipelineStateConfig>,
126
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub handoff: Option<HandoffStateConfig>,
130}
131
132fn default_inherit_parent() -> bool {
133 true
134}
135
136fn default_true() -> bool {
137 true
138}
139
140fn default_extractor_llm() -> String {
141 "router".to_string()
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(untagged, deny_unknown_fields)]
146pub enum ToolRef {
147 Simple(String),
148 Conditional {
149 id: String,
150 condition: ToolCondition,
151 },
152}
153
154impl ToolRef {
155 pub fn id(&self) -> &str {
156 match self {
157 ToolRef::Simple(id) => id,
158 ToolRef::Conditional { id, .. } => id,
159 }
160 }
161
162 pub fn condition(&self) -> Option<&ToolCondition> {
163 match self {
164 ToolRef::Simple(_) => None,
165 ToolRef::Conditional { condition, .. } => Some(condition),
166 }
167 }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case", deny_unknown_fields)]
172pub enum ToolCondition {
173 Context(HashMap<String, ContextMatcher>),
174 State(StateMatcher),
175 AfterTool(String),
176 ToolResult {
177 tool: String,
178 result: HashMap<String, Value>,
179 },
180 Semantic {
181 when: String,
182 #[serde(default = "default_semantic_llm")]
183 llm: String,
184 #[serde(default = "default_threshold")]
185 threshold: f32,
186 },
187 Time(TimeMatcher),
188 All(Vec<ToolCondition>),
189 Any(Vec<ToolCondition>),
190 Not(Box<ToolCondition>),
191}
192
193fn default_semantic_llm() -> String {
194 "router".to_string()
195}
196
197fn default_threshold() -> f32 {
198 0.7
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202#[serde(untagged)]
203pub enum ContextMatcher {
204 Exists { exists: bool },
209 Compare(CompareOp),
210 Exact(Value),
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
214#[serde(rename_all = "snake_case")]
215pub enum CompareOp {
216 Eq(Value),
217 Neq(Value),
218 Gt(f64),
219 Gte(f64),
220 Lt(f64),
221 Lte(f64),
222 In(Vec<Value>),
223 Contains(String),
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize, Default)]
227#[serde(deny_unknown_fields)]
228pub struct StateMatcher {
229 #[serde(default)]
230 pub name: Option<String>,
231 #[serde(default)]
232 pub turn_count: Option<CompareOp>,
233 #[serde(default)]
234 pub previous: Option<String>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize, Default)]
238#[serde(deny_unknown_fields)]
239pub struct TimeMatcher {
240 #[serde(default)]
241 pub hours: Option<CompareOp>,
242 #[serde(default)]
243 pub day_of_week: Option<Vec<String>>,
244 #[serde(default)]
245 pub timezone: Option<String>,
246}
247
248#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
249#[serde(rename_all = "snake_case")]
250pub enum TransitionTiming {
251 #[default]
253 PostResponse,
254 PreResponse,
256 Parallel,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(deny_unknown_fields)]
262pub struct Transition {
263 pub to: String,
264 #[serde(default)]
265 pub when: String,
266 #[serde(default)]
267 pub guard: Option<TransitionGuard>,
268 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub intent: Option<String>,
271 #[serde(default = "default_auto")]
272 pub auto: bool,
273 #[serde(default)]
274 pub priority: u8,
275
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub cooldown_turns: Option<u32>,
279
280 #[serde(default)]
282 pub timing: TransitionTiming,
283
284 #[serde(default)]
286 pub requires_response: bool,
287
288 #[serde(default)]
290 pub run_extractors: bool,
291}
292
293fn default_auto() -> bool {
294 true
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
298#[serde(untagged, deny_unknown_fields)]
299pub enum TransitionGuard {
300 Expression(String),
301 Conditions(GuardConditions),
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305#[serde(rename_all = "snake_case", deny_unknown_fields)]
306pub enum GuardConditions {
307 All(Vec<String>),
308 Any(Vec<String>),
309 Context(HashMap<String, ContextMatcher>),
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(untagged, deny_unknown_fields)]
314pub enum StateAction {
315 Tool {
316 tool: String,
317 #[serde(default)]
318 args: Option<Value>,
319 },
320 Skill {
321 skill: String,
322 },
323 Prompt {
324 prompt: String,
325 #[serde(default)]
326 llm: Option<String>,
327 #[serde(default)]
328 store_as: Option<String>,
329 },
330 SetContext {
331 set_context: HashMap<String, Value>,
332 },
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(deny_unknown_fields)]
338pub struct ContextExtractor {
339 pub key: String,
341
342 #[serde(default)]
344 pub description: Option<String>,
345
346 #[serde(default)]
348 pub llm_extract: Option<String>,
349
350 #[serde(default = "default_extractor_llm")]
352 pub llm: String,
353
354 #[serde(default)]
356 pub required: bool,
357}
358
359#[derive(Debug, Clone, Default, Serialize, Deserialize)]
365#[serde(rename_all = "snake_case")]
366pub enum DelegateContextMode {
367 #[default]
369 InputOnly,
370 Summary,
372 Full,
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize)]
378#[serde(deny_unknown_fields)]
379pub struct ConcurrentStateConfig {
380 pub agents: Vec<ConcurrentAgentRef>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub input: Option<String>,
385 pub aggregation: AggregationConfig,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub min_required: Option<usize>,
390 #[serde(default)]
392 pub on_partial_failure: PartialFailureAction,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub timeout_ms: Option<u64>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub context_mode: Option<DelegateContextMode>,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
403#[serde(untagged, deny_unknown_fields)]
404pub enum ConcurrentAgentRef {
405 Id(String),
406 Weighted { id: String, weight: f64 },
407}
408
409impl ConcurrentAgentRef {
410 pub fn id(&self) -> &str {
411 match self {
412 Self::Id(id) => id,
413 Self::Weighted { id, .. } => id,
414 }
415 }
416
417 pub fn weight(&self) -> f64 {
418 match self {
419 Self::Id(_) => 1.0,
420 Self::Weighted { weight, .. } => *weight,
421 }
422 }
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize)]
427#[serde(deny_unknown_fields)]
428pub struct AggregationConfig {
429 pub strategy: AggregationStrategy,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub synthesizer_llm: Option<String>,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub synthesizer_prompt: Option<String>,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub vote: Option<VoteConfig>,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
443#[serde(rename_all = "snake_case")]
444pub enum AggregationStrategy {
445 Voting,
446 LlmSynthesis,
447 FirstWins,
448 All,
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize)]
453#[serde(deny_unknown_fields)]
454pub struct VoteConfig {
455 #[serde(default)]
456 pub method: VoteMethod,
457 #[serde(default)]
458 pub tiebreaker: TiebreakerStrategy,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub vote_prompt: Option<String>,
462}
463
464#[derive(Debug, Clone, Default, Serialize, Deserialize)]
465#[serde(rename_all = "snake_case")]
466pub enum VoteMethod {
467 #[default]
468 Majority,
469 Weighted,
470 Unanimous,
471}
472
473#[derive(Debug, Clone, Default, Serialize, Deserialize)]
474#[serde(rename_all = "snake_case")]
475pub enum TiebreakerStrategy {
476 #[default]
477 First,
478 Random,
479 RouterDecides,
480}
481
482#[derive(Debug, Clone, Default, Serialize, Deserialize)]
483#[serde(rename_all = "snake_case")]
484pub enum PartialFailureAction {
485 #[default]
486 ProceedWithAvailable,
487 Abort,
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize)]
492#[serde(deny_unknown_fields)]
493pub struct GroupChatStateConfig {
494 pub participants: Vec<ChatParticipant>,
496 #[serde(default)]
498 pub style: ChatStyle,
499 #[serde(default = "default_max_rounds")]
501 pub max_rounds: u32,
502 #[serde(default, skip_serializing_if = "Option::is_none")]
504 pub manager: Option<ChatManagerConfig>,
505 #[serde(default)]
507 pub termination: TerminationConfig,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
510 pub debate: Option<DebateStyleConfig>,
511 #[serde(default, skip_serializing_if = "Option::is_none")]
513 pub maker_checker: Option<MakerCheckerConfig>,
514 #[serde(default, skip_serializing_if = "Option::is_none")]
516 pub timeout_ms: Option<u64>,
517 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub input: Option<String>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub context_mode: Option<DelegateContextMode>,
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize)]
528#[serde(deny_unknown_fields)]
529pub struct ChatParticipant {
530 pub id: String,
532 #[serde(default, skip_serializing_if = "Option::is_none")]
534 pub role: Option<String>,
535}
536
537#[derive(Debug, Clone, Default, Serialize, Deserialize)]
538#[serde(rename_all = "snake_case")]
539pub enum ChatStyle {
540 #[default]
541 Brainstorm,
542 Debate,
543 MakerChecker,
544 Consensus,
545}
546
547#[derive(Debug, Clone, Serialize, Deserialize)]
549#[serde(deny_unknown_fields)]
550pub struct ChatManagerConfig {
551 #[serde(default, skip_serializing_if = "Option::is_none")]
553 pub agent: Option<String>,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
556 pub method: Option<TurnMethod>,
557}
558
559#[derive(Debug, Clone, Serialize, Deserialize)]
560#[serde(rename_all = "snake_case")]
561pub enum TurnMethod {
562 RoundRobin,
563 Random,
564 LlmDirected,
565}
566
567#[derive(Debug, Clone, Serialize, Deserialize)]
569#[serde(deny_unknown_fields)]
570pub struct TerminationConfig {
571 #[serde(default)]
572 pub method: TerminationMethod,
573 #[serde(default = "default_stall_rounds")]
574 pub max_stall_rounds: u32,
575}
576
577impl Default for TerminationConfig {
578 fn default() -> Self {
579 Self {
580 method: TerminationMethod::default(),
581 max_stall_rounds: default_stall_rounds(),
582 }
583 }
584}
585
586#[derive(Debug, Clone, Default, Serialize, Deserialize)]
587#[serde(rename_all = "snake_case")]
588pub enum TerminationMethod {
589 #[default]
590 ManagerDecides,
591 MaxRounds,
592 ConsensusReached,
593}
594
595#[derive(Debug, Clone, Serialize, Deserialize)]
597#[serde(deny_unknown_fields)]
598pub struct DebateStyleConfig {
599 #[serde(default = "default_debate_rounds")]
600 pub rounds: u32,
601 pub synthesizer: String,
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize)]
607#[serde(deny_unknown_fields)]
608pub struct MakerCheckerConfig {
609 #[serde(default = "default_maker_checker_iterations")]
610 pub max_iterations: u32,
611 pub acceptance_criteria: String,
613 #[serde(default)]
614 pub on_max_iterations: MaxIterationsAction,
615}
616
617#[derive(Debug, Clone, Default, Serialize, Deserialize)]
618#[serde(rename_all = "snake_case")]
619pub enum MaxIterationsAction {
620 #[default]
621 AcceptLast,
622 Escalate,
623 Fail,
624}
625
626fn default_max_rounds() -> u32 {
627 5
628}
629fn default_stall_rounds() -> u32 {
630 2
631}
632fn default_debate_rounds() -> u32 {
633 3
634}
635fn default_maker_checker_iterations() -> u32 {
636 3
637}
638
639#[derive(Debug, Clone, Serialize, Deserialize)]
641#[serde(deny_unknown_fields)]
642pub struct PipelineStateConfig {
643 pub stages: Vec<PipelineStageEntry>,
644
645 #[serde(default, skip_serializing_if = "Option::is_none")]
646 pub timeout_ms: Option<u64>,
647 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub context_mode: Option<DelegateContextMode>,
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize)]
654#[serde(untagged, deny_unknown_fields)]
655pub enum PipelineStageEntry {
656 Id(String),
658 Config {
660 id: String,
661 #[serde(default, skip_serializing_if = "Option::is_none")]
662 input: Option<String>,
663 },
664}
665
666impl PipelineStageEntry {
667 pub fn id(&self) -> &str {
668 match self {
669 Self::Id(id) => id,
670 Self::Config { id, .. } => id,
671 }
672 }
673
674 pub fn input(&self) -> Option<&str> {
675 match self {
676 Self::Id(_) => None,
677 Self::Config { input, .. } => input.as_deref(),
678 }
679 }
680}
681
682#[derive(Debug, Clone, Serialize, Deserialize)]
684#[serde(deny_unknown_fields)]
685pub struct HandoffStateConfig {
686 pub initial_agent: String,
687 pub available_agents: Vec<String>,
688
689 #[serde(default = "default_max_handoffs")]
690 pub max_handoffs: u32,
691
692 #[serde(default, skip_serializing_if = "Option::is_none")]
695 pub input: Option<String>,
696 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub context_mode: Option<DelegateContextMode>,
699}
700
701fn default_max_handoffs() -> u32 {
702 5
703}
704
705fn validate_transition_timing(
706 transition: &Transition,
707 scope: &str,
708 state_path: Option<&str>,
709) -> Result<()> {
710 if transition.requires_response && !matches!(transition.timing, TransitionTiming::PostResponse)
711 {
712 let location = state_path
713 .map(|path| format!("State '{}'", path))
714 .unwrap_or_else(|| "Global transition".to_string());
715 return Err(AgentError::InvalidSpec(format!(
716 "{} has response-dependent transition '{}' with non-post-response timing",
717 location, transition.to
718 )));
719 }
720 if matches!(transition.timing, TransitionTiming::Parallel)
721 && transition.guard.is_none()
722 && transition.intent.is_none()
723 && transition.when.trim().is_empty()
724 {
725 return Err(AgentError::InvalidSpec(format!(
726 "{} transition '{}' uses parallel timing without a guard, intent, or when condition",
727 scope, transition.to
728 )));
729 }
730 if matches!(transition.timing, TransitionTiming::PreResponse) {
731 if transition.guard.is_none() && transition.intent.is_none() {
732 return Err(AgentError::InvalidSpec(format!(
733 "{} transition '{}' uses pre-response timing without a guard or intent",
734 scope, transition.to
735 )));
736 }
737 if !transition.when.trim().is_empty() {
738 return Err(AgentError::InvalidSpec(format!(
739 "{} transition '{}' uses pre-response timing with response-dependent when text",
740 scope, transition.to
741 )));
742 }
743 }
744 Ok(())
745}
746
747impl StateConfig {
748 pub fn validate(&self) -> Result<()> {
749 if self.initial.is_empty() {
750 return Err(AgentError::InvalidSpec(
751 "State machine initial state cannot be empty".into(),
752 ));
753 }
754 if !self.states.contains_key(&self.initial) {
755 return Err(AgentError::InvalidSpec(format!(
756 "Initial state '{}' not found in states",
757 self.initial
758 )));
759 }
760 for transition in &self.global_transitions {
761 validate_transition_timing(transition, "Global", None)?;
762 if !self.is_valid_transition_target(&transition.to, &[], &self.states) {
763 return Err(AgentError::InvalidSpec(format!(
764 "Global transition targets unknown state '{}'",
765 transition.to
766 )));
767 }
768 }
769
770 self.validate_states(&self.states, &[])?;
771
772 for warning in self.check_reachability() {
774 tracing::warn!("{}", warning);
775 }
776
777 Ok(())
778 }
779
780 fn validate_states(
781 &self,
782 states: &HashMap<String, StateDefinition>,
783 parent_path: &[String],
784 ) -> Result<()> {
785 for (name, def) in states {
786 let current_path: Vec<String> = parent_path
787 .iter()
788 .cloned()
789 .chain(std::iter::once(name.clone()))
790 .collect();
791
792 for transition in &def.transitions {
793 let path = current_path.join(".");
794 validate_transition_timing(transition, "State", Some(&path))?;
795
796 if !self.is_valid_transition_target(&transition.to, ¤t_path, states) {
797 return Err(AgentError::InvalidSpec(format!(
798 "State '{}' has transition to unknown state '{}'",
799 current_path.join("."),
800 transition.to
801 )));
802 }
803 }
804
805 if let Some(ref timeout_state) = def.timeout_to
806 && !self.is_valid_transition_target(timeout_state, ¤t_path, states)
807 {
808 return Err(AgentError::InvalidSpec(format!(
809 "State '{}' has timeout_to unknown state '{}'",
810 current_path.join("."),
811 timeout_state
812 )));
813 }
814
815 if let Some(ref sub_states) = def.states {
816 if let Some(ref initial) = def.initial
817 && !sub_states.contains_key(initial)
818 {
819 return Err(AgentError::InvalidSpec(format!(
820 "State '{}' has initial sub-state '{}' that doesn't exist",
821 current_path.join("."),
822 initial
823 )));
824 }
825 self.validate_states(sub_states, ¤t_path)?;
826 }
827 }
828 Ok(())
829 }
830
831 fn is_valid_transition_target(
832 &self,
833 target: &str,
834 current_path: &[String],
835 states: &HashMap<String, StateDefinition>,
836 ) -> bool {
837 if let Some(target_name) = target.strip_prefix('^') {
838 return self.states.contains_key(target_name);
839 }
840
841 if states.contains_key(target) {
842 return true;
843 }
844
845 if current_path.len() > 1 {
846 let parent_path = ¤t_path[..current_path.len() - 1];
847 if let Some(parent_states) = self.get_states_at_path(parent_path)
848 && parent_states.contains_key(target)
849 {
850 return true;
851 }
852 }
853
854 self.states.contains_key(target)
855 }
856
857 fn get_states_at_path(&self, path: &[String]) -> Option<&HashMap<String, StateDefinition>> {
858 let mut current = &self.states;
859 for segment in path {
860 current = current.get(segment)?.states.as_ref()?;
861 }
862 Some(current)
863 }
864
865 pub fn get_state(&self, path: &str) -> Option<&StateDefinition> {
866 let parts: Vec<&str> = path.split('.').collect();
867 self.get_state_by_path(&parts)
868 }
869
870 fn get_state_by_path(&self, path: &[&str]) -> Option<&StateDefinition> {
871 if path.is_empty() {
872 return None;
873 }
874
875 let mut current = self.states.get(path[0])?;
876 for segment in &path[1..] {
877 current = current.states.as_ref()?.get(*segment)?;
878 }
879 Some(current)
880 }
881
882 pub fn resolve_full_path(&self, current_path: &str, target: &str) -> String {
885 if let Some(target) = target.strip_prefix('^') {
886 return target.to_string();
887 }
888
889 if self.states.contains_key(target) {
890 return target.to_string();
891 }
892
893 if !current_path.is_empty() {
894 let parts: Vec<&str> = current_path.split('.').collect();
895 if parts.len() > 1 {
896 let parent_path = parts[..parts.len() - 1].join(".");
897 let potential = format!("{}.{}", parent_path, target);
898 if self.get_state(&potential).is_some() {
899 return potential;
900 }
901 }
902
903 let potential = format!("{}.{}", current_path, target);
904 if self.get_state(&potential).is_some() {
905 return potential;
906 }
907 }
908
909 target.to_string()
910 }
911
912 pub fn check_reachability(&self) -> Vec<String> {
914 let mut reachable: HashSet<String> = HashSet::new();
915 reachable.insert(self.initial.clone());
916
917 if let Some(ref fb) = self.fallback {
918 reachable.insert(fb.clone());
919 }
920 for gt in &self.global_transitions {
921 reachable.insert(self.normalize_target(>.to));
922 }
923
924 let mut queue: Vec<String> = reachable.iter().cloned().collect();
925 while let Some(state_path) = queue.pop() {
926 if let Some(def) = self.get_state(&state_path) {
927 for t in &def.transitions {
928 let target = self.resolve_full_path(&state_path, &t.to);
929 if reachable.insert(target.clone()) {
930 queue.push(target);
931 }
932 }
933 if let Some(ref timeout) = def.timeout_to {
934 let target = self.resolve_full_path(&state_path, timeout);
935 if reachable.insert(target.clone()) {
936 queue.push(target);
937 }
938 }
939 if let (Some(initial), Some(_sub)) = (&def.initial, &def.states) {
940 let sub_path = format!("{}.{}", state_path, initial);
941 if reachable.insert(sub_path.clone()) {
942 queue.push(sub_path);
943 }
944 }
945 }
946 }
947
948 let all_states = self.collect_all_state_paths(&self.states, &[]);
949 let mut warnings = Vec::new();
950 for state_path in &all_states {
951 if !reachable.contains(state_path) {
952 warnings.push(format!(
953 "State '{}' appears unreachable — no transitions lead to it",
954 state_path
955 ));
956 }
957 }
958 warnings
959 }
960
961 fn normalize_target(&self, target: &str) -> String {
962 target.strip_prefix('^').unwrap_or(target).to_string()
963 }
964
965 fn collect_all_state_paths(
966 &self,
967 states: &HashMap<String, StateDefinition>,
968 parent: &[String],
969 ) -> Vec<String> {
970 let mut paths = Vec::new();
971 for (name, def) in states {
972 let mut current: Vec<String> = parent.to_vec();
973 current.push(name.clone());
974 paths.push(current.join("."));
975 if let Some(ref sub) = def.states {
976 paths.extend(self.collect_all_state_paths(sub, ¤t));
977 }
978 }
979 paths
980 }
981}
982
983impl StateDefinition {
984 pub fn has_sub_states(&self) -> bool {
985 self.states.as_ref().map(|s| !s.is_empty()).unwrap_or(false)
986 }
987
988 pub fn get_effective_tools<'a>(
989 &'a self,
990 parent: Option<&'a StateDefinition>,
991 ) -> Option<Vec<&'a ToolRef>> {
992 match &self.tools {
993 Some(tools) => Some(tools.iter().collect()),
995 None => {
997 if !self.inherit_parent {
998 return None;
999 }
1000 parent
1001 .and_then(|p| p.tools.as_ref())
1002 .map(|t| t.iter().collect())
1003 }
1004 }
1005 }
1006
1007 pub fn get_effective_skills<'a>(
1008 &'a self,
1009 parent: Option<&'a StateDefinition>,
1010 ) -> Vec<&'a String> {
1011 if !self.inherit_parent || parent.is_none() {
1012 return self.skills.iter().collect();
1013 }
1014
1015 let parent = parent.unwrap();
1016 let mut skills: Vec<&'a String> = parent.skills.iter().collect();
1017 skills.extend(self.skills.iter());
1018 skills
1019 }
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024 use super::*;
1025
1026 #[test]
1027 fn test_transition_timing_defaults_to_post_response() {
1028 let yaml = r#"
1029to: next
1030when: "ready"
1031"#;
1032 let transition: Transition = serde_yaml::from_str(yaml).unwrap();
1033 assert_eq!(transition.timing, TransitionTiming::PostResponse);
1034 assert!(!transition.requires_response);
1035 assert!(!transition.run_extractors);
1036 }
1037
1038 #[test]
1039 fn test_state_config_deserialize() {
1040 let yaml = r#"
1041initial: greeting
1042states:
1043 greeting:
1044 prompt: "Welcome!"
1045 transitions:
1046 - to: support
1047 when: "user needs help"
1048 auto: true
1049 support:
1050 prompt: "How can I help?"
1051 llm: fast
1052 tools:
1053 - search
1054"#;
1055 let config: StateConfig = serde_yaml::from_str(yaml).unwrap();
1056 assert_eq!(config.initial, "greeting");
1057 assert_eq!(config.states.len(), 2);
1058 assert!(config.validate().is_ok());
1059 }
1060
1061 #[test]
1062 fn test_state_accepts_dynamic_id_and_explicit_empty_disambiguation_fields() {
1063 let yaml = r#"
1064initial: checkout-v2
1065states:
1066 checkout-v2:
1067 skills: []
1068 tools: []
1069 disambiguation:
1070 required_clarity: []
1071"#;
1072 let config: StateConfig = serde_yaml::from_str(yaml).unwrap();
1073 let state = config.states.get("checkout-v2").unwrap();
1074 assert!(state.skills.is_empty());
1075 assert!(state.tools.as_ref().unwrap().is_empty());
1076 let disambiguation = state.disambiguation.as_ref().unwrap();
1077 assert!(disambiguation.required_clarity.is_empty());
1078 }
1079
1080 #[test]
1081 fn test_state_action_rejects_field_typo() {
1082 let yaml = r#"
1083tool: log_event
1084argz:
1085 event: "entered"
1086"#;
1087 assert!(serde_yaml::from_str::<StateAction>(yaml).is_err());
1088 }
1089
1090 #[test]
1091 fn test_transition_rejects_field_typo() {
1092 let yaml = r#"
1093to: done
1094priorty: 10
1095"#;
1096 assert!(serde_yaml::from_str::<Transition>(yaml).is_err());
1097 }
1098
1099 #[test]
1100 fn test_prompt_mode_default() {
1101 let def = StateDefinition::default();
1102 assert_eq!(def.prompt_mode, PromptMode::Append);
1103 }
1104
1105 #[test]
1106 fn test_response_dependent_pre_response_transition_is_invalid() {
1107 let yaml = r#"
1108initial: greeting
1109states:
1110 greeting:
1111 transitions:
1112 - to: done
1113 when: "after answer"
1114 timing: pre_response
1115 requires_response: true
1116 done:
1117 prompt: "Done"
1118"#;
1119 let config: StateConfig = serde_yaml::from_str(yaml).unwrap();
1120 assert!(config.validate().is_err());
1121 }
1122
1123 #[test]
1124 fn test_parallel_transition_timing_accepts_response_independent_condition() {
1125 let yaml = r#"
1126initial: greeting
1127states:
1128 greeting:
1129 transitions:
1130 - to: done
1131 when: "ready"
1132 timing: parallel
1133 done:
1134 prompt: "Done"
1135"#;
1136 let config: StateConfig = serde_yaml::from_str(yaml).unwrap();
1137 assert!(config.validate().is_ok());
1138 }
1139
1140 #[test]
1141 fn test_parallel_transition_without_condition_is_invalid() {
1142 let yaml = r#"
1143initial: greeting
1144states:
1145 greeting:
1146 transitions:
1147 - to: done
1148 timing: parallel
1149 done:
1150 prompt: "Done"
1151"#;
1152 let config: StateConfig = serde_yaml::from_str(yaml).unwrap();
1153 let err = config.validate().unwrap_err();
1154 assert!(err.to_string().contains("parallel timing without"));
1155 }
1156
1157 #[test]
1158 fn test_pre_response_when_without_guard_or_intent_is_invalid() {
1159 let yaml = r#"
1160initial: greeting
1161states:
1162 greeting:
1163 transitions:
1164 - to: done
1165 when: "ready"
1166 timing: pre_response
1167 done:
1168 prompt: "Done"
1169"#;
1170 let config: StateConfig = serde_yaml::from_str(yaml).unwrap();
1171 assert!(config.validate().is_err());
1172 }
1173
1174 #[test]
1175 fn test_pre_response_with_when_text_is_invalid() {
1176 let yaml = r#"
1177initial: greeting
1178states:
1179 greeting:
1180 transitions:
1181 - to: done
1182 when: "ready"
1183 guard:
1184 context:
1185 ready:
1186 eq: true
1187 timing: pre_response
1188 done:
1189 prompt: "Done"
1190"#;
1191 let config: StateConfig = serde_yaml::from_str(yaml).unwrap();
1192 assert!(config.validate().is_err());
1193 }
1194
1195 #[test]
1196 fn test_invalid_initial_state() {
1197 let config = StateConfig {
1198 initial: "nonexistent".into(),
1199 states: HashMap::new(),
1200 global_transitions: vec![],
1201 fallback: None,
1202 max_no_transition: None,
1203 regenerate_on_transition: true,
1204 };
1205 assert!(config.validate().is_err());
1206 }
1207
1208 #[test]
1209 fn test_invalid_transition_target() {
1210 let mut states = HashMap::new();
1211 states.insert(
1212 "start".into(),
1213 StateDefinition {
1214 transitions: vec![Transition {
1215 to: "nonexistent".into(),
1216 when: "always".into(),
1217 guard: None,
1218 intent: None,
1219 auto: true,
1220 priority: 0,
1221 cooldown_turns: None,
1222 timing: TransitionTiming::PostResponse,
1223 requires_response: false,
1224 run_extractors: false,
1225 }],
1226 ..Default::default()
1227 },
1228 );
1229 let config = StateConfig {
1230 initial: "start".into(),
1231 states,
1232 global_transitions: vec![],
1233 fallback: None,
1234 max_no_transition: None,
1235 regenerate_on_transition: true,
1236 };
1237 assert!(config.validate().is_err());
1238 }
1239
1240 #[test]
1241 fn test_hierarchical_states() {
1242 let yaml = r#"
1243initial: problem_solving
1244states:
1245 problem_solving:
1246 initial: gathering_info
1247 prompt: "Solving customer problem"
1248 states:
1249 gathering_info:
1250 prompt: "Ask questions"
1251 transitions:
1252 - to: proposing_solution
1253 when: "understood"
1254 proposing_solution:
1255 prompt: "Offer solution"
1256 transitions:
1257 - to: ^closing
1258 when: "resolved"
1259 closing:
1260 prompt: "Thank you"
1261"#;
1262 let config: StateConfig = serde_yaml::from_str(yaml).unwrap();
1263 assert!(config.validate().is_ok());
1264 assert!(
1265 config
1266 .states
1267 .get("problem_solving")
1268 .unwrap()
1269 .has_sub_states()
1270 );
1271 }
1272
1273 #[test]
1274 fn test_tool_ref_simple() {
1275 let yaml = r#"
1276tools:
1277 - calculator
1278 - search
1279"#;
1280 #[derive(Deserialize)]
1281 struct Test {
1282 tools: Vec<ToolRef>,
1283 }
1284 let t: Test = serde_yaml::from_str(yaml).unwrap();
1285 assert_eq!(t.tools.len(), 2);
1286 assert_eq!(t.tools[0].id(), "calculator");
1287 }
1288
1289 #[test]
1290 fn test_tool_ref_conditional() {
1291 let yaml = r#"
1292tools:
1293 - calculator
1294 - id: admin_tool
1295 condition:
1296 context:
1297 user.role: "admin"
1298"#;
1299 #[derive(Deserialize)]
1300 struct Test {
1301 tools: Vec<ToolRef>,
1302 }
1303 let t: Test = serde_yaml::from_str(yaml).unwrap();
1304 assert_eq!(t.tools.len(), 2);
1305 assert_eq!(t.tools[1].id(), "admin_tool");
1306 assert!(t.tools[1].condition().is_some());
1307 }
1308
1309 #[test]
1310 fn test_transition_with_guard() {
1311 let yaml = r#"
1312to: next_state
1313when: "user wants to proceed"
1314guard: "{{ context.has_data }}"
1315auto: true
1316priority: 10
1317"#;
1318 let t: Transition = serde_yaml::from_str(yaml).unwrap();
1319 assert!(t.guard.is_some());
1320 assert_eq!(t.priority, 10);
1321 }
1322
1323 #[test]
1324 fn test_state_action() {
1325 let yaml = r#"
1326- tool: log_event
1327 args:
1328 event: "entered"
1329- skill: greeting_skill
1330- set_context:
1331 entered: true
1332"#;
1333 let actions: Vec<StateAction> = serde_yaml::from_str(yaml).unwrap();
1334 assert_eq!(actions.len(), 3);
1335 match &actions[0] {
1336 StateAction::Tool { tool, .. } => assert_eq!(tool, "log_event"),
1337 _ => panic!("Expected Tool action"),
1338 }
1339 match &actions[1] {
1340 StateAction::Skill { skill } => assert_eq!(skill, "greeting_skill"),
1341 _ => panic!("Expected Skill action"),
1342 }
1343 match &actions[2] {
1344 StateAction::SetContext { set_context } => {
1345 assert!(set_context.contains_key("entered"));
1346 }
1347 _ => panic!("Expected SetContext action"),
1348 }
1349 }
1350
1351 #[test]
1352 fn test_complex_tool_condition() {
1353 let yaml = r#"
1354id: refund_tool
1355condition:
1356 all:
1357 - context:
1358 user.verified: true
1359 - semantic:
1360 when: "user wants refund"
1361 threshold: 0.85
1362"#;
1363 let tool: ToolRef = serde_yaml::from_str(yaml).unwrap();
1364 assert_eq!(tool.id(), "refund_tool");
1365 match tool.condition().unwrap() {
1366 ToolCondition::All(conditions) => assert_eq!(conditions.len(), 2),
1367 _ => panic!("Expected All condition"),
1368 }
1369 }
1370
1371 #[test]
1372 fn test_state_get_path() {
1373 let yaml = r#"
1374initial: problem_solving
1375states:
1376 problem_solving:
1377 initial: gathering_info
1378 states:
1379 gathering_info:
1380 prompt: "Ask"
1381 proposing:
1382 prompt: "Propose"
1383 closing:
1384 prompt: "Done"
1385"#;
1386 let config: StateConfig = serde_yaml::from_str(yaml).unwrap();
1387 assert!(config.get_state("problem_solving").is_some());
1388 assert!(config.get_state("problem_solving.gathering_info").is_some());
1389 assert!(config.get_state("closing").is_some());
1390 assert!(config.get_state("nonexistent").is_none());
1391 }
1392
1393 #[test]
1394 fn test_resolve_full_path() {
1395 let yaml = r#"
1396initial: problem_solving
1397states:
1398 problem_solving:
1399 initial: gathering_info
1400 states:
1401 gathering_info:
1402 prompt: "Ask"
1403 proposing:
1404 prompt: "Propose"
1405 closing:
1406 prompt: "Done"
1407"#;
1408 let config: StateConfig = serde_yaml::from_str(yaml).unwrap();
1409
1410 assert_eq!(
1411 config.resolve_full_path("problem_solving.gathering_info", "proposing"),
1412 "problem_solving.proposing"
1413 );
1414 assert_eq!(
1415 config.resolve_full_path("problem_solving.gathering_info", "^closing"),
1416 "closing"
1417 );
1418 assert_eq!(
1419 config.resolve_full_path("problem_solving", "closing"),
1420 "closing"
1421 );
1422 }
1423
1424 #[test]
1425 fn test_inherit_parent() {
1426 let parent = StateDefinition {
1427 tools: Some(vec![ToolRef::Simple("parent_tool".into())]),
1428 skills: vec!["parent_skill".into()],
1429 ..Default::default()
1430 };
1431
1432 let child = StateDefinition {
1433 tools: Some(vec![ToolRef::Simple("child_tool".into())]),
1434 skills: vec!["child_skill".into()],
1435 inherit_parent: true,
1436 ..Default::default()
1437 };
1438
1439 let effective_tools = child.get_effective_tools(Some(&parent)).unwrap();
1440 assert_eq!(effective_tools.len(), 1); let effective_skills = child.get_effective_skills(Some(&parent));
1443 assert_eq!(effective_skills.len(), 2);
1444 }
1445
1446 #[test]
1447 fn test_no_inherit_parent() {
1448 let parent = StateDefinition {
1449 tools: Some(vec![ToolRef::Simple("parent_tool".into())]),
1450 ..Default::default()
1451 };
1452
1453 let child = StateDefinition {
1454 tools: Some(vec![ToolRef::Simple("child_tool".into())]),
1455 inherit_parent: false,
1456 ..Default::default()
1457 };
1458
1459 let effective_tools = child.get_effective_tools(Some(&parent)).unwrap();
1460 assert_eq!(effective_tools.len(), 1);
1461 assert_eq!(effective_tools[0].id(), "child_tool");
1462 }
1463
1464 #[test]
1465 fn test_tools_none_inherits() {
1466 let parent = StateDefinition {
1467 tools: Some(vec![ToolRef::Simple("parent_tool".into())]),
1468 ..Default::default()
1469 };
1470
1471 let child = StateDefinition {
1472 tools: None, inherit_parent: true,
1474 ..Default::default()
1475 };
1476
1477 let effective_tools = child.get_effective_tools(Some(&parent)).unwrap();
1478 assert_eq!(effective_tools.len(), 1);
1479 assert_eq!(effective_tools[0].id(), "parent_tool");
1480 }
1481
1482 #[test]
1483 fn test_tools_empty_means_no_tools() {
1484 let parent = StateDefinition {
1485 tools: Some(vec![ToolRef::Simple("parent_tool".into())]),
1486 ..Default::default()
1487 };
1488
1489 let child = StateDefinition {
1490 tools: Some(vec![]), inherit_parent: true,
1492 ..Default::default()
1493 };
1494
1495 let effective_tools = child.get_effective_tools(Some(&parent)).unwrap();
1496 assert!(effective_tools.is_empty());
1497 }
1498
1499 #[test]
1500 fn test_state_with_disambiguation_override() {
1501 let yaml = r#"
1502initial: greeting
1503states:
1504 greeting:
1505 prompt: "Hello"
1506 transitions:
1507 - to: payment
1508 when: "User wants to pay"
1509 payment:
1510 prompt: "Processing payment"
1511 disambiguation:
1512 threshold: 0.95
1513 require_confirmation: true
1514 required_clarity:
1515 - recipient
1516 - amount
1517"#;
1518 let config: StateConfig = serde_yaml::from_str(yaml).unwrap();
1519 let payment = config.get_state("payment").unwrap();
1520 let disambig = payment.disambiguation.as_ref().unwrap();
1521 assert_eq!(disambig.threshold, Some(0.95));
1522 assert!(disambig.require_confirmation);
1523 assert_eq!(disambig.required_clarity.len(), 2);
1524 assert!(disambig.required_clarity.contains(&"recipient".to_string()));
1525
1526 let greeting = config.get_state("greeting").unwrap();
1527 assert!(greeting.disambiguation.is_none());
1528 }
1529
1530 #[test]
1531 fn test_context_extractor_vec_deserialize() {
1532 let yaml = r#"
1533initial: a
1534states:
1535 a:
1536 extract:
1537 - key: user_email
1538 description: "The user's email address"
1539 - key: order_id
1540 llm_extract: "Extract the order ID"
1541 required: true
1542"#;
1543 let config: StateConfig = serde_yaml::from_str(yaml).unwrap();
1544 let state = config.get_state("a").unwrap();
1545 assert_eq!(state.extract.len(), 2);
1546 assert_eq!(state.extract[0].key, "user_email");
1547 assert_eq!(
1548 state.extract[0].description.as_deref(),
1549 Some("The user's email address")
1550 );
1551 assert!(!state.extract[0].required);
1552 assert_eq!(state.extract[0].llm, "router");
1553 assert_eq!(state.extract[1].key, "order_id");
1554 assert!(state.extract[1].required);
1555 assert!(state.extract[1].llm_extract.is_some());
1556 }
1557
1558 #[test]
1559 fn test_context_extractor_default_empty() {
1560 let yaml = r#"
1561initial: a
1562states:
1563 a:
1564 prompt: "Hello"
1565"#;
1566 let config: StateConfig = serde_yaml::from_str(yaml).unwrap();
1567 let state = config.get_state("a").unwrap();
1568 assert!(state.extract.is_empty());
1569 }
1570
1571 #[test]
1572 fn test_state_process_override_deserialize() {
1573 let yaml = r#"
1574initial: a
1575states:
1576 a:
1577 process:
1578 input:
1579 - type: normalize
1580 config:
1581 trim: true
1582"#;
1583 let config: StateConfig = serde_yaml::from_str(yaml).unwrap();
1584 let state = config.get_state("a").unwrap();
1585 assert!(state.process.is_some());
1586 assert_eq!(state.process.as_ref().unwrap().input.len(), 1);
1587 }
1588
1589 #[test]
1590 fn test_state_process_default_none() {
1591 let yaml = r#"
1592initial: a
1593states:
1594 a:
1595 prompt: "Hello"
1596"#;
1597 let config: StateConfig = serde_yaml::from_str(yaml).unwrap();
1598 let state = config.get_state("a").unwrap();
1599 assert!(state.process.is_none());
1600 }
1601}