Skip to main content

ai_agents_state/
config.rs

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    /// Whether to re-generate a response after state transitions (default: true).
32    #[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    /// Tool availability for this state.
52    /// - `None` (omitted in YAML): inherit from parent or agent-level tools
53    /// - `Some([])` (`tools: []` in YAML): explicitly no tools available
54    /// - `Some([...])`: only these tools available
55    #[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    /// Actions on re-entering a previously visited state. Falls back to on_enter if empty.
80    #[serde(default)]
81    pub on_reenter: Vec<StateAction>,
82
83    #[serde(default)]
84    pub on_exit: Vec<StateAction>,
85
86    /// Per-state override: skip re-generation on entering this state.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub regenerate_on_enter: Option<bool>,
89
90    /// Context extractors: pull structured data from user input into context.
91    #[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    /// Per-state process pipeline override (replaces agent-level pipeline for this state).
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub process: Option<ProcessConfig>,
106
107    /// Delegate state messages to a registry agent by ID.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub delegate: Option<String>,
110
111    /// Context mode for delegated states.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub delegate_context: Option<DelegateContextMode>,
114
115    /// Run multiple registry agents concurrently in this state.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub concurrent: Option<ConcurrentStateConfig>,
118
119    /// Run a multi-agent group chat in this state.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub group_chat: Option<GroupChatStateConfig>,
122
123    /// Run a sequential agent pipeline in this state.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub pipeline: Option<PipelineStateConfig>,
126
127    /// Run an LLM-directed handoff chain in this state.
128    #[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    // Order matters for serde untagged: structured variants must come before
205    // Exact(Value) because Value matches any valid JSON — including objects
206    // like `{ "exists": true }` or `{ "eq": "admin" }` that should be parsed
207    // as Exists or Compare instead.
208    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    /// Evaluate after the assistant response is available.
252    #[default]
253    PostResponse,
254    /// Evaluate before main response generation when the route is response independent.
255    PreResponse,
256    /// Evaluate in parallel with a draft response when explicitly enabled.
257    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    /// Intent label for deterministic routing after disambiguation.
269    #[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    /// Minimum turns before this transition can fire again after last use.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub cooldown_turns: Option<u32>,
279
280    /// Controls whether the transition can be selected before a response exists.
281    #[serde(default)]
282    pub timing: TransitionTiming,
283
284    /// Marks transitions whose condition needs the assistant response text.
285    #[serde(default)]
286    pub requires_response: bool,
287
288    /// Allows this transition to run current-state extractors before pre-response selection.
289    #[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/// Extract structured data from conversation into context via LLM.
336#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(deny_unknown_fields)]
338pub struct ContextExtractor {
339    /// Context key to store the extracted value.
340    pub key: String,
341
342    /// Short description of what to extract (LLM-based).
343    #[serde(default)]
344    pub description: Option<String>,
345
346    /// Custom LLM extraction prompt (takes precedence over `description`).
347    #[serde(default)]
348    pub llm_extract: Option<String>,
349
350    /// LLM alias for extraction (default: "router").
351    #[serde(default = "default_extractor_llm")]
352    pub llm: String,
353
354    /// If true, extraction failure is logged as a warning.
355    #[serde(default)]
356    pub required: bool,
357}
358
359//
360// Multi-agent orchestration config types for state delegation, concurrent execution, and group chat.
361//
362
363/// Context mode for delegated states.
364#[derive(Debug, Clone, Default, Serialize, Deserialize)]
365#[serde(rename_all = "snake_case")]
366pub enum DelegateContextMode {
367    /// Delegated agent receives only the user's current message.
368    #[default]
369    InputOnly,
370    /// Parent summarizes recent conversation via router LLM.
371    Summary,
372    /// Parent passes full recent message history.
373    Full,
374}
375
376/// Config for running multiple registry agents concurrently.
377#[derive(Debug, Clone, Serialize, Deserialize)]
378#[serde(deny_unknown_fields)]
379pub struct ConcurrentStateConfig {
380    /// Agent IDs in the registry (simple list or weighted entries).
381    pub agents: Vec<ConcurrentAgentRef>,
382    /// Jinja2 template for input sent to each agent.
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub input: Option<String>,
385    /// How to aggregate results from all agents.
386    pub aggregation: AggregationConfig,
387    /// Minimum agents that must succeed.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub min_required: Option<usize>,
390    /// What to do when some agents fail.
391    #[serde(default)]
392    pub on_partial_failure: PartialFailureAction,
393    /// Per-agent timeout in milliseconds.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub timeout_ms: Option<u64>,
396    /// Parent conversation context forwarded to each agent.
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub context_mode: Option<DelegateContextMode>,
399}
400
401/// Either a plain agent ID string or a weighted entry.
402#[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/// How to aggregate results from concurrent agents.
426#[derive(Debug, Clone, Serialize, Deserialize)]
427#[serde(deny_unknown_fields)]
428pub struct AggregationConfig {
429    /// Aggregation strategy.
430    pub strategy: AggregationStrategy,
431    /// LLM alias for synthesis or vote extraction.
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub synthesizer_llm: Option<String>,
434    /// Custom prompt for LLM synthesis.
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub synthesizer_prompt: Option<String>,
437    /// Voting sub-config.
438    #[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/// Voting config for concurrent agent aggregation.
452#[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    /// Custom prompt for extracting a vote from each agent's response.
460    #[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/// Group chat state config for multi-agent conversation.
491#[derive(Debug, Clone, Serialize, Deserialize)]
492#[serde(deny_unknown_fields)]
493pub struct GroupChatStateConfig {
494    /// Participant agent IDs with optional roles.
495    pub participants: Vec<ChatParticipant>,
496    /// Conversation style.
497    #[serde(default)]
498    pub style: ChatStyle,
499    /// Maximum conversation rounds.
500    #[serde(default = "default_max_rounds")]
501    pub max_rounds: u32,
502    /// Chat manager config.
503    #[serde(default, skip_serializing_if = "Option::is_none")]
504    pub manager: Option<ChatManagerConfig>,
505    /// When and how to terminate.
506    #[serde(default)]
507    pub termination: TerminationConfig,
508    /// Debate-specific config.
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub debate: Option<DebateStyleConfig>,
511    /// Maker-checker-specific config.
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub maker_checker: Option<MakerCheckerConfig>,
514    /// Total timeout for the group chat in milliseconds.
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub timeout_ms: Option<u64>,
517    /// Jinja2 template for the topic sent to participants.
518    /// `{{ user_input }}` is the user's message. `{{ context.<key> }}` accesses context values. When omitted, the raw user message is used as the topic.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub input: Option<String>,
521    /// Parent conversation context included in the topic.
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub context_mode: Option<DelegateContextMode>,
524}
525
526/// A participant in a group chat.
527#[derive(Debug, Clone, Serialize, Deserialize)]
528#[serde(deny_unknown_fields)]
529pub struct ChatParticipant {
530    /// Agent ID in the registry.
531    pub id: String,
532    /// Role description visible to all participants.
533    #[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/// Chat manager config for controlling turn order.
548#[derive(Debug, Clone, Serialize, Deserialize)]
549#[serde(deny_unknown_fields)]
550pub struct ChatManagerConfig {
551    /// Registry agent ID for chat management.
552    #[serde(default, skip_serializing_if = "Option::is_none")]
553    pub agent: Option<String>,
554    /// Built-in turn policy.
555    #[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/// Termination config for group chat.
568#[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/// Debate-specific config for group chat.
596#[derive(Debug, Clone, Serialize, Deserialize)]
597#[serde(deny_unknown_fields)]
598pub struct DebateStyleConfig {
599    #[serde(default = "default_debate_rounds")]
600    pub rounds: u32,
601    /// Agent ID that synthesizes the final answer.
602    pub synthesizer: String,
603}
604
605/// Maker-checker-specific config for group chat.
606#[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    /// LLM-evaluated acceptance criteria.
612    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/// Config for a pipeline state type.
640#[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    /// Parent conversation context forwarded to the first stage.
648    #[serde(default, skip_serializing_if = "Option::is_none")]
649    pub context_mode: Option<DelegateContextMode>,
650}
651
652/// A single stage in a pipeline state.
653#[derive(Debug, Clone, Serialize, Deserialize)]
654#[serde(untagged, deny_unknown_fields)]
655pub enum PipelineStageEntry {
656    /// Simple agent ID string.
657    Id(String),
658    /// Agent with optional input template.
659    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/// Config for a handoff state type.
683#[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    /// Jinja2 template for the input sent to the initial agent.
693    /// `{{ user_input }}` is the user's message. `{{ context.<key> }}` accesses context values. When omitted, the raw user message is forwarded directly.
694    #[serde(default, skip_serializing_if = "Option::is_none")]
695    pub input: Option<String>,
696    /// Parent conversation context forwarded to the initial agent.
697    #[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        // Warn about unreachable states (non-fatal)
773        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, &current_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, &current_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, &current_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 = &current_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    /// Resolve a transition target to a full dotted state path.
883    /// Order: `^prefix` (parent-level) → top-level → sibling → child → fallback literal.
884    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    /// Check for unreachable states. Returns warning messages.
913    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(&gt.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, &current));
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            // Explicitly set (including empty): use as-is, no inheritance
994            Some(tools) => Some(tools.iter().collect()),
995            // Not set: inherit from parent if available
996            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); // explicit tools override, no merge
1441
1442        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, // not specified → inherit
1473            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![]), // explicitly empty → no tools
1491            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}