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