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