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.tool_security.validate()?;
634        self.runtime.optimization.validate()?;
635        self.validate_runtime_optimization_cross_fields()?;
636
637        Ok(())
638    }
639
640    fn validate_runtime_optimization_cross_fields(&self) -> Result<()> {
641        let optimization = &self.runtime.optimization;
642        if matches!(
643            optimization.streaming_policy,
644            super::StreamingOptimizationPolicy::BufferUntilRoutingDone
645        ) {
646            if !optimization.enabled || !self.streaming.enabled {
647                return Err(AgentError::InvalidSpec(
648                    "runtime.optimization.streaming_policy=buffer_until_routing_done requires runtime optimization and streaming.enabled=true".into(),
649                ));
650            }
651            if self.streaming.buffer_size == 0 {
652                return Err(AgentError::InvalidSpec(
653                    "streaming.buffer_size must be greater than 0 with buffer_until_routing_done"
654                        .into(),
655                ));
656            }
657        }
658
659        if let Some(states) = &self.states {
660            let has_parallel = state_config_has_parallel_transitions(states);
661            if has_parallel
662                && (!optimization.enabled || !optimization.speculative_state_transitions)
663            {
664                return Err(AgentError::InvalidSpec(
665                    "transition timing parallel requires runtime.optimization.enabled=true and speculative_state_transitions=true".into(),
666                ));
667            }
668            if has_parallel && optimization.max_speculative_llm_calls_per_turn == 0 {
669                return Err(AgentError::InvalidSpec(
670                    "transition timing parallel requires max_speculative_llm_calls_per_turn greater than 0".into(),
671                ));
672            }
673        }
674        Ok(())
675    }
676
677    pub fn has_multi_llm(&self) -> bool {
678        !self.llms.is_empty()
679    }
680
681    pub fn has_skills(&self) -> bool {
682        !self.skills.is_empty()
683    }
684
685    pub fn has_process(&self) -> bool {
686        !self.process.input.is_empty() || !self.process.output.is_empty()
687    }
688
689    pub fn has_tool_security(&self) -> bool {
690        self.tool_security.enabled
691    }
692
693    pub fn has_states(&self) -> bool {
694        self.states.is_some()
695    }
696
697    pub fn has_context(&self) -> bool {
698        !self.context.is_empty()
699    }
700
701    pub fn has_parallel_tools(&self) -> bool {
702        self.parallel_tools.enabled
703    }
704
705    pub fn has_streaming(&self) -> bool {
706        self.streaming.enabled
707    }
708
709    pub fn has_hitl(&self) -> bool {
710        self.hitl.is_some()
711    }
712
713    pub fn has_storage(&self) -> bool {
714        !self.storage.is_none()
715    }
716
717    pub fn has_tool_aliases(&self) -> bool {
718        !self.tool_aliases.tools.is_empty()
719    }
720
721    pub fn has_reasoning(&self) -> bool {
722        self.reasoning.is_enabled()
723    }
724
725    pub fn has_reflection(&self) -> bool {
726        self.reflection.requires_evaluation()
727    }
728
729    pub fn has_disambiguation(&self) -> bool {
730        self.disambiguation.is_enabled()
731    }
732
733    pub fn has_observability(&self) -> bool {
734        self.observability.enabled
735    }
736
737    pub fn has_runtime_optimization(&self) -> bool {
738        self.runtime.optimization.enabled
739    }
740
741    pub fn has_persona(&self) -> bool {
742        self.persona.as_ref().is_some_and(|p| p.is_configured())
743    }
744
745    pub fn has_actor_memory(&self) -> bool {
746        self.memory.has_actor_memory()
747    }
748
749    pub fn has_facts(&self) -> bool {
750        self.memory.has_facts()
751    }
752
753    pub fn has_relationships(&self) -> bool {
754        self.memory.has_relationships()
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761
762    fn strict_error(yaml: &str) -> String {
763        AgentSpec::from_yaml_strict(yaml).unwrap_err().to_string()
764    }
765
766    fn assert_unknown_path(yaml: &str, expected_path: &str) {
767        let error = strict_error(yaml);
768        assert!(error.contains(expected_path), "{error}");
769    }
770
771    #[test]
772    fn test_agent_spec_minimal() {
773        let yaml = r#"
774name: TestAgent
775system_prompt: "You are a helpful assistant."
776llm:
777  provider: openai
778  model: gpt-4
779"#;
780        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
781        assert_eq!(spec.name, "TestAgent");
782        assert_eq!(spec.version, "1.0.0");
783        assert_eq!(spec.max_iterations, 10);
784        assert!(spec.validate().is_ok());
785    }
786
787    #[test]
788    fn test_agent_spec_rejects_top_level_typo() {
789        let yaml = r#"
790name: TestAgent
791system_prompt: "You are a helpful assistant."
792max_iteratons: 20
793"#;
794        assert_unknown_path(yaml, "max_iteratons");
795    }
796
797    #[test]
798    fn test_agent_spec_rejects_nested_typo() {
799        let yaml = r#"
800name: TestAgent
801system_prompt: "You are a helpful assistant."
802storage:
803  type: redis
804  url: redis://localhost:6379
805  ttl_second: 60
806"#;
807        assert_unknown_path(yaml, "storage.ttl_second");
808    }
809
810    #[test]
811    fn test_agent_spec_rejects_memory_typo() {
812        let yaml = r#"
813name: TestAgent
814system_prompt: "You are a helpful assistant."
815memory:
816  type: compacting
817  compress_thresold: 30
818"#;
819        assert_unknown_path(yaml, "memory.compress_thresold");
820    }
821
822    #[test]
823    fn test_agent_spec_preserves_llm_provider_extras() {
824        let yaml = r#"
825name: OllamaAgent
826system_prompt: "You are a helpful assistant."
827llm:
828  provider: ollama
829  model: llama3.1
830  num_ctx: 8192
831  keep_alive: 5m
832llms:
833  router:
834    provider: openai
835    model: gpt-4.1-nano
836    provider_extension: enabled
837"#;
838        let spec = AgentSpec::from_yaml_strict(yaml).unwrap();
839        let llm = spec.llm.as_config().unwrap();
840        assert_eq!(llm.extra.get("num_ctx"), Some(&serde_json::json!(8192)));
841        assert_eq!(llm.extra.get("keep_alive"), Some(&serde_json::json!("5m")));
842        assert_eq!(
843            spec.llms["router"].extra.get("provider_extension"),
844            Some(&serde_json::json!("enabled"))
845        );
846    }
847
848    #[test]
849    fn referenced_llm_aliases_use_typed_active_configuration() {
850        let yaml = r#"
851name: AliasAgent
852system_prompt: test
853llm:
854  provider: openai
855  model: test
856  extension:
857    llm: ignored_extension_value
858memory:
859  type: compacting
860  summarizer_llm: memory_summary
861  facts:
862    enabled: true
863    extractor_llm: fact_extract
864  relationships:
865    enabled: true
866    auto_update:
867      enabled: true
868      llm: relationship_eval
869reasoning:
870  mode: plan_and_execute
871  judge_llm: reasoning_judge
872  planning:
873    planner_llm: reasoning_plan
874reflection:
875  enabled: auto
876  evaluator_llm: reflection_eval
877process:
878  input:
879    - type: transform
880      config:
881        llm: process_transform
882states:
883  initial: active
884  states:
885    active:
886      llm: state_response
887      extract:
888        - key: value
889          description: value
890          llm: state_extract
891      concurrent:
892        agents: [worker]
893        aggregation:
894          strategy: llm_synthesis
895          synthesizer_llm: state_synthesis
896error_recovery:
897  llm:
898    on_failure:
899      action: fallback_llm
900      fallback_llm: recovery_fallback
901    on_rate_limit:
902      action: switch_model
903      fallback_llm: recovery_rate_limit
904    on_context_overflow:
905      action: summarize
906      summarizer_llm: recovery_summary
907disambiguation:
908  enabled: true
909  detection:
910    llm: disambiguation_detect
911  clarification:
912    llm: disambiguation_clarify
913hitl:
914  message_language:
915    strategy: llm_generate
916    llm_generate:
917      llm: hitl_generate
918skills:
919  - id: inline
920    description: inline
921    trigger: always
922    steps:
923      - prompt: test
924        llm: skill_prompt
925"#;
926        let spec = AgentSpec::from_yaml_strict(yaml).unwrap();
927
928        assert_eq!(
929            spec.referenced_llm_aliases(),
930            BTreeSet::from([
931                "disambiguation_clarify".to_string(),
932                "disambiguation_detect".to_string(),
933                "fact_extract".to_string(),
934                "hitl_generate".to_string(),
935                "memory_summary".to_string(),
936                "process_transform".to_string(),
937                "reasoning_judge".to_string(),
938                "reasoning_plan".to_string(),
939                "recovery_fallback".to_string(),
940                "recovery_rate_limit".to_string(),
941                "recovery_summary".to_string(),
942                "reflection_eval".to_string(),
943                "relationship_eval".to_string(),
944                "skill_prompt".to_string(),
945                "state_extract".to_string(),
946                "state_response".to_string(),
947                "state_synthesis".to_string(),
948            ])
949        );
950    }
951
952    #[test]
953    fn test_strict_yaml_reports_tagged_unknown_field_path() {
954        let yaml = "name: TestAgent\nsystem_prompt: test\nstorage:\n  type: redis\n  url: redis://localhost:6379\n  ttl_second: 60\n";
955        assert_unknown_path(yaml, "storage.ttl_second");
956    }
957
958    #[test]
959    fn test_strict_yaml_reports_untagged_selector_unknown_field_path() {
960        let yaml = "name: TestAgent\nsystem_prompt: test\nllm:\n  defualt: default\n";
961        assert_unknown_path(yaml, "llm.defualt");
962    }
963
964    #[test]
965    fn test_strict_yaml_reports_untagged_template_unknown_field_path() {
966        let yaml = "name: TestAgent\nsystem_prompt: test\nspawner:\n  templates:\n    npc:\n      pat: child.yaml\n";
967        assert_unknown_path(yaml, "spawner.templates.npc.pat");
968    }
969
970    #[test]
971    fn test_strict_yaml_rejects_runtime_optimization_typo() {
972        let yaml = r#"
973name: TestAgent
974system_prompt: test
975runtime:
976  optimization:
977    max_parallel_runtime_task: 4
978"#;
979        assert_unknown_path(yaml, "runtime.optimization.max_parallel_runtime_task");
980    }
981
982    #[test]
983    fn test_strict_yaml_rejects_tool_security_typo() {
984        let yaml = r#"
985name: TestAgent
986system_prompt: test
987tool_security:
988  enabeld: true
989"#;
990        assert_unknown_path(yaml, "tool_security.enabeld");
991    }
992
993    #[test]
994    fn test_strict_yaml_rejects_process_typo() {
995        let yaml = r#"
996name: TestAgent
997system_prompt: test
998process:
999  input:
1000    - type: normalize
1001      config:
1002        trm: true
1003"#;
1004        let error = AgentSpec::from_yaml_strict(yaml).unwrap_err().to_string();
1005        assert!(error.contains("process.input[0]"), "{error}");
1006        assert!(error.contains("trm"), "{error}");
1007    }
1008
1009    #[test]
1010    fn test_strict_yaml_rejects_state_typo() {
1011        let yaml = r#"
1012name: TestAgent
1013system_prompt: test
1014states:
1015  initial: start
1016  states:
1017    start:
1018      promt: hello
1019"#;
1020        assert_unknown_path(yaml, "states.states.start.promt");
1021    }
1022
1023    #[test]
1024    fn test_strict_yaml_rejects_hitl_typo() {
1025        let yaml = r#"
1026name: TestAgent
1027system_prompt: test
1028hitl:
1029  default_timeout_second: 30
1030"#;
1031        assert_unknown_path(yaml, "hitl.default_timeout_second");
1032    }
1033
1034    #[test]
1035    fn test_strict_yaml_rejects_memory_and_storage_typos() {
1036        let memory_yaml = r#"
1037name: TestAgent
1038system_prompt: test
1039memory:
1040  type: compacting
1041  compress_thresold: 30
1042"#;
1043        assert_unknown_path(memory_yaml, "memory.compress_thresold");
1044
1045        let storage_yaml = r#"
1046name: TestAgent
1047system_prompt: test
1048storage:
1049  type: redis
1050  url: redis://localhost:6379
1051  ttl_second: 60
1052"#;
1053        let error = AgentSpec::from_yaml_strict(storage_yaml)
1054            .unwrap_err()
1055            .to_string();
1056        assert!(error.contains("storage"), "{error}");
1057        assert!(error.contains("ttl_second"), "{error}");
1058    }
1059
1060    #[test]
1061    fn test_strict_yaml_preserves_structured_tool_extensions() {
1062        let yaml = r#"
1063name: ToolAgent
1064system_prompt: test
1065tools:
1066  - name: github
1067    type: mcp
1068    transport: stdio
1069    command: npx
1070    args: ["-y", "@modelcontextprotocol/server-github"]
1071    env:
1072      GITHUB_TOKEN: test
1073  - name: http
1074    custom_header: X-Test
1075
1076tool_aliases:
1077  custom_tool:
1078    names:
1079      en: Custom Tool
1080metadata:
1081  custom:
1082    arbitrary: true
1083tool_security:
1084  tools:
1085    dangerous:
1086      require_approval: true
1087"#;
1088        let spec = AgentSpec::from_yaml_strict(yaml).unwrap();
1089        let tools = spec.tools.unwrap();
1090        assert!(tools[0].is_mcp());
1091        match &tools[1] {
1092            ToolEntry::Structured(tool) => {
1093                assert_eq!(
1094                    tool.extra.get("custom_header"),
1095                    Some(&serde_json::json!("X-Test"))
1096                );
1097            }
1098            ToolEntry::Simple(_) => panic!("expected structured tool"),
1099        }
1100
1101        assert!(spec.tool_aliases.tools.contains_key("custom_tool"));
1102        assert!(spec.tool_security.tools["dangerous"].require_confirmation);
1103        assert_eq!(
1104            spec.metadata.as_ref().unwrap()["custom"]["arbitrary"],
1105            serde_json::json!(true)
1106        );
1107    }
1108
1109    #[test]
1110    fn test_strict_yaml_rejects_removed_provider_sections() {
1111        for field in ["providers", "provider_security"] {
1112            let yaml = format!("name: TestAgent\nsystem_prompt: test\n{field}: {{}}\n");
1113            assert_unknown_path(&yaml, field);
1114        }
1115    }
1116
1117    #[test]
1118    fn test_strict_yaml_accepts_explicit_empty_known_fields() {
1119        let yaml = r#"
1120name: EmptyFieldsAgent
1121system_prompt: test
1122skills:
1123  - id: inline
1124    description: test
1125    trigger: test
1126    steps:
1127      - prompt: hello
1128    disambiguation:
1129      required_clarity: []
1130      clarification_templates: {}
1131"#;
1132        AgentSpec::from_yaml_strict(yaml).unwrap();
1133    }
1134
1135    #[test]
1136    fn test_strict_yaml_rejects_null_and_non_string_skill_keys() {
1137        let null_typo = r#"
1138name: NullTypoAgent
1139system_prompt: test
1140skills:
1141  - file: child.yaml
1142    typo:
1143"#;
1144        assert_unknown_path(null_typo, "skills[0]");
1145
1146        let non_string_key = r#"
1147name: NumericKeyAgent
1148system_prompt: test
1149skills:
1150  - file: child.yaml
1151    1: ignored
1152"#;
1153        assert_unknown_path(non_string_key, "skills[0].<non-string-key>");
1154    }
1155
1156    #[test]
1157    fn test_strict_yaml_rejects_merge_keys_everywhere() {
1158        let yaml = r#"
1159name: MergeAgent
1160system_prompt: test
1161llm:
1162  provider: ollama
1163  model: llama3.1
1164  <<:
1165    num_ctx: 8192
1166"#;
1167        assert_unknown_path(yaml, "llm.<<");
1168    }
1169
1170    #[test]
1171    fn test_agent_spec_with_states() {
1172        let yaml = r#"
1173name: StatefulAgent
1174system_prompt: "You are helpful."
1175llm:
1176  provider: openai
1177  model: gpt-4
1178states:
1179  initial: greeting
1180  states:
1181    greeting:
1182      prompt: "Welcome!"
1183      transitions:
1184        - to: support
1185          when: "user needs help"
1186    support:
1187      prompt: "How can I help?"
1188"#;
1189        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1190        assert!(spec.has_states());
1191        assert!(spec.validate().is_ok());
1192    }
1193
1194    #[test]
1195    fn test_agent_spec_with_context() {
1196        let yaml = r#"
1197name: ContextAgent
1198system_prompt: "Hello, {{ context.user.name }}!"
1199llm:
1200  provider: openai
1201  model: gpt-4
1202context:
1203  user:
1204    type: runtime
1205    required: true
1206  time:
1207    type: builtin
1208    source: datetime
1209    refresh: per_turn
1210"#;
1211        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1212        assert!(spec.has_context());
1213        assert_eq!(spec.context.len(), 2);
1214    }
1215
1216    #[test]
1217    fn test_agent_spec_with_tool_security() {
1218        let yaml = r#"
1219name: SecureAgent
1220version: 2.0.0
1221system_prompt: "You are an advanced AI."
1222llm:
1223  provider: openai
1224  model: gpt-4
1225max_context_tokens: 8192
1226error_recovery:
1227  default:
1228    max_retries: 5
1229tool_security:
1230  enabled: true
1231  default_timeout_ms: 10000
1232  tools:
1233    http:
1234      rate_limit: 10
1235      blocked_domains:
1236        - evil.com
1237process:
1238  input:
1239    - type: normalize
1240      config:
1241        trim: true
1242"#;
1243        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1244        assert_eq!(spec.name, "SecureAgent");
1245        assert_eq!(spec.max_context_tokens, 8192);
1246        assert_eq!(spec.error_recovery.default.max_retries, 5);
1247        assert!(spec.tool_security.enabled);
1248        assert!(spec.has_tool_security());
1249        assert!(!spec.process.input.is_empty());
1250        assert!(spec.has_process());
1251    }
1252
1253    #[test]
1254    fn test_agent_spec_with_multi_llm() {
1255        let yaml = r#"
1256name: MultiLLMAgent
1257system_prompt: "You are helpful."
1258llms:
1259  default:
1260    provider: openai
1261    model: gpt-4.1-nano
1262  router:
1263    provider: openai
1264    model: gpt-4.1-nano
1265llm:
1266  default: default
1267  router: router
1268"#;
1269        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1270        assert!(spec.has_multi_llm());
1271        assert_eq!(spec.llms.len(), 2);
1272        assert!(spec.llms.contains_key("default"));
1273        assert!(spec.llms.contains_key("router"));
1274    }
1275
1276    #[test]
1277    fn test_agent_spec_with_skills() {
1278        let yaml = r#"
1279name: SkillAgent
1280system_prompt: "You are helpful."
1281llm:
1282  provider: openai
1283  model: gpt-4
1284skills:
1285  - weather_clothes
1286  - file: ./custom.yaml
1287  - id: inline_skill
1288    description: "An inline skill"
1289    trigger: "When user asks"
1290    steps:
1291      - prompt: "Hello"
1292"#;
1293        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1294        assert!(spec.has_skills());
1295        assert_eq!(spec.skills.len(), 3);
1296    }
1297
1298    #[test]
1299    fn test_agent_spec_validation_empty_name() {
1300        let mut spec = AgentSpec {
1301            name: String::new(),
1302            ..AgentSpec::default()
1303        };
1304        assert!(spec.validate().is_err());
1305
1306        spec.name = "Valid".to_string();
1307        assert!(spec.validate().is_ok());
1308    }
1309
1310    #[test]
1311    fn test_agent_spec_validation_empty_prompt() {
1312        let mut spec = AgentSpec {
1313            system_prompt: String::new(),
1314            ..AgentSpec::default()
1315        };
1316        assert!(spec.validate().is_err());
1317
1318        spec.system_prompt = "Valid prompt".to_string();
1319        assert!(spec.validate().is_ok());
1320    }
1321
1322    #[test]
1323    fn test_agent_spec_validation_zero_iterations() {
1324        let mut spec = AgentSpec {
1325            max_iterations: 0,
1326            ..AgentSpec::default()
1327        };
1328        assert!(spec.validate().is_err());
1329
1330        spec.max_iterations = 5;
1331        assert!(spec.validate().is_ok());
1332    }
1333
1334    #[test]
1335    fn test_agent_spec_validation_rejects_zero_max_results() {
1336        let mut spec = AgentSpec::default();
1337        spec.tool_security.tools.insert(
1338            "web_search".to_string(),
1339            ai_agents_tools::ToolPolicyConfig {
1340                max_results: Some(0),
1341                ..Default::default()
1342            },
1343        );
1344        let error = spec.validate().unwrap_err();
1345        assert!(
1346            error
1347                .to_string()
1348                .contains("tool_security.tools.web_search.max_results must be greater than 0")
1349        );
1350
1351        spec.tool_security
1352            .tools
1353            .get_mut("web_search")
1354            .unwrap()
1355            .max_results = Some(1);
1356        assert!(spec.validate().is_ok());
1357    }
1358
1359    #[test]
1360    fn test_agent_spec_with_parallel_tools() {
1361        let yaml = r#"
1362name: ParallelAgent
1363system_prompt: "You are helpful."
1364llm:
1365  provider: openai
1366  model: gpt-4
1367parallel_tools:
1368  enabled: true
1369  max_parallel: 10
1370"#;
1371        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1372        assert!(spec.has_parallel_tools());
1373        assert_eq!(spec.parallel_tools.max_parallel, 10);
1374    }
1375
1376    #[test]
1377    fn test_agent_spec_with_streaming() {
1378        let yaml = r#"
1379name: StreamingAgent
1380system_prompt: "You are helpful."
1381llm:
1382  provider: openai
1383  model: gpt-4
1384streaming:
1385  enabled: true
1386  buffer_size: 64
1387  include_tool_events: true
1388"#;
1389        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1390        assert!(spec.has_streaming());
1391        assert_eq!(spec.streaming.buffer_size, 64);
1392    }
1393
1394    #[test]
1395    fn test_agent_spec_defaults() {
1396        let spec = AgentSpec::default();
1397        assert!(spec.parallel_tools.enabled);
1398        assert_eq!(spec.parallel_tools.max_parallel, 5);
1399        assert!(spec.streaming.enabled);
1400        assert!(!spec.has_hitl());
1401    }
1402
1403    #[test]
1404    fn test_agent_spec_with_hitl() {
1405        let yaml = r#"
1406name: HITLAgent
1407system_prompt: "You are helpful."
1408llm:
1409  provider: openai
1410  model: gpt-4
1411hitl:
1412  default_timeout_seconds: 600
1413  on_timeout: reject
1414  tools:
1415    send_payment:
1416      require_approval: true
1417      approval_context:
1418        - amount
1419        - recipient
1420      approval_message: "Approve payment?"
1421  conditions:
1422    - name: high_value
1423      when: "amount > 1000"
1424      require_approval: true
1425  states:
1426    escalation:
1427      on_enter: require_approval
1428"#;
1429        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1430        assert!(spec.has_hitl());
1431        let hitl = spec.hitl.as_ref().unwrap();
1432        assert_eq!(hitl.default_timeout_seconds, 600);
1433        assert_eq!(hitl.tools.len(), 1);
1434        assert_eq!(hitl.conditions.len(), 1);
1435        assert_eq!(hitl.states.len(), 1);
1436    }
1437
1438    #[test]
1439    fn test_agent_spec_with_storage_file() {
1440        let yaml = r#"
1441name: PersistentAgent
1442system_prompt: "You are helpful."
1443llm:
1444  provider: openai
1445  model: gpt-4
1446storage:
1447  type: file
1448  path: "./data/sessions"
1449"#;
1450        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1451        assert!(spec.has_storage());
1452        assert!(spec.storage.is_file());
1453        assert_eq!(spec.storage.get_path(), Some("./data/sessions"));
1454    }
1455
1456    #[test]
1457    fn test_agent_spec_with_storage_sqlite() {
1458        let yaml = r#"
1459name: PersistentAgent
1460system_prompt: "You are helpful."
1461llm:
1462  provider: openai
1463  model: gpt-4
1464storage:
1465  type: sqlite
1466  path: "./data/sessions.db"
1467"#;
1468        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1469        assert!(spec.has_storage());
1470        assert!(spec.storage.is_sqlite());
1471    }
1472
1473    #[test]
1474    fn test_agent_spec_with_storage_redis() {
1475        let yaml = r#"
1476name: PersistentAgent
1477system_prompt: "You are helpful."
1478llm:
1479  provider: openai
1480  model: gpt-4
1481storage:
1482  type: redis
1483  url: "redis://localhost:6379"
1484  prefix: "myagent:"
1485  ttl_seconds: 86400
1486"#;
1487        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1488        assert!(spec.has_storage());
1489        assert!(spec.storage.is_redis());
1490        assert_eq!(spec.storage.get_url(), Some("redis://localhost:6379"));
1491        assert_eq!(spec.storage.get_prefix(), "myagent:");
1492        assert_eq!(spec.storage.get_ttl(), Some(86400));
1493    }
1494
1495    #[test]
1496    fn test_agent_spec_no_storage_by_default() {
1497        let spec = AgentSpec::default();
1498        assert!(!spec.has_storage());
1499        assert!(spec.storage.is_none());
1500    }
1501
1502    #[test]
1503    fn test_agent_spec_with_tool_aliases() {
1504        let yaml = r#"
1505name: AliasAgent
1506system_prompt: "You are helpful."
1507llm:
1508  provider: openai
1509  model: gpt-4
1510tool_aliases:
1511  calculator:
1512    names:
1513      ko: 계산기
1514      ja: 計算機
1515    descriptions:
1516      ko: 수학 계산을 합니다
1517"#;
1518        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1519        assert!(spec.has_tool_aliases());
1520        let calc_aliases = spec.tool_aliases.tools.get("calculator").unwrap();
1521        assert_eq!(calc_aliases.get_name("ko"), Some("계산기"));
1522    }
1523
1524    #[test]
1525    fn test_agent_spec_with_reasoning() {
1526        let yaml = r#"
1527    name: ReasoningAgent
1528    system_prompt: "You are helpful."
1529    llm:
1530      provider: openai
1531      model: gpt-4
1532    reasoning:
1533      mode: cot
1534      judge_llm: router
1535      output: tagged
1536      max_iterations: 8
1537    "#;
1538        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1539        assert!(spec.has_reasoning());
1540        assert_eq!(spec.reasoning.max_iterations, 8);
1541    }
1542
1543    #[test]
1544    fn test_agent_spec_with_reflection() {
1545        let yaml = r#"
1546    name: ReflectionAgent
1547    system_prompt: "You are helpful."
1548    llm:
1549      provider: openai
1550      model: gpt-4
1551    reflection:
1552      enabled: auto
1553      evaluator_llm: router
1554      max_retries: 3
1555      pass_threshold: 0.8
1556      criteria:
1557        - "Response addresses the question"
1558        - "Response is accurate"
1559    "#;
1560        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1561        assert!(spec.has_reflection());
1562        assert_eq!(spec.reflection.max_retries, 3);
1563        assert_eq!(spec.reflection.criteria.len(), 2);
1564    }
1565
1566    #[test]
1567    fn test_agent_spec_with_plan_and_execute() {
1568        let yaml = r#"
1569    name: PlanningAgent
1570    system_prompt: "You are helpful."
1571    llm:
1572      provider: openai
1573      model: gpt-4
1574    reasoning:
1575      mode: plan_and_execute
1576      planning:
1577        planner_llm: router
1578        max_steps: 15
1579        available:
1580          tools: all
1581          skills:
1582            - analyze
1583            - summarize
1584        reflection:
1585          enabled: true
1586          on_step_failure: replan
1587          max_replans: 3
1588    "#;
1589        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1590        assert!(spec.has_reasoning());
1591        let planning = spec.reasoning.planning.as_ref().unwrap();
1592        assert_eq!(planning.max_steps, 15);
1593        assert!(planning.reflection.enabled);
1594    }
1595
1596    #[test]
1597    fn test_agent_spec_reasoning_defaults() {
1598        let spec = AgentSpec::default();
1599        assert!(!spec.has_reasoning());
1600        assert!(!spec.has_reflection());
1601    }
1602
1603    #[test]
1604    fn test_agent_spec_state_level_reasoning_override() {
1605        let yaml = r#"
1606    name: StateReasoningAgent
1607    system_prompt: "You are helpful."
1608    llm:
1609      provider: openai
1610      model: gpt-4
1611    reasoning:
1612      mode: auto
1613    states:
1614      initial: greeting
1615      states:
1616        greeting:
1617          prompt: "Welcome!"
1618          reasoning:
1619            mode: none
1620        complex_analysis:
1621          prompt: "Analyze this"
1622          reasoning:
1623            mode: cot
1624            output: tagged
1625          reflection:
1626            enabled: true
1627            criteria:
1628              - "Analysis is thorough"
1629    "#;
1630        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1631        assert!(spec.has_reasoning());
1632        assert!(spec.has_states());
1633
1634        let states = spec.states.as_ref().unwrap();
1635        let greeting = states.states.get("greeting").unwrap();
1636        assert!(greeting.reasoning.is_some());
1637        let greeting_reasoning = greeting.reasoning.as_ref().unwrap();
1638        assert_eq!(
1639            greeting_reasoning.mode,
1640            ai_agents_reasoning::ReasoningMode::None
1641        );
1642
1643        let analysis = states.states.get("complex_analysis").unwrap();
1644        assert!(analysis.reasoning.is_some());
1645        assert!(analysis.reflection.is_some());
1646        let analysis_reasoning = analysis.reasoning.as_ref().unwrap();
1647        assert_eq!(
1648            analysis_reasoning.mode,
1649            ai_agents_reasoning::ReasoningMode::CoT
1650        );
1651    }
1652
1653    #[test]
1654    fn test_agent_spec_skill_level_reasoning_override() {
1655        use ai_agents_skills::SkillDefinition;
1656
1657        let skill_yaml = r#"
1658id: complex_analysis
1659description: "Analyze data"
1660trigger: "When user asks for analysis"
1661reasoning:
1662  mode: cot
1663reflection:
1664  enabled: true
1665  criteria:
1666    - "Analysis covers all aspects"
1667steps:
1668  - prompt: "Analyze the input"
1669"#;
1670        let skill_def: SkillDefinition = serde_yaml::from_str(skill_yaml).unwrap();
1671        assert!(skill_def.reasoning.is_some());
1672        assert!(skill_def.reflection.is_some());
1673        let reasoning = skill_def.reasoning.as_ref().unwrap();
1674        assert_eq!(reasoning.mode, ai_agents_reasoning::ReasoningMode::CoT);
1675        let reflection = skill_def.reflection.as_ref().unwrap();
1676        assert!(reflection.is_enabled());
1677
1678        let simple_yaml = r#"
1679id: simple_lookup
1680description: "Look up simple facts"
1681trigger: "When user asks for facts"
1682reasoning:
1683  mode: none
1684reflection:
1685  enabled: false
1686steps:
1687  - prompt: "Look up the fact"
1688"#;
1689        let simple_def: SkillDefinition = serde_yaml::from_str(simple_yaml).unwrap();
1690        assert!(simple_def.reasoning.is_some());
1691        let simple_reasoning = simple_def.reasoning.as_ref().unwrap();
1692        assert_eq!(
1693            simple_reasoning.mode,
1694            ai_agents_reasoning::ReasoningMode::None
1695        );
1696    }
1697
1698    #[test]
1699    fn test_agent_spec_with_disambiguation() {
1700        let yaml = r#"
1701name: DisambiguatingAgent
1702system_prompt: "You are a helpful assistant."
1703disambiguation:
1704  enabled: true
1705  detection:
1706    llm: router
1707    threshold: 0.8
1708    aspects:
1709      - missing_target
1710      - vague_references
1711  clarification:
1712    style: auto
1713    max_attempts: 3
1714    on_max_attempts: proceed_with_best_guess
1715  skip_when:
1716    - type: social
1717    - type: short_input
1718      max_chars: 10
1719llms:
1720  default:
1721    provider: openai
1722    model: gpt-4.1-nano
1723"#;
1724        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1725        assert!(spec.has_disambiguation());
1726        assert!(spec.disambiguation.is_enabled());
1727        assert_eq!(spec.disambiguation.detection.threshold, 0.8);
1728        assert_eq!(spec.disambiguation.clarification.max_attempts, 3);
1729        assert_eq!(spec.disambiguation.skip_when.len(), 2);
1730    }
1731
1732    #[test]
1733    fn test_agent_spec_disambiguation_minimal() {
1734        let yaml = r#"
1735name: MinimalDisambiguatingAgent
1736system_prompt: "You are helpful."
1737disambiguation:
1738  enabled: true
1739llms:
1740  default:
1741    provider: openai
1742    model: gpt-4.1-nano
1743"#;
1744        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1745        assert!(spec.has_disambiguation());
1746        assert_eq!(spec.disambiguation.detection.llm, "router");
1747        assert_eq!(spec.disambiguation.detection.threshold, 0.7);
1748        assert_eq!(spec.disambiguation.clarification.max_attempts, 2);
1749    }
1750
1751    #[test]
1752    fn test_agent_spec_no_disambiguation_by_default() {
1753        let yaml = r#"
1754name: SimpleAgent
1755system_prompt: "You are helpful."
1756llms:
1757  default:
1758    provider: openai
1759    model: gpt-4.1-nano
1760"#;
1761        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1762        assert!(!spec.has_disambiguation());
1763        assert!(!spec.disambiguation.is_enabled());
1764    }
1765
1766    #[test]
1767    fn test_state_machine_examples_parse() {
1768        // Resolve workspace root: this crate is at crates/ai-agents-runtime
1769        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1770            .parent()
1771            .unwrap()
1772            .parent()
1773            .unwrap();
1774        let examples = [
1775            "examples/yaml/state-machine/two_state_greeting.yaml",
1776            "examples/yaml/state-machine/guard_transitions.yaml",
1777            "examples/yaml/state-machine/nested_states.yaml",
1778            "examples/yaml/state-machine/state_with_tools.yaml",
1779            "examples/yaml/state-machine/state_lifecycle.yaml",
1780            "examples/yaml/state-machine/support_state_machine.yaml",
1781        ];
1782        for rel_path in &examples {
1783            let path = workspace_root.join(rel_path);
1784            let content = std::fs::read_to_string(&path)
1785                .unwrap_or_else(|_| panic!("Failed to read {}", path.display()));
1786            let spec: AgentSpec = serde_yaml::from_str(&content)
1787                .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path.display(), e));
1788            if let Some(ref states) = spec.states {
1789                states
1790                    .validate()
1791                    .unwrap_or_else(|e| panic!("Validation failed for {}: {}", path.display(), e));
1792            }
1793        }
1794    }
1795}