Skip to main content

ai_agents_runtime/spec/
mod.rs

1//! Agent specification types
2
3mod llm;
4mod memory;
5mod provider;
6pub mod spawner;
7pub(crate) mod storage;
8mod tool;
9
10pub use llm::{CliHitlMetadata, CliHitlStyle, CliMetadata, CliPromptStyle, LLMConfig, LLMSelector};
11pub use memory::MemoryConfig;
12pub use provider::ToolAliasesConfig;
13pub use spawner::{
14    AutoSpawnEntry, ManagementToolsConfig, OrchestrationToolsConfig, SpawnerConfig,
15    SpawnerToolGrantConfig, TemplateSource,
16};
17pub use storage::{FileStorageConfig, RedisStorageConfig, SqliteStorageConfig, StorageConfig};
18pub use tool::{StructuredToolEntry, ToolConfig, ToolEntry};
19
20use serde::{Deserialize, Deserializer, Serialize};
21use std::collections::{BTreeSet, HashMap};
22
23use ai_agents_context::ContextSource;
24use ai_agents_core::{AgentError, Result};
25use ai_agents_disambiguation::DisambiguationConfig;
26use ai_agents_hitl::HITLConfig;
27use ai_agents_observability::ObservabilityConfig;
28use ai_agents_persona::PersonaConfig;
29use ai_agents_process::{ProcessConfig, ProcessStage};
30use ai_agents_reasoning::{ReasoningConfig, ReflectionConfig};
31use ai_agents_recovery::{
32    ContextOverflowAction, ErrorRecoveryConfig, LLMFailureAction, RateLimitAction,
33};
34use ai_agents_skills::{SkillRef, SkillStep};
35use ai_agents_state::{
36    StateAction, StateConfig, StateDefinition, ToolCondition, Transition, TransitionTiming,
37};
38use ai_agents_tools::ToolSecurityConfig;
39
40pub use super::RuntimeConfig;
41use super::{ParallelToolsConfig, StreamingConfig};
42
43/// Stable agent schema loaded through strict framework-owned YAML boundaries.
44/// Fields remain in this contract only when the runtime implements their effect; removed inert sections such as `providers` and `provider_security` are rejected instead of silently accepted.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AgentSpec {
47    pub name: String,
48
49    #[serde(default = "default_version")]
50    pub version: String,
51
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub description: Option<String>,
54
55    pub system_prompt: String,
56
57    #[serde(default)]
58    pub llm: LLMConfigOrSelector,
59
60    #[serde(default)]
61    pub llms: HashMap<String, LLMConfig>,
62
63    #[serde(default)]
64    pub skills: Vec<SkillRef>,
65
66    #[serde(default)]
67    pub memory: MemoryConfig,
68
69    #[serde(default)]
70    pub storage: StorageConfig,
71
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub tools: Option<Vec<ToolConfig>>,
74
75    #[serde(default = "default_max_iterations")]
76    pub max_iterations: u32,
77
78    #[serde(default = "default_max_context_tokens")]
79    pub max_context_tokens: u32,
80
81    #[serde(default)]
82    pub error_recovery: ErrorRecoveryConfig,
83
84    #[serde(default)]
85    pub tool_security: ToolSecurityConfig,
86
87    #[serde(default)]
88    pub process: ProcessConfig,
89
90    #[serde(default)]
91    pub context: HashMap<String, ContextSource>,
92
93    #[serde(default)]
94    pub states: Option<StateConfig>,
95
96    #[serde(default)]
97    pub parallel_tools: ParallelToolsConfig,
98
99    #[serde(default)]
100    pub streaming: StreamingConfig,
101
102    #[serde(default)]
103    pub hitl: Option<HITLConfig>,
104
105    #[serde(default)]
106    pub reasoning: ReasoningConfig,
107
108    #[serde(default)]
109    pub reflection: ReflectionConfig,
110
111    #[serde(default)]
112    pub disambiguation: DisambiguationConfig,
113
114    #[serde(default)]
115    pub observability: ObservabilityConfig,
116
117    #[serde(default)]
118    pub runtime: RuntimeConfig,
119
120    #[serde(default)]
121    pub tool_aliases: ToolAliasesConfig,
122
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub metadata: Option<serde_json::Value>,
125
126    /// Dynamic agent spawning configuration (optional).
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub spawner: Option<SpawnerConfig>,
129
130    /// Agent persona configuration (optional).
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub persona: Option<PersonaConfig>,
133}
134
135#[derive(Debug, Clone, Serialize)]
136#[serde(untagged)]
137pub enum LLMConfigOrSelector {
138    Config(LLMConfig),
139    Selector(LLMSelector),
140}
141
142impl<'de> Deserialize<'de> for LLMConfigOrSelector {
143    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
144    where
145        D: Deserializer<'de>,
146    {
147        let value = serde_yaml::Value::deserialize(deserializer)?;
148        let mapping = value.as_mapping().ok_or_else(|| {
149            serde::de::Error::custom("llm must be a provider configuration or alias selector")
150        })?;
151        let has_provider_field = mapping
152            .keys()
153            .any(|key| matches!(key.as_str(), Some("provider") | Some("model")));
154
155        if has_provider_field {
156            serde_yaml::from_value(value)
157                .map(Self::Config)
158                .map_err(serde::de::Error::custom)
159        } else {
160            serde_yaml::from_value(value)
161                .map(Self::Selector)
162                .map_err(serde::de::Error::custom)
163        }
164    }
165}
166
167impl Default for LLMConfigOrSelector {
168    fn default() -> Self {
169        LLMConfigOrSelector::Config(LLMConfig::default())
170    }
171}
172
173impl LLMConfigOrSelector {
174    pub fn as_config(&self) -> Option<&LLMConfig> {
175        match self {
176            LLMConfigOrSelector::Config(c) => Some(c),
177            LLMConfigOrSelector::Selector(_) => None,
178        }
179    }
180
181    pub fn as_selector(&self) -> Option<&LLMSelector> {
182        match self {
183            LLMConfigOrSelector::Config(_) => None,
184            LLMConfigOrSelector::Selector(s) => Some(s),
185        }
186    }
187
188    pub fn get_default_alias(&self) -> String {
189        match self {
190            LLMConfigOrSelector::Config(_) => "default".to_string(),
191            LLMConfigOrSelector::Selector(s) => s.default.clone(),
192        }
193    }
194
195    pub fn get_router_alias(&self) -> Option<String> {
196        match self {
197            LLMConfigOrSelector::Config(_) => None,
198            LLMConfigOrSelector::Selector(s) => s.router.clone(),
199        }
200    }
201}
202
203fn default_version() -> String {
204    "1.0.0".to_string()
205}
206
207fn default_max_iterations() -> u32 {
208    10
209}
210
211fn default_max_context_tokens() -> u32 {
212    128000
213}
214
215fn state_config_has_parallel_transitions(config: &StateConfig) -> bool {
216    config.global_transitions.iter().any(transition_is_parallel)
217        || definitions_have_parallel_transitions(&config.states)
218}
219
220fn definitions_have_parallel_transitions(states: &HashMap<String, StateDefinition>) -> bool {
221    states.values().any(|definition| {
222        definition.transitions.iter().any(transition_is_parallel)
223            || definition
224                .states
225                .as_ref()
226                .map(definitions_have_parallel_transitions)
227                .unwrap_or(false)
228    })
229}
230
231fn transition_is_parallel(transition: &Transition) -> bool {
232    matches!(transition.timing, TransitionTiming::Parallel)
233}
234
235fn insert_alias(aliases: &mut BTreeSet<String>, alias: Option<&String>) {
236    if let Some(alias) = alias {
237        aliases.insert(alias.clone());
238    }
239}
240
241fn collect_reasoning_aliases(config: &ReasoningConfig, aliases: &mut BTreeSet<String>) {
242    if !config.is_enabled() {
243        return;
244    }
245    insert_alias(aliases, config.judge_llm.as_ref());
246    if config.needs_planning() {
247        insert_alias(
248            aliases,
249            config
250                .planning
251                .as_ref()
252                .and_then(|plan| plan.planner_llm.as_ref()),
253        );
254    }
255}
256
257fn collect_reflection_aliases(config: &ReflectionConfig, aliases: &mut BTreeSet<String>) {
258    if config.enabled.requires_evaluation() {
259        insert_alias(aliases, config.evaluator_llm.as_ref());
260    }
261}
262
263fn collect_process_aliases(config: &ProcessConfig, aliases: &mut BTreeSet<String>) {
264    fn collect_stage(stage: &ProcessStage, aliases: &mut BTreeSet<String>) {
265        let alias = match stage {
266            ProcessStage::Detect(stage) => stage.config.llm.as_ref(),
267            ProcessStage::Extract(stage) => stage.config.llm.as_ref(),
268            ProcessStage::Sanitize(stage) => stage.config.llm.as_ref(),
269            ProcessStage::Transform(stage) => stage.config.llm.as_ref(),
270            ProcessStage::Validate(stage) => stage.config.llm.as_ref(),
271            ProcessStage::Conditional(stage) => {
272                for nested in stage
273                    .config
274                    .then_stages
275                    .iter()
276                    .chain(&stage.config.else_stages)
277                {
278                    collect_stage(nested, aliases);
279                }
280                None
281            }
282            _ => None,
283        };
284        insert_alias(aliases, alias);
285    }
286
287    for stage in config.input.iter().chain(&config.output) {
288        collect_stage(stage, aliases);
289    }
290}
291
292fn collect_tool_condition_aliases(condition: &ToolCondition, aliases: &mut BTreeSet<String>) {
293    match condition {
294        ToolCondition::Semantic { llm, .. } => {
295            aliases.insert(llm.clone());
296        }
297        ToolCondition::All(conditions) | ToolCondition::Any(conditions) => {
298            for condition in conditions {
299                collect_tool_condition_aliases(condition, aliases);
300            }
301        }
302        ToolCondition::Not(condition) => collect_tool_condition_aliases(condition, aliases),
303        _ => {}
304    }
305}
306
307fn collect_state_aliases(config: &StateConfig, aliases: &mut BTreeSet<String>) {
308    fn collect_definition(definition: &StateDefinition, aliases: &mut BTreeSet<String>) {
309        insert_alias(aliases, definition.llm.as_ref());
310        for extractor in &definition.extract {
311            aliases.insert(extractor.llm.clone());
312        }
313        for action in definition
314            .on_enter
315            .iter()
316            .chain(&definition.on_reenter)
317            .chain(&definition.on_exit)
318        {
319            if let StateAction::Prompt { llm, .. } = action {
320                insert_alias(aliases, llm.as_ref());
321            }
322        }
323        for tool in definition.tools.iter().flatten() {
324            if let Some(condition) = tool.condition() {
325                collect_tool_condition_aliases(condition, aliases);
326            }
327        }
328        if let Some(reasoning) = definition.reasoning.as_ref() {
329            collect_reasoning_aliases(reasoning, aliases);
330        }
331        if let Some(reflection) = definition.reflection.as_ref() {
332            collect_reflection_aliases(reflection, aliases);
333        }
334        if let Some(process) = definition.process.as_ref() {
335            collect_process_aliases(process, aliases);
336        }
337        if let Some(concurrent) = definition.concurrent.as_ref() {
338            insert_alias(aliases, concurrent.aggregation.synthesizer_llm.as_ref());
339        }
340        if let Some(states) = definition.states.as_ref() {
341            for definition in states.values() {
342                collect_definition(definition, aliases);
343            }
344        }
345    }
346
347    for definition in config.states.values() {
348        collect_definition(definition, aliases);
349    }
350}
351
352impl Default for AgentSpec {
353    fn default() -> Self {
354        Self {
355            name: "Agent".to_string(),
356            version: default_version(),
357            description: None,
358            system_prompt: "You are a helpful assistant.".to_string(),
359            llm: LLMConfigOrSelector::default(),
360            llms: HashMap::new(),
361            skills: vec![],
362            memory: MemoryConfig::default(),
363            storage: StorageConfig::default(),
364            tools: None,
365            max_iterations: default_max_iterations(),
366            max_context_tokens: default_max_context_tokens(),
367            error_recovery: ErrorRecoveryConfig::default(),
368            tool_security: ToolSecurityConfig::default(),
369            process: ProcessConfig::default(),
370            context: HashMap::new(),
371            states: None,
372            parallel_tools: ParallelToolsConfig::default(),
373            streaming: StreamingConfig::default(),
374            hitl: None,
375            reasoning: ReasoningConfig::default(),
376            reflection: ReflectionConfig::default(),
377            disambiguation: DisambiguationConfig::default(),
378            observability: ObservabilityConfig::default(),
379            runtime: RuntimeConfig::default(),
380            tool_aliases: ToolAliasesConfig::default(),
381            metadata: None,
382            spawner: None,
383            persona: None,
384        }
385    }
386}
387
388fn normalize_unknown_path(path: &str) -> String {
389    path.replace(".?.", ".")
390        .trim_start_matches("?.")
391        .to_string()
392}
393
394fn format_paths(mut paths: Vec<String>) -> String {
395    paths.sort();
396    paths.dedup();
397    paths
398        .iter()
399        .map(|path| format!("'{path}'"))
400        .collect::<Vec<_>>()
401        .join(", ")
402}
403
404fn unknown_fields_error(paths: Vec<String>) -> AgentError {
405    AgentError::InvalidSpec(format!(
406        "Unknown AgentSpec field(s): {}",
407        format_paths(paths)
408    ))
409}
410
411fn unknown_field_from_error(error: &str) -> Option<&str> {
412    error
413        .split_once("unknown field `")
414        .and_then(|(_, rest)| rest.split_once('`'))
415        .map(|(field, _)| field)
416}
417
418fn detailed_error_path(path: &str, error: &str) -> String {
419    let Some(field) = unknown_field_from_error(error) else {
420        return path.to_string();
421    };
422    if path.is_empty() {
423        field.to_string()
424    } else if path == field || path.ends_with(&format!(".{field}")) {
425        path.to_string()
426    } else {
427        format!("{path}.{field}")
428    }
429}
430
431fn serde_error_message(error: &serde_yaml::Error) -> String {
432    let message = error.to_string();
433    let Some(location) = error.location() else {
434        return message;
435    };
436    let suffix = format!(" at line {} column {}", location.line(), location.column());
437    message
438        .strip_suffix(&suffix)
439        .unwrap_or(&message)
440        .to_string()
441}
442
443fn collect_unsupported_yaml_keys(
444    value: &serde_yaml::Value,
445    path: &str,
446    unsupported_paths: &mut Vec<String>,
447) {
448    match value {
449        serde_yaml::Value::Mapping(mapping) => {
450            for (key, child) in mapping {
451                let Some(key) = key.as_str() else {
452                    unsupported_paths.push(if path.is_empty() {
453                        "<non-string-key>".to_string()
454                    } else {
455                        format!("{path}.<non-string-key>")
456                    });
457                    continue;
458                };
459                let child_path = if path.is_empty() {
460                    key.to_string()
461                } else {
462                    format!("{path}.{key}")
463                };
464                if key == "<<" {
465                    unsupported_paths.push(child_path);
466                    continue;
467                }
468                collect_unsupported_yaml_keys(child, &child_path, unsupported_paths);
469            }
470        }
471        serde_yaml::Value::Sequence(values) => {
472            for (index, child) in values.iter().enumerate() {
473                collect_unsupported_yaml_keys(
474                    child,
475                    &format!("{path}[{index}]"),
476                    unsupported_paths,
477                );
478            }
479        }
480        _ => {}
481    }
482}
483
484impl AgentSpec {
485    pub(crate) fn referenced_llm_aliases(&self) -> BTreeSet<String> {
486        let mut aliases = BTreeSet::new();
487
488        if self.memory.memory_type == "compacting" {
489            insert_alias(&mut aliases, self.memory.summarizer_llm.as_ref());
490        }
491        if let Some(facts) = self.memory.facts.as_ref()
492            && facts.enabled
493        {
494            insert_alias(&mut aliases, facts.extractor_llm.as_ref());
495        }
496        if let Some(relationships) = self.memory.relationships.as_ref()
497            && relationships.enabled
498            && relationships.auto_update.enabled
499        {
500            insert_alias(&mut aliases, relationships.auto_update.llm.as_ref());
501        }
502
503        collect_reasoning_aliases(&self.reasoning, &mut aliases);
504        collect_reflection_aliases(&self.reflection, &mut aliases);
505        collect_process_aliases(&self.process, &mut aliases);
506        if let Some(states) = self.states.as_ref() {
507            collect_state_aliases(states, &mut aliases);
508        }
509
510        match &self.error_recovery.llm.on_failure {
511            LLMFailureAction::FallbackLlm { fallback_llm } => {
512                aliases.insert(fallback_llm.clone());
513            }
514            LLMFailureAction::Error | LLMFailureAction::FallbackResponse { .. } => {}
515        }
516        if let RateLimitAction::SwitchModel { fallback_llm } =
517            &self.error_recovery.llm.on_rate_limit
518        {
519            aliases.insert(fallback_llm.clone());
520        }
521        if let ContextOverflowAction::Summarize { summarizer_llm, .. } =
522            &self.error_recovery.llm.on_context_overflow
523        {
524            insert_alias(&mut aliases, summarizer_llm.as_ref());
525        }
526
527        if self.disambiguation.is_enabled() {
528            aliases.insert(self.disambiguation.detection.llm.clone());
529            insert_alias(&mut aliases, self.disambiguation.clarification.llm.as_ref());
530        }
531        if let Some(hitl) = self.hitl.as_ref()
532            && let Some(generate) = hitl.message_language.llm_generate.as_ref()
533        {
534            aliases.insert(generate.llm.clone());
535        }
536
537        for skill in &self.skills {
538            let SkillRef::Inline(skill) = skill else {
539                continue;
540            };
541            if let Some(reasoning) = skill.reasoning.as_ref() {
542                collect_reasoning_aliases(reasoning, &mut aliases);
543            }
544            if let Some(reflection) = skill.reflection.as_ref() {
545                collect_reflection_aliases(reflection, &mut aliases);
546            }
547            for step in &skill.steps {
548                if let SkillStep::Prompt { llm, .. } = step {
549                    insert_alias(&mut aliases, llm.as_ref());
550                }
551            }
552        }
553
554        aliases
555    }
556
557    pub fn from_yaml_strict(yaml: &str) -> Result<Self> {
558        let input_value: serde_yaml::Value = serde_yaml::from_str(yaml)?;
559        let mut unsupported_paths = Vec::new();
560        collect_unsupported_yaml_keys(&input_value, "", &mut unsupported_paths);
561        if !unsupported_paths.is_empty() {
562            return Err(AgentError::InvalidSpec(format!(
563                "Unsupported AgentSpec YAML key(s): {}",
564                format_paths(unsupported_paths)
565            )));
566        }
567
568        let mut unknown_paths = Vec::new();
569        let deserializer = serde_yaml::Deserializer::from_str(yaml);
570        let spec = match serde_ignored::deserialize(deserializer, |path| {
571            unknown_paths.push(normalize_unknown_path(&path.to_string()));
572        }) {
573            Ok(spec) => spec,
574            Err(error) => {
575                let deserializer = serde_yaml::Deserializer::from_str(yaml);
576                let detailed = serde_path_to_error::deserialize::<_, AgentSpec>(deserializer)
577                    .map_err(|path_error| {
578                        let path = normalize_unknown_path(&path_error.path().to_string());
579                        let error = path_error.inner();
580                        let message = serde_error_message(error);
581                        let detailed_path = detailed_error_path(&path, &message);
582                        let location = error
583                            .location()
584                            .map(|location| {
585                                format!(
586                                    " at line {}, column {}",
587                                    location.line(),
588                                    location.column()
589                                )
590                            })
591                            .unwrap_or_default();
592                        AgentError::InvalidSpec(format!(
593                            "Invalid AgentSpec field '{detailed_path}'{location}: {message}"
594                        ))
595                    });
596                return match detailed {
597                    Ok(_) => Err(error.into()),
598                    Err(error) => Err(error),
599                };
600            }
601        };
602
603        if !unknown_paths.is_empty() {
604            return Err(unknown_fields_error(unknown_paths));
605        }
606
607        Ok(spec)
608    }
609
610    pub fn validate(&self) -> Result<()> {
611        if self.name.is_empty() {
612            return Err(AgentError::InvalidSpec(
613                "Agent name cannot be empty".to_string(),
614            ));
615        }
616
617        if self.system_prompt.is_empty() {
618            return Err(AgentError::InvalidSpec(
619                "System prompt cannot be empty".to_string(),
620            ));
621        }
622
623        if self.max_iterations == 0 {
624            return Err(AgentError::InvalidSpec(
625                "Max iterations must be greater than 0".to_string(),
626            ));
627        }
628
629        if let Some(ref states) = self.states {
630            states.validate()?;
631        }
632
633        self.runtime.optimization.validate()?;
634        self.validate_runtime_optimization_cross_fields()?;
635
636        Ok(())
637    }
638
639    fn validate_runtime_optimization_cross_fields(&self) -> Result<()> {
640        let optimization = &self.runtime.optimization;
641        if matches!(
642            optimization.streaming_policy,
643            super::StreamingOptimizationPolicy::BufferUntilRoutingDone
644        ) {
645            if !optimization.enabled || !self.streaming.enabled {
646                return Err(AgentError::InvalidSpec(
647                    "runtime.optimization.streaming_policy=buffer_until_routing_done requires runtime optimization and streaming.enabled=true".into(),
648                ));
649            }
650            if self.streaming.buffer_size == 0 {
651                return Err(AgentError::InvalidSpec(
652                    "streaming.buffer_size must be greater than 0 with buffer_until_routing_done"
653                        .into(),
654                ));
655            }
656        }
657
658        if let Some(states) = &self.states {
659            let has_parallel = state_config_has_parallel_transitions(states);
660            if has_parallel
661                && (!optimization.enabled || !optimization.speculative_state_transitions)
662            {
663                return Err(AgentError::InvalidSpec(
664                    "transition timing parallel requires runtime.optimization.enabled=true and speculative_state_transitions=true".into(),
665                ));
666            }
667            if has_parallel && optimization.max_speculative_llm_calls_per_turn == 0 {
668                return Err(AgentError::InvalidSpec(
669                    "transition timing parallel requires max_speculative_llm_calls_per_turn greater than 0".into(),
670                ));
671            }
672        }
673        Ok(())
674    }
675
676    pub fn has_multi_llm(&self) -> bool {
677        !self.llms.is_empty()
678    }
679
680    pub fn has_skills(&self) -> bool {
681        !self.skills.is_empty()
682    }
683
684    pub fn has_process(&self) -> bool {
685        !self.process.input.is_empty() || !self.process.output.is_empty()
686    }
687
688    pub fn has_tool_security(&self) -> bool {
689        self.tool_security.enabled
690    }
691
692    pub fn has_states(&self) -> bool {
693        self.states.is_some()
694    }
695
696    pub fn has_context(&self) -> bool {
697        !self.context.is_empty()
698    }
699
700    pub fn has_parallel_tools(&self) -> bool {
701        self.parallel_tools.enabled
702    }
703
704    pub fn has_streaming(&self) -> bool {
705        self.streaming.enabled
706    }
707
708    pub fn has_hitl(&self) -> bool {
709        self.hitl.is_some()
710    }
711
712    pub fn has_storage(&self) -> bool {
713        !self.storage.is_none()
714    }
715
716    pub fn has_tool_aliases(&self) -> bool {
717        !self.tool_aliases.tools.is_empty()
718    }
719
720    pub fn has_reasoning(&self) -> bool {
721        self.reasoning.is_enabled()
722    }
723
724    pub fn has_reflection(&self) -> bool {
725        self.reflection.requires_evaluation()
726    }
727
728    pub fn has_disambiguation(&self) -> bool {
729        self.disambiguation.is_enabled()
730    }
731
732    pub fn has_observability(&self) -> bool {
733        self.observability.enabled
734    }
735
736    pub fn has_runtime_optimization(&self) -> bool {
737        self.runtime.optimization.enabled
738    }
739
740    pub fn has_persona(&self) -> bool {
741        self.persona.as_ref().is_some_and(|p| p.is_configured())
742    }
743
744    pub fn has_actor_memory(&self) -> bool {
745        self.memory.has_actor_memory()
746    }
747
748    pub fn has_facts(&self) -> bool {
749        self.memory.has_facts()
750    }
751
752    pub fn has_relationships(&self) -> bool {
753        self.memory.has_relationships()
754    }
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760
761    fn strict_error(yaml: &str) -> String {
762        AgentSpec::from_yaml_strict(yaml).unwrap_err().to_string()
763    }
764
765    fn assert_unknown_path(yaml: &str, expected_path: &str) {
766        let error = strict_error(yaml);
767        assert!(error.contains(expected_path), "{error}");
768    }
769
770    #[test]
771    fn test_agent_spec_minimal() {
772        let yaml = r#"
773name: TestAgent
774system_prompt: "You are a helpful assistant."
775llm:
776  provider: openai
777  model: gpt-4
778"#;
779        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
780        assert_eq!(spec.name, "TestAgent");
781        assert_eq!(spec.version, "1.0.0");
782        assert_eq!(spec.max_iterations, 10);
783        assert!(spec.validate().is_ok());
784    }
785
786    #[test]
787    fn test_agent_spec_rejects_top_level_typo() {
788        let yaml = r#"
789name: TestAgent
790system_prompt: "You are a helpful assistant."
791max_iteratons: 20
792"#;
793        assert_unknown_path(yaml, "max_iteratons");
794    }
795
796    #[test]
797    fn test_agent_spec_rejects_nested_typo() {
798        let yaml = r#"
799name: TestAgent
800system_prompt: "You are a helpful assistant."
801storage:
802  type: redis
803  url: redis://localhost:6379
804  ttl_second: 60
805"#;
806        assert_unknown_path(yaml, "storage.ttl_second");
807    }
808
809    #[test]
810    fn test_agent_spec_rejects_memory_typo() {
811        let yaml = r#"
812name: TestAgent
813system_prompt: "You are a helpful assistant."
814memory:
815  type: compacting
816  compress_thresold: 30
817"#;
818        assert_unknown_path(yaml, "memory.compress_thresold");
819    }
820
821    #[test]
822    fn test_agent_spec_preserves_llm_provider_extras() {
823        let yaml = r#"
824name: OllamaAgent
825system_prompt: "You are a helpful assistant."
826llm:
827  provider: ollama
828  model: llama3.1
829  num_ctx: 8192
830  keep_alive: 5m
831llms:
832  router:
833    provider: openai
834    model: gpt-4.1-nano
835    provider_extension: enabled
836"#;
837        let spec = AgentSpec::from_yaml_strict(yaml).unwrap();
838        let llm = spec.llm.as_config().unwrap();
839        assert_eq!(llm.extra.get("num_ctx"), Some(&serde_json::json!(8192)));
840        assert_eq!(llm.extra.get("keep_alive"), Some(&serde_json::json!("5m")));
841        assert_eq!(
842            spec.llms["router"].extra.get("provider_extension"),
843            Some(&serde_json::json!("enabled"))
844        );
845    }
846
847    #[test]
848    fn referenced_llm_aliases_use_typed_active_configuration() {
849        let yaml = r#"
850name: AliasAgent
851system_prompt: test
852llm:
853  provider: openai
854  model: test
855  extension:
856    llm: ignored_extension_value
857memory:
858  type: compacting
859  summarizer_llm: memory_summary
860  facts:
861    enabled: true
862    extractor_llm: fact_extract
863  relationships:
864    enabled: true
865    auto_update:
866      enabled: true
867      llm: relationship_eval
868reasoning:
869  mode: plan_and_execute
870  judge_llm: reasoning_judge
871  planning:
872    planner_llm: reasoning_plan
873reflection:
874  enabled: auto
875  evaluator_llm: reflection_eval
876process:
877  input:
878    - type: transform
879      config:
880        llm: process_transform
881states:
882  initial: active
883  states:
884    active:
885      llm: state_response
886      extract:
887        - key: value
888          description: value
889          llm: state_extract
890      concurrent:
891        agents: [worker]
892        aggregation:
893          strategy: llm_synthesis
894          synthesizer_llm: state_synthesis
895error_recovery:
896  llm:
897    on_failure:
898      action: fallback_llm
899      fallback_llm: recovery_fallback
900    on_rate_limit:
901      action: switch_model
902      fallback_llm: recovery_rate_limit
903    on_context_overflow:
904      action: summarize
905      summarizer_llm: recovery_summary
906disambiguation:
907  enabled: true
908  detection:
909    llm: disambiguation_detect
910  clarification:
911    llm: disambiguation_clarify
912hitl:
913  message_language:
914    strategy: llm_generate
915    llm_generate:
916      llm: hitl_generate
917skills:
918  - id: inline
919    description: inline
920    trigger: always
921    steps:
922      - prompt: test
923        llm: skill_prompt
924"#;
925        let spec = AgentSpec::from_yaml_strict(yaml).unwrap();
926
927        assert_eq!(
928            spec.referenced_llm_aliases(),
929            BTreeSet::from([
930                "disambiguation_clarify".to_string(),
931                "disambiguation_detect".to_string(),
932                "fact_extract".to_string(),
933                "hitl_generate".to_string(),
934                "memory_summary".to_string(),
935                "process_transform".to_string(),
936                "reasoning_judge".to_string(),
937                "reasoning_plan".to_string(),
938                "recovery_fallback".to_string(),
939                "recovery_rate_limit".to_string(),
940                "recovery_summary".to_string(),
941                "reflection_eval".to_string(),
942                "relationship_eval".to_string(),
943                "skill_prompt".to_string(),
944                "state_extract".to_string(),
945                "state_response".to_string(),
946                "state_synthesis".to_string(),
947            ])
948        );
949    }
950
951    #[test]
952    fn test_strict_yaml_reports_tagged_unknown_field_path() {
953        let yaml = "name: TestAgent\nsystem_prompt: test\nstorage:\n  type: redis\n  url: redis://localhost:6379\n  ttl_second: 60\n";
954        assert_unknown_path(yaml, "storage.ttl_second");
955    }
956
957    #[test]
958    fn test_strict_yaml_reports_untagged_selector_unknown_field_path() {
959        let yaml = "name: TestAgent\nsystem_prompt: test\nllm:\n  defualt: default\n";
960        assert_unknown_path(yaml, "llm.defualt");
961    }
962
963    #[test]
964    fn test_strict_yaml_reports_untagged_template_unknown_field_path() {
965        let yaml = "name: TestAgent\nsystem_prompt: test\nspawner:\n  templates:\n    npc:\n      pat: child.yaml\n";
966        assert_unknown_path(yaml, "spawner.templates.npc.pat");
967    }
968
969    #[test]
970    fn test_strict_yaml_rejects_runtime_optimization_typo() {
971        let yaml = r#"
972name: TestAgent
973system_prompt: test
974runtime:
975  optimization:
976    max_parallel_runtime_task: 4
977"#;
978        assert_unknown_path(yaml, "runtime.optimization.max_parallel_runtime_task");
979    }
980
981    #[test]
982    fn test_strict_yaml_rejects_tool_security_typo() {
983        let yaml = r#"
984name: TestAgent
985system_prompt: test
986tool_security:
987  enabeld: true
988"#;
989        assert_unknown_path(yaml, "tool_security.enabeld");
990    }
991
992    #[test]
993    fn test_strict_yaml_rejects_process_typo() {
994        let yaml = r#"
995name: TestAgent
996system_prompt: test
997process:
998  input:
999    - type: normalize
1000      config:
1001        trm: true
1002"#;
1003        let error = AgentSpec::from_yaml_strict(yaml).unwrap_err().to_string();
1004        assert!(error.contains("process.input[0]"), "{error}");
1005        assert!(error.contains("trm"), "{error}");
1006    }
1007
1008    #[test]
1009    fn test_strict_yaml_rejects_state_typo() {
1010        let yaml = r#"
1011name: TestAgent
1012system_prompt: test
1013states:
1014  initial: start
1015  states:
1016    start:
1017      promt: hello
1018"#;
1019        assert_unknown_path(yaml, "states.states.start.promt");
1020    }
1021
1022    #[test]
1023    fn test_strict_yaml_rejects_hitl_typo() {
1024        let yaml = r#"
1025name: TestAgent
1026system_prompt: test
1027hitl:
1028  default_timeout_second: 30
1029"#;
1030        assert_unknown_path(yaml, "hitl.default_timeout_second");
1031    }
1032
1033    #[test]
1034    fn test_strict_yaml_rejects_memory_and_storage_typos() {
1035        let memory_yaml = r#"
1036name: TestAgent
1037system_prompt: test
1038memory:
1039  type: compacting
1040  compress_thresold: 30
1041"#;
1042        assert_unknown_path(memory_yaml, "memory.compress_thresold");
1043
1044        let storage_yaml = r#"
1045name: TestAgent
1046system_prompt: test
1047storage:
1048  type: redis
1049  url: redis://localhost:6379
1050  ttl_second: 60
1051"#;
1052        let error = AgentSpec::from_yaml_strict(storage_yaml)
1053            .unwrap_err()
1054            .to_string();
1055        assert!(error.contains("storage"), "{error}");
1056        assert!(error.contains("ttl_second"), "{error}");
1057    }
1058
1059    #[test]
1060    fn test_strict_yaml_preserves_structured_tool_extensions() {
1061        let yaml = r#"
1062name: ToolAgent
1063system_prompt: test
1064tools:
1065  - name: github
1066    type: mcp
1067    transport: stdio
1068    command: npx
1069    args: ["-y", "@modelcontextprotocol/server-github"]
1070    env:
1071      GITHUB_TOKEN: test
1072  - name: http
1073    custom_header: X-Test
1074
1075tool_aliases:
1076  custom_tool:
1077    names:
1078      en: Custom Tool
1079metadata:
1080  custom:
1081    arbitrary: true
1082tool_security:
1083  tools:
1084    dangerous:
1085      require_approval: true
1086"#;
1087        let spec = AgentSpec::from_yaml_strict(yaml).unwrap();
1088        let tools = spec.tools.unwrap();
1089        assert!(tools[0].is_mcp());
1090        match &tools[1] {
1091            ToolEntry::Structured(tool) => {
1092                assert_eq!(
1093                    tool.extra.get("custom_header"),
1094                    Some(&serde_json::json!("X-Test"))
1095                );
1096            }
1097            ToolEntry::Simple(_) => panic!("expected structured tool"),
1098        }
1099
1100        assert!(spec.tool_aliases.tools.contains_key("custom_tool"));
1101        assert!(spec.tool_security.tools["dangerous"].require_confirmation);
1102        assert_eq!(
1103            spec.metadata.as_ref().unwrap()["custom"]["arbitrary"],
1104            serde_json::json!(true)
1105        );
1106    }
1107
1108    #[test]
1109    fn test_strict_yaml_rejects_removed_provider_sections() {
1110        for field in ["providers", "provider_security"] {
1111            let yaml = format!("name: TestAgent\nsystem_prompt: test\n{field}: {{}}\n");
1112            assert_unknown_path(&yaml, field);
1113        }
1114    }
1115
1116    #[test]
1117    fn test_strict_yaml_accepts_explicit_empty_known_fields() {
1118        let yaml = r#"
1119name: EmptyFieldsAgent
1120system_prompt: test
1121skills:
1122  - id: inline
1123    description: test
1124    trigger: test
1125    steps:
1126      - prompt: hello
1127    disambiguation:
1128      required_clarity: []
1129      clarification_templates: {}
1130"#;
1131        AgentSpec::from_yaml_strict(yaml).unwrap();
1132    }
1133
1134    #[test]
1135    fn test_strict_yaml_rejects_null_and_non_string_skill_keys() {
1136        let null_typo = r#"
1137name: NullTypoAgent
1138system_prompt: test
1139skills:
1140  - file: child.yaml
1141    typo:
1142"#;
1143        assert_unknown_path(null_typo, "skills[0]");
1144
1145        let non_string_key = r#"
1146name: NumericKeyAgent
1147system_prompt: test
1148skills:
1149  - file: child.yaml
1150    1: ignored
1151"#;
1152        assert_unknown_path(non_string_key, "skills[0].<non-string-key>");
1153    }
1154
1155    #[test]
1156    fn test_strict_yaml_rejects_merge_keys_everywhere() {
1157        let yaml = r#"
1158name: MergeAgent
1159system_prompt: test
1160llm:
1161  provider: ollama
1162  model: llama3.1
1163  <<:
1164    num_ctx: 8192
1165"#;
1166        assert_unknown_path(yaml, "llm.<<");
1167    }
1168
1169    #[test]
1170    fn test_agent_spec_with_states() {
1171        let yaml = r#"
1172name: StatefulAgent
1173system_prompt: "You are helpful."
1174llm:
1175  provider: openai
1176  model: gpt-4
1177states:
1178  initial: greeting
1179  states:
1180    greeting:
1181      prompt: "Welcome!"
1182      transitions:
1183        - to: support
1184          when: "user needs help"
1185    support:
1186      prompt: "How can I help?"
1187"#;
1188        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1189        assert!(spec.has_states());
1190        assert!(spec.validate().is_ok());
1191    }
1192
1193    #[test]
1194    fn test_agent_spec_with_context() {
1195        let yaml = r#"
1196name: ContextAgent
1197system_prompt: "Hello, {{ context.user.name }}!"
1198llm:
1199  provider: openai
1200  model: gpt-4
1201context:
1202  user:
1203    type: runtime
1204    required: true
1205  time:
1206    type: builtin
1207    source: datetime
1208    refresh: per_turn
1209"#;
1210        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1211        assert!(spec.has_context());
1212        assert_eq!(spec.context.len(), 2);
1213    }
1214
1215    #[test]
1216    fn test_agent_spec_with_tool_security() {
1217        let yaml = r#"
1218name: SecureAgent
1219version: 2.0.0
1220system_prompt: "You are an advanced AI."
1221llm:
1222  provider: openai
1223  model: gpt-4
1224max_context_tokens: 8192
1225error_recovery:
1226  default:
1227    max_retries: 5
1228tool_security:
1229  enabled: true
1230  default_timeout_ms: 10000
1231  tools:
1232    http:
1233      rate_limit: 10
1234      blocked_domains:
1235        - evil.com
1236process:
1237  input:
1238    - type: normalize
1239      config:
1240        trim: true
1241"#;
1242        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1243        assert_eq!(spec.name, "SecureAgent");
1244        assert_eq!(spec.max_context_tokens, 8192);
1245        assert_eq!(spec.error_recovery.default.max_retries, 5);
1246        assert!(spec.tool_security.enabled);
1247        assert!(spec.has_tool_security());
1248        assert!(!spec.process.input.is_empty());
1249        assert!(spec.has_process());
1250    }
1251
1252    #[test]
1253    fn test_agent_spec_with_multi_llm() {
1254        let yaml = r#"
1255name: MultiLLMAgent
1256system_prompt: "You are helpful."
1257llms:
1258  default:
1259    provider: openai
1260    model: gpt-4.1-nano
1261  router:
1262    provider: openai
1263    model: gpt-4.1-nano
1264llm:
1265  default: default
1266  router: router
1267"#;
1268        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1269        assert!(spec.has_multi_llm());
1270        assert_eq!(spec.llms.len(), 2);
1271        assert!(spec.llms.contains_key("default"));
1272        assert!(spec.llms.contains_key("router"));
1273    }
1274
1275    #[test]
1276    fn test_agent_spec_with_skills() {
1277        let yaml = r#"
1278name: SkillAgent
1279system_prompt: "You are helpful."
1280llm:
1281  provider: openai
1282  model: gpt-4
1283skills:
1284  - weather_clothes
1285  - file: ./custom.yaml
1286  - id: inline_skill
1287    description: "An inline skill"
1288    trigger: "When user asks"
1289    steps:
1290      - prompt: "Hello"
1291"#;
1292        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1293        assert!(spec.has_skills());
1294        assert_eq!(spec.skills.len(), 3);
1295    }
1296
1297    #[test]
1298    fn test_agent_spec_validation_empty_name() {
1299        let mut spec = AgentSpec {
1300            name: String::new(),
1301            ..AgentSpec::default()
1302        };
1303        assert!(spec.validate().is_err());
1304
1305        spec.name = "Valid".to_string();
1306        assert!(spec.validate().is_ok());
1307    }
1308
1309    #[test]
1310    fn test_agent_spec_validation_empty_prompt() {
1311        let mut spec = AgentSpec {
1312            system_prompt: String::new(),
1313            ..AgentSpec::default()
1314        };
1315        assert!(spec.validate().is_err());
1316
1317        spec.system_prompt = "Valid prompt".to_string();
1318        assert!(spec.validate().is_ok());
1319    }
1320
1321    #[test]
1322    fn test_agent_spec_validation_zero_iterations() {
1323        let mut spec = AgentSpec {
1324            max_iterations: 0,
1325            ..AgentSpec::default()
1326        };
1327        assert!(spec.validate().is_err());
1328
1329        spec.max_iterations = 5;
1330        assert!(spec.validate().is_ok());
1331    }
1332
1333    #[test]
1334    fn test_agent_spec_with_parallel_tools() {
1335        let yaml = r#"
1336name: ParallelAgent
1337system_prompt: "You are helpful."
1338llm:
1339  provider: openai
1340  model: gpt-4
1341parallel_tools:
1342  enabled: true
1343  max_parallel: 10
1344"#;
1345        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1346        assert!(spec.has_parallel_tools());
1347        assert_eq!(spec.parallel_tools.max_parallel, 10);
1348    }
1349
1350    #[test]
1351    fn test_agent_spec_with_streaming() {
1352        let yaml = r#"
1353name: StreamingAgent
1354system_prompt: "You are helpful."
1355llm:
1356  provider: openai
1357  model: gpt-4
1358streaming:
1359  enabled: true
1360  buffer_size: 64
1361  include_tool_events: true
1362"#;
1363        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1364        assert!(spec.has_streaming());
1365        assert_eq!(spec.streaming.buffer_size, 64);
1366    }
1367
1368    #[test]
1369    fn test_agent_spec_defaults() {
1370        let spec = AgentSpec::default();
1371        assert!(spec.parallel_tools.enabled);
1372        assert_eq!(spec.parallel_tools.max_parallel, 5);
1373        assert!(spec.streaming.enabled);
1374        assert!(!spec.has_hitl());
1375    }
1376
1377    #[test]
1378    fn test_agent_spec_with_hitl() {
1379        let yaml = r#"
1380name: HITLAgent
1381system_prompt: "You are helpful."
1382llm:
1383  provider: openai
1384  model: gpt-4
1385hitl:
1386  default_timeout_seconds: 600
1387  on_timeout: reject
1388  tools:
1389    send_payment:
1390      require_approval: true
1391      approval_context:
1392        - amount
1393        - recipient
1394      approval_message: "Approve payment?"
1395  conditions:
1396    - name: high_value
1397      when: "amount > 1000"
1398      require_approval: true
1399  states:
1400    escalation:
1401      on_enter: require_approval
1402"#;
1403        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1404        assert!(spec.has_hitl());
1405        let hitl = spec.hitl.as_ref().unwrap();
1406        assert_eq!(hitl.default_timeout_seconds, 600);
1407        assert_eq!(hitl.tools.len(), 1);
1408        assert_eq!(hitl.conditions.len(), 1);
1409        assert_eq!(hitl.states.len(), 1);
1410    }
1411
1412    #[test]
1413    fn test_agent_spec_with_storage_file() {
1414        let yaml = r#"
1415name: PersistentAgent
1416system_prompt: "You are helpful."
1417llm:
1418  provider: openai
1419  model: gpt-4
1420storage:
1421  type: file
1422  path: "./data/sessions"
1423"#;
1424        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1425        assert!(spec.has_storage());
1426        assert!(spec.storage.is_file());
1427        assert_eq!(spec.storage.get_path(), Some("./data/sessions"));
1428    }
1429
1430    #[test]
1431    fn test_agent_spec_with_storage_sqlite() {
1432        let yaml = r#"
1433name: PersistentAgent
1434system_prompt: "You are helpful."
1435llm:
1436  provider: openai
1437  model: gpt-4
1438storage:
1439  type: sqlite
1440  path: "./data/sessions.db"
1441"#;
1442        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1443        assert!(spec.has_storage());
1444        assert!(spec.storage.is_sqlite());
1445    }
1446
1447    #[test]
1448    fn test_agent_spec_with_storage_redis() {
1449        let yaml = r#"
1450name: PersistentAgent
1451system_prompt: "You are helpful."
1452llm:
1453  provider: openai
1454  model: gpt-4
1455storage:
1456  type: redis
1457  url: "redis://localhost:6379"
1458  prefix: "myagent:"
1459  ttl_seconds: 86400
1460"#;
1461        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1462        assert!(spec.has_storage());
1463        assert!(spec.storage.is_redis());
1464        assert_eq!(spec.storage.get_url(), Some("redis://localhost:6379"));
1465        assert_eq!(spec.storage.get_prefix(), "myagent:");
1466        assert_eq!(spec.storage.get_ttl(), Some(86400));
1467    }
1468
1469    #[test]
1470    fn test_agent_spec_no_storage_by_default() {
1471        let spec = AgentSpec::default();
1472        assert!(!spec.has_storage());
1473        assert!(spec.storage.is_none());
1474    }
1475
1476    #[test]
1477    fn test_agent_spec_with_tool_aliases() {
1478        let yaml = r#"
1479name: AliasAgent
1480system_prompt: "You are helpful."
1481llm:
1482  provider: openai
1483  model: gpt-4
1484tool_aliases:
1485  calculator:
1486    names:
1487      ko: 계산기
1488      ja: 計算機
1489    descriptions:
1490      ko: 수학 계산을 합니다
1491"#;
1492        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1493        assert!(spec.has_tool_aliases());
1494        let calc_aliases = spec.tool_aliases.tools.get("calculator").unwrap();
1495        assert_eq!(calc_aliases.get_name("ko"), Some("계산기"));
1496    }
1497
1498    #[test]
1499    fn test_agent_spec_with_reasoning() {
1500        let yaml = r#"
1501    name: ReasoningAgent
1502    system_prompt: "You are helpful."
1503    llm:
1504      provider: openai
1505      model: gpt-4
1506    reasoning:
1507      mode: cot
1508      judge_llm: router
1509      output: tagged
1510      max_iterations: 8
1511    "#;
1512        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1513        assert!(spec.has_reasoning());
1514        assert_eq!(spec.reasoning.max_iterations, 8);
1515    }
1516
1517    #[test]
1518    fn test_agent_spec_with_reflection() {
1519        let yaml = r#"
1520    name: ReflectionAgent
1521    system_prompt: "You are helpful."
1522    llm:
1523      provider: openai
1524      model: gpt-4
1525    reflection:
1526      enabled: auto
1527      evaluator_llm: router
1528      max_retries: 3
1529      pass_threshold: 0.8
1530      criteria:
1531        - "Response addresses the question"
1532        - "Response is accurate"
1533    "#;
1534        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1535        assert!(spec.has_reflection());
1536        assert_eq!(spec.reflection.max_retries, 3);
1537        assert_eq!(spec.reflection.criteria.len(), 2);
1538    }
1539
1540    #[test]
1541    fn test_agent_spec_with_plan_and_execute() {
1542        let yaml = r#"
1543    name: PlanningAgent
1544    system_prompt: "You are helpful."
1545    llm:
1546      provider: openai
1547      model: gpt-4
1548    reasoning:
1549      mode: plan_and_execute
1550      planning:
1551        planner_llm: router
1552        max_steps: 15
1553        available:
1554          tools: all
1555          skills:
1556            - analyze
1557            - summarize
1558        reflection:
1559          enabled: true
1560          on_step_failure: replan
1561          max_replans: 3
1562    "#;
1563        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1564        assert!(spec.has_reasoning());
1565        let planning = spec.reasoning.planning.as_ref().unwrap();
1566        assert_eq!(planning.max_steps, 15);
1567        assert!(planning.reflection.enabled);
1568    }
1569
1570    #[test]
1571    fn test_agent_spec_reasoning_defaults() {
1572        let spec = AgentSpec::default();
1573        assert!(!spec.has_reasoning());
1574        assert!(!spec.has_reflection());
1575    }
1576
1577    #[test]
1578    fn test_agent_spec_state_level_reasoning_override() {
1579        let yaml = r#"
1580    name: StateReasoningAgent
1581    system_prompt: "You are helpful."
1582    llm:
1583      provider: openai
1584      model: gpt-4
1585    reasoning:
1586      mode: auto
1587    states:
1588      initial: greeting
1589      states:
1590        greeting:
1591          prompt: "Welcome!"
1592          reasoning:
1593            mode: none
1594        complex_analysis:
1595          prompt: "Analyze this"
1596          reasoning:
1597            mode: cot
1598            output: tagged
1599          reflection:
1600            enabled: true
1601            criteria:
1602              - "Analysis is thorough"
1603    "#;
1604        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1605        assert!(spec.has_reasoning());
1606        assert!(spec.has_states());
1607
1608        let states = spec.states.as_ref().unwrap();
1609        let greeting = states.states.get("greeting").unwrap();
1610        assert!(greeting.reasoning.is_some());
1611        let greeting_reasoning = greeting.reasoning.as_ref().unwrap();
1612        assert_eq!(
1613            greeting_reasoning.mode,
1614            ai_agents_reasoning::ReasoningMode::None
1615        );
1616
1617        let analysis = states.states.get("complex_analysis").unwrap();
1618        assert!(analysis.reasoning.is_some());
1619        assert!(analysis.reflection.is_some());
1620        let analysis_reasoning = analysis.reasoning.as_ref().unwrap();
1621        assert_eq!(
1622            analysis_reasoning.mode,
1623            ai_agents_reasoning::ReasoningMode::CoT
1624        );
1625    }
1626
1627    #[test]
1628    fn test_agent_spec_skill_level_reasoning_override() {
1629        use ai_agents_skills::SkillDefinition;
1630
1631        let skill_yaml = r#"
1632id: complex_analysis
1633description: "Analyze data"
1634trigger: "When user asks for analysis"
1635reasoning:
1636  mode: cot
1637reflection:
1638  enabled: true
1639  criteria:
1640    - "Analysis covers all aspects"
1641steps:
1642  - prompt: "Analyze the input"
1643"#;
1644        let skill_def: SkillDefinition = serde_yaml::from_str(skill_yaml).unwrap();
1645        assert!(skill_def.reasoning.is_some());
1646        assert!(skill_def.reflection.is_some());
1647        let reasoning = skill_def.reasoning.as_ref().unwrap();
1648        assert_eq!(reasoning.mode, ai_agents_reasoning::ReasoningMode::CoT);
1649        let reflection = skill_def.reflection.as_ref().unwrap();
1650        assert!(reflection.is_enabled());
1651
1652        let simple_yaml = r#"
1653id: simple_lookup
1654description: "Look up simple facts"
1655trigger: "When user asks for facts"
1656reasoning:
1657  mode: none
1658reflection:
1659  enabled: false
1660steps:
1661  - prompt: "Look up the fact"
1662"#;
1663        let simple_def: SkillDefinition = serde_yaml::from_str(simple_yaml).unwrap();
1664        assert!(simple_def.reasoning.is_some());
1665        let simple_reasoning = simple_def.reasoning.as_ref().unwrap();
1666        assert_eq!(
1667            simple_reasoning.mode,
1668            ai_agents_reasoning::ReasoningMode::None
1669        );
1670    }
1671
1672    #[test]
1673    fn test_agent_spec_with_disambiguation() {
1674        let yaml = r#"
1675name: DisambiguatingAgent
1676system_prompt: "You are a helpful assistant."
1677disambiguation:
1678  enabled: true
1679  detection:
1680    llm: router
1681    threshold: 0.8
1682    aspects:
1683      - missing_target
1684      - vague_references
1685  clarification:
1686    style: auto
1687    max_attempts: 3
1688    on_max_attempts: proceed_with_best_guess
1689  skip_when:
1690    - type: social
1691    - type: short_input
1692      max_chars: 10
1693llms:
1694  default:
1695    provider: openai
1696    model: gpt-4.1-nano
1697"#;
1698        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1699        assert!(spec.has_disambiguation());
1700        assert!(spec.disambiguation.is_enabled());
1701        assert_eq!(spec.disambiguation.detection.threshold, 0.8);
1702        assert_eq!(spec.disambiguation.clarification.max_attempts, 3);
1703        assert_eq!(spec.disambiguation.skip_when.len(), 2);
1704    }
1705
1706    #[test]
1707    fn test_agent_spec_disambiguation_minimal() {
1708        let yaml = r#"
1709name: MinimalDisambiguatingAgent
1710system_prompt: "You are helpful."
1711disambiguation:
1712  enabled: true
1713llms:
1714  default:
1715    provider: openai
1716    model: gpt-4.1-nano
1717"#;
1718        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1719        assert!(spec.has_disambiguation());
1720        assert_eq!(spec.disambiguation.detection.llm, "router");
1721        assert_eq!(spec.disambiguation.detection.threshold, 0.7);
1722        assert_eq!(spec.disambiguation.clarification.max_attempts, 2);
1723    }
1724
1725    #[test]
1726    fn test_agent_spec_no_disambiguation_by_default() {
1727        let yaml = r#"
1728name: SimpleAgent
1729system_prompt: "You are helpful."
1730llms:
1731  default:
1732    provider: openai
1733    model: gpt-4.1-nano
1734"#;
1735        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1736        assert!(!spec.has_disambiguation());
1737        assert!(!spec.disambiguation.is_enabled());
1738    }
1739
1740    #[test]
1741    fn test_state_machine_examples_parse() {
1742        // Resolve workspace root: this crate is at crates/ai-agents-runtime
1743        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1744            .parent()
1745            .unwrap()
1746            .parent()
1747            .unwrap();
1748        let examples = [
1749            "examples/yaml/state-machine/two_state_greeting.yaml",
1750            "examples/yaml/state-machine/guard_transitions.yaml",
1751            "examples/yaml/state-machine/nested_states.yaml",
1752            "examples/yaml/state-machine/state_with_tools.yaml",
1753            "examples/yaml/state-machine/state_lifecycle.yaml",
1754            "examples/yaml/state-machine/support_state_machine.yaml",
1755        ];
1756        for rel_path in &examples {
1757            let path = workspace_root.join(rel_path);
1758            let content = std::fs::read_to_string(&path)
1759                .unwrap_or_else(|_| panic!("Failed to read {}", path.display()));
1760            let spec: AgentSpec = serde_yaml::from_str(&content)
1761                .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path.display(), e));
1762            if let Some(ref states) = spec.states {
1763                states
1764                    .validate()
1765                    .unwrap_or_else(|e| panic!("Validation failed for {}: {}", path.display(), e));
1766            }
1767        }
1768    }
1769}