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::try_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 =
1304                agent.with_recovery_manager(RecoveryManager::try_new(spec.error_recovery.clone())?);
1305        }
1306
1307        if let Some(engine) = self.tool_security {
1308            agent = agent.with_tool_security(engine);
1309        } else if let Some(ref spec) = self.spec {
1310            agent =
1311                agent.with_tool_security(ToolSecurityEngine::try_new(spec.tool_security.clone())?);
1312        }
1313
1314        if let Some(processor) = self.process_processor {
1315            agent =
1316                agent.with_process_processor(processor.with_llm_registry(llm_registry_arc.clone()));
1317        } else if let Some(ref spec) = self.spec
1318            && spec.has_process()
1319        {
1320            let processor = ProcessProcessor::new(spec.process.clone())
1321                .with_llm_registry(llm_registry_arc.clone());
1322            agent = agent.with_process_processor(processor);
1323        }
1324
1325        for (name, filter) in self.message_filters {
1326            agent.register_message_filter(name, filter);
1327        }
1328
1329        // Configure state machine from spec or builder
1330        if let Some(state_machine) = self.state_machine {
1331            let evaluator = self.transition_evaluator.unwrap_or_else(|| {
1332                let eval_llm = llm_registry_arc
1333                    .get("evaluator")
1334                    .or_else(|_| llm_registry_arc.router())
1335                    .or_else(|_| llm_registry_arc.default())
1336                    .expect("At least one LLM required for transition evaluator");
1337                Arc::new(LLMTransitionEvaluator::new(eval_llm))
1338            });
1339            agent = agent.with_state_machine(state_machine, evaluator);
1340        } else if let Some(ref spec) = self.spec
1341            && let Some(ref state_config) = spec.states
1342        {
1343            let state_machine = StateMachine::new(state_config.clone())?;
1344            let evaluator = self.transition_evaluator.unwrap_or_else(|| {
1345                let eval_llm = llm_registry_arc
1346                    .get("evaluator")
1347                    .or_else(|_| llm_registry_arc.router())
1348                    .or_else(|_| llm_registry_arc.default())
1349                    .expect("At least one LLM required for transition evaluator");
1350                Arc::new(LLMTransitionEvaluator::new(eval_llm))
1351            });
1352            agent = agent.with_state_machine(Arc::new(state_machine), evaluator);
1353        }
1354
1355        // Configure context manager from spec or builder
1356        if let Some(context_manager) = self.context_manager {
1357            agent = agent.with_context_manager(context_manager);
1358        } else if let Some(ref spec) = self.spec
1359            && !spec.context.is_empty()
1360        {
1361            let context_manager = ContextManager::new(
1362                spec.context.clone(),
1363                spec.name.clone(),
1364                spec.version.clone(),
1365            );
1366            agent = agent.with_context_manager(Arc::new(context_manager));
1367        }
1368
1369        // Configure parallel tools and streaming from spec
1370        if let Some(ref spec) = self.spec {
1371            agent = agent.with_parallel_tools(spec.parallel_tools.clone());
1372            let streaming_config = self
1373                .streaming
1374                .clone()
1375                .unwrap_or_else(|| spec.streaming.clone());
1376            agent = agent.with_streaming(streaming_config);
1377
1378            agent = agent.with_runtime_config(spec.runtime.clone());
1379
1380            // Configure memory token budget if specified
1381            if let Some(ref budget) = spec.memory.token_budget {
1382                agent = agent.with_memory_token_budget(budget.clone());
1383            }
1384
1385            // Configure storage from spec if not explicitly set
1386            if self.storage_config.is_none() && spec.has_storage() {
1387                agent = agent.with_storage_config(spec.storage.clone());
1388            }
1389        }
1390
1391        // Configure storage from builder
1392        if let Some(storage_config) = self.storage_config {
1393            agent = agent.with_storage_config(storage_config);
1394        }
1395        if let Some(storage) = self.storage {
1396            agent = agent.with_storage(storage);
1397        }
1398
1399        // Wire spawner handles into the agent so CLI can access registry.
1400        if let (Some(spawner), Some(registry)) = (self.spawner, self.spawner_registry) {
1401            agent = agent.with_spawner_handles(spawner, registry);
1402        }
1403
1404        // Configure hooks
1405        if let Some(manager) = observability_manager {
1406            agent = agent.with_observability(Arc::clone(&manager));
1407            let observability_hooks: Arc<dyn AgentHooks> =
1408                Arc::new(ObservabilityHooks::new(manager));
1409            let hooks: Arc<dyn AgentHooks> = if let Some(user_hooks) = self.hooks {
1410                Arc::new(
1411                    CompositeHooks::new()
1412                        .add(user_hooks)
1413                        .add(observability_hooks),
1414                )
1415            } else {
1416                observability_hooks
1417            };
1418            agent = agent.with_hooks(hooks);
1419        } else if let Some(hooks) = self.hooks {
1420            agent = agent.with_hooks(hooks);
1421        }
1422
1423        // Configure HITL from spec or builder
1424        if let Some(hitl_engine) = self.hitl_engine {
1425            let handler = self
1426                .approval_handler
1427                .unwrap_or_else(|| Arc::new(RejectAllHandler::new()));
1428            agent = agent.with_hitl(hitl_engine, handler);
1429        } else if let Some(ref spec) = self.spec
1430            && let Some(ref hitl_config) = spec.hitl
1431        {
1432            let hitl_engine = HITLEngine::new(hitl_config.clone());
1433            let handler = self
1434                .approval_handler
1435                .unwrap_or_else(|| Arc::new(RejectAllHandler::new()));
1436            agent = agent.with_hitl(hitl_engine, handler);
1437        }
1438
1439        if let Some(reasoning) = self.reasoning {
1440            agent = agent.with_reasoning(reasoning);
1441        } else if let Some(ref spec) = self.spec {
1442            agent = agent.with_reasoning(spec.reasoning.clone());
1443        }
1444
1445        if let Some(reflection) = self.reflection {
1446            agent = agent.with_reflection(reflection);
1447        } else if let Some(ref spec) = self.spec {
1448            agent = agent.with_reflection(spec.reflection.clone());
1449        }
1450
1451        // Configure disambiguation from spec
1452        if let Some(ref spec) = self.spec
1453            && spec.disambiguation.is_enabled()
1454        {
1455            agent = agent.with_disambiguation(spec.disambiguation.clone());
1456        }
1457
1458        // Wire persona manager into the agent (created earlier before tools_arc).
1459        if let Some(pm) = persona_manager {
1460            agent = agent.with_persona(pm);
1461        }
1462
1463        if let Some(relationship_manager) = relationship_manager {
1464            agent = agent.with_relationships(relationship_manager);
1465        }
1466
1467        // Store actor memory and facts configs on the agent now.
1468        // The actual FactStore and extractor are created lazily in init_storage()
1469        // once the storage backend is available. This avoids the sync/async
1470        // mismatch that caused the builder to silently skip facts setup when
1471        // storage was not yet initialized at build() time.
1472        if actor_memory_config.is_some() || facts_config.is_some() {
1473            agent = agent.with_facts_config(actor_memory_config, facts_config);
1474        }
1475
1476        Ok(agent)
1477    }
1478}
1479
1480/// Collect all agent IDs referenced by orchestration state fields.
1481fn collect_orchestration_refs(
1482    states: &std::collections::HashMap<String, ai_agents_state::StateDefinition>,
1483) -> Vec<(String, String, &'static str)> {
1484    let mut refs = Vec::new();
1485
1486    for (state_name, def) in states {
1487        if let Some(ref delegate_id) = def.delegate {
1488            refs.push((delegate_id.clone(), state_name.clone(), "delegate"));
1489        }
1490        if let Some(ref concurrent) = def.concurrent {
1491            for agent_ref in &concurrent.agents {
1492                refs.push((agent_ref.id().to_string(), state_name.clone(), "concurrent"));
1493            }
1494        }
1495        if let Some(ref gc) = def.group_chat {
1496            for participant in &gc.participants {
1497                refs.push((participant.id.clone(), state_name.clone(), "group_chat"));
1498            }
1499        }
1500        if let Some(ref pipeline) = def.pipeline {
1501            for stage in &pipeline.stages {
1502                refs.push((stage.id().to_string(), state_name.clone(), "pipeline"));
1503            }
1504        }
1505        if let Some(ref handoff) = def.handoff {
1506            refs.push((handoff.initial_agent.clone(), state_name.clone(), "handoff"));
1507            for agent_id in &handoff.available_agents {
1508                refs.push((agent_id.clone(), state_name.clone(), "handoff"));
1509            }
1510        }
1511
1512        // Recurse into sub-states.
1513        if let Some(ref sub_states) = def.states {
1514            refs.extend(collect_orchestration_refs(sub_states));
1515        }
1516    }
1517
1518    refs
1519}
1520
1521impl Default for AgentBuilder {
1522    fn default() -> Self {
1523        Self::new()
1524    }
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529    use super::*;
1530
1531    #[test]
1532    fn test_builder_new() {
1533        let builder = AgentBuilder::new();
1534        assert!(builder.spec.is_none());
1535        assert!(builder.system_prompt.is_none());
1536    }
1537
1538    #[test]
1539    fn test_builder_from_yaml() {
1540        let yaml = r#"
1541name: TestAgent
1542system_prompt: "You are helpful."
1543llm:
1544  provider: openai
1545  model: gpt-4
1546"#;
1547        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1548        assert!(builder.spec.is_some());
1549        assert_eq!(builder.spec.as_ref().unwrap().name, "TestAgent");
1550    }
1551
1552    #[test]
1553    fn test_builder_from_yaml_rejects_nested_unknown_path() {
1554        let yaml = r#"
1555name: TestAgent
1556system_prompt: test
1557runtime:
1558  optimization:
1559    max_parallel_runtime_task: 4
1560"#;
1561        let error = match AgentBuilder::from_yaml(yaml) {
1562            Ok(_) => panic!("expected strict parse failure"),
1563            Err(error) => error.to_string(),
1564        };
1565        assert!(
1566            error.contains("runtime.optimization.max_parallel_runtime_task"),
1567            "{error}"
1568        );
1569    }
1570
1571    #[test]
1572    fn test_feature_override_single_llm_builder_path() {
1573        let yaml = r#"
1574name: LocalAgent
1575system_prompt: "You are helpful."
1576llm:
1577  provider: ollama
1578  model: llama3.1
1579  function_calling: true
1580"#;
1581        let builder = AgentBuilder::from_yaml(yaml)
1582            .unwrap()
1583            .auto_configure_llms()
1584            .unwrap();
1585
1586        let llm = builder.llm.as_ref().unwrap();
1587        assert!(llm.supports(LLMFeature::FunctionCalling));
1588    }
1589
1590    #[test]
1591    fn test_feature_override_named_llms_builder_path() {
1592        let yaml = r#"
1593name: LocalAgent
1594system_prompt: "You are helpful."
1595llms:
1596  default:
1597    provider: openai-compatible
1598    model: qwen3:8b
1599    base_url: http://localhost:11434/v1
1600    json_mode: true
1601llm:
1602  default: default
1603"#;
1604        let builder = AgentBuilder::from_yaml(yaml)
1605            .unwrap()
1606            .auto_configure_llms()
1607            .unwrap();
1608
1609        let registry = builder.llm_registry.as_ref().unwrap();
1610        let llm = registry.get("default").unwrap();
1611        assert!(llm.supports(LLMFeature::JsonMode));
1612    }
1613
1614    #[test]
1615    fn test_builder_from_yaml_with_tool_security() {
1616        let yaml = r#"
1617name: SecureAgent
1618system_prompt: "You are helpful."
1619llm:
1620  provider: openai
1621  model: gpt-4
1622max_context_tokens: 8192
1623error_recovery:
1624  default:
1625    max_retries: 5
1626tool_security:
1627  enabled: true
1628  tools:
1629    http:
1630      rate_limit: 10
1631"#;
1632        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1633        assert!(builder.spec.is_some());
1634        let spec = builder.spec.as_ref().unwrap();
1635        assert_eq!(spec.max_context_tokens, 8192);
1636        assert_eq!(spec.error_recovery.default.max_retries, 5);
1637        assert!(spec.tool_security.enabled);
1638    }
1639
1640    #[test]
1641    fn test_builder_from_yaml_rejects_zero_max_results() {
1642        let yaml = r#"
1643name: SecureAgent
1644system_prompt: "You are helpful."
1645tool_security:
1646  enabled: true
1647  tools:
1648    web_search:
1649      max_results: 0
1650"#;
1651        let error = AgentBuilder::from_yaml(yaml).err().unwrap();
1652        assert!(
1653            error
1654                .to_string()
1655                .contains("tool_security.tools.web_search.max_results must be greater than 0")
1656        );
1657    }
1658
1659    #[test]
1660    fn test_builder_from_yaml_with_skills() {
1661        let yaml = r#"
1662name: SkillAgent
1663system_prompt: "You are helpful."
1664llm:
1665  provider: openai
1666  model: gpt-4
1667skills:
1668  - id: greeting
1669    description: "Greet users"
1670    trigger: "When user says hello"
1671    steps:
1672      - prompt: "Hello!"
1673"#;
1674        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1675        assert!(builder.spec.is_some());
1676        assert!(!builder.spec.as_ref().unwrap().skills.is_empty());
1677    }
1678
1679    #[test]
1680    fn test_builder_from_spec() {
1681        let spec = AgentSpec {
1682            name: "test".to_string(),
1683            version: "1.0".to_string(),
1684            description: Some("Test agent".to_string()),
1685            system_prompt: "You are helpful".to_string(),
1686            ..Default::default()
1687        };
1688
1689        let builder = AgentBuilder::from_spec(spec);
1690        assert!(builder.spec.is_some());
1691        assert_eq!(builder.system_prompt, Some("You are helpful".to_string()));
1692    }
1693
1694    #[test]
1695    fn test_builder_rejects_invalid_programmatic_tool_security() {
1696        use ai_agents_llm::mock::MockLLMProvider;
1697
1698        let mut spec = AgentSpec::default();
1699        spec.tool_security.tools.insert(
1700            "web_search".to_string(),
1701            ai_agents_tools::ToolPolicyConfig {
1702                max_results: Some(0),
1703                ..Default::default()
1704            },
1705        );
1706
1707        let error = AgentBuilder::from_spec(spec)
1708            .llm(Arc::new(MockLLMProvider::new("test")))
1709            .build()
1710            .unwrap_err();
1711        assert!(
1712            error
1713                .to_string()
1714                .contains("max_results must be greater than 0")
1715        );
1716    }
1717
1718    #[test]
1719    fn test_builder_from_spec_with_base_dir_preserves_mutations() {
1720        let yaml = r#"
1721name: OriginalAgent
1722system_prompt: "Original prompt"
1723max_iterations: 10
1724llm:
1725  provider: openai
1726  model: gpt-4
1727"#;
1728        let mut spec = AgentBuilder::from_yaml(yaml).unwrap().spec.unwrap();
1729        spec.name = "RewrittenAgent".to_string();
1730        spec.system_prompt = "Rewritten prompt".to_string();
1731        spec.max_iterations = 37;
1732        let base_dir = PathBuf::from("rewritten-agent-dir");
1733
1734        let builder = AgentBuilder::from_spec_with_base_dir(spec, &base_dir);
1735
1736        let stored_spec = builder.spec.as_ref().unwrap();
1737        assert_eq!(stored_spec.name, "RewrittenAgent");
1738        assert_eq!(stored_spec.system_prompt, "Rewritten prompt");
1739        assert_eq!(stored_spec.max_iterations, 37);
1740        assert_eq!(builder.system_prompt.as_deref(), Some("Rewritten prompt"));
1741        assert_eq!(builder.max_iterations, Some(37));
1742        assert_eq!(builder.yaml_dir.as_deref(), Some(base_dir.as_path()));
1743    }
1744
1745    #[tokio::test]
1746    async fn test_builder_from_spec_with_base_dir_resolves_spawner_paths() {
1747        use ai_agents_llm::mock::MockLLMProvider;
1748
1749        let base_dir = std::env::temp_dir().join(format!(
1750            "ai-agents-builder-base-dir-{}",
1751            uuid::Uuid::new_v4()
1752        ));
1753        let templates_dir = base_dir.join("templates");
1754        let agents_dir = base_dir.join("agents");
1755        std::fs::create_dir_all(&templates_dir).unwrap();
1756        std::fs::create_dir_all(&agents_dir).unwrap();
1757
1758        let template_content = "name: {{ name }}\nsystem_prompt: Template prompt\n";
1759        std::fs::write(templates_dir.join("worker.yaml"), template_content).unwrap();
1760        std::fs::write(
1761            agents_dir.join("child.yaml"),
1762            r#"
1763name: ChildAgent
1764system_prompt: "Child prompt"
1765llm:
1766  provider: definitely-not-a-provider
1767  model: unavailable
1768"#,
1769        )
1770        .unwrap();
1771
1772        let yaml = r#"
1773name: ParentAgent
1774system_prompt: "Parent prompt"
1775llm:
1776  provider: openai
1777  model: gpt-4
1778spawner:
1779  shared_llms: true
1780  templates:
1781    worker:
1782      path: templates/worker.yaml
1783  auto_spawn:
1784    - id: child
1785      agent: agents/child.yaml
1786"#;
1787        let spec = AgentBuilder::from_yaml(yaml).unwrap().spec.unwrap();
1788
1789        let builder = AgentBuilder::from_spec_with_base_dir(spec, &base_dir)
1790            .llm(Arc::new(MockLLMProvider::new("test")))
1791            .auto_configure_spawner()
1792            .await
1793            .unwrap();
1794
1795        let template = builder
1796            .spawner
1797            .as_ref()
1798            .unwrap()
1799            .templates()
1800            .get("worker")
1801            .unwrap();
1802        assert_eq!(template.content, template_content);
1803        assert!(builder.spawner_registry.as_ref().unwrap().contains("child"));
1804
1805        std::fs::remove_dir_all(base_dir).unwrap();
1806    }
1807
1808    #[tokio::test]
1809    async fn test_builder_auto_spawn_fails_on_any_declared_child_error() {
1810        use ai_agents_llm::mock::MockLLMProvider;
1811
1812        let base_dir = std::env::temp_dir().join(format!(
1813            "ai-agents-builder-child-failure-{}",
1814            uuid::Uuid::new_v4()
1815        ));
1816        std::fs::create_dir_all(&base_dir).unwrap();
1817        std::fs::write(
1818            base_dir.join("valid.yaml"),
1819            "name: ValidChild\nsystem_prompt: valid\n",
1820        )
1821        .unwrap();
1822
1823        let yaml = r#"
1824name: ParentAgent
1825system_prompt: parent
1826llm:
1827  default: default
1828spawner:
1829  shared_llms: true
1830  auto_spawn:
1831    - id: valid
1832      agent: valid.yaml
1833    - id: missing
1834      agent: missing.yaml
1835"#;
1836        let spec = AgentBuilder::from_yaml(yaml).unwrap().spec.unwrap();
1837        let mut registry = LLMRegistry::new();
1838        registry.register("default", Arc::new(MockLLMProvider::new("test")));
1839        registry.set_default("default");
1840
1841        let error = AgentBuilder::from_spec_with_base_dir(spec, &base_dir)
1842            .llm_registry(registry)
1843            .auto_configure_spawner()
1844            .await
1845            .err()
1846            .unwrap()
1847            .to_string();
1848        assert!(error.contains("missing"), "{error}");
1849        assert!(error.contains("missing.yaml"), "{error}");
1850
1851        std::fs::remove_dir_all(base_dir).unwrap();
1852    }
1853
1854    #[test]
1855    fn test_builder_chain() {
1856        let builder = AgentBuilder::new()
1857            .system_prompt("Test prompt")
1858            .max_iterations(5)
1859            .max_context_tokens(4096);
1860
1861        assert_eq!(builder.system_prompt, Some("Test prompt".to_string()));
1862        assert_eq!(builder.max_iterations, Some(5));
1863        assert_eq!(builder.max_context_tokens, Some(4096));
1864    }
1865
1866    #[test]
1867    fn test_builder_skills() {
1868        use ai_agents_skills::{SkillDefinition, SkillStep};
1869
1870        let skill = SkillDefinition {
1871            id: "test".to_string(),
1872            description: "Test skill".to_string(),
1873            trigger: "When testing".to_string(),
1874            steps: vec![SkillStep::Prompt {
1875                prompt: "Hello".to_string(),
1876                llm: None,
1877            }],
1878            reasoning: None,
1879            reflection: None,
1880            disambiguation: None,
1881        };
1882
1883        let builder = AgentBuilder::new().skill(skill.clone()).skills(vec![skill]);
1884
1885        assert_eq!(builder.skills.len(), 2);
1886    }
1887
1888    #[test]
1889    fn test_builder_from_yaml_with_states() {
1890        let yaml = r#"
1891name: StatefulAgent
1892system_prompt: "You are helpful."
1893llm:
1894  provider: openai
1895  model: gpt-4
1896states:
1897  initial: greeting
1898  states:
1899    greeting:
1900      prompt: "Welcome!"
1901      transitions:
1902        - to: support
1903          when: "user needs help"
1904    support:
1905      prompt: "How can I help?"
1906"#;
1907        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1908        assert!(builder.spec.is_some());
1909        let spec = builder.spec.as_ref().unwrap();
1910        assert!(spec.has_states());
1911        assert!(spec.states.is_some());
1912        let states = spec.states.as_ref().unwrap();
1913        assert_eq!(states.initial, "greeting");
1914        assert_eq!(states.states.len(), 2);
1915    }
1916
1917    #[test]
1918    fn test_builder_from_yaml_with_context() {
1919        let yaml = r#"
1920name: ContextAgent
1921system_prompt: "Hello, {{ context.user.name }}!"
1922llm:
1923  provider: openai
1924  model: gpt-4
1925context:
1926  user:
1927    type: runtime
1928    required: true
1929  time:
1930    type: builtin
1931    source: datetime
1932    refresh: per_turn
1933"#;
1934        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1935        assert!(builder.spec.is_some());
1936        let spec = builder.spec.as_ref().unwrap();
1937        assert!(spec.has_context());
1938        assert_eq!(spec.context.len(), 2);
1939        assert!(spec.context.contains_key("user"));
1940        assert!(spec.context.contains_key("time"));
1941    }
1942
1943    #[test]
1944    fn test_builder_from_yaml_with_full_v04_features() {
1945        let yaml = r#"
1946name: FullFeaturedAgent
1947version: "0.4.0"
1948system_prompt: |
1949  You are a helpful assistant.
1950  User: {{ context.user.name }}
1951  Language: {{ context.user.language }}
1952llm:
1953  provider: openai
1954  model: gpt-4
1955context:
1956  user:
1957    type: runtime
1958    required: true
1959    default:
1960      name: "Guest"
1961      language: "en"
1962  time:
1963    type: builtin
1964    source: datetime
1965    refresh: per_turn
1966states:
1967  initial: greeting
1968  states:
1969    greeting:
1970      prompt: "Welcome to our service!"
1971      prompt_mode: append
1972      transitions:
1973        - to: support
1974          when: "user needs help"
1975          auto: true
1976          priority: 10
1977    support:
1978      prompt: "I'm here to help you."
1979      max_turns: 5
1980      timeout_to: escalation
1981      transitions:
1982        - to: closing
1983          when: "issue resolved"
1984          auto: true
1985    escalation:
1986      prompt: "Let me connect you with a human agent."
1987    closing:
1988      prompt: "Thank you for using our service!"
1989"#;
1990        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1991        assert!(builder.spec.is_some());
1992        let spec = builder.spec.as_ref().unwrap();
1993
1994        // Check context
1995        assert!(spec.has_context());
1996        assert_eq!(spec.context.len(), 2);
1997
1998        // Check states
1999        assert!(spec.has_states());
2000        let states = spec.states.as_ref().unwrap();
2001        assert_eq!(states.initial, "greeting");
2002        assert_eq!(states.states.len(), 4);
2003
2004        // Check greeting state details
2005        let greeting = states.states.get("greeting").unwrap();
2006        assert!(greeting.prompt.is_some());
2007        assert_eq!(greeting.transitions.len(), 1);
2008        assert_eq!(greeting.transitions[0].to, "support");
2009        assert!(greeting.transitions[0].auto);
2010
2011        // Check support state has timeout
2012        let support = states.states.get("support").unwrap();
2013        assert_eq!(support.max_turns, Some(5));
2014        assert_eq!(support.timeout_to, Some("escalation".to_string()));
2015    }
2016
2017    #[test]
2018    fn test_builder_from_yaml_with_hitl() {
2019        let yaml = r#"
2020name: HITLAgent
2021system_prompt: "You are a secure assistant."
2022llm:
2023  provider: openai
2024  model: gpt-4
2025hitl:
2026  default_timeout_seconds: 600
2027  on_timeout: reject
2028  tools:
2029    send_payment:
2030      require_approval: true
2031      approval_context:
2032        - amount
2033        - recipient
2034      approval_message: "Approve payment?"
2035    delete_record:
2036      require_approval: true
2037  conditions:
2038    - name: high_value
2039      when: "amount > 1000"
2040      require_approval: true
2041      approval_message: "High value transaction"
2042  states:
2043    escalation:
2044      on_enter: require_approval
2045      approval_message: "Escalate to human?"
2046"#;
2047        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2048        assert!(builder.spec.is_some());
2049        let spec = builder.spec.as_ref().unwrap();
2050
2051        assert!(spec.has_hitl());
2052        let hitl = spec.hitl.as_ref().unwrap();
2053        assert_eq!(hitl.default_timeout_seconds, 600);
2054        assert_eq!(hitl.tools.len(), 2);
2055        assert!(hitl.tools.get("send_payment").unwrap().require_approval);
2056        assert_eq!(hitl.conditions.len(), 1);
2057        assert_eq!(hitl.conditions[0].name, "high_value");
2058        assert_eq!(hitl.states.len(), 1);
2059    }
2060
2061    #[test]
2062    fn test_builder_from_yaml_with_compacting_memory() {
2063        let yaml = r#"
2064name: CompactingAgent
2065system_prompt: "You are a helpful assistant."
2066memory:
2067  type: compacting
2068  max_messages: 100
2069  max_recent_messages: 20
2070  compress_threshold: 15
2071  summarize_batch_size: 5
2072  summarizer_llm: router
2073llm:
2074  provider: openai
2075  model: gpt-4
2076"#;
2077        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2078        assert!(builder.spec.is_some());
2079        let spec = builder.spec.as_ref().unwrap();
2080
2081        assert!(spec.memory.is_compacting());
2082        assert_eq!(spec.memory.max_recent_messages, Some(20));
2083        assert_eq!(spec.memory.compress_threshold, Some(15));
2084        assert_eq!(spec.memory.summarize_batch_size, Some(5));
2085        assert_eq!(spec.memory.summarizer_llm, Some("router".to_string()));
2086
2087        let compacting_config = spec.memory.to_compacting_config();
2088        assert_eq!(compacting_config.max_recent_messages, 20);
2089        assert_eq!(compacting_config.compress_threshold, 15);
2090        assert_eq!(compacting_config.summarize_batch_size, 5);
2091    }
2092
2093    #[test]
2094    fn test_builder_from_yaml_with_token_budget() {
2095        let yaml = r#"
2096name: BudgetAgent
2097system_prompt: "You are a helpful assistant."
2098memory:
2099  type: compacting
2100  max_messages: 100
2101  token_budget:
2102    total: 8192
2103    allocation:
2104      summary: 2048
2105      recent_messages: 4096
2106      facts: 1024
2107    overflow_strategy: summarize_more
2108    warn_at_percent: 75
2109llm:
2110  provider: openai
2111  model: gpt-4
2112"#;
2113        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2114        assert!(builder.spec.is_some());
2115        let spec = builder.spec.as_ref().unwrap();
2116
2117        assert!(spec.memory.token_budget.is_some());
2118        let budget = spec.memory.token_budget.as_ref().unwrap();
2119        assert_eq!(budget.total, 8192);
2120        assert_eq!(budget.allocation.summary, 2048);
2121        assert_eq!(budget.allocation.recent_messages, 4096);
2122        assert_eq!(budget.allocation.facts, 1024);
2123        assert_eq!(budget.warn_at_percent, 75);
2124    }
2125
2126    #[test]
2127    fn test_builder_from_yaml_with_overflow_strategies() {
2128        use ai_agents_memory::OverflowStrategy;
2129
2130        let yaml = r#"
2131name: TruncateAgent
2132system_prompt: "You are helpful."
2133memory:
2134  type: compacting
2135  token_budget:
2136    total: 4096
2137    overflow_strategy: truncate_oldest
2138llm:
2139  provider: openai
2140  model: gpt-4
2141"#;
2142        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2143        let budget = builder
2144            .spec
2145            .as_ref()
2146            .unwrap()
2147            .memory
2148            .token_budget
2149            .as_ref()
2150            .unwrap();
2151        assert_eq!(budget.overflow_strategy, OverflowStrategy::TruncateOldest);
2152
2153        let yaml = r#"
2154name: ErrorAgent
2155system_prompt: "You are helpful."
2156memory:
2157  type: compacting
2158  token_budget:
2159    total: 4096
2160    overflow_strategy: error
2161llm:
2162  provider: openai
2163  model: gpt-4
2164"#;
2165        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2166        let budget = builder
2167            .spec
2168            .as_ref()
2169            .unwrap()
2170            .memory
2171            .token_budget
2172            .as_ref()
2173            .unwrap();
2174        assert_eq!(budget.overflow_strategy, OverflowStrategy::Error);
2175    }
2176
2177    #[test]
2178    fn test_builder_from_yaml_with_storage_file() {
2179        let yaml = r#"
2180name: PersistentAgent
2181system_prompt: "You are helpful."
2182llm:
2183  provider: openai
2184  model: gpt-4
2185storage:
2186  type: file
2187  path: "./data/sessions"
2188"#;
2189        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2190        let spec = builder.spec.as_ref().unwrap();
2191        assert!(spec.has_storage());
2192        assert!(spec.storage.is_file());
2193        assert_eq!(spec.storage.get_path(), Some("./data/sessions"));
2194    }
2195
2196    #[test]
2197    fn test_builder_from_yaml_with_storage_sqlite() {
2198        let yaml = r#"
2199name: PersistentAgent
2200system_prompt: "You are helpful."
2201llm:
2202  provider: openai
2203  model: gpt-4
2204storage:
2205  type: sqlite
2206  path: "./data/sessions.db"
2207"#;
2208        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2209        let spec = builder.spec.as_ref().unwrap();
2210        assert!(spec.has_storage());
2211        assert!(spec.storage.is_sqlite());
2212    }
2213
2214    #[test]
2215    fn test_builder_from_yaml_with_storage_redis() {
2216        let yaml = r#"
2217name: PersistentAgent
2218system_prompt: "You are helpful."
2219llm:
2220  provider: openai
2221  model: gpt-4
2222storage:
2223  type: redis
2224  url: "redis://localhost:6379"
2225  prefix: "myagent:"
2226  ttl_seconds: 86400
2227"#;
2228        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2229        let spec = builder.spec.as_ref().unwrap();
2230        assert!(spec.has_storage());
2231        assert!(spec.storage.is_redis());
2232        assert_eq!(spec.storage.get_prefix(), "myagent:");
2233        assert_eq!(spec.storage.get_ttl(), Some(86400));
2234    }
2235
2236    #[test]
2237    fn test_builder_no_storage_by_default() {
2238        let yaml = r#"
2239name: SimpleAgent
2240system_prompt: "You are helpful."
2241llm:
2242  provider: openai
2243  model: gpt-4
2244"#;
2245        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2246        let spec = builder.spec.as_ref().unwrap();
2247        assert!(!spec.has_storage());
2248    }
2249
2250    #[test]
2251    fn test_build_fails_on_missing_declared_tool() {
2252        use ai_agents_llm::mock::MockLLMProvider;
2253
2254        let yaml = r#"
2255name: ToolAgent
2256system_prompt: "You are helpful."
2257llm:
2258  provider: openai
2259  model: gpt-4
2260tools:
2261  - name: lookup_order
2262  - name: calculator
2263"#;
2264        let llm = Arc::new(MockLLMProvider::new("test"));
2265        let result = AgentBuilder::from_yaml(yaml)
2266            .unwrap()
2267            .llm(llm)
2268            .auto_configure_features()
2269            .unwrap()
2270            // NOT registering lookup_order — should fail
2271            .build();
2272
2273        assert!(result.is_err());
2274        let err = result.unwrap_err().to_string();
2275        assert!(
2276            err.contains("lookup_order"),
2277            "error should name the missing tool: {}",
2278            err
2279        );
2280        // calculator is built-in, so it should NOT appear in the error
2281        assert!(
2282            !err.contains("calculator"),
2283            "calculator is registered, should not be missing: {}",
2284            err
2285        );
2286    }
2287
2288    #[test]
2289    fn test_build_succeeds_when_declared_tool_is_registered() {
2290        use ai_agents_core::Tool;
2291        use ai_agents_llm::mock::MockLLMProvider;
2292
2293        struct FakeTool;
2294        #[async_trait::async_trait]
2295        impl Tool for FakeTool {
2296            fn id(&self) -> &str {
2297                "lookup_order"
2298            }
2299            fn name(&self) -> &str {
2300                "Order Lookup"
2301            }
2302            fn description(&self) -> &str {
2303                "Look up an order"
2304            }
2305            fn input_schema(&self) -> serde_json::Value {
2306                serde_json::json!({})
2307            }
2308            async fn execute(
2309                &self,
2310                _args: serde_json::Value,
2311                _ctx: ai_agents_core::ToolExecutionContext,
2312            ) -> ai_agents_core::ToolResult {
2313                ai_agents_core::ToolResult::ok("ok")
2314            }
2315        }
2316
2317        let yaml = r#"
2318name: ToolAgent
2319system_prompt: "You are helpful."
2320llm:
2321  provider: openai
2322  model: gpt-4
2323tools:
2324  - name: lookup_order
2325  - name: calculator
2326"#;
2327        let llm = Arc::new(MockLLMProvider::new("test"));
2328        let result = AgentBuilder::from_yaml(yaml)
2329            .unwrap()
2330            .llm(llm)
2331            .auto_configure_features()
2332            .unwrap()
2333            .tool(Arc::new(FakeTool))
2334            .build();
2335
2336        assert!(
2337            result.is_ok(),
2338            "build should succeed when all declared tools are registered: {:?}",
2339            result.err()
2340        );
2341    }
2342
2343    #[test]
2344    fn test_spawner_config_deserializes_shared_storage() {
2345        let yaml = r#"
2346name: TestAgent
2347system_prompt: "Test"
2348llm:
2349  provider: openai
2350  model: gpt-4
2351spawner:
2352  shared_llms: true
2353  shared_storage:
2354    type: sqlite
2355    path: ./data/test.db
2356  max_agents: 5
2357"#;
2358        let builder = AgentBuilder::from_yaml(yaml).unwrap();
2359        let spec = builder.spec.as_ref().unwrap();
2360        let sc = spec.spawner.as_ref().unwrap();
2361        assert!(sc.shared_storage.is_some());
2362        assert!(sc.shared_storage.as_ref().unwrap().is_sqlite());
2363    }
2364
2365    #[test]
2366    fn test_build_succeeds_with_no_tools_declared() {
2367        use ai_agents_llm::mock::MockLLMProvider;
2368
2369        let yaml = r#"
2370name: SimpleAgent
2371system_prompt: "You are helpful."
2372llm:
2373  provider: openai
2374  model: gpt-4
2375"#;
2376        let llm = Arc::new(MockLLMProvider::new("test"));
2377        let result = AgentBuilder::from_yaml(yaml)
2378            .unwrap()
2379            .llm(llm)
2380            .auto_configure_features()
2381            .unwrap()
2382            .build();
2383
2384        assert!(
2385            result.is_ok(),
2386            "no tools: section means no validation needed: {:?}",
2387            result.err()
2388        );
2389    }
2390}