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