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