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