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