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::{
13    BuiltinProviderConfig, ProviderPolicyConfig, ProviderSecurityConfig, ProvidersConfig,
14    ToolAliasesConfig, ToolPolicyConfig, YamlProviderConfig, YamlToolConfig,
15};
16pub use spawner::{
17    AutoSpawnEntry, ManagementToolsConfig, OrchestrationToolsConfig, SpawnerConfig, TemplateSource,
18};
19pub use storage::{FileStorageConfig, RedisStorageConfig, SqliteStorageConfig, StorageConfig};
20pub use tool::{StructuredToolEntry, ToolConfig, ToolEntry};
21
22use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24
25use ai_agents_context::ContextSource;
26use ai_agents_core::{AgentError, Result};
27use ai_agents_disambiguation::DisambiguationConfig;
28use ai_agents_hitl::HITLConfig;
29use ai_agents_observability::ObservabilityConfig;
30use ai_agents_persona::PersonaConfig;
31use ai_agents_process::ProcessConfig;
32use ai_agents_reasoning::{ReasoningConfig, ReflectionConfig};
33use ai_agents_recovery::ErrorRecoveryConfig;
34use ai_agents_skills::SkillRef;
35use ai_agents_state::{StateConfig, StateDefinition, Transition, TransitionTiming};
36use ai_agents_tools::ToolSecurityConfig;
37
38pub use super::RuntimeConfig;
39use super::{ParallelToolsConfig, StreamingConfig};
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct AgentSpec {
43    pub name: String,
44
45    #[serde(default = "default_version")]
46    pub version: String,
47
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub description: Option<String>,
50
51    pub system_prompt: String,
52
53    #[serde(default)]
54    pub llm: LLMConfigOrSelector,
55
56    #[serde(default)]
57    pub llms: HashMap<String, LLMConfig>,
58
59    #[serde(default)]
60    pub skills: Vec<SkillRef>,
61
62    #[serde(default)]
63    pub memory: MemoryConfig,
64
65    #[serde(default)]
66    pub storage: StorageConfig,
67
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub tools: Option<Vec<ToolConfig>>,
70
71    #[serde(default = "default_max_iterations")]
72    pub max_iterations: u32,
73
74    #[serde(default = "default_max_context_tokens")]
75    pub max_context_tokens: u32,
76
77    #[serde(default)]
78    pub error_recovery: ErrorRecoveryConfig,
79
80    #[serde(default)]
81    pub tool_security: ToolSecurityConfig,
82
83    #[serde(default)]
84    pub process: ProcessConfig,
85
86    #[serde(default)]
87    pub context: HashMap<String, ContextSource>,
88
89    #[serde(default)]
90    pub states: Option<StateConfig>,
91
92    #[serde(default)]
93    pub parallel_tools: ParallelToolsConfig,
94
95    #[serde(default)]
96    pub streaming: StreamingConfig,
97
98    #[serde(default)]
99    pub hitl: Option<HITLConfig>,
100
101    #[serde(default)]
102    pub reasoning: ReasoningConfig,
103
104    #[serde(default)]
105    pub reflection: ReflectionConfig,
106
107    #[serde(default)]
108    pub disambiguation: DisambiguationConfig,
109
110    #[serde(default)]
111    pub observability: ObservabilityConfig,
112
113    #[serde(default)]
114    pub runtime: RuntimeConfig,
115
116    #[serde(default)]
117    pub providers: ProvidersConfig,
118
119    #[serde(default)]
120    pub provider_security: ProviderSecurityConfig,
121
122    #[serde(default)]
123    pub tool_aliases: ToolAliasesConfig,
124
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub metadata: Option<serde_json::Value>,
127
128    /// Dynamic agent spawning configuration (optional).
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub spawner: Option<SpawnerConfig>,
131
132    /// Agent persona configuration (optional).
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub persona: Option<PersonaConfig>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(untagged)]
139pub enum LLMConfigOrSelector {
140    Config(LLMConfig),
141    Selector(LLMSelector),
142}
143
144impl Default for LLMConfigOrSelector {
145    fn default() -> Self {
146        LLMConfigOrSelector::Config(LLMConfig::default())
147    }
148}
149
150impl LLMConfigOrSelector {
151    pub fn as_config(&self) -> Option<&LLMConfig> {
152        match self {
153            LLMConfigOrSelector::Config(c) => Some(c),
154            LLMConfigOrSelector::Selector(_) => None,
155        }
156    }
157
158    pub fn as_selector(&self) -> Option<&LLMSelector> {
159        match self {
160            LLMConfigOrSelector::Config(_) => None,
161            LLMConfigOrSelector::Selector(s) => Some(s),
162        }
163    }
164
165    pub fn get_default_alias(&self) -> String {
166        match self {
167            LLMConfigOrSelector::Config(_) => "default".to_string(),
168            LLMConfigOrSelector::Selector(s) => s.default.clone(),
169        }
170    }
171
172    pub fn get_router_alias(&self) -> Option<String> {
173        match self {
174            LLMConfigOrSelector::Config(_) => None,
175            LLMConfigOrSelector::Selector(s) => s.router.clone(),
176        }
177    }
178}
179
180fn default_version() -> String {
181    "1.0.0".to_string()
182}
183
184fn default_max_iterations() -> u32 {
185    10
186}
187
188fn default_max_context_tokens() -> u32 {
189    128000
190}
191
192fn state_config_has_parallel_transitions(config: &StateConfig) -> bool {
193    config.global_transitions.iter().any(transition_is_parallel)
194        || definitions_have_parallel_transitions(&config.states)
195}
196
197fn definitions_have_parallel_transitions(states: &HashMap<String, StateDefinition>) -> bool {
198    states.values().any(|definition| {
199        definition.transitions.iter().any(transition_is_parallel)
200            || definition
201                .states
202                .as_ref()
203                .map(definitions_have_parallel_transitions)
204                .unwrap_or(false)
205    })
206}
207
208fn transition_is_parallel(transition: &Transition) -> bool {
209    matches!(transition.timing, TransitionTiming::Parallel)
210}
211
212impl Default for AgentSpec {
213    fn default() -> Self {
214        Self {
215            name: "Agent".to_string(),
216            version: default_version(),
217            description: None,
218            system_prompt: "You are a helpful assistant.".to_string(),
219            llm: LLMConfigOrSelector::default(),
220            llms: HashMap::new(),
221            skills: vec![],
222            memory: MemoryConfig::default(),
223            storage: StorageConfig::default(),
224            tools: None,
225            max_iterations: default_max_iterations(),
226            max_context_tokens: default_max_context_tokens(),
227            error_recovery: ErrorRecoveryConfig::default(),
228            tool_security: ToolSecurityConfig::default(),
229            process: ProcessConfig::default(),
230            context: HashMap::new(),
231            states: None,
232            parallel_tools: ParallelToolsConfig::default(),
233            streaming: StreamingConfig::default(),
234            hitl: None,
235            reasoning: ReasoningConfig::default(),
236            reflection: ReflectionConfig::default(),
237            disambiguation: DisambiguationConfig::default(),
238            observability: ObservabilityConfig::default(),
239            runtime: RuntimeConfig::default(),
240            providers: ProvidersConfig::default(),
241            provider_security: ProviderSecurityConfig::default(),
242            tool_aliases: ToolAliasesConfig::default(),
243            metadata: None,
244            spawner: None,
245            persona: None,
246        }
247    }
248}
249
250impl AgentSpec {
251    pub fn validate(&self) -> Result<()> {
252        if self.name.is_empty() {
253            return Err(AgentError::InvalidSpec(
254                "Agent name cannot be empty".to_string(),
255            ));
256        }
257
258        if self.system_prompt.is_empty() {
259            return Err(AgentError::InvalidSpec(
260                "System prompt cannot be empty".to_string(),
261            ));
262        }
263
264        if self.max_iterations == 0 {
265            return Err(AgentError::InvalidSpec(
266                "Max iterations must be greater than 0".to_string(),
267            ));
268        }
269
270        if let Some(ref states) = self.states {
271            states.validate()?;
272        }
273
274        self.runtime.optimization.validate()?;
275        self.validate_runtime_optimization_cross_fields()?;
276
277        Ok(())
278    }
279
280    fn validate_runtime_optimization_cross_fields(&self) -> Result<()> {
281        let optimization = &self.runtime.optimization;
282        if matches!(
283            optimization.streaming_policy,
284            super::StreamingOptimizationPolicy::BufferUntilRoutingDone
285        ) {
286            if !optimization.enabled || !self.streaming.enabled {
287                return Err(AgentError::InvalidSpec(
288                    "runtime.optimization.streaming_policy=buffer_until_routing_done requires runtime optimization and streaming.enabled=true".into(),
289                ));
290            }
291            if self.streaming.buffer_size == 0 {
292                return Err(AgentError::InvalidSpec(
293                    "streaming.buffer_size must be greater than 0 with buffer_until_routing_done"
294                        .into(),
295                ));
296            }
297        }
298
299        if let Some(states) = &self.states {
300            let has_parallel = state_config_has_parallel_transitions(states);
301            if has_parallel
302                && (!optimization.enabled || !optimization.speculative_state_transitions)
303            {
304                return Err(AgentError::InvalidSpec(
305                    "transition timing parallel requires runtime.optimization.enabled=true and speculative_state_transitions=true".into(),
306                ));
307            }
308            if has_parallel && optimization.max_speculative_llm_calls_per_turn == 0 {
309                return Err(AgentError::InvalidSpec(
310                    "transition timing parallel requires max_speculative_llm_calls_per_turn greater than 0".into(),
311                ));
312            }
313        }
314        Ok(())
315    }
316
317    pub fn has_multi_llm(&self) -> bool {
318        !self.llms.is_empty()
319    }
320
321    pub fn has_skills(&self) -> bool {
322        !self.skills.is_empty()
323    }
324
325    pub fn has_process(&self) -> bool {
326        !self.process.input.is_empty() || !self.process.output.is_empty()
327    }
328
329    pub fn has_tool_security(&self) -> bool {
330        self.tool_security.enabled
331    }
332
333    pub fn has_states(&self) -> bool {
334        self.states.is_some()
335    }
336
337    pub fn has_context(&self) -> bool {
338        !self.context.is_empty()
339    }
340
341    pub fn has_parallel_tools(&self) -> bool {
342        self.parallel_tools.enabled
343    }
344
345    pub fn has_streaming(&self) -> bool {
346        self.streaming.enabled
347    }
348
349    pub fn has_hitl(&self) -> bool {
350        self.hitl.is_some()
351    }
352
353    pub fn has_storage(&self) -> bool {
354        !self.storage.is_none()
355    }
356
357    pub fn has_providers(&self) -> bool {
358        self.providers.yaml.is_some()
359    }
360
361    pub fn has_tool_aliases(&self) -> bool {
362        !self.tool_aliases.tools.is_empty()
363    }
364
365    pub fn has_reasoning(&self) -> bool {
366        self.reasoning.is_enabled()
367    }
368
369    pub fn has_reflection(&self) -> bool {
370        self.reflection.requires_evaluation()
371    }
372
373    pub fn has_disambiguation(&self) -> bool {
374        self.disambiguation.is_enabled()
375    }
376
377    pub fn has_observability(&self) -> bool {
378        self.observability.enabled
379    }
380
381    pub fn has_runtime_optimization(&self) -> bool {
382        self.runtime.optimization.enabled
383    }
384
385    pub fn has_persona(&self) -> bool {
386        self.persona.as_ref().map_or(false, |p| p.is_configured())
387    }
388
389    pub fn has_actor_memory(&self) -> bool {
390        self.memory.has_actor_memory()
391    }
392
393    pub fn has_facts(&self) -> bool {
394        self.memory.has_facts()
395    }
396
397    pub fn has_relationships(&self) -> bool {
398        self.memory.has_relationships()
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn test_agent_spec_minimal() {
408        let yaml = r#"
409name: TestAgent
410system_prompt: "You are a helpful assistant."
411llm:
412  provider: openai
413  model: gpt-4
414"#;
415        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
416        assert_eq!(spec.name, "TestAgent");
417        assert_eq!(spec.version, "1.0.0");
418        assert_eq!(spec.max_iterations, 10);
419        assert!(spec.validate().is_ok());
420    }
421
422    #[test]
423    fn test_agent_spec_with_states() {
424        let yaml = r#"
425name: StatefulAgent
426system_prompt: "You are helpful."
427llm:
428  provider: openai
429  model: gpt-4
430states:
431  initial: greeting
432  states:
433    greeting:
434      prompt: "Welcome!"
435      transitions:
436        - to: support
437          when: "user needs help"
438    support:
439      prompt: "How can I help?"
440"#;
441        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
442        assert!(spec.has_states());
443        assert!(spec.validate().is_ok());
444    }
445
446    #[test]
447    fn test_agent_spec_with_context() {
448        let yaml = r#"
449name: ContextAgent
450system_prompt: "Hello, {{ context.user.name }}!"
451llm:
452  provider: openai
453  model: gpt-4
454context:
455  user:
456    type: runtime
457    required: true
458  time:
459    type: builtin
460    source: datetime
461    refresh: per_turn
462"#;
463        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
464        assert!(spec.has_context());
465        assert_eq!(spec.context.len(), 2);
466    }
467
468    #[test]
469    fn test_agent_spec_with_tool_security() {
470        let yaml = r#"
471name: SecureAgent
472version: 2.0.0
473system_prompt: "You are an advanced AI."
474llm:
475  provider: openai
476  model: gpt-4
477max_context_tokens: 8192
478error_recovery:
479  default:
480    max_retries: 5
481tool_security:
482  enabled: true
483  default_timeout_ms: 10000
484  tools:
485    http:
486      rate_limit: 10
487      blocked_domains:
488        - evil.com
489process:
490  input:
491    - type: normalize
492      config:
493        trim: true
494"#;
495        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
496        assert_eq!(spec.name, "SecureAgent");
497        assert_eq!(spec.max_context_tokens, 8192);
498        assert_eq!(spec.error_recovery.default.max_retries, 5);
499        assert!(spec.tool_security.enabled);
500        assert!(spec.has_tool_security());
501        assert!(!spec.process.input.is_empty());
502        assert!(spec.has_process());
503    }
504
505    #[test]
506    fn test_agent_spec_with_multi_llm() {
507        let yaml = r#"
508name: MultiLLMAgent
509system_prompt: "You are helpful."
510llms:
511  default:
512    provider: openai
513    model: gpt-4.1-nano
514  router:
515    provider: openai
516    model: gpt-4.1-nano
517llm:
518  default: default
519  router: router
520"#;
521        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
522        assert!(spec.has_multi_llm());
523        assert_eq!(spec.llms.len(), 2);
524        assert!(spec.llms.contains_key("default"));
525        assert!(spec.llms.contains_key("router"));
526    }
527
528    #[test]
529    fn test_agent_spec_with_skills() {
530        let yaml = r#"
531name: SkillAgent
532system_prompt: "You are helpful."
533llm:
534  provider: openai
535  model: gpt-4
536skills:
537  - weather_clothes
538  - file: ./custom.yaml
539  - id: inline_skill
540    description: "An inline skill"
541    trigger: "When user asks"
542    steps:
543      - prompt: "Hello"
544"#;
545        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
546        assert!(spec.has_skills());
547        assert_eq!(spec.skills.len(), 3);
548    }
549
550    #[test]
551    fn test_agent_spec_validation_empty_name() {
552        let mut spec = AgentSpec::default();
553        spec.name = "".to_string();
554        assert!(spec.validate().is_err());
555
556        spec.name = "Valid".to_string();
557        assert!(spec.validate().is_ok());
558    }
559
560    #[test]
561    fn test_agent_spec_validation_empty_prompt() {
562        let mut spec = AgentSpec::default();
563        spec.system_prompt = "".to_string();
564        assert!(spec.validate().is_err());
565
566        spec.system_prompt = "Valid prompt".to_string();
567        assert!(spec.validate().is_ok());
568    }
569
570    #[test]
571    fn test_agent_spec_validation_zero_iterations() {
572        let mut spec = AgentSpec::default();
573        spec.max_iterations = 0;
574        assert!(spec.validate().is_err());
575
576        spec.max_iterations = 5;
577        assert!(spec.validate().is_ok());
578    }
579
580    #[test]
581    fn test_agent_spec_with_parallel_tools() {
582        let yaml = r#"
583name: ParallelAgent
584system_prompt: "You are helpful."
585llm:
586  provider: openai
587  model: gpt-4
588parallel_tools:
589  enabled: true
590  max_parallel: 10
591"#;
592        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
593        assert!(spec.has_parallel_tools());
594        assert_eq!(spec.parallel_tools.max_parallel, 10);
595    }
596
597    #[test]
598    fn test_agent_spec_with_streaming() {
599        let yaml = r#"
600name: StreamingAgent
601system_prompt: "You are helpful."
602llm:
603  provider: openai
604  model: gpt-4
605streaming:
606  enabled: true
607  buffer_size: 64
608  include_tool_events: true
609"#;
610        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
611        assert!(spec.has_streaming());
612        assert_eq!(spec.streaming.buffer_size, 64);
613    }
614
615    #[test]
616    fn test_agent_spec_defaults() {
617        let spec = AgentSpec::default();
618        assert!(spec.parallel_tools.enabled);
619        assert_eq!(spec.parallel_tools.max_parallel, 5);
620        assert!(spec.streaming.enabled);
621        assert!(!spec.has_hitl());
622    }
623
624    #[test]
625    fn test_agent_spec_with_hitl() {
626        let yaml = r#"
627name: HITLAgent
628system_prompt: "You are helpful."
629llm:
630  provider: openai
631  model: gpt-4
632hitl:
633  default_timeout_seconds: 600
634  on_timeout: reject
635  tools:
636    send_payment:
637      require_approval: true
638      approval_context:
639        - amount
640        - recipient
641      approval_message: "Approve payment?"
642  conditions:
643    - name: high_value
644      when: "amount > 1000"
645      require_approval: true
646  states:
647    escalation:
648      on_enter: require_approval
649"#;
650        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
651        assert!(spec.has_hitl());
652        let hitl = spec.hitl.as_ref().unwrap();
653        assert_eq!(hitl.default_timeout_seconds, 600);
654        assert_eq!(hitl.tools.len(), 1);
655        assert_eq!(hitl.conditions.len(), 1);
656        assert_eq!(hitl.states.len(), 1);
657    }
658
659    #[test]
660    fn test_agent_spec_with_storage_file() {
661        let yaml = r#"
662name: PersistentAgent
663system_prompt: "You are helpful."
664llm:
665  provider: openai
666  model: gpt-4
667storage:
668  type: file
669  path: "./data/sessions"
670"#;
671        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
672        assert!(spec.has_storage());
673        assert!(spec.storage.is_file());
674        assert_eq!(spec.storage.get_path(), Some("./data/sessions"));
675    }
676
677    #[test]
678    fn test_agent_spec_with_storage_sqlite() {
679        let yaml = r#"
680name: PersistentAgent
681system_prompt: "You are helpful."
682llm:
683  provider: openai
684  model: gpt-4
685storage:
686  type: sqlite
687  path: "./data/sessions.db"
688"#;
689        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
690        assert!(spec.has_storage());
691        assert!(spec.storage.is_sqlite());
692    }
693
694    #[test]
695    fn test_agent_spec_with_storage_redis() {
696        let yaml = r#"
697name: PersistentAgent
698system_prompt: "You are helpful."
699llm:
700  provider: openai
701  model: gpt-4
702storage:
703  type: redis
704  url: "redis://localhost:6379"
705  prefix: "myagent:"
706  ttl_seconds: 86400
707"#;
708        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
709        assert!(spec.has_storage());
710        assert!(spec.storage.is_redis());
711        assert_eq!(spec.storage.get_url(), Some("redis://localhost:6379"));
712        assert_eq!(spec.storage.get_prefix(), "myagent:");
713        assert_eq!(spec.storage.get_ttl(), Some(86400));
714    }
715
716    #[test]
717    fn test_agent_spec_no_storage_by_default() {
718        let spec = AgentSpec::default();
719        assert!(!spec.has_storage());
720        assert!(spec.storage.is_none());
721    }
722
723    #[test]
724    fn test_agent_spec_with_providers() {
725        let yaml = r#"
726name: ProviderAgent
727system_prompt: "You are helpful."
728llm:
729  provider: openai
730  model: gpt-4
731providers:
732  builtin:
733    enabled: true
734  yaml:
735    enabled: true
736    tools:
737      - id: custom_search
738        name: Custom Search
739        description: Search custom API
740        implementation:
741          type: http
742          url: https://api.example.com/search
743          method: GET
744"#;
745        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
746        assert!(spec.has_providers());
747        assert!(spec.providers.builtin.enabled);
748        assert!(spec.providers.yaml.is_some());
749    }
750
751    #[test]
752    fn test_agent_spec_with_tool_aliases() {
753        let yaml = r#"
754name: AliasAgent
755system_prompt: "You are helpful."
756llm:
757  provider: openai
758  model: gpt-4
759tool_aliases:
760  calculator:
761    names:
762      ko: 계산기
763      ja: 計算機
764    descriptions:
765      ko: 수학 계산을 합니다
766"#;
767        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
768        assert!(spec.has_tool_aliases());
769        let calc_aliases = spec.tool_aliases.tools.get("calculator").unwrap();
770        assert_eq!(calc_aliases.get_name("ko"), Some("계산기"));
771    }
772
773    #[test]
774    fn test_agent_spec_with_reasoning() {
775        let yaml = r#"
776    name: ReasoningAgent
777    system_prompt: "You are helpful."
778    llm:
779      provider: openai
780      model: gpt-4
781    reasoning:
782      mode: cot
783      judge_llm: router
784      output: tagged
785      max_iterations: 8
786    "#;
787        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
788        assert!(spec.has_reasoning());
789        assert_eq!(spec.reasoning.max_iterations, 8);
790    }
791
792    #[test]
793    fn test_agent_spec_with_reflection() {
794        let yaml = r#"
795    name: ReflectionAgent
796    system_prompt: "You are helpful."
797    llm:
798      provider: openai
799      model: gpt-4
800    reflection:
801      enabled: auto
802      evaluator_llm: router
803      max_retries: 3
804      pass_threshold: 0.8
805      criteria:
806        - "Response addresses the question"
807        - "Response is accurate"
808    "#;
809        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
810        assert!(spec.has_reflection());
811        assert_eq!(spec.reflection.max_retries, 3);
812        assert_eq!(spec.reflection.criteria.len(), 2);
813    }
814
815    #[test]
816    fn test_agent_spec_with_plan_and_execute() {
817        let yaml = r#"
818    name: PlanningAgent
819    system_prompt: "You are helpful."
820    llm:
821      provider: openai
822      model: gpt-4
823    reasoning:
824      mode: plan_and_execute
825      planning:
826        planner_llm: router
827        max_steps: 15
828        available:
829          tools: all
830          skills:
831            - analyze
832            - summarize
833        reflection:
834          enabled: true
835          on_step_failure: replan
836          max_replans: 3
837    "#;
838        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
839        assert!(spec.has_reasoning());
840        let planning = spec.reasoning.planning.as_ref().unwrap();
841        assert_eq!(planning.max_steps, 15);
842        assert!(planning.reflection.enabled);
843    }
844
845    #[test]
846    fn test_agent_spec_reasoning_defaults() {
847        let spec = AgentSpec::default();
848        assert!(!spec.has_reasoning());
849        assert!(!spec.has_reflection());
850    }
851
852    #[test]
853    fn test_agent_spec_state_level_reasoning_override() {
854        let yaml = r#"
855    name: StateReasoningAgent
856    system_prompt: "You are helpful."
857    llm:
858      provider: openai
859      model: gpt-4
860    reasoning:
861      mode: auto
862    states:
863      initial: greeting
864      states:
865        greeting:
866          prompt: "Welcome!"
867          reasoning:
868            mode: none
869        complex_analysis:
870          prompt: "Analyze this"
871          reasoning:
872            mode: cot
873            output: tagged
874          reflection:
875            enabled: true
876            criteria:
877              - "Analysis is thorough"
878    "#;
879        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
880        assert!(spec.has_reasoning());
881        assert!(spec.has_states());
882
883        let states = spec.states.as_ref().unwrap();
884        let greeting = states.states.get("greeting").unwrap();
885        assert!(greeting.reasoning.is_some());
886        let greeting_reasoning = greeting.reasoning.as_ref().unwrap();
887        assert_eq!(
888            greeting_reasoning.mode,
889            ai_agents_reasoning::ReasoningMode::None
890        );
891
892        let analysis = states.states.get("complex_analysis").unwrap();
893        assert!(analysis.reasoning.is_some());
894        assert!(analysis.reflection.is_some());
895        let analysis_reasoning = analysis.reasoning.as_ref().unwrap();
896        assert_eq!(
897            analysis_reasoning.mode,
898            ai_agents_reasoning::ReasoningMode::CoT
899        );
900    }
901
902    #[test]
903    fn test_agent_spec_skill_level_reasoning_override() {
904        use ai_agents_skills::SkillDefinition;
905
906        let skill_yaml = r#"
907id: complex_analysis
908description: "Analyze data"
909trigger: "When user asks for analysis"
910reasoning:
911  mode: cot
912reflection:
913  enabled: true
914  criteria:
915    - "Analysis covers all aspects"
916steps:
917  - prompt: "Analyze the input"
918"#;
919        let skill_def: SkillDefinition = serde_yaml::from_str(skill_yaml).unwrap();
920        assert!(skill_def.reasoning.is_some());
921        assert!(skill_def.reflection.is_some());
922        let reasoning = skill_def.reasoning.as_ref().unwrap();
923        assert_eq!(reasoning.mode, ai_agents_reasoning::ReasoningMode::CoT);
924        let reflection = skill_def.reflection.as_ref().unwrap();
925        assert!(reflection.is_enabled());
926
927        let simple_yaml = r#"
928id: simple_lookup
929description: "Look up simple facts"
930trigger: "When user asks for facts"
931reasoning:
932  mode: none
933reflection:
934  enabled: false
935steps:
936  - prompt: "Look up the fact"
937"#;
938        let simple_def: SkillDefinition = serde_yaml::from_str(simple_yaml).unwrap();
939        assert!(simple_def.reasoning.is_some());
940        let simple_reasoning = simple_def.reasoning.as_ref().unwrap();
941        assert_eq!(
942            simple_reasoning.mode,
943            ai_agents_reasoning::ReasoningMode::None
944        );
945    }
946
947    #[test]
948    fn test_agent_spec_with_disambiguation() {
949        let yaml = r#"
950name: DisambiguatingAgent
951system_prompt: "You are a helpful assistant."
952disambiguation:
953  enabled: true
954  detection:
955    llm: router
956    threshold: 0.8
957    aspects:
958      - missing_target
959      - vague_references
960  clarification:
961    style: auto
962    max_attempts: 3
963    on_max_attempts: proceed_with_best_guess
964  skip_when:
965    - type: social
966    - type: short_input
967      max_chars: 10
968llms:
969  default:
970    provider: openai
971    model: gpt-4.1-nano
972"#;
973        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
974        assert!(spec.has_disambiguation());
975        assert!(spec.disambiguation.is_enabled());
976        assert_eq!(spec.disambiguation.detection.threshold, 0.8);
977        assert_eq!(spec.disambiguation.clarification.max_attempts, 3);
978        assert_eq!(spec.disambiguation.skip_when.len(), 2);
979    }
980
981    #[test]
982    fn test_agent_spec_disambiguation_minimal() {
983        let yaml = r#"
984name: MinimalDisambiguatingAgent
985system_prompt: "You are helpful."
986disambiguation:
987  enabled: true
988llms:
989  default:
990    provider: openai
991    model: gpt-4.1-nano
992"#;
993        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
994        assert!(spec.has_disambiguation());
995        assert_eq!(spec.disambiguation.detection.llm, "router");
996        assert_eq!(spec.disambiguation.detection.threshold, 0.7);
997        assert_eq!(spec.disambiguation.clarification.max_attempts, 2);
998    }
999
1000    #[test]
1001    fn test_agent_spec_no_disambiguation_by_default() {
1002        let yaml = r#"
1003name: SimpleAgent
1004system_prompt: "You are helpful."
1005llms:
1006  default:
1007    provider: openai
1008    model: gpt-4.1-nano
1009"#;
1010        let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
1011        assert!(!spec.has_disambiguation());
1012        assert!(!spec.disambiguation.is_enabled());
1013    }
1014
1015    #[test]
1016    fn test_state_machine_examples_parse() {
1017        // Resolve workspace root: this crate is at crates/ai-agents-runtime
1018        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1019            .parent()
1020            .unwrap()
1021            .parent()
1022            .unwrap();
1023        let examples = [
1024            "examples/yaml/state-machine/two_state_greeting.yaml",
1025            "examples/yaml/state-machine/guard_transitions.yaml",
1026            "examples/yaml/state-machine/nested_states.yaml",
1027            "examples/yaml/state-machine/state_with_tools.yaml",
1028            "examples/yaml/state-machine/state_lifecycle.yaml",
1029            "examples/yaml/state-machine/support_state_machine.yaml",
1030        ];
1031        for rel_path in &examples {
1032            let path = workspace_root.join(rel_path);
1033            let content = std::fs::read_to_string(&path)
1034                .unwrap_or_else(|_| panic!("Failed to read {}", path.display()));
1035            let spec: AgentSpec = serde_yaml::from_str(&content)
1036                .unwrap_or_else(|e| panic!("Failed to parse {}: {}", path.display(), e));
1037            if let Some(ref states) = spec.states {
1038                states
1039                    .validate()
1040                    .unwrap_or_else(|e| panic!("Validation failed for {}: {}", path.display(), e));
1041            }
1042        }
1043    }
1044}