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