Skip to main content

ai_agents_runtime/
builder.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::str::FromStr;
4use std::sync::Arc;
5
6use ai_agents_context::ContextManager;
7use ai_agents_core::{AgentError, AgentStorage, LLMFeature, LLMProvider, Result, Tool};
8use ai_agents_hitl::{ApprovalHandler, HITLEngine, RejectAllHandler};
9use ai_agents_hooks::{AgentHooks, CompositeHooks};
10use ai_agents_llm::LLMRegistry;
11use ai_agents_llm::providers::{ProviderType, UnifiedLLMProvider};
12use ai_agents_memory::{
13    CompactingMemory, InMemoryStore, LLMSummarizer, Memory, NoopSummarizer, Summarizer,
14};
15use ai_agents_observability::{
16    ObservabilityConfig, ObservabilityHooks, ObservabilityManager, ObservedLLMProvider,
17    ObservedTool,
18};
19use ai_agents_process::ProcessProcessor;
20use ai_agents_reasoning::{ReasoningConfig, ReflectionConfig};
21use ai_agents_recovery::{MessageFilter, RecoveryManager};
22use ai_agents_relationships::{
23    RelationshipEvaluator, RelationshipEvaluatorTrait, RelationshipManager,
24};
25use ai_agents_skills::{SkillDefinition, SkillLoader};
26use ai_agents_state::{LLMTransitionEvaluator, StateMachine, TransitionEvaluator};
27use ai_agents_template::{TemplateInheritance, TemplateLoader, TemplateRenderer};
28use ai_agents_tools::mcp::view::MCPViewTool;
29use ai_agents_tools::mcp::wrapper::MCPWrapperTool;
30use ai_agents_tools::{ToolRegistry, ToolSecurityEngine, create_builtin_registry};
31
32use super::AgentInfo;
33use super::StreamingConfig;
34use super::runtime::{RuntimeAgent, ToolResourceLocks};
35use crate::spec::{AgentSpec, StorageConfig};
36
37fn feature_overrides_from_config(config: &crate::spec::LLMConfig) -> HashMap<LLMFeature, bool> {
38    let mut overrides = HashMap::new();
39    if let Some(enabled) = config.function_calling {
40        overrides.insert(LLMFeature::FunctionCalling, enabled);
41    }
42    if let Some(enabled) = config.vision {
43        overrides.insert(LLMFeature::Vision, enabled);
44    }
45    if let Some(enabled) = config.json_mode {
46        overrides.insert(LLMFeature::JsonMode, enabled);
47    }
48    overrides
49}
50
51/// Builds alias-to-model metadata used by observed LLM wrappers.
52fn model_by_alias_from_spec(spec: Option<&AgentSpec>) -> HashMap<String, String> {
53    spec.map(|spec| {
54        let mut models: HashMap<String, String> = spec
55            .llms
56            .iter()
57            .map(|(alias, config)| (alias.clone(), config.model.clone()))
58            .collect();
59        if let Some(config) = spec.llm.as_config() {
60            models.insert("default".to_string(), config.model.clone());
61        }
62        models
63    })
64    .unwrap_or_default()
65}
66
67/// Wraps every provider in a registry while preserving aliases and router/default settings.
68fn wrap_registry_with_observability(
69    registry: LLMRegistry,
70    manager: Arc<ObservabilityManager>,
71    model_by_alias: &HashMap<String, String>,
72) -> LLMRegistry {
73    registry.map_providers(|alias, provider| {
74        let provider_name = provider.provider_name().to_string();
75        let model = model_by_alias
76            .get(alias)
77            .cloned()
78            .unwrap_or_else(|| alias.to_string());
79        Arc::new(ObservedLLMProvider::new(
80            provider,
81            Arc::clone(&manager),
82            Some(alias.to_string()),
83            provider_name,
84            model,
85        )) as Arc<dyn LLMProvider>
86    })
87}
88
89pub struct AgentBuilder {
90    spec: Option<AgentSpec>,
91    llm: Option<Arc<dyn LLMProvider>>,
92    llm_registry: Option<LLMRegistry>,
93    memory: Option<Arc<dyn Memory>>,
94    tools: Option<ToolRegistry>,
95    skills: Vec<SkillDefinition>,
96    skill_loader: Option<SkillLoader>,
97    yaml_dir: Option<PathBuf>,
98    system_prompt: Option<String>,
99    tools_prompt: Option<String>,
100    auto_tools_prompt: bool,
101    max_iterations: Option<u32>,
102    max_context_tokens: Option<u32>,
103    recovery_manager: Option<RecoveryManager>,
104    tool_security: Option<ToolSecurityEngine>,
105    process_processor: Option<ProcessProcessor>,
106    message_filters: HashMap<String, Arc<dyn MessageFilter>>,
107    context_manager: Option<Arc<ContextManager>>,
108    state_machine: Option<Arc<StateMachine>>,
109    transition_evaluator: Option<Arc<dyn TransitionEvaluator>>,
110    hooks: Option<Arc<dyn AgentHooks>>,
111    hitl_engine: Option<HITLEngine>,
112    approval_handler: Option<Arc<dyn ApprovalHandler>>,
113    storage_config: Option<StorageConfig>,
114    storage: Option<Arc<dyn AgentStorage>>,
115    reasoning: Option<ReasoningConfig>,
116    reflection: Option<ReflectionConfig>,
117    streaming: Option<StreamingConfig>,
118    spawner: Option<Arc<crate::spawner::AgentSpawner>>,
119    spawner_registry: Option<Arc<crate::spawner::AgentRegistry>>,
120    persona_manager: Option<Arc<ai_agents_persona::PersonaManager>>,
121    persona_templates: Option<Arc<ai_agents_persona::PersonaTemplateRegistry>>,
122    observability_manager: Option<Arc<ObservabilityManager>>,
123    resource_locks: Option<ToolResourceLocks>,
124    llm_registry_observed: bool,
125}
126
127impl AgentBuilder {
128    pub fn new() -> Self {
129        Self {
130            reasoning: None,
131            reflection: None,
132            spec: None,
133            llm: None,
134            llm_registry: None,
135            memory: None,
136            tools: None,
137            skills: Vec::new(),
138            skill_loader: None,
139            yaml_dir: None,
140            system_prompt: None,
141            tools_prompt: None,
142            auto_tools_prompt: true,
143            max_iterations: None,
144            max_context_tokens: None,
145            recovery_manager: None,
146            tool_security: None,
147            process_processor: None,
148            message_filters: HashMap::new(),
149            context_manager: None,
150            state_machine: None,
151            transition_evaluator: None,
152            hooks: None,
153            hitl_engine: None,
154            approval_handler: None,
155            storage_config: None,
156            storage: None,
157            streaming: None,
158            spawner: None,
159            spawner_registry: None,
160            persona_manager: None,
161            persona_templates: None,
162            observability_manager: None,
163            resource_locks: None,
164            llm_registry_observed: false,
165        }
166    }
167
168    pub fn from_spec(spec: AgentSpec) -> Self {
169        let system_prompt = spec.system_prompt.clone();
170        let max_iterations = Some(spec.max_iterations);
171        let max_context_tokens = Some(spec.max_context_tokens);
172        let reasoning = Some(spec.reasoning.clone());
173        let reflection = Some(spec.reflection.clone());
174
175        Self {
176            spec: Some(spec),
177            llm: None,
178            llm_registry: None,
179            memory: None,
180            tools: None,
181            skills: Vec::new(),
182            skill_loader: None,
183            yaml_dir: None,
184            system_prompt: Some(system_prompt),
185            tools_prompt: None,
186            auto_tools_prompt: true,
187            max_iterations,
188            max_context_tokens,
189            recovery_manager: None,
190            tool_security: None,
191            process_processor: None,
192            message_filters: HashMap::new(),
193            context_manager: None,
194            state_machine: None,
195            transition_evaluator: None,
196            hooks: None,
197            hitl_engine: None,
198            approval_handler: None,
199            storage_config: None,
200            storage: None,
201            reasoning,
202            reflection,
203            streaming: None,
204            spawner: None,
205            spawner_registry: None,
206            persona_manager: None,
207            persona_templates: None,
208            observability_manager: None,
209            resource_locks: None,
210            llm_registry_observed: false,
211        }
212    }
213
214    /// Builds from an existing spec while resolving relative resources from the supplied directory.
215    pub fn from_spec_with_base_dir(spec: AgentSpec, base_dir: impl Into<PathBuf>) -> Self {
216        let mut builder = Self::from_spec(spec);
217        builder.yaml_dir = Some(base_dir.into());
218        builder
219    }
220
221    pub fn from_yaml(yaml_content: &str) -> Result<Self> {
222        let spec = AgentSpec::from_yaml_strict(yaml_content)?;
223        spec.validate()?;
224        Ok(Self::from_spec(spec))
225    }
226
227    pub(crate) fn shared_resource_locks(&mut self) -> ToolResourceLocks {
228        self.resource_locks
229            .get_or_insert_with(|| Arc::new(parking_lot::RwLock::new(HashMap::new())))
230            .clone()
231    }
232
233    pub(crate) fn with_shared_resource_locks(mut self, locks: ToolResourceLocks) -> Self {
234        self.resource_locks = Some(locks);
235        self
236    }
237
238    pub fn from_yaml_file(path: impl AsRef<Path>) -> Result<Self> {
239        let path = path.as_ref();
240        let content = std::fs::read_to_string(path).map_err(AgentError::IoError)?;
241        let spec = AgentSpec::from_yaml_strict(&content)?;
242        spec.validate()?;
243        Ok(match path.parent() {
244            Some(parent) => Self::from_spec_with_base_dir(spec, parent),
245            None => Self::from_spec(spec),
246        })
247    }
248
249    pub fn from_template(template_name: &str) -> Result<Self> {
250        let loader = TemplateLoader::new();
251        Self::from_template_with_loader(template_name, &loader)
252    }
253
254    pub fn from_template_with_loader(template_name: &str, loader: &TemplateLoader) -> Result<Self> {
255        let renderer = TemplateRenderer::new();
256        let variables = loader.variables();
257
258        let load_and_render = |name: &str| -> Result<String> {
259            let content = loader.load_template(name)?;
260            renderer.render(&content, variables)
261        };
262
263        let rendered_root = load_and_render(template_name)?;
264        let processed = TemplateInheritance::process(&rendered_root, load_and_render)?;
265        let spec = AgentSpec::from_yaml_strict(&processed)?;
266        spec.validate()?;
267        Ok(Self::from_spec(spec))
268    }
269
270    pub fn auto_configure_llms(mut self) -> Result<Self> {
271        let spec = self
272            .spec
273            .as_ref()
274            .ok_or_else(|| AgentError::Config("Cannot auto-configure LLMs without spec".into()))?;
275
276        if !spec.llms.is_empty() {
277            let mut registry = LLMRegistry::new();
278
279            for (alias, config) in &spec.llms {
280                let provider_type = ProviderType::from_str(&config.provider)
281                    .map_err(|e| AgentError::Config(e.to_string()))?;
282
283                let core_config = ai_agents_core::LLMConfig {
284                    temperature: Some(config.temperature),
285                    max_tokens: Some(config.max_tokens),
286                    top_p: config.top_p,
287                    top_k: None,
288                    frequency_penalty: None,
289                    presence_penalty: None,
290                    stop_sequences: None,
291                    timeout_seconds: config.timeout_seconds,
292                    reasoning: config.reasoning,
293                    reasoning_effort: config.reasoning_effort.clone(),
294                    reasoning_budget_tokens: config.reasoning_budget_tokens,
295                    extra: config.extra.clone(),
296                };
297                // base_url: first-class field, fallback to extra for backward compat
298                let base_url = config.base_url.clone().or_else(|| {
299                    config
300                        .extra
301                        .get("base_url")
302                        .and_then(|v| v.as_str())
303                        .map(|s| s.to_string())
304                });
305
306                // api_key: resolve from api_key_env if specified
307                let api_key = config
308                    .api_key_env
309                    .as_ref()
310                    .and_then(|env_var| std::env::var(env_var).ok());
311
312                let mut provider = UnifiedLLMProvider::from_spec_config(
313                    provider_type,
314                    &config.model,
315                    api_key,
316                    base_url,
317                    core_config,
318                )
319                .map_err(|e| AgentError::LLM(e.to_string()))?
320                .with_feature_overrides(feature_overrides_from_config(config));
321                if let Some(choice) = config.tool_choice.clone() {
322                    provider = provider.with_tool_choice(choice);
323                }
324
325                registry.register(alias, Arc::new(provider));
326            }
327
328            let default_alias = spec.llm.get_default_alias();
329            let router_alias = spec.llm.get_router_alias();
330
331            registry.set_default(&default_alias);
332            if let Some(router) = router_alias {
333                registry.set_router(&router);
334            }
335
336            self.llm_registry = Some(registry);
337            self.llm_registry_observed = false;
338        } else if let Some(config) = spec.llm.as_config() {
339            let provider_type = ProviderType::from_str(&config.provider)
340                .map_err(|e| AgentError::Config(e.to_string()))?;
341
342            let core_config = ai_agents_core::LLMConfig {
343                temperature: Some(config.temperature),
344                max_tokens: Some(config.max_tokens),
345                top_p: config.top_p,
346                top_k: None,
347                frequency_penalty: None,
348                presence_penalty: None,
349                stop_sequences: None,
350                timeout_seconds: config.timeout_seconds,
351                reasoning: config.reasoning,
352                reasoning_effort: config.reasoning_effort.clone(),
353                reasoning_budget_tokens: config.reasoning_budget_tokens,
354                extra: config.extra.clone(),
355            };
356            // base_url: first-class field, fallback to extra for backward compat
357            let base_url = config.base_url.clone().or_else(|| {
358                config
359                    .extra
360                    .get("base_url")
361                    .and_then(|v| v.as_str())
362                    .map(|s| s.to_string())
363            });
364
365            // api_key: resolve from api_key_env if specified
366            let api_key = config
367                .api_key_env
368                .as_ref()
369                .and_then(|env_var| std::env::var(env_var).ok());
370
371            let mut provider = UnifiedLLMProvider::from_spec_config(
372                provider_type,
373                &config.model,
374                api_key,
375                base_url,
376                core_config,
377            )
378            .map_err(|e| AgentError::LLM(e.to_string()))?
379            .with_feature_overrides(feature_overrides_from_config(config));
380            if let Some(choice) = config.tool_choice.clone() {
381                provider = provider.with_tool_choice(choice);
382            }
383
384            self.llm = Some(Arc::new(provider));
385            self.llm_registry_observed = false;
386        }
387
388        Ok(self)
389    }
390
391    /// Auto-configure recovery, tool security, process pipeline, and built-in tools from the spec.
392    ///
393    /// **Call order matters for tools**: this method only registers built-in tools when `self.tools` is `None`.
394    /// If `.tool()` or `.tools()` was called before this, `self.tools` is already `Some` and built-ins will NOT be added.
395    ///
396    /// Correct:
397    /// ```ignore
398    /// .auto_configure_features()?   // registers built-ins (self.tools was None)
399    /// .tool(Arc::new(MyTool))       // adds MyTool into the builtin registry
400    /// ```
401    ///
402    /// Wrong — built-ins are lost:
403    /// ```ignore
404    /// .tool(Arc::new(MyTool))       // self.tools = Some(empty + MyTool)
405    /// .auto_configure_features()?   // self.tools is Some -> skips builtin registration
406    /// ```
407    pub fn auto_configure_features(mut self) -> Result<Self> {
408        if let Some(ref spec) = self.spec {
409            self.recovery_manager = Some(RecoveryManager::new(spec.error_recovery.clone()));
410            self.tool_security = Some(ToolSecurityEngine::try_new(spec.tool_security.clone())?);
411
412            if spec.has_process() {
413                let mut processor = ProcessProcessor::new(spec.process.clone());
414                if let Some(ref registry) = self.llm_registry {
415                    processor = processor.with_llm_registry(Arc::new(registry.clone()));
416                }
417                self.process_processor = Some(processor);
418            }
419
420            // Auto-register builtin tools if the user hasn't provided a custom registry.
421            if self.tools.is_none() {
422                self.tools = Some(create_builtin_registry());
423            }
424        }
425        Ok(self)
426    }
427
428    /// Initialize MCP wrapper tools from `tools:` entries with `type: mcp`.
429    ///
430    /// Each MCP entry becomes an `MCPWrapperTool` registered as a normal builtin
431    /// tool in the `ToolRegistry`. Views defined in the entry's `views:` field are registered as separate `MCPViewTool` instances sharing the parent's MCP connection.
432    ///
433    /// Call this after `auto_configure_features()` so the tool registry exists.
434    pub async fn auto_configure_mcp(mut self) -> Result<Self> {
435        if let Some(ref spec) = self.spec {
436            // Collect MCP configs from tools: entries with type: mcp
437            let mcp_configs: Vec<_> = spec
438                .tools
439                .as_ref()
440                .map(|tools| {
441                    tools
442                        .iter()
443                        .filter_map(|entry| entry.to_mcp_config())
444                        .collect()
445                })
446                .unwrap_or_default();
447
448            if !mcp_configs.is_empty() {
449                let registry = self.tools.get_or_insert_with(create_builtin_registry);
450
451                for config in mcp_configs {
452                    let tool_name = config.name.clone();
453                    let timeout_ms = config.startup_timeout_ms;
454                    let views_config = config.views.clone();
455
456                    let wrapper = MCPWrapperTool::new(config);
457
458                    // Initialize with timeout
459                    match tokio::time::timeout(
460                        std::time::Duration::from_millis(timeout_ms),
461                        wrapper.initialized(),
462                    )
463                    .await
464                    {
465                        Ok(Ok(initialized_tool)) => {
466                            tracing::info!(
467                                tool = %tool_name,
468                                functions = initialized_tool.function_count(),
469                                "MCP wrapper tool registered"
470                            );
471
472                            let parent = Arc::new(initialized_tool);
473
474                            // Register the parent tool
475                            registry.register(parent.clone()).map_err(|e| {
476                                AgentError::Config(format!(
477                                    "Failed to register MCP tool '{}': {}",
478                                    tool_name, e
479                                ))
480                            })?;
481
482                            // Register views as separate tools sharing the parent connection
483                            for (view_name, view_config) in &views_config {
484                                let view_tool = MCPViewTool::new(
485                                    view_name.clone(),
486                                    parent.clone(),
487                                    view_config.functions.clone(),
488                                    view_config.description.clone(),
489                                )
490                                .map_err(|e| {
491                                    AgentError::Config(format!(
492                                        "Failed to create MCP view '{}': {}",
493                                        view_name, e
494                                    ))
495                                })?;
496
497                                tracing::info!(
498                                    view = %view_name,
499                                    parent = %tool_name,
500                                    functions = view_config.functions.len(),
501                                    "MCP view tool registered"
502                                );
503
504                                registry.register(Arc::new(view_tool)).map_err(|e| {
505                                    AgentError::Config(format!(
506                                        "Failed to register MCP view '{}': {}",
507                                        view_name, e
508                                    ))
509                                })?;
510                            }
511                        }
512                        Ok(Err(e)) => {
513                            return Err(AgentError::Config(format!(
514                                "MCP tool '{}' initialization failed: {}",
515                                tool_name, e
516                            )));
517                        }
518                        Err(_) => {
519                            return Err(AgentError::Config(format!(
520                                "MCP tool '{}' timed out after {}ms",
521                                tool_name, timeout_ms
522                            )));
523                        }
524                    }
525                }
526            }
527        }
528        Ok(self)
529    }
530
531    pub fn llm(mut self, llm: Arc<dyn LLMProvider>) -> Self {
532        self.llm = Some(llm);
533        self
534    }
535
536    pub fn llm_alias(mut self, alias: impl Into<String>, provider: Arc<dyn LLMProvider>) -> Self {
537        if self.llm_registry.is_none() {
538            self.llm_registry = Some(LLMRegistry::new());
539        }
540        if let Some(ref mut registry) = self.llm_registry {
541            registry.register(alias, provider);
542        }
543        self
544    }
545
546    /// Set a raw LLM registry that may still need observability wrapping.
547    pub fn llm_registry(mut self, registry: LLMRegistry) -> Self {
548        self.llm_registry = Some(registry);
549        self.llm_registry_observed = false;
550        self
551    }
552
553    pub(crate) fn authoritative_llm_registry(
554        mut self,
555        registry: LLMRegistry,
556        observed: bool,
557    ) -> Self {
558        self.llm_registry = Some(registry);
559        self.llm_registry_observed = observed;
560        self
561    }
562
563    pub fn memory(mut self, memory: Arc<dyn Memory>) -> Self {
564        self.memory = Some(memory);
565        self
566    }
567
568    /// Replace the entire tool registry.
569    ///
570    /// If `auto_configure_features()` was called before this, the auto-registered builtins will be overwritten.
571    pub fn tools(mut self, tools: ToolRegistry) -> Self {
572        self.tools = Some(tools);
573        self
574    }
575
576    /// Register a single tool into the existing registry.
577    ///
578    /// If no registry exists yet, creates an empty one first.
579    /// Use this to add custom tools on top of auto-configured builtins.
580    pub fn tool(mut self, tool: Arc<dyn Tool>) -> Self {
581        let registry = self.tools.get_or_insert_with(ToolRegistry::new);
582        let _ = registry.register(tool);
583        self
584    }
585
586    /// Merge tools from another registry into the existing one.
587    ///
588    /// Skips tools whose ID already exists (no overwrite).
589    /// If no registry exists yet, creates an empty one first.
590    pub fn extend_tools(mut self, additional: ToolRegistry) -> Self {
591        let registry = self.tools.get_or_insert_with(ToolRegistry::new);
592        for id in additional.list_ids() {
593            if registry.get(&id).is_none()
594                && let Some(tool) = additional.get(&id)
595            {
596                let _ = registry.register(tool);
597            }
598        }
599        self
600    }
601
602    pub fn skill(mut self, skill: SkillDefinition) -> Self {
603        self.skills.push(skill);
604        self
605    }
606
607    pub fn skills(mut self, skills: Vec<SkillDefinition>) -> Self {
608        self.skills.extend(skills);
609        self
610    }
611
612    pub fn skill_loader(mut self, loader: SkillLoader) -> Self {
613        self.skill_loader = Some(loader);
614        self
615    }
616
617    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
618        self.system_prompt = Some(prompt.into());
619        self
620    }
621
622    pub fn tools_prompt(mut self, prompt: impl Into<String>) -> Self {
623        self.tools_prompt = Some(prompt.into());
624        self.auto_tools_prompt = false;
625        self
626    }
627
628    pub fn auto_tools_prompt(mut self, auto: bool) -> Self {
629        self.auto_tools_prompt = auto;
630        self
631    }
632
633    pub fn max_iterations(mut self, max: u32) -> Self {
634        self.max_iterations = Some(max);
635        self
636    }
637
638    pub fn max_context_tokens(mut self, tokens: u32) -> Self {
639        self.max_context_tokens = Some(tokens);
640        self
641    }
642
643    pub fn recovery_manager(mut self, manager: RecoveryManager) -> Self {
644        self.recovery_manager = Some(manager);
645        self
646    }
647
648    pub fn tool_security(mut self, engine: ToolSecurityEngine) -> Self {
649        self.tool_security = Some(engine);
650        self
651    }
652
653    pub fn process_processor(mut self, processor: ProcessProcessor) -> Self {
654        self.process_processor = Some(processor);
655        self
656    }
657
658    pub fn message_filter(
659        mut self,
660        name: impl Into<String>,
661        filter: Arc<dyn MessageFilter>,
662    ) -> Self {
663        self.message_filters.insert(name.into(), filter);
664        self
665    }
666
667    pub fn context_manager(mut self, manager: Arc<ContextManager>) -> Self {
668        self.context_manager = Some(manager);
669        self
670    }
671
672    pub fn state_machine(mut self, machine: Arc<StateMachine>) -> Self {
673        self.state_machine = Some(machine);
674        self
675    }
676
677    pub fn transition_evaluator(mut self, evaluator: Arc<dyn TransitionEvaluator>) -> Self {
678        self.transition_evaluator = Some(evaluator);
679        self
680    }
681
682    pub fn hooks(mut self, hooks: Arc<dyn AgentHooks>) -> Self {
683        self.hooks = Some(hooks);
684        self
685    }
686
687    pub fn approval_handler(mut self, handler: Arc<dyn ApprovalHandler>) -> Self {
688        self.approval_handler = Some(handler);
689        self
690    }
691
692    pub fn hitl_engine(mut self, engine: HITLEngine) -> Self {
693        self.hitl_engine = Some(engine);
694        self
695    }
696
697    pub fn storage_config(mut self, config: StorageConfig) -> Self {
698        self.storage_config = Some(config);
699        self
700    }
701
702    pub fn storage(mut self, storage: Arc<dyn AgentStorage>) -> Self {
703        self.storage = Some(storage);
704        self
705    }
706
707    pub fn reasoning(mut self, config: ReasoningConfig) -> Self {
708        self.reasoning = Some(config);
709        self
710    }
711
712    pub fn reflection(mut self, config: ReflectionConfig) -> Self {
713        self.reflection = Some(config);
714        self
715    }
716
717    /// Set persona config directly (overrides spec).
718    pub fn persona(mut self, manager: Arc<ai_agents_persona::PersonaManager>) -> Self {
719        self.persona_manager = Some(manager);
720        self
721    }
722
723    /// Provide a shared persona template registry.
724    pub fn persona_templates(
725        mut self,
726        registry: Arc<ai_agents_persona::PersonaTemplateRegistry>,
727    ) -> Self {
728        self.persona_templates = Some(registry);
729        self
730    }
731
732    /// Provide a shared observability manager instead of creating one from YAML.
733    pub fn observability(mut self, manager: Arc<ObservabilityManager>) -> Self {
734        self.observability_manager = Some(manager);
735        self
736    }
737
738    /// Creates or reuses the manager before components retain provider handles.
739    fn ensure_observability_manager(&mut self) -> Result<Option<Arc<ObservabilityManager>>> {
740        if let Some(manager) = self.observability_manager.as_ref() {
741            return Ok(Some(Arc::clone(manager)));
742        }
743        let Some(ref spec) = self.spec else {
744            return Ok(None);
745        };
746        if !spec.observability.enabled {
747            return Ok(None);
748        }
749        let config = self.observability_config_with_pricing(&spec.observability)?;
750        config
751            .validate()
752            .map_err(|e| AgentError::Config(e.to_string()))?;
753        let manager = ObservabilityManager::new(config);
754        self.observability_manager = Some(Arc::clone(&manager));
755        Ok(Some(manager))
756    }
757
758    /// Loads pricing_file relative to the YAML directory before manager creation.
759    fn observability_config_with_pricing(
760        &self,
761        config: &ObservabilityConfig,
762    ) -> Result<ObservabilityConfig> {
763        config
764            .clone()
765            .with_pricing_file_loaded(self.yaml_dir.as_deref())
766            .map_err(|e| AgentError::Config(e.to_string()))
767    }
768
769    /// Wraps the builder registry once and refreshes any stored process processor registry.
770    fn wrap_llm_registry_for_observability(&mut self) -> Result<()> {
771        if self.llm_registry_observed {
772            return Ok(());
773        }
774        let Some(manager) = self.ensure_observability_manager()? else {
775            return Ok(());
776        };
777        let Some(registry) = self.llm_registry.take() else {
778            return Ok(());
779        };
780        let model_by_alias = model_by_alias_from_spec(self.spec.as_ref());
781        let wrapped = wrap_registry_with_observability(registry, manager, &model_by_alias);
782        let wrapped_arc = Arc::new(wrapped.clone());
783        if let Some(processor) = self.process_processor.take() {
784            self.process_processor = Some(processor.with_llm_registry(wrapped_arc));
785        }
786        self.llm_registry = Some(wrapped);
787        self.llm_registry_observed = true;
788        Ok(())
789    }
790
791    pub fn streaming(mut self, enabled: bool) -> Self {
792        let mut config = self.streaming.unwrap_or_default();
793        config.enabled = enabled;
794        self.streaming = Some(config);
795        self
796    }
797
798    /// Wire spawner tools when the spec has a `spawner:` section.
799    /// Call after `auto_configure_llms()` and `auto_configure_features()`.
800    pub async fn auto_configure_spawner(mut self) -> Result<Self> {
801        let spawner_config = match self.spec.as_ref().and_then(|s| s.spawner.as_ref()) {
802            Some(c) => c.clone(),
803            None => return Ok(self),
804        };
805
806        if spawner_config.shared_llms && self.llm_registry.is_none() {
807            let provider = self.llm.as_ref().cloned().ok_or_else(|| {
808                AgentError::Config(
809                    "spawner.shared_llms requires the parent LLM provider to be configured"
810                        .to_string(),
811                )
812            })?;
813            let mut registry = LLMRegistry::new();
814            registry.register("default", provider);
815            registry.set_default("default");
816            self.llm_registry = Some(registry);
817            self.llm_registry_observed = false;
818        }
819
820        self.wrap_llm_registry_for_observability()?;
821        let observability_manager = self.observability_manager.clone();
822
823        use crate::spawner::{
824            AgentRegistry, AgentSpawner,
825            config::{configure_spawner_tools, resolve_templates},
826        };
827
828        let mut spawner = AgentSpawner::new();
829
830        if let Some(ref manager) = observability_manager {
831            spawner = spawner.with_observability(Arc::clone(manager));
832        }
833        spawner = spawner.with_resource_locks(self.shared_resource_locks());
834
835        if spawner_config.shared_llms {
836            let reg = self.llm_registry.as_ref().ok_or_else(|| {
837                AgentError::Config(
838                    "spawner.shared_llms requires the parent LLM registry to be configured"
839                        .to_string(),
840                )
841            })?;
842            spawner = if self.llm_registry_observed {
843                spawner.with_shared_observed_llms(reg.clone())
844            } else {
845                spawner.with_shared_llms(reg.clone())
846            };
847        }
848
849        if !spawner_config.shared_context.is_empty() {
850            spawner = spawner.with_shared_context_map(spawner_config.shared_context.clone());
851        }
852
853        if let Some(max) = spawner_config.max_agents {
854            spawner = spawner.with_max_agents(max);
855        }
856
857        if let Some(ref prefix) = spawner_config.name_prefix {
858            spawner = spawner.with_name_prefix(prefix.clone())?;
859        }
860
861        // Resolve file-path templates against the parent YAML directory.
862        if !spawner_config.templates.is_empty() {
863            let resolved = resolve_templates(&spawner_config.templates, self.yaml_dir.as_deref())?;
864            spawner = spawner.with_templates(resolved);
865        }
866
867        if let Some(ref allowed) = spawner_config.allowed_tools {
868            spawner = spawner.with_allowed_tools(allowed.clone());
869        }
870
871        // Resolve shared storage from YAML config into a live backend.
872        if let Some(ref sc) = spawner_config.shared_storage {
873            let converted = crate::spec::storage::to_storage_config(sc);
874            if let Some(st) = ai_agents_storage::create_storage(&converted).await? {
875                spawner = spawner.with_shared_storage(Arc::clone(&st));
876
877                // Auto-inject into parent when no explicit storage: is configured.
878                let parent_has_storage = self.storage.is_some()
879                    || self.storage_config.is_some()
880                    || self.spec.as_ref().is_some_and(|s| s.has_storage());
881                if !parent_has_storage {
882                    self.storage = Some(st);
883                }
884            }
885        }
886
887        let spawner = Arc::new(spawner);
888        let registry = Arc::new(AgentRegistry::new());
889
890        self.spawner = Some(Arc::clone(&spawner));
891        self.spawner_registry = Some(Arc::clone(&registry));
892        let llm_for_tools = Arc::new(self.llm_registry.clone().unwrap_or_default());
893        let agent_name = self
894            .spec
895            .as_ref()
896            .map(|s| s.name.clone())
897            .unwrap_or_default();
898
899        let tools = configure_spawner_tools(
900            Arc::clone(&spawner),
901            Arc::clone(&registry),
902            Arc::clone(&llm_for_tools),
903            &agent_name,
904        );
905
906        let tool_registry = self.tools.get_or_insert_with(create_builtin_registry);
907        for tool in tools {
908            let _ = tool_registry.register(tool);
909        }
910
911        tracing::info!("Spawner tools registered");
912
913        // Register orchestration tools if configured.
914        if spawner_config.orchestration_tools.is_enabled() {
915            let orch_tools = crate::orchestration::tools::configure_orchestration_tools(
916                &spawner_config.orchestration_tools,
917                Arc::clone(&registry),
918                Arc::clone(&llm_for_tools),
919            );
920            let tool_registry = self.tools.get_or_insert_with(create_builtin_registry);
921            for tool in orch_tools {
922                let _ = tool_registry.register(tool);
923            }
924            tracing::info!("Orchestration tools registered");
925        }
926
927        for entry in &spawner_config.auto_spawn {
928            let yaml_path = if let Some(ref dir) = self.yaml_dir {
929                dir.join(&entry.agent)
930            } else {
931                std::path::PathBuf::from(&entry.agent)
932            };
933
934            tracing::info!(id = %entry.id, path = %yaml_path.display(), "Auto-spawning agent");
935            let spawned = spawner
936                .spawn_from_yaml_file_with_id(entry.id.clone(), &yaml_path)
937                .await
938                .map_err(|error| {
939                    AgentError::Config(format!(
940                        "Failed to auto-spawn agent '{}' from '{}': {}",
941                        entry.id,
942                        yaml_path.display(),
943                        error
944                    ))
945                })?;
946            registry.register(spawned).await.map_err(|error| {
947                AgentError::Config(format!(
948                    "Failed to register auto-spawned agent '{}': {}",
949                    entry.id, error
950                ))
951            })?;
952            tracing::info!(id = %entry.id, "Auto-spawned agent registered");
953        }
954
955        // Validate that all orchestration state references have matching agents.
956        if let Some(ref spec) = self.spec
957            && let Some(ref state_config) = spec.states
958        {
959            let refs = collect_orchestration_refs(&state_config.states);
960            let mut missing: Vec<String> = Vec::new();
961
962            for (agent_id, state_name, pattern) in &refs {
963                if !registry.contains(agent_id) {
964                    missing.push(format!(
965                        "  - '{}' (referenced by state '{}' via {})",
966                        agent_id, state_name, pattern
967                    ));
968                }
969            }
970
971            if !missing.is_empty() {
972                missing.sort();
973                missing.dedup();
974                return Err(AgentError::Config(format!(
975                    "Auto-spawn validation failed. These agents are referenced by \
976                     orchestration states but were not successfully spawned:\n\n{}\n\n\
977                     Check that agent YAML files exist and contain valid specs.",
978                    missing.join("\n")
979                )));
980            }
981        }
982
983        Ok(self)
984    }
985
986    pub fn build(mut self) -> Result<RuntimeAgent> {
987        let resource_locks = self.shared_resource_locks();
988        // Capture actor memory and facts configs before partial moves of spec consume fields.
989        let actor_memory_config = self
990            .spec
991            .as_ref()
992            .and_then(|s| s.memory.actor_memory.clone());
993        let facts_config = self.spec.as_ref().and_then(|s| s.memory.facts.clone());
994        let relationships_config = self
995            .spec
996            .as_ref()
997            .and_then(|s| s.memory.relationships.clone());
998
999        let observability_manager = self.ensure_observability_manager()?;
1000
1001        let base_prompt = self
1002            .system_prompt
1003            .ok_or_else(|| AgentError::Config("System prompt is required".into()))?;
1004
1005        let mut tools = self.tools.unwrap_or_default();
1006
1007        // ERROR NOTE: Don't include tools prompt here
1008        // - it will be added AFTER template rendering in get_effective_system_prompt() to avoid Jinja2 parsing JSON braces
1009        let system_prompt = base_prompt;
1010
1011        let max_iterations = self.max_iterations.unwrap_or(10);
1012
1013        let info = if let Some(ref spec) = self.spec {
1014            AgentInfo::new(&spec.name, &spec.name, &spec.version)
1015                .with_description(spec.description.clone().unwrap_or_default())
1016        } else {
1017            AgentInfo::new("agent", "Agent", "1.0.0")
1018        };
1019
1020        if let Some(ref spec) = self.spec
1021            && !spec.skills.is_empty()
1022        {
1023            let mut loader = self.skill_loader.take().unwrap_or_default();
1024            if let Some(ref dir) = self.yaml_dir {
1025                loader.set_base_dir(dir);
1026            }
1027            let loaded_skills = loader.load_refs(&spec.skills)?;
1028            self.skills.extend(loaded_skills);
1029        }
1030
1031        let mut llm_registry = self.llm_registry.unwrap_or_default();
1032
1033        if let Some(llm) = self.llm
1034            && !llm_registry.has("default")
1035        {
1036            let provider = if self.llm_registry_observed {
1037                if let Some(ref manager) = observability_manager {
1038                    Arc::new(ObservedLLMProvider::new(
1039                        llm.clone(),
1040                        Arc::clone(manager),
1041                        Some("default".to_string()),
1042                        llm.provider_name().to_string(),
1043                        model_by_alias_from_spec(self.spec.as_ref())
1044                            .get("default")
1045                            .cloned()
1046                            .unwrap_or_else(|| "default".to_string()),
1047                    )) as Arc<dyn LLMProvider>
1048                } else {
1049                    llm.clone()
1050                }
1051            } else {
1052                llm.clone()
1053            };
1054            llm_registry.register("default", provider);
1055        }
1056
1057        if let Some(ref spec) = self.spec {
1058            let default_alias = spec.llm.get_default_alias();
1059            let router_alias = spec.llm.get_router_alias();
1060
1061            llm_registry.set_default(&default_alias);
1062            if let Some(router) = router_alias {
1063                llm_registry.set_router(&router);
1064            }
1065        }
1066
1067        if llm_registry.is_empty() {
1068            return Err(AgentError::Config(
1069                "At least one LLM provider is required".into(),
1070            ));
1071        }
1072
1073        if let Some(ref manager) = observability_manager
1074            && !self.llm_registry_observed
1075        {
1076            let model_by_alias = model_by_alias_from_spec(self.spec.as_ref());
1077            llm_registry = wrap_registry_with_observability(
1078                llm_registry,
1079                Arc::clone(manager),
1080                &model_by_alias,
1081            );
1082            self.llm_registry_observed = true;
1083        }
1084
1085        // Create memory after LLM registry is ready (needed for CompactingMemory summarizer)
1086        let memory = self.memory.unwrap_or_else(|| {
1087            if let Some(ref spec) = self.spec {
1088                if spec.memory.is_compacting() {
1089                    let summarizer_llm = spec
1090                        .memory
1091                        .summarizer_llm
1092                        .as_ref()
1093                        .and_then(|alias| llm_registry.get(alias).ok())
1094                        .or_else(|| llm_registry.router().ok())
1095                        .or_else(|| llm_registry.default().ok());
1096
1097                    let summarizer: Arc<dyn Summarizer> = match summarizer_llm {
1098                        Some(llm) => Arc::new(LLMSummarizer::new(llm)),
1099                        None => Arc::new(NoopSummarizer),
1100                    };
1101                    let config = spec.memory.to_compacting_config();
1102                    return Arc::new(CompactingMemory::new(summarizer, config));
1103                }
1104                Arc::new(InMemoryStore::new(spec.memory.max_messages))
1105            } else {
1106                Arc::new(InMemoryStore::new(100))
1107            }
1108        });
1109
1110        // Configure persona before freezing tools (evolve tool may need registration).
1111        let persona_manager: Option<Arc<ai_agents_persona::PersonaManager>> =
1112            if let Some(pm) = self.persona_manager.take() {
1113                Some(pm)
1114            } else if let Some(ref spec) = self.spec {
1115                if spec.has_persona() {
1116                    let persona_config = spec.persona.clone().unwrap();
1117                    let renderer = ai_agents_context::TemplateRenderer::new();
1118                    let registry = self.persona_templates.clone();
1119                    let manager = ai_agents_persona::PersonaManager::from_config(
1120                        persona_config,
1121                        registry,
1122                        renderer,
1123                    )
1124                    .map_err(|e| {
1125                        AgentError::Config(format!("Failed to create PersonaManager: {}", e))
1126                    })?;
1127                    Some(Arc::new(manager))
1128                } else {
1129                    None
1130                }
1131            } else {
1132                None
1133            };
1134
1135        // Register persona_evolve tool if allow_llm_evolve is true.
1136        if let Some(ref pm) = persona_manager
1137            && pm.should_register_evolve_tool()
1138        {
1139            let evolve_tool = ai_agents_persona::PersonaEvolveTool::new(pm.clone());
1140            let _ = tools.register(Arc::new(evolve_tool));
1141        }
1142
1143        if let Some(ref manager) = observability_manager {
1144            tools = tools.map_tools(|tool| {
1145                Arc::new(ObservedTool::new(tool, Arc::clone(manager))) as Arc<dyn Tool>
1146            });
1147        }
1148
1149        let relationship_manager: Option<Arc<RelationshipManager>> =
1150            if let Some(ref config) = relationships_config {
1151                if config.enabled {
1152                    let evaluator: Option<Arc<dyn RelationshipEvaluatorTrait>> =
1153                        if config.auto_update.enabled {
1154                            let llm = config
1155                                .auto_update
1156                                .llm
1157                                .as_ref()
1158                                .and_then(|alias| llm_registry.get(alias).ok())
1159                                .or_else(|| llm_registry.router().ok())
1160                                .or_else(|| llm_registry.default().ok());
1161                            llm.map(|llm| {
1162                                Arc::new(RelationshipEvaluator::new(llm))
1163                                    as Arc<dyn RelationshipEvaluatorTrait>
1164                            })
1165                        } else {
1166                            None
1167                        };
1168                    Some(Arc::new(RelationshipManager::from_config_with_evaluator(
1169                        config.clone(),
1170                        evaluator,
1171                    )?))
1172                } else {
1173                    None
1174                }
1175            } else {
1176                None
1177            };
1178
1179        let tools_arc = Arc::new(tools);
1180        let llm_registry_arc = Arc::new(llm_registry);
1181        tools_arc.set_web_fetch_extractor(
1182            llm_registry_arc
1183                .router()
1184                .ok()
1185                .or_else(|| llm_registry_arc.default().ok()),
1186        );
1187
1188        // Build the effective tool grant.
1189        // YAML top-level tools are explicit ordinary grants, while feature flags such as spawner management, persona evolution, and orchestration tools are explicit feature grants.
1190        let declared_tool_ids: Option<Vec<String>> = Some(if self.spec.is_none() {
1191            tools_arc.list_ids()
1192        } else {
1193            let mut ids: Vec<String> = self
1194                .spec
1195                .as_ref()
1196                .and_then(|s| s.tools.as_ref())
1197                .map(|tools| {
1198                    let mut ids: Vec<String> = tools
1199                        .iter()
1200                        .filter_map(|t| {
1201                            tools_arc
1202                                .canonical_id(t.name())
1203                                .or_else(|| Some(t.name().to_string()))
1204                        })
1205                        .collect();
1206
1207                    for entry in tools {
1208                        if let Some(mcp_config) = entry.to_mcp_config() {
1209                            for view_name in mcp_config.views.keys() {
1210                                ids.push(
1211                                    tools_arc
1212                                        .canonical_id(view_name)
1213                                        .unwrap_or_else(|| view_name.clone()),
1214                                );
1215                            }
1216                        }
1217                    }
1218
1219                    ids
1220                })
1221                .unwrap_or_default();
1222
1223            if let Some(ref spec) = self.spec
1224                && let Some(ref spawner) = spec.spawner
1225            {
1226                // management_tools and orchestration_tools are explicit registration and grant signals.
1227                ids.extend(spawner.management_tools.granted_management_tool_ids());
1228                ids.extend(spawner.orchestration_tools.granted_orchestration_tool_ids());
1229            }
1230
1231            if persona_manager
1232                .as_ref()
1233                .is_some_and(|pm| pm.should_register_evolve_tool())
1234            {
1235                // allow_llm_evolve is an explicit registration and grant signal.
1236                ids.push("persona_evolve".to_string());
1237            }
1238
1239            ids.sort();
1240            ids.dedup();
1241            ids
1242        });
1243
1244        // Validate: every effective non-MCP tool grant must exist in the registry.
1245        // MCP tools are excluded because they are registered via auto_configure_mcp() which
1246        // may or may not have been called (and MCP view names are synthetic).
1247        // Feature grants such as spawner management tools, orchestration tools, and persona_evolve must be registered before build.
1248        if let Some(ref ids) = declared_tool_ids {
1249            let mcp_names: Vec<String> = self
1250                .spec
1251                .as_ref()
1252                .and_then(|s| s.tools.as_ref())
1253                .map(|tools| {
1254                    let mut names = Vec::new();
1255                    for entry in tools {
1256                        if entry.is_mcp() {
1257                            names.push(entry.name().to_string());
1258                            if let Some(cfg) = entry.to_mcp_config() {
1259                                names.extend(cfg.views.keys().cloned());
1260                            }
1261                        }
1262                    }
1263                    names
1264                })
1265                .unwrap_or_default();
1266
1267            let missing: Vec<&str> = ids
1268                .iter()
1269                .filter(|id| !mcp_names.contains(id))
1270                .filter(|id| tools_arc.get(id).is_none())
1271                .map(|s| s.as_str())
1272                .collect();
1273
1274            if !missing.is_empty() {
1275                return Err(AgentError::Config(format!(
1276                    "Tools granted by YAML but not registered: [{}]. \
1277                     Register them via .tool(Arc::new(...)) or the matching auto_configure_* method before .build(), \
1278                     or remove the grant from YAML.",
1279                    missing.join(", ")
1280                )));
1281            }
1282        }
1283
1284        let mut agent = RuntimeAgent::new(
1285            info,
1286            llm_registry_arc.clone(),
1287            memory,
1288            tools_arc,
1289            self.skills,
1290            system_prompt,
1291            max_iterations,
1292        )
1293        .with_shared_resource_locks(resource_locks)
1294        .with_declared_tool_ids(declared_tool_ids);
1295
1296        if let Some(tokens) = self.max_context_tokens {
1297            agent = agent.with_max_context_tokens(tokens);
1298        }
1299
1300        if let Some(manager) = self.recovery_manager {
1301            agent = agent.with_recovery_manager(manager);
1302        } else if let Some(ref spec) = self.spec {
1303            agent = agent.with_recovery_manager(RecoveryManager::new(spec.error_recovery.clone()));
1304        }
1305
1306        if let Some(engine) = self.tool_security {
1307            agent = agent.with_tool_security(engine);
1308        } else if let Some(ref spec) = self.spec {
1309            agent =
1310                agent.with_tool_security(ToolSecurityEngine::try_new(spec.tool_security.clone())?);
1311        }
1312
1313        if let Some(processor) = self.process_processor {
1314            agent =
1315                agent.with_process_processor(processor.with_llm_registry(llm_registry_arc.clone()));
1316        } else if let Some(ref spec) = self.spec
1317            && spec.has_process()
1318        {
1319            let processor = ProcessProcessor::new(spec.process.clone())
1320                .with_llm_registry(llm_registry_arc.clone());
1321            agent = agent.with_process_processor(processor);
1322        }
1323
1324        for (name, filter) in self.message_filters {
1325            agent.register_message_filter(name, filter);
1326        }
1327
1328        // Configure state machine from spec or builder
1329        if let Some(state_machine) = self.state_machine {
1330            let evaluator = self.transition_evaluator.unwrap_or_else(|| {
1331                let eval_llm = llm_registry_arc
1332                    .get("evaluator")
1333                    .or_else(|_| llm_registry_arc.router())
1334                    .or_else(|_| llm_registry_arc.default())
1335                    .expect("At least one LLM required for transition evaluator");
1336                Arc::new(LLMTransitionEvaluator::new(eval_llm))
1337            });
1338            agent = agent.with_state_machine(state_machine, evaluator);
1339        } else if let Some(ref spec) = self.spec
1340            && let Some(ref state_config) = spec.states
1341        {
1342            let state_machine = StateMachine::new(state_config.clone())?;
1343            let evaluator = self.transition_evaluator.unwrap_or_else(|| {
1344                let eval_llm = llm_registry_arc
1345                    .get("evaluator")
1346                    .or_else(|_| llm_registry_arc.router())
1347                    .or_else(|_| llm_registry_arc.default())
1348                    .expect("At least one LLM required for transition evaluator");
1349                Arc::new(LLMTransitionEvaluator::new(eval_llm))
1350            });
1351            agent = agent.with_state_machine(Arc::new(state_machine), evaluator);
1352        }
1353
1354        // Configure context manager from spec or builder
1355        if let Some(context_manager) = self.context_manager {
1356            agent = agent.with_context_manager(context_manager);
1357        } else if let Some(ref spec) = self.spec
1358            && !spec.context.is_empty()
1359        {
1360            let context_manager = ContextManager::new(
1361                spec.context.clone(),
1362                spec.name.clone(),
1363                spec.version.clone(),
1364            );
1365            agent = agent.with_context_manager(Arc::new(context_manager));
1366        }
1367
1368        // Configure parallel tools and streaming from spec
1369        if let Some(ref spec) = self.spec {
1370            agent = agent.with_parallel_tools(spec.parallel_tools.clone());
1371            let streaming_config = self
1372                .streaming
1373                .clone()
1374                .unwrap_or_else(|| spec.streaming.clone());
1375            agent = agent.with_streaming(streaming_config);
1376
1377            agent = agent.with_runtime_config(spec.runtime.clone());
1378
1379            // Configure memory token budget if specified
1380            if let Some(ref budget) = spec.memory.token_budget {
1381                agent = agent.with_memory_token_budget(budget.clone());
1382            }
1383
1384            // Configure storage from spec if not explicitly set
1385            if self.storage_config.is_none() && spec.has_storage() {
1386                agent = agent.with_storage_config(spec.storage.clone());
1387            }
1388        }
1389
1390        // Configure storage from builder
1391        if let Some(storage_config) = self.storage_config {
1392            agent = agent.with_storage_config(storage_config);
1393        }
1394        if let Some(storage) = self.storage {
1395            agent = agent.with_storage(storage);
1396        }
1397
1398        // Wire spawner handles into the agent so CLI can access registry.
1399        if let (Some(spawner), Some(registry)) = (self.spawner, self.spawner_registry) {
1400            agent = agent.with_spawner_handles(spawner, registry);
1401        }
1402
1403        // Configure hooks
1404        if let Some(manager) = observability_manager {
1405            agent = agent.with_observability(Arc::clone(&manager));
1406            let observability_hooks: Arc<dyn AgentHooks> =
1407                Arc::new(ObservabilityHooks::new(manager));
1408            let hooks: Arc<dyn AgentHooks> = if let Some(user_hooks) = self.hooks {
1409                Arc::new(
1410                    CompositeHooks::new()
1411                        .add(user_hooks)
1412                        .add(observability_hooks),
1413                )
1414            } else {
1415                observability_hooks
1416            };
1417            agent = agent.with_hooks(hooks);
1418        } else if let Some(hooks) = self.hooks {
1419            agent = agent.with_hooks(hooks);
1420        }
1421
1422        // Configure HITL from spec or builder
1423        if let Some(hitl_engine) = self.hitl_engine {
1424            let handler = self
1425                .approval_handler
1426                .unwrap_or_else(|| Arc::new(RejectAllHandler::new()));
1427            agent = agent.with_hitl(hitl_engine, handler);
1428        } else if let Some(ref spec) = self.spec
1429            && let Some(ref hitl_config) = spec.hitl
1430        {
1431            let hitl_engine = HITLEngine::new(hitl_config.clone());
1432            let handler = self
1433                .approval_handler
1434                .unwrap_or_else(|| Arc::new(RejectAllHandler::new()));
1435            agent = agent.with_hitl(hitl_engine, handler);
1436        }
1437
1438        if let Some(reasoning) = self.reasoning {
1439            agent = agent.with_reasoning(reasoning);
1440        } else if let Some(ref spec) = self.spec {
1441            agent = agent.with_reasoning(spec.reasoning.clone());
1442        }
1443
1444        if let Some(reflection) = self.reflection {
1445            agent = agent.with_reflection(reflection);
1446        } else if let Some(ref spec) = self.spec {
1447            agent = agent.with_reflection(spec.reflection.clone());
1448        }
1449
1450        // Configure disambiguation from spec
1451        if let Some(ref spec) = self.spec
1452            && spec.disambiguation.is_enabled()
1453        {
1454            agent = agent.with_disambiguation(spec.disambiguation.clone());
1455        }
1456
1457        // Wire persona manager into the agent (created earlier before tools_arc).
1458        if let Some(pm) = persona_manager {
1459            agent = agent.with_persona(pm);
1460        }
1461
1462        if let Some(relationship_manager) = relationship_manager {
1463            agent = agent.with_relationships(relationship_manager);
1464        }
1465
1466        // Store actor memory and facts configs on the agent now.
1467        // The actual FactStore and extractor are created lazily in init_storage()
1468        // once the storage backend is available. This avoids the sync/async
1469        // mismatch that caused the builder to silently skip facts setup when
1470        // storage was not yet initialized at build() time.
1471        if actor_memory_config.is_some() || facts_config.is_some() {
1472            agent = agent.with_facts_config(actor_memory_config, facts_config);
1473        }
1474
1475        Ok(agent)
1476    }
1477}
1478
1479/// Collect all agent IDs referenced by orchestration state fields.
1480fn collect_orchestration_refs(
1481    states: &std::collections::HashMap<String, ai_agents_state::StateDefinition>,
1482) -> Vec<(String, String, &'static str)> {
1483    let mut refs = Vec::new();
1484
1485    for (state_name, def) in states {
1486        if let Some(ref delegate_id) = def.delegate {
1487            refs.push((delegate_id.clone(), state_name.clone(), "delegate"));
1488        }
1489        if let Some(ref concurrent) = def.concurrent {
1490            for agent_ref in &concurrent.agents {
1491                refs.push((agent_ref.id().to_string(), state_name.clone(), "concurrent"));
1492            }
1493        }
1494        if let Some(ref gc) = def.group_chat {
1495            for participant in &gc.participants {
1496                refs.push((participant.id.clone(), state_name.clone(), "group_chat"));
1497            }
1498        }
1499        if let Some(ref pipeline) = def.pipeline {
1500            for stage in &pipeline.stages {
1501                refs.push((stage.id().to_string(), state_name.clone(), "pipeline"));
1502            }
1503        }
1504        if let Some(ref handoff) = def.handoff {
1505            refs.push((handoff.initial_agent.clone(), state_name.clone(), "handoff"));
1506            for agent_id in &handoff.available_agents {
1507                refs.push((agent_id.clone(), state_name.clone(), "handoff"));
1508            }
1509        }
1510
1511        // Recurse into sub-states.
1512        if let Some(ref sub_states) = def.states {
1513            refs.extend(collect_orchestration_refs(sub_states));
1514        }
1515    }
1516
1517    refs
1518}
1519
1520impl Default for AgentBuilder {
1521    fn default() -> Self {
1522        Self::new()
1523    }
1524}
1525
1526#[cfg(test)]
1527mod tests {
1528    use super::*;
1529
1530    #[test]
1531    fn test_builder_new() {
1532        let builder = AgentBuilder::new();
1533        assert!(builder.spec.is_none());
1534        assert!(builder.system_prompt.is_none());
1535    }
1536
1537    #[test]
1538    fn test_builder_from_yaml() {
1539        let yaml = r#"
1540name: TestAgent
1541system_prompt: "You are helpful."
1542llm:
1543  provider: openai
1544  model: gpt-4
1545"#;
1546        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1547        assert!(builder.spec.is_some());
1548        assert_eq!(builder.spec.as_ref().unwrap().name, "TestAgent");
1549    }
1550
1551    #[test]
1552    fn test_builder_from_yaml_rejects_nested_unknown_path() {
1553        let yaml = r#"
1554name: TestAgent
1555system_prompt: test
1556runtime:
1557  optimization:
1558    max_parallel_runtime_task: 4
1559"#;
1560        let error = match AgentBuilder::from_yaml(yaml) {
1561            Ok(_) => panic!("expected strict parse failure"),
1562            Err(error) => error.to_string(),
1563        };
1564        assert!(
1565            error.contains("runtime.optimization.max_parallel_runtime_task"),
1566            "{error}"
1567        );
1568    }
1569
1570    #[test]
1571    fn test_feature_override_single_llm_builder_path() {
1572        let yaml = r#"
1573name: LocalAgent
1574system_prompt: "You are helpful."
1575llm:
1576  provider: ollama
1577  model: llama3.1
1578  function_calling: true
1579"#;
1580        let builder = AgentBuilder::from_yaml(yaml)
1581            .unwrap()
1582            .auto_configure_llms()
1583            .unwrap();
1584
1585        let llm = builder.llm.as_ref().unwrap();
1586        assert!(llm.supports(LLMFeature::FunctionCalling));
1587    }
1588
1589    #[test]
1590    fn test_feature_override_named_llms_builder_path() {
1591        let yaml = r#"
1592name: LocalAgent
1593system_prompt: "You are helpful."
1594llms:
1595  default:
1596    provider: openai-compatible
1597    model: qwen3:8b
1598    base_url: http://localhost:11434/v1
1599    json_mode: true
1600llm:
1601  default: default
1602"#;
1603        let builder = AgentBuilder::from_yaml(yaml)
1604            .unwrap()
1605            .auto_configure_llms()
1606            .unwrap();
1607
1608        let registry = builder.llm_registry.as_ref().unwrap();
1609        let llm = registry.get("default").unwrap();
1610        assert!(llm.supports(LLMFeature::JsonMode));
1611    }
1612
1613    #[test]
1614    fn test_builder_from_yaml_with_tool_security() {
1615        let yaml = r#"
1616name: SecureAgent
1617system_prompt: "You are helpful."
1618llm:
1619  provider: openai
1620  model: gpt-4
1621max_context_tokens: 8192
1622error_recovery:
1623  default:
1624    max_retries: 5
1625tool_security:
1626  enabled: true
1627  tools:
1628    http:
1629      rate_limit: 10
1630"#;
1631        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1632        assert!(builder.spec.is_some());
1633        let spec = builder.spec.as_ref().unwrap();
1634        assert_eq!(spec.max_context_tokens, 8192);
1635        assert_eq!(spec.error_recovery.default.max_retries, 5);
1636        assert!(spec.tool_security.enabled);
1637    }
1638
1639    #[test]
1640    fn test_builder_from_yaml_rejects_zero_max_results() {
1641        let yaml = r#"
1642name: SecureAgent
1643system_prompt: "You are helpful."
1644tool_security:
1645  enabled: true
1646  tools:
1647    web_search:
1648      max_results: 0
1649"#;
1650        let error = AgentBuilder::from_yaml(yaml).err().unwrap();
1651        assert!(
1652            error
1653                .to_string()
1654                .contains("tool_security.tools.web_search.max_results must be greater than 0")
1655        );
1656    }
1657
1658    #[test]
1659    fn test_builder_from_yaml_with_skills() {
1660        let yaml = r#"
1661name: SkillAgent
1662system_prompt: "You are helpful."
1663llm:
1664  provider: openai
1665  model: gpt-4
1666skills:
1667  - id: greeting
1668    description: "Greet users"
1669    trigger: "When user says hello"
1670    steps:
1671      - prompt: "Hello!"
1672"#;
1673        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1674        assert!(builder.spec.is_some());
1675        assert!(!builder.spec.as_ref().unwrap().skills.is_empty());
1676    }
1677
1678    #[test]
1679    fn test_builder_from_spec() {
1680        let spec = AgentSpec {
1681            name: "test".to_string(),
1682            version: "1.0".to_string(),
1683            description: Some("Test agent".to_string()),
1684            system_prompt: "You are helpful".to_string(),
1685            ..Default::default()
1686        };
1687
1688        let builder = AgentBuilder::from_spec(spec);
1689        assert!(builder.spec.is_some());
1690        assert_eq!(builder.system_prompt, Some("You are helpful".to_string()));
1691    }
1692
1693    #[test]
1694    fn test_builder_rejects_invalid_programmatic_tool_security() {
1695        use ai_agents_llm::mock::MockLLMProvider;
1696
1697        let mut spec = AgentSpec::default();
1698        spec.tool_security.tools.insert(
1699            "web_search".to_string(),
1700            ai_agents_tools::ToolPolicyConfig {
1701                max_results: Some(0),
1702                ..Default::default()
1703            },
1704        );
1705
1706        let error = AgentBuilder::from_spec(spec)
1707            .llm(Arc::new(MockLLMProvider::new("test")))
1708            .build()
1709            .unwrap_err();
1710        assert!(
1711            error
1712                .to_string()
1713                .contains("max_results must be greater than 0")
1714        );
1715    }
1716
1717    #[test]
1718    fn test_builder_from_spec_with_base_dir_preserves_mutations() {
1719        let yaml = r#"
1720name: OriginalAgent
1721system_prompt: "Original prompt"
1722max_iterations: 10
1723llm:
1724  provider: openai
1725  model: gpt-4
1726"#;
1727        let mut spec = AgentBuilder::from_yaml(yaml).unwrap().spec.unwrap();
1728        spec.name = "RewrittenAgent".to_string();
1729        spec.system_prompt = "Rewritten prompt".to_string();
1730        spec.max_iterations = 37;
1731        let base_dir = PathBuf::from("rewritten-agent-dir");
1732
1733        let builder = AgentBuilder::from_spec_with_base_dir(spec, &base_dir);
1734
1735        let stored_spec = builder.spec.as_ref().unwrap();
1736        assert_eq!(stored_spec.name, "RewrittenAgent");
1737        assert_eq!(stored_spec.system_prompt, "Rewritten prompt");
1738        assert_eq!(stored_spec.max_iterations, 37);
1739        assert_eq!(builder.system_prompt.as_deref(), Some("Rewritten prompt"));
1740        assert_eq!(builder.max_iterations, Some(37));
1741        assert_eq!(builder.yaml_dir.as_deref(), Some(base_dir.as_path()));
1742    }
1743
1744    #[tokio::test]
1745    async fn test_builder_from_spec_with_base_dir_resolves_spawner_paths() {
1746        use ai_agents_llm::mock::MockLLMProvider;
1747
1748        let base_dir = std::env::temp_dir().join(format!(
1749            "ai-agents-builder-base-dir-{}",
1750            uuid::Uuid::new_v4()
1751        ));
1752        let templates_dir = base_dir.join("templates");
1753        let agents_dir = base_dir.join("agents");
1754        std::fs::create_dir_all(&templates_dir).unwrap();
1755        std::fs::create_dir_all(&agents_dir).unwrap();
1756
1757        let template_content = "name: {{ name }}\nsystem_prompt: Template prompt\n";
1758        std::fs::write(templates_dir.join("worker.yaml"), template_content).unwrap();
1759        std::fs::write(
1760            agents_dir.join("child.yaml"),
1761            r#"
1762name: ChildAgent
1763system_prompt: "Child prompt"
1764llm:
1765  provider: definitely-not-a-provider
1766  model: unavailable
1767"#,
1768        )
1769        .unwrap();
1770
1771        let yaml = r#"
1772name: ParentAgent
1773system_prompt: "Parent prompt"
1774llm:
1775  provider: openai
1776  model: gpt-4
1777spawner:
1778  shared_llms: true
1779  templates:
1780    worker:
1781      path: templates/worker.yaml
1782  auto_spawn:
1783    - id: child
1784      agent: agents/child.yaml
1785"#;
1786        let spec = AgentBuilder::from_yaml(yaml).unwrap().spec.unwrap();
1787
1788        let builder = AgentBuilder::from_spec_with_base_dir(spec, &base_dir)
1789            .llm(Arc::new(MockLLMProvider::new("test")))
1790            .auto_configure_spawner()
1791            .await
1792            .unwrap();
1793
1794        let template = builder
1795            .spawner
1796            .as_ref()
1797            .unwrap()
1798            .templates()
1799            .get("worker")
1800            .unwrap();
1801        assert_eq!(template.content, template_content);
1802        assert!(builder.spawner_registry.as_ref().unwrap().contains("child"));
1803
1804        std::fs::remove_dir_all(base_dir).unwrap();
1805    }
1806
1807    #[tokio::test]
1808    async fn test_builder_auto_spawn_fails_on_any_declared_child_error() {
1809        use ai_agents_llm::mock::MockLLMProvider;
1810
1811        let base_dir = std::env::temp_dir().join(format!(
1812            "ai-agents-builder-child-failure-{}",
1813            uuid::Uuid::new_v4()
1814        ));
1815        std::fs::create_dir_all(&base_dir).unwrap();
1816        std::fs::write(
1817            base_dir.join("valid.yaml"),
1818            "name: ValidChild\nsystem_prompt: valid\n",
1819        )
1820        .unwrap();
1821
1822        let yaml = r#"
1823name: ParentAgent
1824system_prompt: parent
1825llm:
1826  default: default
1827spawner:
1828  shared_llms: true
1829  auto_spawn:
1830    - id: valid
1831      agent: valid.yaml
1832    - id: missing
1833      agent: missing.yaml
1834"#;
1835        let spec = AgentBuilder::from_yaml(yaml).unwrap().spec.unwrap();
1836        let mut registry = LLMRegistry::new();
1837        registry.register("default", Arc::new(MockLLMProvider::new("test")));
1838        registry.set_default("default");
1839
1840        let error = AgentBuilder::from_spec_with_base_dir(spec, &base_dir)
1841            .llm_registry(registry)
1842            .auto_configure_spawner()
1843            .await
1844            .err()
1845            .unwrap()
1846            .to_string();
1847        assert!(error.contains("missing"), "{error}");
1848        assert!(error.contains("missing.yaml"), "{error}");
1849
1850        std::fs::remove_dir_all(base_dir).unwrap();
1851    }
1852
1853    #[test]
1854    fn test_builder_chain() {
1855        let builder = AgentBuilder::new()
1856            .system_prompt("Test prompt")
1857            .max_iterations(5)
1858            .max_context_tokens(4096);
1859
1860        assert_eq!(builder.system_prompt, Some("Test prompt".to_string()));
1861        assert_eq!(builder.max_iterations, Some(5));
1862        assert_eq!(builder.max_context_tokens, Some(4096));
1863    }
1864
1865    #[test]
1866    fn test_builder_skills() {
1867        use ai_agents_skills::{SkillDefinition, SkillStep};
1868
1869        let skill = SkillDefinition {
1870            id: "test".to_string(),
1871            description: "Test skill".to_string(),
1872            trigger: "When testing".to_string(),
1873            steps: vec![SkillStep::Prompt {
1874                prompt: "Hello".to_string(),
1875                llm: None,
1876            }],
1877            reasoning: None,
1878            reflection: None,
1879            disambiguation: None,
1880        };
1881
1882        let builder = AgentBuilder::new().skill(skill.clone()).skills(vec![skill]);
1883
1884        assert_eq!(builder.skills.len(), 2);
1885    }
1886
1887    #[test]
1888    fn test_builder_from_yaml_with_states() {
1889        let yaml = r#"
1890name: StatefulAgent
1891system_prompt: "You are helpful."
1892llm:
1893  provider: openai
1894  model: gpt-4
1895states:
1896  initial: greeting
1897  states:
1898    greeting:
1899      prompt: "Welcome!"
1900      transitions:
1901        - to: support
1902          when: "user needs help"
1903    support:
1904      prompt: "How can I help?"
1905"#;
1906        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1907        assert!(builder.spec.is_some());
1908        let spec = builder.spec.as_ref().unwrap();
1909        assert!(spec.has_states());
1910        assert!(spec.states.is_some());
1911        let states = spec.states.as_ref().unwrap();
1912        assert_eq!(states.initial, "greeting");
1913        assert_eq!(states.states.len(), 2);
1914    }
1915
1916    #[test]
1917    fn test_builder_from_yaml_with_context() {
1918        let yaml = r#"
1919name: ContextAgent
1920system_prompt: "Hello, {{ context.user.name }}!"
1921llm:
1922  provider: openai
1923  model: gpt-4
1924context:
1925  user:
1926    type: runtime
1927    required: true
1928  time:
1929    type: builtin
1930    source: datetime
1931    refresh: per_turn
1932"#;
1933        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1934        assert!(builder.spec.is_some());
1935        let spec = builder.spec.as_ref().unwrap();
1936        assert!(spec.has_context());
1937        assert_eq!(spec.context.len(), 2);
1938        assert!(spec.context.contains_key("user"));
1939        assert!(spec.context.contains_key("time"));
1940    }
1941
1942    #[test]
1943    fn test_builder_from_yaml_with_full_v04_features() {
1944        let yaml = r#"
1945name: FullFeaturedAgent
1946version: "0.4.0"
1947system_prompt: |
1948  You are a helpful assistant.
1949  User: {{ context.user.name }}
1950  Language: {{ context.user.language }}
1951llm:
1952  provider: openai
1953  model: gpt-4
1954context:
1955  user:
1956    type: runtime
1957    required: true
1958    default:
1959      name: "Guest"
1960      language: "en"
1961  time:
1962    type: builtin
1963    source: datetime
1964    refresh: per_turn
1965states:
1966  initial: greeting
1967  states:
1968    greeting:
1969      prompt: "Welcome to our service!"
1970      prompt_mode: append
1971      transitions:
1972        - to: support
1973          when: "user needs help"
1974          auto: true
1975          priority: 10
1976    support:
1977      prompt: "I'm here to help you."
1978      max_turns: 5
1979      timeout_to: escalation
1980      transitions:
1981        - to: closing
1982          when: "issue resolved"
1983          auto: true
1984    escalation:
1985      prompt: "Let me connect you with a human agent."
1986    closing:
1987      prompt: "Thank you for using our service!"
1988"#;
1989        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1990        assert!(builder.spec.is_some());
1991        let spec = builder.spec.as_ref().unwrap();
1992
1993        // Check context
1994        assert!(spec.has_context());
1995        assert_eq!(spec.context.len(), 2);
1996
1997        // Check states
1998        assert!(spec.has_states());
1999        let states = spec.states.as_ref().unwrap();
2000        assert_eq!(states.initial, "greeting");
2001        assert_eq!(states.states.len(), 4);
2002
2003        // Check greeting state details
2004        let greeting = states.states.get("greeting").unwrap();
2005        assert!(greeting.prompt.is_some());
2006        assert_eq!(greeting.transitions.len(), 1);
2007        assert_eq!(greeting.transitions[0].to, "support");
2008        assert!(greeting.transitions[0].auto);
2009
2010        // Check support state has timeout
2011        let support = states.states.get("support").unwrap();
2012        assert_eq!(support.max_turns, Some(5));
2013        assert_eq!(support.timeout_to, Some("escalation".to_string()));
2014    }
2015
2016    #[test]
2017    fn test_builder_from_yaml_with_hitl() {
2018        let yaml = r#"
2019name: HITLAgent
2020system_prompt: "You are a secure assistant."
2021llm:
2022  provider: openai
2023  model: gpt-4
2024hitl:
2025  default_timeout_seconds: 600
2026  on_timeout: reject
2027  tools:
2028    send_payment:
2029      require_approval: true
2030      approval_context:
2031        - amount
2032        - recipient
2033      approval_message: "Approve payment?"
2034    delete_record:
2035      require_approval: true
2036  conditions:
2037    - name: high_value
2038      when: "amount > 1000"
2039      require_approval: true
2040      approval_message: "High value transaction"
2041  states:
2042    escalation:
2043      on_enter: require_approval
2044      approval_message: "Escalate to human?"
2045"#;
2046        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2047        assert!(builder.spec.is_some());
2048        let spec = builder.spec.as_ref().unwrap();
2049
2050        assert!(spec.has_hitl());
2051        let hitl = spec.hitl.as_ref().unwrap();
2052        assert_eq!(hitl.default_timeout_seconds, 600);
2053        assert_eq!(hitl.tools.len(), 2);
2054        assert!(hitl.tools.get("send_payment").unwrap().require_approval);
2055        assert_eq!(hitl.conditions.len(), 1);
2056        assert_eq!(hitl.conditions[0].name, "high_value");
2057        assert_eq!(hitl.states.len(), 1);
2058    }
2059
2060    #[test]
2061    fn test_builder_from_yaml_with_compacting_memory() {
2062        let yaml = r#"
2063name: CompactingAgent
2064system_prompt: "You are a helpful assistant."
2065memory:
2066  type: compacting
2067  max_messages: 100
2068  max_recent_messages: 20
2069  compress_threshold: 15
2070  summarize_batch_size: 5
2071  summarizer_llm: router
2072llm:
2073  provider: openai
2074  model: gpt-4
2075"#;
2076        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2077        assert!(builder.spec.is_some());
2078        let spec = builder.spec.as_ref().unwrap();
2079
2080        assert!(spec.memory.is_compacting());
2081        assert_eq!(spec.memory.max_recent_messages, Some(20));
2082        assert_eq!(spec.memory.compress_threshold, Some(15));
2083        assert_eq!(spec.memory.summarize_batch_size, Some(5));
2084        assert_eq!(spec.memory.summarizer_llm, Some("router".to_string()));
2085
2086        let compacting_config = spec.memory.to_compacting_config();
2087        assert_eq!(compacting_config.max_recent_messages, 20);
2088        assert_eq!(compacting_config.compress_threshold, 15);
2089        assert_eq!(compacting_config.summarize_batch_size, 5);
2090    }
2091
2092    #[test]
2093    fn test_builder_from_yaml_with_token_budget() {
2094        let yaml = r#"
2095name: BudgetAgent
2096system_prompt: "You are a helpful assistant."
2097memory:
2098  type: compacting
2099  max_messages: 100
2100  token_budget:
2101    total: 8192
2102    allocation:
2103      summary: 2048
2104      recent_messages: 4096
2105      facts: 1024
2106    overflow_strategy: summarize_more
2107    warn_at_percent: 75
2108llm:
2109  provider: openai
2110  model: gpt-4
2111"#;
2112        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2113        assert!(builder.spec.is_some());
2114        let spec = builder.spec.as_ref().unwrap();
2115
2116        assert!(spec.memory.token_budget.is_some());
2117        let budget = spec.memory.token_budget.as_ref().unwrap();
2118        assert_eq!(budget.total, 8192);
2119        assert_eq!(budget.allocation.summary, 2048);
2120        assert_eq!(budget.allocation.recent_messages, 4096);
2121        assert_eq!(budget.allocation.facts, 1024);
2122        assert_eq!(budget.warn_at_percent, 75);
2123    }
2124
2125    #[test]
2126    fn test_builder_from_yaml_with_overflow_strategies() {
2127        use ai_agents_memory::OverflowStrategy;
2128
2129        let yaml = r#"
2130name: TruncateAgent
2131system_prompt: "You are helpful."
2132memory:
2133  type: compacting
2134  token_budget:
2135    total: 4096
2136    overflow_strategy: truncate_oldest
2137llm:
2138  provider: openai
2139  model: gpt-4
2140"#;
2141        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2142        let budget = builder
2143            .spec
2144            .as_ref()
2145            .unwrap()
2146            .memory
2147            .token_budget
2148            .as_ref()
2149            .unwrap();
2150        assert_eq!(budget.overflow_strategy, OverflowStrategy::TruncateOldest);
2151
2152        let yaml = r#"
2153name: ErrorAgent
2154system_prompt: "You are helpful."
2155memory:
2156  type: compacting
2157  token_budget:
2158    total: 4096
2159    overflow_strategy: error
2160llm:
2161  provider: openai
2162  model: gpt-4
2163"#;
2164        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2165        let budget = builder
2166            .spec
2167            .as_ref()
2168            .unwrap()
2169            .memory
2170            .token_budget
2171            .as_ref()
2172            .unwrap();
2173        assert_eq!(budget.overflow_strategy, OverflowStrategy::Error);
2174    }
2175
2176    #[test]
2177    fn test_builder_from_yaml_with_storage_file() {
2178        let yaml = r#"
2179name: PersistentAgent
2180system_prompt: "You are helpful."
2181llm:
2182  provider: openai
2183  model: gpt-4
2184storage:
2185  type: file
2186  path: "./data/sessions"
2187"#;
2188        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2189        let spec = builder.spec.as_ref().unwrap();
2190        assert!(spec.has_storage());
2191        assert!(spec.storage.is_file());
2192        assert_eq!(spec.storage.get_path(), Some("./data/sessions"));
2193    }
2194
2195    #[test]
2196    fn test_builder_from_yaml_with_storage_sqlite() {
2197        let yaml = r#"
2198name: PersistentAgent
2199system_prompt: "You are helpful."
2200llm:
2201  provider: openai
2202  model: gpt-4
2203storage:
2204  type: sqlite
2205  path: "./data/sessions.db"
2206"#;
2207        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2208        let spec = builder.spec.as_ref().unwrap();
2209        assert!(spec.has_storage());
2210        assert!(spec.storage.is_sqlite());
2211    }
2212
2213    #[test]
2214    fn test_builder_from_yaml_with_storage_redis() {
2215        let yaml = r#"
2216name: PersistentAgent
2217system_prompt: "You are helpful."
2218llm:
2219  provider: openai
2220  model: gpt-4
2221storage:
2222  type: redis
2223  url: "redis://localhost:6379"
2224  prefix: "myagent:"
2225  ttl_seconds: 86400
2226"#;
2227        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2228        let spec = builder.spec.as_ref().unwrap();
2229        assert!(spec.has_storage());
2230        assert!(spec.storage.is_redis());
2231        assert_eq!(spec.storage.get_prefix(), "myagent:");
2232        assert_eq!(spec.storage.get_ttl(), Some(86400));
2233    }
2234
2235    #[test]
2236    fn test_builder_no_storage_by_default() {
2237        let yaml = r#"
2238name: SimpleAgent
2239system_prompt: "You are helpful."
2240llm:
2241  provider: openai
2242  model: gpt-4
2243"#;
2244        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2245        let spec = builder.spec.as_ref().unwrap();
2246        assert!(!spec.has_storage());
2247    }
2248
2249    #[test]
2250    fn test_build_fails_on_missing_declared_tool() {
2251        use ai_agents_llm::mock::MockLLMProvider;
2252
2253        let yaml = r#"
2254name: ToolAgent
2255system_prompt: "You are helpful."
2256llm:
2257  provider: openai
2258  model: gpt-4
2259tools:
2260  - name: lookup_order
2261  - name: calculator
2262"#;
2263        let llm = Arc::new(MockLLMProvider::new("test"));
2264        let result = AgentBuilder::from_yaml(yaml)
2265            .unwrap()
2266            .llm(llm)
2267            .auto_configure_features()
2268            .unwrap()
2269            // NOT registering lookup_order — should fail
2270            .build();
2271
2272        assert!(result.is_err());
2273        let err = result.unwrap_err().to_string();
2274        assert!(
2275            err.contains("lookup_order"),
2276            "error should name the missing tool: {}",
2277            err
2278        );
2279        // calculator is built-in, so it should NOT appear in the error
2280        assert!(
2281            !err.contains("calculator"),
2282            "calculator is registered, should not be missing: {}",
2283            err
2284        );
2285    }
2286
2287    #[test]
2288    fn test_build_succeeds_when_declared_tool_is_registered() {
2289        use ai_agents_core::Tool;
2290        use ai_agents_llm::mock::MockLLMProvider;
2291
2292        struct FakeTool;
2293        #[async_trait::async_trait]
2294        impl Tool for FakeTool {
2295            fn id(&self) -> &str {
2296                "lookup_order"
2297            }
2298            fn name(&self) -> &str {
2299                "Order Lookup"
2300            }
2301            fn description(&self) -> &str {
2302                "Look up an order"
2303            }
2304            fn input_schema(&self) -> serde_json::Value {
2305                serde_json::json!({})
2306            }
2307            async fn execute(
2308                &self,
2309                _args: serde_json::Value,
2310                _ctx: ai_agents_core::ToolExecutionContext,
2311            ) -> ai_agents_core::ToolResult {
2312                ai_agents_core::ToolResult::ok("ok")
2313            }
2314        }
2315
2316        let yaml = r#"
2317name: ToolAgent
2318system_prompt: "You are helpful."
2319llm:
2320  provider: openai
2321  model: gpt-4
2322tools:
2323  - name: lookup_order
2324  - name: calculator
2325"#;
2326        let llm = Arc::new(MockLLMProvider::new("test"));
2327        let result = AgentBuilder::from_yaml(yaml)
2328            .unwrap()
2329            .llm(llm)
2330            .auto_configure_features()
2331            .unwrap()
2332            .tool(Arc::new(FakeTool))
2333            .build();
2334
2335        assert!(
2336            result.is_ok(),
2337            "build should succeed when all declared tools are registered: {:?}",
2338            result.err()
2339        );
2340    }
2341
2342    #[test]
2343    fn test_spawner_config_deserializes_shared_storage() {
2344        let yaml = r#"
2345name: TestAgent
2346system_prompt: "Test"
2347llm:
2348  provider: openai
2349  model: gpt-4
2350spawner:
2351  shared_llms: true
2352  shared_storage:
2353    type: sqlite
2354    path: ./data/test.db
2355  max_agents: 5
2356"#;
2357        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2358        let spec = builder.spec.as_ref().unwrap();
2359        let sc = spec.spawner.as_ref().unwrap();
2360        assert!(sc.shared_storage.is_some());
2361        assert!(sc.shared_storage.as_ref().unwrap().is_sqlite());
2362    }
2363
2364    #[test]
2365    fn test_build_succeeds_with_no_tools_declared() {
2366        use ai_agents_llm::mock::MockLLMProvider;
2367
2368        let yaml = r#"
2369name: SimpleAgent
2370system_prompt: "You are helpful."
2371llm:
2372  provider: openai
2373  model: gpt-4
2374"#;
2375        let llm = Arc::new(MockLLMProvider::new("test"));
2376        let result = AgentBuilder::from_yaml(yaml)
2377            .unwrap()
2378            .llm(llm)
2379            .auto_configure_features()
2380            .unwrap()
2381            .build();
2382
2383        assert!(
2384            result.is_ok(),
2385            "no tools: section means no validation needed: {:?}",
2386            result.err()
2387        );
2388    }
2389}