mod llm;
mod memory;
mod provider;
pub mod spawner;
pub(crate) mod storage;
mod tool;
pub use llm::{CliHitlMetadata, CliHitlStyle, CliMetadata, CliPromptStyle, LLMConfig, LLMSelector};
pub use memory::MemoryConfig;
pub use provider::ToolAliasesConfig;
pub use spawner::{
AutoSpawnEntry, ManagementToolsConfig, OrchestrationToolsConfig, SpawnerConfig,
SpawnerToolGrantConfig, TemplateSource,
};
pub use storage::{FileStorageConfig, RedisStorageConfig, SqliteStorageConfig, StorageConfig};
pub use tool::{StructuredToolEntry, ToolConfig, ToolEntry};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::{BTreeSet, HashMap};
use ai_agents_context::ContextSource;
use ai_agents_core::{AgentError, Result};
use ai_agents_disambiguation::DisambiguationConfig;
use ai_agents_hitl::HITLConfig;
use ai_agents_observability::ObservabilityConfig;
use ai_agents_persona::PersonaConfig;
use ai_agents_process::{ProcessConfig, ProcessStage};
use ai_agents_reasoning::{ReasoningConfig, ReflectionConfig};
use ai_agents_recovery::{
ContextOverflowAction, ErrorRecoveryConfig, LLMFailureAction, RateLimitAction,
};
use ai_agents_skills::{SkillRef, SkillStep};
use ai_agents_state::{
StateAction, StateConfig, StateDefinition, ToolCondition, Transition, TransitionTiming,
};
use ai_agents_tools::ToolSecurityConfig;
pub use super::RuntimeConfig;
use super::{ParallelToolsConfig, StreamingConfig};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSpec {
pub name: String,
#[serde(default = "default_version")]
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub system_prompt: String,
#[serde(default)]
pub llm: LLMConfigOrSelector,
#[serde(default)]
pub llms: HashMap<String, LLMConfig>,
#[serde(default)]
pub skills: Vec<SkillRef>,
#[serde(default)]
pub memory: MemoryConfig,
#[serde(default)]
pub storage: StorageConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<ToolConfig>>,
#[serde(default = "default_max_iterations")]
pub max_iterations: u32,
#[serde(default = "default_max_context_tokens")]
pub max_context_tokens: u32,
#[serde(default)]
pub error_recovery: ErrorRecoveryConfig,
#[serde(default)]
pub tool_security: ToolSecurityConfig,
#[serde(default)]
pub process: ProcessConfig,
#[serde(default)]
pub context: HashMap<String, ContextSource>,
#[serde(default)]
pub states: Option<StateConfig>,
#[serde(default)]
pub parallel_tools: ParallelToolsConfig,
#[serde(default)]
pub streaming: StreamingConfig,
#[serde(default)]
pub hitl: Option<HITLConfig>,
#[serde(default)]
pub reasoning: ReasoningConfig,
#[serde(default)]
pub reflection: ReflectionConfig,
#[serde(default)]
pub disambiguation: DisambiguationConfig,
#[serde(default)]
pub observability: ObservabilityConfig,
#[serde(default)]
pub runtime: RuntimeConfig,
#[serde(default)]
pub tool_aliases: ToolAliasesConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spawner: Option<SpawnerConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub persona: Option<PersonaConfig>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum LLMConfigOrSelector {
Config(LLMConfig),
Selector(LLMSelector),
}
impl<'de> Deserialize<'de> for LLMConfigOrSelector {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_yaml::Value::deserialize(deserializer)?;
let mapping = value.as_mapping().ok_or_else(|| {
serde::de::Error::custom("llm must be a provider configuration or alias selector")
})?;
let has_provider_field = mapping
.keys()
.any(|key| matches!(key.as_str(), Some("provider") | Some("model")));
if has_provider_field {
serde_yaml::from_value(value)
.map(Self::Config)
.map_err(serde::de::Error::custom)
} else {
serde_yaml::from_value(value)
.map(Self::Selector)
.map_err(serde::de::Error::custom)
}
}
}
impl Default for LLMConfigOrSelector {
fn default() -> Self {
LLMConfigOrSelector::Config(LLMConfig::default())
}
}
impl LLMConfigOrSelector {
pub fn as_config(&self) -> Option<&LLMConfig> {
match self {
LLMConfigOrSelector::Config(c) => Some(c),
LLMConfigOrSelector::Selector(_) => None,
}
}
pub fn as_selector(&self) -> Option<&LLMSelector> {
match self {
LLMConfigOrSelector::Config(_) => None,
LLMConfigOrSelector::Selector(s) => Some(s),
}
}
pub fn get_default_alias(&self) -> String {
match self {
LLMConfigOrSelector::Config(_) => "default".to_string(),
LLMConfigOrSelector::Selector(s) => s.default.clone(),
}
}
pub fn get_router_alias(&self) -> Option<String> {
match self {
LLMConfigOrSelector::Config(_) => None,
LLMConfigOrSelector::Selector(s) => s.router.clone(),
}
}
}
fn default_version() -> String {
"1.0.0".to_string()
}
fn default_max_iterations() -> u32 {
10
}
fn default_max_context_tokens() -> u32 {
128000
}
fn state_config_has_parallel_transitions(config: &StateConfig) -> bool {
config.global_transitions.iter().any(transition_is_parallel)
|| definitions_have_parallel_transitions(&config.states)
}
fn definitions_have_parallel_transitions(states: &HashMap<String, StateDefinition>) -> bool {
states.values().any(|definition| {
definition.transitions.iter().any(transition_is_parallel)
|| definition
.states
.as_ref()
.map(definitions_have_parallel_transitions)
.unwrap_or(false)
})
}
fn transition_is_parallel(transition: &Transition) -> bool {
matches!(transition.timing, TransitionTiming::Parallel)
}
fn insert_alias(aliases: &mut BTreeSet<String>, alias: Option<&String>) {
if let Some(alias) = alias {
aliases.insert(alias.clone());
}
}
fn collect_reasoning_aliases(config: &ReasoningConfig, aliases: &mut BTreeSet<String>) {
if !config.is_enabled() {
return;
}
insert_alias(aliases, config.judge_llm.as_ref());
if config.needs_planning() {
insert_alias(
aliases,
config
.planning
.as_ref()
.and_then(|plan| plan.planner_llm.as_ref()),
);
}
}
fn collect_reflection_aliases(config: &ReflectionConfig, aliases: &mut BTreeSet<String>) {
if config.enabled.requires_evaluation() {
insert_alias(aliases, config.evaluator_llm.as_ref());
}
}
fn collect_process_aliases(config: &ProcessConfig, aliases: &mut BTreeSet<String>) {
fn collect_stage(stage: &ProcessStage, aliases: &mut BTreeSet<String>) {
let alias = match stage {
ProcessStage::Detect(stage) => stage.config.llm.as_ref(),
ProcessStage::Extract(stage) => stage.config.llm.as_ref(),
ProcessStage::Sanitize(stage) => stage.config.llm.as_ref(),
ProcessStage::Transform(stage) => stage.config.llm.as_ref(),
ProcessStage::Validate(stage) => stage.config.llm.as_ref(),
ProcessStage::Conditional(stage) => {
for nested in stage
.config
.then_stages
.iter()
.chain(&stage.config.else_stages)
{
collect_stage(nested, aliases);
}
None
}
_ => None,
};
insert_alias(aliases, alias);
}
for stage in config.input.iter().chain(&config.output) {
collect_stage(stage, aliases);
}
}
fn collect_tool_condition_aliases(condition: &ToolCondition, aliases: &mut BTreeSet<String>) {
match condition {
ToolCondition::Semantic { llm, .. } => {
aliases.insert(llm.clone());
}
ToolCondition::All(conditions) | ToolCondition::Any(conditions) => {
for condition in conditions {
collect_tool_condition_aliases(condition, aliases);
}
}
ToolCondition::Not(condition) => collect_tool_condition_aliases(condition, aliases),
_ => {}
}
}
fn collect_state_aliases(config: &StateConfig, aliases: &mut BTreeSet<String>) {
fn collect_definition(definition: &StateDefinition, aliases: &mut BTreeSet<String>) {
insert_alias(aliases, definition.llm.as_ref());
for extractor in &definition.extract {
aliases.insert(extractor.llm.clone());
}
for action in definition
.on_enter
.iter()
.chain(&definition.on_reenter)
.chain(&definition.on_exit)
{
if let StateAction::Prompt { llm, .. } = action {
insert_alias(aliases, llm.as_ref());
}
}
for tool in definition.tools.iter().flatten() {
if let Some(condition) = tool.condition() {
collect_tool_condition_aliases(condition, aliases);
}
}
if let Some(reasoning) = definition.reasoning.as_ref() {
collect_reasoning_aliases(reasoning, aliases);
}
if let Some(reflection) = definition.reflection.as_ref() {
collect_reflection_aliases(reflection, aliases);
}
if let Some(process) = definition.process.as_ref() {
collect_process_aliases(process, aliases);
}
if let Some(concurrent) = definition.concurrent.as_ref() {
insert_alias(aliases, concurrent.aggregation.synthesizer_llm.as_ref());
}
if let Some(states) = definition.states.as_ref() {
for definition in states.values() {
collect_definition(definition, aliases);
}
}
}
for definition in config.states.values() {
collect_definition(definition, aliases);
}
}
impl Default for AgentSpec {
fn default() -> Self {
Self {
name: "Agent".to_string(),
version: default_version(),
description: None,
system_prompt: "You are a helpful assistant.".to_string(),
llm: LLMConfigOrSelector::default(),
llms: HashMap::new(),
skills: vec![],
memory: MemoryConfig::default(),
storage: StorageConfig::default(),
tools: None,
max_iterations: default_max_iterations(),
max_context_tokens: default_max_context_tokens(),
error_recovery: ErrorRecoveryConfig::default(),
tool_security: ToolSecurityConfig::default(),
process: ProcessConfig::default(),
context: HashMap::new(),
states: None,
parallel_tools: ParallelToolsConfig::default(),
streaming: StreamingConfig::default(),
hitl: None,
reasoning: ReasoningConfig::default(),
reflection: ReflectionConfig::default(),
disambiguation: DisambiguationConfig::default(),
observability: ObservabilityConfig::default(),
runtime: RuntimeConfig::default(),
tool_aliases: ToolAliasesConfig::default(),
metadata: None,
spawner: None,
persona: None,
}
}
}
fn normalize_unknown_path(path: &str) -> String {
path.replace(".?.", ".")
.trim_start_matches("?.")
.to_string()
}
fn format_paths(mut paths: Vec<String>) -> String {
paths.sort();
paths.dedup();
paths
.iter()
.map(|path| format!("'{path}'"))
.collect::<Vec<_>>()
.join(", ")
}
fn unknown_fields_error(paths: Vec<String>) -> AgentError {
AgentError::InvalidSpec(format!(
"Unknown AgentSpec field(s): {}",
format_paths(paths)
))
}
fn unknown_field_from_error(error: &str) -> Option<&str> {
error
.split_once("unknown field `")
.and_then(|(_, rest)| rest.split_once('`'))
.map(|(field, _)| field)
}
fn detailed_error_path(path: &str, error: &str) -> String {
let Some(field) = unknown_field_from_error(error) else {
return path.to_string();
};
if path.is_empty() {
field.to_string()
} else if path == field || path.ends_with(&format!(".{field}")) {
path.to_string()
} else {
format!("{path}.{field}")
}
}
fn serde_error_message(error: &serde_yaml::Error) -> String {
let message = error.to_string();
let Some(location) = error.location() else {
return message;
};
let suffix = format!(" at line {} column {}", location.line(), location.column());
message
.strip_suffix(&suffix)
.unwrap_or(&message)
.to_string()
}
fn collect_unsupported_yaml_keys(
value: &serde_yaml::Value,
path: &str,
unsupported_paths: &mut Vec<String>,
) {
match value {
serde_yaml::Value::Mapping(mapping) => {
for (key, child) in mapping {
let Some(key) = key.as_str() else {
unsupported_paths.push(if path.is_empty() {
"<non-string-key>".to_string()
} else {
format!("{path}.<non-string-key>")
});
continue;
};
let child_path = if path.is_empty() {
key.to_string()
} else {
format!("{path}.{key}")
};
if key == "<<" {
unsupported_paths.push(child_path);
continue;
}
collect_unsupported_yaml_keys(child, &child_path, unsupported_paths);
}
}
serde_yaml::Value::Sequence(values) => {
for (index, child) in values.iter().enumerate() {
collect_unsupported_yaml_keys(
child,
&format!("{path}[{index}]"),
unsupported_paths,
);
}
}
_ => {}
}
}
impl AgentSpec {
pub(crate) fn referenced_llm_aliases(&self) -> BTreeSet<String> {
let mut aliases = BTreeSet::new();
if self.memory.memory_type == "compacting" {
insert_alias(&mut aliases, self.memory.summarizer_llm.as_ref());
}
if let Some(facts) = self.memory.facts.as_ref()
&& facts.enabled
{
insert_alias(&mut aliases, facts.extractor_llm.as_ref());
}
if let Some(relationships) = self.memory.relationships.as_ref()
&& relationships.enabled
&& relationships.auto_update.enabled
{
insert_alias(&mut aliases, relationships.auto_update.llm.as_ref());
}
collect_reasoning_aliases(&self.reasoning, &mut aliases);
collect_reflection_aliases(&self.reflection, &mut aliases);
collect_process_aliases(&self.process, &mut aliases);
if let Some(states) = self.states.as_ref() {
collect_state_aliases(states, &mut aliases);
}
match &self.error_recovery.llm.on_failure {
LLMFailureAction::FallbackLlm { fallback_llm } => {
aliases.insert(fallback_llm.clone());
}
LLMFailureAction::Error | LLMFailureAction::FallbackResponse { .. } => {}
}
if let RateLimitAction::SwitchModel { fallback_llm } =
&self.error_recovery.llm.on_rate_limit
{
aliases.insert(fallback_llm.clone());
}
if let ContextOverflowAction::Summarize { summarizer_llm, .. } =
&self.error_recovery.llm.on_context_overflow
{
insert_alias(&mut aliases, summarizer_llm.as_ref());
}
if self.disambiguation.is_enabled() {
aliases.insert(self.disambiguation.detection.llm.clone());
insert_alias(&mut aliases, self.disambiguation.clarification.llm.as_ref());
}
if let Some(hitl) = self.hitl.as_ref()
&& let Some(generate) = hitl.message_language.llm_generate.as_ref()
{
aliases.insert(generate.llm.clone());
}
for skill in &self.skills {
let SkillRef::Inline(skill) = skill else {
continue;
};
if let Some(reasoning) = skill.reasoning.as_ref() {
collect_reasoning_aliases(reasoning, &mut aliases);
}
if let Some(reflection) = skill.reflection.as_ref() {
collect_reflection_aliases(reflection, &mut aliases);
}
for step in &skill.steps {
if let SkillStep::Prompt { llm, .. } = step {
insert_alias(&mut aliases, llm.as_ref());
}
}
}
aliases
}
pub fn from_yaml_strict(yaml: &str) -> Result<Self> {
let input_value: serde_yaml::Value = serde_yaml::from_str(yaml)?;
let mut unsupported_paths = Vec::new();
collect_unsupported_yaml_keys(&input_value, "", &mut unsupported_paths);
if !unsupported_paths.is_empty() {
return Err(AgentError::InvalidSpec(format!(
"Unsupported AgentSpec YAML key(s): {}",
format_paths(unsupported_paths)
)));
}
let mut unknown_paths = Vec::new();
let deserializer = serde_yaml::Deserializer::from_str(yaml);
let spec = match serde_ignored::deserialize(deserializer, |path| {
unknown_paths.push(normalize_unknown_path(&path.to_string()));
}) {
Ok(spec) => spec,
Err(error) => {
let deserializer = serde_yaml::Deserializer::from_str(yaml);
let detailed = serde_path_to_error::deserialize::<_, AgentSpec>(deserializer)
.map_err(|path_error| {
let path = normalize_unknown_path(&path_error.path().to_string());
let error = path_error.inner();
let message = serde_error_message(error);
let detailed_path = detailed_error_path(&path, &message);
let location = error
.location()
.map(|location| {
format!(
" at line {}, column {}",
location.line(),
location.column()
)
})
.unwrap_or_default();
AgentError::InvalidSpec(format!(
"Invalid AgentSpec field '{detailed_path}'{location}: {message}"
))
});
return match detailed {
Ok(_) => Err(error.into()),
Err(error) => Err(error),
};
}
};
if !unknown_paths.is_empty() {
return Err(unknown_fields_error(unknown_paths));
}
Ok(spec)
}
pub fn validate(&self) -> Result<()> {
if self.name.is_empty() {
return Err(AgentError::InvalidSpec(
"Agent name cannot be empty".to_string(),
));
}
if self.system_prompt.is_empty() {
return Err(AgentError::InvalidSpec(
"System prompt cannot be empty".to_string(),
));
}
if self.max_iterations == 0 {
return Err(AgentError::InvalidSpec(
"Max iterations must be greater than 0".to_string(),
));
}
if let Some(ref states) = self.states {
states.validate()?;
}
self.error_recovery.validate()?;
self.tool_security.validate()?;
self.runtime.optimization.validate()?;
self.validate_runtime_optimization_cross_fields()?;
Ok(())
}
fn validate_runtime_optimization_cross_fields(&self) -> Result<()> {
let optimization = &self.runtime.optimization;
if matches!(
optimization.streaming_policy,
super::StreamingOptimizationPolicy::BufferUntilRoutingDone
) {
if !optimization.enabled || !self.streaming.enabled {
return Err(AgentError::InvalidSpec(
"runtime.optimization.streaming_policy=buffer_until_routing_done requires runtime optimization and streaming.enabled=true".into(),
));
}
if self.streaming.buffer_size == 0 {
return Err(AgentError::InvalidSpec(
"streaming.buffer_size must be greater than 0 with buffer_until_routing_done"
.into(),
));
}
}
if let Some(states) = &self.states {
let has_parallel = state_config_has_parallel_transitions(states);
if has_parallel
&& (!optimization.enabled || !optimization.speculative_state_transitions)
{
return Err(AgentError::InvalidSpec(
"transition timing parallel requires runtime.optimization.enabled=true and speculative_state_transitions=true".into(),
));
}
if has_parallel && optimization.max_speculative_llm_calls_per_turn == 0 {
return Err(AgentError::InvalidSpec(
"transition timing parallel requires max_speculative_llm_calls_per_turn greater than 0".into(),
));
}
}
Ok(())
}
pub fn has_multi_llm(&self) -> bool {
!self.llms.is_empty()
}
pub fn has_skills(&self) -> bool {
!self.skills.is_empty()
}
pub fn has_process(&self) -> bool {
!self.process.input.is_empty() || !self.process.output.is_empty()
}
pub fn has_tool_security(&self) -> bool {
self.tool_security.enabled
}
pub fn has_states(&self) -> bool {
self.states.is_some()
}
pub fn has_context(&self) -> bool {
!self.context.is_empty()
}
pub fn has_parallel_tools(&self) -> bool {
self.parallel_tools.enabled
}
pub fn has_streaming(&self) -> bool {
self.streaming.enabled
}
pub fn has_hitl(&self) -> bool {
self.hitl.is_some()
}
pub fn has_storage(&self) -> bool {
!self.storage.is_none()
}
pub fn has_tool_aliases(&self) -> bool {
!self.tool_aliases.tools.is_empty()
}
pub fn has_reasoning(&self) -> bool {
self.reasoning.is_enabled()
}
pub fn has_reflection(&self) -> bool {
self.reflection.requires_evaluation()
}
pub fn has_disambiguation(&self) -> bool {
self.disambiguation.is_enabled()
}
pub fn has_observability(&self) -> bool {
self.observability.enabled
}
pub fn has_runtime_optimization(&self) -> bool {
self.runtime.optimization.enabled
}
pub fn has_persona(&self) -> bool {
self.persona.as_ref().is_some_and(|p| p.is_configured())
}
pub fn has_actor_memory(&self) -> bool {
self.memory.has_actor_memory()
}
pub fn has_facts(&self) -> bool {
self.memory.has_facts()
}
pub fn has_relationships(&self) -> bool {
self.memory.has_relationships()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn strict_error(yaml: &str) -> String {
AgentSpec::from_yaml_strict(yaml).unwrap_err().to_string()
}
fn assert_unknown_path(yaml: &str, expected_path: &str) {
let error = strict_error(yaml);
assert!(error.contains(expected_path), "{error}");
}
#[test]
fn test_agent_spec_minimal() {
let yaml = r#"
name: TestAgent
system_prompt: "You are a helpful assistant."
llm:
provider: openai
model: gpt-4
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spec.name, "TestAgent");
assert_eq!(spec.version, "1.0.0");
assert_eq!(spec.max_iterations, 10);
assert!(spec.validate().is_ok());
}
#[test]
fn test_agent_spec_rejects_top_level_typo() {
let yaml = r#"
name: TestAgent
system_prompt: "You are a helpful assistant."
max_iteratons: 20
"#;
assert_unknown_path(yaml, "max_iteratons");
}
#[test]
fn test_agent_spec_rejects_nested_typo() {
let yaml = r#"
name: TestAgent
system_prompt: "You are a helpful assistant."
storage:
type: redis
url: redis://localhost:6379
ttl_second: 60
"#;
assert_unknown_path(yaml, "storage.ttl_second");
}
#[test]
fn test_agent_spec_rejects_memory_typo() {
let yaml = r#"
name: TestAgent
system_prompt: "You are a helpful assistant."
memory:
type: compacting
compress_thresold: 30
"#;
assert_unknown_path(yaml, "memory.compress_thresold");
}
#[test]
fn test_agent_spec_preserves_llm_provider_extras() {
let yaml = r#"
name: OllamaAgent
system_prompt: "You are a helpful assistant."
llm:
provider: ollama
model: llama3.1
num_ctx: 8192
keep_alive: 5m
llms:
router:
provider: openai
model: gpt-4.1-nano
provider_extension: enabled
"#;
let spec = AgentSpec::from_yaml_strict(yaml).unwrap();
let llm = spec.llm.as_config().unwrap();
assert_eq!(llm.extra.get("num_ctx"), Some(&serde_json::json!(8192)));
assert_eq!(llm.extra.get("keep_alive"), Some(&serde_json::json!("5m")));
assert_eq!(
spec.llms["router"].extra.get("provider_extension"),
Some(&serde_json::json!("enabled"))
);
}
#[test]
fn referenced_llm_aliases_use_typed_active_configuration() {
let yaml = r#"
name: AliasAgent
system_prompt: test
llm:
provider: openai
model: test
extension:
llm: ignored_extension_value
memory:
type: compacting
summarizer_llm: memory_summary
facts:
enabled: true
extractor_llm: fact_extract
relationships:
enabled: true
auto_update:
enabled: true
llm: relationship_eval
reasoning:
mode: plan_and_execute
judge_llm: reasoning_judge
planning:
planner_llm: reasoning_plan
reflection:
enabled: auto
evaluator_llm: reflection_eval
process:
input:
- type: transform
config:
llm: process_transform
states:
initial: active
states:
active:
llm: state_response
extract:
- key: value
description: value
llm: state_extract
concurrent:
agents: [worker]
aggregation:
strategy: llm_synthesis
synthesizer_llm: state_synthesis
error_recovery:
llm:
on_failure:
action: fallback_llm
fallback_llm: recovery_fallback
on_rate_limit:
action: switch_model
fallback_llm: recovery_rate_limit
on_context_overflow:
action: summarize
summarizer_llm: recovery_summary
disambiguation:
enabled: true
detection:
llm: disambiguation_detect
clarification:
llm: disambiguation_clarify
hitl:
message_language:
strategy: llm_generate
llm_generate:
llm: hitl_generate
skills:
- id: inline
description: inline
trigger: always
steps:
- prompt: test
llm: skill_prompt
"#;
let spec = AgentSpec::from_yaml_strict(yaml).unwrap();
assert_eq!(
spec.referenced_llm_aliases(),
BTreeSet::from([
"disambiguation_clarify".to_string(),
"disambiguation_detect".to_string(),
"fact_extract".to_string(),
"hitl_generate".to_string(),
"memory_summary".to_string(),
"process_transform".to_string(),
"reasoning_judge".to_string(),
"reasoning_plan".to_string(),
"recovery_fallback".to_string(),
"recovery_rate_limit".to_string(),
"recovery_summary".to_string(),
"reflection_eval".to_string(),
"relationship_eval".to_string(),
"skill_prompt".to_string(),
"state_extract".to_string(),
"state_response".to_string(),
"state_synthesis".to_string(),
])
);
}
#[test]
fn test_strict_yaml_reports_tagged_unknown_field_path() {
let yaml = "name: TestAgent\nsystem_prompt: test\nstorage:\n type: redis\n url: redis://localhost:6379\n ttl_second: 60\n";
assert_unknown_path(yaml, "storage.ttl_second");
}
#[test]
fn test_strict_yaml_reports_untagged_selector_unknown_field_path() {
let yaml = "name: TestAgent\nsystem_prompt: test\nllm:\n defualt: default\n";
assert_unknown_path(yaml, "llm.defualt");
}
#[test]
fn test_strict_yaml_reports_untagged_template_unknown_field_path() {
let yaml = "name: TestAgent\nsystem_prompt: test\nspawner:\n templates:\n npc:\n pat: child.yaml\n";
assert_unknown_path(yaml, "spawner.templates.npc.pat");
}
#[test]
fn test_strict_yaml_rejects_runtime_optimization_typo() {
let yaml = r#"
name: TestAgent
system_prompt: test
runtime:
optimization:
max_parallel_runtime_task: 4
"#;
assert_unknown_path(yaml, "runtime.optimization.max_parallel_runtime_task");
}
#[test]
fn test_strict_yaml_rejects_tool_security_typo() {
let yaml = r#"
name: TestAgent
system_prompt: test
tool_security:
enabeld: true
"#;
assert_unknown_path(yaml, "tool_security.enabeld");
}
#[test]
fn test_strict_yaml_rejects_process_typo() {
let yaml = r#"
name: TestAgent
system_prompt: test
process:
input:
- type: normalize
config:
trm: true
"#;
let error = AgentSpec::from_yaml_strict(yaml).unwrap_err().to_string();
assert!(error.contains("process.input[0]"), "{error}");
assert!(error.contains("trm"), "{error}");
}
#[test]
fn test_strict_yaml_rejects_state_typo() {
let yaml = r#"
name: TestAgent
system_prompt: test
states:
initial: start
states:
start:
promt: hello
"#;
assert_unknown_path(yaml, "states.states.start.promt");
}
#[test]
fn test_strict_yaml_rejects_hitl_typo() {
let yaml = r#"
name: TestAgent
system_prompt: test
hitl:
default_timeout_second: 30
"#;
assert_unknown_path(yaml, "hitl.default_timeout_second");
}
#[test]
fn test_strict_yaml_rejects_memory_and_storage_typos() {
let memory_yaml = r#"
name: TestAgent
system_prompt: test
memory:
type: compacting
compress_thresold: 30
"#;
assert_unknown_path(memory_yaml, "memory.compress_thresold");
let storage_yaml = r#"
name: TestAgent
system_prompt: test
storage:
type: redis
url: redis://localhost:6379
ttl_second: 60
"#;
let error = AgentSpec::from_yaml_strict(storage_yaml)
.unwrap_err()
.to_string();
assert!(error.contains("storage"), "{error}");
assert!(error.contains("ttl_second"), "{error}");
}
#[test]
fn test_strict_yaml_preserves_structured_tool_extensions() {
let yaml = r#"
name: ToolAgent
system_prompt: test
tools:
- name: github
type: mcp
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-github"]
env:
GITHUB_TOKEN: test
- name: http
custom_header: X-Test
tool_aliases:
custom_tool:
names:
en: Custom Tool
metadata:
custom:
arbitrary: true
tool_security:
tools:
dangerous:
require_approval: true
"#;
let spec = AgentSpec::from_yaml_strict(yaml).unwrap();
let tools = spec.tools.unwrap();
assert!(tools[0].is_mcp());
match &tools[1] {
ToolEntry::Structured(tool) => {
assert_eq!(
tool.extra.get("custom_header"),
Some(&serde_json::json!("X-Test"))
);
}
ToolEntry::Simple(_) => panic!("expected structured tool"),
}
assert!(spec.tool_aliases.tools.contains_key("custom_tool"));
assert!(spec.tool_security.tools["dangerous"].require_confirmation);
assert_eq!(
spec.metadata.as_ref().unwrap()["custom"]["arbitrary"],
serde_json::json!(true)
);
}
#[test]
fn test_strict_yaml_rejects_removed_provider_sections() {
for field in ["providers", "provider_security"] {
let yaml = format!("name: TestAgent\nsystem_prompt: test\n{field}: {{}}\n");
assert_unknown_path(&yaml, field);
}
}
#[test]
fn test_strict_yaml_accepts_explicit_empty_known_fields() {
let yaml = r#"
name: EmptyFieldsAgent
system_prompt: test
skills:
- id: inline
description: test
trigger: test
steps:
- prompt: hello
disambiguation:
required_clarity: []
clarification_templates: {}
"#;
AgentSpec::from_yaml_strict(yaml).unwrap();
}
#[test]
fn test_strict_yaml_rejects_null_and_non_string_skill_keys() {
let null_typo = r#"
name: NullTypoAgent
system_prompt: test
skills:
- file: child.yaml
typo:
"#;
assert_unknown_path(null_typo, "skills[0]");
let non_string_key = r#"
name: NumericKeyAgent
system_prompt: test
skills:
- file: child.yaml
1: ignored
"#;
assert_unknown_path(non_string_key, "skills[0].<non-string-key>");
}
#[test]
fn test_strict_yaml_rejects_merge_keys_everywhere() {
let yaml = r#"
name: MergeAgent
system_prompt: test
llm:
provider: ollama
model: llama3.1
<<:
num_ctx: 8192
"#;
assert_unknown_path(yaml, "llm.<<");
}
#[test]
fn test_agent_spec_with_states() {
let yaml = r#"
name: StatefulAgent
system_prompt: "You are helpful."
llm:
provider: openai
model: gpt-4
states:
initial: greeting
states:
greeting:
prompt: "Welcome!"
transitions:
- to: support
when: "user needs help"
support:
prompt: "How can I help?"
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.has_states());
assert!(spec.validate().is_ok());
}
#[test]
fn test_agent_spec_with_context() {
let yaml = r#"
name: ContextAgent
system_prompt: "Hello, {{ context.user.name }}!"
llm:
provider: openai
model: gpt-4
context:
user:
type: runtime
required: true
time:
type: builtin
source: datetime
refresh: per_turn
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.has_context());
assert_eq!(spec.context.len(), 2);
}
#[test]
fn test_agent_spec_with_tool_security() {
let yaml = r#"
name: SecureAgent
version: 2.0.0
system_prompt: "You are an advanced AI."
llm:
provider: openai
model: gpt-4
max_context_tokens: 8192
error_recovery:
default:
max_retries: 5
tool_security:
enabled: true
default_timeout_ms: 10000
tools:
http:
rate_limit: 10
blocked_domains:
- evil.com
process:
input:
- type: normalize
config:
trim: true
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spec.name, "SecureAgent");
assert_eq!(spec.max_context_tokens, 8192);
assert_eq!(spec.error_recovery.default.max_retries, 5);
assert!(spec.tool_security.enabled);
assert!(spec.has_tool_security());
assert!(!spec.process.input.is_empty());
assert!(spec.has_process());
}
#[test]
fn test_agent_spec_with_multi_llm() {
let yaml = r#"
name: MultiLLMAgent
system_prompt: "You are helpful."
llms:
default:
provider: openai
model: gpt-4.1-nano
router:
provider: openai
model: gpt-4.1-nano
llm:
default: default
router: router
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.has_multi_llm());
assert_eq!(spec.llms.len(), 2);
assert!(spec.llms.contains_key("default"));
assert!(spec.llms.contains_key("router"));
}
#[test]
fn test_agent_spec_with_skills() {
let yaml = r#"
name: SkillAgent
system_prompt: "You are helpful."
llm:
provider: openai
model: gpt-4
skills:
- weather_clothes
- file: ./custom.yaml
- id: inline_skill
description: "An inline skill"
trigger: "When user asks"
steps:
- prompt: "Hello"
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.has_skills());
assert_eq!(spec.skills.len(), 3);
}
#[test]
fn test_agent_spec_validation_empty_name() {
let mut spec = AgentSpec {
name: String::new(),
..AgentSpec::default()
};
assert!(spec.validate().is_err());
spec.name = "Valid".to_string();
assert!(spec.validate().is_ok());
}
#[test]
fn test_agent_spec_validation_empty_prompt() {
let mut spec = AgentSpec {
system_prompt: String::new(),
..AgentSpec::default()
};
assert!(spec.validate().is_err());
spec.system_prompt = "Valid prompt".to_string();
assert!(spec.validate().is_ok());
}
#[test]
fn test_agent_spec_validation_zero_iterations() {
let mut spec = AgentSpec {
max_iterations: 0,
..AgentSpec::default()
};
assert!(spec.validate().is_err());
spec.max_iterations = 5;
assert!(spec.validate().is_ok());
}
#[test]
fn test_agent_spec_validation_rejects_zero_max_results() {
let mut spec = AgentSpec::default();
spec.tool_security.tools.insert(
"web_search".to_string(),
ai_agents_tools::ToolPolicyConfig {
max_results: Some(0),
..Default::default()
},
);
let error = spec.validate().unwrap_err();
assert!(
error
.to_string()
.contains("tool_security.tools.web_search.max_results must be greater than 0")
);
spec.tool_security
.tools
.get_mut("web_search")
.unwrap()
.max_results = Some(1);
assert!(spec.validate().is_ok());
}
#[test]
fn test_agent_spec_validation_rejects_unrepresentable_tool_timeouts() {
let yaml = format!(
r#"
name: TimeoutAgent
system_prompt: Test timeout validation.
tool_security:
default_timeout_ms: {}
tools:
slow:
timeout_ms: {}
"#,
ai_agents_tools::MAX_TOOL_TIMEOUT_MS + 1,
u64::MAX
);
let spec = AgentSpec::from_yaml_strict(&yaml).unwrap();
let error = spec.validate().unwrap_err();
let message = error.to_string();
assert!(message.contains("tool_security.default_timeout_ms"));
assert!(message.contains("tool_security.tools.slow.timeout_ms"));
assert!(message.contains("3153600000000000 milliseconds"));
}
#[test]
fn test_agent_spec_validation_rejects_unrepresentable_recovery_timeouts() {
let yaml = format!(
r#"
name: RecoveryTimeoutAgent
system_prompt: Test recovery timeout validation.
error_recovery:
tools:
default:
timeout_ms: {}
slow:
timeout_ms: {}
"#,
ai_agents_core::MAX_TOOL_TIMEOUT_MS + 1,
u64::MAX
);
let spec = AgentSpec::from_yaml_strict(&yaml).unwrap();
let error = spec.validate().unwrap_err();
let message = error.to_string();
assert!(message.contains("error_recovery.tools.default.timeout_ms"));
assert!(message.contains("error_recovery.tools.slow.timeout_ms"));
assert!(message.contains("3153600000000000 milliseconds"));
}
#[test]
fn test_agent_spec_with_parallel_tools() {
let yaml = r#"
name: ParallelAgent
system_prompt: "You are helpful."
llm:
provider: openai
model: gpt-4
parallel_tools:
enabled: true
max_parallel: 10
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.has_parallel_tools());
assert_eq!(spec.parallel_tools.max_parallel, 10);
}
#[test]
fn test_agent_spec_with_streaming() {
let yaml = r#"
name: StreamingAgent
system_prompt: "You are helpful."
llm:
provider: openai
model: gpt-4
streaming:
enabled: true
buffer_size: 64
include_tool_events: true
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.has_streaming());
assert_eq!(spec.streaming.buffer_size, 64);
}
#[test]
fn test_agent_spec_defaults() {
let spec = AgentSpec::default();
assert!(spec.parallel_tools.enabled);
assert_eq!(spec.parallel_tools.max_parallel, 5);
assert!(spec.streaming.enabled);
assert!(!spec.has_hitl());
}
#[test]
fn test_agent_spec_with_hitl() {
let yaml = r#"
name: HITLAgent
system_prompt: "You are helpful."
llm:
provider: openai
model: gpt-4
hitl:
default_timeout_seconds: 600
on_timeout: reject
tools:
send_payment:
require_approval: true
approval_context:
- amount
- recipient
approval_message: "Approve payment?"
conditions:
- name: high_value
when: "amount > 1000"
require_approval: true
states:
escalation:
on_enter: require_approval
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.has_hitl());
let hitl = spec.hitl.as_ref().unwrap();
assert_eq!(hitl.default_timeout_seconds, 600);
assert_eq!(hitl.tools.len(), 1);
assert_eq!(hitl.conditions.len(), 1);
assert_eq!(hitl.states.len(), 1);
}
#[test]
fn test_agent_spec_with_storage_file() {
let yaml = r#"
name: PersistentAgent
system_prompt: "You are helpful."
llm:
provider: openai
model: gpt-4
storage:
type: file
path: "./data/sessions"
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.has_storage());
assert!(spec.storage.is_file());
assert_eq!(spec.storage.get_path(), Some("./data/sessions"));
}
#[test]
fn test_agent_spec_with_storage_sqlite() {
let yaml = r#"
name: PersistentAgent
system_prompt: "You are helpful."
llm:
provider: openai
model: gpt-4
storage:
type: sqlite
path: "./data/sessions.db"
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.has_storage());
assert!(spec.storage.is_sqlite());
}
#[test]
fn test_agent_spec_with_storage_redis() {
let yaml = r#"
name: PersistentAgent
system_prompt: "You are helpful."
llm:
provider: openai
model: gpt-4
storage:
type: redis
url: "redis://localhost:6379"
prefix: "myagent:"
ttl_seconds: 86400
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.has_storage());
assert!(spec.storage.is_redis());
assert_eq!(spec.storage.get_url(), Some("redis://localhost:6379"));
assert_eq!(spec.storage.get_prefix(), "myagent:");
assert_eq!(spec.storage.get_ttl(), Some(86400));
}
#[test]
fn test_agent_spec_no_storage_by_default() {
let spec = AgentSpec::default();
assert!(!spec.has_storage());
assert!(spec.storage.is_none());
}
#[test]
fn test_agent_spec_with_tool_aliases() {
let yaml = r#"
name: AliasAgent
system_prompt: "You are helpful."
llm:
provider: openai
model: gpt-4
tool_aliases:
calculator:
names:
ko: 계산기
ja: 計算機
descriptions:
ko: 수학 계산을 합니다
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.has_tool_aliases());
let calc_aliases = spec.tool_aliases.tools.get("calculator").unwrap();
assert_eq!(calc_aliases.get_name("ko"), Some("계산기"));
}
#[test]
fn test_agent_spec_with_reasoning() {
let yaml = r#"
name: ReasoningAgent
system_prompt: "You are helpful."
llm:
provider: openai
model: gpt-4
reasoning:
mode: cot
judge_llm: router
output: tagged
max_iterations: 8
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.has_reasoning());
assert_eq!(spec.reasoning.max_iterations, 8);
}
#[test]
fn test_agent_spec_with_reflection() {
let yaml = r#"
name: ReflectionAgent
system_prompt: "You are helpful."
llm:
provider: openai
model: gpt-4
reflection:
enabled: auto
evaluator_llm: router
max_retries: 3
pass_threshold: 0.8
criteria:
- "Response addresses the question"
- "Response is accurate"
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.has_reflection());
assert_eq!(spec.reflection.max_retries, 3);
assert_eq!(spec.reflection.criteria.len(), 2);
}
#[test]
fn test_agent_spec_with_plan_and_execute() {
let yaml = r#"
name: PlanningAgent
system_prompt: "You are helpful."
llm:
provider: openai
model: gpt-4
reasoning:
mode: plan_and_execute
planning:
planner_llm: router
max_steps: 15
available:
tools: all
skills:
- analyze
- summarize
reflection:
enabled: true
on_step_failure: replan
max_replans: 3
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.has_reasoning());
let planning = spec.reasoning.planning.as_ref().unwrap();
assert_eq!(planning.max_steps, 15);
assert!(planning.reflection.enabled);
}
#[test]
fn test_agent_spec_reasoning_defaults() {
let spec = AgentSpec::default();
assert!(!spec.has_reasoning());
assert!(!spec.has_reflection());
}
#[test]
fn test_agent_spec_state_level_reasoning_override() {
let yaml = r#"
name: StateReasoningAgent
system_prompt: "You are helpful."
llm:
provider: openai
model: gpt-4
reasoning:
mode: auto
states:
initial: greeting
states:
greeting:
prompt: "Welcome!"
reasoning:
mode: none
complex_analysis:
prompt: "Analyze this"
reasoning:
mode: cot
output: tagged
reflection:
enabled: true
criteria:
- "Analysis is thorough"
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.has_reasoning());
assert!(spec.has_states());
let states = spec.states.as_ref().unwrap();
let greeting = states.states.get("greeting").unwrap();
assert!(greeting.reasoning.is_some());
let greeting_reasoning = greeting.reasoning.as_ref().unwrap();
assert_eq!(
greeting_reasoning.mode,
ai_agents_reasoning::ReasoningMode::None
);
let analysis = states.states.get("complex_analysis").unwrap();
assert!(analysis.reasoning.is_some());
assert!(analysis.reflection.is_some());
let analysis_reasoning = analysis.reasoning.as_ref().unwrap();
assert_eq!(
analysis_reasoning.mode,
ai_agents_reasoning::ReasoningMode::CoT
);
}
#[test]
fn test_agent_spec_skill_level_reasoning_override() {
use ai_agents_skills::SkillDefinition;
let skill_yaml = r#"
id: complex_analysis
description: "Analyze data"
trigger: "When user asks for analysis"
reasoning:
mode: cot
reflection:
enabled: true
criteria:
- "Analysis covers all aspects"
steps:
- prompt: "Analyze the input"
"#;
let skill_def: SkillDefinition = serde_yaml::from_str(skill_yaml).unwrap();
assert!(skill_def.reasoning.is_some());
assert!(skill_def.reflection.is_some());
let reasoning = skill_def.reasoning.as_ref().unwrap();
assert_eq!(reasoning.mode, ai_agents_reasoning::ReasoningMode::CoT);
let reflection = skill_def.reflection.as_ref().unwrap();
assert!(reflection.is_enabled());
let simple_yaml = r#"
id: simple_lookup
description: "Look up simple facts"
trigger: "When user asks for facts"
reasoning:
mode: none
reflection:
enabled: false
steps:
- prompt: "Look up the fact"
"#;
let simple_def: SkillDefinition = serde_yaml::from_str(simple_yaml).unwrap();
assert!(simple_def.reasoning.is_some());
let simple_reasoning = simple_def.reasoning.as_ref().unwrap();
assert_eq!(
simple_reasoning.mode,
ai_agents_reasoning::ReasoningMode::None
);
}
#[test]
fn test_agent_spec_with_disambiguation() {
let yaml = r#"
name: DisambiguatingAgent
system_prompt: "You are a helpful assistant."
disambiguation:
enabled: true
detection:
llm: router
threshold: 0.8
aspects:
- missing_target
- vague_references
clarification:
style: auto
max_attempts: 3
on_max_attempts: proceed_with_best_guess
skip_when:
- type: social
- type: short_input
max_chars: 10
llms:
default:
provider: openai
model: gpt-4.1-nano
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.has_disambiguation());
assert!(spec.disambiguation.is_enabled());
assert_eq!(spec.disambiguation.detection.threshold, 0.8);
assert_eq!(spec.disambiguation.clarification.max_attempts, 3);
assert_eq!(spec.disambiguation.skip_when.len(), 2);
}
#[test]
fn test_agent_spec_disambiguation_minimal() {
let yaml = r#"
name: MinimalDisambiguatingAgent
system_prompt: "You are helpful."
disambiguation:
enabled: true
llms:
default:
provider: openai
model: gpt-4.1-nano
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.has_disambiguation());
assert_eq!(spec.disambiguation.detection.llm, "router");
assert_eq!(spec.disambiguation.detection.threshold, 0.7);
assert_eq!(spec.disambiguation.clarification.max_attempts, 2);
}
#[test]
fn test_agent_spec_no_disambiguation_by_default() {
let yaml = r#"
name: SimpleAgent
system_prompt: "You are helpful."
llms:
default:
provider: openai
model: gpt-4.1-nano
"#;
let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(!spec.has_disambiguation());
assert!(!spec.disambiguation.is_enabled());
}
#[test]
fn test_state_machine_examples_parse() {
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap();
let examples = [
"examples/yaml/state-machine/two_state_greeting.yaml",
"examples/yaml/state-machine/guard_transitions.yaml",
"examples/yaml/state-machine/nested_states.yaml",
"examples/yaml/state-machine/state_with_tools.yaml",
"examples/yaml/state-machine/state_lifecycle.yaml",
"examples/yaml/state-machine/support_state_machine.yaml",
];
for rel_path in &examples {
let path = workspace_root.join(rel_path);
let content = std::fs::read_to_string(&path)
.unwrap_or_else(|_| panic!("Failed to read {}", path.display()));
let spec: AgentSpec = serde_yaml::from_str(&content)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path.display(), e));
if let Some(ref states) = spec.states {
states
.validate()
.unwrap_or_else(|e| panic!("Validation failed for {}: {}", path.display(), e));
}
}
}
}